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>
This commit is contained in:
2026-09-02 18:54:10 +02:00
parent 4e267492c2
commit 583b89f0bd
8 changed files with 179 additions and 85 deletions

View File

@@ -312,6 +312,7 @@ class Camera:
("autopoweroff", 0), ("autopoweroff", 0),
("viewfinder", 1), ("viewfinder", 1),
("iso", "800"), ("iso", "800"),
("aperture", "4"),
("drivemode", "Single"), ("drivemode", "Single"),
] ]
for nom, valeur in reglages: for nom, valeur in reglages:

View File

@@ -38,7 +38,7 @@ def lister_evenements() -> list[dict]:
def creer_evenement(nom: str, **kwargs) -> dict: def creer_evenement(nom: str, **kwargs) -> dict:
event_id = uuid.uuid4().hex[:8] event_id = kwargs.get("event_id") or uuid.uuid4().hex[:8]
event = { event = {
"id": event_id, "id": event_id,
"nom": nom, "nom": nom,

View File

@@ -83,6 +83,7 @@ _canon_stats = {
} }
_CANON_VEILLE_MINUTES = 30 # couper le Canon après X min sans activité utilisateur _CANON_VEILLE_MINUTES = 30 # couper le Canon après X min sans activité utilisateur
_canon_en_veille = False _canon_en_veille = False
_reveil_en_cours = False
def _thread_preview(): def _thread_preview():
"""Thread de fond : capture les frames DSLR. """Thread de fond : capture les frames DSLR.
@@ -103,7 +104,7 @@ def _thread_preview():
_canon_stats["derniere_capture"], _canon_stats["derniere_capture"],
_canon_stats["derniere_preview"], _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 inactif_min = (now - derniere_activite) / 60
if inactif_min >= _CANON_VEILLE_MINUTES and relais.est_connecte(): if inactif_min >= _CANON_VEILLE_MINUTES and relais.est_connecte():
log.info(f"Canon inactif depuis {inactif_min:.0f} min — mise en veille relais") log.info(f"Canon inactif depuis {inactif_min:.0f} min — mise en veille relais")
@@ -124,14 +125,21 @@ def _thread_preview():
_derniere_keepalive = now _derniere_keepalive = now
if ok: if ok:
_canon_stats["keepalive_ok"] += 1 _canon_stats["keepalive_ok"] += 1
_canon_stats["keepalive_fail_consecutifs"] = 0
_canon_stats["dernier_keepalive_ok"] = now _canon_stats["dernier_keepalive_ok"] = now
if now - _dernier_log_keepalive > 300: if now - _dernier_log_keepalive > 300:
log.info(f"Canon keepalive OK (total: {_canon_stats['keepalive_ok']}, fail: {_canon_stats['keepalive_fail']})") log.info(f"Canon keepalive OK (total: {_canon_stats['keepalive_ok']}, fail: {_canon_stats['keepalive_fail']})")
_dernier_log_keepalive = now _dernier_log_keepalive = now
else: else:
_canon_stats["keepalive_fail"] += 1 _canon_stats["keepalive_fail"] += 1
_canon_stats["keepalive_fail_consecutifs"] = _canon_stats.get("keepalive_fail_consecutifs", 0) + 1
_canon_stats["dernier_keepalive_fail"] = now _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) time.sleep(1)
continue continue
try: try:
@@ -362,7 +370,7 @@ async def surveiller_dslr():
await asyncio.sleep(60) await asyncio.sleep(60)
else: else:
await asyncio.sleep(5) await asyncio.sleep(5)
if capture_en_cours: if capture_en_cours or _reveil_en_cours:
continue continue
try: try:
if not GPHOTO2_DISPONIBLE: if not GPHOTO2_DISPONIBLE:
@@ -373,7 +381,13 @@ async def surveiller_dslr():
with camera._gp_lock: with camera._gp_lock:
camera.camera.get_config() camera.camera.get_config()
except Exception: 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: if camera.preview_dslr_ok:
_dslr_erreurs = 0 _dslr_erreurs = 0
_echecs_connexion = 0 _echecs_connexion = 0
@@ -398,14 +412,28 @@ async def surveiller_dslr():
_usb_reset_canon() _usb_reset_canon()
await asyncio.sleep(3) await asyncio.sleep(3)
await _reconnecter_dslr_avec_reset(_echecs_connexion) 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: else:
log.info("DSLR non detecte en USB, tentative USB rebind...") 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) 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: except Exception as e:
log.debug(f"surveiller_dslr: {e}") log.debug(f"surveiller_dslr: {e}")
@@ -457,6 +485,8 @@ async def _reconnecter_dslr_avec_reset(echecs: int):
if camera.connectee: if camera.connectee:
log.info("DSLR reconnecte avec succes") log.info("DSLR reconnecte avec succes")
_canon_stats["connexions"] += 1 _canon_stats["connexions"] += 1
_canon_stats["derniere_preview"] = time.time()
_canon_stats["derniere_capture"] = time.time()
_appliquer_config_camera() _appliquer_config_camera()
await diffuser_ws({"type": "camera_ok"}) await diffuser_ws({"type": "camera_ok"})
else: else:
@@ -618,22 +648,29 @@ async def _surveiller_imprimante():
def _reveiller_canon(): def _reveiller_canon():
"""Réveil Canon depuis veille relais : restaure alimentation, attend boot, reconnecte.""" """Réveil Canon depuis veille relais : restaure alimentation, attend boot, reconnecte."""
global _reveil_en_cours
_reveil_en_cours = True
try:
log.info("Réveil Canon — restauration alimentation relais") log.info("Réveil Canon — restauration alimentation relais")
relais.desactiver("canon") relais.desactiver("canon")
time.sleep(15) time.sleep(15)
relais.desactiver("canon_usb") relais.desactiver("canon_usb")
time.sleep(10) time.sleep(25)
if _canon_sur_usb(): if _canon_sur_usb():
log.info("Canon visible USB après réveil — connexion...") log.info("Canon visible USB après réveil — connexion...")
camera.connecter(source="gphoto2") camera.connecter(source="gphoto2")
if camera.connectee: if camera.connectee:
_appliquer_config_camera() _appliquer_config_camera()
_canon_stats["derniere_preview"] = time.time()
_canon_stats["derniere_capture"] = time.time()
log.info("Canon reconnecté après réveil") log.info("Canon reconnecté après réveil")
_canon_stats["connexions"] += 1 _canon_stats["connexions"] += 1
else: else:
log.warning("Canon visible USB mais connexion gphoto2 échouée") log.warning("Canon visible USB mais connexion gphoto2 échouée")
else: 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: def _canon_sur_usb() -> bool:
@@ -846,9 +883,21 @@ async def api_config_update(modifications: dict):
@app.post("/api/capturer") @app.post("/api/capturer")
async def 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 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["captures"] += 1
_canon_stats["derniere_capture"] = time.time() _canon_stats["derniere_capture"] = time.time()
config = charger_config() config = charger_config()
@@ -1107,7 +1156,7 @@ async def api_relais_veille():
@app.post("/api/relais/reveil") @app.post("/api/relais/reveil")
async def api_relais_reveil(): async def api_relais_reveil():
global _veille_eclairage global _veille_eclairage, _canon_en_veille
_veille_eclairage = False _veille_eclairage = False
if relais.est_connecte(): if relais.est_connecte():
config = charger_config() config = charger_config()
@@ -1116,6 +1165,10 @@ async def api_relais_reveil():
relais.activer("projecteur_gauche") relais.activer("projecteur_gauche")
_proj_etat["proj1"] = True _proj_etat["proj1"] = True
_proj_etat["proj2"] = False _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)") log.info("Eclairage reveille (1 projecteur)")
return {"ok": True} return {"ok": True}
@@ -1634,6 +1687,7 @@ async def api_creer_evenement(donnees: dict):
return JSONResponse({"erreur": "Nom requis"}, status_code=400) return JSONResponse({"erreur": "Nom requis"}, status_code=400)
event = creer_evenement( event = creer_evenement(
nom, nom,
event_id=donnees.get("event_id"),
theme=donnees.get("theme", "base"), theme=donnees.get("theme", "base"),
couleur_primaire=donnees.get("couleur_primaire", "#e91e63"), couleur_primaire=donnees.get("couleur_primaire", "#e91e63"),
couleur_secondaire=donnees.get("couleur_secondaire", "#ffffff"), couleur_secondaire=donnees.get("couleur_secondaire", "#ffffff"),

View File

@@ -302,11 +302,13 @@ def imprimer(
rembobinage = conf_imp.get("rembobinage_ruban", False) rembobinage = conf_imp.get("rembobinage_ruban", False)
try: try:
jobs = []
for copie in range(1, copies + 1):
job_ok = False
for tentative in range(1, 4): for tentative in range(1, 4):
cmd = [ cmd = [
"lp", "lp",
"-d", imprimante, "-d", imprimante,
"-n", str(copies),
"-o", f"PageSize={page_size}", "-o", f"PageSize={page_size}",
"-o", "StpiShrinkOutput=Crop", "-o", "StpiShrinkOutput=Crop",
] ]
@@ -318,10 +320,12 @@ def imprimer(
if code == 0: if code == 0:
job = out.strip() job = out.strip()
log.info(f"Impression lancée : {job} ({format_papier} {largeur}x{hauteur}px, x{copies})") log.info(f"Impression copie {copie}/{copies} lancée : {job} ({format_papier} {largeur}x{hauteur}px)")
return {"succes": True, "job": job} jobs.append(job)
job_ok = True
break
log.warning(f"Impression échouée (tentative {tentative}/3) : {err.strip()}") log.warning(f"Impression copie {copie}/{copies} échouée (tentative {tentative}/3) : {err.strip()}")
if tentative < 3: if tentative < 3:
if _statut(imprimante) == "stopped": if _statut(imprimante) == "stopped":
@@ -341,11 +345,18 @@ def imprimer(
_reactiver(imprimante) _reactiver(imprimante)
time.sleep(2) time.sleep(2)
if not job_ok:
return { return {
"succes": False, "succes": False,
"erreur": ERREUR_IMPRESSION, "erreur": ERREUR_IMPRESSION,
"message": "Impression échouée après 3 tentatives", "message": f"Impression copie {copie}/{copies} échouée après 3 tentatives",
} }
if copie < copies:
time.sleep(1)
log.info(f"Impression terminée : {copies} copie(s), jobs={jobs}")
return {"succes": True, "job": ", ".join(jobs)}
finally: finally:
for tmp in tmp_a_supprimer: for tmp in tmp_a_supprimer:
tmp.unlink(missing_ok=True) tmp.unlink(missing_ok=True)

View File

@@ -1766,6 +1766,13 @@ h3 {
color: var(--primaire); 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 === */ /* === Animations compte a rebours === */
/* Classique */ /* Classique */
.anim-classique { animation: anim-pop 0.5s ease; } .anim-classique { animation: anim-pop 0.5s ease; }

View File

@@ -246,7 +246,7 @@
</div> </div>
<div id="statut-partage" class="statut-partage cache"></div> <div id="statut-partage" class="statut-partage cache"></div>
<div class="partage-bas"> <div class="partage-bas">
<button class="btn-action" onclick="allerA('accueil')">Terminer</button> <button class="btn-action" id="btn-terminer" onclick="allerA('accueil')">Terminer</button>
<button class="btn-secondaire" onclick="recommencer()">Nouvelle photo</button> <button class="btn-secondaire" onclick="recommencer()">Nouvelle photo</button>
</div> </div>
</section> </section>

View File

@@ -269,11 +269,14 @@ async function lancerCapture() {
for (let i = 0; i < nbPhotos; i++) { for (let i = 0; i < nbPhotos; i++) {
if (nbPhotos > 1) { if (nbPhotos > 1) {
compteurEl.textContent = `Photo ${i + 1} / ${nbPhotos}`; compteurEl.textContent = `Photo ${i + 1} / ${nbPhotos}`;
} else {
compteurEl.textContent = '';
} }
lancerPreview(); // Miroir live pendant le compte à rebours lancerPreview(); // Miroir live pendant le compte à rebours
wsEnvoyer({type: 'prepare_capture'}); wsEnvoyer({type: 'prepare_capture'});
await compteARebours(); const avecPreMessages = (i === 0);
await compteARebours(avecPreMessages);
// Surprise : afficher media juste avant capture // Surprise : afficher media juste avant capture
await afficherSurprise(); await afficherSurprise();
@@ -285,30 +288,29 @@ async function lancerCapture() {
flash.offsetHeight; flash.offsetHeight;
flash.style.animation = ''; flash.style.animation = '';
const promesseCapture = apiPost('/api/capturer').catch(() => null);
arreterPreview(); arreterPreview();
let resultat = await promesseCapture; let resultat = await apiPost('/api/capturer').catch(() => null);
if (!resultat && !resultat?.erreur) {
flash.classList.add('cache'); flash.classList.add('cache');
document.getElementById('capture-en-cours').classList.add('cache'); document.getElementById('capture-en-cours').classList.add('cache');
let erreurReseau = false;
if (!resultat) {
erreurReseau = true;
}
if (erreurReseau) {
captureEnCours = false; captureEnCours = false;
afficherErreurCapture('Erreur reseau', 'Le serveur ne repond pas — redemarrez le photobooth'); afficherErreurCapture('Erreur reseau', 'Le serveur ne repond pas — redemarrez le photobooth');
return; return;
} }
if (!resultat || resultat.erreur) { if (resultat?.erreur) {
flash.classList.add('cache');
document.getElementById('capture-en-cours').classList.add('cache');
captureEnCours = false; captureEnCours = false;
const msg = resultat?.erreur || 'Erreur inconnue'; const msg = resultat.erreur;
afficherErreurCapture('Echec de la capture', msg.includes('Echec') ? 'Verifiez le DSLR et reessayez' : msg); afficherErreurCapture('Echec de la capture', msg.includes('Echec') ? 'Verifiez le DSLR et reessayez' : msg);
return; return;
} }
flash.classList.add('cache');
document.getElementById('capture-en-cours').classList.add('cache');
if (resultat.nom) { if (resultat.nom) {
photosSession.push(resultat.nom); photosSession.push(resultat.nom);
} }
@@ -338,7 +340,7 @@ function choisirAnimationAleatoire() {
return pool[Math.floor(Math.random() * pool.length)]; return pool[Math.floor(Math.random() * pool.length)];
} }
async function compteARebours() { async function compteARebours(avecPreMessages = true) {
const conteneur = document.getElementById('compte-a-rebours'); const conteneur = document.getElementById('compte-a-rebours');
const chiffre = document.getElementById('chiffre-car'); const chiffre = document.getElementById('chiffre-car');
const duree = config.camera?.compte_a_rebours || 3; const duree = config.camera?.compte_a_rebours || 3;
@@ -354,6 +356,17 @@ async function compteARebours() {
// Animation CSS classique // Animation CSS classique
conteneur.classList.remove('cache'); conteneur.classList.remove('cache');
if (avecPreMessages) {
const preMessages = ['Attention !', 'Préparez-vous !'];
for (const msg of preMessages) {
chiffre.textContent = msg;
chiffre.className = 'anim-chiffre anim-texte';
void chiffre.offsetWidth;
chiffre.classList.add('anim-' + anim.id);
await pause(1000);
}
}
for (let i = duree; i > 0; i--) { for (let i = duree; i > 0; i--) {
if (anim.id === 'emoji') { if (anim.id === 'emoji') {
chiffre.textContent = EMOJI_CAR[i] || i; chiffre.textContent = EMOJI_CAR[i] || i;

View File

@@ -9,6 +9,11 @@ async function ouvrirImpression() {
document.getElementById('nb-exemplaires').textContent = nbExemplaires; document.getElementById('nb-exemplaires').textContent = nbExemplaires;
cadreChoisi = null; cadreChoisi = null;
if (modeActuel === 'multi') {
document.getElementById('form-impression').classList.remove('cache');
return;
}
const hasCadres = await chargerCadresChoixImpression(); const hasCadres = await chargerCadresChoixImpression();
if (hasCadres) { if (hasCadres) {
document.getElementById('popup-cadre-impression').classList.remove('cache'); document.getElementById('popup-cadre-impression').classList.remove('cache');
@@ -101,6 +106,8 @@ function changerExemplaires(delta) {
async function lancerImpression() { async function lancerImpression() {
if (!photoFinale) return; if (!photoFinale) return;
fermerImpression(); 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 // Pour les strips, imprimer la version 2 bandes sur 10x15
const fichierImpression = photoImpression || photoFinale; const fichierImpression = photoImpression || photoFinale;
afficherStatut(`Impression de ${nbExemplaires} exemplaire(s)...`, 'succes'); afficherStatut(`Impression de ${nbExemplaires} exemplaire(s)...`, 'succes');
@@ -110,6 +117,7 @@ async function lancerImpression() {
cadre: cadreChoisi || undefined, cadre: cadreChoisi || undefined,
format_papier: formatImpression || undefined, format_papier: formatImpression || undefined,
}); });
if (btnTerminer) { btnTerminer.disabled = false; btnTerminer.style.opacity = ''; }
if (resultat.attente) { if (resultat.attente) {
afficherStatutEco('⏳', resultat.message || 'Votre photo sortira avec la suivante !', 'attente-eco'); afficherStatutEco('⏳', resultat.message || 'Votre photo sortira avec la suivante !', 'attente-eco');
} else if (resultat.jumeau && resultat.succes) { } else if (resultat.jumeau && resultat.succes) {