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é

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

┌─────────────────────────────────────────────────────────┐
│                     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.

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<Utc>,       // Timestamp lecture
    pub source: String,                 // "linky_ores", "linky_enedis"
    pub metadata: Option<serde_json::Value>,  // Métadonnées additionnelles
    pub created_at: DateTime<Utc>,
}

// 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.

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<String>,  // OAuth2 access token
    pub refresh_token_encrypted: Option<String>, // OAuth2 refresh token
    pub token_expires_at: Option<DateTime<Utc>>,
    pub last_sync_at: Option<DateTime<Utc>>,
    pub sync_frequency_hours: i32,      // Fréquence sync (24h par défaut)
    pub is_active: bool,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

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

pub async fn sync_linky_data(
    &self,
    building_id: Uuid,
) -> Result<Vec<IoTReading>, 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

// 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

pub async fn get_ores_consumption(
    &self,
    prm: &str,
    access_token: &str,
    start_date: DateTime<Utc>,
    end_date: DateTime<Utc>,
) -> Result<ConsumptionData, String> {
    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

-- 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

-- 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

{
  "prm": "30001234567890",
  "provider": "Ores",
  "authorization_code": "abc123...",
  "redirect_uri": "https://koprogo.com/auth/linky/callback"
}

Response 201 Created

{
  "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

{
  "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

{
  "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

{
  "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

{
  "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)

// backend/src/main.rs

#[tokio::spawn]
async fn schedule_daily_linky_sync(
    iot_use_cases: Arc<IoTUseCases>,
) {
    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.

// 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

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:

{
  "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

┌─────────────────────────────────────────────────────────────┐
│  🏠 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

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

#[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)

#[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)

#[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

use aes_gcm::{Aes256Gcm, Key, Nonce};
use aes_gcm::aead::{Aead, NewAead};

pub fn encrypt_token(plaintext: &str, key: &[u8; 32]) -> Result<String, String> {
    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<String, String> {
    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

# .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

{
  "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

# Backend .env
LINKY_ORES_CLIENT_ID=<ores-client-id>
LINKY_ORES_CLIENT_SECRET=<ores-client-secret>
LINKY_ORES_REDIRECT_URI=https://koprogo.com/auth/linky/callback

LINKY_ENEDIS_CLIENT_ID=<enedis-client-id>
LINKY_ENEDIS_CLIENT_SECRET=<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)