diff --git a/backend/api/leds_api.py b/backend/api/leds_api.py index 7223b41..52661e5 100644 --- a/backend/api/leds_api.py +++ b/backend/api/leds_api.py @@ -51,6 +51,42 @@ async def leds_off(request: Request): return {"ok": True} +@router.get("/leds/brightness") +async def get_brightness(request: Request): + cfg = request.app.state.config.leds + return {"brightness": cfg.brightness, "auto_brightness": cfg.auto_brightness} + + +@router.put("/leds/brightness") +async def set_brightness( + request: Request, + value: int = Query(..., ge=0, le=255), + save: bool = Query(default=True), +): + """Regle la luminosite globale (0-255) et l'applique immediatement.""" + led = request.app.state.led_service + led.set_brightness(value) + if save: + config_svc = request.app.state.config_service + config_svc.save_led_brightness(value) + return {"ok": True, "brightness": value} + + +@router.put("/leds/auto-brightness") +async def set_auto_brightness( + request: Request, + enabled: bool = Query(...), + save: bool = Query(default=True), +): + """Active ou desactive la luminosite automatique du flash selon l'ambiance.""" + cfg = request.app.state.config.leds + cfg.auto_brightness = enabled + if save: + config_svc = request.app.state.config_service + config_svc.save_led_auto_brightness(enabled) + return {"ok": True, "auto_brightness": enabled} + + @router.get("/leds/effects") async def get_effects(request: Request): """Retourne la configuration de tous les effets LED.""" diff --git a/backend/api/webhooks.py b/backend/api/webhooks.py index a849d31..f67087a 100644 --- a/backend/api/webhooks.py +++ b/backend/api/webhooks.py @@ -27,6 +27,9 @@ async def photobooth_webhook( match event_key: case "counting": led.play("countdown", countdown_duration=DEFAULT_COUNTDOWN) + if request.app.state.config.leds.auto_brightness: + pb = request.app.state.photobooth_service + asyncio.create_task(_analyze_ambient_lux(led, pb)) case "capture": led.play("capture") @@ -58,3 +61,21 @@ async def _delayed_relay_on(btn, delay: float): await asyncio.sleep(delay) if btn: btn.relay_on() + + +async def _analyze_ambient_lux(led, pb): + """Capture une frame du liveview et calcule la luminosite moyenne.""" + try: + snapshot = await pb.get_liveview_snapshot() + if not snapshot: + logger.debug("Analyse lux: aucun snapshot disponible") + return + from PIL import Image + import io + img = Image.open(io.BytesIO(snapshot)).convert("L").resize((80, 60)) + pixels = list(img.getdata()) + avg = sum(pixels) / len(pixels) + led.set_ambient_lux(avg) + logger.info("Luminosite ambiante: %.1f / 255", avg) + except Exception as e: + logger.warning("Analyse lux echouee: %s", e) diff --git a/backend/services/button_service.py b/backend/services/button_service.py index 3333b11..1ff1090 100644 --- a/backend/services/button_service.py +++ b/backend/services/button_service.py @@ -57,8 +57,8 @@ class ButtonService: try: self._relay = OutputDevice( self._cfg.relay_pin, - active_high=True, - initial_value=True, + active_high=False, + initial_value=False, ) self._button = Button( self._cfg.pin, diff --git a/backend/services/config_service.py b/backend/services/config_service.py index b250602..7775dd1 100644 --- a/backend/services/config_service.py +++ b/backend/services/config_service.py @@ -58,6 +58,7 @@ class LEDConfig: pin: int = 18 count: int = 35 brightness: int = 180 + auto_brightness: bool = False freq_hz: int = 800000 dma: int = 10 strip_type: str = "WS2812" @@ -140,6 +141,7 @@ class ConfigService: pin=led_raw.get("pin", 18), count=led_raw.get("count", 35), brightness=led_raw.get("brightness", 180), + auto_brightness=led_raw.get("auto_brightness", False), freq_hz=led_raw.get("freq_hz", 800000), dma=led_raw.get("dma", 10), strip_type=led_raw.get("strip_type", "WS2812"), @@ -221,6 +223,24 @@ class ConfigService: self.load() return self._config + def save_led_brightness(self, brightness: int): + with open(self.path, encoding="utf-8") as f: + raw = yaml.safe_load(f) or {} + raw.setdefault("leds", {})["brightness"] = brightness + with open(self.path, "w", encoding="utf-8") as f: + yaml.dump(raw, f, allow_unicode=True, default_flow_style=False) + if self._config: + self._config.leds.brightness = brightness + + def save_led_auto_brightness(self, enabled: bool): + with open(self.path, encoding="utf-8") as f: + raw = yaml.safe_load(f) or {} + raw.setdefault("leds", {})["auto_brightness"] = enabled + with open(self.path, "w", encoding="utf-8") as f: + yaml.dump(raw, f, allow_unicode=True, default_flow_style=False) + if self._config: + self._config.leds.auto_brightness = enabled + def save_led_effect(self, effect_name: str, effect_data: dict): with open(self.path, encoding="utf-8") as f: raw = yaml.safe_load(f) or {} diff --git a/backend/services/led_service.py b/backend/services/led_service.py index 1ed890b..125061a 100644 --- a/backend/services/led_service.py +++ b/backend/services/led_service.py @@ -58,6 +58,7 @@ class LEDService: 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 ───────────────────────────────────────────────────────────── @@ -111,6 +112,20 @@ class LEDService: 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): @@ -154,8 +169,14 @@ class LEDService: def _effect_idle(self): cfg = self._cfg.get_effect("idle") c = cfg.color - speed = cfg.speed + 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 @@ -202,9 +223,23 @@ class LEDService: flash_color = WS_Color(cfg.color[0], cfg.color[1], cfg.color[2]) off = WS_Color(0, 0, 0) - # Boost luminosite max pendant le flash + # 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(255) + self._strip.setBrightness(flash_brightness) try: for _ in range(cfg.flashes): diff --git a/backend/services/photobooth_service.py b/backend/services/photobooth_service.py index 2e3ac0e..5d8f2d6 100644 --- a/backend/services/photobooth_service.py +++ b/backend/services/photobooth_service.py @@ -162,5 +162,49 @@ class PhotoboothService: """Lit l'état actuel depuis le fichier private.css (ou depuis la config).""" return self._cfg.show_delete_button + async def get_liveview_snapshot(self) -> bytes | None: + """Tente de recuperer une frame JPEG du liveview de photobooth-app. + + Essaie dans l'ordre : + 1. /api/stream/snapshot — endpoint snapshot direct (si disponible) + 2. /stream.mjpg — flux MJPEG classique, extrait la premiere frame + 3. /api/stream — flux MJPEG alternatif + + Retourne des bytes JPEG ou None si rien n'est disponible. + """ + # 1. Snapshot direct + for path in ("/api/stream/snapshot",): + try: + async with httpx.AsyncClient(timeout=2.0) as client: + r = await client.get(f"{self._base}{path}") + if r.status_code == 200: + ct = r.headers.get("content-type", "") + if "jpeg" in ct or "image" in ct: + return r.content + except Exception as e: + logger.debug("Snapshot %s: %s", path, e) + + # 2. Premiere frame d'un flux MJPEG + for path in ("/stream.mjpg", "/api/stream"): + try: + async with httpx.AsyncClient(timeout=3.0) as client: + async with client.stream("GET", f"{self._base}{path}") as resp: + if resp.status_code != 200: + continue + buf = b"" + async for chunk in resp.aiter_bytes(4096): + buf += chunk + start = buf.find(b"\xff\xd8") + if start >= 0: + end = buf.find(b"\xff\xd9", start) + if end >= 0: + return buf[start:end + 2] + if len(buf) > 500_000: + break + except Exception as e: + logger.debug("MJPEG %s: %s", path, e) + + return None + async def close(self): await self._client.aclose() diff --git a/config/settings.yaml b/config/settings.yaml index 84842b1..899f56e 100644 --- a/config/settings.yaml +++ b/config/settings.yaml @@ -42,13 +42,14 @@ leds: pin: 18 count: 35 brightness: 180 # Luminosite normale (0-255) + auto_brightness: false # Ajustement automatique du flash selon la luminosite ambiante freq_hz: 800000 dma: 10 strip_type: "WS2812" effects: idle: color: [0, 30, 80] - mode: "breathe" + mode: "solid" speed: 0.025 countdown: diff --git a/frontend/templates/admin/settings.html b/frontend/templates/admin/settings.html index e9bf71b..91b12e8 100644 --- a/frontend/templates/admin/settings.html +++ b/frontend/templates/admin/settings.html @@ -361,6 +361,51 @@ select.field { + +
+
💡 Luminosité LED
+ +
+
+ + +
+ Appliquée en permanence (idle, countdown, captured…). Le flash peut dépasser cette valeur. +
+
+ +
+ + + +
+ +
+
+
+
🌞 Luminosité flash automatique
+
+ Analyse une frame de la caméra avant la prise pour adapter le flash.
+ Salle sombre → flash plein (255)  |  Salle claire → flash réduit (80+). +
+
+ +
+

+ ⏳ Chargement… +

+
+
+
+
⚡ Flash photo (LED)
@@ -744,6 +789,58 @@ async function refreshScreen() { } +// ════════════════════════════════════════════════════════════════════════════ +// Luminosité LED +// ════════════════════════════════════════════════════════════════════════════ +async function loadBrightnessConfig() { + try { + const d = await api('GET', '/api/leds/brightness'); + document.getElementById('brightness-slider').value = d.brightness ?? 180; + document.getElementById('brightness-val').textContent = d.brightness ?? 180; + const auto = d.auto_brightness ?? false; + document.getElementById('toggle-auto-brightness').checked = auto; + document.getElementById('auto-brightness-status').textContent = auto + ? '✅ Auto-flash actif — le flash s\'adapte à l\'ambiance' + : '⏸ Auto-flash désactivé — luminosité flash fixe à 100 %'; + } catch(e) { console.warn('loadBrightnessConfig:', e); } +} + +async function previewBrightness() { + const v = parseInt(document.getElementById('brightness-slider').value); + await api('PUT', `/api/leds/brightness?value=${v}&save=false`); + showToast(`Luminosité ${v}/255 appliquée`, 'info'); +} + +async function saveBrightness() { + const v = parseInt(document.getElementById('brightness-slider').value); + const status = document.getElementById('brightness-status'); + try { + await api('PUT', `/api/leds/brightness?value=${v}&save=true`); + status.textContent = '✅ Sauvegardé'; + showToast(`Luminosité ${v}/255 sauvegardée`, 'success'); + } catch(e) { + status.textContent = '❌ Erreur'; + showToast('Erreur : ' + e.message, 'error'); + } + setTimeout(() => { status.textContent = ''; }, 3000); +} + +async function setAutoBrightness(enabled) { + const statusEl = document.getElementById('auto-brightness-status'); + try { + await api('PUT', `/api/leds/auto-brightness?enabled=${enabled}&save=true`); + statusEl.textContent = enabled + ? '✅ Auto-flash actif — le flash s\'adapte à l\'ambiance' + : '⏸ Auto-flash désactivé — luminosité flash fixe à 100 %'; + showToast(enabled ? '✅ Auto-luminosité activée' : '⏸ Auto-luminosité désactivée', 'success'); + } catch(e) { + showToast('Erreur : ' + e.message, 'error'); + document.getElementById('toggle-auto-brightness').checked = !enabled; + } +} + +loadBrightnessConfig(); + // ════════════════════════════════════════════════════════════════════════════ // Flash LED // ════════════════════════════════════════════════════════════════════════════