Initial commit — Serveur Lucas SmartEye
API réception alertes chute (SmartEye/YOLO), analyse IA (Gemini 2.5 Flash), gestion alertes avec escalade (watchdog), notifications Firebase, dashboard web, documentation MkDocs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
328
alert_manager.py
Normal file
328
alert_manager.py
Normal file
@@ -0,0 +1,328 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user