""" SmartEye SENTINEL - Gestionnaire d'Alertes avec Acquittement ============================================================= Gère le cycle de vie complet d'une alerte : PENDING → DELIVERED → SEEN → ACKNOWLEDGED Tant qu'une alerte n'est pas au minimum SEEN, le watchdog la renverra. Auteur : Unigest Solutions / SmartEye V30 """ import json import os import time import fcntl from datetime import datetime, timedelta from enum import Enum # --- CONFIGURATION --- ALERTS_DIR = "/var/www/lucas/alerts" ALERTS_DB = os.path.join(ALERTS_DIR, "alerts_active.json") ALERTS_HISTORY = os.path.join(ALERTS_DIR, "alerts_history.json") LOCK_FILE = os.path.join(ALERTS_DIR, ".alerts.lock") # Stratégie d'escalade (en secondes après la création) ESCALATION_SCHEDULE = [ {"delay": 120, "label": "Rappel 1", "priority": "high"}, {"delay": 300, "label": "Rappel 2", "priority": "critical"}, {"delay": 600, "label": "Rappel 3", "priority": "critical"}, {"delay": 1200, "label": "Rappel URGENT", "priority": "emergency"}, {"delay": 1800, "label": "ESCALADE MAX", "priority": "emergency"}, ] # Durée max avant qu'une alerte soit considérée comme expirée (2h) ALERT_MAX_AGE = 7200 class AlertStatus: PENDING = "PENDING" # Créée, notification envoyée DELIVERED = "DELIVERED" # Firebase confirme la livraison SEEN = "SEEN" # L'app a été ouverte, alerte affichée ACKNOWLEDGED = "ACKNOWLEDGED" # L'utilisateur a appuyé "J'ai vu" / "J'appelle" RESOLVED = "RESOLVED" # Situation traitée (fausse alerte ou secours envoyés) EXPIRED = "EXPIRED" # Timeout sans réponse (après escalade max) def _ensure_dir(): """Crée le répertoire d'alertes s'il n'existe pas""" os.makedirs(ALERTS_DIR, exist_ok=True) def _load_alerts(): """Charge les alertes actives depuis le fichier JSON avec verrou""" _ensure_dir() if not os.path.exists(ALERTS_DB): return {} try: with open(ALERTS_DB, 'r') as f: return json.load(f) except (json.JSONDecodeError, IOError): return {} def _save_alerts(alerts): """Sauvegarde les alertes avec verrou fichier (concurrence safe)""" _ensure_dir() lock_fd = None try: lock_fd = open(LOCK_FILE, 'w') fcntl.flock(lock_fd, fcntl.LOCK_EX) with open(ALERTS_DB, 'w') as f: json.dump(alerts, f, indent=2, default=str) finally: if lock_fd: fcntl.flock(lock_fd, fcntl.LOCK_UN) lock_fd.close() def _archive_alert(alert): """Archive une alerte terminée dans l'historique""" _ensure_dir() history = [] if os.path.exists(ALERTS_HISTORY): try: with open(ALERTS_HISTORY, 'r') as f: history = json.load(f) except: history = [] alert["archived_at"] = datetime.now().isoformat() history.append(alert) # Garder max 500 alertes en historique if len(history) > 500: history = history[-500:] with open(ALERTS_HISTORY, 'w') as f: json.dump(history, f, indent=2, default=str) # ============================================= # API PUBLIQUE # ============================================= def creer_alerte(alert_id, image_path, camera_id=None, audio_relay_ws=None, camera_ip_map=None, analyse_data=None): """ Crée une nouvelle alerte et l'enregistre comme PENDING. Appelée par analyze.py après détection d'une chute. Returns: dict avec les infos de l'alerte créée """ alerts = _load_alerts() now = datetime.now() alerte = { "alert_id": alert_id, "status": AlertStatus.PENDING, "image_path": image_path, "camera_id": camera_id or "cam_default", "audio_relay_ws": audio_relay_ws or "ws://57.128.74.87:8800", "camera_ip_map": camera_ip_map or "{}", "created_at": now.isoformat(), "updated_at": now.isoformat(), "notification_count": 1, # Première notif déjà envoyée par analyze.py "last_notification_at": now.isoformat(), "escalation_level": 0, "analyse": analyse_data or {}, "events": [ { "timestamp": now.isoformat(), "event": "CREATED", "detail": "Alerte créée suite à détection de chute" }, { "timestamp": now.isoformat(), "event": "NOTIFICATION_SENT", "detail": "Première notification Firebase envoyée" } ] } alerts[alert_id] = alerte _save_alerts(alerts) return alerte def mettre_a_jour_statut(alert_id, new_status, detail=None): """ Met à jour le statut d'une alerte. Appelée par l'endpoint PHP quand l'app envoie un ACK. Returns: True si mise à jour OK, False si alerte non trouvée """ alerts = _load_alerts() if alert_id not in alerts: return False now = datetime.now() alerte = alerts[alert_id] old_status = alerte["status"] # Empêcher les régressions de statut (ACKNOWLEDGED ne revient pas à SEEN) status_order = [ AlertStatus.PENDING, AlertStatus.DELIVERED, AlertStatus.SEEN, AlertStatus.ACKNOWLEDGED, AlertStatus.RESOLVED ] try: old_idx = status_order.index(old_status) new_idx = status_order.index(new_status) if new_idx < old_idx and new_status != AlertStatus.EXPIRED: return False # Pas de régression except ValueError: pass # Statut inconnu, on laisse passer alerte["status"] = new_status alerte["updated_at"] = now.isoformat() alerte["events"].append({ "timestamp": now.isoformat(), "event": f"STATUS_CHANGED", "detail": f"{old_status} → {new_status}" + (f" | {detail}" if detail else "") }) # Si l'alerte est résolue ou expirée, l'archiver if new_status in (AlertStatus.RESOLVED, AlertStatus.EXPIRED): _archive_alert(alerte) del alerts[alert_id] else: alerts[alert_id] = alerte _save_alerts(alerts) return True def get_alertes_pending(): """ Retourne toutes les alertes qui nécessitent un renvoi. Utilisée par le watchdog (cron). Returns: liste des alertes à renvoyer avec leur niveau d'escalade """ alerts = _load_alerts() now = datetime.now() a_renvoyer = [] for alert_id, alerte in alerts.items(): # Seules les alertes PENDING ou DELIVERED nécessitent un renvoi if alerte["status"] not in (AlertStatus.PENDING, AlertStatus.DELIVERED): continue created_at = datetime.fromisoformat(alerte["created_at"]) age_seconds = (now - created_at).total_seconds() # Vérifier si l'alerte a expiré if age_seconds > ALERT_MAX_AGE: mettre_a_jour_statut(alert_id, AlertStatus.EXPIRED, "Timeout 2h sans réponse") continue # Déterminer le niveau d'escalade actuel current_level = alerte.get("escalation_level", 0) # Chercher le prochain palier d'escalade à déclencher for i, palier in enumerate(ESCALATION_SCHEDULE): if i <= current_level: continue # Déjà passé ce palier if age_seconds >= palier["delay"]: # Vérifier qu'on n'a pas envoyé récemment (anti-spam : 60s min entre 2 envois) last_notif = datetime.fromisoformat(alerte["last_notification_at"]) if (now - last_notif).total_seconds() < 60: continue a_renvoyer.append({ "alert_id": alert_id, "alerte": alerte, "escalation_level": i, "escalation_label": palier["label"], "priority": palier["priority"], "age_seconds": int(age_seconds), "notification_count": alerte["notification_count"] }) break # Un seul palier à la fois return a_renvoyer def enregistrer_envoi(alert_id, escalation_level): """ Enregistre qu'une notification de rappel a été envoyée. Appelée par le watchdog après renvoi. """ alerts = _load_alerts() if alert_id not in alerts: return False now = datetime.now() alerte = alerts[alert_id] alerte["notification_count"] = alerte.get("notification_count", 0) + 1 alerte["last_notification_at"] = now.isoformat() alerte["escalation_level"] = escalation_level alerte["updated_at"] = now.isoformat() alerte["events"].append({ "timestamp": now.isoformat(), "event": "NOTIFICATION_RESENT", "detail": f"Rappel niveau {escalation_level} (envoi #{alerte['notification_count']})" }) alerts[alert_id] = alerte _save_alerts(alerts) return True def get_alerte(alert_id): """Récupère une alerte par son ID""" alerts = _load_alerts() return alerts.get(alert_id, None) def get_toutes_alertes_actives(): """Retourne toutes les alertes actives (pour debug/monitoring)""" return _load_alerts() def compter_alertes_actives(): """Compte les alertes actives (pour anti-spam dans analyze.py)""" alerts = _load_alerts() return len([a for a in alerts.values() if a["status"] in (AlertStatus.PENDING, AlertStatus.DELIVERED)]) def nettoyer_alertes_obsoletes(max_age_seen=14400, max_age_ack=7200): """ Nettoie les alertes SEEN/ACKNOWLEDGED trop vieilles. Appelée par le watchdog à chaque cycle. - SEEN > 4h → EXPIRED (personne n'a réagi) - ACKNOWLEDGED > 2h → RESOLVED (situation traitée) Returns: nombre d'alertes nettoyées """ alerts = _load_alerts() now = datetime.now() cleaned = 0 for alert_id in list(alerts.keys()): alerte = alerts[alert_id] created = datetime.fromisoformat(alerte["created_at"]) age = (now - created).total_seconds() if alerte["status"] == AlertStatus.SEEN and age > max_age_seen: mettre_a_jour_statut(alert_id, AlertStatus.EXPIRED, f"Auto-expiré après {int(age//3600)}h sans acquittement") cleaned += 1 elif alerte["status"] == AlertStatus.ACKNOWLEDGED and age > max_age_ack: mettre_a_jour_statut(alert_id, AlertStatus.RESOLVED, f"Auto-résolu après acquittement ({int(age//3600)}h)") cleaned += 1 elif alerte["status"] == AlertStatus.PENDING and age > ALERT_MAX_AGE: mettre_a_jour_statut(alert_id, AlertStatus.EXPIRED, f"Timeout {int(age//3600)}h sans réponse") cleaned += 1 return cleaned