Compare commits

...

141 Commits

Author SHA1 Message Date
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
182042350d Calibration impression : offsets coupe (mm) + rotation 180° configurable
- impression.offsets_coupe_mm : {haut, bas, gauche, droite} compense la coupe asymétrique
- impression.rotation_180 : retourne l'image si le driver inverse le sens

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-23 09:29:40 +02:00
e83d5c8d58 Fix impression paysage : inverser largeur/hauteur quand orientation=paysage
Les dimensions FORMATS sont en portrait — sans inversion, une photo paysage
était crop en portrait pour l'impression.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-23 09:14:36 +02:00
e6e7c5a9f2 Fix cadre overlay centré sur preview live (top/left/width/height écrasés par .preview-live img)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-23 09:00:40 +02:00
6dafe61417 Escalade DSLR recovery : uhubctl power cycle + backoff 60s après 10 échecs
Niveaux : ioctl reset (2) -> unbind/rebind (3-5) -> uhubctl 5V cut (6+) -> backoff 60s (10+)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-23 08:44:12 +02:00
e2f65b1ba7 USB rebind via sudo tee (permission sysfs requiert root)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-23 08:37:41 +02:00
29a7db028e USB rebind Canon : unbind/rebind sysfs pour recovery hard DSLR PTP freeze
Escalade progressive : ioctl reset (tentative 2), unbind/rebind USB (tentative 3+)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-23 08:35:03 +02:00
97e3f6508d DSLR auto-recovery USB reset + cadre overlay non-mirroré + lien galerie événement
- surveiller_dslr: reset USB ioctl après 2 échecs, double reset après 3
- deconnecter(): timeout threading 3s pour éviter blocage PTP
- CSS: .preview-live .cadre-overlay sans scaleX(-1)
- Admin événement: lien direct vers galerie en ligne

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-23 08:30:15 +02:00
6bbcf85e52 Formats activables par evenement + cadre impose exclut strips
- Toggles formats (10x15, 15x20, strip) par evenement
- Strip desactive masque le bouton pellicule sur l'accueil
- Cadre impose/defaut s'applique uniquement sur 10x15 et 15x20 (pas strip)
- Upload cadre evenement le copie dans les deux formats automatiquement
- Section cadres admin simplifiee (un seul bloc au lieu de 3)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-23 00:45:47 +02:00
77b1127816 Cadre impose visible en overlay sur live + preview + partage
- Overlay cadre sur les 3 ecrans : live (capture), preview (filtres), partage
- chargerCadreImpose() au debut de chaque capture : affiche le cadre event
- Photos sauvegardees sans cadre, cadre applique uniquement a l'impression
- afficherCadreOverlay cherche d'abord dans les cadres evenement

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-23 00:36:57 +02:00
e5c73d4855 Theme Viking Celte : bois sculpte, bronze, entrelacs
Palette bois fonce (#1a0f08/#2b1810), bronze (#b08d57), parchemin (#f0e6d2).
Polices MedievalSharp + Cinzel. Boutons style bois sculpte avec bordures bronze.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-23 00:31:51 +02:00
9ce8fc0e55 Gestion evenements + cadres par event + fix cadre paysage + dossier non-imprimees
- CRUD evenements : creer, activer, editer, supprimer dans panneau admin
- Cadres specifiques par evenement (aucun/disponible/defaut/impose)
- Fix cadre paysage deformé : rotation auto si orientation ne correspond pas
- Photos non imprimees vont dans sous-dossier "non_imprimees" (USB/FTP)
  au lieu d'etre ignorees ; booth recoit toujours toutes les photos

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-23 00:16:35 +02:00
545d8cd6ad Admin : galerie en ligne dans Destinations + booth envoie toutes les photos
- Remplace le toggle "Site internet" par "Galerie en ligne" dans le panneau Destinations
- Supprime la section dupliquée "Galerie Live" du panneau Fonctions
- Le booth reçoit toutes les photos (imprimées ou non), le filtre sauvegarder_tout ne s'applique qu'à USB/FTP

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-23 00:05:47 +02:00
98c738cf79 Galerie booth : QR avec auto-login, event_id aleatoire, flyer imprimable
- QR code inclut le mot de passe (?p=XXXX) pour acces direct sans saisie
- Supprime le code texte visible sur l'ecran d'accueil, QR plus petit
- Bouton admin 'Imprimer flyer QR galerie' genere un 10x15 avec
  nom evenement, QR code, texte invitation, design assorti
- Auto-login ajoute cote serveur booth (booth.copydev.fr)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-22 20:07:52 +02:00
4d2d3e7a93 Bump cache-busting versions (css v5, app v5, camera v11, share v6)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-22 19:46:24 +02:00
85f32c35bb Rapport email a contact@copydev.fr apres vidage du spool
Au demarrage, si le spool contient des emails en attente, les envoie
puis envoie un rapport recapitulatif (nb envoyes + liste destinataires)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-22 19:42:25 +02:00
9bdad23cf5 Spool email : envoi au demarrage uniquement, message 'dans la semaine'
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-22 19:34:26 +02:00
e47ff04933 Email spool : file d'attente quand envoi echoue, retry toutes les 60s
- Si l'envoi SMTP echoue (pas de connexion, config incomplete, etc.),
  l'email est stocke dans data/email_spool.json
- Tache asyncio periodique (60s) tente de vider le spool
- L'adresse est quand meme ajoutee a l'historique de suggestions
- Frontend affiche "Email en file d'attente" au lieu de "Erreur"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-22 19:17:58 +02:00
158f1b509f Email : fermer formulaire avant envoi + raccourcis domaines FR
- Ferme le formulaire email immediatement au clic Envoyer (le toast
  etait cache derriere l'overlay fullscreen)
- Ajout domaines : @outlook, @yahoo, @icloud, @orange, @free, @sfr, @laposte

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-22 19:13:37 +02:00
0b7b55f57b Email plein ecran avec suggestions d'adresses precedentes
- Formulaire email : overlay fullscreen au lieu d'inline (debordait de l'ecran)
- Historique emails : stocke chaque adresse dans data/emails_history.json
- Suggestions : affiche les emails deja saisis en pills cliquables,
  filtrees au fur et a mesure de la saisie
- API GET /api/emails/historique + DELETE pour reset

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-22 18:57:28 +02:00
e730b6bc8b Cadres : preview coin haut-gauche + compteur bloque impression pas capture
- Thumbnails cadres : positionnement coin haut-gauche au lieu du centre
  (le centre du cadre PNG est transparent = invisible)
- Compteur photos : ne bloque plus la capture quand limite atteinte,
  masque le bouton imprimer sur l'ecran partage a la place

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-22 09:03:26 +02:00
f49d5511ef Pellicule : marge de coupe plus large + warmup LiveView apres capture
La coupe physique du K60 n'est jamais parfaitement centree, donc une marge
noire trop fine fait perdre la pellicule d'un cote du strip dedouble. Marge
de coupe (perforations) elargie de 34 a 55px (~4.6mm) de chaque cote.

Ajoute aussi un warmup LiveView (_chauffer_liveview) juste apres la remontee
du miroir post-capture : consomme les ~1-2s de stabilisation pendant le
spinner de traitement plutot que de laisser le preview du countdown suivant
figé sur l'ancienne photo.
2026-06-21 23:47:06 +02:00
640715282c Fix impression pellicule : la feuille -div2 doit etre envoyee a taille pleine
La PageSize CUPS w288h432-div2 declare la meme taille de page que w288h432
(confirme par le PPD Mitsubishi) : le K60 coupe la feuille pleine apres
impression, il ne duplique pas une image fournie a taille reduite. On envoyait
la bande seule (600x1800) sous cette PageSize, donc CUPS l'etirait pour
remplir la feuille 1200x1800 puis le K60 la coupait en 2 — d'ou la photo
etiree et coupee au lieu de 2 bandes identiques.

Ajoute aussi le miroir horizontal sur le preview live (selfie) et un flou de
chargement pendant le bref delai de remontee du miroir DSLR apres capture,
pour eviter l'impression d'image figee sur la photo precedente.
2026-06-21 23:32:50 +02:00
a79762197d Fix impression pellicule : strip genere en paysage au lieu du portrait attendu
creer_strip() produisait une bande paysage (1800x595) alors que printer.py
('10x15-2up' -> PageSize ...-div2) et tous les cadres existants (assets/cadres/strip/*.png)
attendent une bande portrait fixe 600x1800 (5x15cm), dupliquee et coupee
automatiquement par l'imprimante. Le mismatch faisait pivoter/etirer
l'image a l'impression (StpiShrinkOutput=Crop sur un ratio incompatible),
d'ou le rendu deforme coupe en 2 mauvais morceaux au lieu de 2 pellicules
identiques.

- creer_strip() : bande 600x1800 portrait, photos empilees verticalement,
  perforations sur les cotes (au lieu de haut/bas)
- Cadre de visee live (camera.js) : formule de ratio mise a jour en
  consequence pour rester fidele au resultat final
2026-06-21 23:00:48 +02:00
0df68da41a Cadre de visee live correspondant au recadrage final pellicule/collage
L'apercu live montrait le plein cadre camera (ratio ~3:2) alors que le
recadrage final de chaque case (creer_strip/creer_collage) est plus carre
(~1.11:1 pour 3 photos en pellicule) : le cadrage visualise pendant le
compte a rebours ne correspondait pas au resultat imprime.

Ajoute un cadre pointille superpose au live preview, dimensionne en
reproduisant cote JS la meme formule de recadrage que backend/collage.py,
visible uniquement en mode multi-shot (strip ou collage).
2026-06-21 22:52:05 +02:00
b8f3bef852 DSLR : empecher la mise en veille (autopoweroff Canon + udev no-autosuspend)
Le boitier se mettait en veille apres quelques minutes d'inactivite et
apparaissait deconnecte. Deux causes possibles traitees :
- autopoweroff Canon desactive a la connexion (cote firmware)
- USB autosuspend du host desactive pour les peripheriques Canon (cote noyau)
2026-06-21 22:15:56 +02:00
66847dccf8 Ecran partage : photo miniature flexible pour toujours rentrer dans l'ecran
Le bloc partage (photo + boutons + form impression) pouvait depasser la
hauteur de l'ecran quand le choix du nombre d'exemplaires s'affichait.
La miniature absorbe maintenant l'espace restant (flex + min-height:0)
au lieu d'une hauteur fixe en vh, les autres elements gardent leur taille.
2026-06-21 22:13:26 +02:00
fe0a559c85 Flash Canon configurable, choix de cadre a l'impression, fixes capture/reconnexion DSLR
- Flash integre Canon activable/desactivable depuis l'admin (configurer_flash)
- Popup de choix de cadre juste avant impression, layout sans defilement (flex nowrap + aspect-ratio)
- Capture : ignore camera_erreur pendant une capture en cours (laisse l'API trancher)
- surveiller_dslr : grace period preview augmentee a 2s, reapplique la config camera apres reconnexion
- start.sh simplifie (Chromium gere par kiosk-session.sh), remote debugging port 9222 active
2026-06-21 21:59:36 +02:00
c7225cc9f6 surveiller_dslr : retirer le reset USB automatique (contre-productif)
Les resets USB ioctl répétés en boucle aggravent l'état des appareils Canon
au lieu de les débloquer. La fonction _usb_reset_canon est conservée pour
usage manuel mais n'est plus appelée automatiquement.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 14:33:14 +02:00
f66f08da4e surveiller_dslr : pause 12s après reset USB Canon pour ré-énumération
Le reset USB ioctl prend ~10s sur le Canon EOS 6D pour que l'appareil
se ré-énumère sur le bus. Avec 3s on retombait en boucle de resets.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 14:04:23 +02:00
e4e5ee05d4 surveiller_dslr : reset USB automatique après 3 échecs de connexion Canon
Quand gphoto2 détecte le DSLR mais que camera.init() timeout répétitivement
(USB stall après kill -9 ou déconnexion brutale), on tente un reset USB ioctl
(USBDEVFS_RESET sur /dev/bus/usb/<bus>/<dev>) toutes les 3 tentatives (~15s).
Évite de rester bloqué indéfiniment sur "Timeout reading from or writing to the port".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 14:02:29 +02:00
4f12f77106 Capture : viewfinder=0 avant prise (AF phase-détection), timeout 20s, spinner UI
- camera.py : désactiver LiveView avant capture (viewfinder=0) → miroir abaissé →
  AF phase-détection Canon actif. Remplace _declencher_af_liveview (autofocusdrive=1
  pouvait bloquer sur certains corps Canon). activer_viewfinder() après la prise.
- main.py : fix bug global _preview_actif dans api_capturer (était variable locale,
  le global restait True). Timeout asyncio 20s sur run_in_executor pour éviter freeze
  permanent si capture bloque. Logs INFO avant/après capture.
- camera.js + index.html + style.css : spinner "Capture en cours..." entre le flash
  et l'affichage du résultat. Cacher le spinner dans afficherErreurCapture aussi.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 13:51:51 +02:00
d02cc90fee Bullet-proof caméra : reconnexion auto + fix crash capture + AF LiveView
backend:
- surveiller_dslr : fix condition qui court-circuitait le check santé
  quand mode==gphoto2 — watchdog surveille maintenant via preview_dslr_ok
  et reconnecte automatiquement après 15s d'échec (3x5s)
- _pusher_preview : ne pas envoyer camera_erreur pendant capture_en_cours
  (race condition HTTP/WS : le POST /capturer peut arriver avant preview_stop)
- api_capturer : force _preview_actif=False dès le début de la capture
- _capture_gphoto2 : déclenche autofocusdrive avant la prise en LiveView

frontend:
- websocket.js : support multi-callbacks par type (wsOnMessage ne remplace
  plus le callback précédent, tous s'exécutent)
- app.js : camera_erreur affiche bannière + reload dernier recours à 2min
  (plus 10s), camera_ok cache la bannière automatiquement
- camera.js : camera_erreur abort capture + arreterPreview(); camera_ok
  reset captureAbortee

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 12:11:33 +02:00
7e239ea0f9 Camera déconnectée : retour accueil automatique + abort capture
- camera_erreur pendant la capture : abort immédiat + afficherErreurCapture()
  → retour accueil en 4s (au lieu de rester bloqué sur "Reconnexion en cours...")
- camera_ok : cache l'erreur et reset le flag captureAbortee
- captureAbortee flag : permet à lancerCapture() de sortir proprement
  sans déclencher de capture fantôme après une déconnexion

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 11:53:30 +02:00
a51d235faf Fix preview live : position:absolute sur img (height:100% cassé dans flex avec align-items:center)
La balise <img> avec height:100% ne se propage pas quand le parent est un
flex item positionné avec align-self:center au lieu de stretch. Le fix :
position:absolute top:0 left:0 width/height 100% sur .preview-live img.

Retiré aussi --remote-debugging-port=9222 (diagnostic temporaire).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 11:16:19 +02:00
831c64f4f7 Debug: active remote debugging port 9222 sur Chromium 2026-06-10 10:48:24 +02:00
652d0424e3 Retirer le .deb du repo, ajouter .gitignore
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 09:57:02 +02:00
f977189a93 Kiosk : git pull auto + diagnostic caméra dans admin
- kiosk-session.sh : git pull à chaque démarrage du backend
  (les correctifs Gitea sont maintenant déployés automatiquement)
- Log backend dans /tmp/photobooth-backend.log (lisible via admin)
- Admin > Infos : bouton "Diagnostic camera" → appelle /api/camera/debug
  et affiche mode, connectee, erreur capture_preview, widgets gphoto2

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 09:56:55 +02:00
d3a71a9866 Fix caméra : gvfsd-gphoto2, race condition viewfinder, exif_transpose
- kiosk-session.sh : tue gvfsd-gphoto2 au démarrage (démon GNOME qui
  monopolisait le DSLR sur USB et bloquait gphoto2)
- camera.py : viewfinder activé AVANT mode="gphoto2" → le thread preview
  ne peut plus appeler capture_preview() avant que LiveView soit prêt
- _activer_viewfinder_init() sans lock pour l'init (thread pas encore actif)
- exif_transpose sur les captures DSLR → corrige l'orientation et les
  bandes blanches dues aux photos mal orientées

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 17:33:31 +02:00
0b4c5f9430 Fix strip (K60 coupe auto), diagnostic preview, nettoyage
Strip 10x15 :
- Envoie la strip simple avec format 10x15-2up (w288h432-div2)
  le K60 duplique et coupe automatiquement → plus de papier non coupé
- Supprime creer_impression_strip et le 2-up manuel avec marges blanches
- api/imprimer accepte format_papier depuis le frontend
- Frontend stocke formatImpression et le passe à l'impression

Diagnostic preview :
- GET /api/camera/debug : teste capture_preview(), liste les widgets
  config gphoto2, état du thread → permet de voir l'erreur exacte

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 16:52:13 +02:00
1364b62297 Fix preview + miroir : thread continu, grace period, lock gphoto2
- _thread_preview tourne TOUJOURS quand DSLR connecté (plus de condition
  _preview_actif) → capture_preview() appelé en continu → miroir reste levé
- Grace period 15 frames (~500ms) avant de signaler camera_erreur : évite
  la race condition qui tuait le preview avant la 1ère frame
- _preview_gphoto2 protégé par _gp_lock (accès concurrent safe)
- surveiller_dslr ne tourne plus quand le DSLR est en mode gphoto2 connecté

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 23:01:25 +02:00
0a918666b3 Camera : viewfinder permanent + preview live pendant compte à rebours
- activer_viewfinder() à la connexion DSLR (miroir levé dès le démarrage)
- activer_viewfinder() après chaque capture (certains Canon le désactivent)
- Suppression desactiver_viewfinder() — miroir ne redescend plus jamais
- lancerPreview() au début du compte à rebours, arreterPreview() juste avant la capture
- Preview live fonctionnel : l'utilisateur se voit pendant tout le compte à rebours

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 22:32:46 +02:00
0477831679 Imprimante : bouton Couper papier (micro-job blanc déclenche le cutter K60)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 21:19:27 +02:00
843762b6d7 Imprimante : bandeau erreur tous écrans + reset USB + statut détaillé
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 21:17:27 +02:00
2b204ddb5e Admin : statut détaillé imprimante + message erreur CUPS traduit
- GET /api/imprimante/statut-detail : statut + dernière erreur log
- Bouton évacuer retourne le vrai message (format papier, bourrage...)
- Bloc d'erreur affiché dans l'onglet Matériel si problème détecté

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 20:38:48 +02:00
3cd5525981 detecter_usb : 2 niveaux /media/<user>/<label> + filtre fstype 2026-06-04 13:53:05 +02:00
c013c9e69e Ajout udiskie automount USB dans kiosk-session 2026-06-04 13:48:28 +02:00
4de88877c7 Fix clignotement : verrou instance unique kiosk + meta notranslate HTML
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 13:06:12 +02:00
a85cc100f2 Fix translate bar (--lang=fr + prefs FR) + popup photostation popup-overlay
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 12:58:09 +02:00
0c00011de0 Fix popup photostation (hors ecran-accueil) + z-index boutons + profil Chromium sans translate
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 12:50:41 +02:00
8db27bf77d Fix : clic btn-photostation ne déclenche plus la navigation vers mode
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 12:42:26 +02:00
d66a9c2fa9 Fix : bit exécutable scripts kiosk-session.sh et install-photostation.sh
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 12:37:13 +02:00
babd89425c Intégration station d'impression photo (photostation)
- Icône discrète coin bas-gauche, visible seulement si photostation installée
- Popup PIN (4 chiffres) avant lancement
- Backend : /api/photostation/disponible + /api/photostation/lancer
- kiosk-session.sh : Chromium attend le flag /tmp/photostation_running avant redémarrage
- Script install-photostation.sh pour déploiement sur la Surface

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 09:20:48 +02:00
fe71921d1a Cadres choix : zoom 2.5x + fond photo simulé, bordures bien visibles
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 09:14:40 +02:00
ba2b119e1f Kiosk : boucle infinie backend + Chromium, plus d'écran login
Si Chromium ou le backend plantent, ils redémarrent automatiquement.
La session LightDM ne se termine jamais → plus d'écran de login intempestif.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 21:40:23 +02:00
6af9d7c073 Fix : desactiver Translate dans Chromium kiosk
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 21:39:18 +02:00
e3d1d9cc07 Impression Mitsubishi K60 + cadres + interface admin distante
- Intégration selphy_print (backend CUPS dyesub Mitsubishi CP-K60DW-S)
- Formats impression : 15x20, 10x15, 10x15 2-strips avec cadre et orientation portrait/paysage
- Cadres d'impression par format (strip/10x15/15x20) : upload, sélection, aperçu live
- Cadres démo générés : pellicule noir/vintage/couleurs + bordures classique/doré/rose gold
- Écran de choix de cadre avant capture (clic direct, aperçu grand format)
- Filtres réduits à 3 (Couleur, N&B, Sépia) et appliqués sur tous les strips
- Port 80 via authbind, route /admin avec redirection auto depuis poste distant
- Autologin LightDM corrigé (pam-autologin-service)
- Bouton évacuer bourrage papier dans admin
- Fix : _gp_lock manquant dans Camera.__init__

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 21:38:31 +02:00
6451f5774c Mise a jour theme GRUB + logo copydev + test pygame
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 22:43:21 +02:00
1120513309 Switch trigger_capture() : eosremoterelease ne genere FILE_ADDED qu'une fois
eosremoterelease='Immediate' envoyait GP_EVENT_FILE_ADDED uniquement
pour la 1ere pose (bug Canon/gphoto2 connu). trigger_capture() genere
l'evenement pour CHAQUE pose. En liveview actif (miroir deja leve),
trigger_capture() ne bouge pas le miroir — uniquement l'obturateur.
Timeout reduit 15s -> 12s, poll 500ms -> 200ms (reaction plus rapide).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 16:06:30 +02:00
46752bf29b Fix capture multi-pose : supp eosviewfinder (cause principale timeout)
- Retire eosviewfinder=1 avant/apres chaque prise : relancait le liveview
  (4-6s de blocage) → timeout 15s → ch=None → photos=[] → S_ACCUEIL
- Le miroir est deja leve par capture_preview() continu, l'obturateur
  seul se declenche via eosremoterelease=Immediate
- Logs detailles : chaque etape imprime son resultat pour debug
- Apres capture echouee : _prev_thumbs.append(None) pour garder index cohérent
- S_PREV 2.5s → 1.5s (plus reactif entre les poses)
- Apres capture : revient en S_PREV 0.6s pour montrer la vignette
- Print "Séquence terminée N/M photos" pour suivi

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 15:22:30 +02:00
eb0634fab4 Fix captures 2/3 perdues + miroir + stabilite camera post-prise
- post_capture_grace=10 : ignore les erreurs -52 transitoires juste
  apres une prise (Canon EOS a besoin ~1s pour stabiliser le capteur)
- Threshold abandon capture : 3 -> 8 erreurs consecutives
- Threshold reconnexion : 5 -> 10 erreurs consecutives
- Avant chaque prise : eosviewfinder=1 pour garantir miroir leve
- Apres prise : viewfinder relance + sleep 1s + drain 8 evenements
- Sim auto-detect : pas de USB reset (evite boucle -52), verification
  fonctionnelle avec un capture_preview() avant de valider la camera
- Sim poll : 15s au lieu de 10s

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 15:02:51 +02:00
529cb41581 Fix saccades LV + distorsion pellicule + draw_capture plein ecran
- Supprime sleep(0.3) apres chaque capture_preview() reussi
  (limitait le live view a ~3fps, cause principale des saccades)
- Fixe crash silencieux thread camera : _nb_retry non initialise
  pour errs 1-4 (NameError) -> restructure en for/else propre
- Pellicule live view : _pfit() au lieu de transform.scale() -> plus de distorsion
- draw_capture() : mode pellicule affiché au lieu de fullscreen black

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 11:15:46 +02:00
9c57c8f5f2 Bouton Quitter admin + boucle reconnexion caméra améliorée
- Admin onglet Système : bouton Quitter (gris) pour fermer l'appli
- Reconnexion caméra : max 10 tentatives (~30s) puis bascule en mode
  simulation ; le loop sim reprend la détection automatique toutes les 10s

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 19:14:51 +02:00
d4ad1dec17 Fix déformation photos + miroir Canon EOS
- _pfit() : helper scale+crop centré pygame sans déformer
- ImageOps.fit() partout : vignettes, galerie, composite (recadrage centré
  au lieu de resize qui écrase)
- eosremoterelease 'Immediate' : déclenche l'obturateur Canon en restant
  en LV, évite la levée/baisse de miroir à chaque prise

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 17:49:46 +02:00
32e83250d2 USB reset aussi dans le loop de reconnexion automatique 2026-04-11 17:44:17 +02:00
5fc2f11bd4 Pellicule galerie + délai S_PREV + USB unbind/bind
- draw_galerie() : rendu pellicule (bande film avec perforations) quand le
  mode est actif
- _load_gal() : charge les surfs à la taille des cadres pellicule si actif
- S_PREV : 1.5s → 2.5s pour laisser le DSLR revenir en live view
- _usb_reset_done() : utilise unbind/bind sysfs au lieu de usbreset
  (unbind/bind = seule méthode qui libère réellement le claim USB)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 17:43:10 +02:00
0a90ae1de6 Auto-détection DSLR + reset USB au démarrage et toutes les 10s
- _usb_reset_done() : reset USB via usbreset pour libérer tout claim
  obsolète avant d'initialiser libgphoto2 (fix erreur -53)
- Appelé au démarrage et dans _cam_loop() toutes les 10s en simulation
- _cam_loop() : log les erreurs d'auto-detect pour diagnostic

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 17:40:10 +02:00
59370a4965 Fix pellicule compte à rebours + auto-détection DSLR
- draw_compte() pellicule : suppression de l'overlay global qui masquait la
  bande film pendant le compte à rebours
- _cam_loop() : ne quitte plus en mode simulation, tente une reconnexion
  automatique toutes les 8s si le DSLR est branché après démarrage
- _reconnect_cam() : fonctionne aussi depuis le mode simulation (bouton admin)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 17:34:05 +02:00
99ae7c785c Fix toggle pellicule_mode : premier clic activait en sens inverse
Le handler utilisait True comme défaut pour tous les toggles, ce qui faisait
que not f.get("pellicule_mode", True) = False au premier clic (toggle semblait
bloqué). Ajout d'un ensemble _false_defs pour les features à défaut False.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 17:24:49 +02:00
34f66bde1c Mode pellicule : bande style film 35mm activable dans l'admin
- Nouveau toggle "Mode pellicule" dans l'onglet Fonctions de l'admin
- _draw_pellicule() : bande noire cinéma avec perforations haut/bas,
  cadres numérotés (01A, 02A...) ; le live view est rendu dans le cadre
  courant, les photos prises occupent leur case, les futures sont en gris
- draw_preview() / draw_compte() : utilisent _draw_pellicule() quand activé
- _make_composite() : génère une bande simple (non doublée) en mode pellicule
- Thumbnails chargés à _PELL_FH (748px) pour la qualité native en pellicule

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 17:17:43 +02:00
1c1f0323cc Multipose: composite upload + strip preview layout
- _make_composite(): crée une image bande (2 rangées) ou grille 2x2 à partir
  des photos prises, sauvée en /tmp/photobooth_composite.jpg
- _upload_booth(): upload le composite unique en mode multi au lieu des photos
  individuelles
- _draw_multi_strip(): overlay bas avec les cases numérotées — thumbnails pour
  les prises faites, cadre pulsant pour la case courante, placeholder gris pour
  les suivantes
- draw_preview() / draw_compte(): utilisent _draw_multi_strip() en mode multi

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 17:08:17 +02:00
8ae42e1bc6 Backend: galerie session /s/{code} + upload API
Ajoute deux routes FastAPI :
- GET /s/{code} → galerie HTML mobile avec auto-refresh 15s
- POST /api/sessions/{code}/upload → stockage photo dans session_{code}/

Corrige layout admin : titre sm au lieu de lg, compteur sans chevauchement.
Wizard step 2 : code affiché en lg (96px) au lieu de xxl (300px).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 14:33:35 +02:00
a1a0ce0503 Scripts test liveview : pygame, pyqt6, opencv, tkinter - mesure FPS et premiere image 2026-04-09 12:42:50 +02:00
19d1998704 Suppression preview pendant capture : plus de conflit gphoto2, capture propre 2026-04-09 11:25:17 +02:00
a3c7d6e4e4 Capture en parallele du compte a rebours : photo prise pendant le 3 2 1 2026-04-09 11:21:19 +02:00
c31cab8dc0 Script bench camera : mesure temps preview et capture gphoto2 2026-04-09 11:14:06 +02:00
0d24c8af26 Kiosk PyQt6 WebEngine : alternative navigateur, process unique, auto-reload 2026-04-09 07:46:39 +02:00
b4ec4dbf3f Fix race condition gphoto2 : lock non-bloquant preview cede la place a la capture 2026-04-09 07:28:33 +02:00
57ac16d366 Retrait viewfinder : priorite a la capture fonctionnelle 2026-04-09 02:05:32 +02:00
95f1a597e6 Fix critique : camera.capturer() dans run_in_executor pour ne pas bloquer asyncio 2026-04-09 02:03:58 +02:00
718d17a49c Auto-reload page si erreur camera (10s) ou WS deconnecte (5s) 2026-04-09 02:01:35 +02:00
db751ca48c Retrait verrou camera et threading : revert approche simple sans deadlock 2026-04-09 02:00:11 +02:00
03102c803e Fix blocage asyncio viewfinder + bouton retour sur erreur camera 2026-04-09 01:54:46 +02:00
4273caf1ee Fix race condition gphoto2 : verrou camera sur preview et capture 2026-04-09 01:49:48 +02:00
076e6d0030 Canon LiveView : activer viewfinder gphoto2 avant capture_preview 2026-04-09 01:44:46 +02:00
a59f0ef766 Fix freeze liveview : bloquer surveiller_dslr pendant preview (conflit gphoto2) 2026-04-09 01:38:59 +02:00
94e3d68dd7 Preview DSLR : resize 960px + base64 hors asyncio pour fluidifier liveview 2026-04-09 01:36:20 +02:00
8e7fb61e90 Logo SikaPics : width forcee a 32vw (double taille naturelle 310px) 2026-04-09 01:27:16 +02:00
f4662807d4 Logo SikaPics 200% de la taille precedente (90vh->180vh) 2026-04-09 01:15:46 +02:00
1926e8fcf7 Logo SikaPics 200% (45vh->90vh) + QR code 150% (180->270px)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 22:46:26 +02:00
b871fc4c3b Preview push 30fps : thread de fond + diffusion WS sans polling
Avant : frontend poll WS toutes les 100ms -> gphoto2 bloquant -> lag
Apres : thread dedie capture en continu a 30fps, pusher asyncio
        diffuse a 20fps a tous les clients, frontend dit juste
        preview_start / preview_stop

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 22:21:29 +02:00
9a6ede02f6 Supprimer tout fallback webcam/simulation quand le DSLR est absent
- Preview gphoto2 echoue -> None direct, pas de webcam ni simulation
- Connexion sans DSLR -> mode erreur, camera non connectee
- Suppression _preview_webcam_fallback et _init_preview_fallback
- WS preview : appel direct camera.preview(), plus de logique speciale

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 22:17:20 +02:00
deca890b34 Admin: onglet Infos systeme (reseau, RAM, disque, temp, camera)
- Endpoint /api/systeme/info : IPs par interface, hostname, uptime,
  disque, RAM, temperature CPU, version Python, statut camera
- Onglet Infos dans le backoffice avec affichage visuel (barres, couleurs)
- Bouton Actualiser pour rafraichir les donnees en temps reel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 20:55:34 +02:00
cf41f66367 Gestion erreurs capture : messages clairs a chaque etape
- Verification etat camera AVANT le lancement (pas de DSLR = erreur immediate)
- Detection erreur camera PENDANT le compte a rebours (pas de capture aveugle)
- Capture : erreur reseau catchee, retry x3, message explicite si echec
- Limite de photos : message + detail au lieu de retour accueil silencieux
- Strip/collage : verification resultat, message si generation echoue
- traiterCapture : try/catch global, plus de retour accueil silencieux
- Overlay erreur : titre + detail lisible, 4s avant retour accueil

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 20:47:58 +02:00
f9fd4c4f22 Fix erreur DSLR: detection immediate + message clair a l'ecran
- Backend: detecte quand le preview DSLR echoue (fallback silencieux)
  et envoie camera_erreur au frontend (debounce 5s)
- Frontend: overlay d'erreur avec message si preview DSLR indisponible
- Frontend: si capture echoue, affiche le motif 3s avant retour accueil
  au lieu de retourner silencieusement

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 20:46:46 +02:00
ef6679dc97 Preview: overlay erreur si DSLR deconnecte pendant la capture
- Overlay semi-transparent sur le preview quand camera_erreur recue
- Timeout 3s : si aucune frame recue, affiche l'erreur automatiquement
- Disparait des que les frames reprennent (camera_ok ou nouvelle frame)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 20:42:05 +02:00
82 changed files with 12448 additions and 699 deletions

2
.gitignore vendored
View File

@@ -16,3 +16,5 @@ data/exports/*
!data/exports/.gitkeep
*.log
.DS_Store
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

@@ -1,8 +1,11 @@
import io
import logging
import subprocess
import threading
import time
from datetime import datetime
import numpy as np
from pathlib import Path
import cv2
@@ -63,7 +66,8 @@ class Camera:
self.mode = "simulation" # "gphoto2", "webcam", "simulation"
self.webcam = None
self.webcam_index = -1
self._preview_webcam_fallback = None
self.preview_dslr_ok = True # True si le dernier preview DSLR a reussi
self._gp_lock = threading.Lock()
def connecter(self, source=None) -> bool:
"""Connecte la camera. source: 'gphoto2', 'webcam:0', 'webcam:2', etc."""
@@ -75,27 +79,42 @@ class Camera:
if source == "gphoto2" or source is None:
if GPHOTO2_DISPONIBLE:
cam = None
try:
self.camera = gp.Camera()
self.camera.init()
cam = gp.Camera()
try:
cam.exit()
except Exception:
pass
cam.init()
self.camera = cam
time.sleep(1)
self._configurer_init()
self.connectee = True
self.mode = "gphoto2"
log.info("Camera DSLR connectee")
self.preview_dslr_ok = True
self._log_reglages()
log.info("Camera DSLR connectee (LiveView actif)")
return True
except gp.GPhoto2Error as e:
except Exception as 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
# Fallback webcam si pas de source specifique
if source is None:
webcams = lister_webcams()
if webcams:
return self._connecter_webcam(webcams[0]["index"])
# Fallback simulation
log.info("Mode simulation active")
self.connectee = True
self.mode = "simulation"
return True
# Pas de DSLR disponible : mode erreur, pas de fallback
log.warning("Aucun DSLR disponible, camera non connectee")
self.connectee = False
self.mode = "erreur"
return False
def _connecter_webcam(self, index: int) -> bool:
"""Connecte une webcam via OpenCV."""
@@ -112,24 +131,32 @@ class Camera:
log.error(f"Impossible d'ouvrir /dev/video{index}")
return False
def deconnecter(self):
"""Deconnecte l'appareil photo."""
if self._preview_webcam_fallback:
self._preview_webcam_fallback.release()
self._preview_webcam_fallback = None
if self.camera and GPHOTO2_DISPONIBLE:
def deconnecter(self, timeout: float = 3.0):
"""Deconnecte l'appareil photo avec timeout pour eviter le blocage PTP."""
cam_ref = self.camera
ctx_ref = self.contexte
if cam_ref and GPHOTO2_DISPONIBLE:
def _exit():
try:
self.camera.exit()
except gp.GPhoto2Error:
cam_ref.exit()
except Exception:
pass
t = threading.Thread(target=_exit, daemon=True)
t.start()
t.join(timeout=timeout)
if t.is_alive():
log.warning("camera.exit() bloque — force del camera/contexte pour liberer le FD USB")
if self.webcam:
self.webcam.release()
self.camera = None
self.contexte = None
del cam_ref
del ctx_ref
self.webcam = None
self.webcam_index = -1
self.connectee = False
self.mode = "simulation"
self.preview_dslr_ok = False
log.info("Camera deconnectee")
def capturer(self) -> Path | None:
@@ -154,23 +181,47 @@ class Camera:
for attempt in range(3):
try:
with self._gp_lock:
if not self.camera:
log.error("Camera None pendant capture")
self.preview_dslr_ok = False
return None
try:
cfg = self.camera.get_config()
vf = cfg.get_child_by_name("viewfinder")
vf.set_value(0)
self.camera.set_config(cfg)
time.sleep(0.8)
except Exception:
pass
log.info(f"Déclenchement capture DSLR (tentative {attempt+1}/3)")
t0 = time.time()
chemin_camera = self.camera.capture(gp.GP_CAPTURE_IMAGE)
t1 = time.time()
fichier_camera = gp.CameraFile()
self.camera.file_get(
chemin_camera.folder, chemin_camera.name, gp.GP_FILE_TYPE_NORMAL, fichier_camera
)
# Sauvegarder le fichier brut temporairement
t2 = time.time()
tmp_path = str(chemin_dest) + ".tmp"
fichier_camera.save(tmp_path)
# Redimensionner (max 4000px de large, JPEG 92%)
img = PILImage.open(tmp_path)
log.info(f"Capture: shutter={t1-t0:.1f}s download={t2-t1:.1f}s")
threading.Thread(target=self._post_capture_warmup, daemon=True).start()
from PIL import ImageOps as PILImageOps
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:
ratio = 4000 / img.width
img = img.resize((4000, int(img.height * ratio)), PILImage.LANCZOS)
img.save(str(chemin_dest), "JPEG", quality=92)
img = img.resize((4000, int(img.height * ratio)), PILImage.BILINEAR)
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)
log.info(f"Photo capturee (DSLR) : {chemin_dest} ({img.width}x{img.height})")
return chemin_dest
except gp.GPhoto2Error as e:
@@ -179,7 +230,29 @@ class Camera:
time.sleep(0.5)
continue
log.error(f"Erreur capture DSLR : {e}")
self.preview_dslr_ok = False
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):
"""Consomme les premieres frames LiveView juste apres la remontee du miroir
(souvent en erreur ~1-2s le temps que l'AF/capteur se stabilise), pendant
que le spinner de traitement est affiche — pour que le preview du prochain
countdown soit deja a jour au lieu de rester figé sur l'ancienne photo."""
for _ in range(tentatives):
try:
with self._gp_lock:
self.camera.capture_preview()
return
except Exception:
time.sleep(pause)
def _capture_webcam(self, chemin_dest: Path) -> Path | None:
if not self.webcam or not self.webcam.isOpened():
@@ -201,39 +274,124 @@ class Camera:
return self._preview_webcam()
elif self.mode == "gphoto2":
data = self._preview_gphoto2()
if data:
return data
# Fallback webcam pour le preview si le DSLR ne supporte pas le live view
if not self._preview_webcam_fallback:
self._init_preview_fallback()
if self._preview_webcam_fallback:
ret, frame = self._preview_webcam_fallback.read()
if ret:
_, buf = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 70])
return buf.tobytes()
return self._preview_simulation()
self.preview_dslr_ok = data is not None
return data # None si le DSLR ne repond pas, pas de fallback
else:
return self._preview_simulation()
return None # Pas de simulation ni webcam en dehors du mode dedie
def _init_preview_fallback(self):
"""Ouvre une webcam pour le preview quand le DSLR ne supporte pas le live view."""
webcams = lister_webcams()
for wc in webcams:
cap = cv2.VideoCapture(wc["index"])
if cap.isOpened():
cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720)
self._preview_webcam_fallback = cap
log.info(f"Preview fallback webcam : {wc['nom']}")
def configurer_flash(self, actif: bool):
"""Active ou désactive le flash intégré Canon.
Essaie plusieurs noms de widgets selon le modèle."""
if not self.camera or self.mode != "gphoto2":
return
log.warning("Aucune webcam disponible pour le preview fallback")
# strobofiring : "0"=inhibé(off), "2"=auto — Canon EOS 60D (widget radio, valeurs string)
# flashmode : "On"/"Off" — autres Canon
candidats = [
("strobofiring", "2", "0"), # (widget, valeur_on, valeur_off)
("flashmode", "On", "Off"),
]
try:
with self._gp_lock:
cfg = self.camera.get_config()
for nom, val_on, val_off in candidats:
try:
widget = cfg.get_child_by_name(nom)
widget.set_value(val_on if actif else val_off)
self.camera.set_config(cfg)
log.info(f"Flash intégré ({nom}) : {'On' if actif else 'Off'}")
return
except Exception:
pass
log.debug("Aucun widget flash trouvé sur ce modèle Canon")
except Exception as e:
log.debug(f"configurer_flash : {e}")
def _configurer_init(self):
"""Configure le Canon : veille OFF, ISO, drivemode, viewfinder (appels séparés)."""
reglages = [
("autopoweroff", 0),
("viewfinder", 1),
("iso", "800"),
("aperture", "4"),
("drivemode", "Single"),
]
for nom, valeur in reglages:
for tentative in range(3):
try:
cfg = self.camera.get_config()
w = cfg.get_child_by_name(nom)
w.set_value(valeur)
self.camera.set_config(cfg)
log.info(f"Canon init: {nom} = {valeur}")
break
except gp.GPhoto2Error as e:
if tentative < 2:
time.sleep(0.5)
else:
log.warning(f"Canon init {nom}: {e}")
except Exception:
break
def _log_reglages(self):
"""Log les réglages Canon actuels pour diagnostic."""
try:
cfg = self.camera.get_config()
vals = {}
for nom in ["autoexposuremode", "iso", "shutterspeed", "aperture", "meteringmode"]:
try:
w = cfg.get_child_by_name(nom)
vals[nom] = w.get_value()
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):
"""Active le LiveView (miroir levé) depuis le thread — thread-safe."""
if self.mode != "gphoto2" or not self.connectee:
return
try:
with self._gp_lock:
cfg = self.camera.get_config()
vf = cfg.get_child_by_name("viewfinder")
vf.set_value(1)
self.camera.set_config(cfg)
log.info("Viewfinder activé")
except Exception as 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:
try:
with self._gp_lock:
if not self.camera:
return None
fichier = self.camera.capture_preview()
donnees = fichier.get_data_and_size()
return bytes(donnees)
except gp.GPhoto2Error as e:
donnees = bytes(fichier.get_data_and_size())
img = cv2.imdecode(np.frombuffer(donnees, dtype=np.uint8), cv2.IMREAD_COLOR)
if img is not None:
h, w = img.shape[:2]
if w > 960:
img = cv2.resize(img, (960, int(h * 960 / w)), interpolation=cv2.INTER_LINEAR)
_, buf = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, 75])
return buf.tobytes()
return donnees
except Exception as e:
log.error(f"Erreur preview DSLR : {e}")
return None

View File

@@ -4,6 +4,7 @@ from pathlib import Path
from PIL import Image, ImageDraw
from backend.config import DOSSIER_EXPORTS, charger_config
from backend.effects import appliquer_cadre_impression
log = logging.getLogger("photobooth.collage")
@@ -28,28 +29,38 @@ def _crop_center(img: Image.Image, ratio_cible: float) -> Image.Image:
return img.crop((0, offset, img.width, offset + nouvelle_hauteur))
def creer_strip(photos: list[Path], marge: int = 10, couleur_fond: str = "#000000") -> Path:
"""Cree une bande portrait style pellicule (4 photos de gauche a droite).
def creer_strip(photos: list[Path], marge: int = 10, couleur_fond: str = "#000000",
cadre: str | None = None) -> Path:
"""Cree une bande portrait style pellicule (photos empilees de haut en bas).
Pellicule noire avec perforations, photos separees par des bandes noires.
Plus large : utilise toute la hauteur d'un 10x15 paysage divise en 2.
Pellicule noire avec perforations sur les cotes, photos separees par des
bandes noires. Taille fixe 600x1800px (5x15cm a 300dpi) : c'est le format
attendu par l'imprimante en mode "10x15-2up" (PageSize ...-div2), qui
duplique et decoupe automatiquement la bande en 2 exemplaires sur une
feuille 10x15. Doit rester strictement portrait (largeur < hauteur) et a
cette taille exacte pour eviter toute rotation/etirement a l'impression.
"""
nb = len(photos)
images = [Image.open(p) for p in photos]
# Dimensions de la bande : largeur d'un 10x15 paysage, hauteur = moitie
bande_largeur = LARGEUR_10x15_L
bande_hauteur = (HAUTEUR_10x15_L - marge) // 2
# Dimensions de la bande : format fixe attendu par printer.py (FORMATS["10x15-2up"])
bande_largeur = 600
bande_hauteur = 1800
# Zone perforations pellicule (haut et bas)
perf_hauteur = 34
zone_photos_hauteur = bande_hauteur - perf_hauteur * 2
# Zone perforations pellicule (gauche et droite). Cette zone noire est aussi
# la marge de securite pour la coupe physique du K60 : la feuille pleine
# (2 bandes cote a cote) est coupee au centre apres impression, et la coupe
# n'est jamais parfaitement precise. Une marge trop fine fait que la coupe
# tombe dans la photo d'un cote (pellicule absente d'un cote, visible de
# l'autre). Elargie a 55px (~4.6mm) pour absorber le jeu mecanique du cutter.
perf_largeur = 55
zone_photos_largeur = bande_largeur - perf_largeur * 2
# Taille de chaque photo dans la bande (separees par bandes noires)
sep = 12 # separateur noir entre photos
espace_photos = bande_largeur - marge * 2 - sep * (nb - 1)
largeur_cellule = espace_photos // nb
hauteur_cellule = zone_photos_hauteur
espace_photos = bande_hauteur - marge * 2 - sep * (nb - 1)
hauteur_cellule = espace_photos // nb
largeur_cellule = zone_photos_largeur
ratio_cellule = largeur_cellule / hauteur_cellule
# Recadrer et redimensionner
@@ -64,24 +75,30 @@ def creer_strip(photos: list[Path], marge: int = 10, couleur_fond: str = "#00000
draw = ImageDraw.Draw(strip)
# Bords pellicule noirs (deja le fond)
# Perforations pellicule (haut) - trous blancs dans le bord noir
for px in range(marge + 10, bande_largeur - marge, 44):
# Perforations pellicule (gauche) - trous gris dans le bord noir, centrees
# dans la marge (laisse du noir plein des 2 cotes pour la marge de coupe)
trou_largeur = 26
trou_x = (perf_largeur - trou_largeur) // 2
for py in range(marge + 10, bande_hauteur - marge, 44):
draw.rounded_rectangle(
[px, 8, px + 22, perf_hauteur - 8],
[trou_x, py, trou_x + trou_largeur, py + 22],
radius=5, fill=(60, 60, 60)
)
# Perforations pellicule (bas)
for px in range(marge + 10, bande_largeur - marge, 44):
# Perforations pellicule (droite)
for py in range(marge + 10, bande_hauteur - marge, 44):
draw.rounded_rectangle(
[px, bande_hauteur - perf_hauteur + 8, px + 22, bande_hauteur - 8],
[bande_largeur - perf_largeur + trou_x, py, bande_largeur - perf_largeur + trou_x + trou_largeur, py + 22],
radius=5, fill=(60, 60, 60)
)
# Coller les photos (separees par des bandes noires verticales)
x = marge
# Coller les photos (separees par des bandes noires horizontales)
y = marge
for img in images_redim:
strip.paste(img, (x, perf_hauteur))
x += largeur_cellule + sep
strip.paste(img, (perf_largeur, y))
y += hauteur_cellule + sep
if cadre:
strip = appliquer_cadre_impression(strip, "strip", cadre)
nom = f"strip_{photos[0].stem}.jpg"
chemin = DOSSIER_EXPORTS / nom
@@ -115,7 +132,7 @@ def creer_impression_strip(chemin_strip: Path, marge: int = 20,
def creer_collage(photos: list[Path], colonnes: int = 2, marge: int = 25,
couleur_fond: str = "#ffffff") -> Path:
couleur_fond: str = "#ffffff", cadre: str | None = None) -> Path:
"""Cree un collage en grille au format 10x15cm."""
nb = len(photos)
images = [Image.open(p) for p in photos]
@@ -141,6 +158,9 @@ def creer_collage(photos: list[Path], colonnes: int = 2, marge: int = 25,
y = marge + lig * (hauteur_cellule + marge)
collage.paste(img, (x, y))
if cadre:
collage = appliquer_cadre_impression(collage, "10x15", cadre)
nom = f"collage_{photos[0].stem}.jpg"
chemin = DOSSIER_EXPORTS / nom
collage.save(chemin, "JPEG", quality=95, dpi=(300, 300))

View File

@@ -11,6 +11,8 @@ DOSSIER_EXPORTS = RACINE / "data" / "exports"
DOSSIER_OVERLAYS = RACINE / "frontend" / "assets" / "overlays"
DOSSIER_FONDS = RACINE / "frontend" / "assets" / "backgrounds"
DOSSIER_ANIMATIONS = RACINE / "frontend" / "assets" / "animations"
DOSSIER_CADRES = RACINE / "frontend" / "assets" / "cadres"
FORMATS_CADRES = ["strip", "10x15", "15x20"]
def charger_config() -> dict:
@@ -51,3 +53,5 @@ def _merge_profond(base: dict, modifications: dict):
# Initialisation des dossiers au chargement du module
for dossier in [DOSSIER_PHOTOS, DOSSIER_EXPORTS, DOSSIER_OVERLAYS, DOSSIER_FONDS, DOSSIER_ANIMATIONS]:
dossier.mkdir(parents=True, exist_ok=True)
for fmt in FORMATS_CADRES:
(DOSSIER_CADRES / fmt).mkdir(parents=True, exist_ok=True)

View File

@@ -1,4 +1,5 @@
import ftplib
import json
import logging
import shutil
import threading
@@ -6,30 +7,20 @@ from pathlib import Path
from urllib.request import Request, urlopen
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")
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."""
config = charger_config()
dest = config.get("destinations", {})
# Verifier si on sauvegarde tout ou seulement les imprimees
if not dest.get("sauvegarder_tout", True) and not imprimee:
log.info(f"Photo non imprimee, pas de distribution : {chemin_photo.name}")
return
# Cle USB
if dest.get("cle_usb", False):
copier_usb(chemin_photo, dest.get("chemin_usb", "/media/usb"))
# FTP
if dest.get("ftp", False):
envoyer_ftp(chemin_photo, dest)
# Galerie live (booth)
# Galerie live (booth) — toujours envoyer, imprimee ou non
booth = config.get("booth", {})
if booth.get("actif", False):
threading.Thread(
@@ -38,14 +29,36 @@ def distribuer_photo(chemin_photo: Path, imprimee: bool = False):
daemon=True,
).start()
# Incrementer le compteur
sous_dossier = None
if not dest.get("sauvegarder_tout", True) and not imprimee:
sous_dossier = "non_imprimees"
# Cle USB
if dest.get("cle_usb", False):
copier_usb(chemin_photo, dest.get("chemin_usb", "/media/usb"), sous_dossier)
# FTP
if dest.get("ftp", False):
envoyer_ftp(chemin_photo, dest, sous_dossier)
# 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", {})
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})
maj_consommables(config, copies, format_papier)
def copier_usb(chemin_photo: Path, chemin_usb: str):
def copier_usb(chemin_photo: Path, chemin_usb: str, sous_dossier: str | None = None):
"""Copie une photo sur la cle USB."""
dossier_usb = Path(chemin_usb)
if not dossier_usb.exists():
@@ -53,24 +66,28 @@ def copier_usb(chemin_photo: Path, chemin_usb: str):
return False
dossier_dest = dossier_usb / "photobooth"
dossier_dest.mkdir(exist_ok=True)
if sous_dossier:
dossier_dest = dossier_dest / sous_dossier
dossier_dest.mkdir(parents=True, exist_ok=True)
try:
shutil.copy2(chemin_photo, dossier_dest / chemin_photo.name)
log.info(f"Photo copiee sur USB : {chemin_photo.name}")
log.info(f"Photo copiee sur USB : {chemin_photo.name} ({sous_dossier or 'root'})")
return True
except OSError as e:
log.error(f"Erreur copie USB : {e}")
return False
def envoyer_ftp(chemin_photo: Path, config_dest: dict):
def envoyer_ftp(chemin_photo: Path, config_dest: dict, sous_dossier: str | None = None):
"""Envoie une photo par FTP."""
host = config_dest.get("ftp_host", "")
port = config_dest.get("ftp_port", 21)
user = config_dest.get("ftp_user", "")
password = config_dest.get("ftp_password", "")
chemin_distant = config_dest.get("ftp_chemin", "/photobooth")
if sous_dossier:
chemin_distant = f"{chemin_distant}/{sous_dossier}"
if not host:
log.warning("FTP non configure")
@@ -95,48 +112,152 @@ def envoyer_ftp(chemin_photo: Path, config_dest: dict):
return False
def envoyer_booth(chemin_photo: Path, config_booth: dict):
"""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
def _upload_booth(url: str, api_key: str, event_id: str, chemin_photo: Path) -> bool:
boundary = "----BoothUpload"
filename = chemin_photo.name
with open(chemin_photo, "rb") as f:
file_data = f.read()
body = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="photo"; filename="{filename}"\r\n'
f"Content-Type: image/jpeg\r\n\r\n"
).encode() + file_data + f"\r\n--{boundary}--\r\n".encode()
req = Request(
f"{url}/api/{event_id}/upload",
data=body,
method="POST",
)
req = Request(f"{url}/admin/gallery/{event_id}/upload", data=body, method="POST")
req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}")
req.add_header("X-Api-Key", api_key)
with urlopen(req, timeout=10) as resp:
return resp.status == 200
def envoyer_booth(chemin_photo: Path, config_booth: dict):
"""Envoie une photo vers la galerie live booth. 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:
if resp.status == 200:
log.info(f"Photo envoyee au booth : {filename}")
return True
else:
log.error(f"Booth erreur HTTP {resp.status}")
return False
log.info(f"Booth notifie impression: {filename} x{copies}")
return
except (URLError, OSError) as e:
log.error(f"Erreur envoi booth : {e}")
return False
log.warning(f"Echec notification impression booth: {e}")
# --- 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:
@@ -144,50 +265,168 @@ def recuperer_booth_password() -> dict:
config = charger_config()
booth = config.get("booth", {})
url = booth.get("url", "").rstrip("/")
url_tunnel = booth.get("url_tunnel", "").rstrip("/")
api_key = booth.get("api_key", "")
event_id = booth.get("event_id", "default")
if not url:
return {"password": None}
event_id = config.get("evenement", {}).get("event_id") or booth.get("event_id", "default")
for tentative_url in [u for u in (url_tunnel, url) if u]:
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)
with urlopen(req, timeout=5) as resp:
import json
data = json.loads(resp.read())
return data
return json.loads(resp.read())
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}
def detecter_usb() -> list[str]:
"""Detecte les cles USB montees."""
chemins_possibles = [Path("/media"), Path("/mnt")]
"""Detecte les cles USB montees (cherche dans /media, /mnt et /media/<user>/)."""
usb_trouvees = []
for racine in chemins_possibles:
if racine.exists():
for sous_dossier in racine.iterdir():
if sous_dossier.is_mount() or (sous_dossier.is_dir() and any(sous_dossier.iterdir())):
usb_trouvees.append(str(sous_dossier))
candidats = []
for racine in [Path("/media"), Path("/mnt")]:
if not racine.exists():
continue
try:
for niveau1 in racine.iterdir():
if not niveau1.is_dir():
continue
if niveau1.is_mount():
candidats.append(niveau1)
else:
# /media/<user>/ → descendre encore (udiskie)
try:
for niveau2 in niveau1.iterdir():
if niveau2.is_dir() and niveau2.is_mount():
candidats.append(niveau2)
except PermissionError:
pass
except PermissionError:
pass
for chemin in candidats:
# Exclure les partitions système
try:
result = __import__("subprocess").run(
["findmnt", "-no", "FSTYPE", str(chemin)],
capture_output=True, text=True, timeout=3
)
fstype = result.stdout.strip()
if fstype in ("vfat", "exfat", "ntfs", "ext4", "ext3", "hfsplus"):
usb_trouvees.append(str(chemin))
except Exception:
usb_trouvees.append(str(chemin))
return usb_trouvees
def compteur_restant() -> dict:
"""Retourne l'etat du compteur."""
"""Retourne l'etat du compteur, basé sur les consommables restants."""
config = charger_config()
compteur = config.get("compteur", {})
limite = compteur.get("limite", 400)
conso = config.get("consommables", {})
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 {
"actif": compteur.get("actif", False),
"limite": limite,
"limite": limite_event,
"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():
"""Remet le compteur a zero."""
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

@@ -4,21 +4,15 @@ from pathlib import Path
from PIL import Image, ImageEnhance, ImageFilter, ImageOps
from backend.config import DOSSIER_OVERLAYS, DOSSIER_EXPORTS, DOSSIER_FONDS
from backend.config import DOSSIER_OVERLAYS, DOSSIER_EXPORTS, DOSSIER_FONDS, DOSSIER_CADRES
log = logging.getLogger("photobooth.effects")
# Filtres disponibles
FILTRES = {
"original": "Original",
"original": "Couleur",
"nb": "Noir & Blanc",
"sepia": "Sepia",
"vintage": "Vintage",
"contraste": "Contraste fort",
"lumineux": "Lumineux",
"chaud": "Tons chauds",
"froid": "Tons froids",
"flou_artistique": "Flou artistique",
}
@@ -127,6 +121,58 @@ def chroma_key(chemin_photo: Path, nom_fond: str | None = None,
return chemin_export
def _chercher_cadre(format_papier: str, nom_cadre: str, event_id: str | None = None) -> Path | None:
"""Cherche le cadre dans le format demandé, sinon fallback autres formats."""
candidats = []
if event_id:
from backend.evenements import DOSSIER_EVENEMENTS
base_event = DOSSIER_EVENEMENTS / event_id / "cadres"
candidats.append(base_event / format_papier / nom_cadre)
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:
log.warning(f"Cadre introuvable : {nom_cadre} pour {format_papier}")
return img
cadre = Image.open(chemin).convert("RGBA")
img_portrait = img.height > img.width
cadre_portrait = cadre.height > cadre.width
if img_portrait != cadre_portrait:
cadre = cadre.rotate(90, expand=True)
if cadre.size != img.size:
cadre = cadre.resize(img.size, Image.LANCZOS)
base = img.convert("RGBA")
composite = Image.alpha_composite(base, cadre)
return composite.convert("RGB")
def lister_cadres(format_papier: str) -> list[str]:
"""Liste les cadres disponibles pour un format. Inclut ceux d'autres formats en fallback."""
noms = set()
dossier = DOSSIER_CADRES / format_papier
if dossier.exists():
noms.update(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]:
"""Liste les overlays disponibles."""
if not DOSSIER_OVERLAYS.exists():

193
backend/evenements.py Normal file
View File

@@ -0,0 +1,193 @@
import json
import logging
import shutil
import uuid
from datetime import date, datetime
from pathlib import Path
from backend.config import RACINE, DOSSIER_CADRES, FORMATS_CADRES, charger_config, mettre_a_jour_config
log = logging.getLogger("photobooth.evenements")
DOSSIER_EVENEMENTS = RACINE / "data" / "evenements"
DOSSIER_EVENEMENTS.mkdir(parents=True, exist_ok=True)
def _chemin_event(event_id: str) -> Path:
return DOSSIER_EVENEMENTS / f"{event_id}.json"
def _chemin_cadres_event(event_id: str) -> Path:
d = DOSSIER_EVENEMENTS / event_id / "cadres"
d.mkdir(parents=True, exist_ok=True)
return d
def lister_evenements() -> list[dict]:
events = []
config = charger_config()
actif_id = config.get("evenement", {}).get("event_id")
for f in sorted(DOSSIER_EVENEMENTS.glob("*.json")):
try:
data = json.loads(f.read_text("utf-8"))
data["actif"] = (data.get("id") == actif_id)
events.append(data)
except (json.JSONDecodeError, KeyError):
continue
return events
def creer_evenement(nom: str, **kwargs) -> dict:
event_id = kwargs.get("event_id") or uuid.uuid4().hex[:8]
event = {
"id": event_id,
"nom": nom,
"theme": kwargs.get("theme", "base"),
"couleur_primaire": kwargs.get("couleur_primaire", "#e91e63"),
"couleur_secondaire": kwargs.get("couleur_secondaire", "#ffffff"),
"media_accueil": kwargs.get("media_accueil"),
"formats_actifs": kwargs.get("formats_actifs", ["10x15", "15x20", "strip"]),
"date_fin": kwargs.get("date_fin"),
"termine": False,
"cadres": {},
}
_chemin_event(event_id).write_text(json.dumps(event, ensure_ascii=False, indent=2), "utf-8")
log.info(f"Evenement cree : {nom} ({event_id})")
return event
def obtenir_evenement(event_id: str) -> dict | None:
chemin = _chemin_event(event_id)
if not chemin.exists():
return None
return json.loads(chemin.read_text("utf-8"))
def modifier_evenement(event_id: str, modifications: dict) -> dict | None:
event = obtenir_evenement(event_id)
if not event:
return None
for k, v in modifications.items():
if k != "id":
event[k] = v
_chemin_event(event_id).write_text(json.dumps(event, ensure_ascii=False, indent=2), "utf-8")
return event
def supprimer_evenement(event_id: str) -> bool:
chemin = _chemin_event(event_id)
if not chemin.exists():
return False
chemin.unlink()
dossier_cadres = DOSSIER_EVENEMENTS / event_id
if dossier_cadres.exists():
shutil.rmtree(dossier_cadres)
config = charger_config()
if config.get("evenement", {}).get("event_id") == event_id:
mettre_a_jour_config({"evenement": {"event_id": None}})
log.info(f"Evenement supprime : {event_id}")
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:
event = obtenir_evenement(event_id)
if not event:
return None
config = charger_config()
booth = config.get("booth", {})
if booth.get("actif"):
booth["event_id"] = event_id
mettre_a_jour_config({
"evenement": {
"event_id": event_id,
"nom": event["nom"],
"theme": event.get("theme", "base"),
"couleur_primaire": event.get("couleur_primaire", "#e91e63"),
"couleur_secondaire": event.get("couleur_secondaire", "#ffffff"),
"media_accueil": event.get("media_accueil"),
"formats_actifs": event.get("formats_actifs", ["10x15", "15x20", "strip"]),
},
"booth": booth,
})
log.info(f"Evenement active : {event['nom']} ({event_id})")
return event
def lister_cadres_event(event_id: str, format_papier: str) -> list[str]:
dossier = _chemin_cadres_event(event_id) / format_papier
if not dossier.exists():
return []
return sorted(f.name for f in dossier.iterdir() if f.suffix.lower() == ".png")
def config_cadre_event(event_id: str) -> dict:
"""Retourne la config cadres de l'evenement : {format: {mode, cadre}}
mode: 'disponible' (choix user), 'impose' (force), 'defaut' (pre-selectionne)
"""
event = obtenir_evenement(event_id)
if not event:
return {}
return event.get("cadres", {})
def set_cadre_event(event_id: str, format_papier: str, mode: str, cadre: str | None) -> bool:
"""mode: 'aucun', 'disponible', 'impose', 'defaut'"""
event = obtenir_evenement(event_id)
if not event:
return False
if "cadres" not in event:
event["cadres"] = {}
event["cadres"][format_papier] = {"mode": mode, "cadre": cadre}
_chemin_event(event_id).write_text(json.dumps(event, ensure_ascii=False, indent=2), "utf-8")
return True
def cadre_actif_pour_impression(format_papier: str) -> tuple[str | None, str]:
"""Retourne (nom_cadre, mode) en tenant compte de l'evenement actif.
mode: 'aucun', 'disponible', 'impose', 'defaut'
Strips exclus du cadre impose/defaut.
"""
if format_papier == "strip":
return None, "aucun"
config = charger_config()
event_id = config.get("evenement", {}).get("event_id")
if not event_id:
return None, "aucun"
event = obtenir_evenement(event_id)
if not event:
return None, "aucun"
cadres = event.get("cadres", {})
fmt_conf = cadres.get(format_papier, {})
return fmt_conf.get("cadre"), fmt_conf.get("mode", "aucun")

View File

@@ -1,15 +1,21 @@
import asyncio
import json
import logging
import smtplib
import threading
from email.mime.base import MIMEBase
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email import encoders
from pathlib import Path
from backend.config import charger_config
from backend.config import charger_config, RACINE
log = logging.getLogger("photobooth.mailer")
FICHIER_SPOOL = RACINE / "data" / "email_spool.json"
_spool_lock = threading.Lock()
def envoyer_photo(destinataire: str, chemin_photo: Path) -> bool:
"""Envoie une photo par email."""
@@ -58,7 +64,212 @@ def envoyer_photo(destinataire: str, chemin_photo: Path) -> bool:
serveur.login(smtp_user, smtp_password)
serveur.send_message(msg)
log.info(f"Email envoye a {destinataire}")
_sauvegarder_email_historique(destinataire)
return True
except (smtplib.SMTPException, OSError) as e:
log.error(f"Erreur envoi email : {e}")
return False
# --- Spool : file d'attente pour emails en echec ---
def ajouter_au_spool(destinataire: str, chemin_photo: str):
with _spool_lock:
spool = _charger_spool()
spool.append({"email": destinataire, "photo": chemin_photo})
_sauver_spool(spool)
log.info(f"Email pour {destinataire} mis en file d'attente ({len(spool)} en attente)")
def _charger_spool() -> list:
if not FICHIER_SPOOL.exists():
return []
try:
with open(FICHIER_SPOOL, "r", encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
return []
def _sauver_spool(spool: list):
try:
with open(FICHIER_SPOOL, "w", encoding="utf-8") as f:
json.dump(spool, f, ensure_ascii=False)
except OSError:
pass
def taille_spool() -> int:
with _spool_lock:
return len(_charger_spool())
EMAIL_RAPPORT = "contact@copydev.fr"
def traiter_spool() -> tuple[int, list[str]]:
"""Tente d'envoyer les emails en attente. Retourne (nb envoyes, liste destinataires)."""
with _spool_lock:
spool = _charger_spool()
if not spool:
return 0, []
envoyes = 0
destinataires = []
restants = []
for entry in spool:
chemin = Path(entry["photo"])
if not chemin.exists():
log.warning(f"Spool: photo introuvable {chemin}, email ignore")
continue
ok = envoyer_photo(entry["email"], chemin)
if ok:
envoyes += 1
destinataires.append(entry["email"])
else:
restants.append(entry)
break
if envoyes > 0 or len(restants) < len(spool):
idx = envoyes + len(restants)
restants.extend(spool[idx:])
with _spool_lock:
_sauver_spool(restants)
log.info(f"Spool: {envoyes} envoye(s), {len(restants)} en attente")
return envoyes, destinataires
def envoyer_rapport_spool(nb_envoyes: int, destinataires: list[str], nb_echoues: int):
"""Envoie un rapport a contact@copydev.fr apres vidage du spool."""
config = charger_config()
conf_email = config.get("email", {})
conf_event = config.get("evenement", {})
nom_event = conf_event.get("nom", "Photobooth")
smtp_host = conf_email.get("smtp_host", "")
smtp_port = conf_email.get("smtp_port", 587)
smtp_user = conf_email.get("smtp_user", "")
smtp_password = conf_email.get("smtp_password", "")
expediteur = conf_email.get("expediteur", smtp_user)
if not smtp_host or not smtp_user:
return
liste = "\n".join(f" - {d}" for d in destinataires)
corps = f"{nb_envoyes} photo(s) envoyée(s) par email.\n\n"
corps += f"Destinataires :\n{liste}\n"
if nb_echoues > 0:
corps += f"\n{nb_echoues} email(s) en échec (restent en file d'attente).\n"
msg = MIMEMultipart()
msg["From"] = expediteur
msg["To"] = EMAIL_RAPPORT
msg["Subject"] = f"[{nom_event}] {nb_envoyes} photo(s) envoyée(s)"
msg.attach(MIMEText(corps, "plain", "utf-8"))
try:
with smtplib.SMTP(smtp_host, smtp_port, timeout=10) as serveur:
serveur.starttls()
serveur.login(smtp_user, smtp_password)
serveur.send_message(msg)
log.info(f"Rapport spool envoye a {EMAIL_RAPPORT}")
except (smtplib.SMTPException, OSError) as e:
log.error(f"Erreur envoi rapport spool : {e}")
RETRY_INTERVAL = 300 # 5 minutes
WATCHDOG_INTERVAL = 60 # 1 minute
async def _flush_spools():
"""Vide les spools email + galerie."""
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:
echoues = total_mail - nb
await loop.run_in_executor(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"
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):
toutes = _charger_toutes_historiques()
event_id = _event_id_actif()
historique = toutes.setdefault(event_id, [])
email_lower = email.lower().strip()
if email_lower not in historique:
historique.append(email_lower)
try:
with open(FICHIER_EMAILS, "w", encoding="utf-8") as f:
json.dump(toutes, f, ensure_ascii=False)
except OSError:
pass
def charger_emails_historique() -> list:
return _charger_toutes_historiques().get(_event_id_actif(), [])
def effacer_emails_historique():
toutes = _charger_toutes_historiques()
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

@@ -1,81 +1,369 @@
import logging
import subprocess
import tempfile
import time
from pathlib import Path
from backend.config import charger_config
from backend.effects import appliquer_cadre_impression
log = logging.getLogger("photobooth.printer")
# Essayer d'importer cups
# PageSize CUPS + dimensions exactes à 300 DPI pour la Mitsubishi CP-K60DW-S
# Le resize vers ces dimensions exactes évite toute bordure blanche
FORMATS = {
"10x15": {"cups": "w288h432", "px": (1200, 1800)},
"15x15": {"cups": "w432h432", "px": (1800, 1800)},
"15x20": {"cups": "w432h576", "px": (1800, 2400)},
# "-div2" declare en CUPS la MEME taille de page que le format plein (le PPD le confirme :
# PaperDimension identique pour w288h432 et w288h432-div2). Le K60 ne duplique pas une
# image fournie a taille reduite : il imprime la feuille entiere telle qu'envoyee puis la
# coupe en 2 apres impression. "px" = taille d'une bande seule, "px_page" = feuille entiere
# a envoyer a lp (bande dupliquee cote a cote par _dupliquer_page).
"10x15-2up": {"cups": "w288h432-div2", "px": (600, 1800), "px_page": (1200, 1800)},
"15x20-2up": {"cups": "w432h576-div2", "px": (900, 2400), "px_page": (1800, 2400)},
}
ERREUR_FICHIER = "fichier_introuvable"
ERREUR_IMPRIMANTE = "imprimante_indisponible"
ERREUR_BOURRAGE = "bourrage_papier"
ERREUR_PAPIER = "plus_de_papier"
ERREUR_IMPRESSION = "erreur_impression"
def _run(cmd: list[str], timeout: int = 10) -> tuple[int, str, str]:
try:
import cups
CUPS_DISPONIBLE = True
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
return r.returncode, r.stdout, r.stderr
except subprocess.TimeoutExpired:
return -1, "", "timeout"
except FileNotFoundError:
return -1, "", f"commande introuvable : {cmd[0]}"
def preparer_apercu(chemin: Path, largeur: int, hauteur: int,
orientation: str = "paysage") -> Path:
"""
Génère un aperçu JPEG recadré aux bonnes dimensions (pour le frontend).
orientation : 'portrait' pivote le DSLR paysage de 90° avant recadrage.
"""
from PIL import Image, ImageOps
img = Image.open(chemin).convert("RGB")
img = ImageOps.exif_transpose(img)
if orientation == "paysage" and hauteur > largeur:
largeur, hauteur = hauteur, largeur
if orientation == "portrait" and img.width > img.height:
img = img.rotate(90, expand=True)
img = _crop_to(img, largeur, hauteur)
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
img.save(tmp.name, "JPEG", quality=85)
return Path(tmp.name)
def _crop_to(img, largeur: int, hauteur: int):
"""Cover crop centre vers les dimensions exactes."""
from PIL import Image
iw, ih = img.size
ratio_cible = largeur / hauteur
ratio_src = iw / ih
if ratio_src > ratio_cible:
new_w = int(ih * ratio_cible)
left = (iw - new_w) // 2
img = img.crop((left, 0, left + new_w, ih))
elif ratio_src < ratio_cible:
new_h = int(iw / ratio_cible)
top = (ih - new_h) // 2
img = img.crop((0, top, iw, top + new_h))
return img.resize((largeur, hauteur), Image.LANCZOS)
def _preparer_image(chemin: Path, largeur: int, hauteur: int,
format_papier: str | None = None, cadre: str | None = None,
orientation: str = "paysage", event_id: str | None = None,
skip_rotate: bool = False) -> Path:
"""
Pivote si nécessaire, recadre aux dimensions exactes (cover crop),
applique le cadre, compense les marges de coupe, sauvegarde en temp.
"""
try:
from PIL import Image, ImageOps
except ImportError:
CUPS_DISPONIBLE = False
log.warning("pycups non installe, impression indisponible")
log.warning("PIL absent — image envoyée telle quelle, risque de bordures")
return chemin
img = Image.open(chemin).convert("RGB")
img = ImageOps.exif_transpose(img)
if not skip_rotate:
if orientation == "portrait" and img.width > img.height:
img = img.rotate(90, expand=True)
elif orientation == "paysage" and img.height > img.width:
img = img.rotate(-90, expand=True)
img = _crop_to(img, largeur, hauteur)
if cadre and format_papier:
img = appliquer_cadre_impression(img, format_papier, cadre, event_id=event_id)
img = _appliquer_offsets_coupe(img)
config_imp = charger_config().get("impression", {})
if config_imp.get("rotation_180", False) and not skip_rotate:
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)
img.save(tmp.name, "JPEG", quality=95, dpi=(300, 300))
log.debug(f"Image préparée : {orientation} {largeur}x{hauteur}px → {tmp.name}")
return Path(tmp.name)
def _appliquer_offsets_coupe(img):
"""Décale le contenu de l'image pour compenser les marges de coupe asymétriques.
Config impression.offsets_coupe_mm : {haut, bas, gauche, droite} en mm."""
from PIL import Image
config = charger_config()
offsets = config.get("impression", {}).get("offsets_coupe_mm", {})
if not offsets:
return img
DPI = 300
mm_px = DPI / 25.4
haut = int(offsets.get("haut", 0) * mm_px)
bas = int(offsets.get("bas", 0) * mm_px)
gauche = int(offsets.get("gauche", 0) * mm_px)
droite = int(offsets.get("droite", 0) * mm_px)
if haut == 0 and bas == 0 and gauche == 0 and droite == 0:
return img
w, h = img.size
nouvelle = Image.new("RGB", (w, h), (255, 255, 255))
# Décale : offset positif = l'imprimante coupe trop de ce côté, on décale le contenu vers l'opposé
# crop la source pour que le contenu tienne malgré le décalage
src_x = max(0, droite - gauche)
src_y = max(0, bas - haut)
dst_x = max(0, gauche - droite)
dst_y = max(0, haut - bas)
crop_w = w - abs(gauche - droite)
crop_h = h - abs(haut - bas)
region = img.crop((src_x, src_y, src_x + crop_w, src_y + crop_h))
nouvelle.paste(region, (dst_x, dst_y))
return nouvelle
def _dupliquer_page(chemin: Path, taille_page: tuple[int, int]) -> Path:
"""Colle la bande 2 fois cote a cote pour remplir la feuille entiere.
Necessaire pour les formats "-div2" : le K60 coupe la feuille imprimee en 2
apres impression, il ne duplique pas une image fournie a taille reduite.
"""
from PIL import Image
img = Image.open(chemin)
page = Image.new("RGB", taille_page, (255, 255, 255))
page.paste(img, (0, 0))
page.paste(img, (img.width, 0))
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
page.save(tmp.name, "JPEG", quality=95, dpi=(300, 300))
return Path(tmp.name)
def _statut(nom: str) -> str:
"""Retourne 'idle', 'printing', 'stopped' ou 'inconnue'."""
code, out, _ = _run(["lpstat", "-p", nom])
if code != 0:
return "inconnue"
out = out.lower()
if "disabled" in out or "stopped" in out:
return "stopped"
if "printing" in out or "processing" in out:
return "printing"
if "idle" in out or "inactive" in out:
return "idle"
return "inconnue"
def _reactiver(nom: str) -> bool:
"""Réactive l'imprimante via cupsenable (sudoers sans mot de passe requis)."""
code, _, err = _run(["sudo", "cupsenable", nom], timeout=5)
if code == 0:
log.info(f"Imprimante {nom} réactivée")
return True
log.error(f"Impossible de réactiver {nom} : {err}")
return False
def _detecter_erreur_physique(nom: str) -> str | None:
"""Interroge l'état physique via le backend dyesub."""
try:
r = subprocess.run(
["/usr/lib/cups/backend/gutenprint53+usb", "-s"],
capture_output=True, text=True, timeout=10,
)
combined = (r.stdout + r.stderr).lower()
except Exception:
return None
if "jam" in combined:
return ERREUR_BOURRAGE
if "media out" in combined or "no media" in combined or "remaining: 0/" in combined:
return ERREUR_PAPIER
return None
def lister_imprimantes() -> list[dict]:
"""Liste les imprimantes disponibles via CUPS."""
if not CUPS_DISPONIBLE:
"""Liste les imprimantes CUPS disponibles."""
code, out, _ = _run(["lpstat", "-p"])
if code != 0 or not out.strip():
return [{"nom": "[Simulation] Imprimante virtuelle", "statut": "prete"}]
try:
conn = cups.Connection()
imprimantes = conn.getPrinters()
return [
{
imprimantes = []
for ligne in out.splitlines():
if not ligne.startswith("printer "):
continue
parties = ligne.split()
if len(parties) < 2:
continue
nom = parties[1]
arretee = "disabled" in ligne.lower() or "stopped" in ligne.lower()
imprimantes.append({
"nom": nom,
"statut": "prete" if info.get("printer-state") == 3 else "occupee",
"info": info.get("printer-info", ""),
}
for nom, info in imprimantes.items()
]
except cups.IPPError as e:
log.error(f"Erreur CUPS : {e}")
return []
"statut": "arretee" if arretee else "prete",
})
return imprimantes or [{"nom": "[Simulation] Imprimante virtuelle", "statut": "prete"}]
def imprimer(chemin_photo: Path, imprimante: str | None = None, copies: int | None = None) -> bool:
"""Imprime une photo sur l'imprimante configuree."""
def imprimer(
chemin_photo: Path,
imprimante: str | None = None,
copies: int | None = None,
format_papier: str | None = None,
cadre_override: str | None = None,
) -> dict:
"""
Imprime une photo sans bordure blanche.
L'image est recadrée et redimensionnée aux dimensions exactes (300 DPI)
avant envoi à CUPS.
Retourne {"succes": True} ou {"succes": False, "erreur": CODE, "message": str}
"""
config = charger_config()
conf_imp = config.get("impression", {})
if imprimante is None:
imprimante = conf_imp.get("imprimante")
imprimante = conf_imp.get("imprimante") or "Mitsubishi"
if copies is None:
copies = conf_imp.get("copies", 1)
if format_papier is None:
format_papier = conf_imp.get("format", "15x20")
fmt = FORMATS.get(format_papier, FORMATS["15x20"])
page_size = fmt["cups"]
largeur, hauteur = fmt["px"]
fmt_base = format_papier.replace("-2up", "")
is_strip = "-2up" in format_papier
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:
largeur, hauteur = hauteur, largeur
event_id = config.get("evenement", {}).get("event_id")
if event_id and cadre_override is None and not is_strip:
from backend.evenements import cadre_actif_pour_impression
ev_cadre, ev_mode = cadre_actif_pour_impression(fmt_base)
if ev_mode == "impose" and ev_cadre:
cadre = ev_cadre
elif ev_mode == "defaut" and ev_cadre and not cadre:
cadre = ev_cadre
if not chemin_photo.exists():
log.error(f"Fichier introuvable : {chemin_photo}")
return False
return {"succes": False, "erreur": ERREUR_FICHIER, "message": "Fichier photo introuvable"}
if not CUPS_DISPONIBLE:
log.info(f"[Simulation] Impression de {chemin_photo} x{copies}")
return True
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 []
px_page = fmt.get("px_page")
if px_page:
chemin_page = _dupliquer_page(chemin_print, px_page)
tmp_a_supprimer.append(chemin_page)
chemin_print = chemin_page
if _statut(imprimante) == "stopped":
log.warning(f"Imprimante {imprimante} arrêtée — réactivation")
if not _reactiver(imprimante):
return {
"succes": False,
"erreur": ERREUR_IMPRIMANTE,
"message": "Imprimante arrêtée, réactivation impossible (vérifiez sudo cupsenable)",
}
time.sleep(1)
rembobinage = conf_imp.get("rembobinage_ruban", False)
try:
conn = cups.Connection()
jobs = []
for copie in range(1, copies + 1):
job_ok = False
for tentative in range(1, 4):
cmd = [
"lp",
"-d", imprimante,
"-o", f"PageSize={page_size}",
"-o", "StpiShrinkOutput=Crop",
]
if rembobinage:
cmd.extend(["-o", "StpiDecklist=true"])
cmd.append(str(chemin_print))
if imprimante is None:
imprimante = conn.getDefault()
if imprimante is None:
imprimantes = conn.getPrinters()
if imprimantes:
imprimante = list(imprimantes.keys())[0]
else:
log.error("Aucune imprimante trouvee")
return False
code, out, err = _run(cmd, timeout=30)
options = {
"copies": str(copies),
"media": conf_imp.get("format", "10x15"),
"fit-to-page": "true",
if code == 0:
job = out.strip()
log.info(f"Impression copie {copie}/{copies} lancée : {job} ({format_papier} {largeur}x{hauteur}px)")
jobs.append(job)
job_ok = True
break
log.warning(f"Impression copie {copie}/{copies} échouée (tentative {tentative}/3) : {err.strip()}")
if tentative < 3:
if _statut(imprimante) == "stopped":
erreur = _detecter_erreur_physique(imprimante)
if erreur == ERREUR_BOURRAGE:
return {
"succes": False,
"erreur": ERREUR_BOURRAGE,
"message": "Bourrage papier — retirez le papier coincé puis réessayez",
}
if erreur == ERREUR_PAPIER:
return {
"succes": False,
"erreur": ERREUR_PAPIER,
"message": "Plus de papier — rechargez le rouleau",
}
_reactiver(imprimante)
time.sleep(2)
if not job_ok:
return {
"succes": False,
"erreur": ERREUR_IMPRESSION,
"message": f"Impression copie {copie}/{copies} échouée après 3 tentatives",
}
job_id = conn.printFile(imprimante, str(chemin_photo), "Photobooth", options)
log.info(f"Impression lancee : job #{job_id} sur {imprimante}")
return True
if copie < copies:
time.sleep(3)
except cups.IPPError as e:
log.error(f"Erreur impression : {e}")
return False
log.info(f"Impression terminée : {copies} copie(s), jobs={jobs}")
return {"succes": True, "job": ", ".join(jobs)}
finally:
for tmp in tmp_a_supprimer:
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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -195,5 +195,187 @@
color: rgba(36, 4, 57, 0.5);
}
/* Import Google Fonts pour SikaPics */
@import url('https://fonts.googleapis.com/css2?family=Dancing+Script:wght@400;600;700&family=Montserrat:wght@300;400;500;600;700&display=swap');
/* --- Theme Viking Celte (bois sculpte, bronze, entrelacs) --- */
[data-theme="viking"] {
--primaire: #b08d57;
--secondaire: #f0e6d2;
--fond: #1a0f08;
--fond-carte: #2b1810;
--texte: #f0e6d2;
--texte-secondaire: #a08a6e;
--danger: #8b2500;
--succes: #5a7a3a;
--rayon: 8px;
--ombre: 0 8px 32px rgba(0,0,0,0.5);
--accent-gradient: linear-gradient(135deg, #b08d57, #d4a843);
--pellicule-fond: #1a0f08;
--pellicule-perf: #4a3520;
}
[data-theme="viking"] body,
[data-theme="viking"] {
font-family: 'MedievalSharp', 'Cinzel', 'Georgia', serif;
}
[data-theme="viking"] .ecran {
background: var(--fond);
}
[data-theme="viking"] #ecran-accueil {
background:
radial-gradient(ellipse at center, rgba(176,141,87,0.08) 0%, transparent 70%),
linear-gradient(180deg, #1a0f08 0%, #2b1810 50%, #1a0f08 100%);
}
[data-theme="viking"] #nom-evenement {
font-family: 'MedievalSharp', 'Cinzel', serif;
font-size: 4rem;
color: var(--primaire);
font-weight: 700;
text-shadow: 0 2px 8px rgba(0,0,0,0.6), 0 0 30px rgba(176,141,87,0.2);
letter-spacing: 3px;
}
[data-theme="viking"] .accueil-instruction {
color: var(--texte-secondaire);
font-weight: 400;
letter-spacing: 3px;
text-transform: uppercase;
font-size: 0.95rem;
}
[data-theme="viking"] .cercle-pulse {
border-color: var(--primaire);
background: rgba(176,141,87,0.1);
box-shadow: 0 0 40px rgba(176,141,87,0.15);
}
[data-theme="viking"] .btn-mode {
background: linear-gradient(145deg, #2b1810, #3a2418);
border: 2px solid #5a3d28;
color: var(--texte);
box-shadow: 0 2px 12px rgba(0,0,0,0.3), inset 0 1px 0 rgba(176,141,87,0.1);
}
[data-theme="viking"] .btn-mode:active {
border-color: var(--primaire);
box-shadow: 0 2px 16px rgba(176,141,87,0.3);
}
[data-theme="viking"] .btn-action {
background: var(--accent-gradient);
border: none;
color: #1a0f08;
font-weight: 700;
letter-spacing: 2px;
text-shadow: 0 1px 0 rgba(255,255,255,0.2);
}
[data-theme="viking"] .btn-secondaire {
background: transparent;
border: 2px solid var(--primaire);
color: var(--primaire);
}
[data-theme="viking"] .btn-retour {
color: var(--texte-secondaire);
border-color: #5a3d28;
}
[data-theme="viking"] .btn-partage {
background: linear-gradient(145deg, #2b1810, #3a2418);
border: 2px solid #5a3d28;
color: var(--texte);
}
[data-theme="viking"] .btn-filtre,
[data-theme="viking"] .btn-overlay {
background: linear-gradient(145deg, #2b1810, #3a2418);
border: 2px solid #5a3d28;
color: var(--texte);
}
[data-theme="viking"] .btn-filtre.actif,
[data-theme="viking"] .btn-overlay.actif {
border-color: var(--primaire);
background: rgba(176,141,87,0.15);
}
[data-theme="viking"] .popup-box {
background: linear-gradient(145deg, #2b1810, #1a0f08);
color: var(--texte);
border: 1px solid #5a3d28;
}
[data-theme="viking"] .popup-box input {
background: #1a0f08;
border-color: #5a3d28;
color: var(--texte);
}
[data-theme="viking"] .pave-touche {
background: rgba(176,141,87,0.1);
color: var(--texte);
border: 1px solid #5a3d28;
}
[data-theme="viking"] .pave-touche:active {
background: rgba(176,141,87,0.25);
}
[data-theme="viking"] .pave-effacer {
background: rgba(139,37,0,0.2);
}
[data-theme="viking"] .pave-valider {
background: rgba(90,122,58,0.2);
}
[data-theme="viking"] .admin-contenu {
background: #2b1810;
}
[data-theme="viking"] .admin-onglets {
background: #1a0f08;
}
[data-theme="viking"] .onglet {
color: var(--texte-secondaire);
}
[data-theme="viking"] .onglet.actif {
color: var(--primaire);
background: #2b1810;
}
[data-theme="viking"] .compte-a-rebours {
color: var(--primaire);
text-shadow: 0 4px 20px rgba(176,141,87,0.4), 0 0 60px rgba(176,141,87,0.15);
}
[data-theme="viking"] .flash-blanc {
background: rgba(240,230,210,0.85);
}
[data-theme="viking"] .booth-info {
background: rgba(26,15,8,0.8);
border: 1px solid #5a3d28;
color: var(--texte);
}
[data-theme="viking"] .compteur-accueil {
background: rgba(26,15,8,0.6);
border: 1px solid rgba(176,141,87,0.3);
color: var(--texte);
}
[data-theme="viking"] .btn-admin {
color: rgba(176,141,87,0.15);
}
[data-theme="viking"] .btn-admin:active {
color: rgba(176,141,87,0.4);
}
/* Import Google Fonts pour SikaPics + Viking */
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;700&family=Dancing+Script:wght@400;600;700&family=MedievalSharp&family=Montserrat:wght@300;400;500;600;700&display=swap');

View File

@@ -5,17 +5,34 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Photobooth</title>
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="/css/themes.css">
<meta name="google" content="notranslate">
<meta http-equiv="Content-Language" content="fr">
<link rel="stylesheet" href="/css/style.css?v=19">
<link rel="stylesheet" href="/css/themes.css?v=2">
</head>
<body>
<div id="app">
<!-- Overlay erreur camera -->
<div id="camera-erreur" class="camera-erreur cache">
<div class="camera-erreur-icon">&#128247;</div>
<p>Probleme de communication avec l'appareil photo</p>
<span class="camera-erreur-sub">Reconnexion en cours...</span>
<!-- Bandeau erreur imprimante (tous écrans) -->
<div id="printer-erreur" class="printer-erreur cache">
<span class="printer-erreur-icon">&#128424;</span>
<span id="printer-erreur-msg">Problème imprimante</span>
<button onclick="document.getElementById('printer-erreur').classList.add('cache')">&#10005;</button>
</div>
<!-- Overlay erreur equipement (camera/imprimante) -->
<div id="erreur-equipement" class="erreur-overlay cache">
<div class="erreur-icon" id="erreur-equip-icon">&#128247;</div>
<div class="erreur-titre" id="erreur-equip-titre">Preparation en cours</div>
<div class="erreur-msg" id="erreur-equip-msg">L'appareil photo se prepare, un instant...</div>
<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>
<!-- Ecran d'accueil -->
@@ -34,13 +51,46 @@
<div id="compteur-accueil" class="compteur-accueil cache">
<span id="compteur-restant">400</span> / <span id="compteur-limite">400</span>
</div>
<!-- Booth : code + QR en bas a gauche -->
<!-- Booth : QR discret en bas a gauche -->
<div id="booth-info" class="booth-info cache">
<img id="booth-qr" class="booth-qr" src="">
<span id="booth-code" class="booth-code"></span>
</div>
<!-- Icone admin coin bas-droit -->
<div id="btn-admin" class="btn-admin" onclick="ouvrirAdmin()">&#9881;</div>
<!-- Icone photostation coin bas-gauche (visible seulement si installée) -->
<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>
<!-- Choix du mode -->
@@ -54,20 +104,45 @@
<button class="btn-mode" data-mode="multi" id="btn-multi">
<div class="mode-icone mode-icone-large">&#127902;</div>
<span>Pellicule</span>
<span class="mode-detail" id="pellicule-detail"></span>
</button>
</div>
<button class="btn-retour" onclick="allerA('accueil')">Retour</button>
</section>
<!-- Choix de cadre avant capture -->
<section id="ecran-cadre-choix" class="ecran">
<h2>Choisissez un cadre</h2>
<div id="grille-cadres-choix" class="grille-cadres-choix">
<div class="cadre-choix-item cadre-choix-aucun" onclick="lancerSansCadre()">
<div class="cadre-choix-vide"></div>
<span>Sans cadre</span>
</div>
</div>
<button class="btn-retour" onclick="allerA('mode')">Retour</button>
</section>
<!-- Compte a rebours + Capture -->
<section id="ecran-capture" class="ecran">
<div id="preview-live" class="preview-live">
<img id="img-preview" src="" alt="Preview">
<img id="cadre-overlay-live" class="cadre-overlay cache" src="" alt="">
<div id="cadre-guide-recadrage" class="cadre-guide-recadrage cache"></div>
<div id="preview-erreur-camera" class="preview-erreur-camera cache">
<div class="preview-erreur-icon">&#128247;</div>
<p>Appareil photo deconnecte</p>
<span>Reconnexion en cours...</span>
</div>
</div>
<div id="compte-a-rebours" class="compte-a-rebours cache">
<span id="chiffre-car">3</span>
</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 class="capture-spinner"></div>
<span>Capture en cours...</span>
</div>
<div class="capture-info">
<span id="capture-compteur"></span>
</div>
@@ -77,6 +152,7 @@
<section id="ecran-preview" class="ecran">
<div class="preview-photo-container">
<img id="photo-resultat" src="" alt="Photo">
<img id="cadre-overlay-preview" class="cadre-overlay cache" src="" alt="">
</div>
<div id="barre-filtres" class="barre-filtres">
</div>
@@ -95,6 +171,7 @@
<h2>Partagez votre photo !</h2>
<div class="photo-miniature">
<img id="photo-partage" src="" alt="Photo">
<img id="cadre-overlay-partage" class="cadre-overlay cache" src="" alt="">
</div>
<div class="grille-partage">
<button class="btn-partage" id="btn-imprimer" onclick="ouvrirImpression()">
@@ -110,7 +187,11 @@
<span>QR Code</span>
</button>
</div>
<!-- Choix exemplaires impression -->
<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 class="exemplaires-choix">
<button class="btn-exemplaire" onclick="changerExemplaires(-1)">-</button>
@@ -118,11 +199,13 @@
<button class="btn-exemplaire" onclick="changerExemplaires(1)">+</button>
</div>
<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-secondaire" onclick="fermerImpression()">Annuler</button>
</div>
<div id="form-email" class="form-email cache">
<input type="email" id="input-email" placeholder="votre@email.com" autocomplete="off" readonly>
<div id="email-suggestions" class="email-suggestions"></div>
<div class="clavier-virtuel" id="clavier-email">
<div class="clavier-ligne">
<button onclick="clavierTape('1')">1</button>
@@ -176,8 +259,15 @@
<button class="clavier-wide" onclick="clavierEffacer()">&#9003;</button>
<button class="clavier-wide" onclick="clavierTape('@gmail.com')">@gmail</button>
<button class="clavier-wide" onclick="clavierTape('@hotmail.com')">@hotmail</button>
<button class="clavier-wide" onclick="clavierTape('.com')">.com</button>
<button class="clavier-wide" onclick="clavierTape('.fr')">.fr</button>
<button class="clavier-wide" onclick="clavierTape('@outlook.com')">@outlook</button>
<button class="clavier-wide" onclick="clavierTape('@yahoo.fr')">@yahoo</button>
</div>
<div class="clavier-ligne clavier-speciales">
<button class="clavier-wide" onclick="clavierTape('@icloud.com')">@icloud</button>
<button class="clavier-wide" onclick="clavierTape('@orange.fr')">@orange</button>
<button class="clavier-wide" onclick="clavierTape('@free.fr')">@free</button>
<button class="clavier-wide" onclick="clavierTape('@sfr.fr')">@sfr</button>
<button class="clavier-wide" onclick="clavierTape('@laposte.net')">@laposte</button>
</div>
</div>
<div style="display:flex;gap:1rem;margin-top:0.5rem">
@@ -190,8 +280,7 @@
</div>
<div id="statut-partage" class="statut-partage cache"></div>
<div class="partage-bas">
<button class="btn-action" onclick="allerA('accueil')">Terminer</button>
<button class="btn-secondaire" onclick="recommencer()">Nouvelle photo</button>
<button class="btn-action" id="btn-terminer" onclick="recommencer()">Recommencer</button>
</div>
</section>
@@ -248,6 +337,10 @@
<div class="wizard-card-icon" style="background:#fbe7e8;color:#c31f48">&#10047;</div>
<span>SikaPics</span>
</button>
<button class="wizard-card" data-val="viking" onclick="wizardChoisir(this, 'theme')">
<div class="wizard-card-icon" style="background:#2b1810;color:#b08d57">&#9876;</div>
<span>Viking</span>
</button>
</div>
<button class="wizard-btn-next" onclick="wizardSuivant()">Suivant</button>
</div>
@@ -319,13 +412,27 @@
<button class="btn-fermer" onclick="allerA('accueil')">&times;</button>
</div>
<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" data-onglet="compteur-admin">Compteur</button>
<button class="onglet" data-onglet="destinations-admin">Destinations</button>
<button class="onglet" data-onglet="personnalisation">Personnalisation</button>
<button class="onglet" data-onglet="evenement">Evenement</button>
<button class="onglet" data-onglet="consommables-admin" onclick="chargerConsommables()">Consommables</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>
</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) -->
@@ -344,6 +451,9 @@
<label>Compte a rebours (secondes)</label>
<input type="number" id="admin-car" min="1" max="10" value="3">
</div>
<div class="champ">
<label class="toggle"><input type="checkbox" id="tog-flash-integre" checked><span class="toggle-slider"></span> Flash intégré Canon (décocher = flash Off)</label>
</div>
<h3>Imprimante</h3>
<div class="champ">
@@ -351,11 +461,37 @@
<select id="admin-imprimante"><option value="">Detection...</option></select>
<button class="btn-secondaire btn-petit" onclick="rafraichirImprimantes()">Rafraichir</button>
</div>
<div class="champ">
<label>Format papier</label>
<select id="admin-format-papier">
<option value="15x20">15&times;20 cm</option>
<option value="10x15">10&times;15 cm</option>
<option value="10x15-2up">10&times;15 cm &mdash; 2 strips</option>
</select>
</div>
<div class="champ">
<label>Orientation photo</label>
<div class="radio-group">
<label><input type="radio" name="admin-orientation" value="portrait"> Portrait</label>
<label><input type="radio" name="admin-orientation" value="paysage"> Paysage</label>
</div>
</div>
<div class="champ">
<label>Nombre max d'exemplaires</label>
<input type="number" id="admin-copies-max" min="1" max="20" value="5">
</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>
<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-secondaire" onclick="resetUsb()">&#8635; Réinit. USB</button>
<button class="btn-secondaire" onclick="couperPapier()">&#9988; Couper papier</button>
</div>
<div id="imprimante-statut-detail" class="champ" style="display:none">
<div id="imprimante-erreur-msg" style="background:rgba(244,67,54,0.1);border-radius:8px;padding:0.8rem;color:#f44336;font-size:0.85rem;margin-top:0.5rem"></div>
</div>
</div>
<!-- Panneau Compteur -->
@@ -371,9 +507,10 @@
<span id="admin-compteur-limite-display" class="compteur-nombre petit">400</span>
</div>
<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 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">
</div>
<div class="champ-row">
@@ -382,6 +519,54 @@
</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 -->
<div class="admin-panneau" id="panneau-destinations-admin">
<h3>Ou sauvegarder les photos ?</h3>
@@ -409,10 +594,18 @@
<div class="champ"><label>Chemin distant</label><input type="text" id="admin-ftp-chemin" value="/photobooth"></div>
</div>
<div class="toggle-groupe">
<label class="toggle"><input type="checkbox" id="tog-dest-web"><span class="toggle-slider"></span> Site internet</label>
<label class="toggle"><input type="checkbox" id="tog-dest-galerie"><span class="toggle-slider"></span> Galerie en ligne</label>
</div>
<div id="dest-web-details" class="sous-config cache">
<div class="champ"><label>URL d'upload</label><input type="text" id="admin-web-url" placeholder="https://..."></div>
<div id="dest-galerie-details" class="sous-config cache">
<div class="champ"><label>URL du serveur</label><input type="text" id="admin-booth-url" placeholder="https://booth.copydev.fr" value="https://booth.copydev.fr"></div>
<div class="champ"><label>Cle API</label><input type="text" id="admin-booth-apikey" placeholder="booth-photobooth-2026" value="booth-photobooth-2026"></div>
<div class="champ"><label>ID evenement</label><input type="text" id="admin-booth-eventid" placeholder="auto" value=""></div>
<div class="champ">
<label>Code invites : <span id="admin-booth-password" style="font-size:1.5rem;font-weight:bold;letter-spacing:4px">--</span></label>
</div>
<button class="btn-action" onclick="sauvegarderBooth()">Sauvegarder</button>
<button class="btn-secondaire" onclick="regenererBoothPassword()">Nouveau code</button>
<button class="btn-secondaire" onclick="imprimerFlyerQR()">&#128424; Imprimer flyer QR</button>
</div>
<div class="toggle-groupe">
<label class="toggle"><input type="checkbox" id="tog-dest-email-auto"><span class="toggle-slider"></span> Email automatique</label>
@@ -428,8 +621,39 @@
<!-- Panneau Personnalisation (Cadres + Fonds + Animations) -->
<div class="admin-panneau" id="panneau-personnalisation">
<h3>Cadres / Overlays</h3>
<p class="aide">Cochez les cadres a proposer aux utilisateurs.</p>
<h3>Cadres d'impression</h3>
<p class="aide">Selectionnez un cadre PNG par format. Le cadre est applique sur chaque impression. Laissez vide pour imprimer sans cadre.</p>
<div class="cadres-formats">
<div class="cadres-format-bloc" id="cadres-bloc-strip">
<h4>Strip (5&times;15 cm)</h4>
<div class="cadres-liste" id="cadres-liste-strip"></div>
<div class="champ cadres-upload">
<input type="file" id="input-cadre-strip" accept=".png" class="input-fichier">
<button class="btn-secondaire btn-petit" onclick="uploaderCadreFormat('strip')">Importer</button>
</div>
</div>
<div class="cadres-format-bloc" id="cadres-bloc-10x15">
<h4>10&times;15 cm</h4>
<div class="cadres-liste" id="cadres-liste-10x15"></div>
<div class="champ cadres-upload">
<input type="file" id="input-cadre-10x15" accept=".png" class="input-fichier">
<button class="btn-secondaire btn-petit" onclick="uploaderCadreFormat('10x15')">Importer</button>
</div>
</div>
<div class="cadres-format-bloc" id="cadres-bloc-15x20">
<h4>15&times;20 cm</h4>
<div class="cadres-liste" id="cadres-liste-15x20"></div>
<div class="champ cadres-upload">
<input type="file" id="input-cadre-15x20" accept=".png" class="input-fichier">
<button class="btn-secondaire btn-petit" onclick="uploaderCadreFormat('15x20')">Importer</button>
</div>
</div>
</div>
<h3>Cadres / Overlays photo</h3>
<p class="aide">Cochez les cadres a proposer aux utilisateurs en live.</p>
<div id="liste-cadres" class="liste-cadres">
<p class="texte-secondaire">Aucun cadre importe</p>
</div>
@@ -471,11 +695,21 @@
<!-- Panneau Evenement -->
<div class="admin-panneau" id="panneau-evenement">
<h3>Evenements</h3>
<div class="champ-row" style="margin-bottom:1rem">
<input type="text" id="input-nouveau-event" placeholder="Nom du nouvel evenement" style="flex:1">
<button class="btn-action btn-petit" onclick="creerEvenementAdmin()">Creer</button>
</div>
<div id="liste-evenements"></div>
<h3 id="titre-event-detail" class="cache">Configuration evenement</h3>
<div id="detail-evenement" class="cache">
<div class="champ">
<label>Theme visuel</label>
<select id="admin-theme" onchange="changerTheme(this.value)">
<option value="base">Base (Sombre moderne)</option>
<option value="sikapics">SikaPics (Rose elegant)</option>
<option value="viking">Viking Celte (Bois & Bronze)</option>
</select>
</div>
<div class="champ">
@@ -501,7 +735,33 @@
<label>Couleur secondaire</label>
<input type="color" id="admin-couleur-secondaire" value="#ffffff">
</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-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">
<label>Galerie en ligne</label>
<a id="url-galerie-event" href="#" target="_blank" class="btn-secondaire btn-petit" style="text-decoration:none;display:inline-block">Ouvrir la galerie</a>
</div>
<h3>Formats d'impression</h3>
<p class="aide">Formats papier disponibles pour cet evenement.</p>
<div class="toggle-groupe" id="event-formats-toggles">
<label class="toggle"><input type="checkbox" id="tog-event-fmt-10x15"><span class="toggle-slider"></span> 10&times;15 cm</label>
<label class="toggle"><input type="checkbox" id="tog-event-fmt-15x20"><span class="toggle-slider"></span> 15&times;20 cm</label>
<label class="toggle"><input type="checkbox" id="tog-event-fmt-strip"><span class="toggle-slider"></span> Strip / Pellicule</label>
</div>
<h3>Cadre evenement</h3>
<p class="aide">Cadre specifique a cet evenement. Applique sur tous les formats sauf strips.</p>
<div id="cadres-event-formats"></div>
</div>
</div>
<!-- Panneau Fonctions (toggles + email + booth + galerie + systeme) -->
@@ -531,20 +791,6 @@
<div class="champ"><label>Expediteur</label><input type="text" id="admin-expediteur" placeholder="photobooth@event.com"></div>
<button class="btn-action" onclick="sauvegarderEmail()">Sauvegarder email</button>
<h3>Galerie Live</h3>
<p class="aide">Photos envoyees en temps reel. QR code + code sur l'ecran d'accueil.</p>
<div class="champ">
<label class="toggle"><input type="checkbox" id="admin-booth-actif" onchange="sauvegarderBooth()"><span class="toggle-slider"></span> Galerie live active</label>
</div>
<div class="champ"><label>URL du serveur</label><input type="text" id="admin-booth-url" placeholder="https://booth.copydev.fr" value="https://booth.copydev.fr"></div>
<div class="champ"><label>Cle API</label><input type="text" id="admin-booth-apikey" placeholder="booth-photobooth-2026" value="booth-photobooth-2026"></div>
<div class="champ"><label>ID evenement</label><input type="text" id="admin-booth-eventid" placeholder="default" value="default"></div>
<div class="champ">
<label>Code invites : <span id="admin-booth-password" style="font-size:1.5rem;font-weight:bold;letter-spacing:4px">--</span></label>
</div>
<button class="btn-action" onclick="sauvegarderBooth()">Sauvegarder booth</button>
<button class="btn-secondaire" onclick="regenererBoothPassword()">Nouveau code</button>
<h3>Galerie locale</h3>
<div class="champ">
<label>Photos prises : <span id="admin-nb-photos">0</span> | Exports : <span id="admin-nb-exports">0</span></label>
@@ -562,9 +808,332 @@
<button class="btn-danger" onclick="confirmerRedemarrage()">Redemarrer le systeme</button>
<button class="btn-danger" onclick="confirmerExtinction()">Eteindre le systeme</button>
</div>
<!-- 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="infos-systeme-header">
<h3>Informations systeme</h3>
<button class="btn-action btn-petit" onclick="chargerInfosSysteme()">&#8635; Actualiser</button>
</div>
<div id="infos-systeme-contenu" class="infos-systeme-contenu">
<p class="infos-chargement">Chargement...</p>
</div>
<div class="infos-systeme-header" style="margin-top:1.5rem">
<h3>Diagnostic camera</h3>
<button class="btn-action btn-petit" onclick="chargerDiagCamera()">&#8635; Tester</button>
</div>
<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...
</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>
</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 -->
<div id="popup-cadre-impression" class="popup-overlay cache">
<div class="popup-box popup-box-cadre">
<h3>Cadre d'impression</h3>
<div id="grille-cadres-impression" class="grille-cadres-choix grille-cadres-popup"></div>
<div class="popup-actions">
<button class="btn-action" onclick="confirmerCadreImpression()">Confirmer</button>
<button class="btn-secondaire" onclick="annulerCadreImpression()">Annuler</button>
</div>
</div>
</div>
<!-- Popup PIN photostation -->
<div id="popup-pin-photostation" class="popup-overlay cache">
<div class="popup-box popup-box-pave">
<h3>Station d'impression</h3>
<p class="aide">Entrez le code PIN</p>
<div id="pin-photostation-affichage" class="mdp-affichage" style="font-size:2rem;letter-spacing:0.5rem;margin:1rem 0">&#9675;&#9675;&#9675;&#9675;</div>
<div id="pin-photostation-erreur" class="mdp-erreur cache">Code incorrect</div>
<div class="pave-numerique">
<button class="pave-touche" onclick="pinPhotostationTouche('1')">1</button>
<button class="pave-touche" onclick="pinPhotostationTouche('2')">2</button>
<button class="pave-touche" onclick="pinPhotostationTouche('3')">3</button>
<button class="pave-touche" onclick="pinPhotostationTouche('4')">4</button>
<button class="pave-touche" onclick="pinPhotostationTouche('5')">5</button>
<button class="pave-touche" onclick="pinPhotostationTouche('6')">6</button>
<button class="pave-touche" onclick="pinPhotostationTouche('7')">7</button>
<button class="pave-touche" onclick="pinPhotostationTouche('8')">8</button>
<button class="pave-touche" onclick="pinPhotostationTouche('9')">9</button>
<button class="pave-touche" onclick="pinPhotostationEffacer()">&#8592;</button>
<button class="pave-touche" onclick="pinPhotostationTouche('0')">0</button>
<button class="pave-touche" onclick="fermerPopupPhotostation()">&#10005;</button>
</div>
</div>
</div>
<!-- Popup mot de passe admin -->
<div id="popup-mdp" class="popup-overlay cache">
<div class="popup-box popup-box-pave">
@@ -591,12 +1160,29 @@
</div>
<script src="/js/websocket.js"></script>
<script src="/js/app.js"></script>
<script src="/js/camera.js"></script>
<script src="/js/effects.js"></script>
<script src="/js/gallery.js"></script>
<script src="/js/share.js"></script>
<script src="/js/admin.js"></script>
<!-- Ecran de pause (deconnexion backend) -->
<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">
<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/gallery.js?v=5"></script>
<script src="/js/share.js?v=12"></script>
<script src="/js/admin.js?v=17"></script>
</body>
</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 photoFinale = null; // Photo finale (avec effets)
let ecranActuel = 'accueil';
let _veilleTimer = null;
let _enVeille = false;
let _spotsAllumes = false;
const _shutterSound = new Audio('/sounds/shutter.wav');
_shutterSound.volume = 0.8;
// --- Initialisation ---
@@ -15,6 +20,7 @@ async function init() {
setupModes();
setupOnglets();
chargerBoothInfo();
verifierPhotostationDisponible();
}
function appliquerConfig() {
@@ -47,8 +53,10 @@ function appliquerConfig() {
const h1 = document.getElementById('nom-evenement');
if (h1) h1.textContent = event.nom || 'Photobooth';
// Visibilite des modes
toggleVisible('btn-multi', fonc.multi_shot);
// Visibilite des modes (restreint par formats evenement)
const fmtsEvent = event.formats_actifs;
const stripDispo = !fmtsEvent || fmtsEvent.includes('strip');
toggleVisible('btn-multi', fonc.multi_shot && stripDispo);
toggleVisible('btn-imprimer', fonc.impression);
toggleVisible('btn-email', fonc.email);
const boothActif = config.booth?.actif;
@@ -56,6 +64,9 @@ function appliquerConfig() {
// Media d'accueil (video/gif en boucle)
appliquerMediaAccueil(event.media_accueil);
// Precharger image surprise
if (typeof prechargerSurprise === 'function') prechargerSurprise();
}
function appliquerMediaAccueil(media) {
@@ -101,24 +112,44 @@ function allerA(ecran) {
ecranActuel = ecran;
}
// Masquer le popup QR a chaque changement d'ecran
fermerQR();
// Actions au changement d'ecran
if (ecran === 'accueil') {
photosSession = [];
photoFinale = null;
arreterPreview();
majCompteurAccueil();
} else if (ecran === 'capture') {
eteindreSpots();
lancerTimerVeille();
} else {
arreterTimerVeille();
reveillerEclairage();
}
if (ecran === 'capture') {
lancerCapture();
} else if (ecran === 'partage') {
majBoutonImprimerCompteur();
} else if (ecran === 'galerie') {
chargerGalerie();
} else if (ecran === 'admin') {
chargerAdmin();
demarrerTimeoutAdmin();
} else {
arreterTimeoutAdmin();
}
}
function demarrerTimeoutAdmin() {}
function arreterTimeoutAdmin() {}
function recommencer() {
photosSession = [];
photoFinale = null;
photoImpression = null;
formatImpression = null;
nbExemplaires = 1;
allerA('mode');
}
@@ -130,6 +161,8 @@ function setupEcranAccueil() {
// Toucher pour commencer (ignorer le bouton admin et le popup)
accueil.addEventListener('click', (e) => {
if (e.target.closest('.btn-admin')) return;
if (e.target.closest('.btn-photostation')) return;
if (_enVeille) reveillerEclairage();
allerA('mode');
});
}
@@ -137,6 +170,8 @@ function setupEcranAccueil() {
// --- Admin : icone + mot de passe ---
const MDP_ADMIN = '1234';
const PIN_PHOTOSTATION = '1234';
let _pinPhotostationSaisi = '';
function ouvrirAdmin() {
const popup = document.getElementById('popup-mdp');
@@ -161,6 +196,59 @@ function paveEffacer() {
input.value = input.value.slice(0, -1);
}
// --- Photostation ---
async function verifierPhotostationDisponible() {
const data = await apiGet('/api/photostation/disponible').catch(() => null);
if (data?.disponible) {
document.getElementById('btn-photostation').classList.remove('cache');
}
}
function ouvrirPhotostation() {
_pinPhotostationSaisi = '';
_majAffichagePin();
document.getElementById('pin-photostation-erreur').classList.add('cache');
document.getElementById('popup-pin-photostation').classList.remove('cache');
}
function fermerPopupPhotostation() {
document.getElementById('popup-pin-photostation').classList.add('cache');
_pinPhotostationSaisi = '';
}
function pinPhotostationTouche(c) {
if (_pinPhotostationSaisi.length >= 4) return;
_pinPhotostationSaisi += c;
_majAffichagePin();
if (_pinPhotostationSaisi.length === 4) {
setTimeout(_validerPinPhotostation, 150);
}
}
function pinPhotostationEffacer() {
_pinPhotostationSaisi = _pinPhotostationSaisi.slice(0, -1);
_majAffichagePin();
}
function _majAffichagePin() {
const n = _pinPhotostationSaisi.length;
const el = document.getElementById('pin-photostation-affichage');
el.textContent = '●'.repeat(n) + '○'.repeat(4 - n);
}
async function _validerPinPhotostation() {
if (_pinPhotostationSaisi !== PIN_PHOTOSTATION) {
document.getElementById('pin-photostation-erreur').classList.remove('cache');
_pinPhotostationSaisi = '';
_majAffichagePin();
return;
}
fermerPopupPhotostation();
afficherStatut('Lancement de la station d\'impression...', 'succes');
await apiPost('/api/photostation/lancer', {});
}
function validerMdpAdmin() {
const input = document.getElementById('input-mdp-admin');
if (input.value === MDP_ADMIN) {
@@ -186,21 +274,78 @@ document.addEventListener('keydown', (e) => {
// --- Modes ---
let cadreChoisi = null; // cadre sélectionné par l'utilisateur avant capture
function setupModes() {
document.querySelectorAll('#ecran-mode .btn-mode').forEach(btn => {
btn.addEventListener('click', () => {
modeActuel = btn.dataset.mode;
cadreChoisi = null;
allerA('capture');
});
});
majDetailPellicule();
}
// --- Onglets admin ---
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() {
const cfg = await apiGet('/api/config');
const fmt = (cfg.impression?.format || '15x20').replace('-2up', '');
const data = await apiGet(`/api/cadres-impression/${fmt}`).catch(() => null);
if (!data || data.disponibles.length === 0) return false;
const grille = document.getElementById('grille-cadres-choix');
// Conserver "Sans cadre" en premier, supprimer les anciens cadres
const aucun = grille.querySelector('.cadre-choix-aucun');
grille.innerHTML = '';
if (aucun) grille.appendChild(aucun);
for (const nom of data.disponibles) {
const div = document.createElement('div');
div.className = 'cadre-choix-item';
div.innerHTML = `<div class="cadre-thumb-wrap"><img src="/assets/cadres/${fmt}/${nom}" alt="${nom}"></div><span>${nom.replace('.png','')}</span>`;
div.onclick = () => {
cadreChoisi = nom;
allerA('capture');
};
grille.appendChild(div);
}
return true;
}
function lancerSansCadre() {
cadreChoisi = null;
allerA('capture');
}
// --- 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() {
document.querySelectorAll('.onglet').forEach(onglet => {
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'));
onglet.classList.add('actif');
const panneau = document.getElementById('panneau-' + onglet.dataset.onglet);
@@ -243,10 +388,8 @@ async function chargerBoothInfo() {
try {
const info = await apiGet('/api/booth/info');
if (info.password) {
document.getElementById('booth-code').textContent = info.password;
// QR code vers le site booth
const eventId = booth.event_id || 'default';
const qrUrl = `${booth.url}/${eventId}`;
const eventId = (config.evenement || {}).event_id || booth.event_id || 'default';
const qrUrl = `${booth.url}/g/${eventId}?p=${info.password}`;
document.getElementById('booth-qr').src = `/api/qr?url=${encodeURIComponent(qrUrl)}`;
el.classList.remove('cache');
}
@@ -255,6 +398,10 @@ async function chargerBoothInfo() {
}
}
function fermerQR() {
document.getElementById('popup-qr').classList.add('cache');
}
function afficherStatut(message, type = 'succes') {
const el = document.getElementById('statut-partage');
el.textContent = message;
@@ -267,17 +414,164 @@ function afficherStatut(message, type = 'succes') {
wsOnMessage('config_maj', (msg) => {
config = msg.config;
appliquerConfig();
if (ecranActuel === 'accueil') lancerTimerVeille();
});
// Erreur camera
wsOnMessage('camera_erreur', (msg) => {
document.getElementById('camera-erreur').classList.remove('cache');
// === Erreur equipement : overlay intelligent avec escalade ===
let _erreurCameraTimer = null;
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;
_erreurCameraCount++;
_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);
_erreurCameraTimer = setTimeout(() => { location.reload(); }, 180000);
});
wsOnMessage('shutter', () => {
_shutterSound.currentTime = 0;
_shutterSound.play().catch(() => {});
});
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; }
});
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) {
const el = document.getElementById('printer-erreur');
document.getElementById('printer-erreur-msg').textContent = msg;
el.classList.remove('cache');
}
async function _pollStatutImprimante() {
try {
const r = await apiGet('/api/imprimante/statut-detail');
const el = document.getElementById('printer-erreur');
if (r.statut && !r.statut.includes('stopped') && !r.statut.includes('disabled')) {
el.classList.add('cache');
if (_erreurEquipType === 'imprimante') {
document.getElementById('erreur-equipement').classList.add('cache');
}
}
} catch(e) {}
}
setInterval(_pollStatutImprimante, 30000);
// === WIZARD ===
let wizardStep = 1;
@@ -394,5 +688,126 @@ async function wizardTerminer() {
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
document.addEventListener('DOMContentLoaded', init);
document.addEventListener('DOMContentLoaded', () => {
init().then(() => {
if (window.location.pathname === '/admin') {
allerA('admin-choix');
}
});
});

View File

@@ -1,90 +1,370 @@
/* Module capture photo - Preview live, compte a rebours, declenchement */
let previewActif = false;
let previewTimeoutId = null;
let previewInterval = null;
const PREVIEW_TIMEOUT_MS = 3000;
let captureAbortee = false;
let captureEnCours = false; // true pendant tout le flux lancerCapture()
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 ---
function afficherErreurCapture(titre, detail = '') {
arreterPreview();
document.getElementById('capture-en-cours')?.classList.add('cache');
const el = document.getElementById('preview-erreur-camera');
if (!el) { allerA('accueil'); return; }
el.querySelector('p').textContent = titre;
el.querySelector('span').textContent = detail || 'Retour dans 4 secondes...';
el.classList.remove('cache');
setTimeout(() => {
el.classList.add('cache');
allerA('accueil');
}, 4000);
}
function afficherErreurPreview(visible) {
const el = document.getElementById('preview-erreur-camera');
if (!el) return;
if (visible) {
el.querySelector('p').textContent = 'Un instant...';
el.querySelector('span').textContent = 'L\'appareil photo se reconnecte';
el.classList.remove('cache');
} else {
el.classList.add('cache');
}
}
function resetPreviewTimeout() {
if (previewTimeoutId) clearTimeout(previewTimeoutId);
previewTimeoutId = setTimeout(() => {
if (previewActif) afficherErreurPreview(true);
}, PREVIEW_TIMEOUT_MS);
}
function lancerPreview() {
if (previewActif) return;
previewActif = true;
afficherErreurPreview(false);
document.getElementById('img-preview')?.classList.add('preview-chargement');
wsEnvoyer({ type: 'preview_start' });
resetPreviewTimeout();
}
previewInterval = setInterval(() => {
if (previewActif) wsEnvoyer({ type: 'preview' });
}, 100);
function demarrerEclairageLive() {
if (!previewActif) {
previewActif = true;
wsEnvoyer({ type: 'preview_start' });
resetPreviewTimeout();
}
}
function arreterPreview() {
if (!previewActif) return;
previewActif = false;
wsEnvoyer({ type: 'preview_stop' });
if (previewInterval) {
clearInterval(previewInterval);
previewInterval = null;
}
if (previewTimeoutId) {
clearTimeout(previewTimeoutId);
previewTimeoutId = null;
}
afficherErreurPreview(false);
document.getElementById('cadre-guide-recadrage')?.classList.add('cache');
}
wsOnMessage('preview', (msg) => {
if (!previewActif) return;
const img = document.getElementById('img-preview');
if (img) img.src = msg.image;
if (img) {
img.onload = () => {
img.classList.remove('preview-chargement');
positionnerGuideRecadrage();
};
img.src = msg.image;
}
const adminPrev = document.getElementById('eclairage-preview');
if (adminPrev && document.getElementById('panneau-eclairage')?.classList.contains('actif')) {
adminPrev.src = msg.image;
}
afficherErreurPreview(false);
resetPreviewTimeout();
});
// --- Cadre de visee : montre en live la zone qui sera gardee apres recadrage ---
// (la pellicule/le collage recadrent chaque photo au centre selon un ratio different du plein cadre camera)
function getRatioCelluleAttendu() {
if (modeActuel !== 'multi') return null;
const nb = getNombrePhotos();
const mode = config.multi_shot?.mode || 'strip';
if (mode === 'strip') {
// Reproduit creer_strip() dans backend/collage.py (bande portrait 600x1800, photos empilees)
const bandeLargeur = 600, bandeHauteur = 1800, marge = 10, sep = 12, perfLargeur = 55;
const zonePhotosLargeur = bandeLargeur - perfLargeur * 2;
const espacePhotos = bandeHauteur - marge * 2 - sep * (nb - 1);
const hauteurCellule = Math.floor(espacePhotos / nb);
return zonePhotosLargeur / hauteurCellule;
} else {
// Reproduit creer_collage() dans backend/collage.py
const colonnes = 2;
const largeurPage = 1200, hauteurPage = 1800, margeC = 25;
const lignes = Math.ceil(nb / colonnes);
const largeurCellule = Math.floor((largeurPage - margeC * (colonnes + 1)) / colonnes);
const hauteurCellule = Math.floor((hauteurPage - margeC * (lignes + 1)) / lignes);
return largeurCellule / hauteurCellule;
}
}
function positionnerGuideRecadrage() {
const guide = document.getElementById('cadre-guide-recadrage');
const img = document.getElementById('img-preview');
const container = document.getElementById('preview-live');
if (!guide || !img || !container) return;
const ratioCible = getRatioCelluleAttendu();
if (!ratioCible || !img.naturalWidth || !img.naturalHeight) {
guide.classList.add('cache');
return;
}
const cw = container.clientWidth, ch = container.clientHeight;
const ratioImg = img.naturalWidth / img.naturalHeight;
const ratioContainer = cw / ch;
// Boite affichee de l'image dans le conteneur (object-fit: contain)
let imgW, imgH, imgTop, imgLeft;
if (ratioImg > ratioContainer) {
imgW = cw; imgH = cw / ratioImg;
imgLeft = 0; imgTop = (ch - imgH) / 2;
} else {
imgH = ch; imgW = ch * ratioImg;
imgTop = 0; imgLeft = (cw - imgW) / 2;
}
// Boite du recadrage final, centree dans l'image affichee (meme logique que _crop_center cote backend)
let guideW, guideH;
if (ratioImg > ratioCible) {
guideH = imgH;
guideW = imgH * ratioCible;
} else {
guideW = imgW;
guideH = imgW / ratioCible;
}
guide.style.left = `${imgLeft + (imgW - guideW) / 2}px`;
guide.style.top = `${imgTop + (imgH - guideH) / 2}px`;
guide.style.width = `${guideW}px`;
guide.style.height = `${guideH}px`;
guide.classList.remove('cache');
}
window.addEventListener('resize', positionnerGuideRecadrage);
// Erreur camera pendant preview idle : retour accueil
// Si on est en cours de capture (compte à rebours ou attente API), on ignore :
// l'API /api/capturer retournera elle-même une erreur si le DSLR est vraiment mort.
wsOnMessage('camera_erreur', () => {
arreterPreview();
if (!captureEnCours) {
captureAbortee = true;
if (ecranActuel === 'capture') {
afficherErreurCapture('Un instant...', 'L\'appareil photo se prepare');
}
}
});
wsOnMessage('camera_ok', () => {
captureAbortee = 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 ---
async function lancerCapture() {
captureEnCours = true;
captureAbortee = false;
photosSession = [];
photoImpression = null;
formatImpression = null;
// Vider les aperçus precedents
document.getElementById('photo-resultat').src = '';
document.getElementById('photo-partage').src = '';
await chargerCadreImpose();
majCompteurAccueil();
// Verifier l'etat de la camera — attendre si pas prete ou reveil en cours
try {
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') {
captureEnCours = false;
afficherErreurCapture('Appareil photo indisponible', 'Touchez l\'ecran et reessayez');
return;
}
} catch (e) {
captureEnCours = false;
afficherErreurCapture('Preparation en cours', 'Touchez l\'ecran et reessayez');
return;
}
const nbPhotos = getNombrePhotos();
const compteurEl = document.getElementById('capture-compteur');
for (let i = 0; i < nbPhotos; i++) {
// Lancer la preview pour que l'utilisateur se voit
lancerPreview();
await pause(500);
if (nbPhotos > 1) {
compteurEl.textContent = `Photo ${i + 1} / ${nbPhotos}`;
} else {
compteurEl.textContent = '';
}
// Compte a rebours par dessus la preview
await compteARebours();
lancerPreview(); // Miroir live pendant le compte à rebours
wsEnvoyer({type: 'prepare_capture'});
const avecPreMessages = (i === 0);
await compteARebours(avecPreMessages);
// Arreter la preview et attendre que le DSLR soit libre
// 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();
await pause(300);
if (aSurprise) { cacherSurprise(); prechargerSurprise(); }
afficherFlash();
let resultat = await capturePromise;
// Tenter la capture avec retry
let resultat = null;
for (let r = 0; r < 3; r++) {
resultat = await apiPost('/api/capturer');
if (!resultat.erreur) break;
if (resultat.erreur === 'Limite de photos atteinte') {
allerA('accueil');
if (!resultat && !resultat?.erreur) {
flash.classList.add('cache');
document.getElementById('capture-en-cours').classList.add('cache');
captureEnCours = false;
afficherErreurCapture('Oups !', 'La photo n\'a pas pu etre prise, reessayez');
return;
}
await pause(500);
}
if (resultat.erreur) {
allerA('accueil');
if (resultat?.erreur) {
flash.classList.add('cache');
document.getElementById('capture-en-cours').classList.add('cache');
captureEnCours = false;
const msg = resultat.erreur;
afficherErreurCapture('Oups !', 'La photo n\'a pas pu etre prise, reessayez');
return;
}
flash.classList.add('cache');
document.getElementById('capture-en-cours').classList.add('cache');
if (resultat.nom) {
photosSession.push(resultat.nom);
}
}
captureEnCours = false;
await traiterCapture();
}
@@ -108,7 +388,7 @@ function choisirAnimationAleatoire() {
return pool[Math.floor(Math.random() * pool.length)];
}
async function compteARebours() {
async function compteARebours(avecPreMessages = true) {
const conteneur = document.getElementById('compte-a-rebours');
const chiffre = document.getElementById('chiffre-car');
const duree = config.camera?.compte_a_rebours || 3;
@@ -124,6 +404,17 @@ async function compteARebours() {
// Animation CSS classique
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--) {
if (anim.id === 'emoji') {
chiffre.textContent = EMOJI_CAR[i] || i;
@@ -190,35 +481,51 @@ function afficherFlash() {
setTimeout(() => flash.classList.add('cache'), 400);
}
let photoImpression = null; // Nom du fichier d'impression (2 bandes sur 10x15)
let photoImpression = null; // Fichier à imprimer (strip ou photo finale)
let formatImpression = null; // Format CUPS à utiliser (ex: "10x15-2up")
async function traiterCapture() {
if (photosSession.length === 0) {
allerA('accueil');
afficherErreurCapture('Oups !', 'La photo n\'a pas pu etre prise, reessayez');
return;
}
photoImpression = null;
formatImpression = null;
try {
if (modeActuel === 'multi') {
const mode = config.multi_shot?.mode || 'strip';
let resultat;
if (mode === 'strip') {
resultat = await apiPost('/api/strip', { photos: photosSession });
if (resultat.impression) {
photoImpression = resultat.impression;
if (resultat.erreur) {
afficherErreurCapture('Oups !', 'La planche n\'a pas pu etre creee, reessayez');
return;
}
if (resultat.impression) photoImpression = resultat.impression;
if (resultat.format_impression) formatImpression = resultat.format_impression;
} else {
resultat = await apiPost('/api/collage', { photos: photosSession });
if (resultat.erreur) {
afficherErreurCapture('Oups !', 'Le collage n\'a pas pu etre cree, reessayez');
return;
}
}
if (!resultat.nom) {
afficherErreurCapture('Oups !', 'Un probleme est survenu, reessayez');
return;
}
if (resultat.nom) {
photoFinale = resultat.nom;
afficherPreviewPhoto('/data/exports/' + resultat.nom);
}
} else {
photoFinale = photosSession[0];
afficherPreviewPhoto('/data/photos/' + photosSession[0]);
}
} catch (e) {
afficherErreurCapture('Erreur reseau', 'Impossible de traiter les photos');
return;
}
allerA('preview');
}
@@ -226,8 +533,9 @@ async function traiterCapture() {
function afficherPreviewPhoto(chemin) {
document.getElementById('photo-resultat').src = chemin;
document.getElementById('photo-partage').src = chemin;
afficherCadreOverlay(cadreChoisi);
if (modeActuel === 'simple' && config.fonctionnalites?.filtres) {
if (config.fonctionnalites?.filtres) {
chargerFiltres();
}
if (config.fonctionnalites?.overlays) {
@@ -243,12 +551,37 @@ function afficherPreviewPhoto(chemin) {
async function majCompteurAccueil() {
const etat = await apiGet('/api/compteur');
const el = document.getElementById('compteur-accueil');
if (etat.actif) {
el.classList.remove('cache');
document.getElementById('compteur-restant').textContent = etat.restantes;
document.getElementById('compteur-limite').textContent = etat.limite;
document.getElementById('compteur-limite').textContent =
(etat.actif && etat.limite > 0) ? etat.limite : etat.capacite;
}
async function majBoutonImprimerCompteur() {
const etat = await apiGet('/api/compteur');
const btn = document.getElementById('btn-imprimer');
if (!btn) return;
if (etat.restantes <= 0) {
btn.classList.add('cache');
}
}
async function chargerCadreImpose() {
if (modeActuel === 'multi') {
afficherCadreOverlay(null);
return;
}
const fmt = (config.impression?.format || '15x20').replace('-2up', '');
const data = await apiGet(`/api/cadres-impression/${fmt}`).catch(() => null);
if (!data) { afficherCadreOverlay(null); return; }
if (data.event_mode === 'impose' && data.event_cadre) {
cadreChoisi = data.event_cadre;
afficherCadreOverlay(cadreChoisi);
} else if (data.event_mode === 'defaut' && data.event_cadre) {
cadreChoisi = data.event_cadre;
afficherCadreOverlay(cadreChoisi);
} else {
el.classList.add('cache');
afficherCadreOverlay(null);
}
}

View File

@@ -23,11 +23,18 @@ async function appliquerFiltreUI(filtre, btn) {
btn.classList.add('actif');
filtreActuel = filtre;
if (modeActuel === 'multi') {
// En mode multi : ré-assembler le strip avec le filtre appliqué sur chaque photo
await appliquerFiltreStrip(filtre);
return;
}
if (filtre === 'original') {
const chemin = '/data/photos/' + photosSession[0];
document.getElementById('photo-resultat').src = chemin;
document.getElementById('photo-partage').src = chemin;
photoFinale = photosSession[0];
afficherCadreOverlay(cadreChoisi);
return;
}
@@ -40,9 +47,66 @@ async function appliquerFiltreUI(filtre, btn) {
photoFinale = resultat.nom;
document.getElementById('photo-resultat').src = '/data/exports/' + resultat.nom;
document.getElementById('photo-partage').src = '/data/exports/' + resultat.nom;
afficherCadreOverlay(cadreChoisi);
}
}
async function appliquerFiltreStrip(filtre) {
let photosAAssembler = photosSession;
let source = 'photos'; // les originaux sont dans data/photos/
if (filtre !== 'original') {
const promises = photosSession.map(p => apiPost('/api/filtre', { photo: p, filtre }));
const resultats = await Promise.all(promises);
photosAAssembler = resultats.filter(r => r.nom).map(r => r.nom);
source = 'exports'; // les filtrés sont dans data/exports/
if (photosAAssembler.length === 0) return;
}
const mode = config.multi_shot?.mode || 'strip';
const endpoint = mode === 'strip' ? '/api/strip' : '/api/collage';
const resultat = await apiPost(endpoint, { photos: photosAAssembler, source });
if (resultat.nom) {
photoFinale = resultat.nom;
if (resultat.impression) photoImpression = resultat.impression;
const src = '/data/exports/' + resultat.nom;
document.getElementById('photo-resultat').src = src;
document.getElementById('photo-partage').src = src;
afficherCadreOverlay(cadreChoisi);
}
}
function afficherCadreOverlay(nom) {
const ids = ['cadre-overlay-preview', 'cadre-overlay-live', 'cadre-overlay-partage'];
if (!nom) {
ids.forEach(id => {
const el = document.getElementById(id);
if (el) { el.classList.add('cache'); el.src = ''; }
});
return;
}
const config_imp = config.impression || {};
const fmt = (config_imp.format || '15x20').replace('-2up', '');
const event_id = (config.evenement || {}).event_id;
let src = `/assets/cadres/${fmt}/${nom}`;
if (event_id) {
const eventSrc = `/data/evenements/${event_id}/cadres/${fmt}/${nom}`;
src = eventSrc;
}
ids.forEach(id => {
const el = document.getElementById(id);
if (el) {
el.src = src;
el.onerror = () => {
el.src = `/assets/cadres/${fmt}/${nom}`;
el.onerror = null;
};
el.classList.remove('cache');
}
});
}
async function chargerOverlays() {
const overlays = await apiGet('/api/overlays');
const barre = document.getElementById('barre-overlays');

View File

@@ -25,3 +25,98 @@ function voirPhoto(photo) {
document.getElementById('photo-partage').src = '/' + photo.chemin;
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,91 +1,236 @@
/* Module partage - Impression, email, QR code */
let nbExemplaires = 1;
let copiesMax = 5;
let copiesMax = 2;
let _restantActuel = null;
function ouvrirImpression() {
copiesMax = config.impression?.copies_max || 5;
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() {
copiesMax = config.impression?.copies_max || 2;
nbExemplaires = 1;
document.getElementById('nb-exemplaires').textContent = nbExemplaires;
cadreChoisi = null;
_masquerBoutonsBas(true);
_majRestantImpression();
if (modeActuel === 'multi') {
document.getElementById('form-impression').classList.remove('cache');
return;
}
const hasCadres = await chargerCadresChoixImpression();
if (hasCadres) {
document.getElementById('popup-cadre-impression').classList.remove('cache');
} else {
document.getElementById('form-impression').classList.remove('cache');
}
}
function fermerImpression() {
document.getElementById('form-impression').classList.add('cache');
cadreChoisi = null;
_masquerBoutonsBas(false);
}
function confirmerCadreImpression() {
document.getElementById('popup-cadre-impression').classList.add('cache');
document.getElementById('form-impression').classList.remove('cache');
}
function annulerCadreImpression() {
document.getElementById('popup-cadre-impression').classList.add('cache');
cadreChoisi = null;
_masquerBoutonsBas(false);
}
async function chargerCadresChoixImpression() {
const fmt = (config.impression?.format || '15x20').replace('-2up', '');
const data = await apiGet(`/api/cadres-impression/${fmt}`).catch(() => null);
if (!data) return false;
// Mode impose : pas de choix, cadre force
if (data.event_mode === 'impose' && data.event_cadre) {
cadreChoisi = data.event_cadre;
return false;
}
const tousLesGlobaux = data.disponibles || [];
const tousLesEvent = data.event_cadres || [];
if (tousLesGlobaux.length === 0 && tousLesEvent.length === 0) return false;
const grille = document.getElementById('grille-cadres-impression');
grille.innerHTML = '';
const eventCadreDefaut = (data.event_mode === 'defaut') ? data.event_cadre : null;
const divAucun = document.createElement('div');
divAucun.className = 'cadre-choix-item cadre-choix-aucun' + (!eventCadreDefaut ? ' cadre-selectionne' : '');
divAucun.innerHTML = '<div class="cadre-choix-vide"></div><span>Sans cadre</span>';
divAucun.onclick = () => {
cadreChoisi = null;
grille.querySelectorAll('.cadre-choix-item').forEach(el => el.classList.remove('cadre-selectionne'));
divAucun.classList.add('cadre-selectionne');
};
grille.appendChild(divAucun);
// Cadres evenement
for (const nom of tousLesEvent) {
const isDefaut = (nom === eventCadreDefaut);
const div = document.createElement('div');
div.className = 'cadre-choix-item' + (isDefaut ? ' cadre-selectionne' : '');
div.innerHTML = `<div class="cadre-thumb-wrap"><img src="/data/evenements/${data.event_id}/cadres/${fmt}/${nom}" alt="${nom}"></div><span>${nom.replace('.png','')}</span>`;
div.onclick = () => {
cadreChoisi = nom;
grille.querySelectorAll('.cadre-choix-item').forEach(el => el.classList.remove('cadre-selectionne'));
div.classList.add('cadre-selectionne');
};
grille.appendChild(div);
if (isDefaut) cadreChoisi = nom;
}
// Cadres globaux
for (const nom of tousLesGlobaux) {
const div = document.createElement('div');
div.className = 'cadre-choix-item';
div.innerHTML = `<div class="cadre-thumb-wrap"><img src="/assets/cadres/${fmt}/${nom}" alt="${nom}"></div><span>${nom.replace('.png','')}</span>`;
div.onclick = () => {
cadreChoisi = nom;
grille.querySelectorAll('.cadre-choix-item').forEach(el => el.classList.remove('cadre-selectionne'));
div.classList.add('cadre-selectionne');
};
grille.appendChild(div);
}
return true;
}
function changerExemplaires(delta) {
nbExemplaires = Math.max(1, Math.min(copiesMax, nbExemplaires + delta));
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() {
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
const fichierImpression = photoImpression || photoFinale;
afficherStatut(`Impression de ${nbExemplaires} exemplaire(s)...`, 'succes');
const resultat = await apiPost('/api/imprimer', { photo: fichierImpression, copies: nbExemplaires });
if (resultat.succes) {
const resultat = await apiPost('/api/imprimer', {
photo: fichierImpression,
copies: nbExemplaires,
cadre: cadreChoisi || undefined,
format_papier: formatImpression || undefined,
});
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');
} else if (resultat.erreur === 'evenement_termine') {
afficherStatut('L\'impression n\'est plus disponible pour cet evenement', 'erreur');
} else {
afficherStatut('Erreur d\'impression', 'erreur');
afficherStatut('L\'impression a rencontre un probleme, reessayez', 'erreur');
}
}
function ouvrirEmail() {
async function ouvrirEmail() {
document.getElementById('form-email').classList.remove('cache');
document.getElementById('input-email').value = '';
_masquerBoutonsBas(true);
await chargerSuggestionsEmail();
}
function fermerEmail() {
document.getElementById('form-email').classList.add('cache');
document.getElementById('input-email').value = '';
_masquerBoutonsBas(false);
}
async function chargerSuggestionsEmail() {
const container = document.getElementById('email-suggestions');
container.innerHTML = '';
const data = await apiGet('/api/emails/historique').catch(() => null);
if (!data || !data.emails || data.emails.length === 0) return;
for (const email of data.emails) {
const btn = document.createElement('button');
btn.textContent = email;
btn.onclick = () => {
document.getElementById('input-email').value = email;
};
container.appendChild(btn);
}
}
function clavierTape(car) {
const input = document.getElementById('input-email');
input.value += car;
filtrerSuggestionsEmail();
}
function clavierEffacer() {
const input = document.getElementById('input-email');
input.value = input.value.slice(0, -1);
filtrerSuggestionsEmail();
}
function filtrerSuggestionsEmail() {
const val = document.getElementById('input-email').value.toLowerCase();
const btns = document.querySelectorAll('#email-suggestions button');
btns.forEach(btn => {
btn.style.display = (!val || btn.textContent.includes(val)) ? '' : 'none';
});
}
async function envoyerEmail() {
const email = document.getElementById('input-email').value.trim();
if (!email || !photoFinale) return;
fermerEmail();
afficherStatut('Envoi en cours...', 'succes');
const resultat = await apiPost('/api/email', { email, photo: photoFinale });
if (resultat.succes) {
afficherStatut('Email envoye !', 'succes');
fermerEmail();
if (resultat.spool) {
afficherStatut('Votre photo sera envoyee par email des que possible', 'succes');
} else if (resultat.succes) {
afficherStatut('Email envoyé !', 'succes');
} else {
afficherStatut('Erreur d\'envoi email', 'erreur');
afficherStatut('L\'email n\'a pas pu etre envoye, reessayez', 'erreur');
}
}
async function afficherQR() {
const zone = document.getElementById('zone-qr');
zone.classList.toggle('cache');
if (!zone.classList.contains('cache')) {
const booth = config.booth || {};
if (booth.actif && booth.url) {
// QR vers la galerie live
const eventId = booth.event_id || 'default';
const qrUrl = `${booth.url}/${eventId}`;
document.getElementById('img-qr').src = `/api/qr?url=${encodeURIComponent(qrUrl)}`;
} else {
const resultat = await apiGet('/api/qr/galerie');
if (resultat.chemin) {
document.getElementById('img-qr').src = resultat.chemin;
} else {
afficherStatut('QR Code non configure', 'erreur');
zone.classList.add('cache');
}
}
}
if (!booth.actif || !booth.url || !photoFinale) return;
try {
const info = await apiGet('/api/booth/info');
if (!info.password) return;
const eventId = (config.evenement || {}).event_id || booth.event_id || 'default';
const qrUrl = `${booth.url}/g/${eventId}/view/${photoFinale}?p=${info.password}`;
document.getElementById('qr-photo').src = `/api/qr?url=${encodeURIComponent(qrUrl)}`;
document.getElementById('popup-qr').classList.remove('cache');
} catch (e) { console.error('afficherQR error:', e); }
}

View File

@@ -1,32 +1,38 @@
/* Communication WebSocket avec le backend */
/* Communication WebSocket avec le backend — reconnexion résiliente */
let ws = null;
let wsReconnectTimer = null;
let _wsReconnectDelay = 1000;
let _wsWasConnected = false;
const wsCallbacks = {};
function wsConnecter() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${proto}//${location.host}/ws`);
try { ws = new WebSocket(`${proto}//${location.host}/ws`); } catch(e) { _wsScheduleReconnect(); return; }
ws.onopen = () => {
console.log('WebSocket connecte');
if (wsReconnectTimer) {
clearInterval(wsReconnectTimer);
wsReconnectTimer = null;
_wsReconnectDelay = 1000;
if (_wsWasConnected) {
_masquerPause();
// Recharger la config pour resynchroniser l'état
if (typeof chargerConfig === 'function') chargerConfig();
}
_wsWasConnected = true;
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
const cb = wsCallbacks[msg.type];
if (cb) cb(msg);
const cbs = wsCallbacks[msg.type];
if (cbs) cbs.forEach(cb => cb(msg));
} catch(e) {}
};
ws.onclose = () => {
console.log('WebSocket deconnecte, reconnexion dans 3s...');
if (!wsReconnectTimer) {
wsReconnectTimer = setInterval(wsConnecter, 3000);
}
console.log('WebSocket deconnecte');
ws = null;
_afficherPause();
_wsScheduleReconnect();
};
ws.onerror = () => {
@@ -34,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) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(msg));
@@ -41,7 +67,8 @@ function wsEnvoyer(msg) {
}
function wsOnMessage(type, callback) {
wsCallbacks[type] = callback;
if (!wsCallbacks[type]) wsCallbacks[type] = [];
wsCallbacks[type].push(callback);
}
// Connexion au demarrage

0
frontend/sounds/.gitkeep Normal file
View File

View File

@@ -2,7 +2,7 @@
## Description
Photobooth professionnel pour location evenementielle.
RPi4 + ecran tactile + DSLR (gphoto2) + imprimante sublimation.
Surface 6 + ecran tactile + DSLR Canon (gphoto2) + imprimante sublimation Mitsubishi.
## Architecture
- **Backend** : Python FastAPI + WebSocket
@@ -33,3 +33,268 @@ Phase 4 : scripts production (install.sh, start.sh, systemd).
- Projet cree le 2026-03-21
- Mode simulation camera si gphoto2 non installe (dev sans DSLR)
- 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

2269
photobooth_pygame.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
SUBSYSTEM=="usb", ATTR{idVendor}=="04a9", TEST=="power/control", ATTR{power/control}="on"

43
scripts/bench_camera.py Normal file
View File

@@ -0,0 +1,43 @@
#!/usr/bin/env python3
"""Test direct gphoto2 : mesure temps preview et capture."""
import time
try:
import gphoto2 as gp
except ImportError:
print("gphoto2 non installe")
exit(1)
cam = gp.Camera()
cam.init()
print("Camera connectee")
# Test preview
print("\n--- Preview (5 frames) ---")
times = []
for i in range(5):
t0 = time.time()
f = cam.capture_preview()
data = bytes(f.get_data_and_size())
dt = time.time() - t0
times.append(dt)
print("Frame " + str(i+1) + ": " + str(round(dt, 3)) + "s " + str(len(data)//1024) + "KB")
print("Moyenne preview: " + str(round(sum(times)/len(times), 3)) + "s")
# Test capture photo
print("\n--- Capture photo ---")
t0 = time.time()
chemin = cam.capture(gp.GP_CAPTURE_IMAGE)
dt_declenchement = time.time() - t0
print("Declenchement: " + str(round(dt_declenchement, 3)) + "s")
t1 = time.time()
cf = gp.CameraFile()
cam.file_get(chemin.folder, chemin.name, gp.GP_FILE_TYPE_NORMAL, cf)
data = bytes(cf.get_data_and_size())
dt_transfert = time.time() - t1
print("Transfert: " + str(round(dt_transfert, 3)) + "s " + str(len(data)//1024) + "KB")
print("Total capture: " + str(round(time.time() - t0, 3)) + "s")
cam.exit()

View File

@@ -0,0 +1,178 @@
#!/usr/bin/env python3
"""Génère des cadres de démonstration pour chaque format d'impression."""
from pathlib import Path
from PIL import Image, ImageDraw
RACINE = Path(__file__).resolve().parent.parent
DOSSIER_CADRES = RACINE / "frontend" / "assets" / "cadres"
FORMATS = {
"strip": (600, 1800),
"10x15": (1200, 1800),
"15x20": (1800, 2400),
}
# ─── Cadres simples (tous formats) ───────────────────────────────────────────
def cadre_simple(largeur, hauteur, couleur_bord, epaisseur=30, couleur_coin=None):
img = Image.new("RGBA", (largeur, hauteur), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
cc = couleur_coin or couleur_bord
ep = epaisseur
# 4 bords
d.rectangle([0, 0, largeur, ep], fill=couleur_bord)
d.rectangle([0, hauteur - ep, largeur, hauteur], fill=couleur_bord)
d.rectangle([0, 0, ep, hauteur], fill=couleur_bord)
d.rectangle([largeur - ep, 0, largeur, hauteur], fill=couleur_bord)
# coins
tc = ep + 20
for x, y in [(0, 0), (largeur - tc, 0), (0, hauteur - tc), (largeur - tc, hauteur - tc)]:
d.rectangle([x, y, x + tc, y + tc], fill=cc)
# ligne intérieure fine
m = ep + 8
d.rectangle([m, m, largeur - m, hauteur - m], outline=cc, width=2)
return img
# ─── Cadres pellicule (format strip uniquement) ───────────────────────────────
def cadre_pellicule(largeur, hauteur, couleur_bord=(15, 15, 15, 255),
couleur_perf=(50, 50, 50, 255), label=""):
"""Cadre pellicule avec perforations sur les côtés gauche et droit."""
img = Image.new("RGBA", (largeur, hauteur), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
bord = 52 # largeur de la bande pellicule de chaque côté
perf_h = 22 # hauteur d'une perforation
perf_w = 16 # largeur d'une perforation
perf_r = 4 # rayon arrondi
perf_gap = 14 # espace entre perforations
# Bandes noires gauche et droite
d.rectangle([0, 0, bord, hauteur], fill=couleur_bord)
d.rectangle([largeur - bord, 0, largeur, hauteur], fill=couleur_bord)
# Bande fine haut et bas
bord_h = 10
d.rectangle([0, 0, largeur, bord_h], fill=couleur_bord)
d.rectangle([0, hauteur - bord_h, largeur, hauteur], fill=couleur_bord)
# Perforations gauche
y = perf_gap
while y + perf_h < hauteur:
x = (bord - perf_w) // 2
d.rounded_rectangle([x, y, x + perf_w, y + perf_h], radius=perf_r, fill=couleur_perf)
y += perf_h + perf_gap
# Perforations droite
y = perf_gap
while y + perf_h < hauteur:
x = largeur - bord + (bord - perf_w) // 2
d.rounded_rectangle([x, y, x + perf_w, y + perf_h], radius=perf_r, fill=couleur_perf)
y += perf_h + perf_gap
# Ligne intérieure fine pour délimiter la zone photo
m = bord + 4
d.rectangle([m, bord_h + 4, largeur - m, hauteur - bord_h - 4], outline=(80, 80, 80, 180), width=1)
return img
def cadre_pellicule_vintage(largeur, hauteur):
"""Pellicule couleur sépia avec numéros de frame."""
img = Image.new("RGBA", (largeur, hauteur), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
bord = 58
bord_sepia = (40, 28, 12, 255)
perf_col = (90, 65, 30, 255)
perf_h, perf_w, perf_r, perf_gap = 20, 14, 3, 16
d.rectangle([0, 0, bord, hauteur], fill=bord_sepia)
d.rectangle([largeur - bord, 0, largeur, hauteur], fill=bord_sepia)
d.rectangle([0, 0, largeur, 8], fill=bord_sepia)
d.rectangle([0, hauteur - 8, largeur, hauteur], fill=bord_sepia)
for side in [0, 1]:
y = perf_gap
while y + perf_h < hauteur:
x = (bord - perf_w) // 2 if side == 0 else largeur - bord + (bord - perf_w) // 2
d.rounded_rectangle([x, y, x + perf_w, y + perf_h], radius=perf_r, fill=perf_col)
y += perf_h + perf_gap
# Filet intérieur
m = bord + 5
d.rectangle([m, 12, largeur - m, hauteur - 12], outline=(100, 75, 35, 200), width=2)
return img
def cadre_pellicule_couleur(largeur, hauteur, teinte=(220, 50, 50)):
"""Pellicule colorée style photobooth rétro."""
img = Image.new("RGBA", (largeur, hauteur), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
bord = 50
r, g, b = teinte
col_bord = (r, g, b, 255)
col_perf = (min(255, r + 60), min(255, g + 60), min(255, b + 60), 255)
perf_h, perf_w, perf_r, perf_gap = 20, 14, 4, 14
d.rectangle([0, 0, bord, hauteur], fill=col_bord)
d.rectangle([largeur - bord, 0, largeur, hauteur], fill=col_bord)
d.rectangle([0, 0, largeur, 8], fill=col_bord)
d.rectangle([0, hauteur - 8, largeur, hauteur], fill=col_bord)
for side in [0, 1]:
y = perf_gap
while y + perf_h < hauteur:
x = (bord - perf_w) // 2 if side == 0 else largeur - bord + (bord - perf_w) // 2
d.rounded_rectangle([x, y, x + perf_w, y + perf_h], radius=perf_r, fill=col_perf)
y += perf_h + perf_gap
m = bord + 5
d.rectangle([m, 12, largeur - m, hauteur - 12], outline=col_perf, width=2)
return img
# ─── Main ─────────────────────────────────────────────────────────────────────
def main():
# Cadres simples pour tous les formats
SIMPLES = [
("classique_blanc.png", (255, 255, 255, 255), (210, 210, 210, 255), 30),
("elegant_noir.png", (20, 20, 20, 255), (70, 70, 70, 255), 40),
("rose_gold.png", (198, 143, 115, 255), (230, 180, 150, 255), 35),
("festif_dore.png", (212, 175, 55, 255), (255, 215, 0, 255), 32),
]
for fmt, (larg, haut) in FORMATS.items():
dossier = DOSSIER_CADRES / fmt
dossier.mkdir(parents=True, exist_ok=True)
for nom, col_bord, col_coin, ep in SIMPLES:
cadre = cadre_simple(larg, haut, col_bord, ep, col_coin)
cadre.save(dossier / nom, "PNG")
print(f" ✓ {fmt}/{nom}")
# Cadres pellicule — uniquement format strip
dossier_strip = DOSSIER_CADRES / "strip"
larg, haut = FORMATS["strip"]
pellicules = [
("pellicule_noir.png", cadre_pellicule(larg, haut)),
("pellicule_vintage.png", cadre_pellicule_vintage(larg, haut)),
("pellicule_rouge.png", cadre_pellicule_couleur(larg, haut, (200, 40, 40))),
("pellicule_bleu.png", cadre_pellicule_couleur(larg, haut, (30, 80, 180))),
("pellicule_vert.png", cadre_pellicule_couleur(larg, haut, (30, 140, 60))),
]
for nom, img in pellicules:
img.save(dossier_strip / nom, "PNG")
print(f" ✓ strip/{nom} (pellicule)")
if __name__ == "__main__":
print("Génération des cadres de démonstration...")
main()
print("Terminé.")

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 360 B

After

Width:  |  Height:  |  Size: 493 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 B

After

Width:  |  Height:  |  Size: 99 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 B

After

Width:  |  Height:  |  Size: 102 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 B

After

Width:  |  Height:  |  Size: 78 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 B

After

Width:  |  Height:  |  Size: 78 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 B

After

Width:  |  Height:  |  Size: 102 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 B

After

Width:  |  Height:  |  Size: 78 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 B

After

Width:  |  Height:  |  Size: 78 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 B

After

Width:  |  Height:  |  Size: 99 B

View File

@@ -1,35 +1,25 @@
# Theme GRUB tactile - Triple Boot Terra
title-text: ""
desktop-color: "#1a1a2e"
desktop-image: "background.png"
desktop-color: "#0f1428"
message-color: "#ffffff"
message-bg-color: "#000000"
terminal-font: "DejaVu Sans Bold 24"
+ boot_menu {
left = 10%
top = 15%
width = 80%
height = 70%
item_font = "DejaVu Sans Bold 36"
item_color = "#cccccc"
selected_item_font = "DejaVu Sans Bold 42"
left = 15%
top = 25%
width = 70%
height = 60%
item_font = "DejaVu Sans Bold 48"
item_color = "#aabbcc"
selected_item_font = "DejaVu Sans Bold 48"
selected_item_color = "#ffffff"
item_height = 120
item_padding = 20
item_spacing = 30
item_icon_space = 30
icon_width = 96
icon_height = 96
item_height = 160
item_padding = 30
item_spacing = 20
item_icon_space = 40
icon_width = 128
icon_height = 128
selected_item_pixmap_style = "select_*.png"
}
+ label {
left = 0
top = 90%
width = 100%
height = 30
text = "Toucher pour selectionner - Appuyer sur Entree pour demarrer"
font = "DejaVu Sans 18"
color = "#888888"
align = "center"
}

View File

@@ -0,0 +1,34 @@
#!/bin/bash
# Installe PyQt6 WebEngine et configure le service kiosk Qt
set -e
echo "=== Installation PyQt6 WebEngine ==="
pip3 install PyQt6 PyQt6-WebEngine --break-system-packages
echo "=== Creation service systemd ==="
cat > /etc/systemd/system/photobooth-kiosk-qt.service << EOF
[Unit]
Description=Photobooth Kiosk Qt
After=graphical-session.target photobooth.service
Wants=photobooth.service
[Service]
Type=simple
User=$(logname)
Environment=DISPLAY=:0
Environment=XAUTHORITY=/home/$(logname)/.Xauthority
ExecStart=/usr/bin/python3 /home/$(logname)/photobooth/scripts/kiosk-qt.py
Restart=always
RestartSec=3
[Install]
WantedBy=graphical-session.target
EOF
systemctl daemon-reload
systemctl enable photobooth-kiosk-qt
echo "=== Pour basculer sur le kiosk Qt ==="
echo " sudo systemctl stop photobooth-kiosk"
echo " sudo systemctl disable photobooth-kiosk"
echo " sudo systemctl start photobooth-kiosk-qt"

37
scripts/install-photostation.sh Executable file
View File

@@ -0,0 +1,37 @@
#!/bin/bash
# Installe la photostation sur la Surface pour intégration photobooth
set -e
DEST="/opt/photostation"
REPO="https://git.copydev.fr/jules/photostation.git"
echo "=== Installation Photostation ==="
# Dépendances système
sudo apt-get install -y \
python3-pyqt6 python3-pyqt6.qtsvg \
python3-pillow python3-flask \
python3-qrcode python3-cups \
libgl1 libglib2.0-0 2>&1 | grep -E 'Install|Upgrade|already'
# Cloner ou mettre à jour
if [ -d "$DEST/.git" ]; then
echo "Mise à jour du repo..."
git -C "$DEST" pull
else
echo "Clonage du repo..."
sudo git clone "$REPO" "$DEST"
sudo chown -R jules:jules "$DEST"
fi
# Venv avec accès aux paquets système (PyQt6 est en paquet système)
if [ ! -d "$DEST/venv" ]; then
python3 -m venv --system-site-packages "$DEST/venv"
fi
# Dépendances Python supplémentaires
"$DEST/venv/bin/pip" install --quiet qrcode[pil] 2>/dev/null || true
echo ""
echo "=== Installation terminée ==="
echo "Test : DISPLAY=:0 bash -c 'source $DEST/venv/bin/activate && python $DEST/src/main.py'"

View File

@@ -19,6 +19,10 @@ sudo apt install -y \
xinput \
xdotool
# Empecher le DSLR Canon de se mettre en veille USB (sinon il se deconnecte seul)
sudo cp "$(dirname "$0")/99-canon-no-autosuspend.rules" /etc/udev/rules.d/
sudo udevadm control --reload-rules
# Repertoire du projet
APP_DIR="/home/jules/photobooth"

91
scripts/kiosk-qt.py Normal file
View File

@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""
Kiosk PyQt6 WebEngine pour photobooth.
Alternative au browser : process unique, pas de "already running", restart propre.
Usage : python3 scripts/kiosk-qt.py
"""
import sys
import time
import subprocess
from PyQt6.QtCore import Qt, QUrl, QTimer
from PyQt6.QtWidgets import QApplication
from PyQt6.QtWebEngineWidgets import QWebEngineView
from PyQt6.QtWebEngineCore import QWebEngineSettings
BACKEND_URL = "http://localhost:8080"
RETRY_INTERVAL_MS = 3000
def wait_backend(url: str, timeout: int = 30) -> bool:
"""Attend que le backend soit dispo avant d'afficher."""
import urllib.request
for _ in range(timeout):
try:
urllib.request.urlopen(url, timeout=1)
return True
except Exception:
time.sleep(1)
return False
class KioskWindow(QWebEngineView):
def __init__(self):
super().__init__()
# Plein ecran sans decoration
self.setWindowFlags(Qt.WindowType.FramelessWindowHint)
self.showFullScreen()
# Parametres WebEngine
settings = self.settings()
settings.setAttribute(QWebEngineSettings.WebAttribute.JavascriptEnabled, True)
settings.setAttribute(QWebEngineSettings.WebAttribute.LocalStorageEnabled, True)
settings.setAttribute(QWebEngineSettings.WebAttribute.TouchIconsEnabled, True)
settings.setAttribute(QWebEngineSettings.WebAttribute.ScrollAnimatorEnabled, True)
# Cacher le curseur
self.setCursor(Qt.CursorShape.BlankCursor)
self.setUrl(QUrl(BACKEND_URL))
# Watchdog : recharge si la page ne repond plus (WebSocket coupe)
self._watchdog = QTimer(self)
self._watchdog.setInterval(10000)
self._watchdog.timeout.connect(self._check_backend)
self._watchdog.start()
def _check_backend(self):
import urllib.request
try:
urllib.request.urlopen(BACKEND_URL, timeout=2)
except Exception:
self.reload()
def keyPressEvent(self, event):
# F5 = reload, F11 = fullscreen toggle, Echap ignoré
if event.key() == Qt.Key.Key_F5:
self.reload()
elif event.key() == Qt.Key.Key_F11:
if self.isFullScreen():
self.showNormal()
else:
self.showFullScreen()
def main():
app = QApplication(sys.argv)
app.setApplicationName("Photobooth Kiosk")
# Attendre que le backend soit pret
if not wait_backend(BACKEND_URL):
print("Backend non disponible apres 30s", file=sys.stderr)
sys.exit(1)
window = KioskWindow()
sys.exit(app.exec())
if __name__ == "__main__":
main()

81
scripts/kiosk-session.sh Executable file
View File

@@ -0,0 +1,81 @@
#!/bin/bash
# Verrou atomique — une seule session kiosk autorisee
exec 9>/tmp/photobooth-kiosk.lock
flock -n 9 || { echo "[kiosk] Déjà en cours, abandon."; exit 0; }
PHOTOBOOTH_DIR=/home/jules/photobooth
# Port du backend (lu depuis config.json, fallback 8080)
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 -dpms
xset s noblank
# Automount USB (pour la photostation)
udiskie --no-notify --no-appindicator &
# Liberer le DSLR : gvfsd-gphoto2 (GNOME) le monopolise sinon
pkill -f gvfsd-gphoto2 2>/dev/null || true
pkill -f gvfs-gphoto2-volume-monitor 2>/dev/null || true
# Backend en boucle : redemarre automatiquement s'il plante
_backend_loop() {
while true; do
while [ -f /tmp/photobooth-maintenance ]; do sleep 2; done
cd "$PHOTOBOOTH_DIR"
source .venv/bin/activate
authbind --deep python -m backend.main 2>&1 | tee -a /tmp/photobooth-backend.log
echo "[kiosk] backend terminé, redémarrage dans 3s..."
sleep 3
done
}
_backend_loop &
BACKEND_LOOP_PID=$!
# Nettoyer le backend si le kiosk est tue
trap "kill $BACKEND_LOOP_PID 2>/dev/null; pkill -f 'python.*backend.main' 2>/dev/null" EXIT
# 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
done
# Chromium en boucle : redemarre s'il plante, attend si photostation active
while true; do
while [ -f /tmp/photostation_running ]; do sleep 1; done
chromium \
--kiosk \
--noerrdialogs \
--disable-infobars \
--disable-translate \
--disable-features=TranslateUI,Translate,LockProfileCookieDatabase \
--disable-session-crashed-bubble \
--disable-component-update \
--check-for-update-interval=31536000 \
--autoplay-policy=no-user-gesture-required \
--start-fullscreen \
--user-data-dir=/home/jules/.config/chromium-kiosk \
--touch-events=enabled \
--enable-touch-drag-drop \
--force-device-scale-factor=3 \
--password-store=basic \
--lang=fr \
--remote-debugging-port=9222 \
"${BACKEND_URL}"
echo "[kiosk] Chromium terminé, redémarrage dans 2s..."
sleep 2
done

View File

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

View File

@@ -1,5 +1,5 @@
#!/bin/bash
# Demarrage du photobooth (backend + chromium kiosk)
# Demarrage du backend photobooth (Chromium est gere par la session kiosk)
set -e
DIR="$(cd "$(dirname "$0")/.." && pwd)"
@@ -8,32 +8,5 @@ cd "$DIR"
# Activer le venv
source .venv/bin/activate
# Demarrer le backend en arriere-plan
echo "Demarrage du backend..."
python -m backend.main &
BACKEND_PID=$!
# Attendre que le backend soit pret
sleep 3
# Cacher le curseur souris
unclutter -idle 0.1 -root &
# Demarrer Chromium en mode kiosk
echo "Demarrage de Chromium kiosk..."
chromium-browser \
--kiosk \
--noerrdialogs \
--disable-infobars \
--disable-translate \
--disable-features=TranslateUI \
--disable-session-crashed-bubble \
--disable-component-update \
--check-for-update-interval=31536000 \
--autoplay-policy=no-user-gesture-required \
--start-fullscreen \
--incognito \
http://localhost:8080
# Si Chromium se ferme, arreter le backend
kill $BACKEND_PID 2>/dev/null
echo "Demarrage du backend photobooth..."
exec python -m backend.main

48
scripts/test_opencv.py Normal file
View File

@@ -0,0 +1,48 @@
#!/usr/bin/env python3
"""Test liveview avec OpenCV. Lance: DISPLAY=:0 python3 scripts/test_opencv.py"""
import time
import io
import numpy as np
import cv2
import gphoto2 as gp
cam = gp.Camera()
cam.init()
print("Camera connectee")
t_start = time.time()
frame_count = 0
premier_frame = None
print("Appuie sur ESC pour quitter")
while True:
try:
t0 = time.time()
f = cam.capture_preview()
data = bytes(f.get_data_and_size())
dt = time.time() - t0
if premier_frame is None:
premier_frame = time.time() - t_start
print(f"1ere image en : {premier_frame:.2f}s")
arr = np.frombuffer(data, dtype=np.uint8)
img = cv2.imdecode(arr, cv2.IMREAD_COLOR)
img = cv2.resize(img, (960, 640))
frame_count += 1
fps = frame_count / (time.time() - t_start)
cv2.putText(img, f"FPS: {fps:.1f} frame: {dt*1000:.0f}ms", (10, 30),
cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 255), 2)
cv2.imshow("Test liveview OpenCV", img)
if cv2.waitKey(1) & 0xFF == 27: # ESC
break
except Exception as e:
print(f"Erreur: {e}")
break
cam.exit()
cv2.destroyAllWindows()
print(f"Total: {frame_count} frames, {frame_count/(time.time()-t_start):.1f} FPS")

View File

@@ -0,0 +1,242 @@
#!/usr/bin/env python3
"""
Prototype photobooth Pygame.
- Bouton -> liveview -> 3 2 1 -> photo x4 -> grille 4 photos
"""
import time
import threading
import io
import os
from pathlib import Path
from datetime import datetime
import pygame
import gphoto2 as gp
from PIL import Image
# Config
LARGEUR, HAUTEUR = 1920, 1280
NB_PHOTOS = 4
DUREE_COMPTE = 3
DOSSIER_PHOTOS = Path("/tmp/photobooth_test")
DOSSIER_PHOTOS.mkdir(exist_ok=True)
# Couleurs
NOIR = (0, 0, 0)
BLANC = (255, 255, 255)
ROSE = (233, 30, 99)
GRIS = (40, 40, 40)
class Photobooth:
def __init__(self):
pygame.init()
self.screen = pygame.display.set_mode((LARGEUR, HAUTEUR), pygame.FULLSCREEN)
pygame.display.set_caption("Photobooth")
self.clock = pygame.time.Clock()
self.font_grand = pygame.font.SysFont(None, 300)
self.font_moyen = pygame.font.SysFont(None, 100)
self.font_petit = pygame.font.SysFont(None, 60)
# Camera
self.cam = gp.Camera()
self.cam.init()
# Etat
self.etat = "accueil" # accueil, preview, compte, capture, galerie
self.frame_courante = None
self.frame_lock = threading.Lock()
self.preview_actif = False
# Capture via le thread preview (evite le baisse de miroir)
self.capture_demande = False
self.capture_resultat = None
self.capture_done = threading.Event()
self.photos = []
self.num_photo = 0
self.chiffre_compte = 0
# Thread preview
self.thread_preview = threading.Thread(target=self._loop_preview, daemon=True)
self.thread_preview.start()
def _loop_preview(self):
while True:
if self.preview_actif:
try:
if self.capture_demande:
try:
chemin_cam = self.cam.capture(gp.GP_CAPTURE_IMAGE)
cf = gp.CameraFile()
self.cam.file_get(chemin_cam.folder, chemin_cam.name, gp.GP_FILE_TYPE_NORMAL, cf)
nom = f"photo_{datetime.now().strftime('%H%M%S_%f')}.jpg"
chemin = str(DOSSIER_PHOTOS / nom)
cf.save(chemin)
self.capture_resultat = chemin
print(f"Photo capturee: {chemin}")
except Exception as e:
print(f"Erreur capture: {e}")
self.capture_resultat = None
self.capture_demande = False
self.capture_done.set()
else:
f = self.cam.capture_preview()
data = bytes(f.get_data_and_size())
surf = pygame.image.load(io.BytesIO(data))
surf = pygame.transform.scale(surf, (LARGEUR, HAUTEUR))
with self.frame_lock:
self.frame_courante = surf
except Exception as e:
print(f"Preview erreur: {e}")
time.sleep(0.3) # Camera peut etre occupee apres capture
else:
time.sleep(0.05)
def capturer_photo(self):
"""Demande une capture au thread preview (miroir reste leve)."""
self.capture_resultat = None
self.capture_done.clear()
self.capture_demande = True
ok = self.capture_done.wait(timeout=10)
if not ok:
print("Timeout capture")
self.capture_demande = False
return self.capture_resultat
def dessiner_accueil(self):
self.screen.fill(NOIR)
txt = self.font_moyen.render("PHOTOBOOTH", True, ROSE)
self.screen.blit(txt, (LARGEUR//2 - txt.get_width()//2, 300))
# Bouton
btn = pygame.Rect(LARGEUR//2 - 300, HAUTEUR//2, 600, 150)
pygame.draw.rect(self.screen, ROSE, btn, border_radius=20)
t = self.font_moyen.render("COMMENCER", True, BLANC)
self.screen.blit(t, (btn.centerx - t.get_width()//2, btn.centery - t.get_height()//2))
return btn
def dessiner_preview(self):
with self.frame_lock:
if self.frame_courante:
self.screen.blit(self.frame_courante, (0, 0))
else:
self.screen.fill(NOIR)
# Info
t = self.font_petit.render(f"Photo {self.num_photo+1}/{NB_PHOTOS} - Souriez!", True, BLANC)
self.screen.blit(t, (20, 20))
def dessiner_compte(self):
with self.frame_lock:
if self.frame_courante:
self.screen.blit(self.frame_courante, (0, 0))
else:
self.screen.fill(NOIR)
# Overlay sombre
overlay = pygame.Surface((LARGEUR, HAUTEUR), pygame.SRCALPHA)
overlay.fill((0, 0, 0, 100))
self.screen.blit(overlay, (0, 0))
# Chiffre
txt = self.font_grand.render(str(self.chiffre_compte), True, BLANC)
self.screen.blit(txt, (LARGEUR//2 - txt.get_width()//2, HAUTEUR//2 - txt.get_height()//2))
def dessiner_galerie(self):
self.screen.fill(NOIR)
if not self.photos:
t = self.font_petit.render("Aucune photo", True, BLANC)
self.screen.blit(t, (LARGEUR//2 - t.get_width()//2, HAUTEUR//2))
else:
# 4 photos en grille 2x2
w, h = LARGEUR//2 - 20, HAUTEUR//2 - 20
positions = [(10, 10), (LARGEUR//2+10, 10), (10, HAUTEUR//2+10), (LARGEUR//2+10, HAUTEUR//2+10)]
for i, chemin in enumerate(self.photos[:4]):
if i >= len(positions):
break
try:
# PIL pour redimensionner efficacement les grands JPEG Canon
img = Image.open(chemin).convert('RGB')
img = img.resize((w, h), Image.LANCZOS)
surf = pygame.image.fromstring(img.tobytes(), img.size, 'RGB')
self.screen.blit(surf, positions[i])
except Exception as e:
print(f"Erreur galerie photo {i}: {e}")
# Bouton recommencer
btn = pygame.Rect(LARGEUR//2 - 200, HAUTEUR - 120, 400, 100)
pygame.draw.rect(self.screen, ROSE, btn, border_radius=15)
t = self.font_petit.render("RECOMMENCER", True, BLANC)
self.screen.blit(t, (btn.centerx - t.get_width()//2, btn.centery - t.get_height()//2))
return btn
def lancer_sequence(self):
"""Lance la séquence 4 photos dans un thread."""
def sequence():
self.photos = []
for i in range(NB_PHOTOS):
self.num_photo = i
# Preview
self.preview_actif = True
self.etat = "preview"
time.sleep(1.5)
# Compte a rebours
self.etat = "compte"
for c in range(DUREE_COMPTE, 0, -1):
self.chiffre_compte = c
time.sleep(1)
# Flash + capture (miroir reste leve, liveview continue)
self.etat = "capture"
chemin = self.capturer_photo()
if chemin:
self.photos.append(chemin)
print(f"Photo {i+1} : {chemin}")
time.sleep(0.3)
# Galerie
self.preview_actif = False
self.etat = "galerie"
threading.Thread(target=sequence, daemon=True).start()
def run(self):
btn_accueil = None
btn_galerie = None
running = True
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
if event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE:
running = False
if event.type == pygame.MOUSEBUTTONDOWN:
pos = pygame.mouse.get_pos()
if self.etat == "accueil" and btn_accueil and btn_accueil.collidepoint(pos):
self.lancer_sequence()
if self.etat == "galerie" and btn_galerie and btn_galerie.collidepoint(pos):
self.etat = "accueil"
self.photos = []
if self.etat == "accueil":
btn_accueil = self.dessiner_accueil()
btn_galerie = None
elif self.etat == "preview":
self.dessiner_preview()
elif self.etat == "compte":
self.dessiner_compte()
elif self.etat == "capture":
self.dessiner_compte() # Reste sur le liveview pendant la capture
elif self.etat == "galerie":
btn_galerie = self.dessiner_galerie()
btn_accueil = None
pygame.display.flip()
self.clock.tick(30)
self.preview_actif = False
self.cam.exit()
pygame.quit()
if __name__ == "__main__":
app = Photobooth()
app.run()

56
scripts/test_pygame.py Normal file
View File

@@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""Test liveview avec Pygame. Lance: python3 scripts/test_pygame.py"""
import time
import io
import gphoto2 as gp
import pygame
cam = gp.Camera()
cam.init()
print("Camera connectee")
pygame.init()
screen = pygame.display.set_mode((960, 640))
pygame.display.set_caption("Test liveview Pygame")
clock = pygame.time.Clock()
font = pygame.font.SysFont(None, 36)
t_start = time.time()
frame_count = 0
premier_frame = None
running = True
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT or (event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE):
running = False
try:
t0 = time.time()
f = cam.capture_preview()
data = bytes(f.get_data_and_size())
dt = time.time() - t0
if premier_frame is None:
premier_frame = time.time() - t_start
print(f"1ere image en : {premier_frame:.2f}s")
img_surface = pygame.image.load(io.BytesIO(data))
img_surface = pygame.transform.scale(img_surface, (960, 640))
screen.blit(img_surface, (0, 0))
frame_count += 1
fps = frame_count / (time.time() - t_start)
txt = font.render(f"FPS: {fps:.1f} frame: {dt*1000:.0f}ms", True, (255, 255, 0))
screen.blit(txt, (10, 10))
pygame.display.flip()
except Exception as e:
print(f"Erreur: {e}")
break
clock.tick(30)
cam.exit()
pygame.quit()
print(f"Total frames: {frame_count}, FPS moyen: {frame_count/(time.time()-t_start):.1f}")

93
scripts/test_pyqt6.py Normal file
View File

@@ -0,0 +1,93 @@
#!/usr/bin/env python3
"""Test liveview avec PyQt6. Lance: python3 scripts/test_pyqt6.py"""
import sys
import time
import io
import gphoto2 as gp
from PyQt6.QtWidgets import QApplication, QLabel, QMainWindow, QVBoxLayout, QWidget
from PyQt6.QtCore import QThread, pyqtSignal, Qt
from PyQt6.QtGui import QPixmap, QImage
class PreviewThread(QThread):
frame_ready = pyqtSignal(bytes, float)
premier_frame_signal = pyqtSignal(float)
def __init__(self, cam):
super().__init__()
self.cam = cam
self._running = True
self._premier = False
def run(self):
t_start = time.time()
while self._running:
try:
t0 = time.time()
f = self.cam.capture_preview()
data = bytes(f.get_data_and_size())
dt = time.time() - t0
if not self._premier:
self._premier = True
self.premier_frame_signal.emit(time.time() - t_start)
self.frame_ready.emit(data, dt)
except Exception as e:
print(f"Erreur preview: {e}")
break
def stop(self):
self._running = False
class MainWindow(QMainWindow):
def __init__(self, cam):
super().__init__()
self.setWindowTitle("Test liveview PyQt6")
self.resize(960, 640)
self.t_start = time.time()
self.frame_count = 0
self.label_img = QLabel()
self.label_img.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.label_info = QLabel("Attente premiere image...")
self.label_info.setStyleSheet("color: yellow; background: black; font-size: 18px; padding: 5px;")
layout = QVBoxLayout()
layout.addWidget(self.label_img)
layout.addWidget(self.label_info)
widget = QWidget()
widget.setLayout(layout)
self.setCentralWidget(widget)
self.thread = PreviewThread(cam)
self.thread.frame_ready.connect(self.on_frame)
self.thread.premier_frame_signal.connect(self.on_premier)
self.thread.start()
def on_premier(self, t):
print(f"1ere image en : {t:.2f}s")
def on_frame(self, data, dt):
self.frame_count += 1
img = QImage.fromData(data)
pix = QPixmap.fromImage(img).scaled(960, 600, Qt.AspectRatioMode.KeepAspectRatio)
self.label_img.setPixmap(pix)
fps = self.frame_count / (time.time() - self.t_start)
self.label_info.setText(f"FPS: {fps:.1f} frame: {dt*1000:.0f}ms total frames: {self.frame_count}")
def closeEvent(self, e):
self.thread.stop()
self.thread.wait()
super().closeEvent(e)
cam = gp.Camera()
cam.init()
print("Camera connectee")
app = QApplication(sys.argv)
win = MainWindow(cam)
win.show()
ret = app.exec()
cam.exit()
sys.exit(ret)

60
scripts/test_tkinter.py Normal file
View File

@@ -0,0 +1,60 @@
#!/usr/bin/env python3
"""Test liveview avec Tkinter. Lance: DISPLAY=:0 python3 scripts/test_tkinter.py"""
import time
import io
import tkinter as tk
from PIL import Image, ImageTk
import gphoto2 as gp
cam = gp.Camera()
cam.init()
print("Camera connectee")
root = tk.Tk()
root.title("Test liveview Tkinter")
root.geometry("960x680")
root.configure(bg="black")
label_img = tk.Label(root, bg="black")
label_img.pack()
label_info = tk.Label(root, text="Attente...", fg="yellow", bg="black", font=("Arial", 16))
label_info.pack()
t_start = time.time()
frame_count = 0
premier_frame = None
def update():
global frame_count, premier_frame
try:
t0 = time.time()
f = cam.capture_preview()
data = bytes(f.get_data_and_size())
dt = time.time() - t0
if premier_frame is None:
premier_frame = time.time() - t_start
print(f"1ere image en : {premier_frame:.2f}s")
img = Image.open(io.BytesIO(data)).resize((960, 640))
photo = ImageTk.PhotoImage(img)
label_img.config(image=photo)
label_img.image = photo
frame_count += 1
fps = frame_count / (time.time() - t_start)
label_info.config(text=f"FPS: {fps:.1f} frame: {dt*1000:.0f}ms frames: {frame_count}")
except Exception as e:
print(f"Erreur: {e}")
root.quit()
return
root.after(33, update) # ~30fps
root.after(100, update)
root.mainloop()
cam.exit()
print(f"Total: {frame_count} frames")

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