Full rearchitecture using Claude

- event bus systen
- all components are independent
- preparation for web frontend
This commit is contained in:
2026-08-26 13:22:28 +02:00
parent 55bcc9448c
commit d44c24ec97
70 changed files with 7591 additions and 1011 deletions

120
python-backend/README.md Normal file
View File

@@ -0,0 +1,120 @@
# 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.
```
ESP32 ⇄ MusicMouseDevice ─┐ ┌─► MqttService (state out, intents in)
VLC ⇄ VlcPlayer ────────┼──► EventBus ──────────►┤
broker ⇄ MqttService ──────┘ ▲ └─► (a web service would go here)
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.
## 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.
### 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/services/mqtt/` | Home Assistant entities: three lights, a player sensor, a volume number, transport buttons, device triggers, a tag scanner. |
| `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.