"""Service de controle de l'anneau LED WS2812b (GPIO18, 35 LEDs). Mode reel : rpi_ws281x (Raspberry Pi, root ou /dev/mem) Mode mock : log uniquement (developpement) """ from __future__ import annotations import asyncio import logging import math import threading import time from typing import Callable from backend.services.config_service import Config, LEDConfig logger = logging.getLogger(__name__) try: from rpi_ws281x import PixelStrip, Color as WS_Color HAS_WS281X = True except ImportError: HAS_WS281X = False logger.warning("rpi_ws281x non disponible -- mode mock LED") 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, *a, **kw): 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(int(rgb[0]), int(rgb[1]), int(rgb[2])) class LEDService: 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 self._ambient_lux: float = -1.0 # -1 = inconnu # ── Lifecycle ───────────────────────────────────────────────────────────── def start(self): if HAS_WS281X: self._strip = PixelStrip( self._cfg.count, self._cfg.pin, self._cfg.freq_hz, self._cfg.dma, False, self._cfg.brightness, 0, ) try: self._strip.begin() logger.info("Strip WS2812b initialise (%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() 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 ─────────────────────────────────────────────────── def play(self, effect: str, countdown_duration: float = 5.0): 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): self._fill(WS_Color(r, g, b)) def set_brightness(self, value: int): """Regle la luminosite globale (0-255) et l'applique immediatement.""" value = max(0, min(255, value)) self._cfg.brightness = value if HAS_WS281X and self._strip: self._strip.setBrightness(value) self._strip.show() logger.info("Luminosite LED: %d", value) def set_ambient_lux(self, lux: float): """Definit la luminosite ambiante mesuree (0-255 echelle pixel).""" self._ambient_lux = lux logger.debug("Lux ambiant: %.1f", lux) # ── 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 == "countdown": fn(cd_dur) else: fn() except Exception as e: logger.error("Erreur effet LED '%s': %s", current, e) if not self._cmd_event.is_set() and not self._stop_event.is_set(): with self._lock: if self._current_effect == current: if current in ("capture", "captured", "finished", "error"): self._current_effect = "idle" self._cmd_event.wait(timeout=0.1) # ── Effets ─────────────────────────────────────────────────────────────── def _effect_idle(self): cfg = self._cfg.get_effect("idle") c = cfg.color if cfg.mode == "solid": self._fill(WS_Color(c[0], c[1], c[2])) self._cmd_event.wait() return # mode "breathe" (defaut) speed = cfg.speed step = 0 while not self._cmd_event.is_set() and not self._stop_event.is_set(): t = (1 + math.sin(step * 0.1)) / 2 brightness = max(0.05, t) self._fill(WS_Color( int(c[0] * brightness), int(c[1] * brightness), int(c[2] * brightness), )) time.sleep(speed) step += 1 def _effect_countdown(self, duration: float = 5.0): 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 photo. Utilise la couleur configuree dans settings.yaml > leds > effects > capture. Booste la luminosite au maximum pendant le flash pour maximiser l'eclairage, puis la restaure a la valeur normale. Conseil couleur WS2812b : les LEDs bleues sont plus efficaces que les rouges. Pour un blanc neutre/chaud utilisez par ex. [255, 180, 60] au lieu de [255,255,255]. """ cfg = self._cfg.get_effect("capture") flash_color = WS_Color(cfg.color[0], cfg.color[1], cfg.color[2]) off = WS_Color(0, 0, 0) # Calcul de la luminosite du flash # Si auto_brightness activee et lux connu, on adapte. Sinon boost a 255. if self._cfg.auto_brightness and self._ambient_lux >= 0: lux = self._ambient_lux if lux < 50: flash_brightness = 255 # salle sombre → flash plein elif lux < 180: # lineaire : 255 a 50 lux → 120 a 180 lux flash_brightness = int(255 - (lux - 50) / 130 * 135) else: flash_brightness = 80 # salle tres claire → flash reduit logger.debug("Flash brightness auto: %d (lux=%.1f)", flash_brightness, lux) else: flash_brightness = 255 if HAS_WS281X and self._strip: self._strip.setBrightness(flash_brightness) try: for _ in range(cfg.flashes): if self._cmd_event.is_set(): break self._fill(flash_color) time.sleep(cfg.flash_duration) self._fill(off) # Pause inter-flash plus courte que le flash lui-meme if _ < cfg.flashes - 1: time.sleep(cfg.flash_duration * 0.4) finally: # Toujours restaurer la luminosite normale meme en cas d'erreur if HAS_WS281X and self._strip: self._strip.setBrightness(self._cfg.brightness) self._fill(off) def _effect_solid(self): with self._lock: current = self._current_effect cfg = self._cfg.get_effect(current) self._fill(WS_Color(*cfg.color)) self._cmd_event.wait() def _effect_finished(self): cfg = self._cfg.get_effect("finished") self._fill(WS_Color(*cfg.color)) self._cmd_event.wait(timeout=cfg.duration) def _effect_spin(self): cfg = self._cfg.get_effect("printing") c = cfg.color n = self._cfg.count 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(cfg.speed) def _effect_error(self): 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