Files
musicmouse/python-backend
Martin Bauer 75b0eed080 Remove the Quallenalarm (jellyfish) typing minigame
Letters-round lessons only had bubbles and jellyfish as eligible arcade
modes; with jellyfish gone, bubbles is now always used instead of the
two alternating - no lessons need to be dropped.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 00:38:19 +02:00
..
2026-08-26 13:22:28 +02:00
2026-08-27 12:32:20 +02:00

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

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:

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.

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:

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

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.