26 Commits

Author SHA1 Message Date
e243862769 Fix barely-visible music-note tab icon
The glass-bar redesign dropped the explicit icon color, so inactive
icons fell back to the browser's default text color - fine for icons
that render as full-color emoji, but the music note apparently renders
as a plain glyph on at least one platform, making it nearly invisible
against the bar's near-white glass background. Set it explicitly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 00:21:34 +02:00
b8f9f6d537 Give the tab rail its own glass bar and reserve space for it
The three tab buttons now share one frosted glass pill instead of each
floating separately, and BrowseView/RoomView reserve TAB_RAIL_CLEARANCE
of right-side padding so a full-width row or grid never renders under
it - verified against the real library, where dense rows (e.g. Conni's
82 audiobooks) previously butted right up against the rail.

Also gitignore the real (non-.example) tippen-curriculum.yml and
tippen-progress.json, matching config.yml's own privacy treatment,
since the curriculum now contains real reward paths into a personal
library.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 00:16:42 +02:00
57ead93497 Apply lock state to category-tile cover previews too
Verified the whole reward-lock feature against a real library with an
actual browser (Playwright + headless Chromium): the album-grid cards
already showed the question-mark placeholder for a locked album, but
its 2x2 preview thumbnail in the category-tile view (the screen before
drilling into an artist/category) still rendered the real cover art,
since that Cover call never received the locked prop.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 21:37:57 +02:00
7f5e2733c2 Merge the typing game into the music player as a tab, with lock/unlock UI
Moves tippen from a standalone app into web/ as a third tab (audio player /
smarthome / typing), replacing the old single room-toggle corner button with a
vertical icon tab rail. Curriculum and progress now come from the backend
(musicmouse/tippen/*) instead of a build-time YAML import and localStorage.

Adds reward-driven lock rendering: Cover/BrowseView/AlbumModal show a question
mark for locked albums/tracks with a hint on what unlocks them, and
ResultSheet gets a new unlock-animation block alongside the existing
lesson-unlock and aquarium-creature celebrations.

CSS from the two apps is merged carefully: identical rules (bubble/card/
key-cap/view-enter/backdrop-enter and their keyframes) are shared as-is,
while rules that bake in each app's own hue are kept separate under a
`tp-` prefix and scoped to the typing tab's own .tp-stage wrapper, so
neither app's look bleeds into the other's.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 21:25:42 +02:00
f7a5d24d8d Add backend support for the typing game: curriculum, progress, and reward unlocks
Moves the typing app's lesson plan and progress from client-side YAML/localStorage
into the backend, and adds a reward system that ties passing a lesson to unlocking
part of the music library. Lock state is always recomputed live from curriculum x
progress x the live library, never persisted separately.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 21:03:45 +02:00
a5210fead2 Typing lessons like duolingo & musicmouse cleanup 2026-09-12 18:58:02 +02:00
498243af46 Anglicize tippen's codebase and finish pending UI/curriculum cleanup
- Rename all German identifiers, types, mode ids, file names, CSS classes
  and data-attributes to English throughout tippen/src; only user-facing
  text (lesson titles, word lists, labels, spoken praise) stays German.
- Add a word/nonsense-word list to the "Übung: die Grundstellung" home-row
  lesson in the curriculum.
- Remove the unused HandHint component and speech.ts, and carry forward
  the in-progress App.tsx/component/generator/progress edits from other
  sessions.
- Refresh the regenerated music-library cache index.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 00:07:18 +02:00
f97de193d8 First implementation of 10 finger typing 2026-09-11 21:55:45 +02:00
7e5fd5ab75 Animations 2026-09-11 13:56:02 +02:00
fbf03a9847 UI cleanup, visual and keyboard navigation 2026-09-11 13:32:07 +02:00
df89acd9a8 Fix podcast episode downloads: ffmpeg muxer bug and dead-URL retry storm
_extract_audio wrote transcoded audio to a .tmp temp path, so ffmpeg
couldn't guess a muxer from the filename and aborted on every video
episode whose source was still reachable. Pass -f mp3 explicitly instead
of relying on the extension.

Separately, a permanently 404'd episode was retried on every startup and
every 6-hour poll forever, since nothing remembered past failures. Track
failed downloads per show folder with a 7-day backoff before retrying.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 12:30:42 +02:00
829ea89386 Fix search: allow spaces and match multi-word queries as separate words
Space was hard-coded as the global play/pause toggle, so it never reached
the search-typing logic - typing "geolino azte" would toggle playback
instead of adding the space. Now a space is appended to an in-progress
search instead.

Search also required the whole query to be one contiguous substring of
title + artist, which misses queries like "conni rad" for "Conni lernt
Rad fahren" or "geolino azte" for a podcast episode titled "Azteken" by
"GEOlino Spezial" (title comes before artist). Each word is now matched
independently, in any order.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 12:11:36 +02:00
a3b2c0ce2f Add a vim-style keyboard layer: Ctrl navigates, Shift controls transport
Ctrl+h/j/k/l move the highlight in whatever list is on screen - the browse
grid, search results, or an open album's track list - the same job the
arrow keys already do, just reachable without leaving the home row.
Ctrl+d/u add vim's own half-page jump. Shift+h/j/k/l/m are transport
(previous/next, volume, mute) from anywhere, including the room page,
matching the muscle memory of other vim-ish media apps; every other
Shift+letter still reaches search untouched; only the shifted letter
form of those five keys is intercepted.

Shift+Enter opens an album's track list (the keyboard equivalent of
clicking its cover, which had no key of its own until now) instead of
playing it outright; Ctrl+j/k then move a visible highlight through that
list, and Enter plays whichever track is highlighted. Falls back to plain
ENTER's behaviour for a category tile or a podcast episode, neither of
which has a track list to show.

Mute (Shift+M) is new - nothing was bound to it before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 11:40:40 +02:00
ba7f082f48 Rework navigation for a consistent music/room toggle and back button
- Replace the "Mein Zimmer"/"Musik" text pills and the help button with
  two fixed circular corner buttons: a music/room toggle (top-right)
  and a single "back one level" button (top-left) used everywhere -
  search, categories, and the play view.
- Split `view` (browse/play) from a new `page` (music/room) in UiState
  so toggling to the room and back leaves the music side - search,
  selection, even the full-screen play view - exactly as it was.
- Back peels one step at a time (search, then track-search mode, then
  group+category together, since a root shelf tile sets both at once
  and undoing that jump should be one step too).
- Album cards: click the cover to open the track list, click the title
  to start playing immediately (matching what Enter already did from
  the keyboard). Audiobook cards drop the artist line and clamp the
  title to a fixed height so covers stay aligned across a row.
- Root shelf group headings ("Musik", "Hörbücher", "Podcasts") are now
  the link into that group's full category grid, replacing the
  separate search-icon button.
- "Taste zuweisen" is now an icon-only round button, moved to the
  bottom-right so it doesn't sit under the new toggle.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 11:12:39 +02:00
69119bb72a Add per-episode podcast cover art and fix nested album grouping
- podcast_feeds.py: fetch per-episode cover art from a feed's itunes:image
  (when it's genuinely distinct from the channel image) or, failing that,
  from the og:image on the episode's own linked page; extract audio from
  video-only enclosures via ffmpeg; match podcast-dl's filename convention
  (illegal characters become "_" instead of being dropped) so enabling
  feed.txt on an already-downloaded show doesn't re-download its back catalog
- scanner.py: _cover_for_episode now checks for a same-stem sidecar cover
  image before falling back to embedded ID3 art and the shared folder cover
- scanner.py/__init__.py: fixed a bug where an album folder nested one level
  deeper than usual (an age-range grouping folder, say) was mistaken for an
  empty album and skipped; added periodic progress logging for long scans
  and analysis passes

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 09:54:33 +02:00
747c390303 Add IR remote control (LIRC) with a number-key content mapping
Adds a TCP client for lircd's classic protocol: play/pause/next/prev/
volume/mute map to the same intents every other front-end already
emits, and number keys 0-9 play an assigned album/audiobook from the
start or a podcast show's newest episode, resolved fresh on every
press. The mapping is configured in config.yml and editable from the
frontend: a small "Taste zuweisen" button on the play screen (or the
A+digit keyboard shortcut) opens a 10-key picker to assign whatever is
currently playing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 08:31:28 +02:00
57afc32f4a Auto-download new podcast episodes from an RSS feed
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>
2026-09-10 22:43:50 +02:00
2e0e6ad199 Add librosa beat/mood analysis and an Ambience background driven by it
The background worker now runs a real librosa analyzer (tempo, beat grid,
per-second energy/valence curves) instead of only the null baseline, kept
behind build_analyzer() so a plain checkout without the analysis extra still
runs fine. The web player reads that per-track analysis and drives a new
animated "Ambience" background (bubbles, colour, current) that reacts to the
beat and the mood curve as the track plays, plus a debug overlay for tuning
it. Also adds a one-off script to backfill podcast cover art from iTunes.
2026-09-10 22:42:51 +02:00
8aed3b022b updates 2026-08-27 23:46:19 +02:00
a8ed350aec Add "Mein Zimmer" room-control page (Home Assistant, proxied through the backend)
Implements the room-control page from the design mockup: a scenes row above cards
for shutters, color lamps, and brightness-only lamps, all driven by a new
`general.ha` config section (server URL, token, ordered device/scene lists).

The backend proxies every Home Assistant call server-side (GET/POST /api/ha/...)
rather than the browser calling Home Assistant directly, so the long-lived token
never leaves the LAN device and Home Assistant's own CORS settings don't need to
know about musicmouse at all. Card kind (shutter/color/brightness-only) is
inferred at runtime from what Home Assistant reports about each entity, not
configured explicitly.

Also stops tracking python-backend/config.yml, which had drifted into the repo
despite its own header saying it shouldn't be - it now carries real credentials
locally and needs to stay untracked.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 23:45:29 +02:00
edb6e5e027 Web frontend 2026-08-27 12:32:20 +02:00
d44c24ec97 Full rearchitecture using Claude
- event bus systen
- all components are independent
- preparation for web frontend
2026-08-26 13:22:28 +02:00
55bcc9448c color parsing cleanup 2026-08-26 11:28:48 +02:00
1cb3387fbe Fix: lights now should turn off correctly when playlist ends 2026-08-26 10:54:40 +02:00
7aa7fe4693 Claude cleanup 2026-08-26 10:38:56 +02:00
bd8925a278 Cleaned up repository
- only moving files around
2026-08-25 17:06:53 +02:00
309 changed files with 547398 additions and 1055 deletions

8
.gitignore vendored
View File

@@ -1,6 +1,14 @@
generated_3d generated_3d
venv venv
.venv
build build
*.egg-info
*.FCStd1 *.FCStd1
*.blend1 *.blend1
__pycache__ __pycache__
.ipynb_checkpoints
.pytest_cache
.mypy_cache
.ruff_cache
.envrc
.direnv

125
docs/REPO_OVERVIEW.md Normal file
View File

@@ -0,0 +1,125 @@
# MusicMouse — repo overview
Orientation doc for AI agents (or humans) working on this repo for the first time.
## What this is
MusicMouse is a DIY, Toniebox-style physical music player for kids, shaped like a mouse
and living on a shelf. Small 3D-printed animal figurines (fox, owl, dog, elephant,
squirrel, crocodile, rabbit, snowman, puppy — see `hardware/3dprints/figures/`) each
carry an RFID tag. Placing a figurine on the mouse starts that figure's playlist. The
mouse also has a rotary encoder and capacitive touch areas (ears/feet) for
volume/skip control, addressable RGBW LED rings with animated effects, and MQTT/Home
Assistant integration.
## Repo layout
| Path | What it is |
|---|---|
| `python-backend/` | Python host application — the main runtime. Has its own `README.md` with the architecture; start there for backend work. |
| `web/` | React + TypeScript front-end (Vite). Browse the whole library and play any of it from a browser. Built output is served by `python-backend` itself. |
| `esp-firmware/` | ESP32 firmware (C++, Arduino framework via PlatformIO). Reads the RFID reader and buttons, drives the LED strips, talks to `python-backend` over serial. |
| `hardware/` | 3D-print models for the figurines and enclosure (FreeCAD/Blender/OBJ/STL), a Fritzing electronics sketch, datasheets, and `pinout.md` (RFID reader + button-board wiring). |
| `claude-design/` | The interaction and visual spec the web front-end was built from ("Dolphin Beats"), as a standalone HTML mockup with hardcoded data. `web/` is the real implementation; the mockup is kept as the reference for the keyboard model and the styling. It also contains a second page, "Mein Zimmer" (room lights), which is **not** implemented. |
## How the pieces talk to each other
- **ESP32 firmware ↔ `python-backend`**: a length-prefixed binary protocol over serial.
Frames are `uint32 magic | uint8 type | uint16 size | payload`, little-endian, with
firmware log text interleaved on the same link. The Python side lives in
`python-backend/musicmouse/devices/wire.py`, the firmware side in
`esp-firmware/src/Messages.h`.
The contract is hand-duplicated in two languages. `tests/test_wire.py` parses
`Messages.h` and fails if the message ids drift, and `tests/test_effects.py` pins the
exact bytes of every effect payload — so a firmware change that breaks the host now
breaks a test instead of just the LEDs.
- **`python-backend``web/`**: JSON over HTTP for the library and for commands, plus a
push-only websocket at `/api/ws` for state. Commands emit exactly the same *intents*
the physical buttons emit, so the web UI has no privileged path — and no way to get
out of step with a figure someone puts on the reader. See
`python-backend/README.md` for the endpoint list.
- **`python-backend` ↔ Home Assistant**: MQTT only. The backend publishes three
discoverable lights, a player sensor, a volume number, transport buttons, device
triggers for every button and touch area, and a tag scanner for the RFID reader. It
does *not* call Home Assistant services directly any more; behaviour like "the left
ear turns the room light pink" is an HA automation. See `python-backend/README.md`
for the trigger topics and the old colour mapping.
## Running it
```sh
python -m musicmouse --config /media/musicmouse/config.yml
```
On a machine with no mouse attached — real audio and a real web UI, no serial port:
```sh
python -m musicmouse --config ./config.yml --no-hardware
```
`general.serial_port: simulate` does the same thing from the config, and
`general.alsa_device: simulate` swaps in a silent player. Both warn at startup, and
both keys are required - omitting one is an error rather than an implicit simulation.
Or with no hardware *and* no audio:
```sh
python -m musicmouse --config ./config.yml --simulate
```
The simulator runs the whole app against a fake serial link and a fake player, either
interactively or from a scenario file. `python-backend/musicmouse.service` is the
systemd unit for the device.
For front-end work, run the backend (either way above) and then:
```sh
cd web && npm install && npm run dev # http://localhost:5173, /api proxied to :8080
```
`npm run build` writes `web/dist`, which `general.web.static_dir` points at in
production so one process serves both the UI and the API.
## Config
`python-backend/config.yml.example` documents the schema. In short:
- `general.{library.*, serial_port, baudrate, reconnect_interval, alsa_device,
min_volume, max_volume, initial_volume, volume_increment, button_leds_brightness,
audio_extensions, mqtt.*, web.*}`
- `figures.<name>.{id, colors}` — `id` is a 5-byte hex RFID tag, unique per figure;
`colors` is exactly four (`primary, secondary, bg, accent`), each `"#rrggbb"` or
`"wNN"`.
`general.library.root` is the one path to the music. The shelves under it are fixed
names, not settings, because each has quirks the scanner knows about:
```
<root>/Figuren/<figure name>/ one folder per figurine
<root>/Musik/<Artist> - <Album>/ albums, grouped by artist
<root>/Hörbücher/<Artist> - <Album>/ audiobooks, grouped by character
<root>/Kinderpodcasts/<Show>/ shows, newest episode first
```
Config is validated with pydantic: unknown keys are errors, and every problem is
reported at once.
## Notes for agents
- The backend has `pytest`, `ruff` and `mypy --strict` configured in
`python-backend/pyproject.toml`, and no CI. Run all three before proposing changes.
- Prefer adding a scenario in `python-backend/scenarios/` over a hand-rolled test when
the behaviour is end-to-end — those files are executed by the test suite.
- `python-backend/notebooks/` is university course material on chord recognition, not
part of the app.
- The firmware has no automated tests beyond a PlatformIO `native` env for LED effects.
- The front-end has `vitest` over the pure modules (`web/src/lib/`) and `tsc --noEmit`;
there is no component-level test harness. `npm run test` and `npx tsc --noEmit` are
the two checks.
- The library index is cached under `general.library.cache`. It is keyed by file mtime
and size, so **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.

View File

@@ -1,74 +0,0 @@
Reader
----------
- GND black
- RST blue 3.3V
- 3.3V red
- MISO brown 21
- SDA green 19
- SCK yellow 18
- MOSI orange 5
- IRQ green single cable not connected
Button Board:
-------------
- rot in | white 13
- btn2 led | grey 12
- btn2 in | purple 14
- rotB | blue 27
- rotA | green 26
- btn1 in | yellow 25
- btn1 led | orange 33
rot="rotary encoder"
in=button sense in
led = 5V pwm
Firmware Planning
-----------------
- input commands:
- led: effect + parameters
- off
- single color
- multiple color HSV fade, list of colors with timings
- circular motion (already exists)
- chained events? e.g. circle two times then fade
- effects:
- welle fuer an und aus
- breathe waehrend an, oder farbgradient
-
- output infos:
- nfc read: with id
- nfc remove
- button presses, (possible also long press, double click, etc)
- rotary encoder up down + current numeric state
- on led effect end?
TODO
----
1) case redesign
- slightly smaller led ring (10mm -> 9mm) [ok]
- thicker top of inner ring, but cutouts for reader [ok]
- adjust reader stands position [ok]
- bottom for led ring snap-in [ok]
- bottom for inner ring [ok]
- stands for own "pcb" [ok]
- 2 cutouts for cables [ok]
- checks, compared to existing print
- same diameter, very slightly smaller
- larger overlap of LED ring
- minimal wall thickness for led ring top and side
- check total height - compare to existing

View File

@@ -1,47 +0,0 @@
"""Some simple tests/examples for the Home Assistant client."""
import asyncio
import logging
import sys
from hass_client import HomeAssistantClient
LOGGER = logging.getLogger()
if __name__ == "__main__":
logformat = logging.Formatter(
"%(asctime)-15s %(levelname)-5s %(name)s.%(module)s -- %(message)s")
consolehandler = logging.StreamHandler()
consolehandler.setFormatter(logformat)
LOGGER.addHandler(consolehandler)
LOGGER.setLevel(logging.DEBUG)
if len(sys.argv) < 3:
LOGGER.error("usage: test.py <url> <token>")
sys.exit()
url = sys.argv[1]
token = sys.argv[2]
loop = asyncio.get_event_loop()
hass = HomeAssistantClient(url, token)
async def hass_event(event, event_details):
"""Handle hass event callback."""
LOGGER.info("received event %s --> %s\n", event, event_details)
hass.register_event_callback(hass_event)
async def run():
"""Run tests."""
await hass.async_connect()
await asyncio.sleep(10)
await hass.async_close()
loop.stop()
try:
loop.create_task(run())
loop.run_forever()
except KeyboardInterrupt:
loop.stop()
loop.close()

View File

@@ -1,198 +0,0 @@
import asyncio
from enum import Enum
import struct
from led_cmds import *
MAGIC_TOKEN_HOST_TO_FW = 0x1d6379e3
MAGIC_TOKEN_FW_TO_HOST = 0x10c65631
class MessageFwToHost(Enum):
RFID_TOKEN_READ = 0
ROTARY_ENCODER = 1
TOUCH_BUTTON_PRESS = 2
TOUCH_BUTTON_RELEASE = 3
class TouchButton(Enum):
LEFT_FOOT = 0
RIGHT_FOOT = 1
LEFT_EAR = 2
RIGHT_EAR = 3
led_ring_effect_to_message_id = {
EffectStaticConfig: 0,
EffectAlexaSwipeConfig: 1,
EffectCircularConfig: 2,
EffectRandomTwoColorInterpolationConfig: 3,
EffectSwipeAndChange: 4,
EffectReverseSwipe: 5,
}
mouse_led_effect_to_message_id = {
EffectStaticConfig: 6,
EffectCircularConfig: 7,
EffectRandomTwoColorInterpolationConfig: 8,
EffectSwipeAndChange: 9,
EffectReverseSwipe: 10,
}
shelve_led_effect_to_message_id = {
EffectStaticConfig: 15,
EffectCircularConfig: 16,
EffectRandomTwoColorInterpolationConfig: 17,
EffectSwipeAndChange: 18,
EffectReverseSwipe: 19,
EffectStaticDetailedConfig: 20,
}
mouse_leds_index_ranges = {
TouchButton.RIGHT_FOOT: (0, 6),
TouchButton.LEFT_FOOT: (6, 6 + 6),
TouchButton.LEFT_EAR: (6 + 6, 6 + 6 + 16),
TouchButton.RIGHT_EAR: (6 + 6 + 16, 6 + 6 + 16 + 17),
}
PREV_BUTTON_LED_MSG = 21
NEXT_BUTTON_LED_MSG = 22
class RfidTokenRead:
def __init__(self, id: bytes):
self.id = id
def __repr__(self):
return "RFID Token (" + " ".join(f"{v:02x}" for v in self.id) + ")"
class RotaryEncoderEvent:
def __init__(self, msg_content: bytes):
self.position, self.increment, self.direction = struct.unpack("<iiB", msg_content)
def __repr__(self):
return f"Rotary event: pos {self.position}, incr {self.increment}, dir {self.direction}"
class TouchButtonPress:
def __init__(self, msg_content: bytes):
val = int(msg_content[0])
self.touch_button = TouchButton(val)
def __repr__(self) -> str:
return "Pressed " + repr(self.touch_button)
class TouchButtonRelease:
def __init__(self, msg_content: bytes):
val = int(msg_content[0])
self.touch_button = TouchButton(val)
def __repr__(self) -> str:
return "Released " + repr(self.touch_button)
class ButtonEvent:
button_name = {1: 'left', 2: 'right', 3: 'rotary'}
event_name = {
0: 'pressed',
1: 'released',
2: 'clicked',
3: 'double_clicked',
4: 'long_pressed',
5: 'repeat_pressed',
6: 'long_released'
}
def __init__(self, msg_content: bytes):
button_nr, event_nr = struct.unpack("<BB", msg_content)
self.button = self.button_name[button_nr]
self.event = self.event_name[event_nr]
def __repr__(self) -> str:
return f"Button {self.button} {self.event}"
incomingMsgMap = {
0: RfidTokenRead,
1: RotaryEncoderEvent,
2: TouchButtonPress,
3: TouchButtonRelease,
4: ButtonEvent,
}
class MusicMouseProtocol(asyncio.Protocol):
def __init__(self):
super()
self._msg_callback = None
def register_message_callback(self, cb):
self._msg_callback = cb
def connection_made(self, transport):
self.transport = transport
self.in_buff = bytes()
def __led_effect(self, effect_cfg, msg_dict):
msg_content = effect_cfg.as_bytes()
header = struct.pack("<IBH", MAGIC_TOKEN_HOST_TO_FW, msg_dict[type(effect_cfg)],
len(msg_content))
self.transport.write(header + msg_content)
def led_ring_effect(self, effect_cfg):
self.__led_effect(effect_cfg, led_ring_effect_to_message_id)
def mouse_led_effect(self, effect_cfg):
self.__led_effect(effect_cfg, mouse_led_effect_to_message_id)
def shelve_led_effect(self, effect_cfg):
self.__led_effect(effect_cfg, shelve_led_effect_to_message_id)
def button_background_led_prev(self, val):
msg_content = struct.pack("<f", val)
header = struct.pack("<IBH", MAGIC_TOKEN_HOST_TO_FW, PREV_BUTTON_LED_MSG, len(msg_content))
self.transport.write(header + msg_content)
def button_background_led_next(self, val):
msg_content = struct.pack("<f", val)
header = struct.pack("<IBH", MAGIC_TOKEN_HOST_TO_FW, NEXT_BUTTON_LED_MSG, len(msg_content))
self.transport.write(header + msg_content)
def data_received(self, data):
self.in_buff += data
self._parse_message()
def connection_lost(self, exc):
print('port closed')
self.transport.loop.stop()
def pause_writing(self):
print('pause writing')
print(self.transport.get_write_buffer_size())
def resume_writing(self):
print(self.transport.get_write_buffer_size())
print('resume writing')
def _parse_message(self):
HEADER_SIZE = 4 + 1 + 2
if len(self.in_buff) == 0:
return
if len(self.in_buff) >= HEADER_SIZE:
token, msg_type, msg_size = struct.unpack("<IBH", self.in_buff[:HEADER_SIZE])
if token == MAGIC_TOKEN_FW_TO_HOST and len(self.in_buff) >= HEADER_SIZE + msg_size:
self._on_msg_receive(msg_type, self.in_buff[HEADER_SIZE:HEADER_SIZE + msg_size])
self.in_buff = self.in_buff[HEADER_SIZE + msg_size:]
else:
idx = self.in_buff.find("\n".encode())
if idx >= 0:
text_msg = self.in_buff[:idx]
print("LOG:", text_msg.decode())
self.in_buff = self.in_buff[idx + 1:]
def _on_msg_receive(self, msg_type, msg_payload):
parsed_msg = incomingMsgMap[msg_type](msg_payload)
if self._msg_callback is not None:
self._msg_callback(self, parsed_msg)

View File

@@ -1,167 +0,0 @@
from dataclasses import dataclass, field
import struct
import colorsys
@dataclass
class ColorRGBW:
r: float
g: float
b: float
w: float
def __repr__(self):
return f"#({self.r}, {self.g}, {self.b}, {self.w})"
def as_bytes(self) -> bytes:
assert self.is_valid(), "Trying to send invalid " + repr(self)
return struct.pack("<BBBB", int(self.r * 255), int(self.g * 255), int(self.b * 255),
int(self.w * 255))
def is_valid(self):
vals = (self.r, self.g, self.b, self.w)
return all(0 <= v <= 1 for v in vals)
def __mul__(self, other:float):
assert 0<= other <= 1
return ColorRGBW(self.r * other, self.g * other, self.b * other, self.w * other)
def __eq__(self, other:'ColorRGBW'):
return self.r == other.r and self.g == other.g and self.b == other.b and self.w == other.w
def __neq__(self, other:'ColorRGBW'):
return not self == other
def without_white_channel(self, scale=1):
args = (min(1, e + self.w) for e in (self.r, self.g, self.b) )
return ColorRGBW(*args, 0)
@dataclass
class ColorHSV:
h: float
s: float
v: float
@staticmethod
def fromRGB(rgb):
conv = colorsys.rgb_to_hsv(rgb.r, rgb.g, rgb.b)
return ColorHSV(conv[0] * 360, conv[1], conv[2])
def __repr__(self):
return f"ColorHSV({self.h}, {self.s}, {self.v})"
def as_bytes(self) -> bytes:
return struct.pack("<fff", self.h, self.s, self.v)
def is_valid(self):
if not 0 <= self.h <= 360:
return False
if not 0 <= self.s <= 1:
return False
if not 0 <= self.v <= 2:
return False
return True
@dataclass
class EffectStaticConfig:
color: ColorRGBW
begin: int = 0
end: int = 0
def __repr__(self):
return f"EffectStaticConfig {str(self.color)}, beg: {self.begin}, end {self.end}"
def as_bytes(self) -> bytes:
return self.color.as_bytes() + struct.pack("<HH", self.begin, self.end)
@dataclass
class EffectStaticDetailedConfig:
color: ColorRGBW
increment: int = 1
begin: float = 0.0
end: float = 1.0
transition_time_in_ms : float = 500
def __repr__(self):
return f"EffectStaticDetailedConfig {str(self.color)}, beg: {self.begin}, end {self.end}, incr {self.increment}, transition in ms {self.transition_time_in_ms}"
def as_bytes(self) -> bytes:
return self.color.as_bytes() + struct.pack("<Hfff", self.increment, self.begin, self.end, self.transition_time_in_ms)
@dataclass
class EffectAlexaSwipeConfig:
primary_color_width: float = 20 # in degrees
transition_width: float = 30 # in degrees
swipe_speed: float = 2 * 360 # in degrees per second
bell_curve_width_in_leds: float = 3
start_position: float = 180 # in degrees
forward: bool = True
primary_color: ColorRGBW = field(default_factory=lambda: ColorRGBW(0, 0, 1, 0))
secondary_color: ColorRGBW = field(default_factory=lambda: ColorRGBW(0, 200 / 255, 1, 0))
def as_bytes(self) -> bytes:
return struct.pack(
"<fffff?", self.primary_color_width, self.transition_width, self.swipe_speed,
self.bell_curve_width_in_leds, self.start_position,
self.forward) + self.primary_color.as_bytes() + self.secondary_color.as_bytes()
def __repr__(self):
return f"EffectAlexaSwipe primary {str(self.primary_color)}, {str(self.secondary_color)}"
@dataclass
class EffectRandomTwoColorInterpolationConfig:
cycle_durations_ms: int = 6000
start_with_existing: bool = True
num_segments: int = 3
hue1_random: bool = False
hue2_random: bool = False
color1: ColorHSV = field(default_factory=lambda: ColorHSV(240, 1, 1))
color2: ColorHSV = field(default_factory=lambda: ColorHSV(192, 1, 1))
def as_bytes(self) -> bytes:
c1 = ColorHSV.fromRGB(self.color1) if isinstance(self.color1, ColorRGBW) else self.color1
c2 = ColorHSV.fromRGB(self.color2) if isinstance(self.color2, ColorRGBW) else self.color2
return struct.pack("<i?i??", self.cycle_durations_ms, self.start_with_existing,
self.num_segments, self.hue1_random,
self.hue2_random) + c1.as_bytes() + c2.as_bytes()
def __repr__(self):
return f"RandTwoColor {str(self.color1)}, {str(self.color2)}, segments {self.num_segments}"
@dataclass
class EffectCircularConfig:
speed: float = 360 # in degrees per second
width: float = 180 # in degrees
color: ColorRGBW = field(default_factory=lambda: ColorRGBW(0, 0, 1, 0))
def as_bytes(self) -> bytes:
return struct.pack("<ff", self.speed, self.width) + self.color.as_bytes()
@dataclass
class EffectSwipeAndChange:
swipe: EffectAlexaSwipeConfig = field(default_factory=lambda: EffectAlexaSwipeConfig())
change: EffectRandomTwoColorInterpolationConfig = field(default_factory=lambda: EffectRandomTwoColorInterpolationConfig())
def as_bytes(self) -> bytes:
return self.swipe.as_bytes() + self.change.as_bytes()
def __repr__(self) -> str:
return f"Swipe and Change: \n {str(self.swipe)}\n {str(self.change)}"
@dataclass
class EffectReverseSwipe:
swipeSpeed: float = 2 * 360
bellCurveWidthInLeds: float = 3
startPosition: float = 180
def as_bytes(self) -> bytes:
return struct.pack("<fff", self.swipeSpeed, self.bellCurveWidthInLeds, self.startPosition)
def __repr__(self) -> str:
return f"Reverse swipe, speed {self.swipeSpeed}, width in leds {self.bellCurveWidthInLeds}, start position {self.startPosition}"

View File

@@ -1,271 +0,0 @@
#!/usr/bin/env python
import asyncio
import sys
import serial_asyncio
from led_cmds import (ColorRGBW, ColorHSV, EffectCircularConfig, EffectStaticConfig,
EffectRandomTwoColorInterpolationConfig, EffectAlexaSwipeConfig,
EffectSwipeAndChange, EffectReverseSwipe)
from host_driver import MusicMouseProtocol, RfidTokenRead, RotaryEncoderEvent, ButtonEvent, TouchButton, TouchButtonPress, TouchButtonRelease, mouse_leds_index_ranges
from player import AudioPlayer
from glob import glob
from copy import deepcopy
import os
from hass_client import HomeAssistantClient
import argparse
from ruamel.yaml import YAML
import warnings
from pprint import pprint
from typing import Optional
from mqtt_json import start_mqtt
import aiohttp
yaml = YAML(typ='safe')
OFF_COLOR = ColorRGBW(0, 0, 0, 0)
def parse_color(color_str: str):
if isinstance(color_str, ColorRGBW):
return color_str
elif color_str.startswith("#"):
color_str = color_str.lstrip('#')
t = tuple(int(color_str[i:i + 2], 16) / 255 for i in (0, 2, 4))
return ColorRGBW(*t, 0)
elif color_str.startswith("w"):
color_str = color_str.lstrip("w")
return ColorRGBW(0, 0, 0, int(color_str, 16) / 255)
def load_config(config_path):
with open(os.path.join(config_path, "config.yml")) as cfg_file:
cfg = yaml.load(cfg_file)
for figure_name, figure_cfg in cfg["figures"].items():
figure_cfg["colors"] = [parse_color(c) for c in figure_cfg["colors"]]
if 'media_files' not in figure_cfg:
figure_cfg['media_files'] = sorted(glob(os.path.join(config_path, figure_name)))
return cfg
def hass_service(hass, domain, service, **kwargs):
asyncio.create_task(hass.call_service(domain, service, kwargs))
class MusicMouseState:
def __init__(self, protocol: MusicMouseProtocol):
self.active_figure: Optional[
str] = None # None if no figure is placed on the reader, or the name of the figure
self.last_partially_played_figure: Optional[
str] = None # figure whose playlist wasn't played completely and was removed
self.current_mouse_led_effect = None
self.current_led_ring_effect = None
self.protocol: MusicMouseProtocol = protocol
self.button_led_brightness = None
def mouse_led_effect(self, effect_cfg):
self.current_mouse_led_effect = effect_cfg
self.protocol.mouse_led_effect(effect_cfg)
def led_ring_effect(self, effect_cfg):
self.current_led_ring_effect = effect_cfg
self.protocol.led_ring_effect(effect_cfg)
self.protocol.shelve_led_effect(effect_cfg)
def button_leds(self, brightness):
assert 0 <= brightness <= 1
self.protocol.button_background_led_prev(brightness)
self.protocol.button_background_led_next(brightness)
self.button_led_brightness = brightness
def reset(self):
self.mouse_led_effect(EffectStaticConfig(OFF_COLOR))
self.led_ring_effect(EffectStaticConfig(OFF_COLOR))
class Controller:
def __init__(self, protocol, hass, cfg):
self.cfg = cfg
self.audio_player = AudioPlayer(cfg["general"]["alsa_device"])
self.audio_player.set_volume(50)
self.mmstate = MusicMouseState(protocol)
self.protocol = protocol
self.hass = hass
vol_min = self.cfg["general"].get("min_volume", None)
vol_max = self.cfg["general"].get("max_volume", None)
self.audio_player.set_volume_limits(vol_min, vol_max)
protocol.register_message_callback(self.on_firmware_msg)
self.audio_player.on_playlist_end_callback = self._on_playlist_end
self.playlists = {
fig: self.audio_player.create_playlist(fig_cfg['media_files'])
for fig, fig_cfg in cfg['figures'].items()
}
self._rfid_to_figure_name = {
bytes.fromhex(figure_cfg["id"]): figure_name
for figure_name, figure_cfg in cfg["figures"].items()
}
self.protocol.shelve_led_effect(EffectStaticConfig(ColorRGBW(0, 0, 0.1, 0)))
shelf_eff = EffectCircularConfig()
shelf_eff.color = ColorRGBW(0, 0, 0.4, 0)
shelf_eff = EffectStaticConfig(ColorRGBW(0, 0, 0, 0))
self.protocol.shelve_led_effect(shelf_eff)
def _on_playlist_end(self):
if not self.audio_player.is_playing():
self.mmstate.last_partially_played_figure = None
self._run_off_animation()
else:
print("Playlist end was called, even if player remains playing?!")
def handle_rfid_event(self, tagid):
if tagid == bytes.fromhex("0000000000"):
if self.audio_player.is_playing():
print("Got 000 rfid -> playing off animation")
self._run_off_animation()
self.audio_player.pause()
self.mmstate.last_partially_played_figure = self.mmstate.active_figure
else:
self.mmstate.last_partially_played_figure = None
self.mmstate.active_figure = None
elif tagid in self._rfid_to_figure_name:
newly_placed_figure = self._rfid_to_figure_name[tagid]
primary_color, secondary_color, *rest = self.cfg["figures"][newly_placed_figure][
"colors"]
self._start_animation(primary_color, secondary_color)
self.mmstate.button_leds(self.cfg["general"].get("button_leds_brightness", 0.5))
if newly_placed_figure in self.cfg['figures']:
if self.mmstate.last_partially_played_figure == newly_placed_figure:
print("Continuing playlist")
self.audio_player.play()
else:
print("Restarting playlist")
self.audio_player.set_playlist(
self.audio_player.create_playlist(
self.cfg['figures'][newly_placed_figure]['media_files']))
self.audio_player.play_from_start()
self.mmstate.active_figure = newly_placed_figure
else:
warnings.warn(f"Unknown figure/tag with id {tagid}")
def on_firmware_msg(self, _, message):
print("FW msg:", message)
if isinstance(message, RfidTokenRead):
self.handle_rfid_event(message.id)
elif isinstance(message, RotaryEncoderEvent):
volume_increment = self.cfg["general"].get("volume_increment", 2) * abs(
message.increment)
if message.direction == 2:
self.audio_player.change_volume(volume_increment)
elif message.direction == 1:
self.audio_player.change_volume(-volume_increment)
elif isinstance(message, ButtonEvent):
btn = message.button
if btn == "left" and message.event == "pressed" and self.audio_player.is_playing():
self.audio_player.previous()
elif btn == "right" and message.event == "pressed" and self.audio_player.is_playing():
self.audio_player.next()
elif message.button == "rotary" and message.event == "pressed":
hass_service(self.hass, "light", "toggle", entity_id="light.kinderzimmer_fluter")
elif isinstance(message, TouchButtonPress):
figure = self.mmstate.active_figure
if figure and self.audio_player.is_playing():
primary_color, secondary_color, bg, accent = self.cfg["figures"][figure]["colors"]
self.protocol.mouse_led_effect(
EffectStaticConfig(accent, *mouse_leds_index_ranges[message.touch_button]))
colors = {
TouchButton.RIGHT_FOOT: {
'rgb_color': [235, 255, 67]
},
TouchButton.LEFT_FOOT: {
'color_temp': 469
},
TouchButton.RIGHT_EAR: {
'rgb_color': [101, 49, 255]
},
TouchButton.LEFT_EAR: {
'rgb_color': [255, 74, 254]
},
}
hass_service(
self.hass,
"light",
"turn_on",
entity_id=["light.kinderzimmer_fluter", "light.music_mouse_regal_licht"],
**colors[message.touch_button])
elif isinstance(message, TouchButtonRelease):
figure = self.mmstate.active_figure
eff_change = EffectRandomTwoColorInterpolationConfig()
eff_static = EffectStaticConfig(ColorRGBW(0, 0, 0, 0),
*mouse_leds_index_ranges[message.touch_button])
if self.audio_player.is_playing():
primary_color, secondary_color, bg, accent = self.cfg["figures"][figure]["colors"]
eff_static.color = primary_color
self.protocol.mouse_led_effect(eff_static)
if self.audio_player.is_playing():
primary_color, secondary_color, bg, accent = self.cfg["figures"][figure]["colors"]
eff_change.color1 = primary_color
eff_change.color2 = secondary_color
eff_change.start_with_existing = True
self.protocol.mouse_led_effect(eff_change)
def _start_animation(self, primary_color, secondary_color):
ring_eff = EffectSwipeAndChange()
ring_eff.swipe.primary_color = primary_color
ring_eff.swipe.secondary_color = secondary_color
ring_eff.swipe.swipe_speed = 180
ring_eff.change.color1 = primary_color
ring_eff.change.color2 = secondary_color
self.mmstate.led_ring_effect(ring_eff)
mouse_eff = deepcopy(ring_eff)
mouse_eff.swipe.start_position = 6 / 45 * 360
mouse_eff.swipe.bell_curve_width_in_leds = 16
mouse_eff.swipe.swipe_speed = 180
self.mmstate.mouse_led_effect(mouse_eff)
def _run_off_animation(self):
print("Running off animation")
ring_eff = EffectReverseSwipe()
self.mmstate.led_ring_effect(ring_eff)
mouse_eff = EffectReverseSwipe()
mouse_eff.startPosition = 6 / 45 * 360
self.mmstate.mouse_led_effect(mouse_eff)
self.mmstate.button_leds(0)
def main(config_path):
cfg = load_config(config_path)
loop = asyncio.get_event_loop()
hass = HomeAssistantClient(cfg["general"]["hass_url"], cfg["general"]["hass_token"], loop=loop)
coro = serial_asyncio.create_serial_connection(loop,
MusicMouseProtocol,
cfg["general"]["serial_port"],
baudrate=115200)
transport, protocol = loop.run_until_complete(coro)
controller = Controller(protocol, hass, cfg)
mqtt_cfg = cfg["general"]["mqtt"]
loop.create_task(start_mqtt(protocol, mqtt_cfg["server"], mqtt_cfg["user"], mqtt_cfg["password"] ))
loop.create_task(hass.connect())
return controller, loop
if __name__ == "__main__":
if len(sys.argv) == 2:
controller, loop = main(config_path=sys.argv[1])
loop.run_forever()
loop.close()
else:
print("Error: run with config file path as first argument")

View File

@@ -1,197 +0,0 @@
from led_cmds import ColorRGBW, EffectStaticConfig, EffectStaticDetailedConfig, EffectCircularConfig, EffectRandomTwoColorInterpolationConfig, EffectAlexaSwipeConfig, EffectSwipeAndChange
import asyncio
import aiomqtt
import json
from copy import deepcopy
class ShelveLightMqtt:
def __init__(self, protocol, client: aiomqtt.Client):
self._protocol = protocol
self._mqtt_client = client
self._state = {
"state": "OFF",
"color": {
"r": 255,
"g": 255,
"b": 255,
"w": 0,
},
"color_mode": "rgbw",
"brightness": 30,
"effect": "static",
}
self._last_color = ColorRGBW(0.5, 0.5, 0.5, 0)
self._discovery_spec = self._create_discovery_msg_light()
async def init(self):
"""Init method, because constructor can't be async"""
self._protocol.shelve_led_effect(EffectStaticConfig(ColorRGBW(0, 0, 0, 0)))
await self._send_autodiscovery_msg()
await self._notify_mqtt_state({"state": "OFF"})
async def handle_light_message(self, msg):
if msg.topic.value == self._discovery_spec['command_topic']:
payload = msg.payload.decode()
new_state = json.loads(payload)
print("IN ", new_state)
await self._update_state(new_state)
await self._notify_mqtt_state(new_state)
async def _update_state(self, new_state):
"""Merges current state with new state, updates device"""
# memorize last color - this is used for effects that need 2 colors
if 'color' in new_state:
brightness = new_state.get('brightness', self._state['brightness'])
new_color = self._color_from_json(new_state['color'], brightness)
current_color = self._color_from_json(self._state['color'])
if new_color != current_color:
self._last_color = current_color
print("last color", self._last_color)
self._state.update(new_state)
self._update_device()
@staticmethod
def _color_from_json(json_color, brightness=255):
args = ((json_color[e] / 255) * (brightness / 255) for e in ('r', 'g', 'b', 'w'))
return ColorRGBW(*args)
def _update_device(self):
s = self._state
current_color = self._color_from_json(s['color'], brightness=s["brightness"])
transition = s.get("transition", 0.3) * 1000
print(f"Effect {s['effect']} Transition {transition}")
if s['state'] == "OFF":
if transition > 0:
eff = EffectStaticDetailedConfig(ColorRGBW(0,0,0,0), transition_time_in_ms=transition)
else:
eff = EffectStaticConfig(ColorRGBW(0, 0, 0, 0))
elif s['effect'] == 'static':
if transition > 0:
eff = EffectStaticDetailedConfig(current_color, transition_time_in_ms=transition)
else:
eff = EffectStaticConfig(current_color)
elif s['effect'] == 'circular':
eff = EffectCircularConfig(speed=180, width=90, color=current_color)
elif s['effect'] == 'wipeup':
eff = EffectSwipeAndChange()
eff.swipe.secondary_color = current_color
eff.swipe.primary_color = self._last_color
eff.swipe.bell_curve_width_in_leds = 10
eff.swipe.transition_width = 30
eff.swipe.start_position = 0
eff.swipe.swipe_speed = 260
eff.change.color1 = current_color
eff.change.color2 = self._last_color
elif s['effect'] == "twocolor":
eff = EffectRandomTwoColorInterpolationConfig()
eff.color1 = current_color
eff.color2 = self._last_color
eff.start_with_existing = True
elif s['effect'] == "twocolorrandom":
eff = EffectRandomTwoColorInterpolationConfig()
eff.color1 = current_color
eff.color2 = self._last_color
eff.hue1_random = True
eff.hue2_random = True
eff.start_with_existing = True
elif s['effect'] == "side_0.2":
eff = EffectStaticDetailedConfig(current_color, begin=0.9, end=0.1, increment=1, transition_time_in_ms=transition)
elif s['effect'] == "side_0.2_inc4":
eff = EffectStaticDetailedConfig(current_color, begin=0.9, end=0.1, increment=4, transition_time_in_ms=transition)
elif s['effect'] == "side_0.2_inc8":
eff = EffectStaticDetailedConfig(current_color, begin=0.9, end=0.1, increment=8, transition_time_in_ms=transition)
elif s['effect'] == "side_0.5":
eff = EffectStaticDetailedConfig(current_color, begin=0.75, end=0.25, increment=1, transition_time_in_ms=transition)
elif s['effect'] == "side_0.5_inc4":
eff = EffectStaticDetailedConfig(current_color, begin=0.75, end=0.25, increment=4, transition_time_in_ms=transition)
elif s['effect'] == "top_0.2":
eff = EffectStaticDetailedConfig(current_color, begin=0.4, end=0.6, increment=1, transition_time_in_ms=transition)
elif s['effect'] == "top_0.2_inc4":
eff = EffectStaticDetailedConfig(current_color, begin=0.4, end=0.6, increment=4, transition_time_in_ms=transition)
elif s['effect'] == "top_0.5":
eff = EffectStaticDetailedConfig(current_color, begin=0.25, end=0.75, increment=1, transition_time_in_ms=transition)
elif s['effect'] == "top_0.5_inc4":
eff = EffectStaticDetailedConfig(current_color, begin=0.25, end=0.75, increment=4, transition_time_in_ms=transition)
else:
print(f"Unknown effect {s['effect']}")
eff = EffectStaticConfig(ColorRGBW(0, 0, 0, 0))
self._protocol.shelve_led_effect(eff)
@staticmethod
def _create_discovery_msg_light(base_name="musicmouse_json",
display_name="Music Mouse Regal Licht"):
id = "shelve"
return {
'platform': 'mqtt',
'schema': 'json',
'name': display_name,
'unique_id': f'{base_name}_{id}',
'command_topic': f'{base_name}/lights_{id}/command',
'state_topic': f'{base_name}/lights_{id}/state',
'color_mode': True,
'brightness': True,
#'device': {
# 'manufacturer': 'bauer.tech',
# 'model': "SK6812 LED strip",
#},
'effect': True,
'effect_list': ['static', 'circular', 'wipeup', 'twocolor', 'twocolorrandom',
"side_0.2", "side_0.5", "side_0.2_inc4", "side_0.2_inc8", "side_0.5_inc4",
"top_0.2", "top_0.5", "top_0.2_inc4", "top_0.5_inc4"],
'supported_color_modes': ['rgbw'],
}
async def _send_autodiscovery_msg(self):
topic = f"homeassistant/light/{self._discovery_spec['unique_id']}/config"
await self._mqtt_client.publish(topic, json.dumps(self._discovery_spec).encode(), retain=True)
async def _notify_mqtt_state(self, state):
state_payload = json.dumps(self._state)
print("OUT ", state_payload)
await self._mqtt_client.publish(self._discovery_spec['state_topic'], state_payload.encode())
return
direct_ack = False
if direct_ack == True:
state_payload = json.dumps(state)
else:
s = deepcopy(self._state)
if s['state'] == "OFF":
state_payload = json.dumps({"state": "OFF"})
else:
s['color_mode'] = "rgbw"
state_payload = json.dumps(s)
print("OUT ", state_payload)
await self._mqtt_client.publish(self._discovery_spec['state_topic'], state_payload.encode())
async def start_mqtt(music_mouse_protocol, server, username, password):
reconnect_interval = 10 # [seconds]
while True:
try:
async with aiomqtt.Client(hostname=server, username=username, password=password) as client:
shelve_light = ShelveLightMqtt(music_mouse_protocol, client)
await shelve_light.init()
await client.subscribe("musicmouse_json/#")
async for message in client.messages:
await shelve_light.handle_light_message(message)
except aiomqtt.MqttError as error:
print(f'Error "{error}". Reconnecting in {reconnect_interval} seconds')
finally:
await asyncio.sleep(reconnect_interval)
if __name__ == "__main__":
class DummyProtocol:
def shelve_led_effect(self, effect):
print("EFF ", repr(effect))
password = ""
asyncio.run(start_mqtt(DummyProtocol(), "homeassistant", "musicmouse", password))

View File

@@ -1,82 +0,0 @@
import vlc
class AudioPlayer:
def __init__(self, alsa_device=None):
params = ["-A", "alsa", "--alsa-audio-device", alsa_device] if alsa_device else []
self.instance = vlc.Instance(*params)
self.media_list_player = self.instance.media_list_player_new()
self.media_player = self.media_list_player.get_media_player()
evm = self.media_player.event_manager()
evm.event_attach(vlc.EventType.MediaPlayerStopped, self._callback)
evm2 = self.media_list_player.event_manager()
evm2.event_attach(vlc.EventType.MediaListPlayerPlayed, self._callback)
evm2.event_attach(vlc.EventType.MediaListPlayerStopped, self._callback)
self.on_playlist_end_callback = None
self.volume_min = None
self.volume_max = None
def create_playlist(self, files):
result = vlc.MediaList()
for e in files:
result.add_media(self.instance.media_new(e))
evm = result.event_manager()
evm.event_attach(vlc.EventType.MediaListEndReached,
lambda e: print("Ml CB", str(vlc.EventType(e.type))))
evm.event_attach(vlc.EventType.MediaListItemAdded,
lambda e: print("Ml ia CB", str(vlc.EventType(e.type))))
return result
def set_playlist(self, media_list):
self.media_list_player.set_media_list(media_list)
print("Setting media list of length ", media_list.count())
self.media_list_player.set_playback_mode(vlc.PlaybackMode.default)
def next(self):
return self.media_list_player.next()
def previous(self):
return self.media_list_player.previous()
def play(self):
self.media_list_player.play()
def play_from_start(self):
self.media_list_player.play_item_at_index(0)
def is_playing(self):
return self.media_list_player.is_playing()
def pause(self):
self.media_list_player.pause()
def _callback(self, event, *args, **kwargs):
eventStr = str(vlc.EventType(event.type))
print(f"Got vlc event type {event.type} {eventStr} , event {event}")
if event.type == vlc.EventType.MediaPlayerStopped:
if self.on_playlist_end_callback:
print("Calling playlist end cb")
self.on_playlist_end_callback()
#print("Callback from VLC", event, args, kwargs)
#print(event.meta_type, event.obj, event.type)
def set_volume(self, volume):
if self.volume_min and volume < self.volume_min:
volume = self.volume_min
if self.volume_max and volume > self.volume_max:
volume = self.volume_max
self.media_player.audio_set_volume(volume)
def set_volume_limits(self, vmin, vmax):
self.volume_min = vmin
self.volume_max = vmax
def change_volume(self, amount=1):
vol = self.media_player.audio_get_volume() + amount
self.set_volume(vol)

View File

@@ -1,5 +0,0 @@
pyserial-asyncio==0.6
python-vlc==3.0.20123
hass-client==0.1.2
ruamel.yaml==0.18.6
aiomqtt==2.0.0

View File

@@ -1,13 +0,0 @@
# Put this into /etc/systemd/system/musicmouse.service
[Unit]
Description=Music Mouse RFID Music Player
After=multi-user.target
[Service]
Type=simple
Restart=always
ExecStart=/opt/musicmouse/venv/bin/python /opt/musicmouse/espmusicmouse/host_driver/main.py /media/musicmouse/
[Install]
WantedBy=multi-user.target

View File

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

Binary file not shown.

28
hardware/pinout.md Normal file
View File

@@ -0,0 +1,28 @@
Reader
----------
- GND black
- RST blue 3.3V
- 3.3V red
- MISO brown 21
- SDA green 19
- SCK yellow 18
- MOSI orange 5
- IRQ green single cable not connected
Button Board:
-------------
- rot in | white 13
- btn2 led | grey 12
- btn2 in | purple 14
- rotB | blue 27
- rotA | green 26
- btn1 in | yellow 25
- btn1 led | orange 33
rot="rotary encoder"
in=button sense in
led = 5V pwm

BIN
hardware/sketch.fzz Normal file

Binary file not shown.

4
python-backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
config.yml
tippen-curriculum.yml
tippen-progress.json
/.musicmouse-cache

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 650 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 567 KiB

File diff suppressed because one or more lines are too long

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

@@ -0,0 +1,241 @@
# 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.

View File

@@ -0,0 +1,165 @@
# Example config for the MusicMouse backend.
#
# python -m musicmouse --config /media/musicmouse/config.yml
#
# Unknown keys are rejected rather than ignored, and every problem in the file is
# reported at once, so a typo fails at startup with the path to the offending line.
# Keep the real config (with credentials) off the repo - on the device only.
general:
# The music collection. One path; the shelves underneath it are fixed names, not
# settings, because each one has its own quirks the code already knows about:
#
# <root>/Figuren/<figure name>/ one folder per figurine
# <root>/Musik/<Artist> - <Album>/ albums, grouped by artist
# <root>/Hörbücher/<Artist> - <Album>/ audiobooks, grouped by character
# <root>/Kinderpodcasts/<Show>/ shows, newest episode first
#
# A cover.jpg next to the audio is used if present, otherwise the art is pulled out
# of the files' tags. Relative paths resolve against this file's directory.
library:
root: /home/martin/Music
# Scan results, extracted cover art and track analysis. Safe to delete: the index
# is rebuilt on the next start. Deleting it does throw away track analysis, which
# is expensive to recompute.
cache: .musicmouse-cache
# Serial port the ESP32 firmware is on. A dropped link is retried, not fatal.
# Required - use "simulate" to run without the mouse attached, which is a complete
# setup on its own because the web front-end can drive the player by itself. RFID,
# buttons and LEDs then do nothing, and startup says so every boot.
serial_port: "/dev/ttyUSB0"
baudrate: 115200
reconnect_interval: 5.0
# ALSA output device passed to VLC, e.g. "hw:0,0", or "default" for the system
# default output. Required - use "simulate" for a player that makes no sound, which
# is handy when working on the web UI on a machine whose audio you would rather not
# commandeer. Startup says so every boot.
#
# Both of these are required rather than optional on purpose: 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.
alsa_device: "softvol_effects"
# Volume, 0..100. min/max clamp everything, including the rotary encoder.
min_volume: 0
max_volume: 60
initial_volume: 40
volume_increment: 5 # per rotary-encoder click
# Backlight of the prev/next buttons while a figure is playing, 0..1.
button_leds_brightness: 0.5
# Which files count as music. Anything else - a podcast downloader's archive.json,
# a half-finished .tmp - is ignored.
audio_extensions: [".mp3", ".ogg", ".oga", ".opus", ".flac", ".wav", ".m4a", ".aac"]
# The web front-end. Omit the whole section to run without it.
#
# There is no authentication: this is a device on a home network. The settings panel
# at ?parentMode=1 is hidden from the child, not protected from them - it writes back
# to this file. Put it behind a reverse proxy if that is not good enough.
web:
host: "0.0.0.0"
port: 8080
# Built frontend to serve at /. Omit to expose only the JSON API.
static_dir: ../web/dist
# IR remote control, over lircd's TCP socket (see ansible/roles/pi_lirc for how
# lircd itself is set up on the Pi). Omit the whole section to run without a remote.
# Play/pause/stop/previous/forward/rewind/volume/mute map to normal music control;
# number keys 0-9 play whatever the "remote:" section below assigns them.
lirc:
host: "musicmouse-pi.local"
port: 2222 # this deployment's lircd listens on 2222, not its own
# default of 8765 - see the ansible role
remote_name: "Hauppauge" # other remotes registered with the same lircd (an LED
# remote, say) are ignored
reconnect_interval: 5.0
# Home Assistant integration. Omit the whole section to run without MQTT.
# The backend exposes three lights, a player sensor, a volume slider, transport
# buttons, device triggers for every button/touch area, and a tag scanner.
mqtt:
server: "homeassistant.local"
port: 1883
user: "musicmouse"
password: "REPLACE_WITH_MQTT_PASSWORD"
base_topic: "musicmouse"
discovery_prefix: "homeassistant"
device_id: "musicmouse"
device_name: "Music Mouse"
reconnect_interval: 10.0
# Room control page ("Mein Zimmer"). Omit the whole section to hide the page. This is
# the opposite direction from mqtt above: it's musicmouse controlling Home Assistant
# entities, not the other way round. The backend proxies every call to Home
# Assistant's REST API with this token attached; the browser never sees it, only
# entity ids and display names. Home Assistant's own CORS settings do not need to
# allow musicmouse's origin for this - the browser only ever talks to musicmouse.
ha:
url: "http://homeassistant.local:8123"
# A long-lived access token, created under the HA user's own profile page.
token: "REPLACE_WITH_HA_LONG_LIVED_TOKEN"
# Cards on the room page, in this order. "name" is optional; falls back to the
# entity id if omitted.
devices:
- entity_id: cover.kinderzimmer_rollo
name: "Rollo"
- entity_id: light.kinderzimmer_hue_beyond_links
name: "Hue Beyond links"
- entity_id: light.kinderzimmer_deckenlampe
name: "Deckenlampe"
# Scene pill row above the cards, in this order.
scenes:
- entity_id: scene.kinderzimmer_lesen
name: "Lesen"
- entity_id: scene.kinderzimmer_gute_nacht
name: "Gute Nacht"
# The typing game ("Tippen"). Omit the whole section to hide its tab in the web
# front-end. The lesson plan is content, not device config, so it lives in its own
# file - see tippen-curriculum.yml.example for the format, including the optional
# `unlocks:` key that turns passing a lesson into unlocking part of the library.
tippen:
curriculum_file: tippen-curriculum.yml
# Where progress (stars, unlocked lessons, streak, ...) is saved. Written by the
# app itself - never hand-edited. Relative to this file, like curriculum_file.
progress_file: tippen-progress.json
# One entry per figurine. The key is the figure name and the subfolder name.
figures:
fuchs:
# RFID tag id, 5 bytes as hex. Must be unique across figures.
id: "04a1b2c3d4"
# Exactly four colours: primary, secondary, background, accent.
# Either "#rrggbb" (RGB) or "wNN" (white channel only, hex).
colors: ["#ff6600", "#ffcc00", "#331100", "wff"]
# "music" (default) or "book". Every other shelf is named after what is on it, so
# its type is obvious; a figure folder is named after the figurine, so this is the
# one thing that has to be said out loud. The web UI draws albums square and
# audiobooks taller than wide, so getting it wrong is visible at a glance.
kind: music
eule:
id: "04b2c3d4e5"
colors: ["#3355ff", "#66aaff", "#001133", "#ffffff"]
kind: book
# Number keys 0-9 on the IR remote, mapped to what they play. Omit the whole section,
# or any digit within it, for "unassigned" - a fresh install boots with none of this
# and that is not an error. Editable from the web front-end, which writes back here.
#
# target_kind: album -> always starts from the first track (music, audiobooks).
# target is an album id, as shown at GET /api/library.
# target_kind: series -> always plays the newest episode of a podcast show, resolved
# fresh on every press - never a fixed episode. target is the
# show's folder name under Kinderpodcasts, e.g. "Wissen macht Ah".
remote:
"1":
target_kind: album
target: "3f9a0c12ab44"
"2":
target_kind: series
target: "Wissen macht Ah"

View File

@@ -0,0 +1,24 @@
# Put this into /etc/systemd/system/musicmouse.service
#
# Assumes the repo is checked out at /opt/musicmouse with a venv at /opt/musicmouse/.venv:
# /opt/musicmouse/.venv/bin/pip install -e /opt/musicmouse/python-backend
#
# A dropped serial link is now handled in-process (SerialLink reconnects), so
# Restart=always is only for genuine crashes.
[Unit]
Description=Music Mouse RFID Music Player
After=multi-user.target sound.target network-online.target
Wants=network-online.target
[Service]
Type=simple
Restart=always
RestartSec=5
WorkingDirectory=/opt/musicmouse/python-backend
ExecStart=/opt/musicmouse/.venv/bin/python -m musicmouse --config /media/musicmouse/config.yml
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,5 @@
"""MusicMouse backend: an RFID music player for kids."""
__all__ = ["__version__"]
__version__ = "2.0.0"

View File

@@ -0,0 +1,394 @@
"""Entry point and composition root.
python -m musicmouse --config /media/musicmouse/config.yml
python -m musicmouse --config ./config.yml --no-hardware
python -m musicmouse --config ./config.yml --simulate
python -m musicmouse --config ./config.yml --simulate --script scenarios/smoke.txt
This is the only module that knows which concrete implementations are in play; the
difference between "real mouse", "no mouse attached" and "simulated mouse" is which
transport and which player get built here.
"""
from __future__ import annotations
import argparse
import asyncio
import contextlib
import logging
import sys
from collections.abc import Awaitable, Callable, Coroutine
from pathlib import Path
from typing import Any
import httpx2
from musicmouse import __version__
from musicmouse.app import App
from musicmouse.bus import EventBus
from musicmouse.clock import RealClock
from musicmouse.config import SIMULATE, Config, ConfigError, GeneralConfig, load_config
from musicmouse.devices.mouse import MusicMouseDevice
from musicmouse.devices.null_transport import NullTransport
from musicmouse.devices.player import Player, VlcPlayer
from musicmouse.devices.serial_link import SerialLink
from musicmouse.library import MusicLibrary
from musicmouse.library.analysis import build_analyzer
from musicmouse.reactions import register_all
from musicmouse.services.base import Service
from musicmouse.services.lirc import LircService
from musicmouse.services.mqtt import MqttService, build_entities
from musicmouse.services.podcasts import PodcastFeedService
from musicmouse.services.web import WebService
from musicmouse.tippen.curriculum import CurriculumError
from musicmouse.tippen.runtime import TippenRuntime, build_tippen_runtime
_log = logging.getLogger("musicmouse")
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
prog="musicmouse", description="Host backend for the MusicMouse RFID music player."
)
parser.add_argument("-c", "--config", type=Path, required=True, help="path to config.yml")
parser.add_argument(
"-s",
"--simulate",
action="store_true",
help="run against fake hardware and a fake player (no serial port, no audio)",
)
parser.add_argument(
"--no-hardware",
action="store_true",
help="real audio and a real web front-end, but no serial port: for a host with "
"no mouse attached",
)
parser.add_argument(
"--script",
type=Path,
help="with --simulate: run a scenario file instead of the interactive prompt",
)
parser.add_argument(
"--log-level",
default="INFO",
choices=["DEBUG", "INFO", "WARNING", "ERROR"],
help="default: INFO",
)
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
args = parser.parse_args(argv)
if args.script and not args.simulate:
parser.error("--script only makes sense together with --simulate")
if args.simulate and args.no_hardware:
parser.error("--simulate already runs without hardware; drop --no-hardware")
return args
def setup_logging(level: str) -> None:
logging.basicConfig(
level=getattr(logging, level),
format="%(asctime)s %(levelname)-7s %(name)-28s %(message)s",
datefmt="%H:%M:%S",
)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
setup_logging(args.log_level)
try:
config = load_config(args.config, check_paths=True)
except ConfigError as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
tippen: TippenRuntime | None = None
if config.general.tippen is not None:
try:
tippen = build_tippen_runtime(config.general.tippen)
except CurriculumError as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
runner = (
run_simulated(config, args.config, args.script, tippen=tippen)
if args.simulate
else run_real(
config, args.config, hardware=wants_hardware(config, args.no_hardware), tippen=tippen
)
)
try:
asyncio.run(runner)
except KeyboardInterrupt:
_log.info("Interrupted")
return 0
# ------------------------------------------------------------------------- real
async def run_real(
config: Config,
config_path: Path,
*,
hardware: bool = True,
tippen: TippenRuntime | None = None,
) -> None:
bus = EventBus()
await bus.start()
clock = RealClock()
general = config.general
link: SerialLink | None = None
if hardware:
link = SerialLink(
general.serial_port,
general.baudrate,
reconnect_interval=general.reconnect_interval,
clock=clock,
)
mouse = MusicMouseDevice(bus, link, config.tag_map, port=general.serial_port)
link.attach(mouse.feed, on_connect=mouse.on_connected, on_disconnect=mouse.on_disconnected)
else:
mouse = MusicMouseDevice(bus, NullTransport(), config.tag_map, port="none")
player = _build_player(bus, general, clock=clock)
library = await build_library(config)
app = _build_app(config, bus, mouse, player, library, clock=clock, tippen=tippen)
services = _build_services(app, mouse, player, clock=clock, config_path=config_path)
_log.info(
"MusicMouse %s starting: %d figures, %d albums, serial %s, audio %s, mqtt %s",
__version__,
len(config.figures),
len(library.albums),
# Report what the config says, with a marker when --no-hardware overrode it,
# so the line never disagrees with the file it was started from.
general.serial_port + ("" if link or general.serial_simulated else " (no link)"),
general.alsa_device,
general.mqtt.server if general.mqtt else "disabled",
)
try:
await _run_forever(
[
*([link.run()] if link else []),
player.run(),
# `is_busy` reads `player.is_playing` directly rather than the library
# knowing about playback at all: analysis must never compete with audio
# decoding for CPU, and this device is otherwise idle most of the day, so
# the pass simply resumes once it is. `on_batch` is unset unless the web
# front-end is on - nothing else has a use for the notification.
library.run_analysis(
is_busy=lambda: player.is_playing,
on_batch=_analysis_batch_hook(services),
),
*(service.run() for service in services),
]
)
finally:
player.close()
await bus.stop()
# -------------------------------------------------------------------- simulated
async def run_simulated(
config: Config, config_path: Path, script: Path | None, *, tippen: TippenRuntime | None = None
) -> None:
# Imported here so the production path never touches the simulator.
from musicmouse.simulator.harness import build_simulation
from musicmouse.simulator.repl import run_repl
from musicmouse.simulator.script import run_script_file
# A script runs on virtual time, so `wait 1s` is instant. The prompt runs on the
# real clock, so playback ticks along while you watch it.
sim = await build_simulation(
config, clock=RealClock() if script is None else None, track_duration=5.0, tippen=tippen
)
services = _build_services(
sim.app, sim.app.mouse, sim.player, clock=RealClock(), config_path=config_path
)
tasks = [asyncio.create_task(service.run(), name=service.name) for service in services]
tasks.append(
asyncio.create_task(
sim.app.library.run_analysis(
is_busy=lambda: sim.player.is_playing,
on_batch=_analysis_batch_hook(services),
),
name="library-analysis",
)
)
try:
if script is not None:
await run_script_file(sim, script)
else:
await run_repl(sim)
finally:
for task in tasks:
task.cancel()
await sim.aclose()
# ---------------------------------------------------------------------- wiring
def wants_hardware(config: Config, no_hardware_flag: bool) -> bool:
"""Whether to open a serial port at all.
``serial_port: simulate`` says the same thing ``--no-hardware`` does. It is asked
for rather than inferred from a missing key, but it is still worth saying out loud
every boot - a mouse whose RFID reader does nothing should say why.
"""
if no_hardware_flag:
return False
if config.general.serial_simulated:
_log.warning(
'general.serial_port is "%s": running without the mouse. '
"The web front-end still works; RFID, buttons and LEDs do not.",
SIMULATE,
)
return False
return True
def _build_player(bus: EventBus, general: GeneralConfig, *, clock: RealClock) -> Player:
"""A real player, or a silent one when the config asked for that.
``alsa_device: simulate`` gets the simulator's player: everything above it behaves
identically, it just makes no sound. Useful for working on the web UI without
commandeering the machine's audio.
"""
if not general.audio_simulated:
return VlcPlayer(
bus,
alsa_device=general.alsa_device,
clock=clock,
**VlcPlayer.volume_kwargs(general),
)
_log.warning(
'general.alsa_device is "%s": using a simulated player, so nothing will be '
'audible. Set it to "default" for the system default output.',
SIMULATE,
)
# Imported here rather than at module scope so the real audio path never loads the
# simulator - and so this still runs on a machine without libVLC at all.
from musicmouse.simulator.fake_player import FakePlayer
return FakePlayer(bus, clock=clock, **FakePlayer.volume_kwargs(general))
async def build_library(config: Config) -> MusicLibrary:
library_config = config.general.library
return await MusicLibrary.build(
library_config.root,
library_config.cache,
frozenset(config.general.audio_extensions),
analyzer=build_analyzer(),
figure_kinds=config.figure_kinds,
)
def _build_app(
config: Config,
bus: EventBus,
mouse: MusicMouseDevice,
player: Player,
library: MusicLibrary,
*,
clock: RealClock,
tippen: TippenRuntime | None = None,
) -> App:
app = App(
config=config,
bus=bus,
mouse=mouse,
player=player,
library=library,
# One source of truth: the figure path and the web path must hand the player the
# same Playlist object for the same folder, because `play_figure` resumes on an
# identity check.
playlists=library.figure_playlists(),
clock=clock,
tippen=tippen,
)
register_all(bus, app)
return app
def _service_of_type[T: Service](services: list[Service], kind: type[T]) -> T | None:
return next((s for s in services if isinstance(s, kind)), None)
def _analysis_batch_hook(services: list[Service]) -> Callable[[], Awaitable[None]] | None:
"""The web front-end's own hub, if it is running - so open tabs refetch the library
as background analysis lands, instead of only after a manual reload. `None` when
there is no web service, which `MusicLibrary.analyze_pending` treats as "nobody to
tell".
"""
web_service = _service_of_type(services, WebService)
return web_service.hub.broadcast_library if web_service else None
def _build_services(
app: App,
mouse: MusicMouseDevice,
player: Player,
*,
clock: RealClock,
config_path: Path,
) -> list[Service]:
"""Every front-end. Each one only speaks intents, so they cannot conflict."""
services: list[Service] = []
mqtt_config = app.config.general.mqtt
if mqtt_config is None:
_log.info("No mqtt section in the config: Home Assistant integration is off")
else:
entities = build_entities(app.bus, mqtt_config, mouse, player)
services.append(MqttService(app.bus, mqtt_config, entities, clock=clock))
web_config = app.config.general.web
if web_config is None:
_log.info("No web section in the config: the web front-end is off")
else:
services.append(WebService(app, web_config, config_path))
lirc_config = app.config.general.lirc
if lirc_config is None:
_log.info("No lirc section in the config: the IR remote is off")
else:
services.append(LircService(app, lirc_config, clock=clock))
# Unconditional: a show only starts downloading once someone drops a `feed.txt`
# into its folder, so there is nothing to gate here with its own config section.
web_service = _service_of_type(services, WebService)
services.append(
PodcastFeedService(
app.library,
client=httpx2.AsyncClient(timeout=30.0),
on_change=lambda: app.rescan_library(
broadcast=web_service.hub.broadcast_library if web_service else None
),
)
)
return services
async def _run_forever(coroutines: list[Coroutine[Any, Any, None]]) -> None:
tasks = [asyncio.create_task(coro) for coro in coroutines]
try:
await asyncio.gather(*tasks)
finally:
for task in tasks:
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await asyncio.gather(*tasks, return_exceptions=True)
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,78 @@
"""What the reactions get handed: the three objects, the bus, and a little shared state."""
from __future__ import annotations
import logging
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from musicmouse.bus import EventBus
from musicmouse.clock import Clock, RealClock
from musicmouse.config import Config, FigureColors
from musicmouse.devices.mouse import MusicMouseDevice
from musicmouse.devices.player import Player
from musicmouse.library import MusicLibrary
from musicmouse.library.models import Album
from musicmouse.media import Playlist
from musicmouse.tippen.runtime import TippenRuntime
_log = logging.getLogger(__name__)
__all__ = ["App", "AppState"]
@dataclass
class AppState:
"""State that belongs to no single device but is shared between reactions."""
#: Figure that was taken off the reader mid-playlist, so putting it back resumes
#: instead of starting over. Cleared once its playlist runs out.
last_partially_played_figure: str | None = None
#: Whether the MQTT broker is currently reachable. The firmware's equivalent is
#: readable off the transport; a broker's is not, so it is remembered here.
mqtt_connected: bool = False
#: Whether the lircd TCP link for the IR remote is currently reachable.
lirc_connected: bool = False
@dataclass
class App:
config: Config
bus: EventBus
mouse: MusicMouseDevice
player: Player
library: MusicLibrary
playlists: dict[str, Playlist]
clock: Clock = field(default_factory=RealClock)
state: AppState = field(default_factory=AppState)
#: `None` when `general.tippen` is absent - the typing game is off.
tippen: TippenRuntime | None = None
def colors(self, figure: str) -> FigureColors:
return self.config.figures[figure].colors
def playlist(self, figure: str) -> Playlist | None:
playlist = self.playlists.get(figure)
if playlist is None:
_log.warning("No playlist for figure %r", figure)
return playlist
def album_for(self, playlist: Playlist | None) -> Album | None:
"""The library album a playlist came from, if any."""
return self.library.get(playlist.album_id if playlist else None)
async def rescan_library(
self, *, broadcast: Callable[[], Awaitable[None]] | None = None
) -> None:
"""Rescan from disk, rebuild figure playlists, and tell whoever's listening.
Shared by the manual "Bibliothek neu einlesen" endpoint and anything else that
can change what's on disk on its own, such as the podcast feed poller.
"""
await self.library.refresh()
self.playlists.clear()
self.playlists.update(self.library.figure_playlists())
if broadcast is not None:
await broadcast()

View File

@@ -0,0 +1,153 @@
"""The event bus.
Everything in the process is serialised through one FIFO queue on one loop, which is
what makes "last event wins" a well-defined rule for LED zone arbitration and what
makes scenario tests deterministic.
Handlers may be sync or async; async handlers are awaited, so one event is fully
handled before the next is dispatched. A handler that raises is logged and does not
stop the others or the bus.
"""
from __future__ import annotations
import asyncio
import contextlib
import inspect
import logging
from collections.abc import Callable, Coroutine
from typing import Any
from musicmouse.events import Event
_log = logging.getLogger(__name__)
__all__ = ["EventBus", "Handler", "Unsubscribe"]
type Handler[E: Event] = Callable[[E], Coroutine[Any, Any, None] | None]
type Unsubscribe = Callable[[], None]
class EventBus:
def __init__(self) -> None:
self._handlers: dict[type[Event], list[Handler[Any]]] = {}
self._wildcard: list[Handler[Any]] = []
self._resolved: dict[type[Event], tuple[Handler[Any], ...]] = {}
self._queue: asyncio.Queue[Event] = asyncio.Queue()
self._loop: asyncio.AbstractEventLoop | None = None
self._dispatcher: asyncio.Task[None] | None = None
# ------------------------------------------------------------------ lifecycle
async def start(self) -> None:
if self._dispatcher is not None:
return
self._loop = asyncio.get_running_loop()
self._dispatcher = asyncio.create_task(self._run(), name="event-bus")
async def stop(self) -> None:
if self._dispatcher is None:
return
self._dispatcher.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._dispatcher
self._dispatcher = None
self._loop = None
async def __aenter__(self) -> EventBus:
await self.start()
return self
async def __aexit__(self, *exc_info: object) -> None:
await self.stop()
# --------------------------------------------------------------- subscription
def subscribe[E: Event](self, event_type: type[E], handler: Handler[E]) -> Unsubscribe:
"""Register ``handler`` for ``event_type`` and any subclass of it."""
self._handlers.setdefault(event_type, []).append(handler)
self._resolved.clear()
def unsubscribe() -> None:
handlers = self._handlers.get(event_type)
if handlers and handler in handlers:
handlers.remove(handler)
self._resolved.clear()
return unsubscribe
def subscribe_all(self, handler: Handler[Event]) -> Unsubscribe:
"""Register ``handler`` for every event. Useful for logging and broadcasting."""
self._wildcard.append(handler)
def unsubscribe() -> None:
if handler in self._wildcard:
self._wildcard.remove(handler)
return unsubscribe
# ---------------------------------------------------------------- publication
def emit(self, event: Event) -> None:
"""Queue ``event`` for dispatch. Safe to call from any thread.
libVLC fires its callbacks on its own thread; this is where that crossing is
made safe instead of reaching the serial transport off-loop.
"""
loop = self._loop
if loop is None:
raise RuntimeError("EventBus.emit() before start()")
try:
running = asyncio.get_running_loop()
except RuntimeError:
running = None
if running is loop:
self._queue.put_nowait(event)
else:
loop.call_soon_threadsafe(self._queue.put_nowait, event)
async def drain(self) -> None:
"""Wait until every queued event, and everything they emitted, is handled."""
await self._queue.join()
async def emit_and_wait(self, event: Event) -> None:
self.emit(event)
await self.drain()
# -------------------------------------------------------------------- internals
async def _run(self) -> None:
while True:
event = await self._queue.get()
try:
await self._dispatch(event)
finally:
self._queue.task_done()
async def _dispatch(self, event: Event) -> None:
for handler in self._handlers_for(type(event)):
try:
result = handler(event)
if inspect.isawaitable(result):
await result
except asyncio.CancelledError:
raise
except Exception:
_log.exception("Handler %s failed on %r", _name(handler), event)
def _handlers_for(self, event_type: type[Event]) -> tuple[Handler[Any], ...]:
cached = self._resolved.get(event_type)
if cached is None:
matched: list[Handler[Any]] = []
for klass in event_type.__mro__:
if klass is object:
continue
matched.extend(self._handlers.get(klass, ()))
cached = tuple(matched)
self._resolved[event_type] = cached
# Wildcards are not cached: they are appended last and change rarely.
return cached + tuple(self._wildcard)
def _name(handler: Handler[Any]) -> str:
return getattr(handler, "__qualname__", repr(handler))

View File

@@ -0,0 +1,94 @@
"""Time, behind a protocol.
Anything that waits takes a :class:`Clock` instead of calling :func:`asyncio.sleep`
directly. Under :class:`RealClock` a scenario runs in real time; under
:class:`FakeClock` the identical scenario runs in microseconds, which is what makes
``wait 1s`` affordable inside the test suite.
"""
from __future__ import annotations
import asyncio
import heapq
import itertools
import time
from collections.abc import Awaitable, Callable
from typing import Protocol
__all__ = ["Clock", "FakeClock", "RealClock"]
class Clock(Protocol):
def now(self) -> float:
"""Monotonic seconds. Only differences are meaningful."""
...
async def sleep(self, seconds: float) -> None:
"""Suspend the calling task for ``seconds``."""
...
async def advance(self, seconds: float) -> None:
"""Let ``seconds`` pass, from the driver's point of view."""
...
class RealClock:
def now(self) -> float:
return time.monotonic()
async def sleep(self, seconds: float) -> None:
await asyncio.sleep(seconds)
async def advance(self, seconds: float) -> None:
await asyncio.sleep(seconds)
class FakeClock:
"""Virtual time.
``sleep()`` parks the caller until ``advance()`` moves time past its deadline.
``idle`` is awaited after each wake-up so that whatever the woken task emitted has
been fully handled before virtual time moves on - pass ``EventBus.drain``.
"""
def __init__(self, start: float = 0.0, idle: Callable[[], Awaitable[None]] | None = None):
self._now = start
self._idle = idle
self._counter = itertools.count()
self._sleepers: list[tuple[float, int, asyncio.Future[None]]] = []
def now(self) -> float:
return self._now
async def sleep(self, seconds: float) -> None:
if seconds <= 0:
await self._settle()
return
future: asyncio.Future[None] = asyncio.get_running_loop().create_future()
heapq.heappush(self._sleepers, (self._now + seconds, next(self._counter), future))
await future
async def advance(self, seconds: float) -> None:
# Settle first: a task created but not yet started has not registered its
# sleep, and would otherwise have its deadline computed from the new time.
await self._settle()
target = self._now + max(0.0, seconds)
while self._sleepers and self._sleepers[0][0] <= target:
deadline, _, future = heapq.heappop(self._sleepers)
self._now = max(self._now, deadline)
if not future.done():
future.set_result(None)
await self._settle()
self._now = target
await self._settle()
@property
def pending_timers(self) -> int:
return len(self._sleepers)
async def _settle(self) -> None:
# Give woken tasks a chance to run, then let their events be handled.
await asyncio.sleep(0)
if self._idle is not None:
await self._idle()
await asyncio.sleep(0)

View File

@@ -0,0 +1,128 @@
"""Colour types shared by the LED wire format and the config schema.
Kept separate from :mod:`musicmouse.devices.effects` so that :mod:`musicmouse.config`
can validate colours without importing anything device-related.
"""
from __future__ import annotations
import colorsys
import struct
from dataclasses import dataclass
__all__ = ["ColorHSV", "ColorRGBW", "parse_color"]
@dataclass(frozen=True, slots=True)
class ColorRGBW:
"""An RGBW colour with all channels normalised to ``0.0 .. 1.0``."""
r: float
g: float
b: float
w: float
def __repr__(self) -> str:
return f"#({self.r}, {self.g}, {self.b}, {self.w})"
@property
def is_valid(self) -> bool:
return all(0 <= v <= 1 for v in (self.r, self.g, self.b, self.w))
def as_bytes(self) -> bytes:
if not self.is_valid:
raise ValueError(f"Channel values must be within 0..1, got {self!r}")
return struct.pack(
"<BBBB",
int(self.r * 255),
int(self.g * 255),
int(self.b * 255),
int(self.w * 255),
)
@classmethod
def from_bytes(cls, data: bytes) -> ColorRGBW:
r, g, b, w = struct.unpack("<BBBB", data)
return cls(r / 255, g / 255, b / 255, w / 255)
def __mul__(self, scale: float) -> ColorRGBW:
if not 0 <= scale <= 1:
raise ValueError(f"Scale must be within 0..1, got {scale}")
return ColorRGBW(self.r * scale, self.g * scale, self.b * scale, self.w * scale)
def without_white_channel(self) -> ColorRGBW:
"""Fold the white channel into RGB, for strips driven without a W channel."""
r, g, b = (min(1.0, c + self.w) for c in (self.r, self.g, self.b))
return ColorRGBW(r, g, b, 0)
@dataclass(frozen=True, slots=True)
class ColorHSV:
"""Hue in degrees (``0..360``), saturation ``0..1``, value ``0..2``."""
h: float
s: float
v: float
def __repr__(self) -> str:
return f"ColorHSV({self.h}, {self.s}, {self.v})"
@staticmethod
def from_rgb(rgb: ColorRGBW) -> ColorHSV:
h, s, v = colorsys.rgb_to_hsv(rgb.r, rgb.g, rgb.b)
return ColorHSV(h * 360, s, v)
@property
def is_valid(self) -> bool:
return 0 <= self.h <= 360 and 0 <= self.s <= 1 and 0 <= self.v <= 2
def as_bytes(self) -> bytes:
if not self.is_valid:
raise ValueError(f"Out-of-range HSV colour {self!r}")
return struct.pack("<fff", self.h, self.s, self.v)
@classmethod
def from_bytes(cls, data: bytes) -> ColorHSV:
return cls(*struct.unpack("<fff", data))
def parse_color(value: str | ColorRGBW) -> ColorRGBW:
"""Parse ``"#rrggbb"`` (RGB) or ``"wNN"`` (white channel only) into a colour.
Raises:
ValueError: with a message naming the accepted formats.
"""
if isinstance(value, ColorRGBW):
return value
if not isinstance(value, str):
raise ValueError(f"expected a colour string, got {type(value).__name__}")
text = value.strip()
if text.startswith("#"):
digits = text[1:]
if len(digits) != 6:
raise ValueError(
f"unrecognized color format {value!r} "
f"(expected '#rrggbb' with 6 hex digits, got {len(digits)})"
)
try:
r, g, b = (int(digits[i : i + 2], 16) / 255 for i in (0, 2, 4))
except ValueError:
raise ValueError(
f"unrecognized color format {value!r} (expected '#rrggbb' with hex digits)"
) from None
return ColorRGBW(r, g, b, 0)
if text.startswith("w"):
digits = text[1:]
try:
white = int(digits, 16)
except ValueError:
raise ValueError(
f"unrecognized color format {value!r} (expected 'wNN' with hex digits)"
) from None
if not 0 <= white <= 255:
raise ValueError(f"white value in {value!r} must be within 00..ff")
return ColorRGBW(0, 0, 0, white / 255)
raise ValueError(f"unrecognized color format {value!r} (expected '#rrggbb' or 'wNN')")

Some files were not shown because too many files have changed in this diff Show More