From 19557b20fba992140c97fed764ab218b12174e1e Mon Sep 17 00:00:00 2001 From: jbperrin Date: Mon, 20 Jul 2026 19:06:16 +0200 Subject: [PATCH] fix: timing flash + couleur idle LED + diagnostic auto-brightness --- Opencode/scripts/script_print.sh | 32 ++++++++++++--- backend/api/leds_api.py | 55 ++++++++++++++++++++++++++ backend/api/print_api.py | 30 ++++++++++---- backend/services/config_service.py | 18 +++++++++ backend/services/photobooth_service.py | 30 ++++++++++---- backend/services/printer_service.py | 18 ++++++--- config/settings.yaml | 4 +- frontend/templates/admin/print.html | 54 ++++++++++++++++++++----- frontend/templates/admin/settings.html | 41 +++++++++++++++++++ photobooth-app/config/config.json | 2 +- 10 files changed, 247 insertions(+), 37 deletions(-) diff --git a/Opencode/scripts/script_print.sh b/Opencode/scripts/script_print.sh index ed1e1a2..1c985e2 100644 --- a/Opencode/scripts/script_print.sh +++ b/Opencode/scripts/script_print.sh @@ -32,6 +32,8 @@ filename="${1:-}" media_type="${2:-}" action_config_name="${3:-}" copies="${4:-1}" +# Liste CSV d'imprimantes à exclure du load-balancing (optionnel, passé par JH-Photomaton) +skip_printers="${5:-}" # --- Noms des imprimantes CUPS (WiFi) --- PRINTER_1="Selphy_Blanche_WiFi" @@ -90,24 +92,44 @@ fi printer_1_status=$(lpstat -p "$PRINTER_1" 2>/dev/null || echo "not found") printer_2_status=$(lpstat -p "$PRINTER_2" 2>/dev/null || echo "not found") +# Verifie si une imprimante est dans la liste skip_printers (CSV) +is_skipped() { [[ ",$skip_printers," == *",$1,"* ]]; } + +# Une imprimante est utilisable si CUPS la connait ET qu'elle n'est pas exclue +p1_ok=false +p2_ok=false +[[ "$printer_1_status" != "not found" ]] && ! is_skipped "$PRINTER_1" && p1_ok=true +[[ "$printer_2_status" != "not found" ]] && ! is_skipped "$PRINTER_2" && p2_ok=true + SELECTED_PRINTER="" -if [[ "$printer_1_status" == *"idle"* ]]; then +if $p1_ok && [[ "$printer_1_status" == *"idle"* ]]; then SELECTED_PRINTER="$PRINTER_1" log_info "Imprimante selectionnee: $PRINTER_1 (idle)" -elif [[ "$printer_2_status" == *"idle"* ]]; then +elif $p2_ok && [[ "$printer_2_status" == *"idle"* ]]; then SELECTED_PRINTER="$PRINTER_2" log_info "Imprimante selectionnee: $PRINTER_2 (idle)" -else +elif $p1_ok && $p2_ok; then + # Les deux sont accessibles mais aucune n'est idle — load balance sur les jobs jobs_1=$(lpstat -o "$PRINTER_1" 2>/dev/null | wc -l) jobs_2=$(lpstat -o "$PRINTER_2" 2>/dev/null | wc -l) - if [ "$jobs_1" -le "$jobs_2" ] 2>/dev/null; then SELECTED_PRINTER="$PRINTER_1" else SELECTED_PRINTER="$PRINTER_2" fi - log_info "Aucune imprimante idle, file sur: $SELECTED_PRINTER (P1=$jobs_1 jobs, P2=$jobs_2 jobs)" + log_info "Load balancing: $SELECTED_PRINTER (P1=$jobs_1 jobs, P2=$jobs_2 jobs)" +elif $p1_ok; then + SELECTED_PRINTER="$PRINTER_1" + log_info "Seule imprimante disponible: $PRINTER_1 (P2 inaccessible ou exclue)" +elif $p2_ok; then + SELECTED_PRINTER="$PRINTER_2" + log_info "Seule imprimante disponible: $PRINTER_2 (P1 inaccessible ou exclue)" +else + log_error "Aucune imprimante accessible dans CUPS" + echo "PRINT_ERROR::no_printer_available" + [ -f "$filename_print" ] && rm -f "$filename_print" 2>/dev/null + exit 1 fi # --- Impression --- diff --git a/backend/api/leds_api.py b/backend/api/leds_api.py index 52661e5..e6cee72 100644 --- a/backend/api/leds_api.py +++ b/backend/api/leds_api.py @@ -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, diff --git a/backend/api/print_api.py b/backend/api/print_api.py index f4a4741..56060c1 100644 --- a/backend/api/print_api.py +++ b/backend/api/print_api.py @@ -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).""" diff --git a/backend/services/config_service.py b/backend/services/config_service.py index fd75717..404a367 100644 --- a/backend/services/config_service.py +++ b/backend/services/config_service.py @@ -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 {} diff --git a/backend/services/photobooth_service.py b/backend/services/photobooth_service.py index 1c2c510..6fd9552 100644 --- a/backend/services/photobooth_service.py +++ b/backend/services/photobooth_service.py @@ -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): diff --git a/backend/services/printer_service.py b/backend/services/printer_service.py index 45cd62f..d816657 100644 --- a/backend/services/printer_service.py +++ b/backend/services/printer_service.py @@ -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) diff --git a/config/settings.yaml b/config/settings.yaml index 7f72c1d..cb6a008 100644 --- a/config/settings.yaml +++ b/config/settings.yaml @@ -83,9 +83,9 @@ leds: mode: solid idle: color: - - 0 + - 50 - 30 - - 80 + - 10 mode: solid speed: 0.025 printing: diff --git a/frontend/templates/admin/print.html b/frontend/templates/admin/print.html index bb287ec..aaa886e 100644 --- a/frontend/templates/admin/print.html +++ b/frontend/templates/admin/print.html @@ -52,10 +52,13 @@ .printer-actions { display: flex; gap: .5rem; flex-wrap: wrap; } .btn-xs { padding: .3rem .65rem; font-size: .8rem; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; transition: opacity .2s; } .btn-xs:hover { opacity: .8; } -.btn-enable { background: #1a7340; color: #fff; } -.btn-disable { background: #784700; color: #fff; } -.btn-clear { background: #7a0000; color: #fff; } -.btn-reject { background: #555; color: #fff; } +.btn-enable { background: #1a7340; color: #fff; } +.btn-disable { background: #784700; color: #fff; } +.btn-clear { background: #7a0000; color: #fff; } +.btn-reject { background: #555; color: #fff; } +.btn-exclude { background: #4a2080; color: #fff; } +.btn-include { background: #205080; color: #fff; } +.bs-excluded { background: rgba(100,40,160,.2); color: #b085f0; border: 1px solid rgba(100,40,160,.3); } /* Jobs CUPS détaillés */ .cups-jobs { font-size: .82rem; } @@ -131,10 +134,13 @@ {% if p.state == 'idle' %}✅ Disponible {% elif p.state == 'printing' %}🖨 En impression - {% elif p.state == 'disabled' %}⏸ Désactivée + {% elif p.state == 'disabled' %}⏸ Désactivée CUPS {% elif p.state == 'offline' %}❌ Hors ligne {% else %}❓ {{ p.state }}{% endif %} + {% if p.excluded %} + 🚫 Exclue du load-balancing + {% endif %} {{ '✓ Accepte les jobs' if p.accepting else '✗ Refuse les jobs' }} @@ -193,15 +199,20 @@
{% if p.state == 'disabled' %} - + {% else %} - + {% endif %} {% if p.accepting %} {% else %} {% endif %} + {% if p.excluded %} + + {% else %} + + {% endif %} {% if p.jobs_count %} {% endif %} @@ -426,16 +437,33 @@ async function refreshPrinters() { acceptBadge.textContent = p.accepting ? '✓ Accepte les jobs' : '✗ Refuse les jobs'; } + // Badge excluded + const statesDiv = card.querySelector('.printer-states'); + if (statesDiv) { + const excBadge = statesDiv.querySelector('.bs-excluded'); + if (p.excluded && !excBadge) { + const b = document.createElement('span'); + b.className = 'badge-printer bs-excluded'; + b.textContent = '🚫 Exclue du load-balancing'; + statesDiv.appendChild(b); + } else if (!p.excluded && excBadge) { + excBadge.remove(); + } + } + // Boutons actions const actionsDiv = card.querySelector('.printer-actions'); if (actionsDiv) { actionsDiv.innerHTML = ` ${p.state === 'disabled' - ? `` - : ``} + ? `` + : ``} ${p.accepting ? `` : ``} + ${p.excluded + ? `` + : ``} ${p.jobs_count ? `` : ''} `; } @@ -472,6 +500,14 @@ async function printerAction(name, action) { } catch(err) { showToast('Erreur : ' + err.message, 'error'); } } +async function setExcluded(name, excluded) { + try { + await api('POST', `/api/print/printers/${name}/set-excluded?excluded=${excluded}`); + showToast(excluded ? `🚫 ${name} exclue du load-balancing` : `✓ ${name} réintégrée`, excluded ? 'info' : 'success'); + setTimeout(refreshPrinters, 400); + } catch(err) { showToast('Erreur : ' + err.message, 'error'); } +} + async function clearJobs(printer) { if (!confirm(`Vider toute la file CUPS de ${printer} ?`)) return; try { diff --git a/frontend/templates/admin/settings.html b/frontend/templates/admin/settings.html index 4686a5f..4838197 100644 --- a/frontend/templates/admin/settings.html +++ b/frontend/templates/admin/settings.html @@ -394,6 +394,16 @@ select.field {

⏳ Chargement…

+
+ + +
+
@@ -875,6 +885,37 @@ async function loadFlashConfig() { } catch(e) { console.warn('loadFlashConfig:', e); } } +async function measureAmbient() { + const btn = document.getElementById('btn-measure-lux'); + const status = document.getElementById('lux-status'); + const info = document.getElementById('lux-info'); + btn.disabled = true; + status.textContent = '⏳ Mesure en cours…'; + status.style.color = 'var(--text-muted)'; + try { + const r = await api('POST', '/api/leds/measure-ambient'); + if (r.ok) { + document.getElementById('lux-val').textContent = r.ambient_lux; + document.getElementById('lux-desc').textContent = r.description; + info.style.display = ''; + status.textContent = '✅ Mesure réussie'; + status.style.color = '#4caf50'; + showToast(`✅ Luminosité : ${r.ambient_lux} — ${r.description}`, 'success'); + } else { + status.textContent = '❌ ' + (r.error || 'Erreur inconnue'); + status.style.color = '#e05050'; + showToast('❌ Mesure impossible : ' + r.error, 'error'); + } + } catch(e) { + status.textContent = '❌ Liveview inaccessible'; + status.style.color = '#e05050'; + showToast('❌ Liveview inaccessible (endpoint 404). Vérifiez que photobooth-app est démarré.', 'error'); + } finally { + btn.disabled = false; + setTimeout(() => { status.textContent = ''; }, 6000); + } +} + async function setAutoBrightness(enabled) { try { await api('PUT', `/api/leds/auto-brightness?enabled=${enabled}`); diff --git a/photobooth-app/config/config.json b/photobooth-app/config/config.json index f5098ce..0c38c80 100644 --- a/photobooth-app/config/config.json +++ b/photobooth-app/config/config.json @@ -503,7 +503,7 @@ "backends": { "enable_livestream": true, "retry_capture": 3, - "countdown_camera_capture_offset": 0.2, + "countdown_camera_capture_offset": 0.5, "index_backend_stills": 0, "index_backend_video": 0, "index_backend_multicam": 0,