fix: timing flash + couleur idle LED + diagnostic auto-brightness
🚀 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-20 19:06:16 +02:00
parent f192ffc92f
commit 19557b20fb
10 changed files with 247 additions and 37 deletions
+55
View File
@@ -1,5 +1,6 @@
"""API de contrôle des LEDs WS2812b."""
import asyncio
import logging
from fastapi import APIRouter, Request, Query
from fastapi.responses import JSONResponse
@@ -103,6 +104,60 @@ async def get_effects(request: Request):
}
@router.get("/leds/ambient-lux")
async def get_ambient_lux(request: Request):
"""Retourne la luminosité ambiante mesurée (dernière valeur connue)."""
led = request.app.state.led_service
lux = led._ambient_lux
return {
"ambient_lux": lux,
"known": lux >= 0,
"auto_brightness": request.app.state.config.leds.auto_brightness,
"description": (
"sombre (flash plein)" if lux < 50 and lux >= 0 else
"luminosité moyenne (flash adapté)" if lux < 180 and lux >= 0 else
"clair (flash réduit)" if lux >= 180 else
"non mesuré"
),
}
@router.post("/leds/measure-ambient")
async def measure_ambient_lux(request: Request):
"""Déclenche une mesure de luminosité ambiante via snapshot liveview.
Utile pour diagnostiquer si le flux liveview est accessible depuis JH-Photomaton.
"""
pb = request.app.state.photobooth_service
led = request.app.state.led_service
snapshot = await pb.get_liveview_snapshot()
if not snapshot:
return JSONResponse({
"ok": False,
"error": "Aucun endpoint liveview accessible sur photobooth-app. "
"Vérifiez que photobooth-app est démarré et que le flux vidéo est actif.",
}, status_code=503)
try:
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)
return {
"ok": True,
"ambient_lux": round(avg, 1),
"description": (
"sombre → flash plein (255)" if avg < 50 else
f"moyen → flash {int(255 - (avg - 50) / 130 * 135)}" if avg < 180 else
"clair → flash réduit (80)"
),
}
except Exception as e:
return JSONResponse({"ok": False, "error": str(e)}, status_code=500)
@router.put("/leds/effect/{effect_name}")
async def update_effect(
request: Request,
+22 -8
View File
@@ -168,8 +168,6 @@ async def gallery_print_request(request: Request, body: dict = Body(...)):
"""Demande d'impression depuis la galerie publique.
Vérifie que l'impression est activée et que le quota session n'est pas dépassé.
"""
from pathlib import Path as _Path
cfg_print = request.app.state.config.print
# ── Activation ────────────────────────────────────────────────────────────
@@ -191,15 +189,21 @@ async def gallery_print_request(request: Request, body: dict = Body(...)):
"quota_exceeded": True,
}, status_code=429)
# ── Résolution du fichier ─────────────────────────────────────────────────
media_dir = _Path(request.app.state.config.photobooth.media_dir)
candidates = sorted(media_dir.glob(f"{photo_id}*"))
if not candidates:
# ── Résolution du fichier via l'API photobooth-app ───────────────────────
# On passe par get_media_item() comme le fait admin_print_photo — le champ
# "processed" contient le chemin réel sur le disque (ex: media/processed_full/xxx.jpg).
# Un simple glob sur photo_id ne marche pas car l'id interne ≠ nom de fichier.
pb = request.app.state.photobooth_service
item = await pb.get_media_item(photo_id)
if not item:
return JSONResponse({"error": "Photo introuvable"}, status_code=404)
filepath = str(candidates[0])
file_path = pb.media_file_path(item)
if not file_path or not file_path.exists():
logger.error("Fichier manquant pour %s: %s", photo_id, file_path)
return JSONResponse({"error": "Fichier photo introuvable sur le disque"}, status_code=404)
filepath = str(file_path)
# ── Enqueue ───────────────────────────────────────────────────────────────
pb = request.app.state.photobooth_service
printer_svc = request.app.state.printer_service
ws = request.app.state.ws_manager
led = request.app.state.led_service
@@ -234,6 +238,16 @@ async def gallery_print_request(request: Request, body: dict = Body(...)):
# ── Gestion avancée des imprimantes CUPS ──────────────────────────────────────
@router.post("/print/printers/{printer_name}/set-excluded")
async def set_printer_excluded(request: Request, printer_name: str, excluded: bool = Query(...)):
"""Exclut (ou réintègre) une imprimante du load-balancing JH-Photomaton.
N'agit pas sur CUPS — sert uniquement à ignorer une imprimante temporairement hors ligne."""
config_svc = request.app.state.config_service
config_svc.save_printer_excluded(printer_name, excluded)
logger.info("Imprimante %s : excluded=%s", printer_name, excluded)
return {"ok": True, "printer": printer_name, "excluded": excluded}
@router.post("/print/printers/{printer_name}/enable")
async def printer_enable(request: Request, printer_name: str):
"""Active une imprimante CUPS (cupsenable + cupsaccept)."""
+18
View File
@@ -228,6 +228,24 @@ class ConfigService:
self._config.print.user_print_quota = quota
self._config.print.user_print_max_copies = max_copies
def save_printer_excluded(self, printer_name: str, excluded: bool):
"""Marque (ou démarque) une imprimante comme exclue du load-balancing."""
with open(self.path, encoding="utf-8") as f:
raw = yaml.safe_load(f) or {}
printers = raw.get("print", {}).get("printers", [])
for p in printers:
if isinstance(p, dict) and p.get("name") == printer_name:
p["excluded"] = excluded
break
raw.setdefault("print", {})["printers"] = printers
with open(self.path, "w", encoding="utf-8") as f:
yaml.dump(raw, f, allow_unicode=True, default_flow_style=False)
if self._config:
for p in self._config.print.printers:
if isinstance(p, dict) and p.get("name") == printer_name:
p["excluded"] = excluded
break
def save_print_mode(self, mode: str):
with open(self.path, encoding="utf-8") as f:
raw = yaml.safe_load(f) or {}
+23 -7
View File
@@ -191,26 +191,40 @@ class PhotoboothService:
"""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
1. Endpoints snapshot direct (retour JPEG immédiat)
2. Flux MJPEG — extrait la premiere frame
Retourne des bytes JPEG ou None si rien n'est disponible.
"""
# 1. Snapshot direct
for path in ("/api/stream/snapshot",):
# 1. Endpoints snapshot direct (differentes versions de photobooth-app)
snapshot_paths = (
"/api/stream/snapshot",
"/api/video/preview/snapshot",
"/api/livestream/snapshot",
"/api/cam/stream/snapshot",
)
for path in snapshot_paths:
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:
logger.debug("Snapshot OK via %s", path)
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"):
# 2. Premiere frame d'un flux MJPEG (differentes versions)
mjpeg_paths = (
"/stream.mjpg",
"/api/stream",
"/api/video/stream",
"/api/video/preview",
"/api/livestream",
"/livestream",
)
for path in mjpeg_paths:
try:
async with httpx.AsyncClient(timeout=3.0) as client:
async with client.stream("GET", f"{self._base}{path}") as resp:
@@ -223,12 +237,14 @@ class PhotoboothService:
if start >= 0:
end = buf.find(b"\xff\xd9", start)
if end >= 0:
logger.debug("MJPEG frame OK via %s", path)
return buf[start:end + 2]
if len(buf) > 500_000:
break
except Exception as e:
logger.debug("MJPEG %s: %s", path, e)
logger.debug("Aucun endpoint liveview disponible sur %s", self._base)
return None
async def close(self):
+13 -5
View File
@@ -244,7 +244,13 @@ class PrinterService:
if not Path(filename).exists():
return {"success": False, "error": f"Fichier introuvable: {filename}"}
cmd = ["/bin/bash", script, filename, "image", "default", str(copies)]
# Imprimantes exclues du load-balancing (flag excluded dans config)
excluded_list = ",".join(
self._printer_name(p)
for p in self._cfg.printers
if isinstance(p, dict) and p.get("excluded", False)
)
cmd = ["/bin/bash", script, filename, "image", "default", str(copies), excluded_list]
try:
proc = subprocess.run(
cmd, capture_output=True, text=True, timeout=120
@@ -361,11 +367,13 @@ class PrinterService:
statuses = []
for p in self._cfg.printers:
name = self._printer_name(p)
excluded = isinstance(p, dict) and bool(p.get("excluded", False))
status = await asyncio.to_thread(self._get_printer_status, name)
stats = await self._get_printer_db_stats(name)
statuses.append({
"name": name,
"label": self._printer_label(p),
"excluded": excluded,
**status,
**stats,
})
@@ -619,8 +627,8 @@ class PrinterService:
async def enable_printer(self, printer_name: str) -> bool:
"""Active l'imprimante CUPS (cupsenable) et accepte les nouveaux jobs."""
try:
r1 = subprocess.run(["cupsenable", printer_name], capture_output=True, timeout=10)
r2 = subprocess.run(["cupsaccept", printer_name], capture_output=True, timeout=10)
r1 = subprocess.run(["sudo", "cupsenable", printer_name], capture_output=True, timeout=10)
r2 = subprocess.run(["sudo", "cupsaccept", printer_name], capture_output=True, timeout=10)
return r1.returncode == 0 and r2.returncode == 0
except Exception as e:
logger.error("Erreur enable printer %s: %s", printer_name, e)
@@ -629,7 +637,7 @@ class PrinterService:
async def disable_printer(self, printer_name: str) -> bool:
"""Désactive l'imprimante CUPS (cupsdisable)."""
try:
result = subprocess.run(["cupsdisable", printer_name], capture_output=True, timeout=10)
result = subprocess.run(["sudo", "cupsdisable", printer_name], capture_output=True, timeout=10)
return result.returncode == 0
except Exception as e:
logger.error("Erreur disable printer %s: %s", printer_name, e)
@@ -638,7 +646,7 @@ class PrinterService:
async def reject_jobs(self, printer_name: str) -> bool:
"""Refuse les nouveaux jobs (cupsreject) sans stopper l'impression en cours."""
try:
result = subprocess.run(["cupsreject", printer_name], capture_output=True, timeout=10)
result = subprocess.run(["sudo", "cupsreject", printer_name], capture_output=True, timeout=10)
return result.returncode == 0
except Exception as e:
logger.error("Erreur reject printer %s: %s", printer_name, e)