     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|