WIP: Orion Grafana blocked (readOnly) + move to OpenRemote MQTT

This commit is contained in:
Eric FELIXINE
2026-05-04 19:00:23 -04:00
parent 69e08ba633
commit 3e302b0732
2 changed files with 501 additions and 0 deletions

View File

@@ -0,0 +1,501 @@
1|#!/usr/bin/env python3
2|"""
3|Smart City IoT Simulator — Martinique (14.6°N, 61.2°W)
4|=======================================================
5|Publie vers MULTIPLES brokers MQTT + context brokers NGSI-LD.
6|
7|Brokers MQTT:
8| - EMQX: emqx_emqx_1:1883 (sans auth)
9| - Mosquitto: mosquitto-traefik:1883 (bunker/bunker)
10| - BunkerM: bunkerm_bunkerm_1:1900 (TLS, bunker/bunker)
11| - OpenRemote: openremote-manager-1:1883 (admin/Digitribe972)
12|
13|Context Brokers REST:
14| - Orion-LD: fiware-gis-quickstart-orion-1:1026 (NGSI-LD)
15| - Stellio: stellio-api-gateway:8080 (NGSI-LD)
16| - FROST: frost_allinone-web-1:8080/FROST-Server/v1.1 (SensorThings)
17|
18|Variables d'environnement:
19| PUBLISH_INTERVAL_SEC : intervalle de publication (défaut: 10s)
20| BASE_LAT / BASE_LON : coordonnées de base (défaut: Fort-de-France)
21| ENABLE_ORION=1 : activer Orion-LD (défaut: 1)
22| ENABLE_STELLIO=1 : activer Stellio (défaut: 1)
23| ENABLE_FROST=1 : activer FROST-Server (défaut: 1)
24|"""
25|
26|import os, sys, json, time, random, signal, queue, threading, ssl, urllib.parse
27|import paho.mqtt.client as mqtt
28|import urllib.request, urllib.error
29|from datetime import datetime, timezone
30|from typing import Any
31|import influxdb_client
32|from influxdb_client.client.write_api import SYNCHRONOUS
33|
34|# =============================================================================
35|# Configuration
36|# =============================================================================
37|BASE_LAT = float(os.environ.get("BASE_LAT", "14.6091"))
38|BASE_LON = float(os.environ.get("BASE_LON", "-61.2155"))
39|INTERVAL = int(os.environ.get("PUBLISH_INTERVAL_SEC", "10"))
40|ENABLE_ORION = os.environ.get("ENABLE_ORION", "1") == "1"
41|ENABLE_STELLIO = os.environ.get("ENABLE_STELLIO", "1") == "1"
42|ENABLE_FROST = os.environ.get("ENABLE_FROST", "1") == "1"
43|ENABLE_OPENREMOTE = os.environ.get("ENABLE_OPENREMOTE", "1") == "1"
44|OR_ADMIN_USER = os.environ.get("OR_ADMIN_USER", "admin")
45|OR_ADMIN_PASS = os.environ.get("OR_ADMIN_PASS", "Digitribe972")
46|OR_REALM = os.environ.get("OR_REALM", "smartcity")
47|OR_TOKEN_REALM = os.environ.get("OR_TOKEN_REALM", "master") # Realm pour obtention token
48|
49|# InfluxDB config
50|ENABLE_INFLUX = os.environ.get("ENABLE_INFLUX", "1") == "1"
51|INFLUX_URL = os.environ.get("INFLUX_URL", "http://digital-twin-influxdb:8086")
52|INFLUX_ORG = os.environ.get("INFLUX_ORG", "digitribe")
53|INFLUX_BUCKET = os.environ.get("INFLUX_BUCKET", "iot_data")
54|INFLUX_TOKEN = os.environ.get("INFLUX_TOKEN",
55| "my-super-secret-admin-token")
56|
57|# Initialize InfluxDB client
58|_influx_client = None
59|_influx_write_api = None
60|if ENABLE_INFLUX:
61| try:
62| _influx_client = influxdb_client.InfluxDBClient(url=INFLUX_URL, token=INFLUX_TOKEN, org=INFLUX_ORG)
63| _influx_write_api = _influx_client.write_api(write_options=SYNCHRONOUS)
64| print(f"[INFLUX] ✅ Connected to {INFLUX_URL}")
65| except Exception as e:
66| print(f"[INFLUX] ❌ Connection failed: {e}")
FROST_URL = os.environ.get("FROST_URL", "http://frost_http-web-1:8080/FROST-Server/v1.1")
68|
69|SENSOR_COUNTS = {
70| "traffic": int(os.environ.get("SENSOR_COUNT_traffic", "3")),
71| "airquality": int(os.environ.get("SENSOR_COUNT_airquality", "2")),
72| "parking": int(os.environ.get("SENSOR_COUNT_parking", "2")),
73| "noise": int(os.environ.get("SENSOR_COUNT_noise", "1")),
74| "weather": int(os.environ.get("SENSOR_COUNT_weather", "1")),
75| "light": int(os.environ.get("SENSOR_COUNT_light", "1")),
76|}
77|# Si SENSOR_COUNT est défini, multiplier les counts de façon proportionnelle
78|_total_default = sum(SENSOR_COUNTS.values())
79|if "SENSOR_COUNT" in os.environ:
80| target = int(os.environ["SENSOR_COUNT"])
81| ratio = target / _total_default
82| for k in SENSOR_COUNTS:
83| SENSOR_COUNTS[k] = max(1, int(SENSOR_COUNTS[k] * ratio))
84|
85|# =============================================================================
86|# Localisation des capteurs Martinique
87|# =============================================================================
88|SENSOR_LOCATIONS: dict[str, list[dict]] = {}
89|SENSOR_NAMES: dict[str, list[str]] = {
90| "traffic": ["Carrefour Central", "Avenue des Caraïbes", "Boulevard Pasteur",
91| "Rue des Flamboyants", "Place de la République"],
92| "airquality": ["Quartier Bonde", "Port de Fort-de-France", "Château Denis",
93| "Lamentin Aéroport", "Schoelcher Village"],
94| "parking": ["Parking Rivière-Saleé", "Parking Cluny", "Parking Média",
95| "Parking Grand-Camp", "Parking Dillon"],
96| "noise": ["Rue des Arts", "Marché Central", "Université Fort-de-France",
97| "Stade de Dillon", "Place du Champs de Mars"],
98| "weather": ["Station Météo Lamentin", "Station Schoelcher",
99| "Station Ajoupa-Bouillon", "Station Le François", "Station Le Robert"],
100| "light": ["Eclairage Rue des Mouettes", "Candela Boulevard",
101| "Lumiere Rue des Acacias", "Feux Signalisation Centre", "Eclairage Port"],
102|}
103|
104|def _gen_locs(stype: str, count: int) -> list[dict]:
105| locs = []
106| for i in range(count):
107| lat = BASE_LAT + random.uniform(-0.05, 0.05)
108| lon = BASE_LON + random.uniform(-0.05, 0.05)
109| names = SENSOR_NAMES.get(stype, [stype])
110| locs.append({
111| "lat": round(lat, 6),
112| "lon": round(lon, 6),
113| "name": names[i % len(names)],
114| })
115| return locs
116|
117|for stype, count in SENSOR_COUNTS.items():
118| SENSOR_LOCATIONS[stype] = _gen_locs(stype, count)
119|
120|# Ranges par type
121|SENSOR_RANGES: dict[str, dict] = {
122| "traffic": {"vehicle_count":(10,150),"average_speed_kmh":(10,80),
123| "congestion_level":(0,5),"occupancy_percent":(0,100)},
124| "airquality": {"pm25_ugm3":(5,80),"pm10_ugm3":(10,150),"no2_ugm3":(5,60),
125| "o3_ugm3":(20,120),"co_mgm3":(0.1,5.0),
126| "temperature_celsius":(20,35),"humidity_percent":(40,95)},
127| "parking": {"total_spots":(50,500),"available_spots":(0,500),
128| "occupancy_percent":(0,100),"turnover_per_hour":(5,50)},
129| "noise": {"noise_level_db":(40,95),"peak_db":(60,110)},
130| "weather": {"temperature_celsius":(22,34),"humidity_percent":(50,95),
131| "wind_speed_kmh":(0,50),"pressure_hpa":(1005,1025),
132| "rain_mm":(0,20),"uv_index":(0,11)},
133| "light": {"brightness_lux":(0,100000),"power_consumption_w":(0,500)},
134|}
135|
136|NOISE_CATEGORIES = ["quiet","moderate","loud","very_loud"]
137|LIGHT_STATUSES = ["on","off","dimmed","auto"]
138|
139|# =============================================================================
140|# Capteurs déclarés
141|# =============================================================================
142|SENSORS: dict[str, dict] = {}
143|counter = 0
144|for stype, locs in SENSOR_LOCATIONS.items():
145| for loc in locs:
146| sid = f"{stype}_{counter:03d}"
147| SENSORS[sid] = {"type": stype, "lat": loc["lat"], "lon": loc["lon"], "name": loc["name"]}
148| counter += 1
149|
150|# =============================================================================
151|# Payload NGSI-LD pour Orion-LD / Stellio
152|# =============================================================================
153|# Contextes NGSI-LD : core + Smart Data Models
154|# https://smartdatamodels.org pour les @context officiels
155|# Contexte NGSI-LD pur pour Orion-LD (vocabulaires standards uniquement)
156|# Orion-LD ne peut pas résoudre raw.githubusercontent.com — utiliser uri.etsi.org uniquement
157|ORION_CONTEXT = [
158| "https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context.jsonld",
159|]
160|
161|# Mapping sensor type → Smart Data Model type NGSI-LD
162|SMART_MODEL_MAPPING = {
163| "airquality": "AirQualityObserved",
164| "traffic": "TrafficFlowObserved",
165| "parking": "OffStreetParking",
166| "noise": "NoiseLevelObserved",
167| "weather": "WeatherObserved",
168| "light": "Device",
169|}
170|FROST_HEADERS = {"Accept": "application/json", "Content-Type": "application/json"}
171|
172|# Cache FROST : éviter de recréer Thing/Datastream
173|_frost_cache: dict[str, tuple[str, str]] = {} # (sid, field) -> (thing_id, ds_id)
174|
175|# Contexte NGSI-LD pur pour Stellio et Orion-LD (vocabulaires standards uniquement)
176|# Stellio et Orion-LD embarquent le contexte core NGSI-LD : https://uri.etsi.org/ngsi-ld/
177|# On n'utilise PAS les vocabulaires smartdatamodels.org distants (inaccessibles depuis les containers)
178|# Les types d'entité Smart Data Models (AirQualityObserved, etc.) sont reconnus par leur nom
179|# Les propriétés spécifiques sont stockées telles quelles (vocabulaire libre)
180|STELLIO_INLINE_CONTEXT = [
181| "https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context.jsonld",
182|]
183|
184|def _ngsi_payload(sid: str, sensor: dict, context: list | dict = ORION_CONTEXT) -> dict:
185| """Construit un payload NGSI-LD avec Smart Data Models officiels."""
186| stype = sensor["type"]
187| model_type = SMART_MODEL_MAPPING.get(stype, "Device")
188| now = datetime.now(timezone.utc).isoformat()
189|
190| # Attributs communs à tous les modèles
191| payload = {
192| "@context": context,
193| "id": f"urn:ngsi-ld:{model_type}:{sid}",
194| "type": model_type,
195| "dateObserved": {"type": "Property", "value": now},
196| "location": {"type": "GeoProperty",
197| "value": {"type": "Point",
198| "coordinates": [sensor["lon"], sensor["lat"]]}},
199| "name": {"type": "Property", "value": sensor["name"]},
200| "batteryLevel": {"type": "Property", "value": random.randint(60, 100)},
201| }
202|
203| # Attributs spécifiques par type de modèle
204| ranges = SENSOR_RANGES.get(stype, {})
205| props = {}
206| for field, val_range in ranges.items():
207| if isinstance(val_range, tuple) and len(val_range) == 2:
208| lo, hi = val_range
209| if isinstance(lo, (int, float)):
210| props[field] = {"type": "Property", "value": round(random.uniform(lo, hi), 1)}
211| elif isinstance(val_range, list):
212| props[field] = {"type": "Property", "value": random.choice(val_range)}
213|
214| # Mapping vers les noms d'attributs Smart Data Models
215| if stype == "airquality":
216| if "pm25_ugm3" in props: payload["NO2"] = props.pop("pm25_ugm3") # Simplifié
217| if "pm10_ugm3" in props: payload["PM10"] = props.pop("pm10_ugm3")
218| if "no2_ugm3" in props: payload["NO2"] = props.pop("no2_ugm3")
219| if "o3_ugm3" in props: payload["O3"] = props.pop("o3_ugm3")
220| if "co_mgm3" in props: payload["CO"] = props.pop("co_mgm3")
221| if "temperature_celsius" in props: payload["temperature"] = props.pop("temperature_celsius")
222| if "humidity_percent" in props: payload["relativeHumidity"] = props.pop("humidity_percent")
223|
224| elif stype == "traffic":
225| if "vehicle_count" in props: payload["vehicleCount"] = props.pop("vehicle_count")
226| if "average_speed_kmh" in props: payload["averageVehicleSpeed"] = props.pop("average_speed_kmh")
227| if "congestion_level" in props: payload["congestion"] = props.pop("congestion_level")
228| if "occupancy_percent" in props: payload["occupancy"] = props.pop("occupancy_percent")
229|
230| elif stype == "parking":
231| if "available_spots" in props: payload["availableSpotNumber"] = props.pop("available_spots")
232| if "total_spots" in props: payload["totalSpotNumber"] = props.pop("total_spots")
233| if "occupancy_percent" in props: payload["occupancy"] = props.pop("occupancy_percent")
234| if "turnover_per_hour" in props: payload["turnover"] = props.pop("turnover_per_hour")
235|
236| elif stype == "noise":
237| if "noise_level_db" in props: payload["noiseLevel"] = props.pop("noise_level_db")
238| if "peak_db" in props: payload["noisePeak"] = props.pop("peak_db")
239| payload["noiseCategory"] = {"type": "Property", "value": random.choice(NOISE_CATEGORIES)}
240|
241| elif stype == "weather":
242| if "temperature_celsius" in props: payload["temperature"] = props.pop("temperature_celsius")
243| if "humidity_percent" in props: payload["relativeHumidity"] = props.pop("humidity_percent")
244| if "rain_mm" in props: payload["rainfall"] = props.pop("rain_mm")
245| if "uv_index" in props: payload["uvIndex"] = props.pop("uv_index")
246| if "wind_speed_kmh" in props: payload["windSpeed"] = props.pop("wind_speed_kmh")
247|
248| elif stype == "light":
249| if "brightness_lux" in props: payload["illuminance"] = props.pop("brightness_lux")
250| if "power_consumption_w" in props: payload["power"] = props.pop("power_consumption_w")
251| payload["status"] = {"type": "Property", "value": random.choice(LIGHT_STATUSES)}
252|
253| return payload
254|
255|def _frost_payload(sid: str, sensor: dict) -> dict:
256| """Construit un payload SensorThings pour FROST-Server."""
257| stype = sensor["type"]
258| ranges = SENSOR_RANGES.get(stype, {})
259| datastreams = []
260|
261| for field, val_range in ranges.items():
262| if isinstance(val_range, tuple) and len(val_range) == 2:
263| lo, hi = val_range
264| if isinstance(lo, (int, float)) and isinstance(hi, (int, float)):
265| val = round(random.uniform(lo, hi), 1)
266| unit = "http://www.qudt.org/vocab/unit#DegreeCelsius"
267| obs_prop = {
268| "name": f"{field} Observation",
269| "description": f"Observation of {field}",
270| "definition": unit,
271| }
272| sensor_data = {
273| "name": f"Sensor {sid} {field}",
274| "description": f"Sensor {sid} measuring {field}",
275| "encodingType": "http://www.opengis.net/doc/IS/SensorML/2.0",
276| "metadata": {"unit": unit},
277| }
278| ds = {
279| "name": f"Datastream {stype}/{field}",
280| "description": f"Datastream for {stype} sensor {sid} - {field}",
281| "observationType": "http://www.opengis.net/def/observationType/OGC-OM/2.0/OM_Measurement",
282| "unitOfMeasurement": {"name": field, "symbol": "", "definition": unit},
283| "Sensor": sensor_data,
284| "ObservedProperty": obs_prop,
285| }
286| datastreams.append((field, ds, val))
287|
288| thing_payload = {
289| "name": f"Thing_{sid}",
290| "description": f"Smart City {stype} sensor in Martinique",
291| "properties": {"sensorType": stype, "region": "Martinique"},
292| }
293| return thing_payload, datastreams
294|
295|# =============================================================================
296|# HTTP helper
297|# =============================================================================
298|def _http_post(url: str, data: dict, headers: dict) -> str:
299| """POST et retourne 'ok' ou 'created' (ou '' si échec)."""
300| try:
301| body = json.dumps(data).encode()
302| req = urllib.request.Request(url, data=body, headers=headers, method="POST")
303| with urllib.request.urlopen(req, timeout=8) as resp:
304| if resp.status == 204:
305| return 'created' # No Content — succès
306| if resp.status not in (200, 201):
307| return ''
308| # Lire le corps pour extraire l'ID (FROST)
309| try:
310| result = json.loads(resp.read())
311| if '@iot.selfLink' in result:
312| link = result['@iot.selfLink']
313| return link.split('(')[1].rstrip(')')
314| if '@iot.id' in result:
315| return str(result['@iot.id'])
316| except Exception:
317| pass
318| location = resp.headers.get('Location', '')
319| if location:
320| return location.split('(')[1].rstrip(')') if '(' in location else ''
321| return 'created'
322| except urllib.error.HTTPError as e:
323| # Lire le corps de l'erreur pour debug
324| try:
325| err_body = e.read().decode()[:200]
326| except Exception:
327| err_body = str(e)
328| print(f" ⚠️ HTTP POST {url} → {e.code}: {err_body}")
329| return ''
330| except Exception as e:
331| print(f" ⚠️ HTTP POST {url} → {e}")
332| return ''
333|
334|def _http_put(url: str, data: dict, headers: dict) -> bool:
335| try:
336| body = json.dumps(data).encode()
337| req = urllib.request.Request(url, data=body, headers=headers, method="PUT")
338| with urllib.request.urlopen(req, timeout=5) as resp:
339| return resp.status in (200, 204)
340| except urllib.error.HTTPError as e:
341| if e.code == 409:
342| return True # Already exists - that's fine
343| print(f" ⚠️ HTTP PUT {url} → {e}")
344| return False
345| except Exception as e:
346| print(f" ⚠️ HTTP PUT {url} → {e}")
347| return False
348|
349|# =============================================================================
350|# MQTT Client multi-broker
351|# =============================================================================
352|class MultiMQTT:
353| def __init__(self):
354| self.clients: dict[str, mqtt.Client] = {}
355| self.ok: dict[str, bool] = {}
356| self._lock = threading.Lock()
357| self._setup()
358|
359| def _mk_client(self, name: str, host: str, port: int,
360| tls: bool = False, user: str = "", pwd: str = "",
361| ws: bool = False) -> mqtt.Client:
362| cid = f"smartcity-sim-{name}-{os.getpid()}"
363| c = mqtt.Client(client_id=cid, protocol=mqtt.MQTTv311)
364| if user:
365| c.username_pw_set(user, pwd)
366| if tls:
367| c.tls_set(cert_reqs=ssl.CERT_NONE)
368| c.tls_insecure_set(True)
369| if ws:
370| c.ws_set(b"/mqtt")
371| c.on_connect = lambda _c, _, __, rc: self._on_connect(name, rc)
372| c.on_disconnect = lambda _c, _, __: self._on_disconnect(name)
373| try:
374| c.connect(host, port, keepalive=30)
375| c.loop_start()
376| except Exception as e:
377| print(f"[MQTT] ❌ {name} @ {host}:{port} → {e}")
378| self.ok[name] = False
379| return c
380|
381| def _on_connect(self, name: str, rc: int):
382| with self._lock:
383| if rc == 0:
384| self.ok[name] = True
385| print(f"[MQTT] ✅ {name} connecté")
386| else:
387| self.ok[name] = False
388| print(f"[MQTT] ❌ {name} rc={rc}")
389|
390| def _on_disconnect(self, name: str):
391| with self._lock:
392| self.ok[name] = False
393| print(f"[MQTT] ⚠️ {name} déconnecté")
394|
395| def _setup(self):
396| # Garder que EMQX et Mosquitto (MQTT fonctionnels)
397| # BunkerM via HTTP API (port 2000) au lieu de MQTT/TLS
398| brokers = [
399| ("EMQX", "emqx_emqx_1", 1883, False, "", ""),
400| ("Mosquitto", "mosquitto-traefik", 1883, False, "bunker", "bunker"),
401| ]
402| print("[MQTT] 🔌 Connexion aux brokers...")
403| for name, host, port, tls, user, pwd in brokers:
404| c = self._mk_client(name, host, port, tls=tls, user=user, pwd=pwd)
405| self.clients[name] = c
406| self.ok[name] = False
407| time.sleep(3) # Attend les connexions
408|
409| def publish(self, topic: str, payload: str) -> dict[str, bool]:
410| results = {}
411| with self._lock:
412| for name, client in self.clients.items():
413| if self.ok.get(name, False):
414| try:
415| r = client.publish(topic, payload, qos=1)
416| results[name] = (r.rc == mqtt.MQTT_ERR_SUCCESS)
417| except Exception:
418| results[name] = False
419| else:
420| results[name] = False
421| return results
422|
423| def stop(self):
424| for name, c in self.clients.items():
425| try:
426| c.loop_stop()
427| c.disconnect()
428| except Exception:
429| pass
430|
431|# =============================================================================
432|# URLs de base (résolues au démarrage)
433|# =============================================================================
434|ORION_HOST = "fiware-gis-quickstart-orion-1"
435|ORION_IP = ""
436|try:
437| import socket
438| ORION_IP = socket.gethostbyname(ORION_HOST)
439|except:
440| pass
441|ORION_URL = f"http://{ORION_IP or ORION_HOST}:1026" if ORION_IP else "http://fiware-gis-quickstart-orion-1:1026"
442|STELLIO_URL = os.environ.get("STELLIO_URL", "http://stellio-api-gateway:8080")
443|# Configuration OpenRemote (URLs dynamiques)
444|OR_URL = os.environ.get("OR_URL", "http://openremote-manager-1:8080") # Hostname Docker interne
445|OR_REALM = os.environ.get("OR_REALM", "smartcity") # Default: smartcity
446|OR_TOKEN_URL = os.environ.get("OR_TOKEN_URL", f"http://openremote-keycloak-1:8080/auth/realms/{OR_TOKEN_REALM}/protocol/openid-connect/token")
447|OR_TOKEN_TTL = int(os.environ.get("OR_TOKEN_TTL", "3600")) # Refresh token every hour
448|STELLIO_TENANT = os.environ.get("STELLIO_TENANT", "urn:ngsi-ld:tenant:default")
449|
450|def publish_stellio(sid: str, sensor: dict) -> bool:
451| """Publie sur Stellio via Traefik (gère le 409)."""
452| entity = _ngsi_payload(sid, sensor, context=STELLIO_INLINE_CONTEXT)
453| # Stellio a besoin du @context pour résoudre les vocabulaires NGSI-LD
454| # (uri.etsi.org résolu depuis le JAR embarqué)
455| url = f"{STELLIO_URL}/ngsi-ld/v1/entities"
456| headers = {
457| "Content-Type": "application/ld+json",
458| "Accept": "application/ld+json",
459| "NGSILD-Tenant": STELLIO_TENANT,
460| }
461| try:
462| body = json.dumps(entity).encode()
463| req = urllib.request.Request(url, data=body, headers=headers, method="POST")
464| with urllib.request.urlopen(req, timeout=8) as resp:
465| print(f" 🏢 Stellio: ✅ (HTTP {resp.status})")
466| return True
467| except urllib.error.HTTPError as e:
468| if e.code == 409: # Already exists, do update with PUT
469| try:
470| entity_id = urllib.parse.quote(entity["id"], safe="")
471| update_url = f"{STELLIO_URL}/ngsi-ld/v1/entities/{entity_id}"
472| req2 = urllib.request.Request(update_url, data=body, headers=headers, method="PUT")
473| with urllib.request.urlopen(req2, timeout=8) as resp2:
474| print(f" 🏢 Stellio: ✅ (HTTP {resp2.status} updated)")
475| return True
476| except Exception as e2:
477| print(f" ⚠️ Stellio update failed: {e2}")
478| return False
479| try:
480| err = e.read().decode()[:300]
481| except Exception:
482| err = str(e)
483| print(f" ⚠️ Stellio → {e.code}: {err}")
484| return False
485| except Exception as e:
486| print(f" ⚠️ Stellio → {e}")
487| return False
488|
489|def publish_orion(sid: str, sensor: dict) -> bool:
490| """Publie sur Orion-LD (POST create, PATCH update)."""
491| import socket
492| entity = _ngsi_payload(sid, sensor)
493| if not hasattr(publish_orion, "orion_ip"):
494| try:
495| publish_orion.orion_ip = socket.gethostbyname("fiware-gis-quickstart-orion-1")
496| except Exception:
497| publish_orion.orion_ip = "192.168.192.20"
498| base = f"http://{publish_orion.orion_ip}:1026/ngsi-ld/v1"
499| # 1. Essayer de créer (POST)
500| try:
501|