Compare commits

...

2 Commits

Author SHA1 Message Date
4e431507fc Resilience : ecran pause, reconnexion WS auto, watchdog systemd, WiFi monitor
- WebSocket : reconnexion avec backoff exponentiel au lieu de reload page
- Ecran pause avec tasse de cafe quand le backend est injoignable
- Endpoint /api/health pour monitoring externe
- Watchdog systemd (sd_notify READY=1 + WATCHDOG=1 toutes les 10s)
- Service systemd durci (Type=notify, WatchdogSec=30, KillMode, limites)
- WiFi watchdog : script + timer systemd, verifie la gateway toutes les 60s
- Destinations : fallback url_tunnel (WireGuard) en priorite

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-26 19:16:39 +02:00
48f54ab70a Robustesse DSLR + éclairage visage + fix pellicule cadre imposé
- camera.py: fix fuite FD USB sur échec reconnexion (gp.Camera non libéré),
  cleanup PTP session avant init, catch toutes exceptions preview/capture,
  deconnecter() avec timeout thread + force del pour libérer le port USB
- main.py: thread preview anti-crash (outer try/except), reconnexion DSLR
  même si preview actif, notification camera_erreur après échec capture,
  intégration module éclairage (analyse luminosité visage via WS)
- printer.py: mode pellicule (-2up) ignoré par le cadre imposé événement
- eclairage.py: nouveau module analyse luminosité visage (Haar + HSV)
- camera.js: indicateur éclairage temps réel (score/100 + visages)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-25 19:11:10 +02:00
13 changed files with 466 additions and 99 deletions

View File

@@ -79,19 +79,31 @@ class Camera:
if source == "gphoto2" or source is None: if source == "gphoto2" or source is None:
if GPHOTO2_DISPONIBLE: if GPHOTO2_DISPONIBLE:
cam = None
try: try:
self.camera = gp.Camera() cam = gp.Camera()
self.camera.init() try:
# Desactiver la mise en veille auto du DSLR (sinon il se deconnecte tout seul) cam.exit()
except Exception:
pass
cam.init()
self.camera = cam
self._desactiver_veille_init() self._desactiver_veille_init()
# Viewfinder activé AVANT de rendre la caméra visible au thread preview
self._activer_viewfinder_init() self._activer_viewfinder_init()
self.connectee = True self.connectee = True
self.mode = "gphoto2" self.mode = "gphoto2"
self.preview_dslr_ok = True
log.info("Camera DSLR connectee (LiveView actif)") log.info("Camera DSLR connectee (LiveView actif)")
return True return True
except gp.GPhoto2Error as e: except Exception as e:
log.warning(f"Pas de DSLR : {e}") log.warning(f"Pas de DSLR : {e}")
if cam is not None and cam is not self.camera:
try:
cam.exit()
except Exception:
pass
del cam
self.camera = None
# Pas de DSLR disponible : mode erreur, pas de fallback # Pas de DSLR disponible : mode erreur, pas de fallback
log.warning("Aucun DSLR disponible, camera non connectee") log.warning("Aucun DSLR disponible, camera non connectee")
@@ -116,25 +128,30 @@ class Camera:
def deconnecter(self, timeout: float = 3.0): def deconnecter(self, timeout: float = 3.0):
"""Deconnecte l'appareil photo avec timeout pour eviter le blocage PTP.""" """Deconnecte l'appareil photo avec timeout pour eviter le blocage PTP."""
if self.camera and GPHOTO2_DISPONIBLE: cam_ref = self.camera
ctx_ref = self.contexte
if cam_ref and GPHOTO2_DISPONIBLE:
def _exit(): def _exit():
try: try:
self.camera.exit() cam_ref.exit()
except Exception: except Exception:
pass pass
t = threading.Thread(target=_exit, daemon=True) t = threading.Thread(target=_exit, daemon=True)
t.start() t.start()
t.join(timeout=timeout) t.join(timeout=timeout)
if t.is_alive(): if t.is_alive():
log.warning("camera.exit() bloque — abandon (le FD sera libere au GC)") log.warning("camera.exit() bloque — force del camera/contexte pour liberer le FD USB")
if self.webcam: if self.webcam:
self.webcam.release() self.webcam.release()
self.camera = None self.camera = None
self.contexte = None self.contexte = None
del cam_ref
del ctx_ref
self.webcam = None self.webcam = None
self.webcam_index = -1 self.webcam_index = -1
self.connectee = False self.connectee = False
self.mode = "simulation" self.mode = "simulation"
self.preview_dslr_ok = False
log.info("Camera deconnectee") log.info("Camera deconnectee")
def capturer(self) -> Path | None: def capturer(self) -> Path | None:
@@ -160,13 +177,16 @@ class Camera:
for attempt in range(3): for attempt in range(3):
try: try:
with self._gp_lock: with self._gp_lock:
# Désactiver LiveView : miroir abaissé → AF phase-détection Canon disponible if not self.camera:
log.error("Camera None pendant capture")
self.preview_dslr_ok = False
return None
try: try:
cfg = self.camera.get_config() cfg = self.camera.get_config()
vf = cfg.get_child_by_name("viewfinder") vf = cfg.get_child_by_name("viewfinder")
vf.set_value(0) vf.set_value(0)
self.camera.set_config(cfg) self.camera.set_config(cfg)
time.sleep(0.2) # Laisser le miroir descendre time.sleep(0.2)
except Exception: except Exception:
pass pass
log.info(f"Déclenchement capture DSLR (tentative {attempt+1}/3)") log.info(f"Déclenchement capture DSLR (tentative {attempt+1}/3)")
@@ -177,8 +197,8 @@ class Camera:
) )
tmp_path = str(chemin_dest) + ".tmp" tmp_path = str(chemin_dest) + ".tmp"
fichier_camera.save(tmp_path) fichier_camera.save(tmp_path)
self.activer_viewfinder() # Miroir relevé immédiatement après la prise self.activer_viewfinder()
self._chauffer_liveview() # Absorbe la stabilisation LiveView ici (spinner visible), pas sur le countdown suivant self._chauffer_liveview()
from PIL import ImageOps as PILImageOps from PIL import ImageOps as PILImageOps
img = PILImageOps.exif_transpose(PILImage.open(tmp_path)) img = PILImageOps.exif_transpose(PILImage.open(tmp_path))
if img.width > 4000: if img.width > 4000:
@@ -194,6 +214,11 @@ class Camera:
time.sleep(0.5) time.sleep(0.5)
continue continue
log.error(f"Erreur capture DSLR : {e}") log.error(f"Erreur capture DSLR : {e}")
self.preview_dslr_ok = False
return None
except Exception as e:
log.error(f"Erreur fatale capture DSLR : {e}")
self.preview_dslr_ok = False
return None return None
def _chauffer_liveview(self, tentatives: int = 8, pause: float = 0.15): def _chauffer_liveview(self, tentatives: int = 8, pause: float = 0.15):
@@ -308,6 +333,8 @@ class Camera:
def _preview_gphoto2(self) -> bytes | None: def _preview_gphoto2(self) -> bytes | None:
try: try:
with self._gp_lock: with self._gp_lock:
if not self.camera:
return None
fichier = self.camera.capture_preview() fichier = self.camera.capture_preview()
donnees = bytes(fichier.get_data_and_size()) donnees = bytes(fichier.get_data_and_size())
img = cv2.imdecode(np.frombuffer(donnees, dtype=np.uint8), cv2.IMREAD_COLOR) img = cv2.imdecode(np.frombuffer(donnees, dtype=np.uint8), cv2.IMREAD_COLOR)
@@ -318,7 +345,7 @@ class Camera:
_, buf = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, 75]) _, buf = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, 75])
return buf.tobytes() return buf.tobytes()
return donnees return donnees
except gp.GPhoto2Error as e: except Exception as e:
log.error(f"Erreur preview DSLR : {e}") log.error(f"Erreur preview DSLR : {e}")
return None return None

View File

@@ -37,11 +37,12 @@ def distribuer_photo(chemin_photo: Path, imprimee: bool = False):
if dest.get("ftp", False): if dest.get("ftp", False):
envoyer_ftp(chemin_photo, dest, sous_dossier) envoyer_ftp(chemin_photo, dest, sous_dossier)
# Incrementer le compteur # Incrementer le compteur (photos imprimees seulement)
compteur = config.get("compteur", {}) if imprimee:
if compteur.get("actif", False): compteur = config.get("compteur", {})
compteur["photos_prises"] = compteur.get("photos_prises", 0) + 1 if compteur.get("actif", False):
mettre_a_jour_config({"compteur": compteur}) compteur["photos_prises"] = compteur.get("photos_prises", 0) + 1
mettre_a_jour_config({"compteur": compteur})
def copier_usb(chemin_photo: Path, chemin_usb: str, sous_dossier: str | None = None): def copier_usb(chemin_photo: Path, chemin_usb: str, sous_dossier: str | None = None):
@@ -98,48 +99,44 @@ def envoyer_ftp(chemin_photo: Path, config_dest: dict, sous_dossier: str | None
return False return False
def _upload_booth(url: str, api_key: str, event_id: str, chemin_photo: Path) -> bool:
boundary = "----BoothUpload"
filename = chemin_photo.name
with open(chemin_photo, "rb") as f:
file_data = f.read()
body = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="photo"; filename="{filename}"\r\n'
f"Content-Type: image/jpeg\r\n\r\n"
).encode() + file_data + f"\r\n--{boundary}--\r\n".encode()
req = Request(f"{url}/admin/gallery/{event_id}/upload", data=body, method="POST")
req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}")
req.add_header("X-Api-Key", api_key)
with urlopen(req, timeout=10) as resp:
return resp.status == 200
def envoyer_booth(chemin_photo: Path, config_booth: dict): def envoyer_booth(chemin_photo: Path, config_booth: dict):
"""Envoie une photo vers la galerie live booth.""" """Envoie une photo vers la galerie live booth."""
url = config_booth.get("url", "").rstrip("/") url = config_booth.get("url", "").rstrip("/")
url_tunnel = config_booth.get("url_tunnel", "").rstrip("/")
api_key = config_booth.get("api_key", "") api_key = config_booth.get("api_key", "")
event_id = config_booth.get("event_id", "default") config = charger_config()
event_id = config.get("evenement", {}).get("event_id") or config_booth.get("event_id", "default")
if not url: if not url and not url_tunnel:
log.warning("Booth non configure (URL manquante)") log.warning("Booth non configure (URL manquante)")
return False return False
try: for tentative_url in [u for u in (url_tunnel, url) if u]:
import mimetypes try:
boundary = "----BoothUpload" if _upload_booth(tentative_url, api_key, event_id, chemin_photo):
filename = chemin_photo.name log.info(f"Photo envoyee au booth via {tentative_url} : {chemin_photo.name}")
with open(chemin_photo, "rb") as f:
file_data = f.read()
body = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="photo"; filename="{filename}"\r\n'
f"Content-Type: image/jpeg\r\n\r\n"
).encode() + file_data + f"\r\n--{boundary}--\r\n".encode()
req = Request(
f"{url}/api/{event_id}/upload",
data=body,
method="POST",
)
req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}")
req.add_header("X-Api-Key", api_key)
with urlopen(req, timeout=10) as resp:
if resp.status == 200:
log.info(f"Photo envoyee au booth : {filename}")
return True return True
else: log.error(f"Booth erreur HTTP via {tentative_url}")
log.error(f"Booth erreur HTTP {resp.status}") except (URLError, OSError) as e:
return False log.warning(f"Echec envoi booth via {tentative_url} : {e}")
except (URLError, OSError) as e: return False
log.error(f"Erreur envoi booth : {e}")
return False
def recuperer_booth_password() -> dict: def recuperer_booth_password() -> dict:
@@ -147,22 +144,20 @@ def recuperer_booth_password() -> dict:
config = charger_config() config = charger_config()
booth = config.get("booth", {}) booth = config.get("booth", {})
url = booth.get("url", "").rstrip("/") url = booth.get("url", "").rstrip("/")
url_tunnel = booth.get("url_tunnel", "").rstrip("/")
api_key = booth.get("api_key", "") api_key = booth.get("api_key", "")
event_id = booth.get("event_id", "default") event_id = config.get("evenement", {}).get("event_id") or booth.get("event_id", "default")
if not url: for tentative_url in [u for u in (url_tunnel, url) if u]:
return {"password": None} try:
req = Request(f"{tentative_url}/admin/gallery/{event_id}/info")
try: req.add_header("X-Api-Key", api_key)
req = Request(f"{url}/api/{event_id}/info") with urlopen(req, timeout=5) as resp:
req.add_header("X-Api-Key", api_key) import json
with urlopen(req, timeout=5) as resp: return json.loads(resp.read())
import json except (URLError, OSError) as e:
data = json.loads(resp.read()) log.warning(f"Echec recuperation booth info via {tentative_url} : {e}")
return data return {"password": None}
except (URLError, OSError) as e:
log.error(f"Erreur recuperation booth info : {e}")
return {"password": None}
def detecter_usb() -> list[str]: def detecter_usb() -> list[str]:

114
backend/eclairage.py Normal file
View File

@@ -0,0 +1,114 @@
"""Analyse de luminosité sur les visages détectés dans le preview DSLR.
Produit une note d'éclairage (0-100) et une recommandation (allumer/ok/sombre).
Échantillonne toutes les ~3 secondes pour ne pas charger le CPU.
"""
import logging
import time
from pathlib import Path
import cv2
import numpy as np
log = logging.getLogger("photobooth.eclairage")
_CASCADE_CANDIDATES = [
"/usr/share/opencv4/haarcascades/haarcascade_frontalface_default.xml",
"/usr/share/opencv/haarcascades/haarcascade_frontalface_default.xml",
"/usr/local/share/opencv4/haarcascades/haarcascade_frontalface_default.xml",
]
try:
_CASCADE_PATH = cv2.data.haarcascades + "haarcascade_frontalface_default.xml"
except AttributeError:
_CASCADE_PATH = next((p for p in _CASCADE_CANDIDATES if Path(p).exists()), _CASCADE_CANDIDATES[0])
_face_cascade = cv2.CascadeClassifier(_CASCADE_PATH)
SEUIL_SOMBRE = 45
SEUIL_CORRECT = 65
_dernier_analyse: float = 0
_INTERVALLE = 3.0
_dernier_resultat: dict | None = None
def analyser_frame(jpeg_data: bytes) -> dict | None:
"""Analyse une frame JPEG du preview.
Retourne None si l'intervalle n'est pas écoulé (throttle).
Sinon retourne:
{
"score": int 0-100,
"action": "allumer" | "attention" | "ok",
"visages": int,
"detail": str,
}
"""
global _dernier_analyse, _dernier_resultat
now = time.monotonic()
if now - _dernier_analyse < _INTERVALLE:
return None
_dernier_analyse = now
try:
arr = np.frombuffer(jpeg_data, dtype=np.uint8)
img = cv2.imdecode(arr, cv2.IMREAD_COLOR)
if img is None:
return None
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
faces = _face_cascade.detectMultiScale(
gray, scaleFactor=1.2, minNeighbors=4, minSize=(60, 60)
)
if len(faces) == 0:
_dernier_resultat = {
"score": -1,
"action": "no_face",
"visages": 0,
"detail": "Aucun visage détecté",
}
return _dernier_resultat
hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
scores = []
for (x, y, w, h) in faces:
pad_x = int(w * 0.1)
pad_y = int(h * 0.1)
x1 = max(0, x + pad_x)
y1 = max(0, y + pad_y)
x2 = min(img.shape[1], x + w - pad_x)
y2 = min(img.shape[0], y + h - pad_y)
roi_v = hsv[y1:y2, x1:x2, 2]
score = int(np.mean(roi_v) / 2.55)
scores.append(score)
score_moyen = int(np.mean(scores))
if score_moyen < SEUIL_SOMBRE:
action = "allumer"
detail = f"Visages sombres ({score_moyen}/100)"
elif score_moyen < SEUIL_CORRECT:
action = "attention"
detail = f"Éclairage limite ({score_moyen}/100)"
else:
action = "ok"
detail = f"Éclairage correct ({score_moyen}/100)"
_dernier_resultat = {
"score": score_moyen,
"action": action,
"visages": len(faces),
"detail": detail,
}
return _dernier_resultat
except Exception as e:
log.debug(f"Erreur analyse éclairage : {e}")
return None
def dernier_resultat() -> dict | None:
return _dernier_resultat

View File

@@ -2,6 +2,7 @@ import asyncio
import base64 import base64
import json import json
import logging import logging
import os
import threading import threading
import time import time
from pathlib import Path from pathlib import Path
@@ -36,6 +37,7 @@ from backend.evenements import (
lister_cadres_event, set_cadre_event, cadre_actif_pour_impression, lister_cadres_event, set_cadre_event, cadre_actif_pour_impression,
) )
from backend.evenements import DOSSIER_EVENEMENTS from backend.evenements import DOSSIER_EVENEMENTS
from backend.eclairage import analyser_frame as analyser_eclairage, dernier_resultat as dernier_eclairage
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(message)s") logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(message)s")
log = logging.getLogger("photobooth") log = logging.getLogger("photobooth")
@@ -63,17 +65,21 @@ def _thread_preview():
Tourne TOUJOURS quand le DSLR est connecte pour maintenir le miroir leve.""" Tourne TOUJOURS quand le DSLR est connecte pour maintenir le miroir leve."""
global _derniere_frame_preview global _derniere_frame_preview
while True: while True:
if capture_en_cours or camera.mode != "gphoto2" or not camera.connectee:
time.sleep(0.05)
continue
try: try:
donnees = camera.preview() if capture_en_cours or camera.mode != "gphoto2" or not camera.connectee:
with _preview_lock: time.sleep(0.05)
_derniere_frame_preview = donnees continue
except Exception: try:
with _preview_lock: donnees = camera.preview()
_derniere_frame_preview = None with _preview_lock:
time.sleep(0.033) # ~30 fps max _derniere_frame_preview = donnees
except Exception:
with _preview_lock:
_derniere_frame_preview = None
time.sleep(0.033) # ~30 fps max
except Exception as e:
log.error(f"_thread_preview crash: {e}")
time.sleep(1)
async def _pusher_preview(): async def _pusher_preview():
@@ -103,6 +109,10 @@ async def _pusher_preview():
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
b64 = await loop.run_in_executor(None, lambda d=donnees: base64.b64encode(d).decode("ascii")) b64 = await loop.run_in_executor(None, lambda d=donnees: base64.b64encode(d).decode("ascii"))
await diffuser_ws({"type": "preview", "image": f"data:image/jpeg;base64,{b64}"}) await diffuser_ws({"type": "preview", "image": f"data:image/jpeg;base64,{b64}"})
if not capture_en_cours:
eclairage = await loop.run_in_executor(None, analyser_eclairage, donnees)
if eclairage is not None:
await diffuser_ws({"type": "eclairage", **eclairage})
def _usb_reset_canon(): def _usb_reset_canon():
"""Reset USB ioctl du Canon EOS (vendor 04a9) pour débloquer un port stall.""" """Reset USB ioctl du Canon EOS (vendor 04a9) pour débloquer un port stall."""
@@ -206,7 +216,7 @@ async def surveiller_dslr():
_echecs_connexion = 0 _echecs_connexion = 0
else: else:
_dslr_erreurs += 1 _dslr_erreurs += 1
if _dslr_erreurs >= 3 and not _preview_actif: if _dslr_erreurs >= 3:
log.warning(f"camera_erreur : DSLR preview KO depuis {_dslr_erreurs * 5}s, reconnexion forcee...") log.warning(f"camera_erreur : DSLR preview KO depuis {_dslr_erreurs * 5}s, reconnexion forcee...")
_dslr_erreurs = 0 _dslr_erreurs = 0
_echecs_connexion += 1 _echecs_connexion += 1
@@ -264,6 +274,24 @@ def _appliquer_config_camera():
camera.configurer_flash(flash_integre) camera.configurer_flash(flash_integre)
async def _watchdog_systemd():
"""Notifie systemd que le service est vivant + surveille la santé."""
try:
import socket
addr = os.environ.get("NOTIFY_SOCKET")
if not addr:
return
sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
if addr[0] == "@":
addr = "\0" + addr[1:]
sock.sendto(b"READY=1", addr)
while True:
await asyncio.sleep(10)
sock.sendto(b"WATCHDOG=1", addr)
except Exception:
pass
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
"""Demarrage et arret de l'application.""" """Demarrage et arret de l'application."""
@@ -278,10 +306,12 @@ async def lifespan(app: FastAPI):
task_dslr = asyncio.create_task(surveiller_dslr()) task_dslr = asyncio.create_task(surveiller_dslr())
task_push = asyncio.create_task(_pusher_preview()) task_push = asyncio.create_task(_pusher_preview())
task_spool = asyncio.create_task(tache_spool_demarrage()) task_spool = asyncio.create_task(tache_spool_demarrage())
task_watchdog = asyncio.create_task(_watchdog_systemd())
yield yield
task_dslr.cancel() task_dslr.cancel()
task_push.cancel() task_push.cancel()
task_spool.cancel() task_spool.cancel()
task_watchdog.cancel()
log.info("Arret du photobooth") log.info("Arret du photobooth")
camera.deconnecter() camera.deconnecter()
@@ -302,6 +332,18 @@ async def page_admin():
return FileResponse(str(RACINE / "frontend" / "index.html")) return FileResponse(str(RACINE / "frontend" / "index.html"))
@app.get("/api/health")
async def api_health():
return {
"status": "ok",
"camera": camera.mode if camera.connectee else "disconnected",
"clients": len(clients_ws),
"uptime": int(time.time() - _start_time),
}
_start_time = time.time()
@app.get("/") @app.get("/")
async def page_principale(request: Request): async def page_principale(request: Request):
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
@@ -409,6 +451,9 @@ async def api_capturer():
capture_en_cours = False capture_en_cours = False
log.info(f"Capture terminée : {chemin}") log.info(f"Capture terminée : {chemin}")
if chemin is None: if chemin is None:
if camera.mode == "gphoto2" and not camera.preview_dslr_ok:
log.warning("Capture echouee + preview KO → reconnexion DSLR imminente")
await diffuser_ws({"type": "camera_erreur", "message": "Erreur capture — reconnexion en cours"})
return JSONResponse({"erreur": "Echec capture"}, status_code=500) return JSONResponse({"erreur": "Echec capture"}, status_code=500)
nom = chemin.name nom = chemin.name
@@ -547,6 +592,14 @@ async def api_camera_statut():
} }
@app.get("/api/eclairage")
async def api_eclairage():
resultat = dernier_eclairage()
if resultat is None:
return {"score": -1, "action": "no_data", "detail": "Aucune analyse disponible"}
return resultat
@app.get("/api/camera/debug") @app.get("/api/camera/debug")
async def api_camera_debug(): async def api_camera_debug():
"""Diagnostic complet de la caméra (preview, viewfinder, erreurs gphoto2).""" """Diagnostic complet de la caméra (preview, viewfinder, erreurs gphoto2)."""

View File

@@ -258,14 +258,15 @@ def imprimer(
largeur, hauteur = fmt["px"] largeur, hauteur = fmt["px"]
fmt_base = format_papier.replace("-2up", "") fmt_base = format_papier.replace("-2up", "")
cadre = cadre_override if cadre_override is not None else conf_imp.get("cadres_actifs", {}).get(fmt_base) is_strip = "-2up" in format_papier
cadre = cadre_override if cadre_override is not None else (None if is_strip else conf_imp.get("cadres_actifs", {}).get(fmt_base))
orientation = conf_imp.get("orientations", {}).get(fmt_base, "portrait") orientation = conf_imp.get("orientations", {}).get(fmt_base, "portrait")
if orientation == "paysage" and hauteur > largeur: if orientation == "paysage" and hauteur > largeur:
largeur, hauteur = hauteur, largeur largeur, hauteur = hauteur, largeur
event_id = config.get("evenement", {}).get("event_id") event_id = config.get("evenement", {}).get("event_id")
if event_id and cadre_override is None: if event_id and cadre_override is None and not is_strip:
from backend.evenements import cadre_actif_pour_impression from backend.evenements import cadre_actif_pour_impression
ev_cadre, ev_mode = cadre_actif_pour_impression(fmt_base) ev_cadre, ev_mode = cadre_actif_pour_impression(fmt_base)
if ev_mode == "impose" and ev_cadre: if ev_mode == "impose" and ev_cadre:

View File

@@ -373,6 +373,7 @@
<button class="onglet" data-onglet="personnalisation">Personnalisation</button> <button class="onglet" data-onglet="personnalisation">Personnalisation</button>
<button class="onglet" data-onglet="evenement">Evenement</button> <button class="onglet" data-onglet="evenement">Evenement</button>
<button class="onglet" data-onglet="fonctions">Fonctions</button> <button class="onglet" data-onglet="fonctions">Fonctions</button>
<button class="onglet" data-onglet="eclairage" onclick="demarrerEclairageLive()">Eclairage</button>
<button class="onglet" data-onglet="infos" onclick="chargerInfosSysteme()">Infos</button> <button class="onglet" data-onglet="infos" onclick="chargerInfosSysteme()">Infos</button>
</div> </div>
@@ -691,6 +692,19 @@
</div> </div>
<!-- Panneau Infos systeme --> <!-- Panneau Infos systeme -->
<div class="admin-panneau" id="panneau-eclairage">
<div style="display:flex;gap:1rem;align-items:flex-start;flex-wrap:wrap">
<div style="flex:1;min-width:280px">
<img id="eclairage-preview" style="width:100%;border-radius:8px;background:#111" alt="Preview">
</div>
<div style="flex:0 0 200px;text-align:center">
<div id="eclairage-score-box" style="font-size:3rem;font-weight:800;padding:1.5rem;border-radius:16px;background:#222;margin-bottom:1rem">--</div>
<div id="eclairage-action" style="font-size:1.2rem;font-weight:600;margin-bottom:.5rem">--</div>
<div id="eclairage-visages" style="font-size:.9rem;color:#aaa">--</div>
</div>
</div>
</div>
<div class="admin-panneau" id="panneau-infos"> <div class="admin-panneau" id="panneau-infos">
<div class="infos-systeme-header"> <div class="infos-systeme-header">
<h3>Informations systeme</h3> <h3>Informations systeme</h3>
@@ -772,9 +786,26 @@
</div> </div>
<script src="/js/websocket.js?v=3"></script> <!-- Ecran de pause (deconnexion backend) -->
<script src="/js/app.js?v=6"></script> <div id="ecran-pause" style="display:none;position:fixed;inset:0;z-index:9999;background:#0a0a0f;flex-direction:column;align-items:center;justify-content:center;text-align:center">
<script src="/js/camera.js?v=13"></script> <div style="font-size:6rem;animation:pause-bob 2s ease-in-out infinite">&#9749;</div>
<h1 style="color:#fff;font-size:2.2rem;margin:1rem 0 .5rem;font-weight:700">Petite pause...</h1>
<p style="color:#888;font-size:1.2rem;max-width:400px;line-height:1.5">Le photobooth revient dans un instant.<br>Pas de panique, on s'occupe de tout !</p>
<div style="margin-top:2rem;display:flex;gap:8px">
<span class="pause-dot" style="width:12px;height:12px;border-radius:50%;background:#e91e63;animation:pause-dot .6s ease-in-out infinite"></span>
<span class="pause-dot" style="width:12px;height:12px;border-radius:50%;background:#e91e63;animation:pause-dot .6s ease-in-out .2s infinite"></span>
<span class="pause-dot" style="width:12px;height:12px;border-radius:50%;background:#e91e63;animation:pause-dot .6s ease-in-out .4s infinite"></span>
</div>
<p id="pause-status" style="color:#555;font-size:.85rem;margin-top:2rem">Reconnexion en cours...</p>
</div>
<style>
@keyframes pause-bob { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-20px)} }
@keyframes pause-dot { 0%,100%{opacity:.2;transform:scale(.8)} 50%{opacity:1;transform:scale(1.2)} }
</style>
<script src="/js/websocket.js?v=16"></script>
<script src="/js/app.js?v=15"></script>
<script src="/js/camera.js?v=15"></script>
<script src="/js/effects.js?v=4"></script> <script src="/js/effects.js?v=4"></script>
<script src="/js/gallery.js?v=3"></script> <script src="/js/gallery.js?v=3"></script>
<script src="/js/share.js?v=7"></script> <script src="/js/share.js?v=7"></script>

View File

@@ -118,9 +118,34 @@ function allerA(ecran) {
chargerGalerie(); chargerGalerie();
} else if (ecran === 'admin') { } else if (ecran === 'admin') {
chargerAdmin(); chargerAdmin();
demarrerTimeoutAdmin();
} else {
arreterTimeoutAdmin();
} }
} }
let _adminTimeoutId = null;
const ADMIN_TIMEOUT_MS = 30000;
function demarrerTimeoutAdmin() {
arreterTimeoutAdmin();
_adminTimeoutId = setTimeout(() => { allerA('accueil'); }, ADMIN_TIMEOUT_MS);
const ecranAdmin = document.getElementById('ecran-admin');
ecranAdmin.removeEventListener('pointerdown', _resetAdminTimeout);
ecranAdmin.addEventListener('pointerdown', _resetAdminTimeout);
}
function _resetAdminTimeout() {
if (_adminTimeoutId) {
clearTimeout(_adminTimeoutId);
_adminTimeoutId = setTimeout(() => { allerA('accueil'); }, ADMIN_TIMEOUT_MS);
}
}
function arreterTimeoutAdmin() {
if (_adminTimeoutId) { clearTimeout(_adminTimeoutId); _adminTimeoutId = null; }
}
function recommencer() { function recommencer() {
photosSession = []; photosSession = [];
photoFinale = null; photoFinale = null;

View File

@@ -48,13 +48,19 @@ function lancerPreview() {
if (previewActif) return; if (previewActif) return;
previewActif = true; previewActif = true;
afficherErreurPreview(false); afficherErreurPreview(false);
// Flou le temps que la 1ere frame fraiche arrive (le miroir DSLR doit remonter
// apres la capture precedente) : evite l'impression d'image figee sur l'ancienne photo.
document.getElementById('img-preview')?.classList.add('preview-chargement'); document.getElementById('img-preview')?.classList.add('preview-chargement');
wsEnvoyer({ type: 'preview_start' }); wsEnvoyer({ type: 'preview_start' });
resetPreviewTimeout(); resetPreviewTimeout();
} }
function demarrerEclairageLive() {
if (!previewActif) {
previewActif = true;
wsEnvoyer({ type: 'preview_start' });
resetPreviewTimeout();
}
}
function arreterPreview() { function arreterPreview() {
if (!previewActif) return; if (!previewActif) return;
previewActif = false; previewActif = false;
@@ -81,6 +87,10 @@ wsOnMessage('preview', (msg) => {
}; };
img.src = msg.image; img.src = msg.image;
} }
const adminPrev = document.getElementById('eclairage-preview');
if (adminPrev && document.getElementById('panneau-eclairage')?.classList.contains('actif')) {
adminPrev.src = msg.image;
}
afficherErreurPreview(false); afficherErreurPreview(false);
resetPreviewTimeout(); resetPreviewTimeout();
}); });
@@ -173,6 +183,36 @@ wsOnMessage('camera_ok', () => {
afficherErreurPreview(false); afficherErreurPreview(false);
}); });
wsOnMessage('eclairage', (msg) => {
if (captureEnCours) return;
// Admin panel eclairage
const scoreBox = document.getElementById('eclairage-score-box');
const actionEl = document.getElementById('eclairage-action');
const visagesEl = document.getElementById('eclairage-visages');
if (scoreBox) {
const score = msg.score >= 0 ? msg.score : '--';
scoreBox.textContent = score;
if (msg.action === 'allumer') {
scoreBox.style.background = '#dc2626'; scoreBox.style.color = '#fff';
actionEl.textContent = 'ALLUMER LA LUMIERE';
actionEl.style.color = '#f87171';
} else if (msg.action === 'attention') {
scoreBox.style.background = '#d97706'; scoreBox.style.color = '#fff';
actionEl.textContent = 'Eclairage faible';
actionEl.style.color = '#fbbf24';
} else if (msg.action === 'ok') {
scoreBox.style.background = '#16a34a'; scoreBox.style.color = '#fff';
actionEl.textContent = 'Eclairage OK';
actionEl.style.color = '#4ade80';
} else {
scoreBox.style.background = '#222'; scoreBox.style.color = '#888';
actionEl.textContent = 'Aucun visage';
actionEl.style.color = '#888';
}
visagesEl.textContent = msg.visages > 0 ? msg.visages + ' visage' + (msg.visages > 1 ? 's' : '') + ' detecte' + (msg.visages > 1 ? 's' : '') : '';
}
});
// --- Capture --- // --- Capture ---
async function lancerCapture() { async function lancerCapture() {

View File

@@ -1,30 +1,38 @@
/* Communication WebSocket avec le backend */ /* Communication WebSocket avec le backend — reconnexion résiliente */
let ws = null; let ws = null;
let wsReconnectTimer = null; let _wsReconnectDelay = 1000;
let _wsWasConnected = false;
const wsCallbacks = {}; const wsCallbacks = {};
function wsConnecter() { function wsConnecter() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${proto}//${location.host}/ws`); try { ws = new WebSocket(`${proto}//${location.host}/ws`); } catch(e) { _wsScheduleReconnect(); return; }
ws.onopen = () => { ws.onopen = () => {
console.log('WebSocket connecte'); console.log('WebSocket connecte');
if (wsReconnectTimer) { _wsReconnectDelay = 1000;
clearInterval(wsReconnectTimer); if (_wsWasConnected) {
wsReconnectTimer = null; _masquerPause();
// Recharger la config pour resynchroniser l'état
if (typeof chargerConfig === 'function') chargerConfig();
} }
_wsWasConnected = true;
}; };
ws.onmessage = (event) => { ws.onmessage = (event) => {
const msg = JSON.parse(event.data); try {
const cbs = wsCallbacks[msg.type]; const msg = JSON.parse(event.data);
if (cbs) cbs.forEach(cb => cb(msg)); const cbs = wsCallbacks[msg.type];
if (cbs) cbs.forEach(cb => cb(msg));
} catch(e) {}
}; };
ws.onclose = () => { ws.onclose = () => {
console.log('WebSocket deconnecte, rechargement dans 5s...'); console.log('WebSocket deconnecte');
setTimeout(() => { location.reload(); }, 5000); ws = null;
_afficherPause();
_wsScheduleReconnect();
}; };
ws.onerror = () => { ws.onerror = () => {
@@ -32,6 +40,26 @@ function wsConnecter() {
}; };
} }
function _wsScheduleReconnect() {
const delay = Math.min(_wsReconnectDelay, 10000);
_wsReconnectDelay = Math.min(_wsReconnectDelay * 1.5, 10000);
setTimeout(wsConnecter, delay);
}
function _afficherPause() {
const el = document.getElementById('ecran-pause');
if (el) el.style.display = 'flex';
}
function _masquerPause() {
const el = document.getElementById('ecran-pause');
if (el) { el.style.display = 'none'; }
// Relancer le preview si on était sur l'accueil
if (typeof lancerPreview === 'function' && document.getElementById('ecran-accueil')?.classList.contains('actif')) {
lancerPreview();
}
}
function wsEnvoyer(msg) { function wsEnvoyer(msg) {
if (ws && ws.readyState === WebSocket.OPEN) { if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(msg)); ws.send(JSON.stringify(msg));

View File

@@ -1,15 +1,25 @@
[Unit] [Unit]
Description=Photobooth Description=Photobooth backend
After=network.target graphical.target After=network.target graphical.target
Wants=graphical.target
[Service] [Service]
Type=simple Type=notify
User=jules User=jules
WorkingDirectory=/home/jules/Documents/Projet/photobooth-app WorkingDirectory=/home/jules/photobooth
ExecStart=/home/jules/Documents/Projet/photobooth-app/scripts/start.sh ExecStart=/home/jules/photobooth/scripts/start.sh
ExecStopPost=/bin/bash -c 'pkill -9 -f "python.*backend.main" 2>/dev/null; sleep 1'
Restart=always Restart=always
RestartSec=5 RestartSec=3
WatchdogSec=30
KillMode=control-group
KillSignal=SIGTERM
TimeoutStopSec=10
StartLimitIntervalSec=120
StartLimitBurst=10
Environment=DISPLAY=:0 Environment=DISPLAY=:0
Environment=XAUTHORITY=/home/jules/.Xauthority
AmbientCapabilities=CAP_NET_BIND_SERVICE
[Install] [Install]
WantedBy=graphical.target WantedBy=graphical.target

View File

@@ -0,0 +1,6 @@
[Unit]
Description=WiFi watchdog check
[Service]
Type=oneshot
ExecStart=/home/jules/photobooth/scripts/wifi-watchdog.sh

28
scripts/wifi-watchdog.sh Normal file
View File

@@ -0,0 +1,28 @@
#!/bin/bash
# Surveille la connexion WiFi et la rétablit si elle tombe.
# Conçu pour Surface Pro (wlan0) — lancé par cron ou systemd timer.
IFACE="wlan0"
GATEWAY=$(ip route show default dev "$IFACE" 2>/dev/null | awk '{print $3}' | head -1)
LOG_TAG="wifi-watchdog"
if [ -z "$GATEWAY" ]; then
logger -t "$LOG_TAG" "Pas de gateway sur $IFACE, tentative reconnexion..."
nmcli device connect "$IFACE" 2>&1 | logger -t "$LOG_TAG"
sleep 5
GATEWAY=$(ip route show default dev "$IFACE" 2>/dev/null | awk '{print $3}' | head -1)
fi
if [ -n "$GATEWAY" ]; then
if ! ping -c 2 -W 3 -I "$IFACE" "$GATEWAY" > /dev/null 2>&1; then
logger -t "$LOG_TAG" "Ping gateway $GATEWAY echoue, reconnexion WiFi..."
nmcli device disconnect "$IFACE" 2>&1 | logger -t "$LOG_TAG"
sleep 2
nmcli device connect "$IFACE" 2>&1 | logger -t "$LOG_TAG"
sleep 5
if ! ping -c 2 -W 3 -I "$IFACE" "$GATEWAY" > /dev/null 2>&1; then
logger -t "$LOG_TAG" "Toujours pas de connexion, restart NetworkManager..."
systemctl restart NetworkManager
fi
fi
fi

View File

@@ -0,0 +1,9 @@
[Unit]
Description=WiFi watchdog - check every minute
[Timer]
OnBootSec=30
OnUnitActiveSec=60
[Install]
WantedBy=timers.target