# -*- coding: utf-8 -*- """ Kreisfoermige LED-Animationen (CONTINUOUS = True). Animationen die LEDs um den Ring herum bewegen. Fuer Single-LEDs wird auf einfaches Blinken/Pulsieren zurueckgefallen. """ from __future__ import annotations from collections.abc import Iterator from trixy_core.led.animations import IAnimation, RGB from trixy_core.led.layouts import LEDLayout # Schwarz / aus OFF: RGB = (0, 0, 0) # Standard-Fade-Stufen fuer den Trail (prozentual) _DEFAULT_TRAIL_FACTORS = [0.6, 0.3, 0.1] # Chase-Gruppengroesse (Anzahl gleichzeitig leuchtender LEDs) _CHASE_WIDTH = 3 class CirculateAnimation(IAnimation): """Eine leuchtende LED wandert um den Ring. Bei Jewel: Center-LED bleibt konstant (center_color), nur Ring-LEDs animieren. Bei Single-LED: einfaches Blinken. """ NAME = "circulate" CONTINUOUS = True def frames( self, led_count: int, color: RGB, speed: int, layout: LEDLayout, **opts: object, ) -> Iterator[list[RGB]]: center_color: RGB | None = opts.get("center_color") # type: ignore[assignment] ring = layout.ring_indices # Fallback fuer Single-LED: Blinken if len(ring) <= 1: yield from _single_fallback_blink(led_count, color) return while True: for pos in range(len(ring)): frame = [OFF] * led_count # Aktive Ring-LED setzen frame[ring[pos]] = color # Jewel-Center konstant if layout.center_index is not None and center_color is not None: frame[layout.center_index] = center_color yield frame class CircularFadeAnimation(IAnimation): """Kreisbewegung mit nachleuchtender Spur. Wie Circulate, aber mit einem Schweif hinter der Kopf-LED. Die Laenge des Schweifs wird ueber den Parameter ``tail`` gesteuert (Standard: 3). Trail-Helligkeiten: 60%, 30%, 10% (abnehmend). """ NAME = "circular-fade" CONTINUOUS = True def frames( self, led_count: int, color: RGB, speed: int, layout: LEDLayout, **opts: object, ) -> Iterator[list[RGB]]: center_color: RGB | None = opts.get("center_color") # type: ignore[assignment] tail: int = opts.get("tail", 3) # type: ignore[assignment] ring = layout.ring_indices ring_len = len(ring) # Fallback fuer Single-LED: Blinken if ring_len <= 1: yield from _single_fallback_blink(led_count, color) return # Trail-Faktoren: so viele wie tail, ggf. aus Standard erweitern trail_factors = _DEFAULT_TRAIL_FACTORS[:tail] # Falls tail groesser als Standardliste, mit kleinen Werten auffuellen while len(trail_factors) < tail: trail_factors.append(0.05) while True: for pos in range(ring_len): frame = [OFF] * led_count # Kopf-LED volle Helligkeit frame[ring[pos]] = color # Schweif-LEDs mit abnehmender Helligkeit for t_idx, factor in enumerate(trail_factors): trail_pos = (pos - t_idx - 1) % ring_len frame[ring[trail_pos]] = _scale_color(color, factor) # Jewel-Center konstant if layout.center_index is not None and center_color is not None: frame[layout.center_index] = center_color yield frame class ChaseAnimation(IAnimation): """Gruppe von LEDs bewegt sich um den Ring. Wie Circulate, aber mit 2-3 gleichzeitig leuchtenden LEDs die als Gruppe wandern. """ NAME = "chase" CONTINUOUS = True def frames( self, led_count: int, color: RGB, speed: int, layout: LEDLayout, **opts: object, ) -> Iterator[list[RGB]]: center_color: RGB | None = opts.get("center_color") # type: ignore[assignment] ring = layout.ring_indices ring_len = len(ring) # Fallback fuer Single-LED: Blinken if ring_len <= 1: yield from _single_fallback_blink(led_count, color) return # Gruppenbreite an Ringgroesse anpassen (min 2, max _CHASE_WIDTH) width = min(_CHASE_WIDTH, max(2, ring_len // 3)) while True: for pos in range(ring_len): frame = [OFF] * led_count # Alle LEDs der Gruppe einschalten for w in range(width): idx = (pos + w) % ring_len frame[ring[idx]] = color # Jewel-Center konstant if layout.center_index is not None and center_color is not None: frame[layout.center_index] = center_color yield frame class RadarAnimation(IAnimation): """Radar-Sweep mit langem Schweif. Wie CircularFade, aber der Schweif erstreckt sich ueber die halbe Ringlaenge mit langsam abnehmender Helligkeit. Erzeugt einen Radar-Scan-Effekt. """ NAME = "radar" CONTINUOUS = True def frames( self, led_count: int, color: RGB, speed: int, layout: LEDLayout, **opts: object, ) -> Iterator[list[RGB]]: center_color: RGB | None = opts.get("center_color") # type: ignore[assignment] ring = layout.ring_indices ring_len = len(ring) # Fallback fuer Single-LED: Blinken if ring_len <= 1: yield from _single_fallback_blink(led_count, color) return # Schweiflaenge: halber Ring tail_length = ring_len // 2 while True: for pos in range(ring_len): frame = [OFF] * led_count # Kopf-LED volle Helligkeit frame[ring[pos]] = color # Langer Schweif mit linearem Fade for t_idx in range(tail_length): # Linearer Abfall von 0.8 bis nahe 0 factor = 0.8 * (1.0 - (t_idx + 1) / tail_length) trail_pos = (pos - t_idx - 1) % ring_len frame[ring[trail_pos]] = _scale_color(color, factor) # Jewel-Center konstant if layout.center_index is not None and center_color is not None: frame[layout.center_index] = center_color yield frame # ── Hilfsfunktionen ────────────────────────────────────────────────────── def _scale_color(color: RGB, factor: float) -> RGB: """Skaliert eine RGB-Farbe um einen Faktor (0.0 - 1.0). Args: color: Ausgangsfarbe als (R, G, B). factor: Helligkeitsfaktor. Returns: Skalierte RGB-Farbe. """ return ( min(255, max(0, int(color[0] * factor))), min(255, max(0, int(color[1] * factor))), min(255, max(0, int(color[2] * factor))), ) def _single_fallback_blink( led_count: int, color: RGB, ) -> Iterator[list[RGB]]: """Fallback fuer Single-LED: einfaches Endlos-Blinken. Args: led_count: Anzahl LEDs (sollte 1 sein). color: Blinkfarbe. Yields: Abwechselnd an- und aus-Frames. """ on_frame = [color] * led_count off_frame = [OFF] * led_count while True: yield on_frame yield off_frame def get_animations() -> list[type[IAnimation]]: """Gibt alle kreisfoermigen Animationsklassen zurueck.""" return [ CirculateAnimation, CircularFadeAnimation, ChaseAnimation, RadarAnimation, ]