Web frontend
This commit is contained in:
@@ -1,21 +1,22 @@
|
||||
# MusicMouse backend
|
||||
|
||||
The host application: it reads RFID tags, buttons and touch areas from the ESP32
|
||||
firmware over serial, plays music through VLC, drives three LED strips, and exposes
|
||||
everything to Home Assistant over MQTT.
|
||||
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)
|
||||
ESP32 ⇄ MusicMouseDevice ─┐ ┌─► MqttService (state out, intents in)
|
||||
VLC ⇄ VlcPlayer ────────┼──► EventBus ──────────►┤
|
||||
broker ⇄ MqttService ──────┘ ▲ └─► (a web service would go here)
|
||||
│
|
||||
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.
|
||||
changes. The web front-end was added exactly that way.
|
||||
|
||||
## Running it
|
||||
|
||||
@@ -28,6 +29,32 @@ See `config.yml.example` for the schema and `musicmouse.service` for the systemd
|
||||
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
|
||||
@@ -65,7 +92,9 @@ the prompt becomes a regression test by pasting it into a `.txt` file.
|
||||
| `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. |
|
||||
|
||||
@@ -118,3 +147,89 @@ trigger, action `light.toggle` on `light.kinderzimmer_fluter`.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user