Skip to content

Commit be5dc56

Browse files
committed
Add Storebaelt webcam publisher
1 parent a003a40 commit be5dc56

9 files changed

Lines changed: 764 additions & 0 deletions

File tree

publishers/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ server (e.g. [OpenSensorHub](https://opensensorhub.org/)).
2727
| **Digitraffic Rail Trains** | Finnish Fintraffic/Digitraffic live train positions | 5 min |
2828
| **Digitraffic Road Weather** | Finnish Fintraffic/Digitraffic road-weather stations | 5 min |
2929
| **Digitraffic Weathercam** | Finnish Fintraffic/Digitraffic road-camera image references | 5 min |
30+
| **Storebaelt Webcams** | Danish Storebaelt traffic/weather webcam image references | 5 min |
3031
| **FMI Weather** | Finnish Meteorological Institute weather observations | 10 min |
3132
| **FMI Air Quality** | Finnish Meteorological Institute air-quality observations | 1 h |
3233
| **SYKE Hydrology** | Finnish Environment Institute water level and discharge readings | 15 min |
@@ -76,6 +77,7 @@ python -m publishers.digitraffic_marine_ais.bootstrap_digitraffic_marine_ais
7677
python -m publishers.digitraffic_rail_trains.bootstrap_digitraffic_rail_trains
7778
python -m publishers.digitraffic_road_weather.bootstrap_digitraffic_road_weather
7879
python -m publishers.digitraffic_weathercam.bootstrap_digitraffic_weathercam
80+
python -m publishers.storebaelt_webcams.bootstrap_storebaelt_webcams
7981
python -m publishers.fmi_weather.bootstrap_fmi_weather
8082
python -m publishers.fmi_air_quality.bootstrap_fmi_air_quality
8183
python -m publishers.syke_hydrology.bootstrap_syke_hydrology
@@ -172,6 +174,9 @@ python -m publishers.nws.nws_publisher --interval 3600
172174
- **Digitraffic Weathercam** attaches image-reference observations to curated
173175
Digitraffic road-weather station systems, using direct Fintraffic JPEG and
174176
thumbnail URLs for road camera presets.
177+
- **Storebaelt Webcams** publishes image-reference observations for the public
178+
Storebaelt traffic/weather webcams, using the player poster JPEGs and retaining
179+
the public page and embedded player URLs as provenance.
175180
- **Digitraffic Rail Trains** publishes live Finnish train positions from the
176181
public Digitraffic Rail latest-location JSON endpoint.
177182
- **SYKE Hydrology** publishes curated Finnish water level and discharge readings

publishers/docker-compose.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,17 @@ services:
160160
DIGITRAFFIC_WEATHERCAM_REQUEST_DELAY: ${DIGITRAFFIC_WEATHERCAM_REQUEST_DELAY:-0.5}
161161
command: ["--interval", "300"]
162162

163+
# -- Storebaelt Webcam Image References (5min cadence) --
164+
storebaelt-webcams:
165+
build:
166+
context: ..
167+
dockerfile: publishers/storebaelt_webcams/Dockerfile
168+
restart: always
169+
environment:
170+
<<: *osh-env
171+
STOREBAELT_WEBCAMS_REQUEST_DELAY: ${STOREBAELT_WEBCAMS_REQUEST_DELAY:-0.5}
172+
command: ["--interval", "300"]
173+
163174
# ── Digitraffic Rail Live Trains (5min cadence) ──
164175
digitraffic-rail-trains:
165176
build:
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
FROM python:3.12-slim
2+
3+
WORKDIR /app
4+
5+
COPY publishers/ /app/publishers/
6+
7+
ENV OSH_ADDRESS=
8+
ENV OSH_PORT=443
9+
ENV OSH_USER=
10+
ENV OSH_PASS=
11+
12+
ENTRYPOINT ["python", "-m", "publishers.storebaelt_webcams.storebaelt_webcams_publisher"]
13+
CMD ["--interval", "300"]
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Storebaelt Webcams Publisher
2+
3+
Publishes image-reference observations for the public Storebaelt traffic/weather webcams.
4+
5+
The source is an embedded live-video page, not a JSON API. The publisher uses the stable poster JPEG URLs exposed by the Mediathand player pages as the first ingestion target and preserves the public page and player URLs as provenance.
6+
7+
## Cameras
8+
9+
- Storebaelt Tower Webcam: `https://player.sob.m-dn.net/sb1-live.html`
10+
- Sprogo Webcam: `https://player.sob.m-dn.net/sb2-live.html`
11+
12+
## Bootstrap
13+
14+
```bash
15+
python -m publishers.storebaelt_webcams.bootstrap_storebaelt_webcams
16+
```
17+
18+
## Run
19+
20+
```bash
21+
python -m publishers.storebaelt_webcams.storebaelt_webcams_publisher --interval 300
22+
```
23+
24+
Use `--dry-run --once` to inspect observations without posting.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Storebaelt webcam publisher package."""
Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
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

Comments
 (0)