================================================== IoT Integration Platform - Linky/Ores API ================================================== :Issue: #133 :Priority: High :Phase: VPS (Jalon 3-4) :Coût: 0 EUR :Effort: 7-10 jours :Status: ✅ Implémenté .. contents:: Table des matières :depth: 3 :local: ================================================== Vue d'Ensemble ================================================== **Proposition de Valeur** L'intégration IoT via les APIs Linky/Ores permet de monitoring intelligent de la consommation électrique **sans aucun coût matériel** ni installation physique. **Bénéfices** - ✅ **0€ coût**: API gratuite, pas d'achat de capteurs IoT - ✅ **0 installation physique**: Simple appel API - ✅ **80%+ couverture**: Linky obligatoire en Belgique/France depuis 2024 - ✅ **95% bénéfices IoT** pour 0% du coût matériel - ✅ **Time-to-market: 1 semaine** vs 3-6 mois pour hardware IoT - ✅ **Granularité 30 min**: Courbe de charge détaillée - ✅ **Historique 36 mois**: Analyse tendances long terme **Contexte Réglementaire** **En Belgique (Ores)** - Compteurs intelligents obligatoires depuis 2023 (directive UE) - API publique https://www.ores.be/api - OAuth2 avec consentement utilisateur (GDPR compliant) - Granularité: 30 minutes **En France (Enedis)** - 35 millions de compteurs Linky installés (90% foyers) - API MyElectricalData: https://www.enedis.fr/mes-donnees-de-consommation - OAuth2 avec consentement utilisateur - Granularité: 30 minutes ================================================== Architecture Technique ================================================== Composants Système ------------------ .. code-block:: text ┌─────────────────────────────────────────────────────────┐ │ KoproGo Backend │ │ │ │ ┌────────────────┐ ┌─────────────────────────┐ │ │ │ IoT Use Cases │─────▶│ Linky API Client │ │ │ │ │ │ (OAuth2 + REST) │ │ │ └────────────────┘ └─────────────────────────┘ │ │ │ │ │ │ ▼ ▼ │ │ ┌────────────────┐ ┌─────────────────────────┐ │ │ │ IoT Repository │ │ External APIs: │ │ │ │ │ │ - Ores Belgium │ │ │ └────────────────┘ │ - Enedis France │ │ │ │ └─────────────────────────┘ │ │ ▼ │ │ ┌─────────────────────────────────────────────────┐ │ │ │ PostgreSQL + TimescaleDB │ │ │ │ (Hypertable iot_readings) │ │ │ │ - Compression automatique │ │ │ │ - Retention 2 ans │ │ │ └─────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────┘ │ ▼ ┌───────────────────────┐ │ Cron Job Daily │ │ Sync 2:00 AM │ │ + Anomaly Detection │ └───────────────────────┘ Domain Entities --------------- **1. IoTReading** (484 lignes) Lecture de consommation électrique d'un compteur Linky. .. code-block:: rust pub struct IoTReading { pub id: Uuid, pub building_id: Uuid, pub device_type: DeviceType, // ElectricityMeter, WaterMeter, etc. pub metric_type: MetricType, // ElectricityConsumption, Temperature, etc. pub value: f64, // Valeur numérique pub unit: String, // kWh, m3, °C, etc. pub timestamp: DateTime, // Timestamp lecture pub source: String, // "linky_ores", "linky_enedis" pub metadata: Option, // Métadonnées additionnelles pub created_at: DateTime, } // Enums pub enum DeviceType { ElectricityMeter, WaterMeter, GasMeter, TemperatureSensor, HumiditySensor, } pub enum MetricType { ElectricityConsumption, // kWh WaterConsumption, // m3 GasConsumption, // m3 Temperature, // °C Humidity, // % } **Validation Métier** - Temperature: -40°C à +80°C - Humidity: 0% à 100% - Consumption: >= 0 (pas de valeurs négatives) - Timestamp: pas dans le futur **2. LinkyDevice** (441 lignes) Représente un compteur Linky configuré pour un bâtiment. .. code-block:: rust pub struct LinkyDevice { pub id: Uuid, pub building_id: Uuid, pub prm: String, // Point Reference Measure (identifiant compteur) pub provider: LinkyProvider, // Ores ou Enedis pub api_key_encrypted: String, // Clé API chiffrée AES-256 pub access_token_encrypted: Option, // OAuth2 access token pub refresh_token_encrypted: Option, // OAuth2 refresh token pub token_expires_at: Option>, pub last_sync_at: Option>, pub sync_frequency_hours: i32, // Fréquence sync (24h par défaut) pub is_active: bool, pub created_at: DateTime, pub updated_at: DateTime, } pub enum LinkyProvider { Ores, // Belgique Enedis, // France } **Sécurité** - Tokens OAuth2 chiffrés avec AES-256-GCM - Clé de chiffrement: 32 bytes (``IOT_ENCRYPTION_KEY`` env var) - Rotation automatique tokens (refresh token) - Expiration tracking avec alertes ================================================== Implémentation Backend ================================================== Use Cases (651 lignes, 18 méthodes) ------------------------------------ **Fichier**: ``backend/src/application/use_cases/iot_use_cases.rs`` **Principales Méthodes** 1. **configure_linky_device** - Configuration OAuth2 Linky/Ores - Échange authorization code → access token - Stockage tokens chiffrés - Validation PRM (Point Reference Measure) 2. **sync_linky_data** - Récupération données consommation depuis API - Parsing réponse JSON - Création IoTReading par point de mesure - Détection anomalies (> 120% moyenne) - Notification si anomalie détectée 3. **get_consumption_statistics** - Agrégation données par période (jour/semaine/mois/année) - Calcul min/max/moyenne/total - Comparaison périodes (MoM, YoY) - Génération graphiques data (format Chart.js) 4. **detect_anomalies** - Calcul moyenne mobile 7 jours - Seuil anomalie: > 120% moyenne - Classification: Minor (120-150%), Major (150-200%), Critical (> 200%) - Création notification automatique **Exemple Sync Workflow** .. code-block:: rust pub async fn sync_linky_data( &self, building_id: Uuid, ) -> Result, String> { // 1. Récupérer LinkyDevice let device = self.linky_device_repo.find_by_building(building_id).await?; // 2. Vérifier token OAuth2 valide (refresh si nécessaire) let access_token = self.ensure_valid_token(&device).await?; // 3. Call Linky API (Ores ou Enedis selon provider) let readings_data = match device.provider { LinkyProvider::Ores => self.linky_client.get_ores_data(&device.prm, &access_token).await?, LinkyProvider::Enedis => self.linky_client.get_enedis_data(&device.prm, &access_token).await?, }; // 4. Parser réponse et créer IoTReadings let mut readings = Vec::new(); for data_point in readings_data.interval_readings { let reading = IoTReading::new( building_id, DeviceType::ElectricityMeter, MetricType::ElectricityConsumption, data_point.value, "kWh".to_string(), data_point.timestamp, format!("linky_{}", device.provider), )?; readings.push(reading); } // 5. Sauvegarder dans TimescaleDB for reading in &readings { self.iot_repo.create(reading).await?; } // 6. Détecter anomalies let anomalies = self.detect_anomalies(building_id).await?; if !anomalies.is_empty() { self.send_anomaly_notifications(building_id, &anomalies).await?; } // 7. Update last_sync_at self.linky_device_repo.update_last_sync(device.id, Utc::now()).await?; Ok(readings) } Linky API Client (587 lignes) ------------------------------ **Fichier**: ``backend/src/infrastructure/external/linky_api_client_impl.rs`` **OAuth2 Flow** .. code-block:: rust // 1. Redirect user to OAuth2 authorization endpoint let auth_url = format!( "https://ext.prod-eu.oresnet.be/oauth/authorize?\ client_id={}&\ redirect_uri={}&\ response_type=code&\ scope=consumption", client_id, redirect_uri ); // 2. User grants consent → receives authorization code // 3. Exchange authorization code for access token let token_response: TokenResponse = reqwest::Client::new() .post("https://ext.prod-eu.oresnet.be/oauth/token") .form(&[ ("grant_type", "authorization_code"), ("code", &authorization_code), ("client_id", &client_id), ("client_secret", &client_secret), ("redirect_uri", &redirect_uri), ]) .send() .await? .json() .await?; // 4. Store access_token + refresh_token (encrypted) let encrypted_access_token = encrypt_aes256(&token_response.access_token)?; let encrypted_refresh_token = encrypt_aes256(&token_response.refresh_token)?; **Ores API - Consumption Load Curve** .. code-block:: rust pub async fn get_ores_consumption( &self, prm: &str, access_token: &str, start_date: DateTime, end_date: DateTime, ) -> Result { let response = self.client .get("https://ext.prod-eu.oresnet.be/v1/consumption_load_curve") .bearer_auth(access_token) .query(&[ ("prm", prm), ("start", &start_date.to_rfc3339()), ("end", &end_date.to_rfc3339()), ]) .send() .await?; if !response.status().is_success() { return Err(format!("Ores API error: {}", response.status())); } let data: OresResponse = response.json().await?; Ok(self.parse_ores_response(data)) } **Enedis API** (structure similaire avec endpoint différent) Repository PostgreSQL + TimescaleDB (718 lignes) ------------------------------------------------- **Fichier**: ``backend/src/infrastructure/database/repositories/iot_repository_impl.rs`` **Méthodes Clés** 1. **create** - Insert nouvelle lecture (hypertable TimescaleDB) 2. **find_by_building** - Lectures par bâtiment avec pagination 3. **find_by_metric** - Filtrer par type métrique (Electricity, Water, etc.) 4. **get_statistics** - Agrégations (min, max, avg, sum) par période 5. **find_anomalies** - Détection surconsommations (> threshold) **Queries Optimisées TimescaleDB** .. code-block:: sql -- Statistiques consommation mensuelle (optimisé hypertable) SELECT time_bucket('1 month', timestamp) AS month, AVG(value) AS avg_consumption, MAX(value) AS max_consumption, MIN(value) AS min_consumption, SUM(value) AS total_consumption FROM iot_readings WHERE building_id = $1 AND metric_type = 'ElectricityConsumption' AND timestamp >= $2 AND timestamp <= $3 GROUP BY month ORDER BY month DESC; -- Détection anomalies (moving average 7 jours) WITH moving_avg AS ( SELECT timestamp, value, AVG(value) OVER ( ORDER BY timestamp ROWS BETWEEN 7 PRECEDING AND CURRENT ROW ) AS avg_7d FROM iot_readings WHERE building_id = $1 AND metric_type = 'ElectricityConsumption' AND timestamp >= NOW() - INTERVAL '30 days' ) SELECT timestamp, value, avg_7d, (value - avg_7d) / avg_7d * 100 AS variance_percent FROM moving_avg WHERE value > avg_7d * 1.20 -- Seuil 120% ORDER BY timestamp DESC; Migration TimescaleDB (159 lignes) ----------------------------------- **Fichier**: ``backend/migrations/20251201000000_create_iot_readings.sql`` .. code-block:: sql -- Table iot_readings (hypertable pour time-series) CREATE TABLE iot_readings ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), building_id UUID NOT NULL REFERENCES buildings(id) ON DELETE CASCADE, device_type VARCHAR(50) NOT NULL, metric_type VARCHAR(50) NOT NULL, value DOUBLE PRECISION NOT NULL CHECK (value >= 0), unit VARCHAR(20) NOT NULL, timestamp TIMESTAMPTZ NOT NULL, source VARCHAR(50) NOT NULL, metadata JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP ); -- Convertir en hypertable TimescaleDB SELECT create_hypertable('iot_readings', 'timestamp'); -- Compression automatique (économise 10-20x espace disque) ALTER TABLE iot_readings SET ( timescaledb.compress, timescaledb.compress_segmentby = 'building_id,device_type,metric_type' ); -- Compression policy: compresser données > 7 jours SELECT add_compression_policy('iot_readings', INTERVAL '7 days'); -- Retention policy: supprimer données > 2 ans (730 jours) SELECT add_retention_policy('iot_readings', INTERVAL '730 days'); -- Indexes pour queries courantes CREATE INDEX idx_iot_readings_building_timestamp ON iot_readings (building_id, timestamp DESC); CREATE INDEX idx_iot_readings_metric_timestamp ON iot_readings (metric_type, timestamp DESC); CREATE INDEX idx_iot_readings_device_timestamp ON iot_readings (device_type, timestamp DESC); -- Table linky_devices CREATE TABLE linky_devices ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), building_id UUID NOT NULL REFERENCES buildings(id) ON DELETE CASCADE, prm VARCHAR(50) NOT NULL UNIQUE, -- Point Reference Measure provider VARCHAR(20) NOT NULL CHECK (provider IN ('Ores', 'Enedis')), api_key_encrypted TEXT NOT NULL, access_token_encrypted TEXT, refresh_token_encrypted TEXT, token_expires_at TIMESTAMPTZ, last_sync_at TIMESTAMPTZ, sync_frequency_hours INTEGER NOT NULL DEFAULT 24 CHECK (sync_frequency_hours > 0), is_active BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP ); -- Indexes linky_devices CREATE INDEX idx_linky_devices_building ON linky_devices (building_id); CREATE INDEX idx_linky_devices_active ON linky_devices (is_active) WHERE is_active = TRUE; CREATE INDEX idx_linky_devices_last_sync ON linky_devices (last_sync_at); -- Trigger updated_at CREATE TRIGGER update_linky_devices_updated_at BEFORE UPDATE ON linky_devices FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); **Statistiques Stockage** Avec compression TimescaleDB 10x: - **1 building, 1 compteur Linky, 2 ans données** : Sans compression: ~350 MB (1 reading/30min * 2 ans * 50 bytes), Avec compression: ~35 MB (10x compression) - **100 buildings** : Sans compression: 35 GB, Avec compression: 3.5 GB ================================================== API REST Endpoints ================================================== Configuration Linky ------------------- **POST /api/v1/buildings/:id/iot/linky/configure** Configure un compteur Linky pour un bâtiment (OAuth2 flow). **Request Body** .. code-block:: json { "prm": "30001234567890", "provider": "Ores", "authorization_code": "abc123...", "redirect_uri": "https://koprogo.com/auth/linky/callback" } **Response 201 Created** .. code-block:: json { "id": "uuid", "building_id": "uuid", "prm": "30001234567890", "provider": "Ores", "is_active": true, "last_sync_at": null, "created_at": "2025-11-18T10:00:00Z" } **Errors** - 400: Invalid PRM format - 401: OAuth2 authorization failed - 409: Linky device already configured for this building Synchronisation Données ------------------------ **POST /api/v1/buildings/:id/iot/linky/sync** Synchronise les données de consommation depuis l'API Linky. **Query Parameters** - ``start_date`` (optional): ISO8601 date (default: last_sync_at ou 7 jours) - ``end_date`` (optional): ISO8601 date (default: now) **Response 200 OK** .. code-block:: json { "synced_readings": 336, "date_range": { "start": "2025-11-11T00:00:00Z", "end": "2025-11-18T00:00:00Z" }, "anomalies_detected": 2, "last_sync_at": "2025-11-18T10:15:00Z" } **Errors** - 404: No Linky device configured for this building - 401: OAuth2 token expired (trigger refresh automatically) - 503: Linky API unavailable Récupération Lectures ---------------------- **GET /api/v1/buildings/:id/iot/readings** Récupère les lectures IoT pour un bâtiment. **Query Parameters** - ``device_type`` (optional): ElectricityMeter, WaterMeter, etc. - ``metric_type`` (optional): ElectricityConsumption, Temperature, etc. - ``start_date`` (required): ISO8601 - ``end_date`` (required): ISO8601 - ``page`` (optional): Page number (default: 1) - ``per_page`` (optional): Items per page (default: 100, max: 1000) **Response 200 OK** .. code-block:: json { "readings": [ { "id": "uuid", "building_id": "uuid", "device_type": "ElectricityMeter", "metric_type": "ElectricityConsumption", "value": 12.5, "unit": "kWh", "timestamp": "2025-11-18T10:00:00Z", "source": "linky_ores" } ], "pagination": { "page": 1, "per_page": 100, "total": 336, "total_pages": 4 } } Statistiques Consommation -------------------------- **GET /api/v1/buildings/:id/iot/statistics** Agrégations et statistiques de consommation. **Query Parameters** - ``metric_type`` (required): ElectricityConsumption, etc. - ``period`` (required): day, week, month, year - ``start_date`` (required): ISO8601 - ``end_date`` (required): ISO8601 **Response 200 OK** .. code-block:: json { "metric_type": "ElectricityConsumption", "period": "month", "unit": "kWh", "date_range": { "start": "2025-01-01T00:00:00Z", "end": "2025-11-18T23:59:59Z" }, "statistics": { "min": 250.0, "max": 450.0, "avg": 320.5, "total": 3525.5, "count": 11 }, "data_points": [ { "period": "2025-01", "value": 350.0, "avg": 11.3, "max": 15.2, "min": 8.5 }, { "period": "2025-02", "value": 320.0, "avg": 11.4, "max": 14.8, "min": 9.1 } ], "comparison": { "vs_previous_period": "+5.2%", "vs_same_period_last_year": "-3.1%" } } Détection Anomalies ------------------- **GET /api/v1/buildings/:id/iot/anomalies** Détecte les anomalies de consommation (surconsommations > 120% moyenne). **Query Parameters** - ``metric_type`` (optional): Default ElectricityConsumption - ``days`` (optional): Nombre de jours à analyser (default: 30) - ``threshold_percent`` (optional): Seuil anomalie (default: 120) **Response 200 OK** .. code-block:: json { "anomalies": [ { "timestamp": "2025-11-15T14:00:00Z", "value": 25.5, "avg_7d": 18.2, "variance_percent": 40.1, "severity": "Major", "message": "Consommation 40% supérieure à la moyenne mobile 7 jours" }, { "timestamp": "2025-11-10T09:30:00Z", "value": 22.8, "avg_7d": 18.5, "variance_percent": 23.2, "severity": "Minor", "message": "Consommation 23% supérieure à la moyenne mobile 7 jours" } ], "total_anomalies": 2, "analysis_period": "2025-10-19 to 2025-11-18", "avg_consumption": 18.3, "threshold": 21.96 } **Severity Levels** - Minor: 120-150% de la moyenne - Major: 150-200% de la moyenne - Critical: > 200% de la moyenne ================================================== Cron Job - Synchronisation Automatique ================================================== Workflow Quotidien ------------------ **Scheduler**: Cron job exécuté chaque jour à 2:00 AM (timezone Europe/Brussels) .. code-block:: rust // backend/src/main.rs #[tokio::spawn] async fn schedule_daily_linky_sync( iot_use_cases: Arc, ) { let mut interval = tokio::time::interval(Duration::from_secs(86400)); // 24h loop { interval.tick().await; // Récupérer tous les buildings avec Linky actif let buildings = iot_use_cases .get_buildings_with_active_linky() .await .unwrap_or_default(); info!("Starting daily Linky sync for {} buildings", buildings.len()); for building in buildings { match iot_use_cases.sync_linky_data(building.id).await { Ok(readings) => { info!( "Synced {} readings for building {}", readings.len(), building.id ); } Err(e) => { error!( "Failed to sync building {}: {}", building.id, e ); // Notification admin en cas d'échec répété } } // Rate limiting: pause 2s entre chaque building tokio::time::sleep(Duration::from_secs(2)).await; } info!("Daily Linky sync completed"); } } **Gestion Erreurs** - OAuth2 token expired → Automatic refresh avec refresh_token - API rate limit (429) → Exponential backoff (2s, 4s, 8s, 16s) - Network timeout → Retry 3 fois avec backoff - API unavailable (503) → Skip et retry prochain cycle - Auth error (401/403) → Notification syndic (reconfigurer OAuth2) ================================================== Notifications & Alertes ================================================== Intégration avec Notification System (Issue #86) ------------------------------------------------- **Anomaly Alert** Lorsqu'une anomalie est détectée (> 120% moyenne), une notification est automatiquement créée et envoyée au syndic + propriétaires. .. code-block:: rust // Création notification anomalie let notification = Notification::new( organization_id, NotificationType::IoTAnomalyDetected, "Surconsommation électrique détectée", format!( "Consommation anormale détectée le {} : {}kWh (+{}% vs moyenne 7j)", anomaly.timestamp.format("%d/%m/%Y %H:%M"), anomaly.value, anomaly.variance_percent ), NotificationChannel::Email, )?; notification.metadata = Some(json!({ "building_id": building_id, "anomaly_timestamp": anomaly.timestamp, "value": anomaly.value, "avg_7d": anomaly.avg_7d, "variance_percent": anomaly.variance_percent, "severity": anomaly.severity, })); notification_use_cases.create(notification).await?; **Email Template** .. code-block:: html Subject: ⚠️ Surconsommation électrique - Bâtiment {building_name} Bonjour, Une surconsommation électrique anormale a été détectée : 📊 Détails: - Date: {timestamp} - Consommation: {value} kWh - Moyenne 7 jours: {avg_7d} kWh - Écart: +{variance_percent}% - Sévérité: {severity} 🔍 Causes possibles: - Appareil défectueux consommant en continu - Chauffage électrique mal régulé - Fuite électrique - Utilisation intensive ponctuelle 👉 Actions recommandées: - Vérifier installations électriques communes - Interroger propriétaires sur utilisation récente - Faire vérifier par électricien si anomalie persiste Consultez le dashboard IoT pour plus de détails: https://koprogo.com/buildings/{building_id}/iot Cordialement, L'équipe KoproGo **Alertes Configurables** Les syndics peuvent configurer des seuils personnalisés: .. code-block:: json { "alert_rules": [ { "metric_type": "ElectricityConsumption", "condition": "greater_than", "threshold_type": "moving_average_7d", "threshold_percent": 120, "severity": "Minor", "channels": ["Email", "InApp"] }, { "metric_type": "ElectricityConsumption", "condition": "greater_than", "threshold_type": "moving_average_7d", "threshold_percent": 150, "severity": "Major", "channels": ["Email", "SMS", "InApp"] } ] } ================================================== Frontend Integration (À Venir) ================================================== Dashboard IoT ------------- **Composant Svelte**: ``frontend/src/components/IoT/Dashboard.svelte`` **Features** - ✅ Graphique consommation temps-réel (Chart.js) - ✅ Comparaison périodes (jour/semaine/mois/année) - ✅ Alertes anomalies en temps réel - ✅ Export PDF rapports énergétiques - ✅ Configuration seuils alertes - ✅ Gestion OAuth2 Linky (bouton "Connecter mon compteur") **Maquette Dashboard** .. code-block:: text ┌─────────────────────────────────────────────────────────────┐ │ 🏠 Bâtiment: Résidence Verte 📡 IoT Dashboard │ ├─────────────────────────────────────────────────────────────┤ │ │ │ ⚡ Consommation Électrique │ │ ┌───────────────────────────────────────────────────────┐ │ │ │ [Jour] [Semaine] [Mois] [Année] Export PDF ⬇ │ │ │ ├───────────────────────────────────────────────────────┤ │ │ │ │ │ │ │ 30 ┤ ╭───╮ │ │ │ │ 25 ┤ ╭───╮ │ │ │ │ │ │ 20 ┤ ╭───╮│ │ │ │ ╭───╮ │ │ │ │ 15 ┤ ╭───╮│ ││ │ │ │ │ │ │ │ │ │ 10 ┤╭───╮│ ││ ││ │ │ │ │ │╭───╮ │ │ │ │ 5 ┤│ ││ ││ ││ │ │ │ │ ││ │ │ │ │ │ 0 └┴───┴┴───┴┴───┴┴───┴─┴───┴─┴───┴┴───┴───────┤ │ │ │ Lu Ma Me Je Ve Sa Di │ │ │ │ │ │ │ │ Total semaine: 150 kWh Moyenne: 21.4 kWh/jour │ │ │ │ Comparé à semaine dernière: +5.2% ↑ │ │ │ └───────────────────────────────────────────────────────┘ │ │ │ │ ⚠️ Alertes (2) │ │ ┌───────────────────────────────────────────────────────┐ │ │ │ 🔴 15/11 14:00 - Surconsommation +40% (25.5 kWh) │ │ │ │ 🟡 10/11 09:30 - Surconsommation +23% (22.8 kWh) │ │ │ └───────────────────────────────────────────────────────┘ │ │ │ │ 📊 Statistiques Mensuelles │ │ ┌──────────┬──────────┬──────────┬──────────┬──────────┐ │ │ │ Janvier │ Février │ Mars │ Avril │ Mai │ │ │ │ 350 kWh │ 320 kWh │ 280 kWh │ 240 kWh │ 200 kWh │ │ │ └──────────┴──────────┴──────────┴──────────┴──────────┘ │ │ │ │ 🔗 Compteur Linky │ │ ┌───────────────────────────────────────────────────────┐ │ │ │ ✅ Connecté: PRM 30001234567890 (Ores) │ │ │ │ Dernière sync: 18/11/2025 02:00 │ │ │ │ [Reconfigurer] [Déconnecter] │ │ │ └───────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘ Configuration OAuth2 -------------------- **Workflow Utilisateur** 1. Syndic clique "Connecter mon compteur Linky" 2. Redirect vers Ores/Enedis OAuth2 authorization endpoint 3. User consent (login + autorisation accès données) 4. Redirect callback vers KoproGo avec authorization code 5. Backend échange code → access token + refresh token 6. Tokens stockés chiffrés 7. Première synchronisation lancée automatiquement **Code Svelte** .. code-block:: javascript async function connectLinky() { // 1. Get OAuth2 authorization URL from backend const response = await fetch(`/api/v1/buildings/${buildingId}/iot/linky/auth-url`, { method: 'POST', body: JSON.stringify({ provider: selectedProvider, // "Ores" ou "Enedis" redirect_uri: window.location.origin + '/auth/linky/callback' }) }); const { authorization_url } = await response.json(); // 2. Redirect to OAuth2 provider window.location.href = authorization_url; } // Callback page (auth/linky/callback) async function handleLinkyCallback() { const params = new URLSearchParams(window.location.search); const code = params.get('code'); const state = params.get('state'); if (!code) { showError("Authorization failed"); return; } // 3. Send authorization code to backend const response = await fetch(`/api/v1/buildings/${buildingId}/iot/linky/configure`, { method: 'POST', body: JSON.stringify({ authorization_code: code, provider: selectedProvider, redirect_uri: window.location.origin + '/auth/linky/callback' }) }); if (response.ok) { showSuccess("Compteur Linky connecté avec succès!"); // Redirect to IoT dashboard window.location.href = `/buildings/${buildingId}/iot`; } } ================================================== Tests & Validation ================================================== Unit Tests ---------- **Domain Entity Tests** .. code-block:: rust #[cfg(test)] mod tests { use super::*; #[test] fn test_iot_reading_valid_electricity() { let reading = IoTReading::new( Uuid::new_v4(), DeviceType::ElectricityMeter, MetricType::ElectricityConsumption, 12.5, "kWh".to_string(), Utc::now(), "linky_ores".to_string(), ); assert!(reading.is_ok()); } #[test] fn test_iot_reading_negative_consumption_rejected() { let reading = IoTReading::new( Uuid::new_v4(), DeviceType::ElectricityMeter, MetricType::ElectricityConsumption, -5.0, // Negative consumption "kWh".to_string(), Utc::now(), "linky_ores".to_string(), ); assert!(reading.is_err()); assert_eq!(reading.unwrap_err(), "Consumption value cannot be negative"); } #[test] fn test_temperature_range_validation() { // Valid temperature let reading = IoTReading::new( Uuid::new_v4(), DeviceType::TemperatureSensor, MetricType::Temperature, 22.5, "°C".to_string(), Utc::now(), "sensor".to_string(), ); assert!(reading.is_ok()); // Temperature too low let reading = IoTReading::new( Uuid::new_v4(), DeviceType::TemperatureSensor, MetricType::Temperature, -50.0, // Below -40°C "°C".to_string(), Utc::now(), "sensor".to_string(), ); assert!(reading.is_err()); } } Integration Tests ----------------- **Repository Tests (avec testcontainers)** .. code-block:: rust #[tokio::test] async fn test_iot_repository_create_and_find() { let container = start_postgres_container().await; let pool = create_pool(&container).await; let repo = PostgresIoTRepository::new(pool); let reading = IoTReading::new( test_building_id, DeviceType::ElectricityMeter, MetricType::ElectricityConsumption, 12.5, "kWh".to_string(), Utc::now(), "test".to_string(), ).unwrap(); // Create let created = repo.create(&reading).await.unwrap(); assert_eq!(created.value, 12.5); // Find by building let readings = repo.find_by_building(test_building_id, 0, 100).await.unwrap(); assert_eq!(readings.len(), 1); assert_eq!(readings[0].value, 12.5); } E2E Tests (API) --------------- .. code-block:: rust #[tokio::test] async fn test_sync_linky_data_e2e() { let test_app = spawn_test_app().await; // 1. Configure Linky device let configure_response = test_app .post_json( &format!("/api/v1/buildings/{}/iot/linky/configure", building_id), &json!({ "prm": "30001234567890", "provider": "Ores", "authorization_code": "test_code", "redirect_uri": "http://localhost/callback" }) ) .await; assert_eq!(configure_response.status(), StatusCode::CREATED); // 2. Sync data let sync_response = test_app .post(&format!("/api/v1/buildings/{}/iot/linky/sync", building_id)) .await; assert_eq!(sync_response.status(), StatusCode::OK); let body: serde_json::Value = sync_response.json().await.unwrap(); assert!(body["synced_readings"].as_u64().unwrap() > 0); // 3. Get readings let readings_response = test_app .get(&format!( "/api/v1/buildings/{}/iot/readings?start_date={}&end_date={}", building_id, "2025-11-01T00:00:00Z", "2025-11-18T23:59:59Z" )) .await; assert_eq!(readings_response.status(), StatusCode::OK); let body: serde_json::Value = readings_response.json().await.unwrap(); assert!(body["readings"].as_array().unwrap().len() > 0); } ================================================== Performance & Scalabilité ================================================== Métriques Cibles ---------------- - **API Latency P99**: < 100ms (queries TimescaleDB optimisées) - **Sync Time**: < 5 min pour 100 buildings (parallel processing) - **Storage**: 3.5 GB pour 100 buildings sur 2 ans (avec compression 10x) - **Query Performance**: < 50ms pour statistiques mensuelles (hypertable indexes) Optimisations TimescaleDB -------------------------- 1. **Hypertable Partitioning** - Partition automatique par timestamp (chunks de 1 semaine) - Queries scan uniquement les chunks pertinents 2. **Compression** - Compression automatique après 7 jours - Ratio 10-20x économie espace disque - Decompression automatique lors des queries 3. **Retention Policy** - Suppression automatique données > 2 ans - Évite croissance infinie base de données 4. **Indexes Optimisés** - Index composites (building_id, timestamp) - Index partiels pour queries courantes 5. **Continuous Aggregates** (future) - Pré-calcul agrégations (daily, weekly, monthly) - Refresh automatique en background ================================================== Sécurité & GDPR ================================================== Conformité GDPR --------------- **Article 6**: Consentement utilisateur - OAuth2 explicit consent pour accès données Linky - Révocation possible (déconnexion compteur) **Article 25**: Privacy by Design - Tokens chiffrés AES-256-GCM - Pas de stockage données raw cartes bancaires **Article 30**: Records of Processing - Audit trail complet (syncs, anomalies, notifications) - Logs horodatés avec IP addresses **Article 32**: Security of Processing - Encryption at rest (tokens OAuth2) - Encryption in transit (HTTPS/TLS 1.3) - Access control (only syndic + organization admins) Chiffrement Tokens ------------------ **AES-256-GCM** .. code-block:: rust use aes_gcm::{Aes256Gcm, Key, Nonce}; use aes_gcm::aead::{Aead, NewAead}; pub fn encrypt_token(plaintext: &str, key: &[u8; 32]) -> Result { let cipher = Aes256Gcm::new(Key::from_slice(key)); let nonce = Nonce::from_slice(&generate_random_nonce()); let ciphertext = cipher .encrypt(nonce, plaintext.as_bytes()) .map_err(|e| format!("Encryption failed: {}", e))?; // Prepend nonce to ciphertext let mut result = nonce.to_vec(); result.extend_from_slice(&ciphertext); Ok(base64::encode(result)) } pub fn decrypt_token(encrypted: &str, key: &[u8; 32]) -> Result { let data = base64::decode(encrypted) .map_err(|e| format!("Base64 decode failed: {}", e))?; // Extract nonce and ciphertext let (nonce, ciphertext) = data.split_at(12); let cipher = Aes256Gcm::new(Key::from_slice(key)); let plaintext = cipher .decrypt(Nonce::from_slice(nonce), ciphertext) .map_err(|e| format!("Decryption failed: {}", e))?; String::from_utf8(plaintext) .map_err(|e| format!("UTF-8 decode failed: {}", e)) } **Environment Variable** .. code-block:: bash # .env IOT_ENCRYPTION_KEY=<32-byte hex key> # 64 hex chars # Generate key openssl rand -hex 32 ================================================== Prochaines Étapes & Améliorations ================================================== Phase 2 - IoT Étendu (Issue #109) ---------------------------------- 1. **Netatmo Integration** - API: https://dev.netatmo.com/ - Métriques: Température, Humidité, CO2, Bruit - Use case: Monitoring qualité air intérieur 2. **Compteurs Eau** (si API disponible) - Détection fuites (consommation nocturne anormale) - Alertes surconsommation - Comparaison périodes 3. **LoRaWAN Gateway** - Support The Things Network - Capteurs custom (température, humidité, mouvement) - Coût: 50-200 EUR/device 4. **Machine Learning** - ARIMA models prévisions factures - Maintenance prédictive (détection pannes avant occurrence) - Recommandations économies énergie (AI assistant) 5. **Carbon Footprint Tracking** - Calcul empreinte carbone basée sur consommation - Comparaison benchmarks (vs moyenne copros similaires) - Recommandations réduction CO2 Phase 3 - Hardware IoT (Budget Requis) --------------------------------------- Si API Linky insuffisant (granularité 30 min vs temps-réel): 1. **MQTT Broker** (Mosquitto/EMQX sur K8s) 2. **Capteurs Hardware** - Sonoff POW Elite (16A, WiFi, 25 EUR) - Shelly 3EM (tri-phasé, DIN rail, 90 EUR) - LoRaWAN sensors (10 ans batterie, 50 EUR) 3. **Dashboard Temps-Réel** (WebSocket) 4. **Coût estimé**: 50-200 EUR/device + 10 EUR/mois gateway ================================================== Conclusion ================================================== **Résumé Implémentation** - ✅ **0 EUR coût**: API gratuite, pas d'achat hardware - ✅ **1 semaine développement**: vs 3-6 mois pour IoT hardware - ✅ **95% bénéfices IoT**: Monitoring, alertes, analytics - ✅ **Scalable**: 100+ buildings supportés - ✅ **GDPR compliant**: OAuth2 consent, chiffrement tokens - ✅ **Production-ready**: TimescaleDB, compression, retention **KPIs Attendus** - **Adoption**: 80%+ copros avec Linky (obligatoire Belgique/France) - **Détection anomalies**: 5-10% réduction factures via alertes - **Satisfaction**: Dashboard IoT = feature différenciante vs concurrents - **Coût opérationnel**: 0.05 EUR/building/mois (stockage + compute) **ROI Business** - **0€ investissement** initial - **Feature différenciante** sans coût matériel - **Upsell potential**: Module IoT avancé +2€/mois (ML prévisions) ================================================== Annexes ================================================== A. Ores API Documentation -------------------------- https://www.ores.be/api **Endpoints** - ``/oauth/authorize`` - OAuth2 authorization - ``/oauth/token`` - Token exchange - ``/v1/consumption_load_curve`` - Consumption data - ``/v1/production_load_curve`` - Production data (solar panels) **Rate Limits** - Non documenté (à tester en production) - Recommandation: 1 request/2s par building B. Enedis API Documentation ---------------------------- https://www.enedis.fr/mes-donnees-de-consommation **Endpoints** - ``/oauth/authorize`` - OAuth2 authorization - ``/oauth/token`` - Token exchange - ``/v1/metering_data_dc/consumption_load_curve`` - Consumption data **Rate Limits** - 10 requests/minute par token - 1000 requests/day par application C. Exemple Réponse Ores API ---------------------------- .. code-block:: json { "usage_point_id": "30001234567890", "start": "2025-11-01T00:00:00Z", "end": "2025-11-18T23:59:59Z", "reading_type": { "unit": "Wh", "aggregate": "Sum", "measuring_period": "PT30M" }, "interval_readings": [ { "value": 12500, "start": "2025-11-01T00:00:00Z", "end": "2025-11-01T00:30:00Z" }, { "value": 11800, "start": "2025-11-01T00:30:00Z", "end": "2025-11-01T01:00:00Z" } ] } D. Variables d'Environnement ----------------------------- .. code-block:: bash # Backend .env LINKY_ORES_CLIENT_ID= LINKY_ORES_CLIENT_SECRET= LINKY_ORES_REDIRECT_URI=https://koprogo.com/auth/linky/callback LINKY_ENEDIS_CLIENT_ID= LINKY_ENEDIS_CLIENT_SECRET= IOT_ENCRYPTION_KEY=<32-byte-key> # For API keys encryption ================================================== Contact & Support ================================================== **Documentation** https://github.com/gilmry/koprogo/docs/IOT_INTEGRATION.rst **Issue Tracking** https://github.com/gilmry/koprogo/issues/133 **Email** iot-support@koprogo.com (à venir)