|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Bootstrap Storebaelt webcam CSAPI resources.""" |
| 3 | + |
| 4 | +import argparse |
| 5 | +import json |
| 6 | +import os |
| 7 | +import sys |
| 8 | + |
| 9 | +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..")) |
| 10 | +from publishers.bootstrap_helpers import ( |
| 11 | + get_config, |
| 12 | + _auth_header, |
| 13 | + ensure_procedure, |
| 14 | + ensure_system, |
| 15 | + ensure_datastream, |
| 16 | + ensure_deployment, |
| 17 | + clean_resource, |
| 18 | + add_bootstrap_args, |
| 19 | + print_summary, |
| 20 | +) |
| 21 | + |
| 22 | + |
| 23 | +VALID_TIME_START = "2026-01-01T00:00:00Z" |
| 24 | +PUBLISH_INTERVAL_SECONDS = 300 |
| 25 | + |
| 26 | +PROC_UID = "urn:os4csapi:procedure:storebaelt-webcam-poster:v1" |
| 27 | +DEPLOY_ROOT_UID = "urn:os4csapi:deployment:storebaelt-webcams-demo:v1" |
| 28 | +DEPLOY_GROUP_UID = "urn:os4csapi:deployment:storebaelt-webcams:v1" |
| 29 | +DS_OUTPUT_NAME = "storebaeltWebcamImage" |
| 30 | + |
| 31 | +STOREBAELT_HOME = "https://storebaelt.dk/" |
| 32 | +STOREBAELT_WEBCAMS_PAGE = "https://storebaelt.dk/trafik-vejr/webcams/" |
| 33 | +STOREBAELT_OPERATOR = "A/S Storebaelt" |
| 34 | + |
| 35 | + |
| 36 | +def _load_cameras() -> list[dict]: |
| 37 | + here = os.path.dirname(os.path.abspath(__file__)) |
| 38 | + with open(os.path.join(here, "cameras.json"), encoding="utf-8") as file: |
| 39 | + return json.load(file)["cameras"] |
| 40 | + |
| 41 | + |
| 42 | +def _system_uid(camera: dict | str) -> str: |
| 43 | + camera_id = camera if isinstance(camera, str) else camera["id"] |
| 44 | + return f"urn:os4csapi:system:storebaelt-webcam:{camera_id}:v1" |
| 45 | + |
| 46 | + |
| 47 | +def _deploy_uid(camera: dict) -> str: |
| 48 | + return f"urn:os4csapi:deployment:storebaelt-webcam-{camera['id']}:v1" |
| 49 | + |
| 50 | + |
| 51 | +def _datastream_uid(camera: dict) -> str: |
| 52 | + return f"urn:os4csapi:datastream:storebaelt-webcam:{camera['id']}:storebaeltWebcamImage:v1" |
| 53 | + |
| 54 | + |
| 55 | +def _camera_geometry(camera: dict) -> dict: |
| 56 | + return {"type": "Point", "coordinates": [camera["lon"], camera["lat"]]} |
| 57 | + |
| 58 | + |
| 59 | +PROCEDURE_STUB = { |
| 60 | + "type": "Feature", |
| 61 | + "geometry": None, |
| 62 | + "properties": { |
| 63 | + "uid": PROC_UID, |
| 64 | + "featureType": "sosa:ObservingProcedure", |
| 65 | + "name": "Storebaelt Webcam Poster Image Publisher v1", |
| 66 | + "description": "Publishes image-reference observations from the public Storebaelt traffic/weather webcam poster images.", |
| 67 | + "validTime": [VALID_TIME_START, ".."], |
| 68 | + }, |
| 69 | +} |
| 70 | + |
| 71 | +PROCEDURE_SML = { |
| 72 | + "type": "SimpleProcess", |
| 73 | + "id": PROC_UID, |
| 74 | + "uniqueId": PROC_UID, |
| 75 | + "definition": "sosa:ObservingProcedure", |
| 76 | + "label": "Storebaelt Webcam Poster Image Publisher v1", |
| 77 | + "description": ( |
| 78 | + "Polls the public Storebaelt webcam poster JPEG endpoints exposed by the embedded " |
| 79 | + "Mediathand player pages and publishes image-reference observations. The live video " |
| 80 | + "player URLs and public Storebaelt webcam page are retained as provenance." |
| 81 | + ), |
| 82 | + "keywords": ["Storebaelt", "Denmark", "traffic", "weather", "webcam", "image reference"], |
| 83 | + "documents": [ |
| 84 | + {"role": "http://dbpedia.org/resource/Web_page", "name": "Storebaelt", "link": {"href": STOREBAELT_HOME, "type": "text/html"}}, |
| 85 | + {"role": "http://dbpedia.org/resource/Web_page", "name": "Storebaelt Webcams", "link": {"href": STOREBAELT_WEBCAMS_PAGE, "type": "text/html"}}, |
| 86 | + ], |
| 87 | + "contacts": [ |
| 88 | + {"role": "operator", "organisationName": STOREBAELT_OPERATOR, "contactInfo": {"onlineResource": {"linkage": STOREBAELT_HOME}}}, |
| 89 | + {"role": "publisher", "organisationName": "OS4CSAPI", "contactInfo": {"onlineResource": {"linkage": "https://github.com/OS4CSAPI/OSHConnect-Python"}}}, |
| 90 | + ], |
| 91 | +} |
| 92 | + |
| 93 | + |
| 94 | +def _system_stub(camera: dict) -> dict: |
| 95 | + return { |
| 96 | + "type": "Feature", |
| 97 | + "geometry": _camera_geometry(camera), |
| 98 | + "properties": { |
| 99 | + "uid": _system_uid(camera), |
| 100 | + "featureType": "sml:PhysicalSystem", |
| 101 | + "name": camera["title"], |
| 102 | + "description": f"Storebaelt public traffic/weather webcam at {camera['locationName']}.", |
| 103 | + "typeOf@link": {"href": "pending", "uid": PROC_UID, "title": "Storebaelt Webcam Poster Image Publisher v1"}, |
| 104 | + "validTime": [VALID_TIME_START, ".."], |
| 105 | + }, |
| 106 | + } |
| 107 | + |
| 108 | + |
| 109 | +def _system_sml(camera: dict) -> dict: |
| 110 | + return { |
| 111 | + "type": "PhysicalSystem", |
| 112 | + "id": _system_uid(camera), |
| 113 | + "uniqueId": _system_uid(camera), |
| 114 | + "definition": "sosa:System", |
| 115 | + "name": camera["title"], |
| 116 | + "label": camera["title"], |
| 117 | + "description": ( |
| 118 | + f"Public Storebaelt traffic/weather webcam for {camera['locationName']}. " |
| 119 | + "The publisher emits references to the current poster JPEG and preserves the " |
| 120 | + "embedded player URL for live-video context." |
| 121 | + ), |
| 122 | + "identifiers": [ |
| 123 | + {"definition": "http://sensorml.com/ont/swe/property/UniqueID", "label": "OS4CSAPI UID", "value": _system_uid(camera)}, |
| 124 | + {"definition": "http://sensorml.com/ont/swe/property/ProcedureID", "label": "Procedure UID", "value": PROC_UID}, |
| 125 | + {"definition": "http://sensorml.com/ont/swe/property/ShortName", "label": "Short Name", "value": camera["id"]}, |
| 126 | + ], |
| 127 | + "classifiers": [ |
| 128 | + {"definition": "http://sensorml.com/ont/swe/property/SensorType", "label": "System Type", "value": "Traffic/weather webcam"}, |
| 129 | + {"definition": "http://sensorml.com/ont/swe/property/DataSource", "label": "Data Source", "value": "Storebaelt webcams"}, |
| 130 | + {"definition": "http://sensorml.com/ont/swe/property/Coverage", "label": "Coverage", "value": "Great Belt, Denmark"}, |
| 131 | + ], |
| 132 | + "contacts": [ |
| 133 | + {"role": "operator", "organisationName": STOREBAELT_OPERATOR, "contactInfo": {"onlineResource": {"linkage": STOREBAELT_HOME}}}, |
| 134 | + {"role": "publisher", "organisationName": "OS4CSAPI Project", "contactInfo": {"onlineResource": {"linkage": "https://github.com/OS4CSAPI"}}}, |
| 135 | + ], |
| 136 | + "documents": [ |
| 137 | + {"role": "http://dbpedia.org/resource/Web_page", "name": "Storebaelt Webcams", "link": {"href": camera["pageUrl"], "type": "text/html"}}, |
| 138 | + {"role": "http://dbpedia.org/resource/Web_page", "name": "Embedded Webcam Player", "link": {"href": camera["playerUrl"], "type": "text/html"}}, |
| 139 | + {"role": "http://dbpedia.org/resource/Photograph", "name": "Latest Webcam Poster", "link": {"href": camera["posterUrl"], "type": "image/jpeg"}}, |
| 140 | + ], |
| 141 | + "characteristics": [ |
| 142 | + { |
| 143 | + "name": "camera_source", |
| 144 | + "type": "DataRecord", |
| 145 | + "label": "Camera Source", |
| 146 | + "fields": [ |
| 147 | + {"name": "cameraId", "type": "Text", "label": "Camera ID", "value": camera["id"]}, |
| 148 | + {"name": "siteTitle", "type": "Text", "label": "Site Title", "value": camera["siteTitle"]}, |
| 149 | + {"name": "posterUrl", "type": "Text", "label": "Poster URL", "value": camera["posterUrl"]}, |
| 150 | + {"name": "playerUrl", "type": "Text", "label": "Player URL", "value": camera["playerUrl"]}, |
| 151 | + {"name": "publishIntervalSeconds", "type": "Count", "label": "Default Publish Interval", "value": PUBLISH_INTERVAL_SECONDS}, |
| 152 | + ], |
| 153 | + } |
| 154 | + ], |
| 155 | + } |
| 156 | + |
| 157 | + |
| 158 | +def _datastream_schema(camera: dict) -> dict: |
| 159 | + return { |
| 160 | + "uid": _datastream_uid(camera), |
| 161 | + "outputName": DS_OUTPUT_NAME, |
| 162 | + "name": "Storebaelt Webcam Image Reference", |
| 163 | + "description": f"Image-reference observations for {camera['title']}.", |
| 164 | + "documentation": [ |
| 165 | + {"title": "Storebaelt Webcams", "href": camera["pageUrl"], "rel": "about"}, |
| 166 | + {"title": "Embedded Player", "href": camera["playerUrl"], "rel": "service"}, |
| 167 | + {"title": "Latest Poster JPEG", "href": camera["posterUrl"], "rel": "preview"}, |
| 168 | + ], |
| 169 | + "schema": { |
| 170 | + "obsFormat": "application/om+json", |
| 171 | + "resultSchema": { |
| 172 | + "type": "DataRecord", |
| 173 | + "label": "Storebaelt Webcam Image Reference", |
| 174 | + "fields": [ |
| 175 | + {"type": "Text", "name": "cameraId", "label": "Camera ID", "definition": "http://sensorml.com/ont/swe/property/SensorID"}, |
| 176 | + {"type": "Text", "name": "cameraTitle", "label": "Camera Title", "definition": "http://purl.org/dc/elements/1.1/title"}, |
| 177 | + {"type": "Text", "name": "locationName", "label": "Location Name", "definition": "http://purl.org/dc/terms/spatial"}, |
| 178 | + {"type": "Text", "name": "imageUrl", "label": "Image URL", "definition": "http://www.opengis.net/def/property/OGC/0/ImageURL"}, |
| 179 | + {"type": "Text", "name": "latestImageUrl", "label": "Latest Image URL", "definition": "http://www.opengis.net/def/property/OGC/0/ImageURL"}, |
| 180 | + {"type": "Text", "name": "posterUrl", "label": "Poster Image URL", "definition": "http://www.opengis.net/def/property/OGC/0/ImageURL"}, |
| 181 | + {"type": "Text", "name": "thumbUrl", "label": "Thumbnail URL", "definition": "http://www.opengis.net/def/property/OGC/0/ImageURL"}, |
| 182 | + {"type": "Text", "name": "playerUrl", "label": "Player URL", "definition": "http://sensorml.com/ont/swe/property/ReferenceURL"}, |
| 183 | + {"type": "Text", "name": "pageUrl", "label": "Source Page URL", "definition": "http://sensorml.com/ont/swe/property/ReferenceURL"}, |
| 184 | + {"type": "Text", "name": "mediaType", "label": "Media Type", "definition": "http://purl.org/dc/elements/1.1/format"}, |
| 185 | + {"type": "Text", "name": "sourceType", "label": "Source Type", "definition": "http://sensorml.com/ont/swe/property/ProcessingType"}, |
| 186 | + {"type": "Boolean", "name": "live", "label": "Live Source", "definition": "http://sensorml.com/ont/swe/property/Status"}, |
| 187 | + {"type": "Count", "name": "httpStatus", "label": "HTTP Status", "definition": "http://sensorml.com/ont/swe/property/StatusCode"}, |
| 188 | + {"type": "Text", "name": "etag", "label": "HTTP ETag", "definition": "http://sensorml.com/ont/swe/property/Identifier"}, |
| 189 | + {"type": "Text", "name": "lastModified", "label": "HTTP Last-Modified", "definition": "http://sensorml.com/ont/swe/property/Timestamp"}, |
| 190 | + {"type": "Text", "name": "sourceLastModifiedTime", "label": "Parsed Source Last-Modified Time", "definition": "http://sensorml.com/ont/swe/property/Timestamp"}, |
| 191 | + {"type": "Text", "name": "contentLength", "label": "Content Length", "definition": "http://sensorml.com/ont/swe/property/Size"}, |
| 192 | + {"type": "Text", "name": "imageSha256", "label": "Image SHA-256", "definition": "http://sensorml.com/ont/swe/property/Identifier"}, |
| 193 | + {"type": "Text", "name": "sourceUrl", "label": "Source URL", "definition": "http://sensorml.com/ont/swe/property/ReferenceURL"}, |
| 194 | + ], |
| 195 | + }, |
| 196 | + }, |
| 197 | + } |
| 198 | + |
| 199 | + |
| 200 | +def _deploy_root() -> dict: |
| 201 | + return { |
| 202 | + "type": "Feature", |
| 203 | + "geometry": {"type": "Point", "coordinates": [11.0, 55.33]}, |
| 204 | + "properties": { |
| 205 | + "uid": DEPLOY_ROOT_UID, |
| 206 | + "featureType": "sosa:Deployment", |
| 207 | + "name": "Storebaelt Webcams Demo", |
| 208 | + "description": "Top-level grouping for Storebaelt webcam image-reference resources.", |
| 209 | + "validTime": [VALID_TIME_START, ".."], |
| 210 | + }, |
| 211 | + } |
| 212 | + |
| 213 | + |
| 214 | +def _deploy_group() -> dict: |
| 215 | + return { |
| 216 | + "type": "Feature", |
| 217 | + "geometry": {"type": "Point", "coordinates": [11.0, 55.33]}, |
| 218 | + "properties": { |
| 219 | + "uid": DEPLOY_GROUP_UID, |
| 220 | + "featureType": "sosa:Deployment", |
| 221 | + "name": "Storebaelt Webcams", |
| 222 | + "description": "Grouping deployment for public Storebaelt traffic/weather webcams.", |
| 223 | + "validTime": [VALID_TIME_START, ".."], |
| 224 | + }, |
| 225 | + } |
| 226 | + |
| 227 | + |
| 228 | +def _deploy_camera(camera: dict, system_server_id: str, base_url: str) -> dict: |
| 229 | + return { |
| 230 | + "type": "Feature", |
| 231 | + "geometry": _camera_geometry(camera), |
| 232 | + "properties": { |
| 233 | + "uid": _deploy_uid(camera), |
| 234 | + "featureType": "sosa:Deployment", |
| 235 | + "name": camera["title"], |
| 236 | + "description": f"Storebaelt webcam deployment for {camera['locationName']}.", |
| 237 | + "validTime": [VALID_TIME_START, ".."], |
| 238 | + "platform@link": { |
| 239 | + "href": f"{base_url.rstrip('/')}/systems/{system_server_id}", |
| 240 | + "uid": _system_uid(camera), |
| 241 | + "title": camera["title"], |
| 242 | + }, |
| 243 | + }, |
| 244 | + } |
| 245 | + |
| 246 | + |
| 247 | +def clean_all(base_url: str, auth: str, *, dry_run: bool, stats: dict): |
| 248 | + for camera in _load_cameras(): |
| 249 | + clean_resource(base_url, auth, "deployments", _deploy_uid(camera), dry_run=dry_run, stats=stats, cascade=True) |
| 250 | + clean_resource(base_url, auth, "deployments", DEPLOY_GROUP_UID, dry_run=dry_run, stats=stats, cascade=True) |
| 251 | + clean_resource(base_url, auth, "deployments", DEPLOY_ROOT_UID, dry_run=dry_run, stats=stats, cascade=True) |
| 252 | + for camera in _load_cameras(): |
| 253 | + clean_resource(base_url, auth, "systems", _system_uid(camera), dry_run=dry_run, stats=stats, cascade=True) |
| 254 | + clean_resource(base_url, auth, "procedures", PROC_UID, dry_run=dry_run, stats=stats) |
| 255 | + |
| 256 | + |
| 257 | +def bootstrap(*, clean: bool = False, clean_only: bool = False, dry_run: bool = False, force_sml: bool = False): |
| 258 | + config = get_config() |
| 259 | + base_url = config["base_url"] |
| 260 | + auth = _auth_header(config["user"], config["password"]) |
| 261 | + cameras = _load_cameras() |
| 262 | + stats: dict[str, int] = {} |
| 263 | + |
| 264 | + print("\n" + "=" * 70) |
| 265 | + print(" Storebaelt Webcams -- Bootstrap") |
| 266 | + print("=" * 70) |
| 267 | + print(f" Server: {base_url}") |
| 268 | + print(f" Cameras: {len(cameras)}") |
| 269 | + print(f" Clean: {clean} Clean-only: {clean_only} Dry-run: {dry_run} Force-SML: {force_sml}\n") |
| 270 | + |
| 271 | + if clean or clean_only: |
| 272 | + print(" -- Cleaning existing resources --") |
| 273 | + clean_all(base_url, auth, dry_run=dry_run, stats=stats) |
| 274 | + if clean_only: |
| 275 | + print_summary(stats, dry_run) |
| 276 | + return |
| 277 | + |
| 278 | + print(" -- Procedure --") |
| 279 | + ensure_procedure(base_url, auth, PROC_UID, PROCEDURE_STUB, PROCEDURE_SML, dry_run=dry_run, stats=stats, force_sml=force_sml) |
| 280 | + |
| 281 | + print(" -- Systems and Datastreams --") |
| 282 | + system_ids: dict[str, str] = {} |
| 283 | + for camera in cameras: |
| 284 | + sys_id = ensure_system(base_url, auth, _system_uid(camera), _system_stub(camera), _system_sml(camera), dry_run=dry_run, stats=stats, force_sml=force_sml) |
| 285 | + system_ids[camera["id"]] = sys_id or "pending" |
| 286 | + ensure_datastream(base_url, auth, sys_id or "pending", DS_OUTPUT_NAME, _datastream_schema(camera), dry_run=dry_run, stats=stats) |
| 287 | + |
| 288 | + print(" -- Deployments --") |
| 289 | + root_id = ensure_deployment(base_url, auth, DEPLOY_ROOT_UID, _deploy_root(), dry_run=dry_run, stats=stats, force_sml=force_sml) |
| 290 | + group_id = ensure_deployment(base_url, auth, DEPLOY_GROUP_UID, _deploy_group(), parent_id=root_id, dry_run=dry_run, stats=stats, force_sml=force_sml) |
| 291 | + for camera in cameras: |
| 292 | + sys_id = system_ids.get(camera["id"]) |
| 293 | + if not sys_id and not dry_run: |
| 294 | + continue |
| 295 | + ensure_deployment(base_url, auth, _deploy_uid(camera), _deploy_camera(camera, sys_id or "pending", base_url), parent_id=group_id, dry_run=dry_run, stats=stats, force_sml=force_sml) |
| 296 | + |
| 297 | + print_summary(stats, dry_run) |
| 298 | + |
| 299 | + |
| 300 | +def main(): |
| 301 | + parser = argparse.ArgumentParser(description="Bootstrap Storebaelt Webcams resources on the CSAPI server.") |
| 302 | + add_bootstrap_args(parser) |
| 303 | + args = parser.parse_args() |
| 304 | + bootstrap(clean=args.clean, clean_only=args.clean_only, dry_run=args.dry_run, force_sml=args.force_sml) |
| 305 | + |
| 306 | + |
| 307 | +if __name__ == "__main__": |
| 308 | + main() |
0 commit comments