From 4e431507fc62b9143945cd97d8d74ae513f08309 Mon Sep 17 00:00:00 2001 From: Jules Date: Fri, 26 Jun 2026 19:16:39 +0200 Subject: [PATCH] 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 --- backend/destinations.py | 99 +++++++++++++++++------------------ backend/main.py | 33 ++++++++++++ frontend/index.html | 37 +++++++++++-- frontend/js/app.js | 25 +++++++++ frontend/js/camera.js | 65 +++++++++++++---------- frontend/js/websocket.js | 50 ++++++++++++++---- scripts/photobooth.service | 20 +++++-- scripts/wifi-watchdog.service | 6 +++ scripts/wifi-watchdog.sh | 28 ++++++++++ scripts/wifi-watchdog.timer | 9 ++++ 10 files changed, 273 insertions(+), 99 deletions(-) create mode 100644 scripts/wifi-watchdog.service create mode 100644 scripts/wifi-watchdog.sh create mode 100644 scripts/wifi-watchdog.timer diff --git a/backend/destinations.py b/backend/destinations.py index 4108d91..8eb519b 100644 --- a/backend/destinations.py +++ b/backend/destinations.py @@ -37,11 +37,12 @@ def distribuer_photo(chemin_photo: Path, imprimee: bool = False): if dest.get("ftp", False): envoyer_ftp(chemin_photo, dest, sous_dossier) - # Incrementer le compteur - compteur = config.get("compteur", {}) - if compteur.get("actif", False): - compteur["photos_prises"] = compteur.get("photos_prises", 0) + 1 - mettre_a_jour_config({"compteur": compteur}) + # Incrementer le compteur (photos imprimees seulement) + if imprimee: + compteur = config.get("compteur", {}) + if compteur.get("actif", False): + 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): @@ -98,48 +99,44 @@ def envoyer_ftp(chemin_photo: Path, config_dest: dict, sous_dossier: str | None 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): """Envoie une photo vers la galerie live booth.""" url = config_booth.get("url", "").rstrip("/") + url_tunnel = config_booth.get("url_tunnel", "").rstrip("/") 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)") return False - try: - import mimetypes - 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}/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}") + for tentative_url in [u for u in (url_tunnel, url) if u]: + try: + if _upload_booth(tentative_url, api_key, event_id, chemin_photo): + log.info(f"Photo envoyee au booth via {tentative_url} : {chemin_photo.name}") return True - else: - log.error(f"Booth erreur HTTP {resp.status}") - return False - except (URLError, OSError) as e: - log.error(f"Erreur envoi booth : {e}") - return False + log.error(f"Booth erreur HTTP via {tentative_url}") + except (URLError, OSError) as e: + log.warning(f"Echec envoi booth via {tentative_url} : {e}") + return False def recuperer_booth_password() -> dict: @@ -147,22 +144,20 @@ def recuperer_booth_password() -> dict: config = charger_config() booth = config.get("booth", {}) url = booth.get("url", "").rstrip("/") + url_tunnel = booth.get("url_tunnel", "").rstrip("/") 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: - return {"password": None} - - try: - req = Request(f"{url}/api/{event_id}/info") - req.add_header("X-Api-Key", api_key) - with urlopen(req, timeout=5) as resp: - import json - data = json.loads(resp.read()) - return data - except (URLError, OSError) as e: - log.error(f"Erreur recuperation booth info : {e}") - return {"password": None} + for tentative_url in [u for u in (url_tunnel, url) if u]: + try: + req = Request(f"{tentative_url}/admin/gallery/{event_id}/info") + req.add_header("X-Api-Key", api_key) + with urlopen(req, timeout=5) as resp: + import json + return json.loads(resp.read()) + except (URLError, OSError) as e: + log.warning(f"Echec recuperation booth info via {tentative_url} : {e}") + return {"password": None} def detecter_usb() -> list[str]: diff --git a/backend/main.py b/backend/main.py index 3ed6fcb..96854e3 100644 --- a/backend/main.py +++ b/backend/main.py @@ -2,6 +2,7 @@ import asyncio import base64 import json import logging +import os import threading import time from pathlib import Path @@ -273,6 +274,24 @@ def _appliquer_config_camera(): 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 async def lifespan(app: FastAPI): """Demarrage et arret de l'application.""" @@ -287,10 +306,12 @@ async def lifespan(app: FastAPI): task_dslr = asyncio.create_task(surveiller_dslr()) task_push = asyncio.create_task(_pusher_preview()) task_spool = asyncio.create_task(tache_spool_demarrage()) + task_watchdog = asyncio.create_task(_watchdog_systemd()) yield task_dslr.cancel() task_push.cancel() task_spool.cancel() + task_watchdog.cancel() log.info("Arret du photobooth") camera.deconnecter() @@ -311,6 +332,18 @@ async def page_admin(): 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("/") async def page_principale(request: Request): from fastapi.responses import RedirectResponse diff --git a/frontend/index.html b/frontend/index.html index f3e60ae..ee95f8f 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -373,6 +373,7 @@ + @@ -691,6 +692,19 @@ +
+
+
+ Preview +
+
+
--
+
--
+
--
+
+
+
+

Informations systeme

@@ -772,9 +786,26 @@
- - - + + + + + + + diff --git a/frontend/js/app.js b/frontend/js/app.js index 1eb4cfc..965551d 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -118,9 +118,34 @@ function allerA(ecran) { chargerGalerie(); } else if (ecran === 'admin') { 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() { photosSession = []; photoFinale = null; diff --git a/frontend/js/camera.js b/frontend/js/camera.js index 1a4b3a2..e317987 100644 --- a/frontend/js/camera.js +++ b/frontend/js/camera.js @@ -48,13 +48,19 @@ function lancerPreview() { if (previewActif) return; previewActif = true; 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'); wsEnvoyer({ type: 'preview_start' }); resetPreviewTimeout(); } +function demarrerEclairageLive() { + if (!previewActif) { + previewActif = true; + wsEnvoyer({ type: 'preview_start' }); + resetPreviewTimeout(); + } +} + function arreterPreview() { if (!previewActif) return; previewActif = false; @@ -81,6 +87,10 @@ wsOnMessage('preview', (msg) => { }; 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); resetPreviewTimeout(); }); @@ -175,33 +185,32 @@ wsOnMessage('camera_ok', () => { wsOnMessage('eclairage', (msg) => { if (captureEnCours) return; - let el = document.getElementById('indicateur-eclairage'); - if (!el) { - el = document.createElement('div'); - el.id = 'indicateur-eclairage'; - el.style.cssText = 'position:fixed;top:12px;left:12px;padding:8px 16px;border-radius:12px;font-size:.9rem;font-weight:600;z-index:90;transition:all .3s;pointer-events:none;display:flex;align-items:center;gap:8px;'; - document.body.appendChild(el); + // 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' : '') : ''; } - const score = msg.score >= 0 ? msg.score : '?'; - const faces = msg.visages || 0; - if (msg.action === 'allumer') { - el.style.background = 'rgba(239,68,68,.9)'; - el.style.color = '#fff'; - el.innerHTML = `☀️ ALLUMER ${score}/100 · ${faces} visage${faces > 1 ? 's' : ''}`; - } else if (msg.action === 'attention') { - el.style.background = 'rgba(245,158,11,.85)'; - el.style.color = '#fff'; - el.innerHTML = `⚠️ Faible ${score}/100 · ${faces} visage${faces > 1 ? 's' : ''}`; - } else if (msg.action === 'ok') { - el.style.background = 'rgba(34,197,94,.7)'; - el.style.color = '#fff'; - el.innerHTML = `✓ ${score}/100 · ${faces} visage${faces > 1 ? 's' : ''}`; - } else if (msg.action === 'no_face') { - el.style.background = 'rgba(100,100,120,.6)'; - el.style.color = '#ccc'; - el.innerHTML = `Aucun visage`; - } - el.style.display = ''; }); // --- Capture --- diff --git a/frontend/js/websocket.js b/frontend/js/websocket.js index 32f2aee..ca7ee6d 100644 --- a/frontend/js/websocket.js +++ b/frontend/js/websocket.js @@ -1,30 +1,38 @@ -/* Communication WebSocket avec le backend */ +/* Communication WebSocket avec le backend — reconnexion résiliente */ let ws = null; -let wsReconnectTimer = null; +let _wsReconnectDelay = 1000; +let _wsWasConnected = false; const wsCallbacks = {}; function wsConnecter() { 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 = () => { console.log('WebSocket connecte'); - if (wsReconnectTimer) { - clearInterval(wsReconnectTimer); - wsReconnectTimer = null; + _wsReconnectDelay = 1000; + if (_wsWasConnected) { + _masquerPause(); + // Recharger la config pour resynchroniser l'état + if (typeof chargerConfig === 'function') chargerConfig(); } + _wsWasConnected = true; }; ws.onmessage = (event) => { - const msg = JSON.parse(event.data); - const cbs = wsCallbacks[msg.type]; - if (cbs) cbs.forEach(cb => cb(msg)); + try { + const msg = JSON.parse(event.data); + const cbs = wsCallbacks[msg.type]; + if (cbs) cbs.forEach(cb => cb(msg)); + } catch(e) {} }; ws.onclose = () => { - console.log('WebSocket deconnecte, rechargement dans 5s...'); - setTimeout(() => { location.reload(); }, 5000); + console.log('WebSocket deconnecte'); + ws = null; + _afficherPause(); + _wsScheduleReconnect(); }; 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) { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(msg)); diff --git a/scripts/photobooth.service b/scripts/photobooth.service index 1b6d278..69a43e0 100644 --- a/scripts/photobooth.service +++ b/scripts/photobooth.service @@ -1,15 +1,25 @@ [Unit] -Description=Photobooth +Description=Photobooth backend After=network.target graphical.target +Wants=graphical.target [Service] -Type=simple +Type=notify User=jules -WorkingDirectory=/home/jules/Documents/Projet/photobooth-app -ExecStart=/home/jules/Documents/Projet/photobooth-app/scripts/start.sh +WorkingDirectory=/home/jules/photobooth +ExecStart=/home/jules/photobooth/scripts/start.sh +ExecStopPost=/bin/bash -c 'pkill -9 -f "python.*backend.main" 2>/dev/null; sleep 1' Restart=always -RestartSec=5 +RestartSec=3 +WatchdogSec=30 +KillMode=control-group +KillSignal=SIGTERM +TimeoutStopSec=10 +StartLimitIntervalSec=120 +StartLimitBurst=10 Environment=DISPLAY=:0 +Environment=XAUTHORITY=/home/jules/.Xauthority +AmbientCapabilities=CAP_NET_BIND_SERVICE [Install] WantedBy=graphical.target diff --git a/scripts/wifi-watchdog.service b/scripts/wifi-watchdog.service new file mode 100644 index 0000000..6fbc0a7 --- /dev/null +++ b/scripts/wifi-watchdog.service @@ -0,0 +1,6 @@ +[Unit] +Description=WiFi watchdog check + +[Service] +Type=oneshot +ExecStart=/home/jules/photobooth/scripts/wifi-watchdog.sh diff --git a/scripts/wifi-watchdog.sh b/scripts/wifi-watchdog.sh new file mode 100644 index 0000000..596e1d7 --- /dev/null +++ b/scripts/wifi-watchdog.sh @@ -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 diff --git a/scripts/wifi-watchdog.timer b/scripts/wifi-watchdog.timer new file mode 100644 index 0000000..1d05084 --- /dev/null +++ b/scripts/wifi-watchdog.timer @@ -0,0 +1,9 @@ +[Unit] +Description=WiFi watchdog - check every minute + +[Timer] +OnBootSec=30 +OnUnitActiveSec=60 + +[Install] +WantedBy=timers.target