Files
musicmouse/python-backend/musicmouse/reactions/lighting.py

175 lines
5.6 KiB
Python

"""What the LEDs do.
These write straight to the device rather than going through intents: unlike
play/pause, nothing else in the system asks for "the figure's start animation".
Figure animations drive the shelf strip as well as the ring, which means they compete
with Home Assistant for it. That is intentional - the device is the single writer and
the most recent effect wins, whichever side it came from.
"""
from __future__ import annotations
import logging
from copy import deepcopy
from musicmouse.app import App
from musicmouse.color import ColorRGBW, parse_color
from musicmouse.config import FigureColors
from musicmouse.effects import (
EffectCircularConfig,
EffectRandomTwoColorInterpolationConfig,
EffectReverseSwipe,
EffectStaticConfig,
EffectSwipeAndChange,
)
from musicmouse.events import (
ActiveFigureChanged,
PlaybackChanged,
PlaylistFinished,
TouchButtonPressed,
TouchButtonReleased,
)
from musicmouse.hardware import MOUSE_LED_RANGES, LedZone, TouchButton
from musicmouse.reactions.registry import on
_log = logging.getLogger(__name__)
OFF_COLOR = ColorRGBW(0, 0, 0, 0)
#: The mouse strip starts 6 LEDs into its 45, so its swipe is offset to match the ring.
MOUSE_SWIPE_START_DEGREES = 6 / 45 * 360
MOUSE_BELL_CURVE_WIDTH = 16
SWIPE_SPEED = 180
#: The web front-end's animation: a three-quarter arc drifting round twice a minute, in
#: the album's own primary colour. Deliberately unlike the figure swipe - the strips
#: should say *which* way the mouse was started, not just that it was.
WEB_CIRCLE_WIDTH = 270.0 # degrees
WEB_CIRCLE_SPEED = 12.0 # degrees per second
@on(ActiveFigureChanged)
def figure_placed_or_removed(event: ActiveFigureChanged, app: App) -> None:
if event.figure is None:
off_animation(app)
else:
start_animation(app, app.colors(event.figure))
app.mouse.set_button_brightness(
app.config.general.button_leds_brightness, origin="device"
)
@on(PlaybackChanged)
def web_playback(event: PlaybackChanged, app: App) -> None:
"""Light the strips for playback that no figure started.
Reacting to ``PlaybackChanged`` rather than to the play intent covers pause, stop
and playlist-end in one place. The guard is what keeps this off the figure path:
while a figure is on the reader its animation owns all three zones.
"""
if app.mouse.active_figure is not None:
return
if not event.playing:
off_animation(app)
return
album = app.album_for(event.playlist)
if album is None:
return
web_animation(app, parse_color(album.colors[0]))
@on(PlaylistFinished)
def playlist_finished(_event: PlaylistFinished, app: App) -> None:
off_animation(app)
@on(TouchButtonPressed)
def touch_pressed(event: TouchButtonPressed, app: App) -> None:
colors = _active_colors(app)
if colors is None:
return
app.mouse.set_effect(
LedZone.MOUSE, _range_effect(event.button, colors.accent), origin="device"
)
@on(TouchButtonReleased)
def touch_released(event: TouchButtonReleased, app: App) -> None:
colors = _active_colors(app)
# Clear the touched area first, then restore the whole-body effect over it.
app.mouse.set_effect(
LedZone.MOUSE,
_range_effect(event.button, colors.primary if colors else OFF_COLOR),
origin="device",
)
if colors is None:
return
app.mouse.set_effect(
LedZone.MOUSE,
EffectRandomTwoColorInterpolationConfig(
color1=colors.primary, color2=colors.secondary, start_with_existing=True
),
origin="device",
)
# ------------------------------------------------------------------- animations
def start_animation(app: App, colors: FigureColors) -> None:
ring = EffectSwipeAndChange()
ring.swipe.primary_color = colors.primary
ring.swipe.secondary_color = colors.secondary
ring.swipe.swipe_speed = SWIPE_SPEED
ring.change.color1 = colors.primary
ring.change.color2 = colors.secondary
app.mouse.set_effect(LedZone.RING, ring, origin="device")
app.mouse.set_effect(LedZone.SHELF, deepcopy(ring), origin="device")
mouse = deepcopy(ring)
mouse.swipe.start_position = MOUSE_SWIPE_START_DEGREES
mouse.swipe.bell_curve_width_in_leds = MOUSE_BELL_CURVE_WIDTH
app.mouse.set_effect(LedZone.MOUSE, mouse, origin="device")
def web_animation(app: App, color: ColorRGBW) -> None:
for zone in LedZone:
app.mouse.set_effect(
zone,
EffectCircularConfig(speed=WEB_CIRCLE_SPEED, width=WEB_CIRCLE_WIDTH, color=color),
origin="device",
)
app.mouse.set_button_brightness(app.config.general.button_leds_brightness, origin="device")
def off_animation(app: App) -> None:
_log.info("Running off animation")
app.mouse.set_effect(LedZone.RING, EffectReverseSwipe(), origin="device")
app.mouse.set_effect(LedZone.SHELF, EffectReverseSwipe(), origin="device")
app.mouse.set_effect(
LedZone.MOUSE,
EffectReverseSwipe(start_position=MOUSE_SWIPE_START_DEGREES),
origin="device",
)
app.mouse.set_button_brightness(0.0, origin="device")
# --------------------------------------------------------------------- helpers
def _active_colors(app: App) -> FigureColors | None:
"""The current figure's colours, or ``None`` if nothing is playing."""
figure = app.mouse.active_figure
if figure is None or not app.player.is_playing:
return None
return app.colors(figure)
def _range_effect(button: TouchButton, color: ColorRGBW) -> EffectStaticConfig:
begin, end = MOUSE_LED_RANGES[button]
return EffectStaticConfig(color, begin, end)