all
🚀 Deploy — JH Photomaton / 🔍 Vérification (push) Has been cancelled
🚀 Deploy — JH Photomaton / 🍓 Deploy sur le Pi (push) Has been cancelled

This commit is contained in:
2026-07-17 00:44:22 +02:00
parent 209f81aa3d
commit 9a006f1457
23 changed files with 3712 additions and 484 deletions
+61 -67
View File
@@ -1,13 +1,14 @@
"""Service de contrôle de l'anneau LED WS2812b (GPIO18, 35 LEDs).
"""Service de controle 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)
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
@@ -16,13 +17,12 @@ 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
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 activé")
logger.warning("rpi_ws281x non disponible -- mode mock LED")
class WS_Color: # type: ignore
def __init__(self, r: int, g: int, b: int):
@@ -31,7 +31,7 @@ except ImportError:
return f"Color({self.r},{self.g},{self.b})"
class PixelStrip: # type: ignore
def __init__(self, *args, **kwargs): pass
def __init__(self, *a, **kw): pass
def begin(self): pass
def show(self): pass
def setPixelColor(self, i, c): pass
@@ -40,16 +40,10 @@ except ImportError:
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)
return WS_Color(int(rgb[0]), int(rgb[1]), int(rgb[2]))
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")
@@ -74,16 +68,16 @@ class LEDService:
self._cfg.pin,
self._cfg.freq_hz,
self._cfg.dma,
False, # invert
False,
self._cfg.brightness,
0, # channel
0,
)
try:
self._strip.begin()
logger.info("Strip WS2812b initialisé (%d LEDs, GPIO%d)", self._cfg.count, self._cfg.pin)
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() # fallback mock
self._strip = PixelStrip()
else:
self._strip = PixelStrip()
@@ -104,10 +98,9 @@ class LEDService:
def set_on_change(self, cb: Callable):
self._on_change_cb = cb
# ── Commandes publiques (thread-safe) ────────────────────────────────────
# ── Commandes publiques ───────────────────────────────────────────────────
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
@@ -116,22 +109,21 @@ class LEDService:
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,
"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():
@@ -143,19 +135,16 @@ class LEDService:
fn = effect_func.get(current, self._effect_idle)
try:
if current in ("countdown",):
if current == "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)
@@ -163,27 +152,23 @@ class LEDService:
# ── 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
t = (1 + math.sin(step * 0.1)) / 2
brightness = max(0.05, t)
color = WS_Color(
self._fill(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
@@ -191,55 +176,68 @@ class LEDService:
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."""
"""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")
white = WS_Color(255, 255, 255)
flash_color = 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(white)
time.sleep(cfg.flash_duration)
# Boost luminosite max pendant le flash
if HAS_WS281X and self._strip:
self._strip.setBrightness(255)
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)
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
@@ -248,22 +246,18 @@ class LEDService:
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),
),
)
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)
time.sleep(cfg.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)