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]()
+
+
+
+
+
-
-
-
+
+
+
☕
+
Petite pause...
+
Le photobooth revient dans un instant.
Pas de panique, on s'occupe de tout !
+
+
+
+
+
+
Reconnexion en cours...
+
+
+
+
+
+
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