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