diff --git a/.env.example b/.env.example index efc22a16e..ee7620be9 100644 --- a/.env.example +++ b/.env.example @@ -192,6 +192,10 @@ AISSTREAM_API_KEY= # CCTV_CALGARY_ENABLED=1 # set 0 to disable the Open Calgary (Alberta) cameras # CCTV_CALGARY_MAX_SOURCES=220 # cap cameras loaded from the Open Calgary catalog # CCTV_CALGARY_ROWS_URL= # override the Open Calgary catalog URL +# CCTV_VEGVESEN_ENABLED=1 # set 0 to disable the Statens vegvesen (Norway) road cameras +# CCTV_VEGVESEN_MAX_SOURCES=900 # cap cameras loaded from Statens vegvesen (default loads all ~850 live; max 1000) +# CCTV_VEGVESEN_VIDEO=1 # set 0 to show stills instead of live HLS for Statens vegvesen video cameras +# CCTV_VEGVESEN_URL= # override the Statens vegvesen catalog URL # CCTV_MAX_SOURCES=4000 # catalog-wide ceiling, shared round-robin across packs # CCTV_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry # CCTV_FORCE_AUSTIN= # force live default sources when a source-pack JSON is configured diff --git a/CHANGELOG.md b/CHANGELOG.md index 39cf9b524..b433f8c06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ ## [Unreleased] +- Add Norway's road cameras from Statens vegvesen as a CCTV pack. The camera + list is one keyless GeoJSON request to the agency's OGC API view of its + DATEX 3.1 CCTV table, so no DATEX account is needed. Every working camera + loads (~850; faulty ones are dropped) under one "Norway" category, and the + ~135 working cameras that publish HLS play as live video through the existing in-memory + relay with the still as fallback. Frames and manifests are pinned to each + camera's own path on the agency hosts. `CCTV_VEGVESEN_ENABLED=0` disables + the pack, `CCTV_VEGVESEN_MAX_SOURCES` caps it (keeping cameras nearest the + largest cities) and `CCTV_VEGVESEN_VIDEO=0` keeps stills only. Attributed + under NLOD 2.0. + ## [0.2.1] — 2026-10-02 — God's Eye View through Your Agent (with security fixes and other features) - Ship each bundled data pack once. The region, marine, admin-boundary, diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 396469c95..734e35a69 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -54,6 +54,7 @@ How to read this: | **Fintraffic / Digitraffic (weathercams)** | CCTV camera catalog + frames, Finland | [CC BY 4.0](https://www.digitraffic.fi/en/terms-of-service/) — attribution REQUIRED | "Fintraffic / digitraffic.fi, license CC BY 4.0" | | **DriveBC** (Government of British Columbia) | CCTV camera catalog + frames, British Columbia highways | [Open Government Licence – British Columbia](https://www2.gov.bc.ca/gov/content/data/open-data/open-government-licence-bc) — attribution REQUIRED | "DriveBC. Contains information licensed under the Open Government Licence – British Columbia" | | **Open Calgary** (`data.calgary.ca`) | CCTV camera catalog + frames, Calgary | [Open Government Licence – City of Calgary](https://data.calgary.ca/stories/s/Open-Calgary-Terms-of-Use/u45n-7awa) — attribution REQUIRED | "Contains information licensed under the Open Government Licence – City of Calgary" | +| **Statens vegvesen** (catalog `ogckart-sn1.atlas.vegvesen.no`, frames `kamera.atlas.vegvesen.no`, HLS `kamera.vegvesen.no`) | CCTV camera catalog + frames + live HLS video, Norway road cameras | [NLOD 2.0](https://data.norge.no/nlod/en/2.0) — attribution REQUIRED | "Contains data under the Norwegian licence for Open Government data (NLOD) distributed by Statens vegvesen" | | **GBFS (Lyft / BCycle)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` | | **MBTA / MassDOT** (cdn.mbta.com) | Transit layer live vehicles and up to 15 minutes of recently observed positions, Greater Boston | MBTA / MassDOT open realtime vehicle data, used for the live view and up to fifteen minutes of recently observed positions; published for developer use and not against their use. | Attribution to MBTA / MassDOT (courtesy text) | | **CapMetro** (data.texas.gov) | Transit layer live vehicles, Austin TX | CapMetro open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to CapMetro (courtesy text) | @@ -88,6 +89,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **Warendorf webcam.** The Stadt Warendorf Marktplatz webcam, registered from the curated `config/cctv_sources.warendorf.json` (the Kreis Warendorf registration-office cameras from the contributing PR are indoor waiting rooms and were left out); only the municipal image host is proxied, frames are fetched at request time and never stored or redistributed. Disable with `CCTV_WARENDORF_ENABLED=0`. - **Live Traffic NSW cameras.** The camera list is the keyless public `data.livetraffic.com/cameras/traffic-cam.json` feed (Transport for NSW open data, CC BY 4.0); frames are stills on `webcams.transport.nsw.gov.au`, and only that host is registered. That image host answers non-browser clients with an HTML placeholder, so the proxy identifies as a browser for that one host and as itself everywhere else. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_NSW_ENABLED=0`. - **Open Calgary traffic cameras.** Keyless. The camera list is the public Socrata dataset `k7p9-kppz` on `data.calgary.ca`; frames are stills on the city's own `trafficcam.calgary.ca` host, and only that host is registered. Most rows publish an `http://` frame URL and the host redirects those to HTTPS, so URLs are upgraded and pinned to that origin before registration. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `quadrant` field and the quadrant suffix on each camera name are Calgary's address grid, not a bearing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. The licence requires the attribution statement above. Disable with `CCTV_CALGARY_ENABLED=0`. +- **Statens vegvesen road cameras.** Keyless. The camera list is one GeoJSON request to the OGC API Features view of the official DATEX 3.1 CCTV site table (`ogckart-sn1.atlas.vegvesen.no/ogc/features/v1/collections/datex_3_1:CctvSimple/items`), so no DATEX account is needed; frames are stills on `kamera.atlas.vegvesen.no/api/images/`, and only that exact path per camera is registered. Cameras the feed reports as faulty are dropped. About 135 working cameras also publish live HLS (`kamera.vegvesen.no/public//manifest.m3u8`, MPEG-TS); those play as live video through the bounded in-memory HLS relay, with the still as the snapshot fallback, and only that exact manifest path per camera is registered. `CCTV_VEGVESEN_VIDEO=0` keeps every camera on stills. `orientationDescription` names the place a camera faces, not a bearing, so it goes into the label and headings use the id-hash fallback at low confidence. By default every live camera loads (~850); `CCTV_VEGVESEN_MAX_SOURCES` (8–1000) lowers the cap, keeping the cameras nearest Oslo, Bergen, Stavanger, Trondheim, Kristiansand, Bodø and Tromsø and `CCTV_VEGVESEN_ENABLED=0` turns the pack off. Frames are fetched at request time and never stored or redistributed. The NLOD attribution is registered in the Data attribution popover. - **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals. - **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All seven registered regions are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. - **Radio Browser.** `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and malformed or empty successful payloads are rejected atomically. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply. diff --git a/README.md b/README.md index e302014ad..724ea18cb 100644 --- a/README.md +++ b/README.md @@ -294,7 +294,7 @@ Nineteen layers and map sources. **Seventeen have a keyless path.** Some offer a | 🛰️ **Satellites** | 838-object catalog, color-coded by class with a live legend — the **DENSE** chip drops in the whole Starlink shell | CelesTrak | 🟢 | | 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 | | 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds | -| 📹 **CCTV Mesh** | ~3,600 public cameras projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary. Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | +| 📹 **CCTV Mesh** | ~3,900 public cameras projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | | 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 | | 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 | | 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland | Operator GTFS-Realtime feeds | 🟢 | diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 7c06724e2..d4fe022a8 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2919,7 +2919,7 @@ its criteria cannot be silently ignored. | Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s | | Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min | | Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache | -| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) | +| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) | | Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh | | Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) | | Bikeshare 🚲 | GBFS (Lyft + BCycle) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 52f76357b..662b2a43a 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -522,6 +522,7 @@ "src/data/cctvEstonia.test.mjs", "src/data/cctvWarendorf.test.mjs", "src/data/cctvCalgary.test.mjs", + "src/data/cctvVegvesen.test.mjs", "src/data/cctvNswSource.test.mjs", "src/data/cctvTxdotSource.test.mjs", "src/layers/cctv/source.test.mjs", diff --git a/server/providers/cctv/catalog.js b/server/providers/cctv/catalog.js index 7e9be7ac9..9bc3b70ec 100644 --- a/server/providers/cctv/catalog.js +++ b/server/providers/cctv/catalog.js @@ -18,6 +18,7 @@ import { loadNswSourcesFromOpenData, loadCalgarySourcesFromOpenData, loadDelDOTSourcesFromOpenData, + loadVegvesenSourcesFromOpenData, } from './sources.js'; /** Env kill switch: unset or anything but "0" means enabled. */ @@ -92,6 +93,11 @@ const LIVE_PACKS = [ enabled: () => envEnabled('CCTV_DELDOT_ENABLED'), load: loadDelDOTSourcesFromOpenData, }, + { + name: 'vegvesen', + enabled: () => envEnabled('CCTV_VEGVESEN_ENABLED'), + load: loadVegvesenSourcesFromOpenData, + }, ]; /** * Load CCTV sources from a local JSON file (CCTV_SOURCES_FILE env or default). diff --git a/server/providers/cctv/constants.js b/server/providers/cctv/constants.js index da0f2fcda..6298e86a0 100644 --- a/server/providers/cctv/constants.js +++ b/server/providers/cctv/constants.js @@ -236,6 +236,37 @@ export const CALGARY_DOWNTOWN = { lat: 51.0461, lon: -114.0626 }; * body cannot be buffered without limit. */ export const CALGARY_MAX_CATALOG_BYTES = 4 * 1024 * 1024; +/** Statens vegvesen (Norway) road cameras: the keyless OGC API Features view + * of the official DATEX 3.1 CCTV site table, one GeoJSON request for the whole + * country (~900 cameras). The raw DATEX node needs an account; this view does + * not. Frames are stills on the agency's camera host. */ +export const DEFAULT_VEGVESEN_CCTV_URL = + 'https://ogckart-sn1.atlas.vegvesen.no/ogc/features/v1/collections/datex_3_1:CctvSimple/items?f=application/geo%2Bjson&limit=5000'; +/** The only origin Vegvesen camera frames may come from. */ +export const VEGVESEN_IMAGE_ORIGIN = + 'https://kamera.atlas.vegvesen.no/api/images/'; +/** Live HLS for the ~140 cameras that publish video; only this exact + * per-camera manifest path is registered. */ +export const VEGVESEN_VIDEO_URL = (cameraId) => + `https://kamera.vegvesen.no/public/${cameraId}/manifest.m3u8`; +/** Above the ~850 working cameras so a default install keeps them all, while + * staying close to them: the per-pack defaults are meant to sum to no more + * than DEFAULT_CCTV_MAX_SOURCES. */ +export const DEFAULT_VEGVESEN_MAX_SOURCES = 900; +/** Hard ceiling on the catalog body. The whole country is ~800 KB. */ +export const VEGVESEN_MAX_CATALOG_BYTES = 8 * 1024 * 1024; +/** Prioritization anchors: the largest cities from south to north, so a cap + * keeps national coverage rather than just the capital region. */ +export const NORWAY_ANCHORS = [ + { lat: 59.9139, lon: 10.7522 }, // Oslo + { lat: 60.3913, lon: 5.3221 }, // Bergen + { lat: 58.97, lon: 5.7331 }, // Stavanger + { lat: 63.4305, lon: 10.3951 }, // Trondheim + { lat: 58.1467, lon: 7.9956 }, // Kristiansand + { lat: 67.2804, lon: 14.4049 }, // Bodø + { lat: 69.6492, lon: 18.9553 }, // Tromsø +]; + /** DelDOT CCTV: one keyless statewide JSON catalog; live video via RTMP-over-HTTP (rtmpt:80). */ export const DELDOT_CCTV_URL = 'https://tmc.deldot.gov/json/videocamera.json'; export const DEFAULT_DELDOT_MAX_SOURCES = 300; diff --git a/server/providers/cctv/normalize.js b/server/providers/cctv/normalize.js index 9e183b23d..e62ba33f3 100644 --- a/server/providers/cctv/normalize.js +++ b/server/providers/cctv/normalize.js @@ -367,6 +367,17 @@ export function isLikelyCalgaryCoordinate(lat, lon) { ); } +/** Mainland Norway, with slack for the coast and the Finnmark border. */ +export function isLikelyNorwayCoordinate(lat, lon) { + return ( + isPlausibleLatLon(lat, lon) && + lat >= 57.9 && + lat <= 71.3 && + lon >= 4.4 && + lon <= 31.3 + ); +} + export function isLikelyFinlandCoordinate(lat, lon) { if (!Number.isFinite(lat) || !Number.isFinite(lon)) return false; return lat >= 59.5 && lat <= 70.5 && lon >= 19 && lon <= 32; diff --git a/server/providers/cctv/sources.js b/server/providers/cctv/sources.js index ad8a883da..7ecd3434d 100644 --- a/server/providers/cctv/sources.js +++ b/server/providers/cctv/sources.js @@ -59,6 +59,12 @@ import { DELDOT_CCTV_URL, DEFAULT_DELDOT_MAX_SOURCES, DELDOT_ANCHORS, + DEFAULT_VEGVESEN_CCTV_URL, + VEGVESEN_IMAGE_ORIGIN, + VEGVESEN_VIDEO_URL, + DEFAULT_VEGVESEN_MAX_SOURCES, + VEGVESEN_MAX_CATALOG_BYTES, + NORWAY_ANCHORS, } from './constants.js'; import { toFiniteNumber, @@ -76,6 +82,7 @@ import { isLikelyTexasCoordinate, isLikelyNswCoordinate, isLikelyCalgaryCoordinate, + isLikelyNorwayCoordinate, cameraDisplayCode, rowArrayToObject, prioritizeSources, @@ -1701,3 +1708,158 @@ export async function loadDelDOTSourcesFromOpenData() { return []; } } + +/** + * One Statens vegvesen `CctvSimple` feature -> one catalog source, or null. + * + * `orientationDescription` names the place the camera looks towards + * ("Svelgen"), not a bearing, so it goes into the label and the heading uses + * the shared id-hash fallback at low confidence, like Calgary and Fintraffic. + * Cameras the feed reports as faulty are dropped. Cameras that publish HLS + * get live video, with the still as the snapshot fallback. + * + * @param {object} feature - GeoJSON feature from the OGC items response. + * @returns {?object} + */ +export function vegvesenCameraToSource(feature) { + if (!feature || typeof feature !== 'object') return null; + const props = feature.properties; + if (!props || typeof props !== 'object') return null; + const availability = String( + props['status.stillImageAvailability'] ?? '', + ).trim(); + if (availability && availability !== 'videoOrImagesAvailable') return null; + + const coordinates = feature?.geometry?.coordinates; + if (!Array.isArray(coordinates) || coordinates.length < 2) return null; + const lon = toFiniteNumber(coordinates[0]); + const lat = toFiniteNumber(coordinates[1]); + if (!isLikelyNorwayCoordinate(lat, lon)) return null; + + const rawId = String(props.cameraId ?? '').trim(); + if (!/^[A-Za-z0-9_-]{1,40}$/.test(rawId)) return null; + let image; + try { + image = new URL(String(props.stillImageUrl ?? '')); + } catch { + return null; + } + if (image.username || image.password) return null; + const imageUrl = image.href; + if (imageUrl !== `${VEGVESEN_IMAGE_ORIGIN}${rawId}`) return null; + + const cameraId = `no-vegvesen-${rawId.toLowerCase()}`; + const place = String(props.description ?? '').trim() || `Kamera ${rawId}`; + const towards = String(props.orientationDescription ?? '').trim(); + const road = String(props.roadNumber ?? '').trim(); + const name = towards && towards !== place ? `${place} → ${towards}` : place; + // Live video when the camera publishes HLS on the agency's own host; the + // still stays the snapshot fallback. CCTV_VEGVESEN_VIDEO=0 keeps stills only. + const videoUrl = + String(process.env.CCTV_VEGVESEN_VIDEO || '1').trim() !== '0' && + Number(props.videoServiceLevel) > 0 && + String(props.videoEncodingStandard ?? '').toLowerCase() === 'hls' && + String(props.videoUrl ?? '').trim() === VEGVESEN_VIDEO_URL(rawId) + ? VEGVESEN_VIDEO_URL(rawId) + : ''; + + return { + id: cameraId, + name: road ? `${road} ${name}` : name, + // One country-wide category in the camera picker, like Finland; the + // place itself is already in the name. + city: 'Norway', + cityId: 'norway', + provider: 'Statens vegvesen', + lat, + lon, + headingDeg: fallbackHeadingFromId(cameraId), + headingConfidence: 'low', + pitchDeg: -18, + fovDeg: 44, + rangeM: 145, + mountHeightM: 8, + // KNOWN LIMITATION: the feed's coordinates are 2D, so this is one flat + // prior for the whole country, while cameras run from sea level to + // mountain passes near 1,000 m (Haukelifjell, Sjonfjellet, Rugeldalen). + // The point-height prior and the client's one-shot ground snap correct it + // where they resolve; on a stack where neither does (no 3D tiles), a pass + // camera stays hundreds of metres below the terrain, the same risk the + // Caltrans pack documents. A per-camera height (e.g. Kartverket's keyless + // point-height API) would remove it. + groundElevationM: 150, + feedType: videoUrl ? 'hls' : 'image', + url: videoUrl || imageUrl, + snapshotUrl: imageUrl, + sourceKind: 'vegvesen-datex', + license: + 'Contains data under the Norwegian licence for Open Government data (NLOD) distributed by Statens vegvesen', + code: cameraDisplayCode(place.toUpperCase()), + }; +} + +/** + * Fetch Statens vegvesen (Norway) road cameras from the keyless OGC API + * Features view of the DATEX 3.1 CCTV table. Frames are stills on + * kamera.atlas.vegvesen.no. + * + * @returns {Promise>} Normalized camera source objects. + */ +export async function loadVegvesenSourcesFromOpenData() { + try { + const endpoint = process.env.CCTV_VEGVESEN_URL || DEFAULT_VEGVESEN_CCTV_URL; + const resp = await fetch(endpoint, { + headers: { Accept: 'application/geo+json,application/json' }, + redirect: 'manual', + signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS), + }); + const discard = async () => { + try { + await resp.body?.cancel(); + } catch { + /* no-op */ + } + return []; + }; + if (resp.status >= 300 && resp.status < 400) { + console.warn( + '[CCTV] Vegvesen catalog redirected; redirects are not followed', + ); + return discard(); + } + if (!resp.ok) { + console.warn('[CCTV] Vegvesen camera download failed:', resp.status); + return discard(); + } + const payload = await readResponseJsonCapped( + resp, + VEGVESEN_MAX_CATALOG_BYTES, + ); + const features = Array.isArray(payload?.features) ? payload.features : []; + const cameras = []; + const seen = new Set(); + for (const feature of features) { + const camera = vegvesenCameraToSource(feature); + if (!camera || seen.has(camera.id)) continue; + seen.add(camera.id); + cameras.push(camera); + } + const maxRaw = Number( + process.env.CCTV_VEGVESEN_MAX_SOURCES || DEFAULT_VEGVESEN_MAX_SOURCES, + ); + const maxCount = Number.isFinite(maxRaw) + ? Math.max(8, Math.min(1000, Math.floor(maxRaw))) + : DEFAULT_VEGVESEN_MAX_SOURCES; + const prioritized = prioritizeSources(cameras, maxCount, NORWAY_ANCHORS); + console.log( + `[CCTV] Loaded Vegvesen camera sources: ${cameras.length} (using nearest ${prioritized.length})`, + ); + return prioritized; + } catch (error) { + console.warn( + '[CCTV] Vegvesen camera download error:', + error?.message || error, + ); + return []; + } +} diff --git a/src/data/cctvVegvesen.test.mjs b/src/data/cctvVegvesen.test.mjs new file mode 100644 index 000000000..457fecbda --- /dev/null +++ b/src/data/cctvVegvesen.test.mjs @@ -0,0 +1,202 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + loadVegvesenSourcesFromOpenData, + vegvesenCameraToSource, +} from '../../server/providers/cctv/sources.js'; +import { + DEFAULT_VEGVESEN_CCTV_URL, + VEGVESEN_IMAGE_ORIGIN, +} from '../../server/providers/cctv/constants.js'; +import { createCctvCatalog } from '../../server/providers/cctv/catalog.js'; + +/** One `datex_3_1:CctvSimple` feature, shaped like the live OGC payload. */ +const feature = (props = {}, coordinates = [5.459189, 61.832706]) => ({ + type: 'Feature', + geometry: { type: 'Point', coordinates }, + properties: { + stillImageUrl: 'https://kamera.atlas.vegvesen.no/api/images/3000063_1', + cameraId: '3000063_1', + roadNumber: 'F614', + 'status.stillImageAvailability': 'videoOrImagesAvailable', + orientationDescription: 'Svelgen', + description: 'Langesi', + ...props, + }, +}); + +test('a Vegvesen feature maps to a source on the pinned frame host', () => { + const source = vegvesenCameraToSource(feature()); + assert.equal(source.id, 'no-vegvesen-3000063_1'); + assert.equal(source.name, 'F614 Langesi → Svelgen'); + assert.equal(source.city, 'Norway'); + assert.equal(source.cityId, 'norway'); + assert.equal(source.provider, 'Statens vegvesen'); + assert.equal(source.lat, 61.832706); + assert.equal(source.lon, 5.459189); + assert.equal(source.headingConfidence, 'low'); + assert.equal(source.sourceKind, 'vegvesen-datex'); + assert.equal(source.code, 'LANGESI'); + assert.ok(source.url.startsWith(VEGVESEN_IMAGE_ORIGIN)); + assert.equal(source.snapshotUrl, source.url); +}); + +test('cameras that publish HLS get live video with the still as fallback', (t) => { + const video = { + videoServiceLevel: 1, + videoEncodingStandard: 'hls', + videoUrl: 'https://kamera.vegvesen.no/public/3000063_1/manifest.m3u8', + }; + const source = vegvesenCameraToSource(feature(video)); + assert.equal(source.feedType, 'hls'); + assert.equal(source.url, video.videoUrl); + assert.equal( + source.snapshotUrl, + 'https://kamera.atlas.vegvesen.no/api/images/3000063_1', + ); + // A manifest anywhere but the camera's own path stays a still. + const offPath = vegvesenCameraToSource( + feature({ ...video, videoUrl: 'https://evil.test/x/manifest.m3u8' }), + ); + assert.equal(offPath.feedType, 'image'); + assert.equal(offPath.url, offPath.snapshotUrl); + // The kill switch keeps every camera on stills. + const saved = process.env.CCTV_VEGVESEN_VIDEO; + t.after(() => { + if (saved === undefined) delete process.env.CCTV_VEGVESEN_VIDEO; + else process.env.CCTV_VEGVESEN_VIDEO = saved; + }); + process.env.CCTV_VEGVESEN_VIDEO = '0'; + assert.equal(vegvesenCameraToSource(feature(video)).feedType, 'image'); +}); + +test('faulty cameras, off-host frames and bad geometry are dropped', () => { + assert.equal( + vegvesenCameraToSource( + feature({ + 'status.stillImageAvailability': + 'videoOrImagesUnavailableDueToCameraFault', + }), + ), + null, + ); + assert.equal( + vegvesenCameraToSource( + feature({ stillImageUrl: 'https://evil.test/api/images/3000063_1' }), + ), + null, + ); + // The frame URL must be exactly the camera's own image path. + assert.equal( + vegvesenCameraToSource( + feature({ + stillImageUrl: 'https://kamera.atlas.vegvesen.no/api/images/999_1', + }), + ), + null, + ); + assert.equal(vegvesenCameraToSource(feature({ cameraId: '../x' })), null); + // Copenhagen: a plausible coordinate, but outside the Norway box. + assert.equal(vegvesenCameraToSource(feature({}, [12.57, 55.68])), null); + assert.equal(vegvesenCameraToSource(feature({}, [5.4])), null); + assert.equal(vegvesenCameraToSource(null), null); +}); + +test('the loader dedupes, and failures degrade to an empty pack', async (t) => { + t.mock.method(console, 'log', () => {}); + t.mock.method(console, 'warn', () => {}); + const requested = []; + const fetchMock = t.mock.method(globalThis, 'fetch', async (url) => { + requested.push(String(url)); + return Response.json({ + type: 'FeatureCollection', + features: [ + feature(), + feature(), + feature( + { + cameraId: '0629001_1', + stillImageUrl: + 'https://kamera.atlas.vegvesen.no/api/images/0629001_1', + description: 'Oslo S', + }, + [10.752, 59.911], + ), + ], + }); + }); + const cameras = await loadVegvesenSourcesFromOpenData(); + assert.deepEqual(requested, [DEFAULT_VEGVESEN_CCTV_URL]); + // Nearest-to-anchor first: Oslo leads. + assert.deepEqual( + cameras.map((c) => c.id), + ['no-vegvesen-0629001_1', 'no-vegvesen-3000063_1'], + ); + + fetchMock.mock.mockImplementation( + async () => new Response('nope', { status: 503 }), + ); + assert.deepEqual(await loadVegvesenSourcesFromOpenData(), []); + fetchMock.mock.mockImplementation(async () => { + throw new Error('offline'); + }); + assert.deepEqual(await loadVegvesenSourcesFromOpenData(), []); +}); + +const runCatalog = async (t) => { + const requested = []; + t.mock.method(console, 'log', () => {}); + t.mock.method(console, 'warn', () => {}); + t.mock.method(globalThis, 'fetch', async (url) => { + const href = String(url); + requested.push(href); + if (href === DEFAULT_VEGVESEN_CCTV_URL) { + return Response.json({ + type: 'FeatureCollection', + features: [feature()], + }); + } + return Response.json([]); + }); + const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })(); + return { requested, sources }; +}; + +const withEnv = async (patch, fn) => { + const saved = { ...process.env }; + try { + delete process.env.CCTV_SOURCES_FILE; + delete process.env.CCTV_SOURCES_JSON; + delete process.env.CCTV_VEGVESEN_ENABLED; + Object.assign(process.env, patch); + await fn(); + } finally { + for (const key of Object.keys(process.env)) { + if (!(key in saved)) delete process.env[key]; + } + Object.assign(process.env, saved); + } +}; + +test('the Vegvesen lane is wired into the catalog', async (t) => { + await withEnv({}, async () => { + const { requested, sources } = await runCatalog(t); + assert.ok(requested.includes(DEFAULT_VEGVESEN_CCTV_URL)); + assert.deepEqual( + sources.filter((s) => s.cityId === 'norway').map((s) => s.id), + ['no-vegvesen-3000063_1'], + ); + }); +}); + +test('CCTV_VEGVESEN_ENABLED=0 keeps the lane from being loaded', async (t) => { + await withEnv({ CCTV_VEGVESEN_ENABLED: '0' }, async () => { + const { requested, sources } = await runCatalog(t); + assert.equal(requested.includes(DEFAULT_VEGVESEN_CCTV_URL), false); + assert.deepEqual( + sources.filter((s) => s.cityId === 'norway'), + [], + ); + }); +}); diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index f9fc23b70..2967f0a2b 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -240,6 +240,13 @@ export const DATA_CREDITS = [ 'Traffic cameras (Calgary): contains information licensed under the ' + 'Open Government Licence – City of Calgary', }, + { + key: 'vegvesen-cctv', + html: + 'Road cameras (Norway): contains data under the ' + + 'Norwegian licence for Open Government data (NLOD) ' + + 'distributed by Statens vegvesen', + }, { key: 'gbfs', html: 'Bikeshare availability: GBFS operator feeds (e.g. Austin BCycle)',