From 583b89f0bd20beacc7177a295f606514940533ea Mon Sep 17 00:00:00 2001 From: Jules Date: Wed, 2 Sep 2026 18:54:10 +0200 Subject: [PATCH] Canon: fiabilite veille/reveil + detection PTP freeze + UX countdown + fix copies impression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/camera.py | 1 + backend/evenements.py | 2 +- backend/main.py | 114 ++++++++++++++++++++++++++++++----------- backend/printer.py | 89 ++++++++++++++++++-------------- frontend/css/style.css | 7 +++ frontend/index.html | 2 +- frontend/js/camera.js | 41 ++++++++++----- frontend/js/share.js | 8 +++ 8 files changed, 179 insertions(+), 85 deletions(-) diff --git a/backend/camera.py b/backend/camera.py index ecdc58d..a4aab3e 100644 --- a/backend/camera.py +++ b/backend/camera.py @@ -312,6 +312,7 @@ class Camera: ("autopoweroff", 0), ("viewfinder", 1), ("iso", "800"), + ("aperture", "4"), ("drivemode", "Single"), ] for nom, valeur in reglages: diff --git a/backend/evenements.py b/backend/evenements.py index 9e72d99..2a98e3a 100644 --- a/backend/evenements.py +++ b/backend/evenements.py @@ -38,7 +38,7 @@ def lister_evenements() -> list[dict]: def creer_evenement(nom: str, **kwargs) -> dict: - event_id = uuid.uuid4().hex[:8] + event_id = kwargs.get("event_id") or uuid.uuid4().hex[:8] event = { "id": event_id, "nom": nom, diff --git a/backend/main.py b/backend/main.py index c1fcba1..2c6f1a1 100644 --- a/backend/main.py +++ b/backend/main.py @@ -83,6 +83,7 @@ _canon_stats = { } _CANON_VEILLE_MINUTES = 30 # couper le Canon après X min sans activité utilisateur _canon_en_veille = False +_reveil_en_cours = False def _thread_preview(): """Thread de fond : capture les frames DSLR. @@ -103,7 +104,7 @@ def _thread_preview(): _canon_stats["derniere_capture"], _canon_stats["derniere_preview"], ) - if derniere_activite > 0 and not _preview_actif and not _canon_en_veille: + if derniere_activite > 0 and not _preview_actif and not _canon_en_veille and not _reveil_en_cours: inactif_min = (now - derniere_activite) / 60 if inactif_min >= _CANON_VEILLE_MINUTES and relais.est_connecte(): log.info(f"Canon inactif depuis {inactif_min:.0f} min — mise en veille relais") @@ -124,14 +125,21 @@ def _thread_preview(): _derniere_keepalive = now if ok: _canon_stats["keepalive_ok"] += 1 + _canon_stats["keepalive_fail_consecutifs"] = 0 _canon_stats["dernier_keepalive_ok"] = now if now - _dernier_log_keepalive > 300: log.info(f"Canon keepalive OK (total: {_canon_stats['keepalive_ok']}, fail: {_canon_stats['keepalive_fail']})") _dernier_log_keepalive = now else: _canon_stats["keepalive_fail"] += 1 + _canon_stats["keepalive_fail_consecutifs"] = _canon_stats.get("keepalive_fail_consecutifs", 0) + 1 _canon_stats["dernier_keepalive_fail"] = now - log.warning(f"Canon keepalive FAIL #{_canon_stats['keepalive_fail']}") + consec = _canon_stats["keepalive_fail_consecutifs"] + log.warning(f"Canon keepalive FAIL #{_canon_stats['keepalive_fail']} (consec: {consec})") + if consec >= 5: + log.error(f"Canon PTP freeze detecte — {consec} keepalive FAIL consecutifs, deconnexion forcee") + _canon_stats["keepalive_fail_consecutifs"] = 0 + camera.deconnecter() time.sleep(1) continue try: @@ -362,7 +370,7 @@ async def surveiller_dslr(): await asyncio.sleep(60) else: await asyncio.sleep(5) - if capture_en_cours: + if capture_en_cours or _reveil_en_cours: continue try: if not GPHOTO2_DISPONIBLE: @@ -373,7 +381,13 @@ async def surveiller_dslr(): with camera._gp_lock: camera.camera.get_config() except Exception: - pass + _dslr_erreurs += 1 + if _dslr_erreurs >= 3: + log.warning(f"Canon get_config echoue {_dslr_erreurs} fois — reconnexion forcee") + _dslr_erreurs = 0 + _echecs_connexion += 1 + await _reconnecter_dslr_avec_reset(_echecs_connexion) + continue if camera.preview_dslr_ok: _dslr_erreurs = 0 _echecs_connexion = 0 @@ -398,14 +412,28 @@ async def surveiller_dslr(): _usb_reset_canon() await asyncio.sleep(3) await _reconnecter_dslr_avec_reset(_echecs_connexion) - elif _echecs_connexion > 0 and _echecs_connexion % 10 == 0: - if relais.est_connecte() and _echecs_connexion % 20 == 0: - log.info("Canon absent USB — power cycle relais...") - await asyncio.get_event_loop().run_in_executor(None, relais.power_cycle_canon, 10.0) - await asyncio.sleep(20) - else: - log.info("DSLR non detecte en USB, tentative USB rebind...") - await asyncio.get_event_loop().run_in_executor(None, _usb_rebind_canon) + else: + if _echecs_connexion > 0: + _echecs_connexion += 1 + if relais.est_connecte() and _echecs_connexion % 10 == 0: + log.warning(f"Canon absent USB (tentative {_echecs_connexion}) — power cycle relais...") + await asyncio.get_event_loop().run_in_executor(None, relais.power_cycle_canon, 10.0) + await asyncio.sleep(25) + else: + log.info(f"DSLR non detecte en USB (tentative {_echecs_connexion}), rebind...") + await asyncio.get_event_loop().run_in_executor(None, _usb_rebind_canon) + elif _canon_sur_usb(): + log.info("Canon detecte sur USB sans echec precedent — tentative connexion...") + _echecs_connexion = 1 + camera.connecter(source="gphoto2") + if camera.connectee: + log.info("Canon reconnecte automatiquement") + _canon_stats["connexions"] += 1 + _canon_stats["derniere_preview"] = time.time() + _canon_stats["derniere_capture"] = time.time() + _appliquer_config_camera() + _echecs_connexion = 0 + await diffuser_ws({"type": "camera_ok"}) except Exception as e: log.debug(f"surveiller_dslr: {e}") @@ -457,6 +485,8 @@ async def _reconnecter_dslr_avec_reset(echecs: int): if camera.connectee: log.info("DSLR reconnecte avec succes") _canon_stats["connexions"] += 1 + _canon_stats["derniere_preview"] = time.time() + _canon_stats["derniere_capture"] = time.time() _appliquer_config_camera() await diffuser_ws({"type": "camera_ok"}) else: @@ -618,22 +648,29 @@ async def _surveiller_imprimante(): def _reveiller_canon(): """Réveil Canon depuis veille relais : restaure alimentation, attend boot, reconnecte.""" - log.info("Réveil Canon — restauration alimentation relais") - relais.desactiver("canon") - time.sleep(15) - relais.desactiver("canon_usb") - time.sleep(10) - if _canon_sur_usb(): - log.info("Canon visible USB après réveil — connexion...") - camera.connecter(source="gphoto2") - if camera.connectee: - _appliquer_config_camera() - log.info("Canon reconnecté après réveil") - _canon_stats["connexions"] += 1 + global _reveil_en_cours + _reveil_en_cours = True + try: + log.info("Réveil Canon — restauration alimentation relais") + relais.desactiver("canon") + time.sleep(15) + relais.desactiver("canon_usb") + time.sleep(25) + if _canon_sur_usb(): + log.info("Canon visible USB après réveil — connexion...") + camera.connecter(source="gphoto2") + if camera.connectee: + _appliquer_config_camera() + _canon_stats["derniere_preview"] = time.time() + _canon_stats["derniere_capture"] = time.time() + log.info("Canon reconnecté après réveil") + _canon_stats["connexions"] += 1 + else: + log.warning("Canon visible USB mais connexion gphoto2 échouée") else: - log.warning("Canon visible USB mais connexion gphoto2 échouée") - else: - log.warning("Canon absent USB après réveil relais — vérifier branchement physique") + log.warning("Canon absent USB après réveil relais — vérifier branchement physique") + finally: + _reveil_en_cours = False def _canon_sur_usb() -> bool: @@ -846,9 +883,21 @@ async def api_config_update(modifications: dict): @app.post("/api/capturer") async def api_capturer(): - global capture_en_cours, _preview_actif + global capture_en_cours, _preview_actif, _canon_en_veille + if _canon_en_veille and not _reveil_en_cours: + log.info("Canon en veille lors de la capture — réveil automatique") + _canon_en_veille = False + await asyncio.get_event_loop().run_in_executor(None, _reveiller_canon) + if _reveil_en_cours: + log.info("Canon en cours de réveil — attente max 60s") + for _ in range(60): + if not _reveil_en_cours: + break + await asyncio.sleep(1) + if not camera.connectee: + return JSONResponse({"erreur": "Appareil photo en cours de démarrage — réessayez"}, status_code=503) capture_en_cours = True - _preview_actif = False # Eviter faux camera_erreur (race condition HTTP avant preview_stop) + _preview_actif = False _canon_stats["captures"] += 1 _canon_stats["derniere_capture"] = time.time() config = charger_config() @@ -1107,7 +1156,7 @@ async def api_relais_veille(): @app.post("/api/relais/reveil") async def api_relais_reveil(): - global _veille_eclairage + global _veille_eclairage, _canon_en_veille _veille_eclairage = False if relais.est_connecte(): config = charger_config() @@ -1116,6 +1165,10 @@ async def api_relais_reveil(): relais.activer("projecteur_gauche") _proj_etat["proj1"] = True _proj_etat["proj2"] = False + if _canon_en_veille: + log.info("Réveil Canon déclenché par reveil éclairage (HTTP)") + _canon_en_veille = False + asyncio.get_event_loop().run_in_executor(None, _reveiller_canon) log.info("Eclairage reveille (1 projecteur)") return {"ok": True} @@ -1634,6 +1687,7 @@ async def api_creer_evenement(donnees: dict): return JSONResponse({"erreur": "Nom requis"}, status_code=400) event = creer_evenement( nom, + event_id=donnees.get("event_id"), theme=donnees.get("theme", "base"), couleur_primaire=donnees.get("couleur_primaire", "#e91e63"), couleur_secondaire=donnees.get("couleur_secondaire", "#ffffff"), diff --git a/backend/printer.py b/backend/printer.py index ac5151c..6a7b319 100644 --- a/backend/printer.py +++ b/backend/printer.py @@ -302,50 +302,61 @@ def imprimer( rembobinage = conf_imp.get("rembobinage_ruban", False) try: - for tentative in range(1, 4): - cmd = [ - "lp", - "-d", imprimante, - "-n", str(copies), - "-o", f"PageSize={page_size}", - "-o", "StpiShrinkOutput=Crop", - ] - if rembobinage: - cmd.extend(["-o", "StpiDecklist=true"]) - cmd.append(str(chemin_print)) + 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)) - code, out, err = _run(cmd, timeout=30) + code, out, err = _run(cmd, timeout=30) - if code == 0: - job = out.strip() - log.info(f"Impression lancée : {job} ({format_papier} {largeur}x{hauteur}px, x{copies})") - return {"succes": True, "job": job} + 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 échouée (tentative {tentative}/3) : {err.strip()}") + log.warning(f"Impression copie {copie}/{copies} échouée (tentative {tentative}/3) : {err.strip()}") - if tentative < 3: - if _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 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) - return { - "succes": False, - "erreur": ERREUR_IMPRESSION, - "message": "Impression échouée après 3 tentatives", - } + if not job_ok: + return { + "succes": False, + "erreur": ERREUR_IMPRESSION, + "message": f"Impression copie {copie}/{copies} échouée après 3 tentatives", + } + + if copie < copies: + time.sleep(1) + + 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) diff --git a/frontend/css/style.css b/frontend/css/style.css index 0f1df93..abd592e 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -1766,6 +1766,13 @@ h3 { color: var(--primaire); } +.anim-texte { + font-size: clamp(0.9rem, 2.5vw, 1.6rem); + max-width: 80vw; + text-align: center; + line-height: 1.2; +} + /* === Animations compte a rebours === */ /* Classique */ .anim-classique { animation: anim-pop 0.5s ease; } diff --git a/frontend/index.html b/frontend/index.html index 3039dba..c82ad71 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -246,7 +246,7 @@
- +
diff --git a/frontend/js/camera.js b/frontend/js/camera.js index 927efa4..3a97b1b 100644 --- a/frontend/js/camera.js +++ b/frontend/js/camera.js @@ -269,11 +269,14 @@ async function lancerCapture() { for (let i = 0; i < nbPhotos; i++) { if (nbPhotos > 1) { compteurEl.textContent = `Photo ${i + 1} / ${nbPhotos}`; + } else { + compteurEl.textContent = ''; } lancerPreview(); // Miroir live pendant le compte à rebours wsEnvoyer({type: 'prepare_capture'}); - await compteARebours(); + const avecPreMessages = (i === 0); + await compteARebours(avecPreMessages); // Surprise : afficher media juste avant capture await afficherSurprise(); @@ -285,30 +288,29 @@ async function lancerCapture() { flash.offsetHeight; flash.style.animation = ''; - const promesseCapture = apiPost('/api/capturer').catch(() => null); arreterPreview(); - let resultat = await promesseCapture; - flash.classList.add('cache'); - document.getElementById('capture-en-cours').classList.add('cache'); - let erreurReseau = false; + let resultat = await apiPost('/api/capturer').catch(() => null); - if (!resultat) { - erreurReseau = true; - } - - if (erreurReseau) { + if (!resultat && !resultat?.erreur) { + flash.classList.add('cache'); + document.getElementById('capture-en-cours').classList.add('cache'); captureEnCours = false; afficherErreurCapture('Erreur reseau', 'Le serveur ne repond pas — redemarrez le photobooth'); return; } - if (!resultat || resultat.erreur) { + if (resultat?.erreur) { + flash.classList.add('cache'); + document.getElementById('capture-en-cours').classList.add('cache'); captureEnCours = false; - const msg = resultat?.erreur || 'Erreur inconnue'; + const msg = resultat.erreur; afficherErreurCapture('Echec de la capture', msg.includes('Echec') ? 'Verifiez le DSLR et reessayez' : msg); return; } + flash.classList.add('cache'); + document.getElementById('capture-en-cours').classList.add('cache'); + if (resultat.nom) { photosSession.push(resultat.nom); } @@ -338,7 +340,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; @@ -354,6 +356,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; diff --git a/frontend/js/share.js b/frontend/js/share.js index 2010adf..c9b5e83 100644 --- a/frontend/js/share.js +++ b/frontend/js/share.js @@ -9,6 +9,11 @@ async function ouvrirImpression() { document.getElementById('nb-exemplaires').textContent = nbExemplaires; cadreChoisi = null; + 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'); @@ -101,6 +106,8 @@ function changerExemplaires(delta) { async function lancerImpression() { if (!photoFinale) return; fermerImpression(); + 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'); @@ -110,6 +117,7 @@ async function lancerImpression() { cadre: cadreChoisi || undefined, format_papier: formatImpression || undefined, }); + if (btnTerminer) { btnTerminer.disabled = false; btnTerminer.style.opacity = ''; } if (resultat.attente) { afficherStatutEco('⏳', resultat.message || 'Votre photo sortira avec la suivante !', 'attente-eco'); } else if (resultat.jumeau && resultat.succes) {