circular.py 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  1. # -*- coding: utf-8 -*-
  2. """
  3. Kreisfoermige LED-Animationen (CONTINUOUS = True).
  4. Animationen die LEDs um den Ring herum bewegen. Fuer Single-LEDs
  5. wird auf einfaches Blinken/Pulsieren zurueckgefallen.
  6. """
  7. from __future__ import annotations
  8. from collections.abc import Iterator
  9. from trixy_core.led.animations import IAnimation, RGB
  10. from trixy_core.led.layouts import LEDLayout
  11. # Schwarz / aus
  12. OFF: RGB = (0, 0, 0)
  13. # Standard-Fade-Stufen fuer den Trail (prozentual)
  14. _DEFAULT_TRAIL_FACTORS = [0.6, 0.3, 0.1]
  15. # Chase-Gruppengroesse (Anzahl gleichzeitig leuchtender LEDs)
  16. _CHASE_WIDTH = 3
  17. class CirculateAnimation(IAnimation):
  18. """Eine leuchtende LED wandert um den Ring.
  19. Bei Jewel: Center-LED bleibt konstant (center_color), nur
  20. Ring-LEDs animieren. Bei Single-LED: einfaches Blinken.
  21. """
  22. NAME = "circulate"
  23. CONTINUOUS = True
  24. def frames(
  25. self,
  26. led_count: int,
  27. color: RGB,
  28. speed: int,
  29. layout: LEDLayout,
  30. **opts: object,
  31. ) -> Iterator[list[RGB]]:
  32. center_color: RGB | None = opts.get("center_color") # type: ignore[assignment]
  33. ring = layout.ring_indices
  34. # Fallback fuer Single-LED: Blinken
  35. if len(ring) <= 1:
  36. yield from _single_fallback_blink(led_count, color)
  37. return
  38. while True:
  39. for pos in range(len(ring)):
  40. frame = [OFF] * led_count
  41. # Aktive Ring-LED setzen
  42. frame[ring[pos]] = color
  43. # Jewel-Center konstant
  44. if layout.center_index is not None and center_color is not None:
  45. frame[layout.center_index] = center_color
  46. yield frame
  47. class CircularFadeAnimation(IAnimation):
  48. """Kreisbewegung mit nachleuchtender Spur.
  49. Wie Circulate, aber mit einem Schweif hinter der Kopf-LED.
  50. Die Laenge des Schweifs wird ueber den Parameter ``tail``
  51. gesteuert (Standard: 3).
  52. Trail-Helligkeiten: 60%, 30%, 10% (abnehmend).
  53. """
  54. NAME = "circular-fade"
  55. CONTINUOUS = True
  56. def frames(
  57. self,
  58. led_count: int,
  59. color: RGB,
  60. speed: int,
  61. layout: LEDLayout,
  62. **opts: object,
  63. ) -> Iterator[list[RGB]]:
  64. center_color: RGB | None = opts.get("center_color") # type: ignore[assignment]
  65. tail: int = opts.get("tail", 3) # type: ignore[assignment]
  66. ring = layout.ring_indices
  67. ring_len = len(ring)
  68. # Fallback fuer Single-LED: Blinken
  69. if ring_len <= 1:
  70. yield from _single_fallback_blink(led_count, color)
  71. return
  72. # Trail-Faktoren: so viele wie tail, ggf. aus Standard erweitern
  73. trail_factors = _DEFAULT_TRAIL_FACTORS[:tail]
  74. # Falls tail groesser als Standardliste, mit kleinen Werten auffuellen
  75. while len(trail_factors) < tail:
  76. trail_factors.append(0.05)
  77. while True:
  78. for pos in range(ring_len):
  79. frame = [OFF] * led_count
  80. # Kopf-LED volle Helligkeit
  81. frame[ring[pos]] = color
  82. # Schweif-LEDs mit abnehmender Helligkeit
  83. for t_idx, factor in enumerate(trail_factors):
  84. trail_pos = (pos - t_idx - 1) % ring_len
  85. frame[ring[trail_pos]] = _scale_color(color, factor)
  86. # Jewel-Center konstant
  87. if layout.center_index is not None and center_color is not None:
  88. frame[layout.center_index] = center_color
  89. yield frame
  90. class ChaseAnimation(IAnimation):
  91. """Gruppe von LEDs bewegt sich um den Ring.
  92. Wie Circulate, aber mit 2-3 gleichzeitig leuchtenden LEDs
  93. die als Gruppe wandern.
  94. """
  95. NAME = "chase"
  96. CONTINUOUS = True
  97. def frames(
  98. self,
  99. led_count: int,
  100. color: RGB,
  101. speed: int,
  102. layout: LEDLayout,
  103. **opts: object,
  104. ) -> Iterator[list[RGB]]:
  105. center_color: RGB | None = opts.get("center_color") # type: ignore[assignment]
  106. ring = layout.ring_indices
  107. ring_len = len(ring)
  108. # Fallback fuer Single-LED: Blinken
  109. if ring_len <= 1:
  110. yield from _single_fallback_blink(led_count, color)
  111. return
  112. # Gruppenbreite an Ringgroesse anpassen (min 2, max _CHASE_WIDTH)
  113. width = min(_CHASE_WIDTH, max(2, ring_len // 3))
  114. while True:
  115. for pos in range(ring_len):
  116. frame = [OFF] * led_count
  117. # Alle LEDs der Gruppe einschalten
  118. for w in range(width):
  119. idx = (pos + w) % ring_len
  120. frame[ring[idx]] = color
  121. # Jewel-Center konstant
  122. if layout.center_index is not None and center_color is not None:
  123. frame[layout.center_index] = center_color
  124. yield frame
  125. class RadarAnimation(IAnimation):
  126. """Radar-Sweep mit langem Schweif.
  127. Wie CircularFade, aber der Schweif erstreckt sich ueber die
  128. halbe Ringlaenge mit langsam abnehmender Helligkeit.
  129. Erzeugt einen Radar-Scan-Effekt.
  130. """
  131. NAME = "radar"
  132. CONTINUOUS = True
  133. def frames(
  134. self,
  135. led_count: int,
  136. color: RGB,
  137. speed: int,
  138. layout: LEDLayout,
  139. **opts: object,
  140. ) -> Iterator[list[RGB]]:
  141. center_color: RGB | None = opts.get("center_color") # type: ignore[assignment]
  142. ring = layout.ring_indices
  143. ring_len = len(ring)
  144. # Fallback fuer Single-LED: Blinken
  145. if ring_len <= 1:
  146. yield from _single_fallback_blink(led_count, color)
  147. return
  148. # Schweiflaenge: halber Ring
  149. tail_length = ring_len // 2
  150. while True:
  151. for pos in range(ring_len):
  152. frame = [OFF] * led_count
  153. # Kopf-LED volle Helligkeit
  154. frame[ring[pos]] = color
  155. # Langer Schweif mit linearem Fade
  156. for t_idx in range(tail_length):
  157. # Linearer Abfall von 0.8 bis nahe 0
  158. factor = 0.8 * (1.0 - (t_idx + 1) / tail_length)
  159. trail_pos = (pos - t_idx - 1) % ring_len
  160. frame[ring[trail_pos]] = _scale_color(color, factor)
  161. # Jewel-Center konstant
  162. if layout.center_index is not None and center_color is not None:
  163. frame[layout.center_index] = center_color
  164. yield frame
  165. # ── Hilfsfunktionen ──────────────────────────────────────────────────────
  166. def _scale_color(color: RGB, factor: float) -> RGB:
  167. """Skaliert eine RGB-Farbe um einen Faktor (0.0 - 1.0).
  168. Args:
  169. color: Ausgangsfarbe als (R, G, B).
  170. factor: Helligkeitsfaktor.
  171. Returns:
  172. Skalierte RGB-Farbe.
  173. """
  174. return (
  175. min(255, max(0, int(color[0] * factor))),
  176. min(255, max(0, int(color[1] * factor))),
  177. min(255, max(0, int(color[2] * factor))),
  178. )
  179. def _single_fallback_blink(
  180. led_count: int,
  181. color: RGB,
  182. ) -> Iterator[list[RGB]]:
  183. """Fallback fuer Single-LED: einfaches Endlos-Blinken.
  184. Args:
  185. led_count: Anzahl LEDs (sollte 1 sein).
  186. color: Blinkfarbe.
  187. Yields:
  188. Abwechselnd an- und aus-Frames.
  189. """
  190. on_frame = [color] * led_count
  191. off_frame = [OFF] * led_count
  192. while True:
  193. yield on_frame
  194. yield off_frame
  195. def get_animations() -> list[type[IAnimation]]:
  196. """Gibt alle kreisfoermigen Animationsklassen zurueck."""
  197. return [
  198. CirculateAnimation,
  199. CircularFadeAnimation,
  200. ChaseAnimation,
  201. RadarAnimation,
  202. ]