Compare commits

..

47 Commits

Author SHA1 Message Date
d4ccf1738e Rembobinage ruban : UltraFine au lieu de StpiDecklist bidon
selphy_print (backend CUPS natif Mitsubishi) gère le rewind
automatiquement SAUF en mode Fine (speed=0). Force UltraFine
quand rembobinage activé pour que le ruban dure autant que le papier.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-25 23:27:38 +02:00
abc5d78575 Fix compteur : default papier/ruban capacite 320 (pas 400)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-25 11:19:11 +02:00
d845c2606e Impression : affiche photos restantes sur le rouleau avant validation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-25 10:55:36 +02:00
6d58fe5d57 Consommables : capacite defaut 320, 15x20 compte double (papier + ruban)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-25 10:51:08 +02:00
e6a0b268ff Endpoint /api/imprimer-fichier : impression par upload (pour galerie en ligne)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-25 00:24:47 +02:00
2f32cf2f38 Cadres impression : fallback cross-format (15x20 utilise le 10x15 si pas de cadre dédié)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-24 13:29:13 +02:00
61b9a72f10 UX partage : masquer Recommencer pendant impression/email, detail pellicule, reset copies
- Ecran partage : boutons bas masqués quand formulaire impression ou email ouvert
- "Terminer" remplacé par "Recommencer" (retour mode, pas accueil)
- Reset photoImpression/formatImpression/nbExemplaires dans recommencer()
- Bouton Pellicule affiche "N poses = 2 tirages" pour expliquer le strip dupliqué
- CSS .mode-detail pour sous-titre discret sur bouton mode
- Bump cache versions (style v18, app v27, share v11)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-20 20:32:42 +02:00
4931a29d45 Notifier galerie des impressions + luminosite par defaut +15%
- notifier_booth_impression() : appelle /gallery/{event}/photo/{name}/imprimee
  apres chaque impression pour mettre a jour le statut dans la galerie en ligne
- Luminosite impression par defaut +15% (corrige photos sombres a l'impression)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-20 10:59:22 +02:00
5e8cff7951 Mini menu client + luminosite impression + endpoints systeme
- Menu client coin haut-gauche : WiFi, luminosite, relancer, redemarrer, eteindre
- Popup WiFi : scan, statut, connexion (reutilise les endpoints existants)
- Popup luminosite : slider -50% a +50%, ajuste la luminosite avant impression
  (ImageEnhance.Brightness dans _preparer_image)
- Endpoints systeme : /api/systeme/eteindre, redemarrer, redemarrer-app
- Endpoint exposition Canon : /api/camera/exposition (GET choix + POST valeur)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-20 10:22:00 +02:00
bb49c2c82d Fix flow surprise : preview reste live, flash masque la transition
- Preview live continue pendant la surprise (overlay par-dessus)
- Capture DSLR lancee pendant la surprise (1s de reaction)
- Flash blanc couvre tout PUIS on masque surprise + preview derriere
- Plus de frame flou visible entre surprise et photo

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-18 14:46:14 +02:00
812de8ae53 Surprise + capture en parallele : DSLR declenche pendant l'image surprise
- Preview stoppe AVANT la surprise (plus de frame flou)
- Capture DSLR lancee en meme temps que la surprise (apiPost en parallele)
- 800ms de reaction pendant que le DSLR travaille
- Flash + masquer surprise apres le declenchement

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-18 14:44:21 +02:00
5f1a96ab8a Watchdog WiFi + reconnexion auto + fix timing surprise capture
- Watchdog connectivite toutes les 60s (check TCP 1.1.1.1:53)
- Si offline : scan WiFi et reconnexion auto aux reseaux enregistres
- Si internet restaure : flush immediat des spools (email + galerie)
- Surprise : capture declenchee 500ms apres affichage (au lieu de 1.7s apres)
  pour capturer la reaction naturelle des gens

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-18 12:32:31 +02:00
d21d87347d Retry periodique email + spool galerie en cas de coupure internet
- Spool galerie booth : si upload echoue, photo stockee dans
  data/booth_spool.json et retentee toutes les 5 min
- Email spool : retry periodique (5 min) au lieu de juste au demarrage
- Les deux spools traites dans la meme boucle async
- Message frontend spool : "sera envoyee des que possible"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-18 10:05:21 +02:00
5c774babbb Log impressions (JSONL) + sleep 3s entre copies multi
- _log_impression() ecrit chaque demande dans data/impressions.jsonl
  (timestamp, photo, copies, format, succes, event_id, detail CUPS)
- Delai entre copies passe de 1s a 3s pour fiabiliser multi-copies Mitsubishi

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-18 09:57:26 +02:00
6c45faecdc Reveil Canon fiabilise : poll USB au lieu de sleep fixe, retry gphoto2, frontend patient
Backend: reveiller_canon() poll USB toutes les 1s (max 20s) au lieu de sleep(15+25),
3 tentatives gphoto2, temps total 10-30s au lieu de 40s fixe.
Frontend: attend 40s meme si reveil_en_cours=false (reconnexion auto),
messages plus clairs ("Touchez l'ecran et reessayez").

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-18 09:46:29 +02:00
be838a7a48 Surprise : desactiver en mode strip/multi (photo simple uniquement)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-17 22:45:43 +02:00
d9c8d9b779 Retirer data/config.json du tracking git (etat runtime)
Ce fichier contient SMTP, relais, surprise, evenement actif —
il ne doit jamais etre ecrase par un git pull.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-17 19:11:13 +02:00
05f78f731d Surprise : precharger image + animation fade in/out + attente chargement
Evite le freeze percu : image prechargee au boot, fade 200ms,
attente img.onload si pas encore prete, re-precharge apres affichage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-17 19:09:53 +02:00
6b2928c9af Ajout frontend/sounds/.gitkeep pour eviter crash StaticFiles
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-13 18:33:49 +02:00
0e1b951ed4 API creer_evenement : accepte formats_actifs depuis le deploy LXC
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-13 18:19:57 +02:00
f1299fd6a7 Bulletproof: messages novices, kiosk port auto, reset compteur, sync booth event_id
- Messages erreur camera/impression: texte novice ("Oups!", "Reessayez") au lieu de jargon technique
- kiosk-session.sh: lit le port depuis config.json (plus de http://localhost en dur), healthcheck /api/config, supprime git pull auto au boot
- Activer evenement: reset automatique du compteur photos
- Activer evenement: sync booth.event_id quand galerie live active
- Port par defaut backend: 80 → 8080

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-13 09:17:26 +02:00
96464ccca1 Canon réveil: frontend attend connexion + fix taille countdown CSS
- /api/camera/statut expose reveil_en_cours pour que le frontend sache
- lancerCapture() poll toutes les 2s (max 60s) si Canon en réveil au lieu de fail immédiat
- Fix CSS countdown: .compte-a-rebours span était à 14rem (écrasait .anim-texte par spécificité)
  → chiffres clamp(2rem, 8vw, 5rem), texte clamp(0.8rem, 3vw, 1.5rem)
- Bump cache versions (style v17, camera v18)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-09 15:32:27 +02:00
583b89f0bd Canon: fiabilite veille/reveil + detection PTP freeze + UX countdown + fix copies impression
- Canon PTP freeze: 5 keepalive FAIL consecutifs → deconnexion forcee
- Canon veille: reset timestamps reconnexion (pas de boucle de mort)
- Canon reveil: guard _reveil_en_cours dans thread preview + surveiller_dslr
- Canon capture: attente auto reveil (60s max) au lieu de 503 immediat
- Canon reconnexion quand _echecs_connexion == 0
- UX: sequence "Attention!" → "Preparez-vous!" → 3 → 2 → 1
- UX: pre-messages uniquement photo simple + 1ere photo pellicule
- UX: pas de compteur "Photo 1/1" en mode simple
- UX: pas de cadre propose pour pellicule
- UX: taille texte countdown reduite pour Surface (scale-factor 3)
- Impression: N jobs lp separes au lieu de lp -n (Mitsubishi ignore -n)
- Bouton Terminer grise pendant impression

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-02 18:54:10 +02:00
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
e5e9f763ab Bump cache versions (style v12, camera v17, gallery v4)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-30 13:25:22 +02:00
8bc854b5a7 Admin galerie + compteur copies + flash capture + erreur imprimante toast
- Galerie admin : onglet avec sélection photos + impression par lot
- Compteur : compte les copies (×N) et non plus 1 par impression
- Flash blanc immédiat après countdown pour masquer le délai AF
- Erreur imprimante : petit toast discret au lieu de bannière plein écran

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-30 13:24:55 +02:00
e9143f7a72 Remettre viewfinder off avant capture pour activer l'AF phase-detect
Sans ça le Canon reste en LiveView et utilise le contrast-detect AF
(lent et imprécis). Le sleep(0.2) reste supprimé.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-28 11:07:29 +02:00
d31c2cc811 Accélérer la capture : viewfinder warmup en arrière-plan
- Supprime sleep(0.2) avant capture et viewfinder off inutile
- Déplace activer_viewfinder + chauffer_liveview dans un thread
  daemon après le save, pour retourner la photo immédiatement
- Supprime pause(150ms) frontend entre fin countdown et capture

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-27 15:54:17 +02:00
5614be8c6e Supprimer le timeout auto-retour du menu admin
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-27 15:46:38 +02:00
4487af8399 Masquer popup QR à chaque changement d'écran
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-27 15:21:40 +02:00
c7bd92edea Fix QR après photo : corriger share.js qui écrasait afficherQR()
share.js était chargé après app.js et redéfinissait afficherQR()
avec l'ancienne version (mauvaise URL, mauvais éléments DOM).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-27 15:20:21 +02:00
e81daa7305 Cache-bust JS/CSS v16 + QR debug logging
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-27 15:19:08 +02:00
18ca4a8d5f Fix impression strip : ne pas pivoter/rotation_180 les pellicules
Le strip est généré en portrait 600x1800 par design. L'orientation
"paysage" configurée pour 10x15 le faisait pivoter, et rotation_180
l'inversait. Résultat : bandes imprimées à l'envers avec une seule
photo visible par bande après coupe.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-27 14:59:53 +02:00
0407244554 QR code par photo : popup QR dynamique + page vue photo sur booth
- afficherQR() génère un QR vers /g/{event}/view/{photo}?p=
- Page photo-view.html : plein écran + bouton télécharger + galerie
- QR accueil reste la galerie complète, QR après capture = la photo

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-27 14:14:28 +02:00
09c8ea75fa Fix QR code URL : utiliser /g/ au lieu de /admin/gallery/ (routes publiques)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-27 14:04:36 +02:00
1589754a92 Fix QR code URL : ajout /admin/gallery/ prefix + priorité evenement.event_id
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-27 13:49:20 +02:00
3c3dbd6c8b Watchdog systemd + fix stabilité kiosk/backend
- Watchdog (30s) : vérifie backend, caméra, chromium, tue les orphelins,
  reboot auto après 10 échecs consécutifs (~5min sans service)
- kiosk-session.sh : tue les backends orphelins au démarrage, trap EXIT
  pour cleanup quand le kiosk est tué
- camera.py : force release USB avec timeout 2s quand connecter() échoue
- main.py : uvicorn.run(app) au lieu de string pour éviter double import

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-27 13:21:02 +02:00
29e65ab1bd Keepalive Canon anti-veille : get_config() toutes les 10s quand preview KO
Quand capture_preview() echoue (I/O error, miroir bloque), le thread
preview n'envoyait plus de commande PTP au Canon → l'appareil finissait
par s'eteindre via auto power off. Ajoute un keepalive de secours via
get_config() qui declenche ptp_canon_eos_keepdeviceon cote libgphoto2.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-27 00:21:59 +02:00
5a81907a0e Kiosk : flag maintenance + anti-doublon backend
- Check /tmp/photobooth-maintenance pour bloquer le restart loop
- Vérifie qu'aucun backend ne tourne avant d'en lancer un nouveau

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-26 19:49:51 +02:00
8dabba65bb Admin WiFi : scan réseaux, connexion, mots de passe mémorisés
- Module backend/wifi.py : scan nmcli, connexion, mots de passe persistés dans data/wifi_passwords.json
- Gère la locale FR (oui/non), déduplique les AP multiples, prend le meilleur signal
- Onglet WiFi dans le menu admin avec statut, scan, clavier virtuel AZERTY complet
- Réseaux enregistrés avec bouton connecter/oublier et affichage temporaire du mdp
- API: /api/wifi/status, /api/wifi/scan, /api/wifi/connect, /api/wifi/saved, /api/wifi/password/{ssid}

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-26 19:29:12 +02:00
4e431507fc Resilience : ecran pause, reconnexion WS auto, watchdog systemd, WiFi monitor
- WebSocket : reconnexion avec backoff exponentiel au lieu de reload page
- Ecran pause avec tasse de cafe quand le backend est injoignable
- Endpoint /api/health pour monitoring externe
- Watchdog systemd (sd_notify READY=1 + WATCHDOG=1 toutes les 10s)
- Service systemd durci (Type=notify, WatchdogSec=30, KillMode, limites)
- WiFi watchdog : script + timer systemd, verifie la gateway toutes les 60s
- Destinations : fallback url_tunnel (WireGuard) en priorite

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-26 19:16:39 +02:00
48f54ab70a Robustesse DSLR + éclairage visage + fix pellicule cadre imposé
- camera.py: fix fuite FD USB sur échec reconnexion (gp.Camera non libéré),
  cleanup PTP session avant init, catch toutes exceptions preview/capture,
  deconnecter() avec timeout thread + force del pour libérer le port USB
- main.py: thread preview anti-crash (outer try/except), reconnexion DSLR
  même si preview actif, notification camera_erreur après échec capture,
  intégration module éclairage (analyse luminosité visage via WS)
- printer.py: mode pellicule (-2up) ignoré par le cadre imposé événement
- eclairage.py: nouveau module analyse luminosité visage (Haar + HSV)
- camera.js: indicateur éclairage temps réel (score/100 + visages)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-25 19:11:10 +02:00
31 changed files with 5574 additions and 505 deletions

1
.gitignore vendored
View File

@@ -17,3 +17,4 @@ data/exports/*
*.log *.log
.DS_Store .DS_Store
printer-driver-*.deb printer-driver-*.deb
data/config.json

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

@@ -79,19 +79,36 @@ class Camera:
if source == "gphoto2" or source is None: if source == "gphoto2" or source is None:
if GPHOTO2_DISPONIBLE: if GPHOTO2_DISPONIBLE:
cam = None
try: try:
self.camera = gp.Camera() cam = gp.Camera()
self.camera.init() try:
# Desactiver la mise en veille auto du DSLR (sinon il se deconnecte tout seul) cam.exit()
self._desactiver_veille_init() except Exception:
# Viewfinder activé AVANT de rendre la caméra visible au thread preview pass
self._activer_viewfinder_init() cam.init()
self.camera = cam
time.sleep(1)
self._configurer_init()
self.connectee = True self.connectee = True
self.mode = "gphoto2" self.mode = "gphoto2"
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 gp.GPhoto2Error as e: except Exception as e:
log.warning(f"Pas de DSLR : {e}") log.warning(f"Pas de DSLR : {e}")
if cam is not None:
def _force_release(c):
try:
c.exit()
except Exception:
pass
t = threading.Thread(target=_force_release, args=(cam,), daemon=True)
t.start()
t.join(timeout=2.0)
del cam
self.camera = None
# Pas de DSLR disponible : mode erreur, pas de fallback # Pas de DSLR disponible : mode erreur, pas de fallback
log.warning("Aucun DSLR disponible, camera non connectee") log.warning("Aucun DSLR disponible, camera non connectee")
@@ -116,25 +133,30 @@ class Camera:
def deconnecter(self, timeout: float = 3.0): def deconnecter(self, timeout: float = 3.0):
"""Deconnecte l'appareil photo avec timeout pour eviter le blocage PTP.""" """Deconnecte l'appareil photo avec timeout pour eviter le blocage PTP."""
if self.camera and GPHOTO2_DISPONIBLE: cam_ref = self.camera
ctx_ref = self.contexte
if cam_ref and GPHOTO2_DISPONIBLE:
def _exit(): def _exit():
try: try:
self.camera.exit() cam_ref.exit()
except Exception: except Exception:
pass pass
t = threading.Thread(target=_exit, daemon=True) t = threading.Thread(target=_exit, daemon=True)
t.start() t.start()
t.join(timeout=timeout) t.join(timeout=timeout)
if t.is_alive(): if t.is_alive():
log.warning("camera.exit() bloque — abandon (le FD sera libere au GC)") log.warning("camera.exit() bloque — force del camera/contexte pour liberer le FD USB")
if self.webcam: if self.webcam:
self.webcam.release() self.webcam.release()
self.camera = None self.camera = None
self.contexte = None self.contexte = None
del cam_ref
del ctx_ref
self.webcam = None self.webcam = None
self.webcam_index = -1 self.webcam_index = -1
self.connectee = False self.connectee = False
self.mode = "simulation" self.mode = "simulation"
self.preview_dslr_ok = False
log.info("Camera deconnectee") log.info("Camera deconnectee")
def capturer(self) -> Path | None: def capturer(self) -> Path | None:
@@ -160,31 +182,45 @@ class Camera:
for attempt in range(3): for attempt in range(3):
try: try:
with self._gp_lock: with self._gp_lock:
# Désactiver LiveView : miroir abaissé → AF phase-détection Canon disponible if not self.camera:
log.error("Camera None pendant capture")
self.preview_dslr_ok = False
return None
try: try:
cfg = self.camera.get_config() cfg = self.camera.get_config()
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.2) # Laisser le miroir descendre 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)
self.activer_viewfinder() # Miroir relevé immédiatement après la prise log.info(f"Capture: shutter={t1-t0:.1f}s download={t2-t1:.1f}s")
self._chauffer_liveview() # Absorbe la stabilisation LiveView ici (spinner visible), pas sur le countdown suivant 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
@@ -194,7 +230,16 @@ class Camera:
time.sleep(0.5) time.sleep(0.5)
continue continue
log.error(f"Erreur capture DSLR : {e}") log.error(f"Erreur capture DSLR : {e}")
self.preview_dslr_ok = False
return None return None
except Exception as e:
log.error(f"Erreur fatale capture DSLR : {e}")
self.preview_dslr_ok = False
return None
def _post_capture_warmup(self):
self.activer_viewfinder()
self._chauffer_liveview()
def _chauffer_liveview(self, tentatives: int = 8, pause: float = 0.15): def _chauffer_liveview(self, tentatives: int = 8, pause: float = 0.15):
"""Consomme les premieres frames LiveView juste apres la remontee du miroir """Consomme les premieres frames LiveView juste apres la remontee du miroir
@@ -261,35 +306,49 @@ 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),
("viewfinder", 1),
("iso", "800"),
("aperture", "4"),
("drivemode", "Single"),
]
for nom, valeur in reglages:
for tentative in range(3):
try: try:
cfg = self.camera.get_config() cfg = self.camera.get_config()
for nom in candidats: w = cfg.get_child_by_name(nom)
try: w.set_value(valeur)
widget = cfg.get_child_by_name(nom)
widget.set_value(0)
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."""
@@ -305,9 +364,23 @@ class Camera:
except Exception as e: except Exception as e:
log.warning(f"Viewfinder non supporté : {e}") log.warning(f"Viewfinder non supporté : {e}")
def keepalive(self) -> bool:
"""Envoie une commande legere au Canon pour empecher l'auto power off.
Utilise get_config qui declenche internement ptp_canon_eos_keepdeviceon."""
if self.mode != "gphoto2" or not self.connectee or not self.camera:
return False
try:
with self._gp_lock:
self.camera.get_config()
return True
except Exception:
return False
def _preview_gphoto2(self) -> bytes | None: def _preview_gphoto2(self) -> bytes | None:
try: try:
with self._gp_lock: with self._gp_lock:
if not self.camera:
return None
fichier = self.camera.capture_preview() fichier = self.camera.capture_preview()
donnees = bytes(fichier.get_data_and_size()) donnees = bytes(fichier.get_data_and_size())
img = cv2.imdecode(np.frombuffer(donnees, dtype=np.uint8), cv2.IMREAD_COLOR) img = cv2.imdecode(np.frombuffer(donnees, dtype=np.uint8), cv2.IMREAD_COLOR)
@@ -318,7 +391,7 @@ class Camera:
_, buf = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, 75]) _, buf = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, 75])
return buf.tobytes() return buf.tobytes()
return donnees return donnees
except gp.GPhoto2Error as e: except Exception as e:
log.error(f"Erreur preview DSLR : {e}") log.error(f"Erreur preview DSLR : {e}")
return None return None

View File

@@ -1,4 +1,5 @@
import ftplib import ftplib
import json
import logging import logging
import shutil import shutil
import threading import threading
@@ -6,12 +7,15 @@ from pathlib import Path
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
from urllib.error import URLError from urllib.error import URLError
from backend.config import charger_config, mettre_a_jour_config from backend.config import charger_config, mettre_a_jour_config, RACINE
log = logging.getLogger("photobooth.destinations") log = logging.getLogger("photobooth.destinations")
FICHIER_BOOTH_SPOOL = RACINE / "data" / "booth_spool.json"
_booth_spool_lock = threading.Lock()
def distribuer_photo(chemin_photo: Path, imprimee: bool = False):
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", {})
@@ -37,11 +41,21 @@ def distribuer_photo(chemin_photo: Path, imprimee: bool = False):
if dest.get("ftp", False): if dest.get("ftp", False):
envoyer_ftp(chemin_photo, dest, sous_dossier) envoyer_ftp(chemin_photo, dest, sous_dossier)
# Incrementer le compteur # Notifier booth que la photo a ete imprimee
if imprimee and booth.get("actif", False):
threading.Thread(
target=notifier_booth_impression,
args=(chemin_photo.name, copies, booth, config),
daemon=True,
).start()
# Incrementer le compteur (feuilles 10x15 consommees)
if imprimee:
compteur = config.get("compteur", {}) compteur = config.get("compteur", {})
if compteur.get("actif", False): if compteur.get("actif", False):
compteur["photos_prises"] = compteur.get("photos_prises", 0) + 1 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):
@@ -98,48 +112,152 @@ def envoyer_ftp(chemin_photo: Path, config_dest: dict, sous_dossier: str | None
return False return False
def envoyer_booth(chemin_photo: Path, config_booth: dict): def _upload_booth(url: str, api_key: str, event_id: str, chemin_photo: Path) -> bool:
"""Envoie une photo vers la galerie live booth."""
url = config_booth.get("url", "").rstrip("/")
api_key = config_booth.get("api_key", "")
event_id = config_booth.get("event_id", "default")
if not url:
log.warning("Booth non configure (URL manquante)")
return False
try:
import mimetypes
boundary = "----BoothUpload" boundary = "----BoothUpload"
filename = chemin_photo.name filename = chemin_photo.name
with open(chemin_photo, "rb") as f: with open(chemin_photo, "rb") as f:
file_data = f.read() file_data = f.read()
body = ( body = (
f"--{boundary}\r\n" f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="photo"; filename="{filename}"\r\n' f'Content-Disposition: form-data; name="photo"; filename="{filename}"\r\n'
f"Content-Type: image/jpeg\r\n\r\n" f"Content-Type: image/jpeg\r\n\r\n"
).encode() + file_data + f"\r\n--{boundary}--\r\n".encode() ).encode() + file_data + f"\r\n--{boundary}--\r\n".encode()
req = Request(f"{url}/admin/gallery/{event_id}/upload", data=body, method="POST")
req = Request(
f"{url}/api/{event_id}/upload",
data=body,
method="POST",
)
req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}") req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}")
req.add_header("X-Api-Key", api_key) req.add_header("X-Api-Key", api_key)
with urlopen(req, timeout=10) as resp:
return resp.status == 200
def envoyer_booth(chemin_photo: Path, config_booth: dict):
"""Envoie une photo vers la galerie live booth. En cas d'echec, met en spool."""
url = config_booth.get("url", "").rstrip("/")
url_tunnel = config_booth.get("url_tunnel", "").rstrip("/")
api_key = config_booth.get("api_key", "")
config = charger_config()
event_id = config.get("evenement", {}).get("event_id") or config_booth.get("event_id", "default")
if not url and not url_tunnel:
log.warning("Booth non configure (URL manquante)")
return False
for tentative_url in [u for u in (url_tunnel, url) if u]:
try:
if _upload_booth(tentative_url, api_key, event_id, chemin_photo):
log.info(f"Photo envoyee au booth via {tentative_url} : {chemin_photo.name}")
return True
log.error(f"Booth erreur HTTP via {tentative_url}")
except (URLError, OSError) as e:
log.warning(f"Echec envoi booth via {tentative_url} : {e}")
_ajouter_booth_spool(str(chemin_photo))
return False
def notifier_booth_impression(filename: str, copies: int, config_booth: dict, config: dict):
"""Notifie booth.copydev.fr qu'une photo a ete imprimee."""
url = config_booth.get("url", "").rstrip("/")
url_tunnel = config_booth.get("url_tunnel", "").rstrip("/")
api_key = config_booth.get("api_key", "")
event_id = config.get("evenement", {}).get("event_id") or config_booth.get("event_id", "default")
body = json.dumps({"copies": copies}).encode()
for tentative_url in [u for u in (url_tunnel, url) if u]:
try:
req = Request(
f"{tentative_url}/admin/gallery/{event_id}/photo/{filename}/imprimee",
data=body, method="POST",
)
req.add_header("Content-Type", "application/json")
req.add_header("X-Api-Key", api_key)
with urlopen(req, timeout=10) as resp: with urlopen(req, timeout=10) as resp:
if resp.status == 200: if resp.status == 200:
log.info(f"Photo envoyee au booth : {filename}") log.info(f"Booth notifie impression: {filename} x{copies}")
return True return
else:
log.error(f"Booth erreur HTTP {resp.status}")
return False
except (URLError, OSError) as e: except (URLError, OSError) as e:
log.error(f"Erreur envoi booth : {e}") log.warning(f"Echec notification impression booth: {e}")
return False
# --- Spool galerie booth ---
def _ajouter_booth_spool(chemin: str):
with _booth_spool_lock:
spool = _charger_booth_spool()
if chemin not in spool:
spool.append(chemin)
_sauver_booth_spool(spool)
log.info(f"Photo mise en spool galerie ({len(spool)} en attente)")
def _charger_booth_spool() -> list:
if not FICHIER_BOOTH_SPOOL.exists():
return []
try:
with open(FICHIER_BOOTH_SPOOL, "r") as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
return []
def _sauver_booth_spool(spool: list):
try:
with open(FICHIER_BOOTH_SPOOL, "w") as f:
json.dump(spool, f)
except OSError:
pass
def taille_booth_spool() -> int:
with _booth_spool_lock:
return len(_charger_booth_spool())
def traiter_booth_spool() -> int:
"""Retente l'envoi des photos en spool. Retourne le nombre envoyees."""
with _booth_spool_lock:
spool = _charger_booth_spool()
if not spool:
return 0
config = charger_config()
booth = config.get("booth", {})
if not booth.get("actif", False):
return 0
envoyes = 0
restants = []
for chemin_str in spool:
chemin = Path(chemin_str)
if not chemin.exists():
log.warning(f"Spool booth: photo introuvable {chemin}, ignoree")
continue
url = booth.get("url", "").rstrip("/")
url_tunnel = booth.get("url_tunnel", "").rstrip("/")
api_key = booth.get("api_key", "")
event_id = config.get("evenement", {}).get("event_id") or booth.get("event_id", "default")
ok = False
for tentative_url in [u for u in (url_tunnel, url) if u]:
try:
if _upload_booth(tentative_url, api_key, event_id, chemin):
log.info(f"Spool booth: {chemin.name} envoyee via {tentative_url}")
ok = True
break
except (URLError, OSError):
pass
if ok:
envoyes += 1
else:
restants.append(chemin_str)
break
if envoyes > 0 or len(restants) < len(spool):
idx = envoyes + len(restants)
restants.extend(spool[idx:])
with _booth_spool_lock:
_sauver_booth_spool(restants)
log.info(f"Spool booth: {envoyes} envoyee(s), {len(restants)} en attente")
return envoyes
def recuperer_booth_password() -> dict: def recuperer_booth_password() -> dict:
@@ -147,21 +265,19 @@ def recuperer_booth_password() -> dict:
config = charger_config() config = charger_config()
booth = config.get("booth", {}) booth = config.get("booth", {})
url = booth.get("url", "").rstrip("/") url = booth.get("url", "").rstrip("/")
url_tunnel = booth.get("url_tunnel", "").rstrip("/")
api_key = booth.get("api_key", "") api_key = booth.get("api_key", "")
event_id = booth.get("event_id", "default") event_id = config.get("evenement", {}).get("event_id") or booth.get("event_id", "default")
if not url:
return {"password": None}
for tentative_url in [u for u in (url_tunnel, url) if u]:
try: try:
req = Request(f"{url}/api/{event_id}/info") req = Request(f"{tentative_url}/admin/gallery/{event_id}/info")
req.add_header("X-Api-Key", api_key) req.add_header("X-Api-Key", api_key)
with urlopen(req, timeout=5) as resp: with urlopen(req, timeout=5) as resp:
import json import json
data = json.loads(resp.read()) return json.loads(resp.read())
return data
except (URLError, OSError) as e: except (URLError, OSError) as e:
log.error(f"Erreur recuperation booth info : {e}") log.warning(f"Echec recuperation booth info via {tentative_url} : {e}")
return {"password": None} return {"password": None}
@@ -207,19 +323,110 @@ 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", 320) - conso.get("papier_utilise", 0))
ruban_rest = max(0, round(conso.get("ruban_capacite", 320) - 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", 320)
ruban_cap = conso.get("ruban_capacite", 320)
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": 2.0,
}
# Poids papier : combien d'unites 10x15 une feuille consomme
POIDS_PAPIER = {
"10x15": 1, "10x15-2up": 1,
"15x20": 2, "15x20-2up": 2,
"15x15": 2, "15x15-2up": 2,
}
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
poids = POIDS_PAPIER.get(fmt, 1)
conso["papier_utilise"] = conso.get("papier_utilise", 0) + feuilles * poids
conso["photos_imprimees"] = conso.get("photos_imprimees", 0) + copies
ratio_optimal = RATIO_RUBAN_OPTIMAL.get(fmt, 1.0)
ruban_par_feuille = float(poids)
if rembobinage:
ruban_consomme = feuilles * ratio_optimal
else:
ruban_consomme = feuilles * ruban_par_feuille
conso["ruban_utilise"] = round(conso.get("ruban_utilise", 0) + ruban_consomme, 1)
poses_perdues = feuilles * ruban_par_feuille - 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", 320)
ruban_cap = conso.get("ruban_capacite", 320)
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})

114
backend/eclairage.py Normal file
View File

@@ -0,0 +1,114 @@
"""Analyse de luminosité sur les visages détectés dans le preview DSLR.
Produit une note d'éclairage (0-100) et une recommandation (allumer/ok/sombre).
Échantillonne toutes les ~3 secondes pour ne pas charger le CPU.
"""
import logging
import time
from pathlib import Path
import cv2
import numpy as np
log = logging.getLogger("photobooth.eclairage")
_CASCADE_CANDIDATES = [
"/usr/share/opencv4/haarcascades/haarcascade_frontalface_default.xml",
"/usr/share/opencv/haarcascades/haarcascade_frontalface_default.xml",
"/usr/local/share/opencv4/haarcascades/haarcascade_frontalface_default.xml",
]
try:
_CASCADE_PATH = cv2.data.haarcascades + "haarcascade_frontalface_default.xml"
except AttributeError:
_CASCADE_PATH = next((p for p in _CASCADE_CANDIDATES if Path(p).exists()), _CASCADE_CANDIDATES[0])
_face_cascade = cv2.CascadeClassifier(_CASCADE_PATH)
SEUIL_SOMBRE = 45
SEUIL_CORRECT = 65
_dernier_analyse: float = 0
_INTERVALLE = 1.0
_dernier_resultat: dict | None = None
def analyser_frame(jpeg_data: bytes) -> dict | None:
"""Analyse une frame JPEG du preview.
Retourne None si l'intervalle n'est pas écoulé (throttle).
Sinon retourne:
{
"score": int 0-100,
"action": "allumer" | "attention" | "ok",
"visages": int,
"detail": str,
}
"""
global _dernier_analyse, _dernier_resultat
now = time.monotonic()
if now - _dernier_analyse < _INTERVALLE:
return None
_dernier_analyse = now
try:
arr = np.frombuffer(jpeg_data, dtype=np.uint8)
img = cv2.imdecode(arr, cv2.IMREAD_COLOR)
if img is None:
return None
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
faces = _face_cascade.detectMultiScale(
gray, scaleFactor=1.2, minNeighbors=4, minSize=(60, 60)
)
if len(faces) == 0:
_dernier_resultat = {
"score": -1,
"action": "no_face",
"visages": 0,
"detail": "Aucun visage détecté",
}
return _dernier_resultat
hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
scores = []
for (x, y, w, h) in faces:
pad_x = int(w * 0.1)
pad_y = int(h * 0.1)
x1 = max(0, x + pad_x)
y1 = max(0, y + pad_y)
x2 = min(img.shape[1], x + w - pad_x)
y2 = min(img.shape[0], y + h - pad_y)
roi_v = hsv[y1:y2, x1:x2, 2]
score = int(np.mean(roi_v) / 2.55)
scores.append(score)
score_moyen = int(np.mean(scores))
if score_moyen < SEUIL_SOMBRE:
action = "allumer"
detail = f"Visages sombres ({score_moyen}/100)"
elif score_moyen < SEUIL_CORRECT:
action = "attention"
detail = f"Éclairage limite ({score_moyen}/100)"
else:
action = "ok"
detail = f"Éclairage correct ({score_moyen}/100)"
_dernier_resultat = {
"score": score_moyen,
"action": action,
"visages": len(faces),
"detail": detail,
}
return _dernier_resultat
except Exception as e:
log.debug(f"Erreur analyse éclairage : {e}")
return None
def dernier_resultat() -> dict | None:
return _dernier_resultat

View File

@@ -121,18 +121,32 @@ def chroma_key(chemin_photo: Path, nom_fond: str | None = None,
return chemin_export return chemin_export
def appliquer_cadre_impression(img: Image.Image, format_papier: str, nom_cadre: str, event_id: str | None = None) -> Image.Image: def _chercher_cadre(format_papier: str, nom_cadre: str, event_id: str | None = None) -> Path | None:
"""Composite un cadre PNG par-dessus l'image assemblée.""" """Cherche le cadre dans le format demandé, sinon fallback autres formats."""
chemin = None candidats = []
if event_id: if event_id:
from backend.evenements import DOSSIER_EVENEMENTS from backend.evenements import DOSSIER_EVENEMENTS
chemin_event = DOSSIER_EVENEMENTS / event_id / "cadres" / format_papier / nom_cadre base_event = DOSSIER_EVENEMENTS / event_id / "cadres"
if chemin_event.exists(): candidats.append(base_event / format_papier / nom_cadre)
chemin = chemin_event for d in sorted(base_event.iterdir()) if base_event.exists() else []:
if d.is_dir() and d.name != format_papier:
candidats.append(d / nom_cadre)
candidats.append(DOSSIER_CADRES / format_papier / nom_cadre)
for d in sorted(DOSSIER_CADRES.iterdir()) if DOSSIER_CADRES.exists() else []:
if d.is_dir() and d.name != format_papier:
candidats.append(d / nom_cadre)
for c in candidats:
if c.exists():
return c
return None
def appliquer_cadre_impression(img: Image.Image, format_papier: str, nom_cadre: str, event_id: str | None = None) -> Image.Image:
"""Composite un cadre PNG par-dessus l'image assemblée.
Si le cadre n'existe pas dans le format demandé, utilise celui d'un autre format (resize)."""
chemin = _chercher_cadre(format_papier, nom_cadre, event_id)
if chemin is None: if chemin is None:
chemin = DOSSIER_CADRES / format_papier / nom_cadre log.warning(f"Cadre introuvable : {nom_cadre} pour {format_papier}")
if not chemin.exists():
log.warning(f"Cadre introuvable : {chemin}")
return img return img
cadre = Image.open(chemin).convert("RGBA") cadre = Image.open(chemin).convert("RGBA")
img_portrait = img.height > img.width img_portrait = img.height > img.width
@@ -147,11 +161,16 @@ def appliquer_cadre_impression(img: Image.Image, format_papier: str, nom_cadre:
def lister_cadres(format_papier: str) -> list[str]: def lister_cadres(format_papier: str) -> list[str]:
"""Liste les cadres disponibles pour un format donné.""" """Liste les cadres disponibles pour un format. Inclut ceux d'autres formats en fallback."""
noms = set()
dossier = DOSSIER_CADRES / format_papier dossier = DOSSIER_CADRES / format_papier
if not dossier.exists(): if dossier.exists():
return [] noms.update(f.name for f in dossier.iterdir() if f.suffix.lower() == ".png")
return sorted(f.name for f in dossier.iterdir() if f.suffix.lower() == ".png") if not noms and DOSSIER_CADRES.exists():
for d in DOSSIER_CADRES.iterdir():
if d.is_dir():
noms.update(f.name for f in d.iterdir() if f.suffix.lower() == ".png")
return sorted(noms)
def lister_overlays() -> list[str]: def lister_overlays() -> list[str]:

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
@@ -37,7 +38,7 @@ def lister_evenements() -> list[dict]:
def creer_evenement(nom: str, **kwargs) -> dict: def creer_evenement(nom: str, **kwargs) -> dict:
event_id = uuid.uuid4().hex[:8] event_id = kwargs.get("event_id") or uuid.uuid4().hex[:8]
event = { event = {
"id": event_id, "id": event_id,
"nom": nom, "nom": nom,
@@ -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,10 +89,46 @@ 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:
return None return None
config = charger_config()
booth = config.get("booth", {})
if booth.get("actif"):
booth["event_id"] = event_id
mettre_a_jour_config({ mettre_a_jour_config({
"evenement": { "evenement": {
"event_id": event_id, "event_id": event_id,
@@ -99,7 +138,8 @@ def activer_evenement(event_id: str) -> dict | None:
"couleur_secondaire": event.get("couleur_secondaire", "#ffffff"), "couleur_secondaire": event.get("couleur_secondaire", "#ffffff"),
"media_accueil": event.get("media_accueil"), "media_accueil": event.get("media_accueil"),
"formats_actifs": event.get("formats_actifs", ["10x15", "15x20", "strip"]), "formats_actifs": event.get("formats_actifs", ["10x15", "15x20", "strip"]),
} },
"booth": booth,
}) })
log.info(f"Evenement active : {event['nom']} ({event_id})") log.info(f"Evenement active : {event['nom']} ({event_id})")
return event return event

View File

@@ -178,45 +178,98 @@ def envoyer_rapport_spool(nb_envoyes: int, destinataires: list[str], nb_echoues:
log.error(f"Erreur envoi rapport spool : {e}") log.error(f"Erreur envoi rapport spool : {e}")
async def tache_spool_demarrage(): RETRY_INTERVAL = 300 # 5 minutes
"""Tente de vider le spool une seule fois au demarrage du serveur.""" WATCHDOG_INTERVAL = 60 # 1 minute
await asyncio.sleep(10)
total = taille_spool()
if total > 0: async def _flush_spools():
log.info(f"Spool: tentative d'envoi au demarrage ({total} en attente)") """Vide les spools email + galerie."""
nb, dests = await asyncio.get_event_loop().run_in_executor(None, traiter_spool) from backend.destinations import traiter_booth_spool, taille_booth_spool
loop = asyncio.get_event_loop()
total_mail = taille_spool()
if total_mail > 0:
log.info(f"Spool email: retry ({total_mail} en attente)")
nb, dests = await loop.run_in_executor(None, traiter_spool)
if nb > 0: if nb > 0:
echoues = total - nb echoues = total_mail - nb
await asyncio.get_event_loop().run_in_executor( await loop.run_in_executor(None, envoyer_rapport_spool, nb, dests, echoues)
None, envoyer_rapport_spool, nb, dests, echoues total_booth = taille_booth_spool()
) if total_booth > 0:
log.info(f"Spool galerie: retry ({total_booth} en attente)")
await loop.run_in_executor(None, traiter_booth_spool)
async def tache_spool_periodique():
"""Watchdog connectivite (60s) + retry spool (5 min)."""
from backend.wifi import watchdog_tick
from backend.destinations import taille_booth_spool
await asyncio.sleep(15)
loop = asyncio.get_event_loop()
ticks_depuis_flush = 0
while True:
result = await loop.run_in_executor(None, watchdog_tick)
if result == "restored":
await _flush_spools()
ticks_depuis_flush = 0
else:
ticks_depuis_flush += 1
if ticks_depuis_flush >= RETRY_INTERVAL // WATCHDOG_INTERVAL:
has_spool = taille_spool() > 0 or taille_booth_spool() > 0
if has_spool:
await _flush_spools()
ticks_depuis_flush = 0
await asyncio.sleep(WATCHDOG_INTERVAL)
async def tache_spool_demarrage():
"""Alias pour compatibilite — lance la boucle periodique."""
await tache_spool_periodique()
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

@@ -79,7 +79,8 @@ def _crop_to(img, largeur: int, hauteur: int):
def _preparer_image(chemin: Path, largeur: int, hauteur: int, def _preparer_image(chemin: Path, largeur: int, hauteur: int,
format_papier: str | None = None, cadre: str | None = None, format_papier: str | None = None, cadre: str | None = None,
orientation: str = "paysage", event_id: str | None = None) -> Path: orientation: str = "paysage", event_id: str | None = None,
skip_rotate: bool = False) -> Path:
""" """
Pivote si nécessaire, recadre aux dimensions exactes (cover crop), Pivote si nécessaire, recadre aux dimensions exactes (cover crop),
applique le cadre, compense les marges de coupe, sauvegarde en temp. applique le cadre, compense les marges de coupe, sauvegarde en temp.
@@ -93,6 +94,7 @@ def _preparer_image(chemin: Path, largeur: int, hauteur: int,
img = Image.open(chemin).convert("RGB") img = Image.open(chemin).convert("RGB")
img = ImageOps.exif_transpose(img) img = ImageOps.exif_transpose(img)
if not skip_rotate:
if orientation == "portrait" and img.width > img.height: if orientation == "portrait" and img.width > img.height:
img = img.rotate(90, expand=True) img = img.rotate(90, expand=True)
elif orientation == "paysage" and img.height > img.width: elif orientation == "paysage" and img.height > img.width:
@@ -106,9 +108,16 @@ def _preparer_image(chemin: Path, largeur: int, hauteur: int,
img = _appliquer_offsets_coupe(img) img = _appliquer_offsets_coupe(img)
config_imp = charger_config().get("impression", {}) config_imp = charger_config().get("impression", {})
if config_imp.get("rotation_180", False): if config_imp.get("rotation_180", False) and not skip_rotate:
img = img.rotate(180) img = img.rotate(180)
luminosite = config_imp.get("luminosite_impression", 15)
if luminosite != 0:
from PIL import ImageEnhance
facteur = 1.0 + luminosite / 100.0
img = ImageEnhance.Brightness(img).enhance(facteur)
log.debug(f"Luminosité impression ajustée : {luminosite}% (facteur {facteur:.2f})")
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
img.save(tmp.name, "JPEG", quality=95, dpi=(300, 300)) img.save(tmp.name, "JPEG", quality=95, dpi=(300, 300))
log.debug(f"Image préparée : {orientation} {largeur}x{hauteur}px → {tmp.name}") log.debug(f"Image préparée : {orientation} {largeur}x{hauteur}px → {tmp.name}")
@@ -247,7 +256,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:
@@ -258,14 +267,15 @@ def imprimer(
largeur, hauteur = fmt["px"] largeur, hauteur = fmt["px"]
fmt_base = format_papier.replace("-2up", "") fmt_base = format_papier.replace("-2up", "")
cadre = cadre_override if cadre_override is not None else conf_imp.get("cadres_actifs", {}).get(fmt_base) is_strip = "-2up" in format_papier
orientation = conf_imp.get("orientations", {}).get(fmt_base, "portrait") cadre = cadre_override if cadre_override is not None else (None if is_strip else conf_imp.get("cadres_actifs", {}).get(fmt_base))
orientation = "portrait" if is_strip else conf_imp.get("orientations", {}).get(fmt_base, "portrait")
if orientation == "paysage" and hauteur > largeur: if orientation == "paysage" and hauteur > largeur:
largeur, hauteur = hauteur, largeur largeur, hauteur = hauteur, largeur
event_id = config.get("evenement", {}).get("event_id") event_id = config.get("evenement", {}).get("event_id")
if event_id and cadre_override is None: if event_id and cadre_override is None and not is_strip:
from backend.evenements import cadre_actif_pour_impression from backend.evenements import cadre_actif_pour_impression
ev_cadre, ev_mode = cadre_actif_pour_impression(fmt_base) ev_cadre, ev_mode = cadre_actif_pour_impression(fmt_base)
if ev_mode == "impose" and ev_cadre: if ev_mode == "impose" and ev_cadre:
@@ -277,7 +287,7 @@ def imprimer(
log.error(f"Fichier introuvable : {chemin_photo}") log.error(f"Fichier introuvable : {chemin_photo}")
return {"succes": False, "erreur": ERREUR_FICHIER, "message": "Fichier photo introuvable"} return {"succes": False, "erreur": ERREUR_FICHIER, "message": "Fichier photo introuvable"}
chemin_print = _preparer_image(chemin_photo, largeur, hauteur, fmt_base, cadre, orientation, event_id=event_id) chemin_print = _preparer_image(chemin_photo, largeur, hauteur, fmt_base, cadre, orientation, event_id=event_id, skip_rotate=is_strip)
tmp_a_supprimer = [chemin_print] if chemin_print != chemin_photo else [] tmp_a_supprimer = [chemin_print] if chemin_print != chemin_photo else []
px_page = fmt.get("px_page") px_page = fmt.get("px_page")
@@ -296,25 +306,33 @@ def imprimer(
} }
time.sleep(1) time.sleep(1)
rembobinage = conf_imp.get("rembobinage_ruban", False)
try: try:
jobs = []
for copie in range(1, copies + 1):
job_ok = False
for tentative in range(1, 4): for tentative in range(1, 4):
cmd = [ cmd = [
"lp", "lp",
"-d", imprimante, "-d", imprimante,
"-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", "StpPrintSpeed=UltraFine"])
cmd.append(str(chemin_print))
code, out, err = _run(cmd, timeout=30) code, out, err = _run(cmd, timeout=30)
if code == 0: if code == 0:
job = out.strip() job = out.strip()
log.info(f"Impression lancée : {job} ({format_papier} {largeur}x{hauteur}px, x{copies})") log.info(f"Impression copie {copie}/{copies} lancée : {job} ({format_papier} {largeur}x{hauteur}px)")
return {"succes": True, "job": job} jobs.append(job)
job_ok = True
break
log.warning(f"Impression échouée (tentative {tentative}/3) : {err.strip()}") log.warning(f"Impression copie {copie}/{copies} échouée (tentative {tentative}/3) : {err.strip()}")
if tentative < 3: if tentative < 3:
if _statut(imprimante) == "stopped": if _statut(imprimante) == "stopped":
@@ -334,11 +352,18 @@ def imprimer(
_reactiver(imprimante) _reactiver(imprimante)
time.sleep(2) time.sleep(2)
if not job_ok:
return { return {
"succes": False, "succes": False,
"erreur": ERREUR_IMPRESSION, "erreur": ERREUR_IMPRESSION,
"message": "Impression échouée après 3 tentatives", "message": f"Impression copie {copie}/{copies} échouée après 3 tentatives",
} }
if copie < copies:
time.sleep(3)
log.info(f"Impression terminée : {copies} copie(s), jobs={jobs}")
return {"succes": True, "job": ", ".join(jobs)}
finally: finally:
for tmp in tmp_a_supprimer: for tmp in tmp_a_supprimer:
tmp.unlink(missing_ok=True) tmp.unlink(missing_ok=True)

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

262
backend/wifi.py Normal file
View File

@@ -0,0 +1,262 @@
"""Gestion WiFi via nmcli — scan, connexion, réseaux enregistrés."""
import json
import logging
import subprocess
from pathlib import Path
log = logging.getLogger("photobooth.wifi")
WIFI_PASSWORDS_FILE = Path(__file__).parent.parent / "data" / "wifi_passwords.json"
def _nmcli_split(line):
"""Split nmcli -t output, gère les ':' échappés par '\\'."""
parts = []
cur = []
i = 0
while i < len(line):
if line[i] == '\\' and i + 1 < len(line):
cur.append(line[i + 1])
i += 2
elif line[i] == ':':
parts.append(''.join(cur))
cur = []
i += 1
else:
cur.append(line[i])
i += 1
parts.append(''.join(cur))
return parts
def _charger_mdp() -> dict:
if WIFI_PASSWORDS_FILE.exists():
try:
return json.loads(WIFI_PASSWORDS_FILE.read_text())
except Exception:
return {}
return {}
def _sauvegarder_mdp(data: dict):
WIFI_PASSWORDS_FILE.parent.mkdir(parents=True, exist_ok=True)
WIFI_PASSWORDS_FILE.write_text(json.dumps(data, indent=2, ensure_ascii=False))
def wifi_status() -> dict:
result = {"connecte": False, "ssid": None, "signal": None, "ip": None, "interface": None}
try:
r = subprocess.run(
["nmcli", "-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device", "status"],
capture_output=True, text=True, timeout=10,
)
for line in r.stdout.strip().split("\n"):
p = _nmcli_split(line)
if len(p) >= 4 and p[1] == "wifi" and p[2] == "connected":
result.update(connecte=True, interface=p[0], ssid=p[3])
break
if result["connecte"] and result["interface"]:
r2 = subprocess.run(
["nmcli", "-t", "-f", "IP4.ADDRESS", "device", "show", result["interface"]],
capture_output=True, text=True, timeout=5,
)
for line in r2.stdout.strip().split("\n"):
if "IP4.ADDRESS" in line:
result["ip"] = line.split(":", 1)[1].strip().split("/")[0]
break
r3 = subprocess.run(
["nmcli", "-t", "-f", "ACTIVE,SIGNAL", "dev", "wifi", "list"],
capture_output=True, text=True, timeout=5,
)
for line in r3.stdout.strip().split("\n"):
p = _nmcli_split(line)
if len(p) >= 2 and p[0] in ("yes", "oui") and p[1].isdigit():
result["signal"] = int(p[1])
break
except Exception as e:
log.warning(f"wifi_status: {e}")
return result
def wifi_scan() -> list:
saved_ssids = {n["ssid"] for n in wifi_saved_list()}
networks = []
try:
r = subprocess.run(
["nmcli", "-t", "-f", "SSID,SIGNAL,SECURITY,ACTIVE", "dev", "wifi", "list", "--rescan", "yes"],
capture_output=True, text=True, timeout=20,
)
merged = {}
for line in r.stdout.strip().split("\n"):
if not line.strip():
continue
p = _nmcli_split(line)
if len(p) < 4:
continue
ssid = p[0]
if not ssid:
continue
sig = int(p[1]) if p[1].isdigit() else 0
is_active = p[3] in ("yes", "oui")
if ssid in merged:
if sig > merged[ssid]["signal"]:
merged[ssid]["signal"] = sig
if is_active:
merged[ssid]["actif"] = True
else:
merged[ssid] = {
"ssid": ssid,
"signal": sig,
"securise": bool(p[2] and p[2] not in ("--", "")),
"securite": p[2] if p[2] not in ("--", "") else "",
"actif": is_active,
"enregistre": ssid in saved_ssids,
}
networks = list(merged.values())
networks.sort(key=lambda n: (-n["actif"], -n["signal"]))
except Exception as e:
log.warning(f"wifi_scan: {e}")
return networks
def wifi_connect(ssid: str, password: str | None = None) -> dict:
mdp = _charger_mdp()
if not password and ssid in mdp:
password = mdp[ssid]
# Réseau enregistré sans nouveau mot de passe → activer la connexion existante
if not password:
try:
r = subprocess.run(
["nmcli", "connection", "up", ssid],
capture_output=True, text=True, timeout=30,
)
if r.returncode == 0:
return {"succes": True, "message": f"Connecté à {ssid}"}
except Exception:
pass
if password:
try:
# Supprimer l'ancienne connexion pour éviter les doublons
subprocess.run(["nmcli", "connection", "delete", ssid],
capture_output=True, text=True, timeout=5)
r = subprocess.run(
["nmcli", "dev", "wifi", "connect", ssid, "password", password],
capture_output=True, text=True, timeout=30,
)
if r.returncode == 0:
mdp[ssid] = password
_sauvegarder_mdp(mdp)
return {"succes": True, "message": f"Connecté à {ssid}"}
err = r.stderr.strip() or r.stdout.strip()
if "secret" in err.lower() or "no suitable" in err.lower():
return {"succes": False, "message": "Mot de passe incorrect"}
return {"succes": False, "message": err}
except subprocess.TimeoutExpired:
return {"succes": False, "message": "Timeout — le réseau ne répond pas"}
except Exception as e:
return {"succes": False, "message": str(e)}
return {"succes": False, "message": "Mot de passe requis"}
def wifi_saved_list() -> list:
saved = []
mdp = _charger_mdp()
try:
r = subprocess.run(
["nmcli", "-t", "-f", "NAME,TYPE", "connection", "show"],
capture_output=True, text=True, timeout=5,
)
for line in r.stdout.strip().split("\n"):
p = _nmcli_split(line)
if len(p) >= 2 and p[1] == "802-11-wireless":
saved.append({"ssid": p[0], "mdp_connu": p[0] in mdp})
except Exception as e:
log.warning(f"wifi_saved_list: {e}")
return saved
def wifi_forget(ssid: str) -> dict:
mdp = _charger_mdp()
try:
r = subprocess.run(
["nmcli", "connection", "delete", ssid],
capture_output=True, text=True, timeout=10,
)
if ssid in mdp:
del mdp[ssid]
_sauvegarder_mdp(mdp)
if r.returncode == 0:
return {"succes": True}
return {"succes": False, "message": r.stderr.strip()}
except Exception as e:
return {"succes": False, "message": str(e)}
def wifi_get_password(ssid: str) -> str | None:
return _charger_mdp().get(ssid)
# --- Watchdog connectivité ---
_internet_ok = False
def check_internet(timeout: int = 5) -> bool:
"""Teste la connectivité internet (DNS + HTTP rapide)."""
import socket
try:
socket.create_connection(("1.1.1.1", 53), timeout=timeout).close()
return True
except OSError:
return False
def watchdog_tick() -> str | None:
"""Vérifie internet. Retourne 'restored' si passage offline→online, None sinon."""
global _internet_ok
now_ok = check_internet()
if now_ok and not _internet_ok:
_internet_ok = True
log.info("Internet restauré — flush spool")
return "restored"
_internet_ok = now_ok
if not now_ok:
_tenter_reconnexion_wifi()
return None
def _tenter_reconnexion_wifi():
"""Si déconnecté du WiFi, tente de se reconnecter à un réseau enregistré visible."""
status = wifi_status()
if status["connecte"]:
return
log.info("Pas de WiFi — scan des réseaux enregistrés")
saved = {n["ssid"] for n in wifi_saved_list()}
if not saved:
return
try:
r = subprocess.run(
["nmcli", "-t", "-f", "SSID,SIGNAL", "dev", "wifi", "list", "--rescan", "yes"],
capture_output=True, text=True, timeout=15,
)
candidates = []
for line in r.stdout.strip().split("\n"):
p = _nmcli_split(line)
if len(p) >= 2 and p[0] in saved and p[1].isdigit():
candidates.append((p[0], int(p[1])))
candidates.sort(key=lambda x: -x[1])
for ssid, sig in candidates:
log.info(f"Tentative reconnexion WiFi: {ssid} (signal {sig}%)")
result = wifi_connect(ssid)
if result.get("succes"):
log.info(f"Reconnecté à {ssid}")
return
except Exception as e:
log.warning(f"Reconnexion WiFi échouée: {e}")

View File

@@ -1,85 +0,0 @@
{
"evenement": {
"nom": "Mon Evenement",
"logo": null,
"couleur_primaire": "#e91e63",
"couleur_secondaire": "#ffffff",
"media_accueil": null
},
"fonctionnalites": {
"photo_simple": true,
"multi_shot": true,
"filtres": true,
"overlays": true,
"chroma_key": false,
"impression": true,
"email": true,
"qr_code": true,
"galerie": true
},
"camera": {
"appareil": null,
"iso": "auto",
"balance_blancs": "auto",
"compte_a_rebours": 3,
"animation_compte_a_rebours": "classique",
"animations_css_actives": ["classique"]
},
"animations_custom": {
"actives": []
},
"compteur": {
"actif": true,
"limite": 400,
"photos_prises": 0
},
"destinations": {
"memoire_interne": true,
"cle_usb": false,
"chemin_usb": "/media/usb",
"ftp": false,
"ftp_host": "",
"ftp_port": 21,
"ftp_user": "",
"ftp_password": "",
"ftp_chemin": "/photobooth",
"site_web": false,
"site_web_url": "",
"email_auto": false,
"sauvegarder_tout": true
},
"impression": {
"imprimante": null,
"copies_max": 5,
"format": "10x15"
},
"cadres": {
"actifs": []
},
"email": {
"smtp_host": "",
"smtp_port": 587,
"smtp_user": "",
"smtp_password": "",
"expediteur": "",
"sujet": "Votre photo - {evenement}",
"message": "Voici votre photo prise lors de {evenement} ! Merci et a bientot."
},
"qr_code": {
"url_galerie": ""
},
"multi_shot": {
"nombre_photos": 3,
"mode": "strip",
"delai_entre_photos": 2
},
"chroma_key": {
"couleur": "#00ff00",
"tolerance": 40,
"fond_par_defaut": null
},
"serveur": {
"host": "0.0.0.0",
"port": 8080
}
}

View File

@@ -157,6 +157,107 @@ html, body {
color: rgba(255,255,255,0.4); color: rgba(255,255,255,0.4);
} }
/* Mini menu client */
.btn-menu-client {
position: absolute;
top: 12px;
left: 12px;
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.3rem;
color: rgba(255,255,255,0.2);
cursor: pointer;
border-radius: 50%;
transition: color 0.3s;
z-index: 60;
}
.btn-menu-client:active {
color: rgba(255,255,255,0.5);
}
.menu-client {
position: absolute;
top: 60px;
left: 12px;
background: rgba(0,0,0,0.92);
border-radius: 16px;
padding: 16px;
z-index: 200;
min-width: 220px;
display: flex;
flex-direction: column;
gap: 8px;
}
.menu-client button {
display: block;
width: 100%;
padding: 14px 16px;
font-size: 1rem;
text-align: left;
background: rgba(255,255,255,0.08);
color: #fff;
border: none;
border-radius: 10px;
cursor: pointer;
}
.menu-client button:active {
background: rgba(255,255,255,0.2);
}
.menu-client-titre {
font-size: .85rem;
font-weight: 600;
color: rgba(255,255,255,0.5);
text-transform: uppercase;
letter-spacing: 1px;
padding: 0 4px 8px;
}
.popup-wifi {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.85);
z-index: 300;
display: flex;
align-items: center;
justify-content: center;
}
.popup-wifi-inner {
background: rgba(30,30,30,0.98);
border-radius: 20px;
padding: 24px;
width: 90%;
max-width: 400px;
}
.popup-wifi-inner button {
padding: 12px;
font-size: 1rem;
background: rgba(255,255,255,0.1);
color: #fff;
border: none;
border-radius: 10px;
cursor: pointer;
}
.popup-wifi-inner button:active {
background: rgba(255,255,255,0.2);
}
.wifi-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px;
margin: 4px 0;
background: rgba(255,255,255,0.06);
border-radius: 10px;
cursor: pointer;
}
.wifi-item:active {
background: rgba(255,255,255,0.15);
}
.wifi-item.actif {
border-left: 3px solid #4caf50;
}
/* Popup mot de passe */ /* Popup mot de passe */
.popup-overlay { .popup-overlay {
position: fixed; position: fixed;
@@ -285,6 +386,13 @@ html, body {
font-size: 8rem; font-size: 8rem;
} }
.mode-detail {
font-size: 0.85rem;
opacity: 0.6;
font-weight: 400;
margin-top: -0.3rem;
}
/* === Booth info (code + QR sur accueil) === */ /* === Booth info (code + QR sur accueil) === */
.booth-info { .booth-info {
position: absolute; position: absolute;
@@ -389,13 +497,17 @@ html, body {
} }
.compte-a-rebours span { .compte-a-rebours span {
font-size: 14rem; font-size: clamp(2rem, 8vw, 5rem);
font-weight: 800; font-weight: 800;
color: #ffffff; color: #ffffff;
text-shadow: 0 0 40px rgba(0,0,0,0.8), 0 0 80px var(--primaire); text-shadow: 0 0 40px rgba(0,0,0,0.8), 0 0 80px var(--primaire);
animation: pop 0.5s ease; animation: pop 0.5s ease;
} }
.compte-a-rebours span.anim-texte {
font-size: clamp(0.8rem, 3vw, 1.5rem);
}
@keyframes pop { @keyframes pop {
0% { transform: scale(0.3); opacity: 0; } 0% { transform: scale(0.3); opacity: 0; }
60% { transform: scale(1.2); } 60% { transform: scale(1.2); }
@@ -413,9 +525,21 @@ html, body {
@keyframes flash { @keyframes flash {
0% { opacity: 1; } 0% { opacity: 1; }
30% { opacity: 0.9; }
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;
@@ -627,6 +751,27 @@ html, body {
} }
/* Choix exemplaires impression */ /* Choix exemplaires impression */
.popup-qr {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
margin: 1rem 0;
padding: 1.5rem;
background: rgba(255,255,255,0.05);
border-radius: 16px;
}
.qr-photo-img {
width: 200px;
height: 200px;
border-radius: 12px;
background: #fff;
}
.qr-label {
font-size: 1rem;
color: #aaa;
}
.form-impression { .form-impression {
display: flex; display: flex;
gap: 1rem; gap: 1rem;
@@ -675,6 +820,13 @@ html, body {
font-size: 1rem; font-size: 1rem;
} }
.impression-restant {
color: var(--texte-secondaire);
font-size: 0.85rem;
opacity: 0.7;
margin: 0.3rem 0;
}
.form-email { .form-email {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -867,6 +1019,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;
@@ -876,6 +1052,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;
@@ -1654,11 +1880,18 @@ h3 {
} }
.anim-chiffre { .anim-chiffre {
font-size: 8rem; font-size: clamp(2rem, 6vw, 4rem);
font-weight: 700; font-weight: 700;
color: var(--primaire); color: var(--primaire);
} }
.anim-texte {
font-size: clamp(0.7rem, 2vw, 1.2rem);
max-width: 80vw;
text-align: center;
line-height: 1.2;
}
/* === Animations compte a rebours === */ /* === Animations compte a rebours === */
/* Classique */ /* Classique */
.anim-classique { animation: anim-pop 0.5s ease; } .anim-classique { animation: anim-pop 0.5s ease; }
@@ -1751,18 +1984,33 @@ h3 {
border-radius: 3px; border-radius: 3px;
} }
/* === Admin galerie check === */
.admin-galerie-check {
position: absolute; top: 6px; right: 6px;
width: 28px; height: 28px; border-radius: 50%;
background: rgba(0,0,0,0.4); border: 2px solid #fff;
color: #fff; font-size: 1rem; display: flex;
align-items: center; justify-content: center;
opacity: 0; transition: opacity 0.15s;
}
.admin-galerie-check.visible {
opacity: 1; background: var(--accent);
}
/* === Erreur camera === */ /* === Erreur camera === */
.printer-erreur { .printer-erreur {
position: fixed; position: fixed;
bottom: 0; left: 0; right: 0; top: 1rem; right: 1rem;
background: #b71c1c; background: rgba(183, 28, 28, 0.85);
color: #fff; color: #fff;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1rem; gap: 0.5rem;
padding: 0.8rem 1.5rem; padding: 0.5rem 1rem;
z-index: 9999; z-index: 100;
font-size: 1rem; font-size: 0.85rem;
border-radius: 10px;
max-width: 300px;
} }
.printer-erreur-icon { font-size: 1.4rem; flex-shrink: 0; } .printer-erreur-icon { font-size: 1.4rem; flex-shrink: 0; }
@@ -2036,6 +2284,126 @@ h3 {
to { opacity: 1; transform: translateY(0); } to { opacity: 1; transform: translateY(0); }
} }
/* === WiFi === */
.wifi-statut {
display: flex;
align-items: center;
gap: 1rem;
background: rgba(255,255,255,.04);
border-radius: var(--rayon);
padding: 1rem 1.2rem;
}
.wifi-statut-icon { font-size: 2rem; }
.wifi-statut-info { flex: 1; }
.wifi-ssid { font-size: 1.1rem; font-weight: 600; }
.wifi-detail { font-size: .8rem; color: var(--texte-secondaire); margin-top: .15rem; }
.wifi-signal-bars {
display: flex;
align-items: flex-end;
gap: 3px;
height: 24px;
}
.wifi-signal-bars .bar {
width: 5px;
border-radius: 2px;
background: rgba(255,255,255,.15);
transition: background .3s;
}
.wifi-signal-bars .bar.actif { background: var(--succes); }
.wifi-signal-bars .bar:nth-child(1) { height: 6px; }
.wifi-signal-bars .bar:nth-child(2) { height: 11px; }
.wifi-signal-bars .bar:nth-child(3) { height: 16px; }
.wifi-signal-bars .bar:nth-child(4) { height: 22px; }
.wifi-liste { display: flex; flex-direction: column; gap: .4rem; }
.wifi-reseau {
display: flex;
align-items: center;
gap: .8rem;
padding: .7rem 1rem;
background: rgba(255,255,255,.03);
border: 1px solid rgba(255,255,255,.06);
border-radius: 10px;
cursor: pointer;
transition: background .2s, border-color .2s;
}
.wifi-reseau:active { background: rgba(255,255,255,.08); }
.wifi-reseau.wifi-actif {
border-color: var(--succes);
background: rgba(76,175,80,.08);
}
.wifi-reseau-ssid { flex: 1; font-size: .95rem; font-weight: 500; }
.wifi-reseau-meta { font-size: .75rem; color: var(--texte-secondaire); }
.wifi-reseau-icons { display: flex; align-items: center; gap: .5rem; font-size: .9rem; }
.wifi-badge-connecte {
font-size: .7rem;
padding: .15rem .5rem;
border-radius: 6px;
background: rgba(76,175,80,.2);
color: var(--succes);
font-weight: 600;
}
.wifi-badge-enregistre {
font-size: .7rem;
padding: .15rem .5rem;
border-radius: 6px;
background: rgba(255,255,255,.08);
color: var(--texte-secondaire);
}
.wifi-enregistre-item {
display: flex;
align-items: center;
gap: .8rem;
padding: .6rem 1rem;
background: rgba(255,255,255,.03);
border: 1px solid rgba(255,255,255,.06);
border-radius: 10px;
}
.wifi-enregistre-item .wifi-reseau-ssid { flex: 1; }
.wifi-enregistre-mdp {
font-family: monospace;
font-size: .8rem;
color: var(--texte-secondaire);
background: rgba(255,255,255,.05);
padding: .2rem .5rem;
border-radius: 4px;
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=9"> <link rel="stylesheet" href="/css/style.css?v=19">
<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 -->
@@ -52,6 +59,38 @@
<div id="btn-admin" class="btn-admin" onclick="ouvrirAdmin()">&#9881;</div> <div id="btn-admin" class="btn-admin" onclick="ouvrirAdmin()">&#9881;</div>
<!-- Icone photostation coin bas-gauche (visible seulement si installée) --> <!-- Icone photostation coin bas-gauche (visible seulement si installée) -->
<div id="btn-photostation" class="btn-photostation cache" onclick="ouvrirPhotostation()">&#128444;</div> <div id="btn-photostation" class="btn-photostation cache" onclick="ouvrirPhotostation()">&#128444;</div>
<!-- Mini menu client coin haut-gauche -->
<div id="btn-menu-client" class="btn-menu-client" onclick="toggleMenuClient()">&#9776;</div>
<div id="menu-client" class="menu-client cache">
<div class="menu-client-titre">Maintenance</div>
<button onclick="menuClientAction('wifi')">&#128246; WiFi</button>
<button onclick="menuClientAction('exposition')">&#9728; Luminosite</button>
<button onclick="menuClientAction('restart-app')">&#128260; Relancer l'appli</button>
<button onclick="menuClientAction('reboot')">&#128259; Redemarrer</button>
<button onclick="menuClientAction('shutdown')">&#9211; Eteindre</button>
<button onclick="toggleMenuClient()" style="background:transparent;color:var(--text2)">Fermer</button>
</div>
<!-- Popup WiFi -->
<div id="popup-wifi" class="popup-wifi cache">
<div class="popup-wifi-inner">
<div class="menu-client-titre">WiFi</div>
<div id="wifi-status"></div>
<div id="wifi-list" style="max-height:300px;overflow-y:auto"></div>
<button onclick="scanWifi()" style="margin-top:8px;width:100%">Actualiser</button>
<button onclick="document.getElementById('popup-wifi').classList.add('cache')" style="margin-top:4px;width:100%;background:transparent;color:var(--text2)">Fermer</button>
</div>
</div>
<!-- Popup Exposition -->
<div id="popup-exposition" class="popup-wifi cache">
<div class="popup-wifi-inner">
<div class="menu-client-titre">Luminosite photo</div>
<div id="expo-status" style="text-align:center;margin:12px 0;font-size:1.5rem;font-weight:700"></div>
<input type="range" id="expo-slider" min="-9" max="9" value="0" style="width:100%" oninput="updateExpoLabel(this.value)">
<div id="expo-label" style="text-align:center;margin:8px 0;font-size:.9rem;color:var(--text2)">0</div>
<button onclick="appliquerExpo()" style="width:100%">Appliquer</button>
<button onclick="document.getElementById('popup-exposition').classList.add('cache')" style="margin-top:4px;width:100%;background:transparent;color:var(--text2)">Fermer</button>
</div>
</div>
</section> </section>
<!-- Choix du mode --> <!-- Choix du mode -->
@@ -65,6 +104,7 @@
<button class="btn-mode" data-mode="multi" id="btn-multi"> <button class="btn-mode" data-mode="multi" id="btn-multi">
<div class="mode-icone mode-icone-large">&#127902;</div> <div class="mode-icone mode-icone-large">&#127902;</div>
<span>Pellicule</span> <span>Pellicule</span>
<span class="mode-detail" id="pellicule-detail"></span>
</button> </button>
</div> </div>
<button class="btn-retour" onclick="allerA('accueil')">Retour</button> <button class="btn-retour" onclick="allerA('accueil')">Retour</button>
@@ -98,6 +138,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>
@@ -146,6 +187,11 @@
<span>QR Code</span> <span>QR Code</span>
</button> </button>
</div> </div>
<div id="popup-qr" class="popup-qr cache">
<img id="qr-photo" class="qr-photo-img" src="">
<p class="qr-label">Scannez pour voir votre photo</p>
<button class="btn-secondaire" onclick="fermerQR()">Fermer</button>
</div>
<div id="form-impression" class="form-impression cache"> <div id="form-impression" class="form-impression cache">
<div class="exemplaires-choix"> <div class="exemplaires-choix">
<button class="btn-exemplaire" onclick="changerExemplaires(-1)">-</button> <button class="btn-exemplaire" onclick="changerExemplaires(-1)">-</button>
@@ -153,6 +199,7 @@
<button class="btn-exemplaire" onclick="changerExemplaires(1)">+</button> <button class="btn-exemplaire" onclick="changerExemplaires(1)">+</button>
</div> </div>
<span class="exemplaires-label">exemplaire(s)</span> <span class="exemplaires-label">exemplaire(s)</span>
<div id="impression-restant" class="impression-restant"></div>
<button class="btn-action" onclick="lancerImpression()">Imprimer</button> <button class="btn-action" onclick="lancerImpression()">Imprimer</button>
<button class="btn-secondaire" onclick="fermerImpression()">Annuler</button> <button class="btn-secondaire" onclick="fermerImpression()">Annuler</button>
</div> </div>
@@ -233,8 +280,7 @@
</div> </div>
<div id="statut-partage" class="statut-partage cache"></div> <div id="statut-partage" class="statut-partage cache"></div>
<div class="partage-bas"> <div class="partage-bas">
<button class="btn-action" onclick="allerA('accueil')">Terminer</button> <button class="btn-action" id="btn-terminer" onclick="recommencer()">Recommencer</button>
<button class="btn-secondaire" onclick="recommencer()">Nouvelle photo</button>
</div> </div>
</section> </section>
@@ -366,15 +412,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="personnalisation">Personnalisation</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="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">
@@ -421,6 +480,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>
@@ -445,9 +507,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">
@@ -456,6 +519,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>
@@ -624,7 +735,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>
<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-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>
@@ -690,7 +809,215 @@
<button class="btn-danger" onclick="confirmerExtinction()">Eteindre le systeme</button> <button class="btn-danger" onclick="confirmerExtinction()">Eteindre le systeme</button>
</div> </div>
<!-- Panneau Infos systeme --> <!-- Panneau WiFi -->
<div class="admin-panneau" id="panneau-wifi">
<div class="wifi-statut" id="wifi-statut">
<div class="wifi-statut-icon" id="wifi-statut-icon">&#128246;</div>
<div class="wifi-statut-info">
<div id="wifi-ssid" class="wifi-ssid">--</div>
<div id="wifi-detail" class="wifi-detail">Non connecté</div>
</div>
<div id="wifi-signal-bars" class="wifi-signal-bars"></div>
</div>
<div class="champ-row" style="margin:1rem 0">
<button class="btn-action" onclick="scannerWifi()">&#128269; Scanner les réseaux</button>
</div>
<div id="wifi-scan-loading" class="cache" style="text-align:center;padding:1rem;color:#888">
<div class="capture-spinner" style="display:inline-block;width:24px;height:24px"></div>
Recherche en cours...
</div>
<div id="wifi-liste-reseaux" class="wifi-liste"></div>
<h3 style="margin-top:1.5rem">Réseaux enregistrés</h3>
<div id="wifi-liste-enregistres" class="wifi-liste"></div>
</div>
<!-- Popup saisie mot de passe WiFi -->
<div id="popup-wifi-mdp" class="popup-overlay cache">
<div class="popup-box popup-box-pave" style="max-width:600px">
<h3 id="wifi-mdp-titre">Mot de passe WiFi</h3>
<input type="text" id="wifi-mdp-input" placeholder="Mot de passe" autocomplete="off" readonly style="font-size:1.1rem;padding:.8rem;width:100%;border-radius:8px;border:1px solid rgba(255,255,255,.15);background:rgba(255,255,255,.05);color:#fff;margin-bottom:.5rem">
<div id="wifi-mdp-erreur" class="mdp-erreur cache">Echec connexion</div>
<div class="clavier-virtuel" id="clavier-wifi">
<div class="clavier-ligne">
<button onclick="wifiTape('1')">1</button>
<button onclick="wifiTape('2')">2</button>
<button onclick="wifiTape('3')">3</button>
<button onclick="wifiTape('4')">4</button>
<button onclick="wifiTape('5')">5</button>
<button onclick="wifiTape('6')">6</button>
<button onclick="wifiTape('7')">7</button>
<button onclick="wifiTape('8')">8</button>
<button onclick="wifiTape('9')">9</button>
<button onclick="wifiTape('0')">0</button>
</div>
<div class="clavier-ligne" id="wifi-kb-row1">
<button onclick="wifiTape('a')">a</button>
<button onclick="wifiTape('z')">z</button>
<button onclick="wifiTape('e')">e</button>
<button onclick="wifiTape('r')">r</button>
<button onclick="wifiTape('t')">t</button>
<button onclick="wifiTape('y')">y</button>
<button onclick="wifiTape('u')">u</button>
<button onclick="wifiTape('i')">i</button>
<button onclick="wifiTape('o')">o</button>
<button onclick="wifiTape('p')">p</button>
</div>
<div class="clavier-ligne" id="wifi-kb-row2">
<button onclick="wifiTape('q')">q</button>
<button onclick="wifiTape('s')">s</button>
<button onclick="wifiTape('d')">d</button>
<button onclick="wifiTape('f')">f</button>
<button onclick="wifiTape('g')">g</button>
<button onclick="wifiTape('h')">h</button>
<button onclick="wifiTape('j')">j</button>
<button onclick="wifiTape('k')">k</button>
<button onclick="wifiTape('l')">l</button>
<button onclick="wifiTape('m')">m</button>
</div>
<div class="clavier-ligne" id="wifi-kb-row3">
<button onclick="wifiTape('w')">w</button>
<button onclick="wifiTape('x')">x</button>
<button onclick="wifiTape('c')">c</button>
<button onclick="wifiTape('v')">v</button>
<button onclick="wifiTape('b')">b</button>
<button onclick="wifiTape('n')">n</button>
<button onclick="wifiTape('!')">!</button>
<button onclick="wifiTape('.')">.</button>
<button onclick="wifiTape('-')">-</button>
<button onclick="wifiTape('_')">_</button>
</div>
<div class="clavier-ligne clavier-speciales">
<button class="clavier-wide" onclick="wifiToggleMaj()" id="wifi-btn-maj">&#8679; MAJ</button>
<button class="clavier-wide clavier-espace" onclick="wifiTape(' ')">Espace</button>
<button class="clavier-wide" onclick="wifiEffacer()">&#9003;</button>
</div>
<div class="clavier-ligne clavier-speciales">
<button onclick="wifiTape('@')">@</button>
<button onclick="wifiTape('#')">#</button>
<button onclick="wifiTape('$')">$</button>
<button onclick="wifiTape('%')">%</button>
<button onclick="wifiTape('&')">&amp;</button>
<button onclick="wifiTape('*')">*</button>
<button onclick="wifiTape('+')" >+</button>
<button onclick="wifiTape('=')">=</button>
<button onclick="wifiTape('/')">/</button>
<button onclick="wifiTape('?')">?</button>
</div>
</div>
<div style="display:flex;gap:1rem;margin-top:.8rem">
<button class="btn-action" onclick="validerWifiMdp()" id="wifi-btn-connecter">Connecter</button>
<button class="btn-secondaire" onclick="fermerPopupWifi()">Annuler</button>
</div>
</div>
</div>
<!-- Panneau Galerie admin -->
<div class="admin-panneau" id="panneau-admin-galerie">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;flex-wrap:wrap;gap:0.5rem">
<h3 style="margin:0">Photos</h3>
<div style="display:flex;gap:0.5rem;align-items:center">
<span id="admin-galerie-selection" style="font-size:0.85rem;color:#aaa"></span>
<button class="btn-action btn-petit" onclick="adminGalerieSelectAll()">Tout selectionner</button>
<button class="btn-action btn-petit" onclick="adminGalerieDeselectAll()">Deselectionner</button>
<button class="btn-secondaire btn-petit" onclick="adminGalerieImprimer()">Imprimer selection</button>
</div>
</div>
<div id="admin-galerie-grille" class="grille-galerie" style="max-height:60vh;overflow-y:auto"></div>
</div>
<div class="admin-panneau" id="panneau-eclairage">
<div style="display:flex;gap:1rem;align-items:flex-start;flex-wrap:wrap">
<div style="flex:1;min-width:280px">
<img id="eclairage-preview" style="width:100%;border-radius:8px;background:#111" alt="Preview">
</div>
<div style="flex:0 0 200px;text-align:center">
<div id="eclairage-score-box" style="font-size:3rem;font-weight:800;padding:1.5rem;border-radius:16px;background:#222;margin-bottom:1rem">--</div>
<div id="eclairage-action" style="font-size:1.2rem;font-weight:600;margin-bottom:.5rem">--</div>
<div id="eclairage-visages" style="font-size:.9rem;color:#aaa">--</div>
</div>
</div>
<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 class="admin-panneau" id="panneau-infos"> <div class="admin-panneau" id="panneau-infos">
<div class="infos-systeme-header"> <div class="infos-systeme-header">
<h3>Informations systeme</h3> <h3>Informations systeme</h3>
@@ -706,10 +1033,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">
@@ -772,12 +1160,29 @@
</div> </div>
<script src="/js/websocket.js?v=3"></script> <!-- Ecran de pause (deconnexion backend) -->
<script src="/js/app.js?v=6"></script> <div id="ecran-pause" style="display:none;position:fixed;inset:0;z-index:9999;background:#0a0a0f;flex-direction:column;align-items:center;justify-content:center;text-align:center">
<script src="/js/camera.js?v=13"></script> <div style="font-size:6rem;animation:pause-bob 2s ease-in-out infinite">&#9749;</div>
<h1 style="color:#fff;font-size:2.2rem;margin:1rem 0 .5rem;font-weight:700">Petite pause...</h1>
<p style="color:#888;font-size:1.2rem;max-width:400px;line-height:1.5">Le photobooth revient dans un instant.<br>Pas de panique, on s'occupe de tout !</p>
<div style="margin-top:2rem;display:flex;gap:8px">
<span class="pause-dot" style="width:12px;height:12px;border-radius:50%;background:#e91e63;animation:pause-dot .6s ease-in-out infinite"></span>
<span class="pause-dot" style="width:12px;height:12px;border-radius:50%;background:#e91e63;animation:pause-dot .6s ease-in-out .2s infinite"></span>
<span class="pause-dot" style="width:12px;height:12px;border-radius:50%;background:#e91e63;animation:pause-dot .6s ease-in-out .4s infinite"></span>
</div>
<p id="pause-status" style="color:#555;font-size:.85rem;margin-top:2rem">Reconnexion en cours...</p>
</div>
<style>
@keyframes pause-bob { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-20px)} }
@keyframes pause-dot { 0%,100%{opacity:.2;transform:scale(.8)} 50%{opacity:1;transform:scale(1.2)} }
</style>
<script src="/js/websocket.js?v=16"></script>
<script src="/js/app.js?v=27"></script>
<script src="/js/camera.js?v=26"></script>
<script src="/js/effects.js?v=4"></script> <script src="/js/effects.js?v=4"></script>
<script src="/js/gallery.js?v=3"></script> <script src="/js/gallery.js?v=5"></script>
<script src="/js/share.js?v=7"></script> <script src="/js/share.js?v=12"></script>
<script src="/js/admin.js?v=8"></script> <script src="/js/admin.js?v=17"></script>
</body> </body>
</html> </html>

File diff suppressed because it is too large Load Diff

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 ---
@@ -59,6 +64,9 @@ function appliquerConfig() {
// Media d'accueil (video/gif en boucle) // Media d'accueil (video/gif en boucle)
appliquerMediaAccueil(event.media_accueil); appliquerMediaAccueil(event.media_accueil);
// Precharger image surprise
if (typeof prechargerSurprise === 'function') prechargerSurprise();
} }
function appliquerMediaAccueil(media) { function appliquerMediaAccueil(media) {
@@ -104,13 +112,22 @@ function allerA(ecran) {
ecranActuel = ecran; ecranActuel = ecran;
} }
// Masquer le popup QR a chaque changement d'ecran
fermerQR();
// Actions au changement d'ecran // Actions au changement d'ecran
if (ecran === 'accueil') { if (ecran === 'accueil') {
photosSession = []; photosSession = [];
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();
@@ -118,12 +135,21 @@ function allerA(ecran) {
chargerGalerie(); chargerGalerie();
} else if (ecran === 'admin') { } else if (ecran === 'admin') {
chargerAdmin(); chargerAdmin();
demarrerTimeoutAdmin();
} else {
arreterTimeoutAdmin();
} }
} }
function demarrerTimeoutAdmin() {}
function arreterTimeoutAdmin() {}
function recommencer() { function recommencer() {
photosSession = []; photosSession = [];
photoFinale = null; photoFinale = null;
photoImpression = null;
formatImpression = null;
nbExemplaires = 1;
allerA('mode'); allerA('mode');
} }
@@ -136,6 +162,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');
}); });
} }
@@ -257,6 +284,14 @@ function setupModes() {
allerA('capture'); allerA('capture');
}); });
}); });
majDetailPellicule();
}
function majDetailPellicule() {
const el = document.getElementById('pellicule-detail');
if (!el) return;
const nb = config.multi_shot?.nombre_photos || 4;
el.textContent = `${nb} poses = 2 tirages`;
} }
async function chargerCadresChoix() { async function chargerCadresChoix() {
@@ -289,12 +324,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);
@@ -337,8 +388,8 @@ async function chargerBoothInfo() {
try { try {
const info = await apiGet('/api/booth/info'); const info = await apiGet('/api/booth/info');
if (info.password) { if (info.password) {
const eventId = booth.event_id || 'default'; const eventId = (config.evenement || {}).event_id || booth.event_id || 'default';
const qrUrl = `${booth.url}/${eventId}?p=${info.password}`; const qrUrl = `${booth.url}/g/${eventId}?p=${info.password}`;
document.getElementById('booth-qr').src = `/api/qr?url=${encodeURIComponent(qrUrl)}`; document.getElementById('booth-qr').src = `/api/qr?url=${encodeURIComponent(qrUrl)}`;
el.classList.remove('cache'); el.classList.remove('cache');
} }
@@ -347,6 +398,10 @@ async function chargerBoothInfo() {
} }
} }
function fermerQR() {
document.getElementById('popup-qr').classList.add('cache');
}
function afficherStatut(message, type = 'succes') { function afficherStatut(message, type = 'succes') {
const el = document.getElementById('statut-partage'); const el = document.getElementById('statut-partage');
el.textContent = message; el.textContent = message;
@@ -359,24 +414,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;
@@ -387,21 +561,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) {}
} }
@@ -524,6 +688,121 @@ 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(() => {});
}
// --- Mini menu client ---
function toggleMenuClient() {
document.getElementById('menu-client').classList.toggle('cache');
}
async function menuClientAction(action) {
document.getElementById('menu-client').classList.add('cache');
if (action === 'wifi') {
document.getElementById('popup-wifi').classList.remove('cache');
scanWifi();
} else if (action === 'exposition') {
document.getElementById('popup-exposition').classList.remove('cache');
chargerExposition();
} else if (action === 'shutdown') {
if (confirm('Eteindre la borne ?')) apiPost('/api/systeme/eteindre');
} else if (action === 'reboot') {
if (confirm('Redemarrer la borne ?')) apiPost('/api/systeme/redemarrer');
} else if (action === 'restart-app') {
apiPost('/api/systeme/redemarrer-app');
setTimeout(() => location.reload(), 3000);
}
}
async function scanWifi() {
const list = document.getElementById('wifi-list');
const status = document.getElementById('wifi-status');
list.innerHTML = '<div style="text-align:center;padding:16px;color:var(--text2)">Scan en cours...</div>';
try {
const s = await apiGet('/api/wifi/status');
status.innerHTML = s.connecte
? `<div style="padding:8px;color:#4caf50">Connecte a <b>${s.ssid}</b> (${s.signal || '?'}%)</div>`
: '<div style="padding:8px;color:#f44336">Non connecte</div>';
const nets = await apiGet('/api/wifi/scan');
if (!nets.length) { list.innerHTML = '<div style="padding:12px;color:var(--text2)">Aucun reseau</div>'; return; }
list.innerHTML = nets.map(n => `
<div class="wifi-item${n.actif ? ' actif' : ''}" onclick="connecterWifi('${n.ssid.replace(/'/g,"\\'")}', ${n.enregistre})">
<div>
<div style="font-weight:600">${n.ssid}</div>
<div style="font-size:.75rem;color:var(--text2)">${n.signal}% ${n.securise ? '&#128274;' : ''} ${n.enregistre ? '(enregistre)' : ''}</div>
</div>
${n.actif ? '<span style="color:#4caf50;font-weight:700">&#10003;</span>' : ''}
</div>
`).join('');
} catch(e) { list.innerHTML = '<div style="padding:12px;color:#f44336">Erreur: ' + e + '</div>'; }
}
async function connecterWifi(ssid, enregistre) {
if (enregistre) {
const r = await apiPost('/api/wifi/connect', {ssid});
alert(r.message || (r.succes ? 'Connecte' : 'Erreur'));
scanWifi();
return;
}
const mdp = prompt('Mot de passe WiFi pour ' + ssid + ' :');
if (mdp === null) return;
const r = await apiPost('/api/wifi/connect', {ssid, password: mdp});
alert(r.message || (r.succes ? 'Connecte' : 'Erreur'));
scanWifi();
}
async function chargerExposition() {
const cfg = charger_config ? charger_config() : config;
const luminosite = (cfg || config).impression?.luminosite_impression || 0;
document.getElementById('expo-slider').value = luminosite;
document.getElementById('expo-slider').min = -50;
document.getElementById('expo-slider').max = 50;
document.getElementById('expo-slider').step = 5;
updateExpoLabel(luminosite);
}
function updateExpoLabel(val) {
const signe = val > 0 ? '+' : '';
document.getElementById('expo-label').textContent = `${signe}${val}%`;
document.getElementById('expo-status').textContent = val == 0 ? 'Normal' : `${signe}${val}%`;
}
async function appliquerExpo() {
const val = parseInt(document.getElementById('expo-slider').value);
await apiPost('/api/config', {impression: {luminosite_impression: val}});
document.getElementById('popup-exposition').classList.add('cache');
afficherStatut('Luminosite impression : ' + (val > 0 ? '+' : '') + val + '%', 'succes');
config = await apiGet('/api/config');
}
// Demarrage // Demarrage
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
init().then(() => { init().then(() => {

View File

@@ -9,6 +9,55 @@ let captureEnCours = false; // true pendant tout le flux lancerCapture()
const EMOJI_CAR = { 3: '🤪', 2: '😱', 1: '🔥' }; const EMOJI_CAR = { 3: '🤪', 2: '😱', 1: '🔥' };
// --- Surprise avant capture ---
let _surprisePreloaded = null;
function prechargerSurprise() {
const surprise = (config || {}).surprise;
if (!surprise || !surprise.actif || !surprise.fichier) return;
if (surprise.type === 'video') return;
const img = new Image();
img.src = '/api/surprise/media?t=' + Date.now();
_surprisePreloaded = img;
}
function montrerSurprise() {
const surprise = (config || {}).surprise;
if (!surprise || !surprise.actif || !surprise.fichier) return false;
const overlay = document.getElementById('surprise-overlay');
if (!overlay) return false;
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 {
if (_surprisePreloaded && _surprisePreloaded.complete) {
_surprisePreloaded.style.cssText = 'max-width:100%;max-height:100%;object-fit:contain';
overlay.innerHTML = '';
overlay.appendChild(_surprisePreloaded);
} else {
overlay.innerHTML = '<img src="/api/surprise/media" style="max-width:100%;max-height:100%;object-fit:contain">';
}
}
overlay.style.opacity = '0';
overlay.classList.remove('cache');
overlay.style.transition = 'opacity 0.15s';
overlay.style.opacity = '1';
return true;
}
function cacherSurprise() {
const overlay = document.getElementById('surprise-overlay');
if (!overlay) return;
overlay.style.opacity = '0';
setTimeout(() => {
overlay.classList.add('cache');
overlay.style.transition = '';
overlay.style.opacity = '';
}, 200);
}
// --- Preview live --- // --- Preview live ---
function afficherErreurCapture(titre, detail = '') { function afficherErreurCapture(titre, detail = '') {
@@ -29,8 +78,8 @@ function afficherErreurPreview(visible) {
const el = document.getElementById('preview-erreur-camera'); const el = document.getElementById('preview-erreur-camera');
if (!el) return; if (!el) return;
if (visible) { if (visible) {
el.querySelector('p').textContent = 'Appareil photo deconnecte'; el.querySelector('p').textContent = 'Un instant...';
el.querySelector('span').textContent = 'Reconnexion en cours...'; el.querySelector('span').textContent = 'L\'appareil photo se reconnecte';
el.classList.remove('cache'); el.classList.remove('cache');
} else { } else {
el.classList.add('cache'); el.classList.add('cache');
@@ -48,13 +97,19 @@ function lancerPreview() {
if (previewActif) return; if (previewActif) return;
previewActif = true; previewActif = true;
afficherErreurPreview(false); afficherErreurPreview(false);
// Flou le temps que la 1ere frame fraiche arrive (le miroir DSLR doit remonter
// apres la capture precedente) : evite l'impression d'image figee sur l'ancienne photo.
document.getElementById('img-preview')?.classList.add('preview-chargement'); document.getElementById('img-preview')?.classList.add('preview-chargement');
wsEnvoyer({ type: 'preview_start' }); wsEnvoyer({ type: 'preview_start' });
resetPreviewTimeout(); resetPreviewTimeout();
} }
function demarrerEclairageLive() {
if (!previewActif) {
previewActif = true;
wsEnvoyer({ type: 'preview_start' });
resetPreviewTimeout();
}
}
function arreterPreview() { function arreterPreview() {
if (!previewActif) return; if (!previewActif) return;
previewActif = false; previewActif = false;
@@ -81,6 +136,10 @@ wsOnMessage('preview', (msg) => {
}; };
img.src = msg.image; img.src = msg.image;
} }
const adminPrev = document.getElementById('eclairage-preview');
if (adminPrev && document.getElementById('panneau-eclairage')?.classList.contains('actif')) {
adminPrev.src = msg.image;
}
afficherErreurPreview(false); afficherErreurPreview(false);
resetPreviewTimeout(); resetPreviewTimeout();
}); });
@@ -163,7 +222,7 @@ wsOnMessage('camera_erreur', () => {
if (!captureEnCours) { if (!captureEnCours) {
captureAbortee = true; captureAbortee = true;
if (ecranActuel === 'capture') { if (ecranActuel === 'capture') {
afficherErreurCapture('Appareil photo déconnecté', 'Reconnexion en cours...'); afficherErreurCapture('Un instant...', 'L\'appareil photo se prepare');
} }
} }
}); });
@@ -173,6 +232,36 @@ wsOnMessage('camera_ok', () => {
afficherErreurPreview(false); afficherErreurPreview(false);
}); });
wsOnMessage('eclairage', (msg) => {
if (captureEnCours) return;
// Admin panel eclairage
const scoreBox = document.getElementById('eclairage-score-box');
const actionEl = document.getElementById('eclairage-action');
const visagesEl = document.getElementById('eclairage-visages');
if (scoreBox) {
const score = msg.score >= 0 ? msg.score : '--';
scoreBox.textContent = score;
if (msg.action === 'allumer') {
scoreBox.style.background = '#dc2626'; scoreBox.style.color = '#fff';
actionEl.textContent = 'ALLUMER LA LUMIERE';
actionEl.style.color = '#f87171';
} else if (msg.action === 'attention') {
scoreBox.style.background = '#d97706'; scoreBox.style.color = '#fff';
actionEl.textContent = 'Eclairage faible';
actionEl.style.color = '#fbbf24';
} else if (msg.action === 'ok') {
scoreBox.style.background = '#16a34a'; scoreBox.style.color = '#fff';
actionEl.textContent = 'Eclairage OK';
actionEl.style.color = '#4ade80';
} else {
scoreBox.style.background = '#222'; scoreBox.style.color = '#888';
actionEl.textContent = 'Aucun visage';
actionEl.style.color = '#888';
}
visagesEl.textContent = msg.visages > 0 ? msg.visages + ' visage' + (msg.visages > 1 ? 's' : '') + ' detecte' + (msg.visages > 1 ? 's' : '') : '';
}
});
// --- Capture --- // --- Capture ---
async function lancerCapture() { async function lancerCapture() {
@@ -188,17 +277,27 @@ async function lancerCapture() {
await chargerCadreImpose(); await chargerCadreImpose();
majCompteurAccueil(); majCompteurAccueil();
// Verifier l'etat de la camera avant de commencer // Verifier l'etat de la camera — attendre si pas prete ou reveil en cours
try { try {
const statut = await apiGet('/api/camera/statut'); let statut = await apiGet('/api/camera/statut');
if (!statut.connectee || statut.mode === 'erreur') {
afficherStatut('Preparation de l\'appareil photo...', 'succes');
// Attendre jusqu'a 40s que le Canon soit pret (reveil ou reconnexion)
for (let att = 0; att < 20; att++) {
await pause(2000);
statut = await apiGet('/api/camera/statut');
if (statut.connectee && statut.mode !== 'erreur') break;
}
afficherStatut('', '');
}
if (!statut.connectee || statut.mode === 'erreur') { if (!statut.connectee || statut.mode === 'erreur') {
captureEnCours = false; captureEnCours = false;
afficherErreurCapture('Appareil photo non disponible', 'Verifiez la connexion USB du DSLR'); afficherErreurCapture('Appareil photo indisponible', 'Touchez l\'ecran et reessayez');
return; return;
} }
} catch (e) { } catch (e) {
captureEnCours = false; captureEnCours = false;
afficherErreurCapture('Impossible de joindre le serveur', 'Verifiez que le photobooth est bien demarre'); afficherErreurCapture('Preparation en cours', 'Touchez l\'ecran et reessayez');
return; return;
} }
@@ -208,35 +307,58 @@ async function lancerCapture() {
for (let i = 0; i < nbPhotos; i++) { for (let i = 0; i < nbPhotos; i++) {
if (nbPhotos > 1) { if (nbPhotos > 1) {
compteurEl.textContent = `Photo ${i + 1} / ${nbPhotos}`; compteurEl.textContent = `Photo ${i + 1} / ${nbPhotos}`;
} else {
compteurEl.textContent = '';
} }
lancerPreview(); // Miroir live pendant le compte à rebours lancerPreview(); // Miroir live pendant le compte à rebours
await compteARebours(); wsEnvoyer({type: 'prepare_capture'});
const avecPreMessages = (i === 0);
await compteARebours(avecPreMessages);
// Surprise : seulement en photo simple (pas strip/multi)
// S'affiche PAR-DESSUS le preview live (pas besoin de le stopper)
const aSurprise = (modeActuel === 'simple') && montrerSurprise();
// Lancer la capture DSLR pendant que la surprise est visible
const capturePromise = apiPost('/api/capturer').catch(() => null);
// Laisser la surprise visible le temps que le DSLR declenche
if (aSurprise) await pause(1000);
// Flash blanc couvre tout (surprise + preview dessous)
const flash = document.getElementById('flash-blanc');
flash.classList.remove('cache');
flash.style.animation = 'none';
flash.offsetHeight;
flash.style.animation = '';
// Pendant que le flash couvre l'ecran, on nettoie derriere
arreterPreview(); arreterPreview();
await pause(150); if (aSurprise) { cacherSurprise(); prechargerSurprise(); }
const promesseCapture = apiPost('/api/capturer').catch(() => null); let resultat = await capturePromise;
let resultat = await promesseCapture;
if (!resultat && !resultat?.erreur) {
flash.classList.add('cache');
document.getElementById('capture-en-cours').classList.add('cache'); document.getElementById('capture-en-cours').classList.add('cache');
let erreurReseau = false;
if (!resultat) {
erreurReseau = true;
}
if (erreurReseau) {
captureEnCours = false; captureEnCours = false;
afficherErreurCapture('Erreur reseau', 'Le serveur ne repond pas — redemarrez le photobooth'); afficherErreurCapture('Oups !', 'La photo n\'a pas pu etre prise, reessayez');
return; return;
} }
if (!resultat || resultat.erreur) { if (resultat?.erreur) {
flash.classList.add('cache');
document.getElementById('capture-en-cours').classList.add('cache');
captureEnCours = false; captureEnCours = false;
const msg = resultat?.erreur || 'Erreur inconnue'; const msg = resultat.erreur;
afficherErreurCapture('Echec de la capture', msg.includes('Echec') ? 'Verifiez le DSLR et reessayez' : msg); afficherErreurCapture('Oups !', 'La photo n\'a pas pu etre prise, reessayez');
return; return;
} }
flash.classList.add('cache');
document.getElementById('capture-en-cours').classList.add('cache');
if (resultat.nom) { if (resultat.nom) {
photosSession.push(resultat.nom); photosSession.push(resultat.nom);
} }
@@ -266,7 +388,7 @@ function choisirAnimationAleatoire() {
return pool[Math.floor(Math.random() * pool.length)]; return pool[Math.floor(Math.random() * pool.length)];
} }
async function compteARebours() { async function compteARebours(avecPreMessages = true) {
const conteneur = document.getElementById('compte-a-rebours'); const conteneur = document.getElementById('compte-a-rebours');
const chiffre = document.getElementById('chiffre-car'); const chiffre = document.getElementById('chiffre-car');
const duree = config.camera?.compte_a_rebours || 3; const duree = config.camera?.compte_a_rebours || 3;
@@ -282,6 +404,17 @@ async function compteARebours() {
// Animation CSS classique // Animation CSS classique
conteneur.classList.remove('cache'); conteneur.classList.remove('cache');
if (avecPreMessages) {
const preMessages = ['Attention !', 'Préparez-vous !'];
for (const msg of preMessages) {
chiffre.textContent = msg;
chiffre.className = 'anim-chiffre anim-texte';
void chiffre.offsetWidth;
chiffre.classList.add('anim-' + anim.id);
await pause(1000);
}
}
for (let i = duree; i > 0; i--) { for (let i = duree; i > 0; i--) {
if (anim.id === 'emoji') { if (anim.id === 'emoji') {
chiffre.textContent = EMOJI_CAR[i] || i; chiffre.textContent = EMOJI_CAR[i] || i;
@@ -353,7 +486,7 @@ let formatImpression = null; // Format CUPS à utiliser (ex: "10x15-2up")
async function traiterCapture() { async function traiterCapture() {
if (photosSession.length === 0) { if (photosSession.length === 0) {
afficherErreurCapture('Aucune photo capturee', 'Une erreur inattendue s\'est produite'); afficherErreurCapture('Oups !', 'La photo n\'a pas pu etre prise, reessayez');
return; return;
} }
@@ -367,7 +500,7 @@ async function traiterCapture() {
if (mode === 'strip') { if (mode === 'strip') {
resultat = await apiPost('/api/strip', { photos: photosSession }); resultat = await apiPost('/api/strip', { photos: photosSession });
if (resultat.erreur) { if (resultat.erreur) {
afficherErreurCapture('Erreur creation de la planche', resultat.erreur); afficherErreurCapture('Oups !', 'La planche n\'a pas pu etre creee, reessayez');
return; return;
} }
if (resultat.impression) photoImpression = resultat.impression; if (resultat.impression) photoImpression = resultat.impression;
@@ -375,12 +508,12 @@ async function traiterCapture() {
} else { } else {
resultat = await apiPost('/api/collage', { photos: photosSession }); resultat = await apiPost('/api/collage', { photos: photosSession });
if (resultat.erreur) { if (resultat.erreur) {
afficherErreurCapture('Erreur creation du collage', resultat.erreur); afficherErreurCapture('Oups !', 'Le collage n\'a pas pu etre cree, reessayez');
return; return;
} }
} }
if (!resultat.nom) { if (!resultat.nom) {
afficherErreurCapture('Erreur traitement des photos', 'Le fichier final n\'a pas ete genere'); afficherErreurCapture('Oups !', 'Un probleme est survenu, reessayez');
return; return;
} }
photoFinale = resultat.nom; photoFinale = resultat.nom;
@@ -418,20 +551,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 = etat.limite; document.getElementById('compteur-limite').textContent =
} else { (etat.actif && etat.limite > 0) ? etat.limite : etat.capacite;
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

@@ -25,3 +25,98 @@ function voirPhoto(photo) {
document.getElementById('photo-partage').src = '/' + photo.chemin; document.getElementById('photo-partage').src = '/' + photo.chemin;
allerA('partage'); allerA('partage');
} }
/* --- Admin gallery --- */
let adminGaleriePhotos = [];
let adminGalerieSelection = new Set();
async function chargerAdminGalerie() {
const photos = await apiGet('/api/galerie');
adminGaleriePhotos = photos;
adminGalerieSelection.clear();
const grille = document.getElementById('admin-galerie-grille');
grille.innerHTML = '';
if (photos.length === 0) {
grille.innerHTML = '<p style="color:var(--texte-secondaire);grid-column:1/-1;text-align:center">Aucune photo</p>';
majAdminGalerieSelection();
return;
}
for (const photo of photos) {
const wrap = document.createElement('div');
wrap.style.cssText = 'position:relative;cursor:pointer';
wrap.dataset.nom = photo.nom;
const img = document.createElement('img');
img.src = '/' + photo.chemin;
img.alt = photo.nom;
img.loading = 'lazy';
img.style.cssText = 'width:100%;border-radius:8px;display:block';
const check = document.createElement('div');
check.className = 'admin-galerie-check';
check.textContent = '✓';
wrap.appendChild(img);
wrap.appendChild(check);
wrap.addEventListener('click', () => toggleAdminGaleriePhoto(photo.nom, wrap));
grille.appendChild(wrap);
}
majAdminGalerieSelection();
}
function toggleAdminGaleriePhoto(nom, wrap) {
if (adminGalerieSelection.has(nom)) {
adminGalerieSelection.delete(nom);
wrap.querySelector('.admin-galerie-check').classList.remove('visible');
} else {
adminGalerieSelection.add(nom);
wrap.querySelector('.admin-galerie-check').classList.add('visible');
}
majAdminGalerieSelection();
}
function adminGalerieSelectAll() {
const grille = document.getElementById('admin-galerie-grille');
for (const photo of adminGaleriePhotos) {
adminGalerieSelection.add(photo.nom);
}
grille.querySelectorAll('.admin-galerie-check').forEach(c => c.classList.add('visible'));
majAdminGalerieSelection();
}
function adminGalerieDeselectAll() {
adminGalerieSelection.clear();
const grille = document.getElementById('admin-galerie-grille');
grille.querySelectorAll('.admin-galerie-check').forEach(c => c.classList.remove('visible'));
majAdminGalerieSelection();
}
function majAdminGalerieSelection() {
const span = document.getElementById('admin-galerie-selection');
const n = adminGalerieSelection.size;
span.textContent = n > 0 ? n + ' selectionnee' + (n > 1 ? 's' : '') : '';
}
async function adminGalerieImprimer() {
const noms = [...adminGalerieSelection];
if (noms.length === 0) return;
if (!confirm('Imprimer ' + noms.length + ' photo' + (noms.length > 1 ? 's' : '') + ' ?')) return;
let ok = 0, fail = 0, termine = false;
for (const nom of noms) {
try {
const r = await apiPost('/api/imprimer', { photo: nom });
if (r && r.succes) ok++;
else if (r && r.erreur === 'evenement_termine') { termine = true; break; }
else fail++;
} catch { fail++; }
}
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,13 +1,37 @@
/* Module partage - Impression, email, QR code */ /* Module partage - Impression, email, QR code */
let nbExemplaires = 1; let nbExemplaires = 1;
let copiesMax = 5; let copiesMax = 2;
let _restantActuel = null;
function _masquerBoutonsBas(masquer) {
const bas = document.querySelector('#ecran-partage .partage-bas');
if (bas) bas.style.display = masquer ? 'none' : '';
}
async function _majRestantImpression() {
const el = document.getElementById('impression-restant');
if (!el) return;
try {
const etat = await apiGet('/api/compteur');
_restantActuel = etat.restantes;
const apres = Math.max(0, _restantActuel - nbExemplaires);
el.textContent = `${apres} photo(s) restante(s) sur le rouleau`;
} catch (e) { el.textContent = ''; }
}
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;
_masquerBoutonsBas(true);
_majRestantImpression();
if (modeActuel === 'multi') {
document.getElementById('form-impression').classList.remove('cache');
return;
}
const hasCadres = await chargerCadresChoixImpression(); const hasCadres = await chargerCadresChoixImpression();
if (hasCadres) { if (hasCadres) {
@@ -20,6 +44,7 @@ async function ouvrirImpression() {
function fermerImpression() { function fermerImpression() {
document.getElementById('form-impression').classList.add('cache'); document.getElementById('form-impression').classList.add('cache');
cadreChoisi = null; cadreChoisi = null;
_masquerBoutonsBas(false);
} }
function confirmerCadreImpression() { function confirmerCadreImpression() {
@@ -30,6 +55,7 @@ function confirmerCadreImpression() {
function annulerCadreImpression() { function annulerCadreImpression() {
document.getElementById('popup-cadre-impression').classList.add('cache'); document.getElementById('popup-cadre-impression').classList.add('cache');
cadreChoisi = null; cadreChoisi = null;
_masquerBoutonsBas(false);
} }
async function chargerCadresChoixImpression() { async function chargerCadresChoixImpression() {
@@ -96,11 +122,18 @@ async function chargerCadresChoixImpression() {
function changerExemplaires(delta) { function changerExemplaires(delta) {
nbExemplaires = Math.max(1, Math.min(copiesMax, nbExemplaires + delta)); nbExemplaires = Math.max(1, Math.min(copiesMax, nbExemplaires + delta));
document.getElementById('nb-exemplaires').textContent = nbExemplaires; document.getElementById('nb-exemplaires').textContent = nbExemplaires;
if (_restantActuel !== null) {
const apres = Math.max(0, _restantActuel - nbExemplaires);
document.getElementById('impression-restant').textContent = `${apres} photo(s) restante(s) sur le rouleau`;
}
} }
async function lancerImpression() { async function lancerImpression() {
if (!photoFinale) return; if (!photoFinale) return;
fermerImpression(); document.getElementById('form-impression').classList.add('cache');
cadreChoisi = null;
const btnTerminer = document.getElementById('btn-terminer');
if (btnTerminer) { btnTerminer.disabled = true; btnTerminer.style.opacity = '0.4'; }
// Pour les strips, imprimer la version 2 bandes sur 10x15 // Pour les strips, imprimer la version 2 bandes sur 10x15
const fichierImpression = photoImpression || photoFinale; const fichierImpression = photoImpression || photoFinale;
afficherStatut(`Impression de ${nbExemplaires} exemplaire(s)...`, 'succes'); afficherStatut(`Impression de ${nbExemplaires} exemplaire(s)...`, 'succes');
@@ -110,22 +143,32 @@ async function lancerImpression() {
cadre: cadreChoisi || undefined, cadre: cadreChoisi || undefined,
format_papier: formatImpression || undefined, format_papier: formatImpression || undefined,
}); });
if (resultat.succes) { if (btnTerminer) { btnTerminer.disabled = false; btnTerminer.style.opacity = ''; }
_masquerBoutonsBas(false);
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('L\'impression n\'est plus disponible pour cet evenement', 'erreur');
} else { } else {
afficherStatut('Erreur d\'impression', 'erreur'); afficherStatut('L\'impression a rencontre un probleme, reessayez', 'erreur');
} }
} }
async function ouvrirEmail() { async function ouvrirEmail() {
document.getElementById('form-email').classList.remove('cache'); document.getElementById('form-email').classList.remove('cache');
document.getElementById('input-email').value = ''; document.getElementById('input-email').value = '';
_masquerBoutonsBas(true);
await chargerSuggestionsEmail(); await chargerSuggestionsEmail();
} }
function fermerEmail() { function fermerEmail() {
document.getElementById('form-email').classList.add('cache'); document.getElementById('form-email').classList.add('cache');
document.getElementById('input-email').value = ''; document.getElementById('input-email').value = '';
_masquerBoutonsBas(false);
} }
async function chargerSuggestionsEmail() { async function chargerSuggestionsEmail() {
@@ -171,34 +214,23 @@ async function envoyerEmail() {
afficherStatut('Envoi en cours...', 'succes'); afficherStatut('Envoi en cours...', 'succes');
const resultat = await apiPost('/api/email', { email, photo: photoFinale }); const resultat = await apiPost('/api/email', { email, photo: photoFinale });
if (resultat.spool) { if (resultat.spool) {
afficherStatut('Vous recevrez votre photo par email dans la semaine', 'succes'); afficherStatut('Votre photo sera envoyee par email des que possible', 'succes');
} else if (resultat.succes) { } else if (resultat.succes) {
afficherStatut('Email envoyé !', 'succes'); afficherStatut('Email envoyé !', 'succes');
} else { } else {
afficherStatut('Erreur d\'envoi email', 'erreur'); afficherStatut('L\'email n\'a pas pu etre envoye, reessayez', 'erreur');
} }
} }
async function afficherQR() { async function afficherQR() {
const zone = document.getElementById('zone-qr');
zone.classList.toggle('cache');
if (!zone.classList.contains('cache')) {
const booth = config.booth || {}; const booth = config.booth || {};
if (booth.actif && booth.url) { if (!booth.actif || !booth.url || !photoFinale) return;
const info = await apiGet('/api/booth/info').catch(() => null); try {
const eventId = booth.event_id || 'default'; const info = await apiGet('/api/booth/info');
const pw = info?.password || ''; if (!info.password) return;
const qrUrl = `${booth.url}/${eventId}?p=${pw}`; const eventId = (config.evenement || {}).event_id || booth.event_id || 'default';
document.getElementById('img-qr').src = `/api/qr?url=${encodeURIComponent(qrUrl)}`; const qrUrl = `${booth.url}/g/${eventId}/view/${photoFinale}?p=${info.password}`;
} else { document.getElementById('qr-photo').src = `/api/qr?url=${encodeURIComponent(qrUrl)}`;
const resultat = await apiGet('/api/qr/galerie'); document.getElementById('popup-qr').classList.remove('cache');
if (resultat.chemin) { } catch (e) { console.error('afficherQR error:', e); }
document.getElementById('img-qr').src = resultat.chemin;
} else {
afficherStatut('QR Code non configure', 'erreur');
zone.classList.add('cache');
}
}
}
} }

View File

@@ -1,30 +1,38 @@
/* Communication WebSocket avec le backend */ /* Communication WebSocket avec le backend — reconnexion résiliente */
let ws = null; let ws = null;
let wsReconnectTimer = null; let _wsReconnectDelay = 1000;
let _wsWasConnected = false;
const wsCallbacks = {}; const wsCallbacks = {};
function wsConnecter() { function wsConnecter() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${proto}//${location.host}/ws`); try { ws = new WebSocket(`${proto}//${location.host}/ws`); } catch(e) { _wsScheduleReconnect(); return; }
ws.onopen = () => { ws.onopen = () => {
console.log('WebSocket connecte'); console.log('WebSocket connecte');
if (wsReconnectTimer) { _wsReconnectDelay = 1000;
clearInterval(wsReconnectTimer); if (_wsWasConnected) {
wsReconnectTimer = null; _masquerPause();
// Recharger la config pour resynchroniser l'état
if (typeof chargerConfig === 'function') chargerConfig();
} }
_wsWasConnected = true;
}; };
ws.onmessage = (event) => { ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data); const msg = JSON.parse(event.data);
const cbs = wsCallbacks[msg.type]; const cbs = wsCallbacks[msg.type];
if (cbs) cbs.forEach(cb => cb(msg)); if (cbs) cbs.forEach(cb => cb(msg));
} catch(e) {}
}; };
ws.onclose = () => { ws.onclose = () => {
console.log('WebSocket deconnecte, rechargement dans 5s...'); console.log('WebSocket deconnecte');
setTimeout(() => { location.reload(); }, 5000); ws = null;
_afficherPause();
_wsScheduleReconnect();
}; };
ws.onerror = () => { ws.onerror = () => {
@@ -32,6 +40,26 @@ function wsConnecter() {
}; };
} }
function _wsScheduleReconnect() {
const delay = Math.min(_wsReconnectDelay, 10000);
_wsReconnectDelay = Math.min(_wsReconnectDelay * 1.5, 10000);
setTimeout(wsConnecter, delay);
}
function _afficherPause() {
const el = document.getElementById('ecran-pause');
if (el) el.style.display = 'flex';
}
function _masquerPause() {
const el = document.getElementById('ecran-pause');
if (el) { el.style.display = 'none'; }
// Relancer le preview 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')) {
lancerPreview();
}
}
function wsEnvoyer(msg) { function wsEnvoyer(msg) {
if (ws && ws.readyState === WebSocket.OPEN) { if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(msg)); ws.send(JSON.stringify(msg));

0
frontend/sounds/.gitkeep Normal file
View File

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

View File

@@ -1,13 +1,24 @@
#!/bin/bash #!/bin/bash
# Verrou : une seule instance autorisée # Verrou atomique — une seule session kiosk autorisee
LOCK=/tmp/photobooth-kiosk.lock exec 9>/tmp/photobooth-kiosk.lock
if [ -f "$LOCK" ] && kill -0 "$(cat $LOCK)" 2>/dev/null; then flock -n 9 || { echo "[kiosk] Déjà en cours, abandon."; exit 0; }
echo "[kiosk] Déjà en cours (pid $(cat $LOCK)), abandon."
exit 0 PHOTOBOOTH_DIR=/home/jules/photobooth
fi
echo $$ > "$LOCK" # Port du backend (lu depuis config.json, fallback 8080)
trap "rm -f $LOCK" EXIT BACKEND_PORT=$(python3 -c "
import json, sys
try:
d = json.load(open('$PHOTOBOOTH_DIR/data/config.json'))
print(d.get('serveur', {}).get('port', 8080))
except: print(8080)
" 2>/dev/null)
BACKEND_URL="http://localhost:${BACKEND_PORT}"
# Tuer tout backend orphelin d'une session precedente
pkill -f 'python.*backend.main' 2>/dev/null || true
sleep 1
xset s off xset s off
xset -dpms xset -dpms
@@ -16,17 +27,15 @@ xset s noblank
# Automount USB (pour la photostation) # Automount USB (pour la photostation)
udiskie --no-notify --no-appindicator & udiskie --no-notify --no-appindicator &
# Libérer le DSLR : gvfsd-gphoto2 (GNOME) le monopolise sinon # Liberer le DSLR : gvfsd-gphoto2 (GNOME) le monopolise sinon
pkill -f gvfsd-gphoto2 2>/dev/null || true pkill -f gvfsd-gphoto2 2>/dev/null || true
pkill -f gvfs-gphoto2-volume-monitor 2>/dev/null || true pkill -f gvfs-gphoto2-volume-monitor 2>/dev/null || true
sleep 1
# Backend en boucle : redémarre automatiquement s'il plante # Backend en boucle : redemarre automatiquement s'il plante
_backend_loop() { _backend_loop() {
while true; do while true; do
cd /home/jules/photobooth while [ -f /tmp/photobooth-maintenance ]; do sleep 2; done
# Mise à jour automatique depuis Gitea cd "$PHOTOBOOTH_DIR"
git pull --ff-only 2>&1 | logger -t photobooth-git
source .venv/bin/activate source .venv/bin/activate
authbind --deep python -m backend.main 2>&1 | tee -a /tmp/photobooth-backend.log authbind --deep python -m backend.main 2>&1 | tee -a /tmp/photobooth-backend.log
echo "[kiosk] backend terminé, redémarrage dans 3s..." echo "[kiosk] backend terminé, redémarrage dans 3s..."
@@ -34,14 +43,18 @@ _backend_loop() {
done done
} }
_backend_loop & _backend_loop &
BACKEND_LOOP_PID=$!
# Attendre que le backend soit prêt (max 15s) # Nettoyer le backend si le kiosk est tue
for i in $(seq 1 15); do trap "kill $BACKEND_LOOP_PID 2>/dev/null; pkill -f 'python.*backend.main' 2>/dev/null" EXIT
curl -sf http://localhost/ > /dev/null 2>&1 && break
# Attendre que le backend soit pret (max 30s)
for i in $(seq 1 30); do
curl -sf "${BACKEND_URL}/api/config" > /dev/null 2>&1 && break
sleep 1 sleep 1
done done
# Chromium en boucle : redémarre s'il plante, attend si photostation active # Chromium en boucle : redemarre s'il plante, attend si photostation active
while true; do while true; do
while [ -f /tmp/photostation_running ]; do sleep 1; done while [ -f /tmp/photostation_running ]; do sleep 1; done
chromium \ chromium \
@@ -62,7 +75,7 @@ while true; do
--password-store=basic \ --password-store=basic \
--lang=fr \ --lang=fr \
--remote-debugging-port=9222 \ --remote-debugging-port=9222 \
http://localhost "${BACKEND_URL}"
echo "[kiosk] Chromium terminé, redémarrage dans 2s..." echo "[kiosk] Chromium terminé, redémarrage dans 2s..."
sleep 2 sleep 2
done done

View File

@@ -1,15 +1,25 @@
[Unit] [Unit]
Description=Photobooth Description=Photobooth backend
After=network.target graphical.target After=network.target graphical.target
Wants=graphical.target
[Service] [Service]
Type=simple Type=notify
User=jules User=jules
WorkingDirectory=/home/jules/Documents/Projet/photobooth-app WorkingDirectory=/home/jules/photobooth
ExecStart=/home/jules/Documents/Projet/photobooth-app/scripts/start.sh ExecStart=/home/jules/photobooth/scripts/start.sh
ExecStopPost=/bin/bash -c 'pkill -9 -f "python.*backend.main" 2>/dev/null; sleep 1'
Restart=always Restart=always
RestartSec=5 RestartSec=3
WatchdogSec=30
KillMode=control-group
KillSignal=SIGTERM
TimeoutStopSec=10
StartLimitIntervalSec=120
StartLimitBurst=10
Environment=DISPLAY=:0 Environment=DISPLAY=:0
Environment=XAUTHORITY=/home/jules/.Xauthority
AmbientCapabilities=CAP_NET_BIND_SERVICE
[Install] [Install]
WantedBy=graphical.target WantedBy=graphical.target

8
scripts/watchdog.service Normal file
View File

@@ -0,0 +1,8 @@
[Unit]
Description=Photobooth watchdog
After=lightdm.service
[Service]
Type=oneshot
ExecStart=/home/jules/photobooth/scripts/watchdog.sh
User=root

101
scripts/watchdog.sh Normal file
View File

@@ -0,0 +1,101 @@
#!/bin/bash
# Watchdog photobooth — tourne toutes les 30s via systemd timer
# Vérifie backend, camera, chromium. Escalade jusqu'au reboot si nécessaire.
HEALTH_URL="http://localhost/api/health"
STATE_FILE="/tmp/photobooth-watchdog-failures"
MAX_FAILURES=10 # 10 échecs × 30s = 5 min avant reboot
LOG_TAG="photobooth-watchdog"
log() { logger -t "$LOG_TAG" "$1"; }
# Compteur d'échecs persistant
failures=$(cat "$STATE_FILE" 2>/dev/null || echo 0)
# --- 1. Tuer les backends orphelins (ppid=1, pas rattachés au kiosk) ---
while IFS= read -r line; do
pid=$(echo "$line" | awk '{print $1}')
ppid=$(echo "$line" | awk '{print $2}')
if [ "$ppid" = "1" ]; then
log "Backend orphelin détecté (pid=$pid), kill"
kill -9 "$pid" 2>/dev/null
fi
done < <(ps -eo pid,ppid,args | grep 'python.*backend.main' | grep -v grep)
# --- 2. Vérifier que le backend répond ---
health=$(curl -sf --max-time 5 "$HEALTH_URL" 2>/dev/null)
if [ -z "$health" ]; then
log "Backend ne répond pas (échec $((failures+1))/$MAX_FAILURES)"
# Vérifier si un process backend existe
if ! pgrep -f 'python.*backend.main' > /dev/null 2>&1; then
log "Aucun backend en cours — le kiosk devrait le relancer"
fi
failures=$((failures + 1))
echo "$failures" > "$STATE_FILE"
if [ "$failures" -ge "$MAX_FAILURES" ]; then
log "REBOOT: $MAX_FAILURES échecs consécutifs, redémarrage système"
echo 0 > "$STATE_FILE"
/usr/sbin/reboot
fi
exit 0
fi
# Backend répond — extraire l'état caméra
camera=$(echo "$health" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("camera",""))' 2>/dev/null)
clients=$(echo "$health" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("clients",0))' 2>/dev/null)
# --- 3. Vérifier la caméra ---
if [ "$camera" = "disconnected" ] || [ "$camera" = "erreur" ]; then
log "Caméra déconnectée, tentative USB reset Canon"
# USB ioctl reset du Canon (vendor 04a9)
python3 -c '
import fcntl, glob, os
for f in glob.glob("/sys/bus/usb/devices/*/idVendor"):
if open(f).read().strip() == "04a9":
d = os.path.dirname(f)
bus = open(d + "/busnum").read().strip().zfill(3)
dev = open(d + "/devnum").read().strip().zfill(3)
path = f"/dev/bus/usb/{bus}/{dev}"
with open(path, "wb") as fh:
fcntl.ioctl(fh, 0x5514, 0)
print(f"USB reset: {path}")
' 2>/dev/null
# Si le Canon n'est même pas sur le bus USB
if ! lsusb 2>/dev/null | grep -q "04a9"; then
log "Canon absent du bus USB"
failures=$((failures + 1))
else
failures=$((failures + 1))
fi
echo "$failures" > "$STATE_FILE"
if [ "$failures" -ge "$MAX_FAILURES" ]; then
log "REBOOT: caméra injoignable depuis $MAX_FAILURES cycles"
echo 0 > "$STATE_FILE"
/usr/sbin/reboot
fi
exit 0
fi
# --- 4. Vérifier Chromium ---
if ! pgrep -f chromium > /dev/null 2>&1; then
log "Chromium ne tourne pas — restart lightdm"
systemctl restart lightdm
failures=$((failures + 1))
echo "$failures" > "$STATE_FILE"
exit 0
fi
# --- 5. Tuer gvfsd-gphoto2 s'il revient ---
pkill -f gvfsd-gphoto2 2>/dev/null
pkill -f gvfs-gphoto2-volume-monitor 2>/dev/null
# --- Tout va bien : reset compteur ---
if [ "$failures" -gt 0 ]; then
log "Système OK (camera=$camera, clients=$clients) — compteur remis à 0"
fi
echo 0 > "$STATE_FILE"

10
scripts/watchdog.timer Normal file
View File

@@ -0,0 +1,10 @@
[Unit]
Description=Photobooth watchdog timer
[Timer]
OnBootSec=60
OnUnitActiveSec=30
AccuracySec=5
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,6 @@
[Unit]
Description=WiFi watchdog check
[Service]
Type=oneshot
ExecStart=/home/jules/photobooth/scripts/wifi-watchdog.sh

28
scripts/wifi-watchdog.sh Normal file
View File

@@ -0,0 +1,28 @@
#!/bin/bash
# Surveille la connexion WiFi et la rétablit si elle tombe.
# Conçu pour Surface Pro (wlan0) — lancé par cron ou systemd timer.
IFACE="wlan0"
GATEWAY=$(ip route show default dev "$IFACE" 2>/dev/null | awk '{print $3}' | head -1)
LOG_TAG="wifi-watchdog"
if [ -z "$GATEWAY" ]; then
logger -t "$LOG_TAG" "Pas de gateway sur $IFACE, tentative reconnexion..."
nmcli device connect "$IFACE" 2>&1 | logger -t "$LOG_TAG"
sleep 5
GATEWAY=$(ip route show default dev "$IFACE" 2>/dev/null | awk '{print $3}' | head -1)
fi
if [ -n "$GATEWAY" ]; then
if ! ping -c 2 -W 3 -I "$IFACE" "$GATEWAY" > /dev/null 2>&1; then
logger -t "$LOG_TAG" "Ping gateway $GATEWAY echoue, reconnexion WiFi..."
nmcli device disconnect "$IFACE" 2>&1 | logger -t "$LOG_TAG"
sleep 2
nmcli device connect "$IFACE" 2>&1 | logger -t "$LOG_TAG"
sleep 5
if ! ping -c 2 -W 3 -I "$IFACE" "$GATEWAY" > /dev/null 2>&1; then
logger -t "$LOG_TAG" "Toujours pas de connexion, restart NetworkManager..."
systemctl restart NetworkManager
fi
fi
fi

View File

@@ -0,0 +1,9 @@
[Unit]
Description=WiFi watchdog - check every minute
[Timer]
OnBootSec=30
OnUnitActiveSec=60
[Install]
WantedBy=timers.target