Compare commits

...

6 Commits

Author SHA1 Message Date
4e267492c2 Fiabilite Canon : veille/reveil relais, sequence boot, stats diagnostic, fin evenement, compteur consommables
- Preview thread : poll uniquement quand ecran capture actif, keepalive 30s sinon
- Sequence boot Canon : power cycle relais si absent USB au demarrage
- Veille auto 30min : Canon coupe par relais apres inactivite, reveil au preview_start
- Stats diagnostic /api/canon/stats : keepalive, preview, captures, connexions
- Escalade reconnexion adoucie : pas d'USB reset les 2 premieres tentatives
- Hard reset relais 10s (resistance decharge 990ohm sur dummy battery 8V)
- Gestion fin evenement : date_fin + bouton terminer + guard 403 impression
- Compteur base sur min(papier, ruban) avec cap evenement optionnel
- Pre-eclairage countdown : projecteurs ON 3s avant capture pour mesure expo
- Canon _configurer_init() : ISO 800, drivemode Single, viewfinder, EXIF preserve
- Admin : detail evenement, compteur consommables, galerie impression

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WyN9xFp84P9VaWT3Qe2DXu
2026-08-14 19:33:28 +02:00
a6766f529e Module relais USB HID : pilotage projecteurs + alim Canon + eclairage auto
Pilote backend/relais.py (hidapi/ctypes) pour module LCUS 5131:2007.
Relais 1-2 = projecteurs (NO), relais 3 = alim Canon (NF).
API : status, on/off par nom, power-cycle, config seuils.
Eclairage auto : analyse luminosite visage → allume projecteurs selon seuils.
Admin : onglet eclairage enrichi avec boutons relais + seuils configurables.
Note : power-cycle Canon par relais ne redémarre pas le DSLR (contact capot batterie).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-02 15:45:03 +02:00
3aa3068127 Videos didactiques multi-etapes : wizard operateur + admin dynamique
Backend : 8 endpoints API (upload/delete/reorder/toggle/label/stream/creer etape),
stockage data/videos/{situation}/etape_XX.mp4, config.json toggle+labels,
6 situations par defaut, migration auto anciens fichiers flat.
Admin : panneau dynamique avec toggle, etapes reordonnables, labels editables.
Wizard booth : overlay plein ecran, video loop par etape, bouton validation sequentielle.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-12 20:41:05 +02:00
5e482a89e7 Consommables : ajout compteur poses perdues + ratio corrige 0.5 (10/20)
- Nouvelle carte 'Poses perdues' dans le panneau consommables
- Le ratio ruban optimal pour 10x15 est 0.5 (10cm sur 20cm de panneau)
- Diagnostic enrichi : alerte epuisement, ruban avant papier, % gaspille
- Reset poses perdues avec le ruban

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-12 16:41:30 +02:00
3b6f376ded Suivi consommables : compteur papier, ruban et photos avec diagnostic ratio
- Onglet Consommables dans General : barres de progression papier/ruban, compteur photos
- Capacites configurables (feuilles par bobine / poses par bobine)
- Reset individuel papier/ruban (quand on change la bobine)
- Diagnostic ratio ruban/papier pour verifier que le rembobinage fonctionne
- Tracking automatique a chaque impression (format-aware)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-12 15:23:17 +02:00
44a9992557 Admin backoffice : rubriques General/Evenement + videos situation + surprise + rembobinage ruban
- Reorganisation menu admin en 2 rubriques (General / Evenement)
- Destinations deplace dans Evenement
- Nouveau panneau Videos : upload par situation (montage, depannage, bourrage, rechargement)
- Nouveau panneau Surprise : photo/video affichee ~1s avant capture pour provoquer sourire
- Toggle rembobinage ruban Mitsubishi (StpiDecklist) pour economiser le ruban sur petits formats
- Historique email cloisonne par evenement
- Memoire projet consolidee dans memoire.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-09 12:31:27 +02:00
18 changed files with 3208 additions and 164 deletions

135
PROCEDURE_VPN_FIX.md Normal file
View File

@@ -0,0 +1,135 @@
---
name: booth-vpn-fix
description: Procédure urgente pour rétablir le tunnel WireGuard sur la Surface (booth terrain) et installer un watchdog
metadata:
node_type: memory
type: project
originSessionId: 6d8a34f4-db2f-4d5b-92e9-895e3925ccb5
---
## Problème
Le booth (Surface) est sur un WiFi externe, le tunnel WireGuard ne se connecte pas même après reboot.
Dernier handshake : 6+ jours. Aucun accès SSH ni VPN.
**Why:** Le service wg-quick@wg0 n'est peut-être pas enabled au boot, ou l'Endpoint dans la config WG pointe vers une IP locale (192.168.111.x) au lieu de l'IP publique.
## Infos serveur WG (LXC 111)
- IP LXC : 192.168.111.211
- Port WG : 51820 (UDP) — écoute OK
- Clé publique serveur : `g3s7Gh1Er+LIEI/W91HAVDNIo+gsk/KiajdtoDUQRVo=`
- Le port 51820 est redirigé depuis la box vers le LXC
## Étapes à faire sur la Surface (SSH ou clavier physique)
### 1. Diagnostic
```bash
sudo systemctl status wg-quick@wg0
sudo wg show
cat /etc/wireguard/wg0.conf
```
### 2. Vérifier l'Endpoint
Dans `/etc/wireguard/wg0.conf`, le `[Peer]` doit avoir :
```
Endpoint = <IP_PUBLIQUE_MAISON>:51820
```
**PAS** une IP locale type 192.168.111.x — ça ne marche que depuis le LAN.
Pour trouver l'IP publique depuis le PC fixe :
```bash
curl -s ifconfig.me
```
### 3. Activer au boot + relancer
```bash
sudo systemctl enable wg-quick@wg0
sudo systemctl restart wg-quick@wg0
sudo wg show
```
Vérifier que le handshake se fait (latest handshake < 1 minute).
### 4. Installer le watchdog WG (une fois le tunnel rétabli)
Créer `/usr/local/bin/wg-watchdog.sh` :
```bash
#!/bin/bash
IFACE=wg0
MAX_AGE=180 # 3 minutes sans handshake = relance
latest=$(sudo wg show $IFACE latest-handshakes 2>/dev/null | awk '{print $2}')
if [ -z "$latest" ] || [ "$latest" = "0" ]; then
systemctl restart wg-quick@$IFACE
exit 0
fi
now=$(date +%s)
age=$((now - latest))
if [ $age -gt $MAX_AGE ]; then
logger "wg-watchdog: handshake age ${age}s > ${MAX_AGE}s, restarting $IFACE"
systemctl restart wg-quick@$IFACE
fi
```
```bash
sudo chmod +x /usr/local/bin/wg-watchdog.sh
echo "* * * * * root /usr/local/bin/wg-watchdog.sh" | sudo tee /etc/cron.d/wg-watchdog
```
### 5. Installer reverse SSH en fallback
```bash
sudo apt install -y autossh
```
Créer `/etc/systemd/system/autossh-tunnel.service` :
```ini
[Unit]
Description=Reverse SSH tunnel fallback
After=network-online.target
Wants=network-online.target
[Service]
User=jules
ExecStart=/usr/bin/autossh -M 0 -N -o "ServerAliveInterval 30" -o "ServerAliveCountMax 3" -o "ExitOnForwardFailure yes" -R 2222:localhost:22 root@192.168.111.211
Restart=always
RestartSec=30
[Install]
WantedBy=multi-user.target
```
```bash
sudo systemctl enable autossh-tunnel
sudo systemctl start autossh-tunnel
```
Ensuite depuis le LXC 111 : `ssh -p 2222 jules@localhost` pour joindre la Surface même si WG est mort.
**Prérequis :** la clé SSH de jules@surface doit être dans authorized_keys de root@192.168.111.211 (LXC).
### 6. Déployer les dernières modifs
Une fois le tunnel rétabli :
```bash
cd /home/jules/photobooth && git pull && sudo systemctl restart photobooth
```
Commits non déployés sur Surface :
- Flash blanc capture + printer toast CSS + admin galerie + compteur copies
- Cache versions bump
## Changements faits sur LXC 111 (cette session)
- Landing page vitrine complète (tarifs, formulaire résa, calendrier dispo)
- Endpoint `/api/contact` : crée événement + email admin (pas client)
- Endpoint `/api/contact/valider/{id}` : valide résa + envoie email client
- Endpoint `/api/disponibilites` : dates occupées pour calendrier
- ZIP streaming (fix 500 sur gros ZIP)
- Barre progression téléchargement ZIP
- Admin panel : section "Demandes en attente" avec bouton Valider

View File

@@ -88,11 +88,12 @@ class Camera:
pass pass
cam.init() cam.init()
self.camera = cam self.camera = cam
self._desactiver_veille_init() time.sleep(1)
self._activer_viewfinder_init() self._configurer_init()
self.connectee = True self.connectee = True
self.mode = "gphoto2" self.mode = "gphoto2"
self.preview_dslr_ok = True self.preview_dslr_ok = True
self._log_reglages()
log.info("Camera DSLR connectee (LiveView actif)") log.info("Camera DSLR connectee (LiveView actif)")
return True return True
except Exception as e: except Exception as e:
@@ -190,23 +191,36 @@ class Camera:
vf = cfg.get_child_by_name("viewfinder") vf = cfg.get_child_by_name("viewfinder")
vf.set_value(0) vf.set_value(0)
self.camera.set_config(cfg) self.camera.set_config(cfg)
time.sleep(0.8)
except Exception: except Exception:
pass pass
log.info(f"Déclenchement capture DSLR (tentative {attempt+1}/3)") log.info(f"Déclenchement capture DSLR (tentative {attempt+1}/3)")
t0 = time.time()
chemin_camera = self.camera.capture(gp.GP_CAPTURE_IMAGE) chemin_camera = self.camera.capture(gp.GP_CAPTURE_IMAGE)
t1 = time.time()
fichier_camera = gp.CameraFile() fichier_camera = gp.CameraFile()
self.camera.file_get( self.camera.file_get(
chemin_camera.folder, chemin_camera.name, gp.GP_FILE_TYPE_NORMAL, fichier_camera chemin_camera.folder, chemin_camera.name, gp.GP_FILE_TYPE_NORMAL, fichier_camera
) )
t2 = time.time()
tmp_path = str(chemin_dest) + ".tmp" tmp_path = str(chemin_dest) + ".tmp"
fichier_camera.save(tmp_path) fichier_camera.save(tmp_path)
log.info(f"Capture: shutter={t1-t0:.1f}s download={t2-t1:.1f}s")
threading.Thread(target=self._post_capture_warmup, daemon=True).start() threading.Thread(target=self._post_capture_warmup, daemon=True).start()
from PIL import ImageOps as PILImageOps from PIL import ImageOps as PILImageOps
img = PILImageOps.exif_transpose(PILImage.open(tmp_path)) t3 = time.time()
pil_img = PILImage.open(tmp_path)
exif_data = pil_img.info.get("exif")
img = PILImageOps.exif_transpose(pil_img)
if img.width > 4000: if img.width > 4000:
ratio = 4000 / img.width ratio = 4000 / img.width
img = img.resize((4000, int(img.height * ratio)), PILImage.LANCZOS) img = img.resize((4000, int(img.height * ratio)), PILImage.BILINEAR)
img.save(str(chemin_dest), "JPEG", quality=92) save_kwargs = {"quality": 92}
if exif_data:
save_kwargs["exif"] = exif_data
img.save(str(chemin_dest), "JPEG", **save_kwargs)
t4 = time.time()
log.info(f"Post-process: {t4-t3:.1f}s")
Path(tmp_path).unlink(missing_ok=True) Path(tmp_path).unlink(missing_ok=True)
log.info(f"Photo capturee (DSLR) : {chemin_dest} ({img.width}x{img.height})") log.info(f"Photo capturee (DSLR) : {chemin_dest} ({img.width}x{img.height})")
return chemin_dest return chemin_dest
@@ -292,35 +306,48 @@ class Camera:
except Exception as e: except Exception as e:
log.debug(f"configurer_flash : {e}") log.debug(f"configurer_flash : {e}")
def _desactiver_veille_init(self): def _configurer_init(self):
"""Desactive la mise en veille auto du boitier (Canon : autopoweroff en minutes, 0=jamais). """Configure le Canon : veille OFF, ISO, drivemode, viewfinder (appels séparés)."""
Sans ca, le DSLR s'eteint seul apres quelques minutes d'inactivite et apparait deconnecte.""" reglages = [
candidats = ["autopoweroff", "auto_power_off", "/main/settings/autopoweroff"] ("autopoweroff", 0),
try: ("viewfinder", 1),
cfg = self.camera.get_config() ("iso", "800"),
for nom in candidats: ("drivemode", "Single"),
]
for nom, valeur in reglages:
for tentative in range(3):
try: try:
widget = cfg.get_child_by_name(nom) cfg = self.camera.get_config()
widget.set_value(0) w = cfg.get_child_by_name(nom)
w.set_value(valeur)
self.camera.set_config(cfg) self.camera.set_config(cfg)
log.info(f"Mise en veille DSLR désactivée ({nom})") log.info(f"Canon init: {nom} = {valeur}")
return break
except gp.GPhoto2Error as e:
if tentative < 2:
time.sleep(0.5)
else:
log.warning(f"Canon init {nom}: {e}")
except Exception: except Exception:
continue break
log.debug("Widget autopoweroff non trouvé sur ce modèle")
except Exception as e:
log.debug(f"_desactiver_veille_init : {e}")
def _activer_viewfinder_init(self): def _log_reglages(self):
"""Active le LiveView pendant l'init (pas de lock, appelé avant que le thread démarre).""" """Log les réglages Canon actuels pour diagnostic."""
try: try:
cfg = self.camera.get_config() cfg = self.camera.get_config()
vf = cfg.get_child_by_name("viewfinder") vals = {}
vf.set_value(1) for nom in ["autoexposuremode", "iso", "shutterspeed", "aperture", "meteringmode"]:
self.camera.set_config(cfg) try:
log.info("Viewfinder activé (init)") w = cfg.get_child_by_name(nom)
except Exception as e: vals[nom] = w.get_value()
log.warning(f"Viewfinder non supporté : {e}") log.info(f"Canon actuel: {nom} = {vals[nom]}")
except Exception:
pass
mode = vals.get("autoexposuremode", "")
if mode in ("Flash Off", "Auto", "Night Portrait", "Landscape", "Portrait", "Sports"):
log.warning(f"Canon en mode scène '{mode}' — ISO/vitesse non modifiables. Tourner le dial sur M ou Av.")
except Exception:
pass
def activer_viewfinder(self): def activer_viewfinder(self):
"""Active le LiveView (miroir levé) depuis le thread — thread-safe.""" """Active le LiveView (miroir levé) depuis le thread — thread-safe."""

View File

@@ -11,7 +11,7 @@ from backend.config import charger_config, mettre_a_jour_config
log = logging.getLogger("photobooth.destinations") log = logging.getLogger("photobooth.destinations")
def distribuer_photo(chemin_photo: Path, imprimee: bool = False, copies: int = 1): def distribuer_photo(chemin_photo: Path, imprimee: bool = False, copies: int = 1, format_papier: str | None = None):
"""Copie la photo vers toutes les destinations activees.""" """Copie la photo vers toutes les destinations activees."""
config = charger_config() config = charger_config()
dest = config.get("destinations", {}) dest = config.get("destinations", {})
@@ -43,6 +43,7 @@ def distribuer_photo(chemin_photo: Path, imprimee: bool = False, copies: int = 1
if compteur.get("actif", False): if compteur.get("actif", False):
compteur["photos_prises"] = compteur.get("photos_prises", 0) + copies compteur["photos_prises"] = compteur.get("photos_prises", 0) + copies
mettre_a_jour_config({"compteur": compteur}) mettre_a_jour_config({"compteur": compteur})
maj_consommables(config, copies, format_papier)
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):
@@ -202,19 +203,103 @@ def detecter_usb() -> list[str]:
def compteur_restant() -> dict: def compteur_restant() -> dict:
"""Retourne l'etat du compteur.""" """Retourne l'etat du compteur, basé sur les consommables restants."""
config = charger_config() config = charger_config()
compteur = config.get("compteur", {}) compteur = config.get("compteur", {})
limite = compteur.get("limite", 400) conso = config.get("consommables", {})
prises = compteur.get("photos_prises", 0) prises = compteur.get("photos_prises", 0)
papier_rest = max(0, conso.get("papier_capacite", 400) - conso.get("papier_utilise", 0))
ruban_rest = max(0, round(conso.get("ruban_capacite", 400) - conso.get("ruban_utilise", 0), 1))
restantes = int(min(papier_rest, ruban_rest))
limite_event = compteur.get("limite", 0)
if compteur.get("actif") and limite_event > 0:
restantes = min(restantes, max(0, limite_event - prises))
papier_cap = conso.get("papier_capacite", 400)
ruban_cap = conso.get("ruban_capacite", 400)
capacite_conso = int(min(papier_cap, ruban_cap))
return { return {
"actif": compteur.get("actif", False), "actif": compteur.get("actif", False),
"limite": limite, "limite": limite_event,
"photos_prises": prises, "photos_prises": prises,
"restantes": max(0, limite - prises), "restantes": restantes,
"capacite": capacite_conso,
"papier_restant": papier_rest,
"ruban_restant": int(ruban_rest),
} }
def reset_compteur(): def reset_compteur():
"""Remet le compteur a zero.""" """Remet le compteur a zero."""
mettre_a_jour_config({"compteur": {"photos_prises": 0}}) mettre_a_jour_config({"compteur": {"photos_prises": 0}})
# --- Suivi consommables ---
RATIO_RUBAN_OPTIMAL = {
"10x15": 0.5,
"10x15-2up": 1.0,
"15x20": 1.0,
"15x20-2up": 1.0,
}
def maj_consommables(config: dict, copies: int, format_papier: str | None):
"""Met a jour le suivi consommables (papier, ruban, photos)."""
conso = config.get("consommables", {})
fmt = format_papier or config.get("impression", {}).get("format", "15x20")
rembobinage = config.get("impression", {}).get("rembobinage_ruban", False)
is_2up = "-2up" in fmt
feuilles = copies if not is_2up else (copies + 1) // 2
conso["papier_utilise"] = conso.get("papier_utilise", 0) + feuilles
conso["photos_imprimees"] = conso.get("photos_imprimees", 0) + copies
ratio_optimal = RATIO_RUBAN_OPTIMAL.get(fmt, 1.0)
if rembobinage:
ruban_consomme = feuilles * ratio_optimal
else:
ruban_consomme = feuilles * 1.0
conso["ruban_utilise"] = round(conso.get("ruban_utilise", 0) + ruban_consomme, 1)
# Poses perdues = ruban avance inutilement (difference entre consomme et optimal)
poses_perdues = feuilles * 1.0 - feuilles * ratio_optimal if not rembobinage else 0
conso["poses_perdues"] = round(conso.get("poses_perdues", 0) + poses_perdues, 1)
mettre_a_jour_config({"consommables": conso})
def consommables_etat() -> dict:
"""Retourne l'etat des consommables."""
config = charger_config()
conso = config.get("consommables", {})
papier_cap = conso.get("papier_capacite", 400)
ruban_cap = conso.get("ruban_capacite", 400)
papier_used = conso.get("papier_utilise", 0)
ruban_used = conso.get("ruban_utilise", 0)
photos = conso.get("photos_imprimees", 0)
perdues = conso.get("poses_perdues", 0)
return {
"papier_capacite": papier_cap,
"papier_utilise": papier_used,
"papier_restant": max(0, papier_cap - papier_used),
"ruban_capacite": ruban_cap,
"ruban_utilise": ruban_used,
"ruban_restant": max(0, round(ruban_cap - ruban_used, 1)),
"photos_imprimees": photos,
"poses_perdues": perdues,
}
def reset_consommables(quoi: str):
"""Reinitialise papier, ruban ou tout."""
config = charger_config()
conso = config.get("consommables", {})
if quoi in ("papier", "tout"):
conso["papier_utilise"] = 0
if quoi in ("ruban", "tout"):
conso["ruban_utilise"] = 0
conso["poses_perdues"] = 0
if quoi == "tout":
conso["photos_imprimees"] = 0
conso["poses_perdues"] = 0
mettre_a_jour_config({"consommables": conso})

View File

@@ -28,7 +28,7 @@ SEUIL_SOMBRE = 45
SEUIL_CORRECT = 65 SEUIL_CORRECT = 65
_dernier_analyse: float = 0 _dernier_analyse: float = 0
_INTERVALLE = 3.0 _INTERVALLE = 1.0
_dernier_resultat: dict | None = None _dernier_resultat: dict | None = None

View File

@@ -2,6 +2,7 @@ import json
import logging import logging
import shutil import shutil
import uuid import uuid
from datetime import date, datetime
from pathlib import Path from pathlib import Path
from backend.config import RACINE, DOSSIER_CADRES, FORMATS_CADRES, charger_config, mettre_a_jour_config from backend.config import RACINE, DOSSIER_CADRES, FORMATS_CADRES, charger_config, mettre_a_jour_config
@@ -46,6 +47,8 @@ def creer_evenement(nom: str, **kwargs) -> dict:
"couleur_secondaire": kwargs.get("couleur_secondaire", "#ffffff"), "couleur_secondaire": kwargs.get("couleur_secondaire", "#ffffff"),
"media_accueil": kwargs.get("media_accueil"), "media_accueil": kwargs.get("media_accueil"),
"formats_actifs": kwargs.get("formats_actifs", ["10x15", "15x20", "strip"]), "formats_actifs": kwargs.get("formats_actifs", ["10x15", "15x20", "strip"]),
"date_fin": kwargs.get("date_fin"),
"termine": False,
"cadres": {}, "cadres": {},
} }
_chemin_event(event_id).write_text(json.dumps(event, ensure_ascii=False, indent=2), "utf-8") _chemin_event(event_id).write_text(json.dumps(event, ensure_ascii=False, indent=2), "utf-8")
@@ -86,6 +89,38 @@ def supprimer_evenement(event_id: str) -> bool:
return True return True
def terminer_evenement(event_id: str) -> dict | None:
event = obtenir_evenement(event_id)
if not event:
return None
event["termine"] = True
_chemin_event(event_id).write_text(json.dumps(event, ensure_ascii=False, indent=2), "utf-8")
log.info(f"Evenement termine : {event['nom']} ({event_id})")
return event
def evenement_est_termine() -> bool:
"""Verifie si l'evenement actif est termine (manuellement ou date_fin depassee)."""
config = charger_config()
event_id = config.get("evenement", {}).get("event_id")
if not event_id:
return False
event = obtenir_evenement(event_id)
if not event:
return False
if event.get("termine"):
return True
date_fin = event.get("date_fin")
if date_fin:
try:
fin = datetime.strptime(date_fin, "%Y-%m-%d").date()
if date.today() > fin:
return True
except ValueError:
pass
return False
def activer_evenement(event_id: str) -> dict | None: def activer_evenement(event_id: str) -> dict | None:
event = obtenir_evenement(event_id) event = obtenir_evenement(event_id)
if not event: if not event:

View File

@@ -195,28 +195,47 @@ async def tache_spool_demarrage():
FICHIER_EMAILS = RACINE / "data" / "emails_history.json" FICHIER_EMAILS = RACINE / "data" / "emails_history.json"
def _event_id_actif() -> str:
return charger_config().get("evenement", {}).get("event_id") or "_sans_evenement"
def _charger_toutes_historiques() -> dict:
if not FICHIER_EMAILS.exists():
return {}
try:
with open(FICHIER_EMAILS, "r", encoding="utf-8") as f:
data = json.load(f)
# Compatibilite avec l'ancien format (liste globale non cloisonnee)
if isinstance(data, list):
return {"_sans_evenement": data}
return data
except (json.JSONDecodeError, OSError):
return {}
def _sauvegarder_email_historique(email: str): def _sauvegarder_email_historique(email: str):
historique = charger_emails_historique() toutes = _charger_toutes_historiques()
event_id = _event_id_actif()
historique = toutes.setdefault(event_id, [])
email_lower = email.lower().strip() email_lower = email.lower().strip()
if email_lower not in historique: if email_lower not in historique:
historique.append(email_lower) historique.append(email_lower)
try: try:
with open(FICHIER_EMAILS, "w", encoding="utf-8") as f: with open(FICHIER_EMAILS, "w", encoding="utf-8") as f:
json.dump(historique, f, ensure_ascii=False) json.dump(toutes, f, ensure_ascii=False)
except OSError: except OSError:
pass pass
def charger_emails_historique() -> list: def charger_emails_historique() -> list:
if not FICHIER_EMAILS.exists(): return _charger_toutes_historiques().get(_event_id_actif(), [])
return []
try:
with open(FICHIER_EMAILS, "r", encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
return []
def effacer_emails_historique(): def effacer_emails_historique():
if FICHIER_EMAILS.exists(): toutes = _charger_toutes_historiques()
FICHIER_EMAILS.unlink() toutes.pop(_event_id_actif(), None)
try:
with open(FICHIER_EMAILS, "w", encoding="utf-8") as f:
json.dump(toutes, f, ensure_ascii=False)
except OSError:
pass

File diff suppressed because it is too large Load Diff

View File

@@ -249,7 +249,7 @@ def imprimer(
conf_imp = config.get("impression", {}) conf_imp = config.get("impression", {})
if imprimante is None: if imprimante is None:
imprimante = conf_imp.get("imprimante", "Mitsubishi") imprimante = conf_imp.get("imprimante") or "Mitsubishi"
if copies is None: if copies is None:
copies = conf_imp.get("copies", 1) copies = conf_imp.get("copies", 1)
if format_papier is None: if format_papier is None:
@@ -299,6 +299,8 @@ def imprimer(
} }
time.sleep(1) time.sleep(1)
rembobinage = conf_imp.get("rembobinage_ruban", False)
try: try:
for tentative in range(1, 4): for tentative in range(1, 4):
cmd = [ cmd = [
@@ -307,8 +309,10 @@ def imprimer(
"-n", str(copies), "-n", str(copies),
"-o", f"PageSize={page_size}", "-o", f"PageSize={page_size}",
"-o", "StpiShrinkOutput=Crop", "-o", "StpiShrinkOutput=Crop",
str(chemin_print),
] ]
if rembobinage:
cmd.extend(["-o", "StpiDecklist=true"])
cmd.append(str(chemin_print))
code, out, err = _run(cmd, timeout=30) code, out, err = _run(cmd, timeout=30)

186
backend/relais.py Normal file
View File

@@ -0,0 +1,186 @@
"""Pilotage module relais USB HID (LCUS 5131:2007).
4 canaux, protocole LCUS :
ON : 0x00 0xA0 <ch> 0x01 <checksum> 0x00 0x00 0x00 0x00
OFF : 0x00 0xA0 <ch> 0x00 <checksum> 0x00 0x00 0x00 0x00
Status : 0x00 0xD1 ...
Assignation :
1 = Projecteur gauche (NO)
2 = Projecteur droit (NO)
3 = Canon alim (NF — OFF=alimenté, ON=coupé)
4 = Libre
"""
import ctypes
import ctypes.util
import logging
import threading
import time
log = logging.getLogger("photobooth.relais")
VID = 0x5131
PID = 0x2007
CANAUX = {
"projecteur_gauche": {"relay": 1, "mode": "NO"},
"projecteur_droit": {"relay": 2, "mode": "NO"},
"canon": {"relay": 3, "mode": "NF"}, # dummy battery
"canon_usb": {"relay": 4, "mode": "NF"}, # VBUS USB
}
_lib = None
_dev = None
_lock = threading.Lock()
def _charger_hidapi():
global _lib
if _lib is not None:
return _lib
for name in ("hidapi-hidraw", "hidapi-libusb", "hidapi"):
path = ctypes.util.find_library(name)
if path:
_lib = ctypes.CDLL(path)
_lib.hid_init()
_lib.hid_open.restype = ctypes.c_void_p
_lib.hid_read_timeout.restype = ctypes.c_int
log.info(f"hidapi chargé : {path}")
return _lib
try:
_lib = ctypes.CDLL("libhidapi-hidraw.so.0")
_lib.hid_init()
_lib.hid_open.restype = ctypes.c_void_p
_lib.hid_read_timeout.restype = ctypes.c_int
return _lib
except OSError:
log.warning("hidapi introuvable — relais désactivés")
return None
def connecter():
global _dev
lib = _charger_hidapi()
if not lib:
return False
with _lock:
if _dev:
return True
_dev = lib.hid_open(VID, PID, None)
if not _dev:
log.warning("Module relais non trouvé (5131:2007)")
_dev = None
return False
log.info("Module relais connecté")
return True
def deconnecter():
global _dev
with _lock:
if _dev and _lib:
_lib.hid_close(_dev)
_dev = None
def est_connecte():
return _dev is not None
def _ecrire(data: list[int]):
if not _dev or not _lib:
return False
buf = (ctypes.c_ubyte * 9)(*data)
with _lock:
res = _lib.hid_write(_dev, buf, 9)
return res > 0
def _relay_set(num: int, on: bool):
state = 0x01 if on else 0x00
checksum = (0xA0 + num + state) & 0xFF
ok = _ecrire([0x00, 0xA0, num, state, checksum, 0x00, 0x00, 0x00, 0x00])
if ok:
log.info(f"Relais {num} → {'ON' if on else 'OFF'}")
else:
log.error(f"Échec écriture relais {num}")
return ok
def status():
if not _dev or not _lib:
return None
_ecrire([0x00, 0xD1, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00])
time.sleep(0.1)
rbuf = (ctypes.c_ubyte * 64)()
with _lock:
n = _lib.hid_read_timeout(_dev, rbuf, 64, 500)
if n < 5:
return None
return {
"relay_1": rbuf[1] == 1,
"relay_2": rbuf[2] == 1,
"relay_3": rbuf[3] == 1,
"relay_4": rbuf[4] == 1,
}
def activer(nom: str):
cfg = CANAUX.get(nom)
if not cfg:
return False
return _relay_set(cfg["relay"], True)
def desactiver(nom: str):
cfg = CANAUX.get(nom)
if not cfg:
return False
return _relay_set(cfg["relay"], False)
def projecteurs(on: bool):
_relay_set(1, on)
_relay_set(2, on)
log.info(f"Projecteurs {'allumés' if on else 'éteints'}")
def power_cycle_canon(duree: float = 5.0):
"""Cycle complet R3+R4 : coupe dummy battery + VBUS, reboot Canon.
Résistance de décharge 470Ω sur le 8V → condensateurs vidés en ~3s."""
log.info(f"Power-cycle Canon R3+R4 ({duree}s)")
_relay_set(3, True) # R3 ON = coupe dummy battery (NF)
_relay_set(4, True) # R4 ON = coupe VBUS USB (NF)
time.sleep(duree) # résistance vide les caps en ~3s
_relay_set(3, False) # R3 OFF = dummy battery → Canon boot
time.sleep(15) # attente boot Canon
_relay_set(4, False) # R4 OFF = VBUS → USB enumeration
log.info("Canon ré-alimenté — attente USB enumeration")
def hard_reset_canon(duree: float = 10.0):
"""Reset prolongé pour freeze PTP sévère.
Résistance de décharge 470Ω → 5s suffisent, mais on garde une marge."""
log.warning(f"Hard reset Canon — coupure R3+R4 ({duree}s)")
_relay_set(3, True) # R3 ON = coupe dummy battery
_relay_set(4, True) # R4 ON = coupe VBUS
time.sleep(duree) # vidange complète avec résistance
_relay_set(3, False) # R3 OFF = dummy battery → Canon boot
time.sleep(15) # attente boot Canon
_relay_set(4, False) # R4 OFF = VBUS → USB enumeration
log.info("Hard reset Canon terminé — attente démarrage")
def etat_complet():
raw = status()
if raw is None:
return {"connecte": False}
result = {"connecte": True}
for nom, cfg in CANAUX.items():
relay_on = raw.get(f"relay_{cfg['relay']}", False)
if cfg["mode"] == "NF":
result[nom] = not relay_on
else:
result[nom] = relay_on
return result

View File

@@ -417,6 +417,17 @@ html, body {
100% { opacity: 0; } 100% { opacity: 0; }
} }
.surprise-overlay {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
background: #000;
z-index: 19;
display: flex;
align-items: center;
justify-content: center;
}
.capture-en-cours { .capture-en-cours {
position: absolute; position: absolute;
inset: 0; inset: 0;
@@ -889,6 +900,30 @@ html, body {
overflow: hidden; overflow: hidden;
} }
.admin-rubriques {
display: flex;
gap: 0;
background: rgba(0,0,0,0.3);
border-bottom: 1px solid rgba(255,255,255,0.08);
}
.rubrique {
flex: 1;
background: transparent;
border: none;
color: var(--texte-secondaire);
padding: 0.8rem 1rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: var(--transition);
border-bottom: 3px solid transparent;
}
.rubrique.actif {
color: var(--primaire);
border-bottom-color: var(--primaire);
background: rgba(255,255,255,0.03);
}
.admin-onglets { .admin-onglets {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -898,6 +933,56 @@ html, body {
background: var(--fond-carte); background: var(--fond-carte);
overflow-y: auto; overflow-y: auto;
} }
.admin-onglets.cache { display: none; }
.conso-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
margin-bottom: 1.5rem;
}
.conso-card {
background: rgba(255,255,255,0.04);
border-radius: 12px;
padding: 1.2rem;
text-align: center;
}
.conso-icon { font-size: 2rem; margin-bottom: 0.3rem; }
.conso-titre { font-weight: 600; margin-bottom: 0.8rem; font-size: 0.95rem; }
.conso-barre-fond {
height: 12px;
background: rgba(255,255,255,0.08);
border-radius: 6px;
overflow: hidden;
margin-bottom: 0.5rem;
}
.conso-barre {
height: 100%;
border-radius: 6px;
transition: width 0.5s ease, background 0.5s ease;
}
.conso-chiffres { font-size: 0.85rem; color: var(--texte-secondaire); margin-bottom: 0.8rem; }
.conso-gros { font-size: 2.5rem; font-weight: 800; margin: 0.5rem 0; }
.conso-diag {
background: rgba(255,255,255,0.03);
border-radius: 8px;
padding: 1rem;
font-size: 0.85rem;
line-height: 1.6;
}
.video-situation-bloc {
background: rgba(255,255,255,0.03);
border-radius: 8px;
padding: 1rem;
margin-bottom: 0.8rem;
}
.video-situation-bloc h4 { margin-bottom: 0.5rem; }
.video-situation-current {
font-size: 0.85rem;
color: var(--texte-secondaire);
margin-bottom: 0.5rem;
}
.onglet { .onglet {
background: transparent; background: transparent;
@@ -2161,6 +2246,38 @@ h3 {
cursor: pointer; cursor: pointer;
} }
/* === Diagnostic Panel === */
.diag-cards{display:grid;grid-template-columns:repeat(4,1fr);gap:.8rem}
.diag-card{background:rgba(255,255,255,.06);border-radius:12px;padding:1rem;text-align:center;border:2px solid transparent;transition:border-color .3s}
.diag-card.ok{border-color:#4caf50}
.diag-card.warn{border-color:#ff9800}
.diag-card.err{border-color:#f44336}
.diag-card.off{border-color:#666}
.diag-icon{font-size:2rem;margin-bottom:.3rem}
.diag-label{font-size:.85rem;color:var(--texte-secondaire)}
.diag-status{font-size:.75rem;margin-top:.3rem;font-weight:600}
.diag-card.ok .diag-status{color:#4caf50}
.diag-card.warn .diag-status{color:#ff9800}
.diag-card.err .diag-status{color:#f44336}
.diag-card.off .diag-status{color:#888}
.diag-actions{display:flex;flex-wrap:wrap;gap:.6rem}
.diag-actions button{flex:1;min-width:140px;font-size:.85rem}
.diag-logs{font-family:monospace;font-size:.65rem;background:#0a0a0a;color:#0f0;padding:.8rem;border-radius:8px;white-space:pre-wrap;max-height:350px;overflow-y:auto;word-break:break-all;line-height:1.4}
.diag-usb{font-family:monospace;font-size:.75rem;background:#0a0a0a;color:#ccc;padding:.6rem;border-radius:6px;white-space:pre-wrap}
/* === Camera/Printer error overlay (user-facing, full-screen) === */
.erreur-overlay{position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,.92);display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:2rem;gap:1.5rem}
.erreur-overlay .erreur-icon{font-size:5rem}
.erreur-overlay .erreur-titre{font-size:2rem;font-weight:700;color:#fff}
.erreur-overlay .erreur-msg{font-size:1.3rem;color:#ccc;max-width:600px;line-height:1.5}
.erreur-overlay .erreur-steps{text-align:left;font-size:1.1rem;color:#eee;max-width:500px;line-height:2}
.erreur-overlay .erreur-steps li{margin-bottom:.5rem}
.erreur-overlay .erreur-spinner{width:60px;height:60px;border:5px solid #333;border-top-color:var(--primaire);border-radius:50%;animation:spin 1s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
.erreur-overlay .erreur-btn{padding:1rem 2.5rem;font-size:1.3rem;border-radius:1rem;border:none;cursor:pointer;margin-top:1rem}
.erreur-overlay .erreur-btn-primaire{background:var(--primaire);color:#fff}
.erreur-overlay .erreur-btn-secondaire{background:#333;color:#fff}
/* Responsive tactile */ /* Responsive tactile */
@media (max-width: 800px) { @media (max-width: 800px) {
.accueil-contenu h1 { font-size: 2.5rem; } .accueil-contenu h1 { font-size: 2.5rem; }

View File

@@ -7,7 +7,7 @@
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate"> <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta name="google" content="notranslate"> <meta name="google" content="notranslate">
<meta http-equiv="Content-Language" content="fr"> <meta http-equiv="Content-Language" content="fr">
<link rel="stylesheet" href="/css/style.css?v=12"> <link rel="stylesheet" href="/css/style.css?v=15">
<link rel="stylesheet" href="/css/themes.css?v=2"> <link rel="stylesheet" href="/css/themes.css?v=2">
</head> </head>
<body> <body>
@@ -20,12 +20,19 @@
<button onclick="document.getElementById('printer-erreur').classList.add('cache')">&#10005;</button> <button onclick="document.getElementById('printer-erreur').classList.add('cache')">&#10005;</button>
</div> </div>
<!-- Overlay erreur camera --> <!-- Overlay erreur equipement (camera/imprimante) -->
<div id="camera-erreur" class="camera-erreur cache"> <div id="erreur-equipement" class="erreur-overlay cache">
<div class="camera-erreur-icon">&#128247;</div> <div class="erreur-icon" id="erreur-equip-icon">&#128247;</div>
<p>Probleme de communication avec l'appareil photo</p> <div class="erreur-titre" id="erreur-equip-titre">Preparation en cours</div>
<span class="camera-erreur-sub">Reconnexion en cours...</span> <div class="erreur-msg" id="erreur-equip-msg">L'appareil photo se prepare, un instant...</div>
<button onclick="document.getElementById('camera-erreur').classList.add('cache'); afficherEcran('accueil')" style="margin-top:2rem;padding:1rem 2rem;font-size:1.5rem;border-radius:1rem;border:none;background:#e91e63;color:#fff;cursor:pointer;">Retour accueil</button> <div class="erreur-spinner" id="erreur-equip-spinner"></div>
<div class="erreur-steps cache" id="erreur-equip-steps"></div>
<div id="erreur-equip-actions" style="display:flex;gap:1rem;flex-wrap:wrap;justify-content:center">
<button class="erreur-btn erreur-btn-primaire cache" id="erreur-equip-btn-retry" onclick="erreurEquipRetry()">Reessayer</button>
<button class="erreur-btn erreur-btn-secondaire cache" id="erreur-equip-btn-video" onclick="erreurEquipVideo()">Voir l'aide video</button>
<button class="erreur-btn erreur-btn-secondaire cache" id="erreur-equip-btn-restart" onclick="erreurEquipRestart()">Redemarrer la borne</button>
<button class="erreur-btn erreur-btn-secondaire" id="erreur-equip-btn-accueil" onclick="fermerErreurEquipement()">Retour accueil</button>
</div>
</div> </div>
<!-- Ecran d'accueil --> <!-- Ecran d'accueil -->
@@ -98,6 +105,7 @@
<span id="chiffre-car">3</span> <span id="chiffre-car">3</span>
</div> </div>
<div id="flash-blanc" class="flash-blanc cache"></div> <div id="flash-blanc" class="flash-blanc cache"></div>
<div id="surprise-overlay" class="surprise-overlay cache"></div>
<div id="capture-en-cours" class="capture-en-cours cache"> <div id="capture-en-cours" class="capture-en-cours cache">
<div class="capture-spinner"></div> <div class="capture-spinner"></div>
<span>Capture en cours...</span> <span>Capture en cours...</span>
@@ -371,18 +379,28 @@
<button class="btn-fermer" onclick="allerA('accueil')">&times;</button> <button class="btn-fermer" onclick="allerA('accueil')">&times;</button>
</div> </div>
<div class="admin-contenu"> <div class="admin-contenu">
<div class="admin-onglets"> <div class="admin-rubriques">
<button class="rubrique actif" data-rubrique="general" onclick="changerRubrique('general')">General</button>
<button class="rubrique" data-rubrique="evenement" onclick="changerRubrique('evenement')">Evenement</button>
</div>
<div class="admin-onglets" id="onglets-general">
<button class="onglet actif" data-onglet="materiel">Materiel</button> <button class="onglet actif" data-onglet="materiel">Materiel</button>
<button class="onglet" data-onglet="compteur-admin">Compteur</button> <button class="onglet" data-onglet="compteur-admin">Compteur</button>
<button class="onglet" data-onglet="destinations-admin">Destinations</button> <button class="onglet" data-onglet="consommables-admin" onclick="chargerConsommables()">Consommables</button>
<button class="onglet" data-onglet="personnalisation">Personnalisation</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="admin-galerie" onclick="chargerAdminGalerie()">Galerie</button> <button class="onglet" data-onglet="personnalisation">Personnalisation</button>
<button class="onglet" data-onglet="eclairage" onclick="demarrerEclairageLive()">Eclairage</button> <button class="onglet" data-onglet="videos-admin" onclick="chargerVideosAdmin()">Videos</button>
<button class="onglet" data-onglet="eclairage" onclick="demarrerEclairageLive();demarrerEclairageLiveExt()">Eclairage</button>
<button class="onglet" data-onglet="wifi" onclick="chargerWifi()">WiFi</button> <button class="onglet" data-onglet="wifi" onclick="chargerWifi()">WiFi</button>
<button class="onglet" data-onglet="diagnostic" onclick="chargerDiagnostic()">Diagnostic</button>
<button class="onglet" data-onglet="infos" onclick="chargerInfosSysteme()">Infos</button> <button class="onglet" data-onglet="infos" onclick="chargerInfosSysteme()">Infos</button>
</div> </div>
<div class="admin-onglets cache" id="onglets-evenement">
<button class="onglet" data-onglet="evenement">Config</button>
<button class="onglet" data-onglet="destinations-admin">Destinations</button>
<button class="onglet" data-onglet="surprise-admin">Surprise</button>
<button class="onglet" data-onglet="admin-galerie" onclick="chargerAdminGalerie()">Galerie</button>
</div>
<!-- Panneau Materiel (Camera + Imprimante) --> <!-- Panneau Materiel (Camera + Imprimante) -->
<div class="admin-panneau actif" id="panneau-materiel"> <div class="admin-panneau actif" id="panneau-materiel">
@@ -429,6 +447,9 @@
<label>Nombre max d'exemplaires</label> <label>Nombre max d'exemplaires</label>
<input type="number" id="admin-copies-max" min="1" max="20" value="5"> <input type="number" id="admin-copies-max" min="1" max="20" value="5">
</div> </div>
<div class="champ">
<label class="toggle"><input type="checkbox" id="tog-rembobinage-ruban"><span class="toggle-slider"></span> Rembobinage ruban (economise le ruban sur les petits formats)</label>
</div>
<button class="btn-action" onclick="sauvegarderMateriel()">Sauvegarder</button> <button class="btn-action" onclick="sauvegarderMateriel()">Sauvegarder</button>
<div class="champ" style="margin-top:1rem;display:flex;gap:0.8rem;flex-wrap:wrap"> <div class="champ" style="margin-top:1rem;display:flex;gap:0.8rem;flex-wrap:wrap">
<button class="btn-danger" onclick="evacuerBourrage()">&#9888; Annuler jobs</button> <button class="btn-danger" onclick="evacuerBourrage()">&#9888; Annuler jobs</button>
@@ -453,9 +474,10 @@
<span id="admin-compteur-limite-display" class="compteur-nombre petit">400</span> <span id="admin-compteur-limite-display" class="compteur-nombre petit">400</span>
</div> </div>
<span class="compteur-label">photos restantes</span> <span class="compteur-label">photos restantes</span>
<span id="admin-compteur-detail" class="compteur-label" style="font-size:0.7em;opacity:0.7;margin-top:2px"></span>
</div> </div>
<div class="champ"> <div class="champ">
<label>Limite totale</label> <label>Limite evenement (0 = pas de limite)</label>
<input type="number" id="admin-compteur-limite" min="1" max="9999" value="400"> <input type="number" id="admin-compteur-limite" min="1" max="9999" value="400">
</div> </div>
<div class="champ-row"> <div class="champ-row">
@@ -464,6 +486,54 @@
</div> </div>
</div> </div>
<!-- Panneau Consommables -->
<div class="admin-panneau" id="panneau-consommables-admin">
<h3>Suivi consommables</h3>
<p class="aide">Suivez l'usure du papier et du ruban. Reinitalisez quand vous changez une bobine.</p>
<div class="conso-grid">
<div class="conso-card">
<div class="conso-icon">&#128196;</div>
<div class="conso-titre">Papier</div>
<div class="conso-barre-fond"><div class="conso-barre conso-barre-papier" id="conso-barre-papier"></div></div>
<div class="conso-chiffres"><span id="conso-papier-restant">--</span> / <span id="conso-papier-cap">--</span> feuilles</div>
<button class="btn-secondaire btn-petit" onclick="resetConsommable('papier')">Bobine changee</button>
</div>
<div class="conso-card">
<div class="conso-icon">&#127912;</div>
<div class="conso-titre">Ruban</div>
<div class="conso-barre-fond"><div class="conso-barre conso-barre-ruban" id="conso-barre-ruban"></div></div>
<div class="conso-chiffres"><span id="conso-ruban-restant">--</span> / <span id="conso-ruban-cap">--</span> poses</div>
<button class="btn-secondaire btn-petit" onclick="resetConsommable('ruban')">Ruban change</button>
</div>
<div class="conso-card">
<div class="conso-icon">&#128247;</div>
<div class="conso-titre">Photos sorties</div>
<div class="conso-gros" id="conso-photos">--</div>
</div>
<div class="conso-card" id="conso-card-perdues">
<div class="conso-icon">&#9888;</div>
<div class="conso-titre">Poses perdues</div>
<div class="conso-gros" id="conso-perdues" style="color:#fb8c00">--</div>
<div class="conso-chiffres">ruban gaspille sans rembobinage</div>
</div>
</div>
<h3 style="margin-top:1.5rem">Capacites</h3>
<div class="champ">
<label>Capacite papier (feuilles par bobine)</label>
<input type="number" id="conso-cap-papier" min="1" max="9999" value="400">
</div>
<div class="champ">
<label>Capacite ruban (poses par bobine)</label>
<input type="number" id="conso-cap-ruban" min="1" max="9999" value="400">
</div>
<button class="btn-action" onclick="sauvegarderCapacites()">Sauvegarder capacites</button>
<button class="btn-danger" style="margin-top:0.5rem" onclick="resetConsommable('tout')">Tout reinitialiser</button>
<div class="conso-diag" id="conso-diagnostic" style="margin-top:1.5rem"></div>
</div>
<!-- Panneau Destinations --> <!-- Panneau Destinations -->
<div class="admin-panneau" id="panneau-destinations-admin"> <div class="admin-panneau" id="panneau-destinations-admin">
<h3>Ou sauvegarder les photos ?</h3> <h3>Ou sauvegarder les photos ?</h3>
@@ -632,7 +702,15 @@
<label>Couleur secondaire</label> <label>Couleur secondaire</label>
<input type="color" id="admin-couleur-secondaire" value="#ffffff"> <input type="color" id="admin-couleur-secondaire" value="#ffffff">
</div> </div>
<button class="btn-action" onclick="sauvegarderEvenement()">Sauvegarder</button> <div class="champ">
<label>Date de fin (impression bloquee apres)</label>
<input type="date" id="admin-date-fin">
</div>
<div class="champ-row">
<button class="btn-action" onclick="sauvegarderEvenement()">Sauvegarder</button>
<button class="btn-danger" id="btn-terminer-event" onclick="terminerEvenement()">Terminer l'evenement</button>
</div>
<div id="badge-event-termine" class="cache" style="background:#f44336;color:#fff;padding:8px 16px;border-radius:8px;text-align:center;font-weight:bold;margin-top:8px">Evenement termine — impression desactivee</div>
<div class="champ" id="lien-galerie-event" style="display:none"> <div class="champ" id="lien-galerie-event" style="display:none">
<label>Galerie en ligne</label> <label>Galerie en ligne</label>
@@ -829,6 +907,82 @@
<div id="eclairage-visages" style="font-size:.9rem;color:#aaa">--</div> <div id="eclairage-visages" style="font-size:.9rem;color:#aaa">--</div>
</div> </div>
</div> </div>
<h3 style="margin-top:1.5rem">Relais</h3>
<div id="relais-status" style="margin-bottom:1rem;padding:0.5rem;background:#111;border-radius:6px;font-size:0.85rem;color:#aaa">Non connecte</div>
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;margin-bottom:1rem">
<button class="btn-action btn-petit" onclick="relaisAction('projecteurs','on')">Projecteurs ON</button>
<button class="btn-secondaire btn-petit" onclick="relaisAction('projecteurs','off')">Projecteurs OFF</button>
<button class="btn-danger btn-petit" onclick="relaisAction('canon/power-cycle','on')">Power-cycle Canon</button>
<button class="btn-danger btn-petit" style="background:#b71c1c" onclick="relaisAction('canon/hard-reset','on')">Hard Reset Canon</button>
</div>
<h4>Eclairage automatique</h4>
<div class="champ">
<label class="toggle"><input type="checkbox" id="tog-eclairage-auto"><span class="toggle-slider"></span> Allumer les projecteurs automatiquement selon la luminosite</label>
</div>
<div class="champ">
<label>Seuil projecteur 1 (score &lt;)</label>
<input type="number" id="relais-seuil-proj1" min="0" max="100" value="45" step="5">
<span style="font-size:0.75rem;color:#888">1 seul projecteur s'allume</span>
</div>
<div class="champ">
<label>Seuil projecteur 2 (score &lt;)</label>
<input type="number" id="relais-seuil-proj2" min="0" max="100" value="30" step="5">
<span style="font-size:0.75rem;color:#888">Les 2 projecteurs s'allument</span>
</div>
<div class="champ">
<label>Seuil extinction (score &gt;)</label>
<input type="number" id="relais-seuil-off" min="0" max="100" value="65" step="5">
<span style="font-size:0.75rem;color:#888">Projecteurs s'eteignent</span>
</div>
<h4 style="margin-top:1.5rem">Veille</h4>
<div class="champ">
<label>Eteindre les projecteurs apres (minutes d'inactivite)</label>
<input type="number" id="relais-veille-delai" min="0" max="60" value="0" step="1">
<span style="font-size:0.75rem;color:#888">0 = jamais. Les projecteurs se rallument quand un utilisateur touche l'ecran</span>
</div>
<button class="btn-action" onclick="sauvegarderRelaisConfig()">Sauvegarder</button>
</div>
<!-- Panneau Diagnostic -->
<div class="admin-panneau" id="panneau-diagnostic">
<h3>Sante du systeme</h3>
<div class="diag-cards" id="diag-sante">
<div class="diag-card" id="diag-camera">
<div class="diag-icon">&#128247;</div>
<div class="diag-label">Camera</div>
<div class="diag-status" id="diag-camera-status">--</div>
</div>
<div class="diag-card" id="diag-imprimante">
<div class="diag-icon">&#128424;</div>
<div class="diag-label">Imprimante</div>
<div class="diag-status" id="diag-imp-status">--</div>
</div>
<div class="diag-card" id="diag-relais">
<div class="diag-icon">&#128268;</div>
<div class="diag-label">Relais</div>
<div class="diag-status" id="diag-relais-status">--</div>
</div>
<div class="diag-card" id="diag-systeme">
<div class="diag-icon">&#128187;</div>
<div class="diag-label">Systeme</div>
<div class="diag-status" id="diag-sys-status">--</div>
</div>
</div>
<h3 style="margin-top:1.5rem">Actions</h3>
<div class="diag-actions">
<button class="btn-action" onclick="diagReconnecterCamera()">&#128247; Reconnecter camera</button>
<button class="btn-action" onclick="diagReactiverImprimante()">&#128424; Reactiver imprimante</button>
<button class="btn-secondaire" onclick="diagRedemarrerBackend()">&#8635; Redemarrer backend</button>
<button class="btn-secondaire" onclick="diagRefreshChromium()">&#127760; Recharger navigateur</button>
<button class="btn-danger" onclick="if(confirm('Redemarrer la borne ?'))diagRedemarrerSysteme()">&#9888; Redemarrer la borne</button>
</div>
<h3 style="margin-top:1.5rem">Logs backend <button class="btn-secondaire btn-petit" onclick="chargerLogs()" style="margin-left:0.5rem">&#8635; Actualiser</button></h3>
<div id="diag-logs" class="diag-logs">Appuyer sur Actualiser...</div>
<h3 style="margin-top:1.5rem">Peripheriques USB <button class="btn-secondaire btn-petit" onclick="chargerUsb()" style="margin-left:0.5rem">&#8635;</button></h3>
<div id="diag-usb" class="diag-usb">--</div>
</div> </div>
<div class="admin-panneau" id="panneau-infos"> <div class="admin-panneau" id="panneau-infos">
@@ -846,10 +1000,71 @@
<div id="diag-camera-contenu" style="font-family:monospace;font-size:0.75rem;background:#111;color:#0f0;padding:0.75rem;border-radius:6px;white-space:pre-wrap;max-height:300px;overflow-y:auto"> <div id="diag-camera-contenu" style="font-family:monospace;font-size:0.75rem;background:#111;color:#0f0;padding:0.75rem;border-radius:6px;white-space:pre-wrap;max-height:300px;overflow-y:auto">
Appuyer sur Tester... Appuyer sur Tester...
</div> </div>
<div class="admin-panneau" id="panneau-videos-admin">
<h3>Videos didactiques</h3>
<p class="aide">Chaque situation peut contenir plusieurs etapes video. L'operateur verra les videos en sequence : chaque etape boucle jusqu'a validation avant de passer a la suivante.</p>
<div id="video-situations-liste"></div>
<div style="margin-top:1rem;padding:0.75rem;background:#1a1a2e;border-radius:8px;border:1px dashed #444">
<h4 style="margin:0 0 0.5rem">Ajouter une situation</h4>
<div style="display:flex;gap:0.5rem;align-items:center;flex-wrap:wrap">
<input type="text" id="nouvelle-situation-label" placeholder="Nom de la situation" style="flex:1;min-width:150px;padding:0.4rem 0.6rem;border-radius:6px;border:1px solid #555;background:#111;color:#fff">
<button class="btn-action btn-petit" onclick="creerSituation()">+ Ajouter</button>
</div>
</div>
<h4 style="margin-top:1.5rem">Apercu</h4>
<video id="video-situation-preview" style="width:100%;max-height:300px;border-radius:8px;background:#111" controls></video>
<div style="margin-top:1rem">
<button class="btn-action" onclick="lancerWizardDepuisAdmin()">&#9654; Tester le wizard sur le booth</button>
</div>
</div>
<div class="admin-panneau" id="panneau-surprise-admin">
<h3>Photo / Video surprise</h3>
<p class="aide">Media affiche sur l'ecran ~1 seconde avant la prise de photo pour surprendre et provoquer un sourire.</p>
<div class="champ">
<label class="toggle"><input type="checkbox" id="tog-surprise-actif"><span class="toggle-slider"></span> Surprise active</label>
</div>
<div class="champ">
<label>Type de surprise</label>
<div class="radio-group">
<label><input type="radio" name="surprise-type" value="photo" checked> Photo</label>
<label><input type="radio" name="surprise-type" value="video"> Video</label>
</div>
</div>
<div class="champ">
<label>Media actuel</label>
<div id="surprise-current">Aucun media configure</div>
</div>
<div class="champ">
<input type="file" id="input-surprise-media" accept=".jpg,.jpeg,.png,.gif,.mp4,.webm" class="input-fichier">
<button class="btn-action" onclick="uploaderSurprise()">Importer</button>
<button class="btn-danger btn-petit" onclick="supprimerSurprise()">Supprimer</button>
</div>
<div class="champ">
<label>Delai avant capture (ms)</label>
<input type="number" id="admin-surprise-delai" min="500" max="3000" value="1000" step="100">
</div>
<button class="btn-action" onclick="sauvegarderSurprise()">Sauvegarder</button>
<h4 style="margin-top:1.5rem">Apercu</h4>
<div id="surprise-preview" style="width:100%;max-height:300px;border-radius:8px;background:#111;display:flex;align-items:center;justify-content:center;min-height:150px;color:#666">
Aucun apercu
</div>
</div>
</div> </div>
</div> </div>
</section> </section>
<!-- Wizard video didactique plein ecran -->
<div id="wizard-video" class="popup-overlay cache" style="z-index:9999;background:rgba(0,0,0,0.97);display:flex;flex-direction:column;align-items:center;justify-content:center">
<div id="wizard-titre" style="color:#fff;font-size:1.8rem;font-weight:700;margin-bottom:0.3rem;text-align:center"></div>
<div id="wizard-etape-info" style="color:#aaa;font-size:1rem;margin-bottom:1rem"></div>
<video id="wizard-video-player" muted playsinline loop style="width:90%;max-height:65vh;border-radius:12px;background:#000"></video>
<div id="wizard-etape-label" style="color:#ccc;font-size:1.1rem;margin-top:0.8rem;text-align:center;min-height:1.5rem"></div>
<button id="wizard-btn-ok" onclick="wizardEtapeSuivante()" style="margin-top:1.5rem;padding:1.2rem 3rem;font-size:1.5rem;font-weight:700;border:none;border-radius:16px;background:#4caf50;color:#fff;cursor:pointer;min-width:280px">OK, c'est bon !</button>
<button onclick="fermerWizard()" style="margin-top:0.8rem;padding:0.6rem 1.5rem;font-size:0.9rem;border:none;border-radius:8px;background:#333;color:#aaa;cursor:pointer">Quitter</button>
</div>
<!-- Popup sélection cadre impression --> <!-- Popup sélection cadre impression -->
<div id="popup-cadre-impression" class="popup-overlay cache"> <div id="popup-cadre-impression" class="popup-overlay cache">
<div class="popup-box popup-box-cadre"> <div class="popup-box popup-box-cadre">
@@ -930,11 +1145,11 @@
</style> </style>
<script src="/js/websocket.js?v=16"></script> <script src="/js/websocket.js?v=16"></script>
<script src="/js/app.js?v=18"></script> <script src="/js/app.js?v=24"></script>
<script src="/js/camera.js?v=17"></script> <script src="/js/camera.js?v=20"></script>
<script src="/js/effects.js?v=4"></script> <script src="/js/effects.js?v=4"></script>
<script src="/js/gallery.js?v=4"></script> <script src="/js/gallery.js?v=5"></script>
<script src="/js/share.js?v=8"></script> <script src="/js/share.js?v=9"></script>
<script src="/js/admin.js?v=9"></script> <script src="/js/admin.js?v=17"></script>
</body> </body>
</html> </html>

View File

@@ -66,6 +66,7 @@ async function chargerMateriel() {
const orient = (imp.orientations || {})[fmt] || 'portrait'; const orient = (imp.orientations || {})[fmt] || 'portrait';
document.querySelector(`input[name="admin-orientation"][value="${orient}"]`).checked = true; document.querySelector(`input[name="admin-orientation"][value="${orient}"]`).checked = true;
setValue('admin-copies-max', imp.copies_max || 5); setValue('admin-copies-max', imp.copies_max || 5);
document.getElementById('tog-rembobinage-ruban').checked = imp.rembobinage_ruban || false;
} }
async function rafraichirImprimantes() { async function rafraichirImprimantes() {
@@ -149,6 +150,7 @@ async function sauvegarderMateriel() {
format: getValue('admin-format-papier'), format: getValue('admin-format-papier'),
copies_max: parseInt(getValue('admin-copies-max')) || 5, copies_max: parseInt(getValue('admin-copies-max')) || 5,
orientations: _getOrientations(), orientations: _getOrientations(),
rembobinage_ruban: document.getElementById('tog-rembobinage-ruban').checked,
}, },
}); });
afficherStatut('Materiel sauvegarde', 'succes'); afficherStatut('Materiel sauvegarde', 'succes');
@@ -160,8 +162,11 @@ async function chargerCompteur() {
const etat = await apiGet('/api/compteur'); const etat = await apiGet('/api/compteur');
document.getElementById('tog-compteur-actif').checked = etat.actif; document.getElementById('tog-compteur-actif').checked = etat.actif;
document.getElementById('admin-compteur-restant').textContent = etat.restantes; document.getElementById('admin-compteur-restant').textContent = etat.restantes;
document.getElementById('admin-compteur-limite-display').textContent = etat.limite; const limiteAff = (etat.actif && etat.limite > 0) ? etat.limite : etat.capacite;
document.getElementById('admin-compteur-limite-display').textContent = limiteAff;
setValue('admin-compteur-limite', etat.limite); setValue('admin-compteur-limite', etat.limite);
const detail = document.getElementById('admin-compteur-detail');
if (detail) detail.textContent = `Papier: ${etat.papier_restant} | Ruban: ${etat.ruban_restant}`;
} }
async function sauvegarderCompteur() { async function sauvegarderCompteur() {
@@ -711,10 +716,14 @@ async function chargerListeEvenements() {
for (const ev of events) { for (const ev of events) {
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'event-item' + (ev.id === actifId ? ' event-actif' : ''); div.className = 'event-item' + (ev.id === actifId ? ' event-actif' : '');
const badges = [];
if (ev.id === actifId) badges.push('<span class="badge-actif">Actif</span>');
if (ev.termine) badges.push('<span style="background:#f44336;color:#fff;padding:2px 6px;border-radius:4px;font-size:0.7em">Termine</span>');
div.innerHTML = ` div.innerHTML = `
<span class="event-nom">${ev.nom}</span> <span class="event-nom">${ev.nom}${ev.date_fin ? ' <small style="opacity:0.5">(' + ev.date_fin + ')</small>' : ''}</span>
<span class="event-actions"> <span class="event-actions">
${ev.id !== actifId ? `<button class="btn-secondaire btn-petit" onclick="activerEventAdmin('${ev.id}')">Activer</button>` : '<span class="badge-actif">Actif</span>'} ${ev.id !== actifId ? `<button class="btn-secondaire btn-petit" onclick="activerEventAdmin('${ev.id}')">Activer</button>` : ''}
${badges.join(' ')}
<button class="btn-secondaire btn-petit" onclick="editerEventAdmin('${ev.id}')">Editer</button> <button class="btn-secondaire btn-petit" onclick="editerEventAdmin('${ev.id}')">Editer</button>
<button class="btn-danger btn-petit" onclick="supprimerEventAdmin('${ev.id}','${ev.nom}')">Suppr</button> <button class="btn-danger btn-petit" onclick="supprimerEventAdmin('${ev.id}','${ev.nom}')">Suppr</button>
</span>`; </span>`;
@@ -758,6 +767,7 @@ function afficherDetailEvent(event) {
setValue('admin-nom-event', event.nom); setValue('admin-nom-event', event.nom);
setValue('admin-couleur-primaire', event.couleur_primaire || '#e91e63'); setValue('admin-couleur-primaire', event.couleur_primaire || '#e91e63');
setValue('admin-couleur-secondaire', event.couleur_secondaire || '#ffffff'); setValue('admin-couleur-secondaire', event.couleur_secondaire || '#ffffff');
setValue('admin-date-fin', event.date_fin || '');
rafraichirMediaAccueil().then(() => { rafraichirMediaAccueil().then(() => {
const select = document.getElementById('admin-media-accueil'); const select = document.getElementById('admin-media-accueil');
if (event.media_accueil) select.value = event.media_accueil; if (event.media_accueil) select.value = event.media_accueil;
@@ -768,6 +778,17 @@ function afficherDetailEvent(event) {
document.getElementById('tog-event-fmt-15x20').checked = fmts.includes('15x20'); document.getElementById('tog-event-fmt-15x20').checked = fmts.includes('15x20');
document.getElementById('tog-event-fmt-strip').checked = fmts.includes('strip'); document.getElementById('tog-event-fmt-strip').checked = fmts.includes('strip');
// Statut termine
const btnTerminer = document.getElementById('btn-terminer-event');
const badgeTermine = document.getElementById('badge-event-termine');
if (event.termine) {
btnTerminer.classList.add('cache');
badgeTermine.classList.remove('cache');
} else {
btnTerminer.classList.remove('cache');
badgeTermine.classList.add('cache');
}
// Lien galerie en ligne // Lien galerie en ligne
const booth = config.booth || {}; const booth = config.booth || {};
const lienBloc = document.getElementById('lien-galerie-event'); const lienBloc = document.getElementById('lien-galerie-event');
@@ -785,6 +806,17 @@ function afficherDetailEvent(event) {
chargerCadresEvent(); chargerCadresEvent();
} }
async function terminerEvenement() {
if (!eventEditId) return;
if (!confirm('Terminer cet evenement ? L\'impression sera desactivee.')) return;
await apiPost(`/api/evenements/${eventEditId}/terminer`);
config = await apiGet('/api/config');
const event = await apiGet(`/api/evenements/${eventEditId}`);
afficherDetailEvent(event);
await chargerListeEvenements();
afficherStatut('Evenement termine — impression desactivee', 'succes');
}
async function supprimerEventAdmin(id, nom) { async function supprimerEventAdmin(id, nom) {
if (!confirm(`Supprimer l'evenement "${nom}" et ses cadres ?`)) return; if (!confirm(`Supprimer l'evenement "${nom}" et ses cadres ?`)) return;
await fetch(`/api/evenements/${id}`, { method: 'DELETE' }); await fetch(`/api/evenements/${id}`, { method: 'DELETE' });
@@ -823,9 +855,23 @@ async function uploaderMediaAccueil() {
const formData = new FormData(); const formData = new FormData();
formData.append('fichier', input.files[0]); formData.append('fichier', input.files[0]);
await fetch('/api/upload/animation', { method: 'POST', body: formData }); await fetch('/api/upload/animation', { method: 'POST', body: formData });
const nomFichier = input.files[0].name;
input.value = ''; input.value = '';
await rafraichirMediaAccueil(); await rafraichirMediaAccueil();
afficherStatut('Media importe', 'succes'); document.getElementById('admin-media-accueil').value = nomFichier;
// Applique automatiquement la video comme media d'accueil de l'evenement en cours
// (sinon il faut la re-selectionner dans le menu ET cliquer sur "Enregistrer" separement)
if (eventEditId) {
await apiPost('/api/config', { evenement: { media_accueil: nomFichier, event_id: eventEditId } });
await fetch(`/api/evenements/${eventEditId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ media_accueil: nomFichier }),
});
appliquerConfig();
}
afficherStatut('Media importe et applique', 'succes');
} }
async function sauvegarderEvenement() { async function sauvegarderEvenement() {
@@ -840,6 +886,7 @@ async function sauvegarderEvenement() {
couleur_primaire: getValue('admin-couleur-primaire'), couleur_primaire: getValue('admin-couleur-primaire'),
couleur_secondaire: getValue('admin-couleur-secondaire'), couleur_secondaire: getValue('admin-couleur-secondaire'),
media_accueil: getValue('admin-media-accueil') || null, media_accueil: getValue('admin-media-accueil') || null,
date_fin: getValue('admin-date-fin') || null,
formats_actifs, formats_actifs,
}; };
@@ -925,16 +972,19 @@ async function uploaderCadreEvent() {
const input = document.getElementById('input-cadre-event'); const input = document.getElementById('input-cadre-event');
if (!input.files.length) return; if (!input.files.length) return;
const formData = new FormData(); const nomFichier = input.files[0].name;
formData.append('fichier', input.files[0]);
for (const fmt of FORMATS_CADRE_EVENT) { for (const fmt of FORMATS_CADRE_EVENT) {
const fd = new FormData(); const fd = new FormData();
fd.append('fichier', input.files[0]); fd.append('fichier', input.files[0]);
await fetch(`/api/evenements/${eventEditId}/upload-cadre/${fmt}`, { method: 'POST', body: fd }); await fetch(`/api/evenements/${eventEditId}/upload-cadre/${fmt}`, { method: 'POST', body: fd });
} }
input.value = ''; input.value = '';
await chargerCadresEvent();
afficherStatut('Cadre importe (10x15 + 15x20)', 'succes'); // Applique automatiquement le cadre importe (sinon il reste inactif tant qu'on ne clique pas sur sa vignette)
const modeSelect = document.getElementById('mode-cadre-event');
if (modeSelect && modeSelect.value === 'aucun') modeSelect.value = 'impose';
await selectionnerCadreEvent(nomFichier);
afficherStatut('Cadre importe et applique (10x15 + 15x20)', 'succes');
} }
async function supprimerCadreEvent(nom) { async function supprimerCadreEvent(nom) {
@@ -1381,3 +1431,684 @@ async function chargerDiagCamera() {
el.textContent = 'ERREUR : ' + e.message; el.textContent = 'ERREUR : ' + e.message;
} }
} }
// === CONSOMMABLES ===
async function chargerConsommables() {
try {
const data = await apiGet('/api/consommables');
document.getElementById('conso-papier-restant').textContent = data.papier_restant;
document.getElementById('conso-papier-cap').textContent = data.papier_capacite;
document.getElementById('conso-ruban-restant').textContent = data.ruban_restant;
document.getElementById('conso-ruban-cap').textContent = data.ruban_capacite;
document.getElementById('conso-photos').textContent = data.photos_imprimees;
setValue('conso-cap-papier', data.papier_capacite);
setValue('conso-cap-ruban', data.ruban_capacite);
const pctPapier = data.papier_capacite > 0 ? Math.max(0, (data.papier_restant / data.papier_capacite) * 100) : 0;
const pctRuban = data.ruban_capacite > 0 ? Math.max(0, (data.ruban_restant / data.ruban_capacite) * 100) : 0;
const barrePapier = document.getElementById('conso-barre-papier');
const barreRuban = document.getElementById('conso-barre-ruban');
barrePapier.style.width = pctPapier + '%';
barreRuban.style.width = pctRuban + '%';
barrePapier.style.background = pctPapier < 10 ? '#f44336' : pctPapier < 25 ? '#fb8c00' : '#43a047';
barreRuban.style.background = pctRuban < 10 ? '#f44336' : pctRuban < 25 ? '#fb8c00' : '#43a047';
const perdues = data.poses_perdues || 0;
document.getElementById('conso-perdues').textContent = perdues;
const cardPerdues = document.getElementById('conso-card-perdues');
if (perdues > 0) {
document.getElementById('conso-perdues').style.color = '#f44336';
cardPerdues.style.borderLeft = '3px solid #f44336';
} else {
document.getElementById('conso-perdues').style.color = '#43a047';
cardPerdues.style.borderLeft = '3px solid #43a047';
}
const diag = document.getElementById('conso-diagnostic');
const papierUsed = data.papier_utilise;
const rubanUsed = data.ruban_utilise;
const photos = data.photos_imprimees;
let html = '<strong>Diagnostic consommables</strong><br>';
html += `Feuilles papier consommees : ${papierUsed}<br>`;
html += `Poses ruban consommees : ${rubanUsed}<br>`;
html += `Photos imprimees : ${photos}<br>`;
html += `Poses perdues (gaspillees) : ${perdues}<br>`;
if (papierUsed > 0) {
const ratio = (rubanUsed / papierUsed).toFixed(2);
html += `<br>Ratio ruban/papier : <strong>${ratio}</strong><br>`;
if (parseFloat(ratio) <= 0.75) {
html += '&#9989; <strong>Rembobinage efficace</strong> — le ruban est economise';
} else if (parseFloat(ratio) >= 0.95) {
html += '&#9888; <strong>Pas d\'economie ruban</strong> — activez le rembobinage dans Materiel';
} else {
html += '&#128260; Rembobinage partiel';
}
if (perdues > 0) {
const pctPerdu = ((perdues / (rubanUsed + perdues)) * 100).toFixed(0);
html += `<br>&#128308; ${pctPerdu}% du ruban gaspille (${perdues} poses pour rien)`;
}
}
if (data.papier_restant <= 20 || data.ruban_restant <= 20) {
html += '<br><br>&#128680; <strong>Consommable bientot epuise !</strong>';
}
if (data.ruban_restant < data.papier_restant - 10) {
html += '<br>&#9888; Le ruban va s\'epuiser avant le papier';
}
diag.innerHTML = html;
} catch (e) {
console.warn('Erreur chargement consommables:', e);
}
}
async function resetConsommable(quoi) {
const labels = { papier: 'Reinitialiser le compteur papier ?', ruban: 'Reinitialiser le compteur ruban ?', tout: 'Reinitialiser tous les compteurs ?' };
if (!confirm(labels[quoi] || 'Reinitialiser ?')) return;
await apiPost('/api/consommables/reset', { quoi });
chargerConsommables();
afficherStatut('Consommable reinitialise', 'succes');
}
async function sauvegarderCapacites() {
const papier = parseInt(getValue('conso-cap-papier')) || 400;
const ruban = parseInt(getValue('conso-cap-ruban')) || 400;
await apiPost('/api/consommables/capacite', { papier_capacite: papier, ruban_capacite: ruban });
chargerConsommables();
afficherStatut('Capacites sauvegardees', 'succes');
}
// === VIDEOS DIDACTIQUES (multi-etapes) ===
let _videosData = {};
async function chargerVideosAdmin() {
try {
const data = await apiGet('/api/videos');
_videosData = data.situations || {};
const container = document.getElementById('video-situations-liste');
if (!container) return;
container.innerHTML = '';
for (const [sid, sit] of Object.entries(_videosData)) {
container.appendChild(_creerBlocSituation(sid, sit));
}
} catch (e) {
console.warn('Erreur chargement videos:', e);
}
}
function _creerBlocSituation(sid, sit) {
const bloc = document.createElement('div');
bloc.className = 'video-situation-bloc';
bloc.style.cssText = 'margin-bottom:1rem;padding:0.75rem;background:#1a1a2e;border-radius:8px;border:1px solid #333';
const header = document.createElement('div');
header.style.cssText = 'display:flex;align-items:center;gap:0.75rem;margin-bottom:0.5rem;flex-wrap:wrap';
const toggle = document.createElement('label');
toggle.className = 'toggle';
toggle.style.cssText = 'margin:0;flex-shrink:0';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.checked = !!sit.actif;
cb.onchange = () => toggleSituation(sid, cb.checked);
const slider = document.createElement('span');
slider.className = 'toggle-slider';
toggle.appendChild(cb);
toggle.appendChild(slider);
const titre = document.createElement('h4');
titre.style.cssText = 'margin:0;flex:1;font-size:0.95rem';
titre.textContent = sit.label || sid;
const nbEtapes = document.createElement('span');
nbEtapes.style.cssText = 'color:#888;font-size:0.8rem';
nbEtapes.textContent = `${(sit.etapes || []).length} etape(s)`;
header.appendChild(toggle);
header.appendChild(titre);
header.appendChild(nbEtapes);
bloc.appendChild(header);
const etapesDiv = document.createElement('div');
etapesDiv.style.cssText = 'margin-left:0.5rem';
(sit.etapes || []).forEach((etape, i) => {
const row = document.createElement('div');
row.style.cssText = 'display:flex;align-items:center;gap:0.5rem;margin-bottom:0.4rem;padding:0.3rem 0.5rem;background:#111;border-radius:6px';
const num = document.createElement('span');
num.style.cssText = 'color:#888;font-size:0.8rem;min-width:1.5rem';
num.textContent = `${i + 1}.`;
const labelEtape = document.createElement('input');
labelEtape.type = 'text';
labelEtape.placeholder = `Etape ${i + 1}`;
labelEtape.value = (sit.labels_etapes && sit.labels_etapes[String(i)]) || '';
labelEtape.style.cssText = 'flex:1;padding:0.2rem 0.4rem;border-radius:4px;border:1px solid #444;background:#1a1a2e;color:#fff;font-size:0.8rem';
labelEtape.onchange = () => labeliserEtape(sid, i, labelEtape.value);
const btnPlay = document.createElement('button');
btnPlay.className = 'btn-secondaire btn-petit';
btnPlay.textContent = '▶';
btnPlay.title = 'Apercu';
btnPlay.onclick = () => previewEtape(sid, i);
const btnUp = document.createElement('button');
btnUp.className = 'btn-secondaire btn-petit';
btnUp.textContent = '↑';
btnUp.disabled = i === 0;
btnUp.onclick = () => reordonnerEtape(sid, i, i - 1);
const btnDown = document.createElement('button');
btnDown.className = 'btn-secondaire btn-petit';
btnDown.textContent = '↓';
btnDown.disabled = i === (sit.etapes || []).length - 1;
btnDown.onclick = () => reordonnerEtape(sid, i, i + 1);
const btnDel = document.createElement('button');
btnDel.className = 'btn-danger btn-petit';
btnDel.textContent = '✕';
btnDel.onclick = () => supprimerEtape(sid, i);
row.appendChild(num);
row.appendChild(labelEtape);
row.appendChild(btnPlay);
row.appendChild(btnUp);
row.appendChild(btnDown);
row.appendChild(btnDel);
etapesDiv.appendChild(row);
});
const ajoutRow = document.createElement('div');
ajoutRow.style.cssText = 'display:flex;align-items:center;gap:0.5rem;margin-top:0.4rem';
const inputFile = document.createElement('input');
inputFile.type = 'file';
inputFile.accept = '.mp4,.webm,.mov';
inputFile.className = 'input-fichier';
inputFile.id = 'input-etape-' + sid;
inputFile.style.cssText = 'flex:1;font-size:0.75rem';
const btnAjout = document.createElement('button');
btnAjout.className = 'btn-secondaire btn-petit';
btnAjout.textContent = '+ Etape';
btnAjout.onclick = () => uploaderEtape(sid);
ajoutRow.appendChild(inputFile);
ajoutRow.appendChild(btnAjout);
etapesDiv.appendChild(ajoutRow);
bloc.appendChild(etapesDiv);
return bloc;
}
async function toggleSituation(sid, actif) {
try {
await fetch(`/api/videos/${sid}/toggle`, {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ actif })
});
} catch (e) {
afficherStatut('Erreur toggle', 'erreur');
}
}
async function uploaderEtape(sid) {
const input = document.getElementById('input-etape-' + sid);
if (!input || !input.files.length) return;
const fd = new FormData();
fd.append('video', input.files[0]);
try {
const resp = await fetch(`/api/videos/${sid}/upload`, { method: 'POST', body: fd });
if (resp.ok) {
input.value = '';
chargerVideosAdmin();
afficherStatut('Etape ajoutee', 'succes');
}
} catch (e) {
afficherStatut('Erreur upload etape', 'erreur');
}
}
async function supprimerEtape(sid, index) {
try {
await fetch(`/api/videos/${sid}/${index}`, { method: 'DELETE' });
chargerVideosAdmin();
afficherStatut('Etape supprimee', 'succes');
} catch (e) {
afficherStatut('Erreur suppression', 'erreur');
}
}
async function reordonnerEtape(sid, de, vers) {
try {
await fetch(`/api/videos/${sid}/reorder`, {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ de, vers })
});
chargerVideosAdmin();
} catch (e) {
afficherStatut('Erreur reorder', 'erreur');
}
}
async function labeliserEtape(sid, index, label) {
try {
await fetch(`/api/videos/${sid}/label`, {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ index, label })
});
} catch (e) {
console.warn('Erreur label etape:', e);
}
}
function previewEtape(sid, index) {
const preview = document.getElementById('video-situation-preview');
if (preview) {
preview.src = `/api/videos/${sid}/${index}/stream`;
preview.load();
preview.play();
}
}
async function creerSituation() {
const input = document.getElementById('nouvelle-situation-label');
const label = (input && input.value || '').trim();
if (!label) return;
const id = label.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '');
try {
await fetch('/api/videos/situation/creer', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ id, label })
});
input.value = '';
chargerVideosAdmin();
afficherStatut('Situation ajoutee', 'succes');
} catch (e) {
afficherStatut('Erreur creation situation', 'erreur');
}
}
// === WIZARD VIDEO DIDACTIQUE ===
let _wizardSituation = null;
let _wizardEtapes = [];
let _wizardIndex = 0;
async function lancerWizard(situationId) {
const data = await apiGet('/api/videos');
const sit = (data.situations || {})[situationId];
if (!sit || !sit.etapes || sit.etapes.length === 0) {
afficherStatut('Aucune etape pour cette situation', 'erreur');
return;
}
_wizardSituation = { id: situationId, ...sit };
_wizardEtapes = sit.etapes;
_wizardIndex = 0;
_afficherEtapeWizard();
document.getElementById('wizard-video').classList.remove('cache');
}
function _afficherEtapeWizard() {
const titre = document.getElementById('wizard-titre');
const info = document.getElementById('wizard-etape-info');
const video = document.getElementById('wizard-video-player');
const label = document.getElementById('wizard-etape-label');
const btn = document.getElementById('wizard-btn-ok');
titre.textContent = _wizardSituation.label || _wizardSituation.id;
info.textContent = `Etape ${_wizardIndex + 1} / ${_wizardEtapes.length}`;
const etapeLabel = (_wizardSituation.labels_etapes || {})[String(_wizardIndex)] || '';
label.textContent = etapeLabel;
video.src = `/api/videos/${_wizardSituation.id}/${_wizardIndex}/stream`;
video.load();
video.play();
if (_wizardIndex === _wizardEtapes.length - 1) {
btn.textContent = 'Terminer ✓';
btn.style.background = '#2196f3';
} else {
btn.textContent = "OK, c'est bon !";
btn.style.background = '#4caf50';
}
}
function wizardEtapeSuivante() {
_wizardIndex++;
if (_wizardIndex >= _wizardEtapes.length) {
fermerWizard();
return;
}
_afficherEtapeWizard();
}
function fermerWizard() {
const overlay = document.getElementById('wizard-video');
const video = document.getElementById('wizard-video-player');
if (video) { video.pause(); video.src = ''; }
overlay.classList.add('cache');
_wizardSituation = null;
_wizardEtapes = [];
_wizardIndex = 0;
_arreterAutoDetect();
}
let _wizardAutoDetectTimer = null;
let _wizardAutoDetectType = null;
function lancerWizardSituation(situationId, autoDetectType) {
_wizardAutoDetectType = autoDetectType || null;
lancerWizard(situationId);
if (_wizardAutoDetectType) {
_demarrerAutoDetect();
}
}
function _demarrerAutoDetect() {
if (_wizardAutoDetectTimer) clearInterval(_wizardAutoDetectTimer);
_wizardAutoDetectTimer = setInterval(async () => {
try {
const s = await apiGet('/api/systeme/sante');
let resolu = false;
if (_wizardAutoDetectType === 'camera' && s.camera.status === 'ok') resolu = true;
if (_wizardAutoDetectType === 'imprimante' && s.imprimante.status === 'prete') resolu = true;
if (resolu) {
_arreterAutoDetect();
fermerWizard();
afficherStatut('Probleme resolu !', 'succes');
if (typeof fermerErreurEquipement === 'function') fermerErreurEquipement();
}
} catch (e) {}
}, 5000);
}
function _arreterAutoDetect() {
if (_wizardAutoDetectTimer) { clearInterval(_wizardAutoDetectTimer); _wizardAutoDetectTimer = null; }
_wizardAutoDetectType = null;
}
function lancerWizardDepuisAdmin() {
const actives = Object.entries(_videosData).filter(([_, s]) => s.actif && s.etapes && s.etapes.length > 0);
if (actives.length === 0) {
afficherStatut('Aucune situation active avec des etapes', 'erreur');
return;
}
lancerWizard(actives[0][0]);
}
// === SURPRISE (photo/video avant capture) ===
async function chargerSurpriseConfig() {
const surprise = config.surprise || {};
const tog = document.getElementById('tog-surprise-actif');
if (tog) tog.checked = !!surprise.actif;
const delai = document.getElementById('admin-surprise-delai');
if (delai) delai.value = surprise.delai_ms || 1000;
const typeRadios = document.querySelectorAll('input[name="surprise-type"]');
typeRadios.forEach(r => r.checked = r.value === (surprise.type || 'photo'));
const current = document.getElementById('surprise-current');
if (current) current.textContent = surprise.fichier || 'Aucun media configure';
majSurprisePreview(surprise);
}
function majSurprisePreview(surprise) {
const container = document.getElementById('surprise-preview');
if (!container) return;
if (!surprise || !surprise.fichier) {
container.innerHTML = '<span style="color:#666">Aucun apercu</span>';
return;
}
const url = '/api/surprise/media';
if (surprise.type === 'video') {
container.innerHTML = `<video src="${url}" style="width:100%;max-height:300px;border-radius:8px" controls></video>`;
} else {
container.innerHTML = `<img src="${url}" style="width:100%;max-height:300px;border-radius:8px;object-fit:contain">`;
}
}
async function uploaderSurprise() {
const input = document.getElementById('input-surprise-media');
if (!input || !input.files.length) return;
const fd = new FormData();
fd.append('media', input.files[0]);
try {
const resp = await fetch('/api/surprise/upload', { method: 'POST', body: fd });
if (resp.ok) {
const data = await resp.json();
input.value = '';
config = await apiGet('/api/config');
chargerSurpriseConfig();
afficherStatut('Media surprise importe', 'succes');
}
} catch (e) {
afficherStatut('Erreur upload surprise', 'erreur');
}
}
async function supprimerSurprise() {
try {
await fetch('/api/surprise/media', { method: 'DELETE' });
config = await apiGet('/api/config');
chargerSurpriseConfig();
afficherStatut('Surprise supprimee', 'succes');
} catch (e) {
afficherStatut('Erreur suppression surprise', 'erreur');
}
}
async function sauvegarderSurprise() {
const actif = document.getElementById('tog-surprise-actif')?.checked || false;
const delai = parseInt(document.getElementById('admin-surprise-delai')?.value) || 1000;
const type = document.querySelector('input[name="surprise-type"]:checked')?.value || 'photo';
await apiPost('/api/config', {
surprise: { actif, delai_ms: delai, type }
});
afficherStatut('Surprise sauvegardee', 'succes');
}
// === RELAIS ===
async function chargerRelaisStatus() {
try {
const data = await apiGet('/api/relais');
const el = document.getElementById('relais-status');
if (!el) return;
if (!data.connecte) {
el.textContent = 'Module relais non connecte';
el.style.color = '#f44';
return;
}
const proj_g = data.projecteur_gauche ? '🟢' : '⚫';
const proj_d = data.projecteur_droit ? '🟢' : '⚫';
const canon = data.canon ? '🟢' : '🔴';
el.innerHTML = `Proj G: ${proj_g} | Proj D: ${proj_d} | Canon: ${canon} (alim)`;
el.style.color = '#4caf50';
} catch (e) {
console.warn('Erreur relais status:', e);
}
}
async function chargerRelaisConfig() {
try {
const data = await apiGet('/api/relais/config');
const tog = document.getElementById('tog-eclairage-auto');
if (tog) tog.checked = !!data.eclairage_auto;
const s1 = document.getElementById('relais-seuil-proj1');
if (s1) s1.value = data.seuil_proj1 ?? 45;
const s2 = document.getElementById('relais-seuil-proj2');
if (s2) s2.value = data.seuil_proj2 ?? 30;
const soff = document.getElementById('relais-seuil-off');
if (soff) soff.value = data.seuil_off ?? 65;
const vd = document.getElementById('relais-veille-delai');
if (vd) vd.value = data.veille_delai ?? 0;
} catch (e) {
console.warn('Erreur relais config:', e);
}
}
async function sauvegarderRelaisConfig() {
const eclairage_auto = document.getElementById('tog-eclairage-auto')?.checked || false;
const seuil_proj1 = parseInt(document.getElementById('relais-seuil-proj1')?.value) || 45;
const seuil_proj2 = parseInt(document.getElementById('relais-seuil-proj2')?.value) || 30;
const seuil_off = parseInt(document.getElementById('relais-seuil-off')?.value) || 65;
const veille_delai = parseInt(document.getElementById('relais-veille-delai')?.value) || 0;
await apiPost('/api/relais/config', { eclairage_auto, seuil_proj1, seuil_proj2, seuil_off, veille_delai });
afficherStatut('Config relais sauvegardee', 'succes');
}
async function relaisAction(nom, action) {
try {
if (nom === 'canon/hard-reset') {
await fetch('/api/relais/canon/hard-reset', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({duree: 5})
});
afficherStatut('Hard reset Canon lance (trappe + alim)', 'succes');
} else if (nom === 'canon/power-cycle') {
await fetch('/api/relais/canon/power-cycle', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({duree: 3})
});
afficherStatut('Power-cycle Canon lance (3s)', 'succes');
} else {
await fetch(`/api/relais/${nom}/${action}`, { method: 'POST' });
afficherStatut(`${nom} ${action}`, 'succes');
}
setTimeout(chargerRelaisStatus, 500);
} catch (e) {
afficherStatut('Erreur relais', 'erreur');
}
}
function demarrerEclairageLiveExt() {
chargerRelaisStatus();
chargerRelaisConfig();
}
// === DIAGNOSTIC ===
let _diagRefreshTimer = null;
async function chargerDiagnostic() {
chargerSante();
chargerLogs();
chargerUsb();
if (_diagRefreshTimer) clearInterval(_diagRefreshTimer);
_diagRefreshTimer = setInterval(chargerSante, 10000);
}
async function chargerSante() {
try {
const s = await apiGet('/api/systeme/sante');
// Camera
const camCard = document.getElementById('diag-camera');
const camSt = document.getElementById('diag-camera-status');
if (s.camera.status === 'ok') {
camCard.className = 'diag-card ok';
camSt.textContent = 'Connectee — ' + s.camera.mode;
} else if (s.camera.usb) {
camCard.className = 'diag-card warn';
camSt.textContent = 'USB detecte mais non connectee';
} else {
camCard.className = 'diag-card err';
camSt.textContent = s.camera.status === 'erreur' ? 'Non disponible' : s.camera.mode;
}
// Imprimante
const impCard = document.getElementById('diag-imprimante');
const impSt = document.getElementById('diag-imp-status');
if (s.imprimante.status === 'prete') {
impCard.className = 'diag-card ok';
impSt.textContent = s.imprimante.nom + ' — Prete';
} else if (s.imprimante.status === 'impression') {
impCard.className = 'diag-card ok';
impSt.textContent = 'En cours d\'impression';
} else if (s.imprimante.status === 'arretee') {
impCard.className = 'diag-card err';
impSt.textContent = s.imprimante.nom + ' — Arretee' + (s.imprimante.usb ? ' (USB ok)' : ' (USB absente)');
} else {
impCard.className = 'diag-card ' + (s.imprimante.usb ? 'warn' : 'off');
impSt.textContent = s.imprimante.usb ? 'USB detectee — CUPS inconnu' : 'Non detectee';
}
if (s.imprimante.jobs > 0) impSt.textContent += ' — ' + s.imprimante.jobs + ' job(s)';
// Relais
const relCard = document.getElementById('diag-relais');
const relSt = document.getElementById('diag-relais-status');
if (s.relais.connecte) {
relCard.className = 'diag-card ok';
relSt.textContent = 'Connecte';
} else {
relCard.className = 'diag-card off';
relSt.textContent = 'Non detecte';
}
// Systeme
const sysCard = document.getElementById('diag-systeme');
const sysSt = document.getElementById('diag-sys-status');
const ramWarn = s.systeme.ram_pct > 85;
const diskWarn = s.systeme.disque_pct > 90;
const tempWarn = s.systeme.temp && s.systeme.temp > 75;
if (ramWarn || diskWarn || tempWarn) {
sysCard.className = 'diag-card warn';
} else {
sysCard.className = 'diag-card ok';
}
let sysInfo = 'RAM ' + s.systeme.ram_pct + '% — Disque ' + s.systeme.disque_pct + '%';
if (s.systeme.temp) sysInfo += ' — ' + s.systeme.temp + '°C';
if (s.systeme.uptime) sysInfo += ' — Up ' + s.systeme.uptime;
sysSt.textContent = sysInfo;
} catch (e) {
console.error('Diagnostic sante:', e);
}
}
async function chargerLogs() {
try {
const r = await apiGet('/api/systeme/logs?n=80');
const el = document.getElementById('diag-logs');
el.textContent = r.lignes.join('\n');
el.scrollTop = el.scrollHeight;
} catch (e) {
document.getElementById('diag-logs').textContent = 'Erreur chargement logs';
}
}
async function chargerUsb() {
try {
const r = await apiGet('/api/systeme/usb');
const el = document.getElementById('diag-usb');
el.textContent = r.peripheriques.join('\n');
} catch (e) {
document.getElementById('diag-usb').textContent = 'Erreur';
}
}
async function diagReconnecterCamera() {
afficherStatut('Reconnexion camera...', 'succes');
try {
await apiPost('/api/camera/reconnecter', {});
await chargerSante();
afficherStatut('Reconnexion terminee', 'succes');
} catch (e) {
afficherStatut('Echec reconnexion', 'erreur');
}
}
async function diagReactiverImprimante() {
afficherStatut('Reactivation imprimante...', 'succes');
try {
const r = await apiPost('/api/imprimante/reactiver', {});
afficherStatut(r.message || 'OK', r.succes ? 'succes' : 'erreur');
await chargerSante();
} catch (e) {
afficherStatut('Echec reactivation', 'erreur');
}
}
async function diagRedemarrerBackend() {
afficherStatut('Redemarrage backend...', 'succes');
try {
await fetch('/api/systeme/redemarrer-backend', { method: 'POST' });
afficherStatut('Backend redemarre — rechargement dans 5s...', 'succes');
setTimeout(() => location.reload(), 5000);
} catch (e) {
afficherStatut('Erreur', 'erreur');
}
}
async function diagRefreshChromium() {
location.reload();
}
async function diagRedemarrerSysteme() {
afficherStatut('Redemarrage borne...', 'succes');
try {
await fetch('/api/systeme/redemarrer', { method: 'POST' });
} catch (e) {}
}

View File

@@ -5,6 +5,11 @@ let modeActuel = 'simple';
let photosSession = []; // Photos de la session en cours let photosSession = []; // Photos de la session en cours
let photoFinale = null; // Photo finale (avec effets) let photoFinale = null; // Photo finale (avec effets)
let ecranActuel = 'accueil'; let ecranActuel = 'accueil';
let _veilleTimer = null;
let _enVeille = false;
let _spotsAllumes = false;
const _shutterSound = new Audio('/sounds/shutter.wav');
_shutterSound.volume = 0.8;
// --- Initialisation --- // --- Initialisation ---
@@ -113,7 +118,13 @@ function allerA(ecran) {
photoFinale = null; photoFinale = null;
arreterPreview(); arreterPreview();
majCompteurAccueil(); majCompteurAccueil();
} else if (ecran === 'capture') { eteindreSpots();
lancerTimerVeille();
} else {
arreterTimerVeille();
reveillerEclairage();
}
if (ecran === 'capture') {
lancerCapture(); lancerCapture();
} else if (ecran === 'partage') { } else if (ecran === 'partage') {
majBoutonImprimerCompteur(); majBoutonImprimerCompteur();
@@ -145,6 +156,7 @@ function setupEcranAccueil() {
accueil.addEventListener('click', (e) => { accueil.addEventListener('click', (e) => {
if (e.target.closest('.btn-admin')) return; if (e.target.closest('.btn-admin')) return;
if (e.target.closest('.btn-photostation')) return; if (e.target.closest('.btn-photostation')) return;
if (_enVeille) reveillerEclairage();
allerA('mode'); allerA('mode');
}); });
} }
@@ -298,12 +310,28 @@ function lancerSansCadre() {
allerA('capture'); allerA('capture');
} }
// --- Onglets admin --- // --- Rubriques + Onglets admin ---
let rubriqueActive = 'general';
function changerRubrique(nom) {
rubriqueActive = nom;
document.querySelectorAll('.rubrique').forEach(r => r.classList.toggle('actif', r.dataset.rubrique === nom));
document.querySelectorAll('.admin-onglets').forEach(g => g.classList.toggle('cache', g.id !== 'onglets-' + nom));
document.querySelectorAll('.admin-panneau').forEach(p => p.classList.remove('actif'));
document.querySelectorAll('.onglet').forEach(o => o.classList.remove('actif'));
const groupe = document.getElementById('onglets-' + nom);
if (groupe) {
const premier = groupe.querySelector('.onglet');
if (premier) { premier.click(); }
}
}
function setupOnglets() { function setupOnglets() {
document.querySelectorAll('.onglet').forEach(onglet => { document.querySelectorAll('.onglet').forEach(onglet => {
onglet.addEventListener('click', () => { onglet.addEventListener('click', () => {
document.querySelectorAll('.onglet').forEach(o => o.classList.remove('actif')); const groupe = onglet.closest('.admin-onglets');
if (groupe) groupe.querySelectorAll('.onglet').forEach(o => o.classList.remove('actif'));
document.querySelectorAll('.admin-panneau').forEach(p => p.classList.remove('actif')); document.querySelectorAll('.admin-panneau').forEach(p => p.classList.remove('actif'));
onglet.classList.add('actif'); onglet.classList.add('actif');
const panneau = document.getElementById('panneau-' + onglet.dataset.onglet); const panneau = document.getElementById('panneau-' + onglet.dataset.onglet);
@@ -372,24 +400,143 @@ function afficherStatut(message, type = 'succes') {
wsOnMessage('config_maj', (msg) => { wsOnMessage('config_maj', (msg) => {
config = msg.config; config = msg.config;
appliquerConfig(); appliquerConfig();
if (ecranActuel === 'accueil') lancerTimerVeille();
}); });
// Erreur camera : overlay visible sur tous les ecrans, reconnexion automatique cote backend // === Erreur equipement : overlay intelligent avec escalade ===
let _erreurCameraTimer = null; let _erreurCameraTimer = null;
wsOnMessage('camera_erreur', () => { let _erreurCameraCount = 0;
let _erreurCameraPhase = 0; // 0=auto-fix, 1=patience, 2=operator, 3=restart
function _showErreurEquipement(icon, titre, msg, phase) {
const el = document.getElementById('erreur-equipement');
document.getElementById('erreur-equip-icon').textContent = icon;
document.getElementById('erreur-equip-titre').textContent = titre;
document.getElementById('erreur-equip-msg').textContent = msg;
const spinner = document.getElementById('erreur-equip-spinner');
const steps = document.getElementById('erreur-equip-steps');
const btnRetry = document.getElementById('erreur-equip-btn-retry');
const btnVideo = document.getElementById('erreur-equip-btn-video');
const btnRestart = document.getElementById('erreur-equip-btn-restart');
spinner.classList.toggle('cache', phase >= 2);
steps.classList.toggle('cache', phase < 2);
btnRetry.classList.toggle('cache', phase < 1);
btnVideo.classList.toggle('cache', phase < 2);
btnRestart.classList.toggle('cache', phase < 2);
if (phase >= 2) {
steps.innerHTML = '<ol>' +
'<li>Verifiez que l\'appareil photo est allume (bouton ON)</li>' +
'<li>Verifiez le cable USB entre l\'appareil et la borne</li>' +
'<li>Eteignez et rallumez l\'appareil photo</li>' +
'<li>Si le probleme persiste, redemarrez la borne</li>' +
'</ol>';
}
el.classList.remove('cache');
}
function fermerErreurEquipement() {
document.getElementById('erreur-equipement').classList.add('cache');
_erreurCameraCount = 0;
_erreurCameraPhase = 0;
allerA('accueil');
}
function erreurEquipRetry() {
document.getElementById('erreur-equip-titre').textContent = 'Verification en cours...';
document.getElementById('erreur-equip-msg').textContent = 'Un instant...';
document.getElementById('erreur-equip-spinner').classList.remove('cache');
document.getElementById('erreur-equip-steps').classList.add('cache');
document.getElementById('erreur-equip-btn-retry').classList.add('cache');
if (_erreurEquipType === 'imprimante') {
fetch('/api/imprimante/reactiver', { method: 'POST' }).catch(() => {});
} else {
fetch('/api/camera/reconnecter', { method: 'POST', headers: {'Content-Type':'application/json'}, body: '{}' }).catch(() => {});
}
}
let _erreurEquipType = 'camera';
function erreurEquipVideo() {
document.getElementById('erreur-equipement').classList.add('cache');
const situationMap = { camera: 'camera_hs', imprimante: window._imprimanteErreurSituation || 'depannage_imprimante' };
const situation = situationMap[_erreurEquipType] || 'camera_hs';
if (typeof lancerWizardSituation === 'function') {
lancerWizardSituation(situation, _erreurEquipType);
}
}
function erreurEquipRestart() {
_showErreurEquipement('⏳', 'Redemarrage en cours', 'La borne redemarre, patientez 30 secondes...', 0);
document.getElementById('erreur-equip-btn-accueil').classList.add('cache');
fetch('/api/systeme/redemarrer', { method: 'POST' }).catch(() => {});
}
wsOnMessage('camera_erreur', (data) => {
if (window.location.pathname === '/admin') return; if (window.location.pathname === '/admin') return;
document.getElementById('camera-erreur').classList.remove('cache'); _erreurCameraCount++;
// Reload de dernier recours si la camera ne revient pas apres 2 min _erreurEquipType = 'camera';
if (_erreurCameraCount <= 2) {
_erreurCameraPhase = 0;
_showErreurEquipement('📷', 'Preparation de l\'appareil photo', 'Un instant, reconnexion automatique...', 0);
} else if (_erreurCameraCount <= 5) {
_erreurCameraPhase = 1;
_showErreurEquipement('📷', 'L\'appareil photo ne repond pas', 'Le systeme essaie de le reconnecter. Vous pouvez aussi reessayer manuellement.', 1);
} else {
_erreurCameraPhase = 2;
_showErreurEquipement('📷', 'Appareil photo injoignable', 'Suivez ces etapes pour le remettre en marche :', 2);
}
if (_erreurCameraTimer) clearTimeout(_erreurCameraTimer); if (_erreurCameraTimer) clearTimeout(_erreurCameraTimer);
_erreurCameraTimer = setTimeout(() => { location.reload(); }, 120000); _erreurCameraTimer = setTimeout(() => { location.reload(); }, 180000);
});
wsOnMessage('shutter', () => {
_shutterSound.currentTime = 0;
_shutterSound.play().catch(() => {});
}); });
wsOnMessage('camera_ok', () => { wsOnMessage('camera_ok', () => {
document.getElementById('camera-erreur').classList.add('cache'); document.getElementById('erreur-equipement').classList.add('cache');
_erreurCameraCount = 0;
_erreurCameraPhase = 0;
if (_erreurCameraTimer) { clearTimeout(_erreurCameraTimer); _erreurCameraTimer = null; } if (_erreurCameraTimer) { clearTimeout(_erreurCameraTimer); _erreurCameraTimer = null; }
}); });
// Surveillance imprimante — poll toutes les 30s wsOnMessage('imprimante_erreur', (data) => {
if (window.location.pathname === '/admin') return;
_erreurEquipType = 'imprimante';
const titre = data.titre || 'Probleme imprimante';
const msg = data.message || 'L\'imprimante ne fonctionne pas correctement.';
const etapes = data.etapes || [];
const situation = data.situation || 'depannage_imprimante';
_showErreurEquipement('🖨', titre, msg, 2);
const steps = document.getElementById('erreur-equip-steps');
if (etapes.length) {
steps.innerHTML = '<ol>' + etapes.map(e => '<li>' + e + '</li>').join('') + '</ol>';
steps.classList.remove('cache');
}
document.getElementById('erreur-equip-btn-retry').classList.remove('cache');
document.getElementById('erreur-equip-btn-retry').textContent = 'Verifier';
document.getElementById('erreur-equip-btn-retry').onclick = function() {
fetch('/api/imprimante/reactiver', { method: 'POST' }).catch(() => {});
_showErreurEquipement('🖨', 'Verification en cours...', 'Un instant...', 0);
};
document.getElementById('erreur-equip-btn-video').classList.remove('cache');
document.getElementById('erreur-equip-btn-video').onclick = function() {
document.getElementById('erreur-equipement').classList.add('cache');
if (typeof lancerWizardSituation === 'function') lancerWizardSituation(situation, 'imprimante');
};
window._imprimanteErreurSituation = situation;
});
wsOnMessage('imprimante_ok', () => {
if (_erreurEquipType === 'imprimante') {
document.getElementById('erreur-equipement').classList.add('cache');
}
document.getElementById('printer-erreur').classList.add('cache');
});
function _afficherErreurImprimante(msg) { function _afficherErreurImprimante(msg) {
const el = document.getElementById('printer-erreur'); const el = document.getElementById('printer-erreur');
document.getElementById('printer-erreur-msg').textContent = msg; document.getElementById('printer-erreur-msg').textContent = msg;
@@ -400,21 +547,11 @@ async function _pollStatutImprimante() {
try { try {
const r = await apiGet('/api/imprimante/statut-detail'); const r = await apiGet('/api/imprimante/statut-detail');
const el = document.getElementById('printer-erreur'); const el = document.getElementById('printer-erreur');
if (r.derniere_erreur) { if (r.statut && !r.statut.includes('stopped') && !r.statut.includes('disabled')) {
let msg = '';
const e = r.derniere_erreur.toLowerCase();
if (e.includes('media') && e.includes('match'))
msg = '⚠ Imprimante : format papier incorrect — vérifiez la cassette';
else if (e.includes('jam'))
msg = '⚠ Imprimante : bourrage papier — retirez le papier bloqué';
else if (e.includes('cancel'))
msg = '⚠ Imprimante : job annulé — ' + r.derniere_erreur.split(']').pop().trim();
if (msg) { _afficherErreurImprimante(msg); return; }
}
if (r.statut && (r.statut.includes('stopped') || r.statut.includes('disabled'))) {
_afficherErreurImprimante('⚠ Imprimante arrêtée — utilisez le bouton Évacuer dans l\'admin');
} else {
el.classList.add('cache'); el.classList.add('cache');
if (_erreurEquipType === 'imprimante') {
document.getElementById('erreur-equipement').classList.add('cache');
}
} }
} catch(e) {} } catch(e) {}
} }
@@ -537,6 +674,36 @@ async function wizardTerminer() {
allerA('accueil'); allerA('accueil');
} }
// --- Veille eclairage ---
function lancerTimerVeille() {
arreterTimerVeille();
const delai = (config.relais?.veille_delai || 0) * 60000;
if (delai <= 0) return;
_veilleTimer = setTimeout(() => {
_enVeille = true;
_spotsAllumes = false;
fetch('/api/relais/veille', { method: 'POST' }).catch(() => {});
}, delai);
}
function arreterTimerVeille() {
if (_veilleTimer) { clearTimeout(_veilleTimer); _veilleTimer = null; }
}
function reveillerEclairage() {
if (!_enVeille && _spotsAllumes) return;
_enVeille = false;
_spotsAllumes = true;
arreterTimerVeille();
fetch('/api/relais/reveil', { method: 'POST' }).catch(() => {});
}
function eteindreSpots() {
_spotsAllumes = false;
fetch('/api/relais/veille', { method: 'POST' }).catch(() => {});
}
// Demarrage // Demarrage
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
init().then(() => { init().then(() => {

View File

@@ -9,6 +9,27 @@ let captureEnCours = false; // true pendant tout le flux lancerCapture()
const EMOJI_CAR = { 3: '🤪', 2: '😱', 1: '🔥' }; const EMOJI_CAR = { 3: '🤪', 2: '😱', 1: '🔥' };
// --- Surprise avant capture ---
async function afficherSurprise() {
const surprise = (config || {}).surprise;
if (!surprise || !surprise.actif || !surprise.fichier) return;
const delai = surprise.delai_ms || 1000;
const overlay = document.getElementById('surprise-overlay');
if (!overlay) return;
if (surprise.type === 'video') {
overlay.innerHTML = '<video src="/api/surprise/media" autoplay muted playsinline style="max-width:100%;max-height:100%;object-fit:contain"></video>';
} else {
overlay.innerHTML = '<img src="/api/surprise/media" style="max-width:100%;max-height:100%;object-fit:contain">';
}
overlay.classList.remove('cache');
await new Promise(r => setTimeout(r, delai));
overlay.classList.add('cache');
}
// --- Preview live --- // --- Preview live ---
function afficherErreurCapture(titre, detail = '') { function afficherErreurCapture(titre, detail = '') {
@@ -251,8 +272,12 @@ async function lancerCapture() {
} }
lancerPreview(); // Miroir live pendant le compte à rebours lancerPreview(); // Miroir live pendant le compte à rebours
wsEnvoyer({type: 'prepare_capture'});
await compteARebours(); await compteARebours();
// Surprise : afficher media juste avant capture
await afficherSurprise();
// Flash blanc immédiat + lancer capture en parallèle // Flash blanc immédiat + lancer capture en parallèle
const flash = document.getElementById('flash-blanc'); const flash = document.getElementById('flash-blanc');
flash.classList.remove('cache'); flash.classList.remove('cache');
@@ -465,20 +490,17 @@ function afficherPreviewPhoto(chemin) {
async function majCompteurAccueil() { async function majCompteurAccueil() {
const etat = await apiGet('/api/compteur'); const etat = await apiGet('/api/compteur');
const el = document.getElementById('compteur-accueil'); const el = document.getElementById('compteur-accueil');
if (etat.actif) { el.classList.remove('cache');
el.classList.remove('cache'); document.getElementById('compteur-restant').textContent = etat.restantes;
document.getElementById('compteur-restant').textContent = etat.restantes; document.getElementById('compteur-limite').textContent =
document.getElementById('compteur-limite').textContent = etat.limite; (etat.actif && etat.limite > 0) ? etat.limite : etat.capacite;
} else {
el.classList.add('cache');
}
} }
async function majBoutonImprimerCompteur() { async function majBoutonImprimerCompteur() {
const etat = await apiGet('/api/compteur'); const etat = await apiGet('/api/compteur');
const btn = document.getElementById('btn-imprimer'); const btn = document.getElementById('btn-imprimer');
if (!btn) return; if (!btn) return;
if (etat.actif && etat.restantes <= 0) { if (etat.restantes <= 0) {
btn.classList.add('cache'); btn.classList.add('cache');
} }
} }

View File

@@ -105,12 +105,18 @@ async function adminGalerieImprimer() {
if (noms.length === 0) return; if (noms.length === 0) return;
if (!confirm('Imprimer ' + noms.length + ' photo' + (noms.length > 1 ? 's' : '') + ' ?')) return; if (!confirm('Imprimer ' + noms.length + ' photo' + (noms.length > 1 ? 's' : '') + ' ?')) return;
let ok = 0, fail = 0; let ok = 0, fail = 0, termine = false;
for (const nom of noms) { for (const nom of noms) {
try { try {
const r = await apiPost('/api/imprimer', { photo: nom }); const r = await apiPost('/api/imprimer', { photo: nom });
if (r && r.succes) ok++; else fail++; if (r && r.succes) ok++;
else if (r && r.erreur === 'evenement_termine') { termine = true; break; }
else fail++;
} catch { fail++; } } catch { fail++; }
} }
alert('Impression : ' + ok + ' OK' + (fail > 0 ? ', ' + fail + ' echec' : '')); if (termine) {
alert('Evenement termine — impression desactivee. Les photos ne seront pas imprimees.');
} else {
alert('Impression : ' + ok + ' OK' + (fail > 0 ? ', ' + fail + ' echec' : ''));
}
} }

View File

@@ -1,10 +1,10 @@
/* Module partage - Impression, email, QR code */ /* Module partage - Impression, email, QR code */
let nbExemplaires = 1; let nbExemplaires = 1;
let copiesMax = 5; let copiesMax = 2;
async function ouvrirImpression() { async function ouvrirImpression() {
copiesMax = config.impression?.copies_max || 5; copiesMax = config.impression?.copies_max || 2;
nbExemplaires = 1; nbExemplaires = 1;
document.getElementById('nb-exemplaires').textContent = nbExemplaires; document.getElementById('nb-exemplaires').textContent = nbExemplaires;
cadreChoisi = null; cadreChoisi = null;
@@ -110,8 +110,14 @@ async function lancerImpression() {
cadre: cadreChoisi || undefined, cadre: cadreChoisi || undefined,
format_papier: formatImpression || undefined, format_papier: formatImpression || undefined,
}); });
if (resultat.succes) { if (resultat.attente) {
afficherStatutEco('⏳', resultat.message || 'Votre photo sortira avec la suivante !', 'attente-eco');
} else if (resultat.jumeau && resultat.succes) {
afficherStatutEco('🎁', 'Vous recevez 2 tirages identiques — gardez-en un, offrez l\'autre !', 'jumeau-eco');
} else if (resultat.succes) {
afficherStatut(`${nbExemplaires} exemplaire(s) envoye(s) a l'imprimante !`, 'succes'); afficherStatut(`${nbExemplaires} exemplaire(s) envoye(s) a l'imprimante !`, 'succes');
} else if (resultat.erreur === 'evenement_termine') {
afficherStatut('Evenement termine — impression indisponible', 'erreur');
} else { } else {
afficherStatut('Erreur d\'impression', 'erreur'); afficherStatut('Erreur d\'impression', 'erreur');
} }

View File

@@ -54,8 +54,8 @@ function _afficherPause() {
function _masquerPause() { function _masquerPause() {
const el = document.getElementById('ecran-pause'); const el = document.getElementById('ecran-pause');
if (el) { el.style.display = 'none'; } if (el) { el.style.display = 'none'; }
// Relancer le preview si on était sur l'accueil // Relancer le preview seulement si on est sur un ecran de prise de vue (pas l'accueil)
if (typeof lancerPreview === 'function' && document.getElementById('ecran-accueil')?.classList.contains('actif')) { if (typeof lancerPreview === 'function' && !document.getElementById('ecran-accueil')?.classList.contains('actif')) {
lancerPreview(); lancerPreview();
} }
} }

View File

@@ -2,7 +2,7 @@
## Description ## Description
Photobooth professionnel pour location evenementielle. Photobooth professionnel pour location evenementielle.
RPi4 + ecran tactile + DSLR (gphoto2) + imprimante sublimation. Surface 6 + ecran tactile + DSLR Canon (gphoto2) + imprimante sublimation Mitsubishi.
## Architecture ## Architecture
- **Backend** : Python FastAPI + WebSocket - **Backend** : Python FastAPI + WebSocket
@@ -29,10 +29,272 @@ https://git.copydev.fr/jules/photobooth
Phase 1-3 terminees (backend complet + frontend complet). Phase 1-3 terminees (backend complet + frontend complet).
Phase 4 : scripts production (install.sh, start.sh, systemd). Phase 4 : scripts production (install.sh, start.sh, systemd).
## Matériel reçu
- Imprimante sublimation Mitsubishi (reçue le 2026-05-28)
## Notes ## Notes
- Projet cree le 2026-03-21 - Projet cree le 2026-03-21
- Mode simulation camera si gphoto2 non installe (dev sans DSLR) - Mode simulation camera si gphoto2 non installe (dev sans DSLR)
- Config persistante dans data/config.json - Config persistante dans data/config.json
---
## Infra & réseau
### Surface (booth terrain)
- IP LAN : 192.168.111.39 (DHCP, sujette à changement)
- IP VPN : 10.10.0.2
- Accès SSH : `ssh surface` (alias ~/.ssh/config, clé ed25519)
- User : `jules`, repo : `~/photobooth`
- **Pas de clavier physique** — toute intervention SSH uniquement
- Déploiement : pas de systemd (photobooth.service disabled, plante exec 203). Le run = `scripts/kiosk-session.sh` lancé au login LightDM autologin : boucle relance backend + boucle Chromium kiosk. Pour déployer : `git push` depuis dev, puis `kill <pid backend.main>` + `pkill -f 'chromium '` → relance auto avec git pull.
- Remote debugging Chromium : port 9222
- Kernel linux-surface avec iptsd (tactile), scale-factor=3
- `data/config.json` = état runtime (compteur, imprimante) — ne jamais écraser par commit/push depuis dev
### LXC 111 (booth-manager / galerie)
- IP : 192.168.111.211
- Accès : `ssh root@192.168.111.10` puis `pct exec 111 -- <cmd>`
- Code : `/opt/photobooth/backend/booth_admin.py`
- Service : `systemctl restart photobooth`
- Héberge : booth.copydev.fr (landing, galerie, admin)
### WireGuard tunnel
- Serveur (LXC 111) : wg0, 10.10.0.1/24, port 51820/UDP, enabled
- Client (Surface) : 10.10.0.2/24
- Clé publique serveur : `g3s7Gh1Er+LIEI/W91HAVDNIo+gsk/KiajdtoDUQRVo=`
- Clé privée client : `uH9lesPtezigCDlB6cSCYy87QFxrtLFWcnwi93/YgXM=`
- Clé publique client : `p+yIc+slDkKXnogvvuohbWlF8iapbI4LxJPH1RCaUig=`
- Port forward routeur : UDP 51820 → 192.168.111.211
### Proxmox PVE
- IP : 192.168.111.10, accès `ssh root@192.168.111.10`
### NPM (Nginx Proxy Manager)
- IP : 192.168.111.5, port 81
- Cert wildcard *.copydev.fr ID #2
---
## Règles de conduite Claude
- **Pas de clavier sur la borne** : ne jamais suggérer Ctrl+R, F5, ou actions clavier. Forcer reload via SSH (port 9222) ou restart lightdm/chromium.
- **Pas de confirmation sur la Surface** : PC dédié, aucun risque, exécuter directement les commandes SSH.
- **Pas de SSH sauvage** : ne jamais SSH sur des serveurs non demandés sans instruction explicite.
---
## Matériel
### Imprimante sublimation Mitsubishi
- Reçue le 2026-05-28
- Erreur 03/01/01 sur K60 = papier mal chargé (pas fin de rouleau)
### Module relais USB HID
- Commandé le 2026-06-23, livraison estimée ~2026-07-01
- Pour piloter alim secteur Canon (power-cycle PTP freeze) + éclairage booth
- Intégration via /dev/hidraw* dans le backend Python
---
## UX dépannage novices
Utilisateurs finaux = novices (événementiel). Erreurs imprimante : prévoir guides imagés (popup plein écran, étapes numérotées, icônes grandes, pas de codes techniques). Cas : cassette mal insérée, bourrage, fin consommable, câble USB.
---
## VPN booth (2026-07-04)
Tunnel WG a eu une panne le 04/07. Cause probable : wg-quick@wg0 pas enabled au boot, ou Endpoint IP locale.
### Procédure fix (clavier USB temporaire)
1. `sudo systemctl status wg-quick@wg0 && sudo wg show && cat /etc/wireguard/wg0.conf`
2. Vérifier Endpoint = `<IP_PUBLIQUE>:51820` (pas 192.168.111.x). IP publique : `curl -s ifconfig.me`
3. `sudo systemctl enable wg-quick@wg0 && sudo systemctl restart wg-quick@wg0`
4. Installer watchdog + reverse SSH — voir PROCEDURE_VPN_FIX.md
---
## LXC 111 — booth.copydev.fr (état au 2026-07-04)
Code sur le serveur : `/opt/photobooth/` (PAS dans le repo git local).
Backup local des fichiers modifiés : `lxc111_backup/` dans ce dossier projet.
### Fichiers modifiés sur LXC 111
- `backend/main.py` (1126 lignes) — endpoints galerie + réservation
- `backend/booth_admin.py` (808 lignes) — admin panel API + ZIP streaming
- `frontend/landing.html` (900 lignes) — page vitrine complète
- `frontend/admin.html` (930 lignes) — panel admin avec validation résa
- `frontend/gallery.html` (414 lignes) — galerie publique + modal ZIP progress
### Endpoints ajoutés
- `GET /api/disponibilites` → retourne dates réservées (statut "valide" ou "demande")
- `POST /api/contact` → crée événement + envoie email admin (pas client). Champs : nom, email, telephone, nom_evenement, type, date, formule, theme, nb_invites, impressions, opt_livraison, message
- `POST /api/contact/valider/{event_id}` → passe statut "valide" + envoie email confirmation au client avec URL galerie + code accès
### Landing page (frontend/landing.html)
- Vitrine commerciale booth.copydev.fr, couleur indigo #5c6bc0
- 6 cartes prestations (Canon 18Mpx, Mitsubishi sublimation, galerie live, personnalisation, écran tactile, partage)
- 3 tarifs : Pack 300 (300€), Pack 600 (450€), Livraison & installation (sur devis)
- Formulaire réservation → crée événement auto → email admin
- Calendrier dispo : WE = sam+dim (un bloc), semaine = lun→ven (un bloc), même prix. Dates réservées bloquées.
- Modal accès galerie (code 4 digits)
- Lien admin caché (coin bas-droit, opacity:0 → hover 0.3)
### Admin panel (frontend/admin.html)
- Section "Demandes en attente" (orange) avec boutons Valider/Refuser
- Section "Galeries en ligne" séparée
- `validerReservation()` → `POST /api/contact/valider/{id}` avec `credentials: 'include'`
### ZIP galerie
- Corrigé crash mémoire (648 Mo in-memory → temp file + streaming 1 Mo chunks)
- Barre de progression dans modal (ReadableStream, affiche Mo reçus + %)
- Restreint admin only (pas guest)
### SMTP
- Corrigé pour port 587 : STARTTLS (pas SMTP_SSL qui est pour 465)
- Config OVH : ssl0.ovh.net port 587
### Compteur photos
- `distribuer_photo()` accepte paramètre `copies` et incrémente par ce nombre (pas toujours +1)
---
## Historique développement (juin-juillet 2026)
### Kiosk & production (3-4 juin)
- Kiosk boucle infinie : backend + Chromium relancés automatiquement, plus d'écran login
- Impression Mitsubishi K60 + cadres + interface admin distante
- Chromium : désactivation Translate (--lang=fr + prefs FR), meta notranslate
- Verrou instance unique kiosk (anti-clignotement)
- Intégration station d'impression photo (photostation) — popup accueil
- USB automount avec udiskie dans kiosk-session
- Détection USB 2 niveaux /media/<user>/<label> + filtre fstype
- Cadres : choix avec zoom 2.5x + fond photo simulé
### Caméra & preview (5-10 juin)
- Viewfinder permanent + preview live pendant compte à rebours
- Fix preview + miroir : thread continu, grace period, lock gphoto2
- Fix caméra : kill gvfsd-gphoto2, race condition viewfinder, exif_transpose
- Fix strip K60 coupe auto, diagnostic preview
- Caméra déconnectée : retour accueil automatique + abort capture
- Bullet-proof caméra : reconnexion auto + fix crash capture + AF LiveView
- Capture : viewfinder=0 avant prise (AF phase-détection), timeout 20s, spinner UI
- surveiller_dslr : reset USB auto après 3 échecs → retiré (contre-productif)
- Fix preview live : position:absolute sur img
- Remote debugging Chromium port 9222
- Kiosk : git pull auto + diagnostic caméra dans admin
### Imprimante & impression (4-23 juin)
- Statut détaillé imprimante + message erreur CUPS traduit
- Bandeau erreur imprimante tous écrans + reset USB
- Bouton Couper papier (micro-job blanc déclenche le cutter K60)
- Cadres : preview coin haut-gauche + compteur bloque impression pas capture
- Pellicule : marge de coupe plus large + warmup LiveView après capture
- Fix impression pellicule (portrait vs paysage, taille pleine, rotation)
- Cadre de visée live correspondant au recadrage final pellicule/collage
- Calibration impression : offsets coupe (mm) + rotation 180° configurable
- Fix impression paysage : inverser largeur/hauteur
### DSLR robustesse (21-28 juin)
- Flash Canon configurable dans admin
- DSLR anti-veille : autopoweroff Canon + udev no-autosuspend
- Keepalive Canon : get_config() toutes les 10s quand preview KO
- DSLR auto-recovery : USB reset + cadre overlay non-mirroré
- USB rebind Canon : unbind/rebind sysfs pour recovery hard PTP freeze
- Escalade recovery : uhubctl power cycle + backoff 60s après 10 échecs
- Viewfinder off avant capture pour AF phase-detect
- Viewfinder warmup en arrière-plan (accélération capture)
### Email (22 juin)
- Email plein écran avec suggestions d'adresses précédentes
- Fermer formulaire avant envoi + raccourcis domaines FR
- Email spool : file d'attente quand envoi échoue, retry 60s
- Spool envoi au démarrage uniquement, message "dans la semaine"
- Rapport email à contact@copydev.fr après vidage du spool
### Événements & galerie (22-23 juin)
- Galerie booth : QR avec auto-login, event_id aléatoire, flyer imprimable
- Admin : galerie en ligne dans Destinations + booth envoie toutes les photos
- Gestion événements + cadres par event + fix cadre paysage + dossier non-imprimées
- Thème Viking Celte : bois sculpté, bronze, entrelacs
- Cadre imposé visible en overlay sur live + preview + partage
- Formats activables par événement + cadre imposé exclut strips
- Lien galerie événement dans admin
### Résilience & réseau (26-27 juin)
- Écran pause, reconnexion WS auto, watchdog systemd, WiFi monitor
- Admin WiFi : scan réseaux, connexion, mots de passe mémorisés
- Kiosk : flag maintenance + anti-doublon backend
- Watchdog systemd + fix stabilité kiosk/backend
- QR code par photo : popup QR dynamique + page vue photo sur booth
- Fix QR code URL (routes publiques /g/)
- Fix QR après photo : corriger share.js qui écrasait afficherQR()
- Masquer popup QR à chaque changement d'écran
- Supprimer le timeout auto-retour du menu admin
### Admin galerie & copies (30 juin)
- Admin galerie + compteur copies + flash capture + erreur imprimante toast
- Bump cache versions (style v12, camera v17, gallery v4)
### Admin backoffice (9 juillet)
- Rubriques Général / Événement dans admin
- Vidéos situation (tutoriels visuels)
- Mode surprise
- Rembobinage ruban (instruction Mitsubishi)
### Suivi consommables (12 juillet)
- Compteur papier, ruban et photos avec diagnostic ratio
- Compteur poses perdues + ratio corrigé 0.5 (10 poses par feuille, 20 feuilles par rouleau)
### Module relais HID + éclairage auto (1-2 août)
- Module LCUS 4 canaux (5131:2007) piloté via hidapi/ctypes (`backend/relais.py`)
- Assignation : R1=Proj gauche (NO), R2=Proj droit (NO), R3=Canon alim (NF), R4=libre
- udev rule `/etc/udev/rules.d/99-usbrelay.rules` pour accès sans sudo
- 5 endpoints API relais + admin éclairage avec status relais + boutons ON/OFF
- Éclairage auto : analyse luminosité visage → allume projecteurs selon 3 seuils configurables
- Power cycle Canon : relais NF = OFF=alimenté, ON=coupé ; intégré en niveau 10 de l'escalade recovery DSLR
- Bug fix : byte 4 du status LCUS = bruit (0x4F), seuls bytes 1-3 valides
### Vidéos didactiques multi-étapes (1 août)
- Système complet : situations avec N étapes vidéo, chaque étape boucle jusqu'à validation opérateur
- 6 situations par défaut (montage, démontage, bourrage, rouleau, wifi, calibration)
- Wizard overlay plein écran avec bouton "OK c'est bon" + navigation étapes
- Storage `data/videos/{situation}/etape_XX.mp4`, config.json pour toggles/labels
- 8 endpoints API vidéos + migration ancien format plat
### Panneau diagnostic + auto-recovery + overlays erreur (3 août)
- Onglet "Diagnostic" dans admin : 4 cartes santé (camera/imprimante/relais/système), actions (reconnecter/réactiver/redémarrer), logs live, USB
- Endpoints : `/api/systeme/sante`, `/api/systeme/logs`, `/api/systeme/redemarrer-backend`, `/api/imprimante/reactiver`, `/api/systeme/usb`
- Auto-recovery imprimante : `_surveiller_imprimante` toutes les 30s, cupsenable si disabled
- Overlay erreur utilisateur avec escalade : auto-fix → patience → opérateur → aide vidéo → redémarrage
- Wizard vidéo connecté aux erreurs avec auto-détection résolution (poll sante)
- Situation `camera_hs` ajoutée aux vidéos didactiques par défaut
### Fix impression galerie + robustesse (2 août)
- Bug: `imprimante: null` dans config.json → TypeError: subprocess recevait None en argument
- Cause: `.get("imprimante", "Mitsubishi")` retourne None si la clé existe avec valeur null
- Fix: pattern `or "Mitsubishi"` dans 5 endroits (printer.py + main.py)
- statut-detail endpoint: ajout try/except pour ne plus retourner 500
- api_imprimer: ajout try/except global pour erreurs inattendues
- Config Surface corrigée : `imprimante` passé de null à "Mitsubishi"
### Canon DSLR restart — problème ouvert (2 août)
- Canon ne redémarre pas après coupure alim secteur (bouton power doit être pressé)
- Interrupteur doit être laissé sur ON pour que le power cycle fonctionne
- Si ça ne suffit pas : bypass physique du bouton power (soudure pont ou optocoupler sur canal 4)
- Camera fixée au plancher, trappe batterie inaccessible
---
## Commits non pushés (au 2 août 2026)
Incluent relais HID, vidéos didactiques, consommables, admin backoffice.
Push Gitea bloqué (pas de credentials SSH/HTTPS configurés sur Surface).
---
## Tâches Trello du projet
- [📋 À faire] faire essaie avec mon dslr et imprimante ricoh et installer sur ecran tactile terra
(card_id: 69c1122fec1b558a76e207b0)
Utilise `claude-trello` pour mettre à jour les cartes pendant le travail.
Labels: green=fait, yellow=en cours, red=bloqué, (aucun)=à faire