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>
36
.gitignore
vendored
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||||
58
admin/generate_password.php
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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é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é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éné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é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é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é-configurer WiFi hotspot <span class="font-mono text-xs text-amber-400">SmartEye-Setup</span> <span class="text-slate-600 text-xs">(connexion sans é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éparer clé 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érifier que SmartEye tourne <span class="text-slate-600 text-xs">(service actif, YOLO chargé)</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éparer les camé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é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éseau <span class="text-slate-600 text-xs">(hotspot OU clé 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éthode utilisé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 → 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é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é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é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érifier que SmartEye détecte les camé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élé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érifier ré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é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érifier affichage camé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ès distant)</h2>
|
||||||
|
<span class="text-xs text-slate-500 ml-auto">Jetson → 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é SSH du Jetson ajouté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é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é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é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éra live à 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é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érifier réception image + analyse Gemini <span class="text-slate-600 text-xs">(api.php → 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é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é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é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> à <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 à 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é 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 à zé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
@@ -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
@@ -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É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éo</h2>
|
||||||
|
<p class="text-slate-400 text-sm mt-1">Chaque caméra IP diffuse 2 flux RTSP simultanés, utilisés pour des besoins diffé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é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é en continu par YOLO sur le Jetson pour détecter les chutes.</p>
|
||||||
|
<p><span class="text-blue-400 font-bold">Pourquoi basse résolution ?</span> YOLO n'a pas besoin de HD pour détecter une personne. Un flux lé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és)</li>
|
||||||
|
<li>Plusieurs caméras en parallè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> → <span class="text-blue-400">Flux 2 (SD)</span> → <span class="text-slate-400">Jetson YOLO</span> → <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é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ésolution ?</span> L'image de preuve doit être nette pour :</p>
|
||||||
|
<ul class="list-disc list-inside text-slate-400 text-xs space-y-1 ml-2">
|
||||||
|
<li>Analyse précise par Gemini (position, visage, contexte)</li>
|
||||||
|
<li>Preuve visuelle pour la famille dans l'app AVA</li>
|
||||||
|
<li>Archivage en qualité</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> → <span class="text-red-400">Capture HD</span> → <span class="text-emerald-400">Lucas (Gemini)</span> → <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ÉRA IP (ex: 192.168.1.196)
|
||||||
|
│
|
||||||
|
├── <span class="text-blue-400">Flux 2 (subtype=1)</span> — 800x448, 15fps, 300kbps —→ <span class="text-amber-400">Jetson YOLO</span> (détection continue 24/7)
|
||||||
|
│ │
|
||||||
|
│ <span class="text-red-400">Chute détectée !</span>
|
||||||
|
│ │
|
||||||
|
└── <span class="text-red-400">Flux 1 (subtype=0)</span> — 2880x1620, 20fps, 6Mbps —→ <span class="text-amber-400">Capture screenshot HD</span>
|
||||||
|
│
|
||||||
|
POST image → <span class="text-emerald-400">Lucas api.php</span>
|
||||||
|
│
|
||||||
|
<span class="text-emerald-400">Gemini analyse</span> → <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ètres recommandés</h2>
|
||||||
|
<p class="text-slate-400 text-sm mt-1">Configuration optimale pour le système SmartEye. Accessible via l'interface web de la camé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ètres géné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é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é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é par tous les lecteurs RTSP. Le Main Profile offre le meilleur compromis qualité/compatibilité.</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è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é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ésolution max de la caméra. Image nette pour Gemini et la famille.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="px-4 py-3 text-slate-400">Dé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ébit max. Qualité optimale pour la capture de preuve.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="px-4 py-3 text-slate-400">Fré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é</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é par seconde (= intervalle / fps). Capture plus rapide.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="px-4 py-3 text-slate-400">Contrôle dé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ébit constant. Qualité stable même en mouvement.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="px-4 py-3 text-slate-400">Qualité 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é 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ème flux — Dé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è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é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étecte très bien en basse résolution. Économise le GPU.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="px-4 py-3 text-slate-400">Dé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égligeable.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="px-4 py-3 text-slate-400">Fré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étecter un mouvement. Plus = plus de charge GPU inutile.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="px-4 py-3 text-slate-400">Intervalle image clé</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é toutes les 2 secondes. Acceptable pour la détection.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="px-4 py-3 text-slate-400">Contrôle dé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ébit variable. Économise la bande passante quand rien ne bouge.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="px-4 py-3 text-slate-400">Qualité 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é même en SD pour la dé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éras Dahua. Remplacer l'IP par celle de la camé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é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éro de canal de la caméra (1 pour une caméra mono-objectif). Le <span class="font-mono">subtype</span> sé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é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éder à l'interface web de la camé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ètres → Caméra → Vidé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è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érifier que SmartEye reçoit le flux 2 et détecte correctement</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
59
heartbeat.php
Normal 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
@@ -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
328
landing-royaume.html
Normal 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>
|
||||||
3
mkdocs-smarteye/docs/api/endpoints.md
Normal 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.
|
||||||
3
mkdocs-smarteye/docs/api/fcm-payload.md
Normal 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.
|
||||||
3
mkdocs-smarteye/docs/architecture/data-flow.md
Normal 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).
|
||||||
84
mkdocs-smarteye/docs/architecture/overview.md
Normal 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).
|
||||||
|
After Width: | Height: | Size: 454 KiB |
|
After Width: | Height: | Size: 532 KiB |
|
After Width: | Height: | Size: 70 KiB |
BIN
mkdocs-smarteye/docs/assets/images/deploiement/mes-cameras.png
Normal file
|
After Width: | Height: | Size: 578 KiB |
|
After Width: | Height: | Size: 364 KiB |
|
After Width: | Height: | Size: 422 KiB |
|
After Width: | Height: | Size: 260 KiB |
330
mkdocs-smarteye/docs/deploiement/carte-gold.md
Normal 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)
|
||||||
239
mkdocs-smarteye/docs/deploiement/clonage.md
Normal 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)
|
||||||
110
mkdocs-smarteye/docs/deploiement/config-video-camera.md
Normal 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
|
||||||
154
mkdocs-smarteye/docs/deploiement/installation-camera.md
Normal 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**.
|
||||||
|
|
||||||
|
{ 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.
|
||||||
|
|
||||||
|
{ 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.
|
||||||
|
|
||||||
|
{ 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.
|
||||||
|
|
||||||
|
{ 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.
|
||||||
|
|
||||||
|
{ 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.
|
||||||
|
|
||||||
|
{ 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.
|
||||||
|
|
||||||
|
{ 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.
|
||||||
262
mkdocs-smarteye/docs/deploiement/installation-client.md
Normal 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
|
||||||
|
|
||||||
|
{ 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.
|
||||||
380
mkdocs-smarteye/docs/deploiement/jetson-expert.md
Normal 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
|
||||||
|
```
|
||||||
165
mkdocs-smarteye/docs/deploiement/jetson.md
Normal 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>
|
||||||
3
mkdocs-smarteye/docs/guide/alertes.md
Normal 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.
|
||||||
3
mkdocs-smarteye/docs/guide/dashboard.md
Normal 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.
|
||||||
66
mkdocs-smarteye/docs/guide/nouveau-client.md
Normal 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.
|
||||||
|
|
||||||
|
{ 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
|
||||||
60
mkdocs-smarteye/docs/index.md
Normal 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.
|
||||||
57
mkdocs-smarteye/docs/outils/checklist.md
Normal 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)
|
||||||
72
mkdocs-smarteye/mkdocs.yml
Normal 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
@@ -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' => "",
|
||||||
|
'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 • JPG, PNG, GIF, WebP, SVG • 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(' ?>)')">
|
||||||
|
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
@@ -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
|
After Width: | Height: | Size: 16 KiB |
137
provision.php
Normal 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
@@ -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
@@ -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
@@ -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."
|
||||||
|
]);
|
||||||
|
?>
|
||||||
222
shared/deployment/setup_shared.sh
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||