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>
169 lines
5.9 KiB
Python
169 lines
5.9 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
SmartEye SENTINEL - Watchdog d'Alertes
|
|
========================================
|
|
Script cron qui tourne toutes les minutes.
|
|
Vérifie les alertes non acquittées et les renvoie avec escalade.
|
|
|
|
Crontab :
|
|
* * * * * /usr/bin/python3 /var/www/lucas/alert_watchdog.py >> /var/log/smarteye_watchdog.log 2>&1
|
|
|
|
Auteur : Unigest Solutions / SmartEye V30
|
|
"""
|
|
|
|
import json
|
|
import sys
|
|
import os
|
|
import uuid
|
|
from datetime import datetime, timedelta
|
|
|
|
# Ajouter le chemin du projet
|
|
sys.path.insert(0, '/var/www/lucas')
|
|
|
|
from alert_manager import (
|
|
get_alertes_pending,
|
|
enregistrer_envoi,
|
|
mettre_a_jour_statut,
|
|
nettoyer_alertes_obsoletes,
|
|
AlertStatus
|
|
)
|
|
|
|
# Firebase
|
|
import firebase_admin
|
|
from firebase_admin import credentials, messaging
|
|
|
|
# --- CONFIGURATION ---
|
|
TOKEN_TELEPHONE = "fheHxyPRQlS7T0hDnKIpeR:APA91bFPd7v3zO0GqU7pW3X-IfODgnfIUOFUoM4M1_CsCNNE5BSLVSIrCpXqL_xNNNBWdUFE4A4SCNs9_LWUQn8s7mVaw2gnQxhveC1SOc9E0gRiQW1Ct84"
|
|
DOMAIN = "https://lucas.unigest.fr"
|
|
|
|
# --- INITIALISATION FIREBASE ---
|
|
if not firebase_admin._apps:
|
|
try:
|
|
cred = credentials.Certificate("/var/www/lucas/admin_key.json")
|
|
firebase_admin.initialize_app(cred)
|
|
except Exception as e:
|
|
print(f"[WATCHDOG] Erreur Firebase init: {e}")
|
|
sys.exit(1)
|
|
|
|
|
|
def envoyer_rappel(alerte_info):
|
|
"""
|
|
Envoie une notification de rappel avec le niveau d'escalade approprié.
|
|
Le message change selon l'urgence croissante.
|
|
"""
|
|
alert_id = alerte_info["alert_id"]
|
|
alerte = alerte_info["alerte"]
|
|
level = alerte_info["escalation_level"]
|
|
priority = alerte_info["priority"]
|
|
count = alerte_info["notification_count"]
|
|
age = alerte_info["age_seconds"]
|
|
|
|
image_path = alerte.get("image_path", "")
|
|
url_image = f"{DOMAIN}/{image_path}" if image_path else ""
|
|
|
|
# Messages d'escalade progressifs
|
|
messages_escalade = {
|
|
0: {"title": "⚠️ RAPPEL ALERTE CHUTE",
|
|
"body": f"Alerte non vue depuis {age // 60} min — Appuyez pour voir"},
|
|
1: {"title": "🔴 ALERTE CHUTE NON TRAITÉE",
|
|
"body": f"URGENT : Personne potentiellement au sol depuis {age // 60} min !"},
|
|
2: {"title": "🚨 ALERTE CRITIQUE NON TRAITÉE",
|
|
"body": f"Aucune réponse depuis {age // 60} min — Intervention nécessaire !"},
|
|
3: {"title": "🆘 ALERTE MAXIMALE",
|
|
"body": f"SANS RÉPONSE DEPUIS {age // 60} MIN — Vérifiez immédiatement !"},
|
|
4: {"title": "🆘 DERNIÈRE TENTATIVE",
|
|
"body": f"Alerte ignorée depuis {age // 60} min — Escalade en cours"},
|
|
}
|
|
|
|
msg_data = messages_escalade.get(level, messages_escalade[4])
|
|
|
|
now = datetime.now()
|
|
|
|
try:
|
|
message = messaging.Message(
|
|
notification=messaging.Notification(
|
|
title=msg_data["title"],
|
|
body=msg_data["body"],
|
|
),
|
|
android=messaging.AndroidConfig(
|
|
priority='high',
|
|
notification=messaging.AndroidNotification(
|
|
channel_id='fall_alert_channel',
|
|
priority='max' if level >= 2 else 'high',
|
|
default_sound=True,
|
|
default_vibrate_timings=True,
|
|
visibility='public',
|
|
),
|
|
# TTL court pour forcer la livraison rapide
|
|
ttl=timedelta(seconds=60),
|
|
),
|
|
data={
|
|
"urgence": "true",
|
|
"type": "chute_rappel",
|
|
"alert_id": alert_id,
|
|
"escalation_level": str(level),
|
|
"escalation_label": alerte_info["escalation_label"],
|
|
"notification_count": str(count + 1),
|
|
"original_time": alerte.get("created_at", ""),
|
|
"age_minutes": str(age // 60),
|
|
"senior_name": "Mamie Lucas",
|
|
"senior_photo_url": f"{DOMAIN}/photos/mamie.jpg",
|
|
"image_url": url_image,
|
|
"camera_id": alerte.get("camera_id", ""),
|
|
"audio_relay_ws": alerte.get("audio_relay_ws", "ws://57.128.74.87:8800"),
|
|
"camera_ip_map": alerte.get("camera_ip_map", "{}"),
|
|
"is_reminder": "true",
|
|
"click_action": "FLUTTER_NOTIFICATION_CLICK",
|
|
},
|
|
token=TOKEN_TELEPHONE,
|
|
)
|
|
|
|
response = messaging.send(message)
|
|
|
|
# Enregistrer l'envoi
|
|
enregistrer_envoi(alert_id, level)
|
|
|
|
print(f"[WATCHDOG] ✅ Rappel envoyé — Alerte {alert_id[:8]}... "
|
|
f"| Niveau {level} | Envoi #{count + 1} | Âge {age // 60}min "
|
|
f"| Firebase: {response}")
|
|
|
|
return True
|
|
|
|
except messaging.UnregisteredError:
|
|
# Token invalide — le téléphone a été réinitialisé ou l'app désinstallée
|
|
print(f"[WATCHDOG] ❌ Token Firebase invalide ! L'app est peut-être désinstallée.")
|
|
# TODO: Escalader vers SMS ou contact secondaire
|
|
return False
|
|
|
|
except Exception as e:
|
|
print(f"[WATCHDOG] ❌ Erreur envoi rappel {alert_id[:8]}...: {e}")
|
|
return False
|
|
|
|
|
|
def run_watchdog():
|
|
"""Boucle principale du watchdog"""
|
|
now = datetime.now()
|
|
|
|
# Nettoyage des alertes obsolètes (SEEN > 4h, ACKNOWLEDGED > 2h)
|
|
cleaned = nettoyer_alertes_obsoletes()
|
|
if cleaned > 0:
|
|
print(f"[WATCHDOG] 🧹 {cleaned} alerte(s) obsolète(s) nettoyée(s)")
|
|
|
|
# Récupérer les alertes qui nécessitent un renvoi
|
|
alertes_a_renvoyer = get_alertes_pending()
|
|
|
|
if not alertes_a_renvoyer:
|
|
# Silencieux si rien à faire (le cron tourne chaque minute)
|
|
return
|
|
|
|
print(f"\n[WATCHDOG] === {now.strftime('%Y-%m-%d %H:%M:%S')} === "
|
|
f"{len(alertes_a_renvoyer)} alerte(s) à renvoyer")
|
|
|
|
for alerte_info in alertes_a_renvoyer:
|
|
envoyer_rappel(alerte_info)
|
|
|
|
print(f"[WATCHDOG] --- Fin du cycle ---\n")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
run_watchdog() |