# 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/
/` | an album per figurine | the folder name *is* the figure name from the config | | `Musik/ - /` | music, grouped by artist | plain ID3 | | `Hörbücher/ - /` | audiobooks, grouped by character | `album_artist` is a credit list; only the name before the first comma groups usefully | | `Kinderpodcasts//` | 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/.jpg medium: art extracted from an ID3 APIC frame analysis/.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.