- python-backend/README: track analysis is implemented (2e0e6ad); the cache layout, the module table (lirc, screen, timers, podcasts, tippen) and the route table (track analysis, HA proxy, typing game) were missing pieces. - docs/REPO_OVERVIEW: Mein Zimmer and the clock are implemented; Home Assistant is also reached over HTTP through the room page's proxy, not MQTT only; the IR remote, scripts/, the optional config sections and figure kind were not mentioned. - web/README: the key table was the mockup's (Ctrl transport keys, TAB over 'all'); ?pi=1 is now a preset with per-browser overrides, not a read-once flag; the room page and the typing game exist; eslint is part of the checks. - Docstrings that described earlier designs: HaConfig (written as browser-direct in the same commit that built the proxy,a8ed350), services/__init__, events, playback, media, colors, tippen progress and AppHeader, lowPower.ts, and the 'Hoerbuecher' spelling. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
357 lines
9.9 KiB
Python
357 lines
9.9 KiB
Python
"""The event vocabulary.
|
|
|
|
Three flavours, distinguished by base class:
|
|
|
|
* :class:`InputEvent` - something happened (hardware, player).
|
|
* :class:`IntentEvent` - something was requested (button, MQTT, web, simulator).
|
|
* :class:`StateEvent` - something changed.
|
|
|
|
The intent layer is what lets several front-ends drive the same behaviour: a button
|
|
press, an MQTT command, a web request and the IR remote all emit ``NextTrackRequested``
|
|
and a single reaction acts on it.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
from dataclasses import dataclass, field
|
|
from typing import TYPE_CHECKING, Literal, TypeAlias
|
|
|
|
from musicmouse.effects import LedEffect
|
|
from musicmouse.hardware import Button, ButtonAction, LedZone, RotaryDirection, TouchButton
|
|
from musicmouse.media import Playlist, Track
|
|
|
|
if TYPE_CHECKING:
|
|
from musicmouse.config import Digit
|
|
|
|
__all__ = [
|
|
"ActiveFigureChanged",
|
|
"ButtonEvent",
|
|
"CancelTimerRequested",
|
|
"ClockStateChanged",
|
|
"ConnectionChanged",
|
|
"Event",
|
|
"EventSource",
|
|
"FigureSelectionChanged",
|
|
"FigureSelectorExpired",
|
|
"InputEvent",
|
|
"IntentEvent",
|
|
"LedEffectChanged",
|
|
"LedEffectRequested",
|
|
"NextTrackRequested",
|
|
"PauseRequested",
|
|
"PlayAlbumRequested",
|
|
"PlayFigureRequested",
|
|
"PlayRemoteSlotRequested",
|
|
"PlayRequested",
|
|
"PlaySeriesLatestRequested",
|
|
"PlaybackChanged",
|
|
"PlaylistFinished",
|
|
"PreviousTrackRequested",
|
|
"RfidTokenRead",
|
|
"RotaryTurned",
|
|
"SelectorPressed",
|
|
"SelectorTurned",
|
|
"SetTimerRequested",
|
|
"SetVolumeRequested",
|
|
"StateEvent",
|
|
"ToggleClockRequested",
|
|
"ToggleScreenRequested",
|
|
"TouchButtonPressed",
|
|
"TouchButtonReleased",
|
|
"TrackChanged",
|
|
"UserActivity",
|
|
"VolumeChangeRequested",
|
|
"VolumeChanged",
|
|
]
|
|
|
|
EventSource: TypeAlias = Literal["device", "player", "mqtt", "web", "lirc", "simulator", "system"]
|
|
|
|
|
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
class Event:
|
|
"""Base for every event. Keyword-only so subclasses can add required fields."""
|
|
|
|
source: EventSource = "system"
|
|
timestamp: float = field(default_factory=time.monotonic, compare=False)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
class InputEvent(Event):
|
|
"""Something happened out in the world."""
|
|
|
|
|
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
class IntentEvent(Event):
|
|
"""Something was requested. May come from any front-end."""
|
|
|
|
|
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
class StateEvent(Event):
|
|
"""Something changed. Front-ends mirror these outwards."""
|
|
|
|
|
|
# --------------------------------------------------------------------------- input
|
|
|
|
|
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
class RfidTokenRead(InputEvent):
|
|
"""A tag was read. ``figure`` is ``None`` for the all-zero "removed" tag and for
|
|
tags that match no configured figure (``known`` tells the two apart)."""
|
|
|
|
tag_id: bytes
|
|
figure: str | None = None
|
|
known: bool = True
|
|
|
|
def __repr__(self) -> str:
|
|
tag = self.tag_id.hex()
|
|
return f"RfidTokenRead({tag}, figure={self.figure!r})"
|
|
|
|
|
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
class ButtonEvent(InputEvent):
|
|
button: Button
|
|
action: ButtonAction
|
|
|
|
def __repr__(self) -> str:
|
|
return f"ButtonEvent({self.button.slug}, {self.action.slug})"
|
|
|
|
|
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
class TouchButtonPressed(InputEvent):
|
|
button: TouchButton
|
|
|
|
def __repr__(self) -> str:
|
|
return f"TouchButtonPressed({self.button.slug})"
|
|
|
|
|
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
class TouchButtonReleased(InputEvent):
|
|
button: TouchButton
|
|
|
|
def __repr__(self) -> str:
|
|
return f"TouchButtonReleased({self.button.slug})"
|
|
|
|
|
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
class RotaryTurned(InputEvent):
|
|
position: int
|
|
increment: int
|
|
direction: RotaryDirection
|
|
|
|
def __repr__(self) -> str:
|
|
return f"RotaryTurned(pos={self.position}, incr={self.increment}, {self.direction.name})"
|
|
|
|
|
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
class SelectorTurned(InputEvent):
|
|
"""The rotary encoder turned while the ring's album picker had it.
|
|
|
|
Not a :class:`RotaryTurned`: while the picker is open the knob is not a volume
|
|
knob, and nothing that listens for one should hear it.
|
|
"""
|
|
|
|
#: Detents, positive for the direction that turns the volume up.
|
|
steps: int
|
|
|
|
|
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
class SelectorPressed(InputEvent):
|
|
"""The rotary encoder was pressed while the ring's album picker had it. Instead of,
|
|
not as well as, a :class:`ButtonEvent` - so it is not published to Home Assistant."""
|
|
|
|
|
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
class FigureSelectorExpired(InputEvent):
|
|
"""The ring's album picker went untouched for long enough to close by itself."""
|
|
|
|
figure: str
|
|
|
|
|
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
class PlaylistFinished(InputEvent):
|
|
"""The player reached the end of the playlist."""
|
|
|
|
|
|
# -------------------------------------------------------------------------- intents
|
|
|
|
|
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
class PlayRequested(IntentEvent):
|
|
pass
|
|
|
|
|
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
class PauseRequested(IntentEvent):
|
|
pass
|
|
|
|
|
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
class NextTrackRequested(IntentEvent):
|
|
pass
|
|
|
|
|
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
class PreviousTrackRequested(IntentEvent):
|
|
pass
|
|
|
|
|
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
class PlayFigureRequested(IntentEvent):
|
|
"""Start a figure's playlist. ``restart=False`` resumes where it left off."""
|
|
|
|
figure: str
|
|
restart: bool = True
|
|
|
|
|
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
class PlayAlbumRequested(IntentEvent):
|
|
"""Start any album from the library, figure or not."""
|
|
|
|
album_id: str
|
|
track_index: int = 0
|
|
|
|
|
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
class PlaySeriesLatestRequested(IntentEvent):
|
|
"""Start the newest episode of a podcast show.
|
|
|
|
The IR remote's number-key mapping assigns a whole show rather than one fixed
|
|
episode, so this is resolved to an actual album fresh on every press.
|
|
"""
|
|
|
|
series: str
|
|
|
|
|
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
class PlayRemoteSlotRequested(IntentEvent):
|
|
"""Play whatever the top-level ``remote:`` mapping assigns to a number key.
|
|
|
|
The IR remote's digits emit this, and so do the number keys of the web front-end's
|
|
keyboard - one reaction resolves the slot for both.
|
|
"""
|
|
|
|
digit: Digit
|
|
|
|
|
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
class SeekRequested(IntentEvent):
|
|
#: Seconds from the start of the current track.
|
|
position: float
|
|
|
|
|
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
class VolumeChangeRequested(IntentEvent):
|
|
delta: int
|
|
|
|
|
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
class SetVolumeRequested(IntentEvent):
|
|
volume: int
|
|
|
|
|
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
class ToggleScreenRequested(IntentEvent):
|
|
"""Switch the attached monitor off if it is on, on if it is off (``general.screen``)."""
|
|
|
|
|
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
class ToggleClockRequested(IntentEvent):
|
|
"""Show or hide the clock overlay. Only the kiosk acts on it - the overlay is a
|
|
front-end thing, the backend just passes the remote's key along."""
|
|
|
|
|
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
class SetTimerRequested(IntentEvent):
|
|
"""Start the parents' countdown, replacing one already running."""
|
|
|
|
#: Wall-clock end, as Unix seconds.
|
|
ends_at: float
|
|
|
|
|
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
class CancelTimerRequested(IntentEvent):
|
|
"""Stop the parents' countdown, or silence it if it is ringing."""
|
|
|
|
|
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
class UserActivity(IntentEvent):
|
|
"""Someone is at the device - a key press or tap in the kiosk browser. Requests
|
|
nothing by itself; it keeps the screen from switching off (``ScreenService``)."""
|
|
|
|
|
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
class LedEffectRequested(IntentEvent):
|
|
zone: LedZone
|
|
effect: LedEffect
|
|
|
|
def __repr__(self) -> str:
|
|
return f"LedEffectRequested({self.zone}, {self.effect}, from={self.source})"
|
|
|
|
|
|
# ---------------------------------------------------------------------------- state
|
|
|
|
|
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
class PlaybackChanged(StateEvent):
|
|
playing: bool
|
|
figure: str | None = None
|
|
playlist: Playlist | None = None
|
|
|
|
|
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
class TrackChanged(StateEvent):
|
|
index: int
|
|
track: Track | None
|
|
|
|
|
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
class VolumeChanged(StateEvent):
|
|
volume: int
|
|
|
|
|
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
class ActiveFigureChanged(StateEvent):
|
|
figure: str | None
|
|
previous: str | None = None
|
|
|
|
|
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
class FigureSelectionChanged(StateEvent):
|
|
"""The ring's album picker opened, moved or closed."""
|
|
|
|
figure: str
|
|
#: Index into the figure's albums of the highlighted one.
|
|
selected: int
|
|
count: int
|
|
open: bool
|
|
|
|
|
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
class LedEffectChanged(StateEvent):
|
|
"""Emitted on *every* write to an LED zone, whatever caused it.
|
|
|
|
Front-ends publish zone state from this rather than echoing their own commands,
|
|
so Home Assistant keeps showing the strip's real state when a figure animation
|
|
overrides an MQTT-set colour.
|
|
"""
|
|
|
|
zone: LedZone
|
|
effect: LedEffect
|
|
origin: EventSource
|
|
|
|
def __repr__(self) -> str:
|
|
return f"LedEffectChanged({self.zone}, {self.effect}, origin={self.origin})"
|
|
|
|
|
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
class ClockStateChanged(StateEvent):
|
|
"""The parents' timer started, rang or stopped, or today's school day changed.
|
|
The values themselves live on :class:`~musicmouse.app.AppState`."""
|
|
|
|
|
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
class ConnectionChanged(StateEvent):
|
|
target: Literal["firmware", "mqtt", "lirc"]
|
|
connected: bool
|