first commit
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
"""Service de contrôle de l'anneau LED WS2812b (GPIO18, 35 LEDs).
|
||||
|
||||
Mode réel : rpi_ws281x (Raspberry Pi, doit tourner en root ou avec /dev/mem)
|
||||
Mode mock : log des opérations uniquement (développement)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Callable
|
||||
|
||||
from backend.services.config_service import Config, LEDConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Tentative d'import de rpi_ws281x ─────────────────────────────────────────
|
||||
try:
|
||||
from rpi_ws281x import PixelStrip, Color as WS_Color, ws
|
||||
HAS_WS281X = True
|
||||
except ImportError:
|
||||
HAS_WS281X = False
|
||||
logger.warning("rpi_ws281x non disponible — mode mock LED activé")
|
||||
|
||||
class WS_Color: # type: ignore
|
||||
def __init__(self, r: int, g: int, b: int):
|
||||
self.r, self.g, self.b = r, g, b
|
||||
def __repr__(self):
|
||||
return f"Color({self.r},{self.g},{self.b})"
|
||||
|
||||
class PixelStrip: # type: ignore
|
||||
def __init__(self, *args, **kwargs): pass
|
||||
def begin(self): pass
|
||||
def show(self): pass
|
||||
def setPixelColor(self, i, c): pass
|
||||
def numPixels(self): return 35
|
||||
def setBrightness(self, b): pass
|
||||
|
||||
|
||||
def _color(rgb: list[int]) -> WS_Color:
|
||||
return WS_Color(rgb[0], rgb[1], rgb[2])
|
||||
|
||||
|
||||
def _lerp(a: int, b: int, t: float) -> int:
|
||||
return int(a + (b - a) * t)
|
||||
|
||||
|
||||
class LEDService:
|
||||
"""Contrôle l'anneau LED via un thread dédié + queue de commandes."""
|
||||
|
||||
EFFECTS = ("idle", "countdown", "capture", "captured", "finished",
|
||||
"printing", "error", "disabled", "off")
|
||||
|
||||
def __init__(self, config: Config):
|
||||
self._cfg: LEDConfig = config.leds
|
||||
self._strip: PixelStrip | None = None
|
||||
self._thread: threading.Thread | None = None
|
||||
self._stop_event = threading.Event()
|
||||
self._cmd_event = threading.Event()
|
||||
self._current_effect: str = "idle"
|
||||
self._countdown_duration: float = 5.0
|
||||
self._lock = threading.Lock()
|
||||
self._on_change_cb: Callable | None = None
|
||||
self._loop: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
# ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||
|
||||
def start(self):
|
||||
if HAS_WS281X:
|
||||
self._strip = PixelStrip(
|
||||
self._cfg.count,
|
||||
self._cfg.pin,
|
||||
self._cfg.freq_hz,
|
||||
self._cfg.dma,
|
||||
False, # invert
|
||||
self._cfg.brightness,
|
||||
0, # channel
|
||||
)
|
||||
try:
|
||||
self._strip.begin()
|
||||
logger.info("Strip WS2812b initialisé (%d LEDs, GPIO%d)", self._cfg.count, self._cfg.pin)
|
||||
except Exception as e:
|
||||
logger.error("Erreur init strip LED: %s", e)
|
||||
self._strip = PixelStrip() # fallback mock
|
||||
else:
|
||||
self._strip = PixelStrip()
|
||||
|
||||
self._stop_event.clear()
|
||||
self._thread = threading.Thread(target=self._run, daemon=True, name="led-service")
|
||||
self._thread.start()
|
||||
|
||||
def stop(self):
|
||||
self._stop_event.set()
|
||||
self._cmd_event.set()
|
||||
if self._thread:
|
||||
self._thread.join(timeout=2)
|
||||
self._all_off()
|
||||
|
||||
def set_loop(self, loop: asyncio.AbstractEventLoop):
|
||||
self._loop = loop
|
||||
|
||||
def set_on_change(self, cb: Callable):
|
||||
self._on_change_cb = cb
|
||||
|
||||
# ── Commandes publiques (thread-safe) ────────────────────────────────────
|
||||
|
||||
def play(self, effect: str, countdown_duration: float = 5.0):
|
||||
"""Joue un effet nommé. Thread-safe."""
|
||||
with self._lock:
|
||||
self._current_effect = effect
|
||||
self._countdown_duration = countdown_duration
|
||||
self._cmd_event.set()
|
||||
logger.debug("LED effet: %s", effect)
|
||||
self._notify_change(effect)
|
||||
|
||||
def set_color(self, r: int, g: int, b: int):
|
||||
"""Couleur fixe immédiate."""
|
||||
self._fill(WS_Color(r, g, b))
|
||||
|
||||
# ── Thread principal ──────────────────────────────────────────────────────
|
||||
|
||||
def _run(self):
|
||||
effect_func = {
|
||||
"idle": self._effect_idle,
|
||||
"countdown": self._effect_countdown,
|
||||
"capture": self._effect_capture,
|
||||
"captured": self._effect_solid,
|
||||
"finished": self._effect_finished,
|
||||
"printing": self._effect_spin,
|
||||
"error": self._effect_error,
|
||||
"disabled": self._effect_solid,
|
||||
"off": self._effect_off,
|
||||
}
|
||||
|
||||
while not self._stop_event.is_set():
|
||||
self._cmd_event.clear()
|
||||
with self._lock:
|
||||
current = self._current_effect
|
||||
cd_dur = self._countdown_duration
|
||||
|
||||
fn = effect_func.get(current, self._effect_idle)
|
||||
|
||||
try:
|
||||
if current in ("countdown",):
|
||||
fn(cd_dur)
|
||||
else:
|
||||
fn()
|
||||
except Exception as e:
|
||||
logger.error("Erreur effet LED '%s': %s", current, e)
|
||||
|
||||
# Si l'effet s'est terminé naturellement (ex: finished → retour idle)
|
||||
# on vérifie si un nouveau cmd est arrivé
|
||||
if not self._cmd_event.is_set() and not self._stop_event.is_set():
|
||||
with self._lock:
|
||||
if self._current_effect == current:
|
||||
# Retour automatique à idle après effets ponctuels
|
||||
if current in ("capture", "captured", "finished", "error"):
|
||||
self._current_effect = "idle"
|
||||
self._cmd_event.wait(timeout=0.1)
|
||||
|
||||
# ── Effets ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _effect_idle(self):
|
||||
"""Respiration bleue douce."""
|
||||
cfg = self._cfg.get_effect("idle")
|
||||
c = cfg.color
|
||||
speed = cfg.speed
|
||||
n = self._cfg.count
|
||||
|
||||
step = 0
|
||||
while not self._cmd_event.is_set() and not self._stop_event.is_set():
|
||||
t = (1 + __import__("math").sin(step * 0.1)) / 2 # 0..1
|
||||
brightness = max(0.05, t)
|
||||
color = WS_Color(
|
||||
int(c[0] * brightness),
|
||||
int(c[1] * brightness),
|
||||
int(c[2] * brightness),
|
||||
)
|
||||
self._fill(color)
|
||||
time.sleep(speed)
|
||||
step += 1
|
||||
|
||||
def _effect_countdown(self, duration: float = 5.0):
|
||||
"""Remplissage progressif vert LED par LED."""
|
||||
cfg = self._cfg.get_effect("countdown")
|
||||
c = cfg.color
|
||||
n = self._cfg.count
|
||||
blank = WS_Color(0, 0, 0)
|
||||
color = WS_Color(c[0], c[1], c[2])
|
||||
|
||||
self._fill(blank)
|
||||
|
||||
t_start = time.time()
|
||||
while not self._cmd_event.is_set() and not self._stop_event.is_set():
|
||||
elapsed = time.time() - t_start
|
||||
ratio = min(elapsed / duration, 1.0)
|
||||
leds_on = int(ratio * n)
|
||||
|
||||
for i in range(n):
|
||||
self._strip.setPixelColor(i, color if i < leds_on else blank)
|
||||
self._strip.show()
|
||||
|
||||
if ratio >= 1.0:
|
||||
break
|
||||
time.sleep(0.04)
|
||||
|
||||
def _effect_capture(self):
|
||||
"""Flash blanc."""
|
||||
cfg = self._cfg.get_effect("capture")
|
||||
white = WS_Color(255, 255, 255)
|
||||
off = WS_Color(0, 0, 0)
|
||||
for _ in range(cfg.flashes):
|
||||
if self._cmd_event.is_set():
|
||||
break
|
||||
self._fill(white)
|
||||
time.sleep(cfg.flash_duration)
|
||||
self._fill(off)
|
||||
time.sleep(cfg.flash_duration)
|
||||
|
||||
def _effect_solid(self):
|
||||
"""Couleur pleine selon l'effet courant."""
|
||||
with self._lock:
|
||||
current = self._current_effect
|
||||
cfg = self._cfg.get_effect(current)
|
||||
self._fill(WS_Color(*cfg.color))
|
||||
# Attente jusqu'à prochain cmd
|
||||
self._cmd_event.wait()
|
||||
|
||||
def _effect_finished(self):
|
||||
"""Bleu fixe pendant duration secondes, puis retour idle."""
|
||||
cfg = self._cfg.get_effect("finished")
|
||||
self._fill(WS_Color(*cfg.color))
|
||||
self._cmd_event.wait(timeout=cfg.duration)
|
||||
|
||||
def _effect_spin(self):
|
||||
"""Rotation d'une traînée de LEDs."""
|
||||
cfg = self._cfg.get_effect("printing")
|
||||
c = cfg.color
|
||||
n = self._cfg.count
|
||||
speed = cfg.speed
|
||||
tail = 6
|
||||
|
||||
pos = 0
|
||||
while not self._cmd_event.is_set() and not self._stop_event.is_set():
|
||||
for i in range(n):
|
||||
dist = (i - pos) % n
|
||||
if dist < tail:
|
||||
factor = (tail - dist) / tail
|
||||
self._strip.setPixelColor(
|
||||
i,
|
||||
WS_Color(
|
||||
int(c[0] * factor),
|
||||
int(c[1] * factor),
|
||||
int(c[2] * factor),
|
||||
),
|
||||
)
|
||||
else:
|
||||
self._strip.setPixelColor(i, WS_Color(0, 0, 0))
|
||||
self._strip.show()
|
||||
pos = (pos + 1) % n
|
||||
time.sleep(speed)
|
||||
|
||||
def _effect_error(self):
|
||||
"""Flash rouge."""
|
||||
cfg = self._cfg.get_effect("error")
|
||||
red = WS_Color(cfg.color[0], cfg.color[1], cfg.color[2])
|
||||
off = WS_Color(0, 0, 0)
|
||||
for _ in range(cfg.flashes):
|
||||
if self._cmd_event.is_set():
|
||||
break
|
||||
self._fill(red)
|
||||
time.sleep(0.15)
|
||||
self._fill(off)
|
||||
time.sleep(0.15)
|
||||
|
||||
def _effect_off(self):
|
||||
self._fill(WS_Color(0, 0, 0))
|
||||
self._cmd_event.wait()
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _fill(self, color: WS_Color):
|
||||
if not HAS_WS281X:
|
||||
logger.debug("LED mock fill: %s", color)
|
||||
return
|
||||
for i in range(self._cfg.count):
|
||||
self._strip.setPixelColor(i, color)
|
||||
self._strip.show()
|
||||
|
||||
def _all_off(self):
|
||||
try:
|
||||
self._fill(WS_Color(0, 0, 0))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _notify_change(self, effect: str):
|
||||
if self._on_change_cb and self._loop:
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
self._on_change_cb({"type": "led_effect", "effect": effect}),
|
||||
self._loop,
|
||||
)
|
||||
|
||||
@property
|
||||
def current_effect(self) -> str:
|
||||
with self._lock:
|
||||
return self._current_effect
|
||||
Reference in New Issue
Block a user