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:
Debian
2026-03-14 21:26:06 +01:00
commit 24dbc7cd6a
64 changed files with 9677 additions and 0 deletions

36
.gitignore vendored Normal file
View File

@@ -0,0 +1,36 @@
# === SECRETS & CREDENTIALS ===
admin_key.json
lucas_config.ini
database.json
# === DONNÉES RUNTIME ===
clients/
uploads/
alerts/
photos/
error_python.log
sms_history.log
last_analysis_report.json
# === FICHIERS TEMPORAIRES ===
__pycache__/
*.pyc
*.bak
# === IDE ===
.vscode/
.claude/
lucas.code-workspace
# === IMAGES ===
*.jpg
!placeholder.jpg
# === MKDOCS BUILD ===
mkdocs-smarteye/site/
# === ANCIENS RAPPORTS (historique, pas du code) ===
CHANGELOG_v2.0.md
CORRECTIF_TECHNIQUE.md
GEMINI_FIX_README.md
RAPPORT_FINAL_COMPLET.md

181
API_AUTH_DOC.md Normal file
View File

@@ -0,0 +1,181 @@
# Documentation Authentification LucasApp
## Réponses aux questions
### 1. Quel endpoint doit recevoir/vérifier ce mot de passe ?
**Endpoint : `https://lucas.unigest.fr/api/auth.php`**
**Méthode :** POST
**Content-Type :** application/json
**Corps de la requête :**
```json
{
"client_id": "Demo_01",
"password": "75a9d2db3919",
"fcm_token": "fheHxyPRQlS7..." (optionnel)
}
```
**Réponse succès (200) :**
```json
{
"status": "success",
"message": "Authentification réussie",
"data": {
"token": "secret123",
"client_name": "Demo_01",
"senior_name": "Mamie Lucas",
"senior_nickname": "mamie",
"senior_photo": "photos/mamie.jpg",
"latitude": "42.1028",
"longitude": "9.5147",
"emergency_number": "15",
"contacts": [...],
"site_status": "active"
}
}
```
**Réponses erreur :**
- **400** : Identifiant ou mot de passe manquant
- **403** : Identifiant inconnu / Mot de passe incorrect / Compte non configuré
- **500** : Erreur serveur
---
### 2. Le mot de passe est-il haché côté serveur (bcrypt, SHA256) ou comparé en clair ?
**Réponse : BCRYPT**
- Le mot de passe est **haché avec bcrypt** (`password_hash($password, PASSWORD_BCRYPT)`)
- Le hash est stocké dans `database.json` sous le champ `password_hash`
- La vérification utilise `password_verify($password, $password_hash)`
- **Sécurité maximale** : même si database.json est compromis, les mots de passe ne peuvent pas être déchiffrés
---
### 3. Le flux voulu : le serveur génère le mot de passe et le donne au client, ou le client le choisit et le serveur le stocke ?
**Réponse : SERVEUR GÉNÈRE (recommandé)**
**Flux principal (production) :**
1. L'admin exécute `php generate_password.php Demo_01`
2. Le serveur génère un mot de passe aléatoire (12 caractères)
3. Le serveur affiche le mot de passe en clair (une seule fois)
4. L'admin le communique au client (QR code, SMS, email)
5. Le client le saisit dans LucasApp
6. L'app l'utilise pour s'authentifier via `/api/auth.php`
**Flux alternatif (si besoin) :**
- L'admin peut définir un mot de passe personnalisé avec `php set_password.php Demo_01 MonMotDePasse`
- Utile si le client veut choisir son propre mot de passe
---
## Intégration dans LucasApp
### Étape 1 : Configuration initiale
Quand l'utilisateur ouvre le dialog "Configurer le compte" :
- Demander `client_id` (ID du compte)
- Demander `password` (mot de passe fourni par l'admin)
### Étape 2 : Authentification
```dart
Future<void> _authenticate() async {
final response = await http.post(
Uri.parse('https://lucas.unigest.fr/api/auth.php'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'client_id': _clientId,
'password': _clientPassword,
'fcm_token': await FirebaseMessaging.instance.getToken(),
}),
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
final token = data['data']['token'];
// Stocker le token + infos client dans SharedPreferences
await _prefs.setString('client_token', token);
await _prefs.setString('client_name', data['data']['client_name']);
await _prefs.setString('senior_name', data['data']['senior_name']);
// ... etc
} else {
// Erreur d'authentification
_showError('Identifiant ou mot de passe incorrect');
}
}
```
### Étape 3 : Enregistrement FCM token
Le token FCM est **automatiquement enregistré** lors de l'authentification si vous le passez dans le corps de la requête.
**Pas besoin d'appel séparé à `/api/register`** — tout se fait en une seule requête.
---
## Architecture complète
### SmartEye (Jetson) → Lucas
- **Endpoint :** `/api.php`
- **Auth :** `client_id` + `token` (en POST)
- **Usage :** Upload image de détection de chute
- **Inchangé** — SmartEye continue d'utiliser le token directement
### LucasApp (Flutter) → Lucas
- **Endpoint :** `/api/auth.php`
- **Auth :** `client_id` + `password` (en JSON)
- **Usage :** Authentification + récupération du token
- **Nouveau** — LucasApp utilise le mot de passe pour obtenir le token
### Sécurité
- **Mot de passe** : stocké haché (bcrypt) dans database.json
- **Token** : stocké en clair (car utilisé par SmartEye en POST)
- **Le mot de passe NE TRANSITE JAMAIS en clair** sur le réseau (HTTPS obligatoire)
---
## Commandes admin
### Générer un mot de passe aléatoire
```bash
sudo -u www-data php /var/www/lucas/admin/generate_password.php Demo_01
```
### Définir un mot de passe personnalisé
```bash
sudo -u www-data php /var/www/lucas/admin/set_password.php Demo_01 MonMotDePasse123
```
### Vérifier les mots de passe configurés
```bash
grep -A 2 '"name":' /var/www/lucas/database.json | grep -E '"name"|password_hash'
```
---
## Exemple de test curl
```bash
curl -X POST https://lucas.unigest.fr/api/auth.php \
-H "Content-Type: application/json" \
-d '{
"client_id": "Demo_01",
"password": "75a9d2db3919"
}'
```
**Réponse attendue :**
```json
{
"status": "success",
"message": "Authentification réussie",
"data": {
"token": "secret123",
...
}
}
```

10
CLAUDE.md Normal file
View File

@@ -0,0 +1,10 @@
# Instructions Claude Code
## Autonomie
- Travaille de manière autonome sans interruption. Ne pose jamais de question à l'utilisateur sauf s'il le demande explicitement.
- En cas de doute ou d'ambiguïté, fais le choix le plus raisonnable et note-le dans ta réponse finale.
- Si une commande échoue, tente une alternative plutôt que de demander quoi faire.
- Ne demande jamais confirmation avant d'éditer/créer des fichiers — c'est déjà autorisé dans les permissions.
## Langue
- Communique toujours en français.

31
acknowledge.php Executable file
View File

@@ -0,0 +1,31 @@
<?php
header("Content-Type: application/json");
$json_file = 'database.json';
// 1. Lire la DB
if (!file_exists($json_file)) die(json_encode(["status"=>"error"]));
$db = json_decode(file_get_contents($json_file), true);
// 2. Récupérer les infos
$client_in = $_REQUEST['client'] ?? '';
$token_in = $_REQUEST['token'] ?? '';
$user = $_REQUEST['user'] ?? 'Inconnu';
// 3. Trouver le client
$idx = -1;
foreach ($db['clients'] as $i => $c) {
if (strcasecmp($c['name'], $client_in)==0 && $c['token']===$token_in) {
$idx = $i; break;
}
}
if ($idx === -1) die(json_encode(["status"=>"error"]));
// 4. JUSTE METTRE À JOUR LE STATUT (On ne touche pas aux images !)
// On garde 'alerte' = true, mais on ajoute 'handled_by'
$db['clients'][$idx]['handled_by'] = $user;
$db['clients'][$idx]['handled_at'] = date("H:i");
file_put_contents($json_file, json_encode($db, JSON_PRETTY_PRINT));
echo json_encode(["status"=>"success"]);
?>

482
admin.php Executable file
View File

@@ -0,0 +1,482 @@
<?php
// --- SMARTEYE ADMIN DASHBOARD v3.0 (Lucas Edition) ---
session_start();
$ADMIN_PASS = "smart123"; // ⚠️ Mot de passe Admin
$DB_FILE = "database.json";
// --- AUTHENTIFICATION ---
if (isset($_POST['login'])) {
if ($_POST['pass'] === $ADMIN_PASS) { $_SESSION['admin'] = true; }
else { $error = "Mot de passe incorrect"; }
}
if (isset($_GET['logout'])) { session_destroy(); header("Location: admin.php"); exit; }
if (!isset($_SESSION['admin'])) {
// Page de Login
echo '<!DOCTYPE html><html lang="fr"><head><title>Admin Login</title><script src="https://cdn.tailwindcss.com"></script></head>
<body class="bg-slate-900 flex items-center justify-center h-screen">
<div class="bg-slate-800 p-8 rounded-xl shadow-2xl border border-slate-700 w-96 text-center">
<div class="w-12 h-12 bg-emerald-500 rounded-full mx-auto mb-4 animate-pulse"></div>
<h1 class="text-2xl font-bold text-white mb-6 tracking-wider">SMARTEYE <span class="text-emerald-400">LUCAS</span></h1>
<form method="post" class="space-y-4">
<input type="password" name="pass" placeholder="Code d\'accès" class="w-full bg-slate-900 border border-slate-700 text-white px-4 py-3 rounded focus:outline-none focus:border-emerald-500 text-center text-lg">
<button name="login" class="w-full bg-emerald-600 hover:bg-emerald-500 text-white font-bold py-3 rounded transition">Connexion</button>
</form>
'.(isset($error)?'<p class="text-red-500 mt-4 text-sm">'.$error.'</p>':'').'
</div></body></html>';
exit;
}
// --- CHARGEMENT DES DONNÉES ---
$data = file_exists($DB_FILE) ? json_decode(file_get_contents($DB_FILE), true) : ['clients' => []];
// Sécurité : structure minimale si fichier vide ou corrompu
if (!isset($data['clients'])) $data['clients'] = [];
// --- FONCTION TEST ALERTE (SIMULATION SMS) ---
if (isset($_GET['test_alert'])) {
$idTest = $_GET['test_alert'];
if (isset($data['clients'][$idTest])) {
$client = $data['clients'][$idTest];
$contacts = $client['contacts'] ?? [];
$count = 0;
$mobiles = [];
// ICI : Simulation de l'appel API SMS
// Dans le futur api.php, on utilisera ces numéros
foreach($contacts as $c) {
if(!empty($c['phone'])) {
$mobiles[] = $c['phone'];
$count++;
// LOG l'envoi pour preuve
$log_msg = date("Y-m-d H:i:s") . " [TEST ADMIN] SMS vers {$c['phone']} pour {$client['name']}\n";
file_put_contents("sms_history.log", $log_msg, FILE_APPEND);
}
}
$successMsg = "✅ Test d'alerte simulé envoyé à $count contacts !<br><span class='text-xs opacity-70'>(" . implode(', ', $mobiles) . ")</span>";
}
}
// --- SAUVEGARDE CLIENT (AJOUT / EDIT) ---
if (isset($_POST['save_client'])) {
// Nettoyage de l'ID pour éviter les caractères spéciaux
$id = preg_replace('/[^a-z0-9_]/', '', strtolower($_POST['client_id']));
// Initialisation si nouveau
if (!isset($data['clients'][$id])) {
$data['clients'][$id]['created_at'] = date('Y-m-d H:i:s');
$data['clients'][$id]['alerte'] = false;
// Création dossier images
$client_dir = "clients/" . $_POST['name'] . "/";
if (!is_dir($client_dir)) { mkdir($client_dir, 0775, true); }
}
// Token : saisi manuellement, ou auto-généré si vide
$token_input = trim($_POST['token'] ?? '');
if (!empty($token_input)) {
$data['clients'][$id]['token'] = $token_input;
} elseif (!isset($data['clients'][$id]['token'])) {
$data['clients'][$id]['token'] = bin2hex(random_bytes(4));
}
// Mot de passe LucasApp (bcrypt) — ignorer si valeur inchangée (bullets)
$new_password = trim($_POST['client_password'] ?? '');
if (!empty($new_password) && $new_password !== str_repeat("\xE2\x80\xA2", 8) && $new_password !== '••••••••') {
$data['clients'][$id]['password_hash'] = password_hash($new_password, PASSWORD_BCRYPT);
}
// Mise à jour des champs
$data['clients'][$id]['name'] = $_POST['name'];
$data['clients'][$id]['address'] = $_POST['address'];
$data['clients'][$id]['city'] = $_POST['city'];
$data['clients'][$id]['phone_mobile'] = $_POST['phone_mobile'];
$data['clients'][$id]['phone_fixed'] = $_POST['phone_fixed'];
$data['clients'][$id]['cp'] = $_POST['cp'] ?? '';
$data['clients'][$id]['date_naissance'] = $_POST['date_naissance'] ?? '';
$data['clients'][$id]['sex'] = $_POST['sex'];
// Champs bénéficiaire
$data['clients'][$id]['senior_name'] = $_POST['senior_name'] ?? '';
$data['clients'][$id]['senior_nickname'] = $_POST['senior_nickname'] ?? '';
$data['clients'][$id]['senior_photo'] = $_POST['senior_photo'] ?? '';
if (!isset($data['clients'][$id]['fcm_tokens'])) {
$data['clients'][$id]['fcm_tokens'] = [];
}
// Champs compte / technique
$data['clients'][$id]['jetson_id'] = $_POST['jetson_id'] ?? '';
$data['clients'][$id]['jetson_ip'] = $_POST['jetson_ip'] ?? '';
$data['clients'][$id]['jetson_modele'] = $_POST['jetson_modele'] ?? '';
$data['clients'][$id]['jetson_ram'] = $_POST['jetson_ram'] ?? '';
$data['clients'][$id]['jetson_ssd'] = $_POST['jetson_ssd'] ?? '';
// Caméras (5 max)
$cameras = [];
if (isset($_POST['cam_marque'])) {
for ($i = 0; $i < count($_POST['cam_marque']); $i++) {
if (!empty($_POST['cam_marque'][$i]) || !empty($_POST['cam_ip'][$i])) {
$cameras[] = [
'marque' => $_POST['cam_marque'][$i] ?? '',
'modele' => $_POST['cam_modele'][$i] ?? '',
'serie' => $_POST['cam_serie'][$i] ?? '',
'ip' => $_POST['cam_ip'][$i] ?? '',
'port_rtsp' => $_POST['cam_port_rtsp'][$i] ?? '',
];
}
}
}
$data['clients'][$id]['cameras'] = $cameras;
// Gestion des contacts multiples
$contacts = [];
if (isset($_POST['contact_name'])) {
for ($i = 0; $i < count($_POST['contact_name']); $i++) {
if (!empty($_POST['contact_name'][$i])) {
$contacts[] = [
'name' => $_POST['contact_name'][$i],
'role' => $_POST['contact_role'][$i],
'phone' => $_POST['contact_phone'][$i],
'email' => $_POST['contact_email'][$i],
'os' => $_POST['contact_os'][$i] ?? ''
];
}
}
}
$data['clients'][$id]['contacts'] = $contacts;
// Écriture DB
file_put_contents($DB_FILE, json_encode($data, JSON_PRETTY_PRINT));
$tab = $_POST['current_tab'] ?? 'identite';
header("Location: admin.php?edit=$id&success=1&tab=$tab"); exit;
}
// --- ARRÊT ALERTE ---
if (isset($_GET['stop_alert'])) {
$stopId = $_GET['stop_alert'];
if (isset($data['clients'][$stopId])) {
$data['clients'][$stopId]['alerte'] = false;
unset($data['clients'][$stopId]['handled_by']);
unset($data['clients'][$stopId]['handled_at']);
unset($data['clients'][$stopId]['message']);
unset($data['clients'][$stopId]['last_update']);
file_put_contents($DB_FILE, json_encode($data, JSON_PRETTY_PRINT));
// Reload data after save
$data = json_decode(file_get_contents($DB_FILE), true);
}
header("Location: admin.php?alert_stopped=1"); exit;
}
// --- SUPPRESSION ---
if (isset($_GET['del'])) {
unset($data['clients'][$_GET['del']]);
file_put_contents($DB_FILE, json_encode($data, JSON_PRETTY_PRINT));
header("Location: admin.php"); exit;
}
// --- PRÉPARATION VUE ---
$editClient = null; $editId = "";
if (isset($_GET['edit'])) {
$editId = $_GET['edit'];
$editClient = $data['clients'][$editId] ?? null;
} elseif (isset($_GET['new'])) {
// Mode nouveau client
$editClient = null;
}
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SmartEye Admin</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
function addContact() {
const tpl = document.getElementById('contact-template').innerHTML;
document.getElementById('contacts-container').insertAdjacentHTML('beforeend', tpl);
}
function removeContact(btn) { btn.closest('.contact-row').remove(); }
function switchTab(name) {
document.querySelectorAll('.tab-panel').forEach(p => p.classList.add('hidden'));
document.querySelectorAll('.tab-btn').forEach(b => {
b.classList.remove('border-violet-500','border-emerald-500','border-blue-500','text-violet-400','text-emerald-400','text-blue-400','bg-slate-800/50');
b.classList.add('text-slate-500','border-transparent');
});
document.getElementById('panel-'+name).classList.remove('hidden');
const btn = document.getElementById('tab-'+name);
btn.classList.remove('text-slate-500','border-transparent');
const colors = {identite:['violet'],compte:['emerald'],contacts:['blue']};
const c = (colors[name]||['violet'])[0];
btn.classList.add('border-'+c+'-500','text-'+c+'-400','bg-slate-800/50');
const h = document.getElementById('current_tab');
if (h) h.value = name;
}
document.addEventListener('DOMContentLoaded', function() {
const params = new URLSearchParams(window.location.search);
const tab = params.get('tab');
if (tab && document.getElementById('panel-'+tab)) switchTab(tab);
});
</script>
<style>body { background-color: #0f172a; color: #e2e8f0; }</style>
</head>
<body class="min-h-screen flex flex-col">
<nav class="bg-slate-900 border-b border-slate-800 px-6 py-4 flex justify-between items-center sticky top-0 z-50 shadow-md">
<div class="flex items-center gap-3">
<div class="w-3 h-3 rounded-full bg-emerald-500"></div>
<span class="font-bold text-xl tracking-wider text-white">SMARTEYE <span class="text-slate-500 text-sm font-normal">MANAGER</span></span>
</div>
<?php if($editClient): ?>
<span class="text-slate-400 font-mono text-sm bg-slate-800 px-4 py-2 rounded border border-slate-700"><?php echo $editId; ?></span>
<?php endif; ?>
<div class="flex gap-4">
<?php if($editClient || isset($_GET['new'])): ?>
<a href="admin.php" class="bg-slate-700 hover:bg-slate-600 text-white px-4 py-2 rounded text-sm flex items-center gap-2 transition"><span>↩</span> Retour</a>
<?php endif; ?>
<a href="?logout" class="text-red-400 hover:text-white text-sm border border-red-900/50 px-3 py-2 rounded transition">Déconnexion</a>
</div>
</nav>
<div class="flex-1 p-8 max-w-7xl mx-auto w-full">
<?php if(isset($_GET['success'])): ?>
<div class="bg-emerald-900/50 border border-emerald-500/50 text-emerald-200 px-4 py-3 rounded mb-6 text-center animate-bounce">✅ Modifications enregistrées avec succès !</div>
<?php endif; ?>
<?php if(isset($successMsg)): ?>
<div class="bg-blue-900/50 border border-blue-500/50 text-blue-200 px-4 py-3 rounded mb-6 text-center"><?php echo $successMsg; ?></div>
<?php endif; ?>
<?php if(isset($_GET['alert_stopped'])): ?>
<div class="bg-emerald-900/50 border border-emerald-500/50 text-emerald-200 px-4 py-3 rounded mb-6 text-center">✅ Alerte arrêtée avec succès — Le client est revenu en surveillance active.</div>
<?php endif; ?>
<?php if($editClient || isset($_GET['new'])): ?>
<form method="post" class="max-w-4xl mx-auto">
<div class="bg-slate-800 rounded-xl border border-slate-700 shadow-lg overflow-hidden">
<?php if($editClient): ?>
<input type="hidden" name="client_id" value="<?php echo $editId; ?>">
<?php endif; ?>
<input type="hidden" name="current_tab" id="current_tab" value="<?php echo htmlspecialchars($_GET['tab'] ?? 'identite'); ?>">
<!-- ONGLETS -->
<div class="flex border-b border-slate-700 bg-slate-900">
<button type="button" onclick="switchTab('identite')" id="tab-identite" class="tab-btn flex-1 px-4 py-3 text-sm font-bold text-violet-400 border-b-2 border-violet-500 bg-slate-800/50 transition">👤 Identité</button>
<button type="button" onclick="switchTab('compte')" id="tab-compte" class="tab-btn flex-1 px-4 py-3 text-sm font-bold text-slate-500 border-b-2 border-transparent hover:text-emerald-400 transition">📱 Infos Compte</button>
<button type="button" onclick="switchTab('contacts')" id="tab-contacts" class="tab-btn flex-1 px-4 py-3 text-sm font-bold text-slate-500 border-b-2 border-transparent hover:text-blue-400 transition">📞 Réseau de Contact</button>
</div>
<div class="p-6">
<!-- TAB IDENTITÉ -->
<div id="panel-identite" class="tab-panel space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="text-xs text-violet-300 uppercase font-bold">Nom complet</label>
<input name="senior_name" value="<?php echo $editClient['senior_name'] ?? ''; ?>" placeholder="Marie Dupont" class="w-full bg-slate-900 border border-violet-500/50 rounded px-3 py-2 text-white text-lg font-bold">
</div>
<div>
<label class="text-xs text-violet-300 uppercase font-bold">Surnom</label>
<input name="senior_nickname" value="<?php echo $editClient['senior_nickname'] ?? ''; ?>" placeholder="Mamie" class="w-full bg-slate-900 border border-violet-500/50 rounded px-3 py-2 text-white">
</div>
</div>
<div class="grid grid-cols-3 gap-3">
<div><label class="text-xs text-slate-500 uppercase font-bold">Date de naissance</label><input name="date_naissance" type="date" value="<?php echo $editClient['date_naissance'] ?? ''; ?>" class="w-full bg-slate-900 border border-slate-600 rounded px-3 py-2 text-white"></div>
<div><label class="text-xs text-slate-500 uppercase font-bold">Sexe</label><select name="sex" class="w-full bg-slate-900 border border-slate-600 rounded px-3 py-2 text-white"><option value="F" <?php echo ($editClient['sex']??'')=='F'?'selected':''; ?>>Femme</option><option value="H" <?php echo ($editClient['sex']??'')=='H'?'selected':''; ?>>Homme</option></select></div>
<div><label class="text-xs text-slate-500 uppercase font-bold">Photo</label><input name="senior_photo" value="<?php echo $editClient['senior_photo'] ?? ''; ?>" placeholder="photos/mamie.jpg" class="w-full bg-slate-900 border border-slate-600 rounded px-3 py-2 text-white text-sm"></div>
</div>
<div><label class="text-xs text-slate-500 uppercase font-bold">Adresse</label><input name="address" value="<?php echo $editClient['address'] ?? ''; ?>" class="w-full bg-slate-900 border border-slate-600 rounded px-3 py-2 text-white"></div>
<div class="grid grid-cols-3 gap-3">
<div><label class="text-xs text-slate-500 uppercase font-bold">Code postal</label><input name="cp" value="<?php echo $editClient['cp'] ?? ''; ?>" placeholder="75001" class="w-full bg-slate-900 border border-slate-600 rounded px-3 py-2 text-white font-mono"></div>
<div class="col-span-2"><label class="text-xs text-slate-500 uppercase font-bold">Ville</label><input name="city" value="<?php echo $editClient['city'] ?? ''; ?>" placeholder="Paris" class="w-full bg-slate-900 border border-slate-600 rounded px-3 py-2 text-white"></div>
</div>
<div class="grid grid-cols-2 gap-3">
<div><label class="text-xs text-slate-500 uppercase font-bold">Mobile</label><input name="phone_mobile" value="<?php echo $editClient['phone_mobile'] ?? ''; ?>" required class="w-full bg-slate-900 border border-slate-600 rounded px-3 py-2 text-white"></div>
<div><label class="text-xs text-slate-500 uppercase font-bold">Fixe</label><input name="phone_fixed" value="<?php echo $editClient['phone_fixed'] ?? ''; ?>" class="w-full bg-slate-900 border border-slate-600 rounded px-3 py-2 text-white"></div>
</div>
</div>
<!-- TAB INFOS COMPTE -->
<div id="panel-compte" class="tab-panel space-y-4 hidden">
<?php if(!$editClient): ?>
<div>
<label class="text-xs text-emerald-400 uppercase font-bold">ID Système (clé unique)</label>
<input name="client_id" required placeholder="ex: pizzulo" class="w-full bg-slate-900 border border-emerald-500/50 rounded px-3 py-2 text-white font-mono text-sm">
<p class="text-xs text-slate-600 mt-1">Identifiant technique interne, non modifiable</p>
</div>
<?php endif; ?>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="text-xs text-emerald-400 uppercase font-bold">Identifiant App *</label>
<input name="name" value="<?php echo $editClient['name'] ?? ''; ?>" required class="w-full bg-slate-900 border border-emerald-500/50 rounded px-3 py-2 text-white font-bold">
<p class="text-xs text-slate-600 mt-1">Saisi par le client dans LucasApp</p>
</div>
<div>
<label class="text-xs text-emerald-400 uppercase font-bold">Mot de passe App *</label>
<input name="client_password" type="password" value="<?php echo !empty($editClient['password_hash']) ? '••••••••' : ''; ?>" <?php echo empty($editClient['password_hash']) ? 'required' : ''; ?> placeholder="<?php echo empty($editClient['password_hash']) ? 'non renseigné' : ''; ?>" class="w-full bg-slate-900 border <?php echo empty($editClient['password_hash']) ? 'border-red-500/50' : 'border-emerald-500/50'; ?> rounded px-3 py-2 text-white">
<p class="text-xs <?php echo !empty($editClient['password_hash']) ? 'text-slate-500' : 'text-red-400'; ?> mt-1"><?php echo !empty($editClient['password_hash']) ? 'Laisser tel quel pour ne pas changer' : 'Obligatoire — l\'app ne peut pas se connecter'; ?></p>
</div>
</div>
<?php if(!empty($editClient['fcm_tokens'])): ?>
<div class="bg-emerald-900/20 border border-emerald-500/30 rounded px-3 py-2 text-xs text-emerald-300"><?php echo count($editClient['fcm_tokens']); ?> appareil(s) connecté(s)</div>
<?php endif; ?>
<div>
<label class="text-xs text-amber-400 uppercase font-bold">Token SmartEye *</label>
<div class="flex gap-2">
<input name="token" value="<?php echo $editClient['token'] ?? ''; ?>" <?php echo $editClient ? '' : 'placeholder="Auto-généré si vide"'; ?> class="flex-1 bg-slate-900 border border-amber-500/50 rounded px-3 py-2 text-amber-200 font-mono text-sm">
<?php if($editClient): ?>
<button type="button" onclick="if(confirm('Régénérer le token ?')){document.querySelector('input[name=token]').value='<?php echo bin2hex(random_bytes(4)); ?>';}" class="bg-amber-600/20 text-amber-400 hover:bg-amber-600 hover:text-white px-3 rounded text-xs border border-amber-900 transition">Régénérer</button>
<?php endif; ?>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="text-xs text-slate-500 uppercase font-bold">Date de création</label>
<input value="<?php echo $editClient['created_at'] ?? date('Y-m-d H:i:s'); ?>" disabled class="w-full bg-slate-950 border border-slate-700 rounded px-3 py-2 text-slate-400 text-sm">
</div>
<div>
<label class="text-xs text-emerald-400 uppercase font-bold">Identifiant Jetson</label>
<input name="jetson_id" value="<?php echo $editClient['jetson_id'] ?? ''; ?>" placeholder="ex: jetson-nano-01" class="w-full bg-slate-900 border border-emerald-500/50 rounded px-3 py-2 text-white font-mono text-sm">
</div>
</div>
<div class="grid grid-cols-4 gap-3">
<div><label class="text-xs text-slate-500 uppercase font-bold">IP Jetson</label><input name="jetson_ip" value="<?php echo $editClient['jetson_ip'] ?? ''; ?>" placeholder="192.168.1.x" class="w-full bg-slate-900 border border-slate-600 rounded px-2 py-1.5 text-white text-sm font-mono"></div>
<div><label class="text-xs text-slate-500 uppercase font-bold">Modèle</label><input name="jetson_modele" value="<?php echo $editClient['jetson_modele'] ?? ''; ?>" placeholder="Nano / Orin" class="w-full bg-slate-900 border border-slate-600 rounded px-2 py-1.5 text-white text-sm"></div>
<div><label class="text-xs text-slate-500 uppercase font-bold">RAM (Go)</label><input name="jetson_ram" value="<?php echo $editClient['jetson_ram'] ?? ''; ?>" placeholder="4" type="number" class="w-full bg-slate-900 border border-slate-600 rounded px-2 py-1.5 text-white text-sm font-mono"></div>
<div><label class="text-xs text-slate-500 uppercase font-bold">SSD (Go)</label><input name="jetson_ssd" value="<?php echo $editClient['jetson_ssd'] ?? ''; ?>" placeholder="128" type="number" class="w-full bg-slate-900 border border-slate-600 rounded px-2 py-1.5 text-white text-sm font-mono"></div>
</div>
<!-- CAMÉRAS -->
<div class="mt-2">
<label class="text-xs text-amber-400 uppercase font-bold tracking-wider">Caméras</label>
</div>
<?php for ($cam = 1; $cam <= 5; $cam++): $camData = $editClient['cameras'][$cam - 1] ?? []; ?>
<div class="bg-slate-900/50 border border-slate-700 rounded p-3 <?php echo empty($camData['marque']) && $cam > 1 ? 'opacity-50' : ''; ?>">
<div class="text-xs text-slate-500 font-bold mb-2">CAM <?php echo $cam; ?></div>
<div class="grid grid-cols-5 gap-2">
<div><input name="cam_marque[]" value="<?php echo $camData['marque'] ?? ''; ?>" placeholder="Marque" class="w-full bg-slate-900 border border-slate-600 rounded px-2 py-1.5 text-white text-sm"></div>
<div><input name="cam_modele[]" value="<?php echo $camData['modele'] ?? ''; ?>" placeholder="Modèle" class="w-full bg-slate-900 border border-slate-600 rounded px-2 py-1.5 text-white text-sm"></div>
<div><input name="cam_serie[]" value="<?php echo $camData['serie'] ?? ''; ?>" placeholder="N° série" class="w-full bg-slate-900 border border-slate-600 rounded px-2 py-1.5 text-white text-sm font-mono"></div>
<div><input name="cam_ip[]" value="<?php echo $camData['ip'] ?? ''; ?>" placeholder="192.168.1.x" class="w-full bg-slate-900 border border-slate-600 rounded px-2 py-1.5 text-white text-sm font-mono"></div>
<div><input name="cam_port_rtsp[]" value="<?php echo $camData['port_rtsp'] ?? ''; ?>" placeholder="Port tunnel" type="number" class="w-full bg-slate-900 border border-amber-500/50 rounded px-2 py-1.5 text-amber-200 text-sm font-mono"></div>
</div>
</div>
<?php endfor; ?>
</div>
<!-- TAB RÉSEAU DE CONTACT -->
<div id="panel-contacts" class="tab-panel space-y-4 hidden">
<div class="flex justify-between items-center">
<p class="text-xs text-slate-500">Famille, voisins, aidants — prévenus en cas d'alerte</p>
<div class="flex gap-2">
<?php if($editClient): ?>
<a href="?test_alert=<?php echo $editId; ?>" onclick="return confirm('Envoyer un SMS de test simulé ?')" class="bg-orange-600 hover:bg-orange-500 text-white px-3 py-1 rounded text-xs font-bold transition">Simulation Alerte</a>
<?php endif; ?>
<button type="button" onclick="addContact()" class="text-xs bg-blue-600 hover:bg-blue-500 px-3 py-1 rounded text-white transition">+ Ajouter</button>
</div>
</div>
<div id="contacts-container" class="space-y-3 max-h-[400px] overflow-y-auto">
<?php if(!empty($editClient['contacts'])): foreach($editClient['contacts'] as $c): ?>
<div class="contact-row grid grid-cols-12 gap-2 items-center bg-slate-900/50 p-3 rounded border border-slate-700 group hover:border-slate-500 transition">
<div class="col-span-2"><input name="contact_name[]" value="<?php echo $c['name']; ?>" placeholder="Nom" class="w-full bg-transparent border-b border-slate-600 text-sm px-2 py-1 focus:border-blue-500 outline-none text-white"></div>
<div class="col-span-2"><input name="contact_role[]" value="<?php echo $c['role']; ?>" placeholder="Rôle" class="w-full bg-transparent border-b border-slate-600 text-sm px-2 py-1 focus:border-blue-500 outline-none text-white"></div>
<div class="col-span-3"><input name="contact_phone[]" value="<?php echo $c['phone']; ?>" placeholder="Tél" class="w-full bg-transparent border-b border-slate-600 text-sm px-2 py-1 focus:border-blue-500 outline-none text-white"></div>
<div class="col-span-2"><input name="contact_email[]" value="<?php echo $c['email']; ?>" placeholder="Email" class="w-full bg-transparent border-b border-slate-600 text-sm px-2 py-1 focus:border-blue-500 outline-none text-white"></div>
<div class="col-span-2"><select name="contact_os[]" class="w-full bg-transparent border-b border-slate-600 text-sm px-1 py-1 outline-none text-white"><option value="" <?php echo empty($c['os'])?'selected':''; ?>>OS</option><option value="Android" <?php echo ($c['os']??'')==='Android'?'selected':''; ?>>Android</option><option value="iOS" <?php echo ($c['os']??'')==='iOS'?'selected':''; ?>>iOS</option></select></div>
<div class="col-span-1 text-right"><button type="button" onclick="removeContact(this)" class="text-red-500 hover:text-red-300 p-1 font-bold">×</button></div>
</div>
<?php endforeach; endif; ?>
</div>
<script type="text/template" id="contact-template">
<div class="contact-row grid grid-cols-12 gap-2 items-center bg-slate-900/50 p-3 rounded border border-slate-700">
<div class="col-span-2"><input name="contact_name[]" placeholder="Nom" class="w-full bg-transparent border-b border-slate-600 text-sm px-2 py-1 outline-none text-white"></div>
<div class="col-span-2"><input name="contact_role[]" placeholder="Rôle" class="w-full bg-transparent border-b border-slate-600 text-sm px-2 py-1 outline-none text-white"></div>
<div class="col-span-3"><input name="contact_phone[]" placeholder="Tél" class="w-full bg-transparent border-b border-slate-600 text-sm px-2 py-1 outline-none text-white"></div>
<div class="col-span-2"><input name="contact_email[]" placeholder="Email" class="w-full bg-transparent border-b border-slate-600 text-sm px-2 py-1 outline-none text-white"></div>
<div class="col-span-2"><select name="contact_os[]" class="w-full bg-transparent border-b border-slate-600 text-sm px-1 py-1 outline-none text-white"><option value="">OS</option><option value="Android">Android</option><option value="iOS">iOS</option></select></div>
<div class="col-span-1 text-right"><button type="button" onclick="removeContact(this)" class="text-red-500 hover:text-red-300 p-1 font-bold">×</button></div>
</div>
</script>
</div>
<!-- BOUTON SAVE (toujours visible) -->
<div class="mt-6 pt-4 border-t border-slate-700 flex justify-between items-center">
<a href="admin.php" class="text-slate-400 hover:text-white px-4 py-2 rounded border border-slate-600 transition">Annuler</a>
<button name="save_client" class="bg-emerald-600 hover:bg-emerald-500 text-white font-bold py-3 px-8 rounded shadow-lg transition">Enregistrer</button>
</div>
</div>
</div>
</form>
<?php else: ?>
<div class="bg-slate-800 rounded-xl border border-slate-700 shadow-lg overflow-hidden">
<div class="p-6 border-b border-slate-700 flex justify-between items-center bg-slate-800/80 backdrop-blur">
<h2 class="text-slate-200 font-bold text-lg flex items-center gap-2"><span class="bg-emerald-500 w-2 h-6 rounded-full"></span> Parc Clients Surveillés</h2>
<a href="?new=1" class="bg-emerald-600 hover:bg-emerald-500 text-white px-6 py-2 rounded font-bold transition shadow-lg flex items-center gap-2"><span>+</span> Nouveau Dossier</a>
</div>
<?php if(empty($data['clients'])): ?>
<div class="p-12 text-center text-slate-500">
<p class="text-4xl mb-4">📭</p>
<p>Aucun client enregistré.</p>
<p class="text-sm">Cliquez sur "Nouveau Dossier" pour commencer.</p>
</div>
<?php else: ?>
<table class="w-full text-left text-sm">
<thead class="bg-slate-900 text-slate-400 text-xs uppercase"><tr><th class="p-4">Bénéficiaire</th><th class="p-4">Localisation</th><th class="p-4">Contacts</th><th class="p-4">Statut</th><th class="p-4 text-right">Actions</th></tr></thead>
<tbody class="divide-y divide-slate-700">
<?php foreach ($data['clients'] as $id => $client): ?>
<tr class="hover:bg-slate-700/30 transition group">
<td class="p-4">
<div class="font-bold text-white text-base"><?php echo $client['name']; ?></div>
<div class="text-xs text-slate-500 mt-1">ID: <?php echo $id; ?></div>
</td>
<td class="p-4 text-slate-300">
<?php echo $client['city'] ?? '-'; ?><br>
<span class="text-xs text-slate-500 truncate max-w-[150px] block"><?php echo $client['address'] ?? ''; ?></span>
</td>
<td class="p-4">
<div class="flex -space-x-2">
<?php if(!empty($client['contacts'])): foreach(array_slice($client['contacts'],0,3) as $c): ?>
<div class="w-8 h-8 rounded-full bg-blue-900 border-2 border-slate-800 flex items-center justify-center text-xs font-bold text-blue-200" title="<?php echo $c['name']; ?>"><?php echo strtoupper(substr($c['name'],0,1)); ?></div>
<?php endforeach; endif; ?>
<div class="w-8 h-8 rounded-full bg-slate-700 border-2 border-slate-800 flex items-center justify-center text-xs text-slate-400"><?php echo count($client['contacts'] ?? []); ?></div>
</div>
</td>
<td class="p-4">
<?php if($client['alerte'] ?? false): ?>
<span class="bg-red-900 text-red-200 px-2 py-1 rounded text-xs font-bold animate-pulse">ALERTE EN COURS</span>
<?php else: ?>
<span class="bg-emerald-900/30 text-emerald-400 px-2 py-1 rounded text-xs">Surveillance Active</span>
<?php endif; ?>
</td>
<td class="p-4 text-right">
<div class="flex justify-end gap-2 opacity-80 group-hover:opacity-100 transition">
<?php if($client['alerte'] ?? false): ?>
<a href="?stop_alert=<?php echo $id; ?>" onclick="return confirm('Confirmer l\'arrêt de l\'alerte pour <?php echo addslashes($client['name']); ?> ?\nLe statut repassera en Surveillance Active.')" class="bg-emerald-600/20 text-emerald-400 hover:bg-emerald-600 hover:text-white px-2 py-1 rounded text-xs border border-emerald-900 transition animate-pulse" title="Arrêter l'alerte (traitée)">✅</a>
<?php endif; ?>
<a href="sms_dashboard.php" class="bg-purple-600/20 text-purple-400 hover:bg-purple-600 hover:text-white px-2 py-1 rounded text-xs border border-purple-900 transition" title="Historique SMS">📱</a>
<a href="?test_alert=<?php echo $id; ?>" onclick="return confirm('Lancer une simulation ?')" class="bg-orange-600/20 text-orange-400 hover:bg-orange-600 hover:text-white px-2 py-1 rounded text-xs border border-orange-900 transition" title="Test Alerte">🔔</a>
<a href="?edit=<?php echo $id; ?>" class="bg-blue-600/20 text-blue-400 hover:bg-blue-600 hover:text-white px-3 py-1 rounded text-xs border border-blue-900 transition">Éditer</a>
<a href="index.php?id=<?php echo $id; ?>" target="_blank" class="bg-slate-700 text-slate-300 hover:bg-slate-600 hover:text-white px-3 py-1 rounded text-xs border border-slate-600 transition">Voir</a>
<a href="?del=<?php echo $id; ?>" onclick="return confirm('Supprimer ce dossier définitivement ?')" class="text-red-500 hover:text-red-400 px-2 py-1">×</a>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
</body>
</html>

View File

@@ -0,0 +1,58 @@
<?php
/**
* GÉNÉRATEUR DE MOT DE PASSE CLIENT
*
* Usage : php generate_password.php <client_id>
* Génère un mot de passe aléatoire, le hash, et met à jour database.json
*/
if (php_sapi_name() !== 'cli') {
die("Ce script doit être exécuté en ligne de commande.\n");
}
if ($argc < 2) {
echo "Usage : php generate_password.php <client_id>\n";
echo "Exemple : php generate_password.php Demo_01\n";
exit(1);
}
$client_id = $argv[1];
$json_file = dirname(__DIR__) . '/database.json';
if (!file_exists($json_file)) {
die("Erreur : database.json introuvable\n");
}
$db = json_decode(file_get_contents($json_file), true);
// Recherche du client
$client_index = -1;
if (isset($db['clients'])) {
foreach ($db['clients'] as $index => $c) {
if (strcasecmp($c['name'], $client_id) == 0) {
$client_index = $index;
break;
}
}
}
if ($client_index === -1) {
die("Erreur : Client '$client_id' introuvable dans la base de données\n");
}
// Génération mot de passe aléatoire (12 caractères : lettres + chiffres)
$password = bin2hex(random_bytes(6)); // 12 caractères hexadécimaux
$password_hash = password_hash($password, PASSWORD_BCRYPT);
// Mise à jour de la base
$db['clients'][$client_index]['password_hash'] = $password_hash;
file_put_contents($json_file, json_encode($db, JSON_PRETTY_PRINT));
// Affichage
echo "✅ Mot de passe généré avec succès pour '$client_id'\n";
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n";
echo "📱 Mot de passe : $password\n";
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n";
echo "\n⚠️ Communiquez ce mot de passe au client de manière sécurisée.\n";
echo " Le hash bcrypt a été enregistré dans database.json\n\n";
?>

54
admin/set_password.php Normal file
View File

@@ -0,0 +1,54 @@
<?php
/**
* DÉFINIR UN MOT DE PASSE CLIENT PERSONNALISÉ
*
* Usage : php set_password.php <client_id> <password>
* Hash un mot de passe fourni et met à jour database.json
*/
if (php_sapi_name() !== 'cli') {
die("Ce script doit être exécuté en ligne de commande.\n");
}
if ($argc < 3) {
echo "Usage : php set_password.php <client_id> <password>\n";
echo "Exemple : php set_password.php Demo_01 MonMotDePasse123\n";
exit(1);
}
$client_id = $argv[1];
$password = $argv[2];
$json_file = dirname(__DIR__) . '/database.json';
if (!file_exists($json_file)) {
die("Erreur : database.json introuvable\n");
}
$db = json_decode(file_get_contents($json_file), true);
// Recherche du client
$client_index = -1;
if (isset($db['clients'])) {
foreach ($db['clients'] as $index => $c) {
if (strcasecmp($c['name'], $client_id) == 0) {
$client_index = $index;
break;
}
}
}
if ($client_index === -1) {
die("Erreur : Client '$client_id' introuvable dans la base de données\n");
}
// Hash du mot de passe
$password_hash = password_hash($password, PASSWORD_BCRYPT);
// Mise à jour de la base
$db['clients'][$client_index]['password_hash'] = $password_hash;
file_put_contents($json_file, json_encode($db, JSON_PRETTY_PRINT));
// Affichage
echo "✅ Mot de passe défini avec succès pour '$client_id'\n";
echo " Le hash bcrypt a été enregistré dans database.json\n\n";
?>

105
alert_ack.php Normal file
View File

@@ -0,0 +1,105 @@
<?php
/**
* SmartEye SENTINEL - Endpoint d'Acquittement d'Alerte
* =====================================================
* Appelé par LucasApp (Flutter) pour signaler qu'une alerte a été vue/traitée.
*
* URL : https://lucas.unigest.fr/alert_ack.php
*
* Méthode : POST
* Body JSON :
* {
* "alert_id": "uuid-de-l-alerte",
* "status": "SEEN" | "ACKNOWLEDGED" | "RESOLVED",
* "detail": "optionnel - texte libre",
* "app_token": "token firebase pour vérification"
* }
*
* Réponse :
* {"success": true, "message": "..."}
* {"success": false, "error": "..."}
*
* Auteur : Unigest Solutions / SmartEye V30
*/
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
// Pré-vol CORS
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit;
}
// Vérifier la méthode
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(["success" => false, "error" => "Méthode non autorisée"]);
exit;
}
// Lire le body JSON
$input = json_decode(file_get_contents('php://input'), true);
if (!$input || !isset($input['alert_id']) || !isset($input['status'])) {
http_response_code(400);
echo json_encode(["success" => false, "error" => "Paramètres manquants : alert_id et status requis"]);
exit;
}
$alert_id = trim($input['alert_id']);
$status = strtoupper(trim($input['status']));
$detail = isset($input['detail']) ? trim($input['detail']) : null;
// Valider le statut
$statuts_valides = ['DELIVERED', 'SEEN', 'ACKNOWLEDGED', 'RESOLVED'];
if (!in_array($status, $statuts_valides)) {
http_response_code(400);
echo json_encode([
"success" => false,
"error" => "Statut invalide. Valeurs acceptées : " . implode(', ', $statuts_valides)
]);
exit;
}
// Valider le format de l'alert_id (UUID)
if (!preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $alert_id)) {
http_response_code(400);
echo json_encode(["success" => false, "error" => "Format alert_id invalide (UUID attendu)"]);
exit;
}
// Appeler le script Python pour mettre à jour le statut
$python_script = '/var/www/lucas/alert_ack_handler.py';
$cmd_args = escapeshellarg($alert_id) . ' ' . escapeshellarg($status);
if ($detail) {
$cmd_args .= ' ' . escapeshellarg($detail);
}
$command = "/usr/bin/python3 {$python_script} {$cmd_args} 2>&1";
$output = shell_exec($command);
if ($output === null) {
http_response_code(500);
echo json_encode(["success" => false, "error" => "Erreur exécution script Python"]);
exit;
}
// Le script Python retourne du JSON
$result = json_decode(trim($output), true);
if ($result === null) {
http_response_code(500);
echo json_encode([
"success" => false,
"error" => "Réponse Python invalide",
"raw" => substr($output, 0, 200)
]);
exit;
}
http_response_code($result['success'] ? 200 : 404);
echo json_encode($result);

54
alert_ack_handler.py Normal file
View File

@@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""
SmartEye SENTINEL - Handler d'Acquittement
============================================
Appelé par alert_ack.php pour mettre à jour le statut d'une alerte.
Usage : python3 alert_ack_handler.py <alert_id> <status> [detail]
Auteur : Unigest Solutions / SmartEye V30
"""
import sys
import json
sys.path.insert(0, '/var/www/lucas')
from alert_manager import mettre_a_jour_statut, get_alerte, AlertStatus
def main():
if len(sys.argv) < 3:
print(json.dumps({"success": False, "error": "Usage: alert_ack_handler.py <alert_id> <status> [detail]"}))
sys.exit(1)
alert_id = sys.argv[1]
status = sys.argv[2].upper()
detail = sys.argv[3] if len(sys.argv) > 3 else None
# Vérifier que l'alerte existe
alerte = get_alerte(alert_id)
if not alerte:
print(json.dumps({
"success": False,
"error": f"Alerte {alert_id} non trouvée (peut-être déjà résolue ou expirée)"
}))
sys.exit(0)
# Mettre à jour
result = mettre_a_jour_statut(alert_id, status, detail)
if result:
print(json.dumps({
"success": True,
"message": f"Alerte {alert_id[:8]}... mise à jour : {status}",
"alert_id": alert_id,
"new_status": status,
"previous_status": alerte.get("status", "?")
}))
else:
print(json.dumps({
"success": False,
"error": f"Impossible de passer au statut {status} (statut actuel: {alerte.get('status', '?')})"
}))
if __name__ == "__main__":
main()

328
alert_manager.py Normal file
View 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

169
alert_watchdog.py Normal file
View File

@@ -0,0 +1,169 @@
#!/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()

450
analyze.py Executable file
View File

@@ -0,0 +1,450 @@
import warnings
# ON FAIT TAIRE LES AVERTISSEMENTS POUR QUE LE JSON SOIT PROPRE
warnings.filterwarnings("ignore")
import sys
import json
import os
# Import pour Google AI (Gemini) - NOUVEAU SDK
from google import genai
from google.genai import types
# Import pour Firebase (Notifications App)
import firebase_admin
from firebase_admin import credentials, messaging
import uuid
from datetime import datetime, timedelta
# Import du gestionnaire d'alertes
from alert_manager import (
creer_alerte,
compter_alertes_actives,
AlertStatus
)
# --- CONFIGURATION VIA config_loader ---
from config_loader import get_config, get_client_info, get_client_fcm_tokens
_cfg = get_config()
API_KEY = _cfg.get("gemini", "api_key")
# --- INITIALISATION FIREBASE ---
if not firebase_admin._apps:
try:
firebase_cred_path = _cfg.get("firebase", "credentials_path", fallback="/var/www/lucas/admin_key.json")
cred = credentials.Certificate(firebase_cred_path)
firebase_admin.initialize_app(cred)
except Exception as e:
pass
# --- CONFIGURATION GEMINI (NOUVEAU SDK) ---
client = genai.Client(api_key=API_KEY)
generation_config = types.GenerateContentConfig(
temperature=0.1,
top_p=0.95,
top_k=40,
max_output_tokens=512,
response_mime_type="application/json",
safety_settings=[
types.SafetySetting(category="HARM_CATEGORY_HARASSMENT", threshold="OFF"),
types.SafetySetting(category="HARM_CATEGORY_HATE_SPEECH", threshold="OFF"),
types.SafetySetting(category="HARM_CATEGORY_SEXUALLY_EXPLICIT", threshold="OFF"),
types.SafetySetting(category="HARM_CATEGORY_DANGEROUS_CONTENT", threshold="OFF"),
]
)
# --- CHARGEMENT INFOS CLIENT DEPUIS DATABASE.JSON ---
def charger_infos_client():
"""Charge les infos du client depuis database.json via LUCAS_CLIENT_ID (name)"""
client_id = os.environ.get("LUCAS_CLIENT_ID", "")
if not client_id:
return {}
return get_client_info(client_id)
def deriver_camera_urls():
"""Dérive camera_urls (liste RTSP pour grille vidéo) depuis camera_map"""
camera_map_str = os.environ.get("SMARTEYE_CAMERA_MAP", "{}")
rtsp_url = os.environ.get("SMARTEYE_RTSP_URL", "")
try:
camera_map = json.loads(camera_map_str)
# camera_map = {"rtsp://...:8554/12": "192.168.1.143", ...}
# On extrait toutes les URLs RTSP sauf celle de la caméra principale
urls = [url for url in camera_map.keys() if url != rtsp_url]
return json.dumps(urls)
except Exception:
return "[]"
# --- FONCTIONS UTILITAIRES POUR JSON ---
def nettoyer_json_response(text_response):
"""Nettoie agressivement la réponse pour extraire du JSON valide"""
try:
cleaned = text_response.strip()
if cleaned.startswith("```json"):
cleaned = cleaned[7:]
elif cleaned.startswith("```"):
cleaned = cleaned[3:]
if cleaned.endswith("```"):
cleaned = cleaned[:-3]
cleaned = cleaned.strip()
start = cleaned.find('{')
end = cleaned.rfind('}') + 1
if start != -1 and end > start:
cleaned = cleaned[start:end]
cleaned = ''.join(char for char in cleaned if ord(char) >= 32 or char in '\n\r\t')
return cleaned.strip()
except Exception as e:
return text_response
def extraire_json_intelligent(text_response):
"""Tente plusieurs stratégies pour extraire du JSON valide"""
strategies = []
strategies.append(nettoyer_json_response(text_response))
try:
start = text_response.find('{')
end = text_response.rfind('}') + 1
if start != -1 and end > start:
strategies.append(text_response[start:end])
except:
pass
try:
lines = text_response.split('\n')
json_lines = [l for l in lines if '{' in l or '}' in l or '"' in l]
if json_lines:
strategies.append(''.join(json_lines))
except:
pass
for strategy_text in strategies:
try:
result = json.loads(strategy_text)
if isinstance(result, dict):
return result
except:
continue
return None
def valider_reponse_ia(result):
"""Valide que la réponse IA contient les champs requis"""
if not isinstance(result, dict):
return False
if "urgence" not in result:
return False
if not isinstance(result["urgence"], bool):
if str(result["urgence"]).lower() in ["true", "1", "yes", "oui"]:
result["urgence"] = True
elif str(result["urgence"]).lower() in ["false", "0", "no", "non"]:
result["urgence"] = False
else:
return False
if "confiance" not in result:
result["confiance"] = 70
if "message" not in result:
result["message"] = "Analyse effectuée"
return True
# --- FONCTION D'ALERTE APP ---
def envoyer_alerte_app(image_path, alert_id):
"""
Envoie une notification à TOUS les tokens FCM du client via Firebase.
Payload dynamique : infos client depuis database.json + camera_urls dérivé.
"""
try:
client_id = os.environ.get("LUCAS_CLIENT_ID", "")
fcm_tokens = get_client_fcm_tokens(client_id)
if not fcm_tokens:
return
domain = _cfg.get("server", "domain", fallback="https://lucas.unigest.fr")
url_image = f"{domain}/{image_path}"
# Charger infos client dynamiquement
client_info = charger_infos_client()
senior_name = client_info.get("senior_name", client_info.get("name", "Senior"))
senior_nickname = client_info.get("senior_nickname", senior_name)
senior_photo_rel = client_info.get("senior_photo", "photos/mamie.jpg")
senior_photo = f"{domain}/{senior_photo_rel}" if senior_photo_rel else f"{domain}/photos/mamie.jpg"
senior_lat = str(client_info.get("latitude", ""))
senior_lon = str(client_info.get("longitude", ""))
emergency_number = client_info.get("emergency_number", "15")
# Dériver camera_urls depuis camera_map
camera_urls = deriver_camera_urls()
now = datetime.now()
timeline_standing = (now - timedelta(seconds=45)).strftime("%H:%M:%S")
timeline_fall_detected = (now - timedelta(seconds=30)).strftime("%H:%M:%S")
timeline_immobile = (now - timedelta(seconds=15)).strftime("%H:%M:%S")
timeline_alert_sent = now.strftime("%H:%M:%S")
data_payload = {
"urgence": "true",
"type": "chute",
"alert_id": alert_id,
"is_reminder": "false",
"escalation_level": "0",
"senior_name": senior_name,
"senior_nickname": senior_nickname,
"senior_photo_url": senior_photo,
"emergency_number": emergency_number,
"timeline_standing": timeline_standing,
"timeline_fall_detected": timeline_fall_detected,
"timeline_immobile": timeline_immobile,
"timeline_alert_sent": timeline_alert_sent,
"senior_latitude": senior_lat,
"senior_longitude": senior_lon,
"image_url": url_image,
"rtsp_url": os.environ.get("SMARTEYE_RTSP_URL", ""),
"camera_urls": camera_urls,
"audio_relay_ws": os.environ.get("SMARTEYE_AUDIO_WS", ""),
"camera_ip_map": os.environ.get("SMARTEYE_CAMERA_MAP", "{}"),
"click_action": "FLUTTER_NOTIFICATION_CLICK",
}
# Envoyer à chaque token FCM enregistré pour ce client
for token in fcm_tokens:
try:
message = messaging.Message(
notification=messaging.Notification(
title="ALERTE CHUTE",
body="Appuyez pour voir la preuve photo",
),
android=messaging.AndroidConfig(
priority='high',
notification=messaging.AndroidNotification(
channel_id='fall_alert_channel',
priority='high',
default_sound=True,
default_vibrate_timings=True,
visibility='public',
),
),
data=data_payload,
token=token,
)
messaging.send(message)
except Exception:
pass
except Exception as e:
pass
# --- FONCTION D'ANALYSE AVEC RETRY ---
def appeler_gemini_avec_retry(image_path, prompt, max_retries=3):
"""Appelle Gemini avec retry automatique et backoff exponentiel"""
import time
with open(image_path, "rb") as f:
image_bytes = f.read()
last_error = None
last_response_text = None
for attempt in range(max_retries):
try:
response = client.models.generate_content(
model="gemini-2.5-flash",
contents=[
types.Part(text=prompt),
types.Part(inline_data=types.Blob(mime_type="image/jpeg", data=image_bytes))
],
config=generation_config
)
if not response or not response.text:
raise ValueError("Réponse vide de Gemini")
if hasattr(response, 'candidates') and response.candidates:
candidate = response.candidates[0]
finish_reason = getattr(candidate, 'finish_reason', None)
if finish_reason and 'STOP' not in str(finish_reason):
raise ValueError(f"Réponse incomplète: {finish_reason}")
text_response = response.text.strip()
last_response_text = text_response
result = extraire_json_intelligent(text_response)
if result and valider_reponse_ia(result):
return {"success": True, "data": result, "attempt": attempt + 1}
raise json.JSONDecodeError(
"JSON invalide après extraction intelligente",
text_response, 0
)
except json.JSONDecodeError as e:
last_error = e
try:
with open("/tmp/smarteye_gemini_errors.log", 'a') as f:
f.write(f"\n--- TENTATIVE {attempt + 1}/{max_retries} - {datetime.now()} ---\n")
f.write(f"JSONDecodeError: {str(e)}\n")
if last_response_text:
f.write(f"Réponse brute: {repr(last_response_text[:500])}\n")
except:
pass
except Exception as e:
last_error = e
try:
with open("/tmp/smarteye_gemini_errors.log", 'a') as f:
f.write(f"\n--- TENTATIVE {attempt + 1}/{max_retries} - {datetime.now()} ---\n")
f.write(f"Erreur {type(e).__name__}: {str(e)}\n")
if last_response_text:
f.write(f"Réponse brute: {repr(last_response_text[:500])}\n")
except:
pass
if attempt < max_retries - 1:
wait_time = (2 ** attempt)
time.sleep(wait_time)
return {
"success": False,
"error": last_error,
"error_type": type(last_error).__name__ if last_error else "UnknownError",
"last_response": last_response_text[:200] if last_response_text else None
}
# --- FONCTION D'ANALYSE ---
def analyze_image(image_path):
if not os.path.exists(image_path):
return {"urgence": False, "message": "Erreur : Image introuvable"}
prompt = """Caméra de surveillance au domicile d'une personne âgée vivant seule. Le détecteur de chute a déclenché une alerte. Analyse cette image.
URGENCE (true) : personne au sol, affalée, inerte, en détresse, position anormale.
PAS URGENCE (false) : pièce vide, personne debout, assise sur meuble (chaise/canapé/lit), accroupie volontairement, en train de se pencher.
En cas de doute réel entre chute et activité normale → true.
Réponds en JSON : {"urgence": bool, "confiance": int 0-100, "message": "description courte"}"""
try:
api_result = appeler_gemini_avec_retry(image_path, prompt, max_retries=3)
if api_result["success"]:
result = api_result["data"]
# Gemini a répondu → reset du compteur d'erreurs consécutives
try:
error_state_file = "/tmp/smarteye_error_state.json"
if os.path.exists(error_state_file):
os.remove(error_state_file)
except:
pass
# --- DÉCLENCHEUR AVEC GESTION D'ALERTE ---
if result.get("urgence") is True:
# Anti-doublon : ne pas créer 10 alertes si la personne est toujours au sol
alertes_actives = compter_alertes_actives()
if alertes_actives >= 3:
# Trop d'alertes actives non traitées → ne pas en rajouter
result["message"] += " [alerte existante en cours]"
return result
# Générer un ID unique pour cette alerte
alert_id = str(uuid.uuid4())
# 1. Envoyer la notification Firebase (avec l'alert_id)
envoyer_alerte_app(image_path, alert_id)
# 2. Enregistrer l'alerte comme PENDING dans le gestionnaire
creer_alerte(
alert_id=alert_id,
image_path=image_path,
camera_id=os.environ.get("SMARTEYE_CAMERA_ID", "cam_default"),
audio_relay_ws=os.environ.get("SMARTEYE_AUDIO_WS", "ws://57.128.74.87:8800"),
camera_ip_map=os.environ.get("SMARTEYE_CAMERA_MAP", "{}"),
analyse_data=result
)
# 3. Ajouter l'alert_id au résultat pour traçabilité
result["alert_id"] = alert_id
return result
else:
raise Exception(f"{api_result['error_type']}: Échec après 3 tentatives")
except Exception as e:
import time
import traceback
error_state_file = "/tmp/smarteye_error_state.json"
now = time.time()
error_type = type(e).__name__
# Log vers fichier dédié (pas stderr, car api.php utilise 2>&1)
try:
with open("/tmp/lucas_gemini_errors.log", 'a') as logf:
logf.write(f"\n{'='*50}\n")
logf.write(f"[{datetime.now()}] {error_type}: {e}\n")
logf.write(f"Image: {image_path}\n")
traceback.print_exc(file=logf)
except:
pass
# --- Charger l'état des erreurs ---
error_state = {"consecutive": 0, "first_error": now, "last_alert_sent": 0}
try:
if os.path.exists(error_state_file):
with open(error_state_file, 'r') as f:
error_state = json.loads(f.read())
except:
pass
# Reset si dernière erreur date de plus d'1h (Gemini avait récupéré)
if now - error_state.get("first_error", now) > 3600:
error_state = {"consecutive": 0, "first_error": now, "last_alert_sent": 0}
error_state["consecutive"] = error_state.get("consecutive", 0) + 1
error_state["last_error"] = now
if error_state["consecutive"] == 1:
error_state["first_error"] = now
# --- Sauvegarder l'état ---
try:
with open(error_state_file, 'w') as f:
json.dump(error_state, f)
except:
pass
consecutive = error_state["consecutive"]
last_alert = error_state.get("last_alert_sent", 0)
cooldown = 1800 # 30 minutes entre deux alertes d'erreur
# --- SÉCURITÉ : Gemini n'a PAS analysé l'image → TOUJOURS alerter ---
# Principe fondateur : mieux vaut 10 fausses alertes qu'une chute ratée.
# Anti-doublon : on ne spamme pas si des alertes sont déjà actives.
alert_id = str(uuid.uuid4())
alertes_actives = compter_alertes_actives()
if alertes_actives < 3:
envoyer_alerte_app(image_path, alert_id)
creer_alerte(
alert_id=alert_id,
image_path=image_path,
camera_id=os.environ.get("SMARTEYE_CAMERA_ID", "cam_default"),
audio_relay_ws=os.environ.get("SMARTEYE_AUDIO_WS", "ws://57.128.74.87:8800"),
camera_ip_map=os.environ.get("SMARTEYE_CAMERA_MAP", "{}"),
analyse_data={"error": True, "error_type": error_type, "consecutive": consecutive}
)
error_state["last_alert_sent"] = now
try:
with open(error_state_file, 'w') as f:
json.dump(error_state, f)
except:
pass
return {
"urgence": True,
"confiance": 30,
"message": f"⚠️ Erreur IA ({error_type}), alerte de sécurité par précaution",
"alert_id": alert_id
}
if __name__ == "__main__":
if len(sys.argv) < 2:
print(json.dumps({"urgence": False, "message": "No image provided"}))
sys.exit(1)
image_path = sys.argv[1]
result = analyze_image(image_path)
print(json.dumps(result))

266
analyze_errors_and_learn.py Executable file
View File

@@ -0,0 +1,266 @@
#!/usr/bin/env python3
"""
Système d'apprentissage des erreurs
Analyse les logs d'erreurs pour identifier les patterns et proposer des améliorations
"""
import os
import sys
import json
import re
from collections import Counter, defaultdict
from datetime import datetime
def analyze_error_logs():
"""Analyse les logs d'erreurs Gemini"""
error_log = "/tmp/smarteye_gemini_errors.log"
if not os.path.exists(error_log):
return {
"status": "no_errors",
"message": "Aucun log d'erreur trouvé - Système sain !"
}
with open(error_log, 'r') as f:
content = f.read()
# Patterns à détecter
patterns = {
"JSONDecodeError": r"JSONDecodeError",
"ValueError": r"ValueError",
"TimeoutError": r"TimeoutError",
"ConnectionError": r"ConnectionError",
"API quota": r"quota|rate.?limit",
"Empty response": r"Réponse vide|empty response",
"Malformed JSON": r"JSON invalide|malformed",
}
# Comptage des erreurs
error_counts = {}
for name, pattern in patterns.items():
matches = re.findall(pattern, content, re.IGNORECASE)
if matches:
error_counts[name] = len(matches)
# Extraction des tentatives
tentative_pattern = r"TENTATIVE (\d)/(\d)"
tentatives = re.findall(tentative_pattern, content)
# Statistiques
total_errors = sum(error_counts.values())
retry_success = len([t for t in tentatives if t[0] != t[1]])
return {
"status": "analyzed",
"total_errors": total_errors,
"error_counts": error_counts,
"retry_attempts": len(tentatives),
"retry_success_rate": (retry_success / len(tentatives) * 100) if tentatives else 0,
"most_common": max(error_counts.items(), key=lambda x: x[1])[0] if error_counts else None
}
def analyze_image_results():
"""Analyse les résultats d'analyse des images"""
client_folder = "/var/www/lucas/clients/Demo_01"
if not os.path.exists(client_folder):
return None
# Statistiques des analyses
total_images = 0
with_json = 0
urgence_count = 0
false_positive_count = 0
confidence_scores = []
messages = defaultdict(int)
for filename in os.listdir(client_folder):
if filename.endswith('.jpg'):
total_images += 1
json_file = os.path.join(client_folder, filename + '.json')
if os.path.exists(json_file):
with_json += 1
try:
with open(json_file, 'r') as f:
data = json.load(f)
if data.get('urgence') is True:
urgence_count += 1
elif data.get('urgence') is False:
false_positive_count += 1
if 'confiance' in data:
confidence_scores.append(data['confiance'])
msg = data.get('message', 'Unknown')[:50]
messages[msg] += 1
except:
pass
avg_confidence = sum(confidence_scores) / len(confidence_scores) if confidence_scores else 0
return {
"total_images": total_images,
"analyzed": with_json,
"coverage": (with_json / total_images * 100) if total_images else 0,
"urgence_rate": (urgence_count / with_json * 100) if with_json else 0,
"false_positive_rate": (false_positive_count / with_json * 100) if with_json else 0,
"avg_confidence": avg_confidence,
"top_messages": dict(sorted(messages.items(), key=lambda x: x[1], reverse=True)[:5])
}
def generate_recommendations(error_analysis, image_analysis):
"""Génère des recommandations basées sur l'analyse"""
recommendations = []
if not error_analysis or error_analysis["status"] == "no_errors":
recommendations.append({
"priority": "✅ SUCCESS",
"title": "Système stable",
"description": "Aucune erreur détectée - Le correctif fonctionne parfaitement !",
"action": None
})
else:
# Analyse des erreurs
if error_analysis["total_errors"] > 10:
recommendations.append({
"priority": "⚠️ MOYEN",
"title": f"{error_analysis['total_errors']} erreurs détectées",
"description": "Taux d'erreur encore élevé",
"action": "Vérifier la clé API Gemini et les quotas"
})
if error_analysis.get("most_common") == "TimeoutError":
recommendations.append({
"priority": "⚠️ MOYEN",
"title": "Timeouts fréquents",
"description": "L'API Gemini répond lentement",
"action": "Augmenter le timeout à 60s dans analyze.py"
})
if error_analysis.get("retry_success_rate", 0) < 50:
recommendations.append({
"priority": "🔴 URGENT",
"title": "Faible taux de succès des retries",
"description": f"Seulement {error_analysis['retry_success_rate']:.1f}% de succès",
"action": "Augmenter max_retries à 5 ou vérifier la connexion"
})
if image_analysis:
# Analyse des images
if image_analysis["coverage"] < 95:
recommendations.append({
"priority": " INFO",
"title": f"Couverture: {image_analysis['coverage']:.1f}%",
"description": f"{image_analysis['total_images'] - image_analysis['analyzed']} images sans analyse",
"action": "Exécuter repair_missing_analyses.py si besoin"
})
if image_analysis["urgence_rate"] > 50:
recommendations.append({
"priority": "⚠️ MOYEN",
"title": f"Taux d'urgence élevé: {image_analysis['urgence_rate']:.1f}%",
"description": "Beaucoup de vraies alertes détectées",
"action": "Vérifier le positionnement des caméras et le prompt IA"
})
if image_analysis["avg_confidence"] < 70:
recommendations.append({
"priority": " INFO",
"title": f"Confiance moyenne: {image_analysis['avg_confidence']:.0f}%",
"description": "L'IA hésite sur certaines analyses",
"action": "Améliorer le prompt ou utiliser un modèle plus puissant"
})
if not recommendations:
recommendations.append({
"priority": "✅ SUCCESS",
"title": "Système optimal",
"description": "Aucune amélioration nécessaire",
"action": None
})
return recommendations
def main():
print("="*70)
print("ANALYSE DES ERREURS ET APPRENTISSAGE")
print("="*70)
print()
# Analyse des erreurs
print("📊 ANALYSE DES LOGS D'ERREURS")
print("-"*70)
error_analysis = analyze_error_logs()
if error_analysis["status"] == "no_errors":
print("✅ Aucune erreur trouvée - Système parfaitement stable !")
else:
print(f"Total erreurs: {error_analysis['total_errors']}")
print(f"Tentatives de retry: {error_analysis['retry_attempts']}")
print(f"Taux de succès retry: {error_analysis.get('retry_success_rate', 0):.1f}%")
print()
print("Types d'erreurs:")
for error_type, count in sorted(error_analysis['error_counts'].items(), key=lambda x: x[1], reverse=True):
print(f"{error_type}: {count}")
print()
print("📸 ANALYSE DES IMAGES")
print("-"*70)
image_analysis = analyze_image_results()
if image_analysis:
print(f"Total images: {image_analysis['total_images']}")
print(f"Analysées: {image_analysis['analyzed']} ({image_analysis['coverage']:.1f}%)")
print(f"Taux d'urgence: {image_analysis['urgence_rate']:.1f}%")
print(f"Fausses alertes: {image_analysis['false_positive_rate']:.1f}%")
print(f"Confiance moyenne: {image_analysis['avg_confidence']:.0f}%")
print()
print("Messages les plus fréquents:")
for msg, count in list(image_analysis['top_messages'].items())[:3]:
print(f"{msg}: {count}x")
else:
print("⚠️ Dossier client introuvable")
print()
print("💡 RECOMMANDATIONS")
print("="*70)
recommendations = generate_recommendations(error_analysis, image_analysis)
for i, rec in enumerate(recommendations, 1):
print(f"\n{rec['priority']} {rec['title']}")
print(f" {rec['description']}")
if rec['action']:
print(f" → Action: {rec['action']}")
print()
print("="*70)
# Sauvegarde du rapport
report = {
"timestamp": datetime.now().isoformat(),
"error_analysis": error_analysis,
"image_analysis": image_analysis,
"recommendations": recommendations
}
report_file = "/var/www/lucas/last_analysis_report.json"
with open(report_file, 'w') as f:
json.dump(report, f, indent=2)
print(f"📄 Rapport sauvegardé: {report_file}")
print()
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("\n\n⚠️ Interruption")
sys.exit(1)
except Exception as e:
print(f"\n❌ Erreur: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
sys.exit(1)

140
api.php Executable file
View File

@@ -0,0 +1,140 @@
<?php
header("Content-Type: application/json");
header("Access-Control-Allow-Origin: *");
// --- 1. CHARGEMENT CONFIGURATION ---
$json_file = 'database.json';
if (!file_exists($json_file)) {
http_response_code(500);
die(json_encode(["status" => "error", "message" => "Base de données introuvable"]));
}
$db = json_decode(file_get_contents($json_file), true);
// Données reçues
$client_input = $_POST['client_id'] ?? '';
$token_input = $_POST['token'] ?? '';
// --- 2. IDENTIFICATION & SÉCURITÉ ---
$client_index = -1;
$current_client = null;
if (isset($db['clients'])) {
foreach ($db['clients'] as $index => $c) {
if (strcasecmp($c['name'], $client_input) == 0 && $c['token'] === $token_input) {
$client_index = $index;
$current_client = $c;
break;
}
}
}
if ($client_index === -1) {
http_response_code(403);
die(json_encode(["status" => "error", "message" => "Authentification échouée"]));
}
// --- 3. TRAITEMENT IMAGE ---
if (isset($_FILES['image']) && $_FILES['image']['error'] === UPLOAD_ERR_OK) {
// Dossier spécifique
$upload_dir = 'clients/' . $current_client['name'] . '/';
if (!is_dir($upload_dir)) mkdir($upload_dir, 0775, true);
$filename = "alerte_" . date("Ymd_His") . ".jpg";
$target_file = $upload_dir . $filename;
if (move_uploaded_file($_FILES['image']['tmp_name'], $target_file)) {
// --- 4. APPEL IA & AUTOMATISATION ---
// On reconstruit l'URL publique
$public_url = "https://lucas.unigest.fr/" . $target_file;
// SÉCURITÉ AJOUTÉE : "cd " . __DIR__
// On force le serveur à se mettre dans le bon dossier pour trouver admin_key.json
// Champs caméra envoyés par SmartEye
$env_camera_id = escapeshellarg($_POST['camera_id'] ?? '');
$env_rtsp_url = escapeshellarg($_POST['rtsp_url'] ?? '');
$env_audio_ws = escapeshellarg($_POST['audio_ws'] ?? 'ws://57.128.74.87:8800');
$env_camera_map = escapeshellarg($_POST['camera_map'] ?? '{}');
$env_client_id = escapeshellarg($current_client['name']);
$safe_cmd = "cd " . __DIR__ . " && "
. "LUCAS_CLIENT_ID={$env_client_id} "
. "SMARTEYE_CAMERA_ID={$env_camera_id} "
. "SMARTEYE_RTSP_URL={$env_rtsp_url} "
. "SMARTEYE_AUDIO_WS={$env_audio_ws} "
. "SMARTEYE_CAMERA_MAP={$env_camera_map} "
. "python3 analyze.py " . escapeshellarg($target_file) . " 2>>/tmp/lucas_python_stderr.log";
// C'EST ICI QUE TOUT SE JOUE :
// 1. PHP lance Python.
// 2. Python analyse l'image.
// 3. Si Urgence -> Python envoie la notif Firebase au téléphone.
// 4. Python renvoie le résultat JSON à PHP.
$output = shell_exec($safe_cmd);
$resultat_ia = json_decode($output, true);
// --- GESTION DES RÉSULTATS ---
if (!$resultat_ia) {
// Si le JSON est cassé (crash Python), on force l'urgence par sécurité
$est_urgence = true;
$message_ia = "⚠️ Erreur technique IA. Analyse visuelle requise.";
// Optionnel : On logue l'erreur brute pour déboguer plus tard
file_put_contents("error_python.log", date("Y-m-d H:i:s") . " - " . $output . PHP_EOL, FILE_APPEND);
} else {
$est_urgence = $resultat_ia['urgence'] ?? false;
$message_ia = $resultat_ia['message'] ?? "Analyse terminée";
}
// --- 5. MISE A JOUR BASE DE DONNÉES ---
$db['clients'][$client_index]['alerte'] = $est_urgence;
$db['clients'][$client_index]['last_update'] = date("d/m/Y H:i:s");
$db['clients'][$client_index]['message'] = $message_ia;
file_put_contents($json_file, json_encode($db, JSON_PRETTY_PRINT));
// --- 5b. ARCHIVAGE JSON ---
$history_data = [
"urgence" => $est_urgence,
"message" => $message_ia,
"timestamp" => date("Y-m-d H:i:s")
];
file_put_contents($target_file . ".json", json_encode($history_data));
// --- 6. LOG SMS (Optionnel : Backup SMS si urgence) ---
if ($est_urgence) {
$contacts = $current_client['contacts'] ?? [];
$sms_body = "🚨 ALERTE CHUTE : " . $current_client['name']; // Message court pour SMS
foreach ($contacts as $contact) {
if (!empty($contact['phone'])) {
envoyer_sms_reel($contact['phone'], $sms_body);
}
}
}
echo json_encode([
"status" => "success",
"client" => $current_client['name'],
"ia_result" => $resultat_ia
]);
} else {
http_response_code(500);
echo json_encode(["status" => "error", "message" => "Erreur écriture fichier"]);
}
} else {
http_response_code(400);
echo json_encode(["status" => "error", "message" => "Pas d'image reçue"]);
}
function envoyer_sms_reel($numero, $message) {
// Nettoyage sommaire
$numero = str_replace([' ', '.', '-'], '', $numero);
// Appel du gestionnaire SMS (séparé)
$cmd = "python3 sms_manager.py " . escapeshellarg($numero) . " " . escapeshellarg($message) . " > /dev/null 2>&1 &";
shell_exec($cmd);
}
?>

94
api/auth.php Normal file
View File

@@ -0,0 +1,94 @@
<?php
header("Content-Type: application/json");
header("Access-Control-Allow-Origin: *");
/**
* ENDPOINT D'AUTHENTIFICATION LUCASAPP
*
* Reçoit : client_id, password, fcm_token (optionnel)
* Retourne : token, infos client si authentification OK
*/
// --- 1. CHARGEMENT BASE DE DONNÉES ---
$json_file = '../database.json';
if (!file_exists($json_file)) {
http_response_code(500);
die(json_encode(["status" => "error", "message" => "Base de données introuvable"]));
}
$db = json_decode(file_get_contents($json_file), true);
// --- 2. RÉCEPTION DONNÉES ---
$input = json_decode(file_get_contents('php://input'), true);
$client_id = $input['client_id'] ?? '';
$password = $input['password'] ?? '';
$fcm_token = $input['fcm_token'] ?? null;
if (empty($client_id) || empty($password)) {
http_response_code(400);
die(json_encode(["status" => "error", "message" => "Identifiant et mot de passe requis"]));
}
// --- 3. RECHERCHE CLIENT ---
$client_index = -1;
$current_client = null;
if (isset($db['clients'])) {
foreach ($db['clients'] as $index => $c) {
if (strcasecmp($c['name'], $client_id) == 0) {
$client_index = $index;
$current_client = $c;
break;
}
}
}
if ($client_index === -1) {
http_response_code(403);
die(json_encode(["status" => "error", "message" => "Identifiant inconnu"]));
}
// --- 4. VÉRIFICATION MOT DE PASSE ---
$password_hash = $current_client['password_hash'] ?? null;
if ($password_hash === null) {
http_response_code(403);
die(json_encode(["status" => "error", "message" => "Compte non configuré. Contactez l'administrateur."]));
}
if (!password_verify($password, $password_hash)) {
http_response_code(403);
die(json_encode(["status" => "error", "message" => "Mot de passe incorrect"]));
}
// --- 5. ENREGISTREMENT FCM TOKEN (si fourni) ---
if ($fcm_token !== null) {
if (!isset($current_client['fcm_tokens'])) {
$current_client['fcm_tokens'] = [];
}
// Ajouter le token s'il n'existe pas déjà
if (!in_array($fcm_token, $current_client['fcm_tokens'])) {
$current_client['fcm_tokens'][] = $fcm_token;
$db['clients'][$client_index]['fcm_tokens'] = $current_client['fcm_tokens'];
file_put_contents($json_file, json_encode($db, JSON_PRETTY_PRINT));
}
}
// --- 6. RÉPONSE SUCCÈS ---
echo json_encode([
"status" => "success",
"message" => "Authentification réussie",
"data" => [
"token" => $current_client['token'],
"client_name" => $current_client['name'],
"senior_name" => $current_client['senior_name'] ?? '',
"senior_nickname" => $current_client['senior_nickname'] ?? '',
"senior_photo" => $current_client['senior_photo'] ?? '',
"latitude" => $current_client['latitude'] ?? '',
"longitude" => $current_client['longitude'] ?? '',
"emergency_number" => $current_client['emergency_number'] ?? '15',
"contacts" => $current_client['contacts'] ?? [],
"site_status" => $current_client['site_status'] ?? 'provisioned'
]
]);
?>

125
check_gemini_health.py Executable file
View File

@@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""
Script de monitoring pour surveiller la santé de l'API Gemini
Affiche les statistiques d'erreurs et les dernières erreurs
"""
import os
import json
from datetime import datetime
print("="*70)
print("MONITORING SANTÉ API GEMINI")
print("="*70)
# Fichiers de log
error_log = "/tmp/smarteye_gemini_errors.log"
error_counter = "/tmp/smarteye_gemini_error_count"
error_lock = "/tmp/smarteye_gemini_error_last"
# 1. Statistiques d'erreurs
print("\n📊 STATISTIQUES D'ERREURS")
print("-"*70)
if os.path.exists(error_counter):
try:
with open(error_counter, 'r') as f:
data = json.loads(f.read())
error_count = data.get("count", 0)
first_error = data.get("first_error")
last_error = data.get("last_error")
print(f"Nombre d'erreurs (dernière heure): {error_count}")
if first_error:
first_dt = datetime.fromtimestamp(first_error)
print(f"Première erreur: {first_dt.strftime('%Y-%m-%d %H:%M:%S')}")
if last_error:
last_dt = datetime.fromtimestamp(last_error)
print(f"Dernière erreur: {last_dt.strftime('%Y-%m-%d %H:%M:%S')}")
# Évaluation de la santé
if error_count == 0:
print("✓ Statut: EXCELLENT - Aucune erreur")
elif error_count < 3:
print("⚠ Statut: BON - Quelques erreurs mineures")
elif error_count < 5:
print("⚠ Statut: ATTENTION - Erreurs modérées")
else:
print("✗ Statut: CRITIQUE - Problème récurrent détecté")
except Exception as e:
print(f"Erreur lecture compteur: {e}")
else:
print("✓ Aucune erreur enregistrée")
# 2. Cooldown actif
print("\n⏱️ COOLDOWN ANTI-SPAM")
print("-"*70)
if os.path.exists(error_lock):
try:
with open(error_lock, 'r') as f:
last_alert = float(f.read().strip())
last_alert_dt = datetime.fromtimestamp(last_alert)
now = datetime.now().timestamp()
remaining = max(0, 300 - (now - last_alert))
print(f"Dernière alerte envoyée: {last_alert_dt.strftime('%Y-%m-%d %H:%M:%S')}")
if remaining > 0:
print(f"⏳ Cooldown actif: {int(remaining)} secondes restantes")
else:
print("✓ Cooldown terminé - Alertes autorisées")
except Exception as e:
print(f"Erreur lecture cooldown: {e}")
else:
print("✓ Pas de cooldown actif")
# 3. Dernières erreurs du log
print("\n📋 DERNIÈRES ERREURS (10 dernières lignes)")
print("-"*70)
if os.path.exists(error_log):
try:
with open(error_log, 'r') as f:
lines = f.readlines()
last_lines = lines[-30:] if len(lines) > 30 else lines
if last_lines:
print(''.join(last_lines))
else:
print("✓ Aucune erreur dans le log")
except Exception as e:
print(f"Erreur lecture log: {e}")
else:
print("✓ Fichier de log non trouvé (aucune erreur)")
# 4. Recommandations
print("\n💡 RECOMMANDATIONS")
print("-"*70)
if os.path.exists(error_counter):
try:
with open(error_counter, 'r') as f:
data = json.loads(f.read())
error_count = data.get("count", 0)
if error_count >= 5:
print("⚠ ACTIONS RECOMMANDÉES:")
print(" 1. Vérifier la validité de la clé API Gemini")
print(" 2. Vérifier la connectivité réseau")
print(" 3. Consulter le log détaillé: /tmp/smarteye_gemini_errors.log")
print(" 4. Vérifier les quotas API Google")
print(" 5. Tester manuellement l'API avec check_models.py")
elif error_count >= 3:
print(" Surveillance recommandée - Quelques erreurs détectées")
else:
print("✓ Système fonctionnel - Aucune action requise")
except:
pass
else:
print("✓ Système fonctionnel - Aucune action requise")
print("\n" + "="*70)
print("Fin du rapport")
print("="*70)

39
check_missing_json.sh Executable file
View File

@@ -0,0 +1,39 @@
#!/bin/bash
# Script pour identifier les images sans fichier JSON sidecar
cd /var/www/lucas/clients/Demo_01
echo "==================================================================="
echo "VÉRIFICATION DES IMAGES SANS ANALYSE JSON"
echo "==================================================================="
echo ""
missing_count=0
total_count=0
for img in $(ls -t *.jpg | head -30); do
total_count=$((total_count + 1))
if [ ! -f "${img}.json" ]; then
missing_count=$((missing_count + 1))
timestamp=$(stat -c %y "$img" 2>/dev/null | cut -d'.' -f1)
echo "$img"
echo " Date: $timestamp"
echo ""
fi
done
echo "==================================================================="
echo "RÉSUMÉ (30 dernières images)"
echo "==================================================================="
echo "Total: $total_count images"
echo "Avec JSON: $((total_count - missing_count)) images"
echo "Sans JSON: $missing_count images"
if [ $missing_count -gt 0 ]; then
echo ""
echo "⚠️ Il y a des images récentes sans analyse sauvegardée"
echo " Cela peut indiquer des erreurs pendant l'analyse IA"
else
echo ""
echo "✓ Toutes les images récentes ont une analyse"
fi

13
check_models.py Executable file
View File

@@ -0,0 +1,13 @@
import google.generativeai as genai
import os
API_KEY = "AIzaSyC5wIc9nrhtbVesHqodil6V6-WIxkJFSHA"
genai.configure(api_key=API_KEY)
print("🔍 Recherche des modèles disponibles...")
try:
for m in genai.list_models():
if 'generateContent' in m.supported_generation_methods:
print(f"👉 {m.name}")
except Exception as e:
print(f"❌ Erreur : {e}")

494
checklist.php Normal file
View File

@@ -0,0 +1,494 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SmartEye Checklist Installation</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
body { background: #0a0e1a; }
.badge-smarteye { background: #f59e0b22; color: #fbbf24; border: 1px solid #f59e0b44; }
.badge-lucas { background: #10b98122; color: #34d399; border: 1px solid #10b98144; }
.badge-ava { background: #6366f122; color: #818cf8; border: 1px solid #6366f144; }
.check-done:checked { accent-color: #10b981; }
.check-tested:checked { accent-color: #3b82f6; }
.phase-bar-smarteye { border-left: 3px solid #f59e0b; }
.phase-bar-lucas { border-left: 3px solid #10b981; }
.phase-bar-ava { border-left: 3px solid #6366f1; }
.phase-bar-all { border-left: 3px solid #8b5cf6; }
.row-done { opacity: 0.5; }
.row-done td { text-decoration: line-through; text-decoration-color: #475569; }
.row-done .no-strike { text-decoration: none; }
@keyframes pulse-border { 0%,100% { border-color: #ef444488; } 50% { border-color: #ef4444; } }
.pending-critical { animation: pulse-border 2s infinite; }
.progress-fill { transition: width 0.5s ease; }
</style>
</head>
<body class="min-h-screen text-slate-200">
<!-- HEADER -->
<nav class="bg-slate-900/80 backdrop-blur border-b border-slate-800 px-6 py-4 sticky top-0 z-50">
<div class="max-w-6xl mx-auto flex justify-between items-center">
<div class="flex items-center gap-4">
<div class="w-3 h-3 rounded-full bg-emerald-500"></div>
<span class="font-bold text-xl tracking-wider text-white">CHECKLIST <span class="text-slate-500 text-sm font-normal">INSTALLATION</span></span>
</div>
<div class="flex items-center gap-4">
<div class="flex gap-2 text-xs">
<span class="badge-smarteye px-2 py-1 rounded font-bold">SmartEye</span>
<span class="badge-lucas px-2 py-1 rounded font-bold">Lucas</span>
<span class="badge-ava px-2 py-1 rounded font-bold">AVA</span>
</div>
<select id="client-select" onchange="loadClient()" class="bg-slate-800 border border-slate-700 text-white text-sm rounded px-3 py-2">
<option value="">-- Client --</option>
<?php
$db = json_decode(file_get_contents('database.json'), true);
foreach ($db['clients'] as $id => $c) {
echo '<option value="' . htmlspecialchars($id) . '">' . htmlspecialchars($c['name']) . '</option>';
}
?>
</select>
<a href="admin.php" class="text-slate-400 hover:text-white text-sm border border-slate-700 px-3 py-2 rounded transition">Admin</a>
</div>
</div>
</nav>
<!-- PROGRESS BAR -->
<div class="max-w-6xl mx-auto px-6 mt-6">
<div class="bg-slate-900 rounded-xl border border-slate-800 p-4">
<div class="flex justify-between items-center mb-2">
<span class="text-sm text-slate-400">Progression globale</span>
<span id="progress-text" class="text-sm font-bold text-emerald-400">0 / 0</span>
</div>
<div class="w-full bg-slate-800 rounded-full h-3 overflow-hidden">
<div id="progress-bar" class="progress-fill bg-gradient-to-r from-emerald-600 to-emerald-400 h-full rounded-full" style="width: 0%"></div>
</div>
<div class="flex justify-between mt-2 text-xs text-slate-600">
<span>Bureau</span>
<span>Sur place</span>
<span>Config App</span>
<span>Tunnels</span>
<span>Tests</span>
<span>Production</span>
</div>
</div>
</div>
<!-- CHECKLIST -->
<div class="max-w-6xl mx-auto px-6 py-6 space-y-6">
<!-- PHASE 1 : BUREAU -->
<div class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
<div class="px-5 py-3 bg-slate-800/50 flex items-center gap-3">
<span class="text-lg">🏢</span>
<h2 class="font-bold text-white text-sm uppercase tracking-wider">Phase 1 — Au Bureau</h2>
<span class="text-xs text-slate-500 ml-auto">Avant le d&eacute;placement</span>
</div>
<table class="w-full text-sm">
<thead class="text-xs text-slate-500 uppercase bg-slate-950/50">
<tr>
<th class="px-5 py-2 text-left w-8">Fait</th>
<th class="px-2 py-2 text-left w-8">Test</th>
<th class="px-3 py-2 text-left">Action</th>
<th class="px-3 py-2 text-left w-24">Qui</th>
<th class="px-3 py-2 text-left w-48">Notes</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800/50">
<tr class="task-row hover:bg-slate-800/30" data-task="bureau-1">
<td class="px-5 py-3 no-strike"><input type="checkbox" class="check-done w-4 h-4" onchange="saveState()"></td>
<td class="px-2 py-3 no-strike"><input type="checkbox" class="check-tested w-4 h-4" onchange="saveState()"></td>
<td class="px-3 py-3 phase-bar-lucas pl-5">Cr&eacute;er le dossier client dans <span class="text-emerald-400 font-mono text-xs">admin.php</span></td>
<td class="px-3 py-3 no-strike"><span class="badge-lucas px-2 py-0.5 rounded text-xs font-bold">Lucas</span></td>
<td class="px-3 py-3 no-strike"><input type="text" class="note-input w-full bg-transparent border-b border-slate-700 text-xs text-slate-400 px-1 py-0.5 focus:border-slate-500 outline-none" placeholder="..." onchange="saveState()"></td>
</tr>
<tr class="task-row hover:bg-slate-800/30" data-task="bureau-2">
<td class="px-5 py-3 no-strike"><input type="checkbox" class="check-done w-4 h-4" onchange="saveState()"></td>
<td class="px-2 py-3 no-strike"><input type="checkbox" class="check-tested w-4 h-4" onchange="saveState()"></td>
<td class="px-3 py-3 phase-bar-lucas pl-5">G&eacute;n&eacute;rer le mot de passe LucasApp <span class="text-slate-600 text-xs">(admin/generate_password.php)</span></td>
<td class="px-3 py-3 no-strike"><span class="badge-lucas px-2 py-0.5 rounded text-xs font-bold">Lucas</span></td>
<td class="px-3 py-3 no-strike"><input type="text" class="note-input w-full bg-transparent border-b border-slate-700 text-xs text-slate-400 px-1 py-0.5 focus:border-slate-500 outline-none" placeholder="..." onchange="saveState()"></td>
</tr>
<tr class="task-row hover:bg-slate-800/30" data-task="bureau-3">
<td class="px-5 py-3 no-strike"><input type="checkbox" class="check-done w-4 h-4" onchange="saveState()"></td>
<td class="px-2 py-3 no-strike"><input type="checkbox" class="check-tested w-4 h-4" onchange="saveState()"></td>
<td class="px-3 py-3 phase-bar-lucas pl-5">Remplir infos senior (nom, adresse, date naissance)</td>
<td class="px-3 py-3 no-strike"><span class="badge-lucas px-2 py-0.5 rounded text-xs font-bold">Lucas</span></td>
<td class="px-3 py-3 no-strike"><input type="text" class="note-input w-full bg-transparent border-b border-slate-700 text-xs text-slate-400 px-1 py-0.5 focus:border-slate-500 outline-none" placeholder="..." onchange="saveState()"></td>
</tr>
<tr class="task-row hover:bg-slate-800/30" data-task="bureau-4">
<td class="px-5 py-3 no-strike"><input type="checkbox" class="check-done w-4 h-4" onchange="saveState()"></td>
<td class="px-2 py-3 no-strike"><input type="checkbox" class="check-tested w-4 h-4" onchange="saveState()"></td>
<td class="px-3 py-3 phase-bar-lucas pl-5">Ajouter contacts famille (noms, t&eacute;l, OS)</td>
<td class="px-3 py-3 no-strike"><span class="badge-lucas px-2 py-0.5 rounded text-xs font-bold">Lucas</span></td>
<td class="px-3 py-3 no-strike"><input type="text" class="note-input w-full bg-transparent border-b border-slate-700 text-xs text-slate-400 px-1 py-0.5 focus:border-slate-500 outline-none" placeholder="..." onchange="saveState()"></td>
</tr>
<tr class="task-row hover:bg-slate-800/30" data-task="bureau-5">
<td class="px-5 py-3 no-strike"><input type="checkbox" class="check-done w-4 h-4" onchange="saveState()"></td>
<td class="px-2 py-3 no-strike"><input type="checkbox" class="check-tested w-4 h-4" onchange="saveState()"></td>
<td class="px-3 py-3 phase-bar-smarteye pl-5">Pr&eacute;parer le Jetson <span class="text-slate-600 text-xs">(flash SSD, OS, drivers)</span></td>
<td class="px-3 py-3 no-strike"><span class="badge-smarteye px-2 py-0.5 rounded text-xs font-bold">SmartEye</span></td>
<td class="px-3 py-3 no-strike"><input type="text" class="note-input w-full bg-transparent border-b border-slate-700 text-xs text-slate-400 px-1 py-0.5 focus:border-slate-500 outline-none" placeholder="..." onchange="saveState()"></td>
</tr>
<tr class="task-row hover:bg-slate-800/30" data-task="bureau-5b">
<td class="px-5 py-3 no-strike"><input type="checkbox" class="check-done w-4 h-4" onchange="saveState()"></td>
<td class="px-2 py-3 no-strike"><input type="checkbox" class="check-tested w-4 h-4" onchange="saveState()"></td>
<td class="px-3 py-3 phase-bar-smarteye pl-5">Pr&eacute;-configurer WiFi hotspot <span class="font-mono text-xs text-amber-400">SmartEye-Setup</span> <span class="text-slate-600 text-xs">(connexion sans &eacute;cran)</span></td>
<td class="px-3 py-3 no-strike"><span class="badge-smarteye px-2 py-0.5 rounded text-xs font-bold">SmartEye</span></td>
<td class="px-3 py-3 no-strike"><input type="text" class="note-input w-full bg-transparent border-b border-slate-700 text-xs text-slate-400 px-1 py-0.5 focus:border-slate-500 outline-none" placeholder="SSID: SmartEye-Setup / MDP: smarteye2026" onchange="saveState()"></td>
</tr>
<tr class="task-row hover:bg-slate-800/30" data-task="bureau-5c">
<td class="px-5 py-3 no-strike"><input type="checkbox" class="check-done w-4 h-4" onchange="saveState()"></td>
<td class="px-2 py-3 no-strike"><input type="checkbox" class="check-tested w-4 h-4" onchange="saveState()"></td>
<td class="px-3 py-3 phase-bar-smarteye pl-5">Pr&eacute;parer cl&eacute; USB auto-config WiFi <span class="text-slate-600 text-xs">(script + wifi.conf du client)</span></td>
<td class="px-3 py-3 no-strike"><span class="badge-smarteye px-2 py-0.5 rounded text-xs font-bold">SmartEye</span></td>
<td class="px-3 py-3 no-strike"><input type="text" class="note-input w-full bg-transparent border-b border-slate-700 text-xs text-slate-400 px-1 py-0.5 focus:border-slate-500 outline-none" placeholder="SSID + MDP du client" onchange="saveState()"></td>
</tr>
<tr class="task-row hover:bg-slate-800/30" data-task="bureau-6">
<td class="px-5 py-3 no-strike"><input type="checkbox" class="check-done w-4 h-4" onchange="saveState()"></td>
<td class="px-2 py-3 no-strike"><input type="checkbox" class="check-tested w-4 h-4" onchange="saveState()"></td>
<td class="px-3 py-3 phase-bar-smarteye pl-5">V&eacute;rifier que SmartEye tourne <span class="text-slate-600 text-xs">(service actif, YOLO charg&eacute;)</span></td>
<td class="px-3 py-3 no-strike"><span class="badge-smarteye px-2 py-0.5 rounded text-xs font-bold">SmartEye</span></td>
<td class="px-3 py-3 no-strike"><input type="text" class="note-input w-full bg-transparent border-b border-slate-700 text-xs text-slate-400 px-1 py-0.5 focus:border-slate-500 outline-none" placeholder="..." onchange="saveState()"></td>
</tr>
<tr class="task-row hover:bg-slate-800/30" data-task="bureau-7">
<td class="px-5 py-3 no-strike"><input type="checkbox" class="check-done w-4 h-4" onchange="saveState()"></td>
<td class="px-2 py-3 no-strike"><input type="checkbox" class="check-tested w-4 h-4" onchange="saveState()"></td>
<td class="px-3 py-3 phase-bar-smarteye pl-5">Pr&eacute;parer les cam&eacute;ras <span class="text-slate-600 text-xs">(reset usine, config 2 flux RTSP)</span> <a href="doc_camera.php" target="_blank" class="text-amber-400 hover:text-amber-300 text-xs ml-1">[guide]</a></td>
<td class="px-3 py-3 no-strike"><span class="badge-smarteye px-2 py-0.5 rounded text-xs font-bold">SmartEye</span></td>
<td class="px-3 py-3 no-strike"><input type="text" class="note-input w-full bg-transparent border-b border-slate-700 text-xs text-slate-400 px-1 py-0.5 focus:border-slate-500 outline-none" placeholder="..." onchange="saveState()"></td>
</tr>
</tbody>
</table>
</div>
<!-- PHASE 2 : SUR PLACE - HARDWARE -->
<div class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
<div class="px-5 py-3 bg-slate-800/50 flex items-center gap-3">
<span class="text-lg">🏠</span>
<h2 class="font-bold text-white text-sm uppercase tracking-wider">Phase 2 — Sur Place : Mat&eacute;riel</h2>
<span class="text-xs text-slate-500 ml-auto">Installation physique</span>
</div>
<table class="w-full text-sm">
<thead class="text-xs text-slate-500 uppercase bg-slate-950/50">
<tr><th class="px-5 py-2 text-left w-8">Fait</th><th class="px-2 py-2 text-left w-8">Test</th><th class="px-3 py-2 text-left">Action</th><th class="px-3 py-2 text-left w-24">Qui</th><th class="px-3 py-2 text-left w-48">Notes</th></tr>
</thead>
<tbody class="divide-y divide-slate-800/50">
<tr class="task-row hover:bg-slate-800/30" data-task="place-1">
<td class="px-5 py-3 no-strike"><input type="checkbox" class="check-done w-4 h-4" onchange="saveState()"></td>
<td class="px-2 py-3 no-strike"><input type="checkbox" class="check-tested w-4 h-4" onchange="saveState()"></td>
<td class="px-3 py-3 phase-bar-smarteye pl-5">Alimenter le Jetson + connecter au r&eacute;seau <span class="text-slate-600 text-xs">(hotspot OU cl&eacute; USB OU Ethernet)</span></td>
<td class="px-3 py-3 no-strike"><span class="badge-smarteye px-2 py-0.5 rounded text-xs font-bold">SmartEye</span></td>
<td class="px-3 py-3 no-strike"><input type="text" class="note-input w-full bg-transparent border-b border-slate-700 text-xs text-slate-400 px-1 py-0.5 focus:border-slate-500 outline-none" placeholder="M&eacute;thode utilis&eacute;e : ..." onchange="saveState()"></td>
</tr>
<tr class="task-row hover:bg-slate-800/30" data-task="place-1b">
<td class="px-5 py-3 no-strike"><input type="checkbox" class="check-done w-4 h-4" onchange="saveState()"></td>
<td class="px-2 py-3 no-strike"><input type="checkbox" class="check-tested w-4 h-4" onchange="saveState()"></td>
<td class="px-3 py-3 phase-bar-smarteye pl-5">SSH sur le Jetson &rarr; configurer WiFi client <span class="font-mono text-xs text-slate-600">nmcli dev wifi connect "SSID" password "***"</span></td>
<td class="px-3 py-3 no-strike"><span class="badge-smarteye px-2 py-0.5 rounded text-xs font-bold">SmartEye</span></td>
<td class="px-3 py-3 no-strike"><input type="text" class="note-input w-full bg-transparent border-b border-slate-700 text-xs text-slate-400 px-1 py-0.5 focus:border-slate-500 outline-none" placeholder="IP obtenue : ..." onchange="saveState()"></td>
</tr>
<tr class="task-row hover:bg-slate-800/30" data-task="place-2">
<td class="px-5 py-3 no-strike"><input type="checkbox" class="check-done w-4 h-4" onchange="saveState()"></td>
<td class="px-2 py-3 no-strike"><input type="checkbox" class="check-tested w-4 h-4" onchange="saveState()"></td>
<td class="px-3 py-3 phase-bar-smarteye pl-5">Installer et positionner les cam&eacute;ras</td>
<td class="px-3 py-3 no-strike"><span class="badge-smarteye px-2 py-0.5 rounded text-xs font-bold">SmartEye</span></td>
<td class="px-3 py-3 no-strike"><input type="text" class="note-input w-full bg-transparent border-b border-slate-700 text-xs text-slate-400 px-1 py-0.5 focus:border-slate-500 outline-none" placeholder="Nb cam&eacute;ras : ..." onchange="saveState()"></td>
</tr>
<tr class="task-row hover:bg-slate-800/30" data-task="place-3">
<td class="px-5 py-3 no-strike"><input type="checkbox" class="check-done w-4 h-4" onchange="saveState()"></td>
<td class="px-2 py-3 no-strike"><input type="checkbox" class="check-tested w-4 h-4" onchange="saveState()"></td>
<td class="px-3 py-3 phase-bar-lucas pl-5">Renseigner IP Jetson + IPs cam&eacute;ras + ports tunnel dans <span class="text-emerald-400 font-mono text-xs">admin.php</span></td>
<td class="px-3 py-3 no-strike"><span class="badge-lucas px-2 py-0.5 rounded text-xs font-bold">Lucas</span></td>
<td class="px-3 py-3 no-strike"><input type="text" class="note-input w-full bg-transparent border-b border-slate-700 text-xs text-slate-400 px-1 py-0.5 focus:border-slate-500 outline-none" placeholder="Ports : 8560, 8561..." onchange="saveState()"></td>
</tr>
<tr class="task-row hover:bg-slate-800/30" data-task="place-4">
<td class="px-5 py-3 no-strike"><input type="checkbox" class="check-done w-4 h-4" onchange="saveState()"></td>
<td class="px-2 py-3 no-strike"><input type="checkbox" class="check-tested w-4 h-4" onchange="saveState()"></td>
<td class="px-3 py-3 phase-bar-smarteye pl-5">V&eacute;rifier que SmartEye d&eacute;tecte les cam&eacute;ras (YOLO actif)</td>
<td class="px-3 py-3 no-strike"><span class="badge-smarteye px-2 py-0.5 rounded text-xs font-bold">SmartEye</span></td>
<td class="px-3 py-3 no-strike"><input type="text" class="note-input w-full bg-transparent border-b border-slate-700 text-xs text-slate-400 px-1 py-0.5 focus:border-slate-500 outline-none" placeholder="..." onchange="saveState()"></td>
</tr>
</tbody>
</table>
</div>
<!-- PHASE 3 : CONFIG APP AVA -->
<div class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
<div class="px-5 py-3 bg-slate-800/50 flex items-center gap-3">
<span class="text-lg">📱</span>
<h2 class="font-bold text-white text-sm uppercase tracking-wider">Phase 3 — Configuration App AVA</h2>
<span class="text-xs text-slate-500 ml-auto">Sur le t&eacute;l&eacute;phone famille</span>
</div>
<table class="w-full text-sm">
<thead class="text-xs text-slate-500 uppercase bg-slate-950/50">
<tr><th class="px-5 py-2 text-left w-8">Fait</th><th class="px-2 py-2 text-left w-8">Test</th><th class="px-3 py-2 text-left">Action</th><th class="px-3 py-2 text-left w-24">Qui</th><th class="px-3 py-2 text-left w-48">Notes</th></tr>
</thead>
<tbody class="divide-y divide-slate-800/50">
<tr class="task-row hover:bg-slate-800/30" data-task="app-1">
<td class="px-5 py-3 no-strike"><input type="checkbox" class="check-done w-4 h-4" onchange="saveState()"></td>
<td class="px-2 py-3 no-strike"><input type="checkbox" class="check-tested w-4 h-4" onchange="saveState()"></td>
<td class="px-3 py-3 phase-bar-ava pl-5">Saisir identifiant + mot de passe dans AVA</td>
<td class="px-3 py-3 no-strike"><span class="badge-ava px-2 py-0.5 rounded text-xs font-bold">AVA</span></td>
<td class="px-3 py-3 no-strike"><input type="text" class="note-input w-full bg-transparent border-b border-slate-700 text-xs text-slate-400 px-1 py-0.5 focus:border-slate-500 outline-none" placeholder="..." onchange="saveState()"></td>
</tr>
<tr class="task-row hover:bg-slate-800/30" data-task="app-2">
<td class="px-5 py-3 no-strike"><input type="checkbox" class="check-done w-4 h-4" onchange="saveState()"></td>
<td class="px-2 py-3 no-strike"><input type="checkbox" class="check-tested w-4 h-4" onchange="saveState()"></td>
<td class="px-3 py-3 phase-bar-all pl-5">V&eacute;rifier r&eacute;ception <span class="font-mono text-xs text-indigo-400">smarteye_token</span> + <span class="font-mono text-xs text-indigo-400">jetson_ip</span> + <span class="font-mono text-xs text-indigo-400">cameras</span></td>
<td class="px-3 py-3 no-strike">
<span class="badge-ava px-2 py-0.5 rounded text-xs font-bold">AVA</span>
<span class="badge-lucas px-2 py-0.5 rounded text-xs font-bold ml-1">Lucas</span>
</td>
<td class="px-3 py-3 no-strike"><input type="text" class="note-input w-full bg-transparent border-b border-slate-700 text-xs text-slate-400 px-1 py-0.5 focus:border-slate-500 outline-none" placeholder="..." onchange="saveState()"></td>
</tr>
<tr class="task-row hover:bg-slate-800/30" data-task="app-3">
<td class="px-5 py-3 no-strike"><input type="checkbox" class="check-done w-4 h-4" onchange="saveState()"></td>
<td class="px-2 py-3 no-strike"><input type="checkbox" class="check-tested w-4 h-4" onchange="saveState()"></td>
<td class="px-3 py-3 phase-bar-ava pl-5">Scanner cam&eacute;ras via Jetson <span class="text-slate-600 text-xs">(WiFi local : http://jetson_ip:8080)</span></td>
<td class="px-3 py-3 no-strike">
<span class="badge-ava px-2 py-0.5 rounded text-xs font-bold">AVA</span>
<span class="badge-smarteye px-2 py-0.5 rounded text-xs font-bold ml-1">SmartEye</span>
</td>
<td class="px-3 py-3 no-strike"><input type="text" class="note-input w-full bg-transparent border-b border-slate-700 text-xs text-slate-400 px-1 py-0.5 focus:border-slate-500 outline-none" placeholder="..." onchange="saveState()"></td>
</tr>
<tr class="task-row hover:bg-slate-800/30" data-task="app-4">
<td class="px-5 py-3 no-strike"><input type="checkbox" class="check-done w-4 h-4" onchange="saveState()"></td>
<td class="px-2 py-3 no-strike"><input type="checkbox" class="check-tested w-4 h-4" onchange="saveState()"></td>
<td class="px-3 py-3 phase-bar-ava pl-5">V&eacute;rifier affichage cam&eacute;ra en local (WiFi)</td>
<td class="px-3 py-3 no-strike"><span class="badge-ava px-2 py-0.5 rounded text-xs font-bold">AVA</span></td>
<td class="px-3 py-3 no-strike"><input type="text" class="note-input w-full bg-transparent border-b border-slate-700 text-xs text-slate-400 px-1 py-0.5 focus:border-slate-500 outline-none" placeholder="..." onchange="saveState()"></td>
</tr>
</tbody>
</table>
</div>
<!-- PHASE 4 : TUNNELS SSH -->
<div class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
<div class="px-5 py-3 bg-slate-800/50 flex items-center gap-3">
<span class="text-lg">🔗</span>
<h2 class="font-bold text-white text-sm uppercase tracking-wider">Phase 4 — Tunnels SSH (acc&egrave;s distant)</h2>
<span class="text-xs text-slate-500 ml-auto">Jetson &rarr; OVH</span>
</div>
<table class="w-full text-sm">
<thead class="text-xs text-slate-500 uppercase bg-slate-950/50">
<tr><th class="px-5 py-2 text-left w-8">Fait</th><th class="px-2 py-2 text-left w-8">Test</th><th class="px-3 py-2 text-left">Action</th><th class="px-3 py-2 text-left w-24">Qui</th><th class="px-3 py-2 text-left w-48">Notes</th></tr>
</thead>
<tbody class="divide-y divide-slate-800/50">
<tr class="task-row hover:bg-slate-800/30" data-task="tunnel-1">
<td class="px-5 py-3 no-strike"><input type="checkbox" class="check-done w-4 h-4" onchange="saveState()"></td>
<td class="px-2 py-3 no-strike"><input type="checkbox" class="check-tested w-4 h-4" onchange="saveState()"></td>
<td class="px-3 py-3 phase-bar-lucas pl-5">Cl&eacute; SSH du Jetson ajout&eacute;e dans <span class="font-mono text-xs text-emerald-400">authorized_keys</span> sur OVH</td>
<td class="px-3 py-3 no-strike"><span class="badge-lucas px-2 py-0.5 rounded text-xs font-bold">Lucas</span></td>
<td class="px-3 py-3 no-strike"><input type="text" class="note-input w-full bg-transparent border-b border-slate-700 text-xs text-slate-400 px-1 py-0.5 focus:border-slate-500 outline-none" placeholder="..." onchange="saveState()"></td>
</tr>
<tr class="task-row hover:bg-slate-800/30" data-task="tunnel-2">
<td class="px-5 py-3 no-strike"><input type="checkbox" class="check-done w-4 h-4" onchange="saveState()"></td>
<td class="px-2 py-3 no-strike"><input type="checkbox" class="check-tested w-4 h-4" onchange="saveState()"></td>
<td class="px-3 py-3 phase-bar-smarteye pl-5">Lancer <span class="font-mono text-xs text-amber-400">autossh</span> sur le Jetson avec les ports cam&eacute;ras</td>
<td class="px-3 py-3 no-strike"><span class="badge-smarteye px-2 py-0.5 rounded text-xs font-bold">SmartEye</span></td>
<td class="px-3 py-3 no-strike"><input type="text" class="note-input w-full bg-transparent border-b border-slate-700 text-xs text-slate-400 px-1 py-0.5 focus:border-slate-500 outline-none" placeholder="-R 8560:ip:554 ..." onchange="saveState()"></td>
</tr>
<tr class="task-row hover:bg-slate-800/30" data-task="tunnel-3">
<td class="px-5 py-3 no-strike"><input type="checkbox" class="check-done w-4 h-4" onchange="saveState()"></td>
<td class="px-2 py-3 no-strike"><input type="checkbox" class="check-tested w-4 h-4" onchange="saveState()"></td>
<td class="px-3 py-3 phase-bar-lucas pl-5">V&eacute;rifier ports actifs sur OVH <span class="text-slate-600 text-xs">(ss -tlnp)</span></td>
<td class="px-3 py-3 no-strike"><span class="badge-lucas px-2 py-0.5 rounded text-xs font-bold">Lucas</span></td>
<td class="px-3 py-3 no-strike"><input type="text" class="note-input w-full bg-transparent border-b border-slate-700 text-xs text-slate-400 px-1 py-0.5 focus:border-slate-500 outline-none" placeholder="..." onchange="saveState()"></td>
</tr>
<tr class="task-row hover:bg-slate-800/30" data-task="tunnel-4">
<td class="px-5 py-3 no-strike"><input type="checkbox" class="check-done w-4 h-4" onchange="saveState()"></td>
<td class="px-2 py-3 no-strike"><input type="checkbox" class="check-tested w-4 h-4" onchange="saveState()"></td>
<td class="px-3 py-3 phase-bar-smarteye pl-5">Configurer autossh en service (red&eacute;marrage auto)</td>
<td class="px-3 py-3 no-strike"><span class="badge-smarteye px-2 py-0.5 rounded text-xs font-bold">SmartEye</span></td>
<td class="px-3 py-3 no-strike"><input type="text" class="note-input w-full bg-transparent border-b border-slate-700 text-xs text-slate-400 px-1 py-0.5 focus:border-slate-500 outline-none" placeholder="systemd service" onchange="saveState()"></td>
</tr>
</tbody>
</table>
</div>
<!-- PHASE 5 : TESTS COMPLETS -->
<div class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
<div class="px-5 py-3 bg-slate-800/50 flex items-center gap-3">
<span class="text-lg">🧪</span>
<h2 class="font-bold text-white text-sm uppercase tracking-wider">Phase 5 — Tests Complets</h2>
<span class="text-xs text-slate-500 ml-auto">Couper WiFi, tester en 4G</span>
</div>
<table class="w-full text-sm">
<thead class="text-xs text-slate-500 uppercase bg-slate-950/50">
<tr><th class="px-5 py-2 text-left w-8">Fait</th><th class="px-2 py-2 text-left w-8">Test</th><th class="px-3 py-2 text-left">Action</th><th class="px-3 py-2 text-left w-24">Qui</th><th class="px-3 py-2 text-left w-48">Notes</th></tr>
</thead>
<tbody class="divide-y divide-slate-800/50">
<tr class="task-row hover:bg-slate-800/30" data-task="test-1">
<td class="px-5 py-3 no-strike"><input type="checkbox" class="check-done w-4 h-4" onchange="saveState()"></td>
<td class="px-2 py-3 no-strike"><input type="checkbox" class="check-tested w-4 h-4" onchange="saveState()"></td>
<td class="px-3 py-3 phase-bar-ava pl-5">Cam&eacute;ra live &agrave; distance <span class="text-slate-600 text-xs">(rtsp://57.128.74.87:port)</span></td>
<td class="px-3 py-3 no-strike"><span class="badge-ava px-2 py-0.5 rounded text-xs font-bold">AVA</span></td>
<td class="px-3 py-3 no-strike"><input type="text" class="note-input w-full bg-transparent border-b border-slate-700 text-xs text-slate-400 px-1 py-0.5 focus:border-slate-500 outline-none" placeholder="..." onchange="saveState()"></td>
</tr>
<tr class="task-row hover:bg-slate-800/30" data-task="test-2">
<td class="px-5 py-3 no-strike"><input type="checkbox" class="check-done w-4 h-4" onchange="saveState()"></td>
<td class="px-2 py-3 no-strike"><input type="checkbox" class="check-tested w-4 h-4" onchange="saveState()"></td>
<td class="px-3 py-3 phase-bar-smarteye pl-5">Simuler une chute (passer devant cam&eacute;ra, se coucher)</td>
<td class="px-3 py-3 no-strike"><span class="badge-smarteye px-2 py-0.5 rounded text-xs font-bold">SmartEye</span></td>
<td class="px-3 py-3 no-strike"><input type="text" class="note-input w-full bg-transparent border-b border-slate-700 text-xs text-slate-400 px-1 py-0.5 focus:border-slate-500 outline-none" placeholder="..." onchange="saveState()"></td>
</tr>
<tr class="task-row hover:bg-slate-800/30" data-task="test-3">
<td class="px-5 py-3 no-strike"><input type="checkbox" class="check-done w-4 h-4" onchange="saveState()"></td>
<td class="px-2 py-3 no-strike"><input type="checkbox" class="check-tested w-4 h-4" onchange="saveState()"></td>
<td class="px-3 py-3 phase-bar-lucas pl-5">V&eacute;rifier r&eacute;ception image + analyse Gemini <span class="text-slate-600 text-xs">(api.php &rarr; analyze.py)</span></td>
<td class="px-3 py-3 no-strike"><span class="badge-lucas px-2 py-0.5 rounded text-xs font-bold">Lucas</span></td>
<td class="px-3 py-3 no-strike"><input type="text" class="note-input w-full bg-transparent border-b border-slate-700 text-xs text-slate-400 px-1 py-0.5 focus:border-slate-500 outline-none" placeholder="..." onchange="saveState()"></td>
</tr>
<tr class="task-row hover:bg-slate-800/30" data-task="test-4">
<td class="px-5 py-3 no-strike"><input type="checkbox" class="check-done w-4 h-4" onchange="saveState()"></td>
<td class="px-2 py-3 no-strike"><input type="checkbox" class="check-tested w-4 h-4" onchange="saveState()"></td>
<td class="px-3 py-3 phase-bar-ava pl-5">R&eacute;ception notification push Firebase</td>
<td class="px-3 py-3 no-strike"><span class="badge-ava px-2 py-0.5 rounded text-xs font-bold">AVA</span></td>
<td class="px-3 py-3 no-strike"><input type="text" class="note-input w-full bg-transparent border-b border-slate-700 text-xs text-slate-400 px-1 py-0.5 focus:border-slate-500 outline-none" placeholder="..." onchange="saveState()"></td>
</tr>
<tr class="task-row hover:bg-slate-800/30" data-task="test-5">
<td class="px-5 py-3 no-strike"><input type="checkbox" class="check-done w-4 h-4" onchange="saveState()"></td>
<td class="px-2 py-3 no-strike"><input type="checkbox" class="check-tested w-4 h-4" onchange="saveState()"></td>
<td class="px-3 py-3 phase-bar-ava pl-5">Consulter photos captur&eacute;es <span class="text-slate-600 text-xs">(photos.php)</span></td>
<td class="px-3 py-3 no-strike"><span class="badge-ava px-2 py-0.5 rounded text-xs font-bold">AVA</span></td>
<td class="px-3 py-3 no-strike"><input type="text" class="note-input w-full bg-transparent border-b border-slate-700 text-xs text-slate-400 px-1 py-0.5 focus:border-slate-500 outline-none" placeholder="..." onchange="saveState()"></td>
</tr>
<tr class="task-row hover:bg-slate-800/30" data-task="test-6">
<td class="px-5 py-3 no-strike"><input type="checkbox" class="check-done w-4 h-4" onchange="saveState()"></td>
<td class="px-2 py-3 no-strike"><input type="checkbox" class="check-tested w-4 h-4" onchange="saveState()"></td>
<td class="px-3 py-3 phase-bar-ava pl-5">Acquitter l'alerte <span class="text-slate-600 text-xs">(acknowledge.php)</span></td>
<td class="px-3 py-3 no-strike"><span class="badge-ava px-2 py-0.5 rounded text-xs font-bold">AVA</span></td>
<td class="px-3 py-3 no-strike"><input type="text" class="note-input w-full bg-transparent border-b border-slate-700 text-xs text-slate-400 px-1 py-0.5 focus:border-slate-500 outline-none" placeholder="..." onchange="saveState()"></td>
</tr>
<tr class="task-row hover:bg-slate-800/30" data-task="test-7">
<td class="px-5 py-3 no-strike"><input type="checkbox" class="check-done w-4 h-4" onchange="saveState()"></td>
<td class="px-2 py-3 no-strike"><input type="checkbox" class="check-tested w-4 h-4" onchange="saveState()"></td>
<td class="px-3 py-3 phase-bar-ava pl-5">R&eacute;soudre / reset l'alerte <span class="text-slate-600 text-xs">(reset.php)</span></td>
<td class="px-3 py-3 no-strike"><span class="badge-ava px-2 py-0.5 rounded text-xs font-bold">AVA</span></td>
<td class="px-3 py-3 no-strike"><input type="text" class="note-input w-full bg-transparent border-b border-slate-700 text-xs text-slate-400 px-1 py-0.5 focus:border-slate-500 outline-none" placeholder="..." onchange="saveState()"></td>
</tr>
</tbody>
</table>
</div>
<!-- PHASE 6 : MISE EN PRODUCTION -->
<div class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
<div class="px-5 py-3 bg-slate-800/50 flex items-center gap-3">
<span class="text-lg">🚀</span>
<h2 class="font-bold text-white text-sm uppercase tracking-wider">Phase 6 — Mise en Production</h2>
<span class="text-xs text-slate-500 ml-auto">Validation finale</span>
</div>
<table class="w-full text-sm">
<thead class="text-xs text-slate-500 uppercase bg-slate-950/50">
<tr><th class="px-5 py-2 text-left w-8">Fait</th><th class="px-2 py-2 text-left w-8">Test</th><th class="px-3 py-2 text-left">Action</th><th class="px-3 py-2 text-left w-24">Qui</th><th class="px-3 py-2 text-left w-48">Notes</th></tr>
</thead>
<tbody class="divide-y divide-slate-800/50">
<tr class="task-row hover:bg-slate-800/30" data-task="prod-1">
<td class="px-5 py-3 no-strike"><input type="checkbox" class="check-done w-4 h-4" onchange="saveState()"></td>
<td class="px-2 py-3 no-strike"><input type="checkbox" class="check-tested w-4 h-4" onchange="saveState()"></td>
<td class="px-3 py-3 phase-bar-lucas pl-5">Passer <span class="font-mono text-xs text-emerald-400">site_status</span> &agrave; <span class="text-emerald-400 font-bold">"active"</span></td>
<td class="px-3 py-3 no-strike"><span class="badge-lucas px-2 py-0.5 rounded text-xs font-bold">Lucas</span></td>
<td class="px-3 py-3 no-strike"><input type="text" class="note-input w-full bg-transparent border-b border-slate-700 text-xs text-slate-400 px-1 py-0.5 focus:border-slate-500 outline-none" placeholder="..." onchange="saveState()"></td>
</tr>
<tr class="task-row hover:bg-slate-800/30" data-task="prod-2">
<td class="px-5 py-3 no-strike"><input type="checkbox" class="check-done w-4 h-4" onchange="saveState()"></td>
<td class="px-2 py-3 no-strike"><input type="checkbox" class="check-tested w-4 h-4" onchange="saveState()"></td>
<td class="px-3 py-3 phase-bar-all pl-5">Former la famille &agrave; l'utilisation de AVA</td>
<td class="px-3 py-3 no-strike"><span class="badge-ava px-2 py-0.5 rounded text-xs font-bold">AVA</span></td>
<td class="px-3 py-3 no-strike"><input type="text" class="note-input w-full bg-transparent border-b border-slate-700 text-xs text-slate-400 px-1 py-0.5 focus:border-slate-500 outline-none" placeholder="..." onchange="saveState()"></td>
</tr>
<tr class="task-row hover:bg-slate-800/30" data-task="prod-3">
<td class="px-5 py-3 no-strike"><input type="checkbox" class="check-done w-4 h-4" onchange="saveState()"></td>
<td class="px-2 py-3 no-strike"><input type="checkbox" class="check-tested w-4 h-4" onchange="saveState()"></td>
<td class="px-3 py-3 phase-bar-all pl-5">PV de recette sign&eacute; par la famille</td>
<td class="px-3 py-3 no-strike">
<span class="badge-smarteye px-2 py-0.5 rounded text-xs font-bold">SmartEye</span>
<span class="badge-lucas px-2 py-0.5 rounded text-xs font-bold ml-1">Lucas</span>
<span class="badge-ava px-2 py-0.5 rounded text-xs font-bold ml-1">AVA</span>
</td>
<td class="px-3 py-3 no-strike"><input type="text" class="note-input w-full bg-transparent border-b border-slate-700 text-xs text-slate-400 px-1 py-0.5 focus:border-slate-500 outline-none" placeholder="..." onchange="saveState()"></td>
</tr>
</tbody>
</table>
</div>
<!-- RESET -->
<div class="text-center py-4">
<button onclick="if(confirm('Remettre toute la checklist à zéro ?')){localStorage.removeItem(getStorageKey());location.reload();}" class="text-xs text-slate-600 hover:text-red-400 transition border border-slate-800 px-4 py-2 rounded">Remettre &agrave; z&eacute;ro</button>
</div>
</div>
<script>
function getStorageKey() {
const client = document.getElementById('client-select').value || '_default';
return 'checklist_' + client;
}
function saveState() {
const state = {};
document.querySelectorAll('.task-row').forEach(row => {
const task = row.dataset.task;
const done = row.querySelector('.check-done').checked;
const tested = row.querySelector('.check-tested').checked;
const note = row.querySelector('.note-input').value;
state[task] = { done, tested, note };
if (done && tested) {
row.classList.add('row-done');
} else {
row.classList.remove('row-done');
}
});
localStorage.setItem(getStorageKey(), JSON.stringify(state));
updateProgress();
}
function loadState() {
const saved = localStorage.getItem(getStorageKey());
if (!saved) return;
const state = JSON.parse(saved);
document.querySelectorAll('.task-row').forEach(row => {
const task = row.dataset.task;
if (state[task]) {
row.querySelector('.check-done').checked = state[task].done;
row.querySelector('.check-tested').checked = state[task].tested;
row.querySelector('.note-input').value = state[task].note || '';
if (state[task].done && state[task].tested) {
row.classList.add('row-done');
}
}
});
updateProgress();
}
function loadClient() {
// Reset all checkboxes and notes
document.querySelectorAll('.task-row').forEach(row => {
row.querySelector('.check-done').checked = false;
row.querySelector('.check-tested').checked = false;
row.querySelector('.note-input').value = '';
row.classList.remove('row-done');
});
loadState();
}
function updateProgress() {
const rows = document.querySelectorAll('.task-row');
const total = rows.length;
let completed = 0;
rows.forEach(row => {
if (row.querySelector('.check-done').checked && row.querySelector('.check-tested').checked) {
completed++;
}
});
const pct = total > 0 ? Math.round((completed / total) * 100) : 0;
document.getElementById('progress-bar').style.width = pct + '%';
document.getElementById('progress-text').textContent = completed + ' / ' + total + ' (' + pct + '%)';
}
// Init
loadState();
</script>
</body>
</html>

97
config_loader.py Normal file
View File

@@ -0,0 +1,97 @@
"""
config_loader.py — Chargement centralisé de la configuration Lucas
Usage:
from config_loader import get_config, get_client_info
cfg = get_config()
api_key = cfg.get("gemini", "api_key")
client = get_client_info("Demo_01")
senior_name = client.get("senior_name", "Senior")
"""
import configparser
import json
import os
_config = None
_config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "lucas_config.ini")
_db_path = None
def get_config():
"""Charge et cache la configuration globale depuis lucas_config.ini"""
global _config, _db_path
if _config is None:
_config = configparser.ConfigParser()
_config.read(_config_path)
_db_path = _config.get("database", "clients_db", fallback="/var/www/lucas/database.json")
return _config
def get_db():
"""Charge la base de données clients"""
cfg = get_config()
try:
with open(_db_path, 'r') as f:
return json.load(f)
except Exception:
return {"clients": {}}
def get_client_info(client_id):
"""Charge les infos d'un client par son name (client_id)
Cherche dans database.json un client dont le champ 'name' correspond.
Retourne le dict du client ou {} si non trouvé.
"""
db = get_db()
clients = db.get("clients", {})
# Recherche par clé directe
if client_id in clients:
return clients[client_id]
# Recherche par champ 'name' (compatibilité avec les clients indexés numériquement)
for key, client in clients.items():
if client.get("name", "").lower() == client_id.lower():
return client
return {}
def get_client_fcm_tokens(client_id):
"""Retourne la liste des tokens FCM d'un client"""
client = get_client_info(client_id)
return client.get("fcm_tokens", [])
def get_site_ports(site_number):
"""Calcule les ports pour un numéro de site donné
Returns: dict avec base_port, rtsp_ports (list), audio_port, webui_port
"""
cfg = get_config()
base_start = cfg.getint("ports", "base_start", fallback=8550)
ports_per_site = cfg.getint("ports", "ports_per_site", fallback=10)
base_port = base_start + (site_number - 1) * ports_per_site
return {
"base_port": base_port,
"rtsp_ports": list(range(base_port, base_port + 6)),
"audio_port": base_port + 6,
"webui_port": base_port + 7,
}
def get_next_site_number():
"""Retourne le prochain numéro de site disponible"""
db = get_db()
clients = db.get("clients", {})
max_site = 0
for client in clients.values():
sn = client.get("site_number", 0)
if isinstance(sn, int) and sn > max_site:
max_site = sn
return max_site + 1

282
doc_camera.php Normal file
View File

@@ -0,0 +1,282 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SmartEye Configuration Caméra</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
body { background: #0a0e1a; }
.flux-1 { border-left: 3px solid #ef4444; }
.flux-2 { border-left: 3px solid #3b82f6; }
.badge-flux1 { background: #ef444422; color: #f87171; border: 1px solid #ef444444; }
.badge-flux2 { background: #3b82f622; color: #60a5fa; border: 1px solid #3b82f644; }
</style>
</head>
<body class="min-h-screen text-slate-200">
<!-- HEADER -->
<nav class="bg-slate-900/80 backdrop-blur border-b border-slate-800 px-6 py-4 sticky top-0 z-50">
<div class="max-w-5xl mx-auto flex justify-between items-center">
<div class="flex items-center gap-4">
<div class="w-3 h-3 rounded-full bg-red-500"></div>
<span class="font-bold text-xl tracking-wider text-white">CONFIG <span class="text-slate-500 text-sm font-normal">CAM&Eacute;RA</span></span>
</div>
<div class="flex gap-3">
<a href="checklist.php" class="text-slate-400 hover:text-white text-sm border border-slate-700 px-3 py-2 rounded transition">Checklist</a>
<a href="admin.php" class="text-slate-400 hover:text-white text-sm border border-slate-700 px-3 py-2 rounded transition">Admin</a>
</div>
</div>
</nav>
<div class="max-w-5xl mx-auto px-6 py-8 space-y-8">
<!-- CONCEPT : LES 2 FLUX -->
<div class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
<div class="px-6 py-4 bg-slate-800/50 border-b border-slate-800">
<h2 class="font-bold text-white text-lg">Comprendre les 2 flux vid&eacute;o</h2>
<p class="text-slate-400 text-sm mt-1">Chaque cam&eacute;ra IP diffuse 2 flux RTSP simultan&eacute;s, utilis&eacute;s pour des besoins diff&eacute;rents.</p>
</div>
<div class="p-6 grid grid-cols-2 gap-6">
<!-- FLUX 2 = Détection -->
<div class="bg-slate-950 rounded-lg border border-blue-500/30 p-5 space-y-3">
<div class="flex items-center gap-3">
<span class="badge-flux2 px-3 py-1 rounded font-bold text-sm">Flux 2 D&eacute;tection</span>
<span class="font-mono text-xs text-slate-500">/cam/realmonitor?channel=1&subtype=<span class="text-blue-400 font-bold">1</span></span>
</div>
<div class="text-sm text-slate-300 space-y-2">
<p><span class="text-blue-400 font-bold">Usage :</span> Flux analys&eacute; en continu par YOLO sur le Jetson pour d&eacute;tecter les chutes.</p>
<p><span class="text-blue-400 font-bold">Pourquoi basse r&eacute;solution ?</span> YOLO n'a pas besoin de HD pour d&eacute;tecter une personne. Un flux l&eacute;ger (800x448) permet :</p>
<ul class="list-disc list-inside text-slate-400 text-xs space-y-1 ml-2">
<li>Moins de charge GPU sur le Jetson</li>
<li>Analyse plus rapide (+ de FPS trait&eacute;s)</li>
<li>Plusieurs cam&eacute;ras en parall&egrave;le</li>
</ul>
</div>
<div class="bg-blue-950/30 rounded p-3 text-xs font-mono text-blue-300 flex items-center gap-2">
<span class="text-blue-500">CAM</span> &rarr; <span class="text-blue-400">Flux 2 (SD)</span> &rarr; <span class="text-slate-400">Jetson YOLO</span> &rarr; <span class="text-red-400">Chute ?</span>
</div>
</div>
<!-- FLUX 1 = Preuve HD -->
<div class="bg-slate-950 rounded-lg border border-red-500/30 p-5 space-y-3">
<div class="flex items-center gap-3">
<span class="badge-flux1 px-3 py-1 rounded font-bold text-sm">Flux 1 — Preuve HD</span>
<span class="font-mono text-xs text-slate-500">/cam/realmonitor?channel=1&subtype=<span class="text-red-400 font-bold">0</span></span>
</div>
<div class="text-sm text-slate-300 space-y-2">
<p><span class="text-red-400 font-bold">Usage :</span> Quand YOLO d&eacute;tecte une chute, SmartEye capture une image <strong>HD</strong> depuis ce flux et l'envoie au serveur Lucas.</p>
<p><span class="text-red-400 font-bold">Pourquoi haute r&eacute;solution ?</span> L'image de preuve doit &ecirc;tre nette pour :</p>
<ul class="list-disc list-inside text-slate-400 text-xs space-y-1 ml-2">
<li>Analyse pr&eacute;cise par Gemini (position, visage, contexte)</li>
<li>Preuve visuelle pour la famille dans l'app AVA</li>
<li>Archivage en qualit&eacute;</li>
</ul>
</div>
<div class="bg-red-950/30 rounded p-3 text-xs font-mono text-red-300 flex items-center gap-2">
<span class="text-red-500">CHUTE</span> &rarr; <span class="text-red-400">Capture HD</span> &rarr; <span class="text-emerald-400">Lucas (Gemini)</span> &rarr; <span class="text-indigo-400">AVA</span>
</div>
</div>
</div>
<!-- Schéma résumé -->
<div class="px-6 pb-6">
<div class="bg-slate-950 rounded-lg border border-slate-700 p-4">
<pre class="text-xs text-slate-400 font-mono leading-relaxed">
CAM&Eacute;RA IP (ex: 192.168.1.196)
&boxv;
&boxvr;&HorizontalLine;&HorizontalLine; <span class="text-blue-400">Flux 2 (subtype=1)</span> &mdash; 800x448, 15fps, 300kbps &mdash;&rarr; <span class="text-amber-400">Jetson YOLO</span> (d&eacute;tection continue 24/7)
&boxv; &boxv;
&boxv; <span class="text-red-400">Chute d&eacute;tect&eacute;e !</span>
&boxv; &boxv;
&boxur;&HorizontalLine;&HorizontalLine; <span class="text-red-400">Flux 1 (subtype=0)</span> &mdash; 2880x1620, 20fps, 6Mbps &mdash;&rarr; <span class="text-amber-400">Capture screenshot HD</span>
&boxv;
POST image &rarr; <span class="text-emerald-400">Lucas api.php</span>
&boxv;
<span class="text-emerald-400">Gemini analyse</span> &rarr; <span class="text-indigo-400">Push AVA</span></pre>
</div>
</div>
</div>
<!-- PARAMÈTRES RECOMMANDÉS -->
<div class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
<div class="px-6 py-4 bg-slate-800/50 border-b border-slate-800">
<h2 class="font-bold text-white text-lg">Param&egrave;tres recommand&eacute;s</h2>
<p class="text-slate-400 text-sm mt-1">Configuration optimale pour le syst&egrave;me SmartEye. Accessible via l'interface web de la cam&eacute;ra.</p>
</div>
<!-- Paramètres généraux -->
<div class="p-6 space-y-6">
<div>
<h3 class="text-sm font-bold text-slate-300 uppercase tracking-wider mb-3">Param&egrave;tres g&eacute;n&eacute;raux</h3>
<div class="grid grid-cols-2 gap-4">
<div class="bg-slate-950 rounded border border-slate-700 p-4">
<table class="w-full text-sm">
<tr class="border-b border-slate-800">
<td class="py-2 text-slate-500">Format vid&eacute;o</td>
<td class="py-2 text-white font-bold text-right">50Hz</td>
</tr>
<tr>
<td class="py-2 text-slate-500">Codage vid&eacute;o</td>
<td class="py-2 text-white font-bold text-right">H.264 Main Profile</td>
</tr>
</table>
</div>
<div class="bg-slate-950 rounded border border-slate-700 p-4 flex items-center">
<p class="text-xs text-slate-500"><span class="text-amber-400 font-bold">H.264</span> est indispensable. Le H.265 n'est pas support&eacute; par tous les lecteurs RTSP. Le Main Profile offre le meilleur compromis qualit&eacute;/compatibilit&eacute;.</p>
</div>
</div>
</div>
<!-- FLUX 1 : HD -->
<div>
<div class="flex items-center gap-3 mb-3">
<h3 class="text-sm font-bold text-red-400 uppercase tracking-wider">Premier flux Preuve HD</h3>
<span class="badge-flux1 px-2 py-0.5 rounded text-xs font-bold">subtype=0</span>
</div>
<div class="bg-slate-950 rounded border border-red-500/20 overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-red-950/30 text-xs text-red-300 uppercase">
<tr>
<th class="px-4 py-2 text-left">Param&egrave;tre</th>
<th class="px-4 py-2 text-left">Valeur</th>
<th class="px-4 py-2 text-left">Explication</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800">
<tr>
<td class="px-4 py-3 text-slate-400">R&eacute;solution</td>
<td class="px-4 py-3 text-white font-bold font-mono">2880 x 1620</td>
<td class="px-4 py-3 text-xs text-slate-500">R&eacute;solution max de la cam&eacute;ra. Image nette pour Gemini et la famille.</td>
</tr>
<tr>
<td class="px-4 py-3 text-slate-400">D&eacute;bit binaire</td>
<td class="px-4 py-3 text-white font-bold font-mono">6144 kbps</td>
<td class="px-4 py-3 text-xs text-slate-500">D&eacute;bit max. Qualit&eacute; optimale pour la capture de preuve.</td>
</tr>
<tr>
<td class="px-4 py-3 text-slate-400">Fr&eacute;quence</td>
<td class="px-4 py-3 text-white font-bold font-mono">20 fps</td>
<td class="px-4 py-3 text-xs text-slate-500">Suffisant pour capturer un screenshot net. Pas besoin de 25fps.</td>
</tr>
<tr>
<td class="px-4 py-3 text-slate-400">Intervalle image cl&eacute;</td>
<td class="px-4 py-3 text-white font-bold font-mono">20</td>
<td class="px-4 py-3 text-xs text-slate-500">1 image cl&eacute; par seconde (= intervalle / fps). Capture plus rapide.</td>
</tr>
<tr>
<td class="px-4 py-3 text-slate-400">Contr&ocirc;le d&eacute;bit</td>
<td class="px-4 py-3 text-white font-bold">CBR</td>
<td class="px-4 py-3 text-xs text-slate-500">D&eacute;bit constant. Qualit&eacute; stable m&ecirc;me en mouvement.</td>
</tr>
<tr>
<td class="px-4 py-3 text-slate-400">Qualit&eacute; image</td>
<td class="px-4 py-3 text-white font-bold font-mono">1 <span class="text-xs text-slate-500 font-normal">(meilleure)</span></td>
<td class="px-4 py-3 text-xs text-slate-500">Valeur la plus basse = qualit&eacute; max.</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- FLUX 2 : Détection -->
<div>
<div class="flex items-center gap-3 mb-3">
<h3 class="text-sm font-bold text-blue-400 uppercase tracking-wider">Deuxi&egrave;me flux D&eacute;tection YOLO</h3>
<span class="badge-flux2 px-2 py-0.5 rounded text-xs font-bold">subtype=1</span>
</div>
<div class="bg-slate-950 rounded border border-blue-500/20 overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-blue-950/30 text-xs text-blue-300 uppercase">
<tr>
<th class="px-4 py-2 text-left">Param&egrave;tre</th>
<th class="px-4 py-2 text-left">Valeur</th>
<th class="px-4 py-2 text-left">Explication</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800">
<tr>
<td class="px-4 py-3 text-slate-400">R&eacute;solution</td>
<td class="px-4 py-3 text-white font-bold font-mono">800 x 448</td>
<td class="px-4 py-3 text-xs text-slate-500">YOLO d&eacute;tecte tr&egrave;s bien en basse r&eacute;solution. &Eacute;conomise le GPU.</td>
</tr>
<tr>
<td class="px-4 py-3 text-slate-400">D&eacute;bit binaire</td>
<td class="px-4 py-3 text-white font-bold font-mono">300 kbps</td>
<td class="px-4 py-3 text-xs text-slate-500">20x moins que le flux HD. Bande passante n&eacute;gligeable.</td>
</tr>
<tr>
<td class="px-4 py-3 text-slate-400">Fr&eacute;quence</td>
<td class="px-4 py-3 text-white font-bold font-mono">15 fps</td>
<td class="px-4 py-3 text-xs text-slate-500">Suffisant pour d&eacute;tecter un mouvement. Plus = plus de charge GPU inutile.</td>
</tr>
<tr>
<td class="px-4 py-3 text-slate-400">Intervalle image cl&eacute;</td>
<td class="px-4 py-3 text-white font-bold font-mono">30</td>
<td class="px-4 py-3 text-xs text-slate-500">1 image cl&eacute; toutes les 2 secondes. Acceptable pour la d&eacute;tection.</td>
</tr>
<tr>
<td class="px-4 py-3 text-slate-400">Contr&ocirc;le d&eacute;bit</td>
<td class="px-4 py-3 text-white font-bold">VBR</td>
<td class="px-4 py-3 text-xs text-slate-500">D&eacute;bit variable. &Eacute;conomise la bande passante quand rien ne bouge.</td>
</tr>
<tr>
<td class="px-4 py-3 text-slate-400">Qualit&eacute; image</td>
<td class="px-4 py-3 text-white font-bold font-mono">1 <span class="text-xs text-slate-500 font-normal">(meilleure)</span></td>
<td class="px-4 py-3 text-xs text-slate-500">On garde la meilleure qualit&eacute; m&ecirc;me en SD pour la d&eacute;tection.</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- URLs RTSP -->
<div class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
<div class="px-6 py-4 bg-slate-800/50 border-b border-slate-800">
<h2 class="font-bold text-white text-lg">URLs RTSP</h2>
<p class="text-slate-400 text-sm mt-1">Format des URLs pour cam&eacute;ras Dahua. Remplacer l'IP par celle de la cam&eacute;ra.</p>
</div>
<div class="p-6 space-y-4">
<div class="bg-slate-950 rounded border border-red-500/20 p-4">
<div class="flex items-center gap-2 mb-2">
<span class="badge-flux1 px-2 py-0.5 rounded text-xs font-bold">Flux 1 — HD</span>
<span class="text-xs text-slate-500">Capture de preuve</span>
</div>
<code class="text-sm text-red-300 font-mono">rtsp://admin:password@192.168.1.196/cam/realmonitor?channel=1&subtype=0</code>
</div>
<div class="bg-slate-950 rounded border border-blue-500/20 p-4">
<div class="flex items-center gap-2 mb-2">
<span class="badge-flux2 px-2 py-0.5 rounded text-xs font-bold">Flux 2 — SD</span>
<span class="text-xs text-slate-500">D&eacute;tection YOLO 24/7</span>
</div>
<code class="text-sm text-blue-300 font-mono">rtsp://admin:password@192.168.1.196/cam/realmonitor?channel=1&subtype=1</code>
</div>
<div class="bg-amber-950/20 border border-amber-500/30 rounded p-3 text-xs text-amber-300">
<strong>Note :</strong> Le <span class="font-mono">channel</span> correspond au num&eacute;ro de canal de la cam&eacute;ra (1 pour une cam&eacute;ra mono-objectif). Le <span class="font-mono">subtype</span> s&eacute;lectionne le flux : <strong>0</strong> = principal (HD), <strong>1</strong> = secondaire (SD).
</div>
</div>
</div>
<!-- CHECKLIST RAPIDE -->
<div class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
<div class="px-6 py-4 bg-slate-800/50 border-b border-slate-800">
<h2 class="font-bold text-white text-lg">Checklist configuration cam&eacute;ra</h2>
</div>
<div class="p-6">
<ol class="space-y-3 text-sm text-slate-300">
<li class="flex gap-3"><span class="text-slate-600 font-bold w-6">1.</span> Acc&eacute;der &agrave; l'interface web de la cam&eacute;ra <span class="font-mono text-xs text-slate-500">(http://IP_CAM)</span></li>
<li class="flex gap-3"><span class="text-slate-600 font-bold w-6">2.</span> Aller dans <strong>Param&egrave;tres &rarr; Cam&eacute;ra &rarr; Vid&eacute;o</strong></li>
<li class="flex gap-3"><span class="text-slate-600 font-bold w-6">3.</span> Configurer le <span class="text-red-400 font-bold">Premier flux</span> (HD) selon le tableau ci-dessus</li>
<li class="flex gap-3"><span class="text-slate-600 font-bold w-6">4.</span> Configurer le <span class="text-blue-400 font-bold">Deuxi&egrave;me flux</span> (SD) selon le tableau ci-dessus</li>
<li class="flex gap-3"><span class="text-slate-600 font-bold w-6">5.</span> Cliquer <strong>Appliquer</strong></li>
<li class="flex gap-3"><span class="text-slate-600 font-bold w-6">6.</span> Tester les 2 flux RTSP avec VLC <span class="font-mono text-xs text-slate-500">(vlc rtsp://...)</span></li>
<li class="flex gap-3"><span class="text-slate-600 font-bold w-6">7.</span> V&eacute;rifier que SmartEye re&ccedil;oit le flux 2 et d&eacute;tecte correctement</li>
</ol>
</div>
</div>
</div>
</body>
</html>

59
heartbeat.php Normal file
View File

@@ -0,0 +1,59 @@
<?php
/**
* heartbeat.php — Heartbeat des Jetsons SmartEye
*
* Appelé toutes les 5 minutes par chaque Jetson déployé.
* Met à jour le statut du site et le timestamp du dernier contact.
*
* POST /heartbeat.php
* Content-Type: application/json
* {"client_id": "Demo_01", "token": "secret123", "uptime": 86400, "cameras_active": 3}
*/
header("Content-Type: application/json");
header("Access-Control-Allow-Origin: *");
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
die(json_encode(["success" => false, "message" => "POST uniquement"]));
}
$input = json_decode(file_get_contents("php://input"), true);
$client_id = $input['client_id'] ?? '';
$token = $input['token'] ?? '';
if (empty($client_id) || empty($token)) {
http_response_code(400);
die(json_encode(["success" => false, "message" => "client_id et token requis"]));
}
$DB_FILE = "database.json";
$db = json_decode(file_get_contents($DB_FILE), true);
// Authentification
$found_key = null;
foreach ($db['clients'] as $key => $c) {
if (strcasecmp($c['name'] ?? '', $client_id) === 0 && $c['token'] === $token) {
$found_key = $key;
break;
}
}
if ($found_key === null) {
http_response_code(403);
die(json_encode(["success" => false, "message" => "Authentification échouée"]));
}
// Mise à jour heartbeat
$db['clients'][$found_key]['last_heartbeat'] = date("Y-m-d H:i:s");
$db['clients'][$found_key]['site_status'] = "active";
$db['clients'][$found_key]['uptime'] = $input['uptime'] ?? 0;
$db['clients'][$found_key]['cameras_active'] = $input['cameras_active'] ?? 0;
file_put_contents($DB_FILE, json_encode($db, JSON_PRETTY_PRINT));
echo json_encode([
"success" => true,
"message" => "Heartbeat reçu",
"server_time" => date("Y-m-d H:i:s")
]);
?>

328
index.html Normal file
View File

@@ -0,0 +1,328 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LUCAS - Veiller sans surveiller</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;800&display=swap" rel="stylesheet">
<style>
:root {
--bg-cream: #FAF9F6;
--text-dark: #1A2530;
--accent-terra: #D96C5B;
--bg-navy: #1A2530;
--text-light: #F0F4F8;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Inter', sans-serif;
}
body {
background-color: var(--bg-cream);
color: var(--text-dark);
overflow-x: hidden;
scroll-behavior: smooth;
}
/* Classes d'animation d'apparition au scroll */
.fade-in {
opacity: 0;
transform: translateY(40px);
transition: opacity 1s ease-out, transform 1s ease-out;
}
.fade-in.visible {
opacity: 1;
transform: translateY(0);
}
/* SECTION 1 : HERO (L'éblouissement) */
.hero {
height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
padding: 2rem;
/* Gradient doux pour simuler une belle lumière matinale */
background: linear-gradient(135deg, #FAF9F6 0%, #E8E2D9 100%);
}
.hero h1 {
font-size: 4.5rem;
font-weight: 800;
letter-spacing: -2px;
margin-bottom: 1.5rem;
max-width: 900px;
line-height: 1.1;
}
.hero p {
font-size: 1.5rem;
font-weight: 300;
color: #555;
max-width: 700px;
margin-bottom: 3rem;
}
.btn {
background-color: var(--accent-terra);
color: white;
padding: 1.2rem 2.5rem;
font-size: 1.1rem;
font-weight: 600;
border: none;
border-radius: 50px;
cursor: pointer;
transition: all 0.3s ease;
text-decoration: none;
box-shadow: 0 10px 20px rgba(217, 108, 91, 0.3);
}
.btn:hover {
transform: translateY(-3px);
box-shadow: 0 15px 25px rgba(217, 108, 91, 0.4);
}
/* SECTION 2 : LE DILEMME */
.dilemma {
background-color: var(--bg-navy);
color: var(--text-light);
padding: 8rem 2rem;
text-align: center;
}
.dilemma h2 {
font-size: 3.5rem;
font-weight: 600;
margin-bottom: 2rem;
line-height: 1.2;
}
.dilemma p {
font-size: 1.3rem;
font-weight: 300;
max-width: 800px;
margin: 0 auto;
color: #A0B2C6;
}
.dilemma .highlight {
color: var(--accent-terra);
font-style: italic;
}
/* SECTION 3 : LA RÉVÉLATION (Features) */
.features {
padding: 8rem 2rem;
background-color: var(--bg-cream);
text-align: center;
}
.features h2 {
font-size: 3rem;
margin-bottom: 4rem;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 3rem;
max-width: 1200px;
margin: 0 auto;
}
.card {
background: white;
padding: 3rem 2rem;
border-radius: 20px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.05);
transition: transform 0.3s;
}
.card:hover {
transform: translateY(-10px);
}
.card .icon {
font-size: 3rem;
margin-bottom: 1.5rem;
}
.card h3 {
font-size: 1.5rem;
margin-bottom: 1rem;
}
.card p {
color: #666;
line-height: 1.6;
}
/* SECTION 4 : LE SPLIT SCREEN */
.split {
display: flex;
flex-wrap: wrap;
min-height: 80vh;
}
.split-half {
flex: 1;
min-width: 300px;
padding: 6rem 4rem;
display: flex;
flex-direction: column;
justify-content: center;
}
.split-left {
background-color: #E8E2D9;
}
.split-right {
background-color: var(--bg-cream);
}
.split-half h2 {
font-size: 2.5rem;
margin-bottom: 1.5rem;
}
.split-half p {
font-size: 1.2rem;
line-height: 1.8;
color: #555;
}
/* SECTION 5 : FOOTER / CTA */
.footer-cta {
background-color: var(--bg-navy);
color: white;
text-align: center;
padding: 8rem 2rem;
}
.footer-cta h2 {
font-size: 3rem;
margin-bottom: 2rem;
}
.footer-cta p {
font-size: 1.2rem;
color: #A0B2C6;
margin-bottom: 3rem;
}
/* Responsive basique */
@media (max-width: 768px) {
.hero h1 {
font-size: 3rem;
}
.dilemma h2 {
font-size: 2.5rem;
}
.split-half {
padding: 4rem 2rem;
}
}
</style>
</head>
<body>
<section class="hero">
<div class="fade-in">
<h1>Leur maison est leur royaume.<br>Qu'elle le reste.</h1>
<p>Veiller sans surveiller. La première Intelligence Artificielle qui protège vos parents en cas de chute,
sans jamais filmer leur quotidien.</p>
<a href="#decouvrir" class="btn">Découvrir la révolution LUCAS</a>
</div>
</section>
<section class="dilemma" id="decouvrir">
<div class="fade-in">
<h2>Vous voulez les savoir en sécurité.<br>Ils veulent simplement vivre libres.</h2>
<p>La dignité n'est pas une option. <span class="highlight">C'est un droit absolu.</span><br><br>Fini le
choix impossible entre l'angoisse silencieuse de la chute et l'humiliation d'une caméra de surveillance
qui les infantilise. Il existe désormais une autre voie.</p>
</div>
</section>
<section class="features">
<div class="fade-in">
<h2>Une présence invisible.<br>Une tranquillité absolue.</h2>
</div>
<div class="grid">
<div class="card fade-in" style="transition-delay: 0.1s;">
<div class="icon"></div>
<h3>Il ressent, il ne regarde pas</h3>
<p>LUCAS analyse les postures et les mouvements sans aucune lentille photographique. Zéro image, zéro
vidéo. L'intimité est totalement sacrée.</p>
</div>
<div class="card fade-in" style="transition-delay: 0.2s;">
<div class="icon"></div>
<h3>Il ne dort jamais</h3>
<p>Une chute soudaine ? Une immobilité anormale ? L'IA détecte l'urgence en une fraction de seconde et
alerte instantanément les proches.</p>
</div>
<div class="card fade-in" style="transition-delay: 0.3s;">
<div class="icon">💬</div>
<h3>Il recrée le lien</h3>
<p>Grâce à son intercom audio bidirectionnel de haute qualité, parlez directement à votre proche pour le
rassurer en attendant les secours.</p>
</div>
</div>
</section>
<section class="split">
<div class="split-half split-left">
<div class="fade-in">
<h2>Pour elle :<br>La vie, tout simplement.</h2>
<p>Aucune installation compliquée. Pas de bouton rouge stigmatisant à porter autour du cou. Pas de
regard inquisiteur dans le salon. LUCAS est un objet d'art discret qui se fond dans le décor. Elle
vit sa vie, libre et digne, avec la certitude qu'en cas de pépin, elle ne sera jamais seule.</p>
</div>
</div>
<div class="split-half split-right">
<div class="fade-in">
<h2>Pour vous :<br>Le souffle retrouvé.</h2>
<p>Le poids de la culpabilité s'envole. Vous n'avez plus besoin d'appeler trois fois par jour juste pour
"vérifier". Votre smartphone ne s'allume que si c'est strictement nécessaire. Vous protégez ce que
vous avez de plus cher, tout en reprenant le cours de votre propre vie.</p>
</div>
</div>
</section>
<section class="footer-cta">
<div class="fade-in">
<h2>La technologie au service de l'humain.</h2>
<p>Conçu en France. Chiffrement de niveau bancaire. Respect absolu de la vie privée.</p>
<a href="#" class="btn">Réserver mon système LUCAS</a>
</div>
</section>
<script>
document.addEventListener("DOMContentLoaded", function () {
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
}
});
}, { threshold: 0.15 });
const hiddenElements = document.querySelectorAll('.fade-in');
hiddenElements.forEach((el) => observer.observe(el));
});
</script>
</body>
</html>

1403
landing-liberte.html Normal file

File diff suppressed because it is too large Load Diff

328
landing-royaume.html Normal file
View File

@@ -0,0 +1,328 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LUCAS - Veiller sans surveiller</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;800&display=swap" rel="stylesheet">
<style>
:root {
--bg-cream: #FAF9F6;
--text-dark: #1A2530;
--accent-terra: #D96C5B;
--bg-navy: #1A2530;
--text-light: #F0F4F8;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Inter', sans-serif;
}
body {
background-color: var(--bg-cream);
color: var(--text-dark);
overflow-x: hidden;
scroll-behavior: smooth;
}
/* Classes d'animation d'apparition au scroll */
.fade-in {
opacity: 0;
transform: translateY(40px);
transition: opacity 1s ease-out, transform 1s ease-out;
}
.fade-in.visible {
opacity: 1;
transform: translateY(0);
}
/* SECTION 1 : HERO (L'éblouissement) */
.hero {
height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
padding: 2rem;
/* Gradient doux pour simuler une belle lumière matinale */
background: linear-gradient(135deg, #FAF9F6 0%, #E8E2D9 100%);
}
.hero h1 {
font-size: 4.5rem;
font-weight: 800;
letter-spacing: -2px;
margin-bottom: 1.5rem;
max-width: 900px;
line-height: 1.1;
}
.hero p {
font-size: 1.5rem;
font-weight: 300;
color: #555;
max-width: 700px;
margin-bottom: 3rem;
}
.btn {
background-color: var(--accent-terra);
color: white;
padding: 1.2rem 2.5rem;
font-size: 1.1rem;
font-weight: 600;
border: none;
border-radius: 50px;
cursor: pointer;
transition: all 0.3s ease;
text-decoration: none;
box-shadow: 0 10px 20px rgba(217, 108, 91, 0.3);
}
.btn:hover {
transform: translateY(-3px);
box-shadow: 0 15px 25px rgba(217, 108, 91, 0.4);
}
/* SECTION 2 : LE DILEMME */
.dilemma {
background-color: var(--bg-navy);
color: var(--text-light);
padding: 8rem 2rem;
text-align: center;
}
.dilemma h2 {
font-size: 3.5rem;
font-weight: 600;
margin-bottom: 2rem;
line-height: 1.2;
}
.dilemma p {
font-size: 1.3rem;
font-weight: 300;
max-width: 800px;
margin: 0 auto;
color: #A0B2C6;
}
.dilemma .highlight {
color: var(--accent-terra);
font-style: italic;
}
/* SECTION 3 : LA RÉVÉLATION (Features) */
.features {
padding: 8rem 2rem;
background-color: var(--bg-cream);
text-align: center;
}
.features h2 {
font-size: 3rem;
margin-bottom: 4rem;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 3rem;
max-width: 1200px;
margin: 0 auto;
}
.card {
background: white;
padding: 3rem 2rem;
border-radius: 20px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.05);
transition: transform 0.3s;
}
.card:hover {
transform: translateY(-10px);
}
.card .icon {
font-size: 3rem;
margin-bottom: 1.5rem;
}
.card h3 {
font-size: 1.5rem;
margin-bottom: 1rem;
}
.card p {
color: #666;
line-height: 1.6;
}
/* SECTION 4 : LE SPLIT SCREEN */
.split {
display: flex;
flex-wrap: wrap;
min-height: 80vh;
}
.split-half {
flex: 1;
min-width: 300px;
padding: 6rem 4rem;
display: flex;
flex-direction: column;
justify-content: center;
}
.split-left {
background-color: #E8E2D9;
}
.split-right {
background-color: var(--bg-cream);
}
.split-half h2 {
font-size: 2.5rem;
margin-bottom: 1.5rem;
}
.split-half p {
font-size: 1.2rem;
line-height: 1.8;
color: #555;
}
/* SECTION 5 : FOOTER / CTA */
.footer-cta {
background-color: var(--bg-navy);
color: white;
text-align: center;
padding: 8rem 2rem;
}
.footer-cta h2 {
font-size: 3rem;
margin-bottom: 2rem;
}
.footer-cta p {
font-size: 1.2rem;
color: #A0B2C6;
margin-bottom: 3rem;
}
/* Responsive basique */
@media (max-width: 768px) {
.hero h1 {
font-size: 3rem;
}
.dilemma h2 {
font-size: 2.5rem;
}
.split-half {
padding: 4rem 2rem;
}
}
</style>
</head>
<body>
<section class="hero">
<div class="fade-in">
<h1>Leur maison est leur royaume.<br>Qu'elle le reste.</h1>
<p>Veiller sans surveiller. La première Intelligence Artificielle qui protège vos parents en cas de chute,
sans jamais filmer leur quotidien.</p>
<a href="#decouvrir" class="btn">Découvrir la révolution LUCAS</a>
</div>
</section>
<section class="dilemma" id="decouvrir">
<div class="fade-in">
<h2>Vous voulez les savoir en sécurité.<br>Ils veulent simplement vivre libres.</h2>
<p>La dignité n'est pas une option. <span class="highlight">C'est un droit absolu.</span><br><br>Fini le
choix impossible entre l'angoisse silencieuse de la chute et l'humiliation d'une caméra de surveillance
qui les infantilise. Il existe désormais une autre voie.</p>
</div>
</section>
<section class="features">
<div class="fade-in">
<h2>Une présence invisible.<br>Une tranquillité absolue.</h2>
</div>
<div class="grid">
<div class="card fade-in" style="transition-delay: 0.1s;">
<div class="icon"></div>
<h3>Il ressent, il ne regarde pas</h3>
<p>LUCAS analyse les postures et les mouvements sans aucune lentille photographique. Zéro image, zéro
vidéo. L'intimité est totalement sacrée.</p>
</div>
<div class="card fade-in" style="transition-delay: 0.2s;">
<div class="icon"></div>
<h3>Il ne dort jamais</h3>
<p>Une chute soudaine ? Une immobilité anormale ? L'IA détecte l'urgence en une fraction de seconde et
alerte instantanément les proches.</p>
</div>
<div class="card fade-in" style="transition-delay: 0.3s;">
<div class="icon">💬</div>
<h3>Il recrée le lien</h3>
<p>Grâce à son intercom audio bidirectionnel de haute qualité, parlez directement à votre proche pour le
rassurer en attendant les secours.</p>
</div>
</div>
</section>
<section class="split">
<div class="split-half split-left">
<div class="fade-in">
<h2>Pour elle :<br>La vie, tout simplement.</h2>
<p>Aucune installation compliquée. Pas de bouton rouge stigmatisant à porter autour du cou. Pas de
regard inquisiteur dans le salon. LUCAS est un objet d'art discret qui se fond dans le décor. Elle
vit sa vie, libre et digne, avec la certitude qu'en cas de pépin, elle ne sera jamais seule.</p>
</div>
</div>
<div class="split-half split-right">
<div class="fade-in">
<h2>Pour vous :<br>Le souffle retrouvé.</h2>
<p>Le poids de la culpabilité s'envole. Vous n'avez plus besoin d'appeler trois fois par jour juste pour
"vérifier". Votre smartphone ne s'allume que si c'est strictement nécessaire. Vous protégez ce que
vous avez de plus cher, tout en reprenant le cours de votre propre vie.</p>
</div>
</div>
</section>
<section class="footer-cta">
<div class="fade-in">
<h2>La technologie au service de l'humain.</h2>
<p>Conçu en France. Chiffrement de niveau bancaire. Respect absolu de la vie privée.</p>
<a href="#" class="btn">Réserver mon système LUCAS</a>
</div>
</section>
<script>
document.addEventListener("DOMContentLoaded", function () {
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
}
});
}, { threshold: 0.15 });
const hiddenElements = document.querySelectorAll('.fade-in');
hiddenElements.forEach((el) => observer.observe(el));
});
</script>
</body>
</html>

View File

@@ -0,0 +1,3 @@
# Endpoints API
Section en cours de rédaction. Cette page décrira les endpoints de l'API REST, leurs paramètres et leurs réponses.

View File

@@ -0,0 +1,3 @@
# Payload FCM
Section en cours de rédaction. Cette page décrira la structure des payloads Firebase Cloud Messaging utilisés pour les notifications push.

View File

@@ -0,0 +1,3 @@
# Flux de données
Section en cours de rédaction. Cette page décrira le flux de données entre les différents composants du système SmartEye (Jetson, serveur, application mobile).

View File

@@ -0,0 +1,84 @@
# Architecture du système
## Vue d'ensemble
```mermaid
graph TB
subgraph Domicile["🏠 Domicile du bénéficiaire"]
CAM1[Caméra 1 - Salon]
CAM2[Caméra 2 - Chambre]
CAM3[Caméra 3 - Cuisine]
JETSON[Jetson Orin Nano<br/>SmartEye v30]
CAM1 -->|RTSP local| JETSON
CAM2 -->|RTSP local| JETSON
CAM3 -->|RTSP local| JETSON
end
subgraph OVH["☁️ Serveur OVH (57.128.74.87)"]
API[api.php<br/>Réception images]
ANALYZE[analyze.py<br/>Gemini 2.5 Flash]
ALERT[alert_manager.py<br/>Cycle de vie alertes]
WATCHDOG[alert_watchdog.py<br/>Escalade cron]
DB[(database.json)]
API --> ANALYZE
ANALYZE --> ALERT
ANALYZE --> DB
WATCHDOG --> ALERT
end
subgraph Mobile["📱 Téléphones aidants"]
APP1[LucasApp - Fille]
APP2[LucasApp - Fils]
end
JETSON ==>|SSH Tunnel<br/>Image POST| API
ANALYZE ==>|Firebase Push| APP1
ANALYZE ==>|Firebase Push| APP2
JETSON -.->|RTSP Tunnel| APP1
JETSON -.->|Audio WebSocket| APP1
```
## Composants
### SmartEye (Jetson Orin Nano)
| Caractéristique | Détail |
|----------------|--------|
| **Modèle IA** | YOLOv8 (détection) + pose estimation |
| **Caméras max** | 6 par site |
| **Flux** | HD (/11) pour détection, SD (/12) pour streaming |
| **Communication** | SSH tunnels inversés vers OVH |
### Lucas (Serveur OVH)
| Caractéristique | Détail |
|----------------|--------|
| **Analyse IA** | Google Gemini 2.5 Flash (vision) |
| **Notifications** | Firebase Cloud Messaging |
| **SMS backup** | OVH SMS API |
| **Dashboard** | PHP + Tailwind CSS |
### LucasApp (Flutter)
| Caractéristique | Détail |
|----------------|--------|
| **Plateforme** | Android (Flutter) |
| **Flux vidéo** | RTSP via tunnels SSH |
| **Interphone** | WebSocket audio bidirectionnel (PCM A-law) |
| **Acquittement** | POST vers alert_ack.php |
## Convention de ports
Chaque site client utilise une plage de 10 ports :
```
Site N → base_port = 8550 + (N-1) × 10
Offset 0-5 : RTSP caméras (max 6)
Offset 6 : Audio WebSocket relay
Offset 7 : WebUI locale SmartEye
Offset 8-9 : Réservés
```
!!! note "Site pilote (Aléria)"
Le site pilote conserve ses ports historiques : 8554/8555/8556 (RTSP) + 8800 (audio).

Binary file not shown.

After

Width:  |  Height:  |  Size: 454 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 532 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 578 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

View File

@@ -0,0 +1,330 @@
# Preparation de la carte Gold (image maitre)
<div style="background: linear-gradient(135deg, #f57f17 0%, #e65100 100%); color: white; padding: 24px; border-radius: 12px; margin-bottom: 24px;">
<h2 style="margin:0; color: white;">Document reserve au responsable technique</h2>
<p style="margin: 8px 0 0 0; opacity: 0.9;">Cette procedure ne se fait qu'une seule fois. Elle produit l'image maitre qui sera clonee pour chaque nouveau client.</p>
</div>
---
## Qu'est-ce que la carte Gold ?
La carte Gold est une carte SD **de reference** contenant :
- Le systeme d'exploitation (JetPack / Linux)
- Les drivers GPU (CUDA, cuDNN, TensorRT)
- SmartEye complet (YOLO, modeles IA, services)
- **Aucune configuration client** (pas de cameras, pas de compte, pas de logs)
C'est l'equivalent de l'image usine d'une box Free ou Orange. Le Jetson qui demarre avec cette carte arrive directement en **mode installation**, pret a etre configure depuis un smartphone.
---
## Principe : on ne touche JAMAIS a la carte de dev
```
Carte actuelle (DEV/Aleria) Carte neuve n°1 Carte neuve n°2, 3, 4...
┌─────────────────────┐ ┌─────────────┐ ┌─────────────┐
│ SmartEye + Demo_01 │──── clone ──→│ Copie brute │ │ │
│ Cameras configurees │ │ (tout dedans)│ │ │
│ Logs, images, etc. │ └──────┬──────┘ │ Client X │
│ │ │ │ │
│ ON N'Y TOUCHE PAS │ nettoyage config │ │
│ │ suppression logs └─────────────┘
└─────────────────────┘ suppression images ▲
│ │
▼ │
┌──────────────┐ clone a l'infini
│ CARTE GOLD │────────────────┘
│ (mode usine)│
└──────────────┘
```
!!! warning "Regle d'or"
La carte du Jetson de developpement (Aleria/Demo_01) **reste en place, intacte**. On la clone d'abord, puis on nettoie le clone. Comme ca, si quelque chose se passe mal, le Jetson de dev est toujours operationnel.
---
## Pre-requis
| Element | Detail |
|---------|--------|
| **Jetson de dev** | Le Jetson actuel (Aleria) qui fonctionne |
| **Carte SD neuve** | 64 Go minimum, classe A2/U3 |
| **PC Linux ou Mac** | Pour cloner et nettoyer |
| **Lecteur de carte SD** | USB ou integre |
| **Espace disque** | ~64 Go libres sur le PC |
| **Balena Etcher** | [balena.io/etcher](https://www.balena.io/etcher/) (gratuit) |
---
!!! danger "Carte SD de reference"
La carte originale du Jetson de dev est une **Samsung EVO 128 Go**.
Les cartes clones doivent etre de capacite **egale ou superieure** et idealement de la meme marque.
Deux cartes "128 Go" de marques differentes n'ont pas exactement la meme taille en octets.
Si la carte clone est plus petite (meme de quelques Mo), le clone sera corrompu et ne bootera pas.
## Etape 1 — Cloner la carte actuelle (telle quelle)
Eteindre le Jetson de dev proprement :
```bash
# Sur le Jetson
sudo shutdown -h now
# Attendre extinction complete (LED eteintes)
```
Retirer la carte SD et l'inserer dans le lecteur du PC.
=== "Linux"
```bash
# Identifier la carte SD
lsblk
# Cloner la carte entiere (NE RIEN MODIFIER)
sudo dd if=/dev/sdb of=$HOME/smarteye-dev-backup.img bs=4M status=progress
# Compresser le backup
gzip $HOME/smarteye-dev-backup.img
```
=== "Mac"
```bash
# Identifier la carte SD
diskutil list
# Demonter sans ejecter
diskutil unmountDisk /dev/disk4
# Cloner (~ 1h pour une carte 128 Go)
sudo dd if=/dev/rdisk4 of=/Users/$(whoami)/smarteye-dev-backup.img bs=4m status=progress
# Compresser
gzip /Users/$(whoami)/smarteye-dev-backup.img
```
!!! tip "Pourquoi `/Users/$(whoami)/` et pas `~/` ?"
Avec `sudo`, le `~` pointe vers `/var/root/` au lieu de votre dossier utilisateur. `$(whoami)` resout ce probleme en inserant automatiquement votre nom d'utilisateur.
!!! tip "Conserver ce backup"
Le fichier `smarteye-dev-backup.img.gz` est votre **sauvegarde de securite** du Jetson de dev. Gardez-le precieusement. En cas de probleme, vous pouvez toujours reflasher la carte d'origine.
**Remettre immediatement la carte dans le Jetson de dev** et le redemarrer. Il reprend son fonctionnement normal.
---
## Etape 2 — Flasher le clone sur une carte neuve
Inserer une **carte SD neuve** (128 Go) dans le lecteur.
=== "Balena Etcher (recommande)"
1. Ouvrir Balena Etcher
2. **Flash from file** → selectionner `smarteye-dev-backup.img.gz`
3. **Select target** → selectionner la carte SD neuve
4. **Flash!** → attendre ~10 minutes
=== "Linux"
```bash
# 1. Verifier que la carte neuve est detectee
lsblk
# Reperer la carte neuve par sa taille (ex: /dev/sdb, 128 Go)
# 2. Flasher
gunzip -c $HOME/smarteye-dev-backup.img.gz | sudo dd of=/dev/sdb bs=4M status=progress
sync
```
=== "Mac"
```bash
# 1. Verifier que la carte neuve est detectee
diskutil list
# Reperer la carte par sa taille (ex: /dev/disk4, 134.2 GB = 128 Go)
# 2. Demonter la carte sans l'ejecter
diskutil unmountDisk /dev/disk4
# 3. Flasher (~ 1h pour 128 Go, adapter /dev/rdisk4 si necessaire)
gunzip -c /Users/$(whoami)/smarteye-dev-backup.img.gz | sudo dd of=/dev/rdisk4 bs=4m status=progress
```
!!! info "Pourquoi 3 commandes separees ?"
- `diskutil list` → **verifier** qu'on cible la bonne carte (pas le disque du Mac !)
- `diskutil unmountDisk` → **liberer** la carte pour que `dd` puisse y ecrire
- `gunzip -c ... | dd` → **decompresser et flasher** en une seule passe
A ce stade, la carte neuve est une **copie exacte** de la carte de dev (avec Demo_01, les cameras, les logs — tout).
---
## Etape 3 — Nettoyer le clone (le transformer en Gold)
Inserer la carte neuve (le clone) dans le Jetson **ou** la monter sur le PC pour nettoyer.
### Option A : Nettoyer depuis le Jetson
Inserer la carte clone dans un Jetson, le demarrer, puis :
```bash
# 1. Arreter SmartEye
sudo systemctl stop smarteye
# 2. Supprimer la configuration client (Demo_01)
rm -f /w/smarteye_config.yaml
# 3. Supprimer toutes les images capturees
rm -rf /w/runs/*
# 4. Supprimer les logs
rm -rf /w/logs/*
rm -f /w/*.log
# 5. Nettoyer les known_hosts SSH (specifiques au site)
rm -f ~/.ssh/known_hosts
# 6. Vider l'historique bash
history -c
> ~/.bash_history
```
### Option B : Nettoyer depuis le PC (sans Jetson)
Si vous n'avez qu'un seul Jetson, monter la carte sur le PC :
```bash
# Monter la partition principale de la carte SD
sudo mount /dev/sdb1 /mnt
# Nettoyer (adapter les chemins selon la structure)
sudo rm -f /mnt/home/*/w/smarteye_config.yaml
sudo rm -rf /mnt/home/*/w/runs/*
sudo rm -rf /mnt/home/*/w/logs/*
sudo rm -f /mnt/home/*/.ssh/known_hosts
sudo rm -f /mnt/home/*/.bash_history
# Demonter
sudo umount /mnt
```
### Verification du nettoyage
```
=== Verification ===
Config client : ABSENT ✓
Images : 0 fichiers ✓
Logs : 0 fichiers ✓
known_hosts : ABSENT ✓
```
---
## Etape 4 — Verifier le mode setup
Si vous avez nettoye depuis le Jetson (Option A), verifier que SmartEye demarre en mode installation :
```bash
sudo systemctl start smarteye
curl http://localhost:8080/api/identity
```
Reponse attendue :
```json
{
"type": "smarteye",
"version": "v30",
"status": "setup",
"message": "En attente de configuration"
}
```
Puis eteindre :
```bash
sudo shutdown -h now
```
---
## Etape 5 — Creer l'image Gold definitive
Retirer la carte nettoyee du Jetson et la recloner en tant que **Gold** :
=== "Linux"
```bash
# La carte nettoyee est dans le lecteur
lsblk
sudo dd if=/dev/sdb of=$HOME/smarteye-gold-v30.img bs=4M status=progress
gzip $HOME/smarteye-gold-v30.img
# Resultat final
ls -lh $HOME/smarteye-gold-v30.img.gz
```
=== "Mac"
```bash
# La carte nettoyee est dans le lecteur
diskutil list
diskutil unmountDisk /dev/disk4
sudo dd if=/dev/rdisk4 of=/Users/$(whoami)/smarteye-gold-v30.img bs=4m status=progress
gzip /Users/$(whoami)/smarteye-gold-v30.img
# Resultat final
ls -lh /Users/$(whoami)/smarteye-gold-v30.img.gz
```
!!! success "Votre image Gold est prete"
Le fichier `smarteye-gold-v30.img.gz` est votre image maitre. C'est a partir de ce fichier que vous [clonerez chaque nouveau Jetson](clonage.md).
---
## Resume des cartes
| Carte | Contenu | Usage |
|-------|---------|-------|
| **Carte de dev** | SmartEye + Demo_01 + cameras | Reste dans le Jetson de dev, **jamais modifiee** |
| **Backup dev** | `smarteye-dev-backup.img.gz` | Sauvegarde de securite, au cas ou |
| **Image Gold** | `smarteye-gold-v30.img.gz` | Image vierge, **source de tous les clones** |
| **Carte client** | Flashee depuis la Gold | Une par client, configuree sur site |
---
## Checklist finale
- [ ] Carte de dev clonee → `smarteye-dev-backup.img.gz`
- [ ] Carte de dev remise dans le Jetson, fonctionnement verifie
- [ ] Clone flashe sur carte neuve
- [ ] Clone nettoye (pas de config, pas d'images, pas de logs)
- [ ] Mode setup verifie (JSON "status: setup")
- [ ] Clone nettoye reclone → `smarteye-gold-v30.img.gz`
- [ ] Image Gold stockee en lieu sur
---
## Ou stocker les images ?
| Fichier | Taille estimee | Ou le garder |
|---------|:--------------:|--------------|
| `smarteye-dev-backup.img.gz` | ~8-15 Go | PC + disque externe |
| `smarteye-gold-v30.img.gz` | ~8-15 Go | PC + disque externe + serveur OVH |
!!! info "Versionnement"
A chaque mise a jour de SmartEye, creer une nouvelle image Gold :
```
smarteye-gold-v30-2026-02-21.img.gz ← actuelle
smarteye-gold-v31-2026-03-15.img.gz ← apres mise a jour
```
---
## Etape suivante
→ [Cloner et deployer un nouveau Jetson](clonage.md)

View File

@@ -0,0 +1,239 @@
# Clonage des cartes SD (au bureau)
<div style="background: linear-gradient(135deg, #00695c 0%, #004d40 100%); color: white; padding: 24px; border-radius: 12px; margin-bottom: 24px;">
<h2 style="margin:0; color: white;">Operations internes — production en serie</h2>
<p style="margin: 8px 0 0 0; opacity: 0.9;">Cloner, flasher, verifier et reparer les cartes SD avant expedition. Tout en ligne de commande depuis un Mac.</p>
</div>
---
## Image de reference
| Fichier | Taille | Statut |
|---------|:------:|--------|
| `smarteye-dev-backup.img` | ~54 Go | **Fonctionnelle** — validee le 22/02/2026 |
Localisation Mac : `/Users/rachid/jetson_backup/smarteye-dev-backup.img`
!!! warning "Sauvegarde obligatoire"
Cette image doit exister en **au moins 2 exemplaires** :
- Sur le Mac (disque local ou externe)
- Sur le serveur OVH : `scp smarteye-dev-backup.img debian@lucas.unigest.fr:/home/debian/backups/`
---
## 1 — Flasher une carte SD (production)
C'est l'operation principale. Pour chaque nouveau client, on flashe une carte SD neuve depuis l'image de reference.
### Materiel
- Carte SD neuve **SanDisk Extreme Pro** 64 Go minimum
- Lecteur de carte SD (integre ou USB)
### Procedure
```bash
# Identifier la carte SD
diskutil list
# Reperer la carte par sa taille et ses partitions
# Le lecteur integre du Mac peut apparaitre comme "internal, physical", c'est normal
# Demonter sans ejecter
diskutil unmountDisk /dev/diskN
# Flasher (remplacer N par le bon numero)
sudo dd if=/Users/rachid/jetson_backup/smarteye-dev-backup.img of=/dev/rdiskN bs=4m status=progress
# Ejecter proprement
diskutil eject /dev/diskN
```
!!! danger "Verifier le numero de disque"
Toujours verifier **deux fois** le `/dev/diskN` avant de lancer `dd`. Se tromper de disque = ecraser le disque du Mac.
Le `r` devant `diskN` (→ `rdiskN`) utilise le mode raw, **5x plus rapide**.
Duree : ~20-25 min pour 54 Go a ~39 MB/s.
### Verification rapide
Apres le flash, avant d'ejecter :
```bash
diskutil list /dev/diskN
```
Resultat attendu : une **GUID_partition_scheme** avec ~14 partitions Linux + 1 partition EFI. C'est la structure standard du Jetson Nano.
### Production en serie
Pour flasher plusieurs cartes a la suite :
1. Flasher la carte → ejecter
2. Etiqueter la carte (numero de serie / nom client)
3. Inserer la carte dans un Jetson
4. Inserer la carte suivante dans le lecteur → recommencer
La box part chez le client **carte SD deja inseree, prete a brancher**.
---
## 2 — Creer une nouvelle image de reference (backup)
Quand SmartEye evolue (nouvelle version YOLO, corrections, etc.), il faut creer une nouvelle image de reference depuis le Jetson de dev.
### Procedure
Eteindre le Jetson proprement, retirer la carte SD, l'inserer dans le lecteur du Mac.
```bash
# Identifier la carte SD
diskutil list
# Demonter
diskutil unmountDisk /dev/diskN
# Cloner bit-a-bit
sudo dd if=/dev/rdiskN of=/Users/rachid/jetson_backup/smarteye-dev-backup.img bs=4m status=progress
# Ejecter
diskutil eject /dev/diskN
```
Duree : ~20-25 min pour une carte 64 Go.
### Nommage et archivage
```
smarteye-dev-backup-2026-02-22.img ← actuelle (validee)
smarteye-dev-backup-2026-03-XX.img ← future
```
Conserver les anciennes images. Copier sur OVH :
```bash
scp smarteye-dev-backup.img debian@lucas.unigest.fr:/home/debian/backups/
```
---
## 3 — Verifier une image (sans carte SD)
Pour inspecter une image `.img` sans la flasher :
```bash
hdiutil attach -nomount -readonly /Users/rachid/jetson_backup/smarteye-dev-backup.img
```
Resultat attendu :
```
/dev/disk6 GUID_partition_scheme
/dev/disk6s1 EFI
/dev/disk6s2 - s15 Linux Filesystem (multiple)
```
Detacher apres verification :
```bash
hdiutil detach /dev/disk6
```
---
## 4 — Reparer une image corrompue
Si une carte SD ne boot plus (erreur `mmcblk0p1 not found` sur le Jetson), la table de partitions est corrompue. Les donnees sont generalement encore intactes.
### Diagnostic sur le Jetson
Si le Jetson tombe dans un shell d'urgence (`bash-5.1#`) :
```bash
ls /dev/mmcblk0*
```
- `mmcblk0` present mais pas `mmcblk0p1` → table de partitions corrompue → reparable
- `mmcblk0` absent → probleme hardware (carte morte ou mauvais contact)
### Etape 1 : testdisk (retrouver les partitions)
```bash
brew install testdisk
testdisk /Users/rachid/jetson_backup/smarteye-dev-backup.img
```
1. **Create** → nouveau log
2. Selectionner l'image → **Proceed**
3. Type de table → **Intel**
4. **Analyse****Quick Search**
5. testdisk retrouve les partitions (FAT32 + Linux)
6. **Entree** pour continuer → **Write****Y**
!!! warning "testdisk ne suffit pas"
testdisk ecrit une table MBR. Le Jetson Nano utilise une table **GPT** avec ~14 partitions. Il faut enchainer avec gdisk.
### Etape 2 : gdisk (restaurer la GPT)
```bash
brew install gptfdisk
gdisk /Users/rachid/jetson_backup/smarteye-dev-backup.img
```
gdisk detecte le conflit :
```
Found valid MBR and corrupt GPT. Which do you want to use?
1 - MBR
2 - GPT
3 - Create blank GPT
```
1. Taper **`2`** (GPT — le header principal est souvent intact)
2. Taper **`x`** (menu expert)
3. Taper **`e`** (relocate backup structures to end of disk)
4. Taper **`m`** (retour menu principal)
5. Taper **`w`** → **Y**
Si gdisk refuse avec "Secondary partition table overlaps the last partition by N blocks" :
```bash
# Dans un AUTRE terminal, agrandir l'image
dd if=/dev/zero bs=512 count=200 >> /Users/rachid/jetson_backup/smarteye-dev-backup.img
```
Puis **quitter** gdisk (`q`) et le **relancer** (il doit recharger la nouvelle taille). Recommencer : 2 → x → e → m → w → Y.
### Verification finale
```bash
hdiutil attach -nomount -readonly /Users/rachid/jetson_backup/smarteye-dev-backup.img
```
GUID_partition_scheme avec ~14 partitions = image reparee. Flasher sur une carte SD et tester le boot.
---
## Fiabilite en production
!!! danger "Cartes SD : prototypage seulement"
Les cartes SD ont une duree de vie limitee en ecriture. Docker + YOLO = beaucoup d'ecritures. Pour une production a plusieurs centaines de clients :
- **SSD SATA via USB 3.0** pour le systeme (fiabilite industrielle)
- **Carte SD uniquement pour le boot initial** (lecture seule)
- **Backup automatique** des configs vers le serveur OVH
| Composant | Cout unitaire |
|-----------|:------------:|
| SD 32 Go (boot) | ~10 EUR |
| SSD 120 Go SATA | ~15 EUR |
| Adaptateur USB 3.0 → SATA | ~8 EUR |
| **Total par box** | **~33 EUR** |
---
## Etape suivante
→ [Installation chez le client](installation-client.md)

View File

@@ -0,0 +1,110 @@
# Configuration video des cameras
<div style="background: linear-gradient(135deg, #b71c1c 0%, #1565c0 100%); color: white; padding: 24px; border-radius: 12px; margin-bottom: 24px;">
<h2 style="margin:0; color: #ffcdd2;">Les 2 flux RTSP — Detection & Preuve HD</h2>
<p style="margin: 8px 0 0 0; opacity: 0.9;">Chaque camera diffuse 2 flux simultanes utilises pour des besoins differents par SmartEye.</p>
</div>
---
## Comprendre les 2 flux
Chaque camera IP diffuse **2 flux video RTSP** en parallele :
| | Flux 2 (SD) | Flux 1 (HD) |
|---|---|---|
| **Subtype** | `subtype=1` | `subtype=0` |
| **Resolution** | 800 x 448 | 2880 x 1620 |
| **Usage** | Detection YOLO 24/7 | Capture preuve en cas de chute |
| **Debit** | 300 kbps | 6144 kbps |
| **Quand** | En permanence | Uniquement lors d'une detection |
```mermaid
graph LR
CAM[Camera IP] -->|Flux 2 - SD| YOLO[Jetson YOLO]
YOLO -->|Chute detectee| CAPTURE[Capture HD]
CAM -->|Flux 1 - HD| CAPTURE
CAPTURE -->|POST image| LUCAS[Lucas api.php]
LUCAS -->|Gemini analyse| AVA[Push AVA]
```
!!! question "Pourquoi 2 flux ?"
**YOLO n'a pas besoin de HD** pour detecter une personne. Un flux leger (800x448) permet d'analyser plus de FPS avec moins de charge GPU. Par contre, **l'image de preuve doit etre nette** pour que Gemini puisse analyser finement et pour que la famille voie clairement la situation.
---
## Parametres recommandes
### Parametres generaux
| Parametre | Valeur |
|-----------|--------|
| Format video | **50Hz** |
| Codage video | **H.264 Main Profile** |
!!! warning "H.264 obligatoire"
Le H.265 n'est pas supporte par tous les lecteurs RTSP. Toujours utiliser H.264 Main Profile.
### Flux 1 — Preuve HD (subtype=0)
| Parametre | Valeur | Explication |
|-----------|--------|-------------|
| Resolution | **2880 x 1620** | Resolution max. Image nette pour Gemini et la famille |
| Debit binaire | **6144 kbps** | Debit max. Qualite optimale pour la capture |
| Frequence | **20 fps** | Suffisant pour un screenshot net |
| Intervalle image cle | **20** | 1 keyframe/seconde. Capture plus rapide |
| Controle debit | **CBR** | Debit constant. Qualite stable en mouvement |
| Qualite image | **1** (meilleure) | Valeur basse = qualite max |
### Flux 2 — Detection YOLO (subtype=1)
| Parametre | Valeur | Explication |
|-----------|--------|-------------|
| Resolution | **800 x 448** | YOLO detecte bien en SD. Economise le GPU |
| Debit binaire | **300 kbps** | 20x moins que le flux HD |
| Frequence | **15 fps** | Suffisant pour detecter un mouvement |
| Intervalle image cle | **30** | 1 keyframe toutes les 2 secondes |
| Controle debit | **VBR** | Debit variable. Economise la bande passante au repos |
| Qualite image | **1** (meilleure) | Meilleure qualite meme en SD |
---
## URLs RTSP
Format pour cameras Dahua :
=== "Flux 1 — HD (preuve)"
```
rtsp://admin:password@192.168.1.196/cam/realmonitor?channel=1&subtype=0
```
=== "Flux 2 — SD (detection)"
```
rtsp://admin:password@192.168.1.196/cam/realmonitor?channel=1&subtype=1
```
!!! info "Parametres URL"
- `channel` : numero de canal (1 pour camera mono-objectif)
- `subtype` : **0** = flux principal (HD), **1** = flux secondaire (SD)
---
## Procedure de configuration
1. Acceder a l'interface web de la camera (`http://IP_CAMERA`)
2. Aller dans **Parametres > Camera > Video**
3. Configurer le **Premier flux** (HD) selon le tableau ci-dessus
4. Configurer le **Deuxieme flux** (SD) selon le tableau ci-dessus
5. Cliquer **Appliquer**
6. Tester les 2 flux avec VLC : `vlc rtsp://admin:password@IP/cam/realmonitor?channel=1&subtype=1`
7. Verifier que SmartEye recoit le flux 2 et detecte correctement
---
## Voir aussi
- [Installation camera IP](installation-camera.md) — Ajouter une camera au reseau
- [Checklist installation](https://lucas.unigest.fr/checklist.php) — Suivi complet d'une installation
- [Configuration video interactive](https://lucas.unigest.fr/doc_camera.php) — Version web avec tableaux detailles

View File

@@ -0,0 +1,154 @@
# Installation d'une camera IP
<div style="background: linear-gradient(135deg, #1a237e 0%, #283593 100%); color: white; padding: 24px; border-radius: 12px; margin-bottom: 24px;">
<h2 style="margin:0; color: #82b1ff;">Guide technicien — Ajout d'une camera</h2>
<p style="margin: 8px 0 0 0; opacity: 0.9;">Configuration d'une camera IP via l'application mobile. Temps estime : 5 minutes par camera.</p>
</div>
---
## Pre-requis
- [ ] Smartphone avec l'application **CamHi** (ou equivalent) installee
- [ ] Camera IP allumee et en mode appairage (LED clignotante)
- [ ] Acces au reseau WiFi du client (SSID + mot de passe)
- [ ] Bluetooth active sur le smartphone
---
## Etape 1 — Trouver la camera
Ouvrir l'application et lancer la recherche. La camera est detectee automatiquement via **Bluetooth**.
![Recherche de la camera via Bluetooth](../assets/images/deploiement/trouver-camera.png){ width="280" }
1. Verifier que la camera est **allumee** et en mode appairage
2. La camera apparait avec son identifiant (ex: `SSAT-326256-ECCFB`)
3. Appuyer sur **Ajouter a**
!!! tip "La camera n'apparait pas ?"
- Verifier que le Bluetooth est active sur le smartphone
- Rapprocher le telephone de la camera (< 3 metres)
- Redemarrer la camera (debrancher/rebrancher)
---
## Etape 2 — Configurer le WiFi
La camera doit se connecter au **reseau WiFi du client** pour etre accessible par le Jetson.
![Configuration du reseau sans fil](../assets/images/deploiement/configuration-sans-fil.png){ width="280" }
1. Le **nom du WiFi** (SSID) est pre-rempli avec le reseau actuel du smartphone
2. Saisir le **mot de passe WiFi** du client
3. Appuyer sur **Configurer le sans fil et ajouter**
!!! warning "Important"
La camera et le Jetson doivent etre sur le **meme reseau**. Utiliser le WiFi principal du client, pas un reseau invite.
---
## Etape 3 — Nommer la camera
Donner un nom explicite a la camera pour l'identifier facilement.
![Attribution du nom a la camera](../assets/images/deploiement/mise-en-place-nom.png){ width="280" }
1. Saisir le **nom de la piece** ou la camera est installee
2. Utiliser les suggestions rapides si disponibles : `Salon`, `Chambre`, `Corridor`, `Hall`, etc.
3. Valider avec **OK**
!!! info "Convention de nommage"
Utiliser des noms simples et coherents : **Salon cuisine**, **Chambre**, **Terrasse**, **Camera fenetre**. Ces noms apparaitront dans LucasApp.
---
## Etape 4 — Changer le mot de passe
A la premiere connexion, l'application demande de **changer le mot de passe par defaut** de la camera.
![Prompt de changement de mot de passe](../assets/images/deploiement/changement-password.png){ width="280" }
1. Appuyer sur **Ok** pour accepter
2. Definir un **mot de passe securise** (minimum 8 caracteres)
3. **Noter le mot de passe** — il sera necessaire pour configurer le flux RTSP sur le Jetson
!!! danger "Ne pas ignorer cette etape"
Une camera avec le mot de passe par defaut est un risque de securite. Toujours changer le mot de passe.
---
## Etape 5 — Verifier l'installation
La camera apparait maintenant dans la liste des appareils.
![Liste des cameras installees](../assets/images/deploiement/mes-cameras.png){ width="280" }
Verifier que :
- [ ] La camera est **en ligne** (apercu video visible)
- [ ] Le **nom** est correct
- [ ] L'**icone cloud** indique la connectivite
---
## Etape 6 — Parametrer la camera
Acceder aux reglages de la camera pour ajuster les parametres.
![Parametres de la camera](../assets/images/deploiement/settings-chambre.png){ width="280" }
Parametres recommandes :
| Parametre | Reglage |
|-----------|---------|
| **Parametres video** | Resolution maximale, 15-20 FPS |
| **Parametres audio** | Activer le micro et le haut-parleur |
| **Gestion des alarmes** | Desactiver (c'est SmartEye qui gere la detection) |
| **Enregistrement carte TF** | Optionnel — activer si une carte SD est inseree |
| **Reglage de l'heure** | Verifier le fuseau horaire (Europe/Paris) |
---
## Etape 7 — Recuperer l'adresse IP
Aller dans **Information sur l'appareil** pour noter l'adresse IP de la camera.
![Informations reseau de la camera](../assets/images/deploiement/informations-appareil.jpeg){ width="280" }
| Information | Usage |
|-------------|-------|
| **Adresse IP** | Necessaire pour configurer le Jetson (ex: `192.168.1.196`) |
| **Type de reseau** | Verifier que c'est bien `WIFI` (ou Ethernet) |
| **Version du logiciel** | Utile pour le support technique |
!!! success "Camera prete"
L'adresse IP est la derniere information necessaire. Elle sera utilisee pour configurer le flux RTSP dans SmartEye.
---
## Recapitulatif
| Etape | Action | Duree |
|:-----:|--------|:-----:|
| 1 | Trouver la camera (Bluetooth) | 30s |
| 2 | Configurer le WiFi | 30s |
| 3 | Nommer la camera | 15s |
| 4 | Changer le mot de passe | 30s |
| 5 | Verifier la connexion | 15s |
| 6 | Parametrer (video, audio) | 1 min |
| 7 | Recuperer l'adresse IP | 15s |
---
## Prochaine etape
Une fois toutes les cameras installees et leurs IPs notees, passer a l'[installation du Jetson SmartEye](jetson.md) pour configurer la detection IA.
!!! info "Flux RTSP"
Le Jetson utilise les flux RTSP des cameras. L'URL standard est :
```
rtsp://<login>:<password>@<IP>:554/11 (flux HD)
rtsp://<login>:<password>@<IP>:554/12 (flux SD)
```
SmartEye utilise le flux SD (`/12`) pour la detection afin d'optimiser les performances.

View File

@@ -0,0 +1,262 @@
# Installation chez le client
<div style="background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%); color: white; padding: 32px; border-radius: 12px; margin-bottom: 24px; border: 1px solid rgba(0, 188, 212, 0.3); box-shadow: 0 0 30px rgba(0, 188, 212, 0.1);">
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 8px;">
<span style="font-size: 1.8em;">:material-shield-home:</span>
<h2 style="margin:0; color: #00e5ff; font-weight: 300; letter-spacing: 2px;">GUIDE TECHNICIEN TERRAIN</h2>
</div>
<p style="margin: 4px 0 0 0; opacity: 0.85; font-size: 0.95em;">Deploiement du systeme de surveillance intelligent Lucas. Duree estimee sur site : ~30 minutes.</p>
<div style="margin-top: 16px; padding-top: 12px; border-top: 1px solid rgba(255,255,255,0.15); display: flex; gap: 24px; font-size: 0.85em; opacity: 0.7;">
<span>:material-cctv: Surveillance</span>
<span>:material-brain: IA embarquee</span>
<span>:material-shield-check: Protection 24/7</span>
<span>:material-phone-alert: Alerte instantanee</span>
</div>
</div>
---
## Pre-requis
!!! info "Le compte client est deja cree"
Le compte est cree automatiquement lors de la souscription. Le technicien recoit par email :
- **Identifiant client** (`client_id`)
- **Mot de passe** (token d'authentification)
- **Numero de site** attribue
**Chez le client :**
- [ ] Acces Internet (fibre ou ADSL stable)
- [ ] Reseau WiFi pour les cameras (SSID + mot de passe)
- [ ] Prise Ethernet pour le Jetson (cable recommande)
- [ ] Prises electriques a proximite des emplacements cameras
---
## Le kit materiel
Voici ce que le technicien deballera chez le client :
### Jetson Orin Nano — Le cerveau SmartEye
![Jetson Orin Nano Developer Kit](../assets/images/jetson-orin-nano.jpg){ width="500" }
*NVIDIA Jetson Orin Nano — Le boitier d'intelligence artificielle embarquee.*
| Caracteristique | Valeur |
|----------------|--------|
| **Processeur** | 6-core Arm Cortex-A78AE |
| **GPU** | 1024-core NVIDIA Ampere |
| **Performance IA** | Jusqu'a 67 TOPS |
| **RAM** | 8 Go LPDDR5 |
| **Stockage** | Carte SD pre-flashee (image Gold) |
| **Alimentation** | USB-C (adaptateur fourni) |
### Cameras Ctronics PTZ — Les yeux du systeme
<!-- Ajouter les images dans docs/assets/images/ :
ctronics-camera-1.jpg (vue de face)
ctronics-camera-2.jpg (vue d'angle / exterieur)
Source : https://ctronics.com/collections/all ou Amazon -->
*Cameras Ctronics PTZ avec vision nocturne et audio bidirectionnel.*
| Caracteristique | Valeur |
|----------------|--------|
| **Resolution** | 2K / 4MP minimum |
| **PTZ** | 355 pan, 90 tilt |
| **Vision nocturne** | 30m couleur |
| **Audio** | Bidirectionnel (micro + haut-parleur) |
| **Protocole** | ONVIF, RTSP |
| **Connexion** | WiFi 2.4/5GHz ou Ethernet |
| **Flux RTSP** | HD = `/11`, SD = `/12` |
### Checklist depart
- [ ] Boitier Jetson SmartEye (carte SD deja inseree, clonee depuis la Gold)
- [ ] Alimentation USB-C
- [ ] Cable Ethernet (RJ45) — 2 metres minimum
- [ ] Cameras IP (Ctronics) — autant que prevu pour ce client
- [ ] Alimentations cameras (12V)
- [ ] **Fiche credentials** (ID + Token + N site — recus par email)
- [ ] Smartphone avec **LucasApp** installe
!!! tip "Carte SD"
La carte SD doit etre flashee depuis l'image Gold **avant** le deplacement.
Voir [Clonage des cartes SD](clonage.md) pour la procedure.
---
## Sur site (chez le client)
### Etape 1 — Installer les cameras
**Avant de toucher au Jetson**, positionner et brancher toutes les cameras.
**Regles de positionnement** :
| Regle | Detail |
|-------|--------|
| Hauteur | 2 a 2,5 metres du sol (au-dessus des portes) |
| Angle | Plongee a ~30, couvrir le maximum de sol |
| Zones cibles | Salon, chambre, couloir — la ou le senior passe le plus de temps |
| Eviter | Contre-jour (fenetre dans le champ), reflets de miroirs |
| Branchement | Chaque camera a son alimentation + connectee au **reseau du client** (WiFi ou Ethernet) |
!!! info "Combien de cameras ?"
Le systeme supporte jusqu'a **6 cameras par site**. Minimum recommande : 2 (salon + chambre).
**Verifier** que chaque camera est allumee et connectee au reseau du client avant de continuer.
---
### Etape 2 — Brancher et configurer le Jetson
#### Branchement physique
```
Box Internet du client
[ ::: ]
|
Cable Ethernet
|
[ Jetson SmartEye ] ← USB-C (alimentation)
```
1. Brancher le **cable Ethernet** entre le Jetson et la box internet
2. Brancher le **cable USB-C** (alimentation)
3. Attendre **1 minute** — la LED passe au vert fixe
#### Configuration depuis le smartphone
Se connecter au **meme WiFi** que le boitier, puis ouvrir dans le navigateur :
<div style="background: #263238; color: #80cbc4; padding: 16px; border-radius: 8px; font-size: 1.3em; text-align: center; font-family: monospace;">
http://smarteye.local:8080
</div>
<br/>
!!! tip "Ca ne marche pas ?"
**Solution 1** — Ouvrir LucasApp > Parametres > **Installer un SmartEye**
**Solution 2** — Scanner le **QR code** colle sous le boitier
#### Ecran 1/4 : Identification
Saisir les informations recues par email :
| Champ | Quoi mettre |
|-------|-------------|
| **ID Client** | L'identifiant recu par email |
| **Token** | Le mot de passe recu par email |
| **Nom du site** | Nom libre (ex: `Mme Dupont - Aleria`) |
#### Ecran 2/4 : Cameras
Le Jetson detecte les cameras automatiquement sur le reseau.
Pour chaque camera trouvee :
1. **Nommer la piece** (Salon, Chambre, Cuisine...)
2. Saisir les **identifiants** si demande (login/mot de passe de la camera)
3. Appuyer sur **Tester** — verifier que l'image s'affiche
!!! warning "Aucune camera detectee ?"
- Les cameras sont-elles allumees ?
- Sont-elles sur le **meme reseau** que le Jetson ?
- Essayer de redemarrer la camera
#### Ecran 3/4 : Connexion serveur
Les valeurs sont **pre-remplies**. Ne rien modifier sauf indication contraire.
| Parametre | Valeur par defaut |
|-----------|-------------------|
| Serveur | `57.128.74.87` |
| Utilisateur | `debian` |
| N de site | Automatique |
Appuyer sur **Tester la connexion** — doit afficher un resultat vert.
#### Ecran 4/4 : Test final
Le systeme verifie toute la chaine automatiquement :
| Etape | Verification | Resultat |
|:-----:|-------------|:--------:|
| 1 | Connexion aux cameras | :material-check: |
| 2 | Detection IA (YOLO) | :material-check: |
| 3 | Tunnel vers le serveur | :material-check: |
| 4 | Envoi d'une image test | :material-check: |
| 5 | Notification recue sur le telephone | :material-check: |
Si tout est vert : appuyer sur **Terminer**.
Le Jetson passe en mode surveillance active.
---
## Avant de partir
### Etape 3 — Test de bout en bout
**Test obligatoire** — ne pas quitter le domicile sans l'avoir fait.
1. **Simuler une chute** : se coucher au sol devant une camera, rester immobile 30 secondes
2. **Verifier** la notification sur le telephone du technicien (LucasApp)
3. **Verifier** que l'image recue est nette et bien cadree
4. **Verifier** le flux video en direct dans LucasApp (toutes les cameras)
5. **Tester l'interphone** : parler dans l'app, verifier que le son sort de la camera
---
### Etape 4 — Configurer les telephones des aidants
Sur le telephone de **chaque aidant** (fils, fille, aide-soignant...) :
1. **Installer LucasApp** (Play Store / APK)
2. **Ouvrir l'app** — elle s'enregistre automatiquement aupres du serveur
3. **Verifier les notifications** : elles doivent etre autorisees dans les reglages du telephone
4. **Montrer le fonctionnement** :
- Comment repondre a une alerte
- Comment voir les cameras en direct
- Comment utiliser l'interphone
- Expliquer que le numero d'urgence (15 par defaut) est accessible depuis l'alerte
!!! success "Installation terminee"
Le systeme est operationnel. Le Jetson fonctionne 24h/24, redemarre seul en cas de coupure de courant.
Aucune intervention de l'aidant n'est necessaire au quotidien.
---
## Checklist de depart
Avant de quitter le domicile, cocher chaque point :
- [ ] Toutes les cameras sont fixees et alimentees
- [ ] Le Jetson est branche (Ethernet + USB-C)
- [ ] La LED du Jetson clignote rapidement (surveillance active)
- [ ] Le test de chute simulee a fonctionne
- [ ] Le flux video est visible dans LucasApp
- [ ] L'interphone fonctionne dans les deux sens
- [ ] **Chaque aidant** a LucasApp installe et les notifications activees
- [ ] Le boitier est place dans un endroit discret et ventile
---
## Depannage rapide
| Symptome | Cause probable | Solution |
|----------|---------------|----------|
| LED eteinte | Pas d'alimentation | Verifier le cable USB-C et l'adaptateur |
| `smarteye.local` ne repond pas | mDNS non supporte | Utiliser le scan LucasApp ou le QR code |
| Camera non detectee | Reseau different | Verifier que la camera est sur le meme reseau |
| Test serveur echoue | Pas d'internet | Verifier la connexion internet de la box client |
| Pas de notification | Notifications desactivees | Verifier les reglages du telephone de l'aidant |
| Image floue ou sombre | Mauvais positionnement | Repositionner la camera (hauteur, angle, eclairage) |
| Intercom muet | Camera sans audio | Verifier que la camera supporte l'audio bidirectionnel |
!!! info "Probleme non resolu ?"
Consulter le [Guide expert](jetson-expert.md) pour un diagnostic technique approfondi.

View File

@@ -0,0 +1,380 @@
# Installation du Jetson SmartEye
## Philosophie : le Jetson est une Box
Le Jetson SmartEye se deploie comme une box internet (Free, Orange). L'installateur arrive chez le beneficiaire avec le boitier pre-configure, le branche sur le reseau local, et **tout se passe depuis son smartphone**. Aucun ecran, aucun clavier, aucun cable HDMI.
```mermaid
graph LR
subgraph Domicile["Domicile du beneficiaire"]
BOX["Jetson SmartEye"]
CAM1["Camera 1"]
CAM2["Camera 2"]
CAM3["Camera 3"]
ROUTER["Box Internet"]
BOX ---|Ethernet| ROUTER
CAM1 ---|WiFi/Ethernet| ROUTER
CAM2 ---|WiFi/Ethernet| ROUTER
CAM3 ---|WiFi/Ethernet| ROUTER
end
subgraph Install["Smartphone installateur"]
APP["LucasApp / Navigateur"]
end
ROUTER ---|WiFi| APP
APP -.->|"http://smarteye.local:8080"| BOX
```
## Materiel necessaire
| Element | Detail |
|---------|--------|
| **Jetson Orin Nano** | Avec SmartEye pre-installe sur carte SD |
| **Alimentation** | USB-C 5V/3A (fournie) |
| **Cable Ethernet** | RJ45, branchement sur la box internet du client |
| **Cameras IP** | CTronic ou compatible ONVIF, deja installees |
| **Smartphone** | Android ou iOS, connecte au meme reseau WiFi |
---
## Etape 1 : Branchement physique
1. **Brancher le cable Ethernet** du Jetson a la box internet du client
2. **Brancher l'alimentation USB-C** du Jetson
3. **Attendre 60 secondes** — le Jetson demarre et se connecte au reseau local
!!! info "LED de statut"
Le Jetson n'a pas d'ecran mais dispose d'une LED verte :
- **Clignotement lent** : demarrage en cours
- **Fixe** : connecte au reseau, pret pour la configuration
- **Clignotement rapide** : surveillance active, tout fonctionne
---
## Etape 2 : Decouverte sur le reseau local
Le Jetson annonce automatiquement sa presence sur le reseau local via **mDNS (Avahi/Bonjour)**. Depuis le smartphone connecte au meme WiFi :
### Option A : Acces direct (recommande)
Ouvrir un navigateur et aller a :
```
http://smarteye.local:8080
```
!!! tip "Si smarteye.local ne repond pas"
Certaines box internet bloquent le mDNS. Dans ce cas, utiliser l'option B.
### Option B : Scan reseau depuis LucasApp
1. Ouvrir **LucasApp** sur le smartphone
2. Aller dans **Parametres > Installer un SmartEye**
3. L'application scanne le reseau local (plage 192.168.x.x)
4. Le Jetson apparait dans la liste des appareils detectes
5. Appuyer dessus pour ouvrir l'interface de configuration
```mermaid
sequenceDiagram
participant Phone as Smartphone
participant Jetson as Jetson SmartEye
participant Router as Box Internet
Phone->>Router: Scan reseau local (UDP broadcast)
Router-->>Phone: Liste des appareils
Phone->>Jetson: GET /api/identity (port 8080)
Jetson-->>Phone: {"type": "smarteye", "version": "v30", "status": "setup"}
Phone->>Phone: Affiche "SmartEye detecte"
```
### Option C : QR Code sur le boitier
Un QR code colle sur le Jetson contient son adresse MAC. LucasApp peut scanner ce QR code pour identifier le bon appareil sur le reseau.
---
## Etape 3 : Interface de configuration (WebUI)
L'interface web du Jetson s'affiche sur le smartphone. L'installation se fait en **4 sous-etapes** guidees.
### 3.1 — Identification du site
| Champ | Description | Exemple |
|-------|-------------|---------|
| **ID Client** | Identifiant du beneficiaire (fourni par Lucas) | `dupont_marie` |
| **Token** | Jeton d'authentification (fourni par Lucas) | `b5ce5015` |
| **Nom du site** | Nom lisible | `Residence Dupont` |
!!! warning "Ou trouver l'ID et le Token ?"
L'ID client et le token sont generes lors de la creation du dossier dans l'interface d'administration Lucas ([admin.php](https://lucas.unigest.fr/admin.php)). Voir le [Guide nouveau client](../guide/nouveau-client.md).
### 3.2 — Detection automatique des cameras (ONVIF)
Le Jetson scanne le reseau local a la recherche de cameras compatibles ONVIF.
```
Scan en cours...
Camera detectee : 192.168.1.143 (CTronic - Salon)
Camera detectee : 192.168.1.148 (CTronic - Chambre)
Camera detectee : 192.168.1.152 (CTronic - Cuisine)
3 cameras trouvees.
```
Pour chaque camera detectee :
| Champ | Auto-detecte | Modifiable |
|-------|:------------:|:----------:|
| **Adresse IP** | Oui | Oui |
| **Nom** (piece) | Non | Oui |
| **Login RTSP** | Non | Oui |
| **Mot de passe RTSP** | Non | Oui |
| **Flux HD** (/11) | Oui | Oui |
| **Flux SD** (/12) | Oui | Oui |
| **Active** | Oui | Oui |
!!! tip "Verifier le flux en direct"
Appuyer sur **Tester** a cote de chaque camera pour afficher un apercu en direct sur le smartphone. Cela confirme que les identifiants RTSP sont corrects.
### 3.3 — Connexion au serveur Lucas
Le Jetson doit etablir une connexion securisee (tunnel SSH) vers le serveur OVH.
| Parametre | Valeur par defaut | Modifiable |
|-----------|-------------------|:----------:|
| **Serveur** | `57.128.74.87` | Oui |
| **Utilisateur SSH** | `debian` | Oui |
| **Domaine** | `lucas.unigest.fr` | Oui |
| **Numero de site** | Auto (prochain disponible) | Oui |
```mermaid
sequenceDiagram
participant Jetson
participant OVH as Serveur OVH
Jetson->>OVH: Test connexion SSH
OVH-->>Jetson: OK - Cle autorisee
Jetson->>OVH: Ouverture tunnels RTSP (ports 8560-8565)
Jetson->>OVH: Ouverture tunnel Audio (port 8566)
Jetson->>OVH: POST api.php (test image)
OVH-->>Jetson: 200 OK - Client reconnu
Jetson-->>Jetson: Connexion validee
```
!!! note "Attribution automatique des ports"
Le Jetson calcule automatiquement sa plage de ports selon la convention :
```
Site N → base_port = 8550 + (N-1) x 10
Ports attribues :
base+0 a base+5 : RTSP cameras (max 6)
base+6 : Audio WebSocket
base+7 : WebUI locale
base+8 a base+9 : Reserves
```
Exemple pour le site n°2 : ports 8560 a 8569.
### 3.4 — Test de bout en bout
Le Jetson effectue un test complet de la chaine :
```
[1/5] Connexion cameras RTSP............ OK
[2/5] Detection IA (YOLO)............... OK (personne detectee)
[3/5] Tunnel SSH vers OVH............... OK
[4/5] Envoi image test a api.php........ OK (Gemini: "RAS")
[5/5] Notification push Firebase........ OK (recue sur LucasApp)
=== INSTALLATION TERMINEE ===
SmartEye est operationnel.
La surveillance demarre automatiquement.
```
!!! success "Installation terminee"
Une fois le test valide, le Jetson passe en mode **surveillance active**. Il demarre automatiquement a chaque mise sous tension, sans aucune intervention.
---
## Etape 4 : Verification depuis LucasApp
Sur le smartphone de chaque aidant :
1. Ouvrir **LucasApp**
2. Verifier que le flux video en direct est accessible
3. Verifier que l'interphone fonctionne (audio bidirectionnel)
4. Effectuer un **test de chute simule** :
- Se coucher au sol dans le champ d'une camera
- Attendre ~30 secondes
- Verifier la reception de la notification push
- Verifier l'image et le message Gemini dans l'alerte
---
## Architecture reseau complete
```mermaid
graph TB
subgraph Domicile["Domicile du beneficiaire"]
CAM1["Camera 1<br/>192.168.1.143"]
CAM2["Camera 2<br/>192.168.1.148"]
CAM3["Camera 3<br/>192.168.1.152"]
JETSON["Jetson SmartEye<br/>192.168.1.xxx"]
ROUTER["Box Internet<br/>192.168.1.1"]
CAM1 -->|RTSP local| JETSON
CAM2 -->|RTSP local| JETSON
CAM3 -->|RTSP local| JETSON
JETSON ---|Ethernet| ROUTER
end
subgraph OVH["Serveur OVH (57.128.74.87)"]
SSH["SSH Gateway"]
RTSP_RELAY["Relay RTSP<br/>ports 8560-8565"]
AUDIO["Audio WebSocket<br/>port 8566"]
API["api.php"]
GEMINI["Gemini 2.5 Flash"]
FCM["Firebase Cloud Messaging"]
end
JETSON ==>|"SSH Tunnel (autossh)<br/>Persistant, auto-reconnect"| SSH
SSH --> RTSP_RELAY
SSH --> AUDIO
JETSON ==>|"POST image<br/>si chute detectee"| API
API --> GEMINI
GEMINI --> FCM
subgraph Aidants["Smartphones aidants"]
APP1["LucasApp - Fille"]
APP2["LucasApp - Fils"]
end
FCM ==>|Push notification| APP1
FCM ==>|Push notification| APP2
RTSP_RELAY -.->|"Flux video live"| APP1
AUDIO -.->|"Interphone"| APP1
```
---
## Fichier de configuration genere
A la fin de l'installation, le Jetson cree automatiquement le fichier `smarteye_config.yaml` :
```yaml
# Genere automatiquement par l'assistant d'installation
# Ne pas modifier manuellement sauf si necessaire
site:
id: "dupont_marie"
name: "Residence Dupont"
client_token: "b5ce5015"
server:
api_url: "https://lucas.unigest.fr/api.php"
ovh_ip: "57.128.74.87"
ssh_user: "debian"
ports:
site_number: 2
base_port: 8560
cameras:
- id: "Cam1"
name: "Salon"
ip: "192.168.1.143"
username: "admin"
password: "********"
stream_hd: "/11"
stream_sd: "/12"
tunnel_port: 8560
enabled: true
- id: "Cam2"
name: "Chambre"
ip: "192.168.1.148"
username: "admin"
password: "********"
stream_hd: "/11"
stream_sd: "/12"
tunnel_port: 8561
enabled: true
- id: "Cam3"
name: "Cuisine"
ip: "192.168.1.152"
username: "admin"
password: "********"
stream_hd: "/11"
stream_sd: "/12"
tunnel_port: 8562
enabled: true
audio:
local_port: 8800
tunnel_port: 8566
detection:
yolo:
std_confidence: 0.15
pose_confidence: 0.25
alert_interval: 120
```
---
## Maintenance et acces distant
Une fois installe, le Jetson est accessible a distance depuis le serveur OVH via le tunnel SSH inverse :
```bash
# Depuis le serveur OVH, acceder au WebUI du Jetson
# (tunnel inverse sur le port base+7)
curl http://localhost:8567/api/status
```
### Commandes utiles (pour le support technique)
| Action | Commande |
|--------|----------|
| **Statut du Jetson** | `curl http://localhost:<base+7>/api/status` |
| **Redemarrer SmartEye** | `ssh -p <tunnel> jetson "sudo systemctl restart smarteye"` |
| **Voir les logs** | `ssh -p <tunnel> jetson "journalctl -u smarteye -f"` |
| **Mettre a jour** | `ssh -p <tunnel> jetson "cd /w && git pull && sudo systemctl restart smarteye"` |
!!! warning "Resilience reseau"
Le service `autossh` maintient les tunnels SSH en permanence. En cas de coupure internet :
- Les tunnels se retablissent automatiquement a la reconnexion
- SmartEye continue la detection localement meme sans internet
- Les alertes en attente sont envoyees des que la connexion revient
---
## Resume du flux d'installation
```mermaid
graph TD
A["Branchement Ethernet + USB-C"] --> B["Demarrage automatique (60s)"]
B --> C["Annonce mDNS sur le reseau local"]
C --> D{"Smartphone detecte le Jetson"}
D -->|"smarteye.local:8080"| E["WebUI de configuration"]
D -->|"LucasApp scan"| E
D -->|"QR Code"| E
E --> F["Saisie ID client + Token"]
F --> G["Scan ONVIF des cameras"]
G --> H["Configuration des cameras"]
H --> I["Connexion SSH vers OVH"]
I --> J["Test de bout en bout"]
J -->|"Tout OK"| K["Surveillance active"]
J -->|"Echec"| L["Diagnostic et correction"]
L --> J
style A fill:#455a64,color:#fff
style K fill:#00695c,color:#fff
style L fill:#bf360c,color:#fff
```

View File

@@ -0,0 +1,165 @@
# Installation SmartEye - Guide simplifie
<div style="background: linear-gradient(135deg, #00695c 0%, #004d40 100%); color: white; padding: 24px; border-radius: 12px; margin-bottom: 24px;">
<h2 style="margin:0; color: white;">Guide d'installation rapide</h2>
<p style="margin: 8px 0 0 0; opacity: 0.9;">Tout se fait depuis votre smartphone. Temps estime : 10 minutes.</p>
</div>
---
## Ce dont vous avez besoin
| | Element | Fourni |
|:-:|---------|:------:|
| 1 | **Boitier Jetson SmartEye** (avec carte SD) | Oui |
| 2 | **Alimentation USB-C** | Oui |
| 3 | **Cable Ethernet (RJ45)** | Oui |
| 4 | **Cameras** deja installees chez le client | - |
| 5 | **Votre smartphone** connecte au WiFi du client | - |
| 6 | **Fiche client** (ID + Token, fournis par l'admin) | Oui |
---
## Etape 1 — Brancher le boitier
```
Box Internet du client
[ ::: ]
|
Cable Ethernet
|
[ Jetson SmartEye ] ← USB-C (alimentation)
```
1. Brancher le **cable Ethernet** entre le Jetson et la box internet
2. Brancher le **cable USB-C** (alimentation)
3. Attendre **1 minute** que la LED passe au vert fixe
| LED | Signification |
|:---:|---------------|
| Clignotement lent | Demarrage en cours — patientez |
| **Vert fixe** | Pret — passez a l'etape 2 |
| Clignotement rapide | Surveillance active (tout roule) |
---
## Etape 2 — Se connecter au Jetson
Assurez-vous que votre smartphone est connecte **au meme WiFi** que le boitier.
Ouvrez votre navigateur et tapez :
<div style="background: #263238; color: #80cbc4; padding: 16px; border-radius: 8px; font-size: 1.3em; text-align: center; font-family: monospace;">
http://smarteye.local:8080
</div>
<br/>
!!! tip "Ca ne marche pas ?"
**Solution 1** — Ouvrir LucasApp > Parametres > **Installer un SmartEye** (scan automatique)
**Solution 2** — Scanner le **QR code** colle sous le boitier avec LucasApp
---
## Etape 3 — Configurer (4 ecrans)
### Ecran 1/4 : Identification
Saisir les informations de la **fiche client** :
| Champ | Quoi mettre | Exemple |
|-------|-------------|---------|
| **ID Client** | Sur la fiche | `dupont_marie` |
| **Token** | Sur la fiche | `b5ce5015` |
| **Nom du site** | Nom libre | `Mme Dupont - Aleria` |
Appuyer sur **Suivant →**
---
### Ecran 2/4 : Cameras
Le Jetson scanne et trouve les cameras automatiquement.
```
✓ Camera 1 — 192.168.1.143
✓ Camera 2 — 192.168.1.148
✓ Camera 3 — 192.168.1.152
```
Pour chaque camera :
1. **Nommer la piece** (Salon, Chambre, Cuisine...)
2. Saisir les **identifiants RTSP** si demande (login/mot de passe camera)
3. Appuyer sur **Tester** pour verifier l'image en direct
!!! warning "Aucune camera detectee ?"
Verifier que les cameras sont allumees et connectees au meme reseau (WiFi ou Ethernet).
Appuyer sur **Suivant →**
---
### Ecran 3/4 : Connexion serveur
Les valeurs par defaut sont pre-remplies. **Ne rien modifier** sauf indication contraire.
| Parametre | Valeur par defaut |
|-----------|-------------------|
| Serveur | `57.128.74.87` |
| Utilisateur | `debian` |
| Domaine | `lucas.unigest.fr` |
| N° de site | Automatique |
Appuyer sur **Tester la connexion**, puis **Suivant →**
---
### Ecran 4/4 : Test final
Le Jetson verifie toute la chaine automatiquement :
| Etape | Quoi | Resultat attendu |
|:-----:|------|:-----------------:|
| 1 | Connexion cameras | ✓ |
| 2 | Detection IA | ✓ |
| 3 | Tunnel vers serveur | ✓ |
| 4 | Envoi image test | ✓ |
| 5 | Notification sur telephone | ✓ |
Si tout est vert : appuyer sur **Terminer**.
!!! success "C'est fini"
Le Jetson passe en surveillance. Il redemarre seul en cas de coupure de courant. Rien d'autre a faire.
---
## Etape 4 — Verifier sur LucasApp
Sur le telephone de **chaque aidant** :
- [ ] Ouvrir LucasApp → verifier le flux video en direct
- [ ] Tester l'interphone (parler / ecouter)
- [ ] **Simuler une chute** : se coucher au sol devant une camera, attendre 30 secondes, verifier que la notification arrive
---
## En cas de probleme
| Symptome | Solution |
|----------|----------|
| LED ne s'allume pas | Verifier l'alimentation USB-C |
| `smarteye.local` ne repond pas | Utiliser le scan LucasApp ou le QR code |
| Camera non detectee | Verifier qu'elle est sur le meme reseau |
| Test serveur echoue | Verifier la connexion internet du client |
| Pas de notification | Verifier que LucasApp est installe et les notifications activees |
!!! info "Besoin d'aide ?"
Contacter le support technique ou consulter le [Guide expert](jetson-expert.md) pour un diagnostic approfondi.
---
<div style="background: #e8f5e9; padding: 16px; border-radius: 8px; border-left: 4px solid #2e7d32; color: #1b5e20;">
<strong>Recapitulatif</strong> : Brancher → Se connecter → 4 ecrans de config → Verifier sur LucasApp. C'est tout.
</div>

View File

@@ -0,0 +1,3 @@
# Gestion des alertes
Section en cours de rédaction. Cette page décrira le cycle de vie des alertes, de leur déclenchement à leur résolution.

View File

@@ -0,0 +1,3 @@
# Dashboard de surveillance
Section en cours de rédaction. Cette page décrira le tableau de bord de surveillance en temps réel et ses différentes fonctionnalités.

View File

@@ -0,0 +1,66 @@
# Ajouter un nouveau client
## 1. Accéder à l'administration
Rendez-vous sur **[lucas.unigest.fr/admin.php](https://lucas.unigest.fr/admin.php)** et entrez le code d'accès.
![Login SmartEye](../assets/admin-login.png){ width="400" }
## 2. Créer le dossier
Cliquez sur **+ Nouveau Dossier** en haut à droite.
### Fiche bénéficiaire
Remplissez les informations de la personne surveillée :
| Champ | Description | Obligatoire | Exemple |
|-------|-------------|:-----------:|---------|
| **ID Système** | Identifiant unique (minuscules, sans espaces) | :material-check: | `dupont_marie` |
| **Nom & Prénom** | Nom complet du bénéficiaire | :material-check: | `Marie Dupont` |
| **Age** | Age du bénéficiaire | | `87` |
| **Sexe** | F ou H | | `F` |
| **Adresse** | Adresse d'installation des caméras | | `12 rue des Roses` |
| **Ville** | Ville / Code postal | | `20270 Aléria` |
| **Mobile** | Numéro principal (format international) | :material-check: | `+33612345678` |
| **Fixe** | Numéro fixe du domicile | | `+33495123456` |
!!! tip "Convention de nommage"
L'ID système sera utilisé partout : dossier images, configuration SmartEye, base de données.
Choisissez un ID court et descriptif : `nom_prenom` ou `nom_ville`.
## 3. Ajouter les contacts d'urgence
Cliquez sur **+ Ajouter Contact** pour chaque personne à prévenir en cas de chute.
| Champ | Description | Exemple |
|-------|-------------|---------|
| **Nom** | Prénom du contact | Jean |
| **Rôle** | Lien avec le bénéficiaire | Fils |
| **Téléphone** | Numéro mobile (format international) | +33698765432 |
| **Email** | Email (optionnel) | jean@email.fr |
!!! warning "Ordre des contacts"
Les contacts sont alertés **dans l'ordre de la liste**. Placez le contact principal en premier.
### Exemple de réseau de contacts typique
```
1. Fille (proche géographiquement) → Premier appel
2. Fils (disponible en journée) → Deuxième appel
3. Voisin (à côté, peut intervenir) → Troisième appel
4. Médecin traitant → Notification email
```
## 4. Enregistrer
Cliquez sur **:material-content-save: Enregistrer la Fiche**. Le client apparaît dans le tableau principal avec le statut **Surveillance Active**.
## 5. Étapes suivantes
Après la création du client dans Lucas, il faut :
- [ ] **Installer les caméras** chez le bénéficiaire → [Guide déploiement](../deploiement/jetson.md)
- [ ] **Configurer SmartEye** avec l'ID client → [Installation client](../deploiement/installation-client.md)
- [ ] **Installer LucasApp** sur le téléphone des contacts → [Guide LucasApp](dashboard.md)
- [ ] **Tester une chute simulée** pour valider la chaîne complète

View File

@@ -0,0 +1,60 @@
# Lucas — Système Intelligent de Protection à Domicile
**Surveillance IA · Détection de chutes · Alerte instantanée · Protection 24/7**
---
## Comment ça marche ?
```mermaid
graph LR
A[Caméras IP] -->|RTSP| B[Jetson Orin Nano]
B -->|Détection YOLO| C{Chute ?}
C -->|Image| D[Serveur Lucas]
D -->|Gemini AI| E{Confirmée ?}
E -->|Oui| F[Alerte Firebase]
F -->|Push| G[LucasApp Mobile]
E -->|Non| H[Fausse alerte filtrée]
```
## Les 3 composants
| Composant | Rôle | Technologie |
|-----------|------|-------------|
| :material-cctv: **SmartEye** | Détection de chutes en temps réel | YOLO + Jetson Orin Nano |
| :material-server: **Lucas** | Backend IA, alertes, gestion clients | PHP + Python + Gemini |
| :material-cellphone: **LucasApp** | Réception alertes, flux live, interphone | Flutter (Android) |
## Démarrage rapide
<div class="grid cards" markdown>
- :material-account-plus:{ .lg .middle } **Ajouter un client**
---
Créer une fiche bénéficiaire et ses contacts d'urgence
[:octicons-arrow-right-24: Guide nouveau client](guide/nouveau-client.md)
- :material-server-network:{ .lg .middle } **Déployer un site**
---
Installer les caméras, le Jetson et configurer le réseau
[:octicons-arrow-right-24: Guide déploiement](deploiement/jetson.md)
- :material-api:{ .lg .middle } **API & Intégration**
---
Endpoints, payloads FCM et contrats entre composants
[:octicons-arrow-right-24: Documentation API](api/endpoints.md)
</div>
!!! warning "Philosophie de sécurité"
**Mieux vaut 10 fausses alertes filtrées par Gemini qu'une seule chute ignorée.**
YOLO ratisse large, Gemini confirme. En cas de doute, l'alerte part toujours.

View File

@@ -0,0 +1,57 @@
# Checklist Installation
<div style="background: linear-gradient(135deg, #1b5e20 0%, #2e7d32 100%); color: white; padding: 24px; border-radius: 12px; margin-bottom: 24px;">
<h2 style="margin:0; color: #a5d6a7;">Suivi d'installation client</h2>
<p style="margin: 8px 0 0 0; opacity: 0.9;">Outil interactif pour suivre chaque etape du deploiement chez un client — du bureau jusqu'a la mise en production.</p>
</div>
---
## Acceder a la checklist
:material-open-in-new: **[Ouvrir la checklist interactive](https://lucas.unigest.fr/checklist.php)**
---
## Les 6 phases d'installation
La checklist couvre le parcours complet du technicien :
| Phase | Description | Responsable principal |
|:-----:|-------------|:---------------------:|
| 1 | **Au bureau** — Creer le dossier client, preparer le Jetson et les cameras | :material-server: Lucas + :material-eye: SmartEye |
| 2 | **Sur place : Materiel** — Brancher, connecter, positionner | :material-eye: SmartEye |
| 3 | **Configuration AVA** — Enregistrer le compte, scanner les cameras | :material-cellphone: AVA |
| 4 | **Tunnels SSH** — Cle SSH, autossh, verification ports | :material-eye: SmartEye + :material-server: Lucas |
| 5 | **Tests complets** — Camera live, simulation chute, notifications | :material-check-all: Tous |
| 6 | **Mise en production** — Activation, formation famille, PV recette | :material-check-all: Tous |
---
## Fonctionnalites
- **2 cases par tache** : Fait + Teste
- **Selecteur client** : gerer plusieurs installations en parallele
- **Barre de progression** : vue d'ensemble de l'avancement
- **Notes libres** : documenter les particularites de chaque installation
- **Sauvegarde locale** : les donnees persistent dans le navigateur (localStorage)
- **Lien vers les guides** : chaque etape technique renvoie vers la documentation detaillee
---
## 3 systemes, 3 couleurs
| Couleur | Systeme | Role |
|---------|---------|------|
| :material-circle:{ style="color: #f59e0b" } Orange | **SmartEye** | Jetson + cameras + detection YOLO |
| :material-circle:{ style="color: #10b981" } Vert | **Lucas** | Serveur OVH + admin + API + Gemini |
| :material-circle:{ style="color: #6366f1" } Violet | **AVA** | Application mobile famille |
---
## Voir aussi
- [Configuration video camera](../deploiement/config-video-camera.md)
- [Installation camera IP](../deploiement/installation-camera.md)
- [Installation Jetson](../deploiement/jetson.md)
- [Guide nouveau client](../guide/nouveau-client.md)

View File

@@ -0,0 +1,72 @@
site_name: Lucas Documentation
site_description: Système intelligent de protection à domicile — Détection de chutes par IA
site_url: https://lucas.unigest.fr/docs
theme:
name: material
language: fr
palette:
- scheme: slate
primary: deep purple
accent: cyan
toggle:
icon: material/brightness-4
name: Mode clair
- scheme: default
primary: deep purple
accent: cyan
toggle:
icon: material/brightness-7
name: Mode sombre
font:
text: Inter
code: JetBrains Mono
icon:
logo: material/shield-home
features:
- navigation.tabs
- navigation.sections
- navigation.expand
- navigation.top
- search.highlight
- content.code.copy
- content.tabs.link
markdown_extensions:
- admonition
- pymdownx.details
- pymdownx.superfences:
custom_fences:
- name: mermaid
class: mermaid
format: !!python/name:pymdownx.superfences.fence_code_format
- pymdownx.tabbed:
alternate_style: true
- pymdownx.emoji:
emoji_index: !!python/name:material.extensions.emoji.twemoji
emoji_generator: !!python/name:material.extensions.emoji.to_svg
- tables
- attr_list
nav:
- Accueil: index.md
- Architecture:
- Vue d'ensemble: architecture/overview.md
- Flux de données: architecture/data-flow.md
- Guide Utilisateur:
- Nouveau client: guide/nouveau-client.md
- Dashboard: guide/dashboard.md
- Gestion des alertes: guide/alertes.md
- Déploiement:
- Installation caméra IP: deploiement/installation-camera.md
- Configuration vidéo caméra: deploiement/config-video-camera.md
- Installation simplifiée: deploiement/jetson.md
- Installation expert: deploiement/jetson-expert.md
- Préparer la carte Gold: deploiement/carte-gold.md
- Clonage des cartes SD: deploiement/clonage.md
- Installation client: deploiement/installation-client.md
- Outils:
- Checklist installation: outils/checklist.md
- API:
- Endpoints: api/endpoints.md
- Payload FCM: api/fcm-payload.md

415
mkdocs-smarteye/upload.php Normal file
View File

@@ -0,0 +1,415 @@
<?php
/**
* Upload d'images pour la documentation MkDocs SmartEye
* Supporte : drag & drop, sélection de fichier, collage (Cmd+V / Ctrl+V)
*
* Accès : https://lucas.unigest.fr/docs-upload/upload.php
*/
// Sécurité basique — à adapter si besoin
$UPLOAD_DIR = __DIR__ . '/docs/assets/images/';
$ALLOWED_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'];
$MAX_SIZE = 10 * 1024 * 1024; // 10 MB
// Sous-dossiers disponibles
$SUBDIRS = ['general', 'architecture', 'guide', 'deploiement', 'api', 'app'];
// Traitement de l'upload
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['image'])) {
header('Content-Type: application/json');
$file = $_FILES['image'];
$subdir = isset($_POST['subdir']) && in_array($_POST['subdir'], $SUBDIRS) ? $_POST['subdir'] : 'general';
$targetDir = $UPLOAD_DIR . $subdir . '/';
// Vérifications
if ($file['error'] !== UPLOAD_ERR_OK) {
echo json_encode(['success' => false, 'error' => 'Erreur upload: ' . $file['error']]);
exit;
}
if ($file['size'] > $MAX_SIZE) {
echo json_encode(['success' => false, 'error' => 'Fichier trop volumineux (max 10 MB)']);
exit;
}
// Déterminer l'extension
$originalName = $file['name'];
$ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
// Si c'est un collage (blob), détecter le type MIME
if (empty($ext) || $ext === 'blob') {
$mime = mime_content_type($file['tmp_name']);
$mimeMap = [
'image/png' => 'png',
'image/jpeg' => 'jpg',
'image/gif' => 'gif',
'image/webp' => 'webp',
'image/svg+xml' => 'svg',
];
$ext = $mimeMap[$mime] ?? '';
}
if (!in_array($ext, $ALLOWED_EXTENSIONS)) {
echo json_encode(['success' => false, 'error' => "Extension non autorisée: $ext"]);
exit;
}
// Nom du fichier : soit le nom custom, soit le nom original, soit un timestamp
$customName = isset($_POST['filename']) ? trim($_POST['filename']) : '';
if ($customName) {
// Nettoyer le nom
$customName = preg_replace('/[^a-zA-Z0-9_\-]/', '-', $customName);
$filename = $customName . '.' . $ext;
} elseif ($originalName && $originalName !== 'blob' && $originalName !== 'image.png') {
$filename = preg_replace('/[^a-zA-Z0-9_\-\.]/', '-', $originalName);
} else {
$filename = date('Y-m-d_H-i-s') . '.' . $ext;
}
$targetPath = $targetDir . $filename;
// Éviter l'écrasement
if (file_exists($targetPath)) {
$base = pathinfo($filename, PATHINFO_FILENAME);
$filename = $base . '_' . time() . '.' . $ext;
$targetPath = $targetDir . $filename;
}
if (move_uploaded_file($file['tmp_name'], $targetPath)) {
$mdPath = "assets/images/$subdir/$filename";
echo json_encode([
'success' => true,
'filename' => $filename,
'subdir' => $subdir,
'markdown' => "![Description]($mdPath)",
'path' => $mdPath,
]);
} else {
echo json_encode(['success' => false, 'error' => 'Impossible de déplacer le fichier']);
}
exit;
}
// Lister les images existantes
$existingImages = [];
foreach ($SUBDIRS as $sd) {
$dir = $UPLOAD_DIR . $sd . '/';
if (is_dir($dir)) {
$files = scandir($dir);
foreach ($files as $f) {
if ($f === '.' || $f === '..') continue;
$ext = strtolower(pathinfo($f, PATHINFO_EXTENSION));
if (in_array($ext, $ALLOWED_EXTENSIONS)) {
$existingImages[] = [
'name' => $f,
'subdir' => $sd,
'path' => "assets/images/$sd/$f",
'url' => "docs/assets/images/$sd/$f",
'size' => filesize($dir . $f),
];
}
}
}
}
// Image à la racine de images/
$rootDir = $UPLOAD_DIR;
if (is_dir($rootDir)) {
$files = scandir($rootDir);
foreach ($files as $f) {
if ($f === '.' || $f === '..' || is_dir($rootDir . $f)) continue;
$ext = strtolower(pathinfo($f, PATHINFO_EXTENSION));
if (in_array($ext, $ALLOWED_EXTENSIONS)) {
$existingImages[] = [
'name' => $f,
'subdir' => '(racine)',
'path' => "assets/images/$f",
'url' => "docs/assets/images/$f",
'size' => filesize($rootDir . $f),
];
}
}
}
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Upload Images — Documentation SmartEye</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Inter', -apple-system, sans-serif;
background: #1a1a2e;
color: #e0e0e0;
min-height: 100vh;
padding: 2rem;
}
h1 { color: #bb86fc; margin-bottom: 0.5rem; }
.subtitle { color: #888; margin-bottom: 2rem; }
.upload-zone {
border: 3px dashed #bb86fc44;
border-radius: 16px;
padding: 3rem 2rem;
text-align: center;
transition: all 0.3s;
cursor: pointer;
margin-bottom: 2rem;
background: #16213e;
}
.upload-zone:hover, .upload-zone.dragover {
border-color: #bb86fc;
background: #1a1a3e;
transform: scale(1.01);
}
.upload-zone .icon { font-size: 3rem; margin-bottom: 1rem; }
.upload-zone p { color: #aaa; }
.upload-zone .hint { font-size: 0.85rem; color: #666; margin-top: 0.5rem; }
.controls {
display: flex;
gap: 1rem;
margin-bottom: 2rem;
flex-wrap: wrap;
align-items: center;
}
select, input[type="text"] {
padding: 0.6rem 1rem;
border-radius: 8px;
border: 1px solid #333;
background: #16213e;
color: #e0e0e0;
font-size: 0.95rem;
}
select:focus, input:focus { outline: none; border-color: #bb86fc; }
input[type="text"] { flex: 1; min-width: 200px; }
.result {
display: none;
background: #0f3460;
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 2rem;
}
.result.show { display: block; }
.result.error { background: #3d0000; }
.result .md-code {
background: #1a1a2e;
padding: 0.8rem 1rem;
border-radius: 8px;
font-family: 'JetBrains Mono', monospace;
font-size: 0.9rem;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0.5rem;
}
.result .md-code:hover { background: #222244; }
.result .md-code .copy-btn {
background: #bb86fc;
color: #000;
border: none;
padding: 0.3rem 0.8rem;
border-radius: 6px;
cursor: pointer;
font-size: 0.8rem;
}
.preview-img {
max-width: 300px;
max-height: 200px;
border-radius: 8px;
margin-top: 1rem;
}
h2 { color: #bb86fc; margin: 2rem 0 1rem; }
.gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
}
.gallery-item {
background: #16213e;
border-radius: 12px;
padding: 0.8rem;
text-align: center;
}
.gallery-item img {
max-width: 100%;
max-height: 150px;
border-radius: 8px;
object-fit: contain;
}
.gallery-item .name {
font-size: 0.8rem;
color: #aaa;
margin-top: 0.5rem;
word-break: break-all;
}
.gallery-item .badge {
display: inline-block;
background: #bb86fc22;
color: #bb86fc;
padding: 0.15rem 0.5rem;
border-radius: 4px;
font-size: 0.7rem;
margin-top: 0.3rem;
}
.gallery-item .md-snippet {
font-size: 0.7rem;
color: #666;
cursor: pointer;
margin-top: 0.3rem;
}
.gallery-item .md-snippet:hover { color: #bb86fc; }
.empty { color: #555; text-align: center; padding: 2rem; }
.toast {
position: fixed;
bottom: 2rem;
right: 2rem;
background: #bb86fc;
color: #000;
padding: 0.8rem 1.5rem;
border-radius: 8px;
font-weight: 600;
transform: translateY(100px);
opacity: 0;
transition: all 0.3s;
}
.toast.show { transform: translateY(0); opacity: 1; }
</style>
</head>
<body>
<h1>Upload Images</h1>
<p class="subtitle">Documentation SmartEye — Glissez, cliquez ou collez (Cmd+V) une image</p>
<div class="controls">
<select id="subdir">
<?php foreach ($SUBDIRS as $sd): ?>
<option value="<?= $sd ?>"><?= ucfirst($sd) ?></option>
<?php endforeach; ?>
</select>
<input type="text" id="filename" placeholder="Nom du fichier (optionnel, sans extension)">
</div>
<div class="upload-zone" id="dropzone">
<div class="icon">📸</div>
<p><strong>Glissez une image ici</strong> ou cliquez pour sélectionner</p>
<p class="hint">Cmd+V pour coller depuis le presse-papiers &bull; JPG, PNG, GIF, WebP, SVG &bull; Max 10 MB</p>
<input type="file" id="fileInput" accept="image/*" style="display:none">
</div>
<div class="result" id="result"></div>
<h2>Images existantes (<?= count($existingImages) ?>)</h2>
<?php if (empty($existingImages)): ?>
<p class="empty">Aucune image pour l'instant.</p>
<?php else: ?>
<div class="gallery">
<?php foreach ($existingImages as $img): ?>
<div class="gallery-item">
<?php if (in_array(pathinfo($img['name'], PATHINFO_EXTENSION), ['svg'])): ?>
<img src="<?= htmlspecialchars($img['url']) ?>" alt="<?= htmlspecialchars($img['name']) ?>">
<?php else: ?>
<img src="<?= htmlspecialchars($img['url']) ?>" alt="<?= htmlspecialchars($img['name']) ?>" loading="lazy">
<?php endif; ?>
<div class="name"><?= htmlspecialchars($img['name']) ?></div>
<span class="badge"><?= htmlspecialchars($img['subdir']) ?></span>
<div class="md-snippet" onclick="copyText('![](<?= htmlspecialchars($img['path']) ?>)')">
Copier le markdown
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="toast" id="toast">Copié !</div>
<script>
const dropzone = document.getElementById('dropzone');
const fileInput = document.getElementById('fileInput');
const resultDiv = document.getElementById('result');
// Click to select
dropzone.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', () => {
if (fileInput.files[0]) uploadFile(fileInput.files[0]);
});
// Drag & drop
dropzone.addEventListener('dragover', (e) => {
e.preventDefault();
dropzone.classList.add('dragover');
});
dropzone.addEventListener('dragleave', () => dropzone.classList.remove('dragover'));
dropzone.addEventListener('drop', (e) => {
e.preventDefault();
dropzone.classList.remove('dragover');
const file = e.dataTransfer.files[0];
if (file) uploadFile(file);
});
// Paste (Cmd+V)
document.addEventListener('paste', (e) => {
const items = e.clipboardData.items;
for (let item of items) {
if (item.type.startsWith('image/')) {
const file = item.getAsFile();
if (file) uploadFile(file);
return;
}
}
});
function uploadFile(file) {
const formData = new FormData();
formData.append('image', file);
formData.append('subdir', document.getElementById('subdir').value);
const customName = document.getElementById('filename').value.trim();
if (customName) formData.append('filename', customName);
resultDiv.className = 'result show';
resultDiv.innerHTML = '<p>Upload en cours...</p>';
fetch('upload.php', { method: 'POST', body: formData })
.then(r => r.json())
.then(data => {
if (data.success) {
resultDiv.className = 'result show';
resultDiv.innerHTML = `
<p><strong>Upload réussi !</strong> — ${data.filename} dans <em>${data.subdir}/</em></p>
<div class="md-code">
<code>${data.markdown}</code>
<button class="copy-btn" onclick="copyText('${data.markdown}')">Copier</button>
</div>
<img src="docs/assets/images/${data.subdir}/${data.filename}" class="preview-img" alt="Preview">
`;
// Reset le nom custom
document.getElementById('filename').value = '';
} else {
resultDiv.className = 'result show error';
resultDiv.innerHTML = `<p><strong>Erreur :</strong> ${data.error}</p>`;
}
})
.catch(err => {
resultDiv.className = 'result show error';
resultDiv.innerHTML = `<p><strong>Erreur réseau :</strong> ${err.message}</p>`;
});
}
function copyText(text) {
navigator.clipboard.writeText(text).then(() => {
const toast = document.getElementById('toast');
toast.classList.add('show');
setTimeout(() => toast.classList.remove('show'), 1500);
});
}
</script>
</body>
</html>

355
photos.php Executable file
View File

@@ -0,0 +1,355 @@
<?php
// --- 1. CONFIGURATION ---
$json_file = 'database.json';
$authorized_client = null;
$url_client = $_GET['client'] ?? '';
$url_token = $_GET['token'] ?? '';
$url_id = $_GET['id'] ?? null; // Support ID access
if (file_exists($json_file)) {
$data = json_decode(file_get_contents($json_file), true);
if (isset($data['clients'])) {
foreach ($data['clients'] as $id => $c) {
// Check 1: ID based (New)
if ($url_id !== null && (string)$id === (string)$url_id) {
$authorized_client = $c;
break;
}
// Check 2: Name/Token based (Legacy)
if (strcasecmp($c['name'], $url_client) == 0 && $c['token'] === $url_token) {
$authorized_client = $c;
break;
}
}
}
}
if (!$authorized_client) { http_response_code(403); die("<h1>⛔ ACCÈS REFUSÉ</h1>"); }
// --- 2. ANALYSE DE L'ÉTAT ---
$client_folder = 'clients/' . $authorized_client['name'];
$images = is_dir($client_folder) ? glob($client_folder . "/*.jpg") : [];
array_multisort(array_map('filemtime', $images), SORT_DESC, $images);
// État par défaut (SAFE)
$state = 'SAFE';
$banner_color = '#10b981'; // Vert
$banner_text = '✅ SURVEILLANCE ACTIVE';
$status_text = 'R.A.S — Tout va bien';
$current_image = 'placeholder.jpg';
$alert_time = "--/--/----";
// DÉTECTION INTELLIGENTE
if (!empty($images)) {
$current_image = $images[0];
$alert_time = date("d/m/Y à H:i:s", filemtime($current_image));
// On demande à la Base de Données ce qu'en pense l'IA
$is_real_alert = $authorized_client['alerte'] ?? false;
// Lire le verdict RÉEL de l'IA depuis le sidecar JSON de l'image la plus récente
$sidecar_file = $current_image . ".json";
$sidecar_data = null;
$ia_was_urgent = null; // null = pas de données, true = IA a dit urgence, false = IA a dit RAS
if (file_exists($sidecar_file)) {
$sidecar_data = json_decode(file_get_contents($sidecar_file), true);
if ($sidecar_data && isset($sidecar_data['urgence'])) {
$ia_was_urgent = $sidecar_data['urgence'];
}
}
// SÉCURITÉ CRITIQUE : Détecter si la DB est en retard sur les alertes réelles
// Compare le timestamp de l'image vs la dernière mise à jour de la DB
$image_timestamp = filemtime($current_image);
$db_last_update = strtotime($authorized_client['last_update'] ?? '2000-01-01');
$db_is_stale = ($image_timestamp > $db_last_update + 60); // +60s de marge
// Est-ce que quelqu'un s'en occupe déjà ?
$handler = $authorized_client['handled_by'] ?? null;
$handler_time = $authorized_client['handled_at'] ?? '';
if ($handler) {
// ÉTAT ORANGE : PRIS EN CHARGE
$state = 'HANDLED';
$banner_color = '#f59e0b';
$banner_text = "🟠 PRISE EN CHARGE PAR " . strtoupper($handler);
$status_text = "⚠️ Intervention en cours par $handler depuis $handler_time";
}
elseif ($is_real_alert === true) {
// ÉTAT ROUGE : DB dit alerte active
$state = 'DANGER';
$banner_color = '#ef4444';
$banner_text = '🚨 ALERTE EN COURS';
$status_text = '⚠️ Chute Détectée — Intervention Requise';
}
elseif ($ia_was_urgent === true && $db_is_stale) {
// ÉTAT ROUGE : Le sidecar dit URGENCE mais la DB n'a pas été mise à jour
// → L'alerte n'a JAMAIS été traitée, la DB est en retard
$state = 'DANGER';
$banner_color = '#ef4444';
$banner_text = '🚨 ALERTE NON TRAITÉE';
$status_text = '⚠️ Alerte détectée — Aucune prise en charge enregistrée !';
}
elseif ($is_real_alert === false && $ia_was_urgent === true) {
// ÉTAT VERT : Alerte RÉELLE qui a été traitée et archivée
// La DB a été mise à jour APRÈS l'image → quelqu'un a géré l'incident
$state = 'RESOLVED';
$banner_color = '#10b981';
$banner_text = "✅ INCIDENT TRAITÉ & ARCHIVÉ";
$status_text = "L'alerte a été prise en charge et archivée.";
}
elseif ($is_real_alert === false) {
// FAUSSE ALERTE : L'IA a dit "pas d'urgence" (sidecar urgence=false ou absent)
$state = 'FALSE_POSITIVE';
$banner_color = '#3b82f6';
$banner_text = "🛡️ FAUSSE ALERTE ANNULÉE PAR IA";
$status_text = " Mouvement détecté, mais l'IA confirme que tout va bien.";
}
else {
// ÉTAT ROUGE : Fallback sécurité
$state = 'DANGER';
$banner_color = '#ef4444';
$banner_text = '🚨 ALERTE EN COURS';
$status_text = '⚠️ Chute Détectée — Intervention Requise';
}
}
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LUCAS - Supervision</title>
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
<style>
body { background-color: #0B1623; color: white; font-family: 'Roboto', sans-serif; margin: 0; display: flex; flex-direction: column; align-items: center; min-height: 100vh; }
header { width: 100%; padding: 15px; background: #0f1e31; display: flex; align-items: center; border-bottom: 1px solid #2c3e50; }
.status-dot { width: 12px; height: 12px; background: #10b981; border-radius: 50%; margin-right: 10px; box-shadow: 0 0 5px #10b981; }
.brand { font-weight: 700; color: #10b981; font-size: 1.2rem; }
.client-name { margin-left: auto; color: #94a3b8; font-size: 0.8rem; font-weight: bold; }
.alert-card { width: 100%; max-width: 600px; margin-top: 10px; }
.alert-banner { background: <?php echo $banner_color; ?>; padding: 12px; text-align: center; font-weight: bold; border-radius: 8px 8px 0 0; text-transform: uppercase; }
.image-wrapper { background: black; border-bottom: 4px solid #1e293b; }
.cam-image { width: 100%; display: block; }
.info-panel { background: #1e293b; padding: 20px; margin-top: 15px; border-radius: 8px; border: 1px solid #334155; }
.panel-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; border-bottom: 1px solid #334155; padding-bottom: 10px; }
.panel-time { font-size: 1.4rem; font-weight: bold; }
/* Couleur dynamique de la bordure IA */
<?php
$ai_color = '#fbbf24'; // Jaune par défaut
if ($state === 'DANGER') $ai_color = '#ef4444'; // Rouge
elseif ($state === 'FALSE_POSITIVE') $ai_color = '#3b82f6'; // Bleu
elseif ($state === 'RESOLVED') $ai_color = '#10b981'; // Vert
?>
.ai-report { background: #0f172a; border-left: 4px solid <?php echo $ai_color; ?>; padding: 15px; margin-bottom: 15px; border-radius: 4px; }
.ai-title { color: <?php echo $ai_color; ?>; font-weight: bold; font-size: 0.8rem; display: block; margin-bottom: 5px; }
.ai-text { color: #e2e8f0; font-size: 0.9rem; }
.action-zone { width: 100%; max-width: 560px; padding: 20px; }
select { width: 100%; padding: 15px; background: #1e293b; color: white; border: 1px solid #334155; border-radius: 8px; margin-bottom: 15px; font-size: 1rem; }
.btn { width: 100%; padding: 18px; border-radius: 12px; border: none; color: white; font-size: 1.1rem; font-weight: bold; cursor: pointer; display: flex; justify-content: center; align-items: center; text-decoration: none; margin-bottom: 10px; }
.btn-green { background: #10b981; }
.btn-blue { background: #3b82f6; }
.btn-red { background: #ef4444; }
</style>
</head>
<body>
<header>
<div class="status-dot"></div><div class="brand">LUCAS</div>
<div class="client-name"><?php echo htmlspecialchars($authorized_client['name']); ?></div>
</header>
<div class="alert-card">
<div class="alert-banner"><?php echo $banner_text; ?></div>
<div class="image-wrapper">
<img src="<?php echo $current_image; ?>" class="cam-image">
</div>
<?php if ($state !== 'SAFE'): ?>
<div class="info-panel">
<div class="panel-row">
<div><span style="color:#94a3b8;font-size:0.8rem;">HORODATAGE</span><br><span class="panel-time"><?php echo $alert_time; ?></span></div>
<div style="font-size:1.5rem">🕒</div>
</div>
<div class="ai-report">
<span class="ai-title">🤖 ANALYSE IA <?php
if ($state === 'FALSE_POSITIVE') echo '(Rassurante)';
elseif ($state === 'RESOLVED') echo '(Archivée)';
elseif ($state === 'DANGER') echo '(ALERTE)';
?></span>
<div class="ai-text"><?php
// Priorité au sidecar (verdict réel de l'IA) si la DB est en retard
if ($db_is_stale && $sidecar_data && !empty($sidecar_data['message'])) {
echo htmlspecialchars($sidecar_data['message']);
} else {
echo htmlspecialchars($authorized_client['message'] ?? 'Analyse en cours...');
}
?></div>
</div>
<div style="text-align:center; font-weight:bold; color: <?php echo $banner_color; ?>;">
<?php echo $status_text; ?>
</div>
</div>
<?php else: ?>
<div class="info-panel" style="text-align:center;">
<h3 style="color:#10b981">Système Connecté</h3>
<p style="color:#94a3b8">Aucune anomalie détectée.</p>
</div>
<?php endif; ?>
<!-- GALERIE HISTORIQUE -->
<?php if (!empty($images)): ?>
<style>
.history-title { margin-top: 30px; margin-bottom: 10px; color: #94a3b8; font-size: 0.9rem; text-transform: uppercase; letter-spacing: 1px; font-weight: bold; padding-left: 5px; }
.history-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); gap: 8px; max-height: 300px; overflow-y: auto; padding: 5px; background: #0f1e31; border-radius: 8px; border: 1px solid #2c3e50; }
.history-item { position: relative; cursor: pointer; border: 2px solid transparent; border-radius: 4px; overflow: hidden; transition: all 0.2s; aspect-ratio: 16/9; }
.history-item:hover { border-color: #3b82f6; transform: scale(1.05); z-index: 10; }
.history-item img { width: 100%; height: 100%; object-fit: cover; display: block; }
.history-item.active { border-color: #10b981; box-shadow: 0 0 8px rgba(16, 185, 129, 0.4); }
/* Scrollbar custom */
.history-grid::-webkit-scrollbar { width: 6px; }
.history-grid::-webkit-scrollbar-track { background: #0f172a; }
.history-grid::-webkit-scrollbar-thumb { background: #334155; border-radius: 3px; }
</style>
<div class="history-title">Historique des captures (<?php echo count($images); ?>)</div>
<div class="history-grid">
<?php foreach ($images as $idx => $img):
$timestamp = filemtime($img);
$date_str = date("d/m H:i", $timestamp);
// DATA ARCHIVED Check
$json_sidecar = $img . ".json";
$archived_data = null;
if (file_exists($json_sidecar)) {
$archived_data = json_decode(file_get_contents($json_sidecar), true);
}
// Prepare JS attributes
$safe_message = htmlspecialchars($archived_data['message'] ?? '', ENT_QUOTES);
$safe_urgence = $archived_data['urgence'] ?? null; // null if no archive
// If no archive, we pass 'null' to indicate "Unknown/Legacy"
$js_urgence = json_encode($safe_urgence);
?>
<div class="history-item <?php echo $idx === 0 ? 'active' : ''; ?>"
onclick='changeImage("<?php echo $img; ?>", "<?php echo date('d/m/Y à H:i:s', $timestamp); ?>", this, <?php echo $js_urgence; ?>, "<?php echo $safe_message; ?>")'>
<img src="<?php echo $img; ?>" loading="lazy" title="<?php echo $date_str; ?>">
<!-- Indicator for archived data -->
<?php if ($archived_data): ?>
<div style="position:absolute; bottom:2px; right:2px; width:8px; height:8px; border-radius:50%; background:<?php echo $safe_urgence ? '#ef4444' : '#3b82f6'; ?>;"></div>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<script>
function changeImage(src, dateText, el, isUrgent, message) {
// Update Main Image
document.querySelector('.cam-image').src = src;
// Update Time
const timeEl = document.querySelector('.panel-time');
if(timeEl) timeEl.textContent = dateText;
// Update Active State
document.querySelectorAll('.history-item').forEach(d => d.classList.remove('active'));
el.classList.add('active');
// UPDATE AI REPORT CONTEXT
const aiReportBox = document.querySelector('.ai-report');
const aiTitle = document.querySelector('.ai-title');
const aiText = document.querySelector('.ai-text');
const banner = document.querySelector('.alert-banner');
const statusText = document.querySelector('.info-panel > div:last-child'); // The status text div
if (message) {
// We have archived data
aiText.textContent = message;
if (isUrgent === true) {
// Danger
if(aiReportBox) aiReportBox.style.borderLeftColor = '#ef4444';
if(aiTitle) { aiTitle.style.color = '#ef4444'; aiTitle.textContent = '🤖 ANALYSE IA (ARCHIVE)'; }
if(banner) { banner.style.backgroundColor = '#ef4444'; banner.textContent = '🚨 ALERTE (HISTORIQUE)'; }
if(statusText) { statusText.style.color = '#ef4444'; statusText.textContent = '⚠️ Alerte enregistrée à cette date'; }
} else {
// Safe / False Positive
const safeColor = '#3b82f6';
if(aiReportBox) aiReportBox.style.borderLeftColor = safeColor;
if(aiTitle) { aiTitle.style.color = safeColor; aiTitle.textContent = '🤖 ANALYSE IA (ARCHIVE)'; }
if(banner) { banner.style.backgroundColor = safeColor; banner.textContent = '🛡️ SÉCURISÉ (HISTORIQUE)'; }
if(statusText) { statusText.style.color = safeColor; statusText.textContent = ' Situation jugée sûre par l\'IA'; }
}
} else {
// Legacy image (no JSON)
if(aiText) aiText.textContent = "Aucune analyse archivée pour cette image.";
if(aiTitle) { aiTitle.style.color = '#94a3b8'; aiTitle.textContent = '🤖 ANALYSE IA (NON DISPONIBLE)'; }
if(aiReportBox) aiReportBox.style.borderLeftColor = '#94a3b8';
if(banner) { banner.style.backgroundColor = '#64748b'; banner.textContent = '🕰️ ARCHIVE SANS DONNÉES'; }
if(statusText) { statusText.style.color = '#94a3b8'; statusText.textContent = 'Pas de données d\'analyse pour cette date'; }
}
}
</script>
<?php endif; ?>
</div>
<?php if ($state !== 'SAFE'): ?>
<div class="action-zone">
<?php if ($state === 'DANGER'): ?>
<label style="color:#94a3b8; display:block; margin-bottom:5px;">Qui êtes-vous ?</label>
<select id="whoSelect">
<option value="" disabled selected>-- Choisir --</option>
<?php foreach (($authorized_client['contacts']??[]) as $c) echo "<option value='{$c['name']}'>{$c['name']}</option>"; ?>
</select>
<div style="display:flex; gap:10px;">
<a href="tel:112" class="btn btn-red" style="flex:1">📞 112</a>
<button class="btn btn-green" onclick="acknowledgeAlert()" style="flex:1">✅ Je m'en occupe</button>
</div>
<?php elseif ($state === 'FALSE_POSITIVE'): ?>
<p style="text-align:center; color:#94a3b8;">L'IA a invalidé l'alerte du capteur.</p>
<button class="btn btn-blue" onclick="finishIntervention()">🗑️ Archiver la fausse alerte</button>
<?php elseif ($state === 'RESOLVED'): ?>
<p style="text-align:center; color:#94a3b8;">Cet incident a été traité et archivé.</p>
<button class="btn btn-green" onclick="finishIntervention()">📂 Consulter l'historique</button>
<?php elseif ($state === 'HANDLED'): ?>
<p style="text-align:center; color:#94a3b8;">L'alerte est maintenue visible pour les autres.</p>
<button class="btn btn-blue" onclick="finishIntervention()">📂 Clore l'incident & Archiver</button>
<?php endif; ?>
</div>
<?php endif; ?>
<script>
const params = new URLSearchParams(window.location.search);
const client = params.get('client');
const token = params.get('token');
function acknowledgeAlert() {
const user = document.getElementById('whoSelect').value;
if (!user) { alert("Dites-nous qui vous êtes !"); return; }
if(confirm("Confirmer la prise en charge ?")) {
fetch(`acknowledge.php?client=${client}&token=${token}&user=${encodeURIComponent(user)}`)
.then(r => r.json()).then(d => window.location.reload());
}
}
function finishIntervention() {
if(confirm("Confirmer l'archivage ?")) {
fetch(`reset.php?client=${client}&token=${token}&user=AutoArchive`)
.then(r => r.json()).then(d => window.location.reload());
}
}
</script>
</body>
</html>

BIN
placeholder.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

137
provision.php Normal file
View File

@@ -0,0 +1,137 @@
<?php
/**
* provision.php — Provisioning d'un nouveau site client
*
* Crée un nouveau client dans database.json, assigne un site_number,
* génère un token, crée le dossier client, et retourne les infos
* de provisioning (dont un QR code encodé en base64).
*
* POST /provision.php
* Content-Type: application/json
* Authorization: Bearer <ADMIN_PASSWORD>
*
* {
* "client_id": "dupont_marie",
* "name": "Marie Dupont",
* "senior_name": "Mamie Marie",
* "address": "12 rue des Roses",
* "city": "20270 Aleria",
* "latitude": "42.1028",
* "longitude": "9.5147",
* "emergency_number": "15",
* "contacts": [{"name": "Jean", "role": "Fils", "phone": "+33698765432"}]
* }
*/
header("Content-Type: application/json");
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: POST, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type, Authorization");
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit; }
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
die(json_encode(["success" => false, "message" => "POST uniquement"]));
}
// Authentification admin
$ADMIN_PASS = "smart123";
$auth_header = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
$provided_pass = str_replace('Bearer ', '', $auth_header);
if ($provided_pass !== $ADMIN_PASS) {
http_response_code(401);
die(json_encode(["success" => false, "message" => "Authentification admin requise"]));
}
$input = json_decode(file_get_contents("php://input"), true);
$client_id = preg_replace('/[^a-z0-9_]/', '', strtolower($input['client_id'] ?? ''));
if (empty($client_id)) {
http_response_code(400);
die(json_encode(["success" => false, "message" => "client_id requis"]));
}
$DB_FILE = "database.json";
$db = json_decode(file_get_contents($DB_FILE), true);
// Vérifier unicité
foreach ($db['clients'] as $c) {
if (strcasecmp($c['name'] ?? '', $client_id) === 0 || strcasecmp($c['name'] ?? '', $input['name'] ?? '') === 0) {
http_response_code(409);
die(json_encode(["success" => false, "message" => "Client déjà existant"]));
}
}
// Calculer le prochain site_number
$max_site = 0;
foreach ($db['clients'] as $c) {
$sn = $c['site_number'] ?? 0;
if ($sn > $max_site) $max_site = $sn;
}
$site_number = $max_site + 1;
// Générer le token
$token = bin2hex(random_bytes(4));
// Calculer les ports
$base_port = 8550 + ($site_number - 1) * 10;
// Créer l'entrée client
$db['clients'][$client_id] = [
"name" => $input['name'] ?? $client_id,
"token" => $token,
"created_at" => date("Y-m-d H:i:s"),
"site_number" => $site_number,
"site_status" => "provisioned",
"senior_name" => $input['senior_name'] ?? $input['name'] ?? '',
"senior_nickname" => $input['senior_nickname'] ?? '',
"senior_photo" => "",
"latitude" => $input['latitude'] ?? '',
"longitude" => $input['longitude'] ?? '',
"emergency_number" => $input['emergency_number'] ?? '15',
"fcm_tokens" => [],
"contacts" => $input['contacts'] ?? [],
"address" => $input['address'] ?? '',
"city" => $input['city'] ?? '',
"phone_mobile" => $input['phone_mobile'] ?? '',
"phone_fixed" => '',
"age" => $input['age'] ?? '',
"sex" => $input['sex'] ?? 'F',
"alerte" => false,
];
// Créer le dossier client
$client_dir = "clients/" . ($input['name'] ?? $client_id) . "/";
if (!is_dir($client_dir)) {
mkdir($client_dir, 0775, true);
}
// Sauvegarder la DB
file_put_contents($DB_FILE, json_encode($db, JSON_PRETTY_PRINT));
// Données de provisioning pour le QR code
$provision_data = [
"client_id" => $input['name'] ?? $client_id,
"token" => $token,
"site_number" => $site_number,
"server" => "57.128.74.87",
];
// Écrire la demande de firewall dans une file d'attente
$fw_queue = "/var/www/lucas/firewall_queue.txt";
$fw_cmd = "ufw allow " . $base_port . ":" . ($base_port + 9) . "/tcp comment 'Site $site_number - $client_id'";
file_put_contents($fw_queue, $fw_cmd . "\n", FILE_APPEND);
// Réponse
echo json_encode([
"success" => true,
"status" => "provisioned",
"client_id" => $input['name'] ?? $client_id,
"token" => $token,
"site_number" => $site_number,
"base_port" => $base_port,
"port_range" => $base_port . "-" . ($base_port + 9),
"provision_qr_data" => json_encode($provision_data),
"firewall_queued" => true,
"message" => "Client provisionné. Ports $base_port-" . ($base_port + 9) . " en attente d'ouverture firewall.",
], JSON_PRETTY_PRINT);
?>

90
register_token.php Normal file
View File

@@ -0,0 +1,90 @@
<?php
/**
* register_token.php — Enregistrement token FCM
*
* Appelé par LucasApp au premier lancement ou lors d'un refresh token.
* Ajoute le token FCM à la liste des tokens du client (dédupliqué).
*
* POST /register_token.php
* Content-Type: application/json
* {"client_id": "Demo_01", "fcm_token": "fheHxy..."}
*/
header("Content-Type: application/json");
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: POST, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type");
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit; }
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
die(json_encode(["success" => false, "message" => "Méthode non autorisée"]));
}
$input = json_decode(file_get_contents("php://input"), true);
$client_id = $input['client_id'] ?? '';
$fcm_token = $input['fcm_token'] ?? '';
$password = $input['password'] ?? '';
if (empty($client_id) || empty($fcm_token) || empty($password)) {
http_response_code(400);
die(json_encode(["success" => false, "message" => "client_id, fcm_token et password requis"]));
}
$DB_FILE = "database.json";
$db = json_decode(file_get_contents($DB_FILE), true);
if (!$db || !isset($db['clients'])) {
http_response_code(500);
die(json_encode(["success" => false, "message" => "Base de données introuvable"]));
}
// Trouver le client par name
$found_key = null;
foreach ($db['clients'] as $key => $c) {
if (strcasecmp($c['name'] ?? '', $client_id) === 0) {
$found_key = $key;
break;
}
}
if ($found_key === null) {
http_response_code(404);
die(json_encode(["success" => false, "message" => "Client inconnu"]));
}
// Vérification mot de passe (bcrypt)
$password_hash = $db['clients'][$found_key]['password_hash'] ?? null;
if ($password_hash === null) {
http_response_code(403);
die(json_encode(["success" => false, "message" => "Compte non configuré"]));
}
if (!password_verify($password, $password_hash)) {
http_response_code(401);
die(json_encode(["success" => false, "message" => "Mot de passe incorrect"]));
}
// Initialiser fcm_tokens si absent
if (!isset($db['clients'][$found_key]['fcm_tokens'])) {
$db['clients'][$found_key]['fcm_tokens'] = [];
}
// Dédupliquer
if (!in_array($fcm_token, $db['clients'][$found_key]['fcm_tokens'])) {
$db['clients'][$found_key]['fcm_tokens'][] = $fcm_token;
file_put_contents($DB_FILE, json_encode($db, JSON_PRETTY_PRINT));
$cameras = [];
foreach ($db['clients'][$found_key]['cameras'] ?? [] as $cam) {
if (!empty($cam['port_rtsp'])) {
$cameras[] = ["name" => $cam['marque'] . ' ' . $cam['modele'], "rtsp_port" => (int)$cam['port_rtsp']];
}
}
echo json_encode(["success" => true, "message" => "Token enregistré", "smarteye_token" => $db['clients'][$found_key]['token'] ?? '', "jetson_ip" => $db['clients'][$found_key]['jetson_ip'] ?? '', "server_ip" => "57.128.74.87", "cameras" => $cameras]);
} else {
$cameras = [];
foreach ($db['clients'][$found_key]['cameras'] ?? [] as $cam) {
if (!empty($cam['port_rtsp'])) {
$cameras[] = ["name" => $cam['marque'] . ' ' . $cam['modele'], "rtsp_port" => (int)$cam['port_rtsp']];
}
}
echo json_encode(["success" => true, "message" => "Token déjà enregistré", "smarteye_token" => $db['clients'][$found_key]['token'] ?? '', "jetson_ip" => $db['clients'][$found_key]['jetson_ip'] ?? '', "server_ip" => "57.128.74.87", "cameras" => $cameras]);
}
?>

138
repair_missing_analyses.py Executable file
View File

@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""
Script de réparation pour analyser les images historiques
qui n'ont pas de fichier JSON sidecar
"""
import os
import sys
import json
import glob
from datetime import datetime
# Import du module d'analyse
sys.path.insert(0, '/var/www/lucas')
from analyze import analyze_image
def find_images_without_json(client_folder):
"""Trouve toutes les images sans fichier JSON"""
images = glob.glob(f"{client_folder}/*.jpg")
missing = []
for img in images:
json_file = img + ".json"
if not os.path.exists(json_file):
missing.append(img)
return sorted(missing, key=lambda x: os.path.getmtime(x), reverse=True)
def repair_image_analysis(image_path):
"""Analyse une image et crée son fichier JSON sidecar"""
print(f"\n📸 Analyse: {os.path.basename(image_path)}")
try:
# Analyser l'image
result = analyze_image(image_path)
# Créer le fichier JSON sidecar
history_data = {
"urgence": result.get("urgence", False),
"message": result.get("message", "Analyse rétrospective"),
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"retrospective": True # Marqueur pour indiquer que c'est une analyse a posteriori
}
json_file = image_path + ".json"
with open(json_file, 'w') as f:
json.dump(history_data, f, indent=2)
status = "🚨 URGENCE" if result.get("urgence") else "✅ SÉCURISÉ"
print(f" {status} - {result.get('message', '')[:60]}")
return True
except Exception as e:
print(f" ❌ ERREUR: {type(e).__name__}: {str(e)}")
# Créer quand même un JSON avec l'erreur
history_data = {
"urgence": None, # null = inconnu
"message": f"Erreur d'analyse rétrospective: {type(e).__name__}",
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"retrospective": True,
"error": True
}
json_file = image_path + ".json"
with open(json_file, 'w') as f:
json.dump(history_data, f, indent=2)
return False
def main():
print("="*70)
print("RÉPARATION DES ANALYSES MANQUANTES")
print("="*70)
client_folder = "/var/www/lucas/clients/Demo_01"
if not os.path.exists(client_folder):
print(f"❌ Dossier introuvable: {client_folder}")
return 1
# Trouver les images sans JSON
print(f"\n🔍 Recherche des images sans analyse...")
missing_images = find_images_without_json(client_folder)
if not missing_images:
print("✅ Toutes les images ont déjà une analyse JSON!")
return 0
print(f"\n📊 Trouvé: {len(missing_images)} images sans analyse")
# Demander confirmation si beaucoup d'images
if len(missing_images) > 50:
print(f"\n⚠️ ATTENTION: {len(missing_images)} images à analyser")
print(f" Cela peut prendre ~{len(missing_images) * 5} secondes")
response = input(" Continuer ? (o/n): ")
if response.lower() != 'o':
print("Annulé.")
return 0
# Analyser les images
print(f"\n🚀 Démarrage de l'analyse...")
print("-"*70)
success_count = 0
error_count = 0
for i, img in enumerate(missing_images, 1):
print(f"\n[{i}/{len(missing_images)}]", end=" ")
if repair_image_analysis(img):
success_count += 1
else:
error_count += 1
# Résumé
print("\n" + "="*70)
print("RÉSUMÉ")
print("="*70)
print(f"Images analysées: {len(missing_images)}")
print(f"✅ Succès: {success_count}")
print(f"❌ Erreurs: {error_count}")
print("\n✓ Réparation terminée!")
print("="*70)
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt:
print("\n\n⚠️ Interruption par l'utilisateur")
sys.exit(1)
except Exception as e:
print(f"\n❌ Erreur fatale: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
sys.exit(1)

66
reset.php Executable file
View File

@@ -0,0 +1,66 @@
<?php
header("Content-Type: application/json");
// CHEMINS ABSOLUS (Plus fiable)
$base_dir = __DIR__; // Le dossier où est ce fichier (/var/www/lucas)
$json_file = $base_dir . '/database.json';
if (!file_exists($json_file)) {
http_response_code(500);
die(json_encode(["status" => "error", "message" => "DB introuvable"]));
}
$db = json_decode(file_get_contents($json_file), true);
$client_input = $_REQUEST['client'] ?? '';
$token_input = $_REQUEST['token'] ?? '';
$user = $_REQUEST['user'] ?? 'Inconnu';
$idx = -1;
if (isset($db['clients'])) {
foreach ($db['clients'] as $i => $c) {
if (strcasecmp($c['name'], $client_input) == 0 && $c['token'] === $token_input) {
$idx = $i;
break;
}
}
}
if ($idx === -1) {
http_response_code(403);
die(json_encode(["status" => "error", "message" => "Client ou Token incorrect"]));
}
// 1. RESET DB
$db['clients'][$idx]['alerte'] = false;
$db['clients'][$idx]['message'] = "✅ Intervention effectuée par $user. Système réarmé.";
$db['clients'][$idx]['last_update'] = date("d/m/Y H:i:s");
file_put_contents($json_file, json_encode($db, JSON_PRETTY_PRINT));
// 2. ARCHIVAGE ROBUSTE
$client_name = $db['clients'][$idx]['name'];
$source_dir = $base_dir . '/clients/' . $client_name . '/';
$archive_dir = $source_dir . 'archives/';
// Création dossier archives si besoin
if (!is_dir($archive_dir)) {
if (!mkdir($archive_dir, 0777, true)) {
die(json_encode(["status" => "error", "message" => "Impossible de créer le dossier archives (Permissions?)"]));
}
}
// Déplacement des fichiers
$images = glob($source_dir . "*.jpg");
$moved = 0;
foreach($images as $img) {
$filename = basename($img);
if(rename($img, $archive_dir . $filename)) {
$moved++;
}
}
echo json_encode([
"status" => "success",
"message" => "Reset OK. $moved images archivées."
]);
?>

View File

@@ -0,0 +1,222 @@
#!/bin/bash
# ============================================================
# setup_shared.sh — À lancer depuis le serveur OVH (session Lucas)
# Initialise le dossier partagé pour les 3 agents
# ============================================================
SHARED="/var/www/shared/deployment"
echo "📁 Création de l'arborescence..."
mkdir -p "$SHARED"/{smarteye,lucas,lucasapp,synthesis}
# ============================================================
# BRIEF COMMUN
# ============================================================
cat > "$SHARED/BRIEF.md" << 'BRIEF_EOF'
# 🎯 Mission : Préparer le Déploiement Multi-Client
## Contexte
Le système SmartEye/Lucas/LucasApp fonctionne en mode pilote (Aléria).
TOUT est en dur : IPs caméras, ports tunnels, nom du senior, coordonnées GPS,
clés API, tokens Firebase, URLs RTSP, etc.
## Objectif
Rendre le système **déployable chez n'importe quel client** en externalisant
TOUTES les valeurs en dur dans des fichiers de configuration.
## Architecture
```
Caméras IP (réseau local client)
↓ RTSP
Jetson Orin Nano (SmartEye) — détection chutes
↓ SSH tunnels (autossh)
Serveur OVH 57.128.74.87 (Lucas) — backend, Gemini, Firebase
↓ FCM push
App mobile (LucasApp) — alertes, flux live, interphone
```
## Valeurs actuellement en dur
### SmartEye (Jetson)
- IPs caméras : 192.168.1.143, 192.168.1.148, 192.168.1.109
- Ports RTSP : 554, chemins /11 (HD) et /12 (SD)
- Serveur Lucas : 46.105.30.107 (ANCIEN — maintenant 57.128.74.87)
- Tunnels SSH : ports 8554/8555/8556 (RTSP) + 8800 (audio)
- User SSH : rachid (sur ancien serveur) ou debian (nouveau)
- Seuils détection : conf=0.15, pose=0.25, GS=45/30/20, ghost=30s, immobilité=12s
- Port web UI : 8080
### Lucas (OVH)
- senior_name : "Mamie Lucas"
- senior_photo_url : https://lucas.unigest.fr/photos/mamie.jpg
- Coordonnées GPS : 42.1028, 9.5147 (Aléria)
- URLs RTSP publiques : rtsp://46.105.30.107:8554/12, :8555/12, :8556/12
- Clé Gemini : en dur dans analyze.py
- Credentials Firebase : chemin fixe
- Tokens FCM : en dur ou semi-dur
- Config SMS OVH : clés en dur
- Numéro urgence : +33652631829
- Ports firewall : fixes
### LucasApp (Flutter)
- URL API : https://lucas.unigest.fr
- WebSocket audio : ws://46.105.30.107:8800
- Numéro urgence SAMU : 15
## Livrables attendus par agent
Chaque agent doit produire dans son dossier :
1. `plan.md` — Son plan d'action détaillé
2. `config_spec.*` — Le format de fichier config proposé (yaml/ini/dart)
3. `dependencies.md` — Ce dont il a besoin des 2 autres agents
4. `questions.md` — Questions ouvertes pour les autres
5. `status.txt` — "DONE" quand il a terminé son round
## Convention de ports par client
Chaque site client = plage de 10 ports sur OVH :
- Site 1 (pilote) : 8550-8559
- Site 2 : 8560-8569
- Site N : 8550 + N*10
## IMPORTANT
- Le user SSH sur le nouveau OVH est `debian` (pas `rachid`)
- L'IP OVH est maintenant `57.128.74.87`
- Domaine : lucas.unigest.fr
- Les caméras sont des CTronic avec RTSP + audio ONVIF
- Flux HD = /11, flux SD = /12
BRIEF_EOF
echo "✅ Brief créé : $SHARED/BRIEF.md"
# ============================================================
# INSTRUCTIONS SMARTEYE
# ============================================================
cat > "$SHARED/smarteye/INSTRUCTIONS.md" << 'EOF'
# 📷 Instructions — Agent SmartEye
## Ton rôle
Tu es responsable du code Python de détection embarqué sur le Jetson Orin Nano.
## Lis d'abord
- `/var/www/shared/deployment/BRIEF.md` — le brief commun
## Ce que tu dois faire
1. Identifier TOUTES les valeurs en dur dans le code SmartEye
2. Créer un format `smarteye_config.yaml` qui externalise tout
3. Modifier le code pour charger depuis ce YAML au démarrage
4. Adapter le service systemd autossh pour lire les ports depuis la config
5. Créer un script `setup_site.sh` interactif pour générer la config d'un nouveau site
6. Documenter les dépendances vers Lucas (endpoints, format des alertes)
## Écris tes résultats dans
- `/var/www/shared/deployment/smarteye/plan.md`
- `/var/www/shared/deployment/smarteye/config_spec.yaml` (exemple de config)
- `/var/www/shared/deployment/smarteye/dependencies.md`
- `/var/www/shared/deployment/smarteye/questions.md`
## Consulte les travaux des autres
- `/var/www/shared/deployment/lucas/` — ce que Lucas propose
- `/var/www/shared/deployment/lucasapp/` — ce que LucasApp propose
- `/var/www/shared/deployment/synthesis/` — les synthèses croisées
## Quand tu as fini
Écris "DONE" dans `/var/www/shared/deployment/smarteye/status.txt`
EOF
echo "✅ Instructions SmartEye créées"
# ============================================================
# INSTRUCTIONS LUCAS
# ============================================================
cat > "$SHARED/lucas/INSTRUCTIONS.md" << 'EOF'
# 🖥️ Instructions — Agent Lucas
## Ton rôle
Tu es responsable du backend serveur sur OVH (57.128.74.87).
## Lis d'abord
- `/var/www/shared/deployment/BRIEF.md` — le brief commun
## Ce que tu dois faire
1. Identifier TOUTES les valeurs en dur dans analyze.py, alert_ack.php, alert_watchdog.py
2. Créer un format `lucas_config.ini` qui externalise tout
3. Modifier les scripts Python/PHP pour charger depuis cette config
4. Gérer le MULTI-SITE : un seul serveur Lucas doit pouvoir servir N clients
5. Créer un script `add_site.py` pour initialiser un nouveau site (BDD + config + firewall)
6. Documenter le format exact du payload FCM (ce que LucasApp attend)
7. Documenter les endpoints HTTP (ce que SmartEye appelle)
## Écris tes résultats dans
- `/var/www/shared/deployment/lucas/plan.md`
- `/var/www/shared/deployment/lucas/config_spec.ini` (exemple de config)
- `/var/www/shared/deployment/lucas/dependencies.md`
- `/var/www/shared/deployment/lucas/questions.md`
- `/var/www/shared/deployment/lucas/api_contract.md` (endpoints + payloads)
## Consulte les travaux des autres
- `/var/www/shared/deployment/smarteye/` — ce que SmartEye propose
- `/var/www/shared/deployment/lucasapp/` — ce que LucasApp propose
- `/var/www/shared/deployment/synthesis/` — les synthèses croisées
## Quand tu as fini
Écris "DONE" dans `/var/www/shared/deployment/lucas/status.txt`
EOF
echo "✅ Instructions Lucas créées"
# ============================================================
# INSTRUCTIONS LUCASAPP
# ============================================================
cat > "$SHARED/lucasapp/INSTRUCTIONS.md" << 'EOF'
# 📱 Instructions — Agent LucasApp
## Ton rôle
Tu es responsable de l'application mobile Flutter (Android).
## Lis d'abord
- `/var/www/shared/deployment/BRIEF.md` — le brief commun
## Ce que tu dois faire
1. Identifier TOUTES les valeurs en dur dans le code Dart
2. Faire en sorte que l'app soit 100% pilotée par le payload FCM (zéro config statique liée à un site)
3. Vérifier que les URLs RTSP, WebSocket, endpoints API viennent tous du serveur
4. Proposer un `lib/config.dart` minimal (uniquement l'URL de base Lucas)
5. Documenter ce que l'app attend dans le payload FCM (champs requis)
6. S'assurer que l'app fonctionne pour n'importe quel client sans rebuild
## Écris tes résultats dans
- `/var/www/shared/deployment/lucasapp/plan.md`
- `/var/www/shared/deployment/lucasapp/config_spec.dart` (exemple)
- `/var/www/shared/deployment/lucasapp/dependencies.md`
- `/var/www/shared/deployment/lucasapp/questions.md`
- `/var/www/shared/deployment/lucasapp/fcm_payload_spec.md` (ce que le payload doit contenir)
## Consulte les travaux des autres
- `/var/www/shared/deployment/smarteye/` — ce que SmartEye propose
- `/var/www/shared/deployment/lucas/` — ce que Lucas propose
- `/var/www/shared/deployment/synthesis/` — les synthèses croisées
## Quand tu as fini
Écris "DONE" dans `/var/www/shared/deployment/lucasapp/status.txt`
EOF
echo "✅ Instructions LucasApp créées"
# ============================================================
# PERMISSIONS
# ============================================================
chmod -R 775 "$SHARED"
chown -R debian:debian "$SHARED" 2>/dev/null || true
echo ""
echo "════════════════════════════════════════════════════════"
echo "✅ Dossier partagé prêt : $SHARED"
echo "════════════════════════════════════════════════════════"
echo ""
ls -la "$SHARED"
echo ""
echo "Contenu :"
find "$SHARED" -type f | sort
echo ""
echo "🚀 Prochaine étape : coller les prompts dans chaque Claude Code"
echo "════════════════════════════════════════════════════════"

115
sms_dashboard.php Executable file
View File

@@ -0,0 +1,115 @@
<?php
session_start();
if (!isset($_SESSION['admin'])) { header("Location: admin.php"); exit; }
// --- POSTGRES CONNECTION ---
$host = "localhost";
$dbname = "smarteye";
$user = "lucas";
$pass = "smarteye_db_pass";
try {
$pdo = new PDO("pgsql:host=$host;dbname=$dbname", $user, $pass);
} catch (PDOException $e) {
die("Erreur DB: " . $e->getMessage());
}
// Stats
$stats_sql = "SELECT client_name, COUNT(*) as total FROM sms_logs GROUP BY client_name ORDER BY total DESC";
$stats = $pdo->query($stats_sql)->fetchAll(PDO::FETCH_ASSOC);
// Logs
$logs_sql = "SELECT * FROM sms_logs ORDER BY sent_at DESC LIMIT 100";
$logs = $pdo->query($logs_sql)->fetchAll(PDO::FETCH_ASSOC);
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>SMS Manager - Lucas</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>body { background-color: #0f172a; color: #e2e8f0; }</style>
</head>
<body class="min-h-screen flex flex-col p-8 max-w-7xl mx-auto w-full">
<!-- Header -->
<div class="flex justify-between items-center mb-8">
<div>
<h1 class="text-2xl font-bold text-white tracking-wider flex items-center gap-2">
<a href="admin.php" class="text-slate-500 hover:text-white transition">←</a>
SMS MANAGER
</h1>
<p class="text-slate-500 text-sm">Base de données PostgreSQL</p>
</div>
<div class="bg-slate-800 px-4 py-2 rounded border border-slate-700">
<span class="text-emerald-400 font-bold"><?php echo count($logs); ?></span> <span class="text-xs text-slate-400">derniers logs affichés</span>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- Stats Panel -->
<div class="bg-slate-800 rounded-xl p-6 border border-slate-700 shadow-lg h-fit">
<h2 class="text-purple-400 text-xs font-bold uppercase tracking-widest mb-4 border-b border-slate-700 pb-2">📊 Volume par Client</h2>
<div class="space-y-3">
<?php foreach($stats as $s):
$percent = ($s['total'] / max(1, array_sum(array_column($stats, 'total')))) * 100;
?>
<div>
<div class="flex justify-between text-sm mb-1">
<span class="font-bold text-white"><?php echo $s['client_name']; ?></span>
<span class="text-slate-400"><?php echo $s['total']; ?> SMS</span>
</div>
<div class="w-full bg-slate-900 rounded-full h-2">
<div class="bg-purple-600 h-2 rounded-full" style="width: <?php echo $percent; ?>%"></div>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<!-- Logs Table -->
<div class="lg:col-span-2 bg-slate-800 rounded-xl border border-slate-700 shadow-lg overflow-hidden">
<div class="p-6 border-b border-slate-700">
<h2 class="text-slate-200 font-bold text-lg">📝 Historique des envois</h2>
</div>
<div class="overflow-x-auto">
<table class="w-full text-left text-sm">
<thead class="bg-slate-900 text-slate-400 text-xs uppercase">
<tr>
<th class="p-4">Date</th>
<th class="p-4">Client</th>
<th class="p-4">Message</th>
<th class="p-4 text-right">Statut</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-700">
<?php foreach($logs as $log): ?>
<tr class="hover:bg-slate-700/30 transition">
<td class="p-4 text-slate-400 whitespace-nowrap">
<?php echo date("d/m H:i", strtotime($log['sent_at'])); ?>
</td>
<td class="p-4 font-bold text-white">
<?php echo htmlspecialchars($log['client_name']); ?>
<div class="text-xs text-slate-500 font-normal"><?php echo $log['to_number']; ?></div>
</td>
<td class="p-4 text-slate-300 max-w-xs truncate" title="<?php echo htmlspecialchars($log['message']); ?>">
<?php echo htmlspecialchars($log['message']); ?>
</td>
<td class="p-4 text-right">
<?php if($log['status'] === 'SENT'): ?>
<span class="bg-emerald-900/40 text-emerald-400 px-2 py-1 rounded text-xs font-bold border border-emerald-900">OK</span>
<?php else: ?>
<span class="bg-red-900/40 text-red-400 px-2 py-1 rounded text-xs font-bold border border-red-900">ERR</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</div>
</body>
</html>

140
sms_manager.py Executable file
View File

@@ -0,0 +1,140 @@
# -*- coding: utf-8 -*-
import json
import ovh
import os
import sys
import psycopg2
# DB Config
DB_PARAMS = {
"host": "localhost",
"database": "smarteye",
"user": "lucas",
"password": "smarteye_db_pass"
}
# --- CONFIGURATION OVH (A REMPLIR AVEC TES VRAIES CLES) ---
client = ovh.Client(
endpoint='ovh-eu',
application_key='dc809738a0e0ca05',
application_secret='9a0b1723318676047919f397cabbd4d6',
consumer_key='f1cd0f7101fe2f9e57d1e3bc7c230fa0'
)
SERVICE_NAME = "sms-gr31483-1" # Ton ID Service (gardé de ton ancien code)
# --- MAIN : Réception des ordres de PHP ---
if __name__ == "__main__":
# On attend 2 arguments : le numéro et le message
if len(sys.argv) < 3:
print("Erreur: Arguments manquants")
sys.exit(1)
destinataire = sys.argv[1]
message_txt = sys.argv[2]
try:
# ENVOI REEL OVH
result = client.post(f'/sms/{SERVICE_NAME}/jobs',
message=message_txt,
sender="UNIGESTFR",
receivers=[destinataire],
noStopClause=True,
priority="high"
)
print(f"✅ SUCCÈS OVH: {result}")
except Exception as e:
print(f"❌ ERREUR OVH: {str(e)}")
def get_contacts(client_id):
"""Lit database.json et renvoie le Nom du client et la liste des numéros"""
db_path = '/var/www/lucas/database.json' # Chemin absolu pour être sûr
if not os.path.exists(db_path):
return "Inconnu", []
try:
with open(db_path, 'r') as f:
data = json.load(f)
# Structure : clients -> client_id -> contacts
client_data = data.get("clients", {}).get(client_id)
if not client_data:
return "Client Inconnu", []
nom_client = client_data.get("name", "Client")
contacts_list = client_data.get("contacts", [])
numeros_a_contacter = []
for contact in contacts_list:
phone = contact.get("phone")
if phone:
# Nettoyage
clean = phone.replace(" ", "").replace(".", "").replace("-", "")
numeros_a_contacter.append(clean)
return nom_client, numeros_a_contacter
except Exception as e:
sys.stderr.write(f"Erreur lecture DB: {e}\n")
return "Erreur", []
def send_alert(client_id, image_url=""):
"""Envoie le SMS à tous les contacts du client"""
nom_client, numeros = get_contacts(client_id)
if not numeros:
return False, "Aucun contact trouvé"
message_txt = f"ALERTE CHUTE ! {nom_client} est au sol. Voir preuve : {image_url}"
# On tronque si trop long (sécurité)
if len(message_txt) > 160: message_txt = message_txt[:160]
logs = []
for num in numeros:
try:
# ENVOI REEL OVH
result = client.post(f'/sms/{SERVICE_NAME}/jobs',
message=message_txt,
sender="UNIGESTFR", # <--- ON AJOUTE TON SENDER ICI
senderForResponse=False, # <--- ON MET FALSE (Impossible de répondre à un nom)
receivers=[num],
noStopClause=True,
priority="high"
)
)
logs.append(f"OK vers {num}")
# --- LOG TO POSTGRES ---
try:
conn = psycopg2.connect(**DB_PARAMS)
cur = conn.cursor()
cur.execute("""
INSERT INTO sms_logs (to_number, client_name, message, status, provider_id)
VALUES (%s, %s, %s, %s, %s)
""", (num, nom_client, message_txt, "SENT", str(result.get('ids', [''])[0])))
conn.commit()
cur.close()
conn.close()
except Exception as e:
logs.append(f"DB ERROR: {e}")
except Exception as e:
logs.append(f"ECHEC vers {num}: {str(e)}")
# Log failure to DB too
try:
conn = psycopg2.connect(**DB_PARAMS)
cur = conn.cursor()
cur.execute("""
INSERT INTO sms_logs (to_number, client_name, message, status)
VALUES (%s, %s, %s, %s)
""", (num, nom_client, message_txt, "ERROR"))
conn.commit()
cur.close()
conn.close()
except: pass
return True, ", ".join(logs)

69
test_analyze_full.py Normal file
View File

@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""
Test complet du système d'analyse avec une vraie image
"""
import sys
import json
import os
# Import du module d'analyse
sys.path.insert(0, '/var/www/lucas')
from analyze import analyze_image
print("="*70)
print("TEST COMPLET DU SYSTÈME D'ANALYSE")
print("="*70)
# Image de test
test_image = "/var/www/lucas/test_alerte.jpg"
if not os.path.exists(test_image):
print(f"✗ Image de test introuvable: {test_image}")
sys.exit(1)
print(f"\n📸 Image testée: {test_image}")
print(f"📊 Taille: {os.path.getsize(test_image)} octets")
print("\n🤖 Lancement de l'analyse IA...")
print("-"*70)
try:
result = analyze_image(test_image)
print("\n✓ ANALYSE TERMINÉE")
print("-"*70)
print(json.dumps(result, indent=2, ensure_ascii=False))
# Analyser le résultat
print("\n📋 INTERPRÉTATION")
print("-"*70)
if result.get("urgence"):
print("🚨 URGENCE DÉTECTÉE")
print(f" Confiance: {result.get('confiance', 'N/A')}%")
print(f" Message: {result.get('message', 'N/A')}")
if "error_details" in result:
print("\n⚠️ DÉTAILS DE L'ERREUR:")
for key, value in result["error_details"].items():
print(f" - {key}: {value}")
else:
print("✓ PAS D'URGENCE")
print(f" Confiance: {result.get('confiance', 'N/A')}%")
print(f" Message: {result.get('message', 'N/A')}")
print("\n" + "="*70)
print("Test terminé avec succès")
print("="*70)
except KeyboardInterrupt:
print("\n\n⚠️ Test interrompu par l'utilisateur")
sys.exit(1)
except Exception as e:
print(f"\n✗ ERREUR LORS DU TEST")
print(f" Type: {type(e).__name__}")
print(f" Message: {str(e)}")
import traceback
traceback.print_exc()
sys.exit(1)

17
test_annuaire.py Normal file
View File

@@ -0,0 +1,17 @@
import google.generativeai as genai
import os
# Configure ta clé API ici (remplace par la vraie !)
os.environ["GOOGLE_API_KEY"] = "AIzaSyC5wIc9nrhtbVesHqodil6V6-WIxkJFSHA"
genai.configure(api_key=os.environ["GOOGLE_API_KEY"])
print("--- DEBUT DU TEST ---")
try:
print("Interrogation de Google...")
for m in genai.list_models():
# On cherche uniquement les modèles qui voient (vision)
if 'vision' in m.name or 'gemini-1.5' in m.name or 'pro' in m.name:
print(f"✅ MODÈLE DISPO : {m.name}")
except Exception as e:
print(f"❌ ERREUR CRITIQUE : {e}")
print("--- FIN DU TEST ---")

123
test_json_robustness.py Executable file
View File

@@ -0,0 +1,123 @@
#!/usr/bin/env python3
"""
Script de test pour valider la robustesse du parsing JSON
"""
import sys
sys.path.insert(0, '/var/www/lucas')
from analyze import nettoyer_json_response, extraire_json_intelligent, valider_reponse_ia
import json
# Tests de différents formats de réponses malformées
test_cases = [
# Cas 1: JSON propre
{
"input": '{"urgence": true, "confiance": 85, "message": "Personne au sol"}',
"expected_success": True,
"description": "JSON propre"
},
# Cas 2: JSON avec markdown
{
"input": '```json\n{"urgence": false, "confiance": 90, "message": "Pièce vide"}\n```',
"expected_success": True,
"description": "JSON dans markdown"
},
# Cas 3: JSON avec texte avant
{
"input": 'Voici mon analyse:\n{"urgence": true, "confiance": 75, "message": "Chute détectée"}',
"expected_success": True,
"description": "JSON avec texte avant"
},
# Cas 4: JSON avec texte après
{
"input": '{"urgence": false, "confiance": 95, "message": "Tout va bien"}\nVoilà mon analyse.',
"expected_success": True,
"description": "JSON avec texte après"
},
# Cas 5: JSON avec espaces
{
"input": ' \n\n {"urgence": true, "confiance": 80, "message": "Urgence"} \n\n ',
"expected_success": True,
"description": "JSON avec espaces"
},
# Cas 6: JSON multiligne
{
"input": '''{
"urgence": true,
"confiance": 88,
"message": "Personne allongée"
}''',
"expected_success": True,
"description": "JSON multiligne"
},
# Cas 7: Réponse complètement invalide
{
"input": 'Ceci n\'est pas du JSON du tout, juste du texte',
"expected_success": False,
"description": "Texte non-JSON"
},
# Cas 8: JSON incomplet
{
"input": '{"urgence": true, "confiance": ',
"expected_success": False,
"description": "JSON incomplet"
},
]
print("="*70)
print("TEST DE ROBUSTESSE DU PARSING JSON")
print("="*70)
passed = 0
failed = 0
for i, test in enumerate(test_cases, 1):
print(f"\n[TEST {i}] {test['description']}")
print(f"Input: {repr(test['input'][:60])}...")
try:
# Tenter l'extraction
result = extraire_json_intelligent(test['input'])
if result:
# Valider le résultat
is_valid = valider_reponse_ia(result)
if is_valid and test['expected_success']:
print(f"✓ SUCCÈS - JSON extrait: {result}")
passed += 1
elif not is_valid and test['expected_success']:
print(f"✗ ÉCHEC - JSON invalide: {result}")
failed += 1
elif is_valid and not test['expected_success']:
print(f"✗ ÉCHEC INATTENDU - JSON trouvé alors qu'on attendait un échec")
failed += 1
else:
print(f"✓ SUCCÈS - Échec attendu correctement géré")
passed += 1
else:
if test['expected_success']:
print(f"✗ ÉCHEC - Aucun JSON extrait")
failed += 1
else:
print(f"✓ SUCCÈS - Échec correctement détecté")
passed += 1
except Exception as e:
if test['expected_success']:
print(f"✗ ÉCHEC - Exception: {type(e).__name__}: {e}")
failed += 1
else:
print(f"✓ SUCCÈS - Exception attendue: {type(e).__name__}")
passed += 1
print("\n" + "="*70)
print(f"RÉSULTATS: {passed} réussis, {failed} échoués sur {len(test_cases)} tests")
print("="*70)
if failed == 0:
print("\n✓ TOUS LES TESTS SONT PASSÉS ! Le système est robuste.")
sys.exit(0)
else:
print(f"\n{failed} test(s) ont échoué. Révision nécessaire.")
sys.exit(1)

75
unregister_token.php Normal file
View File

@@ -0,0 +1,75 @@
<?php
/**
* unregister_token.php — Désinscription token FCM
*
* Retire le token FCM de la liste des tokens du client.
* Appelé quand l'utilisateur se déconnecte ou change de client.
*
* POST /unregister_token.php
* Content-Type: application/json
* {"client_id": "Demo_01", "fcm_token": "fheHxy..."}
*/
header("Content-Type: application/json");
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: POST, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type");
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit; }
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
die(json_encode(["success" => false, "message" => "Méthode non autorisée"]));
}
$input = json_decode(file_get_contents("php://input"), true);
$client_id = $input['client_id'] ?? '';
$fcm_token = $input['fcm_token'] ?? '';
$password = $input['password'] ?? '';
if (empty($client_id) || empty($fcm_token) || empty($password)) {
http_response_code(400);
die(json_encode(["success" => false, "message" => "client_id, fcm_token et password requis"]));
}
$DB_FILE = "database.json";
$db = json_decode(file_get_contents($DB_FILE), true);
if (!$db || !isset($db['clients'])) {
http_response_code(500);
die(json_encode(["success" => false, "message" => "Base de données introuvable"]));
}
$found_key = null;
foreach ($db['clients'] as $key => $c) {
if (strcasecmp($c['name'] ?? '', $client_id) === 0) {
$found_key = $key;
break;
}
}
if ($found_key === null) {
http_response_code(404);
die(json_encode(["success" => false, "message" => "Client inconnu"]));
}
// Vérification mot de passe (bcrypt)
$password_hash = $db['clients'][$found_key]['password_hash'] ?? null;
if ($password_hash === null) {
http_response_code(403);
die(json_encode(["success" => false, "message" => "Compte non configuré"]));
}
if (!password_verify($password, $password_hash)) {
http_response_code(401);
die(json_encode(["success" => false, "message" => "Mot de passe incorrect"]));
}
if (isset($db['clients'][$found_key]['fcm_tokens'])) {
$tokens = $db['clients'][$found_key]['fcm_tokens'];
$new_tokens = array_values(array_filter($tokens, function($t) use ($fcm_token) {
return $t !== $fcm_token;
}));
$db['clients'][$found_key]['fcm_tokens'] = $new_tokens;
file_put_contents($DB_FILE, json_encode($db, JSON_PRETTY_PRINT));
echo json_encode(["success" => true, "message" => "Token retiré"]);
} else {
echo json_encode(["success" => true, "message" => "Aucun token à retirer"]);
}
?>

95
validate_fix.sh Executable file
View File

@@ -0,0 +1,95 @@
#!/bin/bash
echo "╔════════════════════════════════════════════════════════════════════╗"
echo "║ VALIDATION COMPLÈTE DU CORRECTIF ║"
echo "╚════════════════════════════════════════════════════════════════════╝"
echo ""
ERRORS=0
# Test 1: Syntaxe Python
echo "🔍 Test 1: Validation syntaxe Python..."
if python3 -m py_compile /var/www/lucas/analyze.py 2>/dev/null; then
echo " ✅ Syntaxe valide"
else
echo " ❌ Erreur de syntaxe"
ERRORS=$((ERRORS+1))
fi
echo ""
# Test 2: Import du module
echo "🔍 Test 2: Import du module analyze..."
if python3 -c "import sys; sys.path.insert(0, '/var/www/lucas'); import analyze" 2>/dev/null; then
echo " ✅ Import réussi"
else
echo " ❌ Échec d'import"
ERRORS=$((ERRORS+1))
fi
echo ""
# Test 3: Tests de robustesse JSON
echo "🔍 Test 3: Tests de robustesse JSON..."
if python3 /var/www/lucas/test_json_robustness.py 2>&1 | grep -q "8 réussis, 0 échoués"; then
echo " ✅ 8/8 tests passés"
else
echo " ❌ Certains tests ont échoué"
ERRORS=$((ERRORS+1))
fi
echo ""
# Test 4: Vérification des fichiers créés
echo "🔍 Test 4: Vérification des fichiers..."
FILES=(
"/var/www/lucas/analyze.py"
"/var/www/lucas/check_gemini_health.py"
"/var/www/lucas/test_json_robustness.py"
"/var/www/lucas/test_analyze_full.py"
"/var/www/lucas/GEMINI_FIX_README.md"
"/var/www/lucas/CORRECTIF_TECHNIQUE.md"
"/var/www/lucas/CHANGELOG_v2.0.md"
)
for FILE in "${FILES[@]}"; do
if [ -f "$FILE" ]; then
echo "$(basename $FILE)"
else
echo "$(basename $FILE) manquant"
ERRORS=$((ERRORS+1))
fi
done
echo ""
# Test 5: Vérification des permissions
echo "🔍 Test 5: Vérification des permissions..."
if [ -x "/var/www/lucas/analyze.py" ]; then
echo " ✅ analyze.py est exécutable"
else
echo " ⚠️ analyze.py n'est pas exécutable"
fi
echo ""
# Test 6: Taille du fichier principal
echo "🔍 Test 6: Vérification taille analyze.py..."
SIZE=$(stat -f%z /var/www/lucas/analyze.py 2>/dev/null || stat -c%s /var/www/lucas/analyze.py 2>/dev/null)
if [ "$SIZE" -gt "10000" ]; then
echo " ✅ Taille: $SIZE octets (contenu significatif)"
else
echo " ❌ Taille: $SIZE octets (trop petit)"
ERRORS=$((ERRORS+1))
fi
echo ""
# Résumé
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
if [ $ERRORS -eq 0 ]; then
echo "✅ VALIDATION RÉUSSIE - Tous les tests sont passés"
echo ""
echo "Le correctif est prêt pour la production."
echo "Vous pouvez monitorer avec: python3 /var/www/lucas/check_gemini_health.py"
exit 0
else
echo "❌ VALIDATION ÉCHOUÉE - $ERRORS erreur(s) détectée(s)"
echo ""
echo "Veuillez corriger les erreurs avant de déployer."
exit 1
fi