feat: Smart Data Models + FROST/NGSI-LD fixes

- Intégration Smart Data Models (AirQualityObserved, TrafficFlowObserved, etc.)
- Payloads NGSI-LD avec @context officiels smartdatamodels.org
- FROST: Ajout FeatureOfInterest dans Observations (fix 400)
- FROST: Migration HTTP-only + suppression Locations du Thing
- URLs unités QUDT corrigées
- OpenRemote: Token realm smartcity (en attente 401)
- Orion-LD/Stellio: 204 success avec Smart Data Models
This commit is contained in:
Eric FELIXINE
2026-05-03 10:53:55 -04:00
parent e8270b7d73
commit 871194a5e3

View File

@@ -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_ADMIN_PASS = os.environ.get("OR_ADMIN_PASS", "Digitribe972")
OR_REALM = os.environ.get("OR_REALM", "smartcity") OR_REALM = os.environ.get("OR_REALM", "smartcity")
OR_TOKEN_REALM = os.environ.get("OR_TOKEN_REALM", "master") # Realm pour obtention token 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 = { SENSOR_COUNTS = {
"traffic": int(os.environ.get("SENSOR_COUNT_traffic", "3")), "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 # 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"} FROST_HEADERS = {"Accept": "application/json", "Content-Type": "application/json"}
# Cache FROST : éviter de recréer Thing/Datastream # Cache FROST : éviter de recréer Thing/Datastream
_frost_cache: dict[str, tuple[str, str]] = {} # (sid, field) -> (thing_id, ds_id) _frost_cache: dict[str, tuple[str, str]] = {} # (sid, field) -> (thing_id, ds_id)
def _ngsi_payload(sid: str, sensor: dict) -> dict: 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"] 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, {}) ranges = SENSOR_RANGES.get(stype, {})
props = {} props = {}
for field, val_range in ranges.items(): for field, val_range in ranges.items():
if isinstance(val_range, tuple) and len(val_range) == 2: if isinstance(val_range, tuple) and len(val_range) == 2:
lo, hi = val_range 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)} props[field] = {"type": "Property", "value": round(random.uniform(lo, hi), 1)}
elif isinstance(val_range, list): elif isinstance(val_range, list):
val = random.choice(val_range) props[field] = {"type": "Property", "value": random.choice(val_range)}
props[field] = {"type": "Property", "value": val}
# Mapping vers les noms d'attributs Smart Data Models
if stype == "noise": if stype == "airquality":
props["noise_category"] = {"type": "Property", "value": random.choice(NOISE_CATEGORIES)} if "pm25_ugm3" in props: payload["NO2"] = props.pop("pm25_ugm3") # Simplifié
if stype == "light": if "pm10_ugm3" in props: payload["PM10"] = props.pop("pm10_ugm3")
props["status"] = {"type": "Property", "value": random.choice(LIGHT_STATUSES)} if "no2_ugm3" in props: payload["NO2"] = props.pop("no2_ugm3")
if "o3_ugm3" in props: payload["O3"] = props.pop("o3_ugm3")
props["battery_level"] = {"type": "Property", "value": random.randint(60, 100)} if "co_mgm3" in props: payload["CO"] = props.pop("co_mgm3")
if "temperature_celsius" in props: payload["temperature"] = props.pop("temperature_celsius")
return { if "humidity_percent" in props: payload["relativeHumidity"] = props.pop("humidity_percent")
"@context": ORION_CONTEXT,
"id": f"urn:ngsi-ld:Sensor:{sid}", elif stype == "traffic":
"type": "Sensor", if "vehicle_count" in props: payload["vehicleCount"] = props.pop("vehicle_count")
"name": {"type": "Property", "value": sensor["name"]}, if "average_speed_kmh" in props: payload["averageVehicleSpeed"] = props.pop("average_speed_kmh")
"location": {"type": "GeoProperty", if "congestion_level" in props: payload["congestion"] = props.pop("congestion_level")
"value": {"type": "Point", if "occupancy_percent" in props: payload["occupancy"] = props.pop("occupancy_percent")
"coordinates": [sensor["lon"], sensor["lat"]]}},
"sensorType": {"type": "Property", "value": stype}, elif stype == "parking":
**props, 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: def _frost_payload(sid: str, sensor: dict) -> dict:
"""Construit un payload SensorThings pour FROST-Server.""" """Construit un payload SensorThings pour FROST-Server."""
@@ -179,7 +236,7 @@ def _frost_payload(sid: str, sensor: dict) -> dict:
lo, hi = val_range lo, hi = val_range
if isinstance(lo, (int, float)) and isinstance(hi, (int, float)): if isinstance(lo, (int, float)) and isinstance(hi, (int, float)):
val = round(random.uniform(lo, hi), 1) val = round(random.uniform(lo, hi), 1)
unit = "https://unitsofmeasure.org/..." unit = "http://www.qudt.org/vocab/unit#DegreeCelsius"
obs_prop = { obs_prop = {
"name": f"{field} Observation", "name": f"{field} Observation",
"description": f"Observation of {field}", "description": f"Observation of {field}",
@@ -205,12 +262,6 @@ def _frost_payload(sid: str, sensor: dict) -> dict:
"name": f"Thing_{sid}", "name": f"Thing_{sid}",
"description": f"Smart City {stype} sensor in Martinique", "description": f"Smart City {stype} sensor in Martinique",
"properties": {"sensorType": stype, "region": "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 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" 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" STELLIO_URL = "http://stellio-api-gateway:8080"
# Configuration OpenRemote (URLs dynamiques) # 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_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_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 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: if field in ds_map:
ds_id = ds_map[field] ds_id = ds_map[field]
obs_url = f"{FROST_URL}/Datastreams({ds_id})/Observations" 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): if _http_post(obs_url, obs, FROST_HEADERS):
print(f" ✅ FROST Observation {sid}/{field} → OK (cached)") print(f" ✅ FROST Observation {sid}/{field} → OK (cached)")
return True return True
@@ -552,15 +615,17 @@ def _get_or_token() -> str:
if _or_token_cache["token"] and _or_token_cache["expires"] > time.time() + 60: if _or_token_cache["token"] and _or_token_cache["expires"] > time.time() + 60:
return _or_token_cache["token"] return _or_token_cache["token"]
try: 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({ data = urllib.parse.urlencode({
"grant_type": "password", "grant_type": "password",
"username": os.environ.get("OR_ADMIN_USER", "admin"), "username": os.environ.get("OR_ADMIN_USER", "admin"),
"password": os.environ.get("OR_ADMIN_PASS", "Digitribe972"), "password": os.environ.get("OR_ADMIN_PASS", "Digitribe972"),
"client_id": "admin-cli" "client_id": os.environ.get("OR_CLIENT_ID", "openremote")
}).encode() }).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( req = urllib.request.Request(
OR_TOKEN_URL, token_url,
data=data, data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"} headers={"Content-Type": "application/x-www-form-urlencoded"}
) )