diff --git a/app/animations/__init__.py b/app/animations/__init__.py new file mode 100644 index 0000000..1ddaaef --- /dev/null +++ b/app/animations/__init__.py @@ -0,0 +1,20 @@ +"""Animation registry – add new animations here.""" + +from .chill import ChillAnimation +from .gaming import GamingAnimation +from .party import PartyAnimation + +# Map of mode-name (lowercase) → Animation class +# The MQTT payload / HTTP body should match these keys. +REGISTRY = { + "chill": ChillAnimation, + "chillmode": ChillAnimation, + "party": PartyAnimation, + "partymode": PartyAnimation, + "gaming": GamingAnimation, + "gamingmode": GamingAnimation, +} + + +def get_animation_class(name: str): + return REGISTRY.get(name.strip().lower()) diff --git a/app/animations/base.py b/app/animations/base.py new file mode 100644 index 0000000..0d3f364 --- /dev/null +++ b/app/animations/base.py @@ -0,0 +1,76 @@ +"""Base class for all animations.""" + +from __future__ import annotations + +import abc +import logging +import threading +import time +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from shelly import ShellyRGBW2 + +log = logging.getLogger("animation") + + +class Animation(abc.ABC): + """Subclass this and implement `tick()` to create a new animation. + + `tick()` is called roughly every `interval` seconds while the animation + is active. Keep it fast and non-blocking. + """ + + name: str = "base" + interval: float = 0.12 # seconds between ticks + + def __init__(self, shelly: "ShellyRGBW2"): + self.shelly = shelly + self._stop = threading.Event() + self._thread: threading.Thread | None = None + + def start(self) -> None: + if self._thread and self._thread.is_alive(): + return + self._stop.clear() + self.on_start() + self._thread = threading.Thread( + target=self._run, name=f"anim-{self.name}", daemon=True + ) + self._thread.start() + log.info("Animation '%s' started", self.name) + + def stop(self) -> None: + self._stop.set() + if self._thread: + self._thread.join(timeout=2.0) + self._thread = None + self.on_stop() + log.info("Animation '%s' stopped", self.name) + + @property + def running(self) -> bool: + return self._thread is not None and self._thread.is_alive() + + def _run(self) -> None: + while not self._stop.is_set(): + try: + self.tick() + except Exception: + log.exception("Error in animation '%s'", self.name) + self._stop.wait(self.interval) + + # ---- hooks for subclasses ---- + + def on_start(self) -> None: + """Called once when the animation starts (before first tick).""" + self.shelly.turn_on() + + def on_stop(self) -> None: + """Called once when the animation stops.""" + pass + + @abc.abstractmethod + def tick(self) -> None: + """Called every `interval` seconds. Update the light here.""" + ... diff --git a/app/animations/chill.py b/app/animations/chill.py new file mode 100644 index 0000000..1d90b7d --- /dev/null +++ b/app/animations/chill.py @@ -0,0 +1,48 @@ +"""Warm evening / chill breathing animation.""" + +from __future__ import annotations + +import colorsys +import math + +from .base import Animation + + +class ChillAnimation(Animation): + name = "chill" + interval = 0.12 + + # Warm hues (0–1 range for colorsys) + COLORS = [ + (15 / 360, 1.0), # warm red-orange + (25 / 360, 0.95), # orange + (35 / 360, 0.90), # amber + (45 / 360, 0.85), # golden + (30 / 360, 1.0), # deep orange + (10 / 360, 1.0), # deep red + ] + + def on_start(self) -> None: + super().on_start() + self._phase = 0.0 + self._color_idx = 0 + + def tick(self) -> None: + self._phase += 0.08 + brightness = 40 + math.sin(self._phase) * 18 # ~22–58 + brightness = max(20, min(70, brightness)) + + if self._phase >= math.pi * 2: + self._phase = 0.0 + self._color_idx = (self._color_idx + 1) % len(self.COLORS) + + h, s = self.COLORS[self._color_idx] + r, g, b = colorsys.hsv_to_rgb(h, s, 1.0) + gain = int(brightness) + + self.shelly.set_color( + int(r * 255), + int(g * 255), + int(b * 255), + gain=gain, + ) diff --git a/app/animations/gaming.py b/app/animations/gaming.py new file mode 100644 index 0000000..e5bf84a --- /dev/null +++ b/app/animations/gaming.py @@ -0,0 +1,46 @@ +"""Gaming animation – strong pulse through gaming colors.""" + +from __future__ import annotations + +import colorsys + +from .base import Animation + + +class GamingAnimation(Animation): + name = "gaming" + interval = 0.09 + + # HSB hues + COLORS = [ + 220 / 360, # blue + 190 / 360, # cyan + 275 / 360, # violet + 300 / 360, # magenta + ] + + def on_start(self) -> None: + super().on_start() + self._color_idx = 0 + self._brightness = 40.0 + self._direction = 1.0 + + def tick(self) -> None: + self._brightness += self._direction * 4 + + if self._brightness >= 95: + self._brightness = 95 + self._direction = -1 + if self._brightness <= 25: + self._brightness = 25 + self._direction = 1 + self._color_idx = (self._color_idx + 1) % len(self.COLORS) + + h = self.COLORS[self._color_idx] + r, g, b = colorsys.hsv_to_rgb(h, 1.0, 1.0) + self.shelly.set_color( + int(r * 255), + int(g * 255), + int(b * 255), + gain=int(self._brightness), + ) diff --git a/app/animations/party.py b/app/animations/party.py new file mode 100644 index 0000000..2ba931d --- /dev/null +++ b/app/animations/party.py @@ -0,0 +1,66 @@ +"""Party animation – slow color drift + irregular beat pulses.""" + +from __future__ import annotations + +import colorsys +import random +import time + +from .base import Animation + + +class PartyAnimation(Animation): + name = "party" + interval = 0.10 + + def on_start(self) -> None: + super().on_start() + self._hue = 210.0 # start blue-ish + self._brightness = 35.0 + self._direction = 1.0 + self._pulse = 0.0 + self._next_pulse = time.monotonic() + 1.2 + self._next_color_jump = time.monotonic() + 2.5 + + def tick(self) -> None: + now = time.monotonic() + + # slow hue drift (blue → cyan → violet → magenta → blue) + self._hue += 1.2 + if self._hue >= 330: + self._hue = 210.0 + + # gentle base breathing + self._brightness += self._direction * 0.7 + if self._brightness >= 55: + self._brightness = 55 + self._direction = -1 + if self._brightness <= 28: + self._brightness = 28 + self._direction = 1 + + # irregular beat pulses + if now >= self._next_pulse: + self._pulse = 20 + self._next_pulse = now + 0.6 + random.random() * 1.2 + + if self._pulse > 0: + self._brightness += self._pulse + self._pulse *= 0.65 + + brightness = max(20, min(80, self._brightness)) + + # occasional stronger color jump + if now >= self._next_color_jump: + self._hue += 25 + random.random() * 60 + if self._hue >= 330: + self._hue = 210.0 + self._next_color_jump = now + 1.8 + random.random() * 3.0 + + r, g, b = colorsys.hsv_to_rgb(self._hue / 360.0, 1.0, 1.0) + self.shelly.set_color( + int(r * 255), + int(g * 255), + int(b * 255), + gain=int(brightness), + )