Compare commits
22 Commits
master
...
e243862769
| Author | SHA1 | Date | |
|---|---|---|---|
| e243862769 | |||
| b8f9f6d537 | |||
| 57ead93497 | |||
| 7f5e2733c2 | |||
| f7a5d24d8d | |||
| a5210fead2 | |||
| 498243af46 | |||
| f97de193d8 | |||
| 7e5fd5ab75 | |||
| fbf03a9847 | |||
| df89acd9a8 | |||
| 829ea89386 | |||
| a3b2c0ce2f | |||
| ba7f082f48 | |||
| 69119bb72a | |||
| 747c390303 | |||
| 57afc32f4a | |||
| 2e0e6ad199 | |||
| 8aed3b022b | |||
| a8ed350aec | |||
| edb6e5e027 | |||
| d44c24ec97 |
7
.gitignore
vendored
@@ -1,7 +1,14 @@
|
||||
generated_3d
|
||||
venv
|
||||
.venv
|
||||
build
|
||||
*.egg-info
|
||||
*.FCStd1
|
||||
*.blend1
|
||||
__pycache__
|
||||
.ipynb_checkpoints
|
||||
.pytest_cache
|
||||
.mypy_cache
|
||||
.ruff_cache
|
||||
.envrc
|
||||
.direnv
|
||||
|
||||
@@ -1,42 +1,125 @@
|
||||
# MusicMouse — repo overview
|
||||
|
||||
Orientation doc for AI agents (or humans) working on this repo for the first time. Written from what's actually in the repo — no roadmap speculation beyond `esp-firmware/todo.md`.
|
||||
Orientation doc for AI agents (or humans) working on this repo for the first time.
|
||||
|
||||
## What this is
|
||||
|
||||
MusicMouse is a DIY, Toniebox-style physical music player for kids, shaped like a mouse and living on a shelf. Small 3D-printed animal figurines (fox, owl, dog, elephant, squirrel, crocodile, rabbit, snowman, puppy — see `hardware/3dprints/figures/`) each carry an RFID tag. Placing a figurine on the mouse triggers an RFID read, which starts that figure's music playlist. The mouse also has a rotary encoder + touch buttons (ears/feet) for volume/skip control, addressable RGBW LED rings with animated effects, and MQTT/Home Assistant integration so a "shelf light" shows up as a smart-home device.
|
||||
MusicMouse is a DIY, Toniebox-style physical music player for kids, shaped like a mouse
|
||||
and living on a shelf. Small 3D-printed animal figurines (fox, owl, dog, elephant,
|
||||
squirrel, crocodile, rabbit, snowman, puppy — see `hardware/3dprints/figures/`) each
|
||||
carry an RFID tag. Placing a figurine on the mouse starts that figure's playlist. The
|
||||
mouse also has a rotary encoder and capacitive touch areas (ears/feet) for
|
||||
volume/skip control, addressable RGBW LED rings with animated effects, and MQTT/Home
|
||||
Assistant integration.
|
||||
|
||||
## Repo layout
|
||||
|
||||
| Path | What it is |
|
||||
|---|---|
|
||||
| `python-backend/` | Python host application — the main runtime. Reads RFID/button/encoder events from the ESP32 over serial, drives playback via VLC, sends LED effect commands, bridges to MQTT/Home Assistant. Start here for backend work. |
|
||||
| `python-backend/` | Python host application — the main runtime. Has its own `README.md` with the architecture; start there for backend work. |
|
||||
| `web/` | React + TypeScript front-end (Vite). Browse the whole library and play any of it from a browser. Built output is served by `python-backend` itself. |
|
||||
| `esp-firmware/` | ESP32 firmware (C++, Arduino framework via PlatformIO). Reads the RFID reader and buttons, drives the LED strips, talks to `python-backend` over serial. |
|
||||
| `hardware/` | 3D-print models for the figurines and enclosure (FreeCAD/Blender/OBJ/STL), a Fritzing electronics sketch, datasheets, and `pinout.md` (RFID reader + button-board wiring). |
|
||||
| `claude-design/` | An unrelated exploratory web-UI mockup ("Dolphin Beats Music Player" / rebrand concept). Not integrated with the rest of the repo — no build system ties it in. Don't assume it reflects current product direction. |
|
||||
| `.vscode/` | Editor settings (C++ header associations). |
|
||||
|
||||
There is no top-level README elsewhere in the repo; this file plus `esp-firmware/todo.md` and `hardware/pinout.md` are the only prose docs.
|
||||
| `claude-design/` | The interaction and visual spec the web front-end was built from ("Dolphin Beats"), as a standalone HTML mockup with hardcoded data. `web/` is the real implementation; the mockup is kept as the reference for the keyboard model and the styling. It also contains a second page, "Mein Zimmer" (room lights), which is **not** implemented. |
|
||||
|
||||
## How the pieces talk to each other
|
||||
|
||||
- **ESP32 firmware ↔ `python-backend`**: a length-prefixed binary protocol over serial (`pyserial-asyncio`), implemented in `python-backend/host_driver.py`. Messages are framed with magic tokens (`MAGIC_TOKEN_HOST_TO_FW`/`MAGIC_TOKEN_FW_TO_HOST`) and a `struct`-packed header. The message-ID maps and struct formats in `host_driver.py` must stay byte-for-byte in sync with the firmware's `esp-firmware/src/Messages.h` — there's no shared schema or test verifying this cross-language contract, so a firmware protocol change can silently desync the Python side.
|
||||
- **`python-backend` ↔ MQTT/Home Assistant**: `python-backend/mqtt_json.py` exposes the shelf LED strip as a Home-Assistant-discoverable JSON-schema MQTT light (`ShelveLightMqtt`), and `main.py` also calls Home Assistant services directly (e.g. toggling room lights) via `hass-client`.
|
||||
- **ESP32 firmware ↔ `python-backend`**: a length-prefixed binary protocol over serial.
|
||||
Frames are `uint32 magic | uint8 type | uint16 size | payload`, little-endian, with
|
||||
firmware log text interleaved on the same link. The Python side lives in
|
||||
`python-backend/musicmouse/devices/wire.py`, the firmware side in
|
||||
`esp-firmware/src/Messages.h`.
|
||||
|
||||
The contract is hand-duplicated in two languages. `tests/test_wire.py` parses
|
||||
`Messages.h` and fails if the message ids drift, and `tests/test_effects.py` pins the
|
||||
exact bytes of every effect payload — so a firmware change that breaks the host now
|
||||
breaks a test instead of just the LEDs.
|
||||
|
||||
- **`python-backend` ↔ `web/`**: JSON over HTTP for the library and for commands, plus a
|
||||
push-only websocket at `/api/ws` for state. Commands emit exactly the same *intents*
|
||||
the physical buttons emit, so the web UI has no privileged path — and no way to get
|
||||
out of step with a figure someone puts on the reader. See
|
||||
`python-backend/README.md` for the endpoint list.
|
||||
|
||||
- **`python-backend` ↔ Home Assistant**: MQTT only. The backend publishes three
|
||||
discoverable lights, a player sensor, a volume number, transport buttons, device
|
||||
triggers for every button and touch area, and a tag scanner for the RFID reader. It
|
||||
does *not* call Home Assistant services directly any more; behaviour like "the left
|
||||
ear turns the room light pink" is an HA automation. See `python-backend/README.md`
|
||||
for the trigger topics and the old colour mapping.
|
||||
|
||||
## Running it
|
||||
|
||||
```
|
||||
python python-backend/main.py <config_dir>
|
||||
```sh
|
||||
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.
|
||||
On a machine with no mouse attached — real audio and a real web UI, no serial port:
|
||||
|
||||
## Config schema (see `python-backend/config.yml.example`)
|
||||
```sh
|
||||
python -m musicmouse --config ./config.yml --no-hardware
|
||||
```
|
||||
|
||||
- `general.{alsa_device, serial_port, hass_url, hass_token, mqtt.{server,user,password}, min_volume, max_volume, volume_increment, button_leds_brightness}`
|
||||
- `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).
|
||||
`general.serial_port: simulate` does the same thing from the config, and
|
||||
`general.alsa_device: simulate` swaps in a silent player. Both warn at startup, and
|
||||
both keys are required - omitting one is an error rather than an implicit simulation.
|
||||
|
||||
## Known gaps / notes for agents
|
||||
Or with no hardware *and* no audio:
|
||||
|
||||
- **No automated tests, no lint/formatter config, no CI** anywhere in the repo (neither `python-backend/` nor `esp-firmware/`, aside from a PlatformIO `native` build env for firmware unit testing).
|
||||
- `python-backend/audio_analysis.py` and the three chord-recognition notebooks (`C5S2_ChordRec_Templates.ipynb`, `C5S3_ChordRec_HMM.ipynb`, `C5S3_HiddenMarkovModel.ipynb`) are university-course exploratory material (chroma/chord-recognition DSP), **not imported by `main.py`** and not part of the running app. They reference stale personal absolute paths.
|
||||
```sh
|
||||
python -m musicmouse --config ./config.yml --simulate
|
||||
```
|
||||
|
||||
The simulator runs the whole app against a fake serial link and a fake player, either
|
||||
interactively or from a scenario file. `python-backend/musicmouse.service` is the
|
||||
systemd unit for the device.
|
||||
|
||||
For front-end work, run the backend (either way above) and then:
|
||||
|
||||
```sh
|
||||
cd web && npm install && npm run dev # http://localhost:5173, /api proxied to :8080
|
||||
```
|
||||
|
||||
`npm run build` writes `web/dist`, which `general.web.static_dir` points at in
|
||||
production so one process serves both the UI and the API.
|
||||
|
||||
## Config
|
||||
|
||||
`python-backend/config.yml.example` documents the schema. In short:
|
||||
|
||||
- `general.{library.*, serial_port, baudrate, reconnect_interval, alsa_device,
|
||||
min_volume, max_volume, initial_volume, volume_increment, button_leds_brightness,
|
||||
audio_extensions, mqtt.*, web.*}`
|
||||
- `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"`.
|
||||
|
||||
`general.library.root` is the one path to the music. The shelves under it are fixed
|
||||
names, not settings, because each has quirks the scanner knows about:
|
||||
|
||||
```
|
||||
<root>/Figuren/<figure name>/ one folder per figurine
|
||||
<root>/Musik/<Artist> - <Album>/ albums, grouped by artist
|
||||
<root>/Hörbücher/<Artist> - <Album>/ audiobooks, grouped by character
|
||||
<root>/Kinderpodcasts/<Show>/ shows, newest episode first
|
||||
```
|
||||
|
||||
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.
|
||||
- The front-end has `vitest` over the pure modules (`web/src/lib/`) and `tsc --noEmit`;
|
||||
there is no component-level test harness. `npm run test` and `npx tsc --noEmit` are
|
||||
the two checks.
|
||||
- The library index is cached under `general.library.cache`. It is keyed by file mtime
|
||||
and size, so **a change to how the scanner derives a title, artist or series is
|
||||
invisible until the cache is invalidated** — bump `_INDEX_VERSION` in
|
||||
`musicmouse/library/cache.py` when you touch that logic.
|
||||
|
||||
@@ -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
|
||||
4
python-backend/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
config.yml
|
||||
tippen-curriculum.yml
|
||||
tippen-progress.json
|
||||
/.musicmouse-cache
|
||||
BIN
python-backend/.musicmouse-cache/covers/0772247eeb24.jpg
Normal file
|
After Width: | Height: | Size: 207 KiB |
BIN
python-backend/.musicmouse-cache/covers/13293e87561e.jpg
Normal file
|
After Width: | Height: | Size: 277 KiB |
BIN
python-backend/.musicmouse-cache/covers/22a6a01286bd.jpg
Normal file
|
After Width: | Height: | Size: 207 KiB |
BIN
python-backend/.musicmouse-cache/covers/9bf448484625.jpg
Normal file
|
After Width: | Height: | Size: 650 KiB |
BIN
python-backend/.musicmouse-cache/covers/9cc6a9f226cd.jpg
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
python-backend/.musicmouse-cache/covers/c7dba5e954c4.jpg
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
python-backend/.musicmouse-cache/covers/e95134365b97.jpg
Normal file
|
After Width: | Height: | Size: 567 KiB |
1
python-backend/.musicmouse-cache/index.json
Normal file
241
python-backend/README.md
Normal file
@@ -0,0 +1,241 @@
|
||||
# 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, indexes the
|
||||
music collection, and exposes everything to Home Assistant over MQTT and to a browser
|
||||
over HTTP.
|
||||
|
||||
```
|
||||
ESP32 ⇄ MusicMouseDevice ─┐ ┌─► MqttService (state out, intents in)
|
||||
VLC ⇄ VlcPlayer ────────┼──► EventBus ──────────►┤
|
||||
broker ⇄ MqttService ──────┤ ▲ └─► WebService (state out, intents in)
|
||||
browser ⇄ WebService ───────┘ │
|
||||
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. The web front-end was added exactly that way.
|
||||
|
||||
## 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.
|
||||
|
||||
### On a host with no mouse attached
|
||||
|
||||
The web front-end is a complete way to drive the player, so the backend is useful on a
|
||||
machine with no serial port and no sound card worth grabbing. Two config keys say so,
|
||||
each by taking the literal value `simulate`:
|
||||
|
||||
| Setting | `simulate` gives you | Warned about as |
|
||||
|---|---|---|
|
||||
| `serial_port` | no serial link; RFID, buttons and LEDs are inert | "running without the mouse" |
|
||||
| `alsa_device` | the simulator's player: everything works, nothing is audible | "nothing will be audible" |
|
||||
|
||||
Both keys are **required**. Leaving one out is a config error, not a shortcut to
|
||||
simulation - running blind or silent has to be asked for, so a config that lost a line
|
||||
fails loudly instead of booting into something that looks like it is working. The
|
||||
warnings are repeated on every boot for the same reason.
|
||||
|
||||
`--no-hardware` forces the serial half regardless of the config, for a one-off run:
|
||||
|
||||
```sh
|
||||
python -m musicmouse --config ./config.yml --no-hardware
|
||||
```
|
||||
|
||||
Note that `alsa_device` has no "just use whatever" value. VLC's own default would seize
|
||||
whatever the desktop is playing through, which is the wrong thing to do silently - name
|
||||
a device (`"default"` is the system one) when the machine is meant to make noise.
|
||||
|
||||
### 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/library/` | The music collection: scanning, tags, cover art and its colours, the cache, and the seams for future track analysis. |
|
||||
| `musicmouse/services/mqtt/` | Home Assistant entities: three lights, a player sensor, a volume number, transport buttons, device triggers, a tag scanner. |
|
||||
| `musicmouse/services/web/` | The browser front-end's API: the library, a state websocket, command endpoints, and parent-mode settings. |
|
||||
| `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.
|
||||
|
||||
## The library
|
||||
|
||||
`general.library.root` is the one path to the music. The shelves under it are fixed
|
||||
names rather than settings (`musicmouse/library/sections.py`), because each has quirks
|
||||
the scanner has to know about:
|
||||
|
||||
| Folder | Shown as | Why it is special |
|
||||
|---|---|---|
|
||||
| `Figuren/<figure>/` | an album per figurine | the folder name *is* the figure name from the config |
|
||||
| `Musik/<Artist> - <Album>/` | music, grouped by artist | plain ID3 |
|
||||
| `Hörbücher/<Artist> - <Album>/` | audiobooks, grouped by character | `album_artist` is a credit list; only the name before the first comma groups usefully |
|
||||
| `Kinderpodcasts/<Show>/` | audiobooks, newest episode first | the tags are useless here - `artist` is the presenter list and `album` is the feed name, so the *folder* is the show |
|
||||
|
||||
Only files whose suffix is in `audio_extensions` are read, and dotfiles are skipped, so
|
||||
a podcast downloader's `archive.json` and its half-finished `.download.tmp` never reach
|
||||
a playlist.
|
||||
|
||||
Drop a `feed.txt` into a show folder (its first line the show's RSS feed URL) and the
|
||||
backend becomes that podcast downloader itself: every six hours it checks the feed and
|
||||
saves any episode not already on disk, named `YYYYMMDD - Title.ext` like a hand-placed
|
||||
one so it sorts and scans identically. A show with no `feed.txt` is untouched, exactly
|
||||
as before - the file is the opt-in, there is no separate setting for it.
|
||||
|
||||
Each album carries three colours, pulled out of its cover art with Pillow (or
|
||||
synthesised from a hash of its id when it has none). The frontend paints cards with
|
||||
them and the LED strips run the first of them, so shelf and screen agree.
|
||||
|
||||
### The cache
|
||||
|
||||
`general.library.cache` is a directory, not a file, because its contents cost wildly
|
||||
different amounts to produce:
|
||||
|
||||
```
|
||||
index.json cheap: tags and structure. Rebuilt freely.
|
||||
covers/<album_id>.jpg medium: art extracted from an ID3 APIC frame
|
||||
analysis/<track_key>.json expensive: reserved for offline audio analysis
|
||||
```
|
||||
|
||||
Deleting the whole directory is safe; deleting it throws away analysis that is minutes
|
||||
of DSP per track, which is why anything expensive is keyed by *file content* rather than
|
||||
by album id - renaming a folder or re-sorting a section then costs nothing.
|
||||
|
||||
An entry is reused whenever its files' sizes and mtimes are unchanged. That means a
|
||||
change to how the scanner derives a title, artist or series is invisible until the cache
|
||||
is invalidated: bump `_INDEX_VERSION` in `musicmouse/library/cache.py` when you touch
|
||||
that logic.
|
||||
|
||||
**Track analysis is not implemented.** `musicmouse/library/analysis.py` fixes the shape
|
||||
of the results - scalars (`tempo`, `energy`, `valence`, `brightness`) travel inline with
|
||||
the index, and a beat grid lives in its own file and is fetched per track - so an
|
||||
analyzer can be added later without touching the scanner, the API or the frontend. It
|
||||
will go behind an optional dependency group, and because results are content-keyed
|
||||
files, they can equally well be computed on a workstation and the `analysis/` folder
|
||||
copied to the device.
|
||||
|
||||
## The web front-end
|
||||
|
||||
`web/` in the repo root, served by this backend when `general.web.static_dir` is set.
|
||||
|
||||
| Route | Purpose |
|
||||
|---|---|
|
||||
| `GET /api/library` | Every album with its tracks and colours. ~90 kB, sent once. |
|
||||
| `GET /api/albums/{id}/cover` | The cover, or 404 - the client paints the album's colours instead. |
|
||||
| `POST /api/library/refresh` | Rescan in the background; connected clients are told when it lands. |
|
||||
| `GET /api/state` | Snapshot: what is playing, where, how loud. |
|
||||
| `WS /api/ws` | Push only. A snapshot on connect, then a frame per change, plus the position at 2 Hz while playing. |
|
||||
| `POST /api/play` | `{album_id, track_index?}` |
|
||||
| `POST /api/resume` `/pause` `/next` `/previous` `/seek` | transport |
|
||||
| `POST /api/volume` | `{percent}` or `{delta_percent}` |
|
||||
| `GET` `PUT /api/settings` | parent mode |
|
||||
|
||||
Two decisions worth knowing:
|
||||
|
||||
**Search is not an endpoint.** The whole index goes to the browser and filtering happens
|
||||
there, which is what makes the design's type-to-search feel instant.
|
||||
|
||||
**Volume is a percentage at this boundary.** `max_volume` is a parent's business, not a
|
||||
child's, so it never crosses into the browser: `100 %` means whatever ceiling is
|
||||
configured, and the mapping lives in `services/web/settings.py` so MQTT, the rotary
|
||||
encoder and the firmware carry on in device units.
|
||||
|
||||
### Parent mode
|
||||
|
||||
`?parentMode=1` reveals a settings panel for the volume limits, the rotary step and the
|
||||
button brightness. Saving writes `config.yml` back through ruamel's round-trip loader,
|
||||
so the file keeps its comments, and a new ceiling applies to the running player rather
|
||||
than waiting for a restart.
|
||||
|
||||
This **hides** the settings; it does not protect them. There is no authentication on any
|
||||
endpoint, which matches a device on a home network - put it behind a reverse proxy if
|
||||
that is not good enough.
|
||||
@@ -1,51 +1,165 @@
|
||||
# Example config.yml for the MusicMouse python-backend.
|
||||
# Example config for the MusicMouse backend.
|
||||
#
|
||||
# Reverse-engineered from main.py (load_config/Controller) since no schema
|
||||
# was previously documented. Copy this file to config.yml in the directory
|
||||
# passed as the CLI argument to main.py, e.g.:
|
||||
# python -m musicmouse --config /media/musicmouse/config.yml
|
||||
#
|
||||
# python main.py /media/musicmouse/
|
||||
#
|
||||
# main.py reads "<config_dir>/config.yml". Real credentials (hass_token,
|
||||
# mqtt.password) should never be committed - keep the real config.yml
|
||||
# outside the repo (e.g. only on the deployed device).
|
||||
# Unknown keys are rejected rather than ignored, and every problem in the file is
|
||||
# reported at once, so a typo fails at startup with the path to the offending line.
|
||||
# Keep the real config (with credentials) off the repo - on the device only.
|
||||
|
||||
general:
|
||||
# ALSA output device passed to python-vlc, e.g. "hw:0,0"; omit/null for VLC's default.
|
||||
# The music collection. One path; the shelves underneath it are fixed names, not
|
||||
# settings, because each one has its own quirks the code already knows about:
|
||||
#
|
||||
# <root>/Figuren/<figure name>/ one folder per figurine
|
||||
# <root>/Musik/<Artist> - <Album>/ albums, grouped by artist
|
||||
# <root>/Hörbücher/<Artist> - <Album>/ audiobooks, grouped by character
|
||||
# <root>/Kinderpodcasts/<Show>/ shows, newest episode first
|
||||
#
|
||||
# A cover.jpg next to the audio is used if present, otherwise the art is pulled out
|
||||
# of the files' tags. Relative paths resolve against this file's directory.
|
||||
library:
|
||||
root: /home/martin/Music
|
||||
# Scan results, extracted cover art and track analysis. Safe to delete: the index
|
||||
# is rebuilt on the next start. Deleting it does throw away track analysis, which
|
||||
# is expensive to recompute.
|
||||
cache: .musicmouse-cache
|
||||
|
||||
# Serial port the ESP32 firmware is on. A dropped link is retried, not fatal.
|
||||
# Required - use "simulate" to run without the mouse attached, which is a complete
|
||||
# setup on its own because the web front-end can drive the player by itself. RFID,
|
||||
# buttons and LEDs then do nothing, and startup says so every boot.
|
||||
serial_port: "/dev/ttyUSB0"
|
||||
baudrate: 115200
|
||||
reconnect_interval: 5.0
|
||||
|
||||
# ALSA output device passed to VLC, e.g. "hw:0,0", or "default" for the system
|
||||
# default output. Required - use "simulate" for a player that makes no sound, which
|
||||
# is handy when working on the web UI on a machine whose audio you would rather not
|
||||
# commandeer. Startup says so every boot.
|
||||
#
|
||||
# Both of these are required rather than optional on purpose: running blind or silent
|
||||
# has to be asked for, so a config that lost a line fails loudly instead of booting
|
||||
# into something that looks like it is working.
|
||||
alsa_device: "softvol_effects"
|
||||
|
||||
# Serial port the ESP32 firmware is connected on.
|
||||
serial_port: "/dev/ttyUSB0"
|
||||
# Volume, 0..100. min/max clamp everything, including the rotary encoder.
|
||||
min_volume: 0
|
||||
max_volume: 60
|
||||
initial_volume: 40
|
||||
volume_increment: 5 # per rotary-encoder click
|
||||
|
||||
# Home Assistant connection used for light/service calls (hass_service()).
|
||||
hass_url: "http://homeassistant.local:8123"
|
||||
hass_token: "REPLACE_WITH_LONG_LIVED_ACCESS_TOKEN"
|
||||
# Backlight of the prev/next buttons while a figure is playing, 0..1.
|
||||
button_leds_brightness: 0.5
|
||||
|
||||
# MQTT broker used for the Home-Assistant-discoverable "shelf light".
|
||||
# Which files count as music. Anything else - a podcast downloader's archive.json,
|
||||
# a half-finished .tmp - is ignored.
|
||||
audio_extensions: [".mp3", ".ogg", ".oga", ".opus", ".flac", ".wav", ".m4a", ".aac"]
|
||||
|
||||
# The web front-end. Omit the whole section to run without it.
|
||||
#
|
||||
# There is no authentication: this is a device on a home network. The settings panel
|
||||
# at ?parentMode=1 is hidden from the child, not protected from them - it writes back
|
||||
# to this file. Put it behind a reverse proxy if that is not good enough.
|
||||
web:
|
||||
host: "0.0.0.0"
|
||||
port: 8080
|
||||
# Built frontend to serve at /. Omit to expose only the JSON API.
|
||||
static_dir: ../web/dist
|
||||
|
||||
# IR remote control, over lircd's TCP socket (see ansible/roles/pi_lirc for how
|
||||
# lircd itself is set up on the Pi). Omit the whole section to run without a remote.
|
||||
# Play/pause/stop/previous/forward/rewind/volume/mute map to normal music control;
|
||||
# number keys 0-9 play whatever the "remote:" section below assigns them.
|
||||
lirc:
|
||||
host: "musicmouse-pi.local"
|
||||
port: 2222 # this deployment's lircd listens on 2222, not its own
|
||||
# default of 8765 - see the ansible role
|
||||
remote_name: "Hauppauge" # other remotes registered with the same lircd (an LED
|
||||
# remote, say) are ignored
|
||||
reconnect_interval: 5.0
|
||||
|
||||
# Home Assistant integration. Omit the whole section to run without MQTT.
|
||||
# The backend exposes three lights, a player sensor, a volume slider, transport
|
||||
# buttons, device triggers for every button/touch area, and a tag scanner.
|
||||
mqtt:
|
||||
server: "homeassistant.local"
|
||||
port: 1883
|
||||
user: "musicmouse"
|
||||
password: "REPLACE_WITH_MQTT_PASSWORD"
|
||||
base_topic: "musicmouse"
|
||||
discovery_prefix: "homeassistant"
|
||||
device_id: "musicmouse"
|
||||
device_name: "Music Mouse"
|
||||
reconnect_interval: 10.0
|
||||
|
||||
# Optional playback/UI tuning (all have defaults if omitted).
|
||||
min_volume: 0
|
||||
max_volume: 32
|
||||
volume_increment: 5 # per rotary-encoder tick
|
||||
button_leds_brightness: 0.5 # 0..1, brightness of the prev/next button backlight
|
||||
# Room control page ("Mein Zimmer"). Omit the whole section to hide the page. This is
|
||||
# the opposite direction from mqtt above: it's musicmouse controlling Home Assistant
|
||||
# entities, not the other way round. The backend proxies every call to Home
|
||||
# Assistant's REST API with this token attached; the browser never sees it, only
|
||||
# entity ids and display names. Home Assistant's own CORS settings do not need to
|
||||
# allow musicmouse's origin for this - the browser only ever talks to musicmouse.
|
||||
ha:
|
||||
url: "http://homeassistant.local:8123"
|
||||
# A long-lived access token, created under the HA user's own profile page.
|
||||
token: "REPLACE_WITH_HA_LONG_LIVED_TOKEN"
|
||||
# Cards on the room page, in this order. "name" is optional; falls back to the
|
||||
# entity id if omitted.
|
||||
devices:
|
||||
- entity_id: cover.kinderzimmer_rollo
|
||||
name: "Rollo"
|
||||
- entity_id: light.kinderzimmer_hue_beyond_links
|
||||
name: "Hue Beyond links"
|
||||
- entity_id: light.kinderzimmer_deckenlampe
|
||||
name: "Deckenlampe"
|
||||
# Scene pill row above the cards, in this order.
|
||||
scenes:
|
||||
- entity_id: scene.kinderzimmer_lesen
|
||||
name: "Lesen"
|
||||
- entity_id: scene.kinderzimmer_gute_nacht
|
||||
name: "Gute Nacht"
|
||||
|
||||
# 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).
|
||||
# The typing game ("Tippen"). Omit the whole section to hide its tab in the web
|
||||
# front-end. The lesson plan is content, not device config, so it lives in its own
|
||||
# file - see tippen-curriculum.yml.example for the format, including the optional
|
||||
# `unlocks:` key that turns passing a lesson into unlocking part of the library.
|
||||
tippen:
|
||||
curriculum_file: tippen-curriculum.yml
|
||||
# Where progress (stars, unlocked lessons, streak, ...) is saved. Written by the
|
||||
# app itself - never hand-edited. Relative to this file, like curriculum_file.
|
||||
progress_file: tippen-progress.json
|
||||
|
||||
# One entry per figurine. The key is the figure name and the subfolder name.
|
||||
figures:
|
||||
fuchs:
|
||||
# RFID tag id as a hex string (matched against bytes read from the reader).
|
||||
# RFID tag id, 5 bytes as hex. Must be unique across figures.
|
||||
id: "04a1b2c3d4"
|
||||
# Exactly 4 colors: [primary, secondary, background, accent].
|
||||
# Accepted formats: "#rrggbb" (RGB hex) or "wNN" (white channel hex, e.g. "wff").
|
||||
colors: ["#ff6600", "#ffcc00", "#331100", "#ffffff"]
|
||||
# Optional explicit list of media file paths for this figure's playlist.
|
||||
# If omitted, main.py globs os.path.join(config_dir, "<figure_name>").
|
||||
media_files: []
|
||||
# Exactly four colours: primary, secondary, background, accent.
|
||||
# Either "#rrggbb" (RGB) or "wNN" (white channel only, hex).
|
||||
colors: ["#ff6600", "#ffcc00", "#331100", "wff"]
|
||||
# "music" (default) or "book". Every other shelf is named after what is on it, so
|
||||
# its type is obvious; a figure folder is named after the figurine, so this is the
|
||||
# one thing that has to be said out loud. The web UI draws albums square and
|
||||
# audiobooks taller than wide, so getting it wrong is visible at a glance.
|
||||
kind: music
|
||||
|
||||
eule:
|
||||
id: "04b2c3d4e5"
|
||||
colors: ["#3355ff", "#66aaff", "#001133", "#ffffff"]
|
||||
kind: book
|
||||
|
||||
# Number keys 0-9 on the IR remote, mapped to what they play. Omit the whole section,
|
||||
# or any digit within it, for "unassigned" - a fresh install boots with none of this
|
||||
# and that is not an error. Editable from the web front-end, which writes back here.
|
||||
#
|
||||
# target_kind: album -> always starts from the first track (music, audiobooks).
|
||||
# target is an album id, as shown at GET /api/library.
|
||||
# target_kind: series -> always plays the newest episode of a podcast show, resolved
|
||||
# fresh on every press - never a fixed episode. target is the
|
||||
# show's folder name under Kinderpodcasts, e.g. "Wissen macht Ah".
|
||||
remote:
|
||||
"1":
|
||||
target_kind: album
|
||||
target: "3f9a0c12ab44"
|
||||
"2":
|
||||
target_kind: series
|
||||
target: "Wissen macht Ah"
|
||||
|
||||
@@ -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
@@ -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
@@ -0,0 +1,5 @@
|
||||
"""MusicMouse backend: an RFID music player for kids."""
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "2.0.0"
|
||||
394
python-backend/musicmouse/__main__.py
Normal file
@@ -0,0 +1,394 @@
|
||||
"""Entry point and composition root.
|
||||
|
||||
python -m musicmouse --config /media/musicmouse/config.yml
|
||||
python -m musicmouse --config ./config.yml --no-hardware
|
||||
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", "no mouse attached" 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 Awaitable, Callable, Coroutine
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx2
|
||||
|
||||
from musicmouse import __version__
|
||||
from musicmouse.app import App
|
||||
from musicmouse.bus import EventBus
|
||||
from musicmouse.clock import RealClock
|
||||
from musicmouse.config import SIMULATE, Config, ConfigError, GeneralConfig, load_config
|
||||
from musicmouse.devices.mouse import MusicMouseDevice
|
||||
from musicmouse.devices.null_transport import NullTransport
|
||||
from musicmouse.devices.player import Player, VlcPlayer
|
||||
from musicmouse.devices.serial_link import SerialLink
|
||||
from musicmouse.library import MusicLibrary
|
||||
from musicmouse.library.analysis import build_analyzer
|
||||
from musicmouse.reactions import register_all
|
||||
from musicmouse.services.base import Service
|
||||
from musicmouse.services.lirc import LircService
|
||||
from musicmouse.services.mqtt import MqttService, build_entities
|
||||
from musicmouse.services.podcasts import PodcastFeedService
|
||||
from musicmouse.services.web import WebService
|
||||
from musicmouse.tippen.curriculum import CurriculumError
|
||||
from musicmouse.tippen.runtime import TippenRuntime, build_tippen_runtime
|
||||
|
||||
_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(
|
||||
"--no-hardware",
|
||||
action="store_true",
|
||||
help="real audio and a real web front-end, but no serial port: for a host with "
|
||||
"no mouse attached",
|
||||
)
|
||||
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")
|
||||
if args.simulate and args.no_hardware:
|
||||
parser.error("--simulate already runs without hardware; drop --no-hardware")
|
||||
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
|
||||
|
||||
tippen: TippenRuntime | None = None
|
||||
if config.general.tippen is not None:
|
||||
try:
|
||||
tippen = build_tippen_runtime(config.general.tippen)
|
||||
except CurriculumError as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
runner = (
|
||||
run_simulated(config, args.config, args.script, tippen=tippen)
|
||||
if args.simulate
|
||||
else run_real(
|
||||
config, args.config, hardware=wants_hardware(config, args.no_hardware), tippen=tippen
|
||||
)
|
||||
)
|
||||
try:
|
||||
asyncio.run(runner)
|
||||
except KeyboardInterrupt:
|
||||
_log.info("Interrupted")
|
||||
return 0
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------- real
|
||||
|
||||
|
||||
async def run_real(
|
||||
config: Config,
|
||||
config_path: Path,
|
||||
*,
|
||||
hardware: bool = True,
|
||||
tippen: TippenRuntime | None = None,
|
||||
) -> None:
|
||||
bus = EventBus()
|
||||
await bus.start()
|
||||
clock = RealClock()
|
||||
general = config.general
|
||||
|
||||
link: SerialLink | None = None
|
||||
if hardware:
|
||||
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)
|
||||
else:
|
||||
mouse = MusicMouseDevice(bus, NullTransport(), config.tag_map, port="none")
|
||||
|
||||
player = _build_player(bus, general, clock=clock)
|
||||
|
||||
library = await build_library(config)
|
||||
app = _build_app(config, bus, mouse, player, library, clock=clock, tippen=tippen)
|
||||
services = _build_services(app, mouse, player, clock=clock, config_path=config_path)
|
||||
|
||||
_log.info(
|
||||
"MusicMouse %s starting: %d figures, %d albums, serial %s, audio %s, mqtt %s",
|
||||
__version__,
|
||||
len(config.figures),
|
||||
len(library.albums),
|
||||
# Report what the config says, with a marker when --no-hardware overrode it,
|
||||
# so the line never disagrees with the file it was started from.
|
||||
general.serial_port + ("" if link or general.serial_simulated else " (no link)"),
|
||||
general.alsa_device,
|
||||
general.mqtt.server if general.mqtt else "disabled",
|
||||
)
|
||||
try:
|
||||
await _run_forever(
|
||||
[
|
||||
*([link.run()] if link else []),
|
||||
player.run(),
|
||||
# `is_busy` reads `player.is_playing` directly rather than the library
|
||||
# knowing about playback at all: analysis must never compete with audio
|
||||
# decoding for CPU, and this device is otherwise idle most of the day, so
|
||||
# the pass simply resumes once it is. `on_batch` is unset unless the web
|
||||
# front-end is on - nothing else has a use for the notification.
|
||||
library.run_analysis(
|
||||
is_busy=lambda: player.is_playing,
|
||||
on_batch=_analysis_batch_hook(services),
|
||||
),
|
||||
*(service.run() for service in services),
|
||||
]
|
||||
)
|
||||
finally:
|
||||
player.close()
|
||||
await bus.stop()
|
||||
|
||||
|
||||
# -------------------------------------------------------------------- simulated
|
||||
|
||||
|
||||
async def run_simulated(
|
||||
config: Config, config_path: Path, script: Path | None, *, tippen: TippenRuntime | None = 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, tippen=tippen
|
||||
)
|
||||
|
||||
services = _build_services(
|
||||
sim.app, sim.app.mouse, sim.player, clock=RealClock(), config_path=config_path
|
||||
)
|
||||
tasks = [asyncio.create_task(service.run(), name=service.name) for service in services]
|
||||
tasks.append(
|
||||
asyncio.create_task(
|
||||
sim.app.library.run_analysis(
|
||||
is_busy=lambda: sim.player.is_playing,
|
||||
on_batch=_analysis_batch_hook(services),
|
||||
),
|
||||
name="library-analysis",
|
||||
)
|
||||
)
|
||||
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 wants_hardware(config: Config, no_hardware_flag: bool) -> bool:
|
||||
"""Whether to open a serial port at all.
|
||||
|
||||
``serial_port: simulate`` says the same thing ``--no-hardware`` does. It is asked
|
||||
for rather than inferred from a missing key, but it is still worth saying out loud
|
||||
every boot - a mouse whose RFID reader does nothing should say why.
|
||||
"""
|
||||
if no_hardware_flag:
|
||||
return False
|
||||
if config.general.serial_simulated:
|
||||
_log.warning(
|
||||
'general.serial_port is "%s": running without the mouse. '
|
||||
"The web front-end still works; RFID, buttons and LEDs do not.",
|
||||
SIMULATE,
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _build_player(bus: EventBus, general: GeneralConfig, *, clock: RealClock) -> Player:
|
||||
"""A real player, or a silent one when the config asked for that.
|
||||
|
||||
``alsa_device: simulate`` gets the simulator's player: everything above it behaves
|
||||
identically, it just makes no sound. Useful for working on the web UI without
|
||||
commandeering the machine's audio.
|
||||
"""
|
||||
if not general.audio_simulated:
|
||||
return VlcPlayer(
|
||||
bus,
|
||||
alsa_device=general.alsa_device,
|
||||
clock=clock,
|
||||
**VlcPlayer.volume_kwargs(general),
|
||||
)
|
||||
|
||||
_log.warning(
|
||||
'general.alsa_device is "%s": using a simulated player, so nothing will be '
|
||||
'audible. Set it to "default" for the system default output.',
|
||||
SIMULATE,
|
||||
)
|
||||
# Imported here rather than at module scope so the real audio path never loads the
|
||||
# simulator - and so this still runs on a machine without libVLC at all.
|
||||
from musicmouse.simulator.fake_player import FakePlayer
|
||||
|
||||
return FakePlayer(bus, clock=clock, **FakePlayer.volume_kwargs(general))
|
||||
|
||||
|
||||
async def build_library(config: Config) -> MusicLibrary:
|
||||
library_config = config.general.library
|
||||
return await MusicLibrary.build(
|
||||
library_config.root,
|
||||
library_config.cache,
|
||||
frozenset(config.general.audio_extensions),
|
||||
analyzer=build_analyzer(),
|
||||
figure_kinds=config.figure_kinds,
|
||||
)
|
||||
|
||||
|
||||
def _build_app(
|
||||
config: Config,
|
||||
bus: EventBus,
|
||||
mouse: MusicMouseDevice,
|
||||
player: Player,
|
||||
library: MusicLibrary,
|
||||
*,
|
||||
clock: RealClock,
|
||||
tippen: TippenRuntime | None = None,
|
||||
) -> App:
|
||||
app = App(
|
||||
config=config,
|
||||
bus=bus,
|
||||
mouse=mouse,
|
||||
player=player,
|
||||
library=library,
|
||||
# One source of truth: the figure path and the web path must hand the player the
|
||||
# same Playlist object for the same folder, because `play_figure` resumes on an
|
||||
# identity check.
|
||||
playlists=library.figure_playlists(),
|
||||
clock=clock,
|
||||
tippen=tippen,
|
||||
)
|
||||
register_all(bus, app)
|
||||
return app
|
||||
|
||||
|
||||
def _service_of_type[T: Service](services: list[Service], kind: type[T]) -> T | None:
|
||||
return next((s for s in services if isinstance(s, kind)), None)
|
||||
|
||||
|
||||
def _analysis_batch_hook(services: list[Service]) -> Callable[[], Awaitable[None]] | None:
|
||||
"""The web front-end's own hub, if it is running - so open tabs refetch the library
|
||||
as background analysis lands, instead of only after a manual reload. `None` when
|
||||
there is no web service, which `MusicLibrary.analyze_pending` treats as "nobody to
|
||||
tell".
|
||||
"""
|
||||
web_service = _service_of_type(services, WebService)
|
||||
return web_service.hub.broadcast_library if web_service else None
|
||||
|
||||
|
||||
def _build_services(
|
||||
app: App,
|
||||
mouse: MusicMouseDevice,
|
||||
player: Player,
|
||||
*,
|
||||
clock: RealClock,
|
||||
config_path: Path,
|
||||
) -> list[Service]:
|
||||
"""Every front-end. Each one only speaks intents, so they cannot conflict."""
|
||||
services: list[Service] = []
|
||||
|
||||
mqtt_config = app.config.general.mqtt
|
||||
if mqtt_config is None:
|
||||
_log.info("No mqtt section in the config: Home Assistant integration is off")
|
||||
else:
|
||||
entities = build_entities(app.bus, mqtt_config, mouse, player)
|
||||
services.append(MqttService(app.bus, mqtt_config, entities, clock=clock))
|
||||
|
||||
web_config = app.config.general.web
|
||||
if web_config is None:
|
||||
_log.info("No web section in the config: the web front-end is off")
|
||||
else:
|
||||
services.append(WebService(app, web_config, config_path))
|
||||
|
||||
lirc_config = app.config.general.lirc
|
||||
if lirc_config is None:
|
||||
_log.info("No lirc section in the config: the IR remote is off")
|
||||
else:
|
||||
services.append(LircService(app, lirc_config, clock=clock))
|
||||
|
||||
# Unconditional: a show only starts downloading once someone drops a `feed.txt`
|
||||
# into its folder, so there is nothing to gate here with its own config section.
|
||||
web_service = _service_of_type(services, WebService)
|
||||
services.append(
|
||||
PodcastFeedService(
|
||||
app.library,
|
||||
client=httpx2.AsyncClient(timeout=30.0),
|
||||
on_change=lambda: app.rescan_library(
|
||||
broadcast=web_service.hub.broadcast_library if web_service else None
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
return services
|
||||
|
||||
|
||||
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())
|
||||
78
python-backend/musicmouse/app.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""What the reactions get handed: the three objects, the bus, and a little shared state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
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.library import MusicLibrary
|
||||
from musicmouse.library.models import Album
|
||||
from musicmouse.media import Playlist
|
||||
from musicmouse.tippen.runtime import TippenRuntime
|
||||
|
||||
_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
|
||||
|
||||
#: Whether the MQTT broker is currently reachable. The firmware's equivalent is
|
||||
#: readable off the transport; a broker's is not, so it is remembered here.
|
||||
mqtt_connected: bool = False
|
||||
|
||||
#: Whether the lircd TCP link for the IR remote is currently reachable.
|
||||
lirc_connected: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class App:
|
||||
config: Config
|
||||
bus: EventBus
|
||||
mouse: MusicMouseDevice
|
||||
player: Player
|
||||
library: MusicLibrary
|
||||
playlists: dict[str, Playlist]
|
||||
clock: Clock = field(default_factory=RealClock)
|
||||
state: AppState = field(default_factory=AppState)
|
||||
#: `None` when `general.tippen` is absent - the typing game is off.
|
||||
tippen: TippenRuntime | None = None
|
||||
|
||||
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
|
||||
|
||||
def album_for(self, playlist: Playlist | None) -> Album | None:
|
||||
"""The library album a playlist came from, if any."""
|
||||
return self.library.get(playlist.album_id if playlist else None)
|
||||
|
||||
async def rescan_library(
|
||||
self, *, broadcast: Callable[[], Awaitable[None]] | None = None
|
||||
) -> None:
|
||||
"""Rescan from disk, rebuild figure playlists, and tell whoever's listening.
|
||||
|
||||
Shared by the manual "Bibliothek neu einlesen" endpoint and anything else that
|
||||
can change what's on disk on its own, such as the podcast feed poller.
|
||||
"""
|
||||
await self.library.refresh()
|
||||
self.playlists.clear()
|
||||
self.playlists.update(self.library.figure_playlists())
|
||||
if broadcast is not None:
|
||||
await broadcast()
|
||||
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
@@ -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
@@ -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')")
|
||||
422
python-backend/musicmouse/config.py
Normal file
@@ -0,0 +1,422 @@
|
||||
"""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, Final, Literal, 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
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"SIMULATE",
|
||||
"Config",
|
||||
"ConfigError",
|
||||
"Digit",
|
||||
"FigureColors",
|
||||
"FigureConfig",
|
||||
"GeneralConfig",
|
||||
"HaConfig",
|
||||
"HaDeviceConfig",
|
||||
"LibraryConfig",
|
||||
"LircConfig",
|
||||
"MqttConfig",
|
||||
"RemoteSlotConfig",
|
||||
"TippenConfig",
|
||||
"WebConfig",
|
||||
"format_validation_error",
|
||||
"load_config",
|
||||
]
|
||||
|
||||
#: Number keys on the IR remote, as lircd's ``BTN_0``..``BTN_9`` map to them.
|
||||
type Digit = Literal["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]
|
||||
|
||||
DEFAULT_AUDIO_EXTENSIONS = (".mp3", ".ogg", ".oga", ".opus", ".flac", ".wav", ".m4a", ".aac")
|
||||
|
||||
#: Stand-in value for ``serial_port`` and ``alsa_device``. Running without the mouse or
|
||||
#: without sound is a supported setup, but it has to be *asked for*: a missing key is an
|
||||
#: error, so a config that lost a line fails loudly instead of booting into a silent
|
||||
#: mouse that looks like it is working.
|
||||
SIMULATE: Final = "simulate"
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def _resolve_folder(
|
||||
folder: Path, info: ValidationInfo, *, must_exist: bool, kind: Literal["dir", "file"] = "dir"
|
||||
) -> Path:
|
||||
"""Make a configured path absolute against the config file, and optionally check it."""
|
||||
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 must_exist and context.get("check_paths", True):
|
||||
exists = folder.is_file() if kind == "file" else folder.is_dir()
|
||||
if not exists:
|
||||
noun = "file" if kind == "file" else "directory"
|
||||
raise ValueError(f"no such {noun}: {folder}")
|
||||
return folder
|
||||
|
||||
|
||||
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 LircConfig(_Strict):
|
||||
"""TCP client for lircd's classic network protocol - see ``ansible/roles/pi_lirc``.
|
||||
|
||||
Omit the whole section to run without an IR remote.
|
||||
"""
|
||||
|
||||
host: str
|
||||
#: This deployment's lircd listens on 2222 (see the ansible role); lircd's own
|
||||
#: default is 8765, so this is worth overriding rather than assuming.
|
||||
port: int = Field(default=2222, ge=1, le=65535)
|
||||
#: Only button events from this remote are acted on - other remotes registered
|
||||
#: with the same lircd (an LED remote, say) are ignored.
|
||||
remote_name: str = "Hauppauge"
|
||||
reconnect_interval: float = Field(default=5.0, gt=0)
|
||||
|
||||
|
||||
class LibraryConfig(_Strict):
|
||||
"""Where the music lives.
|
||||
|
||||
One path. The shelves underneath it - ``Figuren``, ``Musik``, ``Hoerbuecher``,
|
||||
``Kinderpodcasts`` - are fixed names, not settings; see
|
||||
:mod:`musicmouse.library.sections`.
|
||||
"""
|
||||
|
||||
root: Path
|
||||
#: Scan results, extracted cover art and track analysis. Relative to this file.
|
||||
cache: Path = Path(".musicmouse-cache")
|
||||
|
||||
@field_validator("root")
|
||||
@classmethod
|
||||
def _resolve_root(cls, folder: Path, info: ValidationInfo) -> Path:
|
||||
return _resolve_folder(folder, info, must_exist=True)
|
||||
|
||||
@field_validator("cache")
|
||||
@classmethod
|
||||
def _resolve_cache(cls, folder: Path, info: ValidationInfo) -> Path:
|
||||
return _resolve_folder(folder, info, must_exist=False)
|
||||
|
||||
@property
|
||||
def figure_folder(self) -> Path:
|
||||
return self.root / "Figuren"
|
||||
|
||||
|
||||
class WebConfig(_Strict):
|
||||
"""The web front-end. Omit the whole section to run without it."""
|
||||
|
||||
#: A LAN appliance with no auth; binding to all interfaces is the point.
|
||||
host: str = "0.0.0.0"
|
||||
port: int = Field(default=8080, ge=1, le=65535)
|
||||
#: Built frontend to serve at ``/``. Omit to expose only the JSON API.
|
||||
static_dir: Path | None = None
|
||||
|
||||
@field_validator("static_dir")
|
||||
@classmethod
|
||||
def _resolve_static(cls, folder: Path | None, info: ValidationInfo) -> Path | None:
|
||||
return None if folder is None else _resolve_folder(folder, info, must_exist=False)
|
||||
|
||||
|
||||
class HaDeviceConfig(_Strict):
|
||||
"""One Home Assistant entity to expose to the room-control page ("Mein Zimmer")."""
|
||||
|
||||
entity_id: str
|
||||
name: str | None = None
|
||||
|
||||
|
||||
class HaConfig(_Strict):
|
||||
"""Home Assistant integration for the room-control page ("Mein Zimmer").
|
||||
|
||||
The backend never calls Home Assistant itself - it only hands the browser the
|
||||
server URL, the token, and these two ordered lists. Control happens directly from
|
||||
the browser to Home Assistant's own REST API, so this token grants full HA control
|
||||
to anything on the LAN that can reach musicmouse. See config.yml.example.
|
||||
"""
|
||||
|
||||
url: str
|
||||
token: str
|
||||
#: Order is preserved and drives the device card grid on the room page.
|
||||
devices: list[HaDeviceConfig] = Field(default_factory=list)
|
||||
#: Order is preserved and drives the scene pill row on the room page.
|
||||
scenes: list[HaDeviceConfig] = Field(default_factory=list)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_something_configured(self) -> Self:
|
||||
if not self.devices and not self.scenes:
|
||||
raise ValueError("configure at least one device or scene, or omit the ha section")
|
||||
return self
|
||||
|
||||
|
||||
class TippenConfig(_Strict):
|
||||
"""The typing game. Omit the whole section to run without it.
|
||||
|
||||
The curriculum is content, not device settings, so it lives in its own file
|
||||
(``curriculum_file``) rather than inline here - see ``tippen-curriculum.yml.example``.
|
||||
``progress_file`` is written by the app itself, not hand-edited, and defaults to a
|
||||
name next to ``config.yml`` if not given a folder of its own.
|
||||
"""
|
||||
|
||||
curriculum_file: Path
|
||||
progress_file: Path = Path("tippen-progress.json")
|
||||
|
||||
@field_validator("curriculum_file")
|
||||
@classmethod
|
||||
def _resolve_curriculum_file(cls, path: Path, info: ValidationInfo) -> Path:
|
||||
return _resolve_folder(path, info, must_exist=True, kind="file")
|
||||
|
||||
@field_validator("progress_file")
|
||||
@classmethod
|
||||
def _resolve_progress_file(cls, path: Path, info: ValidationInfo) -> Path:
|
||||
return _resolve_folder(path, info, must_exist=False, kind="file")
|
||||
|
||||
|
||||
class GeneralConfig(_Strict):
|
||||
library: LibraryConfig
|
||||
|
||||
#: Serial port the ESP32 is on, or ``"simulate"`` to run without the mouse: the
|
||||
#: web front-end is a complete way to drive the player on its own. Required.
|
||||
serial_port: str
|
||||
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"`` or ``"default"``, or
|
||||
#: ``"simulate"`` for a player that makes no sound. Required.
|
||||
alsa_device: str
|
||||
|
||||
mqtt: MqttConfig | None = None
|
||||
web: WebConfig | None = None
|
||||
ha: HaConfig | None = None
|
||||
lirc: LircConfig | None = None
|
||||
tippen: TippenConfig | 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
|
||||
|
||||
@property
|
||||
def serial_simulated(self) -> bool:
|
||||
return self.serial_port == SIMULATE
|
||||
|
||||
@property
|
||||
def audio_simulated(self) -> bool:
|
||||
return self.alsa_device == SIMULATE
|
||||
|
||||
@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
|
||||
|
||||
|
||||
class FigureConfig(_Strict):
|
||||
#: RFID tag id as hex, e.g. "04a1b2c3d4".
|
||||
id: TagId
|
||||
colors: FigureColors
|
||||
#: What this figure holds. Unlike the other shelves a figure folder is named after
|
||||
#: the figurine rather than its contents, so nothing on disk says whether it is an
|
||||
#: album or an audiobook - and the browse view draws the two differently.
|
||||
kind: Literal["music", "book"] = "music"
|
||||
|
||||
|
||||
class RemoteSlotConfig(_Strict):
|
||||
"""What a number key on the IR remote plays.
|
||||
|
||||
``"album"``: ``target`` is an ``Album.id``, always started from track 0 - a music
|
||||
album or an audiobook. ``"series"``: ``target`` is a podcast show name (an
|
||||
``Album.series``); resolved to that show's newest episode fresh on every press,
|
||||
since a podcast show is not itself one playable thing in this library - each
|
||||
episode is its own album.
|
||||
"""
|
||||
|
||||
target_kind: Literal["album", "series"]
|
||||
target: str
|
||||
|
||||
|
||||
class Config(_Strict):
|
||||
general: GeneralConfig
|
||||
figures: dict[str, FigureConfig] = Field(min_length=1)
|
||||
#: Number key (0-9) -> what it plays. Empty by default: a fresh install has no
|
||||
#: assignments, and that is not an error.
|
||||
remote: dict[Digit, RemoteSlotConfig] = Field(default_factory=dict)
|
||||
|
||||
@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()}
|
||||
|
||||
@property
|
||||
def figure_kinds(self) -> dict[str, Literal["music", "book"]]:
|
||||
"""Figure name -> what it holds, as handed to the library scanner."""
|
||||
return {name: figure.kind for name, figure in self.figures.items()}
|
||||
|
||||
def folder_for(self, figure: str) -> Path:
|
||||
return self.general.library.figure_folder / figure
|
||||
|
||||
|
||||
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)"
|
||||
elif entry["type"] == "missing":
|
||||
message = "required (see 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
@@ -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
@@ -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")
|
||||
)
|
||||
22
python-backend/musicmouse/devices/null_transport.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""A transport with nothing on the other end.
|
||||
|
||||
The web front-end is a complete way to drive the mouse, so the backend has to be
|
||||
useful on a machine that has no mouse attached - a spare Pi, a laptop, a container.
|
||||
Rather than making :class:`~musicmouse.devices.mouse.MusicMouseDevice` optional and
|
||||
teaching every reaction to cope with its absence, the device is built as usual against
|
||||
a transport that drops what it is handed. Lighting reactions still run; their bytes go
|
||||
nowhere.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__all__ = ["NullTransport"]
|
||||
|
||||
|
||||
class NullTransport:
|
||||
def write(self, data: bytes) -> None:
|
||||
pass
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
return False
|
||||
365
python-backend/musicmouse/devices/player.py
Normal file
@@ -0,0 +1,365 @@
|
||||
"""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: ...
|
||||
@property
|
||||
def position(self) -> float:
|
||||
"""Seconds into the current track. ``0.0`` when nothing is loaded.
|
||||
|
||||
Read on demand rather than announced: a progress bar wants this twice a second,
|
||||
and an event at that rate would flood the bus, the MQTT service and the log for
|
||||
the benefit of one front-end.
|
||||
"""
|
||||
...
|
||||
|
||||
@property
|
||||
def duration(self) -> float:
|
||||
"""Length of the current track in seconds, or ``0.0`` when unknown."""
|
||||
...
|
||||
|
||||
def set_playlist(self, playlist: Playlist) -> None: ...
|
||||
def play(self) -> None: ...
|
||||
def play_from_start(self) -> None: ...
|
||||
def play_track(self, index: int) -> None: ...
|
||||
def pause(self) -> None: ...
|
||||
def stop(self) -> None: ...
|
||||
def next_track(self) -> None: ...
|
||||
def previous_track(self) -> None: ...
|
||||
def seek(self, position: float) -> None: ...
|
||||
def set_volume(self, volume: int, *, source: EventSource = "system") -> None: ...
|
||||
def change_volume(self, delta: int, *, source: EventSource = "system") -> None: ...
|
||||
def set_volume_limits(self, minimum: int, maximum: int) -> 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
|
||||
self._playlist_changed = 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]
|
||||
|
||||
# ------------------------------------------------------------------ actions
|
||||
|
||||
def set_volume(self, volume: int, *, source: EventSource = "system") -> None:
|
||||
clamped = self._clamp(volume)
|
||||
if clamped == self._volume:
|
||||
return
|
||||
self._volume = clamped
|
||||
self._apply_volume(clamped)
|
||||
self._announce_volume(source)
|
||||
|
||||
def change_volume(self, delta: int, *, source: EventSource = "system") -> None:
|
||||
self.set_volume(self._volume + delta, source=source)
|
||||
|
||||
def set_volume_limits(self, minimum: int, maximum: int) -> None:
|
||||
"""Re-clamp to a new allowed range, and pull the current volume into it.
|
||||
|
||||
Parent mode edits these while the mouse is playing, so they cannot only be
|
||||
constructor arguments.
|
||||
"""
|
||||
self._min_volume = minimum
|
||||
self._max_volume = maximum
|
||||
self.set_volume(self._volume)
|
||||
|
||||
def _apply_volume(self, volume: int) -> None:
|
||||
"""Push the new volume at whatever actually makes sound. No-op by default."""
|
||||
|
||||
# ---------------------------------------------------------------- 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._playlist_changed = False
|
||||
self._bus.emit(
|
||||
PlaybackChanged(
|
||||
playing=playing, figure=figure, playlist=self._playlist, source="player"
|
||||
)
|
||||
)
|
||||
|
||||
def _set_index(self, index: int) -> None:
|
||||
# A playlist swap while already playing resets the index to 0 without going
|
||||
# through here (see `_load_playlist`), so the following `play_track(0)` looks
|
||||
# like a no-op index change. Force it through in that case - `_set_playing`
|
||||
# will not itself announce anything, since it was already playing before and
|
||||
# after. Starting from idle needs no such push: `_set_playing`'s own True
|
||||
# transition already covers the broadcast.
|
||||
force = self._playlist_changed and self._playing
|
||||
if index == self._index and not force:
|
||||
return
|
||||
self._playlist_changed = False
|
||||
self._index = index
|
||||
self._bus.emit(TrackChanged(index=index, track=self.current_track, source="player"))
|
||||
|
||||
def _load_playlist(self, playlist: Playlist) -> None:
|
||||
"""Bookkeeping shared by every player's ``set_playlist``."""
|
||||
self._playlist = playlist
|
||||
self._index = 0
|
||||
self._playlist_changed = True
|
||||
|
||||
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)
|
||||
|
||||
# -------------------------------------------------------------------- state
|
||||
|
||||
@property
|
||||
def position(self) -> float:
|
||||
# libVLC reports -1 for both of these until a media is actually opened.
|
||||
return max(0.0, float(self._media_player.get_time()) / 1000)
|
||||
|
||||
@property
|
||||
def duration(self) -> float:
|
||||
return max(0.0, float(self._media_player.get_length()) / 1000)
|
||||
|
||||
# ------------------------------------------------------------------ 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._load_playlist(playlist)
|
||||
_log.info("Playlist %r loaded (%d tracks)", playlist.name, len(playlist))
|
||||
|
||||
def play(self) -> None:
|
||||
if self._playing:
|
||||
return
|
||||
self._list_player.play()
|
||||
|
||||
def play_from_start(self) -> None:
|
||||
self.play_track(0)
|
||||
|
||||
def play_track(self, index: int) -> 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(max(0, min(index, len(self._playlist) - 1)))
|
||||
|
||||
def pause(self) -> None:
|
||||
if not self._playing:
|
||||
return
|
||||
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 seek(self, position: float) -> None:
|
||||
self._media_player.set_time(int(max(0.0, position) * 1000))
|
||||
|
||||
def _apply_volume(self, volume: int) -> None:
|
||||
self._media_player.audio_set_volume(volume)
|
||||
|
||||
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()))
|
||||
self._sync_index()
|
||||
|
||||
def _sync_index(self) -> None:
|
||||
"""Read which track is playing back off libVLC.
|
||||
|
||||
Asking the media player which media it holds works on every build; the
|
||||
``MediaListPlayerNextItemSet`` payload does not - on some it arrives as a bare
|
||||
int rather than a Media, and the index would then never move off zero.
|
||||
"""
|
||||
media = self._media_player.get_media()
|
||||
if media is None:
|
||||
return
|
||||
index = self._mrl_to_index.get(media.get_mrl())
|
||||
if index is not None:
|
||||
self._set_index(index)
|
||||
|
||||
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:
|
||||
# The event says *when* to look; what it carries is not portable, so ignore it.
|
||||
self._sync_index()
|
||||
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
@@ -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: ...
|
||||
426
python-backend/musicmouse/devices/wire.py
Normal file
@@ -0,0 +1,426 @@
|
||||
"""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 typing import override
|
||||
|
||||
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
|
||||
|
||||
@override
|
||||
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
@@ -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))
|
||||
272
python-backend/musicmouse/events.py
Normal file
@@ -0,0 +1,272 @@
|
||||
"""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",
|
||||
"PlayAlbumRequested",
|
||||
"PlayFigureRequested",
|
||||
"PlayRequested",
|
||||
"PlaySeriesLatestRequested",
|
||||
"PlaybackChanged",
|
||||
"PlaylistFinished",
|
||||
"PreviousTrackRequested",
|
||||
"RfidTokenRead",
|
||||
"RotaryTurned",
|
||||
"SetVolumeRequested",
|
||||
"StateEvent",
|
||||
"TouchButtonPressed",
|
||||
"TouchButtonReleased",
|
||||
"TrackChanged",
|
||||
"VolumeChangeRequested",
|
||||
"VolumeChanged",
|
||||
]
|
||||
|
||||
type EventSource = Literal["device", "player", "mqtt", "web", "lirc", "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."""
|
||||
|
||||
|
||||
@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 PreviousTrackRequested(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 PlayAlbumRequested(IntentEvent):
|
||||
"""Start any album from the library, figure or not."""
|
||||
|
||||
album_id: str
|
||||
track_index: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class PlaySeriesLatestRequested(IntentEvent):
|
||||
"""Start the newest episode of a podcast show.
|
||||
|
||||
The IR remote's number-key mapping assigns a whole show rather than one fixed
|
||||
episode, so this is resolved to an actual album fresh on every press.
|
||||
"""
|
||||
|
||||
series: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class SeekRequested(IntentEvent):
|
||||
#: Seconds from the start of the current track.
|
||||
position: float
|
||||
|
||||
|
||||
@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", "lirc"]
|
||||
connected: bool
|
||||
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),
|
||||
}
|
||||
328
python-backend/musicmouse/library/__init__.py
Normal file
@@ -0,0 +1,328 @@
|
||||
"""The music collection: what exists, what it is called, and what colour it is.
|
||||
|
||||
The scan is the only part of this backend that touches hundreds of files, so it runs
|
||||
in a worker thread and its results are cached. Everything the rest of the app sees is
|
||||
plain immutable data.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable, Collection, Mapping
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
from musicmouse.library.analysis import (
|
||||
ANALYZER_VERSION,
|
||||
Analyzer,
|
||||
BeatGrid,
|
||||
NullAnalyzer,
|
||||
TrackAnalysis,
|
||||
TrackCurves,
|
||||
)
|
||||
from musicmouse.library.cache import Fingerprint, LibraryCache
|
||||
from musicmouse.library.models import Album, LibraryTrack, album_id, track_key
|
||||
from musicmouse.library.scanner import scan_library
|
||||
from musicmouse.library.sections import SECTIONS, AlbumKind
|
||||
from musicmouse.media import Playlist
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"ANALYZER_VERSION",
|
||||
"SECTIONS",
|
||||
"Album",
|
||||
"Analyzer",
|
||||
"BeatGrid",
|
||||
"LibraryCache",
|
||||
"LibraryTrack",
|
||||
"MusicLibrary",
|
||||
"NullAnalyzer",
|
||||
"TrackCurves",
|
||||
"album_id",
|
||||
"track_key",
|
||||
]
|
||||
|
||||
#: The only kind worth spending DSP on: an audiobook chapter or a podcast episode is
|
||||
#: tens of minutes of narration with no musical mood to extract, and there are far more
|
||||
#: of them in a typical library than there are songs.
|
||||
_ANALYZED_KINDS: Final[tuple[AlbumKind, ...]] = ("music",)
|
||||
|
||||
#: How often the background worker checks whether it may resume after `is_busy()` said
|
||||
#: no. A track's own analysis is a couple of seconds of CPU, so overshooting by this
|
||||
#: much when playback stops is not worth polling harder for.
|
||||
_BUSY_POLL_SECONDS: Final = 2.0
|
||||
|
||||
#: Analysis is folded back into the live index and persisted this often during a long
|
||||
#: run, so a first-time pass over an unanalyzed library shows up gradually in open
|
||||
#: browser tabs rather than only after the whole thing finishes.
|
||||
_PUBLISH_BATCH_SIZE: Final = 25
|
||||
|
||||
#: How often a long analysis pass reports where it is, so a first-time run over a large
|
||||
#: library - minutes of DSP per track - doesn't sit silent with nothing on the console
|
||||
#: to say it is still going.
|
||||
_PROGRESS_INTERVAL_SECONDS: Final = 5.0
|
||||
|
||||
|
||||
def _analyze_one(
|
||||
analyzer: Analyzer, path: Path
|
||||
) -> tuple[TrackAnalysis, BeatGrid | None, TrackCurves | None]:
|
||||
"""Runs in a worker thread. Lowers this thread's own scheduling priority first.
|
||||
|
||||
On Linux, ``os.nice`` affects only the calling thread, not the whole process - so
|
||||
this makes idle-time analysis yield CPU to anything else without touching threads
|
||||
used for other work. Niceness only ever increases and clamps at the OS maximum
|
||||
(19), so calling this repeatedly on a reused pool thread is harmless.
|
||||
"""
|
||||
with contextlib.suppress(OSError):
|
||||
os.nice(1)
|
||||
return analyzer.analyze(path)
|
||||
|
||||
|
||||
class MusicLibrary:
|
||||
"""An immutable index of albums, rebuilt wholesale rather than mutated in place."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
root: Path,
|
||||
cache: LibraryCache,
|
||||
extensions: frozenset[str],
|
||||
*,
|
||||
analyzer: Analyzer | None = None,
|
||||
figure_kinds: Mapping[str, AlbumKind] | None = None,
|
||||
) -> None:
|
||||
self.root = root
|
||||
self.cache = cache
|
||||
self.extensions = extensions
|
||||
self.analyzer: Analyzer = analyzer or NullAnalyzer()
|
||||
#: What each figure holds. The only thing a folder name cannot say.
|
||||
self.figure_kinds: Mapping[str, AlbumKind] = figure_kinds or {}
|
||||
self._entries: dict[str, tuple[Album, Fingerprint]] = {}
|
||||
#: Set by `request_analysis`, consumed by `run_analysis`. An `Event` rather than
|
||||
#: a queue: a request raised while one is already pending or running just
|
||||
#: coalesces into it, which is exactly what "look again" should mean here.
|
||||
self._analysis_requested = asyncio.Event()
|
||||
|
||||
# -------------------------------------------------------------------- reading
|
||||
|
||||
@property
|
||||
def albums(self) -> list[Album]:
|
||||
return [album for album, _ in self._entries.values()]
|
||||
|
||||
def get(self, identifier: str | None) -> Album | None:
|
||||
entry = self._entries.get(identifier) if identifier else None
|
||||
return entry[0] if entry else None
|
||||
|
||||
def figure_playlists(self) -> dict[str, Playlist]:
|
||||
"""One playlist per figure folder, keyed by figure name.
|
||||
|
||||
The figure path and the web path must hand the player the *same* object for the
|
||||
same album: ``reactions.playback.play_figure`` resumes on an identity check.
|
||||
"""
|
||||
return {
|
||||
album.figure: album.to_playlist()
|
||||
for album, _ in self._entries.values()
|
||||
if album.figure is not None
|
||||
}
|
||||
|
||||
def latest_episode(self, series: str) -> Album | None:
|
||||
"""The newest episode-unit album of a podcast show, by filename.
|
||||
|
||||
Episode files are named ``YYYYMMDD - Title``, so filename order is
|
||||
chronological - the same fact ``Kinderpodcasts``' ``order="newest_first"``
|
||||
already relies on at scan time. ``None`` if the show is unknown or empty.
|
||||
"""
|
||||
candidates = [
|
||||
album
|
||||
for album in self.albums
|
||||
if album.series == series
|
||||
and (section := SECTIONS.get(album.section)) is not None
|
||||
and section.album_unit == "episode"
|
||||
]
|
||||
if not candidates:
|
||||
return None
|
||||
return max(
|
||||
candidates, key=lambda album: album.tracks[0].path.name if album.tracks else ""
|
||||
)
|
||||
|
||||
def beats(self, identifier: str, index: int) -> BeatGrid | None:
|
||||
album = self.get(identifier)
|
||||
if album is None or not 0 <= index < len(album.tracks):
|
||||
return None
|
||||
return self.cache.load_beats(track_key(album.tracks[index].path))
|
||||
|
||||
def curve(self, identifier: str, index: int) -> TrackCurves | None:
|
||||
album = self.get(identifier)
|
||||
if album is None or not 0 <= index < len(album.tracks):
|
||||
return None
|
||||
return self.cache.load_curve(track_key(album.tracks[index].path))
|
||||
|
||||
# -------------------------------------------------------------------- writing
|
||||
|
||||
async def refresh(self) -> None:
|
||||
"""Rescan from disk. Blocking work happens off the loop; the swap is atomic.
|
||||
|
||||
Ends by waking the background analyzer: a rescan is exactly when new tracks -
|
||||
the only ones analysis can be pending for - enter the index, whether that is
|
||||
the startup scan or a parent tapping "Bibliothek neu einlesen".
|
||||
"""
|
||||
known = dict(self._entries)
|
||||
entries = await asyncio.to_thread(
|
||||
scan_library,
|
||||
self.root,
|
||||
self.extensions,
|
||||
self.cache,
|
||||
known=known,
|
||||
figure_kinds=self.figure_kinds,
|
||||
)
|
||||
self._entries = await asyncio.to_thread(self._with_analysis, entries)
|
||||
await asyncio.to_thread(self.cache.store_index, self._entries)
|
||||
self.request_analysis()
|
||||
|
||||
def _with_analysis(
|
||||
self,
|
||||
entries: dict[str, tuple[Album, Fingerprint]],
|
||||
*,
|
||||
kinds: Collection[AlbumKind] = _ANALYZED_KINDS,
|
||||
) -> dict[str, tuple[Album, Fingerprint]]:
|
||||
"""Fold cached analysis results into the freshly scanned index.
|
||||
|
||||
Restricted to `kinds` (music by default): stat-ing hundreds of book and podcast
|
||||
tracks whose analysis can never exist would be pure waste. Skipped entirely
|
||||
while ``analysis/`` is empty, which is the normal case until an analyzer has
|
||||
actually been run.
|
||||
"""
|
||||
if not any(self.cache.analysis.glob("*.json")):
|
||||
return entries
|
||||
out: dict[str, tuple[Album, Fingerprint]] = {}
|
||||
for identifier, (album, fingerprint) in entries.items():
|
||||
if album.kind not in kinds:
|
||||
out[identifier] = (album, fingerprint)
|
||||
continue
|
||||
tracks = tuple(
|
||||
replace(track, analysis=self.cache.load_analysis(track_key(track.path)))
|
||||
for track in album.tracks
|
||||
)
|
||||
out[identifier] = (replace(album, tracks=tracks), fingerprint)
|
||||
return out
|
||||
|
||||
@classmethod
|
||||
async def build(
|
||||
cls,
|
||||
root: Path,
|
||||
cache_dir: Path,
|
||||
extensions: frozenset[str],
|
||||
*,
|
||||
analyzer: Analyzer | None = None,
|
||||
figure_kinds: Mapping[str, AlbumKind] | None = None,
|
||||
) -> MusicLibrary:
|
||||
cache = LibraryCache(cache_dir)
|
||||
library = cls(root, cache, extensions, analyzer=analyzer, figure_kinds=figure_kinds)
|
||||
library._entries = await asyncio.to_thread(cache.load_index)
|
||||
await library.refresh()
|
||||
return library
|
||||
|
||||
# ------------------------------------------------------------------- analysis
|
||||
|
||||
def request_analysis(self) -> None:
|
||||
"""Wake the background worker to look for tracks with no current analysis.
|
||||
|
||||
Idempotent, and safe to call before `run_analysis` has even started a first
|
||||
time - the request just waits on the event.
|
||||
"""
|
||||
self._analysis_requested.set()
|
||||
|
||||
async def run_analysis(
|
||||
self,
|
||||
*,
|
||||
is_busy: Callable[[], bool] = lambda: False,
|
||||
on_batch: Callable[[], Awaitable[None]] | None = None,
|
||||
) -> None:
|
||||
"""Analyze pending tracks whenever `request_analysis` wakes this up.
|
||||
|
||||
A long-running task, cancelled at shutdown alongside every other one. Loops
|
||||
forever so a request raised *during* a pass (a rescan mid-analysis) starts
|
||||
another pass right after, rather than being lost.
|
||||
"""
|
||||
while True:
|
||||
await self._analysis_requested.wait()
|
||||
self._analysis_requested.clear()
|
||||
await self.analyze_pending(is_busy=is_busy, on_batch=on_batch)
|
||||
|
||||
async def analyze_pending(
|
||||
self,
|
||||
*,
|
||||
kinds: Collection[AlbumKind] = _ANALYZED_KINDS,
|
||||
is_busy: Callable[[], bool] = lambda: False,
|
||||
on_batch: Callable[[], Awaitable[None]] | None = None,
|
||||
batch_size: int = _PUBLISH_BATCH_SIZE,
|
||||
) -> int:
|
||||
"""Run the analyzer over tracks of `kinds` that have no current result.
|
||||
|
||||
Restricted to music by default - see `_ANALYZED_KINDS`. Checked before every
|
||||
track, `is_busy()` pauses the whole pass rather than one file: analysis must
|
||||
never compete with audio decoding for CPU, and a children's player is idle most
|
||||
of the day, so the pass simply resumes next time it is. A track the analyzer
|
||||
fails on (corrupt file, DRM, zero length) is still recorded as attempted - with
|
||||
every scalar left `None` - so it is never retried forever and the frontend falls
|
||||
back to the un-analyzed baseline for it. Results are folded into the live index
|
||||
and persisted every `batch_size` tracks, so a long first run is visible in open
|
||||
browser tabs as it goes rather than only once it finishes.
|
||||
"""
|
||||
analyzer = self.analyzer
|
||||
if analyzer.version < ANALYZER_VERSION:
|
||||
return 0
|
||||
|
||||
done = 0
|
||||
last_report = time.monotonic()
|
||||
for album in self.albums:
|
||||
if album.kind not in kinds:
|
||||
continue
|
||||
for track in album.tracks:
|
||||
while is_busy():
|
||||
await asyncio.sleep(_BUSY_POLL_SECONDS)
|
||||
key = track_key(track.path)
|
||||
cached = self.cache.load_analysis(key)
|
||||
if cached is not None and cached.version >= analyzer.version:
|
||||
continue
|
||||
now = time.monotonic()
|
||||
if now - last_report >= _PROGRESS_INTERVAL_SECONDS:
|
||||
_log.info("Analyzing library: %d tracks done so far, now on %s", done, track.path)
|
||||
last_report = now
|
||||
try:
|
||||
analysis, grid, curve = await asyncio.to_thread(
|
||||
_analyze_one, analyzer, track.path
|
||||
)
|
||||
except Exception:
|
||||
_log.warning(
|
||||
"Analyzer raised on %s; marking it attempted so it is not retried forever",
|
||||
track.path,
|
||||
exc_info=True,
|
||||
)
|
||||
analysis, grid, curve = TrackAnalysis(version=analyzer.version), None, None
|
||||
if grid is not None:
|
||||
self.cache.store_beats(key, grid)
|
||||
if curve is not None:
|
||||
self.cache.store_curve(key, curve)
|
||||
self.cache.store_analysis(key, analysis)
|
||||
done += 1
|
||||
if done % batch_size == 0:
|
||||
await self._publish_analysis(kinds, on_batch)
|
||||
if done % batch_size:
|
||||
await self._publish_analysis(kinds, on_batch)
|
||||
if done:
|
||||
_log.info("Analyzed %d tracks", done)
|
||||
return done
|
||||
|
||||
async def _publish_analysis(
|
||||
self, kinds: Collection[AlbumKind], on_batch: Callable[[], Awaitable[None]] | None
|
||||
) -> None:
|
||||
self._entries = await asyncio.to_thread(self._with_analysis, self._entries, kinds=kinds)
|
||||
await asyncio.to_thread(self.cache.store_index, self._entries)
|
||||
if on_batch is not None:
|
||||
await on_batch()
|
||||
179
python-backend/musicmouse/library/analysis.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""Offline audio analysis, and the seam that lets it be optional.
|
||||
|
||||
:class:`LibrosaAnalyzer` (``musicmouse.library.librosa_analyzer``) does the real work,
|
||||
kept in its own module behind :func:`build_analyzer` so that importing *this* module -
|
||||
which the scanner, the cache and the web API all do - never pulls in librosa or numpy.
|
||||
:class:`MusicLibrary` runs whichever analyzer it is given in the background, between
|
||||
tracks, so the reactive background is a thing a library grows into rather than a
|
||||
migration.
|
||||
|
||||
Two rules hold the result shape together:
|
||||
|
||||
* **Scalars travel with the index, time series do not.** A 25-minute podcast at 120 BPM
|
||||
has ~3000 beats; 900 tracks of that inside ``GET /api/library`` would be tens of
|
||||
megabytes. :class:`TrackAnalysis` is a handful of floats and rides along; the beat
|
||||
grid and the per-second :class:`TrackCurves` each live in their own file and are
|
||||
fetched only for the one track that is playing.
|
||||
* **Every field is optional with a default.** Adding a new scalar later needs no
|
||||
migration and no cache wipe: unknown keys on disk are dropped on load, missing ones
|
||||
fall back to the default. Only :data:`ANALYZER_VERSION` moving past what a file
|
||||
records marks that file stale.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import asdict, dataclass, fields
|
||||
from pathlib import Path
|
||||
from typing import Any, Final, Protocol
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"ANALYZER_VERSION",
|
||||
"Analyzer",
|
||||
"BeatGrid",
|
||||
"NullAnalyzer",
|
||||
"TrackAnalysis",
|
||||
"TrackCurves",
|
||||
"build_analyzer",
|
||||
]
|
||||
|
||||
#: Bumped when an analyzer's output changes meaning. Cached results recorded under a
|
||||
#: lower version are recomputed; results at or above it are left alone. 2: energy/
|
||||
#: valence scalars became mean-of-curve instead of whole-track-percentile/heuristic,
|
||||
#: and every track needs a new TrackCurves artifact producing.
|
||||
ANALYZER_VERSION: Final = 2
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TrackAnalysis:
|
||||
"""What an animation may want to know about a track, in a few dozen bytes."""
|
||||
|
||||
#: The :data:`ANALYZER_VERSION` that produced this. ``0`` means "never analyzed".
|
||||
version: int = 0
|
||||
tempo: float | None = None
|
||||
#: 0..1 overall loudness and drive.
|
||||
energy: float | None = None
|
||||
#: 0..1 mood, dark and calm through bright and happy.
|
||||
valence: float | None = None
|
||||
#: 0..1 spectral centroid. Drives the background hue.
|
||||
brightness: float | None = None
|
||||
#: 0..1 confidence that :attr:`tempo` is an audible, steady beat rather than an
|
||||
#: artifact of free-tempo or spoken-word material. Below-threshold tracks should
|
||||
#: not be pulsed on the beat even though a grid exists for them.
|
||||
pulse: float | None = None
|
||||
#: Whether a beat grid for this track exists on disk.
|
||||
beats: bool = False
|
||||
|
||||
@property
|
||||
def is_analyzed(self) -> bool:
|
||||
return self.version >= ANALYZER_VERSION
|
||||
|
||||
def to_json(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, data: dict[str, Any]) -> TrackAnalysis:
|
||||
"""Load leniently: unknown keys are dropped, missing keys take their default.
|
||||
|
||||
This is what makes the cache survive an analyzer that grew a field.
|
||||
"""
|
||||
known = {f.name for f in fields(cls)}
|
||||
return cls(**{k: v for k, v in data.items() if k in known})
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BeatGrid:
|
||||
"""Beat onsets in seconds with a 0..1 strength each, for beat-synced animation."""
|
||||
|
||||
times: tuple[float, ...]
|
||||
strengths: tuple[float, ...]
|
||||
|
||||
def to_json(self) -> dict[str, Any]:
|
||||
# Flat pairs: half the JSON punctuation of a list of objects.
|
||||
return {"beats": [x for pair in zip(self.times, self.strengths, strict=True) for x in pair]}
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, data: dict[str, Any]) -> BeatGrid:
|
||||
flat: list[float] = data["beats"]
|
||||
return cls(tuple(flat[0::2]), tuple(flat[1::2]))
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TrackCurves:
|
||||
"""Per-second samples of the things that vary *within* a track. Regularly
|
||||
sampled, so just a hop and equal-length arrays, no per-sample timestamps -
|
||||
contrast :class:`BeatGrid`'s event-based irregular times.
|
||||
|
||||
``energy``/``valence`` drive colour; ``drive`` (rhythmic intensity) modulates the
|
||||
water current's magnitude. Tempo is deliberately absent: measured against a real
|
||||
library it is flat to within a few percent inside a track, so a per-second tempo
|
||||
curve would carry estimator noise (including occasional octave errors) and
|
||||
nothing else. Whole-track tempo keeps setting the current's base magnitude;
|
||||
``drive`` is the signal that actually varies.
|
||||
"""
|
||||
|
||||
hop_seconds: float
|
||||
energy: tuple[float, ...]
|
||||
valence: tuple[float, ...]
|
||||
drive: tuple[float, ...]
|
||||
|
||||
def to_json(self) -> dict[str, Any]:
|
||||
return {
|
||||
"hop_seconds": self.hop_seconds,
|
||||
"energy": list(self.energy),
|
||||
"valence": list(self.valence),
|
||||
"drive": list(self.drive),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, data: dict[str, Any]) -> TrackCurves:
|
||||
return cls(
|
||||
hop_seconds=float(data["hop_seconds"]),
|
||||
energy=tuple(data["energy"]),
|
||||
valence=tuple(data["valence"]),
|
||||
drive=tuple(data["drive"]),
|
||||
)
|
||||
|
||||
|
||||
class Analyzer(Protocol):
|
||||
"""Turns one audio file into cacheable analysis results.
|
||||
|
||||
Implementations are CPU-bound and run off the event loop.
|
||||
"""
|
||||
|
||||
version: int
|
||||
|
||||
def analyze(self, path: Path) -> tuple[TrackAnalysis, BeatGrid | None, TrackCurves | None]: ...
|
||||
|
||||
|
||||
class NullAnalyzer:
|
||||
"""Analyzes nothing. What :func:`build_analyzer` falls back to without librosa."""
|
||||
|
||||
version = 0
|
||||
|
||||
def analyze(
|
||||
self,
|
||||
path: Path, # noqa: ARG002
|
||||
) -> tuple[TrackAnalysis, BeatGrid | None, TrackCurves | None]:
|
||||
return TrackAnalysis(), None, None
|
||||
|
||||
|
||||
def build_analyzer() -> Analyzer:
|
||||
"""The real analyzer if its optional dependency group is installed, else a no-op.
|
||||
|
||||
``NullAnalyzer.version`` is ``0``, which is below :data:`ANALYZER_VERSION`, so
|
||||
:meth:`~musicmouse.library.MusicLibrary.analyze_pending` returns immediately and the
|
||||
background stays at its static baseline - not a crash, not a degraded mode, just the
|
||||
feature switched off until ``pip install -e '.[analysis]'`` turns it on.
|
||||
"""
|
||||
try:
|
||||
from musicmouse.library.librosa_analyzer import LibrosaAnalyzer
|
||||
except ImportError:
|
||||
_log.info(
|
||||
"librosa is not installed: the reactive background is off. "
|
||||
"Install the 'analysis' extra to enable it."
|
||||
)
|
||||
return NullAnalyzer()
|
||||
return LibrosaAnalyzer()
|
||||
215
python-backend/musicmouse/library/cache.py
Normal file
@@ -0,0 +1,215 @@
|
||||
"""Where scan results live between runs.
|
||||
|
||||
A directory rather than a single file, because the three kinds of content cost wildly
|
||||
different amounts to produce::
|
||||
|
||||
<cache_dir>/
|
||||
├── index.json cheap: tags and structure. Thrown away freely.
|
||||
├── covers/<album_id>.jpg medium: art pulled out of an ID3 APIC frame
|
||||
└── analysis/<track_key>.json expensive: minutes of DSP per track
|
||||
analysis/<track_key>.beats.json
|
||||
analysis/<track_key>.curve.json
|
||||
|
||||
That split is the whole point. A rescan must be free to rebuild ``index.json`` without
|
||||
destroying analysis, so everything expensive is keyed by a *content* key (see
|
||||
:func:`~musicmouse.library.models.track_key`) rather than by album id - renaming a
|
||||
folder or re-sorting a section then costs nothing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
from musicmouse.library.analysis import BeatGrid, TrackAnalysis, TrackCurves
|
||||
from musicmouse.library.models import Album, LibraryTrack
|
||||
from musicmouse.library.sections import SECTIONS, AlbumKind
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["Fingerprint", "LibraryCache"]
|
||||
|
||||
#: Bump whenever the scanner's *derivation* changes - how a title, artist or series is
|
||||
#: worked out - not just when the JSON shape does. A cached entry is reused whenever its
|
||||
#: files are untouched, so otherwise a change to that logic is invisible until somebody
|
||||
#: edits their music folder.
|
||||
_INDEX_VERSION = 4
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Fingerprint:
|
||||
"""What makes a folder's cached entry still valid: its files, sizes and mtimes."""
|
||||
|
||||
files: tuple[tuple[str, int, int], ...]
|
||||
|
||||
@classmethod
|
||||
def of(cls, paths: list[Path]) -> Fingerprint:
|
||||
entries: list[tuple[str, int, int]] = []
|
||||
for path in paths:
|
||||
stat = path.stat()
|
||||
entries.append((path.name, stat.st_size, int(stat.st_mtime)))
|
||||
return cls(tuple(entries))
|
||||
|
||||
def to_json(self) -> list[list[Any]]:
|
||||
return [list(entry) for entry in self.files]
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, data: list[list[Any]]) -> Fingerprint:
|
||||
return cls(tuple((str(n), int(s), int(m)) for n, s, m in data))
|
||||
|
||||
|
||||
def _write_atomic(path: Path, payload: str) -> None:
|
||||
"""Write via a sibling temp file so a crash never leaves a half-written cache."""
|
||||
temp = path.with_name(f"{path.name}.tmp{os.getpid()}")
|
||||
temp.write_text(payload, encoding="utf-8")
|
||||
temp.replace(path)
|
||||
|
||||
|
||||
class LibraryCache:
|
||||
def __init__(self, directory: Path) -> None:
|
||||
self.directory = directory
|
||||
self.covers = directory / "covers"
|
||||
self.analysis = directory / "analysis"
|
||||
|
||||
def prepare(self) -> None:
|
||||
for folder in (self.directory, self.covers, self.analysis):
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# -------------------------------------------------------------------- covers
|
||||
|
||||
def cover_path(self, album_id: str) -> Path:
|
||||
return self.covers / f"{album_id}.jpg"
|
||||
|
||||
def store_cover(self, album_id: str, data: bytes) -> Path:
|
||||
path = self.cover_path(album_id)
|
||||
path.write_bytes(data)
|
||||
return path
|
||||
|
||||
# ------------------------------------------------------------------ analysis
|
||||
|
||||
def load_analysis(self, key: str) -> TrackAnalysis | None:
|
||||
path = self.analysis / f"{key}.json"
|
||||
try:
|
||||
return TrackAnalysis.from_json(json.loads(path.read_text(encoding="utf-8")))
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
def store_analysis(self, key: str, analysis: TrackAnalysis) -> None:
|
||||
_write_atomic(self.analysis / f"{key}.json", json.dumps(analysis.to_json()))
|
||||
|
||||
def load_beats(self, key: str) -> BeatGrid | None:
|
||||
path = self.analysis / f"{key}.beats.json"
|
||||
try:
|
||||
return BeatGrid.from_json(json.loads(path.read_text(encoding="utf-8")))
|
||||
except (OSError, ValueError, KeyError):
|
||||
return None
|
||||
|
||||
def store_beats(self, key: str, grid: BeatGrid) -> None:
|
||||
_write_atomic(self.analysis / f"{key}.beats.json", json.dumps(grid.to_json()))
|
||||
|
||||
def load_curve(self, key: str) -> TrackCurves | None:
|
||||
path = self.analysis / f"{key}.curve.json"
|
||||
try:
|
||||
return TrackCurves.from_json(json.loads(path.read_text(encoding="utf-8")))
|
||||
except (OSError, ValueError, KeyError):
|
||||
return None
|
||||
|
||||
def store_curve(self, key: str, curve: TrackCurves) -> None:
|
||||
_write_atomic(self.analysis / f"{key}.curve.json", json.dumps(curve.to_json()))
|
||||
|
||||
# --------------------------------------------------------------------- index
|
||||
|
||||
def load_index(self) -> dict[str, tuple[Album, Fingerprint]]:
|
||||
path = self.directory / "index.json"
|
||||
try:
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
if raw.get("version") != _INDEX_VERSION:
|
||||
_log.info("Library index is from an older version; rescanning from scratch")
|
||||
return {}
|
||||
|
||||
out: dict[str, tuple[Album, Fingerprint]] = {}
|
||||
for entry in raw.get("albums", []):
|
||||
try:
|
||||
out[entry["id"]] = (_album_from_json(entry), Fingerprint.from_json(entry["files"]))
|
||||
except (KeyError, TypeError, ValueError):
|
||||
_log.debug("Dropping unreadable index entry %r", entry.get("id"))
|
||||
return out
|
||||
|
||||
def store_index(self, albums: dict[str, tuple[Album, Fingerprint]]) -> None:
|
||||
payload = {
|
||||
"version": _INDEX_VERSION,
|
||||
"albums": [
|
||||
_album_to_json(album) | {"files": fingerprint.to_json()}
|
||||
for album, fingerprint in albums.values()
|
||||
],
|
||||
}
|
||||
self.prepare()
|
||||
_write_atomic(self.directory / "index.json", json.dumps(payload, ensure_ascii=False))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ serialisation
|
||||
|
||||
|
||||
def _album_to_json(album: Album) -> dict[str, Any]:
|
||||
return {
|
||||
"id": album.id,
|
||||
"section": album.section,
|
||||
"kind": album.kind,
|
||||
"title": album.title,
|
||||
"artist": album.artist,
|
||||
"series": album.series,
|
||||
"figure": album.figure,
|
||||
"colors": list(album.colors),
|
||||
"folder": str(album.folder),
|
||||
"cover": str(album.cover) if album.cover else None,
|
||||
"tracks": [
|
||||
{
|
||||
"path": str(track.path),
|
||||
"title": track.title,
|
||||
"duration": track.duration,
|
||||
"analysis": track.analysis.to_json() if track.analysis else None,
|
||||
}
|
||||
for track in album.tracks
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _album_from_json(data: dict[str, Any]) -> Album:
|
||||
section = str(data["section"])
|
||||
if section not in SECTIONS:
|
||||
raise ValueError(f"unknown section {section!r}")
|
||||
# Read back what was stored rather than re-deriving it from the section: a figure's
|
||||
# kind comes from the config, so the section cannot answer for it.
|
||||
kind = str(data["kind"])
|
||||
if kind not in ("music", "book"):
|
||||
raise ValueError(f"unknown kind {kind!r}")
|
||||
red, green, blue = data["colors"]
|
||||
return Album(
|
||||
id=str(data["id"]),
|
||||
section=section,
|
||||
kind=cast("AlbumKind", kind),
|
||||
title=str(data["title"]),
|
||||
artist=str(data["artist"]),
|
||||
series=data["series"],
|
||||
figure=data["figure"],
|
||||
colors=(str(red), str(green), str(blue)),
|
||||
folder=Path(data["folder"]),
|
||||
cover=Path(data["cover"]) if data["cover"] else None,
|
||||
tracks=tuple(
|
||||
LibraryTrack(
|
||||
path=Path(track["path"]),
|
||||
title=str(track["title"]),
|
||||
duration=float(track["duration"]),
|
||||
analysis=(
|
||||
TrackAnalysis.from_json(track["analysis"]) if track.get("analysis") else None
|
||||
),
|
||||
)
|
||||
for track in data["tracks"]
|
||||
),
|
||||
)
|
||||
82
python-backend/musicmouse/library/colors.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""Three colours per album, from its cover art.
|
||||
|
||||
The frontend paints an album card with these and the LED strips run an effect in them,
|
||||
so a web-started album looks the same on the shelf as it does on the screen. Extraction
|
||||
happens once per album ever - the result is cached next to the cover.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import colorsys
|
||||
import hashlib
|
||||
import logging
|
||||
from io import BytesIO
|
||||
from typing import cast
|
||||
|
||||
from musicmouse.library.models import AlbumColors
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["colors_from_cover", "colors_from_id"]
|
||||
|
||||
#: Quantize to this many candidates before ranking. More just returns near-duplicates.
|
||||
_PALETTE_SIZE = 8
|
||||
#: Downscale first: colour proportions survive, the decode gets ~100x cheaper.
|
||||
_SAMPLE_SIZE = (64, 64)
|
||||
|
||||
#: Greys and near-blacks read as mud on an RGBW strip and as dirt on a card, so a
|
||||
#: candidate has to clear both bars to count as one of an album's colours.
|
||||
_MIN_SATURATION = 0.25
|
||||
_MIN_VALUE = 0.25
|
||||
|
||||
|
||||
def _hex(r: int, g: int, b: int) -> str:
|
||||
return f"#{r:02x}{g:02x}{b:02x}"
|
||||
|
||||
|
||||
def colors_from_id(album_id: str) -> AlbumColors:
|
||||
"""Synthesise a palette when there is no cover to take one from.
|
||||
|
||||
Analogous plus complementary off one hashed hue: distinct per album, never muddy.
|
||||
"""
|
||||
hue = int(hashlib.sha1(album_id.encode()).hexdigest()[:4], 16) % 360
|
||||
out: list[str] = []
|
||||
for offset, saturation, value in ((0, 0.72, 0.95), (30, 0.62, 0.80), (180, 0.70, 0.90)):
|
||||
r, g, b = colorsys.hsv_to_rgb(((hue + offset) % 360) / 360, saturation, value)
|
||||
out.append(_hex(int(r * 255), int(g * 255), int(b * 255)))
|
||||
return out[0], out[1], out[2]
|
||||
|
||||
|
||||
def colors_from_cover(image_data: bytes, album_id: str) -> AlbumColors:
|
||||
"""Rank the cover's dominant colours, dropping the ones that would read as mud.
|
||||
|
||||
Falls back to :func:`colors_from_id` for the slots that do not fill - a black-and-
|
||||
white cover legitimately has no three usable colours.
|
||||
"""
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(BytesIO(image_data)) as image:
|
||||
sample = image.convert("RGB").resize(_SAMPLE_SIZE)
|
||||
quantized = sample.quantize(colors=_PALETTE_SIZE, method=Image.Quantize.MAXCOVERAGE)
|
||||
palette: list[int] = quantized.getpalette() or []
|
||||
# On a quantized ("P" mode) image getcolors() yields (count, palette index).
|
||||
# Pillow's annotation covers every mode, hence the cast.
|
||||
counts = cast("list[tuple[int, int]]", quantized.getcolors() or [])
|
||||
except Exception: # pragma: no cover - defensive around arbitrary embedded art
|
||||
_log.debug("Cover of %s could not be read; using a synthesised palette", album_id)
|
||||
return colors_from_id(album_id)
|
||||
|
||||
picked: list[str] = []
|
||||
for _count, index in sorted(counts, reverse=True):
|
||||
r, g, b = palette[index * 3 : index * 3 + 3]
|
||||
_, saturation, value = colorsys.rgb_to_hsv(r / 255, g / 255, b / 255)
|
||||
if saturation < _MIN_SATURATION or value < _MIN_VALUE:
|
||||
continue
|
||||
picked.append(_hex(r, g, b))
|
||||
if len(picked) == 3:
|
||||
return picked[0], picked[1], picked[2]
|
||||
|
||||
fallback = colors_from_id(album_id)
|
||||
filled = picked + list(fallback[len(picked) :])
|
||||
return filled[0], filled[1], filled[2]
|
||||
318
python-backend/musicmouse/library/librosa_analyzer.py
Normal file
@@ -0,0 +1,318 @@
|
||||
"""A concrete :class:`~musicmouse.library.analysis.Analyzer` built on librosa.
|
||||
|
||||
Only reached through :func:`musicmouse.library.analysis.build_analyzer`, so nothing
|
||||
else in the app ever imports librosa or numpy: a device without the ``analysis`` extra
|
||||
installed never executes this module at all.
|
||||
|
||||
Every scalar here is a signal-processing proxy, not a measurement of how a track
|
||||
actually feels - ``valence`` most of all, see its section below. Good enough to drive
|
||||
an ambient background; not a music information retrieval research result.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import warnings
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import librosa
|
||||
import numpy as np
|
||||
|
||||
from musicmouse.library.analysis import ANALYZER_VERSION, BeatGrid, TrackAnalysis, TrackCurves
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["LibrosaAnalyzer"]
|
||||
|
||||
#: librosa's own default. Ample for everything below - the highest band that matters,
|
||||
#: the brightness ceiling, sits well under this rate's 11025 Hz Nyquist frequency.
|
||||
_SAMPLE_RATE = 22050
|
||||
_HOP_LENGTH = 512
|
||||
|
||||
#: `energy`: the 80th-percentile RMS frame, in dB, mapped floor..ceil to 0..1. -32 dB is
|
||||
#: a quiet passage, -8 dB is a hot, compressed master. A percentile rather than the mean
|
||||
#: or max so a quiet intro or a gap between phrases doesn't drag a loud track down, and
|
||||
#: one clipped peak doesn't blow it out.
|
||||
_ENERGY_DB_FLOOR = -32.0
|
||||
_ENERGY_DB_CEIL = -8.0
|
||||
|
||||
#: `brightness`: the median spectral centroid (robust to a single loud transient), in
|
||||
#: Hz, log-mapped floor..ceil to 0..1. 300 Hz is a dark, bass/vocal-heavy mix; 4000 Hz is
|
||||
#: bright, sparkly production.
|
||||
_BRIGHTNESS_HZ_FLOOR = 300.0
|
||||
_BRIGHTNESS_HZ_CEIL = 4000.0
|
||||
|
||||
#: `valence`'s tempo term: 60 BPM reads as a lullaby, 150 BPM as a romp.
|
||||
_TEMPO_BPM_FLOOR = 60.0
|
||||
_TEMPO_BPM_CEIL = 150.0
|
||||
|
||||
#: Key is a global property of a track; the middle minute is its most representative
|
||||
#: one and this keeps the most expensive feature (chroma) off the tail of a long track.
|
||||
_CHROMA_EXCERPT_SECONDS = 120.0
|
||||
|
||||
#: Seconds per curve sample. Fixed at analysis time, not user-facing - changing this
|
||||
#: needs a re-analysis and an ANALYZER_VERSION bump, unlike the frontend's own
|
||||
#: unrelated "curve sample interval" debug slider, which just smooths already-fetched
|
||||
#: samples for live preview.
|
||||
_ANALYSIS_HOP_SECONDS = 1.0
|
||||
|
||||
#: `drive`'s blend of onset activity (steadier, measured 33-55% relative spread across
|
||||
#: a track) and local pulse strength (more dynamic but spikier, 59-189%) - weighted
|
||||
#: toward activity so a busy chorus reads clearly without the current twitching on
|
||||
#: every transient.
|
||||
_DRIVE_ACTIVITY_WEIGHT = 0.6
|
||||
_DRIVE_PLP_WEIGHT = 0.4
|
||||
|
||||
#: Krumhansl-Kessler key profiles (Krumhansl & Kessler 1982), starting from C. Every
|
||||
#: other key is scored by rotating these twelve weights, not by transposing the audio.
|
||||
_MAJOR_PROFILE = np.array([6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88])
|
||||
_MINOR_PROFILE = np.array([6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17])
|
||||
|
||||
|
||||
def _clip01(value: float) -> float:
|
||||
return max(0.0, min(1.0, value))
|
||||
|
||||
|
||||
def _normalize(value: float, floor: float, ceil: float) -> float:
|
||||
"""Linear map ``floor..ceil`` to ``0..1``, clipped at both ends."""
|
||||
return _clip01((value - floor) / (ceil - floor))
|
||||
|
||||
|
||||
def _center_excerpt(y: np.ndarray, sr: float, seconds: float) -> np.ndarray:
|
||||
max_samples = int(seconds * sr)
|
||||
if y.size <= max_samples:
|
||||
return y
|
||||
start = (y.size - max_samples) // 2
|
||||
return y[start : start + max_samples]
|
||||
|
||||
|
||||
def _frames_per_window(sr: float, hop_length: int, window_seconds: float) -> int:
|
||||
return max(1, round(window_seconds * sr / hop_length))
|
||||
|
||||
|
||||
def _windowed(
|
||||
values: np.ndarray, frames_per_window: int, reduce: Callable[[np.ndarray], float]
|
||||
) -> np.ndarray:
|
||||
"""Buckets an already-computed per-frame array (RMS, spectral centroid, onset
|
||||
envelope, PLP - anything on the STFT hop grid) into `frames_per_window`-wide
|
||||
windows, applying `reduce` to each. The last window is short rather than dropped,
|
||||
so a track's tail is never silently excluded from its own curve."""
|
||||
n = max(1, math.ceil(values.size / frames_per_window))
|
||||
out = np.empty(n)
|
||||
for i in range(n):
|
||||
w = values[i * frames_per_window : (i + 1) * frames_per_window]
|
||||
out[i] = reduce(w if w.size else values[-1:])
|
||||
return out
|
||||
|
||||
|
||||
def _energy_window(w: np.ndarray) -> float:
|
||||
"""The same 80th-percentile+dB statistic as the whole-track `energy` scalar,
|
||||
applied to one window."""
|
||||
db = librosa.amplitude_to_db(np.array([np.percentile(w, 80)]), ref=1.0)[0]
|
||||
return _normalize(float(db), _ENERGY_DB_FLOOR, _ENERGY_DB_CEIL)
|
||||
|
||||
|
||||
def _brightness_window(w: np.ndarray) -> float:
|
||||
"""The same median+log statistic as the whole-track `brightness` scalar, applied
|
||||
to one window."""
|
||||
hz = max(float(np.median(w)), 1.0) # guard log2(0)
|
||||
floor, ceil = math.log2(_BRIGHTNESS_HZ_FLOOR), math.log2(_BRIGHTNESS_HZ_CEIL)
|
||||
return _normalize(math.log2(hz), floor, ceil)
|
||||
|
||||
|
||||
def _norm95(values: np.ndarray) -> np.ndarray:
|
||||
"""Scale so the array's 95th percentile maps to 1.0 - a robust max that one loud
|
||||
transient can't blow out, the same reasoning as the beat grid's strengths."""
|
||||
ceiling = float(np.percentile(values, 95))
|
||||
return values / ceiling if ceiling > 0 else np.zeros_like(values)
|
||||
|
||||
|
||||
def _majorness(chroma_mean: np.ndarray) -> float:
|
||||
"""Best major key-profile correlation minus best minor one, over all 12 rotations.
|
||||
|
||||
Positive means the track's pitch-class distribution fits a major key better than
|
||||
any minor one; negative the other way round. `np.corrcoef` is undefined for a
|
||||
perfectly flat chroma vector (silence, pure noise) - `nan_to_num` turns that into
|
||||
"no signal either way" rather than raising.
|
||||
"""
|
||||
|
||||
def best_fit(profile: np.ndarray) -> float:
|
||||
return max(
|
||||
float(np.nan_to_num(np.corrcoef(chroma_mean, np.roll(profile, i))[0, 1]))
|
||||
for i in range(12)
|
||||
)
|
||||
|
||||
return best_fit(_MAJOR_PROFILE) - best_fit(_MINOR_PROFILE)
|
||||
|
||||
|
||||
class LibrosaAnalyzer:
|
||||
"""Turns one music file into :class:`TrackAnalysis` plus an optional beat grid
|
||||
and :class:`TrackCurves`."""
|
||||
|
||||
version = ANALYZER_VERSION
|
||||
|
||||
def analyze(self, path: Path) -> tuple[TrackAnalysis, BeatGrid | None, TrackCurves | None]:
|
||||
"""Never raises: a file this can't make sense of is analyzed as "nothing".
|
||||
|
||||
A corrupt file, a DRM'd one, or a zero-length one must not abort a batch of
|
||||
hundreds - the fallback records `version` so :meth:`MusicLibrary.analyze_pending`
|
||||
does not retry it forever, while every scalar stays `None` so the frontend falls
|
||||
back to the un-analyzed baseline look rather than something half-computed.
|
||||
"""
|
||||
try:
|
||||
y, sr = librosa.load(path, sr=_SAMPLE_RATE, mono=True)
|
||||
except Exception:
|
||||
_log.warning("Could not decode %s; leaving it unanalyzed", path, exc_info=True)
|
||||
return TrackAnalysis(version=self.version), None, None
|
||||
|
||||
if y.size == 0:
|
||||
_log.warning("%s decoded to no audio; leaving it unanalyzed", path)
|
||||
return TrackAnalysis(version=self.version), None, None
|
||||
|
||||
try:
|
||||
return self._analyze(y, sr)
|
||||
except Exception:
|
||||
_log.warning("Analysis failed for %s; leaving it unanalyzed", path, exc_info=True)
|
||||
return TrackAnalysis(version=self.version), None, None
|
||||
|
||||
def _analyze(
|
||||
self, y: np.ndarray, sr: float
|
||||
) -> tuple[TrackAnalysis, BeatGrid | None, TrackCurves | None]:
|
||||
# One onset envelope feeds tempo, the beat grid's strengths, `pulse` and
|
||||
# `drive` - the single most expensive shared computation, so it is done once.
|
||||
onset_env = librosa.onset.onset_strength(y=y, sr=sr, hop_length=_HOP_LENGTH)
|
||||
tempo_raw, beat_frames = librosa.beat.beat_track(
|
||||
onset_envelope=onset_env, sr=sr, hop_length=_HOP_LENGTH
|
||||
)
|
||||
tempo_bpm = float(np.atleast_1d(tempo_raw)[0])
|
||||
grid = self._beat_grid(onset_env, beat_frames, sr)
|
||||
|
||||
frames_per_window = _frames_per_window(sr, _HOP_LENGTH, _ANALYSIS_HOP_SECONDS)
|
||||
|
||||
rms = librosa.feature.rms(y=y, hop_length=_HOP_LENGTH)[0]
|
||||
energy_curve = _windowed(rms, frames_per_window, _energy_window)
|
||||
energy = float(np.mean(energy_curve))
|
||||
|
||||
centroid = librosa.feature.spectral_centroid(y=y, sr=sr, hop_length=_HOP_LENGTH)[0]
|
||||
# Scalar `brightness` stays the whole-track median exactly as before this
|
||||
# refactor - it no longer drives anything on the frontend (only its curve
|
||||
# feeds `valence` below), so its own meaning is deliberately left unchanged.
|
||||
brightness = _brightness_window(centroid)
|
||||
brightness_curve = _windowed(centroid, frames_per_window, _brightness_window)
|
||||
|
||||
majorness_norm, tempo_norm = self._valence_terms(y, sr, tempo_bpm)
|
||||
valence_curve = np.clip(
|
||||
0.5 * majorness_norm + 0.3 * brightness_curve + 0.2 * tempo_norm, 0.0, 1.0
|
||||
)
|
||||
valence = float(np.mean(valence_curve))
|
||||
|
||||
pulse = self._pulse(onset_env, sr, tempo_bpm)
|
||||
drive_curve = self._drive_curve(onset_env, sr, frames_per_window)
|
||||
|
||||
curves = TrackCurves(
|
||||
hop_seconds=_ANALYSIS_HOP_SECONDS,
|
||||
energy=tuple(float(v) for v in energy_curve),
|
||||
valence=tuple(float(v) for v in valence_curve),
|
||||
drive=tuple(float(v) for v in drive_curve),
|
||||
)
|
||||
|
||||
analysis = TrackAnalysis(
|
||||
version=self.version,
|
||||
tempo=tempo_bpm,
|
||||
energy=energy,
|
||||
valence=valence,
|
||||
brightness=brightness,
|
||||
pulse=pulse,
|
||||
beats=grid is not None,
|
||||
)
|
||||
return analysis, grid, curves
|
||||
|
||||
def _beat_grid(
|
||||
self, onset_env: np.ndarray, beat_frames: np.ndarray, sr: float
|
||||
) -> BeatGrid | None:
|
||||
if beat_frames.size == 0:
|
||||
return None
|
||||
times = librosa.frames_to_time(beat_frames, sr=sr, hop_length=_HOP_LENGTH)
|
||||
raw_strengths = onset_env[np.clip(beat_frames, 0, onset_env.size - 1)]
|
||||
# The 95th percentile rather than the max, so one loud crash does not flatten
|
||||
# every other beat's strength toward zero.
|
||||
scale = float(np.percentile(raw_strengths, 95))
|
||||
strengths = raw_strengths / scale if scale > 0 else np.zeros_like(raw_strengths)
|
||||
return BeatGrid(
|
||||
tuple(float(t) for t in times),
|
||||
tuple(_clip01(float(s)) for s in strengths),
|
||||
)
|
||||
|
||||
def _valence_terms(self, y: np.ndarray, sr: float, tempo_bpm: float) -> tuple[float, float]:
|
||||
"""majorness_norm, tempo_norm - the two whole-track-constant terms of the
|
||||
valence formula. See the module docstring for why valence is a heuristic, not
|
||||
a measurement.
|
||||
|
||||
Weighted for a *children's* library specifically: mode (major/minor) is the
|
||||
strongest and most legible cue in this repertoire - a minor-key children's song
|
||||
is almost always deliberately sad or spooky, unlike in pop where mode is a much
|
||||
weaker signal. Tempo adds romp-vs-lullaby. Both stay whole-track constants:
|
||||
key is a global property of a track, and (unlike brightness) chroma-based key
|
||||
detection is too expensive and too noisy over a short window to be worth
|
||||
computing per second for what is now only a secondary, "nudge" contribution to
|
||||
color - see `TrackCurves.valence`.
|
||||
"""
|
||||
excerpt = _center_excerpt(y, sr, _CHROMA_EXCERPT_SECONDS)
|
||||
chroma = librosa.feature.chroma_cqt(y=excerpt, sr=sr, hop_length=_HOP_LENGTH)
|
||||
majorness = _majorness(chroma.mean(axis=1))
|
||||
majorness_norm = _clip01((majorness + 1.0) / 2.0)
|
||||
tempo_norm = _normalize(tempo_bpm, _TEMPO_BPM_FLOOR, _TEMPO_BPM_CEIL)
|
||||
return majorness_norm, tempo_norm
|
||||
|
||||
def _drive_curve(self, onset_env: np.ndarray, sr: float, frames_per_window: int) -> np.ndarray:
|
||||
"""Rhythmic intensity per window, 0..1, normalised *within the track*.
|
||||
|
||||
This - not tempo - is what the frontend's water current breathes with while a
|
||||
track plays. Measured against a real library, local tempo is flat to within a
|
||||
few percent inside a track (recorded children's music is played to a click);
|
||||
the residual "variation" a per-second tempo curve would show is mostly
|
||||
estimator noise, including occasional octave errors. Onset activity and local
|
||||
pulse strength (PLP) both genuinely vary within a track (33-55% and 59-189%
|
||||
relative spread respectively) and don't carry that failure mode.
|
||||
|
||||
Per-track normalisation (each component scaled by its own 95th percentile) is
|
||||
deliberate: absolute "how energetic is this song" is already carried by
|
||||
whole-track `tempo` (the current's base magnitude) and by `energy` (colour).
|
||||
This curve is for relative shape within the track - the intro is calmer than
|
||||
the chorus - so every track uses its own full 0..1 range rather than a
|
||||
uniformly quiet song sitting flat near zero throughout.
|
||||
"""
|
||||
activity = _windowed(onset_env, frames_per_window, lambda w: float(np.mean(w)))
|
||||
plp = librosa.beat.plp(onset_envelope=onset_env, sr=sr, hop_length=_HOP_LENGTH)
|
||||
pulse_curve = _windowed(plp, frames_per_window, lambda w: float(np.mean(w)))
|
||||
activity_term = _DRIVE_ACTIVITY_WEIGHT * _norm95(activity)
|
||||
pulse_term = _DRIVE_PLP_WEIGHT * _norm95(pulse_curve)
|
||||
return np.clip(activity_term + pulse_term, 0.0, 1.0)
|
||||
|
||||
def _pulse(self, onset_env: np.ndarray, sr: float, tempo_bpm: float) -> float:
|
||||
"""0..1 confidence that `tempo` is an audible, steady beat.
|
||||
|
||||
The onset envelope's autocorrelation at the beat period, relative to its value
|
||||
at lag zero: a track that truly pulses at `tempo` has a strong echo of itself
|
||||
one beat later; free-tempo or spoken-word material does not, even though
|
||||
`beat_track` always returns *some* grid for it.
|
||||
"""
|
||||
if tempo_bpm <= 0 or onset_env.size < 2:
|
||||
return 0.0
|
||||
period_frames = round((60.0 / tempo_bpm) * sr / _HOP_LENGTH)
|
||||
if not 0 < period_frames < onset_env.size:
|
||||
return 0.0
|
||||
with warnings.catch_warnings():
|
||||
# A known-spurious warning from numba's complex-magnitude dufunc under
|
||||
# this numba/numpy/librosa combination (confirmed: the input here has no
|
||||
# NaN/Inf, and the result is a normal finite float) - narrowly silenced
|
||||
# rather than left to spam the log once per track analyzed.
|
||||
warnings.filterwarnings(
|
||||
"ignore", message="invalid value encountered in cast", category=RuntimeWarning
|
||||
)
|
||||
ac = librosa.autocorrelate(onset_env)
|
||||
if ac[0] <= 0:
|
||||
return 0.0
|
||||
return _clip01(float(ac[period_frames] / ac[0]))
|
||||
88
python-backend/musicmouse/library/models.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""What the browse API serves: albums of tracks, with the colours to draw them in."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from musicmouse.library.analysis import TrackAnalysis
|
||||
from musicmouse.library.sections import AlbumKind
|
||||
from musicmouse.media import Playlist, Track
|
||||
|
||||
__all__ = ["Album", "AlbumColors", "LibraryTrack", "album_id", "track_key"]
|
||||
|
||||
#: Primary, secondary and accent as ``"#rrggbb"`` - the format
|
||||
#: :func:`musicmouse.color.parse_color` already accepts, so the LED side needs no new
|
||||
#: parsing and the frontend gets CSS colours for free.
|
||||
type AlbumColors = tuple[str, str, str]
|
||||
|
||||
|
||||
def album_id(root: Path, folder: Path) -> str:
|
||||
"""A stable id for an album folder, from its path relative to the library root."""
|
||||
relative = folder.relative_to(root).as_posix()
|
||||
return hashlib.sha1(relative.encode()).hexdigest()[:12]
|
||||
|
||||
|
||||
def track_key(path: Path) -> str:
|
||||
"""Content key for expensive per-track results.
|
||||
|
||||
Keyed on the *file*, not the album, so renaming a folder or re-sorting a section
|
||||
never throws away analysis that took minutes to compute.
|
||||
"""
|
||||
stat = path.stat()
|
||||
material = f"{path.name}:{stat.st_size}:{int(stat.st_mtime)}"
|
||||
return hashlib.sha1(material.encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LibraryTrack:
|
||||
path: Path
|
||||
title: str
|
||||
#: Seconds, from the tags. ``0.0`` when the file carries no duration.
|
||||
duration: float = 0.0
|
||||
analysis: TrackAnalysis | None = None
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"LibraryTrack({self.title!r}, {self.duration:.0f}s)"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Album:
|
||||
id: str
|
||||
section: str
|
||||
kind: AlbumKind
|
||||
title: str
|
||||
artist: str
|
||||
#: Grouping key for audiobooks and podcasts. ``None`` for music, which groups by
|
||||
#: artist instead - the two cases the browse UI's category view distinguishes.
|
||||
series: str | None
|
||||
#: Figure name when this folder sits under ``Figuren``, else ``None``.
|
||||
figure: str | None
|
||||
colors: AlbumColors
|
||||
folder: Path
|
||||
cover: Path | None
|
||||
tracks: tuple[LibraryTrack, ...]
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.tracks)
|
||||
|
||||
@property
|
||||
def duration(self) -> float:
|
||||
return sum(track.duration for track in self.tracks)
|
||||
|
||||
@property
|
||||
def category(self) -> str:
|
||||
"""How the browse view groups this album: series for books, artist for music."""
|
||||
return self.series or self.artist
|
||||
|
||||
def to_playlist(self) -> Playlist:
|
||||
"""The player's view of this album."""
|
||||
return Playlist(
|
||||
name=self.figure or self.title,
|
||||
tracks=tuple(Track(track.path) for track in self.tracks),
|
||||
album_id=self.id,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"Album({self.title!r}, {self.artist!r}, {len(self.tracks)} tracks)"
|
||||
502
python-backend/musicmouse/library/podcast_feeds.py
Normal file
@@ -0,0 +1,502 @@
|
||||
"""Auto-download new episodes for podcast shows that name their RSS feed.
|
||||
|
||||
A show folder opts in by containing a small marker file, ``feed.txt``, whose first
|
||||
non-blank line is the feed's URL. That file is the only thing this module needs to find
|
||||
a show - nothing here is turned on by config, matching how the rest of the library reads
|
||||
its shape entirely off the folder tree (see :mod:`musicmouse.library.sections`).
|
||||
|
||||
Downloaded episodes land in the show folder using the exact ``YYYYMMDD - Title.ext``
|
||||
convention ``sections.py`` already documents for ``Kinderpodcasts``, so a freshly
|
||||
downloaded episode sorts and scans exactly like one a person dropped in by hand. When
|
||||
a feed offers real per-episode artwork, it's saved alongside as a same-named sidecar
|
||||
image, which ``scanner.py``'s ``_cover_for_episode`` picks up automatically. A
|
||||
video-only enclosure (some shows publish no audio feed at all) is transcoded to audio
|
||||
via the ``ffmpeg`` binary, which must be on ``PATH`` for those shows to sync.
|
||||
Nothing here raises on bad input - an unreachable feed or a broken enclosure is logged
|
||||
and skipped, not a crash, mirroring ``scanner.py``'s own rule.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, Final
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import feedparser
|
||||
import httpx2
|
||||
|
||||
from musicmouse.library.sections import SECTIONS
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"FEED_MARKER_NAME",
|
||||
"AudioExtractionError",
|
||||
"Episode",
|
||||
"download_episode",
|
||||
"download_episode_cover",
|
||||
"episode_cover_filename",
|
||||
"episode_filename",
|
||||
"find_feed_shows",
|
||||
"missing_episodes",
|
||||
"parse_feed",
|
||||
"resolve_episode_cover",
|
||||
"sync_all_shows",
|
||||
"sync_show",
|
||||
]
|
||||
|
||||
FEED_MARKER_NAME: Final = "feed.txt"
|
||||
|
||||
#: Enclosure content-type -> file extension, for a URL whose own suffix is missing or
|
||||
#: not a real extension (tracking-redirect URLs are common in the wild).
|
||||
_EXTENSION_BY_TYPE: Final[dict[str, str]] = {
|
||||
"audio/mpeg": ".mp3",
|
||||
"audio/mp3": ".mp3",
|
||||
"audio/mp4": ".m4a",
|
||||
"audio/x-m4a": ".m4a",
|
||||
"audio/aac": ".aac",
|
||||
"audio/ogg": ".ogg",
|
||||
"audio/opus": ".opus",
|
||||
"audio/wav": ".wav",
|
||||
"audio/x-wav": ".wav",
|
||||
}
|
||||
_DEFAULT_EXTENSION: Final = ".mp3"
|
||||
_KNOWN_EXTENSIONS: Final = frozenset({".mp3", ".m4a", ".aac", ".ogg", ".opus", ".wav", ".flac"})
|
||||
|
||||
#: Cover URL suffix -> file extension, for the per-episode sidecar image.
|
||||
_IMAGE_EXTENSIONS: Final[frozenset[str]] = frozenset({".jpg", ".jpeg", ".png", ".webp"})
|
||||
_DEFAULT_IMAGE_EXTENSION: Final = ".jpg"
|
||||
|
||||
#: Characters illegal (or awkward) in a filename, plus the path separators themselves.
|
||||
#: Replaced with "_" rather than dropped, matching the convention every episode
|
||||
#: already downloaded by hand (via the ``podcast-dl`` CLI) was named with - a
|
||||
#: mismatch here would make every one of them look "new" to `missing_episodes`.
|
||||
_ILLEGAL_FILENAME_CHARS: Final = re.compile(r'[\\/:*?"<>|]')
|
||||
_MAX_TITLE_LENGTH: Final = 120
|
||||
|
||||
_HTTP_TIMEOUT: Final = 30.0
|
||||
#: `og:image` scraping is a fallback for a page we don't control; kept short so one
|
||||
#: slow or hanging host can't stall a whole sync pass.
|
||||
_OG_IMAGE_TIMEOUT: Final = 15.0
|
||||
_OG_IMAGE_RE: Final = re.compile(
|
||||
r'<meta[^>]+property=["\']og:image["\'][^>]*content=["\']([^"\']+)["\']'
|
||||
r'|<meta[^>]+content=["\']([^"\']+)["\'][^>]*property=["\']og:image["\']',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
#: How long a video enclosure is given to download-and-transcode before it's treated
|
||||
#: as failed - generous, since this runs on a Pi and a long video can take a while.
|
||||
_FFMPEG_TIMEOUT: Final = 600.0
|
||||
|
||||
#: Where a show folder remembers episodes that failed to download, so a permanently
|
||||
#: dead enclosure (e.g. pulled from the host's CDN) isn't retried every poll.
|
||||
_FAILED_DOWNLOADS_FILENAME: Final = ".failed-downloads.json"
|
||||
#: How long a failed episode is left alone before it's given another chance - long
|
||||
#: enough to stop hammering a dead URL every 6 hours, short enough that a genuinely
|
||||
#: transient failure (a host outage, a flaky network) still recovers on its own.
|
||||
_RETRY_BACKOFF: Final = timedelta(days=7)
|
||||
|
||||
|
||||
class AudioExtractionError(Exception):
|
||||
"""Raised when a video enclosure could not be turned into an audio file."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Episode:
|
||||
title: str
|
||||
published: datetime
|
||||
enclosure_url: str
|
||||
enclosure_type: str
|
||||
#: The feed's own per-episode artwork, when it has one distinct from the show's
|
||||
#: overall cover. ``None`` when the feed has no image at all, or (as with GEOlino)
|
||||
#: every item merely repeats the channel's own image.
|
||||
cover_url: str | None = None
|
||||
#: The episode's own page, when the feed links to one distinct from the show's
|
||||
#: general page - the fallback route to real per-episode art for a feed (like
|
||||
#: Wissen macht Ah) that has no per-item image of its own, via that page's
|
||||
#: `og:image`. ``None`` when the feed has no such per-episode page.
|
||||
link: str | None = None
|
||||
|
||||
|
||||
def find_feed_shows(root: Path) -> list[tuple[str, Path, str]]:
|
||||
"""Every show folder with a feed marker, as ``(section_name, folder, feed_url)``.
|
||||
|
||||
Restricted to sections whose ``album_unit`` is ``"episode"`` (today just
|
||||
``Kinderpodcasts``) - that is what marks a folder as a show rather than a single
|
||||
release, the same distinction the scanner already makes.
|
||||
"""
|
||||
out: list[tuple[str, Path, str]] = []
|
||||
for section_name, section in SECTIONS.items():
|
||||
if section.album_unit != "episode":
|
||||
continue
|
||||
section_root = root / section_name
|
||||
if not section_root.is_dir():
|
||||
continue
|
||||
for folder in sorted(section_root.iterdir(), key=lambda path: path.name):
|
||||
if not folder.is_dir() or folder.name.startswith("."):
|
||||
continue
|
||||
marker = folder / FEED_MARKER_NAME
|
||||
if not marker.is_file():
|
||||
continue
|
||||
feed_url = _read_feed_url(marker)
|
||||
if feed_url is not None:
|
||||
out.append((section_name, folder, feed_url))
|
||||
return out
|
||||
|
||||
|
||||
def _read_feed_url(marker: Path) -> str | None:
|
||||
for line in marker.read_text(encoding="utf-8").splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped:
|
||||
return stripped
|
||||
_log.warning("%s is empty; no feed URL to read", marker)
|
||||
return None
|
||||
|
||||
|
||||
def parse_feed(content: bytes) -> list[Episode]:
|
||||
"""Every episode in a feed that has both a publish date and a playable enclosure.
|
||||
|
||||
Anything else is skipped and logged rather than raising - a malformed or unusual
|
||||
entry in one feed must never stop the episodes around it from being picked up.
|
||||
"""
|
||||
parsed = feedparser.parse(content)
|
||||
# feedparser exposes an `itunes:image` identically at the channel and item level,
|
||||
# under the same "image" key an ordinary RSS `<image>` uses - there is no separate
|
||||
# `itunes_image` field. Read the channel's own image/link once so each entry can
|
||||
# tell whether it has something genuinely its own, or is just repeating them
|
||||
# (as GEOlino's per-item `itunes:image` does).
|
||||
channel_image = parsed.feed.get("image", {}).get("href")
|
||||
channel_link = parsed.feed.get("link")
|
||||
episodes: list[Episode] = []
|
||||
for entry in parsed.entries:
|
||||
published = _entry_published(entry)
|
||||
if published is None:
|
||||
_log.debug("Feed entry %r has no publish date; skipping", entry.get("title"))
|
||||
continue
|
||||
enclosure = _entry_enclosure(entry)
|
||||
if enclosure is None:
|
||||
_log.debug(
|
||||
"Feed entry %r has no audio or video enclosure; skipping", entry.get("title")
|
||||
)
|
||||
continue
|
||||
url, enclosure_type = enclosure
|
||||
|
||||
item_image = entry.get("image", {}).get("href")
|
||||
cover_url = item_image if item_image and item_image != channel_image else None
|
||||
|
||||
item_link = entry.get("link")
|
||||
link = item_link if item_link and item_link != channel_link else None
|
||||
|
||||
episodes.append(
|
||||
Episode(
|
||||
title=entry.get("title") or url,
|
||||
published=published,
|
||||
enclosure_url=url,
|
||||
enclosure_type=enclosure_type,
|
||||
cover_url=cover_url,
|
||||
link=link,
|
||||
)
|
||||
)
|
||||
return episodes
|
||||
|
||||
|
||||
def _entry_published(entry: Any) -> datetime | None:
|
||||
parsed_time = entry.get("published_parsed") or entry.get("updated_parsed")
|
||||
if parsed_time is None:
|
||||
return None
|
||||
year, month, day, hour, minute, second = tuple(parsed_time)[:6]
|
||||
return datetime(year, month, day, hour, minute, second, tzinfo=UTC)
|
||||
|
||||
|
||||
def _entry_enclosure(entry: Any) -> tuple[str, str] | None:
|
||||
"""The enclosure to download for one entry, preferring audio when both are offered.
|
||||
|
||||
A video enclosure (some shows, like Wissen macht Ah, publish no audio version at
|
||||
all) is still returned rather than dropped - :func:`download_episode` turns it
|
||||
into audio via ffmpeg. It's just the least preferred of the three: an audio
|
||||
enclosure, or one with no declared type at all (assumed audio), both win outright.
|
||||
"""
|
||||
video: tuple[str, str] | None = None
|
||||
for enclosure in entry.get("enclosures", []):
|
||||
url = enclosure.get("href") or enclosure.get("url")
|
||||
enclosure_type = enclosure.get("type") or ""
|
||||
if not url:
|
||||
continue
|
||||
if enclosure_type.startswith("audio/") or not enclosure_type:
|
||||
return str(url), str(enclosure_type)
|
||||
if video is None and enclosure_type.startswith("video/"):
|
||||
video = (str(url), str(enclosure_type))
|
||||
return video
|
||||
|
||||
|
||||
def _episode_stem(published: datetime, title: str) -> str:
|
||||
"""``YYYYMMDD - Title``, with no extension yet - shared by the audio filename and
|
||||
its sidecar cover image, so the two always line up."""
|
||||
sanitized = _ILLEGAL_FILENAME_CHARS.sub("_", title).strip().strip(".")
|
||||
sanitized = " ".join(sanitized.split())[:_MAX_TITLE_LENGTH] or "Episode"
|
||||
return f"{published:%Y%m%d} - {sanitized}"
|
||||
|
||||
|
||||
def episode_filename(
|
||||
published: datetime, title: str, enclosure_type: str, enclosure_url: str
|
||||
) -> str:
|
||||
"""``YYYYMMDD - Title.ext``, matching the convention every hand-placed episode
|
||||
already follows (see ``sections.py``'s module comment on ``Kinderpodcasts``)."""
|
||||
return f"{_episode_stem(published, title)}{_extension_for(enclosure_type, enclosure_url)}"
|
||||
|
||||
|
||||
def episode_cover_filename(published: datetime, title: str, cover_url: str) -> str:
|
||||
"""The sidecar image filename for an episode's cover, sharing its stem so the
|
||||
scanner (``scanner.py``'s ``_cover_for_episode``) can find it next to the audio."""
|
||||
return f"{_episode_stem(published, title)}{_image_extension_for(cover_url)}"
|
||||
|
||||
|
||||
def _extension_for(enclosure_type: str, url: str) -> str:
|
||||
suffix = Path(urlsplit(url).path).suffix.lower()
|
||||
if suffix in _KNOWN_EXTENSIONS:
|
||||
return suffix
|
||||
return _EXTENSION_BY_TYPE.get(enclosure_type, _DEFAULT_EXTENSION)
|
||||
|
||||
|
||||
def _image_extension_for(url: str) -> str:
|
||||
suffix = Path(urlsplit(url).path).suffix.lower()
|
||||
return suffix if suffix in _IMAGE_EXTENSIONS else _DEFAULT_IMAGE_EXTENSION
|
||||
|
||||
|
||||
def missing_episodes(folder: Path, episodes: list[Episode]) -> list[tuple[Episode, str]]:
|
||||
"""Episodes whose target filename isn't already on disk, paired with that filename.
|
||||
|
||||
Dedup is purely by filename - no separate manifest of what has been downloaded
|
||||
before, matching how the rest of the library already treats the filesystem as the
|
||||
only source of truth (see the fingerprinting in ``cache.py``).
|
||||
"""
|
||||
out: list[tuple[Episode, str]] = []
|
||||
for episode in episodes:
|
||||
filename = episode_filename(
|
||||
episode.published, episode.title, episode.enclosure_type, episode.enclosure_url
|
||||
)
|
||||
if not (folder / filename).exists():
|
||||
out.append((episode, filename))
|
||||
return out
|
||||
|
||||
|
||||
def _load_failed_downloads(folder: Path) -> dict[str, datetime]:
|
||||
"""Filename -> when it last failed to download, for episodes ``sync_show`` should
|
||||
leave alone until :data:`_RETRY_BACKOFF` has passed.
|
||||
|
||||
A missing or corrupt record is just an empty one - nothing here is precious enough
|
||||
to raise over, matching the module's overall rule that bad input is logged and
|
||||
skipped rather than fatal.
|
||||
"""
|
||||
path = folder / _FAILED_DOWNLOADS_FILENAME
|
||||
try:
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError:
|
||||
return {}
|
||||
except (OSError, ValueError) as exc:
|
||||
_log.debug("Could not read %s; treating as empty: %s", path, exc)
|
||||
return {}
|
||||
try:
|
||||
return {str(filename): datetime.fromisoformat(when) for filename, when in raw.items()}
|
||||
except (AttributeError, TypeError, ValueError) as exc:
|
||||
_log.debug("Could not parse %s; treating as empty: %s", path, exc)
|
||||
return {}
|
||||
|
||||
|
||||
def _save_failed_downloads(folder: Path, failed: dict[str, datetime]) -> None:
|
||||
path = folder / _FAILED_DOWNLOADS_FILENAME
|
||||
if not failed:
|
||||
path.unlink(missing_ok=True)
|
||||
return
|
||||
temp_path = folder / f".{_FAILED_DOWNLOADS_FILENAME}.tmp"
|
||||
payload = {filename: when.isoformat() for filename, when in failed.items()}
|
||||
temp_path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
temp_path.replace(path)
|
||||
|
||||
|
||||
async def download_episode(
|
||||
client: httpx2.AsyncClient, folder: Path, episode: Episode, filename: str
|
||||
) -> None:
|
||||
"""Get an episode to ``folder / filename`` via a dotfile temp path.
|
||||
|
||||
A half-written file must never look like a track: the scanner already skips
|
||||
dotfiles for exactly this reason (see ``scanner.py``'s ``_audio_files``), so the
|
||||
rename to the real name only happens once the download is complete. A video
|
||||
enclosure is routed through ffmpeg instead of being streamed as-is - see
|
||||
:func:`_extract_audio`.
|
||||
"""
|
||||
temp_path = folder / f".downloading-{filename}.tmp"
|
||||
try:
|
||||
if episode.enclosure_type.startswith("video/"):
|
||||
await _extract_audio(episode.enclosure_url, temp_path)
|
||||
else:
|
||||
async with client.stream(
|
||||
"GET", episode.enclosure_url, timeout=_HTTP_TIMEOUT
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
with temp_path.open("wb") as handle:
|
||||
async for chunk in response.aiter_bytes():
|
||||
handle.write(chunk)
|
||||
temp_path.replace(folder / filename)
|
||||
finally:
|
||||
temp_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
async def _extract_audio(source_url: str, dest: Path) -> None:
|
||||
"""Pull ``source_url`` (a video enclosure) through ffmpeg, writing just its audio
|
||||
track to ``dest``. ffmpeg fetches the URL itself, so the video is never stored.
|
||||
|
||||
Raises :class:`AudioExtractionError` rather than a bare ``OSError`` or timeout, so
|
||||
callers can tell a missing/failing ffmpeg apart from an ordinary network error -
|
||||
but either way this is meant to be logged and skipped, not fatal.
|
||||
"""
|
||||
if shutil.which("ffmpeg") is None:
|
||||
raise AudioExtractionError(
|
||||
"ffmpeg is not installed; cannot extract audio from a video enclosure"
|
||||
)
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-i",
|
||||
source_url,
|
||||
"-vn",
|
||||
"-acodec",
|
||||
"libmp3lame",
|
||||
"-q:a",
|
||||
"2",
|
||||
"-f",
|
||||
"mp3",
|
||||
str(dest),
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
_, stderr = await asyncio.wait_for(process.communicate(), timeout=_FFMPEG_TIMEOUT)
|
||||
except TimeoutError:
|
||||
process.kill()
|
||||
await process.wait()
|
||||
raise AudioExtractionError(f"ffmpeg timed out extracting audio from {source_url}") from None
|
||||
if process.returncode != 0:
|
||||
raise AudioExtractionError(
|
||||
f"ffmpeg exited {process.returncode} extracting audio from {source_url}: "
|
||||
f"{stderr.decode(errors='replace')[-500:]}"
|
||||
)
|
||||
|
||||
|
||||
async def download_episode_cover(
|
||||
client: httpx2.AsyncClient, folder: Path, cover_url: str, filename: str
|
||||
) -> None:
|
||||
"""Best-effort sidecar cover image fetch, atomic like :func:`download_episode`.
|
||||
|
||||
Cover art is small enough that streaming it in chunks isn't worth the extra code.
|
||||
"""
|
||||
temp_path = folder / f".downloading-{filename}.tmp"
|
||||
try:
|
||||
response = await client.get(cover_url, timeout=_HTTP_TIMEOUT, follow_redirects=True)
|
||||
response.raise_for_status()
|
||||
temp_path.write_bytes(response.content)
|
||||
temp_path.replace(folder / filename)
|
||||
finally:
|
||||
temp_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _extract_og_image(html: str) -> str | None:
|
||||
match = _OG_IMAGE_RE.search(html)
|
||||
if match is None:
|
||||
return None
|
||||
return match.group(1) or match.group(2)
|
||||
|
||||
|
||||
async def resolve_episode_cover(client: httpx2.AsyncClient, episode: Episode) -> str | None:
|
||||
"""The best cover URL available for ``episode``, or ``None`` when there isn't one.
|
||||
|
||||
Prefers the feed's own per-episode image. Failing that, when the feed links to a
|
||||
page of the episode's own (as Wissen macht Ah does, despite having no per-item
|
||||
image tag), falls back to that page's ``og:image`` - one targeted fetch of a URL
|
||||
the feed itself provided, not a scrape of a list page. Never raises: an
|
||||
unreachable page, a redirect loop, or a page with no such tag all just mean no
|
||||
cover was found here.
|
||||
"""
|
||||
if episode.cover_url:
|
||||
return episode.cover_url
|
||||
if not episode.link:
|
||||
return None
|
||||
try:
|
||||
response = await client.get(episode.link, timeout=_OG_IMAGE_TIMEOUT, follow_redirects=True)
|
||||
response.raise_for_status()
|
||||
except httpx2.HTTPError as exc:
|
||||
_log.debug("Could not fetch %s for its og:image: %s", episode.link, exc)
|
||||
return None
|
||||
return _extract_og_image(response.text)
|
||||
|
||||
|
||||
async def sync_show(client: httpx2.AsyncClient, folder: Path, feed_url: str) -> bool:
|
||||
"""Download every episode in ``feed_url`` that ``folder`` doesn't have yet.
|
||||
|
||||
Returns whether anything changed. Errors - an unreachable feed, a malformed one, a
|
||||
single broken enclosure - are logged and swallowed here so one bad show never stops
|
||||
the others or takes down the poll loop. An episode that fails is remembered and left
|
||||
alone for :data:`_RETRY_BACKOFF` before it's attempted again, so a permanently dead
|
||||
enclosure doesn't get hammered on every poll.
|
||||
"""
|
||||
try:
|
||||
response = await client.get(feed_url, timeout=_HTTP_TIMEOUT, follow_redirects=True)
|
||||
response.raise_for_status()
|
||||
except httpx2.HTTPError as exc:
|
||||
_log.warning("Could not fetch podcast feed %s for %s: %s", feed_url, folder.name, exc)
|
||||
return False
|
||||
|
||||
pending = missing_episodes(folder, parse_feed(response.content))
|
||||
failed = _load_failed_downloads(folder)
|
||||
now = datetime.now(UTC)
|
||||
changed = False
|
||||
for episode, filename in pending:
|
||||
last_failure = failed.get(filename)
|
||||
if last_failure is not None and now - last_failure < _RETRY_BACKOFF:
|
||||
continue
|
||||
try:
|
||||
await download_episode(client, folder, episode, filename)
|
||||
except (httpx2.HTTPError, OSError, AudioExtractionError) as exc:
|
||||
_log.warning(
|
||||
"Could not download episode %r for %s: %s", episode.title, folder.name, exc
|
||||
)
|
||||
failed[filename] = now
|
||||
continue
|
||||
failed.pop(filename, None)
|
||||
_log.info("Downloaded new episode %r for %s", episode.title, folder.name)
|
||||
changed = True
|
||||
|
||||
cover_url = await resolve_episode_cover(client, episode)
|
||||
if cover_url:
|
||||
cover_filename = episode_cover_filename(episode.published, episode.title, cover_url)
|
||||
if not (folder / cover_filename).exists():
|
||||
try:
|
||||
await download_episode_cover(client, folder, cover_url, cover_filename)
|
||||
except (httpx2.HTTPError, OSError) as exc:
|
||||
_log.warning(
|
||||
"Could not download cover for episode %r in %s: %s",
|
||||
episode.title,
|
||||
folder.name,
|
||||
exc,
|
||||
)
|
||||
|
||||
pending_filenames = {filename for _episode, filename in pending}
|
||||
failed = {filename: when for filename, when in failed.items() if filename in pending_filenames}
|
||||
_save_failed_downloads(folder, failed)
|
||||
return changed
|
||||
|
||||
|
||||
async def sync_all_shows(client: httpx2.AsyncClient, root: Path) -> bool:
|
||||
"""Poll every show with a feed marker under ``root``. Returns whether any changed."""
|
||||
changed = False
|
||||
for _section_name, folder, feed_url in find_feed_shows(root):
|
||||
if await sync_show(client, folder, feed_url):
|
||||
changed = True
|
||||
return changed
|
||||
359
python-backend/musicmouse/library/scanner.py
Normal file
@@ -0,0 +1,359 @@
|
||||
"""Turning folders on disk into :class:`~musicmouse.library.models.Album` objects.
|
||||
|
||||
One level under each section folder, every directory holding audio files is one album.
|
||||
Tags come from mutagen; where a section's tags are known to be useless the folder name
|
||||
wins instead (see :mod:`musicmouse.library.sections`). Nothing here raises on bad
|
||||
input - an unreadable file loses its metadata, not the boot.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from collections import Counter
|
||||
from collections.abc import Iterator, Mapping
|
||||
from pathlib import Path
|
||||
|
||||
from musicmouse.library.cache import Fingerprint, LibraryCache
|
||||
from musicmouse.library.colors import colors_from_cover, colors_from_id
|
||||
from musicmouse.library.models import Album, LibraryTrack, album_id
|
||||
from musicmouse.library.sections import SECTIONS, AlbumKind, Section
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["scan_library"]
|
||||
|
||||
#: Checked in order for a cover sitting next to the audio.
|
||||
_COVER_NAMES = ("cover.jpg", "cover.jpeg", "cover.png", "folder.jpg")
|
||||
|
||||
#: Extensions tried for an episode's own same-stem sidecar cover image (see
|
||||
#: :func:`_cover_for_episode`), matching what `podcast_feeds.py`'s
|
||||
#: ``episode_cover_filename`` can produce.
|
||||
_SIDECAR_COVER_EXTENSIONS = (".jpg", ".jpeg", ".png", ".webp")
|
||||
|
||||
#: How often a long scan reports where it is, so a library of thousands of albums
|
||||
#: doesn't sit silent for minutes with nothing on the console to say it is still going.
|
||||
_PROGRESS_INTERVAL_SECONDS = 5.0
|
||||
|
||||
|
||||
def _album_folders(folder: Path, extensions: frozenset[str]) -> Iterator[Path]:
|
||||
"""Every leaf album folder under ``folder``, however deep it is nested.
|
||||
|
||||
A folder that holds audio files directly *is* an album - an artist who groups
|
||||
their books under an extra "ab 3" / "ab 5" age-range folder, or a series folder
|
||||
that groups its episodes one directory further down than usual, still bottoms out
|
||||
here without needing to be special-cased. Only descended into when a folder holds
|
||||
no audio of its own, so an ordinary album folder is never mistaken for a grouping
|
||||
folder just because it also happens to contain subfolders.
|
||||
"""
|
||||
if _audio_files(folder, extensions):
|
||||
yield folder
|
||||
return
|
||||
for sub in sorted(folder.iterdir(), key=lambda path: path.name):
|
||||
if sub.is_dir() and not sub.name.startswith("."):
|
||||
yield from _album_folders(sub, extensions)
|
||||
|
||||
|
||||
def _audio_files(folder: Path, extensions: frozenset[str]) -> list[Path]:
|
||||
"""The playable files in ``folder``, alphabetically.
|
||||
|
||||
Dotfiles are skipped outright and everything else must match ``audio_extensions``,
|
||||
which is what keeps a podcast folder's ``archive.json`` and its half-finished
|
||||
``.podcast-dl-*.download.tmp`` out of the playlist.
|
||||
"""
|
||||
return sorted(
|
||||
(
|
||||
path
|
||||
for path in folder.iterdir()
|
||||
if path.is_file()
|
||||
and not path.name.startswith(".")
|
||||
and path.suffix.lower() in extensions
|
||||
),
|
||||
key=lambda path: path.name,
|
||||
)
|
||||
|
||||
|
||||
def _tags(path: Path) -> tuple[dict[str, str], float]:
|
||||
"""``(tags, duration)`` for one file. Empty and ``0.0`` when it cannot be read."""
|
||||
try:
|
||||
import mutagen
|
||||
|
||||
audio = mutagen.File(path, easy=True)
|
||||
if audio is None:
|
||||
return {}, 0.0
|
||||
tags = {key: values[0] for key, values in dict(audio).items() if values}
|
||||
return tags, float(getattr(audio.info, "length", 0.0) or 0.0)
|
||||
except Exception: # pragma: no cover - mutagen raises freely on damaged files
|
||||
_log.debug("No readable tags in %s", path)
|
||||
return {}, 0.0
|
||||
|
||||
|
||||
def _embedded_art(path: Path) -> bytes | None:
|
||||
try:
|
||||
import mutagen
|
||||
|
||||
raw = mutagen.File(path)
|
||||
if raw is None or raw.tags is None:
|
||||
return None
|
||||
for key in raw.tags:
|
||||
if key.startswith("APIC"):
|
||||
data: bytes = raw.tags[key].data
|
||||
return data
|
||||
except Exception: # pragma: no cover - defensive
|
||||
_log.debug("No readable embedded art in %s", path)
|
||||
return None
|
||||
|
||||
|
||||
def _split_folder_name(name: str) -> tuple[str, str]:
|
||||
"""``"Conni - Conni in den Bergen"`` -> ``("Conni", "Conni in den Bergen")``."""
|
||||
artist, separator, title = name.partition(" - ")
|
||||
return (artist, title) if separator else ("", name)
|
||||
|
||||
|
||||
def _most_common(values: list[str]) -> str:
|
||||
"""The tag value most files in an album agree on, ignoring blanks."""
|
||||
counted = Counter(value for value in values if value)
|
||||
return counted.most_common(1)[0][0] if counted else ""
|
||||
|
||||
|
||||
def _cover_for(
|
||||
folder: Path, paths: list[Path], identifier: str, cache: LibraryCache
|
||||
) -> tuple[Path | None, bytes | None]:
|
||||
for name in _COVER_NAMES:
|
||||
candidate = folder / name
|
||||
if candidate.is_file():
|
||||
return candidate, candidate.read_bytes()
|
||||
for path in paths[:3]:
|
||||
# Podcast feeds sometimes art only some episodes; a couple of tries is enough.
|
||||
art = _embedded_art(path)
|
||||
if art is not None:
|
||||
return cache.store_cover(identifier, art), art
|
||||
return None, None
|
||||
|
||||
|
||||
def _cover_for_episode(
|
||||
folder: Path, path: Path, identifier: str, cache: LibraryCache
|
||||
) -> tuple[Path | None, bytes | None]:
|
||||
"""The reverse priority from :func:`_cover_for`: with one album per episode, art
|
||||
specific to this one episode wins over the folder's shared cover. A same-stem
|
||||
sidecar image (what ``podcast_feeds.py`` saves when a feed has real per-episode
|
||||
art) is tried first - it's a plain file stat, cheaper than reading tags - then the
|
||||
episode's own embedded art, then the folder's shared cover as the last resort for
|
||||
an episode with neither."""
|
||||
for extension in _SIDECAR_COVER_EXTENSIONS:
|
||||
candidate = path.with_suffix(extension)
|
||||
if candidate.is_file():
|
||||
return candidate, candidate.read_bytes()
|
||||
art = _embedded_art(path)
|
||||
if art is not None:
|
||||
return cache.store_cover(identifier, art), art
|
||||
for name in _COVER_NAMES:
|
||||
candidate = folder / name
|
||||
if candidate.is_file():
|
||||
return candidate, candidate.read_bytes()
|
||||
return None, None
|
||||
|
||||
|
||||
def scan_album(
|
||||
folder: Path,
|
||||
*,
|
||||
root: Path,
|
||||
section_name: str,
|
||||
section: Section,
|
||||
extensions: frozenset[str],
|
||||
cache: LibraryCache,
|
||||
figure_kinds: Mapping[str, AlbumKind] | None = None,
|
||||
) -> tuple[Album, Fingerprint] | None:
|
||||
paths = _audio_files(folder, extensions)
|
||||
if not paths:
|
||||
_log.debug("No audio files in %s", folder)
|
||||
return None
|
||||
if section.order == "newest_first":
|
||||
paths.reverse()
|
||||
|
||||
fingerprint = Fingerprint.of(paths)
|
||||
identifier = album_id(root, folder)
|
||||
|
||||
tracks: list[LibraryTrack] = []
|
||||
albums: list[str] = []
|
||||
artists: list[str] = []
|
||||
for path in paths:
|
||||
tags, duration = _tags(path)
|
||||
tracks.append(
|
||||
LibraryTrack(path=path, title=tags.get("title") or path.stem, duration=duration)
|
||||
)
|
||||
albums.append(tags.get("album", ""))
|
||||
artists.append(tags.get("albumartist") or tags.get("artist", ""))
|
||||
|
||||
figure = folder.name if section.figures else None
|
||||
# Every other shelf is named after what is on it. A figure folder is named after the
|
||||
# figurine, so its media type has to be declared in the config.
|
||||
kind = (figure_kinds or {}).get(figure, "music") if figure else section.kind
|
||||
|
||||
folder_artist, folder_title = _split_folder_name(folder.name)
|
||||
|
||||
if section.title_from == "folder":
|
||||
# A figure folder is named in lowercase ("fuchs"); it sits next to real album
|
||||
# titles in the browse grid, so give it a capital.
|
||||
title = folder.name[:1].upper() + folder.name[1:] if section.figures else folder.name
|
||||
else:
|
||||
title = _most_common(albums) or folder_title or folder.name
|
||||
|
||||
if section.artist_from == "folder":
|
||||
artist = folder.name
|
||||
else:
|
||||
artist = _most_common(artists) or folder_artist
|
||||
|
||||
cover, art = _cover_for(folder, paths, identifier, cache)
|
||||
colors = colors_from_cover(art, identifier) if art else colors_from_id(identifier)
|
||||
|
||||
album = Album(
|
||||
id=identifier,
|
||||
section=section_name,
|
||||
kind=kind,
|
||||
title=title,
|
||||
artist=artist,
|
||||
# Books group by who or what they are about; music groups by artist. The browse
|
||||
# view's category row is built straight off this, so it takes only the part
|
||||
# before the first comma - an ``album_artist`` of "Bobo Siebenschlaefer, Markus
|
||||
# Osterwalder, ..." is a credit list whose first name is the character.
|
||||
series=artist.split(",")[0].strip() if kind == "book" else None,
|
||||
figure=figure,
|
||||
colors=colors,
|
||||
folder=folder,
|
||||
cover=cover,
|
||||
tracks=tuple(tracks),
|
||||
)
|
||||
return album, fingerprint
|
||||
|
||||
|
||||
def scan_episodes(
|
||||
folder: Path,
|
||||
*,
|
||||
root: Path,
|
||||
section_name: str,
|
||||
section: Section,
|
||||
extensions: frozenset[str],
|
||||
cache: LibraryCache,
|
||||
known: dict[str, tuple[Album, Fingerprint]],
|
||||
) -> dict[str, tuple[Album, Fingerprint]]:
|
||||
"""One album per audio file, for a section whose folder is a show rather than a
|
||||
single release - a podcast feed's hundreds of episodes, most obviously.
|
||||
|
||||
Fingerprinted per file rather than per folder, so a new episode landing in an
|
||||
already-scanned show only costs scanning that one file, not the whole show.
|
||||
"""
|
||||
paths = _audio_files(folder, extensions)
|
||||
if section.order == "newest_first":
|
||||
paths.reverse()
|
||||
|
||||
out: dict[str, tuple[Album, Fingerprint]] = {}
|
||||
for path in paths:
|
||||
identifier = album_id(root, path)
|
||||
fingerprint = Fingerprint.of([path])
|
||||
cached = known.get(identifier)
|
||||
if cached is not None and cached[1] == fingerprint:
|
||||
out[identifier] = cached
|
||||
continue
|
||||
|
||||
tags, duration = _tags(path)
|
||||
title = tags.get("title") or path.stem
|
||||
cover, art = _cover_for_episode(folder, path, identifier, cache)
|
||||
colors = colors_from_cover(art, identifier) if art else colors_from_id(identifier)
|
||||
|
||||
album = Album(
|
||||
id=identifier,
|
||||
section=section_name,
|
||||
kind=section.kind,
|
||||
title=title,
|
||||
artist=folder.name,
|
||||
# The folder is the show; every episode in it groups under the same series,
|
||||
# exactly like an audiobook's chapters group under its book.
|
||||
series=folder.name,
|
||||
figure=None,
|
||||
colors=colors,
|
||||
folder=folder,
|
||||
cover=cover,
|
||||
tracks=(LibraryTrack(path=path, title=title, duration=duration),),
|
||||
)
|
||||
out[identifier] = (album, fingerprint)
|
||||
return out
|
||||
|
||||
|
||||
def scan_library(
|
||||
root: Path,
|
||||
extensions: frozenset[str],
|
||||
cache: LibraryCache,
|
||||
*,
|
||||
known: dict[str, tuple[Album, Fingerprint]] | None = None,
|
||||
figure_kinds: Mapping[str, AlbumKind] | None = None,
|
||||
) -> dict[str, tuple[Album, Fingerprint]]:
|
||||
"""Scan every section under ``root``.
|
||||
|
||||
``known`` is the previously cached index: a folder whose files, sizes and mtimes are
|
||||
unchanged is taken from it without a single tag being read. ``figure_kinds`` maps a
|
||||
figure name to what it holds, which is the one thing the folders cannot say.
|
||||
"""
|
||||
cache.prepare()
|
||||
known = known or {}
|
||||
out: dict[str, tuple[Album, Fingerprint]] = {}
|
||||
last_report = time.monotonic()
|
||||
|
||||
for section_name, section in SECTIONS.items():
|
||||
section_root = root / section_name
|
||||
if not section_root.is_dir():
|
||||
_log.warning("Library section %r has no folder at %s", section_name, section_root)
|
||||
continue
|
||||
|
||||
for top in sorted(section_root.iterdir(), key=lambda path: path.name):
|
||||
if not top.is_dir() or top.name.startswith("."):
|
||||
continue
|
||||
|
||||
if section.album_unit == "episode":
|
||||
# Fingerprinted per episode inside scan_episodes; the whole-folder
|
||||
# shortcut below does not apply since one folder yields many albums,
|
||||
# and an episode show is never nested deeper than this either.
|
||||
out.update(
|
||||
scan_episodes(
|
||||
top,
|
||||
root=root,
|
||||
section_name=section_name,
|
||||
section=section,
|
||||
extensions=extensions,
|
||||
cache=cache,
|
||||
known=known,
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
for folder in _album_folders(top, extensions):
|
||||
now = time.monotonic()
|
||||
if now - last_report >= _PROGRESS_INTERVAL_SECONDS:
|
||||
_log.info(
|
||||
"Scanning library: %d albums found so far, now in %s",
|
||||
len(out),
|
||||
folder.relative_to(root),
|
||||
)
|
||||
last_report = now
|
||||
|
||||
identifier = album_id(root, folder)
|
||||
cached = known.get(identifier)
|
||||
if cached is not None:
|
||||
paths = _audio_files(folder, extensions)
|
||||
if paths and Fingerprint.of(paths) == cached[1]:
|
||||
out[identifier] = cached
|
||||
continue
|
||||
scanned = scan_album(
|
||||
folder,
|
||||
root=root,
|
||||
section_name=section_name,
|
||||
section=section,
|
||||
extensions=extensions,
|
||||
cache=cache,
|
||||
figure_kinds=figure_kinds,
|
||||
)
|
||||
if scanned is not None:
|
||||
out[identifier] = scanned
|
||||
|
||||
_log.info("Library: %d albums under %s", len(out), root)
|
||||
return out
|
||||
52
python-backend/musicmouse/library/sections.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""The four shelves of the music library, and how each one differs.
|
||||
|
||||
These are folder names, not configuration. The library has one root and the layout
|
||||
underneath it is fixed - a section that needed configuring would be a section whose
|
||||
quirks are not understood yet.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Final, Literal
|
||||
|
||||
__all__ = ["SECTIONS", "AlbumKind", "ArtistSource", "Section", "TitleSource", "TrackOrder"]
|
||||
|
||||
type AlbumKind = Literal["music", "book"]
|
||||
type TrackOrder = Literal["filename", "newest_first"]
|
||||
type TitleSource = Literal["tags", "folder"]
|
||||
type ArtistSource = Literal["tags", "folder"]
|
||||
type AlbumUnit = Literal["folder", "episode"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Section:
|
||||
kind: AlbumKind = "music"
|
||||
#: Subfolders are figure folders: their name is the figure name from the config.
|
||||
figures: bool = False
|
||||
order: TrackOrder = "filename"
|
||||
title_from: TitleSource = "tags"
|
||||
artist_from: ArtistSource = "tags"
|
||||
#: "folder" (default): one album per folder, every audio file a track/chapter of it.
|
||||
#: "episode": one album per audio *file* - a show folder groups its episodes rather
|
||||
#: than being one giant album itself. `title_from`/`artist_from` are not consulted
|
||||
#: for this unit: an episode's title always comes from its own tags (or filename),
|
||||
#: and the folder always supplies the artist/series, so episodes of the same show
|
||||
#: still group together everywhere the browse view groups by category.
|
||||
album_unit: AlbumUnit = "folder"
|
||||
|
||||
|
||||
#: ``Kinderpodcasts`` is the odd one out twice over. Its ``artist`` tag is the full
|
||||
#: presenter list ("Thomas Welling, Sarah Schultes, ...") and its ``album`` tag is the
|
||||
#: feed name, so neither groups usefully - the folder name is the show. And its files
|
||||
#: are named ``YYYYMMDD - Title.mp3``, so reversing filename order puts the newest
|
||||
#: episode first, which is the one anybody wants.
|
||||
SECTIONS: Final[dict[str, Section]] = {
|
||||
# A figure folder is named after the figure, and its contents are whatever that
|
||||
# figure should play - often several albums' worth. The folder name is the honest
|
||||
# title; the tags still supply a useful artist.
|
||||
"Figuren": Section(figures=True, title_from="folder"),
|
||||
"Musik": Section(),
|
||||
"Hörbücher": Section(kind="book"),
|
||||
"Kinderpodcasts": Section(kind="book", order="newest_first", album_unit="episode"),
|
||||
}
|
||||
46
python-backend/musicmouse/media.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""Playlist model.
|
||||
|
||||
Deliberately a real type rather than a bare ``list[str]``: it is what the browse API
|
||||
serves, and it keeps track metadata in one place. Building one from a folder is
|
||||
:mod:`musicmouse.library`'s job.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
__all__ = ["Playlist", "Track"]
|
||||
|
||||
|
||||
@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, ...]
|
||||
#: Set when this playlist came from a library album. It is how a front-end answers
|
||||
#: "what is playing?" without keeping its own copy of that state.
|
||||
album_id: str | None = None
|
||||
|
||||
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)"
|
||||
21
python-backend/musicmouse/reactions/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""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 ( # noqa: F401 (import = register)
|
||||
lighting,
|
||||
playback,
|
||||
status,
|
||||
)
|
||||
from musicmouse.reactions.registry import Reaction, on, register_all, registered
|
||||
|
||||
__all__ = ["Reaction", "on", "register_all", "registered"]
|
||||
174
python-backend/musicmouse/reactions/lighting.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""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, parse_color
|
||||
from musicmouse.config import FigureColors
|
||||
from musicmouse.effects import (
|
||||
EffectCircularConfig,
|
||||
EffectRandomTwoColorInterpolationConfig,
|
||||
EffectReverseSwipe,
|
||||
EffectStaticConfig,
|
||||
EffectSwipeAndChange,
|
||||
)
|
||||
from musicmouse.events import (
|
||||
ActiveFigureChanged,
|
||||
PlaybackChanged,
|
||||
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
|
||||
|
||||
#: The web front-end's animation: a three-quarter arc drifting round twice a minute, in
|
||||
#: the album's own primary colour. Deliberately unlike the figure swipe - the strips
|
||||
#: should say *which* way the mouse was started, not just that it was.
|
||||
WEB_CIRCLE_WIDTH = 270.0 # degrees
|
||||
WEB_CIRCLE_SPEED = 12.0 # degrees per second
|
||||
|
||||
|
||||
@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(PlaybackChanged)
|
||||
def web_playback(event: PlaybackChanged, app: App) -> None:
|
||||
"""Light the strips for playback that no figure started.
|
||||
|
||||
Reacting to ``PlaybackChanged`` rather than to the play intent covers pause, stop
|
||||
and playlist-end in one place. The guard is what keeps this off the figure path:
|
||||
while a figure is on the reader its animation owns all three zones.
|
||||
"""
|
||||
if app.mouse.active_figure is not None:
|
||||
return
|
||||
if not event.playing:
|
||||
off_animation(app)
|
||||
return
|
||||
|
||||
album = app.album_for(event.playlist)
|
||||
if album is None:
|
||||
return
|
||||
web_animation(app, parse_color(album.colors[0]))
|
||||
|
||||
|
||||
@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 web_animation(app: App, color: ColorRGBW) -> None:
|
||||
for zone in LedZone:
|
||||
app.mouse.set_effect(
|
||||
zone,
|
||||
EffectCircularConfig(speed=WEB_CIRCLE_SPEED, width=WEB_CIRCLE_WIDTH, color=color),
|
||||
origin="device",
|
||||
)
|
||||
app.mouse.set_button_brightness(app.config.general.button_leds_brightness, 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)
|
||||
174
python-backend/musicmouse/reactions/playback.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""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,
|
||||
PlayAlbumRequested,
|
||||
PlayFigureRequested,
|
||||
PlaylistFinished,
|
||||
PlayRequested,
|
||||
PlaySeriesLatestRequested,
|
||||
PreviousTrackRequested,
|
||||
RotaryTurned,
|
||||
SeekRequested,
|
||||
SetVolumeRequested,
|
||||
VolumeChangeRequested,
|
||||
)
|
||||
from musicmouse.hardware import Button, ButtonAction, RotaryDirection
|
||||
from musicmouse.library.models import Album
|
||||
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()
|
||||
|
||||
|
||||
def _start_album(app: App, album: Album, track_index: int) -> None:
|
||||
# A figure album keeps the figure's own resume bookkeeping honest: it is the same
|
||||
# Playlist object either way, because both come from the library index.
|
||||
playlist = app.playlists.get(album.figure) if album.figure else album.to_playlist()
|
||||
app.player.set_playlist(playlist or album.to_playlist())
|
||||
app.player.play_track(track_index)
|
||||
|
||||
|
||||
@on(PlayAlbumRequested)
|
||||
def play_album(event: PlayAlbumRequested, app: App) -> None:
|
||||
"""Play any album in the library. This is the web front-end's way in."""
|
||||
album = app.library.get(event.album_id)
|
||||
if album is None:
|
||||
_log.warning("No album %r in the library", event.album_id)
|
||||
return
|
||||
_start_album(app, album, event.track_index)
|
||||
|
||||
|
||||
@on(PlaySeriesLatestRequested)
|
||||
def play_series_latest(event: PlaySeriesLatestRequested, app: App) -> None:
|
||||
"""Play the newest episode of a podcast show - the IR remote's number-key way in."""
|
||||
album = app.library.latest_episode(event.series)
|
||||
if album is None:
|
||||
_log.warning("No episodes for series %r", event.series)
|
||||
return
|
||||
_start_album(app, album, 0)
|
||||
|
||||
|
||||
@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(PreviousTrackRequested(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(PreviousTrackRequested)
|
||||
def previous_track(event: PreviousTrackRequested, 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(SeekRequested)
|
||||
def seek(event: SeekRequested, app: App) -> None:
|
||||
app.player.seek(event.position)
|
||||
|
||||
|
||||
@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
@@ -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
|
||||
20
python-backend/musicmouse/reactions/status.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""Remembering which links are up.
|
||||
|
||||
The firmware's state is readable straight off the transport, but a broker's is not:
|
||||
:class:`~musicmouse.services.mqtt.service.MqttService` announces it and then forgets.
|
||||
A front-end that wants to show a connection dot needs somewhere to read it from.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from musicmouse.app import App
|
||||
from musicmouse.events import ConnectionChanged
|
||||
from musicmouse.reactions.registry import on
|
||||
|
||||
|
||||
@on(ConnectionChanged)
|
||||
def connection_changed(event: ConnectionChanged, app: App) -> None:
|
||||
if event.target == "mqtt":
|
||||
app.state.mqtt_connected = event.connected
|
||||
elif event.target == "lirc":
|
||||
app.state.lirc_connected = event.connected
|
||||
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
@@ -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/lirc/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""IR remote control, via lircd's TCP socket."""
|
||||
|
||||
from musicmouse.services.lirc.protocol import LircButtonEvent, parse_line
|
||||
from musicmouse.services.lirc.service import LircService
|
||||
|
||||
__all__ = ["LircButtonEvent", "LircService", "parse_line"]
|
||||
37
python-backend/musicmouse/services/lirc/protocol.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""lircd's classic network protocol: one line per button press or repeat.
|
||||
|
||||
A line looks like::
|
||||
|
||||
0000000000001781 00 BTN_1 Hauppauge
|
||||
|
||||
that is ``<code> <repeat, hex> <button name> <remote name>``. ``repeat`` is ``00`` for
|
||||
the first press and increments while the button is held - lircd has no separate
|
||||
key-up event, just repeats stopping.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
__all__ = ["LircButtonEvent", "parse_line"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LircButtonEvent:
|
||||
code: str
|
||||
repeat: int
|
||||
button: str
|
||||
remote: str
|
||||
|
||||
|
||||
def parse_line(line: str) -> LircButtonEvent | None:
|
||||
"""One broadcast line, or ``None`` if it does not look like one."""
|
||||
parts = line.strip().split()
|
||||
if len(parts) != 4:
|
||||
return None
|
||||
code, repeat_hex, button, remote = parts
|
||||
try:
|
||||
repeat = int(repeat_hex, 16)
|
||||
except ValueError:
|
||||
return None
|
||||
return LircButtonEvent(code=code, repeat=repeat, button=button, remote=remote)
|
||||
140
python-backend/musicmouse/services/lirc/service.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""The IR remote: a TCP client for lircd, translated into the same intents every other
|
||||
front-end emits.
|
||||
|
||||
Connect, read lines until the link drops, wait, repeat - the same reconnect shape as
|
||||
:class:`~musicmouse.devices.serial_link.SerialLink`, over a plain socket instead of a
|
||||
serial port because lircd speaks its classic protocol on a bare TCP connection.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
|
||||
from musicmouse.app import App
|
||||
from musicmouse.clock import Clock, RealClock
|
||||
from musicmouse.config import Digit, LircConfig
|
||||
from musicmouse.events import (
|
||||
ConnectionChanged,
|
||||
IntentEvent,
|
||||
NextTrackRequested,
|
||||
PauseRequested,
|
||||
PlayAlbumRequested,
|
||||
PlayRequested,
|
||||
PlaySeriesLatestRequested,
|
||||
PreviousTrackRequested,
|
||||
SetVolumeRequested,
|
||||
VolumeChangeRequested,
|
||||
)
|
||||
from musicmouse.services.lirc.protocol import LircButtonEvent, parse_line
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["LircService"]
|
||||
|
||||
#: Acted on only at repeat 0 (the first press) - holding the button must not replay it.
|
||||
_TRANSPORT: dict[str, Callable[[], IntentEvent]] = {
|
||||
"KEY_PLAY": lambda: PlayRequested(source="lirc"),
|
||||
# No separate "stop" concept exists in this player; the remote's stop button just
|
||||
# pauses, like its pause button.
|
||||
"KEY_PAUSE": lambda: PauseRequested(source="lirc"),
|
||||
"KEY_STOP": lambda: PauseRequested(source="lirc"),
|
||||
"KEY_PREVIOUS": lambda: PreviousTrackRequested(source="lirc"),
|
||||
"KEY_REWIND": lambda: PreviousTrackRequested(source="lirc"),
|
||||
"KEY_FORWARD": lambda: NextTrackRequested(source="lirc"),
|
||||
}
|
||||
|
||||
#: Acted on every repeat, for a continuous ramp while held - same feel as the rotary
|
||||
#: encoder (see ``reactions.playback.rotary_turned``).
|
||||
_VOLUME: dict[str, int] = {"KEY_VOLUMEUP": 1, "KEY_VOLUMEDOWN": -1}
|
||||
|
||||
_DIGITS: dict[str, Digit] = {
|
||||
"BTN_0": "0",
|
||||
"BTN_1": "1",
|
||||
"BTN_2": "2",
|
||||
"BTN_3": "3",
|
||||
"BTN_4": "4",
|
||||
"BTN_5": "5",
|
||||
"BTN_6": "6",
|
||||
"BTN_7": "7",
|
||||
"BTN_8": "8",
|
||||
"BTN_9": "9",
|
||||
}
|
||||
|
||||
|
||||
class LircService:
|
||||
name = "lirc"
|
||||
|
||||
def __init__(self, app: App, config: LircConfig, *, clock: Clock | None = None) -> None:
|
||||
self.app = app
|
||||
self.config = config
|
||||
self._clock = clock or RealClock()
|
||||
#: Volume to restore on the next mute press, remembered the way the frontend's
|
||||
#: own mute toggle does (App.tsx) rather than via any new Player API.
|
||||
self._pre_mute_volume: int | None = None
|
||||
|
||||
async def run(self) -> None:
|
||||
"""Connect, read lines until the link drops, wait, repeat. Runs until cancelled."""
|
||||
while True:
|
||||
try:
|
||||
await self._session()
|
||||
except OSError as exc:
|
||||
_log.warning(
|
||||
"lircd link to %s:%d lost (%s); retrying in %gs",
|
||||
self.config.host,
|
||||
self.config.port,
|
||||
exc,
|
||||
self.config.reconnect_interval,
|
||||
)
|
||||
await self._clock.sleep(self.config.reconnect_interval)
|
||||
|
||||
async def _session(self) -> None:
|
||||
reader, writer = await asyncio.open_connection(self.config.host, self.config.port)
|
||||
_log.info("Connected to lircd at %s:%d", self.config.host, self.config.port)
|
||||
self.app.bus.emit(ConnectionChanged(target="lirc", connected=True, source="lirc"))
|
||||
try:
|
||||
while True:
|
||||
raw = await reader.readline()
|
||||
if not raw:
|
||||
return
|
||||
event = parse_line(raw.decode(errors="replace"))
|
||||
if event is None or event.remote != self.config.remote_name:
|
||||
continue
|
||||
self._handle(event)
|
||||
finally:
|
||||
writer.close()
|
||||
self.app.bus.emit(ConnectionChanged(target="lirc", connected=False, source="lirc"))
|
||||
|
||||
def _handle(self, event: LircButtonEvent) -> None:
|
||||
if event.button in _TRANSPORT:
|
||||
if event.repeat == 0:
|
||||
self.app.bus.emit(_TRANSPORT[event.button]())
|
||||
elif event.button in _VOLUME:
|
||||
step = self.app.config.general.volume_increment * _VOLUME[event.button]
|
||||
self.app.bus.emit(VolumeChangeRequested(delta=step, source="lirc"))
|
||||
elif event.button == "KEY_MUTE":
|
||||
if event.repeat == 0:
|
||||
self._toggle_mute()
|
||||
elif (digit := _DIGITS.get(event.button)) is not None and event.repeat == 0:
|
||||
self._play_digit(digit)
|
||||
|
||||
def _toggle_mute(self) -> None:
|
||||
player = self.app.player
|
||||
if player.volume > 0:
|
||||
self._pre_mute_volume = player.volume
|
||||
self.app.bus.emit(SetVolumeRequested(volume=0, source="lirc"))
|
||||
else:
|
||||
restore = self._pre_mute_volume or self.app.config.general.initial_volume
|
||||
self.app.bus.emit(SetVolumeRequested(volume=restore, source="lirc"))
|
||||
|
||||
def _play_digit(self, digit: Digit) -> None:
|
||||
slot = self.app.config.remote.get(digit)
|
||||
if slot is None:
|
||||
return
|
||||
if slot.target_kind == "album":
|
||||
self.app.bus.emit(
|
||||
PlayAlbumRequested(album_id=slot.target, track_index=0, source="lirc")
|
||||
)
|
||||
else:
|
||||
self.app.bus.emit(PlaySeriesLatestRequested(series=slot.target, source="lirc"))
|
||||
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
@@ -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
@@ -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),
|
||||
}
|
||||
171
python-backend/musicmouse/services/mqtt/player.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""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.mouse import MusicMouseDevice
|
||||
from musicmouse.devices.player import Player
|
||||
from musicmouse.events import (
|
||||
ActiveFigureChanged,
|
||||
Event,
|
||||
IntentEvent,
|
||||
NextTrackRequested,
|
||||
PauseRequested,
|
||||
PlaybackChanged,
|
||||
PlayRequested,
|
||||
PreviousTrackRequested,
|
||||
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, mouse: MusicMouseDevice, player: Player
|
||||
) -> None:
|
||||
self.mouse = mouse
|
||||
self.player = player
|
||||
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:
|
||||
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.mouse.active_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": PreviousTrackRequested,
|
||||
"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, mouse: MusicMouseDevice, 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, mouse, 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
@@ -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, mouse, 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
@@ -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),
|
||||
]
|
||||
60
python-backend/musicmouse/services/podcasts.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Front-end for nothing: the only "intent" this service ever produces is new files on
|
||||
disk. It polls every podcast show that named its feed via a ``feed.txt`` marker (see
|
||||
:mod:`musicmouse.library.podcast_feeds`) and downloads whatever episode is missing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Final
|
||||
|
||||
import httpx2
|
||||
|
||||
from musicmouse.library import MusicLibrary
|
||||
from musicmouse.library.podcast_feeds import sync_all_shows
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["PodcastFeedService"]
|
||||
|
||||
#: Podcasts a children's player subscribes to publish at most a few times a week;
|
||||
#: checking a few times a day is plenty and kind to the feed hosts.
|
||||
_CHECK_INTERVAL_SECONDS: Final = 6 * 3600
|
||||
|
||||
|
||||
class PodcastFeedService:
|
||||
name = "podcast-feeds"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
library: MusicLibrary,
|
||||
*,
|
||||
client: httpx2.AsyncClient,
|
||||
on_change: Callable[[], Awaitable[None]],
|
||||
interval: float = _CHECK_INTERVAL_SECONDS,
|
||||
) -> None:
|
||||
self.library = library
|
||||
self.client = client
|
||||
self.on_change = on_change
|
||||
self.interval = interval
|
||||
|
||||
async def run(self) -> None:
|
||||
"""Checks immediately at startup, then every `interval` seconds.
|
||||
|
||||
A no-op when no show has a ``feed.txt`` - which is the common case, so this can
|
||||
run unconditionally instead of needing its own config toggle. Never lets a
|
||||
single bad pass end the task: `Service.run` is cancelled on shutdown and is
|
||||
otherwise expected to keep going on its own.
|
||||
"""
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
if await sync_all_shows(self.client, self.library.root):
|
||||
await self.on_change()
|
||||
except Exception:
|
||||
_log.exception("Podcast feed check failed; will retry next interval")
|
||||
await asyncio.sleep(self.interval)
|
||||
finally:
|
||||
await self.client.aclose()
|
||||
6
python-backend/musicmouse/services/web/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""The web front-end: a browse-and-play UI over the same intents the buttons emit."""
|
||||
|
||||
from musicmouse.services.web.hub import StateHub
|
||||
from musicmouse.services.web.service import WebService, build_app
|
||||
|
||||
__all__ = ["StateHub", "WebService", "build_app"]
|
||||
341
python-backend/musicmouse/services/web/api.py
Normal file
@@ -0,0 +1,341 @@
|
||||
"""The HTTP surface.
|
||||
|
||||
Commands are REST and state is a one-way websocket. That split keeps every control path
|
||||
testable with ``curl`` and means a command needs no new machinery: it emits the same
|
||||
intent the MQTT service and the buttons emit, and lands in the same reaction.
|
||||
|
||||
Search is not here on purpose. The whole index goes to the browser once and filtering
|
||||
happens there, which is what makes the design's type-to-search feel instant.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx2
|
||||
from fastapi import APIRouter, HTTPException, Response, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from musicmouse.app import App
|
||||
from musicmouse.config import Digit, HaConfig, RemoteSlotConfig
|
||||
from musicmouse.events import (
|
||||
IntentEvent,
|
||||
NextTrackRequested,
|
||||
PauseRequested,
|
||||
PlayAlbumRequested,
|
||||
PlayRequested,
|
||||
PreviousTrackRequested,
|
||||
SeekRequested,
|
||||
SetVolumeRequested,
|
||||
)
|
||||
from musicmouse.services.web.hub import StateHub
|
||||
from musicmouse.services.web.remote_settings import read_mapping, write_mapping
|
||||
from musicmouse.services.web.schemas import (
|
||||
AlbumOut,
|
||||
HaConfigOut,
|
||||
HaDeviceOut,
|
||||
LibraryOut,
|
||||
LircConfigOut,
|
||||
PlayerStateOut,
|
||||
PlayIn,
|
||||
RemoteMappingIn,
|
||||
RemoteMappingOut,
|
||||
SeekIn,
|
||||
SettingsIn,
|
||||
SettingsOut,
|
||||
TippenCurriculumOut,
|
||||
TippenProgressOut,
|
||||
TippenRunIn,
|
||||
TippenRunOut,
|
||||
TippenSettingsIn,
|
||||
TippenSettingsOut,
|
||||
TrackCurvesOut,
|
||||
TrackDetailOut,
|
||||
VolumeIn,
|
||||
)
|
||||
from musicmouse.services.web.settings import (
|
||||
read_settings,
|
||||
to_device_volume,
|
||||
to_percent,
|
||||
write_settings,
|
||||
)
|
||||
from musicmouse.services.web.state import snapshot
|
||||
from musicmouse.services.web.tippen_api import curriculum_out, progress_out, record_tippen_run
|
||||
from musicmouse.tippen.rewards import compute_lock_state
|
||||
from musicmouse.tippen.runtime import TippenRuntime
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["build_router"]
|
||||
|
||||
def build_router(
|
||||
app: App, hub: StateHub, config_path: Path, ha_client: httpx2.AsyncClient
|
||||
) -> APIRouter:
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
def emit(intent: IntentEvent) -> Response:
|
||||
app.bus.emit(intent)
|
||||
return Response(status_code=204)
|
||||
|
||||
# ------------------------------------------------------------------- library
|
||||
|
||||
@router.get("/library")
|
||||
def get_library() -> LibraryOut:
|
||||
lock_state = (
|
||||
compute_lock_state(app.tippen.curriculum, app.library, app.tippen.progress)
|
||||
if app.tippen is not None
|
||||
else None
|
||||
)
|
||||
return LibraryOut(
|
||||
albums=[
|
||||
AlbumOut.of(album, lock_state.get(album.id) if lock_state else None)
|
||||
for album in app.library.albums
|
||||
]
|
||||
)
|
||||
|
||||
@router.get("/albums/{album_id}/cover")
|
||||
def get_cover(album_id: str) -> FileResponse:
|
||||
album = app.library.get(album_id)
|
||||
if album is None or album.cover is None:
|
||||
# Not an error: the client paints the album's own colours instead.
|
||||
raise HTTPException(status_code=404, detail="no cover")
|
||||
return FileResponse(
|
||||
album.cover,
|
||||
# Cover files are content-addressed by album id and rewritten only by a
|
||||
# rescan, so a long cache is safe and saves 20 requests per page load.
|
||||
headers={"Cache-Control": "public, max-age=604800"},
|
||||
)
|
||||
|
||||
@router.get("/tracks/{album_id}/{index}/analysis")
|
||||
def get_track_analysis(album_id: str, index: int) -> TrackDetailOut:
|
||||
grid = app.library.beats(album_id, index)
|
||||
curve = app.library.curve(album_id, index)
|
||||
if grid is None and curve is None:
|
||||
raise HTTPException(status_code=404, detail="not analyzed")
|
||||
return TrackDetailOut(
|
||||
times=list(grid.times) if grid else [],
|
||||
strengths=list(grid.strengths) if grid else [],
|
||||
curve=TrackCurvesOut(**curve.to_json()) if curve else None,
|
||||
)
|
||||
|
||||
@router.post("/library/refresh", status_code=202)
|
||||
async def refresh_library() -> Response:
|
||||
# Returns immediately: a cold rescan reads tags from every file.
|
||||
asyncio.create_task(app.rescan_library(broadcast=hub.broadcast_library)) # noqa: RUF006
|
||||
return Response(status_code=202)
|
||||
|
||||
# --------------------------------------------------------------------- state
|
||||
|
||||
@router.get("/state")
|
||||
def get_state() -> PlayerStateOut:
|
||||
return snapshot(app)
|
||||
|
||||
@router.websocket("/ws")
|
||||
async def websocket(socket: WebSocket) -> None:
|
||||
await hub.connect(socket)
|
||||
try:
|
||||
while True:
|
||||
# Push-only. Reading is how we notice the tab closed.
|
||||
await socket.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
hub.disconnect(socket)
|
||||
|
||||
# ------------------------------------------------------------------ commands
|
||||
|
||||
@router.post("/play", status_code=204)
|
||||
def play_album(body: PlayIn) -> Response:
|
||||
if app.library.get(body.album_id) is None:
|
||||
raise HTTPException(status_code=404, detail="no such album")
|
||||
return emit(
|
||||
PlayAlbumRequested(
|
||||
album_id=body.album_id, track_index=body.track_index, source="web"
|
||||
)
|
||||
)
|
||||
|
||||
@router.post("/resume", status_code=204)
|
||||
def resume() -> Response:
|
||||
return emit(PlayRequested(source="web"))
|
||||
|
||||
@router.post("/pause", status_code=204)
|
||||
def pause() -> Response:
|
||||
return emit(PauseRequested(source="web"))
|
||||
|
||||
@router.post("/next", status_code=204)
|
||||
def next_track() -> Response:
|
||||
return emit(NextTrackRequested(source="web"))
|
||||
|
||||
@router.post("/previous", status_code=204)
|
||||
def previous_track() -> Response:
|
||||
return emit(PreviousTrackRequested(source="web"))
|
||||
|
||||
@router.post("/seek", status_code=204)
|
||||
def seek(body: SeekIn) -> Response:
|
||||
return emit(SeekRequested(position=body.position, source="web"))
|
||||
|
||||
@router.post("/volume", status_code=204)
|
||||
def set_volume(body: VolumeIn) -> Response:
|
||||
general = app.config.general
|
||||
if body.percent is not None:
|
||||
target = body.percent
|
||||
elif body.delta_percent is not None:
|
||||
target = to_percent(app.player.volume, general) + body.delta_percent
|
||||
else:
|
||||
raise HTTPException(status_code=422, detail="percent or delta_percent required")
|
||||
return emit(
|
||||
SetVolumeRequested(
|
||||
volume=to_device_volume(max(0, min(100, target)), general), source="web"
|
||||
)
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------- parent mode
|
||||
|
||||
@router.get("/settings")
|
||||
def get_settings() -> SettingsOut:
|
||||
return read_settings(app.config.general)
|
||||
|
||||
@router.put("/settings")
|
||||
async def put_settings(body: SettingsIn) -> SettingsOut:
|
||||
if body.min_volume > body.max_volume:
|
||||
raise HTTPException(
|
||||
status_code=422, detail="min_volume must not exceed max_volume"
|
||||
)
|
||||
if not body.min_volume <= body.initial_volume <= body.max_volume:
|
||||
raise HTTPException(
|
||||
status_code=422, detail="initial_volume must lie between min and max"
|
||||
)
|
||||
|
||||
general = app.config.general
|
||||
# Applied live as well as saved: a parent lowering the ceiling expects the next
|
||||
# song to be quieter, not the next boot.
|
||||
for key, value in body.model_dump().items():
|
||||
setattr(general, key, value)
|
||||
app.player.set_volume_limits(general.min_volume, general.max_volume)
|
||||
await asyncio.to_thread(write_settings, config_path, body)
|
||||
await hub.broadcast_state()
|
||||
return read_settings(general)
|
||||
|
||||
# ----------------------------------------------------------------- tippen
|
||||
|
||||
def _require_tippen() -> TippenRuntime:
|
||||
if app.tippen is None:
|
||||
raise HTTPException(status_code=404, detail="tippen not configured")
|
||||
return app.tippen
|
||||
|
||||
@router.get("/tippen/curriculum")
|
||||
def get_tippen_curriculum() -> TippenCurriculumOut:
|
||||
runtime = _require_tippen()
|
||||
return curriculum_out(runtime.curriculum, app.library)
|
||||
|
||||
@router.get("/tippen/progress")
|
||||
def get_tippen_progress() -> TippenProgressOut:
|
||||
return progress_out(_require_tippen().progress)
|
||||
|
||||
@router.put("/tippen/settings")
|
||||
async def put_tippen_settings(body: TippenSettingsIn) -> TippenSettingsOut:
|
||||
runtime = _require_tippen()
|
||||
runtime.progress = runtime.progress.model_copy(
|
||||
update={"settings": runtime.progress.settings.model_copy(update=body.model_dump())}
|
||||
)
|
||||
await asyncio.to_thread(runtime.save)
|
||||
return TippenSettingsOut(
|
||||
sound=runtime.progress.settings.sound,
|
||||
keyboard_hint=runtime.progress.settings.keyboard_hint,
|
||||
)
|
||||
|
||||
@router.post("/tippen/runs")
|
||||
async def post_tippen_run(body: TippenRunIn) -> TippenRunOut:
|
||||
runtime = _require_tippen()
|
||||
if runtime.curriculum.lessons and not any(
|
||||
lesson.id == body.lesson_id for lesson in runtime.curriculum.lessons
|
||||
):
|
||||
raise HTTPException(status_code=422, detail=f"no such lesson {body.lesson_id!r}")
|
||||
result = await asyncio.to_thread(record_tippen_run, app, body)
|
||||
await hub.broadcast_library()
|
||||
return result
|
||||
|
||||
# -------------------------------------------------------------- IR remote
|
||||
|
||||
@router.get("/lirc")
|
||||
def get_lirc_config() -> LircConfigOut:
|
||||
if app.config.general.lirc is None:
|
||||
raise HTTPException(status_code=404, detail="lirc not configured")
|
||||
return LircConfigOut(connected=app.state.lirc_connected)
|
||||
|
||||
@router.get("/remote/mapping")
|
||||
def get_remote_mapping() -> RemoteMappingOut:
|
||||
return read_mapping(app.config.remote, app.library)
|
||||
|
||||
@router.put("/remote/mapping")
|
||||
async def put_remote_mapping(body: RemoteMappingIn) -> RemoteMappingOut:
|
||||
resolved: dict[Digit, RemoteSlotConfig] = {}
|
||||
for digit, slot in body.slots.items():
|
||||
found = (
|
||||
app.library.get(slot.target)
|
||||
if slot.target_kind == "album"
|
||||
else app.library.latest_episode(slot.target)
|
||||
)
|
||||
if found is None:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"key {digit}: no such {slot.target_kind} {slot.target!r}",
|
||||
)
|
||||
resolved[digit] = RemoteSlotConfig(target_kind=slot.target_kind, target=slot.target)
|
||||
|
||||
app.config.remote = resolved
|
||||
await asyncio.to_thread(write_mapping, config_path, resolved)
|
||||
return read_mapping(app.config.remote, app.library)
|
||||
|
||||
# --------------------------------------------------------------- room control
|
||||
#
|
||||
# The browser never sees the Home Assistant token: it stays server-side, attached
|
||||
# to every proxied request below. The browser only gets to know entity ids and
|
||||
# display names (get_ha_config) and can ask this backend to relay a states read or
|
||||
# a service call - the same shape of access the token itself grants, just without
|
||||
# ever leaving the LAN device. Entity ids are not restricted to the configured
|
||||
# list; that would only stop someone who already has enough access to open this
|
||||
# unauthenticated API from asking Home Assistant about a different entity, which
|
||||
# matches the rest of this API's "trusted LAN device" threat model.
|
||||
|
||||
def _require_ha() -> HaConfig:
|
||||
ha = app.config.general.ha
|
||||
if ha is None:
|
||||
raise HTTPException(status_code=404, detail="ha not configured")
|
||||
return ha
|
||||
|
||||
async def _proxy(method: str, path: str, ha: HaConfig, **kwargs: Any) -> Response:
|
||||
try:
|
||||
upstream = await ha_client.request(
|
||||
method, f"{ha.url}{path}", headers={"Authorization": f"Bearer {ha.token}"}, **kwargs
|
||||
)
|
||||
except httpx2.HTTPError as exc:
|
||||
raise HTTPException(
|
||||
status_code=502, detail=f"Home Assistant unreachable: {exc}"
|
||||
) from exc
|
||||
return Response(
|
||||
content=upstream.content,
|
||||
status_code=upstream.status_code,
|
||||
media_type=upstream.headers.get("content-type", "application/json"),
|
||||
)
|
||||
|
||||
@router.get("/ha")
|
||||
def get_ha_config() -> HaConfigOut:
|
||||
ha = _require_ha()
|
||||
return HaConfigOut(
|
||||
devices=[HaDeviceOut(entity_id=d.entity_id, name=d.name) for d in ha.devices],
|
||||
scenes=[HaDeviceOut(entity_id=d.entity_id, name=d.name) for d in ha.scenes],
|
||||
)
|
||||
|
||||
@router.get("/ha/states/{entity_id}")
|
||||
async def get_ha_state(entity_id: str) -> Response:
|
||||
return await _proxy("GET", f"/api/states/{entity_id}", _require_ha())
|
||||
|
||||
@router.post("/ha/services/{domain}/{service}")
|
||||
async def call_ha_service(domain: str, service: str, body: dict[str, Any]) -> Response:
|
||||
return await _proxy("POST", f"/api/services/{domain}/{service}", _require_ha(), json=body)
|
||||
|
||||
return router
|
||||
104
python-backend/musicmouse/services/web/hub.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""Getting state out to every open browser tab.
|
||||
|
||||
State events only fire on *change*, so a tab that connects halfway through a track
|
||||
would otherwise sit there knowing nothing. The fix is the one
|
||||
:class:`~musicmouse.services.mqtt.service.MqttService` already uses for a reconnecting
|
||||
broker: send a full snapshot on connect, then deltas.
|
||||
|
||||
Position is the exception to "everything goes on the bus". A progress bar wants it
|
||||
twice a second; an event at that rate would flood the queue, the MQTT service and the
|
||||
log to serve one front-end. So the hub reads it straight off the player on its own
|
||||
timer, and only while something is playing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
from musicmouse.app import App
|
||||
from musicmouse.events import Event, StateEvent
|
||||
from musicmouse.services.web.state import snapshot
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["StateHub"]
|
||||
|
||||
#: Twice a second: smooth enough once the client interpolates between frames, cheap
|
||||
#: enough to leave running.
|
||||
POSITION_INTERVAL = 0.5
|
||||
|
||||
|
||||
class StateHub:
|
||||
def __init__(self, app: App) -> None:
|
||||
self.app = app
|
||||
self._clients: set[WebSocket] = set()
|
||||
self._unsubscribe: Any = None
|
||||
|
||||
# ------------------------------------------------------------------- lifecycle
|
||||
|
||||
def start(self) -> None:
|
||||
self._unsubscribe = self.app.bus.subscribe(StateEvent, self._on_state)
|
||||
|
||||
def stop(self) -> None:
|
||||
if self._unsubscribe is not None:
|
||||
self._unsubscribe()
|
||||
self._unsubscribe = None
|
||||
|
||||
async def run(self) -> None:
|
||||
"""Push the playback position while anything is playing."""
|
||||
while True:
|
||||
await asyncio.sleep(POSITION_INTERVAL)
|
||||
if not self._clients or not self.app.player.is_playing:
|
||||
continue
|
||||
await self.broadcast(
|
||||
{
|
||||
"type": "position",
|
||||
"position": self.app.player.position,
|
||||
"duration": self.app.player.duration,
|
||||
}
|
||||
)
|
||||
|
||||
# --------------------------------------------------------------------- clients
|
||||
|
||||
async def connect(self, socket: WebSocket) -> None:
|
||||
await socket.accept()
|
||||
self._clients.add(socket)
|
||||
await self._send(socket, {"type": "state", "state": snapshot(self.app).model_dump()})
|
||||
|
||||
def disconnect(self, socket: WebSocket) -> None:
|
||||
self._clients.discard(socket)
|
||||
|
||||
# ------------------------------------------------------------------ publishing
|
||||
|
||||
async def broadcast(self, message: dict[str, Any]) -> None:
|
||||
if not self._clients:
|
||||
return
|
||||
payload = json.dumps(message)
|
||||
for socket in list(self._clients):
|
||||
try:
|
||||
await socket.send_text(payload)
|
||||
except Exception:
|
||||
# A tab that closed mid-send is normal, not an error worth logging loudly.
|
||||
_log.debug("Dropping a websocket client that went away")
|
||||
self._clients.discard(socket)
|
||||
|
||||
async def broadcast_state(self) -> None:
|
||||
await self.broadcast({"type": "state", "state": snapshot(self.app).model_dump()})
|
||||
|
||||
async def broadcast_library(self) -> None:
|
||||
await self.broadcast({"type": "library"})
|
||||
|
||||
# ------------------------------------------------------------------ internals
|
||||
|
||||
async def _send(self, socket: WebSocket, message: dict[str, Any]) -> None:
|
||||
with contextlib.suppress(Exception):
|
||||
await socket.send_text(json.dumps(message))
|
||||
|
||||
async def _on_state(self, _event: Event) -> None:
|
||||
await self.broadcast_state()
|
||||
48
python-backend/musicmouse/services/web/remote_settings.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""Reading and writing the IR remote's number-key mapping.
|
||||
|
||||
Same job as :mod:`musicmouse.services.web.settings`, for a dict-shaped config section
|
||||
rather than flat scalars: the ``remote:`` top-level key, not something under
|
||||
``general``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from musicmouse.config import Digit, RemoteSlotConfig
|
||||
from musicmouse.library import MusicLibrary
|
||||
from musicmouse.services.web.schemas import RemoteMappingOut, RemoteSlotOut
|
||||
from musicmouse.services.web.settings import atomic_write, load_document
|
||||
|
||||
__all__ = ["read_mapping", "write_mapping"]
|
||||
|
||||
|
||||
def _resolve(slot: RemoteSlotConfig, library: MusicLibrary) -> str | None:
|
||||
if slot.target_kind == "album":
|
||||
album = library.get(slot.target)
|
||||
else:
|
||||
album = library.latest_episode(slot.target)
|
||||
return album.id if album else None
|
||||
|
||||
|
||||
def read_mapping(remote: dict[Digit, RemoteSlotConfig], library: MusicLibrary) -> RemoteMappingOut:
|
||||
slots = [
|
||||
RemoteSlotOut(
|
||||
digit=digit,
|
||||
target_kind=slot.target_kind,
|
||||
target=slot.target,
|
||||
resolved_album_id=_resolve(slot, library),
|
||||
)
|
||||
for digit, slot in sorted(remote.items())
|
||||
]
|
||||
return RemoteMappingOut(slots=slots)
|
||||
|
||||
|
||||
def write_mapping(path: Path, mapping: dict[Digit, RemoteSlotConfig]) -> None:
|
||||
document = load_document(path)
|
||||
remote = {
|
||||
digit: {"target_kind": slot.target_kind, "target": slot.target}
|
||||
for digit, slot in mapping.items()
|
||||
}
|
||||
document["remote"] = remote
|
||||
atomic_write(path, document)
|
||||
418
python-backend/musicmouse/services/web/schemas.py
Normal file
@@ -0,0 +1,418 @@
|
||||
"""What the browser sees.
|
||||
|
||||
Two rules shape these. The whole library ships in one response, because the design's
|
||||
incremental A-Z search has to be instant and ~25 albums of metadata is under 100 kB -
|
||||
so no track carries anything it does not need. And volume is a *percentage* here: the
|
||||
configured ceiling is a parent's business, not a child's, so it never crosses this line.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from musicmouse.config import Digit
|
||||
from musicmouse.library import Album
|
||||
from musicmouse.library.analysis import TrackAnalysis
|
||||
from musicmouse.library.models import LibraryTrack
|
||||
from musicmouse.tippen.progress import AnimalId
|
||||
from musicmouse.tippen.rewards import AlbumLock, TrackLock, UnlockHint
|
||||
|
||||
__all__ = [
|
||||
"AlbumOut",
|
||||
"HaConfigOut",
|
||||
"HaDeviceOut",
|
||||
"LibraryOut",
|
||||
"LircConfigOut",
|
||||
"PlayIn",
|
||||
"PlayerStateOut",
|
||||
"RemoteMappingIn",
|
||||
"RemoteMappingOut",
|
||||
"RemoteSlotIn",
|
||||
"RemoteSlotOut",
|
||||
"SeekIn",
|
||||
"SettingsIn",
|
||||
"SettingsOut",
|
||||
"TippenCurriculumOut",
|
||||
"TippenGhostStrokeOut",
|
||||
"TippenKeyStatOut",
|
||||
"TippenLessonOut",
|
||||
"TippenLessonProgressOut",
|
||||
"TippenProgressOut",
|
||||
"TippenRewardOut",
|
||||
"TippenRunIn",
|
||||
"TippenRunOut",
|
||||
"TippenSettingsIn",
|
||||
"TippenSettingsOut",
|
||||
"TippenStreakOut",
|
||||
"TippenStrokeIn",
|
||||
"TippenUnlockedRewardOut",
|
||||
"TippenWorldOut",
|
||||
"TrackCurvesOut",
|
||||
"TrackDetailOut",
|
||||
"TrackOut",
|
||||
"UnlockHintOut",
|
||||
"VolumeIn",
|
||||
]
|
||||
|
||||
|
||||
class AnalysisOut(BaseModel):
|
||||
tempo: float | None = None
|
||||
energy: float | None = None
|
||||
valence: float | None = None
|
||||
brightness: float | None = None
|
||||
#: 0..1 confidence that `tempo` is an audible, steady beat - see `TrackAnalysis`.
|
||||
pulse: float | None = None
|
||||
beats: bool = False
|
||||
|
||||
@classmethod
|
||||
def of(cls, analysis: TrackAnalysis | None) -> AnalysisOut | None:
|
||||
if analysis is None or not analysis.is_analyzed:
|
||||
return None
|
||||
return cls(
|
||||
tempo=analysis.tempo,
|
||||
energy=analysis.energy,
|
||||
valence=analysis.valence,
|
||||
brightness=analysis.brightness,
|
||||
pulse=analysis.pulse,
|
||||
beats=analysis.beats,
|
||||
)
|
||||
|
||||
|
||||
class UnlockHintOut(BaseModel):
|
||||
"""Which typing lesson unlocks a still-locked track, for "mention when this
|
||||
unlocks" in the browse view."""
|
||||
|
||||
lesson_id: str
|
||||
lesson_title: str
|
||||
world_number: int
|
||||
world_title: str
|
||||
|
||||
@classmethod
|
||||
def of(cls, hint: UnlockHint) -> UnlockHintOut:
|
||||
return cls(
|
||||
lesson_id=hint.lesson_id,
|
||||
lesson_title=hint.lesson_title,
|
||||
world_number=hint.world_number,
|
||||
world_title=hint.world_title,
|
||||
)
|
||||
|
||||
|
||||
class TrackOut(BaseModel):
|
||||
title: str
|
||||
duration: float
|
||||
#: Scalars only. The beat grid is fetched per track from ``/api/tracks/...``.
|
||||
analysis: AnalysisOut | None = None
|
||||
#: A reward-gated track not yet earned - the browse view shows a placeholder
|
||||
#: instead of the real title.
|
||||
locked: bool = False
|
||||
unlock_hint: UnlockHintOut | None = None
|
||||
|
||||
@classmethod
|
||||
def of(cls, track: LibraryTrack, lock: TrackLock | None = None) -> TrackOut:
|
||||
return cls(
|
||||
title=track.title,
|
||||
duration=track.duration,
|
||||
analysis=AnalysisOut.of(track.analysis),
|
||||
locked=lock.locked if lock is not None else False,
|
||||
unlock_hint=UnlockHintOut.of(lock.hint) if lock is not None and lock.hint else None,
|
||||
)
|
||||
|
||||
|
||||
class AlbumOut(BaseModel):
|
||||
id: str
|
||||
section: str
|
||||
kind: str
|
||||
title: str
|
||||
artist: str
|
||||
series: str | None
|
||||
figure: str | None
|
||||
category: str
|
||||
colors: list[str]
|
||||
has_cover: bool
|
||||
duration: float
|
||||
tracks: list[TrackOut]
|
||||
#: Every track is still locked - the browse view shows a question mark instead of
|
||||
#: cover art. `False` for an album no typing reward ever targets.
|
||||
locked: bool = False
|
||||
|
||||
@classmethod
|
||||
def of(cls, album: Album, lock: AlbumLock | None = None) -> AlbumOut:
|
||||
track_locks = lock.tracks if lock is not None else ()
|
||||
return cls(
|
||||
id=album.id,
|
||||
section=album.section,
|
||||
kind=album.kind,
|
||||
title=album.title,
|
||||
artist=album.artist,
|
||||
series=album.series,
|
||||
figure=album.figure,
|
||||
category=album.category,
|
||||
colors=list(album.colors),
|
||||
has_cover=album.cover is not None,
|
||||
duration=album.duration,
|
||||
locked=lock.locked if lock is not None else False,
|
||||
tracks=[
|
||||
TrackOut.of(track, track_locks[i] if i < len(track_locks) else None)
|
||||
for i, track in enumerate(album.tracks)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class LibraryOut(BaseModel):
|
||||
albums: list[AlbumOut]
|
||||
|
||||
|
||||
class TrackCurvesOut(BaseModel):
|
||||
hop_seconds: float
|
||||
energy: list[float]
|
||||
valence: list[float]
|
||||
drive: list[float]
|
||||
|
||||
|
||||
class TrackDetailOut(BaseModel):
|
||||
"""Beats + mood/drive curves for the one track currently playing - fetched
|
||||
together since both are per-track detail the library payload never carries.
|
||||
``curve`` is ``None`` only when the whole track failed analysis; ``times``/
|
||||
``strengths`` are empty (not ``None``) for a track with no reliable beat, since a
|
||||
free-tempo or spoken-word track still has real energy/valence/drive curves."""
|
||||
|
||||
times: list[float]
|
||||
strengths: list[float]
|
||||
curve: TrackCurvesOut | None = None
|
||||
|
||||
|
||||
class ConnectionOut(BaseModel):
|
||||
firmware: bool
|
||||
mqtt: bool
|
||||
lirc: bool
|
||||
|
||||
|
||||
class PlayerStateOut(BaseModel):
|
||||
playing: bool
|
||||
album_id: str | None
|
||||
album_title: str | None
|
||||
artist: str | None
|
||||
kind: str | None
|
||||
track_index: int
|
||||
track_title: str | None
|
||||
track_count: int
|
||||
position: float
|
||||
duration: float
|
||||
#: 0..100. The device's own range is deliberately not exposed.
|
||||
volume: int
|
||||
active_figure: str | None
|
||||
connected: ConnectionOut
|
||||
|
||||
|
||||
class PlayIn(BaseModel):
|
||||
album_id: str
|
||||
track_index: int = Field(default=0, ge=0)
|
||||
|
||||
|
||||
class SeekIn(BaseModel):
|
||||
position: float = Field(ge=0)
|
||||
|
||||
|
||||
class VolumeIn(BaseModel):
|
||||
"""Either an absolute percentage or a relative step; exactly one of the two."""
|
||||
|
||||
percent: int | None = Field(default=None, ge=0, le=100)
|
||||
delta_percent: int | None = Field(default=None, ge=-100, le=100)
|
||||
|
||||
|
||||
class SettingsOut(BaseModel):
|
||||
min_volume: int
|
||||
max_volume: int
|
||||
initial_volume: int
|
||||
volume_increment: int
|
||||
button_leds_brightness: float
|
||||
|
||||
|
||||
class SettingsIn(BaseModel):
|
||||
min_volume: int = Field(ge=0, le=200)
|
||||
max_volume: int = Field(ge=0, le=200)
|
||||
initial_volume: int = Field(ge=0, le=200)
|
||||
volume_increment: int = Field(ge=1, le=100)
|
||||
button_leds_brightness: float = Field(ge=0, le=1)
|
||||
|
||||
|
||||
class HaDeviceOut(BaseModel):
|
||||
entity_id: str
|
||||
name: str | None
|
||||
|
||||
|
||||
class HaConfigOut(BaseModel):
|
||||
"""No ``url``/``token`` here on purpose - the browser talks to this backend, which
|
||||
proxies to Home Assistant with the token attached server-side. See
|
||||
``services/web/api.py``'s room-control section."""
|
||||
|
||||
devices: list[HaDeviceOut]
|
||||
scenes: list[HaDeviceOut]
|
||||
|
||||
|
||||
class LircConfigOut(BaseModel):
|
||||
"""Presence-only, like ``HaConfigOut``: there is nothing secret in a host/port,
|
||||
but the frontend only needs to know whether the remote is set up and connected."""
|
||||
|
||||
connected: bool
|
||||
|
||||
|
||||
class RemoteSlotOut(BaseModel):
|
||||
digit: str
|
||||
target_kind: Literal["album", "series"]
|
||||
target: str
|
||||
#: The album this slot resolves to *right now* - the fixed album for "album" slots,
|
||||
#: or today's newest episode for "series" slots. `None` when the target no longer
|
||||
#: resolves (a moved/deleted album, an unknown show), so the frontend can show a
|
||||
#: broken-assignment state instead of silently dropping it.
|
||||
resolved_album_id: str | None
|
||||
|
||||
|
||||
class RemoteMappingOut(BaseModel):
|
||||
slots: list[RemoteSlotOut]
|
||||
|
||||
|
||||
class RemoteSlotIn(BaseModel):
|
||||
target_kind: Literal["album", "series"]
|
||||
target: str
|
||||
|
||||
|
||||
class RemoteMappingIn(BaseModel):
|
||||
"""Full replacement, like ``SettingsIn``: a digit absent here becomes unassigned."""
|
||||
|
||||
slots: dict[Digit, RemoteSlotIn]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- tippen
|
||||
|
||||
|
||||
class TippenRewardOut(BaseModel):
|
||||
"""What a lesson's ``unlocks:`` resolves to right now - `resolved` is `False` when
|
||||
the configured path matches nothing in the current library (a moved or mistyped
|
||||
path), so the frontend can show a broken-reward state instead of silently
|
||||
dropping it."""
|
||||
|
||||
resolved: bool
|
||||
album_id: str | None = None
|
||||
has_cover: bool = False
|
||||
kind: Literal["tracks", "episode"] | None = None
|
||||
|
||||
|
||||
class TippenLessonOut(BaseModel):
|
||||
id: str
|
||||
world: int
|
||||
number: int
|
||||
title: str
|
||||
subtitle: str
|
||||
kind: str
|
||||
new_keys: list[str]
|
||||
spotlight_keys: list[str]
|
||||
emphasis: Literal["isolated", "mixed"] | None
|
||||
active_keys: list[str]
|
||||
primary_mode: str
|
||||
bonus_modes: list[str]
|
||||
words: list[str]
|
||||
is_drill: bool
|
||||
chunks: int
|
||||
chunk_size: int
|
||||
reward: TippenRewardOut
|
||||
|
||||
|
||||
class TippenWorldOut(BaseModel):
|
||||
number: int
|
||||
title: str
|
||||
emoji: str
|
||||
reward: str
|
||||
|
||||
|
||||
class TippenCurriculumOut(BaseModel):
|
||||
worlds: list[TippenWorldOut]
|
||||
lessons: list[TippenLessonOut]
|
||||
|
||||
|
||||
class TippenGhostStrokeOut(BaseModel):
|
||||
key: str
|
||||
at: float
|
||||
|
||||
|
||||
class TippenLessonProgressOut(BaseModel):
|
||||
unlocked: bool
|
||||
runs: int
|
||||
best_stars: int
|
||||
best_animal: AnimalId | None
|
||||
best_points: float
|
||||
#: Derived, not stored - see ``LessonProgress.earned``.
|
||||
earned: bool
|
||||
ghost: list[TippenGhostStrokeOut] | None
|
||||
|
||||
|
||||
class TippenKeyStatOut(BaseModel):
|
||||
ema: float
|
||||
attempts: int
|
||||
errors: int
|
||||
|
||||
|
||||
class TippenStreakOut(BaseModel):
|
||||
days: int
|
||||
last_played: str | None
|
||||
|
||||
|
||||
class TippenSettingsOut(BaseModel):
|
||||
sound: bool
|
||||
keyboard_hint: Literal["auto", "on", "off"]
|
||||
|
||||
|
||||
class TippenSettingsIn(BaseModel):
|
||||
sound: bool
|
||||
keyboard_hint: Literal["auto", "on", "off"]
|
||||
|
||||
|
||||
class TippenProgressOut(BaseModel):
|
||||
lessons: dict[str, TippenLessonProgressOut]
|
||||
key_stats: dict[str, TippenKeyStatOut]
|
||||
pearls: int
|
||||
aquarium: list[str]
|
||||
streak: TippenStreakOut
|
||||
settings: TippenSettingsOut
|
||||
|
||||
|
||||
class TippenStrokeIn(BaseModel):
|
||||
key: str
|
||||
expected: str
|
||||
correct: bool
|
||||
#: ms timestamp, from the run's own clock.
|
||||
at: float
|
||||
|
||||
|
||||
class TippenRunIn(BaseModel):
|
||||
"""A run the client already graded - see ``grading.ts``. Grading itself stays
|
||||
client-side; this only tells the backend what to do with progress."""
|
||||
|
||||
lesson_id: str
|
||||
stars: Literal[0, 1, 2, 3]
|
||||
animal: AnimalId
|
||||
points: float
|
||||
passed: bool
|
||||
pearls: int = Field(ge=0)
|
||||
strokes: list[TippenStrokeIn] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TippenUnlockedRewardOut(BaseModel):
|
||||
"""The literal track/episode this run's lesson names in its own ``unlocks:`` -
|
||||
what the unlock animation shows, via the existing ``/api/albums/{id}/cover``."""
|
||||
|
||||
album_id: str
|
||||
title: str
|
||||
has_cover: bool
|
||||
kind: Literal["album", "book", "podcast_episode"]
|
||||
|
||||
|
||||
class TippenRunOut(BaseModel):
|
||||
progress: TippenProgressOut
|
||||
unlocked_lesson_id: str | None
|
||||
unlocked_lesson_title: str | None
|
||||
new_creature: str | None
|
||||
is_new_best: bool
|
||||
unlocked_reward: TippenUnlockedRewardOut | None
|
||||
102
python-backend/musicmouse/services/web/service.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""The web front-end, as a :class:`~musicmouse.services.base.Service`.
|
||||
|
||||
Everything runs on the one event loop the rest of the app already has: uvicorn's
|
||||
``Server.serve()`` is a coroutine, so there is no second loop and no thread. The
|
||||
service owns no state of its own - it turns HTTP into intents on the bus, and bus
|
||||
events into websocket frames.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import httpx2
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from musicmouse.app import App
|
||||
from musicmouse.config import WebConfig
|
||||
from musicmouse.services.web.api import build_router
|
||||
from musicmouse.services.web.hub import StateHub
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["WebService", "build_app"]
|
||||
|
||||
|
||||
def build_app(
|
||||
app: App,
|
||||
config: WebConfig,
|
||||
config_path: Path,
|
||||
ha_client: httpx2.AsyncClient | None = None,
|
||||
) -> tuple[FastAPI, StateHub]:
|
||||
"""Assemble the ASGI app. Separate from the service so tests can drive it directly.
|
||||
|
||||
``ha_client`` is the outbound client the room-control routes proxy Home Assistant
|
||||
calls through; tests inject one on a mock transport, production gets a real one
|
||||
that :class:`WebService` closes on shutdown.
|
||||
"""
|
||||
hub = StateHub(app)
|
||||
ha_client = ha_client or httpx2.AsyncClient(timeout=10.0)
|
||||
api = FastAPI(title="MusicMouse", docs_url="/api/docs", openapi_url="/api/openapi.json")
|
||||
api.state.ha_client = ha_client
|
||||
api.include_router(build_router(app, hub, config_path, ha_client))
|
||||
|
||||
if config.static_dir is not None:
|
||||
if config.static_dir.is_dir():
|
||||
# Mounted last and at the root so every /api route still wins; html=True
|
||||
# falls back to index.html, which is what a client-side router needs.
|
||||
api.mount("/", StaticFiles(directory=config.static_dir, html=True), name="web")
|
||||
else:
|
||||
_log.warning(
|
||||
"web.static_dir %s does not exist; serving the API only "
|
||||
"(run `npm run build` in web/, or drop the setting)",
|
||||
config.static_dir,
|
||||
)
|
||||
return api, hub
|
||||
|
||||
|
||||
class WebService:
|
||||
name = "web"
|
||||
|
||||
def __init__(self, app: App, config: WebConfig, config_path: Path) -> None:
|
||||
self.config = config
|
||||
self.api, self.hub = build_app(app, config, config_path)
|
||||
|
||||
async def run(self) -> None:
|
||||
self.hub.start()
|
||||
server = uvicorn.Server(
|
||||
uvicorn.Config(
|
||||
self.api,
|
||||
host=self.config.host,
|
||||
port=self.config.port,
|
||||
# Access logs for a progress-bar poll every 500ms are noise.
|
||||
access_log=False,
|
||||
log_level="warning",
|
||||
)
|
||||
)
|
||||
_log.info("Web front-end on http://%s:%d", self.config.host, self.config.port)
|
||||
|
||||
# serve() borrows SIGINT for a graceful shutdown and then re-raises it, so
|
||||
# Ctrl-C still reaches __main__'s KeyboardInterrupt handler afterwards.
|
||||
position = asyncio.create_task(self.hub.run(), name="web-position")
|
||||
serving = asyncio.create_task(server.serve(), name="web-serve")
|
||||
try:
|
||||
await asyncio.shield(serving)
|
||||
except asyncio.CancelledError:
|
||||
# Shutdown reaches us as a cancellation. Cancelling uvicorn mid-accept
|
||||
# would leave its listening socket to the garbage collector, so ask it to
|
||||
# wind down and wait for it to let the port go.
|
||||
server.should_exit = True
|
||||
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||||
await asyncio.wait_for(serving, timeout=5.0)
|
||||
raise
|
||||
finally:
|
||||
position.cancel()
|
||||
serving.cancel()
|
||||
self.hub.stop()
|
||||
await self.api.state.ha_client.aclose()
|
||||
96
python-backend/musicmouse/services/web/settings.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""Volume as a percentage, and the handful of settings parent mode may change.
|
||||
|
||||
Two jobs that both come down to "keep the config file's numbers out of the child's UI".
|
||||
|
||||
The volume mapping lives here rather than in the frontend so that MQTT, the rotary
|
||||
encoder and the firmware carry on speaking device units, unaware that a browser is
|
||||
using a different scale.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
from musicmouse.config import GeneralConfig
|
||||
from musicmouse.services.web.schemas import SettingsIn, SettingsOut
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"atomic_write",
|
||||
"load_document",
|
||||
"read_settings",
|
||||
"to_device_volume",
|
||||
"to_percent",
|
||||
"write_settings",
|
||||
]
|
||||
|
||||
|
||||
def to_device_volume(percent: int, general: GeneralConfig) -> int:
|
||||
"""Map 0..100 onto the configured range, so 100 % is exactly ``max_volume``."""
|
||||
span = general.max_volume - general.min_volume
|
||||
if span <= 0:
|
||||
return general.min_volume
|
||||
return general.min_volume + round(span * max(0, min(100, percent)) / 100)
|
||||
|
||||
|
||||
def to_percent(volume: int, general: GeneralConfig) -> int:
|
||||
"""The inverse, for the state snapshot."""
|
||||
span = general.max_volume - general.min_volume
|
||||
if span <= 0:
|
||||
return 100
|
||||
return max(0, min(100, round((volume - general.min_volume) / span * 100)))
|
||||
|
||||
|
||||
def read_settings(general: GeneralConfig) -> SettingsOut:
|
||||
return SettingsOut(
|
||||
min_volume=general.min_volume,
|
||||
max_volume=general.max_volume,
|
||||
initial_volume=general.initial_volume,
|
||||
volume_increment=general.volume_increment,
|
||||
button_leds_brightness=general.button_leds_brightness,
|
||||
)
|
||||
|
||||
|
||||
def _yaml() -> YAML:
|
||||
yaml = YAML(typ="rt")
|
||||
yaml.preserve_quotes = True
|
||||
return yaml
|
||||
|
||||
|
||||
def load_document(path: Path) -> Any:
|
||||
"""The config file, round-trip parsed so it keeps its comments and formatting.
|
||||
|
||||
Shared by every writer that patches ``config.yml`` in place - a config that
|
||||
explains itself is worth more than one a save could rewrite from scratch.
|
||||
"""
|
||||
with path.open(encoding="utf-8") as handle:
|
||||
return _yaml().load(handle)
|
||||
|
||||
|
||||
def atomic_write(path: Path, document: Any) -> None:
|
||||
"""Write a round-trip-loaded document back, through a sibling temp file so an
|
||||
interrupted save cannot truncate the real one."""
|
||||
temp = path.with_name(f"{path.name}.tmp{os.getpid()}")
|
||||
try:
|
||||
with temp.open("w", encoding="utf-8") as handle:
|
||||
_yaml().dump(document, handle)
|
||||
temp.replace(path)
|
||||
except BaseException:
|
||||
temp.unlink(missing_ok=True)
|
||||
raise
|
||||
_log.info("Wrote settings to %s", path)
|
||||
|
||||
|
||||
def write_settings(path: Path, settings: SettingsIn) -> None:
|
||||
"""Patch the settings into ``config.yml`` in place."""
|
||||
document = load_document(path)
|
||||
general = document["general"]
|
||||
for key, value in settings.model_dump().items():
|
||||
general[key] = value
|
||||
atomic_write(path, document)
|
||||
48
python-backend/musicmouse/services/web/state.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""One place that answers "what is the mouse doing right now?".
|
||||
|
||||
Assembled on demand rather than accumulated from events. The join between what is
|
||||
playing and which figure is on the reader is the same one the MQTT player entity makes:
|
||||
read ``mouse.active_figure``, do not keep a second copy of it that can drift.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from musicmouse.app import App
|
||||
from musicmouse.services.web.schemas import ConnectionOut, PlayerStateOut
|
||||
from musicmouse.services.web.settings import to_percent
|
||||
|
||||
__all__ = ["snapshot"]
|
||||
|
||||
|
||||
def snapshot(app: App) -> PlayerStateOut:
|
||||
player = app.player
|
||||
playlist = player.playlist
|
||||
album = app.album_for(playlist)
|
||||
index = player.track_index
|
||||
# The player only knows a file path, and a tagged file's name is rarely its title
|
||||
# ("01 - So ein schoener Tag.mp3"). The library read the real one at scan time.
|
||||
indexed = (
|
||||
album.tracks[index] if album and 0 <= index < len(album.tracks) else None
|
||||
)
|
||||
track = player.current_track
|
||||
|
||||
return PlayerStateOut(
|
||||
playing=player.is_playing,
|
||||
album_id=album.id if album else None,
|
||||
album_title=album.title if album else (playlist.name if playlist else None),
|
||||
artist=album.artist if album else None,
|
||||
kind=album.kind if album else None,
|
||||
track_index=index,
|
||||
track_title=indexed.title if indexed else (track.title if track else None),
|
||||
track_count=len(playlist) if playlist else 0,
|
||||
position=player.position,
|
||||
# Likewise the duration: the player only has one once libVLC has opened the file.
|
||||
duration=(indexed.duration if indexed else 0.0) or player.duration,
|
||||
volume=to_percent(player.volume, app.config.general),
|
||||
active_figure=app.mouse.active_figure,
|
||||
connected=ConnectionOut(
|
||||
firmware=app.mouse.connected,
|
||||
mqtt=app.state.mqtt_connected,
|
||||
lirc=app.state.lirc_connected,
|
||||
),
|
||||
)
|
||||
187
python-backend/musicmouse/services/web/tippen_api.py
Normal file
@@ -0,0 +1,187 @@
|
||||
"""Converting between the typing game's domain objects and what the browser sees.
|
||||
|
||||
Same job as ``remote_settings.py`` for the reward mapping: resolving a configured
|
||||
target against the live library. Unlike ``remote_settings.py``, there is no write-back
|
||||
half here - the curriculum is a parent-edited file, never PUT by the app.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from musicmouse.app import App
|
||||
from musicmouse.library import MusicLibrary
|
||||
from musicmouse.services.web.schemas import (
|
||||
TippenCurriculumOut,
|
||||
TippenGhostStrokeOut,
|
||||
TippenKeyStatOut,
|
||||
TippenLessonOut,
|
||||
TippenLessonProgressOut,
|
||||
TippenProgressOut,
|
||||
TippenRewardOut,
|
||||
TippenRunIn,
|
||||
TippenRunOut,
|
||||
TippenSettingsOut,
|
||||
TippenStreakOut,
|
||||
TippenUnlockedRewardOut,
|
||||
TippenWorldOut,
|
||||
)
|
||||
from musicmouse.tippen.curriculum import Curriculum, Lesson, lesson_by_id
|
||||
from musicmouse.tippen.progress import RunResult, Stroke, TypingProgress, record_run
|
||||
from musicmouse.tippen.rewards import resolve_lesson_reward
|
||||
from musicmouse.tippen.runtime import TippenRuntime
|
||||
|
||||
__all__ = ["curriculum_out", "progress_out", "record_tippen_run"]
|
||||
|
||||
|
||||
def _reward_out(lesson: Lesson, library: MusicLibrary) -> TippenRewardOut:
|
||||
reward = resolve_lesson_reward(lesson, library)
|
||||
if reward is None:
|
||||
return TippenRewardOut(resolved=False)
|
||||
album = library.get(reward.album_id)
|
||||
return TippenRewardOut(
|
||||
resolved=True,
|
||||
album_id=reward.album_id,
|
||||
has_cover=album.cover is not None if album else False,
|
||||
kind=reward.kind,
|
||||
)
|
||||
|
||||
|
||||
def curriculum_out(curriculum: Curriculum, library: MusicLibrary) -> TippenCurriculumOut:
|
||||
return TippenCurriculumOut(
|
||||
worlds=[
|
||||
TippenWorldOut(
|
||||
number=world.number, title=world.title, emoji=world.emoji, reward=world.reward
|
||||
)
|
||||
for world in curriculum.worlds
|
||||
],
|
||||
lessons=[
|
||||
TippenLessonOut(
|
||||
id=lesson.id,
|
||||
world=lesson.world,
|
||||
number=lesson.number,
|
||||
title=lesson.title,
|
||||
subtitle=lesson.subtitle,
|
||||
kind=lesson.kind,
|
||||
new_keys=list(lesson.new_keys),
|
||||
spotlight_keys=list(lesson.spotlight_keys),
|
||||
emphasis=lesson.emphasis,
|
||||
active_keys=list(lesson.active_keys),
|
||||
primary_mode=lesson.primary_mode,
|
||||
bonus_modes=list(lesson.bonus_modes),
|
||||
words=list(lesson.words),
|
||||
is_drill=lesson.is_drill,
|
||||
chunks=lesson.chunks,
|
||||
chunk_size=lesson.chunk_size,
|
||||
reward=_reward_out(lesson, library),
|
||||
)
|
||||
for lesson in curriculum.lessons
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def progress_out(progress: TypingProgress) -> TippenProgressOut:
|
||||
return TippenProgressOut(
|
||||
lessons={
|
||||
lesson_id: TippenLessonProgressOut(
|
||||
unlocked=entry.unlocked,
|
||||
runs=entry.runs,
|
||||
best_stars=entry.best_stars,
|
||||
best_animal=entry.best_animal,
|
||||
best_points=entry.best_points,
|
||||
earned=entry.earned,
|
||||
ghost=(
|
||||
[TippenGhostStrokeOut(key=g.key, at=g.at) for g in entry.ghost]
|
||||
if entry.ghost
|
||||
else None
|
||||
),
|
||||
)
|
||||
for lesson_id, entry in progress.lessons.items()
|
||||
},
|
||||
key_stats={
|
||||
key: TippenKeyStatOut(ema=stat.ema, attempts=stat.attempts, errors=stat.errors)
|
||||
for key, stat in progress.key_stats.items()
|
||||
},
|
||||
pearls=progress.pearls,
|
||||
aquarium=list(progress.aquarium),
|
||||
streak=TippenStreakOut(days=progress.streak.days, last_played=progress.streak.last_played),
|
||||
settings=TippenSettingsOut(
|
||||
sound=progress.settings.sound, keyboard_hint=progress.settings.keyboard_hint
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _animation_kind(
|
||||
library: MusicLibrary, album_id: str, reward_kind: Literal["tracks", "episode"]
|
||||
) -> Literal["album", "book", "podcast_episode"]:
|
||||
if reward_kind == "episode":
|
||||
return "podcast_episode"
|
||||
album = library.get(album_id)
|
||||
return "book" if album is not None and album.kind == "book" else "album"
|
||||
|
||||
|
||||
def _unlocked_reward_out(
|
||||
runtime: TippenRuntime, library: MusicLibrary, lesson_id: str
|
||||
) -> TippenUnlockedRewardOut | None:
|
||||
"""The literal target of `lesson_id`'s own ``unlocks:`` - what the unlock
|
||||
animation shows. Not "one of several" tracks: exactly the one the lesson names."""
|
||||
lesson = lesson_by_id(runtime.curriculum, lesson_id)
|
||||
if lesson is None:
|
||||
return None
|
||||
reward = resolve_lesson_reward(lesson, library)
|
||||
if reward is None:
|
||||
return None
|
||||
album = library.get(reward.album_id)
|
||||
if album is None:
|
||||
return None
|
||||
if reward.kind == "episode":
|
||||
title = album.title
|
||||
elif reward.until_index < len(album.tracks):
|
||||
title = album.tracks[reward.until_index].title
|
||||
else:
|
||||
title = album.title
|
||||
return TippenUnlockedRewardOut(
|
||||
album_id=reward.album_id,
|
||||
title=title,
|
||||
has_cover=album.cover is not None,
|
||||
kind=_animation_kind(library, reward.album_id, reward.kind),
|
||||
)
|
||||
|
||||
|
||||
def record_tippen_run(app: App, body: TippenRunIn) -> TippenRunOut:
|
||||
"""Grade-agnostic: the client already graded the run (see ``TippenRunIn``); this
|
||||
only owns what happens to progress, and whether it just revealed a reward."""
|
||||
assert app.tippen is not None
|
||||
runtime = app.tippen
|
||||
|
||||
result = RunResult(
|
||||
stars=body.stars,
|
||||
animal=body.animal,
|
||||
points=body.points,
|
||||
passed=body.passed,
|
||||
pearls=body.pearls,
|
||||
strokes=tuple(
|
||||
Stroke(key=s.key, expected=s.expected, correct=s.correct, at=s.at) for s in body.strokes
|
||||
),
|
||||
)
|
||||
outcome = record_run(runtime.progress, body.lesson_id, result, runtime.curriculum)
|
||||
runtime.progress = outcome.progress
|
||||
runtime.save()
|
||||
|
||||
unlocked_reward = (
|
||||
_unlocked_reward_out(runtime, app.library, body.lesson_id) if outcome.newly_earned else None
|
||||
)
|
||||
unlocked_lesson = (
|
||||
lesson_by_id(runtime.curriculum, outcome.unlocked_lesson_id)
|
||||
if outcome.unlocked_lesson_id
|
||||
else None
|
||||
)
|
||||
|
||||
return TippenRunOut(
|
||||
progress=progress_out(runtime.progress),
|
||||
unlocked_lesson_id=outcome.unlocked_lesson_id,
|
||||
unlocked_lesson_title=unlocked_lesson.title if unlocked_lesson else None,
|
||||
new_creature=outcome.new_creature,
|
||||
is_new_best=outcome.is_new_best,
|
||||
unlocked_reward=unlocked_reward,
|
||||
)
|
||||
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"]
|
||||
344
python-backend/musicmouse/simulator/driver.py
Normal file
@@ -0,0 +1,344 @@
|
||||
"""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,
|
||||
PlayAlbumRequested,
|
||||
PlayRequested,
|
||||
PreviousTrackRequested,
|
||||
RfidTokenRead,
|
||||
RotaryTurned,
|
||||
SeekRequested,
|
||||
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(PreviousTrackRequested(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()
|
||||
|
||||
async def play_album(self, title: str, track_index: int = 0) -> None:
|
||||
"""Start a library album by title - what the web front-end does."""
|
||||
album = next(
|
||||
(a for a in self.app.library.albums if a.title.lower() == title.lower()), None
|
||||
)
|
||||
if album is None:
|
||||
known = ", ".join(sorted(a.title for a in self.app.library.albums))
|
||||
raise ScriptError(f"unknown album {title!r} (known: {known})")
|
||||
self.app.bus.emit(
|
||||
PlayAlbumRequested(
|
||||
album_id=album.id, track_index=track_index, source="simulator"
|
||||
)
|
||||
)
|
||||
await self.settle()
|
||||
|
||||
async def seek(self, position: float) -> None:
|
||||
self.app.bus.emit(SeekRequested(position=position, 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 "album":
|
||||
album = self.app.album_for(player.playlist)
|
||||
return album.title if album else "none"
|
||||
case "position":
|
||||
return f"{player.position:.1f}"
|
||||
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, album, "
|
||||
f"track, title, position, 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 ("album", [*words]) if words:
|
||||
await self.play_album(" ".join(words))
|
||||
case ("seek", [position]):
|
||||
await self.seek(Duration.parse(position).seconds)
|
||||
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)
|
||||
171
python-backend/musicmouse/simulator/fake_player.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""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.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
|
||||
|
||||
# -------------------------------------------------------------------- state
|
||||
|
||||
@property
|
||||
def position(self) -> float:
|
||||
"""Derived from the clock, so it is exact under ``FakeClock`` too."""
|
||||
remaining = self._remaining
|
||||
if self._started_at is not None:
|
||||
remaining = max(0.0, remaining - (self._clock.now() - self._started_at))
|
||||
return max(0.0, self.track_duration - remaining)
|
||||
|
||||
@property
|
||||
def duration(self) -> float:
|
||||
return self.track_duration if self._playlist else 0.0
|
||||
|
||||
# ------------------------------------------------------------------ actions
|
||||
|
||||
def set_playlist(self, playlist: Playlist) -> None:
|
||||
self._cancel_timer()
|
||||
self._load_playlist(playlist)
|
||||
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:
|
||||
self.play_track(0)
|
||||
|
||||
def play_track(self, index: int) -> 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(max(0, min(index, len(self._playlist) - 1)))
|
||||
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 seek(self, position: float) -> None:
|
||||
if self._playlist is None:
|
||||
return
|
||||
self._remaining = max(0.0, self.track_duration - max(0.0, position))
|
||||
if self._playing:
|
||||
self._start_timer()
|
||||
|
||||
def close(self) -> None:
|
||||
self._cancel_timer()
|
||||
|
||||
# ---------------------------------------------------------------- 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
@@ -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
|
||||
96
python-backend/musicmouse/simulator/harness.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""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
|
||||
from musicmouse.devices.mouse import MusicMouseDevice
|
||||
from musicmouse.library import MusicLibrary
|
||||
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
|
||||
from musicmouse.tippen.runtime import TippenRuntime
|
||||
|
||||
__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,
|
||||
library: MusicLibrary | None = None,
|
||||
tippen: TippenRuntime | None = None,
|
||||
) -> 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),
|
||||
)
|
||||
|
||||
if library is None:
|
||||
library_config = config.general.library
|
||||
library = await MusicLibrary.build(
|
||||
library_config.root,
|
||||
library_config.cache,
|
||||
frozenset(config.general.audio_extensions),
|
||||
figure_kinds=config.figure_kinds,
|
||||
)
|
||||
|
||||
app = App(
|
||||
config=config,
|
||||
bus=bus,
|
||||
mouse=mouse,
|
||||
player=player,
|
||||
library=library,
|
||||
playlists=library.figure_playlists(),
|
||||
clock=clock,
|
||||
tippen=tippen,
|
||||
)
|
||||
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
@@ -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
@@ -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)
|
||||
34
python-backend/musicmouse/tippen/__init__.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""The typing game: curriculum, progress and reward-unlock resolution.
|
||||
|
||||
See ``curriculum.py`` (the lesson plan, loaded from a YAML file named by
|
||||
``general.tippen.curriculum_file``), ``progress.py`` (a JSON sidecar recording what has
|
||||
been played and passed), ``rewards.py`` (turning a lesson's ``unlocks:`` into which
|
||||
library tracks/episodes are still locked) and ``runtime.py`` (wiring the three
|
||||
together at startup).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from musicmouse.tippen.curriculum import Curriculum, CurriculumError, Lesson, World, load_curriculum
|
||||
from musicmouse.tippen.progress import RecordOutcome, RunResult, Stroke, TypingProgress, record_run
|
||||
from musicmouse.tippen.rewards import AlbumLock, LockState, ResolvedReward, compute_lock_state
|
||||
from musicmouse.tippen.runtime import TippenRuntime, build_tippen_runtime
|
||||
|
||||
__all__ = [
|
||||
"AlbumLock",
|
||||
"Curriculum",
|
||||
"CurriculumError",
|
||||
"Lesson",
|
||||
"LockState",
|
||||
"RecordOutcome",
|
||||
"ResolvedReward",
|
||||
"RunResult",
|
||||
"Stroke",
|
||||
"TippenRuntime",
|
||||
"TypingProgress",
|
||||
"World",
|
||||
"build_tippen_runtime",
|
||||
"compute_lock_state",
|
||||
"load_curriculum",
|
||||
"record_run",
|
||||
]
|
||||
336
python-backend/musicmouse/tippen/curriculum.py
Normal file
@@ -0,0 +1,336 @@
|
||||
"""The typing game's lesson plan: a server-side mirror of tippen's ``curriculum.ts``.
|
||||
|
||||
Content lives in a YAML file named by ``general.tippen.curriculum_file``; this module
|
||||
only loads, validates and derives it - the same split the frontend used to do entirely
|
||||
on its own before progress (and therefore "has this lesson been passed") moved to the
|
||||
backend, which is also what reward unlocking needs the derived lesson list for.
|
||||
|
||||
Validation follows this codebase's own rule (see ``musicmouse.config``): unknown keys
|
||||
are rejected and every problem in the file is collected and reported at once, not one
|
||||
``ValueError`` per run.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Final, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, ValidationError, model_validator
|
||||
from ruamel.yaml import YAML
|
||||
from ruamel.yaml.error import YAMLError
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"CREATURE_IDS",
|
||||
"CreatureId",
|
||||
"Curriculum",
|
||||
"CurriculumError",
|
||||
"Lesson",
|
||||
"World",
|
||||
"first_lesson_id",
|
||||
"lesson_by_id",
|
||||
"load_curriculum",
|
||||
"next_lesson",
|
||||
"world_reward",
|
||||
]
|
||||
|
||||
type LessonKind = Literal["letters", "fragments", "words", "sentences"]
|
||||
type ModeId = Literal["dive", "bubbles", "jellyfish", "feed", "race"]
|
||||
type CreatureId = Literal["clownfish", "octopus", "seahorse", "turtle", "pearlmussel"]
|
||||
|
||||
#: In world order - see ``tippen/src/lib/aquarium.ts``, the one place this list is
|
||||
#: allowed to grow, since each id names a drawing under ``public/aquarium/``.
|
||||
CREATURE_IDS: Final[tuple[CreatureId, ...]] = (
|
||||
"clownfish",
|
||||
"octopus",
|
||||
"seahorse",
|
||||
"turtle",
|
||||
"pearlmussel",
|
||||
)
|
||||
|
||||
#: Which modes make sense for a kind - letters rounds are single keys, so only the
|
||||
#: arcade modes fit; only words and sentences are long enough for a race.
|
||||
ELIGIBLE_MODES: Final[dict[LessonKind, tuple[ModeId, ...]]] = {
|
||||
"letters": ("bubbles", "jellyfish"),
|
||||
"fragments": ("dive", "feed"),
|
||||
"words": ("dive", "feed", "race"),
|
||||
"sentences": ("dive", "race"),
|
||||
}
|
||||
|
||||
#: The home row, left to right - see ``tippen/src/lib/fingers.ts``.
|
||||
HOME_ROW: Final[tuple[str, ...]] = ("a", "s", "d", "f", "j", "k", "l", "ö")
|
||||
SPACE_KEY: Final = " "
|
||||
|
||||
|
||||
class CurriculumError(Exception):
|
||||
"""Raised with an already human-readable, multi-line message."""
|
||||
|
||||
|
||||
class _Strict(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class _YamlLesson(_Strict):
|
||||
title: str
|
||||
subtitle: str
|
||||
kind: LessonKind | None = None
|
||||
keys: tuple[str, ...] | None = None
|
||||
drill: bool = False
|
||||
mode: ModeId | None = None
|
||||
words: tuple[str, ...] | None = None
|
||||
#: A library path (``~`` allowed) this lesson unlocks, inclusive of the track or
|
||||
#: episode it names - see ``musicmouse.tippen.rewards``.
|
||||
unlocks: str | None = None
|
||||
|
||||
|
||||
class _YamlWorld(_Strict):
|
||||
number: int
|
||||
title: str
|
||||
emoji: str
|
||||
reward: CreatureId
|
||||
lessons: tuple[_YamlLesson, ...]
|
||||
|
||||
|
||||
class _YamlRoot(_Strict):
|
||||
worlds: tuple[_YamlWorld, ...]
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_plan(self) -> _YamlRoot:
|
||||
problems: list[str] = []
|
||||
if len(self.worlds) != len(CREATURE_IDS):
|
||||
problems.append(f"expected {len(CREATURE_IDS)} worlds, found {len(self.worlds)}")
|
||||
|
||||
seen_rewards: set[CreatureId] = set()
|
||||
for world in self.worlds:
|
||||
if world.reward in seen_rewards:
|
||||
problems.append(f"world {world.number} reuses reward {world.reward!r}")
|
||||
seen_rewards.add(world.reward)
|
||||
|
||||
for i, lesson in enumerate(world.lessons):
|
||||
where = f"world {world.number}, lesson {i + 1} ({lesson.title!r})"
|
||||
words = lesson.words or ()
|
||||
kind: LessonKind = lesson.kind or "letters"
|
||||
if words and lesson.kind is None:
|
||||
problems.append(f"{where}: has words but no explicit kind")
|
||||
if kind == "letters" and words:
|
||||
problems.append(f'{where}: kind "letters" cannot have words')
|
||||
if kind != "letters" and not words:
|
||||
problems.append(f'{where}: kind "{kind}" needs a non-empty words list')
|
||||
if lesson.drill and lesson.keys:
|
||||
problems.append(f"{where}: a drill cannot also introduce keys")
|
||||
if lesson.keys and len(lesson.keys) > 2:
|
||||
problems.append(f"{where}: at most two keys per lesson")
|
||||
if lesson.mode and lesson.mode not in ELIGIBLE_MODES[kind]:
|
||||
problems.append(f'{where}: mode "{lesson.mode}" does not fit kind "{kind}"')
|
||||
|
||||
if problems:
|
||||
plural = "s" if len(problems) != 1 else ""
|
||||
raise ValueError(
|
||||
f"{len(problems)} problem{plural} in the curriculum file:\n"
|
||||
+ "\n".join(f" {p}" for p in problems)
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
def _format_curriculum_errors(error: ValidationError) -> str:
|
||||
lines: list[str] = []
|
||||
for entry in error.errors():
|
||||
message = entry["msg"]
|
||||
for prefix in ("Value error, ", "Assertion failed, "):
|
||||
message = message.removeprefix(prefix)
|
||||
location = ".".join(
|
||||
f"[{part}]" if isinstance(part, int) else str(part) for part in entry["loc"]
|
||||
).replace(".[", "[")
|
||||
if entry["type"] == "extra_forbidden":
|
||||
message = f"unknown option: {location}"
|
||||
elif entry["type"] == "missing":
|
||||
message = f"required: {location}"
|
||||
lines.append(message)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class World:
|
||||
number: int
|
||||
title: str
|
||||
emoji: str
|
||||
reward: CreatureId
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Lesson:
|
||||
id: str
|
||||
world: int
|
||||
number: int
|
||||
title: str
|
||||
subtitle: str
|
||||
kind: LessonKind
|
||||
new_keys: tuple[str, ...]
|
||||
spotlight_keys: tuple[str, ...]
|
||||
emphasis: Literal["isolated", "mixed"] | None
|
||||
active_keys: tuple[str, ...]
|
||||
primary_mode: ModeId
|
||||
bonus_modes: tuple[ModeId, ...]
|
||||
words: tuple[str, ...]
|
||||
is_drill: bool
|
||||
chunks: int
|
||||
chunk_size: int
|
||||
#: Raw config value, not yet resolved against the library - see
|
||||
#: ``musicmouse.tippen.rewards.resolve_lesson_reward``.
|
||||
unlocks: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Curriculum:
|
||||
worlds: tuple[World, ...]
|
||||
lessons: tuple[Lesson, ...]
|
||||
|
||||
|
||||
def lesson_by_id(curriculum: Curriculum, lesson_id: str) -> Lesson | None:
|
||||
return next((lesson for lesson in curriculum.lessons if lesson.id == lesson_id), None)
|
||||
|
||||
|
||||
def next_lesson(curriculum: Curriculum, lesson_id: str) -> Lesson | None:
|
||||
ids = [lesson.id for lesson in curriculum.lessons]
|
||||
try:
|
||||
index = ids.index(lesson_id)
|
||||
except ValueError:
|
||||
return None
|
||||
return curriculum.lessons[index + 1] if index + 1 < len(curriculum.lessons) else None
|
||||
|
||||
|
||||
def world_reward(curriculum: Curriculum, world_number: int) -> CreatureId | None:
|
||||
return next((world.reward for world in curriculum.worlds if world.number == world_number), None)
|
||||
|
||||
|
||||
def first_lesson_id(curriculum: Curriculum) -> str | None:
|
||||
return curriculum.lessons[0].id if curriculum.lessons else None
|
||||
|
||||
|
||||
def _length_for(world: int, kind: LessonKind) -> tuple[int, int]:
|
||||
"""Line length by world and kind - a full block of text per round."""
|
||||
if kind == "fragments":
|
||||
return 16, 4 # 64
|
||||
if kind == "sentences":
|
||||
return 10, 4 # ten whole sentences
|
||||
if kind == "words":
|
||||
return 25, 4
|
||||
# kind == "letters"
|
||||
if world == 1:
|
||||
return 24, 3 # 72 characters
|
||||
if world == 2:
|
||||
return 25, 4 # 100
|
||||
return 30, 4 # 120, world 3 onward
|
||||
|
||||
|
||||
def _build_lessons(worlds: tuple[_YamlWorld, ...]) -> tuple[Lesson, ...]:
|
||||
lessons: list[Lesson] = []
|
||||
active: set[str] = set()
|
||||
seen_before: set[str] = set()
|
||||
# Letters rounds alternate bubbles/jellyfish across the whole course, so the arcade
|
||||
# game never repeats twice in a row even across a fragments/words lesson in between.
|
||||
last_arcade: ModeId = "jellyfish" # so lesson 1 opens on bubbles
|
||||
|
||||
for world in worlds:
|
||||
for entry in world.lessons:
|
||||
keys = entry.keys or ()
|
||||
new_keys = tuple(key for key in keys if key not in seen_before)
|
||||
for key in keys:
|
||||
seen_before.add(key)
|
||||
active.add(key)
|
||||
# The space-bar lesson activates every home key - belt-and-braces
|
||||
# confirmation that the four finger-pair lessons before it covered all eight.
|
||||
if SPACE_KEY in keys:
|
||||
active.update(HOME_ROW)
|
||||
|
||||
# Shift is not a character the generator can emit, so it never enters
|
||||
# active_keys.
|
||||
active_keys = tuple(sorted(key for key in active if key != "⇧"))
|
||||
words = entry.words or ()
|
||||
kind: LessonKind = entry.kind or "letters"
|
||||
is_drill = entry.drill
|
||||
|
||||
if kind != "letters" or is_drill:
|
||||
emphasis: Literal["isolated", "mixed"] | None = None
|
||||
elif new_keys:
|
||||
emphasis = "isolated"
|
||||
else:
|
||||
emphasis = "mixed"
|
||||
|
||||
primary_mode: ModeId
|
||||
if kind == "letters":
|
||||
default_arcade: ModeId = "jellyfish" if last_arcade == "bubbles" else "bubbles"
|
||||
primary_mode = entry.mode or default_arcade
|
||||
last_arcade = primary_mode
|
||||
else:
|
||||
primary_mode = entry.mode or "dive"
|
||||
|
||||
# Bonus replays are only ever feed/race - dive is the plain default, and
|
||||
# bubbles/jellyfish already alternate on their own.
|
||||
bonus_modes = tuple(
|
||||
mode
|
||||
for mode in ELIGIBLE_MODES[kind]
|
||||
if mode in ("feed", "race") and mode != primary_mode
|
||||
)
|
||||
|
||||
chunks, chunk_size = _length_for(world.number, kind)
|
||||
|
||||
lessons.append(
|
||||
Lesson(
|
||||
id=f"l{len(lessons) + 1:02d}",
|
||||
world=world.number,
|
||||
number=len(lessons) + 1,
|
||||
title=entry.title,
|
||||
subtitle=entry.subtitle,
|
||||
kind=kind,
|
||||
new_keys=new_keys,
|
||||
spotlight_keys=keys if kind == "letters" and not is_drill else (),
|
||||
emphasis=emphasis,
|
||||
active_keys=active_keys,
|
||||
primary_mode=primary_mode,
|
||||
bonus_modes=bonus_modes,
|
||||
words=words,
|
||||
is_drill=is_drill,
|
||||
chunks=chunks,
|
||||
chunk_size=chunk_size,
|
||||
unlocks=entry.unlocks,
|
||||
)
|
||||
)
|
||||
return tuple(lessons)
|
||||
|
||||
|
||||
def load_curriculum(path: Path) -> Curriculum:
|
||||
"""Load and validate a curriculum file.
|
||||
|
||||
Raises:
|
||||
CurriculumError: with a message that can be printed straight to the terminal.
|
||||
"""
|
||||
path = Path(path)
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
raise CurriculumError(f"Cannot read curriculum file {path}: {exc.strerror}") from exc
|
||||
|
||||
try:
|
||||
data = YAML(typ="safe").load(text)
|
||||
except YAMLError as exc:
|
||||
raise CurriculumError(f"{path} is not valid YAML:\n {exc}") from exc
|
||||
|
||||
if not isinstance(data, dict):
|
||||
raise CurriculumError(
|
||||
f"{path} must contain a mapping at the top level, got {type(data).__name__}"
|
||||
)
|
||||
|
||||
try:
|
||||
root = _YamlRoot.model_validate(data)
|
||||
except ValidationError as exc:
|
||||
raise CurriculumError(f"{path}\n{_format_curriculum_errors(exc)}") from exc
|
||||
|
||||
worlds = tuple(
|
||||
World(number=world.number, title=world.title, emoji=world.emoji, reward=world.reward)
|
||||
for world in root.worlds
|
||||
)
|
||||
return Curriculum(worlds=worlds, lessons=_build_lessons(root.worlds))
|
||||
330
python-backend/musicmouse/tippen/progress.py
Normal file
@@ -0,0 +1,330 @@
|
||||
"""Server-side typing progress: a JSON sidecar, atomic-written, defensively loaded.
|
||||
|
||||
Same convention as everywhere else this codebase persists something outside
|
||||
``config.yml`` - see ``musicmouse.library.podcast_feeds``'s failed-downloads sidecar. A
|
||||
missing or corrupt file is just a fresh start, never a crash: nothing here is precious
|
||||
enough to raise over.
|
||||
|
||||
``record_run`` is a near-verbatim port of tippen's own (client-side, until now)
|
||||
``recordRun`` in ``tippen/src/lib/progress.ts`` - grading a keystroke-by-keystroke run
|
||||
into stars/points/an animal stays entirely client-side (see ``TippenRunIn`` in
|
||||
``musicmouse.services.web.schemas``); this only owns what happens to progress once a
|
||||
graded result arrives.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
|
||||
from musicmouse.tippen.curriculum import (
|
||||
CreatureId,
|
||||
Curriculum,
|
||||
first_lesson_id,
|
||||
lesson_by_id,
|
||||
next_lesson,
|
||||
world_reward,
|
||||
)
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"DILIGENCE_ATTEMPTS",
|
||||
"AnimalId",
|
||||
"KeyStat",
|
||||
"LessonProgress",
|
||||
"RecordOutcome",
|
||||
"RunResult",
|
||||
"Streak",
|
||||
"Stroke",
|
||||
"TippenSettings",
|
||||
"TypingProgress",
|
||||
"fresh_progress",
|
||||
"load_progress",
|
||||
"record_run",
|
||||
"save_progress",
|
||||
"today",
|
||||
]
|
||||
|
||||
#: How many attempts at one lesson unlock the next regardless of score - the safety
|
||||
#: valve against getting stuck on a single stubborn key.
|
||||
DILIGENCE_ATTEMPTS = 5
|
||||
|
||||
AnimalId = Literal[
|
||||
"snail",
|
||||
"crab",
|
||||
"turtle",
|
||||
"jellyfish",
|
||||
"fish",
|
||||
"penguin",
|
||||
"seal",
|
||||
"dolphin",
|
||||
"shark",
|
||||
"orca",
|
||||
]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------- domain
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Stroke:
|
||||
key: str
|
||||
expected: str
|
||||
correct: bool
|
||||
#: ms timestamp, from the run's own clock.
|
||||
at: float
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RunResult:
|
||||
"""What the client already graded a finished run as - see ``grading.ts``."""
|
||||
|
||||
stars: Literal[0, 1, 2, 3]
|
||||
animal: AnimalId
|
||||
points: float
|
||||
passed: bool
|
||||
pearls: int
|
||||
strokes: tuple[Stroke, ...]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- store
|
||||
|
||||
class GhostStroke(BaseModel):
|
||||
key: str
|
||||
at: float
|
||||
|
||||
|
||||
class LessonProgress(BaseModel):
|
||||
unlocked: bool = False
|
||||
runs: int = 0
|
||||
best_stars: Literal[0, 1, 2, 3] = 0
|
||||
best_animal: AnimalId | None = None
|
||||
best_points: float = 0
|
||||
#: Best-run keystrokes, replayed as the opponent in race mode.
|
||||
ghost: list[GhostStroke] | None = None
|
||||
|
||||
@property
|
||||
def earned(self) -> bool:
|
||||
"""Passed on its own merits, or given up on gracefully after enough tries.
|
||||
|
||||
Always derived from ``best_stars``/``runs`` rather than stored, so it can never
|
||||
drift from the rule that computed it. Two stars is the same "passed" gate the
|
||||
frontend's ``isPassed`` uses (accuracy >= 93 %); the fifth attempt is the
|
||||
diligence fallback.
|
||||
"""
|
||||
return self.best_stars >= 2 or self.runs >= DILIGENCE_ATTEMPTS
|
||||
|
||||
|
||||
class KeyStat(BaseModel):
|
||||
#: Smoothed reaction time in ms - keybr's exponential moving average.
|
||||
ema: float = 0
|
||||
attempts: int = 0
|
||||
errors: int = 0
|
||||
|
||||
|
||||
class Streak(BaseModel):
|
||||
days: int = 0
|
||||
last_played: str | None = None
|
||||
|
||||
|
||||
class TippenSettings(BaseModel):
|
||||
sound: bool = True
|
||||
keyboard_hint: Literal["auto", "on", "off"] = "auto"
|
||||
|
||||
|
||||
class TypingProgress(BaseModel):
|
||||
version: Literal[1] = 1
|
||||
lessons: dict[str, LessonProgress] = Field(default_factory=dict)
|
||||
key_stats: dict[str, KeyStat] = Field(default_factory=dict)
|
||||
pearls: int = 0
|
||||
#: Pets that have moved into the aquarium, in the order they arrived.
|
||||
aquarium: list[CreatureId] = Field(default_factory=list)
|
||||
streak: Streak = Field(default_factory=Streak)
|
||||
settings: TippenSettings = Field(default_factory=TippenSettings)
|
||||
|
||||
|
||||
def fresh_progress(curriculum: Curriculum) -> TypingProgress:
|
||||
first = first_lesson_id(curriculum)
|
||||
lessons = {
|
||||
lesson.id: LessonProgress(unlocked=lesson.id == first) for lesson in curriculum.lessons
|
||||
}
|
||||
return TypingProgress(lessons=lessons)
|
||||
|
||||
|
||||
def load_progress(path: Path, curriculum: Curriculum) -> TypingProgress:
|
||||
"""A missing or corrupt file is just a fresh start - never fatal."""
|
||||
try:
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
except FileNotFoundError:
|
||||
return fresh_progress(curriculum)
|
||||
except OSError as exc:
|
||||
_log.warning("Could not read %s; starting fresh: %s", path, exc)
|
||||
return fresh_progress(curriculum)
|
||||
|
||||
try:
|
||||
progress = TypingProgress.model_validate(json.loads(raw))
|
||||
except (ValueError, ValidationError) as exc:
|
||||
_log.warning("Could not parse %s; starting fresh: %s", path, exc)
|
||||
return fresh_progress(curriculum)
|
||||
|
||||
# A lesson added to the curriculum since the last save needs an entry too, and the
|
||||
# first lesson is unlocked by definition - a save that says otherwise is wrong.
|
||||
fresh = fresh_progress(curriculum)
|
||||
lessons = dict(progress.lessons)
|
||||
for lesson_id, blank in fresh.lessons.items():
|
||||
lessons.setdefault(lesson_id, blank)
|
||||
first = first_lesson_id(curriculum)
|
||||
if first is not None and first in lessons:
|
||||
lessons[first] = lessons[first].model_copy(update={"unlocked": True})
|
||||
return progress.model_copy(update={"lessons": lessons})
|
||||
|
||||
|
||||
def save_progress(path: Path, progress: TypingProgress) -> None:
|
||||
"""Write through a sibling temp file so an interrupted save cannot truncate the real
|
||||
one - the same atomic-write idiom used throughout this codebase."""
|
||||
temp = path.with_name(f"{path.name}.tmp{os.getpid()}")
|
||||
try:
|
||||
temp.write_text(progress.model_dump_json(), encoding="utf-8")
|
||||
temp.replace(path)
|
||||
except BaseException:
|
||||
temp.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- record
|
||||
|
||||
def today(day: date | None = None) -> str:
|
||||
"""Today (or `day`) as YYYY-MM-DD."""
|
||||
return (day or date.today()).isoformat()
|
||||
|
||||
|
||||
def _is_better(candidate: RunResult, best_stars: int, best_points: float) -> bool:
|
||||
"""Stars come first, points break the tie - a careful run is never displaced by a
|
||||
sloppy fast one."""
|
||||
if candidate.stars != best_stars:
|
||||
return candidate.stars > best_stars
|
||||
return candidate.points > best_points
|
||||
|
||||
|
||||
def _fold_key_stats(stats: dict[str, KeyStat], strokes: tuple[Stroke, ...]) -> dict[str, KeyStat]:
|
||||
"""Fold the per-key reaction times of a run into the stored averages. The EMA weight
|
||||
of 0.3 is slow enough that one distracted run does not rewrite what is known."""
|
||||
next_stats = dict(stats)
|
||||
previous_at: float | None = None
|
||||
for stroke in strokes:
|
||||
key = stroke.expected.lower()
|
||||
current = next_stats.get(key, KeyStat())
|
||||
gap = None if previous_at is None else stroke.at - previous_at
|
||||
previous_at = stroke.at
|
||||
# Reaction times over two seconds are a pause for thought, not a measure of the
|
||||
# key, so they are ignored rather than averaged in.
|
||||
ema = current.ema
|
||||
if gap is not None and gap < 2000:
|
||||
ema = gap if current.ema == 0 else current.ema * 0.7 + gap * 0.3
|
||||
next_stats[key] = KeyStat(
|
||||
ema=ema,
|
||||
attempts=current.attempts + 1,
|
||||
errors=current.errors + (0 if stroke.correct else 1),
|
||||
)
|
||||
return next_stats
|
||||
|
||||
|
||||
def _bump_streak(streak: Streak, day: str) -> Streak:
|
||||
if streak.last_played == day:
|
||||
return streak
|
||||
yesterday = (date.fromisoformat(day) - timedelta(days=1)).isoformat()
|
||||
consecutive = streak.last_played == yesterday
|
||||
# A missed day restarts at 1, never at 0 - playing today always counts for something.
|
||||
return Streak(days=streak.days + 1 if consecutive else 1, last_played=day)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RecordOutcome:
|
||||
progress: TypingProgress
|
||||
#: Set when this run unlocked the following lesson, for the lesson-map celebration.
|
||||
unlocked_lesson_id: str | None
|
||||
#: Whether *this* lesson's pass threshold was crossed by this run - the signal a
|
||||
#: reward attached to it (``Lesson.unlocks``) should now be checked.
|
||||
newly_earned: bool
|
||||
#: Set when this run finished a world, for the creature that moved in.
|
||||
new_creature: CreatureId | None
|
||||
is_new_best: bool
|
||||
|
||||
|
||||
def record_run(
|
||||
progress: TypingProgress,
|
||||
lesson_id: str,
|
||||
result: RunResult,
|
||||
curriculum: Curriculum,
|
||||
day: str | None = None,
|
||||
) -> RecordOutcome:
|
||||
"""Record a finished run: stars, animal, pearls, key stats, streak, and the unlock.
|
||||
|
||||
The unlock rule, in one place: two stars unlocks the next lesson, and so does the
|
||||
fifth attempt whatever the score. Speed is nowhere in it.
|
||||
"""
|
||||
day = day or today()
|
||||
before = progress.lessons.get(lesson_id) or LessonProgress(unlocked=True)
|
||||
was_earned = before.earned
|
||||
runs = before.runs + 1
|
||||
|
||||
improved = _is_better(result, before.best_stars, before.best_points)
|
||||
lessons = dict(progress.lessons)
|
||||
updated = before.model_copy(
|
||||
update={
|
||||
"runs": runs,
|
||||
"best_stars": result.stars if improved else before.best_stars,
|
||||
"best_animal": result.animal if improved else before.best_animal,
|
||||
"best_points": result.points if improved else before.best_points,
|
||||
"ghost": (
|
||||
[GhostStroke(key=s.key, at=s.at) for s in result.strokes if s.correct]
|
||||
if improved
|
||||
else before.ghost
|
||||
),
|
||||
}
|
||||
)
|
||||
lessons[lesson_id] = updated
|
||||
newly_earned = updated.earned and not was_earned
|
||||
|
||||
nxt = next_lesson(curriculum, lesson_id)
|
||||
unlocked_lesson_id: str | None = None
|
||||
if newly_earned and nxt is not None and not lessons.get(nxt.id, LessonProgress()).unlocked:
|
||||
next_before = lessons.get(nxt.id, LessonProgress())
|
||||
lessons[nxt.id] = next_before.model_copy(update={"unlocked": True})
|
||||
unlocked_lesson_id = nxt.id
|
||||
|
||||
# Finishing the last lesson of a world releases that world's creature. Checked
|
||||
# against the aquarium so it is only ever awarded once.
|
||||
aquarium = list(progress.aquarium)
|
||||
new_creature: CreatureId | None = None
|
||||
if unlocked_lesson_id and nxt is not None:
|
||||
finished = lesson_by_id(curriculum, lesson_id)
|
||||
if finished is not None and nxt.world != finished.world:
|
||||
reward = world_reward(curriculum, finished.world)
|
||||
if reward and reward not in aquarium:
|
||||
aquarium.append(reward)
|
||||
new_creature = reward
|
||||
|
||||
updated_progress = progress.model_copy(
|
||||
update={
|
||||
"lessons": lessons,
|
||||
"aquarium": aquarium,
|
||||
"key_stats": _fold_key_stats(progress.key_stats, result.strokes),
|
||||
"pearls": progress.pearls + result.pearls,
|
||||
"streak": _bump_streak(progress.streak, day),
|
||||
}
|
||||
)
|
||||
return RecordOutcome(
|
||||
progress=updated_progress,
|
||||
unlocked_lesson_id=unlocked_lesson_id,
|
||||
newly_earned=newly_earned,
|
||||
new_creature=new_creature,
|
||||
is_new_best=improved,
|
||||
)
|
||||
247
python-backend/musicmouse/tippen/rewards.py
Normal file
@@ -0,0 +1,247 @@
|
||||
"""Turning a lesson's ``unlocks: <path>`` into which tracks/episodes are still locked.
|
||||
|
||||
Two shapes, one config key:
|
||||
|
||||
* A music album or audiobook (a "folder" section - one album, several tracks): the path
|
||||
names one track, and unlocks every track up to and including it - tracks
|
||||
``0..index`` inclusive, by ordinal position in ``Album.tracks``.
|
||||
* A podcast show (an "episode" section - one album *per episode file*): the path names
|
||||
one episode. Its show's episodes are sorted chronologically (oldest first, by the
|
||||
``YYYYMMDD - Title`` filename convention ``library.sections`` documents - the same
|
||||
fact ``MusicLibrary.latest_episode`` already relies on), and every episode up to and
|
||||
including the target unlocks - the same "up to this one" shape, just over episodes
|
||||
instead of tracks.
|
||||
|
||||
Lock state is never persisted: it is always recomputed fresh from (curriculum reward
|
||||
config) x (current progress) x (the live library), matching how
|
||||
``services/web/state.py``'s ``snapshot`` assembles player state on demand rather than
|
||||
keeping a second copy in sync. An album or show no lesson ever names is simply never
|
||||
locked - the reward system only ever restricts what it explicitly targets.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from musicmouse.library import MusicLibrary
|
||||
from musicmouse.library.models import Album
|
||||
from musicmouse.library.sections import SECTIONS
|
||||
from musicmouse.tippen.curriculum import Curriculum, Lesson
|
||||
from musicmouse.tippen.progress import TypingProgress
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"AlbumLock",
|
||||
"LockState",
|
||||
"ResolvedReward",
|
||||
"TrackLock",
|
||||
"UnlockHint",
|
||||
"compute_lock_state",
|
||||
"resolve_all",
|
||||
"resolve_lesson_reward",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ResolvedReward:
|
||||
"""Where a lesson's ``unlocks`` path landed in the live library."""
|
||||
|
||||
kind: Literal["tracks", "episode"]
|
||||
#: The album a "tracks" reward lives in, or the specific episode-album an "episode"
|
||||
#: reward names - either way, the concrete thing to show cover art/title for.
|
||||
album_id: str
|
||||
#: The show, for an "episode" reward - `None` for "tracks".
|
||||
series: str | None
|
||||
#: 0-based, inclusive: everything up to and including this index unlocks.
|
||||
until_index: int
|
||||
|
||||
|
||||
def _expand(raw: str, library_root: Path) -> Path:
|
||||
path = Path(raw).expanduser()
|
||||
if not path.is_absolute():
|
||||
path = library_root / path
|
||||
return path.resolve()
|
||||
|
||||
|
||||
def _chronological_episodes(library: MusicLibrary, series: str | None) -> list[Album]:
|
||||
"""A show's episode-albums, oldest first."""
|
||||
candidates = [
|
||||
album
|
||||
for album in library.albums
|
||||
if album.series == series
|
||||
and (section := SECTIONS.get(album.section)) is not None
|
||||
and section.album_unit == "episode"
|
||||
]
|
||||
return sorted(candidates, key=lambda album: album.tracks[0].path.name if album.tracks else "")
|
||||
|
||||
|
||||
def resolve_lesson_reward(lesson: Lesson, library: MusicLibrary) -> ResolvedReward | None:
|
||||
"""`None` (logged, not raised) when ``unlocks`` doesn't match anything on disk - a
|
||||
moved or mistyped path must never take the whole app down, only that one reward."""
|
||||
if lesson.unlocks is None:
|
||||
return None
|
||||
target = _expand(lesson.unlocks, library.root)
|
||||
|
||||
for album in library.albums:
|
||||
section = SECTIONS.get(album.section)
|
||||
if section is None:
|
||||
continue
|
||||
for index, track in enumerate(album.tracks):
|
||||
if track.path.resolve() != target:
|
||||
continue
|
||||
if section.album_unit == "folder":
|
||||
return ResolvedReward(
|
||||
kind="tracks", album_id=album.id, series=None, until_index=index
|
||||
)
|
||||
# "episode": the reward's range is this episode's position among its show's
|
||||
# episodes, chronological - not its (always 0) index within its own album.
|
||||
episodes = _chronological_episodes(library, album.series)
|
||||
for episode_index, episode_album in enumerate(episodes):
|
||||
if episode_album.id == album.id:
|
||||
return ResolvedReward(
|
||||
kind="episode",
|
||||
album_id=album.id,
|
||||
series=album.series,
|
||||
until_index=episode_index,
|
||||
)
|
||||
|
||||
_log.warning(
|
||||
"tippen: lesson %r unlocks %r, which matches no track in the library",
|
||||
lesson.id,
|
||||
lesson.unlocks,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def resolve_all(curriculum: Curriculum, library: MusicLibrary) -> dict[str, ResolvedReward | None]:
|
||||
return {
|
||||
lesson.id: resolve_lesson_reward(lesson, library)
|
||||
for lesson in curriculum.lessons
|
||||
if lesson.unlocks is not None
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UnlockHint:
|
||||
lesson_id: str
|
||||
lesson_title: str
|
||||
world_number: int
|
||||
world_title: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TrackLock:
|
||||
locked: bool
|
||||
hint: UnlockHint | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AlbumLock:
|
||||
locked: bool
|
||||
tracks: tuple[TrackLock, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LockState:
|
||||
#: Only albums touched by at least one reward - see the module docstring.
|
||||
albums: dict[str, AlbumLock] = field(default_factory=dict)
|
||||
|
||||
def get(self, album_id: str) -> AlbumLock | None:
|
||||
return self.albums.get(album_id)
|
||||
|
||||
|
||||
def _world_title(curriculum: Curriculum, world_number: int) -> str:
|
||||
return next((world.title for world in curriculum.worlds if world.number == world_number), "")
|
||||
|
||||
|
||||
def _hint_for(
|
||||
curriculum: Curriculum, candidates: list[tuple[int, Lesson]], index: int
|
||||
) -> UnlockHint | None:
|
||||
"""The earliest lesson that would unlock `index`, for "mention when this unlocks"."""
|
||||
eligible = [(until, lesson) for until, lesson in candidates if until >= index]
|
||||
if not eligible:
|
||||
return None
|
||||
_until, lesson = min(eligible, key=lambda pair: pair[0])
|
||||
return UnlockHint(
|
||||
lesson_id=lesson.id,
|
||||
lesson_title=lesson.title,
|
||||
world_number=lesson.world,
|
||||
world_title=_world_title(curriculum, lesson.world),
|
||||
)
|
||||
|
||||
|
||||
def compute_lock_state(
|
||||
curriculum: Curriculum, library: MusicLibrary, progress: TypingProgress
|
||||
) -> LockState:
|
||||
"""Always recomputed fresh - see the module docstring."""
|
||||
resolved = resolve_all(curriculum, library)
|
||||
|
||||
tracks_by_album: dict[str, list[tuple[int, Lesson]]] = {}
|
||||
episodes_by_series: dict[str, list[tuple[int, Lesson]]] = {}
|
||||
#: The best (highest) *earned* until_index, same two keys.
|
||||
earned_album: dict[str, int] = {}
|
||||
earned_series: dict[str, int] = {}
|
||||
|
||||
for lesson in curriculum.lessons:
|
||||
reward = resolved.get(lesson.id)
|
||||
if reward is None:
|
||||
continue
|
||||
progress_entry = progress.lessons.get(lesson.id)
|
||||
is_earned = progress_entry.earned if progress_entry is not None else False
|
||||
|
||||
if reward.kind == "tracks":
|
||||
tracks_by_album.setdefault(reward.album_id, []).append((reward.until_index, lesson))
|
||||
if is_earned:
|
||||
earned_album[reward.album_id] = max(
|
||||
earned_album.get(reward.album_id, -1), reward.until_index
|
||||
)
|
||||
else:
|
||||
series = reward.series
|
||||
assert series is not None
|
||||
episodes_by_series.setdefault(series, []).append((reward.until_index, lesson))
|
||||
if is_earned:
|
||||
earned_series[series] = max(earned_series.get(series, -1), reward.until_index)
|
||||
|
||||
albums: dict[str, AlbumLock] = {}
|
||||
for album in library.albums:
|
||||
section = SECTIONS.get(album.section)
|
||||
if section is None:
|
||||
continue
|
||||
|
||||
if section.album_unit == "folder":
|
||||
if album.id not in tracks_by_album:
|
||||
continue # never targeted by any lesson: never locked
|
||||
best = earned_album.get(album.id, -1)
|
||||
candidates = tracks_by_album[album.id]
|
||||
track_locks = tuple(
|
||||
TrackLock(
|
||||
locked=index > best,
|
||||
hint=_hint_for(curriculum, candidates, index) if index > best else None,
|
||||
)
|
||||
for index in range(len(album.tracks))
|
||||
)
|
||||
albums[album.id] = AlbumLock(locked=best < 0, tracks=track_locks)
|
||||
else:
|
||||
if album.series not in episodes_by_series:
|
||||
continue
|
||||
episodes = _chronological_episodes(library, album.series)
|
||||
chronological_index = next(
|
||||
(i for i, a in enumerate(episodes) if a.id == album.id), None
|
||||
)
|
||||
if chronological_index is None:
|
||||
continue
|
||||
best = earned_series.get(album.series, -1)
|
||||
locked = chronological_index > best
|
||||
hint = (
|
||||
_hint_for(curriculum, episodes_by_series[album.series], chronological_index)
|
||||
if locked
|
||||
else None
|
||||
)
|
||||
episode_lock = TrackLock(locked=locked, hint=hint)
|
||||
albums[album.id] = AlbumLock(locked=locked, tracks=(episode_lock,))
|
||||
|
||||
return LockState(albums=albums)
|
||||
34
python-backend/musicmouse/tippen/runtime.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""The typing game's live in-memory state, built once at startup from config."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from musicmouse.config import TippenConfig
|
||||
from musicmouse.tippen.curriculum import Curriculum, load_curriculum
|
||||
from musicmouse.tippen.progress import TypingProgress, load_progress, save_progress
|
||||
|
||||
__all__ = ["TippenRuntime", "build_tippen_runtime"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class TippenRuntime:
|
||||
curriculum: Curriculum
|
||||
progress_path: Path
|
||||
progress: TypingProgress
|
||||
|
||||
def save(self) -> None:
|
||||
save_progress(self.progress_path, self.progress)
|
||||
|
||||
|
||||
def build_tippen_runtime(config: TippenConfig) -> TippenRuntime:
|
||||
"""Raises :class:`musicmouse.tippen.curriculum.CurriculumError` on a broken
|
||||
curriculum file - a startup-time failure, same gate as a broken ``config.yml``.
|
||||
Progress never raises; see ``musicmouse.tippen.progress.load_progress``.
|
||||
"""
|
||||
curriculum = load_curriculum(config.curriculum_file)
|
||||
progress = load_progress(config.progress_file, curriculum)
|
||||
return TippenRuntime(
|
||||
curriculum=curriculum, progress_path=config.progress_file, progress=progress
|
||||
)
|
||||
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)
|
||||
75
python-backend/pyproject.toml
Normal file
@@ -0,0 +1,75 @@
|
||||
[project]
|
||||
name = "musicmouse"
|
||||
version = "2.0.0"
|
||||
description = "Host backend for the MusicMouse RFID music player"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"aiomqtt>=2.0",
|
||||
"fastapi>=0.115",
|
||||
# Podcast RSS feeds in the wild are full of small quirks (odd dates, missing
|
||||
# namespaces); parsing them by hand invites silently dropping episodes.
|
||||
"feedparser>=6.0",
|
||||
# Also what tests drive the ASGI app with - plain httpx is deprecated in favour of
|
||||
# this for exactly that. Runtime uses it to proxy the room page's Home Assistant
|
||||
# calls, so the long-lived token never has to leave the backend.
|
||||
"httpx2>=2.12",
|
||||
"mutagen>=1.47",
|
||||
"pillow>=10.4",
|
||||
"pydantic>=2.7",
|
||||
"pyserial-asyncio>=0.6",
|
||||
"python-vlc>=3.0",
|
||||
"ruamel.yaml>=0.18",
|
||||
"uvicorn>=0.30",
|
||||
# uvicorn ships no websocket implementation of its own; without this the
|
||||
# state-push upgrade is answered with a 404 and the UI never updates.
|
||||
"websockets>=13",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["mypy>=1.10", "pytest-asyncio>=0.23", "pytest>=8.0", "ruff>=0.5"]
|
||||
# Off by default: the reactive background is nice, not required, and librosa/numpy are
|
||||
# a real install cost on a Pi. Missing this group means `build_analyzer()` falls back
|
||||
# to `NullAnalyzer` and the background stays at its static baseline - never a crash.
|
||||
analysis = ["librosa>=1.0", "numpy>=2.0"]
|
||||
|
||||
[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]]
|
||||
# numpy ships its own types, but only when the `analysis` extra is installed - a plain
|
||||
# checkout must still type-check clean, so it needs the same treatment as librosa.
|
||||
module = ["vlc", "serial_asyncio", "ruamel.*", "librosa.*", "numpy", "feedparser"]
|
||||
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
@@ -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
@@ -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
|
||||
52
python-backend/scenarios/web_play.txt
Normal file
@@ -0,0 +1,52 @@
|
||||
# The web front-end's path: any album, not just the five with a figure.
|
||||
# Run it by hand with:
|
||||
# python -m musicmouse --config <config.yml> --simulate --script scenarios/web_play.txt
|
||||
|
||||
expect playing false
|
||||
expect ring none
|
||||
|
||||
# Starting an album that no figure owns lights the strips differently from a figure -
|
||||
# a slow three-quarter circle in the album's own colour, so the shelf shows which way
|
||||
# the mouse was started.
|
||||
album Kinderparty Lieder
|
||||
expect playing true
|
||||
expect album Kinderparty Lieder
|
||||
expect track 0
|
||||
expect ring EffectCircularConfig
|
||||
expect mouse EffectCircularConfig
|
||||
expect shelf EffectCircularConfig
|
||||
expect brightness 0.50
|
||||
|
||||
# Transport works the same whoever asked for it.
|
||||
next
|
||||
expect track 1
|
||||
|
||||
wait 6s
|
||||
expect playing false
|
||||
expect ring EffectReverseSwipe
|
||||
|
||||
# A figure still wins: its animation owns the strips while it is on the reader.
|
||||
place fuchs
|
||||
expect figure fuchs
|
||||
expect album Fuchs
|
||||
expect ring EffectSwipeAndChange
|
||||
|
||||
# ...and the web animation does not fight it.
|
||||
album Eule
|
||||
expect album Eule
|
||||
expect ring EffectSwipeAndChange
|
||||
|
||||
remove
|
||||
expect playing false
|
||||
|
||||
# Starting a figure's album from the web is the same playlist the figure plays.
|
||||
album Fuchs
|
||||
expect playlist fuchs
|
||||
expect playing true
|
||||
|
||||
seek 2s
|
||||
expect position 2.0
|
||||
|
||||
pause
|
||||
expect playing false
|
||||
expect ring EffectReverseSwipe
|
||||
115
python-backend/scripts/fetch_podcast_covers.py
Normal file
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env python3
|
||||
"""One-off: backfill cover art for the podcast shows already in the library.
|
||||
|
||||
The scanner now reads a podcast episode's own embedded art first, and a show folder's
|
||||
``cover.jpg`` second (see ``musicmouse.library.scanner._cover_for_episode``) - but
|
||||
episodes downloaded before today rarely carry per-episode art, and none of the shows
|
||||
have a folder-level cover on disk yet. This script fills that gap once, by hand, so it
|
||||
is not something the running app does on its own.
|
||||
|
||||
For every ``Kinderpodcasts/<show>`` folder that has no ``cover.jpg``/``.jpeg``/``.png``
|
||||
already, it looks the show up on the public iTunes Search API and saves the top match's
|
||||
artwork as ``cover.jpg`` in that folder. A show it cannot match confidently is left
|
||||
alone and logged, rather than guessed at - check those by hand afterwards.
|
||||
|
||||
This makes real network requests to a third-party service and writes into the real
|
||||
music library, so it is meant to be run and reviewed by a person, not called from the
|
||||
app:
|
||||
|
||||
python scripts/fetch_podcast_covers.py --config /path/to/config.yml
|
||||
python scripts/fetch_podcast_covers.py --config /path/to/config.yml --dry-run
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from musicmouse.config import load_config
|
||||
|
||||
_log = logging.getLogger("fetch_podcast_covers")
|
||||
|
||||
#: Mirrors scanner.py's `_COVER_NAMES` - what counts as "already has a cover".
|
||||
_COVER_NAMES = ("cover.jpg", "cover.jpeg", "cover.png", "folder.jpg")
|
||||
_ITUNES_SEARCH = "https://itunes.apple.com/search"
|
||||
_TIMEOUT = 10.0
|
||||
|
||||
|
||||
def _has_cover(folder: Path) -> bool:
|
||||
return any((folder / name).is_file() for name in _COVER_NAMES)
|
||||
|
||||
|
||||
def _find_artwork_url(show_name: str) -> str | None:
|
||||
query = urllib.parse.urlencode({"media": "podcast", "term": show_name, "limit": 1})
|
||||
try:
|
||||
with urllib.request.urlopen(f"{_ITUNES_SEARCH}?{query}", timeout=_TIMEOUT) as response:
|
||||
body = json.loads(response.read())
|
||||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as error:
|
||||
_log.warning("Lookup for %r failed: %s", show_name, error)
|
||||
return None
|
||||
|
||||
results = body.get("results") or []
|
||||
if not results:
|
||||
return None
|
||||
# iTunes serves a 100x100 thumbnail by default; ask for something worth showing.
|
||||
artwork = results[0].get("artworkUrl100")
|
||||
return artwork.replace("100x100", "600x600") if artwork else None
|
||||
|
||||
|
||||
def backfill(root: Path, *, dry_run: bool) -> None:
|
||||
podcasts_root = root / "Kinderpodcasts"
|
||||
if not podcasts_root.is_dir():
|
||||
_log.warning("No Kinderpodcasts folder at %s", podcasts_root)
|
||||
return
|
||||
|
||||
for folder in sorted(podcasts_root.iterdir()):
|
||||
if not folder.is_dir() or folder.name.startswith("."):
|
||||
continue
|
||||
if _has_cover(folder):
|
||||
_log.info("%-40s already has a cover, skipping", folder.name)
|
||||
continue
|
||||
|
||||
url = _find_artwork_url(folder.name)
|
||||
if url is None:
|
||||
_log.warning("%-40s no confident match - fetch this one by hand", folder.name)
|
||||
continue
|
||||
|
||||
_log.info("%-40s -> %s", folder.name, url)
|
||||
if dry_run:
|
||||
continue
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=_TIMEOUT) as response:
|
||||
data = response.read()
|
||||
except (urllib.error.URLError, TimeoutError) as error:
|
||||
_log.warning("%-40s download failed: %s", folder.name, error)
|
||||
continue
|
||||
(folder / "cover.jpg").write_bytes(data)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("-c", "--config", type=Path, required=True, help="path to config.yml")
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="print what would be fetched without downloading or writing anything",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
config = load_config(args.config)
|
||||
backfill(config.general.library.root, dry_run=args.dry_run)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
103
python-backend/tests/conftest.py
Normal file
@@ -0,0 +1,103 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
#: Half a second of silence, so ``mutagen`` reports a real duration and tags can be
|
||||
#: written onto a file that is actually an MP3. Generated once with ffmpeg.
|
||||
SILENCE = Path(__file__).parent / "data" / "silence.mp3"
|
||||
|
||||
VALID_CONFIG: dict[str, Any] = {
|
||||
"general": {
|
||||
"library": {"root": "music", "cache": ".cache"},
|
||||
"serial_port": "/dev/ttyUSB0",
|
||||
"alsa_device": "simulate",
|
||||
"min_volume": 0,
|
||||
"max_volume": 60,
|
||||
"initial_volume": 40,
|
||||
},
|
||||
"figures": {
|
||||
"fuchs": {"id": "04a1b2c3d4", "colors": ["#ff6600", "#ffcc00", "#331100", "wff"]},
|
||||
# One of each, so the figure-kind branch is exercised by every fixture.
|
||||
"eule": {
|
||||
"id": "04b2c3d4e5",
|
||||
"colors": ["#3355ff", "#66aaff", "#001133", "#ffffff"],
|
||||
"kind": "book",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def write_track(path: Path, **tags: str) -> Path:
|
||||
"""Copy the silence fixture to ``path`` and stamp the given easy-mode ID3 tags."""
|
||||
import mutagen
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(SILENCE.read_bytes())
|
||||
if tags:
|
||||
audio = mutagen.File(path, easy=True)
|
||||
if audio.tags is None:
|
||||
audio.add_tags()
|
||||
for key, value in tags.items():
|
||||
audio[key] = value
|
||||
audio.save()
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config_dir(tmp_path: Path) -> Path:
|
||||
"""A directory holding a small but structurally real music library.
|
||||
|
||||
Two figure folders (matching ``VALID_CONFIG``), one music album, one audiobook and
|
||||
one podcast - enough for every branch the scanner has.
|
||||
"""
|
||||
root = tmp_path / "music"
|
||||
|
||||
for figure, tracks in (("fuchs", 3), ("eule", 2)):
|
||||
for index in range(tracks):
|
||||
write_track(
|
||||
root / "Figuren" / figure / f"{index:02d} - track.mp3",
|
||||
title=f"Track {index}",
|
||||
album="Waldlieder",
|
||||
albumartist="Die Tiere",
|
||||
)
|
||||
|
||||
for index in range(2):
|
||||
write_track(
|
||||
root / "Musik" / "Kinderparty - Kinderparty Lieder" / f"{index:02d} - lied.mp3",
|
||||
title=f"Lied {index}",
|
||||
album="Kinderparty Lieder",
|
||||
albumartist="Kinderparty",
|
||||
)
|
||||
|
||||
for index in range(2):
|
||||
write_track(
|
||||
root / "Hörbücher" / "Conni - Conni in den Bergen" / f"{index:02d} - teil.mp3",
|
||||
title=f"Teil {index}",
|
||||
album="Conni in den Bergen",
|
||||
albumartist="Conni, Julia Boehme",
|
||||
)
|
||||
|
||||
podcast = root / "Kinderpodcasts" / "Wissen macht Ah"
|
||||
for date, title in (("20240101", "Alt"), ("20260101", "Neu")):
|
||||
write_track(
|
||||
podcast / f"{date} - {title}.mp3",
|
||||
title=title,
|
||||
album="Wissen macht Ah! - Podcast",
|
||||
albumartist="Ein Name, Noch Einer, Und Einer",
|
||||
)
|
||||
# The two kinds of scratch file a podcast downloader leaves behind.
|
||||
(podcast / "archive.json").write_text("[]")
|
||||
(podcast / ".podcast-dl-abc.download.tmp").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
|
||||