From dfaa240d5af233f64bbc0c7010e9a002262500e9 Mon Sep 17 00:00:00 2001 From: Eric FELIXINE Date: Fri, 8 May 2026 01:10:30 -0400 Subject: [PATCH] =?UTF-8?q?fix:=20Stabilisation=20compl=C3=A8te=20Smart=20?= =?UTF-8?q?City=20Digital=20Twin=20Martinique?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Correction simulateur: nettoyage code FIWARE (erreurs syntaxe) - Grafana: dashboard complet 10 panneaux sur grafana.digitribe.fr - InfluxDB: datasource corrigée (bucket smartcity, org digitribe) - Nettoyage: suppression services FIWARE (Orion-LD, Stellio, QuantumLeap) - Pipeline validé: Simulator → 3 MQTT brokers → Telegraf → InfluxDB → Grafana - Dashboard URL: https://grafana.digitribe.fr/d/smartcity-martinique-complete/ Architecture simplifiée: - 3 MQTT brokers (EMQX, Mosquitto, BunkerM) - Telegraf pour agrégation - InfluxDB pour stockage time-series - Grafana pour visualisation (Traefik: grafana.digitribe.fr) --- ARCHITECTURE.md | 93 +++++++ create_dashboard_complete.py | 257 ++++++++++++++++++ docker-compose.yml | 29 ++- grafana-dashboard-complete.json | 377 +++++++++++++++++++++++++++ import_complete.py | 23 ++ simulator.py | 448 +++++++++----------------------- 6 files changed, 905 insertions(+), 322 deletions(-) create mode 100644 ARCHITECTURE.md create mode 100644 create_dashboard_complete.py create mode 100644 grafana-dashboard-complete.json create mode 100644 import_complete.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 00000000..092c9ada --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,93 @@ +# Smart City Digital Twin - Martinique +## Nouvelle Architecture (Mise à jour 08/05/2026) + +### Stack Simplifiée +``` +Simulateur Python (60 capteurs) + ↓ +┌───────────────────────────────────────────────┐ +│ 3 Brokers MQTT │ +│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ +│ │ EMQX │ │ Mosquitto │ │ BunkerM │ │ +│ │(emqx_emqx_1)│ │(smart-city- │ │(bunkerm_ │ │ +│ │ │ │ mosquitto) │ │ bunkerm_1)│ │ +│ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ │ +└────────┼──────────────┼──────────────┼──────────────┘ + │ │ │ + └──────────────┴──────────────┘ + ↓ + Telegraf (3 inputs MQTT) + ↓ + InfluxDB v2 + (bucket: smartcity) + ↓ + Grafana + (Dashboard: smartcity-martinique-2026) +``` + +### Détails des Composants + +#### 1. Simulateur (`smart-city-simulator`) +- **Fonction** : Génère des données IoT simulées (60 capteurs) +- **Types** : AirQuality, Traffic, Parking, Noise, Weather, Light +- **Brokers MQTT** : Publie sur les 3 brokers simultanément + - EMQX: `emqx_emqx_1:1883` (MQTT v3.1.1) + - Mosquitto: `smart-city-mosquitto:1883` (MQTT v3.1.1) + - BunkerM: `bunkerm_bunkerm_1:1900` (MQTT v3.1.1, auth: bunker/bunker) +- **InfluxDB** : Écriture asynchrone (ASYNCHRONOUS) vers `smartcity` bucket + +#### 2. Telegraf (`smart-city-telegraf`) +- **Fonction** : Collecte les données MQTT et les écrit dans InfluxDB +- **Configuration** : 3 inputs MQTT (un par broker) +- **Topics** : `airquality/#`, `traffic/#`, `parking/#`, `noise/#`, `weather/#`, `light/#` +- **Format** : JSON → InfluxDB line protocol + +#### 3. InfluxDB (`smart-city-influxdb`) +- **Version** : v2.7.12 +- **Organization** : digitribe +- **Bucket** : `smartcity` (infinite retention) +- **Token** : `my-super-token` + +#### 4. Grafana (`smart-city-grafana`) +- **URL** : http://localhost:3001 +- **Credentials** : admin / Digitribe972 +- **Dashboard** : Smart City Digital Twin - Martinique + - UID: `smartcity-martinique-2026` + - 6 panneaux (AirQuality, Traffic, Parking, Noise, Weather, Light) + - Source: InfluxDB (`smartcity` bucket) + +### Flux de Données +1. **Simulateur** publie sur 3 brokers MQTT (EMQX, Mosquitto, BunkerM) +2. **Telegraf** subscribe aux topics MQTT → convertit en format InfluxDB +3. **InfluxDB** stock les séries temporelles +4. **Grafana** visualise les données via Flux queries + +### Avantages de cette Architecture +- ✅ **Simplicité** : Pas de FIWARE (Orion-LD, Stellio, QuantumLeap) +- ✅ **Performance** : InfluxDB optimisé pour les séries temporelles +- ✅ **Redondance** : 3 brokers MQTT (si un tombe, les autres assurent) +- ✅ **Maintnant** : Stack standard (Telegraf/InfluxDB/Grafana) + +### Commandes Utiles +```bash +# Vérifier les données InfluxDB +docker exec smart-city-influxdb influx query 'from(bucket:"smartcity") |> range(start:-1h) |> group(columns: ["_measurement"]) |> count()' + +# Voir les logs du simulateur +docker logs smart-city-simulator --tail 50 + +# Redémarrer Telegraf +docker restart smart-city-telegraf + +# Accéder à Grafana +open http://localhost:3001 +``` + +### Fichiers de Configuration +- **Simulateur** : `/home/eric/smart-city-digital-twin-martinique/simulator.py` +- **Telegraf** : `/home/eric/smart-city-digital-twin-martinique/telegraf.conf` +- **Docker Compose** : `/home/eric/smart-city-digital-twin-martinique/docker-compose.yml` +- **Dashboard Grafana** : `/home/eric/smart-city-digital-twin-martinique/grafana-dashboard-smartcity.json` + +--- +*Dernière mise à jour : 08/05/2026 - Suppression de FIWARE, passage à Telegraf/InfluxDB* diff --git a/create_dashboard_complete.py b/create_dashboard_complete.py new file mode 100644 index 00000000..ec6a7c10 --- /dev/null +++ b/create_dashboard_complete.py @@ -0,0 +1,257 @@ +#!/usr/bin/env python3 +import json +import requests + +# UID de la datasource correcte +DS_UID = "dd1bfc24-de9d-4c23-8a3c-151d153f8169" + +dashboard = { + "annotations": {"list": []}, + "editable": True, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "id": None, + "links": [], + "panels": [ + # ===== AIR QUALITY ===== + { + "title": "Air Quality - PM2.5 (µg/m³)", + "type": "timeseries", + "gridPos": {"h": 8, "w": 12, "x": 0, "y": 0}, + "datasource": {"type": "influxdb", "uid": DS_UID}, + "targets": [ + { + "query": 'from(bucket:"smartcity")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r["_measurement"] == "airquality")\n |> filter(fn: (r) => r["_field"] == "pm25_ugm3")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> yield(name: "PM2.5")', + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "unit": "µg/m³", + "thresholds": { + "mode": "absolute", + "steps": [ + {"color": "green", "value": None}, + {"color": "yellow", "value": 25}, + {"color": "orange", "value": 50}, + {"color": "red", "value": 100} + ] + } + } + } + }, + { + "title": "Air Quality - CO (mg/m³)", + "type": "timeseries", + "gridPos": {"h": 8, "w": 12, "x": 12, "y": 0}, + "datasource": {"type": "influxdb", "uid": DS_UID}, + "targets": [ + { + "query": 'from(bucket:"smartcity")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r["_measurement"] == "airquality")\n |> filter(fn: (r) => r["_field"] == "co_mgm3")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> yield(name: "CO")', + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "unit": "mg/m³", + "thresholds": { + "mode": "absolute", + "steps": [ + {"color": "green", "value": None}, + {"color": "yellow", "value": 5}, + {"color": "red", "value": 15} + ] + } + } + } + }, + # ===== TRAFFIC ===== + { + "title": "Traffic - Average Speed (km/h)", + "type": "timeseries", + "gridPos": {"h": 8, "w": 12, "x": 0, "y": 16}, + "datasource": {"type": "influxdb", "uid": DS_UID}, + "targets": [ + { + "query": 'from(bucket:"smartcity")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r["_measurement"] == "traffic")\n |> filter(fn: (r) => r["_field"] == "average_speed_kmh")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> yield(name: "Speed")', + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "unit": "km/h", + "thresholds": { + "mode": "absolute", + "steps": [ + {"color": "red", "value": None}, + {"color": "yellow", "value": 20}, + {"color": "green", "value": 40} + ] + } + } + } + }, + { + "title": "Traffic - Congestion Level", + "type": "timeseries", + "gridPos": {"h": 8, "w": 12, "x": 12, "y": 16}, + "datasource": {"type": "influxdb", "uid": DS_UID}, + "targets": [ + { + "query": 'from(bucket:"smartcity")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r["_measurement"] == "traffic")\n |> filter(fn: (r) => r["_field"] == "congestion_level")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> yield(name: "Congestion")', + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "unit": "", + "min": 0, + "max": 1, + "thresholds": { + "mode": "absolute", + "steps": [ + {"color": "green", "value": None}, + {"color": "yellow", "value": 0.5}, + {"color": "red", "value": 0.8} + ] + } + } + } + }, + # ===== PARKING ===== + { + "title": "Parking - Available Spots", + "type": "timeseries", + "gridPos": {"h": 8, "w": 12, "x": 0, "y": 32}, + "datasource": {"type": "influxdb", "uid": DS_UID}, + "targets": [ + { + "query": 'from(bucket:"smartcity")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r["_measurement"] == "parking")\n |> filter(fn: (r) => r["_field"] == "available_spots")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> yield(name: "Available")', + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "unit": "spots" + } + } + }, + { + "title": "Parking - Occupancy (%)", + "type": "timeseries", + "gridPos": {"h": 8, "w": 12, "x": 12, "y": 32}, + "datasource": {"type": "influxdb", "uid": DS_UID}, + "targets": [ + { + "query": 'from(bucket:"smartcity")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r["_measurement"] == "parking")\n |> filter(fn: (r) => r["_field"] == "occupancy_percent")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> yield(name: "Occupancy")', + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percent", + "min": 0, + "max": 100 + } + } + }, + # ===== NOISE ===== + { + "title": "Noise Level (dB)", + "type": "timeseries", + "gridPos": {"h": 8, "w": 12, "x": 0, "y": 48}, + "datasource": {"type": "influxdb", "uid": DS_UID}, + "targets": [ + { + "query": 'from(bucket:"smartcity")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r["_measurement"] == "noise")\n |> filter(fn: (r) => r["_field"] == "noise_level_db")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> yield(name: "Noise")', + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "unit": "dB", + "thresholds": { + "mode": "absolute", + "steps": [ + {"color": "green", "value": None}, + {"color": "yellow", "value": 65}, + {"color": "orange", "value": 80}, + {"color": "red", "value": 95} + ] + } + } + } + }, + # ===== WEATHER ===== + { + "title": "Weather - Temperature (°C)", + "type": "timeseries", + "gridPos": {"h": 8, "w": 12, "x": 12, "y": 48}, + "datasource": {"type": "influxdb", "uid": DS_UID}, + "targets": [ + { + "query": 'from(bucket:"smartcity")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r["_measurement"] == "weather")\n |> filter(fn: (r) => r["_field"] == "temperature_celsius")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> yield(name: "Temperature")', + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "unit": "°C" + } + } + }, + # ===== LIGHT ===== + { + "title": "Light - Brightness (lux)", + "type": "timeseries", + "gridPos": {"h": 8, "w": 12, "x": 0, "y": 64}, + "datasource": {"type": "influxdb", "uid": DS_UID}, + "targets": [ + { + "query": 'from(bucket:"smartcity")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r["_measurement"] == "light")\n |> filter(fn: (r) => r["_field"] == "brightness_lux")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> yield(name: "Brightness")', + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "unit": "lux" + } + } + }, + { + "title": "Light - Power Consumption (W)", + "type": "timeseries", + "gridPos": {"h": 8, "w": 12, "x": 12, "y": 64}, + "datasource": {"type": "influxdb", "uid": DS_UID}, + "targets": [ + { + "query": 'from(bucket:"smartcity")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r["_measurement"] == "light")\n |> filter(fn: (r) => r["_field"] == "power_consumption_w")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> yield(name: "Power")', + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "unit": "W" + } + } + } + ], + "schemaVersion": 38, + "style": "dark", + "tags": ["smart-city", "martinique", "iot", "complete"], + "templating": {"list": []}, + "time": {"from": "now-1h", "to": "now"}, + "title": "Smart City Digital Twin - Martinique (COMPLET)", + "uid": "smartcity-martinique-complete", + "version": 1 +} + +# Sauvegarder localement +with open('/home/eric/smart-city-digital-twin-martinique/grafana-dashboard-complete.json', 'w') as f: + json.dump(dashboard, f, indent=2) + +print("✅ Dashboard complet généré") +print(f" Fichier: grafana-dashboard-complete.json") +print(f" UID: {dashboard['uid']}") +print(f" Panneaux: {len(dashboard['panels'])}") +print(f" Datasource: {DS_UID}") diff --git a/docker-compose.yml b/docker-compose.yml index b4aac1d5..3f049627 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,10 +23,10 @@ services: - ENABLE_EMQX=true - ENABLE_MOSQUITTO=true - ENABLE_BUNKER=true - # Context Brokers - - ENABLE_ORION=true - - ENABLE_STELLIO=true - - ENABLE_FROST=true + # Context Brokers (DESACTIVE - tout passe par les IoT Agents via MQTT) + - ENABLE_ORION=false + - ENABLE_STELLIO=false + - ENABLE_FROST=false # Databases - ENABLE_INFLUX=true - INFLUX_URL=http://smart-city-influxdb:8086 @@ -44,6 +44,27 @@ services: labels: - "traefik.enable=false" + # IoT Agent BunkerM - traduce les msgs MQTT bunker/bunker vers Orion-LD + iot-agent-bunkerm: + image: fiware/iotagent-json:latest + container_name: smart-city-iot-agent-bunkerm + networks: + - smartcity-shared + ports: + - "4043:4041" + environment: + - IOTA_CB_HOST=smart-city-orion-ld + - IOTA_CB_PORT=1026 + - IOTA_CB_NGSI_VERSION=v2 + - IOTA_REGISTRY_TYPE=memory + - IOTA_DEFAULT_APIKEY=smartcity-api-key + - IOTA_MQTT_USERNAME=bunker + - IOTA_MQTT_PASSWORD=bunker + - IOTA_MQTT_HOST=bunkerm_bunkerm_1 + - IOTA_MQTT_PORT=1900 + - IOTA_LOG_LEVEL=DEBUG + restart: unless-stopped + # InfluxDB (defined in docker-compose.influxdb.yml) # Run with: docker compose -f docker-compose.yml -f docker-compose.influxdb.yml up -d diff --git a/grafana-dashboard-complete.json b/grafana-dashboard-complete.json new file mode 100644 index 00000000..af3956ec --- /dev/null +++ b/grafana-dashboard-complete.json @@ -0,0 +1,377 @@ +{ + "annotations": { + "list": [] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "id": null, + "links": [], + "panels": [ + { + "title": "Air Quality - PM2.5 (\u00b5g/m\u00b3)", + "type": "timeseries", + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "datasource": { + "type": "influxdb", + "uid": "dd1bfc24-de9d-4c23-8a3c-151d153f8169" + }, + "targets": [ + { + "query": "from(bucket:\"smartcity\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r[\"_measurement\"] == \"airquality\")\n |> filter(fn: (r) => r[\"_field\"] == \"pm25_ugm3\")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> yield(name: \"PM2.5\")", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "unit": "\u00b5g/m\u00b3", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 25 + }, + { + "color": "orange", + "value": 50 + }, + { + "color": "red", + "value": 100 + } + ] + } + } + } + }, + { + "title": "Air Quality - CO (mg/m\u00b3)", + "type": "timeseries", + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "datasource": { + "type": "influxdb", + "uid": "dd1bfc24-de9d-4c23-8a3c-151d153f8169" + }, + "targets": [ + { + "query": "from(bucket:\"smartcity\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r[\"_measurement\"] == \"airquality\")\n |> filter(fn: (r) => r[\"_field\"] == \"co_mgm3\")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> yield(name: \"CO\")", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "unit": "mg/m\u00b3", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 5 + }, + { + "color": "red", + "value": 15 + } + ] + } + } + } + }, + { + "title": "Traffic - Average Speed (km/h)", + "type": "timeseries", + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 16 + }, + "datasource": { + "type": "influxdb", + "uid": "dd1bfc24-de9d-4c23-8a3c-151d153f8169" + }, + "targets": [ + { + "query": "from(bucket:\"smartcity\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r[\"_measurement\"] == \"traffic\")\n |> filter(fn: (r) => r[\"_field\"] == \"average_speed_kmh\")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> yield(name: \"Speed\")", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "unit": "km/h", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "yellow", + "value": 20 + }, + { + "color": "green", + "value": 40 + } + ] + } + } + } + }, + { + "title": "Traffic - Congestion Level", + "type": "timeseries", + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 16 + }, + "datasource": { + "type": "influxdb", + "uid": "dd1bfc24-de9d-4c23-8a3c-151d153f8169" + }, + "targets": [ + { + "query": "from(bucket:\"smartcity\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r[\"_measurement\"] == \"traffic\")\n |> filter(fn: (r) => r[\"_field\"] == \"congestion_level\")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> yield(name: \"Congestion\")", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "unit": "", + "min": 0, + "max": 1, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 0.5 + }, + { + "color": "red", + "value": 0.8 + } + ] + } + } + } + }, + { + "title": "Parking - Available Spots", + "type": "timeseries", + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 32 + }, + "datasource": { + "type": "influxdb", + "uid": "dd1bfc24-de9d-4c23-8a3c-151d153f8169" + }, + "targets": [ + { + "query": "from(bucket:\"smartcity\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r[\"_measurement\"] == \"parking\")\n |> filter(fn: (r) => r[\"_field\"] == \"available_spots\")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> yield(name: \"Available\")", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "unit": "spots" + } + } + }, + { + "title": "Parking - Occupancy (%)", + "type": "timeseries", + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 32 + }, + "datasource": { + "type": "influxdb", + "uid": "dd1bfc24-de9d-4c23-8a3c-151d153f8169" + }, + "targets": [ + { + "query": "from(bucket:\"smartcity\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r[\"_measurement\"] == \"parking\")\n |> filter(fn: (r) => r[\"_field\"] == \"occupancy_percent\")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> yield(name: \"Occupancy\")", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percent", + "min": 0, + "max": 100 + } + } + }, + { + "title": "Noise Level (dB)", + "type": "timeseries", + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 48 + }, + "datasource": { + "type": "influxdb", + "uid": "dd1bfc24-de9d-4c23-8a3c-151d153f8169" + }, + "targets": [ + { + "query": "from(bucket:\"smartcity\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r[\"_measurement\"] == \"noise\")\n |> filter(fn: (r) => r[\"_field\"] == \"noise_level_db\")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> yield(name: \"Noise\")", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "unit": "dB", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 65 + }, + { + "color": "orange", + "value": 80 + }, + { + "color": "red", + "value": 95 + } + ] + } + } + } + }, + { + "title": "Weather - Temperature (\u00b0C)", + "type": "timeseries", + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 48 + }, + "datasource": { + "type": "influxdb", + "uid": "dd1bfc24-de9d-4c23-8a3c-151d153f8169" + }, + "targets": [ + { + "query": "from(bucket:\"smartcity\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r[\"_measurement\"] == \"weather\")\n |> filter(fn: (r) => r[\"_field\"] == \"temperature_celsius\")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> yield(name: \"Temperature\")", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "unit": "\u00b0C" + } + } + }, + { + "title": "Light - Brightness (lux)", + "type": "timeseries", + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 64 + }, + "datasource": { + "type": "influxdb", + "uid": "dd1bfc24-de9d-4c23-8a3c-151d153f8169" + }, + "targets": [ + { + "query": "from(bucket:\"smartcity\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r[\"_measurement\"] == \"light\")\n |> filter(fn: (r) => r[\"_field\"] == \"brightness_lux\")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> yield(name: \"Brightness\")", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "unit": "lux" + } + } + }, + { + "title": "Light - Power Consumption (W)", + "type": "timeseries", + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 64 + }, + "datasource": { + "type": "influxdb", + "uid": "dd1bfc24-de9d-4c23-8a3c-151d153f8169" + }, + "targets": [ + { + "query": "from(bucket:\"smartcity\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r[\"_measurement\"] == \"light\")\n |> filter(fn: (r) => r[\"_field\"] == \"power_consumption_w\")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> yield(name: \"Power\")", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "unit": "W" + } + } + } + ], + "schemaVersion": 38, + "style": "dark", + "tags": [ + "smart-city", + "martinique", + "iot", + "complete" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "title": "Smart City Digital Twin - Martinique (COMPLET)", + "uid": "smartcity-martinique-complete", + "version": 1 +} \ No newline at end of file diff --git a/import_complete.py b/import_complete.py new file mode 100644 index 00000000..9a821598 --- /dev/null +++ b/import_complete.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +import json +import requests + +# Read the complete dashboard +with open('/home/eric/smart-city-digital-twin-martinique/grafana-dashboard-complete.json', 'r') as f: + dashboard = json.load(f) + +# Import to Grafana +url = "https://grafana.digitribe.fr/api/dashboards/db" +auth = ('admin', 'Digitribe972') + +payload = { + "dashboard": dashboard, + "overwrite": True +} + +try: + resp = requests.post(url, json=payload, auth=auth, verify=False) + print(f"Status: {resp.status_code}") + print(resp.json()) +except Exception as e: + print(f"Error: {e}") diff --git a/simulator.py b/simulator.py index b8ab3469..f4d92461 100644 --- a/simulator.py +++ b/simulator.py @@ -11,8 +11,8 @@ Brokers MQTT: - OpenRemote: openremote-manager-1:1883 (admin/Digitribe972) Context Brokers REST: - - Orion-LD: fiware-gis-quickstart-orion-1:1026 (NGSI-LD) - - Stellio: stellio-api-gateway:8080 (NGSI-LD) +# - Orion-LD: fiware-gis-quickstart-orion-1:1026 (NGSI-LD) +# - Stellio: stellio-api-gateway:8080 (NGSI-LD) - FROST: frost_allinone-web-1:8080/FROST-Server/v1.1 (SensorThings) Streaming Platforms: @@ -25,8 +25,8 @@ Time-Series DB: Variables d'environnement: PUBLISH_INTERVAL_SEC : intervalle de publication (défaut: 10s) BASE_LAT / BASE_LON : coordonnées de base (défaut: Fort-de-France) - ENABLE_ORION=1 : activer Orion-LD (défaut: 1) - ENABLE_STELLIO=1 : activer Stellio (défaut: 1) +# ENABLE_ORION=1 : activer Orion-LD (défaut: 1) +# ENABLE_STELLIO=1 : activer Stellio (défaut: 1) ENABLE_FROST=1 : activer FROST-Server (défaut: 1) ENABLE_INFLUX=1 : activer InfluxDB v2 (défaut: 1) ENABLE_PULSAR=1 : activer Apache Pulsar (défaut: 1) @@ -50,12 +50,12 @@ from influxdb_client.client.write_api import SYNCHRONOUS # ============================================================================= # Configuration des brokers MQTT # Configuration des brokers MQTT -# Par défaut localhost (simulateur tourne sur l'hôte) -EMQX_HOST = os.environ.get("EMQX_HOST", "localhost") -EMQX_PORT = int(os.environ.get("EMQX_PORT", "11883")) -MOSQUITTO_HOST = os.environ.get("MOSQUITTO_HOST", "localhost") +# Utilise les noms de services Docker par défaut +EMQX_HOST = os.environ.get("EMQX_HOST", "emqx_emqx_1") +EMQX_PORT = int(os.environ.get("EMQX_PORT", "1883")) +MOSQUITTO_HOST = os.environ.get("MOSQUITTO_HOST", "smart-city-mosquitto") MOSQUITTO_PORT = int(os.environ.get("MOSQUITTO_PORT", "1883")) -BUNKERM_HOST = os.environ.get("BUNKERM_HOST", "mqtt.digitribe.fr") +BUNKERM_HOST = os.environ.get("BUNKERM_HOST", "bunkerm_bunkerm_1") BUNKERM_PORT = int(os.environ.get("BUNKERM_PORT", "1900")) # ============================================================================= @@ -64,15 +64,15 @@ BUNKERM_PORT = int(os.environ.get("BUNKERM_PORT", "1900")) BASE_LAT = float(os.environ.get("BASE_LAT", "14.6091")) BASE_LON = float(os.environ.get("BASE_LON", "-61.2155")) INTERVAL = int(os.environ.get("PUBLISH_INTERVAL_SEC", "1")) # 1s pour temps réel -ENABLE_ORION = os.environ.get("ENABLE_ORION", "1") == "1" -ENABLE_STELLIO = os.environ.get("ENABLE_STELLIO", "1").lower() in ("1", "true", "yes", "on") +#ENABLE_ORION = os.environ.get("ENABLE_ORION", "1") == "1" +#ENABLE_STELLIO = os.environ.get("ENABLE_STELLIO", "1").lower() in ("1", "true", "yes", "on") ENABLE_FROST = os.environ.get("ENABLE_FROST", "1") == "1" ENABLE_OPENREMOTE = os.environ.get("ENABLE_OPENREMOTE", "1") == "1" OR_ADMIN_USER = os.environ.get("OR_ADMIN_USER", "admin") OR_ADMIN_PASS = os.environ.get("OR_ADMIN_PASS", "Digitribe972") OR_REALM = os.environ.get("OR_REALM", "smartcity") OR_TOKEN_REALM = os.environ.get("OR_TOKEN_REALM", "master") # Realm pour obtention token -ENABLE_IOT_AGENT = os.environ.get("ENABLE_IOT_AGENT", "1") == "1" +#ENABLE_IOT_AGENT = os.environ.get("ENABLE_IOT_AGENT", "1") == "1" FROST_URL = os.environ.get("FROST_URL", "http://localhost:8090/FROST-Server/v1.1") # Exposer frost_http-web-1:8080 -> host:8086 # Pulsar config (HTTP REST — pulsar-admin + producer REST API) @@ -91,8 +91,8 @@ REDPANDA_BASE = f"http://{REDPANDA_HOST}:{REDPANDA_PORT}" ENABLE_INFLUX = os.environ.get("ENABLE_INFLUX", "1").lower() in ("1", "true", "yes", "on") INFLUX_URL = os.environ.get("INFLUX_URL", "http://smart-city-influxdb:8086") # InfluxDB v2 sur smartcity-shared INFLUX_ORG = os.environ.get("INFLUX_ORG", "digitribe") -INFLUX_BUCKET = os.environ.get("INFLUX_BUCKET", "iot_data") -INFLUX_TOKEN = os.environ.get("INFLUX_TOKEN", "my-super-token") +INFLUX_BUCKET = os.environ.get("INFLUX_BUCKET", "smartcity") # Correspond au bucket de Telegraf +INFLUX_TOKEN = os.environ.get("INFLUX_TOKEN", "my-super-token") # Prometheus metrics HTTP server METRICS_PORT = int(os.environ.get("METRICS_PORT", "8001")) @@ -109,7 +109,7 @@ simulator_info.info({ "version": "1.0.0", "python_version": sys.version.split()[0], "mqtt_brokers": "emqx,mosquitto,bunkerm", - "context_brokers": "orion_ld,stellio,frost", +# "context_brokers": "orion_ld,stellio,frost", }) # --- Counters --- @@ -198,15 +198,15 @@ _influx_write_api = None if ENABLE_INFLUX: try: _influx_client = influxdb_client.InfluxDBClient(url=INFLUX_URL, token=INFLUX_TOKEN, org=INFLUX_ORG) - _influx_write_api = _influx_client.write_api(write_options=SYNCHRONOUS) - print(f"[INFLUX] ✅ Connected to {INFLUX_URL}") + _influx_write_api = _influx_client.write_api(write_options=influxdb_client.client.write_api.ASYNCHRONOUS) + print(f"[INFLUX] ✅ Connected to {INFLUX_URL} (async mode)") except Exception as e: print(f"[INFLUX] ❌ Connection failed: {e}") SENSOR_COUNTS = { "traffic": int(os.environ.get("SENSOR_COUNT_traffic", "3")), - "airquality": int(os.environ.get("SENSOR_COUNT_airquality", "2")), - "parking": int(os.environ.get("SENSOR_COUNT_parking", "2")), + "airquality": int(os.environ.get("SENSOR_COUNT_airquality", "10")), + "parking": int(os.environ.get("SENSOR_COUNT_parking", "10")), "noise": int(os.environ.get("SENSOR_COUNT_noise", "1")), "weather": int(os.environ.get("SENSOR_COUNT_weather", "1")), "light": int(os.environ.get("SENSOR_COUNT_light", "1")), @@ -231,98 +231,79 @@ if "SENSOR_COUNT" in os.environ: # Martinique bounds: lat 14.37–14.88°N, lon 61.0–61.25°W FIXED_LOCATIONS: dict[str, dict[str, tuple[float, float]]] = { "traffic": { - # OpenRemote: "Traffic Fort-de-France Centre" - "FdF Centre": (14.6036, -61.1783), - # OpenRemote: "Traffic Fort-de-France North" - "FdF North": (14.6200, -61.1700), - # OpenRemote: "Traffic Fort-de-France South" - "FdF South": (14.5900, -61.1900), - # OpenRemote: "trafficFlow - Fort-de-France" - "FdF Centre Rue": (14.6036, -61.1783), - # OpenRemote: "Test Sensor" - "FdF Place": (14.6000, -61.2000), + "Fort-de-France Centre": (14.6164, -61.07), + "Le Lamentin Aéroport": (14.6167, -61.0035), + "Le Robert D110": (14.6833, -60.9333), + "Sainte-Anne Plage": (14.4333, -60.9833), + "Saint-Joseph D1": (14.7, -61.05), + "Trinité Centre": (14.7167, -60.9167), + "Le François D2": (14.6833, -60.8333), + "Ducos Penitencier": (14.5833, -61.0667), + "Schœlcher Morne": (14.65, -61.1), + "Case-Pilote Bourg": (14.5167, -61.1167), }, "airquality": { - # OpenRemote: "Air Quality Fort-de-France" - "FdF Centre": (14.6036, -61.1783), - # OpenRemote: "airQuality - Fort-de-France" - "FdF Bonde": (14.6050, -61.1750), - # OpenRemote: "airQuality - Sainte-Luce" - "Sainte-Luce": (14.5950, -61.1700), - # OpenRemote: "floodLevel - Schoelcher" - "Schoelcher": (14.7400, -61.1850), - # OpenRemote: "humidity - Le Robert" - "Le Robert": (14.6800, -60.9400), + "Fort-de-France Lamartine": (14.613, -61.0667), + "Le Lamentin Zac": (14.62, -61.0), + "Le Robert Bourg": (14.68, -60.93), + "Sainte-Anne Village": (14.43, -60.98), + "Saint-Joseph Morne": (14.705, -61.04), + "Trinité Eglise": (14.72, -60.91), + "Le François Bourg": (14.68, -60.83), + "Ducos Centre": (14.58, -61.06), + "Schœlcher Plage": (14.655, -61.11), + "Case-Pilote D1": (14.52, -61.12), }, "parking": { - # OpenRemote: "Parking Fort-de-France Centre" - "FdF Centre": (14.6036, -61.1783), - # OpenRemote: "parkingAvailability - Fort-de-France" - "FdF Bonde": (14.6050, -61.1750), - # OpenRemote: "Test Sensor" - "FdF Cluny": (14.6000, -61.2000), - # OpenRemote: "Traffic Fort-de-France South" - "FdF Sud": (14.5900, -61.1900), - # OpenRemote: "Weather Lamentin Airport" - "Lamentin": (14.5950, -61.1700), + "Fort-de-France Place Clémenceau": (14.615, -61.068), + "Le Lamentin Centre Commercial": (14.618, -61.002), + "Le Robert Stade": (14.685, -60.935), + "Sainte-Anne Mairie": (14.432, -60.985), + "Saint-Joseph Ecole": (14.702, -61.045), + "Trinité Port": (14.715, -60.92), + "Le François Mairie": (14.682, -60.835), + "Ducos ZI": (14.585, -61.055), + "Schœlcher Bourg": (14.652, -61.105), + "Case-Pilote Stade": (14.518, -61.118), }, "noise": { - # OpenRemote: "Noise Fort-de-France Centre" - "FdF Centre": (14.6036, -61.1783), - # OpenRemote: "Traffic Fort-de-France Centre" - "FdF Rue": (14.6036, -61.1783), - # OpenRemote: "trafficFlow - Fort-de-France" - "FdF Pasteur": (14.6200, -61.1700), - # OpenRemote: "temperature - Lamentin" - "Lamentin": (14.5950, -61.1650), - # OpenRemote: "temperature - Le Robert" - "Le Robert": (14.6776, -60.9395), + "Fort-de-France Théâtre": (14.617, -61.069), + "Le Lamentin Zone Industrielle": (14.619, -61.001), + "Le Robert Bourg": (14.681, -60.932), + "Sainte-Anne Plage": (14.434, -60.982), + "Saint-Joseph Morne": (14.703, -61.042), + "Trinité Centre": (14.717, -60.918), + "Le François Bourg": (14.681, -60.832), + "Ducos Penitencier": (14.584, -61.058), + "Schœlcher Morne": (14.651, -61.102), + "Case-Pilote Village": (14.519, -61.115), }, "weather": { - # OpenRemote: "Weather Lamentin Airport" - "Lamentin": (14.5950, -61.1700), - # OpenRemote: "temperature - Lamentin" - "Lamentin Ville": (14.5950, -61.1650), - # OpenRemote: "temperature - Le Robert" - "Le Robert": (14.6776, -60.9395), - # OpenRemote: "humidity - Le Robert" - "Le Robert Hum": (14.6800, -60.9400), - # OpenRemote: "floodLevel - Schoelcher" - "Schoelcher": (14.7400, -61.1850), + "Fort-de-France Meteo": (14.616, -61.067), + "Le Lamentin Aéroport": (14.617, -61.004), + "Le Robert Bourg": (14.682, -60.934), + "Sainte-Anne Village": (14.431, -60.981), + "Saint-Joseph Morne": (14.704, -61.043), + "Trinité Eglise": (14.718, -60.912), + "Le François Bourg": (14.683, -60.834), + "Ducos Centre": (14.586, -61.057), + "Schœlcher Plage": (14.654, -61.108), + "Case-Pilote D1": (14.521, -61.113), }, "light": { - # OpenRemote: "Light Fort-de-France" - "FdF Centre": (14.6036, -61.1783), - # OpenRemote: "lightIntensity - Fort-de-France" - "FdF Bonde": (14.6050, -61.1800), - # OpenRemote: "Traffic Fort-de-France North" - "FdF North": (14.6200, -61.1700), - # OpenRemote: "Traffic Fort-de-France South" - "FdF South": (14.5900, -61.1900), - # OpenRemote: "airQuality - Sainte-Luce" - "Sainte-Luce": (14.5950, -61.1700), + "Fort-de-France Place": (14.6155, -61.0685), + "Le Lamentin Rond-point": (14.6185, -61.0025), + "Le Robert D110": (14.6835, -60.9335), + "Sainte-Anne Plage": (14.4335, -60.9835), + "Saint-Joseph D1": (14.7005, -61.0505), + "Trinité Centre": (14.7165, -60.9165), + "Le François D2": (14.6835, -60.8335), + "Ducos Penitencier": (14.5835, -61.0665), + "Schœlcher Morne": (14.6505, -61.1005), + "Case-Pilote Bourg": (14.5165, -61.1165), }, } -def _build_locs(stype: str, count: int) -> list[dict]: - """Construit la liste des capteurs avec coordonnées fixes (sur terre).""" - locs = [] - names = list(FIXED_LOCATIONS.get(stype, {stype: (BASE_LAT, BASE_LON)}).keys()) - # Répéter les noms si count > len(names) - for i in range(count): - name = names[i % len(names)] - coords = FIXED_LOCATIONS.get(stype, {}).get(name, (BASE_LAT, BASE_LON)) - locs.append({ - "lat": round(coords[0], 6), - "lon": round(coords[1], 6), - "name": name, - }) - return locs - -SENSOR_LOCATIONS: dict[str, list[dict]] = {} -for stype, count in SENSOR_COUNTS.items(): - SENSOR_LOCATIONS[stype] = _build_locs(stype, count) - # Ranges par type SENSOR_RANGES: dict[str, dict] = { "traffic": {"vehicle_count":(10,150),"average_speed_kmh":(10,80), @@ -347,22 +328,22 @@ LIGHT_STATUSES = ["on","off","dimmed","auto"] # ============================================================================= SENSORS: dict[str, dict] = {} counter = 0 -for stype, locs in SENSOR_LOCATIONS.items(): - for loc in locs: +for stype, locs in FIXED_LOCATIONS.items(): + for name, coords in locs.items(): sid = f"{stype}_{counter:03d}" - SENSORS[sid] = {"type": stype, "lat": loc["lat"], "lon": loc["lon"], "name": loc["name"]} + SENSORS[sid] = {"type": stype, "lat": coords[0], "lon": coords[1], "name": name} counter += 1 # ============================================================================= -# Payload NGSI-LD pour Orion-LD / Stellio +## Payload NGSI-LD pour Orion-LD / Stellio # ============================================================================= # Contextes NGSI-LD : core + Smart Data Models # https://smartdatamodels.org pour les @context officiels -# Contexte NGSI-LD pur pour Orion-LD (vocabulaires standards uniquement) -# Orion-LD ne peut pas résoudre raw.githubusercontent.com — utiliser uri.etsi.org uniquement -ORION_CONTEXT = [ - "https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context.jsonld", -] +## Contexte NGSI-LD pur pour Orion-LD (vocabulaires standards uniquement) +## Orion-LD ne peut pas résoudre raw.githubusercontent.com — utiliser uri.etsi.org uniquement +#ORION_CONTEXT = [ +# "https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context.jsonld", +#] # Mapping sensor type → Smart Data Model type NGSI-LD SMART_MODEL_MAPPING = { @@ -378,90 +359,14 @@ FROST_HEADERS = {"Accept": "application/json", "Content-Type": "application/json # Cache FROST : éviter de recréer Thing/Datastream _frost_cache: dict[str, tuple[str, str]] = {} # (sid, field) -> (thing_id, ds_id) -# Contexte NGSI-LD pur pour Stellio et Orion-LD (vocabulaires standards uniquement) -# Stellio et Orion-LD embarquent le contexte core NGSI-LD : https://uri.etsi.org/ngsi-ld/ +## Contexte NGSI-LD pur pour Stellio et Orion-LD (vocabulaires standards uniquement) +## Stellio et Orion-LD embarquent le contexte core NGSI-LD : https://uri.etsi.org/ngsi-ld/ # On n'utilise PAS les vocabulaires smartdatamodels.org distants (inaccessibles depuis les containers) # Les types d'entité Smart Data Models (AirQualityObserved, etc.) sont reconnus par leur nom # Les propriétés spécifiques sont stockées telles quelles (vocabulaire libre) -STELLIO_INLINE_CONTEXT = [ - "https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context.jsonld", -] - -def _ngsi_payload(sid: str, sensor: dict, context: list | dict = ORION_CONTEXT, source: str = "simulator", topic: str = "") -> dict: - """Construit un payload NGSI-LD avec Smart Data Models officiels.""" - stype = sensor["type"] - model_type = SMART_MODEL_MAPPING.get(stype, "Device") - now = datetime.now(timezone.utc).isoformat() - - # Attributs communs à tous les modèles - payload = { - "@context": context, - "id": f"urn:ngsi-ld:{model_type}:{sid}", - "type": model_type, - "dateObserved": {"type": "Property", "value": now}, - "location": {"type": "GeoProperty", - "value": {"type": "Point", - "coordinates": [sensor["lon"], sensor["lat"]]}}, - "name": {"type": "Property", "value": sensor["name"]}, - "batteryLevel": {"type": "Property", "value": random.randint(60, 100)}, - # NOUVEAU: Traçabilité MQTT (Conforme NGSI-LD) - # "source" est un champ standard NGSI-LD (ETSI) - # "mqttTopic" est une propriété personnalisée (étendue autorisée) - "source": {"type": "Property", "value": source}, - "mqttTopic": {"type": "Property", "value": topic}, - } - - # Attributs spécifiques par type de modèle - ranges = SENSOR_RANGES.get(stype, {}) - props = {} - for field, val_range in ranges.items(): - if isinstance(val_range, tuple) and len(val_range) == 2: - lo, hi = val_range - if isinstance(lo, (int, float)): - props[field] = {"type": "Property", "value": round(random.uniform(lo, hi), 1)} - elif isinstance(val_range, list): - props[field] = {"type": "Property", "value": random.choice(val_range)} - - # Mapping vers les noms d'attributs Smart Data Models - if stype == "airquality": - if "pm25_ugm3" in props: payload["NO2"] = props.pop("pm25_ugm3") # Simplifié - if "pm10_ugm3" in props: payload["PM10"] = props.pop("pm10_ugm3") - if "no2_ugm3" in props: payload["NO2"] = props.pop("no2_ugm3") - if "o3_ugm3" in props: payload["O3"] = props.pop("o3_ugm3") - if "co_mgm3" in props: payload["CO"] = props.pop("co_mgm3") - if "temperature_celsius" in props: payload["temperature"] = props.pop("temperature_celsius") - if "humidity_percent" in props: payload["relativeHumidity"] = props.pop("humidity_percent") - - elif stype == "traffic": - if "vehicle_count" in props: payload["vehicleCount"] = props.pop("vehicle_count") - if "average_speed_kmh" in props: payload["averageVehicleSpeed"] = props.pop("average_speed_kmh") - if "congestion_level" in props: payload["congestion"] = props.pop("congestion_level") - if "occupancy_percent" in props: payload["occupancy"] = props.pop("occupancy_percent") - - elif stype == "parking": - if "available_spots" in props: payload["availableSpotNumber"] = props.pop("available_spots") - if "total_spots" in props: payload["totalSpotNumber"] = props.pop("total_spots") - if "occupancy_percent" in props: payload["occupancy"] = props.pop("occupancy_percent") - if "turnover_per_hour" in props: payload["turnover"] = props.pop("turnover_per_hour") - - elif stype == "noise": - if "noise_level_db" in props: payload["noiseLevel"] = props.pop("noise_level_db") - if "peak_db" in props: payload["noisePeak"] = props.pop("peak_db") - payload["noiseCategory"] = {"type": "Property", "value": random.choice(NOISE_CATEGORIES)} - - elif stype == "weather": - if "temperature_celsius" in props: payload["temperature"] = props.pop("temperature_celsius") - if "humidity_percent" in props: payload["relativeHumidity"] = props.pop("humidity_percent") - if "rain_mm" in props: payload["rainfall"] = props.pop("rain_mm") - if "uv_index" in props: payload["uvIndex"] = props.pop("uv_index") - if "wind_speed_kmh" in props: payload["windSpeed"] = props.pop("wind_speed_kmh") - - elif stype == "light": - if "brightness_lux" in props: payload["illuminance"] = props.pop("brightness_lux") - if "power_consumption_w" in props: payload["power"] = props.pop("power_consumption_w") - payload["status"] = {"type": "Property", "value": random.choice(LIGHT_STATUSES)} - - return payload +#STELLIO_INLINE_CONTEXT = [ +# "https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context.jsonld", +#] def _frost_payload(sid: str, sensor: dict, source: str = "simulator", topic: str = "") -> dict: """Construit un payload SensorThings pour FROST-Server.""" @@ -589,9 +494,10 @@ class MultiMQTT: def _mk_client(self, name: str, host: str, port: int, tls: bool = False, user: str = "", pwd: str = "", - ws: bool = False) -> mqtt.Client: + ws: bool = False, use_v5: bool = False) -> mqtt.Client: cid = f"smartcity-sim-{name}-{os.getpid()}" - c = mqtt.Client(client_id=cid, protocol=mqtt.MQTTv311) + protocol = mqtt.MQTTv5 if use_v5 else mqtt.MQTTv311 + c = mqtt.Client(client_id=cid, protocol=protocol) if user: c.username_pw_set(user, pwd) if tls: @@ -631,13 +537,13 @@ class MultiMQTT: def _setup(self): # Utiliser les variables d'environnement pour les brokers brokers = [ - ("emqx", EMQX_HOST, EMQX_PORT, False, "", ""), - ("mosquitto", MOSQUITTO_HOST, MOSQUITTO_PORT, False, "", ""), - ("bunkerm", BUNKERM_HOST, BUNKERM_PORT, False, "bunker", "bunker"), # Port 1900 = MQTT simple, pas TLS + ("emqx", EMQX_HOST, EMQX_PORT, False, "", "", False), + ("mosquitto", MOSQUITTO_HOST, MOSQUITTO_PORT, False, "", "", False), # Same as emqx + ("bunkerm", BUNKERM_HOST, BUNKERM_PORT, False, "bunker", "bunker", False), ] print("[MQTT] 🔌 Connexion aux brokers...") - for name, host, port, tls, user, pwd in brokers: - c = self._mk_client(name, host, port, tls=tls, user=user, pwd=pwd) + for name, host, port, tls, user, pwd, use_v5 in brokers: + c = self._mk_client(name, host, port, tls=tls, user=user, pwd=pwd, use_v5=use_v5) self.clients[name] = c self.ok[name] = False time.sleep(3) # Attend les connexions @@ -665,8 +571,8 @@ class MultiMQTT: results[name] = False return results - def publish_iot_agent(self, sid: str, payload: dict, sensor_type: str = "unknown") -> bool: - """Publie sur le topic IoT-Agent (json/smartcity-api-key/{sid}/attrs) via les 3 brokers.""" +# def publish_iot_agent(self, sid: str, payload: dict, sensor_type: str = "unknown") -> bool: +# """Publie sur le topic IoT-Agent (json/smartcity-api-key/{sid}/attrs) via les 3 brokers.""" topic = f"json/smartcity-api-key/{sid}/attrs" msg = json.dumps(payload, ensure_ascii=False) payload_bytes = len(msg.encode()) @@ -674,15 +580,17 @@ class MultiMQTT: success = False # Publier sur les 3 brokers: emqx, mosquitto, bunkerm for broker_name in ['emqx', 'mosquitto', 'bunkerm']: - if broker_name in self.clients and self.ok.get(broker_name, False): + client_ok = self.clients.get(broker_name) is not None and self.ok.get(broker_name, False) + print(f"[MQTT-DEBUG] {broker_name}: client_exists={self.clients.get(broker_name) is not None}, ok={self.ok.get(broker_name, False)}") + if client_ok: try: r = self.clients[broker_name].publish(topic, msg, qos=1) if r.rc == mqtt.MQTT_ERR_SUCCESS: success = True - messages_published_total.labels(broker='iot-agent', sensor_type=sensor_type).inc() - message_payload_size.labels(broker='iot-agent').observe(payload_bytes) +# messages_published_total.labels(broker='iot-agent', sensor_type=sensor_type).inc() +# message_payload_size.labels(broker='iot-agent').observe(payload_bytes) except Exception: - messages_errors_total.labels(broker='iot-agent', sensor_type=sensor_type, error_type="exception").inc() + pass # IoT-Agent code removed return success def stop(self): @@ -696,110 +604,18 @@ class MultiMQTT: # ============================================================================= # URLs de base (résolues au démarrage) # ============================================================================= -ORION_HOST = "localhost" -ORION_PORT = "2026" -ORION_URL = f"http://{ORION_HOST}:{ORION_PORT}" -STELLIO_URL = os.environ.get("STELLIO_URL", "http://localhost:8087") # Stellio API Gateway (à exposer) +#ORION_HOST = "localhost" +#ORION_PORT = "2026" +#ORION_URL = f"http://{ORION_HOST}:{ORION_PORT}" +#STELLIO_URL = os.environ.get("STELLIO_URL", "http://localhost:8087") # Stellio API Gateway (à exposer) # Configuration OpenRemote (URLs dynamiques) OR_URL = os.environ.get("OR_URL", "http://localhost:8080") # OpenRemote Manager (Traefik) OR_REALM = os.environ.get("OR_REALM", "smartcity") # Default: smartcity OR_TOKEN_URL = os.environ.get("OR_TOKEN_URL", "http://localhost:8080/auth/realms/{OR_REALM}/protocol/openid-connect/token") OR_TOKEN_TTL = int(os.environ.get("OR_TOKEN_TTL", "3600")) # Refresh token every hour -STELLIO_TENANT = os.environ.get("STELLIO_TENANT", "urn:ngsi-ld:tenant:default") +#STELLIO_TENANT = os.environ.get("STELLIO_TENANT", "urn:ngsi-ld:tenant:default") -def publish_stellio(sid: str, sensor: dict) -> bool: - """Publie sur Stellio via Traefik (gère le 409).""" - # Topic MQTT correspondant (pour traçabilité) - stype = sensor["type"] - topic = f"city/sensors/{stype}/{sid}" - entity = _ngsi_payload(sid, sensor, context=STELLIO_INLINE_CONTEXT, source="simulator", topic=topic) - # Stellio a besoin du @context pour résoudre les vocabulaires NGSI-LD - # (uri.etsi.org résolu depuis le JAR embarqué) - url = f"{STELLIO_URL}/ngsi-ld/v1/entities" - headers = { - "Content-Type": "application/ld+json", - "Accept": "application/ld+json", - "NGSILD-Tenant": STELLIO_TENANT, - } - try: - body = json.dumps(entity).encode() - req = urllib.request.Request(url, data=body, headers=headers, method="POST") - with http_request_duration.labels(broker="stellio", method="POST").time(): - with urllib.request.urlopen(req, timeout=8) as resp: - http_requests_total.labels(broker="stellio", method="POST", status_code=str(resp.status)).inc() - print(f" 🏢 Stellio: ✅ (HTTP {resp.status})") - return True - except urllib.error.HTTPError as e: - http_requests_total.labels(broker="stellio", method="POST", status_code=str(e.code)).inc() - if e.code == 409: # Already exists, do update with PUT - try: - entity_id = urllib.parse.quote(entity["id"], safe="") - update_url = f"{STELLIO_URL}/ngsi-ld/v1/entities/{entity_id}" - req2 = urllib.request.Request(update_url, data=body, headers=headers, method="PUT") - with http_request_duration.labels(broker="stellio", method="PUT").time(): - with urllib.request.urlopen(req2, timeout=8) as resp2: - http_requests_total.labels(broker="stellio", method="PUT", status_code=str(resp2.status)).inc() - print(f" 🏢 Stellio: ✅ (HTTP {resp2.status} updated)") - return True - except Exception as e2: - http_requests_total.labels(broker="stellio", method="PUT", status_code="error").inc() - messages_errors_total.labels(broker="stellio", sensor_type=stype, error_type="http_error").inc() - print(f" ⚠️ Stellio update failed: {e2}") - return False - try: - err = e.read().decode()[:300] - except Exception: - err = str(e) - messages_errors_total.labels(broker="stellio", sensor_type=stype, error_type="http_error").inc() - print(f" ⚠️ Stellio → {e.code}: {err}") - return False - except Exception as e: - http_requests_total.labels(broker="stellio", method="POST", status_code="exception").inc() - messages_errors_total.labels(broker="stellio", sensor_type=stype, error_type="exception").inc() - print(f" ⚠️ Stellio → {e}") - return False -def publish_orion(sid: str, sensor: dict) -> bool: - """Publie sur Orion-LD (POST create, PATCH update).""" - # Topic MQTT correspondant (pour traçabilité) - stype = sensor["type"] - topic = f"city/sensors/{stype}/{sid}" - entity = _ngsi_payload(sid, sensor, source="simulator", topic=topic) - # Orion-LD est exposé sur localhost:2026 (hôte) - base = "http://localhost:2026/ngsi-ld/v1" - # 1. Essayer de créer (POST) - try: - body = json.dumps(entity).encode() - req = urllib.request.Request(f"{base}/entities", data=body, - headers={"Content-Type": "application/ld+json", "Accept": "application/ld+json"}, method="POST") - with http_request_duration.labels(broker="orion_ld", method="POST").time(): - with urllib.request.urlopen(req, timeout=8) as resp: - http_requests_total.labels(broker="orion_ld", method="POST", status_code=str(resp.status)).inc() - print(f" 🌐 Orion-LD: ✅ (HTTP {resp.status} created)") - return True - except urllib.error.HTTPError as e: - http_requests_total.labels(broker="orion_ld", method="POST", status_code=str(e.code)).inc() - if e.code != 409: - messages_errors_total.labels(broker="orion_ld", sensor_type=stype, error_type="http_error").inc() - print(f" ⚠️ Orion-LD → {e.code}: {e.read().decode()[:200]}") - return False - # 409 = déjà existant → PATCH - # 2. Déjà existant (409) → PATCH sur les attributs - try: - eid = urllib.parse.quote(entity['id'], safe='') - patch_url = f"{base}/entities/{eid}/attrs" - req2 = urllib.request.Request(patch_url, data=body, - headers={"Content-Type": "application/ld+json", "Accept": "application/ld+json"}, method="PATCH") - with http_request_duration.labels(broker="orion_ld", method="PATCH").time(): - with urllib.request.urlopen(req2, timeout=8) as resp2: - http_requests_total.labels(broker="orion_ld", method="PATCH", status_code=str(resp2.status)).inc() - print(f" 🌐 Orion-LD: ✅ (HTTP {resp2.status} updated)") - return True - except Exception as e2: - http_requests_total.labels(broker="orion_ld", method="PATCH", status_code="error").inc() - messages_errors_total.labels(broker="orion_ld", sensor_type=stype, error_type="http_error").inc() - print(f" ⚠️ Orion-LD PATCH failed: {e2}") - return False def publish_bunkerm(sid: str, sensor: dict, values: dict) -> bool: """Publie sur BunkerM via HTTP API (port 2000) avec session.""" @@ -1183,14 +999,14 @@ def publish_influx(sid: str, sensor: dict, values: dict) -> bool: # Exécution asynchrone (non-bloquante) t = threading.Thread(target=_write_async, daemon=True) t.start() - return True + return True # Async: on ne peut pas savoir immédiatement def main(): print("╔══════════════════════════════════════════════════╗") print("║ Smart City Simulator — Martinique ║") print("╚══════════════════════════════════════════════════╝") print(f"[CFG] Capteurs: {len(SENSORS)} | Intervalle: {INTERVAL}s") - print(f"[CFG] Orion-LD: {ENABLE_ORION} | Stellio: {ENABLE_STELLIO} | FROST: {ENABLE_FROST}") +# print(f"[CFG] Orion-LD: {ENABLE_ORION} | Stellio: {ENABLE_STELLIO} | FROST: {ENABLE_FROST}") print(f"[CFG] InfluxDB: {ENABLE_INFLUX} | Pulsar: {ENABLE_PULSAR} | Redpanda: {ENABLE_REDPANDA}") # --- Démarrer le serveur Prometheus --- @@ -1233,14 +1049,10 @@ def main(): stype = sensor["type"] topic = f"city/sensors/{stype}/{sid}" - # --- Payload MQTT --- + # --- Payload MQTT (ATTRIBUTES ONLY - pas de id/type/lat/lon !) +# # L'IoT Agent n'attend que les readings, pas le body complet ranges = SENSOR_RANGES.get(stype, {}) payload_mqtt = { - "id": sid, - "type": stype, - "name": sensor["name"], - "lat": sensor["lat"], - "lon": sensor["lon"], "timestamp": datetime.now(timezone.utc).isoformat(), "battery_level": random.randint(60, 100), } @@ -1260,10 +1072,10 @@ def main(): if ok_mqtt: print(f" 📤 {topic} → {','.join(ok_mqtt)}") - # --- IoT-Agent (via EMQX) --- - if ENABLE_IOT_AGENT: - ok_iot = mqtt_client.publish_iot_agent(sid, payload_mqtt, sensor_type=stype) - print(f" 🤖 IoT-Agent: {'✅' if ok_iot else '❌'}") +# # --- IoT-Agent (via EMQX) --- +# if ENABLE_IOT_AGENT: +# ok_iot = mqtt_client.publish_iot_agent(sid, payload_mqtt, sensor_type=stype) +# print(f" 🤖 IoT-Agent: {'✅' if ok_iot else '❌'}") # Extraire les valeurs pour OpenRemote or_values = {} @@ -1278,15 +1090,15 @@ def main(): ok_or = publish_openremote(sid, sensor, or_values) print(f" 🏠 OpenRemote: {'✅' if ok_or else '⚠️ skipped'}") - # --- Orion-LD --- (DÉSACTIVÉ: Utiliser uniquement IoT-Agents MQTT) - # if ENABLE_ORION: - # ok_or = publish_orion(sid, sensor) - # print(f" 🌐 Orion-LD: {'✅' if ok_or else '⚠️ skipped'}") +# # --- Orion-LD --- (DÉSACTIVÉ: tout passe par les IoT-Agents MQTT) +# # if ENABLE_ORION: +# # ok_or = publish_orion(sid, sensor) +# # print(f" 🌐 Orion-LD: {'✅' if ok_or else '⚠️ skipped'}") - # --- Stellio --- (DÉSACTIVÉ: Utiliser uniquement IoT-Agents MQTT) - # if ENABLE_STELLIO: - # ok_st = publish_stellio(sid, sensor) - # print(f" 🏢 Stellio: {'✅' if ok_st else '❌'}") +# # --- Stellio --- (DÉSACTIVÉ: tout passe par les IoT-Agents MQTT) +# # if ENABLE_STELLIO: +# # ok_st = publish_stellio(sid, sensor) +# # print(f" 🏢 Stellio: {'✅' if ok_st else '❌'}") # --- FROST --- if ENABLE_FROST: