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:
133
simulator.py
133
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}
|
||||
props[field] = {"type": "Property", "value": random.choice(val_range)}
|
||||
|
||||
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)}
|
||||
# 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")
|
||||
|
||||
props["battery_level"] = {"type": "Property", "value": random.randint(60, 100)}
|
||||
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")
|
||||
|
||||
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,
|
||||
}
|
||||
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"}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user