Files
photobooth/backend/main.py
Jules f1299fd6a7 Bulletproof: messages novices, kiosk port auto, reset compteur, sync booth event_id
- Messages erreur camera/impression: texte novice ("Oups!", "Reessayez") au lieu de jargon technique
- kiosk-session.sh: lit le port depuis config.json (plus de http://localhost en dur), healthcheck /api/config, supprime git pull auto au boot
- Activer evenement: reset automatique du compteur photos
- Activer evenement: sync booth.event_id quand galerie live active
- Port par defaut backend: 80 → 8080

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-13 09:17:26 +02:00

2595 lines
95 KiB
Python

import asyncio
import base64
import json
import logging
import os
import threading
import time
from pathlib import Path
from contextlib import asynccontextmanager
import cv2
from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect, UploadFile, File
from fastapi.responses import FileResponse, JSONResponse, HTMLResponse
from fastapi.staticfiles import StaticFiles
from backend.config import (
RACINE, DOSSIER_PHOTOS, DOSSIER_EXPORTS, DOSSIER_OVERLAYS, DOSSIER_FONDS, DOSSIER_ANIMATIONS,
DOSSIER_CADRES, FORMATS_CADRES,
charger_config, sauvegarder_config, mettre_a_jour_config,
)
from backend.camera import camera, GPHOTO2_DISPONIBLE
try:
import gphoto2 as gp
except ImportError:
gp = None
from backend.gallery import lister_photos, compter_photos, supprimer_photo, vider_galerie
from backend.effects import appliquer_filtre, appliquer_overlay, chroma_key, lister_overlays, lister_fonds, lister_cadres, FILTRES
from backend.collage import creer_strip, creer_collage
from backend.destinations import distribuer_photo, detecter_usb, compteur_restant, reset_compteur, recuperer_booth_password, consommables_etat, reset_consommables
from backend.printer import lister_imprimantes, imprimer
from backend.mailer import envoyer_photo, charger_emails_historique, effacer_emails_historique, ajouter_au_spool, taille_spool, tache_spool_demarrage
from backend.qrcode_gen import generer_qr, qr_galerie
from backend import relais
from backend.evenements import (
lister_evenements, creer_evenement, obtenir_evenement,
modifier_evenement, supprimer_evenement, activer_evenement,
terminer_evenement, evenement_est_termine,
lister_cadres_event, set_cadre_event, cadre_actif_pour_impression,
)
from backend.evenements import DOSSIER_EVENEMENTS
from backend.eclairage import analyser_frame as analyser_eclairage, dernier_resultat as dernier_eclairage
from backend.wifi import wifi_status, wifi_scan, wifi_connect, wifi_saved_list, wifi_forget, wifi_get_password
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(message)s")
log = logging.getLogger("photobooth")
# Clients WebSocket connectes
clients_ws: list[WebSocket] = []
capture_en_cours = False
_dslr_erreurs = 0
# --- Preview push : thread de fond qui capture en continu ---
_derniere_frame_preview: bytes | None = None # derniere frame JPEG capturee
_preview_actif = False # True quand au moins un client veut le preview
_preview_lock = threading.Lock()
_loop_principal: asyncio.AbstractEventLoop | None = None # loop asyncio principal
_preview_none_count = 0
_PREVIEW_GRACE_FRAMES = 40 # 40 x 50ms = 2s avant de declarer une erreur (Canon LiveView peut être lent à démarrer)
_derniere_keepalive = 0.0
# --- Suivi activité Canon pour diagnostic et gestion veille ---
_canon_stats = {
"derniere_capture": 0.0,
"derniere_preview": 0.0,
"dernier_keepalive_ok": 0.0,
"dernier_keepalive_fail": 0.0,
"keepalive_ok": 0,
"keepalive_fail": 0,
"preview_ok": 0,
"preview_fail": 0,
"captures": 0,
"connexions": 0,
"deconnexions": 0,
"power_cycles": 0,
"demarrage": time.time(),
}
_CANON_VEILLE_MINUTES = 30 # couper le Canon après X min sans activité utilisateur
_canon_en_veille = False
_reveil_en_cours = False
def _thread_preview():
"""Thread de fond : capture les frames DSLR.
Actif seulement quand _preview_actif=True (ecran capture).
Sinon keepalive leger toutes les 30s pour empecher le Canon de s'eteindre.
Gère la mise en veille Canon après inactivité prolongée."""
global _derniere_frame_preview, _derniere_keepalive, _canon_en_veille
_dernier_log_keepalive = 0.0
while True:
try:
if capture_en_cours or camera.mode != "gphoto2" or not camera.connectee:
time.sleep(0.2)
continue
# Vérifier inactivité prolongée → couper Canon via relais
now = time.time()
derniere_activite = max(
_canon_stats["derniere_capture"],
_canon_stats["derniere_preview"],
)
if derniere_activite > 0 and not _preview_actif and not _canon_en_veille and not _reveil_en_cours:
inactif_min = (now - derniere_activite) / 60
if inactif_min >= _CANON_VEILLE_MINUTES and relais.est_connecte():
log.info(f"Canon inactif depuis {inactif_min:.0f} min — mise en veille relais")
_canon_en_veille = True
camera.deconnecter()
relais.activer("canon")
relais.activer("canon_usb")
_canon_stats["power_cycles"] += 1
continue
if _canon_en_veille:
time.sleep(2)
continue
if not _preview_actif:
if now - _derniere_keepalive > 30:
ok = camera.keepalive()
_derniere_keepalive = now
if ok:
_canon_stats["keepalive_ok"] += 1
_canon_stats["keepalive_fail_consecutifs"] = 0
_canon_stats["dernier_keepalive_ok"] = now
if now - _dernier_log_keepalive > 300:
log.info(f"Canon keepalive OK (total: {_canon_stats['keepalive_ok']}, fail: {_canon_stats['keepalive_fail']})")
_dernier_log_keepalive = now
else:
_canon_stats["keepalive_fail"] += 1
_canon_stats["keepalive_fail_consecutifs"] = _canon_stats.get("keepalive_fail_consecutifs", 0) + 1
_canon_stats["dernier_keepalive_fail"] = now
consec = _canon_stats["keepalive_fail_consecutifs"]
log.warning(f"Canon keepalive FAIL #{_canon_stats['keepalive_fail']} (consec: {consec})")
if consec >= 5:
log.error(f"Canon PTP freeze detecte — {consec} keepalive FAIL consecutifs, deconnexion forcee")
_canon_stats["keepalive_fail_consecutifs"] = 0
camera.deconnecter()
time.sleep(1)
continue
try:
donnees = camera.preview()
with _preview_lock:
_derniere_frame_preview = donnees
_derniere_keepalive = now
if donnees:
_canon_stats["preview_ok"] += 1
_canon_stats["derniere_preview"] = now
else:
_canon_stats["preview_fail"] += 1
except Exception:
with _preview_lock:
_derniere_frame_preview = None
_canon_stats["preview_fail"] += 1
if now - _derniere_keepalive > 10:
camera.keepalive()
_derniere_keepalive = now
time.sleep(0.05) # ~20 fps max
except Exception as e:
log.error(f"_thread_preview crash: {e}")
time.sleep(1)
async def _pusher_preview():
"""Tache asyncio : pousse la derniere frame a tous les clients WS abonnes."""
global _preview_actif, _preview_none_count
while True:
await asyncio.sleep(0.05) # 20 fps max envoye aux clients
if not _preview_actif:
_preview_none_count = 0
continue
if not clients_ws:
_preview_actif = False
continue
with _preview_lock:
donnees = _derniere_frame_preview
if donnees is None:
_preview_none_count += 1
# Grace period : attendre plusieurs frames avant de signaler l'erreur
# Ne pas signaler pendant une capture (race condition HTTP/WS)
if camera.mode == "gphoto2" and _preview_none_count > _PREVIEW_GRACE_FRAMES and not capture_en_cours:
log.warning(f"camera_erreur envoyé : preview None depuis {_preview_none_count * 50}ms")
await diffuser_ws({"type": "camera_erreur", "message": "DSLR ne repond pas au preview"})
_preview_actif = False
_preview_none_count = 0
continue
_preview_none_count = 0
loop = asyncio.get_event_loop()
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}"})
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})
_gerer_eclairage_auto(eclairage)
_proj_etat = {"proj1": False, "proj2": False}
_veille_eclairage = False
_nb_proj_capture = 2
_preview_eclairage_init = False
def _gerer_eclairage_auto(eclairage: dict):
"""Preview : 1 projecteur ON pour estimer le besoin. Le score détermine combien pour la capture."""
global _nb_proj_capture, _preview_eclairage_init
if _veille_eclairage or capture_en_cours:
return
config = charger_config()
rcfg = config.get("relais", {})
if not rcfg.get("eclairage_auto") or not relais.est_connecte():
return
if not _preview_eclairage_init:
relais.activer("projecteur_gauche")
_proj_etat["proj1"] = True
_proj_etat["proj2"] = False
_preview_eclairage_init = True
score = eclairage.get("score", -1)
if score < 0:
return
seuil_2 = rcfg.get("seuil_proj2", 30)
if score < seuil_2:
_nb_proj_capture = 2
else:
_nb_proj_capture = 1
def _usb_reset_canon():
"""Reset USB ioctl du Canon EOS (vendor 04a9) pour débloquer un port stall."""
import fcntl, glob
USBDEVFS_RESET = 0x5514
try:
for f in glob.glob("/sys/bus/usb/devices/*/idVendor"):
if open(f).read().strip() == "04a9":
d = __import__("os.path", fromlist=["dirname"]).dirname(f)
bus = open(d + "/busnum").read().strip().zfill(3)
dev = open(d + "/devnum").read().strip().zfill(3)
path = f"/dev/bus/usb/{bus}/{dev}"
with open(path, "wb") as fh:
fcntl.ioctl(fh, USBDEVFS_RESET, 0)
log.info(f"USB reset Canon: {path}")
return
except Exception as e:
log.debug(f"_usb_reset_canon: {e}")
def _ptp_reset_canon():
"""Envoie un PTP USB Device Reset (class request 0x66) + Clear Halt endpoints.
Cette commande atteint le firmware PTP même quand le Canon est figé en I/O error,
car elle utilise le control transfer USB (endpoint 0) et non les bulk endpoints."""
try:
import usb.core, usb.util, struct
dev = usb.core.find(idVendor=0x04a9)
if not dev:
log.debug("_ptp_reset_canon: Canon non trouvé en USB")
return False
for cfg in dev:
for intf in cfg:
try:
if dev.is_kernel_driver_active(intf.bInterfaceNumber):
dev.detach_kernel_driver(intf.bInterfaceNumber)
except Exception:
pass
dev.set_configuration()
cfg = dev.get_active_configuration()
intf = cfg[(0, 0)]
ep_out = usb.util.find_descriptor(intf, custom_match=lambda e: usb.util.endpoint_direction(e.bEndpointAddress) == usb.util.ENDPOINT_OUT)
ep_in = usb.util.find_descriptor(intf, custom_match=lambda e: usb.util.endpoint_direction(e.bEndpointAddress) == usb.util.ENDPOINT_IN and e.bmAttributes == 2)
dev.ctrl_transfer(0x21, 0x66, 0, 0, None, timeout=5000)
log.info("PTP USB Device Reset (0x66) envoyé")
time.sleep(1)
if ep_out:
try:
dev.clear_halt(ep_out)
except Exception:
pass
if ep_in:
try:
dev.clear_halt(ep_in)
except Exception:
pass
try:
status = dev.ctrl_transfer(0xA1, 0x67, 0, 0, 12, timeout=5000)
code = struct.unpack_from("<H", status, 2)[0]
log.info(f"PTP status après reset: 0x{code:04x}")
except Exception:
pass
usb.util.dispose_resources(dev)
return True
except ImportError:
log.debug("_ptp_reset_canon: pyusb non installé")
return False
except Exception as e:
log.warning(f"_ptp_reset_canon: {e}")
return False
def _usb_rebind_canon():
"""Unbind/rebind USB Canon — simule un debranchement/rebranchement physique.
Necessite sudoers NOPASSWD pour tee sur /sys/bus/usb/drivers/usb/{un,}bind."""
import glob, subprocess, time
try:
for f in glob.glob("/sys/bus/usb/devices/*/idVendor"):
if open(f).read().strip() == "04a9":
dev_id = __import__("os.path", fromlist=["basename"]).basename(
__import__("os.path", fromlist=["dirname"]).dirname(f)
)
log.info(f"USB rebind Canon: unbind {dev_id}")
subprocess.run(
["sudo", "tee", "/sys/bus/usb/drivers/usb/unbind"],
input=dev_id.encode(), stdout=subprocess.DEVNULL, timeout=5,
)
time.sleep(2)
subprocess.run(
["sudo", "tee", "/sys/bus/usb/drivers/usb/bind"],
input=dev_id.encode(), stdout=subprocess.DEVNULL, timeout=5,
)
log.info(f"USB rebind Canon: bind {dev_id}")
time.sleep(3)
return True
except Exception as e:
log.warning(f"_usb_rebind_canon: {e}")
return False
def _usb_power_cycle_canon():
"""Coupe et retablit l'alimentation 5V du port USB Canon via uhubctl.
Necessite un hub USB compatible power switching (Realtek, etc.)."""
import glob, subprocess, time
try:
for f in glob.glob("/sys/bus/usb/devices/*/idVendor"):
if open(f).read().strip() == "04a9":
dev_id = __import__("os.path", fromlist=["basename"]).basename(
__import__("os.path", fromlist=["dirname"]).dirname(f)
)
if "." in dev_id:
hub = dev_id.rsplit(".", 1)[0]
port = dev_id.rsplit(".", 1)[1]
else:
hub = dev_id.rsplit("-", 1)[0]
port = dev_id.rsplit("-", 1)[1]
log.info(f"uhubctl power OFF: hub={hub} port={port}")
subprocess.run(
["sudo", "uhubctl", "-l", hub, "-p", port, "-a", "off"],
timeout=10, capture_output=True,
)
time.sleep(8)
subprocess.run(
["sudo", "uhubctl", "-l", hub, "-p", port, "-a", "on"],
timeout=10, capture_output=True,
)
log.info(f"uhubctl power ON: hub={hub} port={port}")
time.sleep(8)
return True
except Exception as e:
log.warning(f"_usb_power_cycle_canon: {e}")
return False
async def surveiller_dslr():
"""Surveille le DSLR en continu : detecte les deconnexions et reconnecte automatiquement.
Escalade : ioctl reset -> unbind/rebind -> uhubctl power cycle -> backoff long."""
global _dslr_erreurs
_echecs_connexion = 0
while True:
if _echecs_connexion >= 10:
await asyncio.sleep(60)
else:
await asyncio.sleep(5)
if capture_en_cours or _reveil_en_cours:
continue
try:
if not GPHOTO2_DISPONIBLE:
continue
if camera.mode == "gphoto2" and camera.connectee:
if not _preview_actif and not capture_en_cours:
try:
with camera._gp_lock:
camera.camera.get_config()
except Exception:
_dslr_erreurs += 1
if _dslr_erreurs >= 3:
log.warning(f"Canon get_config echoue {_dslr_erreurs} fois — reconnexion forcee")
_dslr_erreurs = 0
_echecs_connexion += 1
await _reconnecter_dslr_avec_reset(_echecs_connexion)
continue
if camera.preview_dslr_ok:
_dslr_erreurs = 0
_echecs_connexion = 0
else:
_dslr_erreurs += 1
if _dslr_erreurs >= 3:
log.warning(f"camera_erreur : DSLR preview KO depuis {_dslr_erreurs * 5}s, reconnexion forcee...")
_dslr_erreurs = 0
_echecs_connexion += 1
await _reconnecter_dslr_avec_reset(_echecs_connexion)
else:
try:
dslrs = gp.Camera.autodetect()
except Exception:
dslrs = []
if len(dslrs) > 0:
_echecs_connexion += 1
log.info(f"DSLR detecte : {dslrs[0][0]}, connexion automatique... (tentative {_echecs_connexion})")
if _echecs_connexion <= 2:
await asyncio.sleep(5)
else:
_usb_reset_canon()
await asyncio.sleep(3)
await _reconnecter_dslr_avec_reset(_echecs_connexion)
else:
if _echecs_connexion > 0:
_echecs_connexion += 1
if relais.est_connecte() and _echecs_connexion % 10 == 0:
log.warning(f"Canon absent USB (tentative {_echecs_connexion}) — power cycle relais...")
await asyncio.get_event_loop().run_in_executor(None, relais.power_cycle_canon, 10.0)
await asyncio.sleep(25)
else:
log.info(f"DSLR non detecte en USB (tentative {_echecs_connexion}), rebind...")
await asyncio.get_event_loop().run_in_executor(None, _usb_rebind_canon)
elif _canon_sur_usb():
log.info("Canon detecte sur USB sans echec precedent — tentative connexion...")
_echecs_connexion = 1
camera.connecter(source="gphoto2")
if camera.connectee:
log.info("Canon reconnecte automatiquement")
_canon_stats["connexions"] += 1
_canon_stats["derniere_preview"] = time.time()
_canon_stats["derniere_capture"] = time.time()
_appliquer_config_camera()
_echecs_connexion = 0
await diffuser_ws({"type": "camera_ok"})
except Exception as e:
log.debug(f"surveiller_dslr: {e}")
async def _reconnecter_dslr_avec_reset(echecs: int):
"""Reconnexion DSLR avec escalade progressive :
1 : simple reconnexion
2 : PTP class reset (0x66) + ioctl reset
3-5 : unbind/rebind USB
6-9 : PTP reset + unbind/rebind (plus agressif)
10+ : relay hard reset (si câblé) + backoff long
20+ : dormant (5 min entre tentatives)
"""
camera.deconnecter()
_canon_stats["deconnexions"] += 1
import gc; gc.collect()
await diffuser_ws({"type": "camera_erreur", "message": "Appareil photo deconnecte"})
if echecs >= 20:
log.info(f"Mode dormant (echec #{echecs}) — attente 5 min avant prochaine tentative")
await diffuser_ws({"type": "camera_erreur", "message": "Appareil photo injoignable — redémarrez-le physiquement"})
await asyncio.sleep(300)
elif echecs >= 10 and echecs % 5 == 0 and relais.est_connecte():
log.warning(f"Hard reset Canon (echec #{echecs}) — trappe + coupure alim 30s")
await diffuser_ws({"type": "camera_erreur", "message": "Reset complet appareil photo..."})
await asyncio.get_event_loop().run_in_executor(None, relais.hard_reset_canon, 30.0)
await asyncio.sleep(20)
elif echecs >= 6:
log.info(f"PTP reset + rebind Canon (echec #{echecs})...")
await asyncio.get_event_loop().run_in_executor(None, _ptp_reset_canon)
await asyncio.sleep(2)
await asyncio.get_event_loop().run_in_executor(None, _usb_rebind_canon)
elif echecs >= 3:
log.info(f"USB rebind Canon (echec #{echecs})...")
ok = await asyncio.get_event_loop().run_in_executor(None, _usb_rebind_canon)
if not ok:
_usb_reset_canon()
await asyncio.sleep(4)
elif echecs >= 2:
log.info(f"PTP reset + USB reset Canon (echec #{echecs})...")
await asyncio.get_event_loop().run_in_executor(None, _ptp_reset_canon)
await asyncio.sleep(1)
_usb_reset_canon()
await asyncio.sleep(3)
else:
await asyncio.sleep(2)
camera.connecter(source="gphoto2")
if camera.connectee:
log.info("DSLR reconnecte avec succes")
_canon_stats["connexions"] += 1
_canon_stats["derniere_preview"] = time.time()
_canon_stats["derniere_capture"] = time.time()
_appliquer_config_camera()
await diffuser_ws({"type": "camera_ok"})
else:
log.warning(f"Echec reconnexion DSLR (tentative {echecs})")
def _appliquer_config_camera():
"""Applique les paramètres caméra persistants (flash, etc.) après connexion."""
cfg = charger_config()
flash_integre = cfg.get("camera", {}).get("flash_integre", True)
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
def _diagnostiquer_erreur_imprimante(nom: str) -> dict:
"""Parse les logs CUPS pour identifier le type d'erreur imprimante."""
import subprocess
try:
r = subprocess.run(
["sudo", "tail", "-20", "/var/log/cups/error_log"],
capture_output=True, text=True, timeout=5
)
logs = r.stdout.lower()
if "ribbon" in logs and ("end" in logs or "count" in logs):
return {
"situation": "changement_rouleau",
"titre": "Ruban termine",
"message": "Le ruban d'impression est termine. Il faut le remplacer.",
"etapes": [
"Ouvrez le capot de l'imprimante",
"Retirez la cassette ruban usagee",
"Inserez une cassette ruban neuve (sens indique par la fleche)",
"Refermez le capot jusqu'au clic",
],
"code": "05/02/02",
}
if "paper" in logs and ("end" in logs or "empty" in logs or "out" in logs):
return {
"situation": "changement_rouleau",
"titre": "Papier termine",
"message": "Le papier est termine. Il faut recharger le bac.",
"etapes": [
"Ouvrez le capot de l'imprimante",
"Retirez le bac papier vide",
"Chargez un nouveau rouleau de papier (face brillante vers le haut)",
"Refermez le capot jusqu'au clic",
],
"code": "paper_end",
}
if "jam" in logs or "bourrage" in logs:
return {
"situation": "bourrage",
"titre": "Bourrage papier",
"message": "Un bourrage papier a ete detecte.",
"etapes": [
"Ouvrez le capot de l'imprimante",
"Retirez delicatement le papier coince (ne pas tirer fort)",
"Verifiez qu'il ne reste pas de morceaux",
"Refermez le capot jusqu'au clic",
],
"code": "jam",
}
if "media" in logs and "mismatch" in logs:
return {
"situation": "depannage_imprimante",
"titre": "Mauvais format",
"message": "Le format papier ne correspond pas a la cassette inseree.",
"etapes": [
"Ouvrez le capot de l'imprimante",
"Verifiez que la cassette correspond au format (10x15 ou 15x20)",
"Retirez et reinserez la cassette correctement",
"Refermez le capot",
],
"code": "media_mismatch",
}
if "offline" in logs or "not connected" in logs:
return {
"situation": "depannage_imprimante",
"titre": "Imprimante deconnectee",
"message": "L'imprimante n'est pas detectee.",
"etapes": [
"Verifiez que l'imprimante est allumee (voyant vert)",
"Verifiez le cable USB entre l'imprimante et la borne",
"Eteignez et rallumez l'imprimante",
"Si le probleme persiste, redemarrez la borne",
],
"code": "offline",
}
except Exception:
pass
return {
"situation": "depannage_imprimante",
"titre": "Probleme imprimante",
"message": f"L'imprimante {nom} ne fonctionne pas correctement.",
"etapes": [
"Verifiez que l'imprimante est allumee",
"Verifiez le cable USB",
"Ouvrez le capot et verifiez papier et ruban",
"Eteignez et rallumez l'imprimante",
],
"code": "unknown",
}
async def _surveiller_imprimante():
"""Surveille l'imprimante CUPS en continu : réactive si disabled, notifie les clients."""
import subprocess
_derniere_alerte = 0
_dernier_code = None
while True:
await asyncio.sleep(30)
try:
config = charger_config()
if not config.get("fonctionnalites", {}).get("impression", False):
continue
nom = config.get("impression", {}).get("imprimante") or "Mitsubishi"
r = subprocess.run(["lpstat", "-p", nom], capture_output=True, text=True, timeout=5)
out = r.stdout.lower()
if "disabled" in out or "stopped" in out:
log.warning(f"Imprimante {nom} désactivée — réactivation automatique")
subprocess.run(["sudo", "cupsenable", nom], capture_output=True, timeout=5)
await asyncio.sleep(2)
r2 = subprocess.run(["lpstat", "-p", nom], capture_output=True, text=True, timeout=5)
if "disabled" not in r2.stdout.lower() and "stopped" not in r2.stdout.lower():
log.info(f"Imprimante {nom} réactivée avec succès")
_dernier_code = None
await diffuser_ws({"type": "imprimante_ok"})
else:
now = time.time()
diag = _diagnostiquer_erreur_imprimante(nom)
if now - _derniere_alerte > 300 or diag["code"] != _dernier_code:
_derniere_alerte = now
_dernier_code = diag["code"]
await diffuser_ws({"type": "imprimante_erreur", **diag})
else:
if _dernier_code is not None:
_dernier_code = None
await diffuser_ws({"type": "imprimante_ok"})
except Exception as e:
log.debug(f"_surveiller_imprimante: {e}")
def _reveiller_canon():
"""Réveil Canon depuis veille relais : restaure alimentation, attend boot, reconnecte."""
global _reveil_en_cours
_reveil_en_cours = True
try:
log.info("Réveil Canon — restauration alimentation relais")
relais.desactiver("canon")
time.sleep(15)
relais.desactiver("canon_usb")
time.sleep(25)
if _canon_sur_usb():
log.info("Canon visible USB après réveil — connexion...")
camera.connecter(source="gphoto2")
if camera.connectee:
_appliquer_config_camera()
_canon_stats["derniere_preview"] = time.time()
_canon_stats["derniere_capture"] = time.time()
log.info("Canon reconnecté après réveil")
_canon_stats["connexions"] += 1
else:
log.warning("Canon visible USB mais connexion gphoto2 échouée")
else:
log.warning("Canon absent USB après réveil relais — vérifier branchement physique")
finally:
_reveil_en_cours = False
def _canon_sur_usb() -> bool:
"""Vérifie si un Canon (vendor 04a9) est visible sur le bus USB."""
import glob
for f in glob.glob("/sys/bus/usb/devices/*/idVendor"):
try:
if open(f).read().strip() == "04a9":
return True
except Exception:
pass
return False
async def _sequence_boot_canon():
"""Séquençage propre du Canon au démarrage via relais.
Coupe l'alimentation, attend la vidange, restaure alim puis USB.
Donne au Canon le temps de booter avant toute tentative gphoto2."""
if not relais.est_connecte():
log.info("Pas de relais — skip sequence boot Canon")
return
if _canon_sur_usb():
log.info("Canon déjà visible USB — skip power cycle")
return
log.info("Canon absent USB — séquence boot relais (power cycle 10s + attente 25s)")
loop = asyncio.get_event_loop()
def _do_power_cycle():
relais.activer("canon")
relais.activer("canon_usb")
time.sleep(10)
relais.desactiver("canon")
time.sleep(15)
relais.desactiver("canon_usb")
await loop.run_in_executor(None, _do_power_cycle)
log.info("Canon alimenté — attente boot 25s...")
await asyncio.sleep(25)
if _canon_sur_usb():
log.info("Canon visible USB après power cycle relais")
else:
log.warning("Canon toujours absent USB après power cycle — problème physique probable")
async def _connecter_camera_bg():
"""Connexion caméra en arrière-plan — ne bloque pas le serveur.
Si relais dispo et Canon absent USB, fait un power cycle propre d'abord."""
if GPHOTO2_DISPONIBLE:
await _sequence_boot_canon()
for _tentative in range(5):
if camera.connecter():
_appliquer_config_camera()
log.info("Camera connectee en arriere-plan")
return
if _tentative < 2:
log.info(f"Connexion camera echouee (tentative {_tentative+1}/5), attente 5s...")
await asyncio.sleep(5)
else:
log.warning(f"Connexion camera echouee (tentative {_tentative+1}/5), USB reset...")
_usb_reset_canon()
import gc; gc.collect()
await asyncio.sleep(3)
log.warning("Camera non connectee au demarrage — surveiller_dslr prendra le relais")
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Demarrage et arret de l'application."""
global _loop_principal
log.info("Demarrage du photobooth")
_loop_principal = asyncio.get_event_loop()
relais.connecter()
# Thread de capture preview (bloquant, tourne en parallele)
t = threading.Thread(target=_thread_preview, daemon=True)
t.start()
task_camera = asyncio.create_task(_connecter_camera_bg())
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())
task_imprimante = asyncio.create_task(_surveiller_imprimante())
yield
task_dslr.cancel()
task_push.cancel()
task_spool.cancel()
task_watchdog.cancel()
task_imprimante.cancel()
relais.deconnecter()
log.info("Arret du photobooth")
camera.deconnecter()
app = FastAPI(title="Photobooth", lifespan=lifespan)
# Servir les fichiers statiques
app.mount("/assets", StaticFiles(directory=str(RACINE / "frontend" / "assets")), name="assets")
app.mount("/css", StaticFiles(directory=str(RACINE / "frontend" / "css")), name="css")
app.mount("/js", StaticFiles(directory=str(RACINE / "frontend" / "js")), name="js")
app.mount("/sounds", StaticFiles(directory=str(RACINE / "frontend" / "sounds")), name="sounds")
app.mount("/data", StaticFiles(directory=str(RACINE / "data")), name="data")
# --- Pages ---
@app.get("/admin", include_in_schema=False)
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
host = request.client.host if request.client else ""
if host not in ("127.0.0.1", "::1", "localhost"):
return RedirectResponse(url="/admin", status_code=302)
return FileResponse(str(RACINE / "frontend" / "index.html"))
@app.post("/api/sessions/{code}/upload")
async def api_session_upload(code: str, fichier: UploadFile = File(...)):
"""Upload d'une photo dans une session identifiée par code à 9 chiffres."""
if not code.isdigit() or len(code) != 9:
return JSONResponse({"erreur": "Code invalide"}, status_code=400)
dossier = DOSSIER_PHOTOS / f"session_{code}"
dossier.mkdir(parents=True, exist_ok=True)
dest = dossier / fichier.filename
dest.write_bytes(await fichier.read())
return {"ok": True, "fichier": fichier.filename}
@app.get("/s/{code}", response_class=HTMLResponse)
async def galerie_session(code: str):
"""Galerie mobile pour une session photo (QR code)."""
if not code.isdigit() or len(code) != 9:
return HTMLResponse("<h1>Code invalide</h1>", status_code=404)
dossier = DOSSIER_PHOTOS / f"session_{code}"
cfg = charger_config()
nom_event = cfg.get("evenement", {}).get("nom", "Photobooth")
photos = sorted(dossier.glob("*.jpg"), key=lambda p: p.stat().st_mtime, reverse=True) if dossier.exists() else []
items = ""
for p in photos:
items += f'<div class="photo"><img src="/data/photos/session_{code}/{p.name}" loading="lazy" onclick="agrandir(this)"></div>\n'
if not items:
items = '<p class="vide">Les photos apparaîtront ici après chaque prise 📷</p>'
html = f"""<!DOCTYPE html>
<html lang="fr"><head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>{nom_event} — Galerie</title>
<style>
*{{margin:0;padding:0;box-sizing:border-box}}
body{{background:#0a0a1a;color:#fff;font-family:system-ui,sans-serif;min-height:100vh}}
header{{background:#111;padding:1rem;text-align:center;border-bottom:2px solid #e91e63}}
header h1{{font-size:1.4rem;color:#e91e63}}
header p{{font-size:.85rem;color:#aaa;margin-top:.3rem}}
.grille{{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:6px;padding:8px}}
.photo img{{width:100%;aspect-ratio:3/2;object-fit:cover;border-radius:6px;cursor:pointer;transition:transform .2s}}
.photo img:active{{transform:scale(.97)}}
.vide{{text-align:center;padding:3rem;color:#666;font-size:1.1rem}}
.lightbox{{display:none;position:fixed;inset:0;background:#000d;align-items:center;justify-content:center;z-index:99}}
.lightbox.open{{display:flex}}
.lightbox img{{max-width:95vw;max-height:95vh;border-radius:8px}}
.lightbox::after{{content:"✕";position:absolute;top:1rem;right:1.2rem;font-size:2rem;cursor:pointer;color:#fff}}
</style></head><body>
<header><h1>📷 {nom_event}</h1><p>Code session : {code}</p></header>
<div class="grille">{items}</div>
<div class="lightbox" id="lb" onclick="this.classList.remove('open')"><img id="lb-img" src=""></div>
<script>
function agrandir(el){{document.getElementById('lb-img').src=el.src;document.getElementById('lb').classList.add('open')}}
// Rafraîchissement auto toutes les 15s
setTimeout(()=>location.reload(), 15000)
</script>
</body></html>"""
return HTMLResponse(html)
# --- API Config ---
@app.get("/api/config")
async def api_config():
return charger_config()
@app.post("/api/config")
async def api_config_update(modifications: dict):
config = mettre_a_jour_config(modifications)
await diffuser_ws({"type": "config_maj", "config": config})
# Appliquer immédiatement les paramètres caméra si modifiés
if "camera" in modifications and camera.connectee:
_appliquer_config_camera()
return config
# --- API Camera ---
@app.post("/api/capturer")
async def api_capturer():
global capture_en_cours, _preview_actif, _canon_en_veille
if _canon_en_veille and not _reveil_en_cours:
log.info("Canon en veille lors de la capture — réveil automatique")
_canon_en_veille = False
await asyncio.get_event_loop().run_in_executor(None, _reveiller_canon)
if _reveil_en_cours:
log.info("Canon en cours de réveil — attente max 60s")
for _ in range(60):
if not _reveil_en_cours:
break
await asyncio.sleep(1)
if not camera.connectee:
return JSONResponse({"erreur": "Appareil photo en cours de démarrage — réessayez"}, status_code=503)
capture_en_cours = True
_preview_actif = False
_canon_stats["captures"] += 1
_canon_stats["derniere_capture"] = time.time()
config = charger_config()
_flash = config.get("relais", {}).get("eclairage_auto") and relais.est_connecte()
if _flash:
relais.projecteurs(True)
_proj_etat["proj1"] = True
_proj_etat["proj2"] = True
log.info("Capture déclenchée — projecteurs déjà ON depuis countdown")
await diffuser_ws({"type": "shutter"})
try:
loop = asyncio.get_event_loop()
try:
chemin = await asyncio.wait_for(
loop.run_in_executor(None, camera.capturer),
timeout=20.0
)
except asyncio.TimeoutError:
log.error("TIMEOUT capture DSLR après 20s — déconnexion forcée")
camera.deconnecter()
await diffuser_ws({"type": "camera_erreur", "message": "Timeout capture"})
return JSONResponse({"erreur": "Timeout capture — vérifiez le DSLR"}, status_code=500)
finally:
capture_en_cours = False
if _flash:
relais.projecteurs(False)
_proj_etat["proj1"] = False
_proj_etat["proj2"] = False
global _preview_eclairage_init
_preview_eclairage_init = False
log.info(f"Capture terminée : {chemin}")
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)
nom = chemin.name
# Distribuer vers les destinations
distribuer_photo(chemin, imprimee=False)
await diffuser_ws({"type": "photo_capturee", "nom": nom})
return {"nom": nom, "chemin": f"/data/photos/{nom}"}
@app.get("/api/preview")
async def api_preview():
donnees = camera.preview()
if donnees is None:
return JSONResponse({"erreur": "Preview indisponible"}, status_code=500)
b64 = base64.b64encode(donnees).decode("ascii")
return {"image": f"data:image/jpeg;base64,{b64}"}
@app.get("/api/systeme/info")
async def api_systeme_info():
import socket
import subprocess
import shutil
# Interfaces reseau avec leurs IPs
interfaces = []
try:
import netifaces
for iface in netifaces.interfaces():
addrs = netifaces.ifaddresses(iface)
ipv4 = addrs.get(netifaces.AF_INET, [])
ipv6 = addrs.get(netifaces.AF_INET6, [])
ips = [a['addr'] for a in ipv4 if a.get('addr') != '127.0.0.1']
ips += [a['addr'].split('%')[0] for a in ipv6 if not a.get('addr', '').startswith('::1')]
if ips:
interfaces.append({"nom": iface, "ips": ips})
except ImportError:
# Fallback sans netifaces
try:
out = subprocess.check_output(["ip", "-o", "addr", "show"], text=True)
for ligne in out.splitlines():
parts = ligne.split()
if len(parts) >= 4:
iface = parts[1]
ip = parts[3].split('/')[0]
if ip not in ('127.0.0.1', '::1') and not ip.startswith('fe80'):
existing = next((x for x in interfaces if x['nom'] == iface), None)
if existing:
existing['ips'].append(ip)
else:
interfaces.append({"nom": iface, "ips": [ip]})
except Exception:
pass
# Hostname
hostname = socket.gethostname()
# Uptime
uptime_str = ""
try:
with open('/proc/uptime') as f:
secs = float(f.read().split()[0])
h, rem = divmod(int(secs), 3600)
m = rem // 60
uptime_str = f"{h}h {m}min"
except Exception:
pass
# Espace disque (partition racine)
disque = {}
try:
total, used, free = shutil.disk_usage("/")
disque = {
"total": round(total / 1e9, 1),
"utilise": round(used / 1e9, 1),
"libre": round(free / 1e9, 1),
}
except Exception:
pass
# Memoire RAM
ram = {}
try:
with open('/proc/meminfo') as f:
lignes = {l.split(':')[0]: l.split(':')[1].strip() for l in f.readlines()}
total_kb = int(lignes.get('MemTotal', '0 kB').split()[0])
libre_kb = int(lignes.get('MemAvailable', '0 kB').split()[0])
utilise_kb = total_kb - libre_kb
ram = {
"total": round(total_kb / 1e6, 1),
"utilise": round(utilise_kb / 1e6, 1),
"libre": round(libre_kb / 1e6, 1),
}
except Exception:
pass
# Temperature CPU (RPi / Linux)
temp = None
try:
with open('/sys/class/thermal/thermal_zone0/temp') as f:
temp = round(int(f.read().strip()) / 1000, 1)
except Exception:
pass
# Version Python et app
import sys
python_version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
return {
"hostname": hostname,
"interfaces": interfaces,
"uptime": uptime_str,
"disque": disque,
"ram": ram,
"temperature_cpu": temp,
"python": python_version,
"camera_mode": camera.mode,
"camera_connectee": camera.connectee,
}
@app.post("/api/log-js")
async def api_log_js(request: Request):
data = await request.json()
log.warning(f"[JS] {data.get('msg', '?')}")
return {"ok": True}
@app.get("/api/camera/statut")
async def api_camera_statut():
return {
"connectee": camera.connectee,
"mode": camera.mode,
"reveil_en_cours": _reveil_en_cours,
"appareils": camera.lister_appareils(),
}
@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
# --- API Relais ---
@app.get("/api/relais")
async def api_relais_status():
return relais.etat_complet()
@app.post("/api/relais/{nom}/on")
async def api_relais_on(nom: str):
if nom == "projecteurs":
relais.projecteurs(True)
return {"ok": True}
if not relais.activer(nom):
return JSONResponse({"erreur": f"relais '{nom}' introuvable ou non connecté"}, 400)
return {"ok": True}
@app.post("/api/relais/{nom}/off")
async def api_relais_off(nom: str):
if nom == "projecteurs":
relais.projecteurs(False)
return {"ok": True}
if not relais.desactiver(nom):
return JSONResponse({"erreur": f"relais '{nom}' introuvable ou non connecté"}, 400)
return {"ok": True}
@app.post("/api/relais/canon/power-cycle")
async def api_power_cycle_canon(req: Request):
body = await req.json() if req.headers.get("content-type", "").startswith("application/json") else {}
duree = body.get("duree", 3.0)
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, relais.power_cycle_canon, duree)
return {"ok": True}
@app.post("/api/relais/canon/hard-reset")
async def api_hard_reset_canon(req: Request):
body = await req.json() if req.headers.get("content-type", "").startswith("application/json") else {}
duree = body.get("duree", 3.0)
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, relais.hard_reset_canon, duree)
return {"ok": True}
@app.get("/api/relais/config")
async def api_relais_config():
config = charger_config()
return config.get("relais", {
"eclairage_auto": False,
"seuil_proj1": 45,
"seuil_proj2": 30,
"seuil_off": 65,
})
@app.post("/api/relais/config")
async def api_relais_config_save(req: Request):
body = await req.json()
mettre_a_jour_config({"relais": body})
return {"ok": True}
@app.post("/api/relais/veille")
async def api_relais_veille():
global _veille_eclairage
_veille_eclairage = True
if relais.est_connecte():
relais.projecteurs(False)
_proj_etat["proj1"] = False
_proj_etat["proj2"] = False
log.info("Eclairage en veille")
return {"ok": True}
@app.post("/api/relais/reveil")
async def api_relais_reveil():
global _veille_eclairage, _canon_en_veille
_veille_eclairage = False
if relais.est_connecte():
config = charger_config()
rcfg = config.get("relais", {})
if rcfg.get("eclairage_auto"):
relais.activer("projecteur_gauche")
_proj_etat["proj1"] = True
_proj_etat["proj2"] = False
if _canon_en_veille:
log.info("Réveil Canon déclenché par reveil éclairage (HTTP)")
_canon_en_veille = False
asyncio.get_event_loop().run_in_executor(None, _reveiller_canon)
log.info("Eclairage reveille (1 projecteur)")
return {"ok": True}
@app.get("/api/camera/debug")
async def api_camera_debug():
"""Diagnostic complet de la caméra (preview, viewfinder, erreurs gphoto2)."""
info = {
"mode": camera.mode,
"connectee": camera.connectee,
"preview_actif": _preview_actif,
"derniere_frame_ok": _derniere_frame_preview is not None,
"preview_none_count": _preview_none_count,
"capture_en_cours": capture_en_cours,
}
if camera.mode == "gphoto2" and camera.connectee:
loop = asyncio.get_event_loop()
try:
frame = await loop.run_in_executor(None, camera._preview_gphoto2)
info["test_preview"] = "ok" if frame else "retourne None"
info["test_preview_taille"] = len(frame) if frame else 0
except Exception as e:
info["test_preview"] = f"ERREUR : {e}"
try:
expo = {}
with camera._gp_lock:
cfg = camera.camera.get_config()
for nom in ["autoexposuremode", "iso", "shutterspeed", "aperture", "meteringmode", "whitebalance"]:
try:
w = cfg.get_child_by_name(nom)
expo[nom] = w.get_value()
except Exception:
pass
info["exposition"] = expo
mode = expo.get("autoexposuremode", "")
if mode in ("Flash Off", "Auto", "Night Portrait", "Landscape", "Portrait", "Sports"):
info["avertissement"] = f"Canon en mode scène '{mode}' — vitesse/ISO auto non modifiables. Tourner le dial sur M ou Av."
except Exception as e:
info["exposition"] = f"ERREUR : {e}"
return info
@app.get("/api/canon/stats")
async def api_canon_stats():
"""Stats diagnostic Canon : keepalive, preview, connexions, veille."""
uptime = time.time() - _canon_stats["demarrage"]
stats = dict(_canon_stats)
stats["uptime_min"] = round(uptime / 60, 1)
stats["canon_en_veille"] = _canon_en_veille
stats["canon_sur_usb"] = _canon_sur_usb()
stats["preview_actif"] = _preview_actif
stats["veille_apres_min"] = _CANON_VEILLE_MINUTES
derniere_activite = max(stats["derniere_capture"], stats["derniere_preview"])
if derniere_activite > 0:
stats["inactif_min"] = round((time.time() - derniere_activite) / 60, 1)
else:
stats["inactif_min"] = None
return stats
@app.post("/api/camera/reconnecter")
async def api_camera_reconnecter(body: dict = {}):
camera.deconnecter()
import gc; gc.collect()
source = body.get("source")
# PTP reset avant reconnexion pour débloquer un éventuel freeze
await asyncio.get_event_loop().run_in_executor(None, _ptp_reset_canon)
await asyncio.sleep(1)
_usb_reset_canon()
await asyncio.sleep(2)
ok = camera.connecter(source=source)
if ok:
_appliquer_config_camera()
return {"connectee": ok, "mode": camera.mode}
# --- API Effets ---
@app.get("/api/filtres")
async def api_filtres():
return FILTRES
@app.post("/api/filtre")
async def api_appliquer_filtre(donnees: dict):
nom_photo = donnees.get("photo", "")
filtre = donnees.get("filtre", "original")
chemin = DOSSIER_PHOTOS / nom_photo
if not chemin.exists():
return JSONResponse({"erreur": "Photo introuvable"}, status_code=404)
if filtre == "original":
return {"nom": nom_photo, "chemin": f"/data/photos/{nom_photo}"}
chemin_export = appliquer_filtre(chemin, filtre)
return {"nom": chemin_export.name, "chemin": f"/data/exports/{chemin_export.name}"}
@app.get("/api/overlays")
async def api_overlays():
return lister_overlays()
@app.post("/api/overlay")
async def api_appliquer_overlay(donnees: dict):
nom_photo = donnees.get("photo", "")
nom_overlay = donnees.get("overlay", "")
chemin = DOSSIER_PHOTOS / nom_photo
if not chemin.exists():
return JSONResponse({"erreur": "Photo introuvable"}, status_code=404)
chemin_export = appliquer_overlay(chemin, nom_overlay)
return {"nom": chemin_export.name, "chemin": f"/data/exports/{chemin_export.name}"}
@app.post("/api/chroma")
async def api_chroma_key(donnees: dict):
nom_photo = donnees.get("photo", "")
nom_fond = donnees.get("fond")
chemin = DOSSIER_PHOTOS / nom_photo
if not chemin.exists():
return JSONResponse({"erreur": "Photo introuvable"}, status_code=404)
config = charger_config()
conf_chroma = config.get("chroma_key", {})
chemin_export = chroma_key(
chemin, nom_fond,
couleur_cle=conf_chroma.get("couleur", "#00ff00"),
tolerance=conf_chroma.get("tolerance", 40),
)
return {"nom": chemin_export.name, "chemin": f"/data/exports/{chemin_export.name}"}
@app.get("/api/fonds")
async def api_fonds():
return lister_fonds()
@app.delete("/api/fonds/{nom}")
async def api_supprimer_fond(nom: str):
chemin = DOSSIER_FONDS / nom
if chemin.exists() and chemin.parent == DOSSIER_FONDS:
chemin.unlink()
return {"succes": True}
return JSONResponse({"erreur": "Fichier introuvable"}, status_code=404)
# --- API Collage ---
@app.post("/api/strip")
async def api_strip(donnees: dict):
noms = donnees.get("photos", [])
source = donnees.get("source", "photos")
dossier_src = DOSSIER_EXPORTS if source == "exports" else DOSSIER_PHOTOS
chemins = []
for n in noms:
c = dossier_src / n
if not c.exists():
c = DOSSIER_EXPORTS / n # fallback
if not c.exists():
return JSONResponse({"erreur": f"Photo introuvable : {n}"}, status_code=404)
chemins.append(c)
chemin_strip = creer_strip(chemins)
return {
"nom": chemin_strip.name,
"chemin": f"/data/exports/{chemin_strip.name}",
# La strip elle-même est envoyée avec format 10x15-2up :
# le K60 duplique et coupe automatiquement via -div2
"impression": chemin_strip.name,
"format_impression": "10x15-2up",
}
@app.post("/api/collage")
async def api_collage(donnees: dict):
noms = donnees.get("photos", [])
colonnes = donnees.get("colonnes", 2)
chemins = [DOSSIER_PHOTOS / n for n in noms]
for c in chemins:
if not c.exists():
return JSONResponse({"erreur": f"Photo introuvable : {c.name}"}, status_code=404)
chemin = creer_collage(chemins, colonnes=colonnes)
return {"nom": chemin.name, "chemin": f"/data/exports/{chemin.name}"}
# --- API Impression ---
@app.get("/api/imprimantes")
async def api_imprimantes():
return lister_imprimantes()
@app.get("/api/imprimante/statut-detail")
async def api_statut_imprimante():
"""Retourne le statut détaillé CUPS + dernière erreur du log."""
import subprocess
config = charger_config()
nom = config.get("impression", {}).get("imprimante") or "Mitsubishi"
try:
r = subprocess.run(["lpstat", "-p", nom], capture_output=True, text=True, timeout=5)
statut_ligne = r.stdout.strip()
except Exception:
statut_ligne = "Impossible de lire le statut CUPS"
try:
r2 = subprocess.run(
["sudo", "grep", f"\\[{nom}\\]\\|Job.*cancel\\|media.*match\\|jam\\|paper",
"/var/log/cups/error_log"],
capture_output=True, text=True, timeout=5
)
lignes = [l for l in r2.stdout.splitlines() if "error_log" not in l]
derniere_erreur = lignes[-1] if lignes else ""
except Exception:
derniere_erreur = ""
return {
"statut": statut_ligne,
"derniere_erreur": derniere_erreur,
"imprimante": nom,
}
@app.post("/api/imprimante/evacuer")
async def api_evacuer_bourrage():
"""Annule tous les jobs, réactive l'imprimante, retourne le dernier message d'erreur."""
import subprocess
config = charger_config()
nom = config.get("impression", {}).get("imprimante") or "Mitsubishi"
subprocess.run(["sudo", "cancel", "-a", nom], capture_output=True)
subprocess.run(["sudo", "cupsenable", nom], capture_output=True)
# Lire la dernière erreur CUPS pour informer l'utilisateur
try:
r = subprocess.run(
["sudo", "tail", "-50", "/var/log/cups/error_log"],
capture_output=True, text=True
)
erreurs = [l for l in r.stdout.splitlines()
if any(k in l.lower() for k in ["media", "match", "jam", "paper", "cancel", "error"])
and "createprofile" not in l.lower()]
derniere = erreurs[-1] if erreurs else ""
except Exception:
derniere = ""
if "media does not match" in derniere.lower():
msg = ("Format papier incorrect — vérifiez que le format configuré "
"correspond à la cassette dans l'imprimante")
elif "jam" in derniere.lower():
msg = "Bourrage papier — retirez le papier bloqué physiquement"
elif derniere:
msg = f"Jobs annulés. Dernière erreur : {derniere.split(']')[-1].strip()}"
else:
msg = f"Jobs annulés, {nom} réactivée"
return {"succes": True, "message": msg}
@app.post("/api/imprimante/couper")
async def api_couper_papier():
"""Déclenche la coupe en envoyant un micro-job blanc (la K60 coupe après chaque impression)."""
import subprocess, tempfile, os
from PIL import Image
config = charger_config()
nom = config.get("impression", {}).get("imprimante") or "Mitsubishi"
try:
# Image blanche 1x15cm (la plus petite possible pour la K60)
img = Image.new("RGB", (600, 1800), (255, 255, 255))
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
img.save(tmp.name, "JPEG", quality=95, dpi=(300, 300))
tmp.close()
r = subprocess.run(
["lp", "-d", nom, "-n", "1",
"-o", "PageSize=w288h432-div2",
"-o", "StpiShrinkOutput=Crop",
tmp.name],
capture_output=True, text=True, timeout=15
)
os.unlink(tmp.name)
if r.returncode == 0:
return {"succes": True, "message": "Coupe lancée — le papier va être coupé"}
return {"succes": False, "message": f"Erreur : {r.stderr.strip() or r.stdout.strip()}"}
except Exception as e:
return {"succes": False, "message": str(e)}
@app.post("/api/imprimante/reset-usb")
async def api_reset_usb():
"""Réinitialise la connexion USB de l'imprimante (unbind/rebind driver)."""
import subprocess, glob
try:
# Trouver le device USB Mitsubishi K60 (VID 06d3)
devs = glob.glob("/sys/bus/usb/devices/*/idVendor")
path = None
for f in devs:
if open(f).read().strip() == "06d3":
path = f.replace("/idVendor", "")
break
if not path:
return {"succes": False, "message": "Imprimante USB non trouvée — vérifiez la connexion"}
dev_id = path.split("/")[-1]
# Annuler les jobs d'abord
nom = charger_config().get("impression", {}).get("imprimante") or "Mitsubishi"
subprocess.run(["sudo", "cancel", "-a", nom], capture_output=True)
# Unbind/rebind USB
subprocess.run(["sudo", "sh", "-c", f"echo '{dev_id}' > /sys/bus/usb/drivers/usb/unbind"],
capture_output=True)
await asyncio.sleep(1)
subprocess.run(["sudo", "sh", "-c", f"echo '{dev_id}' > /sys/bus/usb/drivers/usb/bind"],
capture_output=True)
await asyncio.sleep(2)
subprocess.run(["sudo", "cupsenable", nom], capture_output=True)
return {"succes": True, "message": "Imprimante réinitialisée — prête dans quelques secondes"}
except Exception as e:
return {"succes": False, "message": str(e)}
@app.post("/api/imprimer")
async def api_imprimer(donnees: dict):
if evenement_est_termine():
return JSONResponse({"succes": False, "erreur": "evenement_termine",
"message": "L'evenement est termine — impression desactivee"}, status_code=403)
nom = donnees.get("photo", "")
copies = donnees.get("copies", 1)
config = charger_config()
copies_max = config.get("impression", {}).get("copies_max", 5)
copies = max(1, min(copies, copies_max))
chemin = DOSSIER_EXPORTS / nom
if not chemin.exists():
chemin = DOSSIER_PHOTOS / nom
if not chemin.exists():
return JSONResponse({"erreur": "Photo introuvable"}, status_code=404)
cadre_override = donnees.get("cadre")
format_papier = donnees.get("format_papier") or None
try:
resultat = imprimer(chemin, copies=copies, cadre_override=cadre_override, format_papier=format_papier)
except Exception as e:
log.error(f"Erreur impression inattendue : {e}")
return {"succes": False, "erreur": "erreur_impression", "message": f"Erreur interne : {e}"}
if resultat.get("succes"):
distribuer_photo(chemin, imprimee=True, copies=copies, format_papier=format_papier)
return resultat
# --- API Consommables ---
@app.get("/api/consommables")
async def api_consommables():
return consommables_etat()
@app.post("/api/consommables/reset")
async def api_reset_consommables(request: Request):
body = await request.json()
quoi = body.get("quoi", "tout")
reset_consommables(quoi)
return consommables_etat()
@app.post("/api/consommables/capacite")
async def api_consommables_capacite(request: Request):
body = await request.json()
updates = {}
if "papier_capacite" in body:
updates["papier_capacite"] = int(body["papier_capacite"])
if "ruban_capacite" in body:
updates["ruban_capacite"] = int(body["ruban_capacite"])
if updates:
config = charger_config()
conso = config.get("consommables", {})
conso.update(updates)
mettre_a_jour_config({"consommables": conso})
return consommables_etat()
# --- API Booth (galerie live) ---
@app.get("/api/booth/info")
async def api_booth_info():
return recuperer_booth_password()
# --- API Email ---
@app.post("/api/email")
async def api_email(donnees: dict):
email_dest = donnees.get("email", "")
nom = donnees.get("photo", "")
if not email_dest:
return JSONResponse({"erreur": "Email requis"}, status_code=400)
chemin = DOSSIER_EXPORTS / nom
if not chemin.exists():
chemin = DOSSIER_PHOTOS / nom
if not chemin.exists():
return JSONResponse({"erreur": "Photo introuvable"}, status_code=404)
ok = envoyer_photo(email_dest, chemin)
if ok:
return {"succes": True}
ajouter_au_spool(email_dest, str(chemin))
_sauvegarder_email_historique(email_dest)
return {"succes": True, "spool": True, "en_attente": taille_spool()}
@app.get("/api/emails/historique")
async def api_emails_historique():
return {"emails": charger_emails_historique()}
@app.delete("/api/emails/historique")
async def api_emails_historique_supprimer():
effacer_emails_historique()
return {"succes": True}
# --- API QR Code ---
@app.get("/api/qr")
async def api_qr(url: str = ""):
"""Genere un QR code pour une URL arbitraire."""
if not url:
return JSONResponse({"erreur": "URL requise"}, status_code=400)
import qrcode
import io
qr = qrcode.make(url, box_size=6, border=2)
buf = io.BytesIO()
qr.save(buf, format="PNG")
buf.seek(0)
from fastapi.responses import StreamingResponse
return StreamingResponse(buf, media_type="image/png")
@app.get("/api/qr/galerie")
async def api_qr_galerie():
chemin = qr_galerie()
if chemin is None:
return JSONResponse({"erreur": "URL galerie non configuree"}, status_code=400)
return {"chemin": f"/data/exports/{chemin.name}"}
@app.post("/api/booth/imprimer-qr")
async def api_booth_imprimer_qr():
"""Genere et imprime un flyer QR pour la galerie booth."""
config = charger_config()
booth = config.get("booth", {})
event = config.get("evenement", {})
if not booth.get("actif") or not booth.get("url"):
return JSONResponse({"erreur": "Booth non configure"}, status_code=400)
info = recuperer_booth_password()
password = info.get("password", "")
event_id = booth.get("event_id", "default")
nom_event = event.get("nom", "Photobooth")
url_galerie = f"{booth['url']}/{event_id}?p={password}"
from PIL import Image, ImageDraw, ImageFont
import qrcode
W, H = 1200, 1800 # 10x15 portrait @ 300dpi
img = Image.new("RGB", (W, H), "#0a0a1a")
draw = ImageDraw.Draw(img)
try:
font_big = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 72)
font_med = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 42)
font_small = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 32)
except OSError:
font_big = ImageFont.load_default()
font_med = font_big
font_small = font_big
# Titre evenement
draw.text((W // 2, 120), "📸", font=font_big, anchor="mt", fill="#e91e63")
draw.text((W // 2, 220), nom_event, font=font_big, anchor="mt", fill="#ffffff")
# Ligne decorative
draw.line([(150, 320), (W - 150, 320)], fill="#e91e63", width=3)
# Texte d'invitation
draw.text((W // 2, 400), "Retrouvez toutes les photos", font=font_med, anchor="mt", fill="#cccccc")
draw.text((W // 2, 460), "en direct sur la galerie !", font=font_med, anchor="mt", fill="#cccccc")
# QR code
qr = qrcode.QRCode(version=None, error_correction=qrcode.constants.ERROR_CORRECT_M, box_size=12, border=3)
qr.add_data(url_galerie)
qr.make(fit=True)
qr_img = qr.make_image(fill_color="#ffffff", back_color="#0a0a1a").convert("RGB")
qr_size = 700
qr_img = qr_img.resize((qr_size, qr_size), Image.NEAREST)
img.paste(qr_img, ((W - qr_size) // 2, 540))
# Instruction
draw.text((W // 2, 1300), "Scannez ce QR code", font=font_med, anchor="mt", fill="#ffffff")
draw.text((W // 2, 1360), "avec votre téléphone", font=font_med, anchor="mt", fill="#ffffff")
# Ligne decorative bas
draw.line([(150, 1460), (W - 150, 1460)], fill="#e91e63", width=3)
draw.text((W // 2, 1520), "Les photos apparaissent en temps réel", font=font_small, anchor="mt", fill="#888888")
draw.text((W // 2, 1570), "Téléchargez-les directement !", font=font_small, anchor="mt", fill="#888888")
# Sauvegarder
chemin = DOSSIER_EXPORTS / "flyer_qr_booth.jpg"
img.save(str(chemin), "JPEG", quality=95)
# Imprimer
ok = imprimer(str(chemin), copies=1)
return {"succes": ok, "chemin": f"/data/exports/flyer_qr_booth.jpg"}
# --- API Evenements ---
@app.get("/api/evenements")
async def api_lister_evenements():
return lister_evenements()
@app.post("/api/evenements")
async def api_creer_evenement(donnees: dict):
nom = donnees.get("nom", "").strip()
if not nom:
return JSONResponse({"erreur": "Nom requis"}, status_code=400)
event = creer_evenement(
nom,
event_id=donnees.get("event_id"),
theme=donnees.get("theme", "base"),
couleur_primaire=donnees.get("couleur_primaire", "#e91e63"),
couleur_secondaire=donnees.get("couleur_secondaire", "#ffffff"),
media_accueil=donnees.get("media_accueil"),
date_fin=donnees.get("date_fin"),
)
return event
@app.get("/api/evenements/{event_id}")
async def api_obtenir_evenement(event_id: str):
event = obtenir_evenement(event_id)
if not event:
return JSONResponse({"erreur": "Evenement introuvable"}, status_code=404)
return event
def _event_id_actif() -> str | None:
return charger_config().get("evenement", {}).get("event_id")
@app.put("/api/evenements/{event_id}")
async def api_modifier_evenement(event_id: str, donnees: dict):
event = modifier_evenement(event_id, donnees)
if not event:
return JSONResponse({"erreur": "Evenement introuvable"}, status_code=404)
if event_id == _event_id_actif():
await diffuser_ws({"type": "config_maj", "config": charger_config()})
return event
@app.delete("/api/evenements/{event_id}")
async def api_supprimer_evenement(event_id: str):
etait_actif = event_id == _event_id_actif()
ok = supprimer_evenement(event_id)
if not ok:
return JSONResponse({"erreur": "Evenement introuvable"}, status_code=404)
if etait_actif:
await diffuser_ws({"type": "config_maj", "config": charger_config()})
return {"succes": True}
@app.post("/api/evenements/{event_id}/activer")
async def api_activer_evenement(event_id: str):
event = activer_evenement(event_id)
if not event:
return JSONResponse({"erreur": "Evenement introuvable"}, status_code=404)
reset_compteur()
log.info(f"Compteur photos remis a zero (activation evenement {event_id})")
await diffuser_ws({"type": "config_maj", "config": charger_config()})
return event
@app.post("/api/evenements/{event_id}/terminer")
async def api_terminer_evenement(event_id: str):
event = terminer_evenement(event_id)
if not event:
return JSONResponse({"erreur": "Evenement introuvable"}, status_code=404)
await diffuser_ws({"type": "config_maj", "config": charger_config()})
return event
@app.get("/api/evenements/{event_id}/cadres/{format_papier}")
async def api_cadres_event(event_id: str, format_papier: str):
cadres = lister_cadres_event(event_id, format_papier)
event = obtenir_evenement(event_id)
conf = (event or {}).get("cadres", {}).get(format_papier, {})
return {
"disponibles": cadres,
"mode": conf.get("mode", "aucun"),
"cadre": conf.get("cadre"),
}
@app.post("/api/evenements/{event_id}/cadres/{format_papier}")
async def api_set_cadre_event(event_id: str, format_papier: str, donnees: dict):
mode = donnees.get("mode", "aucun")
cadre = donnees.get("cadre")
set_cadre_event(event_id, format_papier, mode, cadre)
if event_id == _event_id_actif():
await diffuser_ws({"type": "config_maj", "config": charger_config()})
return {"succes": True}
@app.post("/api/evenements/{event_id}/upload-cadre/{format_papier}")
async def api_upload_cadre_event(event_id: str, format_papier: str, fichier: UploadFile = File(...)):
if format_papier not in FORMATS_CADRES:
return JSONResponse({"erreur": "Format inconnu"}, status_code=400)
if not fichier.filename.lower().endswith(".png"):
return JSONResponse({"erreur": "Seuls les PNG sont acceptes"}, status_code=400)
dossier = DOSSIER_EVENEMENTS / event_id / "cadres" / format_papier
dossier.mkdir(parents=True, exist_ok=True)
chemin = dossier / fichier.filename
with open(chemin, "wb") as f:
f.write(await fichier.read())
return {"nom": fichier.filename, "format": format_papier}
@app.delete("/api/evenements/{event_id}/cadres/{format_papier}/{nom}")
async def api_supprimer_cadre_event(event_id: str, format_papier: str, nom: str):
chemin = DOSSIER_EVENEMENTS / event_id / "cadres" / format_papier / nom
if not chemin.exists():
return JSONResponse({"erreur": "Fichier introuvable"}, status_code=404)
chemin.unlink()
return {"succes": True}
# --- API Galerie ---
@app.get("/api/galerie")
async def api_galerie():
return lister_photos("exports")
@app.get("/api/galerie/compteur")
async def api_compteur():
return compter_photos()
@app.delete("/api/galerie/{nom}")
async def api_supprimer_photo(nom: str):
ok = supprimer_photo(nom)
return {"succes": ok}
@app.post("/api/galerie/vider")
async def api_vider_galerie():
vider_galerie()
return {"succes": True}
# --- API Upload overlay/fond ---
@app.post("/api/upload/overlay")
async def api_upload_overlay(fichier: UploadFile = File(...)):
chemin = DOSSIER_OVERLAYS / fichier.filename
with open(chemin, "wb") as f:
f.write(await fichier.read())
return {"nom": fichier.filename}
@app.post("/api/upload/fond")
async def api_upload_fond(fichier: UploadFile = File(...)):
chemin = DOSSIER_FONDS / fichier.filename
with open(chemin, "wb") as f:
f.write(await fichier.read())
return {"nom": fichier.filename}
# --- API Compteur ---
@app.get("/api/compteur")
async def api_compteur_etat():
return compteur_restant()
@app.post("/api/compteur/reset")
async def api_compteur_reset():
reset_compteur()
return compteur_restant()
# --- API Destinations ---
@app.get("/api/usb/detecter")
async def api_detecter_usb():
return detecter_usb()
# --- API Cadres actifs (overlays photo) ---
@app.get("/api/cadres")
async def api_cadres():
config = charger_config()
tous = lister_overlays()
actifs = config.get("cadres", {}).get("actifs", [])
return {"tous": tous, "actifs": actifs}
@app.post("/api/cadres")
async def api_cadres_update(donnees: dict):
actifs = donnees.get("actifs", [])
mettre_a_jour_config({"cadres": {"actifs": actifs}})
return {"actifs": actifs}
# --- API Cadres impression (par format) ---
@app.get("/api/cadres-impression/{format_papier}")
async def api_cadres_impression_list(format_papier: str):
if format_papier not in FORMATS_CADRES:
return JSONResponse({"erreur": "Format inconnu"}, status_code=400)
config = charger_config()
actif = config.get("impression", {}).get("cadres_actifs", {}).get(format_papier)
result = {"format": format_papier, "disponibles": lister_cadres(format_papier), "actif": actif}
event_id = config.get("evenement", {}).get("event_id")
if event_id:
ev_cadres = lister_cadres_event(event_id, format_papier)
ev_cadre, ev_mode = cadre_actif_pour_impression(format_papier)
result["event_cadres"] = ev_cadres
result["event_mode"] = ev_mode
result["event_cadre"] = ev_cadre
result["event_id"] = event_id
return result
@app.post("/api/cadres-impression/{format_papier}/actif")
async def api_cadres_impression_set(format_papier: str, donnees: dict):
if format_papier not in FORMATS_CADRES:
return JSONResponse({"erreur": "Format inconnu"}, status_code=400)
nom = donnees.get("nom") # None = désactiver
config = charger_config()
cadres_actifs = config.get("impression", {}).get("cadres_actifs", {})
cadres_actifs[format_papier] = nom
mettre_a_jour_config({"impression": {"cadres_actifs": cadres_actifs}})
return {"format": format_papier, "actif": nom}
@app.post("/api/upload/cadre/{format_papier}")
async def api_upload_cadre(format_papier: str, fichier: UploadFile = File(...)):
if format_papier not in FORMATS_CADRES:
return JSONResponse({"erreur": "Format inconnu"}, status_code=400)
if not fichier.filename.lower().endswith(".png"):
return JSONResponse({"erreur": "Seuls les PNG sont acceptés"}, status_code=400)
dossier = DOSSIER_CADRES / format_papier
dossier.mkdir(parents=True, exist_ok=True)
chemin = dossier / fichier.filename
with open(chemin, "wb") as f:
f.write(await fichier.read())
return {"nom": fichier.filename, "format": format_papier}
@app.delete("/api/cadres-impression/{format_papier}/{nom}")
async def api_supprimer_cadre(format_papier: str, nom: str):
if format_papier not in FORMATS_CADRES:
return JSONResponse({"erreur": "Format inconnu"}, status_code=400)
chemin = DOSSIER_CADRES / format_papier / nom
if not chemin.exists() or chemin.parent != DOSSIER_CADRES / format_papier:
return JSONResponse({"erreur": "Fichier introuvable"}, status_code=404)
chemin.unlink()
# Désactiver si c'était le cadre actif
config = charger_config()
cadres_actifs = config.get("impression", {}).get("cadres_actifs", {})
if cadres_actifs.get(format_papier) == nom:
cadres_actifs[format_papier] = None
mettre_a_jour_config({"impression": {"cadres_actifs": cadres_actifs}})
return {"succes": True}
# --- API Animations compte a rebours ---
ANIMATIONS_CAR = {
"classique": "Classique (chiffres simples)",
"explosion": "Explosion (chiffres qui eclatent)",
"rebond": "Rebond (chiffres qui rebondissent)",
"rotation": "Rotation 3D",
"fondu": "Fondu enchaine",
"emoji": "Emoji fun",
"shake": "Tremblement",
}
@app.get("/api/animations")
async def api_animations():
return ANIMATIONS_CAR
@app.get("/api/animations/custom")
async def api_animations_custom():
"""Liste les animations personnalisees (GIF/video)."""
extensions = {".gif", ".mp4", ".webm"}
fichiers = []
if DOSSIER_ANIMATIONS.exists():
for f in sorted(DOSSIER_ANIMATIONS.iterdir()):
if f.suffix.lower() in extensions:
fichiers.append(f.name)
config = charger_config()
actives = config.get("animations_custom", {}).get("actives", [])
return {"tous": fichiers, "actives": actives}
@app.post("/api/animations/custom")
async def api_animations_custom_update(donnees: dict):
actives = donnees.get("actives", [])
mettre_a_jour_config({"animations_custom": {"actives": actives}})
return {"actives": actives}
@app.post("/api/upload/animation")
async def api_upload_animation(fichier: UploadFile = File(...)):
chemin = DOSSIER_ANIMATIONS / fichier.filename
with open(chemin, "wb") as f:
f.write(await fichier.read())
return {"nom": fichier.filename}
@app.delete("/api/animations/custom/{nom}")
async def api_supprimer_animation(nom: str):
chemin = DOSSIER_ANIMATIONS / nom
if chemin.exists() and chemin.parent == DOSSIER_ANIMATIONS:
chemin.unlink()
return {"succes": True}
return JSONResponse({"erreur": "Fichier introuvable"}, status_code=404)
# --- API Photostation ---
FLAG_PHOTOSTATION = Path("/tmp/photostation_running")
PHOTOSTATION_DIR = Path("/opt/photostation")
@app.get("/api/photostation/disponible")
async def api_photostation_disponible():
disponible = (PHOTOSTATION_DIR / "src" / "main.py").exists()
return {"disponible": disponible}
@app.post("/api/photostation/lancer")
async def api_photostation_lancer():
if not (PHOTOSTATION_DIR / "src" / "main.py").exists():
return JSONResponse({"erreur": "Photostation non installée"}, status_code=404)
asyncio.create_task(_lancer_photostation())
return {"succes": True}
async def _lancer_photostation():
FLAG_PHOTOSTATION.touch()
await asyncio.sleep(1)
# Fermer Chromium (la boucle kiosk attend le flag avant de le relancer)
await asyncio.create_subprocess_exec("pkill", "chromium")
await asyncio.sleep(2)
import subprocess as _sp
env = {**__import__("os").environ, "DISPLAY": ":0"}
# Récupérer les variables de session X/dbus depuis la session LightDM
try:
r = _sp.run(["grep", "-z", "DISPLAY\|XAUTHORITY\|DBUS_SESSION",
f"/proc/{__import__('os').getpid()}/environ"],
capture_output=True)
for kv in r.stdout.split(b"\x00"):
if b"=" in kv:
k, v = kv.decode(errors="ignore").split("=", 1)
if k in ("DISPLAY", "XAUTHORITY", "DBUS_SESSION_BUS_ADDRESS"):
env[k] = v
except Exception:
pass
log_path = "/tmp/photostation.log"
cmd = (f"source {PHOTOSTATION_DIR}/venv/bin/activate && "
f"python {PHOTOSTATION_DIR}/src/main.py --kiosk >> {log_path} 2>&1")
proc = await asyncio.create_subprocess_exec("bash", "-c", cmd, env=env)
await proc.wait()
FLAG_PHOTOSTATION.unlink(missing_ok=True)
# --- API Videos didactiques (multi-etapes) ---
DOSSIER_VIDEOS = RACINE / "data" / "videos"
DOSSIER_VIDEOS.mkdir(parents=True, exist_ok=True)
FICHIER_VIDEOS_CONFIG = DOSSIER_VIDEOS / "config.json"
SITUATIONS_DEFAUT = [
{"id": "montage", "label": "Montage / Mise en service"},
{"id": "demontage", "label": "Demontage"},
{"id": "camera_hs", "label": "Appareil photo ne repond pas"},
{"id": "bourrage", "label": "Bourrage imprimante"},
{"id": "changement_rouleau", "label": "Changement rouleau / ruban"},
{"id": "depannage_imprimante", "label": "Depannage imprimante"},
{"id": "wifi", "label": "Probleme WiFi"},
]
def charger_videos_config():
if FICHIER_VIDEOS_CONFIG.exists():
with open(FICHIER_VIDEOS_CONFIG) as f:
return json.load(f)
return {}
def sauvegarder_videos_config(cfg):
with open(FICHIER_VIDEOS_CONFIG, "w") as f:
json.dump(cfg, f, indent=2, ensure_ascii=False)
def _lister_etapes(situation: str):
dossier = DOSSIER_VIDEOS / situation
if not dossier.is_dir():
return []
fichiers = sorted(
[f for f in dossier.iterdir() if f.is_file() and f.suffix.lower() in ('.mp4', '.webm', '.mov')],
key=lambda f: f.name
)
return [{"index": i, "fichier": f.name} for i, f in enumerate(fichiers)]
def _migrer_anciens_videos():
for f in DOSSIER_VIDEOS.iterdir():
if f.is_file() and f.suffix.lower() in ('.mp4', '.webm', '.mov'):
situation = f.stem
dossier = DOSSIER_VIDEOS / situation
dossier.mkdir(exist_ok=True)
dest = dossier / f"etape_00{f.suffix}"
f.rename(dest)
_migrer_anciens_videos()
@app.get("/api/videos")
async def api_videos():
cfg = charger_videos_config()
result = {}
for sit in SITUATIONS_DEFAUT:
sid = sit["id"]
etapes = _lister_etapes(sid)
sit_cfg = cfg.get(sid, {})
result[sid] = {
"label": sit.get("label", sid),
"actif": sit_cfg.get("actif", False),
"etapes": etapes,
"labels_etapes": sit_cfg.get("labels_etapes", {}),
}
for sid in sorted(cfg.keys()):
if sid not in result:
etapes = _lister_etapes(sid)
result[sid] = {
"label": cfg[sid].get("label", sid),
"actif": cfg[sid].get("actif", False),
"etapes": etapes,
"labels_etapes": cfg[sid].get("labels_etapes", {}),
}
return {"situations": result}
@app.post("/api/videos/{situation}/toggle")
async def api_toggle_situation(situation: str, req: Request):
body = await req.json()
cfg = charger_videos_config()
if situation not in cfg:
cfg[situation] = {}
cfg[situation]["actif"] = bool(body.get("actif", False))
sauvegarder_videos_config(cfg)
return {"ok": True}
@app.post("/api/videos/{situation}/upload")
async def api_upload_etape(situation: str, video: UploadFile = File(...)):
dossier = DOSSIER_VIDEOS / situation
dossier.mkdir(parents=True, exist_ok=True)
existantes = sorted(dossier.glob("etape_*"))
prochain = len(existantes)
ext = Path(video.filename).suffix.lower() or '.mp4'
dest = dossier / f"etape_{prochain:02d}{ext}"
with open(dest, "wb") as f:
while chunk := await video.read(1024 * 1024):
f.write(chunk)
return {"ok": True, "fichier": dest.name, "index": prochain}
@app.delete("/api/videos/{situation}/{index}")
async def api_delete_etape(situation: str, index: int):
dossier = DOSSIER_VIDEOS / situation
if not dossier.is_dir():
return JSONResponse({"erreur": "situation introuvable"}, 404)
fichiers = sorted(
[f for f in dossier.iterdir() if f.is_file() and f.name.startswith("etape_")],
key=lambda f: f.name
)
if index < 0 or index >= len(fichiers):
return JSONResponse({"erreur": "index invalide"}, 400)
fichiers[index].unlink()
for i, f in enumerate(sorted(
[f for f in dossier.iterdir() if f.is_file() and f.name.startswith("etape_")],
key=lambda f: f.name
)):
nouveau = dossier / f"etape_{i:02d}{f.suffix}"
if f != nouveau:
f.rename(nouveau)
cfg = charger_videos_config()
sit_cfg = cfg.get(situation, {})
old_labels = sit_cfg.get("labels_etapes", {})
new_labels = {}
for k, v in old_labels.items():
ki = int(k)
if ki < index:
new_labels[str(ki)] = v
elif ki > index:
new_labels[str(ki - 1)] = v
if old_labels != new_labels:
sit_cfg["labels_etapes"] = new_labels
cfg[situation] = sit_cfg
sauvegarder_videos_config(cfg)
return {"ok": True}
@app.post("/api/videos/{situation}/reorder")
async def api_reorder_etapes(situation: str, req: Request):
body = await req.json()
old_idx = body.get("de")
new_idx = body.get("vers")
dossier = DOSSIER_VIDEOS / situation
if not dossier.is_dir():
return JSONResponse({"erreur": "situation introuvable"}, 404)
fichiers = sorted(
[f for f in dossier.iterdir() if f.is_file() and f.name.startswith("etape_")],
key=lambda f: f.name
)
if old_idx is None or new_idx is None or old_idx < 0 or old_idx >= len(fichiers):
return JSONResponse({"erreur": "index invalide"}, 400)
moved = fichiers.pop(old_idx)
fichiers.insert(new_idx, moved)
tmp_names = []
for i, f in enumerate(fichiers):
tmp = dossier / f"_tmp_{i:02d}{f.suffix}"
f.rename(tmp)
tmp_names.append(tmp)
for i, tmp in enumerate(tmp_names):
ext = tmp.suffix
final = dossier / f"etape_{i:02d}{ext}"
tmp.rename(final)
return {"ok": True}
@app.post("/api/videos/{situation}/label")
async def api_label_etape(situation: str, req: Request):
body = await req.json()
index = body.get("index")
label = body.get("label", "")
cfg = charger_videos_config()
if situation not in cfg:
cfg[situation] = {}
labels = cfg[situation].setdefault("labels_etapes", {})
labels[str(index)] = label
sauvegarder_videos_config(cfg)
return {"ok": True}
@app.get("/api/videos/{situation}/{index}/stream")
async def api_stream_etape(situation: str, index: int):
dossier = DOSSIER_VIDEOS / situation
if not dossier.is_dir():
return JSONResponse({"erreur": "situation introuvable"}, 404)
fichiers = sorted(
[f for f in dossier.iterdir() if f.is_file() and f.name.startswith("etape_")],
key=lambda f: f.name
)
if index < 0 or index >= len(fichiers):
return JSONResponse({"erreur": "index invalide"}, 404)
f = fichiers[index]
media = "video/mp4" if f.suffix == ".mp4" else "video/webm"
return FileResponse(f, media_type=media)
@app.post("/api/videos/situation/creer")
async def api_creer_situation(req: Request):
body = await req.json()
sid = body.get("id", "").strip().lower().replace(" ", "_")
label = body.get("label", sid)
if not sid:
return JSONResponse({"erreur": "id requis"}, 400)
cfg = charger_videos_config()
if sid not in cfg:
cfg[sid] = {"label": label, "actif": False, "labels_etapes": {}}
sauvegarder_videos_config(cfg)
dossier = DOSSIER_VIDEOS / sid
dossier.mkdir(parents=True, exist_ok=True)
return {"ok": True}
# --- API Surprise ---
DOSSIER_SURPRISE = RACINE / "data" / "surprise"
DOSSIER_SURPRISE.mkdir(parents=True, exist_ok=True)
@app.post("/api/surprise/upload")
async def api_upload_surprise(media: UploadFile = File(...)):
for old in DOSSIER_SURPRISE.iterdir():
old.unlink()
ext = Path(media.filename).suffix.lower() or '.jpg'
dest = DOSSIER_SURPRISE / f"surprise{ext}"
with open(dest, "wb") as f:
while chunk := await media.read(1024 * 1024):
f.write(chunk)
fichier_type = "video" if ext in ('.mp4', '.webm', '.mov') else "photo"
mettre_a_jour_config({"surprise": {"fichier": dest.name, "type": fichier_type}})
return {"ok": True, "fichier": dest.name, "type": fichier_type}
@app.delete("/api/surprise/media")
async def api_delete_surprise():
for f in DOSSIER_SURPRISE.iterdir():
f.unlink()
mettre_a_jour_config({"surprise": {"fichier": None}})
return {"ok": True}
@app.get("/api/surprise/media")
async def api_get_surprise():
for f in DOSSIER_SURPRISE.iterdir():
if f.is_file():
ext = f.suffix.lower()
if ext in ('.mp4', '.webm', '.mov'):
return FileResponse(f, media_type="video/mp4")
return FileResponse(f, media_type="image/jpeg")
return JSONResponse({"erreur": "aucun media"}, 404)
# --- API Systeme ---
@app.post("/api/systeme/redemarrer")
async def api_redemarrer():
import subprocess
log.warning("Redemarrage systeme demande")
subprocess.Popen(["sudo", "reboot"])
return {"succes": True}
@app.post("/api/systeme/eteindre")
async def api_eteindre():
import subprocess
log.warning("Extinction systeme demandee")
subprocess.Popen(["sudo", "shutdown", "-h", "now"])
return {"succes": True}
@app.post("/api/systeme/quitter-navigateur")
async def api_quitter_navigateur():
import subprocess
log.warning("Fermeture navigateur demandee")
subprocess.Popen(["pkill", "-f", "firefox"])
subprocess.Popen(["pkill", "-f", "chromium"])
return {"succes": True}
@app.post("/api/systeme/redemarrer-backend")
async def api_redemarrer_backend():
"""Tue le processus backend — le kiosk-session.sh le relance automatiquement."""
log.warning("Redemarrage backend demande via API")
import signal
asyncio.get_event_loop().call_later(0.5, lambda: os.kill(os.getpid(), signal.SIGTERM))
return {"succes": True, "message": "Backend redémarre dans 1 seconde..."}
@app.get("/api/systeme/logs")
async def api_systeme_logs(n: int = 80):
"""Retourne les dernières N lignes du log backend."""
n = min(n, 500)
log_file = Path("/tmp/photobooth-backend.log")
if not log_file.exists():
return {"lignes": [], "fichier": str(log_file), "existe": False}
try:
with open(log_file, "rb") as f:
f.seek(0, 2)
taille = f.tell()
bloc = min(taille, n * 200)
f.seek(max(0, taille - bloc))
data = f.read().decode("utf-8", errors="replace")
lignes = data.splitlines()[-n:]
return {"lignes": lignes, "fichier": str(log_file), "existe": True}
except Exception as e:
return {"lignes": [f"Erreur lecture log : {e}"], "fichier": str(log_file), "existe": True}
@app.get("/api/systeme/sante")
async def api_systeme_sante():
"""Bilan de santé complet : camera, imprimante, relais, USB, système."""
import subprocess, shutil
# Camera
cam_ok = camera.connectee and camera.mode == "gphoto2"
cam_status = "ok" if cam_ok else ("erreur" if camera.mode == "erreur" else camera.mode)
# Imprimante
config = charger_config()
nom_imp = config.get("impression", {}).get("imprimante") or "Mitsubishi"
imp_status = "inconnu"
try:
r = subprocess.run(["lpstat", "-p", nom_imp], capture_output=True, text=True, timeout=5)
out = r.stdout.lower()
if "disabled" in out or "stopped" in out:
imp_status = "arretee"
elif "idle" in out or "inactive" in out:
imp_status = "prete"
elif "printing" in out:
imp_status = "impression"
else:
imp_status = "inconnu"
except Exception:
imp_status = "erreur"
# Jobs en file
jobs_en_attente = 0
try:
r = subprocess.run(["lpstat", "-o", nom_imp], capture_output=True, text=True, timeout=5)
jobs_en_attente = len([l for l in r.stdout.splitlines() if l.strip()])
except Exception:
pass
# Relais
rel_ok = relais.est_connecte()
# USB Canon détecté
canon_usb = False
try:
import glob
for f in glob.glob("/sys/bus/usb/devices/*/idVendor"):
if open(f).read().strip() == "04a9":
canon_usb = True
break
except Exception:
pass
# USB imprimante détectée (Mitsubishi vendor 06d3)
imp_usb = False
try:
import glob
for f in glob.glob("/sys/bus/usb/devices/*/idVendor"):
if open(f).read().strip() == "06d3":
imp_usb = True
break
except Exception:
pass
# RAM & disque
ram_pct = 0
try:
with open('/proc/meminfo') as f:
lignes = {l.split(':')[0]: l.split(':')[1].strip() for l in f.readlines()}
total = int(lignes.get('MemTotal', '1 kB').split()[0])
libre = int(lignes.get('MemAvailable', '0 kB').split()[0])
ram_pct = round((total - libre) / total * 100)
except Exception:
pass
disque_pct = 0
try:
total, used, _ = shutil.disk_usage("/")
disque_pct = round(used / total * 100)
except Exception:
pass
# Uptime
uptime = ""
try:
with open('/proc/uptime') as f:
secs = int(float(f.read().split()[0]))
h, rem = divmod(secs, 3600)
m = rem // 60
uptime = f"{h}h{m:02d}"
except Exception:
pass
# Temperature
temp = None
try:
with open('/sys/class/thermal/thermal_zone0/temp') as f:
temp = round(int(f.read().strip()) / 1000, 1)
except Exception:
pass
return {
"camera": {"status": cam_status, "mode": camera.mode, "usb": canon_usb, "preview_ok": camera.preview_dslr_ok},
"imprimante": {"status": imp_status, "nom": nom_imp, "usb": imp_usb, "jobs": jobs_en_attente},
"relais": {"connecte": rel_ok},
"systeme": {"ram_pct": ram_pct, "disque_pct": disque_pct, "uptime": uptime, "temp": temp},
}
@app.post("/api/imprimante/reactiver")
async def api_reactiver_imprimante():
"""Réactive l'imprimante si elle est en erreur/stoppée."""
import subprocess
nom = charger_config().get("impression", {}).get("imprimante") or "Mitsubishi"
try:
subprocess.run(["sudo", "cupsenable", nom], capture_output=True, timeout=5)
subprocess.run(["sudo", "accept", nom], capture_output=True, timeout=5)
log.info(f"Imprimante {nom} réactivée manuellement")
return {"succes": True, "message": f"{nom} réactivée"}
except Exception as e:
return {"succes": False, "message": str(e)}
@app.get("/api/systeme/usb")
async def api_systeme_usb():
"""Liste les périphériques USB connectés."""
import subprocess
try:
r = subprocess.run(["lsusb"], capture_output=True, text=True, timeout=5)
peripheriques = []
for l in r.stdout.splitlines():
peripheriques.append(l.strip())
return {"peripheriques": peripheriques}
except Exception:
return {"peripheriques": ["lsusb non disponible"]}
# --- API WiFi ---
@app.get("/api/wifi/status")
async def api_wifi_status():
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, wifi_status)
@app.get("/api/wifi/scan")
async def api_wifi_scan():
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, wifi_scan)
@app.post("/api/wifi/connect")
async def api_wifi_connect(donnees: dict):
ssid = donnees.get("ssid", "")
password = donnees.get("password")
if not ssid:
return JSONResponse({"erreur": "SSID requis"}, status_code=400)
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, wifi_connect, ssid, password)
@app.get("/api/wifi/saved")
async def api_wifi_saved():
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, wifi_saved_list)
@app.delete("/api/wifi/saved/{ssid}")
async def api_wifi_forget(ssid: str):
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, wifi_forget, ssid)
@app.get("/api/wifi/password/{ssid}")
async def api_wifi_password(ssid: str):
mdp = wifi_get_password(ssid)
return {"ssid": ssid, "password": mdp}
# --- WebSocket ---
@app.websocket("/ws")
async def websocket_endpoint(ws: WebSocket):
await ws.accept()
clients_ws.append(ws)
log.info(f"Client WS connecte ({len(clients_ws)} clients)")
try:
while True:
data = await ws.receive_text()
msg = json.loads(data)
await traiter_message_ws(msg, ws)
except WebSocketDisconnect:
clients_ws.remove(ws)
log.info(f"Client WS deconnecte ({len(clients_ws)} clients)")
async def traiter_message_ws(msg: dict, ws: WebSocket):
"""Traite les messages WebSocket entrants."""
type_msg = msg.get("type", "")
if type_msg == "ping":
await ws.send_json({"type": "pong"})
elif type_msg == "preview_start":
global _preview_actif, _canon_en_veille
_preview_actif = True
if _canon_en_veille:
log.info("WS: preview_start — réveil Canon depuis veille relais")
_canon_en_veille = False
await asyncio.get_event_loop().run_in_executor(None, _reveiller_canon)
else:
log.info("WS: preview_start recu")
elif type_msg == "prepare_capture":
config = charger_config()
if config.get("relais", {}).get("eclairage_auto") and relais.est_connecte():
relais.projecteurs(True)
_proj_etat["proj1"] = True
_proj_etat["proj2"] = True
log.info("WS: prepare_capture — 2 projecteurs ON (pré-éclairage countdown)")
elif type_msg == "preview_stop":
_preview_actif = False
global _preview_eclairage_init
_preview_eclairage_init = False
if not capture_en_cours and relais.est_connecte() and (_proj_etat["proj1"] or _proj_etat["proj2"]):
relais.projecteurs(False)
_proj_etat["proj1"] = False
_proj_etat["proj2"] = False
log.info("WS: preview_stop recu" + (" (projecteurs maintenus pour capture)" if capture_en_cours else ""))
async def diffuser_ws(message: dict):
"""Envoie un message a tous les clients WebSocket."""
deconnectes = []
for ws in clients_ws:
try:
await ws.send_json(message)
except Exception:
deconnectes.append(ws)
for ws in deconnectes:
clients_ws.remove(ws)
# Point d'entree
if __name__ == "__main__":
import uvicorn
config = charger_config()
conf_srv = config.get("serveur", {})
uvicorn.run(
app,
host=conf_srv.get("host", "0.0.0.0"),
port=conf_srv.get("port", 8080),
log_level="info",
)