Files
musicmouse/python-backend/musicmouse/devices/mouse.py
Martin Bauer d44c24ec97 Full rearchitecture using Claude
- event bus systen
- all components are independent
- preparation for web frontend
2026-08-26 13:22:28 +02:00

178 lines
6.2 KiB
Python

"""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")
)