diff --git a/backend/camera.py b/backend/camera.py index 585ca14..ecdc58d 100644 --- a/backend/camera.py +++ b/backend/camera.py @@ -88,11 +88,12 @@ class Camera: pass cam.init() self.camera = cam - self._desactiver_veille_init() - self._activer_viewfinder_init() + time.sleep(1) + self._configurer_init() self.connectee = True self.mode = "gphoto2" self.preview_dslr_ok = True + self._log_reglages() log.info("Camera DSLR connectee (LiveView actif)") return True except Exception as e: @@ -190,23 +191,36 @@ class Camera: 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 ) + t2 = time.time() tmp_path = str(chemin_dest) + ".tmp" fichier_camera.save(tmp_path) + log.info(f"Capture: shutter={t1-t0:.1f}s download={t2-t1:.1f}s") threading.Thread(target=self._post_capture_warmup, daemon=True).start() from PIL import ImageOps as PILImageOps - img = PILImageOps.exif_transpose(PILImage.open(tmp_path)) + t3 = time.time() + pil_img = PILImage.open(tmp_path) + exif_data = pil_img.info.get("exif") + img = PILImageOps.exif_transpose(pil_img) if img.width > 4000: 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 @@ -292,35 +306,48 @@ class Camera: except Exception as e: log.debug(f"configurer_flash : {e}") - def _desactiver_veille_init(self): - """Desactive la mise en veille auto du boitier (Canon : autopoweroff en minutes, 0=jamais). - Sans ca, le DSLR s'eteint seul apres quelques minutes d'inactivite et apparait deconnecte.""" - candidats = ["autopoweroff", "auto_power_off", "/main/settings/autopoweroff"] - try: - cfg = self.camera.get_config() - for nom in candidats: + def _configurer_init(self): + """Configure le Canon : veille OFF, ISO, drivemode, viewfinder (appels séparés).""" + reglages = [ + ("autopoweroff", 0), + ("viewfinder", 1), + ("iso", "800"), + ("drivemode", "Single"), + ] + for nom, valeur in reglages: + for tentative in range(3): try: - widget = cfg.get_child_by_name(nom) - widget.set_value(0) + cfg = self.camera.get_config() + w = cfg.get_child_by_name(nom) + w.set_value(valeur) self.camera.set_config(cfg) - log.info(f"Mise en veille DSLR désactivée ({nom})") - return + 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: - continue - log.debug("Widget autopoweroff non trouvé sur ce modèle") - except Exception as e: - log.debug(f"_desactiver_veille_init : {e}") + break - def _activer_viewfinder_init(self): - """Active le LiveView pendant l'init (pas de lock, appelé avant que le thread démarre).""" + def _log_reglages(self): + """Log les réglages Canon actuels pour diagnostic.""" try: 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é (init)") - except Exception as e: - log.warning(f"Viewfinder non supporté : {e}") + 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.""" diff --git a/backend/destinations.py b/backend/destinations.py index 1b40830..65366c4 100644 --- a/backend/destinations.py +++ b/backend/destinations.py @@ -203,16 +203,28 @@ def detecter_usb() -> list[str]: 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", 400) - conso.get("papier_utilise", 0)) + ruban_rest = max(0, round(conso.get("ruban_capacite", 400) - conso.get("ruban_utilise", 0), 1)) + restantes = int(min(papier_rest, ruban_rest)) + limite_event = compteur.get("limite", 0) + if compteur.get("actif") and limite_event > 0: + restantes = min(restantes, max(0, limite_event - prises)) + papier_cap = conso.get("papier_capacite", 400) + ruban_cap = conso.get("ruban_capacite", 400) + capacite_conso = int(min(papier_cap, ruban_cap)) return { "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), } diff --git a/backend/eclairage.py b/backend/eclairage.py index 0fce36b..591fa11 100644 --- a/backend/eclairage.py +++ b/backend/eclairage.py @@ -28,7 +28,7 @@ SEUIL_SOMBRE = 45 SEUIL_CORRECT = 65 _dernier_analyse: float = 0 -_INTERVALLE = 3.0 +_INTERVALLE = 1.0 _dernier_resultat: dict | None = None diff --git a/backend/evenements.py b/backend/evenements.py index af2f4c1..9e72d99 100644 --- a/backend/evenements.py +++ b/backend/evenements.py @@ -2,6 +2,7 @@ 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 @@ -46,6 +47,8 @@ def creer_evenement(nom: str, **kwargs) -> dict: "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") @@ -86,6 +89,38 @@ def supprimer_evenement(event_id: str) -> bool: 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: diff --git a/backend/main.py b/backend/main.py index b71f39a..c1fcba1 100644 --- a/backend/main.py +++ b/backend/main.py @@ -35,6 +35,7 @@ from backend import relais from backend.evenements import ( lister_evenements, creer_evenement, obtenir_evenement, modifier_evenement, supprimer_evenement, activer_evenement, + terminer_evenement, evenement_est_termine, lister_cadres_event, set_cadre_event, cadre_actif_pour_impression, ) from backend.evenements import DOSSIER_EVENEMENTS @@ -64,29 +65,93 @@ _PREVIEW_GRACE_FRAMES = 40 # 40 x 50ms = 2s avant de declarer une erreur (Canon _derniere_keepalive = 0.0 +# --- Suivi activité Canon pour diagnostic et gestion veille --- +_canon_stats = { + "derniere_capture": 0.0, + "derniere_preview": 0.0, + "dernier_keepalive_ok": 0.0, + "dernier_keepalive_fail": 0.0, + "keepalive_ok": 0, + "keepalive_fail": 0, + "preview_ok": 0, + "preview_fail": 0, + "captures": 0, + "connexions": 0, + "deconnexions": 0, + "power_cycles": 0, + "demarrage": time.time(), +} +_CANON_VEILLE_MINUTES = 30 # couper le Canon après X min sans activité utilisateur +_canon_en_veille = False + def _thread_preview(): - """Thread de fond : capture les frames DSLR en continu. - Tourne TOUJOURS quand le DSLR est connecte pour maintenir le miroir leve. - Envoie un keepalive PTP toutes les 30s meme quand le preview n'est pas actif.""" - global _derniere_frame_preview, _derniere_keepalive + """Thread de fond : capture les frames DSLR. + Actif seulement quand _preview_actif=True (ecran capture). + Sinon keepalive leger toutes les 30s pour empecher le Canon de s'eteindre. + Gère la mise en veille Canon après inactivité prolongée.""" + global _derniere_frame_preview, _derniere_keepalive, _canon_en_veille + _dernier_log_keepalive = 0.0 while True: try: if capture_en_cours or camera.mode != "gphoto2" or not camera.connectee: - time.sleep(0.05) + time.sleep(0.2) + continue + + # Vérifier inactivité prolongée → couper Canon via relais + now = time.time() + derniere_activite = max( + _canon_stats["derniere_capture"], + _canon_stats["derniere_preview"], + ) + if derniere_activite > 0 and not _preview_actif and not _canon_en_veille: + 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") + _canon_en_veille = True + camera.deconnecter() + relais.activer("canon") + relais.activer("canon_usb") + _canon_stats["power_cycles"] += 1 + continue + + if _canon_en_veille: + time.sleep(2) + continue + + if not _preview_actif: + if now - _derniere_keepalive > 30: + ok = camera.keepalive() + _derniere_keepalive = now + if ok: + _canon_stats["keepalive_ok"] += 1 + _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["dernier_keepalive_fail"] = now + log.warning(f"Canon keepalive FAIL #{_canon_stats['keepalive_fail']}") + time.sleep(1) continue try: donnees = camera.preview() with _preview_lock: _derniere_frame_preview = donnees - _derniere_keepalive = time.time() + _derniere_keepalive = now + if donnees: + _canon_stats["preview_ok"] += 1 + _canon_stats["derniere_preview"] = now + else: + _canon_stats["preview_fail"] += 1 except Exception: with _preview_lock: _derniere_frame_preview = None - # Preview echoue → keepalive de secours via get_config - if time.time() - _derniere_keepalive > 10: + _canon_stats["preview_fail"] += 1 + if now - _derniere_keepalive > 10: camera.keepalive() - _derniere_keepalive = time.time() - time.sleep(0.033) # ~30 fps max + _derniere_keepalive = now + time.sleep(0.05) # ~20 fps max except Exception as e: log.error(f"_thread_preview crash: {e}") time.sleep(1) @@ -126,29 +191,32 @@ async def _pusher_preview(): _gerer_eclairage_auto(eclairage) _proj_etat = {"proj1": False, "proj2": False} +_veille_eclairage = False +_nb_proj_capture = 2 +_preview_eclairage_init = False def _gerer_eclairage_auto(eclairage: dict): + """Preview : 1 projecteur ON pour estimer le besoin. Le score détermine combien pour la capture.""" + global _nb_proj_capture, _preview_eclairage_init + if _veille_eclairage or capture_en_cours: + return config = charger_config() rcfg = config.get("relais", {}) if not rcfg.get("eclairage_auto") or not relais.est_connecte(): return + if not _preview_eclairage_init: + relais.activer("projecteur_gauche") + _proj_etat["proj1"] = True + _proj_etat["proj2"] = False + _preview_eclairage_init = True score = eclairage.get("score", -1) if score < 0: return - seuil_1 = rcfg.get("seuil_proj1", 45) seuil_2 = rcfg.get("seuil_proj2", 30) - seuil_off = rcfg.get("seuil_off", 65) - if score >= seuil_off and (_proj_etat["proj1"] or _proj_etat["proj2"]): - relais.projecteurs(False) - _proj_etat["proj1"] = False - _proj_etat["proj2"] = False - elif score < seuil_2 and not _proj_etat["proj2"]: - relais.projecteurs(True) - _proj_etat["proj1"] = True - _proj_etat["proj2"] = True - elif score < seuil_1 and not _proj_etat["proj1"]: - relais.activer("projecteur_gauche") - _proj_etat["proj1"] = True + if score < seuil_2: + _nb_proj_capture = 2 + else: + _nb_proj_capture = 1 def _usb_reset_canon(): @@ -170,6 +238,58 @@ def _usb_reset_canon(): log.debug(f"_usb_reset_canon: {e}") +def _ptp_reset_canon(): + """Envoie un PTP USB Device Reset (class request 0x66) + Clear Halt endpoints. + Cette commande atteint le firmware PTP même quand le Canon est figé en I/O error, + car elle utilise le control transfer USB (endpoint 0) et non les bulk endpoints.""" + try: + import usb.core, usb.util, struct + dev = usb.core.find(idVendor=0x04a9) + if not dev: + log.debug("_ptp_reset_canon: Canon non trouvé en USB") + return False + for cfg in dev: + for intf in cfg: + try: + if dev.is_kernel_driver_active(intf.bInterfaceNumber): + dev.detach_kernel_driver(intf.bInterfaceNumber) + except Exception: + pass + dev.set_configuration() + cfg = dev.get_active_configuration() + intf = cfg[(0, 0)] + ep_out = usb.util.find_descriptor(intf, custom_match=lambda e: usb.util.endpoint_direction(e.bEndpointAddress) == usb.util.ENDPOINT_OUT) + ep_in = usb.util.find_descriptor(intf, custom_match=lambda e: usb.util.endpoint_direction(e.bEndpointAddress) == usb.util.ENDPOINT_IN and e.bmAttributes == 2) + + dev.ctrl_transfer(0x21, 0x66, 0, 0, None, timeout=5000) + log.info("PTP USB Device Reset (0x66) envoyé") + time.sleep(1) + if ep_out: + try: + dev.clear_halt(ep_out) + except Exception: + pass + if ep_in: + try: + dev.clear_halt(ep_in) + except Exception: + pass + try: + status = dev.ctrl_transfer(0xA1, 0x67, 0, 0, 12, timeout=5000) + code = struct.unpack_from(" 0: _echecs_connexion += 1 log.info(f"DSLR detecte : {dslrs[0][0]}, connexion automatique... (tentative {_echecs_connexion})") + if _echecs_connexion <= 2: + await asyncio.sleep(5) + else: + _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) except Exception as e: log.debug(f"surveiller_dslr: {e}") @@ -271,17 +413,31 @@ async def surveiller_dslr(): async def _reconnecter_dslr_avec_reset(echecs: int): """Reconnexion DSLR avec escalade progressive : 1 : simple reconnexion - 2 : ioctl reset + 2 : PTP class reset (0x66) + ioctl reset 3-5 : unbind/rebind USB - 6+ : uhubctl power cycle (coupe 5V du port) - 10+ : backoff 60s entre tentatives + 6-9 : PTP reset + unbind/rebind (plus agressif) + 10+ : relay hard reset (si câblé) + backoff long + 20+ : dormant (5 min entre tentatives) """ camera.deconnecter() + _canon_stats["deconnexions"] += 1 + import gc; gc.collect() await diffuser_ws({"type": "camera_erreur", "message": "Appareil photo deconnecte"}) - if echecs >= 6: - log.info(f"USB power cycle Canon (echec #{echecs})...") - await asyncio.get_event_loop().run_in_executor(None, _usb_power_cycle_canon) + if echecs >= 20: + log.info(f"Mode dormant (echec #{echecs}) — attente 5 min avant prochaine tentative") + await diffuser_ws({"type": "camera_erreur", "message": "Appareil photo injoignable — redémarrez-le physiquement"}) + await asyncio.sleep(300) + elif echecs >= 10 and echecs % 5 == 0 and relais.est_connecte(): + log.warning(f"Hard reset Canon (echec #{echecs}) — trappe + coupure alim 30s") + await diffuser_ws({"type": "camera_erreur", "message": "Reset complet appareil photo..."}) + await asyncio.get_event_loop().run_in_executor(None, relais.hard_reset_canon, 30.0) + await asyncio.sleep(20) + elif echecs >= 6: + log.info(f"PTP reset + rebind Canon (echec #{echecs})...") + await asyncio.get_event_loop().run_in_executor(None, _ptp_reset_canon) + await asyncio.sleep(2) + await asyncio.get_event_loop().run_in_executor(None, _usb_rebind_canon) elif echecs >= 3: log.info(f"USB rebind Canon (echec #{echecs})...") ok = await asyncio.get_event_loop().run_in_executor(None, _usb_rebind_canon) @@ -289,15 +445,18 @@ async def _reconnecter_dslr_avec_reset(echecs: int): _usb_reset_canon() await asyncio.sleep(4) elif echecs >= 2: - log.info(f"Reset USB Canon (echec #{echecs})...") + log.info(f"PTP reset + USB reset Canon (echec #{echecs})...") + await asyncio.get_event_loop().run_in_executor(None, _ptp_reset_canon) + await asyncio.sleep(1) _usb_reset_canon() - await asyncio.sleep(4) + await asyncio.sleep(3) else: await asyncio.sleep(2) camera.connecter(source="gphoto2") if camera.connectee: log.info("DSLR reconnecte avec succes") + _canon_stats["connexions"] += 1 _appliquer_config_camera() await diffuser_ws({"type": "camera_ok"}) else: @@ -329,27 +488,243 @@ async def _watchdog_systemd(): pass +def _diagnostiquer_erreur_imprimante(nom: str) -> dict: + """Parse les logs CUPS pour identifier le type d'erreur imprimante.""" + import subprocess + try: + r = subprocess.run( + ["sudo", "tail", "-20", "/var/log/cups/error_log"], + capture_output=True, text=True, timeout=5 + ) + logs = r.stdout.lower() + if "ribbon" in logs and ("end" in logs or "count" in logs): + return { + "situation": "changement_rouleau", + "titre": "Ruban termine", + "message": "Le ruban d'impression est termine. Il faut le remplacer.", + "etapes": [ + "Ouvrez le capot de l'imprimante", + "Retirez la cassette ruban usagee", + "Inserez une cassette ruban neuve (sens indique par la fleche)", + "Refermez le capot jusqu'au clic", + ], + "code": "05/02/02", + } + if "paper" in logs and ("end" in logs or "empty" in logs or "out" in logs): + return { + "situation": "changement_rouleau", + "titre": "Papier termine", + "message": "Le papier est termine. Il faut recharger le bac.", + "etapes": [ + "Ouvrez le capot de l'imprimante", + "Retirez le bac papier vide", + "Chargez un nouveau rouleau de papier (face brillante vers le haut)", + "Refermez le capot jusqu'au clic", + ], + "code": "paper_end", + } + if "jam" in logs or "bourrage" in logs: + return { + "situation": "bourrage", + "titre": "Bourrage papier", + "message": "Un bourrage papier a ete detecte.", + "etapes": [ + "Ouvrez le capot de l'imprimante", + "Retirez delicatement le papier coince (ne pas tirer fort)", + "Verifiez qu'il ne reste pas de morceaux", + "Refermez le capot jusqu'au clic", + ], + "code": "jam", + } + if "media" in logs and "mismatch" in logs: + return { + "situation": "depannage_imprimante", + "titre": "Mauvais format", + "message": "Le format papier ne correspond pas a la cassette inseree.", + "etapes": [ + "Ouvrez le capot de l'imprimante", + "Verifiez que la cassette correspond au format (10x15 ou 15x20)", + "Retirez et reinserez la cassette correctement", + "Refermez le capot", + ], + "code": "media_mismatch", + } + if "offline" in logs or "not connected" in logs: + return { + "situation": "depannage_imprimante", + "titre": "Imprimante deconnectee", + "message": "L'imprimante n'est pas detectee.", + "etapes": [ + "Verifiez que l'imprimante est allumee (voyant vert)", + "Verifiez le cable USB entre l'imprimante et la borne", + "Eteignez et rallumez l'imprimante", + "Si le probleme persiste, redemarrez la borne", + ], + "code": "offline", + } + except Exception: + pass + return { + "situation": "depannage_imprimante", + "titre": "Probleme imprimante", + "message": f"L'imprimante {nom} ne fonctionne pas correctement.", + "etapes": [ + "Verifiez que l'imprimante est allumee", + "Verifiez le cable USB", + "Ouvrez le capot et verifiez papier et ruban", + "Eteignez et rallumez l'imprimante", + ], + "code": "unknown", + } + + +async def _surveiller_imprimante(): + """Surveille l'imprimante CUPS en continu : réactive si disabled, notifie les clients.""" + import subprocess + _derniere_alerte = 0 + _dernier_code = None + while True: + await asyncio.sleep(30) + try: + config = charger_config() + if not config.get("fonctionnalites", {}).get("impression", False): + continue + nom = config.get("impression", {}).get("imprimante") or "Mitsubishi" + r = subprocess.run(["lpstat", "-p", nom], capture_output=True, text=True, timeout=5) + out = r.stdout.lower() + if "disabled" in out or "stopped" in out: + log.warning(f"Imprimante {nom} désactivée — réactivation automatique") + subprocess.run(["sudo", "cupsenable", nom], capture_output=True, timeout=5) + await asyncio.sleep(2) + r2 = subprocess.run(["lpstat", "-p", nom], capture_output=True, text=True, timeout=5) + if "disabled" not in r2.stdout.lower() and "stopped" not in r2.stdout.lower(): + log.info(f"Imprimante {nom} réactivée avec succès") + _dernier_code = None + await diffuser_ws({"type": "imprimante_ok"}) + else: + now = time.time() + diag = _diagnostiquer_erreur_imprimante(nom) + if now - _derniere_alerte > 300 or diag["code"] != _dernier_code: + _derniere_alerte = now + _dernier_code = diag["code"] + await diffuser_ws({"type": "imprimante_erreur", **diag}) + else: + if _dernier_code is not None: + _dernier_code = None + await diffuser_ws({"type": "imprimante_ok"}) + except Exception as e: + log.debug(f"_surveiller_imprimante: {e}") + + +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 + 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") + + +def _canon_sur_usb() -> bool: + """Vérifie si un Canon (vendor 04a9) est visible sur le bus USB.""" + import glob + for f in glob.glob("/sys/bus/usb/devices/*/idVendor"): + try: + if open(f).read().strip() == "04a9": + return True + except Exception: + pass + return False + + +async def _sequence_boot_canon(): + """Séquençage propre du Canon au démarrage via relais. + Coupe l'alimentation, attend la vidange, restaure alim puis USB. + Donne au Canon le temps de booter avant toute tentative gphoto2.""" + if not relais.est_connecte(): + log.info("Pas de relais — skip sequence boot Canon") + return + + if _canon_sur_usb(): + log.info("Canon déjà visible USB — skip power cycle") + return + + log.info("Canon absent USB — séquence boot relais (power cycle 10s + attente 25s)") + loop = asyncio.get_event_loop() + + def _do_power_cycle(): + relais.activer("canon") + relais.activer("canon_usb") + time.sleep(10) + relais.desactiver("canon") + time.sleep(15) + relais.desactiver("canon_usb") + + await loop.run_in_executor(None, _do_power_cycle) + log.info("Canon alimenté — attente boot 25s...") + await asyncio.sleep(25) + + if _canon_sur_usb(): + log.info("Canon visible USB après power cycle relais") + else: + log.warning("Canon toujours absent USB après power cycle — problème physique probable") + + +async def _connecter_camera_bg(): + """Connexion caméra en arrière-plan — ne bloque pas le serveur. + Si relais dispo et Canon absent USB, fait un power cycle propre d'abord.""" + if GPHOTO2_DISPONIBLE: + await _sequence_boot_canon() + + for _tentative in range(5): + if camera.connecter(): + _appliquer_config_camera() + log.info("Camera connectee en arriere-plan") + return + if _tentative < 2: + log.info(f"Connexion camera echouee (tentative {_tentative+1}/5), attente 5s...") + await asyncio.sleep(5) + else: + log.warning(f"Connexion camera echouee (tentative {_tentative+1}/5), USB reset...") + _usb_reset_canon() + import gc; gc.collect() + await asyncio.sleep(3) + log.warning("Camera non connectee au demarrage — surveiller_dslr prendra le relais") + + @asynccontextmanager async def lifespan(app: FastAPI): """Demarrage et arret de l'application.""" global _loop_principal log.info("Demarrage du photobooth") _loop_principal = asyncio.get_event_loop() - camera.connecter() - _appliquer_config_camera() + relais.connecter() # Thread de capture preview (bloquant, tourne en parallele) t = threading.Thread(target=_thread_preview, daemon=True) t.start() + task_camera = asyncio.create_task(_connecter_camera_bg()) task_dslr = asyncio.create_task(surveiller_dslr()) task_push = asyncio.create_task(_pusher_preview()) task_spool = asyncio.create_task(tache_spool_demarrage()) task_watchdog = asyncio.create_task(_watchdog_systemd()) - relais.connecter() + task_imprimante = asyncio.create_task(_surveiller_imprimante()) yield task_dslr.cancel() task_push.cancel() task_spool.cancel() task_watchdog.cancel() + task_imprimante.cancel() relais.deconnecter() log.info("Arret du photobooth") camera.deconnecter() @@ -361,6 +736,7 @@ app = FastAPI(title="Photobooth", lifespan=lifespan) app.mount("/assets", StaticFiles(directory=str(RACINE / "frontend" / "assets")), name="assets") app.mount("/css", StaticFiles(directory=str(RACINE / "frontend" / "css")), name="css") app.mount("/js", StaticFiles(directory=str(RACINE / "frontend" / "js")), name="js") +app.mount("/sounds", StaticFiles(directory=str(RACINE / "frontend" / "sounds")), name="sounds") app.mount("/data", StaticFiles(directory=str(RACINE / "data")), name="data") @@ -473,7 +849,16 @@ async def api_capturer(): global capture_en_cours, _preview_actif capture_en_cours = True _preview_actif = False # Eviter faux camera_erreur (race condition HTTP avant preview_stop) - log.info("Capture déclenchée — preview figé, thread en attente de lock") + _canon_stats["captures"] += 1 + _canon_stats["derniere_capture"] = time.time() + config = charger_config() + _flash = config.get("relais", {}).get("eclairage_auto") and relais.est_connecte() + if _flash: + relais.projecteurs(True) + _proj_etat["proj1"] = True + _proj_etat["proj2"] = True + log.info("Capture déclenchée — projecteurs déjà ON depuis countdown") + await diffuser_ws({"type": "shutter"}) try: loop = asyncio.get_event_loop() try: @@ -488,6 +873,12 @@ async def api_capturer(): return JSONResponse({"erreur": "Timeout capture — vérifiez le DSLR"}, status_code=500) finally: capture_en_cours = False + if _flash: + relais.projecteurs(False) + _proj_etat["proj1"] = False + _proj_etat["proj2"] = False + global _preview_eclairage_init + _preview_eclairage_init = False log.info(f"Capture terminée : {chemin}") if chemin is None: if camera.mode == "gphoto2" and not camera.preview_dslr_ok: @@ -675,6 +1066,15 @@ async def api_power_cycle_canon(req: Request): return {"ok": True} +@app.post("/api/relais/canon/hard-reset") +async def api_hard_reset_canon(req: Request): + body = await req.json() if req.headers.get("content-type", "").startswith("application/json") else {} + duree = body.get("duree", 3.0) + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, relais.hard_reset_canon, duree) + return {"ok": True} + + @app.get("/api/relais/config") async def api_relais_config(): config = charger_config() @@ -693,6 +1093,33 @@ async def api_relais_config_save(req: Request): return {"ok": True} +@app.post("/api/relais/veille") +async def api_relais_veille(): + global _veille_eclairage + _veille_eclairage = True + if relais.est_connecte(): + relais.projecteurs(False) + _proj_etat["proj1"] = False + _proj_etat["proj2"] = False + log.info("Eclairage en veille") + return {"ok": True} + + +@app.post("/api/relais/reveil") +async def api_relais_reveil(): + global _veille_eclairage + _veille_eclairage = False + if relais.est_connecte(): + config = charger_config() + rcfg = config.get("relais", {}) + if rcfg.get("eclairage_auto"): + relais.activer("projecteur_gauche") + _proj_etat["proj1"] = True + _proj_etat["proj2"] = False + log.info("Eclairage reveille (1 projecteur)") + return {"ok": True} + + @app.get("/api/camera/debug") async def api_camera_debug(): """Diagnostic complet de la caméra (preview, viewfinder, erreurs gphoto2).""" @@ -713,22 +1140,52 @@ async def api_camera_debug(): except Exception as e: info["test_preview"] = f"ERREUR : {e}" try: - cfg_names = [] + expo = {} with camera._gp_lock: cfg = camera.camera.get_config() - for i in range(cfg.count_children()): - child = cfg.get_child(i) - cfg_names.append(child.get_name()) - info["config_widgets_top"] = cfg_names + for nom in ["autoexposuremode", "iso", "shutterspeed", "aperture", "meteringmode", "whitebalance"]: + try: + w = cfg.get_child_by_name(nom) + expo[nom] = w.get_value() + except Exception: + pass + info["exposition"] = expo + mode = expo.get("autoexposuremode", "") + if mode in ("Flash Off", "Auto", "Night Portrait", "Landscape", "Portrait", "Sports"): + info["avertissement"] = f"Canon en mode scène '{mode}' — vitesse/ISO auto non modifiables. Tourner le dial sur M ou Av." except Exception as e: - info["config_widgets_top"] = f"ERREUR : {e}" + info["exposition"] = f"ERREUR : {e}" return info +@app.get("/api/canon/stats") +async def api_canon_stats(): + """Stats diagnostic Canon : keepalive, preview, connexions, veille.""" + uptime = time.time() - _canon_stats["demarrage"] + stats = dict(_canon_stats) + stats["uptime_min"] = round(uptime / 60, 1) + stats["canon_en_veille"] = _canon_en_veille + stats["canon_sur_usb"] = _canon_sur_usb() + stats["preview_actif"] = _preview_actif + stats["veille_apres_min"] = _CANON_VEILLE_MINUTES + derniere_activite = max(stats["derniere_capture"], stats["derniere_preview"]) + if derniere_activite > 0: + stats["inactif_min"] = round((time.time() - derniere_activite) / 60, 1) + else: + stats["inactif_min"] = None + return stats + + @app.post("/api/camera/reconnecter") async def api_camera_reconnecter(body: dict = {}): camera.deconnecter() + import gc; gc.collect() source = body.get("source") + # PTP reset avant reconnexion pour débloquer un éventuel freeze + await asyncio.get_event_loop().run_in_executor(None, _ptp_reset_canon) + await asyncio.sleep(1) + _usb_reset_canon() + await asyncio.sleep(2) ok = camera.connecter(source=source) if ok: _appliquer_config_camera() @@ -853,16 +1310,17 @@ async def api_statut_imprimante(): """Retourne le statut détaillé CUPS + dernière erreur du log.""" import subprocess config = charger_config() - nom = config.get("impression", {}).get("imprimante", "Mitsubishi") - # Statut lpstat - r = subprocess.run(["lpstat", "-p", nom], capture_output=True, text=True) - statut_ligne = r.stdout.strip() - # Dernière erreur dans error_log + nom = config.get("impression", {}).get("imprimante") or "Mitsubishi" + try: + r = subprocess.run(["lpstat", "-p", nom], capture_output=True, text=True, timeout=5) + statut_ligne = r.stdout.strip() + except Exception: + statut_ligne = "Impossible de lire le statut CUPS" try: r2 = subprocess.run( ["sudo", "grep", f"\\[{nom}\\]\\|Job.*cancel\\|media.*match\\|jam\\|paper", "/var/log/cups/error_log"], - capture_output=True, text=True + capture_output=True, text=True, timeout=5 ) lignes = [l for l in r2.stdout.splitlines() if "error_log" not in l] derniere_erreur = lignes[-1] if lignes else "" @@ -880,7 +1338,7 @@ async def api_evacuer_bourrage(): """Annule tous les jobs, réactive l'imprimante, retourne le dernier message d'erreur.""" import subprocess config = charger_config() - nom = config.get("impression", {}).get("imprimante", "Mitsubishi") + nom = config.get("impression", {}).get("imprimante") or "Mitsubishi" subprocess.run(["sudo", "cancel", "-a", nom], capture_output=True) subprocess.run(["sudo", "cupsenable", nom], capture_output=True) # Lire la dernière erreur CUPS pour informer l'utilisateur @@ -915,7 +1373,7 @@ async def api_couper_papier(): import subprocess, tempfile, os from PIL import Image config = charger_config() - nom = config.get("impression", {}).get("imprimante", "Mitsubishi") + nom = config.get("impression", {}).get("imprimante") or "Mitsubishi" try: # Image blanche 1x15cm (la plus petite possible pour la K60) img = Image.new("RGB", (600, 1800), (255, 255, 255)) @@ -954,7 +1412,7 @@ async def api_reset_usb(): dev_id = path.split("/")[-1] # Annuler les jobs d'abord - nom = charger_config().get("impression", {}).get("imprimante", "Mitsubishi") + nom = charger_config().get("impression", {}).get("imprimante") or "Mitsubishi" subprocess.run(["sudo", "cancel", "-a", nom], capture_output=True) # Unbind/rebind USB subprocess.run(["sudo", "sh", "-c", f"echo '{dev_id}' > /sys/bus/usb/drivers/usb/unbind"], @@ -971,13 +1429,14 @@ async def api_reset_usb(): @app.post("/api/imprimer") async def api_imprimer(donnees: dict): + if evenement_est_termine(): + return JSONResponse({"succes": False, "erreur": "evenement_termine", + "message": "L'evenement est termine — impression desactivee"}, status_code=403) nom = donnees.get("photo", "") copies = donnees.get("copies", 1) - # Limiter au max configure config = charger_config() copies_max = config.get("impression", {}).get("copies_max", 5) copies = max(1, min(copies, copies_max)) - # Chercher dans exports d'abord, puis photos chemin = DOSSIER_EXPORTS / nom if not chemin.exists(): chemin = DOSSIER_PHOTOS / nom @@ -985,7 +1444,11 @@ async def api_imprimer(donnees: dict): return JSONResponse({"erreur": "Photo introuvable"}, status_code=404) cadre_override = donnees.get("cadre") format_papier = donnees.get("format_papier") or None - resultat = imprimer(chemin, copies=copies, cadre_override=cadre_override, format_papier=format_papier) + try: + resultat = imprimer(chemin, copies=copies, cadre_override=cadre_override, format_papier=format_papier) + except Exception as e: + log.error(f"Erreur impression inattendue : {e}") + return {"succes": False, "erreur": "erreur_impression", "message": f"Erreur interne : {e}"} if resultat.get("succes"): distribuer_photo(chemin, imprimee=True, copies=copies, format_papier=format_papier) return resultat @@ -1175,6 +1638,7 @@ async def api_creer_evenement(donnees: dict): couleur_primaire=donnees.get("couleur_primaire", "#e91e63"), couleur_secondaire=donnees.get("couleur_secondaire", "#ffffff"), media_accueil=donnees.get("media_accueil"), + date_fin=donnees.get("date_fin"), ) return event @@ -1221,6 +1685,15 @@ async def api_activer_evenement(event_id: str): return event +@app.post("/api/evenements/{event_id}/terminer") +async def api_terminer_evenement(event_id: str): + event = terminer_evenement(event_id) + if not event: + return JSONResponse({"erreur": "Evenement introuvable"}, status_code=404) + await diffuser_ws({"type": "config_maj", "config": charger_config()}) + return event + + @app.get("/api/evenements/{event_id}/cadres/{format_papier}") async def api_cadres_event(event_id: str, format_papier: str): cadres = lister_cadres_event(event_id, format_papier) @@ -1520,10 +1993,11 @@ FICHIER_VIDEOS_CONFIG = DOSSIER_VIDEOS / "config.json" SITUATIONS_DEFAUT = [ {"id": "montage", "label": "Montage / Mise en service"}, {"id": "demontage", "label": "Demontage"}, + {"id": "camera_hs", "label": "Appareil photo ne repond pas"}, {"id": "bourrage", "label": "Bourrage imprimante"}, {"id": "changement_rouleau", "label": "Changement rouleau / ruban"}, - {"id": "wifi", "label": "Probleme WiFi"}, {"id": "depannage_imprimante", "label": "Depannage imprimante"}, + {"id": "wifi", "label": "Probleme WiFi"}, ] def charger_videos_config(): @@ -1784,6 +2258,168 @@ async def api_quitter_navigateur(): return {"succes": True} +@app.post("/api/systeme/redemarrer-backend") +async def api_redemarrer_backend(): + """Tue le processus backend — le kiosk-session.sh le relance automatiquement.""" + log.warning("Redemarrage backend demande via API") + import signal + asyncio.get_event_loop().call_later(0.5, lambda: os.kill(os.getpid(), signal.SIGTERM)) + return {"succes": True, "message": "Backend redémarre dans 1 seconde..."} + + +@app.get("/api/systeme/logs") +async def api_systeme_logs(n: int = 80): + """Retourne les dernières N lignes du log backend.""" + n = min(n, 500) + log_file = Path("/tmp/photobooth-backend.log") + if not log_file.exists(): + return {"lignes": [], "fichier": str(log_file), "existe": False} + try: + with open(log_file, "rb") as f: + f.seek(0, 2) + taille = f.tell() + bloc = min(taille, n * 200) + f.seek(max(0, taille - bloc)) + data = f.read().decode("utf-8", errors="replace") + lignes = data.splitlines()[-n:] + return {"lignes": lignes, "fichier": str(log_file), "existe": True} + except Exception as e: + return {"lignes": [f"Erreur lecture log : {e}"], "fichier": str(log_file), "existe": True} + + +@app.get("/api/systeme/sante") +async def api_systeme_sante(): + """Bilan de santé complet : camera, imprimante, relais, USB, système.""" + import subprocess, shutil + + # Camera + cam_ok = camera.connectee and camera.mode == "gphoto2" + cam_status = "ok" if cam_ok else ("erreur" if camera.mode == "erreur" else camera.mode) + + # Imprimante + config = charger_config() + nom_imp = config.get("impression", {}).get("imprimante") or "Mitsubishi" + imp_status = "inconnu" + try: + r = subprocess.run(["lpstat", "-p", nom_imp], capture_output=True, text=True, timeout=5) + out = r.stdout.lower() + if "disabled" in out or "stopped" in out: + imp_status = "arretee" + elif "idle" in out or "inactive" in out: + imp_status = "prete" + elif "printing" in out: + imp_status = "impression" + else: + imp_status = "inconnu" + except Exception: + imp_status = "erreur" + + # Jobs en file + jobs_en_attente = 0 + try: + r = subprocess.run(["lpstat", "-o", nom_imp], capture_output=True, text=True, timeout=5) + jobs_en_attente = len([l for l in r.stdout.splitlines() if l.strip()]) + except Exception: + pass + + # Relais + rel_ok = relais.est_connecte() + + # USB Canon détecté + canon_usb = False + try: + import glob + for f in glob.glob("/sys/bus/usb/devices/*/idVendor"): + if open(f).read().strip() == "04a9": + canon_usb = True + break + except Exception: + pass + + # USB imprimante détectée (Mitsubishi vendor 06d3) + imp_usb = False + try: + import glob + for f in glob.glob("/sys/bus/usb/devices/*/idVendor"): + if open(f).read().strip() == "06d3": + imp_usb = True + break + except Exception: + pass + + # RAM & disque + ram_pct = 0 + try: + with open('/proc/meminfo') as f: + lignes = {l.split(':')[0]: l.split(':')[1].strip() for l in f.readlines()} + total = int(lignes.get('MemTotal', '1 kB').split()[0]) + libre = int(lignes.get('MemAvailable', '0 kB').split()[0]) + ram_pct = round((total - libre) / total * 100) + except Exception: + pass + + disque_pct = 0 + try: + total, used, _ = shutil.disk_usage("/") + disque_pct = round(used / total * 100) + except Exception: + pass + + # Uptime + uptime = "" + try: + with open('/proc/uptime') as f: + secs = int(float(f.read().split()[0])) + h, rem = divmod(secs, 3600) + m = rem // 60 + uptime = f"{h}h{m:02d}" + except Exception: + pass + + # Temperature + temp = None + try: + with open('/sys/class/thermal/thermal_zone0/temp') as f: + temp = round(int(f.read().strip()) / 1000, 1) + except Exception: + pass + + return { + "camera": {"status": cam_status, "mode": camera.mode, "usb": canon_usb, "preview_ok": camera.preview_dslr_ok}, + "imprimante": {"status": imp_status, "nom": nom_imp, "usb": imp_usb, "jobs": jobs_en_attente}, + "relais": {"connecte": rel_ok}, + "systeme": {"ram_pct": ram_pct, "disque_pct": disque_pct, "uptime": uptime, "temp": temp}, + } + + +@app.post("/api/imprimante/reactiver") +async def api_reactiver_imprimante(): + """Réactive l'imprimante si elle est en erreur/stoppée.""" + import subprocess + nom = charger_config().get("impression", {}).get("imprimante") or "Mitsubishi" + try: + subprocess.run(["sudo", "cupsenable", nom], capture_output=True, timeout=5) + subprocess.run(["sudo", "accept", nom], capture_output=True, timeout=5) + log.info(f"Imprimante {nom} réactivée manuellement") + return {"succes": True, "message": f"{nom} réactivée"} + except Exception as e: + return {"succes": False, "message": str(e)} + + +@app.get("/api/systeme/usb") +async def api_systeme_usb(): + """Liste les périphériques USB connectés.""" + import subprocess + try: + r = subprocess.run(["lsusb"], capture_output=True, text=True, timeout=5) + peripheriques = [] + for l in r.stdout.splitlines(): + peripheriques.append(l.strip()) + return {"peripheriques": peripheriques} + except Exception: + return {"peripheriques": ["lsusb non disponible"]} + + # --- API WiFi --- @app.get("/api/wifi/status") @@ -1850,12 +2486,30 @@ async def traiter_message_ws(msg: dict, ws: WebSocket): if type_msg == "ping": await ws.send_json({"type": "pong"}) elif type_msg == "preview_start": - global _preview_actif + global _preview_actif, _canon_en_veille _preview_actif = True - log.info("WS: preview_start recu") + if _canon_en_veille: + log.info("WS: preview_start — réveil Canon depuis veille relais") + _canon_en_veille = False + await asyncio.get_event_loop().run_in_executor(None, _reveiller_canon) + else: + log.info("WS: preview_start recu") + elif type_msg == "prepare_capture": + config = charger_config() + if config.get("relais", {}).get("eclairage_auto") and relais.est_connecte(): + relais.projecteurs(True) + _proj_etat["proj1"] = True + _proj_etat["proj2"] = True + log.info("WS: prepare_capture — 2 projecteurs ON (pré-éclairage countdown)") elif type_msg == "preview_stop": _preview_actif = False - log.info("WS: preview_stop recu") + global _preview_eclairage_init + _preview_eclairage_init = False + if not capture_en_cours and relais.est_connecte() and (_proj_etat["proj1"] or _proj_etat["proj2"]): + relais.projecteurs(False) + _proj_etat["proj1"] = False + _proj_etat["proj2"] = False + log.info("WS: preview_stop recu" + (" (projecteurs maintenus pour capture)" if capture_en_cours else "")) async def diffuser_ws(message: dict): diff --git a/backend/printer.py b/backend/printer.py index a641f7a..ac5151c 100644 --- a/backend/printer.py +++ b/backend/printer.py @@ -249,7 +249,7 @@ def imprimer( conf_imp = config.get("impression", {}) if imprimante is None: - imprimante = conf_imp.get("imprimante", "Mitsubishi") + imprimante = conf_imp.get("imprimante") or "Mitsubishi" if copies is None: copies = conf_imp.get("copies", 1) if format_papier is None: diff --git a/backend/relais.py b/backend/relais.py index 85916ff..0625324 100644 --- a/backend/relais.py +++ b/backend/relais.py @@ -26,7 +26,8 @@ PID = 0x2007 CANAUX = { "projecteur_gauche": {"relay": 1, "mode": "NO"}, "projecteur_droit": {"relay": 2, "mode": "NO"}, - "canon": {"relay": 3, "mode": "NF"}, + "canon": {"relay": 3, "mode": "NF"}, # dummy battery + "canon_usb": {"relay": 4, "mode": "NF"}, # VBUS USB } _lib = None @@ -115,12 +116,13 @@ def status(): rbuf = (ctypes.c_ubyte * 64)() with _lock: n = _lib.hid_read_timeout(_dev, rbuf, 64, 500) - if n < 4: + 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, } @@ -144,12 +146,30 @@ def projecteurs(on: bool): log.info(f"Projecteurs {'allumés' if on else 'éteints'}") -def power_cycle_canon(duree: float = 3.0): - log.info(f"Power-cycle Canon ({duree}s)") - _relay_set(3, True) # NF : ON = coupé - time.sleep(duree) - _relay_set(3, False) # NF : OFF = alimenté - log.info("Canon ré-alimenté") +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(): diff --git a/frontend/css/style.css b/frontend/css/style.css index 1c53dd4..0f1df93 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -2246,6 +2246,38 @@ h3 { cursor: pointer; } +/* === Diagnostic Panel === */ +.diag-cards{display:grid;grid-template-columns:repeat(4,1fr);gap:.8rem} +.diag-card{background:rgba(255,255,255,.06);border-radius:12px;padding:1rem;text-align:center;border:2px solid transparent;transition:border-color .3s} +.diag-card.ok{border-color:#4caf50} +.diag-card.warn{border-color:#ff9800} +.diag-card.err{border-color:#f44336} +.diag-card.off{border-color:#666} +.diag-icon{font-size:2rem;margin-bottom:.3rem} +.diag-label{font-size:.85rem;color:var(--texte-secondaire)} +.diag-status{font-size:.75rem;margin-top:.3rem;font-weight:600} +.diag-card.ok .diag-status{color:#4caf50} +.diag-card.warn .diag-status{color:#ff9800} +.diag-card.err .diag-status{color:#f44336} +.diag-card.off .diag-status{color:#888} +.diag-actions{display:flex;flex-wrap:wrap;gap:.6rem} +.diag-actions button{flex:1;min-width:140px;font-size:.85rem} +.diag-logs{font-family:monospace;font-size:.65rem;background:#0a0a0a;color:#0f0;padding:.8rem;border-radius:8px;white-space:pre-wrap;max-height:350px;overflow-y:auto;word-break:break-all;line-height:1.4} +.diag-usb{font-family:monospace;font-size:.75rem;background:#0a0a0a;color:#ccc;padding:.6rem;border-radius:6px;white-space:pre-wrap} + +/* === Camera/Printer error overlay (user-facing, full-screen) === */ +.erreur-overlay{position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,.92);display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:2rem;gap:1.5rem} +.erreur-overlay .erreur-icon{font-size:5rem} +.erreur-overlay .erreur-titre{font-size:2rem;font-weight:700;color:#fff} +.erreur-overlay .erreur-msg{font-size:1.3rem;color:#ccc;max-width:600px;line-height:1.5} +.erreur-overlay .erreur-steps{text-align:left;font-size:1.1rem;color:#eee;max-width:500px;line-height:2} +.erreur-overlay .erreur-steps li{margin-bottom:.5rem} +.erreur-overlay .erreur-spinner{width:60px;height:60px;border:5px solid #333;border-top-color:var(--primaire);border-radius:50%;animation:spin 1s linear infinite} +@keyframes spin{to{transform:rotate(360deg)}} +.erreur-overlay .erreur-btn{padding:1rem 2.5rem;font-size:1.3rem;border-radius:1rem;border:none;cursor:pointer;margin-top:1rem} +.erreur-overlay .erreur-btn-primaire{background:var(--primaire);color:#fff} +.erreur-overlay .erreur-btn-secondaire{background:#333;color:#fff} + /* Responsive tactile */ @media (max-width: 800px) { .accueil-contenu h1 { font-size: 2.5rem; } diff --git a/frontend/index.html b/frontend/index.html index b8dea8c..3039dba 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -7,7 +7,7 @@ - + @@ -20,12 +20,19 @@ - -
-
📷
-

Probleme de communication avec l'appareil photo

- Reconnexion en cours... - + +
+
📷
+
Preparation en cours
+
L'appareil photo se prepare, un instant...
+
+
+
+ + + + +
@@ -385,6 +392,7 @@ +
@@ -466,9 +474,10 @@ 400
photos restantes +
- +
@@ -693,7 +702,15 @@
- +
+ + +
+
+ + +
+
Evenement termine — impression desactivee

Eclairage automatique

@@ -916,9 +934,57 @@ Projecteurs s'eteignent
+

Veille

+
+ + + 0 = jamais. Les projecteurs se rallument quand un utilisateur touche l'ecran +
+ +
+

Sante du systeme

+
+
+
📷
+
Camera
+
--
+
+
+
🖨
+
Imprimante
+
--
+
+
+
🔌
+
Relais
+
--
+
+
+
💻
+
Systeme
+
--
+
+
+ +

Actions

+
+ + + + + +
+ +

Logs backend

+
Appuyer sur Actualiser...
+ +

Peripheriques USB

+
--
+
+

Informations systeme

@@ -1079,11 +1145,11 @@ - - + + - - - + + + diff --git a/frontend/js/admin.js b/frontend/js/admin.js index 828d6d7..b13be2a 100644 --- a/frontend/js/admin.js +++ b/frontend/js/admin.js @@ -162,8 +162,11 @@ async function chargerCompteur() { const etat = await apiGet('/api/compteur'); document.getElementById('tog-compteur-actif').checked = etat.actif; document.getElementById('admin-compteur-restant').textContent = etat.restantes; - document.getElementById('admin-compteur-limite-display').textContent = etat.limite; + const limiteAff = (etat.actif && etat.limite > 0) ? etat.limite : etat.capacite; + document.getElementById('admin-compteur-limite-display').textContent = limiteAff; setValue('admin-compteur-limite', etat.limite); + const detail = document.getElementById('admin-compteur-detail'); + if (detail) detail.textContent = `Papier: ${etat.papier_restant} | Ruban: ${etat.ruban_restant}`; } async function sauvegarderCompteur() { @@ -713,10 +716,14 @@ async function chargerListeEvenements() { for (const ev of events) { const div = document.createElement('div'); div.className = 'event-item' + (ev.id === actifId ? ' event-actif' : ''); + const badges = []; + if (ev.id === actifId) badges.push('Actif'); + if (ev.termine) badges.push('Termine'); div.innerHTML = ` - ${ev.nom} + ${ev.nom}${ev.date_fin ? ' (' + ev.date_fin + ')' : ''} - ${ev.id !== actifId ? `` : 'Actif'} + ${ev.id !== actifId ? `` : ''} + ${badges.join(' ')} `; @@ -760,6 +767,7 @@ function afficherDetailEvent(event) { setValue('admin-nom-event', event.nom); setValue('admin-couleur-primaire', event.couleur_primaire || '#e91e63'); setValue('admin-couleur-secondaire', event.couleur_secondaire || '#ffffff'); + setValue('admin-date-fin', event.date_fin || ''); rafraichirMediaAccueil().then(() => { const select = document.getElementById('admin-media-accueil'); if (event.media_accueil) select.value = event.media_accueil; @@ -770,6 +778,17 @@ function afficherDetailEvent(event) { document.getElementById('tog-event-fmt-15x20').checked = fmts.includes('15x20'); document.getElementById('tog-event-fmt-strip').checked = fmts.includes('strip'); + // Statut termine + const btnTerminer = document.getElementById('btn-terminer-event'); + const badgeTermine = document.getElementById('badge-event-termine'); + if (event.termine) { + btnTerminer.classList.add('cache'); + badgeTermine.classList.remove('cache'); + } else { + btnTerminer.classList.remove('cache'); + badgeTermine.classList.add('cache'); + } + // Lien galerie en ligne const booth = config.booth || {}; const lienBloc = document.getElementById('lien-galerie-event'); @@ -787,6 +806,17 @@ function afficherDetailEvent(event) { chargerCadresEvent(); } +async function terminerEvenement() { + if (!eventEditId) return; + if (!confirm('Terminer cet evenement ? L\'impression sera desactivee.')) return; + await apiPost(`/api/evenements/${eventEditId}/terminer`); + config = await apiGet('/api/config'); + const event = await apiGet(`/api/evenements/${eventEditId}`); + afficherDetailEvent(event); + await chargerListeEvenements(); + afficherStatut('Evenement termine — impression desactivee', 'succes'); +} + async function supprimerEventAdmin(id, nom) { if (!confirm(`Supprimer l'evenement "${nom}" et ses cadres ?`)) return; await fetch(`/api/evenements/${id}`, { method: 'DELETE' }); @@ -856,6 +886,7 @@ async function sauvegarderEvenement() { couleur_primaire: getValue('admin-couleur-primaire'), couleur_secondaire: getValue('admin-couleur-secondaire'), media_accueil: getValue('admin-media-accueil') || null, + date_fin: getValue('admin-date-fin') || null, formats_actifs, }; @@ -1743,6 +1774,41 @@ function fermerWizard() { _wizardSituation = null; _wizardEtapes = []; _wizardIndex = 0; + _arreterAutoDetect(); +} + +let _wizardAutoDetectTimer = null; +let _wizardAutoDetectType = null; + +function lancerWizardSituation(situationId, autoDetectType) { + _wizardAutoDetectType = autoDetectType || null; + lancerWizard(situationId); + if (_wizardAutoDetectType) { + _demarrerAutoDetect(); + } +} + +function _demarrerAutoDetect() { + if (_wizardAutoDetectTimer) clearInterval(_wizardAutoDetectTimer); + _wizardAutoDetectTimer = setInterval(async () => { + try { + const s = await apiGet('/api/systeme/sante'); + let resolu = false; + if (_wizardAutoDetectType === 'camera' && s.camera.status === 'ok') resolu = true; + if (_wizardAutoDetectType === 'imprimante' && s.imprimante.status === 'prete') resolu = true; + if (resolu) { + _arreterAutoDetect(); + fermerWizard(); + afficherStatut('Probleme resolu !', 'succes'); + if (typeof fermerErreurEquipement === 'function') fermerErreurEquipement(); + } + } catch (e) {} + }, 5000); +} + +function _arreterAutoDetect() { + if (_wizardAutoDetectTimer) { clearInterval(_wizardAutoDetectTimer); _wizardAutoDetectTimer = null; } + _wizardAutoDetectType = null; } function lancerWizardDepuisAdmin() { @@ -1857,6 +1923,8 @@ async function chargerRelaisConfig() { if (s2) s2.value = data.seuil_proj2 ?? 30; const soff = document.getElementById('relais-seuil-off'); if (soff) soff.value = data.seuil_off ?? 65; + const vd = document.getElementById('relais-veille-delai'); + if (vd) vd.value = data.veille_delai ?? 0; } catch (e) { console.warn('Erreur relais config:', e); } @@ -1867,13 +1935,20 @@ async function sauvegarderRelaisConfig() { const seuil_proj1 = parseInt(document.getElementById('relais-seuil-proj1')?.value) || 45; const seuil_proj2 = parseInt(document.getElementById('relais-seuil-proj2')?.value) || 30; const seuil_off = parseInt(document.getElementById('relais-seuil-off')?.value) || 65; - await apiPost('/api/relais/config', { eclairage_auto, seuil_proj1, seuil_proj2, seuil_off }); + const veille_delai = parseInt(document.getElementById('relais-veille-delai')?.value) || 0; + await apiPost('/api/relais/config', { eclairage_auto, seuil_proj1, seuil_proj2, seuil_off, veille_delai }); afficherStatut('Config relais sauvegardee', 'succes'); } async function relaisAction(nom, action) { try { - if (nom === 'canon/power-cycle') { + if (nom === 'canon/hard-reset') { + await fetch('/api/relais/canon/hard-reset', { + method: 'POST', headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({duree: 5}) + }); + afficherStatut('Hard reset Canon lance (trappe + alim)', 'succes'); + } else if (nom === 'canon/power-cycle') { await fetch('/api/relais/canon/power-cycle', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({duree: 3}) @@ -1893,3 +1968,147 @@ function demarrerEclairageLiveExt() { chargerRelaisStatus(); chargerRelaisConfig(); } + +// === DIAGNOSTIC === + +let _diagRefreshTimer = null; + +async function chargerDiagnostic() { + chargerSante(); + chargerLogs(); + chargerUsb(); + if (_diagRefreshTimer) clearInterval(_diagRefreshTimer); + _diagRefreshTimer = setInterval(chargerSante, 10000); +} + +async function chargerSante() { + try { + const s = await apiGet('/api/systeme/sante'); + + // Camera + const camCard = document.getElementById('diag-camera'); + const camSt = document.getElementById('diag-camera-status'); + if (s.camera.status === 'ok') { + camCard.className = 'diag-card ok'; + camSt.textContent = 'Connectee — ' + s.camera.mode; + } else if (s.camera.usb) { + camCard.className = 'diag-card warn'; + camSt.textContent = 'USB detecte mais non connectee'; + } else { + camCard.className = 'diag-card err'; + camSt.textContent = s.camera.status === 'erreur' ? 'Non disponible' : s.camera.mode; + } + + // Imprimante + const impCard = document.getElementById('diag-imprimante'); + const impSt = document.getElementById('diag-imp-status'); + if (s.imprimante.status === 'prete') { + impCard.className = 'diag-card ok'; + impSt.textContent = s.imprimante.nom + ' — Prete'; + } else if (s.imprimante.status === 'impression') { + impCard.className = 'diag-card ok'; + impSt.textContent = 'En cours d\'impression'; + } else if (s.imprimante.status === 'arretee') { + impCard.className = 'diag-card err'; + impSt.textContent = s.imprimante.nom + ' — Arretee' + (s.imprimante.usb ? ' (USB ok)' : ' (USB absente)'); + } else { + impCard.className = 'diag-card ' + (s.imprimante.usb ? 'warn' : 'off'); + impSt.textContent = s.imprimante.usb ? 'USB detectee — CUPS inconnu' : 'Non detectee'; + } + if (s.imprimante.jobs > 0) impSt.textContent += ' — ' + s.imprimante.jobs + ' job(s)'; + + // Relais + const relCard = document.getElementById('diag-relais'); + const relSt = document.getElementById('diag-relais-status'); + if (s.relais.connecte) { + relCard.className = 'diag-card ok'; + relSt.textContent = 'Connecte'; + } else { + relCard.className = 'diag-card off'; + relSt.textContent = 'Non detecte'; + } + + // Systeme + const sysCard = document.getElementById('diag-systeme'); + const sysSt = document.getElementById('diag-sys-status'); + const ramWarn = s.systeme.ram_pct > 85; + const diskWarn = s.systeme.disque_pct > 90; + const tempWarn = s.systeme.temp && s.systeme.temp > 75; + if (ramWarn || diskWarn || tempWarn) { + sysCard.className = 'diag-card warn'; + } else { + sysCard.className = 'diag-card ok'; + } + let sysInfo = 'RAM ' + s.systeme.ram_pct + '% — Disque ' + s.systeme.disque_pct + '%'; + if (s.systeme.temp) sysInfo += ' — ' + s.systeme.temp + '°C'; + if (s.systeme.uptime) sysInfo += ' — Up ' + s.systeme.uptime; + sysSt.textContent = sysInfo; + } catch (e) { + console.error('Diagnostic sante:', e); + } +} + +async function chargerLogs() { + try { + const r = await apiGet('/api/systeme/logs?n=80'); + const el = document.getElementById('diag-logs'); + el.textContent = r.lignes.join('\n'); + el.scrollTop = el.scrollHeight; + } catch (e) { + document.getElementById('diag-logs').textContent = 'Erreur chargement logs'; + } +} + +async function chargerUsb() { + try { + const r = await apiGet('/api/systeme/usb'); + const el = document.getElementById('diag-usb'); + el.textContent = r.peripheriques.join('\n'); + } catch (e) { + document.getElementById('diag-usb').textContent = 'Erreur'; + } +} + +async function diagReconnecterCamera() { + afficherStatut('Reconnexion camera...', 'succes'); + try { + await apiPost('/api/camera/reconnecter', {}); + await chargerSante(); + afficherStatut('Reconnexion terminee', 'succes'); + } catch (e) { + afficherStatut('Echec reconnexion', 'erreur'); + } +} + +async function diagReactiverImprimante() { + afficherStatut('Reactivation imprimante...', 'succes'); + try { + const r = await apiPost('/api/imprimante/reactiver', {}); + afficherStatut(r.message || 'OK', r.succes ? 'succes' : 'erreur'); + await chargerSante(); + } catch (e) { + afficherStatut('Echec reactivation', 'erreur'); + } +} + +async function diagRedemarrerBackend() { + afficherStatut('Redemarrage backend...', 'succes'); + try { + await fetch('/api/systeme/redemarrer-backend', { method: 'POST' }); + afficherStatut('Backend redemarre — rechargement dans 5s...', 'succes'); + setTimeout(() => location.reload(), 5000); + } catch (e) { + afficherStatut('Erreur', 'erreur'); + } +} + +async function diagRefreshChromium() { + location.reload(); +} + +async function diagRedemarrerSysteme() { + afficherStatut('Redemarrage borne...', 'succes'); + try { + await fetch('/api/systeme/redemarrer', { method: 'POST' }); + } catch (e) {} +} diff --git a/frontend/js/app.js b/frontend/js/app.js index 334c8b9..4a976df 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -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 --- @@ -113,7 +118,13 @@ function allerA(ecran) { photoFinale = null; arreterPreview(); majCompteurAccueil(); - } else if (ecran === 'capture') { + eteindreSpots(); + lancerTimerVeille(); + } else { + arreterTimerVeille(); + reveillerEclairage(); + } + if (ecran === 'capture') { lancerCapture(); } else if (ecran === 'partage') { majBoutonImprimerCompteur(); @@ -145,6 +156,7 @@ function setupEcranAccueil() { accueil.addEventListener('click', (e) => { if (e.target.closest('.btn-admin')) return; if (e.target.closest('.btn-photostation')) return; + if (_enVeille) reveillerEclairage(); allerA('mode'); }); } @@ -388,24 +400,143 @@ function afficherStatut(message, type = 'succes') { wsOnMessage('config_maj', (msg) => { config = msg.config; appliquerConfig(); + if (ecranActuel === 'accueil') lancerTimerVeille(); }); -// Erreur camera : overlay visible sur tous les ecrans, reconnexion automatique cote backend +// === Erreur equipement : overlay intelligent avec escalade === let _erreurCameraTimer = null; -wsOnMessage('camera_erreur', () => { +let _erreurCameraCount = 0; +let _erreurCameraPhase = 0; // 0=auto-fix, 1=patience, 2=operator, 3=restart + +function _showErreurEquipement(icon, titre, msg, phase) { + const el = document.getElementById('erreur-equipement'); + document.getElementById('erreur-equip-icon').textContent = icon; + document.getElementById('erreur-equip-titre').textContent = titre; + document.getElementById('erreur-equip-msg').textContent = msg; + const spinner = document.getElementById('erreur-equip-spinner'); + const steps = document.getElementById('erreur-equip-steps'); + const btnRetry = document.getElementById('erreur-equip-btn-retry'); + const btnVideo = document.getElementById('erreur-equip-btn-video'); + const btnRestart = document.getElementById('erreur-equip-btn-restart'); + + spinner.classList.toggle('cache', phase >= 2); + steps.classList.toggle('cache', phase < 2); + btnRetry.classList.toggle('cache', phase < 1); + btnVideo.classList.toggle('cache', phase < 2); + btnRestart.classList.toggle('cache', phase < 2); + + if (phase >= 2) { + steps.innerHTML = '
    ' + + '
  1. Verifiez que l\'appareil photo est allume (bouton ON)
  2. ' + + '
  3. Verifiez le cable USB entre l\'appareil et la borne
  4. ' + + '
  5. Eteignez et rallumez l\'appareil photo
  6. ' + + '
  7. Si le probleme persiste, redemarrez la borne
  8. ' + + '
'; + } + 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; - document.getElementById('camera-erreur').classList.remove('cache'); - // Reload de dernier recours si la camera ne revient pas apres 2 min + _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(); }, 120000); + _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; } }); -// Surveillance imprimante — poll toutes les 30s +wsOnMessage('imprimante_erreur', (data) => { + if (window.location.pathname === '/admin') return; + _erreurEquipType = 'imprimante'; + const titre = data.titre || 'Probleme imprimante'; + const msg = data.message || 'L\'imprimante ne fonctionne pas correctement.'; + const etapes = data.etapes || []; + const situation = data.situation || 'depannage_imprimante'; + _showErreurEquipement('🖨', titre, msg, 2); + const steps = document.getElementById('erreur-equip-steps'); + if (etapes.length) { + steps.innerHTML = '
    ' + etapes.map(e => '
  1. ' + e + '
  2. ').join('') + '
'; + 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; @@ -416,21 +547,11 @@ async function _pollStatutImprimante() { try { const r = await apiGet('/api/imprimante/statut-detail'); const el = document.getElementById('printer-erreur'); - if (r.derniere_erreur) { - let msg = ''; - const e = r.derniere_erreur.toLowerCase(); - if (e.includes('media') && e.includes('match')) - msg = '⚠ Imprimante : format papier incorrect — vérifiez la cassette'; - else if (e.includes('jam')) - msg = '⚠ Imprimante : bourrage papier — retirez le papier bloqué'; - else if (e.includes('cancel')) - msg = '⚠ Imprimante : job annulé — ' + r.derniere_erreur.split(']').pop().trim(); - if (msg) { _afficherErreurImprimante(msg); return; } - } - if (r.statut && (r.statut.includes('stopped') || r.statut.includes('disabled'))) { - _afficherErreurImprimante('⚠ Imprimante arrêtée — utilisez le bouton Évacuer dans l\'admin'); - } else { + 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) {} } @@ -553,6 +674,36 @@ 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(() => {}); +} + // Demarrage document.addEventListener('DOMContentLoaded', () => { init().then(() => { diff --git a/frontend/js/camera.js b/frontend/js/camera.js index 1a8f710..927efa4 100644 --- a/frontend/js/camera.js +++ b/frontend/js/camera.js @@ -272,6 +272,7 @@ async function lancerCapture() { } lancerPreview(); // Miroir live pendant le compte à rebours + wsEnvoyer({type: 'prepare_capture'}); await compteARebours(); // Surprise : afficher media juste avant capture @@ -489,20 +490,17 @@ 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; - } else { - el.classList.add('cache'); - } + el.classList.remove('cache'); + document.getElementById('compteur-restant').textContent = etat.restantes; + 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.actif && etat.restantes <= 0) { + if (etat.restantes <= 0) { btn.classList.add('cache'); } } diff --git a/frontend/js/gallery.js b/frontend/js/gallery.js index bd00e8d..5062882 100644 --- a/frontend/js/gallery.js +++ b/frontend/js/gallery.js @@ -105,12 +105,18 @@ async function adminGalerieImprimer() { if (noms.length === 0) return; if (!confirm('Imprimer ' + noms.length + ' photo' + (noms.length > 1 ? 's' : '') + ' ?')) return; - let ok = 0, fail = 0; + let ok = 0, fail = 0, termine = false; for (const nom of noms) { try { const r = await apiPost('/api/imprimer', { photo: nom }); - if (r && r.succes) ok++; else fail++; + if (r && r.succes) ok++; + else if (r && r.erreur === 'evenement_termine') { termine = true; break; } + else fail++; } catch { fail++; } } - alert('Impression : ' + ok + ' OK' + (fail > 0 ? ', ' + fail + ' echec' : '')); + if (termine) { + alert('Evenement termine — impression desactivee. Les photos ne seront pas imprimees.'); + } else { + alert('Impression : ' + ok + ' OK' + (fail > 0 ? ', ' + fail + ' echec' : '')); + } } diff --git a/frontend/js/share.js b/frontend/js/share.js index 09ed748..2010adf 100644 --- a/frontend/js/share.js +++ b/frontend/js/share.js @@ -1,10 +1,10 @@ /* Module partage - Impression, email, QR code */ let nbExemplaires = 1; -let copiesMax = 5; +let copiesMax = 2; async function ouvrirImpression() { - copiesMax = config.impression?.copies_max || 5; + copiesMax = config.impression?.copies_max || 2; nbExemplaires = 1; document.getElementById('nb-exemplaires').textContent = nbExemplaires; cadreChoisi = null; @@ -110,8 +110,14 @@ async function lancerImpression() { cadre: cadreChoisi || undefined, format_papier: formatImpression || undefined, }); - if (resultat.succes) { + if (resultat.attente) { + afficherStatutEco('⏳', resultat.message || 'Votre photo sortira avec la suivante !', 'attente-eco'); + } else if (resultat.jumeau && resultat.succes) { + afficherStatutEco('🎁', 'Vous recevez 2 tirages identiques — gardez-en un, offrez l\'autre !', 'jumeau-eco'); + } else if (resultat.succes) { afficherStatut(`${nbExemplaires} exemplaire(s) envoye(s) a l'imprimante !`, 'succes'); + } else if (resultat.erreur === 'evenement_termine') { + afficherStatut('Evenement termine — impression indisponible', 'erreur'); } else { afficherStatut('Erreur d\'impression', 'erreur'); } diff --git a/frontend/js/websocket.js b/frontend/js/websocket.js index ca7ee6d..501d5f6 100644 --- a/frontend/js/websocket.js +++ b/frontend/js/websocket.js @@ -54,8 +54,8 @@ function _afficherPause() { function _masquerPause() { const el = document.getElementById('ecran-pause'); if (el) { el.style.display = 'none'; } - // Relancer le preview si on était sur l'accueil - if (typeof lancerPreview === 'function' && document.getElementById('ecran-accueil')?.classList.contains('actif')) { + // 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(); } } diff --git a/memoire.md b/memoire.md index af5eb78..cf9c9ba 100644 --- a/memoire.md +++ b/memoire.md @@ -244,14 +244,50 @@ Backup local des fichiers modifiés : `lxc111_backup/` dans ce dossier projet. - 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 12 juillet 2026) +## Commits non pushés (au 2 août 2026) -3 commits en avance sur origin/main : -- `5e482a8` Consommables : poses perdues + ratio 0.5 -- `3b6f376` Suivi consommables : compteur papier/ruban/photos + diagnostic -- `44a9992` Admin backoffice : rubriques + vidéos + surprise + rembobinage +Incluent relais HID, vidéos didactiques, consommables, admin backoffice. +Push Gitea bloqué (pas de credentials SSH/HTTPS configurés sur Surface). ---