A show folder under Kinderpodcasts/ opts in by containing a feed.txt marker naming its RSS feed. A new PodcastFeedService polls every such feed every 6 hours (and once at startup), downloads any episode not already on disk using the existing YYYYMMDD - Title.ext convention, and triggers the same rescan-and-broadcast sequence "Bibliothek neu einlesen" already uses - now shared via App.rescan_library() instead of duplicated. A show with no feed.txt is untouched, so there is no new config section for this. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
242 lines
11 KiB
Markdown
242 lines
11 KiB
Markdown
# 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.
|