61 Commits

Author SHA1 Message Date
ec9d617ad8 Vendor Nunito as a TTF so the device renders in the right face
Slint cannot read woff2, which is the only form web/public/fonts/ carries, and Raspbian
has no fonts-nunito to install instead - so the device was going to fall back to
DejaVu Sans, which is not what any of this was drawn in. The upstream variable TTF
(SIL OFL, weights 200-1000) is small enough to vendor and covers every weight the
design uses, including the 800/900 the headings lean on.

SLINT_DEFAULT_FONT takes one file and makes it the primary font. shell.nix now points
at this copy rather than at nixpkgs' own, so development and the device render from the
identical file, and the binary falls back to finding it beside the executable when
neither the dev shell nor the device's launcher has set the variable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 22:45:37 +02:00
6ccb3e458f Add a native Slint front-end for the music player
The React UI is sluggish on the kiosk and the reasons are browser-shaped: a compositor
deciding what gets its own layer, a requestAnimationFrame loop repainting the viewport,
and a JPEG decode per album card per cold start. The last three commits chased that
through the service worker and the cover pipeline. This is the other direction - the
same backend, the same layout, the same German copy, without a browser.

Browse and play only. The typing game, the room-lights page, parent mode and the
IR-remote assignment stay web-only, and nothing here touches python-backend.

Four things carry the performance claim, in rough order of how much they should matter:

Covers are downscaled once, ever. The backend serves one size - 640px on the long edge,
no ?size= - and a grid card is 180. src/covers.rs fetches each cover once on a worker
thread, resizes it, and writes a JPEG thumbnail to ~/.cache/musicmouse-slint/covers/.
Two tiers and only two, which is what makes the cache worth keeping: a per-widget pixel
size would give each album a dozen near-identical files and a fresh decode for each. The
cache is dropped when the websocket announces a rescan, because that is the one moment
the backend rewrites the art behind an unchanged cover URL - the trap sw.js had to learn
about the hard way.

The grid is virtualized. Rust hands the UI the album list pre-chunked into rows and the
view puts those in a ListView, which instantiates only what is on screen. A flat list of
660 cards gives it no rows to skip, hence the chunking. Same purpose as
content-visibility: auto on .grid > .card.

The progress bar animates between the 2 Hz pushes rather than running a clock, so
interpolation costs a property evaluation per frame on the render side and there is no
equivalent of usePlaybackClock. It is suppressed for the frame a track changes on, so a
new track jumps instead of sliding across two unrelated positions.

Nothing on screen animates by itself. The ambient canvas is not ported, for the reason
lib/lowPower.ts already gives: a loop repainting the viewport is a floor you cannot get
under while it runs at all.

The device must not use FemtoVG. On Mesa V3D it draws every runtime-loaded Image as
solid black (slint-ui/slint#11785, open), which here means every album cover; Skia and
the software renderer are unaffected. So `kiosk` is linuxkms + Skia and FemtoVG stays
the default only for desktop development. Both profiles are verified to build; the kiosk
binary links libinput/libgbm/libdrm and no X11 or Wayland at all.

lib/search.ts, lib/keyboard.ts and lib/format.ts were already pure functions with their
own tests, so they port across as pure Rust with theirs: 43 tests, no window required.
The .slint files are layout only - nothing in them formats a number or picks a word.

Verified against the real 660-album library rather than a fixture. tools/headless-shots.sh
renders the UI inside a nested headless compositor and grabs a frame per screen, which
is how that was checked on a machine whose session was locked; a Slint window needs a
real compositor, and under bare Xvfb nothing maps at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 22:26:09 +02:00
83d16e0058 Take the service worker off the cover path, and cut cover art to 384px
Search was still stalling for seconds a keystroke. A trace across five keystrokes said
the renderer main thread was blocked for 9.4 s in a single task with *zero* V8 samples
inside it - no JavaScript ran at all. What ran instead, on the worker pool during those
same 9.4 s: ImageDecodeTask 7.6 s, RasterTask 1.7 s, and only 229 ms of actual "Decode
Image". The main thread was waiting on cover bytes, not computing anything.

Two causes, and the first one was mine. The stale-while-revalidate handler added earlier
today made every cover do a Cache Storage read *plus* a network fetch *plus* a cache
write, all serialised through one worker thread. Measured with
Network.setBypassServiceWorker: worst keystroke 5580 ms through the worker against
461 ms without it. So covers no longer go through the worker at all. That costs nothing
here: this worker only registers on localhost or over HTTPS, which on this setup is the
kiosk on the device itself, where the backend is the same machine and a cache lookup is
strictly more work than asking for the file. The shell caching, which is what makes it
installable, stays.

The second is that decode cost goes with pixel count, and 640 px was headroom for a
tablet at devicePixelRatio 2 that nobody had asked for. A browse grid paints a card
132 px wide; the largest any screen asks for is 340. At 384 px, typing "conni" over 343
albums, keydown to painted, three runs:

  before   3735,  270, 5580,  88,  57 ms
  after     873,   75,   17,  24,  26 ms

`shrink_cover` never scales art up, so dropping the limit cannot be applied by
re-reading the cache - only by going back to the original art. _INDEX_VERSION 6 does
that: the index is discarded and every album is scanned again through store_cover.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 18:31:57 +02:00
ebab03b700 Cache every cover, not only the ones pulled out of tags
The downscaling landed and the device kept serving 1920px art, because _cover_for has
two branches and only one of them went through the cache. An album with a cover.jpg
already sitting beside its audio - which most of this library has - had its `cover`
point straight at that file, so FileResponse served whatever the internet had given it.
The cache directory was full of tidy 640px files that half the albums never used.

Both branches now store through the cache, in _cover_for and in _cover_for_episode
(sidecar and shared-folder art alike). The undownscaled bytes still come back alongside
the path, because colour extraction wants the real thing.

That re-points `cover` for every album, but only for albums that are actually rescanned,
and the scanner reuses anything whose fingerprint is unchanged - so _INDEX_VERSION goes
to 5. An index from 4 is discarded and rebuilt, which is what that mechanism is for and
is cheap by design.

Two tests asserted `cover == <the library file>`, which is precisely the behaviour being
changed. The episode one was also checking something real - that an episode's own
sidecar art beats the show's shared cover - and both covers now live under their own
album id, so it reads the colour back out of the stored file instead of comparing paths.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 16:57:00 +02:00
3fea56d4e1 Stop cover art going stale in two caches at once
Shrinking the covers changed nothing on the device, twice over, because two layers were
independently serving the old bytes and each had the same wrong premise written into its
comment: that a cover is "content-addressed by album id". The id addresses which album
the art belongs to. The bytes behind the URL change whenever the art is reprocessed.

The backend sent `public, max-age=604800`, so every browser that had loaded the page in
the previous week kept decoding the 3000px original out of its own disk cache without
asking. It now sends `no-cache`, which does not mean "do not store" but "revalidate
before reusing" - the file stays cached and the usual answer is a 304.

The service worker cached covers cache-first on a URL that never changes, which means a
client could serve a stale cover for ever; bumping the cache name fixed today's covers
and would have had to be done again for the next batch. It now does
stale-while-revalidate: the cached copy is returned immediately, so a grid of album art
still never waits on the network, and a fresh copy is fetched behind the page for next
time. Neither layer needs a version bump when art is reprocessed again.

The revalidation is off the paint path entirely, which is what makes `no-cache`
affordable here: the service worker answers first, the network call happens behind it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 16:43:56 +02:00
e1c10f5408 Retire the service worker's cache of the old, huge cover art
A DevTools trace recorded on musicdolphin put the whole question beyond argument:

  ImageDecodeTask      29,939 ms  x20   (~1.5 s each)
  Decode LazyPixelRef   3,924 ms  x20
  Decode Image          3,530 ms  x22
  Paint                    99.7 ms
  Layout                   77.3 ms
  all app JavaScript      <400 ms  across 22 seconds

Image decoding is not the largest cost on that page, it is very nearly the only one.

The covers should already have been small - the backend downscales to 640 px now - but
the page was still decoding them at 1920, 1600, 1400 px, with deliveryType
"cache-storage" on every one. The service worker caches cover art cache-first, keyed on
URL, on the premise that it is "immutable per album id". The id did not change; the
bytes did. So every client that had ever loaded a cover kept serving the 3000 px
original from its own disk and never asked the backend for the new one.

Renaming the cache is the retirement mechanism the worker already has - `activate`
deletes every cache that is not one of the two current names - so COVERS becomes v2,
with a note saying that changing how covers are produced means bumping it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 16:29:37 +02:00
0ef5a04cb9 Downscale cached cover art, and stop every animation loop under ?pi=1
Covers. Art out of an ID3 APIC frame is sized for a record sleeve: this library
averaged 3000x3000 and 580 kB per cover, 140 MB across 284 albums. The browser was
decoding nine megapixels - around 36 MB of bitmap - for every cover it painted, to show
it in a 185 px card, on a Pi with 2 GB of RAM. The largest any screen in this app asks
for is 340 px (the play view), so cache.store_cover now downscales to a 640 px long
edge, which leaves room for a tablet at devicePixelRatio 2 and cuts the decode about
twentyfold. Pillow was already a hard dependency, for colour extraction.

scan_library reuses an album whose fingerprint is unchanged without re-reading its
tags, so covers already on disk would never be rewritten - hence shrink_stored_covers(),
a pass at the top of a scan. Reading a JPEG's dimensions only parses its header, so
after the first run it costs one small read per album. Art already small enough is
returned byte-identical rather than re-encoded, so repeated scans cannot slowly grind
it down, and anything Pillow cannot read is passed through untouched: a cover that is
too big is a performance problem, a cover that is missing is a visible one.

Animation. Halving the ambient canvas to a quarter of the pixels at 30fps took it from
53.5% of a core to 25%, and 25% was still not good enough to use. A requestAnimationFrame
loop repainting the viewport is a floor you cannot get under while it runs at all, so
?pi=1 now switches it off outright rather than thinning it, along with the decorative
CSS loops, the view transitions, the typing game's bubbles and its next-key pulse. The
pets stay on screen but hold still, through the same path prefers-reduced-motion already
took - taking the animation away is the point, taking away what she earned is not.
.stage keeps its own static gradient, so there is still a sea behind everything.

Both blurs on the panels stay on. Dropping those measured five times worse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 15:44:02 +02:00
3cdad1e714 Stop rendering album cards that are off screen, and split card blur from panel blur
Searching a single letter over a real library puts 340 cards in one grid, each with a
cover image, a shadow and a backdrop blur, all of them live whether or not they are on
screen. On musicdolphin that saturated the renderer badly enough that a DevTools
Runtime.evaluate could not be scheduled on the main thread inside 30 seconds, which is
a fair description of what "sluggish" felt like.

content-visibility: auto on the grid's cards is the browser's own answer: off-screen
cards skip layout, paint and compositing and come back as they scroll near the
viewport. The grid's tracks are sized by minmax(180px, 1fr) rather than by card
content, so skipping that content cannot move the columns; contain-intrinsic-size
supplies the block-axis guess and `auto` remembers each card's real size after its
first render, so scroll height and offsetTop - which BrowseView's keep-the-selection-
on-screen effect reads - stay honest. Not behind ?pi=1: there is no visual difference
to trade away.

SHOW_CARD_BLUR separates the repeated frosted surfaces (every card, every list row)
from the handful of panels wrapped around them, because the two behave nothing alike
and lumping them together is what made the first Pi profile five times slower. A panel
is one live backdrop copy that buys its whole subtree a compositing layer; a card is
one of three hundred sitting directly over the animated canvas. ?pi=1 now drops the
card blur and keeps the panel blur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 15:12:26 +02:00
2514ecbc35 Measure the Pi profile on the Pi, and keep only what helped
The first cut of ?pi=1 switched off the two things that look most expensive - the
backdrop-filter glass blur and the decorative CSS animation loops - and made the device
slower, not faster: 53.5% of a core idle became 118%. Measured on musicdolphin, sum of
the Firefox process tree, idle on the browse screen, 15s average:

  full app                                       53.5%
  + ambient canvas at half resolution            25.0%   <- the whole win
  + 30fps cap on top of that                     25.0%   (no idle change)
  + decorative CSS animation loops off           24.6%   (noise; left on)
  + backdrop-filter glass blur off              118.1%   <- 5x worse

The blur is what promotes each glass panel to its own compositing layer. Without it the
animated canvas and the whole album grid above it collapse into one layer and every
canvas frame repaints all of it. The most expensive-looking CSS in the app is what was
keeping the rest of it cheap. SHOW_GLASS_BLUR and SHOW_DECORATIVE_ANIMATIONS therefore
go back to unconditionally on, in both the music app and the typing game, with the
numbers written down next to them so the next person does not repeat this.

What is left is one real change - paint a quarter of the pixels - plus three caps that
only bite while something is playing and so are not in the table above: 30fps on the
canvas, 60 bubbles alive at once, and ten progress-bar re-renders a second. Those three
are unmeasured; measuring them means playing audio.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 13:44:05 +02:00
cc3db44c4e Add a ?pi=1 profile, and stop re-deriving search keys per keystroke
Two separate costs, both measured on musicdolphin (Pi 4, 1920x1080 kiosk), where the
app was burning ~70% of a core with nothing happening on screen.

Per-frame work. The ambient canvas repaints a full-screen gradient plus a particle
field every frame, and `usePlaybackClock` pushes a React setState per animation frame
into both PlayView and PlayerBar for the whole length of a track. `?pi=1` (lib/
lowPower.ts) makes those cheaper rather than switching them off: the canvas paints a
quarter of the pixels at 30fps with a bubble cap, and the clock renders ten times a
second - a progress bar advances one pixel every few hundred ms and its label has
one-second resolution, so nothing on screen can tell. Only the effects with no cheap
version actually go: the backdrop-filter glass blur and the decorative CSS loops.
Also drops a redundant full-canvas clearRect that the opaque gradient always covered.

Search. normalize() runs a Unicode NFD decomposition, and albumMatches/songMatches
called it on every album title and every track title on every keystroke - 4969 of them
for a track search, whose answer cannot change until the library does. buildSearchIndex
does it once per library payload; a keystroke is now String.includes over strings that
already exist. On the real library that is 33ms -> 3.4ms for an eight-letter track
query on a laptop, and this runs on a Pi. The same index partitions albums by shelf and
pre-sorts each shelf's categories, which App and BrowseView were deriving separately
from the same data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 13:15:23 +02:00
a7fb56c9fe Target the Python that Raspberry Pi OS ships
Requiring 3.13 meant the device needed an interpreter the distribution does not
have, which is what dragged uv in, and uv then had to be matched to the Pi's
32-bit userland by hand and to build Pillow from source because no armv7 wheel
exists for a 3.13 ABI. Dropping to 3.11 removes all of that: apt provides the
interpreter and piwheels has prebuilt armhf wheels for the native dependencies.

The 3.13-only syntax was shallow - PEP 695 throughout, which converts back
mechanically:

  type X = Y               ->  X: TypeAlias = Y
  type Handler[E: Event]   ->  E = TypeVar("E", bound=Event) plus a plain alias,
                               which is generic anyway because it carries a TypeVar
  def f[T: Bound](...)     ->  a module-level TypeVar

Also drop the one @override (3.12, and static-only), and stop the lirc test
harness calling Server.close_clients(), which is 3.13: the scripted handler now
releases its connection when asked, which is what that call was there to force.

Verified on 3.11.14 - 485 passed, mypy strict clean - and still 496 passed on
the 3.14 dev venv, which additionally has the analysis extra.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 21:20:59 +02:00
fa3d92189c Keep only the newest 50 episodes of each podcast
A show that has published for years is unbounded: GEOlino Spezial alone is 358
episodes and 5.8 GB, and the device it syncs onto is a 30 GB SD card that also
holds the rest of the library. Nothing stopped the 6-hourly poll from eventually
filling it.

Cap each show's folder at general.podcast_episode_limit (default 50, null to
keep everything), pruning the oldest past that after each sync pass.

The same limit caps what is downloaded, and it has to be one number for both.
Prune to the newest N but keep fetching everything the feed offers, and every
poll would re-download exactly the episodes the previous one deleted - forever,
at full size, since missing_episodes() decides purely from what is on disk.
There is a test for that specific loop.

Pruning only touches files named the way this module names them
(YYYYMMDD - Title.ext), so feed.txt, folder.jpg, the failed-download record and
anything placed by hand are all left alone; a parse that fails means "not ours",
not "delete it". An episode's sidecar cover goes with it. A pass that only
deleted still reports a change, because the library needs the rescan just as
much as it does after a download.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 20:40:53 +02:00
dd14f5d901 Add a script to seed a device's library and analysis cache
Copying the library to a Pi is one rsync; copying the cache with it is not,
because its three parts travel differently. covers/ is keyed by the album
folder's path relative to the library root, so it survives the move. analysis/
is keyed by name:size:mtime, so it survives only if mtimes do - hence -a
everywhere, and a comment saying why. index.json holds absolute source paths and
must not be copied at all: scan_library() reuses a cached album on a matching
(name, size, mtime) fingerprint without re-checking the path, so copying it
leaves every album pointing at the dev machine and playback fails on files that
are present. It is the cheap part, and the device rebuilds it on first scan
while reusing everything expensive.

Syncs shelf by shelf rather than the whole directory, which is what makes
--delete safe: config.yml, tippen-curriculum.yml, tippen-progress.json and the
cache all live in the same directory on the device and have no counterpart here.
Preflight checks ssh, sizes the transfer and refuses to fill the SD card.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 18:58:50 +02:00
274085b92e Stop tracking the library cache
python-backend/.gitignore lists /.musicmouse-cache, but index.json and seven
cover JPGs were committed before that ignore existed, so the ignore never
applied to them. index.json is a 2 MB machine-specific blob holding 6606
absolute /home/martin/... paths, rewritten on every app start - it showed up as
modified in git status permanently, and a deploy checkout would have shipped a
dev machine's index to the device.

The files stay on disk; only the tracking goes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 18:26:45 +02:00
f3337975c2 Celebrate a media unlock with a treasure chest that opens
The reward pipeline worked end to end already, but it had nothing to show for
itself: the unlock was one more line at the bottom of the result sheet, a 52px
thumbnail with the generic 520ms pop, below the stars, the stats, the progress
bar, the animal ladder and three other badges. The sound was `playFanfare`, the
same chirp used for a new lesson and a new aquarium pet. It fired correctly and
was impossible to notice.

This is the only reward that reaches outside the game, so it now gets the whole
screen. A chest drops in shut and rattles, the lid swings open on a burst of
light and confetti, the cover art rises out of it, and the tune is a real melody
- two seconds landing on a held major chord - rather than another blip. It is
dismissed by hand, so she can look at what she won for as long as she likes.

On the map, the 15px 🎁 becomes a drawn chest, shut while the reward is unwon
and open with the cover inside once it has been. It is rendered as a sibling of
the lesson node rather than a child, because a locked node is dimmed to 45% and
the chest that most needs to be bright is the one three worlds away.

One real bug behind the missing badge state: `progressFromApi` dropped the
`earned` flag the backend already sends, so the map could not tell a claimed
reward from an unclaimed one. Added, with a test that names it - a hand-written
field mapping loses fields without failing a type check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 18:10:11 +02:00
e6f6b15cc7 Make the lesson map the floor screen of the typing tab
The aquarium was a separate landing screen in front of the map, showing the pets
collected so far and a "Weiter üben" button. But the pets already swim behind
every screen in the tab, so the screen mostly restated what was visible anyway,
at the cost of one extra step between opening the tab and typing.

The map absorbs what was worth keeping: the "Weiter üben" button now floats over
it, and the pets, streak and best animal move into the header. One Escape from
the map leaves the tab entirely, where it used to go back a screen first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 18:08:11 +02:00
f3f7082fb8 Draw locked covers as artwork instead of a question mark
A reward-gated album showed a bare  over the generated gradient, which reads as
"something is broken" rather than "something is waiting". It is now a proper
placeholder cover, with a separate one for audiobooks and podcasts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 18:05:19 +02:00
36e93be79b Let a lesson list review keys beside the two it introduces
A lesson's `keys` was capped at two outright, which made it impossible to write
"K, with the four keys before it still in play". The cap now counts only what is
*new*; keys already taught can be listed freely, and the spotlight falls on the
new ones alone, so a lesson titled "K" drills K instead of spreading itself
evenly over all five keys it names. A round that introduces nothing new still
spotlights its whole list - that is the "mixed" replay, unchanged.

Also documents `unlocks:` paths as relative to the library root. Absolute and
"~" paths still resolve, so an existing file keeps working, but a relative path
survives moving the library.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 18:05:19 +02:00
b6342cc117 Drop the pearls currency
Pearls were earned every run and spent on nothing: the aquarium fills up by
finishing worlds, not by paying for it. A counter that only ever goes up is one
more stat competing for attention on the result sheet and the home screen, and
one more field to carry through the run payload, the progress file and both test
suites.

Stars and the animal ladder already say how a run went, so nothing is lost.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 18:05:08 +02:00
fd6283718c Analyse tracks in a pool of worker processes
A first pass over an unanalysed library is hours of librosa, and there was no
reason for a desktop to spend them one core at a time. Analysis now runs in a
process pool sized by `general.library.analysis_workers`, defaulting to one per
core bar one (capped at 8) when the key is absent.

Two consequences worth knowing before touching this: whatever an `Analyzer`
returns has to be picklable, and an analyzer that keeps state on its own
instance - a test double counting calls - only behaves as written with
`analysis_workers=1`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 18:04:01 +02:00
75b0eed080 Remove the Quallenalarm (jellyfish) typing minigame
Letters-round lessons only had bubbles and jellyfish as eligible arcade
modes; with jellyfish gone, bubbles is now always used instead of the
two alternating - no lessons need to be dropped.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 00:38:19 +02:00
a69db98241 Use a plain text note glyph for the music tab, not the emoji
Setting color explicitly wasn't enough: the 🎵 emoji has its own
baked-in colour (a muted grey-blue on at least one real platform) and
simply ignores the CSS color property, unlike a plain Unicode symbol.
Swapped to the bare eighth-note character (♪), which renders as text
and finally responds to the color already set on the button.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 00:29:18 +02:00
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
Martin Bauer
b2c060fcc9 Adapted to new aiomqtt version 2024-03-07 16:38:05 +01:00
Martin Bauer
11bf0505fb Changes for python3.11 2024-03-06 17:15:07 +01:00
Martin Bauer
ef038a29a4 fixes 2024-01-02 17:41:43 +01:00
Martin Bauer
d5a4bbb7e2 new static effect with range select, increment, and transition time 2024-01-01 18:54:24 +01:00
Martin Bauer
6128d8706d MQTT persist discovery msg 2023-10-09 10:54:33 +02:00
Martin Bauer
6850c9646e from desktop pc 2023-10-06 08:51:28 +02:00
Martin Bauer
828d1b3360 missing files 2023-10-06 08:51:27 +02:00
Martin Bauer
804940bfac modification on musicmouse host 2023-01-03 20:50:45 +01:00
Martin Bauer
a67c6078c5 Local modification 2022-10-02 12:40:42 +02:00
Martin Bauer
42f0701c0b mqtt for shelve light using json protocol now -> more reliable 2022-02-04 18:13:46 +01:00
Martin Bauer
4c5db49c61 mqtt integration 2022-01-13 19:20:33 +01:00
Martin Bauer
1458793d74 started mqtt shelve led control 2022-01-12 22:35:49 +01:00
Martin Bauer
1a17d49599 adding shelve leds 2022-01-12 18:13:59 +01:00
332 changed files with 4200920 additions and 1134 deletions

9
.gitignore vendored
View File

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

129
docs/REPO_OVERVIEW.md Normal file
View File

@@ -0,0 +1,129 @@
# 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.
- Track analysis (librosa) runs in a pool of worker *processes* - see
`musicmouse/library/workers.py`. Anything an `Analyzer` returns therefore has to be
picklable, and an analyzer that records state in its own instance (a test double
counting calls) only behaves as written with `analysis_workers=1`.

View File

@@ -1,7 +1,10 @@
{
// See http://go.microsoft.com/fwlink/?LinkId=827846
// for the documentation about the extensions.json format
"recommendations": [
"platformio.platformio-ide"
]
}
{
// See http://go.microsoft.com/fwlink/?LinkId=827846
// for the documentation about the extensions.json format
"recommendations": [
"platformio.platformio-ide"
],
"unwantedRecommendations": [
"ms-vscode.cpptools-extension-pack"
]
}

View File

@@ -43,5 +43,15 @@
"cinttypes": "cpp",
"utility": "cpp",
"typeinfo": "cpp"
}
},
"vsmqtt.brokerProfiles": [
{
"name": "homeassistant",
"host": "homeassistant",
"port": 1883,
"username": "musicmouse",
"clientId": "vsmqtt_client",
"password": "KNLEFLZF94yA6Zhj141",
}
]
}

View File

@@ -8,6 +8,7 @@ enum class EffectId
RANDOM_TWO_COLOR_INTERPOLATION,
SWIPE_AND_CHANGE, // combination of ALEXA_SWIPE and RANDOM_TWO_COLOR_INTERPOLATION
REVERSE_SWIPE,
STATIC_DETAILED,
};
template <EffectId id>

View File

@@ -3,6 +3,7 @@
#include "effects/Common.h"
#include "helpers/ColorRGBW.h"
#pragma pack(push, 1)
struct EffectStaticConfig
{
EffectStaticConfig(const ColorRGBW &c = ColorRGBW{0, 0, 0, 0}, uint16_t beg = 0, uint16_t en = 0)
@@ -12,12 +13,14 @@ struct EffectStaticConfig
uint16_t begin = 0;
uint16_t end = 0;
};
#pragma pack(pop)
template <typename TLedStrip>
class EffectStatic
{
public:
static constexpr auto NUM_LEDS = numLeds<TLedStrip>();
using ConfigType = EffectStaticConfig;
EffectStatic(const EffectStaticConfig &cfg, TLedStrip &ledStrip)
: config_(cfg),

View File

@@ -0,0 +1,107 @@
#pragma once
#include "effects/Common.h"
#include "helpers/ColorRGBW.h"
#include "helpers/ColorConversions.h"
#pragma pack(push, 1)
struct EffectStaticDetailedConfig
{
EffectStaticDetailedConfig(const ColorRGBW &c = ColorRGBW{0, 0, 0, 0}, uint16_t beg = 0, uint16_t en = 0)
: color(c), begin(beg), end(en) {}
ColorRGBW color;
uint16_t increment = 1;
float begin = 0.0f;
float end = 0.0f;
float transition_time_in_ms = 0.0f;
};
#pragma pack(pop)
template <typename TLedStrip>
class EffectStaticDetailed
{
public:
static constexpr auto NUM_LEDS = numLeds<TLedStrip>();
static constexpr int DELAY_MS = 10;
using ConfigType = EffectStaticDetailedConfig;
EffectStaticDetailed(const EffectStaticDetailedConfig &cfg, TLedStrip &ledStrip)
: config_(cfg),
ledStrip_(ledStrip)
{
for (int i = 0; i < NUM_LEDS; ++i)
state_[i] = getLedRGBW(ledStrip_, i);
beginIdx_ = constrain(static_cast<int>(cfg.begin * NUM_LEDS + 0.5f), 0, NUM_LEDS - 1);
endIdx_ = constrain(static_cast<int>(cfg.end * NUM_LEDS + 0.5f), 0, NUM_LEDS - 1);
while (endIdx_ < beginIdx_)
endIdx_ += NUM_LEDS;
}
bool finished() const { return finished_; }
int operator()()
{
if (finished_)
return 1000000;
const float progress = config_.transition_time_in_ms > 0.0f ? static_cast<float>(DELAY_MS * calls_) / config_.transition_time_in_ms : 1.f;
// Finished case
if (config_.transition_time_in_ms <= 0.0f || progress >= 1.0)
{
finished_ = true;
clear(ledStrip_);
for (int i = beginIdx_; i < endIdx_; i += config_.increment)
setLedRGBW(ledStrip_, i % NUM_LEDS, config_.color);
return 10000000;
}
// In-progress case
clear(ledStrip_);
for (int i = beginIdx_; i < endIdx_; i += config_.increment)
{
const auto idx = i % NUM_LEDS;
ColorRGBW newColor = ColorRGBW::interpolate(state_[idx], config_.color, progress);
setLedRGBW(ledStrip_, idx, newColor);
}
++calls_;
return DELAY_MS;
}
private:
static int int_interpolate(int prev, int next, float progress)
{
return static_cast<float>(prev) * (1 - progress) +
static_cast<float>(next) * progress;
}
EffectStaticDetailedConfig config_;
TLedStrip &ledStrip_;
ColorRGBW state_[NUM_LEDS];
int beginIdx_;
int endIdx_;
int calls_ = 0;
bool finished_ = false;
};
// Traits
template <>
struct EffectIdToConfig<EffectId::STATIC_DETAILED>
{
using type = EffectStaticDetailedConfig;
};
template <>
struct EffectConfigToId<EffectStaticDetailedConfig>
{
static constexpr auto id = EffectId::STATIC_DETAILED;
};
template <typename TLedStrip>
struct EffectIdToClass<EffectId::STATIC_DETAILED, TLedStrip>
{
using type = EffectStaticDetailed<TLedStrip>;
};

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,26 @@
#pragma once
#include <cstdint>
struct ColorRGBW
{
uint8_t r, g, b, w;
ColorRGBW operator*(float s) const
{
return {uint8_t(s * r),
uint8_t(s * g),
uint8_t(s * b),
uint8_t(s * w)};
}
static inline ColorRGBW interpolate(const ColorRGBW &c1, const ColorRGBW &c2, float f)
{
return ColorRGBW{
static_cast<uint8_t>((1.0f - f) * static_cast<float>(c1.r) + f * static_cast<float>(c2.r)),
static_cast<uint8_t>((1.0f - f) * static_cast<float>(c1.g) + f * static_cast<float>(c2.g)),
static_cast<uint8_t>((1.0f - f) * static_cast<float>(c1.b) + f * static_cast<float>(c2.b)),
static_cast<uint8_t>((1.0f - f) * static_cast<float>(c1.w) + f * static_cast<float>(c2.w)),
};
}
};

View File

@@ -104,14 +104,22 @@ enum class MessageHostToFw : uint8_t
LED_WHEEL_EFFECT_RANDOM_TWO_COLOR_INTERPOLATION = 3,
LED_WHEEL_EFFECT_SWIPE_AND_CHANGE = 4,
LED_WHEEL_EFFECT_REVERSE_SWIPE = 5,
MOUSE_LED_EFFECT_STATIC = 6,
MOUSE_LED_EFFECT_CIRCULAR = 7,
MOUSE_LED_EFFECT_RANDOM_TWO_COLOR_INTERPOLATION = 8,
MOUSE_LED_EFFECT_SWIPE_AND_CHANGE = 9,
MOUSE_LED_EFFECT_REVERSE_SWIPE = 10,
PREV_BUTTON_LED = 20,
NEXT_BUTTON_LED = 21,
SHELF_LED_EFFECT_STATIC = 15,
SHELF_LED_EFFECT_CIRCULAR = 16,
SHELF_LED_EFFECT_RANDOM_TWO_COLOR_INTERPOLATION = 17,
SHELF_LED_EFFECT_SWIPE_AND_CHANGE = 18,
SHELF_LED_EFFECT_REVERSE_SWIPE = 19,
SHELF_LED_EFFECT_STATIC_DETAILED = 20,
PREV_BUTTON_LED = 21,
NEXT_BUTTON_LED = 22,
};
template <>
@@ -158,8 +166,21 @@ void sendMessageToHost(const TMessage &msg)
Serial.write((uint8_t *)&msg, sizeof(msg));
}
template <typename LedTask1, typename LedTask2>
inline void handleIncomingMessagesFromHost(LedTask1 *ledTaskCircle, LedTask2 *ledTaskMouse, uint8_t ledChannelLeft, uint8_t ledChannelRight)
template <typename TEffectConfig, typename TLedTask>
inline bool handleLedEffect(TLedTask *ledTask, MessageHostToFw msgType, MessageHostToFw incomingMsgType, uint8_t *msgBuffer)
{
if (msgType == incomingMsgType)
{
auto cfg = reinterpret_cast<TEffectConfig *>(msgBuffer);
ledTask->startEffect(*cfg);
return true;
}
else
return false;
}
template <typename LedTask1, typename LedTask2, typename LedTaskShelf>
inline void handleIncomingMessagesFromHost(LedTask1 *ledTaskCircle, LedTask2 *ledTaskMouse, LedTaskShelf *ledTaskShelf, uint8_t ledChannelLeft, uint8_t ledChannelRight)
{
if (Serial.available() < sizeof(MAGIC_TOKEN_FW_TO_HOST) + sizeof(MessageHostToFw) + sizeof(uint16_t))
return;
@@ -180,65 +201,36 @@ inline void handleIncomingMessagesFromHost(LedTask1 *ledTaskCircle, LedTask2 *le
static constexpr int maxIncomingBufferSize = 1024;
static uint8_t msgBuffer[maxIncomingBufferSize];
if (msgSize < maxIncomingBufferSize)
{
Serial.readBytes(msgBuffer, msgSize);
if (msgType == MessageHostToFw::LED_WHEEL_EFFECT_STATIC)
{
auto cfg = reinterpret_cast<EffectStaticConfig *>(msgBuffer);
ledTaskCircle->startEffect(*cfg);
}
else if (msgType == MessageHostToFw::LED_WHEEL_EFFECT_ALEXA_SWIPE)
{
auto cfg = reinterpret_cast<EffectAlexaSwipeConfig *>(msgBuffer);
ledTaskCircle->startEffect(*cfg);
}
else if (msgType == MessageHostToFw::LED_WHEEL_EFFECT_CIRCULAR)
{
auto cfg = reinterpret_cast<EffectCircularConfig *>(msgBuffer);
ledTaskCircle->startEffect(*cfg);
}
else if (msgType == MessageHostToFw::LED_WHEEL_EFFECT_RANDOM_TWO_COLOR_INTERPOLATION)
{
auto cfg = reinterpret_cast<EffectRandomTwoColorInterpolationConfig *>(msgBuffer);
ledTaskCircle->startEffect(*cfg);
}
else if (msgType == MessageHostToFw::LED_WHEEL_EFFECT_SWIPE_AND_CHANGE)
{
auto cfg = reinterpret_cast<EffectSwipeAndChangeConfig *>(msgBuffer);
ledTaskCircle->startEffect(*cfg);
}
else if (msgType == MessageHostToFw::LED_WHEEL_EFFECT_REVERSE_SWIPE)
{
auto cfg = reinterpret_cast<EffectReverseSwipeConfig *>(msgBuffer);
ledTaskCircle->startEffect(*cfg);
}
//
else if (msgType == MessageHostToFw::MOUSE_LED_EFFECT_STATIC)
{
auto cfg = reinterpret_cast<EffectStaticConfig *>(msgBuffer);
ledTaskMouse->startEffect(*cfg);
}
else if (msgType == MessageHostToFw::MOUSE_LED_EFFECT_CIRCULAR)
{
auto cfg = reinterpret_cast<EffectCircularConfig *>(msgBuffer);
ledTaskMouse->startEffect(*cfg);
}
else if (msgType == MessageHostToFw::MOUSE_LED_EFFECT_RANDOM_TWO_COLOR_INTERPOLATION)
{
auto cfg = reinterpret_cast<EffectRandomTwoColorInterpolationConfig *>(msgBuffer);
ledTaskMouse->startEffect(*cfg);
}
else if (msgType == MessageHostToFw::MOUSE_LED_EFFECT_SWIPE_AND_CHANGE)
{
auto cfg = reinterpret_cast<EffectSwipeAndChangeConfig *>(msgBuffer);
ledTaskMouse->startEffect(*cfg);
}
else if (msgType == MessageHostToFw::MOUSE_LED_EFFECT_REVERSE_SWIPE)
{
auto cfg = reinterpret_cast<EffectReverseSwipeConfig *>(msgBuffer);
ledTaskMouse->startEffect(*cfg);
}
// clang-format off
// LED Circle
if(handleLedEffect<EffectStaticConfig >(ledTaskCircle, MessageHostToFw::LED_WHEEL_EFFECT_STATIC, msgType, msgBuffer)) {}
else if(handleLedEffect<EffectAlexaSwipeConfig >(ledTaskCircle, MessageHostToFw::LED_WHEEL_EFFECT_ALEXA_SWIPE, msgType, msgBuffer)) {}
else if(handleLedEffect<EffectCircularConfig >(ledTaskCircle, MessageHostToFw::LED_WHEEL_EFFECT_CIRCULAR, msgType, msgBuffer)) {}
else if(handleLedEffect<EffectRandomTwoColorInterpolationConfig>(ledTaskCircle, MessageHostToFw::LED_WHEEL_EFFECT_RANDOM_TWO_COLOR_INTERPOLATION, msgType, msgBuffer)) {}
else if(handleLedEffect<EffectSwipeAndChangeConfig >(ledTaskCircle, MessageHostToFw::LED_WHEEL_EFFECT_SWIPE_AND_CHANGE, msgType, msgBuffer)) {}
else if(handleLedEffect<EffectReverseSwipeConfig >(ledTaskCircle, MessageHostToFw::LED_WHEEL_EFFECT_REVERSE_SWIPE, msgType, msgBuffer)) {}
// Mouse LEDs
else if(handleLedEffect<EffectStaticConfig >(ledTaskMouse, MessageHostToFw::MOUSE_LED_EFFECT_STATIC, msgType, msgBuffer)) {}
else if(handleLedEffect<EffectCircularConfig >(ledTaskMouse, MessageHostToFw::MOUSE_LED_EFFECT_CIRCULAR, msgType, msgBuffer)) {}
else if(handleLedEffect<EffectRandomTwoColorInterpolationConfig>(ledTaskMouse, MessageHostToFw::MOUSE_LED_EFFECT_RANDOM_TWO_COLOR_INTERPOLATION, msgType, msgBuffer)) {}
else if(handleLedEffect<EffectSwipeAndChangeConfig >(ledTaskMouse, MessageHostToFw::MOUSE_LED_EFFECT_SWIPE_AND_CHANGE, msgType, msgBuffer)) {}
else if(handleLedEffect<EffectReverseSwipeConfig >(ledTaskMouse, MessageHostToFw::MOUSE_LED_EFFECT_REVERSE_SWIPE, msgType, msgBuffer)) {}
// Shelf LEDs
else if (handleLedEffect<EffectStaticConfig >(ledTaskShelf, MessageHostToFw::SHELF_LED_EFFECT_STATIC, msgType, msgBuffer)) {}
else if (handleLedEffect<EffectCircularConfig >(ledTaskShelf, MessageHostToFw::SHELF_LED_EFFECT_CIRCULAR, msgType, msgBuffer)) {}
else if (handleLedEffect<EffectRandomTwoColorInterpolationConfig>(ledTaskShelf, MessageHostToFw::SHELF_LED_EFFECT_RANDOM_TWO_COLOR_INTERPOLATION, msgType, msgBuffer)) {}
else if (handleLedEffect<EffectSwipeAndChangeConfig >(ledTaskShelf, MessageHostToFw::SHELF_LED_EFFECT_SWIPE_AND_CHANGE, msgType, msgBuffer)) {}
else if (handleLedEffect<EffectReverseSwipeConfig >(ledTaskShelf, MessageHostToFw::SHELF_LED_EFFECT_REVERSE_SWIPE, msgType, msgBuffer)) {}
else if (handleLedEffect<EffectStaticDetailedConfig >(ledTaskShelf, MessageHostToFw::SHELF_LED_EFFECT_STATIC_DETAILED, msgType, msgBuffer)) {}
// clang-format on
else if (msgType == MessageHostToFw::PREV_BUTTON_LED)
{
float *val = reinterpret_cast<float *>(msgBuffer);

View File

@@ -77,6 +77,7 @@ void _led_task_func(void *params)
// clang-format off
if (dispatchEffectId<EffectId::CIRCULAR >(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {}
else if (dispatchEffectId<EffectId::STATIC >(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {}
else if (dispatchEffectId<EffectId::STATIC_DETAILED >(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {}
else if (dispatchEffectId<EffectId::ALEXA_SWIPE >(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {}
else if (dispatchEffectId<EffectId::RANDOM_TWO_COLOR_INTERPOLATION>(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {}
else if (dispatchEffectId<EffectId::SWIPE_AND_CHANGE >(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {}

View File

@@ -8,6 +8,7 @@
#include "drivers/Esp32DriverRGBW.h"
#include "effects/Circular.h"
#include "effects/Static.h"
#include "effects/StaticDetailed.h"
#include "effects/AlexaSwipe.h"
#include "effects/RandomTwoColorInterpolation.h"
@@ -158,7 +159,7 @@ void setupMouseLeds()
// -------------------------------------------------- Shelf Leds -------------------------------------------
LedStripRGBW<250> ledStripShelf;
LedStripRGBW<252> ledStripShelf;
Esp32DriverRGBW ledDriverShelf;
LedTask<decltype(ledStripShelf)> ledTaskShelf;
@@ -166,7 +167,7 @@ void setupShelfLeds()
{
ledDriverShelf.begin(17, 2);
ledTaskShelf.begin(ledStripShelf, ledDriverShelf);
ledTaskShelf.startEffect(EffectStaticConfig{ColorRGBW{0, 0, 30}, 0, 0});
ledTaskShelf.startEffect(EffectStaticConfig{ColorRGBW{0, 0, 0, 0}, 0, 0});
}
// -------------------------------------------------- Touch Buttons ----------------------------------------
@@ -233,7 +234,7 @@ void setup()
void loop()
{
handleIncomingMessagesFromHost(&ledTaskCircle, &ledTaskMouse, 0, 1);
handleIncomingMessagesFromHost(&ledTaskCircle, &ledTaskMouse, &ledTaskShelf, 0, 1);
handleTouchInputs();
handleRotaryEncoder();
handleButtons();

40
esp-firmware/todo.md Normal file
View File

@@ -0,0 +1,40 @@
- button hintergrund beleuchtung [ok]
- playlisten
- runterladen
- befestigung im regal
- winkel
- mehrfachsteckdose
- lan kabel
- effekt kanal fuer audioeffekte
- "boing" etc runterladen
- Fernbedienung wenn empfaenger da
- HA regeln fuer standard
- ansible cleanup
- lirc
- musicmouse kanal
- musicmouse effect kanal
- home assistant anbindung
- events an HA (figur, button press, ...)
- mouse & ring leds von HA
- HA device control (led fluter, rollos)
- regal licht von HA aus
- Regal LEDs
- kabel von musikmaus
- Leisten zuschneiden
- kabel auf richtige laenge zuschneiden
- Kabel loeten
- im Arbeitszimmer testen
- Bonus: Ecken drucken
- Effekte Regal LEDs
- Musik-abhaengige Effekte

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,186 +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,
}
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 = 20
NEXT_BUTTON_LED_MSG = 21
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_ring_effect(self, effect_cfg):
msg_content = effect_cfg.as_bytes()
header = struct.pack("<IBH", MAGIC_TOKEN_HOST_TO_FW,
led_ring_effect_to_message_id[type(effect_cfg)], len(msg_content))
self.transport.write(header + msg_content)
def mouse_led_effect(self, effect_cfg):
msg_content = effect_cfg.as_bytes()
header = struct.pack("<IBH", MAGIC_TOKEN_HOST_TO_FW,
mouse_led_effect_to_message_id[type(effect_cfg)], len(msg_content))
self.transport.write(header + msg_content)
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,140 +0,0 @@
from dataclasses import dataclass
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)
@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 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 = ColorRGBW(0, 0, 1, 0)
secondary_color: ColorRGBW = 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 = ColorHSV(240, 1, 1)
color2: ColorHSV = 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 = 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 = EffectAlexaSwipeConfig()
change: EffectRandomTwoColorInterpolationConfig = 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,248 +0,0 @@
#!/usr/bin/env python
import asyncio
import sys
import serial_asyncio
from led_cmds import (ColorRGBW, ColorHSV, 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
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.current_figure: str = None
self.last_figure: str = None
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)
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))
def figure_placed(self, figure_state):
self.last_figure = self.current_figure
self.current_figure = figure_state
def figure_removed(self):
self.last_figure = self.current_figure
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._run_off_animation
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()
}
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.figure_removed()
elif tagid in self._rfid_to_figure_name:
figure = self._rfid_to_figure_name[tagid]
primary_color, secondary_color, *rest = self.cfg["figures"][figure]["colors"]
self._start_animation(primary_color, secondary_color)
self.mmstate.button_leds(self.cfg["general"].get("button_leds_brightness", 0.5))
if figure in self.playlists:
self.audio_player.set_playlist(self.playlists[figure])
if self.mmstate.last_figure == figure:
self.audio_player.play()
else:
self.audio_player.play_from_start()
self.mmstate.figure_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():
res = self.audio_player.previous()
print(f"Prev {res}")
elif btn == "right" and message.event == "pressed" and self.audio_player.is_playing():
res = self.audio_player.nex()
print(f"Next {res}")
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.current_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",
**colors[message.touch_button])
elif isinstance(message, TouchButtonRelease):
figure = self.mmstate.current_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)
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)
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,149 +0,0 @@
import vlc
all_events = (
vlc.EventType.MediaDiscovererEnded,
vlc.EventType.MediaDiscovererStarted,
vlc.EventType.MediaDurationChanged,
vlc.EventType.MediaFreed,
vlc.EventType.MediaListEndReached,
vlc.EventType.MediaListItemAdded,
vlc.EventType.MediaListItemDeleted,
vlc.EventType.MediaListPlayerNextItemSet,
vlc.EventType.MediaListPlayerPlayed,
vlc.EventType.MediaListPlayerStopped,
vlc.EventType.MediaListViewItemAdded,
vlc.EventType.MediaListViewItemDeleted,
vlc.EventType.MediaListViewWillAddItem,
vlc.EventType.MediaListViewWillDeleteItem,
vlc.EventType.MediaListWillAddItem,
vlc.EventType.MediaListWillDeleteItem,
vlc.EventType.MediaMetaChanged,
vlc.EventType.MediaParsedChanged,
vlc.EventType.MediaPlayerAudioDevice,
vlc.EventType.MediaPlayerAudioVolume,
vlc.EventType.MediaPlayerBackward,
vlc.EventType.MediaPlayerBuffering,
vlc.EventType.MediaPlayerChapterChanged,
vlc.EventType.MediaPlayerCorked,
vlc.EventType.MediaPlayerESAdded,
vlc.EventType.MediaPlayerESDeleted,
vlc.EventType.MediaPlayerESSelected,
vlc.EventType.MediaPlayerEncounteredError,
vlc.EventType.MediaPlayerEndReached,
vlc.EventType.MediaPlayerForward,
#vlc.EventType.MediaPlayerLengthChanged,
vlc.EventType.MediaPlayerMediaChanged,
vlc.EventType.MediaPlayerMuted,
vlc.EventType.MediaPlayerNothingSpecial,
vlc.EventType.MediaPlayerOpening,
vlc.EventType.MediaPlayerPausableChanged,
vlc.EventType.MediaPlayerPaused,
vlc.EventType.MediaPlayerPlaying,
#vlc.EventType.MediaPlayerPositionChanged,
vlc.EventType.MediaPlayerScrambledChanged,
vlc.EventType.MediaPlayerSeekableChanged,
vlc.EventType.MediaPlayerSnapshotTaken,
vlc.EventType.MediaPlayerStopped,
#vlc.EventType.MediaPlayerTimeChanged,
vlc.EventType.MediaPlayerTitleChanged,
vlc.EventType.MediaPlayerUncorked,
vlc.EventType.MediaPlayerUnmuted,
vlc.EventType.MediaPlayerVout,
vlc.EventType.MediaStateChanged,
vlc.EventType.MediaSubItemAdded,
vlc.EventType.MediaSubItemTreeAdded,
vlc.EventType.RendererDiscovererItemAdded,
vlc.EventType.RendererDiscovererItemDeleted,
vlc.EventType.VlmMediaAdded,
vlc.EventType.VlmMediaChanged,
vlc.EventType.VlmMediaInstanceStarted,
vlc.EventType.VlmMediaInstanceStatusEnd,
vlc.EventType.VlmMediaInstanceStatusError,
vlc.EventType.VlmMediaInstanceStatusInit,
vlc.EventType.VlmMediaInstanceStatusOpening,
vlc.EventType.VlmMediaInstanceStatusPause,
vlc.EventType.VlmMediaInstanceStatusPlaying,
vlc.EventType.VlmMediaInstanceStopped,
vlc.EventType.VlmMediaRemoved,
)
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,16 +0,0 @@
#pragma once
#include <cstdint>
struct ColorRGBW
{
uint8_t r, g, b, w;
ColorRGBW operator*(float s) const
{
return {uint8_t(s * r),
uint8_t(s * g),
uint8_t(s * b),
uint8_t(s * w)};
}
};

File diff suppressed because one or more lines are too long

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.

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

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

Binary file not shown.

BIN
hardware/sketch.fzz Normal file

Binary file not shown.

Binary file not shown.

View File

@@ -1,6 +0,0 @@
cmake_minimum_required(VERSION 3.16)
project("pyaudioplayeralsa")
add_executable(play main.cpp src/WavFile.cpp)
target_link_libraries(play -lasound -pthread)

View File

@@ -1,177 +0,0 @@
/*
* Simple sound playback using ALSA API and libasound.
*
* Compile:
* $ cc -o play sound_playback.c -lasound
*
* Usage:
* $ ./play <sample_rate> <channels> <seconds> < <file>
*
* Examples:
* $ ./play 44100 2 5 < /dev/urandom
* $ ./play 22050 1 8 < /path/to/file.wav
*
* Copyright (C) 2009 Alessandro Ghedini <al3xbio@gmail.com>
* --------------------------------------------------------------
* "THE BEER-WARE LICENSE" (Revision 42):
* Alessandro Ghedini wrote this file. As long as you retain this
* notice you can do whatever you want with this stuff. If we
* meet some day, and you think this stuff is worth it, you can
* buy me a beer in return.
* --------------------------------------------------------------
*/
#include <alsa/asoundlib.h>
#include <stdio.h>
#include <fstream>
#include "src/WavFile.h"
#include <future>
#include <thread>
#include <chrono>
#include <queue>
#define PCM_DEVICE "default"
std::deque<std::string> queue;
std::mutex queueMutex;
static std::string getInput()
{
std::cout << "starting input thread" << std::endl;
while (true)
{
std::string result;
std::getline(std::cin, result);
{
std::lock_guard<std::mutex> guard(queueMutex);
std::cout << "adding to queue" << std::endl;
queue.push_back(result);
}
}
}
int main(int argc, char **argv)
{
std::ifstream wavFileStream("test.wav", std::ios::binary);
WavFile wav(wavFileStream);
wavFileStream.close();
std::cout << "Wav samples " << wav.size() << std::endl;
unsigned int pcm, tmp, dir;
snd_pcm_t *pcm_handle;
snd_pcm_hw_params_t *params;
snd_pcm_uframes_t frames;
char *buff;
int buff_size, loops;
/* Open the PCM device in playback mode */
if (pcm = snd_pcm_open(&pcm_handle, PCM_DEVICE,
SND_PCM_STREAM_PLAYBACK, 0) < 0)
printf("ERROR: Can't open \"%s\" PCM device. %s\n",
PCM_DEVICE, snd_strerror(pcm));
/* Allocate parameters object and fill it with default values*/
snd_pcm_hw_params_alloca(&params);
snd_pcm_hw_params_any(pcm_handle, params);
/* Set parameters */
if (pcm = snd_pcm_hw_params_set_access(pcm_handle, params,
SND_PCM_ACCESS_RW_INTERLEAVED) < 0)
printf("ERROR: Can't set interleaved mode. %s\n", snd_strerror(pcm));
if (pcm = snd_pcm_hw_params_set_format(pcm_handle, params,
SND_PCM_FORMAT_S16_LE) < 0)
printf("ERROR: Can't set format. %s\n", snd_strerror(pcm));
if (pcm = snd_pcm_hw_params_set_channels(pcm_handle, params, wav.channels()) < 0)
printf("ERROR: Can't set channels number. %s\n", snd_strerror(pcm));
snd_pcm_hw_params_set_buffer_size(pcm_handle, params, 2 * 2048);
unsigned int rate = wav.sampleRate();
if (pcm = snd_pcm_hw_params_set_rate_near(pcm_handle, params, &rate, 0) < 0)
printf("ERROR: Can't set rate. %s\n", snd_strerror(pcm));
/* Write parameters */
if (pcm = snd_pcm_hw_params(pcm_handle, params) < 0)
printf("ERROR: Can't set harware parameters. %s\n", snd_strerror(pcm));
/* Resume information */
printf("PCM name: '%s'\n", snd_pcm_name(pcm_handle));
printf("PCM state: %s\n", snd_pcm_state_name(snd_pcm_state(pcm_handle)));
snd_pcm_hw_params_get_channels(params, &tmp);
printf("channels: %i ", tmp);
if (tmp == 1)
printf("(mono)\n");
else if (tmp == 2)
printf("(stereo)\n");
snd_pcm_hw_params_get_rate(params, &tmp, 0);
printf("rate: %d bps\n", tmp);
/* Allocate buffer to hold single period */
snd_pcm_hw_params_get_period_size(params, &frames, 0);
buff_size = frames * wav.channels() * 2 /* 2 -> sample size */;
buff = (char *)malloc(buff_size);
std::cout << "Buffer size " << buff_size << " frames " << frames << std::endl;
snd_pcm_hw_params_get_period_time(params, &tmp, NULL);
std::thread inputThread(getInput);
bool playing = true;
size_t wavFilePosition = 0;
while (wavFilePosition < wav.size())
{
{
std::lock_guard<std::mutex> guard(queueMutex);
if (queue.size() > 0)
{
queue.pop_back();
std::cout << "Toggling" << std::endl;
//snd_pcm_drain(pcm_handle);
std::cout << "Done" << std::endl;
playing = !playing;
if (playing)
{
std::cout << "continuing at " << wavFilePosition << std::endl;
snd_pcm_pause(pcm_handle, 0);
//snd_pcm_prepare(pcm_handle);
}
else
snd_pcm_pause(pcm_handle, 1);
}
}
if (!playing)
continue;
size_t dataToWrite = std::min(size_t(buff_size), wav.size() - wavFilePosition);
int framesToWrite = dataToWrite / wav.channels() / 2;
if (pcm = snd_pcm_writei(pcm_handle, wav[wavFilePosition], framesToWrite) == -EPIPE)
{
std::cout << "XRUN at wav position " << wavFilePosition << std::endl;
snd_pcm_prepare(pcm_handle);
}
else if (pcm < 0)
printf("ERROR. Can't write to PCM device. %s\n", snd_strerror(pcm));
wavFilePosition += dataToWrite;
}
std::cout << "done filling buffer" << std::endl;
snd_pcm_drain(pcm_handle);
snd_pcm_close(pcm_handle);
free(buff);
return 0;
}

View File

@@ -1,5 +0,0 @@
- play wav file [ok]
- read & parse wav file [ok]
- stop wave file in the middle, wait 2 secs and continue
- fade in/out
- mix second wave file on top (some effect)

View File

@@ -1,61 +0,0 @@
#pragma once
#include <iostream>
#include <memory>
template <size_t len>
std::string readStr(std::istream &str)
{
char buff[len];
str.read(buff, len);
return std::string(buff, len);
}
template <typename T>
T read(std::istream &str)
{
T res;
str.read((char *)&res, sizeof(res));
return res;
}
class WavFile
{
public:
WavFile(std::istream &str)
{
auto chunkId = readStr<4>(str);
auto chunkSize = read<uint32_t>(str);
auto format = readStr<4>(str);
auto subchunk1Id = readStr<4>(str);
auto subchunk1Size = read<uint32_t>(str);
auto audioFormat = read<uint16_t>(str);
numChannels_ = read<uint16_t>(str);
sampleRate_ = read<uint32_t>(str);
auto byteRate = read<uint32_t>(str);
auto blockAlign = read<uint16_t>(str);
bitsPerSample_ = read<uint16_t>(str);
auto subchunk2Id = readStr<4>(str);
dataSize_ = read<uint32_t>(str);
data_ = std::unique_ptr<char>(new char[dataSize_]);
str.read(data_.get(), dataSize_);
}
uint32_t sampleRate() const { return sampleRate_; }
uint16_t channels() const { return numChannels_; }
uint32_t size() const { return dataSize_; }
const char *operator[](int offset) const { return &data_.get()[offset]; }
private:
std::unique_ptr<char> data_;
uint32_t sampleRate_;
uint16_t bitsPerSample_;
uint32_t dataSize_;
uint16_t numChannels_;
};

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

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

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,184 @@
# 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
# How many tracks the background analyzer may work on at once, each in its own
# worker process. Omitted means one per core bar one (capped at 8), which is what
# turns a first-time pass over a whole library from an overnight job into a coffee
# break on a desktop. Set it to 1 on a machine that has better things to do, or to
# a specific number to cap how much of it analysis may take.
# analysis_workers: 4
# 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"]
# How many episodes of each podcast show to keep, newest first. A show that has
# published for years grows without bound: GEOlino Spezial alone is 358 episodes and
# 5.8 GB, which will not sit next to the rest of a library on a Pi's SD card.
#
# The same number caps what gets downloaded, which is what makes the folder settle.
# Prune to the newest N but fetch everything the feed offers, and every poll would
# re-download the episodes the last one deleted.
#
# Lowering this DELETES the episodes that fall outside the window on the next poll,
# and an episode that has aged out of its feed cannot be fetched again. Use `null` to
# keep every episode and mind the free space yourself.
podcast_episode_limit: 50
# 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"

1
python-backend/music/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
**/*.mp3

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,403 @@
"""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, TypeVar
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, default_worker_count
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
workers = library_config.analysis_workers
return await MusicLibrary.build(
library_config.root,
library_config.cache,
frozenset(config.general.audio_extensions),
analyzer=build_analyzer(),
# Unset in the config means "use the machine": a first pass over an unanalyzed
# library is hours of DSP, and there is no reason for a desktop to do it one
# core at a time. `MusicLibrary` itself defaults to 1 - see its docstring.
analysis_workers=default_worker_count() if workers is None else workers,
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
T = TypeVar("T", bound=Service)
def _service_of_type(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
),
episode_limit=app.config.general.podcast_episode_limit,
)
)
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,157 @@
"""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, TypeAlias, TypeVar
from musicmouse.events import Event
_log = logging.getLogger(__name__)
__all__ = ["EventBus", "Handler", "Unsubscribe"]
#: An alias carrying a TypeVar is generic on its own, so ``Handler[SomeEvent]`` still
#: parameterises it the way the PEP 695 form did.
E = TypeVar("E", bound=Event)
Handler: TypeAlias = Callable[[E], Coroutine[Any, Any, None] | None]
Unsubscribe: TypeAlias = 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(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')")

View File

@@ -0,0 +1,436 @@
"""Config schema and loading.
Validation is strict on purpose: unknown keys are rejected (a typo'd setting that is
silently ignored is worse than a startup failure), and every problem in the file is
reported at once rather than one per run.
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Annotated, Any, Final, Literal, Self, TypeAlias
from pydantic import (
BaseModel,
ConfigDict,
Field,
PlainValidator,
ValidationError,
ValidationInfo,
field_validator,
model_validator,
)
from ruamel.yaml import YAML
from ruamel.yaml.error import YAMLError
from musicmouse.color import ColorRGBW, parse_color
from musicmouse.hardware import NO_FIGURE_TAG, RFID_TAG_LENGTH
from musicmouse.library.podcast_feeds import DEFAULT_EPISODE_LIMIT
_log = logging.getLogger(__name__)
__all__ = [
"SIMULATE",
"Config",
"ConfigError",
"Digit",
"FigureColors",
"FigureConfig",
"GeneralConfig",
"HaConfig",
"HaDeviceConfig",
"LibraryConfig",
"LircConfig",
"MqttConfig",
"RemoteSlotConfig",
"TippenConfig",
"WebConfig",
"format_validation_error",
"load_config",
]
#: Number keys on the IR remote, as lircd's ``BTN_0``..``BTN_9`` map to them.
Digit: TypeAlias = Literal["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]
DEFAULT_AUDIO_EXTENSIONS = (".mp3", ".ogg", ".oga", ".opus", ".flac", ".wav", ".m4a", ".aac")
#: Stand-in value for ``serial_port`` and ``alsa_device``. Running without the mouse or
#: without sound is a supported setup, but it has to be *asked for*: a missing key is an
#: error, so a config that lost a line fails loudly instead of booting into a silent
#: mouse that looks like it is working.
SIMULATE: Final = "simulate"
class ConfigError(Exception):
"""Raised with an already human-readable, multi-line message."""
def _parse_tag_id(value: Any) -> bytes:
if isinstance(value, bytes):
raw = value
else:
if not isinstance(value, str):
raise ValueError(f"expected a hex string, got {type(value).__name__}")
text = value.strip().replace(":", "").replace(" ", "")
try:
raw = bytes.fromhex(text)
except ValueError:
raise ValueError(f"{value!r} is not a valid hex string") from None
if len(raw) != RFID_TAG_LENGTH:
raise ValueError(
f"expected {RFID_TAG_LENGTH} bytes ({RFID_TAG_LENGTH * 2} hex digits), "
f"got {len(raw)} ({raw.hex()!r})"
)
if raw == NO_FIGURE_TAG:
raise ValueError("the all-zero tag id is reserved for 'no figure on the reader'")
return raw
Color = Annotated[ColorRGBW, PlainValidator(parse_color)]
TagId = Annotated[bytes, PlainValidator(_parse_tag_id)]
_COLOR_ROLES = ("primary", "secondary", "bg", "accent")
class _Strict(BaseModel):
model_config = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
class FigureColors(_Strict):
"""The four colours of a figure, given in config as a list of colour strings."""
primary: Color
secondary: Color
bg: Color
accent: Color
@model_validator(mode="before")
@classmethod
def _accept_sequence(cls, data: Any) -> Any:
if isinstance(data, (list, tuple)):
if len(data) != len(_COLOR_ROLES):
raise ValueError(
f"expected exactly {len(_COLOR_ROLES)} colors "
f"({', '.join(_COLOR_ROLES)}), got {len(data)}"
)
return dict(zip(_COLOR_ROLES, data, strict=True))
return data
def _resolve_folder(
folder: Path, info: ValidationInfo, *, must_exist: bool, kind: Literal["dir", "file"] = "dir"
) -> Path:
"""Make a configured path absolute against the config file, and optionally check it."""
context = info.context or {}
base = context.get("config_dir")
if base is not None and not folder.is_absolute():
folder = (Path(base) / folder).resolve()
if must_exist and context.get("check_paths", True):
exists = folder.is_file() if kind == "file" else folder.is_dir()
if not exists:
noun = "file" if kind == "file" else "directory"
raise ValueError(f"no such {noun}: {folder}")
return folder
class MqttConfig(_Strict):
server: str
port: int = Field(default=1883, ge=1, le=65535)
user: str | None = None
password: str | None = None
base_topic: str = "musicmouse"
discovery_prefix: str = "homeassistant"
device_id: str = "musicmouse"
device_name: str = "Music Mouse"
reconnect_interval: float = Field(default=10.0, gt=0)
class LircConfig(_Strict):
"""TCP client for lircd's classic network protocol - see ``ansible/roles/pi_lirc``.
Omit the whole section to run without an IR remote.
"""
host: str
#: This deployment's lircd listens on 2222 (see the ansible role); lircd's own
#: default is 8765, so this is worth overriding rather than assuming.
port: int = Field(default=2222, ge=1, le=65535)
#: Only button events from this remote are acted on - other remotes registered
#: with the same lircd (an LED remote, say) are ignored.
remote_name: str = "Hauppauge"
reconnect_interval: float = Field(default=5.0, gt=0)
class LibraryConfig(_Strict):
"""Where the music lives.
One path. The shelves underneath it - ``Figuren``, ``Musik``, ``Hoerbuecher``,
``Kinderpodcasts`` - are fixed names, not settings; see
:mod:`musicmouse.library.sections`.
"""
root: Path
#: Scan results, extracted cover art and track analysis. Relative to this file.
cache: Path = Path(".musicmouse-cache")
#: How many tracks background analysis may work on at once, each in its own worker
#: process. Omit for one per core bar one (see
#: :func:`musicmouse.library.workers.default_worker_count`); set it to 1 to keep
#: analysis to a single process on a machine that has other work to do.
analysis_workers: int | None = Field(default=None, ge=1)
@field_validator("root")
@classmethod
def _resolve_root(cls, folder: Path, info: ValidationInfo) -> Path:
return _resolve_folder(folder, info, must_exist=True)
@field_validator("cache")
@classmethod
def _resolve_cache(cls, folder: Path, info: ValidationInfo) -> Path:
return _resolve_folder(folder, info, must_exist=False)
@property
def figure_folder(self) -> Path:
return self.root / "Figuren"
class WebConfig(_Strict):
"""The web front-end. Omit the whole section to run without it."""
#: A LAN appliance with no auth; binding to all interfaces is the point.
host: str = "0.0.0.0"
port: int = Field(default=8080, ge=1, le=65535)
#: Built frontend to serve at ``/``. Omit to expose only the JSON API.
static_dir: Path | None = None
@field_validator("static_dir")
@classmethod
def _resolve_static(cls, folder: Path | None, info: ValidationInfo) -> Path | None:
return None if folder is None else _resolve_folder(folder, info, must_exist=False)
class HaDeviceConfig(_Strict):
"""One Home Assistant entity to expose to the room-control page ("Mein Zimmer")."""
entity_id: str
name: str | None = None
class HaConfig(_Strict):
"""Home Assistant integration for the room-control page ("Mein Zimmer").
The backend never calls Home Assistant itself - it only hands the browser the
server URL, the token, and these two ordered lists. Control happens directly from
the browser to Home Assistant's own REST API, so this token grants full HA control
to anything on the LAN that can reach musicmouse. See config.yml.example.
"""
url: str
token: str
#: Order is preserved and drives the device card grid on the room page.
devices: list[HaDeviceConfig] = Field(default_factory=list)
#: Order is preserved and drives the scene pill row on the room page.
scenes: list[HaDeviceConfig] = Field(default_factory=list)
@model_validator(mode="after")
def _check_something_configured(self) -> Self:
if not self.devices and not self.scenes:
raise ValueError("configure at least one device or scene, or omit the ha section")
return self
class TippenConfig(_Strict):
"""The typing game. Omit the whole section to run without it.
The curriculum is content, not device settings, so it lives in its own file
(``curriculum_file``) rather than inline here - see ``tippen-curriculum.yml.example``.
``progress_file`` is written by the app itself, not hand-edited, and defaults to a
name next to ``config.yml`` if not given a folder of its own.
"""
curriculum_file: Path
progress_file: Path = Path("tippen-progress.json")
@field_validator("curriculum_file")
@classmethod
def _resolve_curriculum_file(cls, path: Path, info: ValidationInfo) -> Path:
return _resolve_folder(path, info, must_exist=True, kind="file")
@field_validator("progress_file")
@classmethod
def _resolve_progress_file(cls, path: Path, info: ValidationInfo) -> Path:
return _resolve_folder(path, info, must_exist=False, kind="file")
class GeneralConfig(_Strict):
library: LibraryConfig
#: Serial port the ESP32 is on, or ``"simulate"`` to run without the mouse: the
#: web front-end is a complete way to drive the player on its own. Required.
serial_port: str
baudrate: int = Field(default=115200, gt=0)
reconnect_interval: float = Field(default=5.0, gt=0)
#: ALSA output device passed to VLC, e.g. ``"hw:0,0"`` or ``"default"``, or
#: ``"simulate"`` for a player that makes no sound. Required.
alsa_device: str
mqtt: MqttConfig | None = None
web: WebConfig | None = None
ha: HaConfig | None = None
lirc: LircConfig | None = None
tippen: TippenConfig | None = None
min_volume: int = Field(default=0, ge=0, le=200)
max_volume: int = Field(default=100, ge=0, le=200)
initial_volume: int = Field(default=50, ge=0, le=200)
volume_increment: int = Field(default=5, ge=1, le=100)
button_leds_brightness: float = Field(default=0.5, ge=0, le=1)
audio_extensions: tuple[str, ...] = DEFAULT_AUDIO_EXTENSIONS
#: How many episodes of each podcast show to keep, newest first. A show that has
#: published for years grows without bound and will eventually fill the device's SD
#: card. ``null`` keeps every episode, and minding the free space is then on you.
#:
#: Lowering this *deletes* the episodes that fall outside the window on the next
#: poll, and an episode that has aged out of its feed cannot be fetched again.
podcast_episode_limit: int | None = Field(default=DEFAULT_EPISODE_LIMIT, ge=1)
@property
def serial_simulated(self) -> bool:
return self.serial_port == SIMULATE
@property
def audio_simulated(self) -> bool:
return self.alsa_device == SIMULATE
@model_validator(mode="after")
def _check_volumes(self) -> Self:
if self.min_volume > self.max_volume:
raise ValueError(
f"min_volume ({self.min_volume}) must not exceed max_volume ({self.max_volume})"
)
if not self.min_volume <= self.initial_volume <= self.max_volume:
raise ValueError(
f"initial_volume ({self.initial_volume}) must lie between "
f"min_volume ({self.min_volume}) and max_volume ({self.max_volume})"
)
return self
class FigureConfig(_Strict):
#: RFID tag id as hex, e.g. "04a1b2c3d4".
id: TagId
colors: FigureColors
#: What this figure holds. Unlike the other shelves a figure folder is named after
#: the figurine rather than its contents, so nothing on disk says whether it is an
#: album or an audiobook - and the browse view draws the two differently.
kind: Literal["music", "book"] = "music"
class RemoteSlotConfig(_Strict):
"""What a number key on the IR remote plays.
``"album"``: ``target`` is an ``Album.id``, always started from track 0 - a music
album or an audiobook. ``"series"``: ``target`` is a podcast show name (an
``Album.series``); resolved to that show's newest episode fresh on every press,
since a podcast show is not itself one playable thing in this library - each
episode is its own album.
"""
target_kind: Literal["album", "series"]
target: str
class Config(_Strict):
general: GeneralConfig
figures: dict[str, FigureConfig] = Field(min_length=1)
#: Number key (0-9) -> what it plays. Empty by default: a fresh install has no
#: assignments, and that is not an error.
remote: dict[Digit, RemoteSlotConfig] = Field(default_factory=dict)
@model_validator(mode="after")
def _check_unique_tag_ids(self) -> Self:
seen: dict[bytes, str] = {}
for name, figure in self.figures.items():
if (other := seen.get(figure.id)) is not None:
raise ValueError(
f"figures {other!r} and {name!r} both use tag id {figure.id.hex()}"
)
seen[figure.id] = name
return self
@property
def tag_map(self) -> dict[bytes, str]:
"""Tag id -> figure name, as handed to the device."""
return {figure.id: name for name, figure in self.figures.items()}
@property
def figure_kinds(self) -> dict[str, Literal["music", "book"]]:
"""Figure name -> what it holds, as handed to the library scanner."""
return {name: figure.kind for name, figure in self.figures.items()}
def folder_for(self, figure: str) -> Path:
return self.general.library.figure_folder / figure
def format_validation_error(error: ValidationError) -> str:
"""Render a pydantic error as one short ``path: message`` line per problem."""
lines: list[str] = []
for entry in error.errors():
location = ".".join(
f"[{part}]" if isinstance(part, int) else str(part) for part in entry["loc"]
).replace(".[", "[")
message = entry["msg"]
for prefix in ("Value error, ", "Assertion failed, "):
message = message.removeprefix(prefix)
if entry["type"] == "extra_forbidden":
message = "unknown option (check the spelling against config.yml.example)"
elif entry["type"] == "missing":
message = "required (see config.yml.example)"
lines.append(f" {location or '<root>'}: {message}")
plural = "s" if len(lines) != 1 else ""
return f"{len(lines)} problem{plural} in the config file:\n" + "\n".join(lines)
def load_config(path: Path, *, check_paths: bool = True) -> Config:
"""Load and validate a config file.
Raises:
ConfigError: with a message that can be printed straight to the terminal.
"""
path = Path(path)
if path.is_dir():
raise ConfigError(
f"{path} is a directory. Pass the config file itself, e.g. {path / 'config.yml'}"
)
try:
text = path.read_text(encoding="utf-8")
except OSError as exc:
raise ConfigError(f"Cannot read config file {path}: {exc.strerror}") from exc
try:
data = YAML(typ="safe").load(text)
except YAMLError as exc:
raise ConfigError(f"{path} is not valid YAML:\n {exc}") from exc
if data is None:
raise ConfigError(f"{path} is empty")
if not isinstance(data, dict):
raise ConfigError(
f"{path} must contain a mapping at the top level, got {type(data).__name__}"
)
context = {"config_dir": path.parent, "check_paths": check_paths}
try:
config = Config.model_validate(data, context=context)
except ValidationError as exc:
raise ConfigError(f"{path}\n{format_validation_error(exc)}") from exc
if check_paths:
for name in config.figures:
folder = config.folder_for(name)
if not folder.is_dir():
_log.warning("Figure %r has no media folder at %s", name, folder)
return config

View File

@@ -0,0 +1 @@
"""Objects that own a piece of hardware: the mouse itself and the audio player."""

View File

@@ -0,0 +1,177 @@
"""The mouse itself: the object that talks to the firmware.
It owns the physical state - which figure is on the reader, how bright the button
backlights are, which effect each LED zone is showing - and it is the single writer to
all three LED zones. "Last write wins" is therefore a well-defined rule: whoever sets a
zone last, whether a figure animation or an MQTT command, is what the strip shows.
Every write emits :class:`~musicmouse.events.LedEffectChanged`, so front-ends can
publish the strip's real state instead of echoing their own commands back.
"""
from __future__ import annotations
import logging
from dataclasses import replace
from musicmouse.bus import EventBus
from musicmouse.devices.transport import Transport
from musicmouse.devices.wire import (
FirmwareLog,
FrameDecoder,
ProtocolError,
UnsupportedEffectError,
encode_button_brightness,
encode_effect,
)
from musicmouse.effects import OFF, LedEffect
from musicmouse.events import (
ActiveFigureChanged,
ConnectionChanged,
DeviceConnected,
DeviceDisconnected,
EventSource,
InputEvent,
LedEffectChanged,
RfidTokenRead,
)
from musicmouse.hardware import NO_FIGURE_TAG, Button, LedZone
_log = logging.getLogger(__name__)
__all__ = ["MusicMouseDevice"]
_BACKLIT_BUTTONS = (Button.LEFT, Button.RIGHT)
class MusicMouseDevice:
def __init__(
self,
bus: EventBus,
transport: Transport,
tag_map: dict[bytes, str],
*,
port: str = "",
) -> None:
self._bus = bus
self._transport = transport
self._tag_map = dict(tag_map)
self._decoder = FrameDecoder()
self.port = port
self._active_figure: str | None = None
self._button_brightness: float = 0.0
self._effects: dict[LedZone, LedEffect] = {}
# -------------------------------------------------------------------- state
@property
def active_figure(self) -> str | None:
"""The figure currently on the reader, or ``None`` if there is none."""
return self._active_figure
@property
def button_led_brightness(self) -> float:
return self._button_brightness
@property
def connected(self) -> bool:
return self._transport.connected
def effect(self, zone: LedZone) -> LedEffect | None:
return self._effects.get(zone)
# ------------------------------------------------------------------ actions
def set_effect(
self, zone: LedZone, effect: LedEffect, *, origin: EventSource = "system"
) -> None:
"""Show ``effect`` on ``zone``. The most recent call wins."""
try:
frame = encode_effect(zone, effect)
except UnsupportedEffectError as exc:
_log.error("%s", exc)
return
self._effects[zone] = effect
self._transport.write(frame)
self._bus.emit(
LedEffectChanged(zone=zone, effect=effect, origin=origin, source="device")
)
def set_button_brightness(self, brightness: float, *, origin: EventSource = "system") -> None:
"""Set both prev/next button backlights (``0..1``)."""
brightness = min(1.0, max(0.0, brightness))
self._button_brightness = brightness
for button in _BACKLIT_BUTTONS:
self._transport.write(encode_button_brightness(button, brightness))
_log.debug("Button backlights -> %.2f (%s)", brightness, origin)
def all_leds_off(self, *, origin: EventSource = "system") -> None:
for zone in LedZone:
self.set_effect(zone, OFF(), origin=origin)
self.set_button_brightness(0.0, origin=origin)
# ------------------------------------------------------------- link callbacks
def on_connected(self) -> None:
"""Re-apply memorized state, so a reconnect is invisible from the outside."""
self._bus.emit(DeviceConnected(port=self.port, source="device"))
self._bus.emit(ConnectionChanged(target="firmware", connected=True, source="device"))
for zone, effect in self._effects.items():
self._transport.write(encode_effect(zone, effect))
for button in _BACKLIT_BUTTONS:
self._transport.write(encode_button_brightness(button, self._button_brightness))
if self._effects:
_log.info("Restored %d LED zone(s) after reconnect", len(self._effects))
def on_disconnected(self, reason: str = "") -> None:
self._bus.emit(DeviceDisconnected(port=self.port, reason=reason or None, source="device"))
self._bus.emit(ConnectionChanged(target="firmware", connected=False, source="device"))
def feed(self, data: bytes) -> None:
"""Hand bytes from the link to the decoder and publish what comes out."""
self._decoder.push(data)
while True:
try:
item = self._decoder.take()
except ProtocolError as exc:
_log.warning("Discarding bad frame from firmware: %s", exc)
continue
if item is None:
return
if isinstance(item, FirmwareLog):
if item.text:
_log.info("[firmware] %s", item.text)
else:
self._publish(item)
# ---------------------------------------------------------------- internals
def _publish(self, event: InputEvent) -> None:
if isinstance(event, RfidTokenRead):
self._publish_tag_read(event)
else:
self._bus.emit(event)
def _publish_tag_read(self, event: RfidTokenRead) -> None:
if event.tag_id == NO_FIGURE_TAG:
figure, known = None, True
elif (name := self._tag_map.get(event.tag_id)) is not None:
figure, known = name, True
else:
figure, known = None, False
_log.warning("Unknown RFID tag %s - not configured as a figure", event.tag_id.hex())
self._bus.emit(replace(event, figure=figure, known=known))
if not known:
# Leave the active figure alone: an unreadable tag is not a removal.
return
previous, self._active_figure = self._active_figure, figure
if previous != figure:
self._bus.emit(
ActiveFigureChanged(figure=figure, previous=previous, source="device")
)

View File

@@ -0,0 +1,22 @@
"""A transport with nothing on the other end.
The web front-end is a complete way to drive the mouse, so the backend has to be
useful on a machine that has no mouse attached - a spare Pi, a laptop, a container.
Rather than making :class:`~musicmouse.devices.mouse.MusicMouseDevice` optional and
teaching every reaction to cope with its absence, the device is built as usual against
a transport that drops what it is handed. Lighting reactions still run; their bytes go
nowhere.
"""
from __future__ import annotations
__all__ = ["NullTransport"]
class NullTransport:
def write(self, data: bytes) -> None:
pass
@property
def connected(self) -> bool:
return False

View File

@@ -0,0 +1,365 @@
"""Audio playback, behind a protocol.
:class:`VlcPlayer` is the only real implementation; the simulator supplies another.
libVLC fires its callbacks on its own thread, so every one of them goes through
``bus.emit()``, which hops back onto the event loop. The old code called straight into
the serial transport from that thread.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any, Protocol
from musicmouse.bus import EventBus
from musicmouse.clock import Clock, RealClock
from musicmouse.events import (
EventSource,
PlaybackChanged,
PlaylistFinished,
TrackChanged,
VolumeChanged,
)
from musicmouse.media import Playlist, Track
if TYPE_CHECKING:
from musicmouse.config import GeneralConfig
_log = logging.getLogger(__name__)
__all__ = ["Player", "PlayerBase", "VlcPlayer"]
class Player(Protocol):
"""What reactions and front-ends are allowed to do with the audio player."""
@property
def is_playing(self) -> bool: ...
@property
def volume(self) -> int: ...
@property
def playlist(self) -> Playlist | None: ...
@property
def track_index(self) -> int: ...
@property
def current_track(self) -> Track | None: ...
@property
def position(self) -> float:
"""Seconds into the current track. ``0.0`` when nothing is loaded.
Read on demand rather than announced: a progress bar wants this twice a second,
and an event at that rate would flood the bus, the MQTT service and the log for
the benefit of one front-end.
"""
...
@property
def duration(self) -> float:
"""Length of the current track in seconds, or ``0.0`` when unknown."""
...
def set_playlist(self, playlist: Playlist) -> None: ...
def play(self) -> None: ...
def play_from_start(self) -> None: ...
def play_track(self, index: int) -> None: ...
def pause(self) -> None: ...
def stop(self) -> None: ...
def next_track(self) -> None: ...
def previous_track(self) -> None: ...
def seek(self, position: float) -> None: ...
def set_volume(self, volume: int, *, source: EventSource = "system") -> None: ...
def change_volume(self, delta: int, *, source: EventSource = "system") -> None: ...
def set_volume_limits(self, minimum: int, maximum: int) -> None: ...
async def run(self) -> None:
"""Long-running task, if the implementation needs one."""
...
def close(self) -> None: ...
class PlayerBase:
"""Volume clamping, playlist bookkeeping and state events, shared by the
real and the simulated player."""
def __init__(
self,
bus: EventBus,
*,
min_volume: int = 0,
max_volume: int = 100,
initial_volume: int = 50,
) -> None:
self._bus = bus
self._min_volume = min_volume
self._max_volume = max_volume
self._volume = self._clamp(initial_volume)
self._playlist: Playlist | None = None
self._index = 0
self._playing = False
self._playlist_changed = False
@classmethod
def volume_kwargs(cls, config: GeneralConfig) -> dict[str, int]:
return {
"min_volume": config.min_volume,
"max_volume": config.max_volume,
"initial_volume": config.initial_volume,
}
# -------------------------------------------------------------------- state
@property
def is_playing(self) -> bool:
return self._playing
@property
def volume(self) -> int:
return self._volume
@property
def playlist(self) -> Playlist | None:
return self._playlist
@property
def track_index(self) -> int:
return self._index
@property
def current_track(self) -> Track | None:
if self._playlist is None or not 0 <= self._index < len(self._playlist):
return None
return self._playlist[self._index]
# ------------------------------------------------------------------ actions
def set_volume(self, volume: int, *, source: EventSource = "system") -> None:
clamped = self._clamp(volume)
if clamped == self._volume:
return
self._volume = clamped
self._apply_volume(clamped)
self._announce_volume(source)
def change_volume(self, delta: int, *, source: EventSource = "system") -> None:
self.set_volume(self._volume + delta, source=source)
def set_volume_limits(self, minimum: int, maximum: int) -> None:
"""Re-clamp to a new allowed range, and pull the current volume into it.
Parent mode edits these while the mouse is playing, so they cannot only be
constructor arguments.
"""
self._min_volume = minimum
self._max_volume = maximum
self.set_volume(self._volume)
def _apply_volume(self, volume: int) -> None:
"""Push the new volume at whatever actually makes sound. No-op by default."""
# ---------------------------------------------------------------- internals
def _clamp(self, volume: int) -> int:
# `if self._min_volume and ...` in the old code silently ignored min_volume: 0,
# which is what config.yml.example shipped with.
return max(self._min_volume, min(self._max_volume, volume))
def _set_playing(self, playing: bool, *, figure: str | None = None) -> None:
if playing == self._playing:
return
self._playing = playing
self._playlist_changed = False
self._bus.emit(
PlaybackChanged(
playing=playing, figure=figure, playlist=self._playlist, source="player"
)
)
def _set_index(self, index: int) -> None:
# A playlist swap while already playing resets the index to 0 without going
# through here (see `_load_playlist`), so the following `play_track(0)` looks
# like a no-op index change. Force it through in that case - `_set_playing`
# will not itself announce anything, since it was already playing before and
# after. Starting from idle needs no such push: `_set_playing`'s own True
# transition already covers the broadcast.
force = self._playlist_changed and self._playing
if index == self._index and not force:
return
self._playlist_changed = False
self._index = index
self._bus.emit(TrackChanged(index=index, track=self.current_track, source="player"))
def _load_playlist(self, playlist: Playlist) -> None:
"""Bookkeeping shared by every player's ``set_playlist``."""
self._playlist = playlist
self._index = 0
self._playlist_changed = True
def _announce_volume(self, source: EventSource = "player") -> None:
self._bus.emit(VolumeChanged(volume=self._volume, source=source))
def _announce_playlist_finished(self) -> None:
self._bus.emit(PlaylistFinished(source="player"))
async def run(self) -> None: # pragma: no cover - overridden where needed
return
def close(self) -> None: # pragma: no cover - overridden where needed
return
class VlcPlayer(PlayerBase):
def __init__(
self,
bus: EventBus,
*,
alsa_device: str | None = None,
min_volume: int = 0,
max_volume: int = 100,
initial_volume: int = 50,
poll_interval: float = 1.0,
clock: Clock | None = None,
) -> None:
super().__init__(
bus, min_volume=min_volume, max_volume=max_volume, initial_volume=initial_volume
)
# Imported here rather than at module scope: python-vlc loads libvlc eagerly,
# and the simulator must run on machines without it.
import vlc
self._vlc = vlc
self._poll_interval = poll_interval
self._clock = clock or RealClock()
args = ["-A", "alsa", "--alsa-audio-device", alsa_device] if alsa_device else []
self._instance = vlc.Instance(*args)
self._list_player = self._instance.media_list_player_new()
self._media_player = self._list_player.get_media_player()
self._mrl_to_index: dict[str, int] = {}
self._attach_events()
self._media_player.audio_set_volume(self._volume)
# -------------------------------------------------------------------- state
@property
def position(self) -> float:
# libVLC reports -1 for both of these until a media is actually opened.
return max(0.0, float(self._media_player.get_time()) / 1000)
@property
def duration(self) -> float:
return max(0.0, float(self._media_player.get_length()) / 1000)
# ------------------------------------------------------------------ actions
def set_playlist(self, playlist: Playlist) -> None:
media_list = self._vlc.MediaList()
self._mrl_to_index.clear()
for index, track in enumerate(playlist.tracks):
media = self._instance.media_new(str(track.path))
media_list.add_media(media)
self._mrl_to_index[media.get_mrl()] = index
self._list_player.set_media_list(media_list)
self._list_player.set_playback_mode(self._vlc.PlaybackMode.default)
self._load_playlist(playlist)
_log.info("Playlist %r loaded (%d tracks)", playlist.name, len(playlist))
def play(self) -> None:
if self._playing:
return
self._list_player.play()
def play_from_start(self) -> None:
self.play_track(0)
def play_track(self, index: int) -> None:
if self._playlist is None or not self._playlist:
_log.warning("Nothing to play: the playlist is empty")
return
self._list_player.play_item_at_index(max(0, min(index, len(self._playlist) - 1)))
def pause(self) -> None:
if not self._playing:
return
self._media_player.set_pause(1)
def stop(self) -> None:
self._list_player.stop()
def next_track(self) -> None:
self._list_player.next()
def previous_track(self) -> None:
self._list_player.previous()
def seek(self, position: float) -> None:
self._media_player.set_time(int(max(0.0, position) * 1000))
def _apply_volume(self, volume: int) -> None:
self._media_player.audio_set_volume(volume)
async def run(self) -> None:
"""Poll for state libVLC does not reliably report by event."""
while True:
await self._clock.sleep(self._poll_interval)
try:
self._poll()
except Exception: # pragma: no cover - defensive around a C library
_log.exception("VLC poll failed")
def close(self) -> None:
self._list_player.stop()
# ---------------------------------------------------------------- internals
def _poll(self) -> None:
volume = self._media_player.audio_get_volume()
if volume >= 0 and volume != self._volume:
self._volume = volume
self._announce_volume()
self._set_playing(bool(self._list_player.is_playing()))
self._sync_index()
def _sync_index(self) -> None:
"""Read which track is playing back off libVLC.
Asking the media player which media it holds works on every build; the
``MediaListPlayerNextItemSet`` payload does not - on some it arrives as a bare
int rather than a Media, and the index would then never move off zero.
"""
media = self._media_player.get_media()
if media is None:
return
index = self._mrl_to_index.get(media.get_mrl())
if index is not None:
self._set_index(index)
def _attach_events(self) -> None:
vlc = self._vlc
player_events = self._media_player.event_manager()
player_events.event_attach(vlc.EventType.MediaPlayerPlaying, self._on_playing)
player_events.event_attach(vlc.EventType.MediaPlayerPaused, self._on_stopped)
player_events.event_attach(vlc.EventType.MediaPlayerStopped, self._on_stopped)
list_events = self._list_player.event_manager()
list_events.event_attach(vlc.EventType.MediaListPlayerPlayed, self._on_playlist_end)
list_events.event_attach(vlc.EventType.MediaListPlayerNextItemSet, self._on_next_item)
# These four run on a libVLC thread. bus.emit() is the thread hop; nothing else
# here may touch the loop.
def _on_playing(self, _event: Any) -> None:
self._set_playing(True)
def _on_stopped(self, _event: Any) -> None:
self._set_playing(False)
def _on_playlist_end(self, _event: Any) -> None:
self._set_playing(False)
self._announce_playlist_finished()
def _on_next_item(self, _event: Any) -> None:
# The event says *when* to look; what it carries is not portable, so ignore it.
self._sync_index()

View File

@@ -0,0 +1,122 @@
"""Serial transport with reconnect.
The old ``host_driver.py`` stopped the event loop when the USB cable was pulled and
relied on systemd to restart the whole process. Here a dropped link is just a
reconnect loop, and the device re-applies its LED state when it comes back.
"""
from __future__ import annotations
import asyncio
import logging
from collections.abc import Callable
import serial_asyncio
from musicmouse.clock import Clock, RealClock
_log = logging.getLogger(__name__)
__all__ = ["SerialLink"]
_READ_CHUNK = 1024
class SerialLink:
"""Owns the serial port and keeps trying to hold it open."""
def __init__(
self,
port: str,
baudrate: int = 115200,
*,
reconnect_interval: float = 5.0,
clock: Clock | None = None,
) -> None:
self.port = port
self.baudrate = baudrate
self._reconnect_interval = reconnect_interval
self._clock = clock or RealClock()
self._writer: asyncio.StreamWriter | None = None
self._on_data: Callable[[bytes], None] = lambda _data: None
self._on_connect: Callable[[], None] | None = None
self._on_disconnect: Callable[[str], None] | None = None
def attach(
self,
on_data: Callable[[bytes], None],
*,
on_connect: Callable[[], None] | None = None,
on_disconnect: Callable[[str], None] | None = None,
) -> None:
"""Wire up the device. Separate from ``__init__`` because the device needs the
link as its transport, so one of the two has to be built first."""
self._on_data = on_data
self._on_connect = on_connect
self._on_disconnect = on_disconnect
@property
def connected(self) -> bool:
return self._writer is not None
def write(self, data: bytes) -> None:
writer = self._writer
if writer is None:
_log.debug("Dropping %d bytes: %s is not connected", len(data), self.port)
return
try:
writer.write(data)
except OSError as exc: # pragma: no cover - needs a real port dying mid-write
_log.warning("Write to %s failed: %s", self.port, exc)
async def run(self) -> None:
"""Connect, read until the link drops, wait, repeat. Runs until cancelled."""
while True:
reader = await self._connect()
if reader is None:
await self._clock.sleep(self._reconnect_interval)
continue
reason = await self._pump(reader)
self._writer = None
_log.warning("Lost connection to %s: %s", self.port, reason)
if self._on_disconnect is not None:
self._on_disconnect(reason)
await self._clock.sleep(self._reconnect_interval)
async def _connect(self) -> asyncio.StreamReader | None:
reader: asyncio.StreamReader
writer: asyncio.StreamWriter
try:
reader, writer = await serial_asyncio.open_serial_connection(
url=self.port, baudrate=self.baudrate
)
except (OSError, ValueError) as exc:
_log.warning(
"Cannot open %s (%s); retrying in %gs",
self.port,
exc,
self._reconnect_interval,
)
return None
self._writer = writer
_log.info("Connected to firmware on %s at %d baud", self.port, self.baudrate)
if self._on_connect is not None:
self._on_connect()
return reader
async def _pump(self, reader: asyncio.StreamReader) -> str:
try:
while True:
data = await reader.read(_READ_CHUNK)
if not data:
return "port closed"
self._on_data(data)
except asyncio.CancelledError:
self._writer = None
raise
except (OSError, asyncio.IncompleteReadError) as exc:
return str(exc)

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