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>
This commit is contained in:
2026-06-26 19:16:39 +02:00
parent 48f54ab70a
commit 4e431507fc
10 changed files with 273 additions and 99 deletions

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]:

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
@@ -273,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."""
@@ -287,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()
@@ -311,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

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();
}); });
@@ -175,33 +185,32 @@ wsOnMessage('camera_ok', () => {
wsOnMessage('eclairage', (msg) => { wsOnMessage('eclairage', (msg) => {
if (captureEnCours) return; if (captureEnCours) return;
let el = document.getElementById('indicateur-eclairage'); // Admin panel eclairage
if (!el) { const scoreBox = document.getElementById('eclairage-score-box');
el = document.createElement('div'); const actionEl = document.getElementById('eclairage-action');
el.id = 'indicateur-eclairage'; const visagesEl = document.getElementById('eclairage-visages');
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;'; if (scoreBox) {
document.body.appendChild(el); 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 <span style="opacity:.7;font-size:.8rem">${score}/100 · ${faces} visage${faces > 1 ? 's' : ''}</span>`;
} else if (msg.action === 'attention') {
el.style.background = 'rgba(245,158,11,.85)';
el.style.color = '#fff';
el.innerHTML = `⚠️ Faible <span style="opacity:.7;font-size:.8rem">${score}/100 · ${faces} visage${faces > 1 ? 's' : ''}</span>`;
} else if (msg.action === 'ok') {
el.style.background = 'rgba(34,197,94,.7)';
el.style.color = '#fff';
el.innerHTML = `✓ <span style="opacity:.7;font-size:.8rem">${score}/100 · ${faces} visage${faces > 1 ? 's' : ''}</span>`;
} else if (msg.action === 'no_face') {
el.style.background = 'rgba(100,100,120,.6)';
el.style.color = '#ccc';
el.innerHTML = `<span style="font-size:.8rem">Aucun visage</span>`;
}
el.style.display = '';
}); });
// --- Capture --- // --- Capture ---

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