diff --git a/simulator.py b/simulator.py index ce5c93fa..e636df37 100644 --- a/simulator.py +++ b/simulator.py @@ -43,7 +43,7 @@ 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 -FROST_URL = os.environ.get("FROST_URL", "http://frost_allinone-web-1:8080/FROST-Server/v1.1") +FROST_URL = os.environ.get("FROST_URL", "http://192.168.208.3:8080/FROST-Server/v1.1") SENSOR_COUNTS = { "traffic": int(os.environ.get("SENSOR_COUNT_traffic", "3")), @@ -129,44 +129,101 @@ for stype, locs in SENSOR_LOCATIONS.items(): # ============================================================================= # Payload NGSI-LD pour Orion-LD / Stellio # ============================================================================= -ORION_CONTEXT = ["https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context-v1.8.jsonld"] # schema.org invalide en JSON-LD +# Contextes NGSI-LD : core + Smart Data Models +# https://smartdatamodels.org pour les @context officiels +ORION_CONTEXT = [ + "https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context-v1.8.jsonld", + "https://raw.githubusercontent.com/smart-data-models/dataModel.Environment/master/context.jsonld", + "https://raw.githubusercontent.com/smart-data-models/dataModel.Transportation/master/context.jsonld", + "https://raw.githubusercontent.com/smart-data-models/dataModel.Parking/master/context.jsonld", + "https://raw.githubusercontent.com/smart-data-models/dataModel.Weather/master/context.jsonld", + "https://raw.githubusercontent.com/smart-data-models/dataModel.Device/master/context.jsonld", +] + +# Mapping sensor type → Smart Data Model type NGSI-LD +SMART_MODEL_MAPPING = { + "airquality": "AirQualityObserved", + "traffic": "TrafficFlowObserved", + "parking": "OffStreetParking", + "noise": "NoiseLevelObserved", + "weather": "WeatherObserved", + "light": "Device", +} 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) def _ngsi_payload(sid: str, sensor: dict) -> dict: - """Construit un payload NGSI-LD pour Orion-LD / Stellio.""" + """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": ORION_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)}, + } + + # 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)) and isinstance(hi, (int, float)): + if isinstance(lo, (int, float)): props[field] = {"type": "Property", "value": round(random.uniform(lo, hi), 1)} elif isinstance(val_range, list): - val = random.choice(val_range) - props[field] = {"type": "Property", "value": val} - - if stype == "noise": - props["noise_category"] = {"type": "Property", "value": random.choice(NOISE_CATEGORIES)} - if stype == "light": - props["status"] = {"type": "Property", "value": random.choice(LIGHT_STATUSES)} - - props["battery_level"] = {"type": "Property", "value": random.randint(60, 100)} - - return { - "@context": ORION_CONTEXT, - "id": f"urn:ngsi-ld:Sensor:{sid}", - "type": "Sensor", - "name": {"type": "Property", "value": sensor["name"]}, - "location": {"type": "GeoProperty", - "value": {"type": "Point", - "coordinates": [sensor["lon"], sensor["lat"]]}}, - "sensorType": {"type": "Property", "value": stype}, - **props, - } + 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 def _frost_payload(sid: str, sensor: dict) -> dict: """Construit un payload SensorThings pour FROST-Server.""" @@ -179,7 +236,7 @@ def _frost_payload(sid: str, sensor: dict) -> dict: lo, hi = val_range if isinstance(lo, (int, float)) and isinstance(hi, (int, float)): val = round(random.uniform(lo, hi), 1) - unit = "https://unitsofmeasure.org/..." + unit = "http://www.qudt.org/vocab/unit#DegreeCelsius" obs_prop = { "name": f"{field} Observation", "description": f"Observation of {field}", @@ -205,12 +262,6 @@ def _frost_payload(sid: str, sensor: dict) -> dict: "name": f"Thing_{sid}", "description": f"Smart City {stype} sensor in Martinique", "properties": {"sensorType": stype, "region": "Martinique"}, - "Locations": [{ - "name": sensor["name"], - "description": f"Location of {stype} sensor {sid}", - "encodingType": "application/vnd.geo+json", - "location": {"type": "Point", "coordinates": [sensor["lon"], sensor["lat"]]}, - }], } return thing_payload, datastreams @@ -363,7 +414,7 @@ except: ORION_URL = f"http://{ORION_IP or ORION_HOST}:1026" if ORION_IP else "http://fiware-gis-quickstart-orion-1:1026" STELLIO_URL = "http://stellio-api-gateway:8080" # Configuration OpenRemote (URLs dynamiques) -OR_URL = os.environ.get("OR_URL", "http://192.168.192.10:8080") # IP directe (évite DNS) +OR_URL = os.environ.get("OR_URL", "http://openremote-manager-1:8080") # Hostname Docker interne OR_REALM = os.environ.get("OR_REALM", "smartcity") # Default: smartcity OR_TOKEN_URL = os.environ.get("OR_TOKEN_URL", f"http://openremote-keycloak-1:8080/auth/realms/{OR_TOKEN_REALM}/protocol/openid-connect/token") OR_TOKEN_TTL = int(os.environ.get("OR_TOKEN_TTL", "3600")) # Refresh token every hour @@ -500,7 +551,19 @@ def publish_frost(sid: str, sensor: dict, field: str, value: float) -> bool: if field in ds_map: ds_id = ds_map[field] obs_url = f"{FROST_URL}/Datastreams({ds_id})/Observations" - obs = {"resultTime": datetime.now(timezone.utc).isoformat(), "result": value} + obs = { + "resultTime": datetime.now(timezone.utc).isoformat(), + "result": value, + "FeatureOfInterest": { + "name": f"Location {sid}", + "description": f"Feature of interest for sensor {sid}", + "encodingType": "application/vnd.geo+json", + "feature": { + "type": "Point", + "coordinates": [sensor.get("lon", -61.0), sensor.get("lat", 14.6)] + } + } + } if _http_post(obs_url, obs, FROST_HEADERS): print(f" ✅ FROST Observation {sid}/{field} → OK (cached)") return True @@ -552,15 +615,17 @@ def _get_or_token() -> str: if _or_token_cache["token"] and _or_token_cache["expires"] > time.time() + 60: return _or_token_cache["token"] try: - # Use password grant with admin-cli client (directAccessGrants enabled) + # Use password grant with openremote client in the target realm (smartcity) data = urllib.parse.urlencode({ "grant_type": "password", "username": os.environ.get("OR_ADMIN_USER", "admin"), "password": os.environ.get("OR_ADMIN_PASS", "Digitribe972"), - "client_id": "admin-cli" + "client_id": os.environ.get("OR_CLIENT_ID", "openremote") }).encode() + # Token URL uses OR_REALM (smartcity) not OR_TOKEN_REALM + token_url = f"http://openremote-keycloak-1:8080/auth/realms/{OR_REALM}/protocol/openid-connect/token" req = urllib.request.Request( - OR_TOKEN_URL, + token_url, data=data, headers={"Content-Type": "application/x-www-form-urlencoded"} )