feat(cctv): add the Autostrada del Brennero (A22) motorway webcams

Closes bilawalsidhu/gods-eye-view#155. The first Italian cameras: the 13
webcams along the A22 from the Brenner Pass to Modena.

The camera list is one keyless request to the Open Data Hub (NOI Techpark)
tourism API, which republishes Autobrennero's own list with coordinates
and kilometre markers. Frames are the operator's stills, refreshed about
once a minute, and only each camera's own WebCamImg/km<N>.jpg is
registered. The catalog fetch refuses redirects and is size-capped.

The road climbs from ~20 m in the Po valley to ~1,370 m at the Brenner
Pass, so ground elevation comes from a per-kilometre table sampled from
the Copernicus GLO-90 DEM instead of one flat prior. Headings use the
id-hash fallback at low confidence. CCTV_A22_ENABLED=0 disables the pack.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
jibraaanandClaude Opus 5.5 committed 2026-10-07 16:19:45 +05:00
1 parent e685449a52
commit b11390404a
12 files changed
+398 -2

No files matched your search

+6
View File
@@ -19,6 +19,7 @@ import {
loadCalgarySourcesFromOpenData,
loadDelDOTSourcesFromOpenData,
loadVegvesenSourcesFromOpenData,
loadA22SourcesFromOpenDataHub,
} from './sources.js';
/** Env kill switch: unset or anything but "0" means enabled. */
@@ -98,6 +99,11 @@ const LIVE_PACKS = [
enabled: () => envEnabled('CCTV_VEGVESEN_ENABLED'),
load: loadVegvesenSourcesFromOpenData,
},
{
name: 'a22',
enabled: () => envEnabled('CCTV_A22_ENABLED'),
load: loadA22SourcesFromOpenDataHub,
},
];
/**
* Load CCTV sources from a local JSON file (CCTV_SOURCES_FILE env or default).
+33
View File
@@ -267,6 +267,39 @@ export const NORWAY_ANCHORS = [
{ lat: 69.6492, lon: 18.9553 }, // Tromsø
];
/** Autostrada del Brennero (A22, Brenner–Modena) webcams: the keyless Open
* Data Hub (NOI Techpark, South Tyrol) tourism API republishes Autobrennero's
* own camera list with coordinates, one request for the whole motorway
* (13 cameras). Frames are stills on the operator's host, refreshed about
* once a minute. */
export const DEFAULT_A22_CCTV_URL =
'https://tourism.api.opendatahub.com/v1/WebcamInfo?source=a22&pagesize=200&removenullvalues=true';
/** The only origin A22 camera frames may come from: `km<N>.jpg` per camera. */
export const A22_IMAGE_ORIGIN = 'https://www.autobrennero.it/WebCamImg/';
/** Hard ceiling on the catalog body. The whole motorway is ~45 KB. */
export const A22_MAX_CATALOG_BYTES = 2 * 1024 * 1024;
/** Orthometric ground elevation (m) at each camera, keyed by its motorway
* kilometre, from the Copernicus GLO-90 DEM via Open-Meteo's elevation API.
* The feed's coordinates are 2D and the road climbs from the Po valley (~20 m)
* to the Brenner Pass (~1,370 m), so one flat prior would bury the Alpine
* cameras. A camera not listed here uses A22_DEFAULT_GROUND_ELEVATION_M. */
export const A22_GROUND_ELEVATION_BY_KM = Object.freeze({
1: 1365,
68: 375,
77: 301,
100: 219,
129: 206,
138: 204,
159: 175,
205: 188,
228: 72,
240: 38,
272: 16,
285: 19,
313: 40,
});
export const A22_DEFAULT_GROUND_ELEVATION_M = 200;
/** 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;
+11
View File
@@ -378,6 +378,17 @@ export function isLikelyNorwayCoordinate(lat, lon) {
);
}
/** The A22 corridor, Brenner Pass to Modena, with slack either side. */
export function isLikelyA22Coordinate(lat, lon) {
return (
isPlausibleLatLon(lat, lon) &&
lat >= 44.5 &&
lat <= 47.1 &&
lon >= 10.5 &&
lon <= 11.8
);
}
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;
+126
View File
@@ -65,6 +65,11 @@ import {
DEFAULT_VEGVESEN_MAX_SOURCES,
VEGVESEN_MAX_CATALOG_BYTES,
NORWAY_ANCHORS,
DEFAULT_A22_CCTV_URL,
A22_IMAGE_ORIGIN,
A22_MAX_CATALOG_BYTES,
A22_GROUND_ELEVATION_BY_KM,
A22_DEFAULT_GROUND_ELEVATION_M,
} from './constants.js';
import {
toFiniteNumber,
@@ -83,6 +88,7 @@ import {
isLikelyNswCoordinate,
isLikelyCalgaryCoordinate,
isLikelyNorwayCoordinate,
isLikelyA22Coordinate,
cameraDisplayCode,
rowArrayToObject,
prioritizeSources,
@@ -1863,3 +1869,123 @@ export async function loadVegvesenSourcesFromOpenData() {
return [];
}
}
/**
* One Open Data Hub `WebcamInfo` item (source `a22`) -> one catalog source,
* or null.
*
* The frame must be the operator's own `WebCamImg/km<N>.jpg` for the
* camera's motorway kilometre. The feed names the place and the kilometre but
* no bearing, so the heading uses the shared id-hash fallback at low
* confidence, like Calgary and Vegvesen; the ground elevation comes from the
* per-kilometre DEM table because the road spans ~1,350 m of altitude.
*
* @param {object} item - One element of the API's `Items` array.
* @returns {?object}
*/
export function a22WebcamToSource(item) {
if (!item || typeof item !== 'object') return null;
if (item.Active === false) return null;
if (String(item.Source ?? '').toLowerCase() !== 'a22') return null;
const gps = Array.isArray(item.GpsInfo) ? item.GpsInfo[0] : null;
const lat = toFiniteNumber(gps?.Latitude);
const lon = toFiniteNumber(gps?.Longitude);
if (!isLikelyA22Coordinate(lat, lon)) return null;
const km = String(item.Mapping?.a22?.km ?? '').trim();
if (!/^\d{1,3}$/.test(km)) return null;
const imageUrl = `${A22_IMAGE_ORIGIN}km${Number(km)}.jpg`;
let image;
try {
image = new URL(String(item.Webcamurl ?? ''));
} catch {
return null;
}
if (image.username || image.password || image.href !== imageUrl) return null;
const place =
String(item.Shortname ?? item.Webcamname?.it ?? '')
.replace(/\s+/g, ' ')
.trim() || `Km ${Number(km)}`;
const cameraId = `it-a22-km${Number(km)}`;
const ground = A22_GROUND_ELEVATION_BY_KM[Number(km)];
return {
id: cameraId,
name: `A22 ${place} (km ${Number(km)})`,
// One motorway-wide category in the camera picker; the place is already
// in the name.
city: 'A22 Brennero',
cityId: 'a22',
provider: 'Autostrada del Brennero',
lat,
lon,
headingDeg: fallbackHeadingFromId(cameraId),
headingConfidence: 'low',
pitchDeg: -15,
fovDeg: 50,
rangeM: 160,
mountHeightM: 8,
groundElevationM: Number.isFinite(ground)
? ground
: A22_DEFAULT_GROUND_ELEVATION_M,
feedType: 'image',
url: imageUrl,
snapshotUrl: imageUrl,
sourceKind: 'a22-opendatahub',
license:
'Webcam images © Autostrada del Brennero S.p.A., via the Open Data Hub (NOI Techpark)',
code: cameraDisplayCode(place.toUpperCase()),
};
}
/**
* Fetch the Autostrada del Brennero (A22) webcams from the keyless Open Data
* Hub tourism API. Frames are stills on www.autobrennero.it.
*
* @returns {Promise<Array<object>>} Normalized camera source objects.
*/
export async function loadA22SourcesFromOpenDataHub() {
try {
const endpoint = process.env.CCTV_A22_URL || DEFAULT_A22_CCTV_URL;
const resp = await fetch(endpoint, {
headers: { Accept: '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] A22 catalog redirected; redirects are not followed');
return discard();
}
if (!resp.ok) {
console.warn('[CCTV] A22 camera download failed:', resp.status);
return discard();
}
const payload = await readResponseJsonCapped(resp, A22_MAX_CATALOG_BYTES);
const items = Array.isArray(payload?.Items) ? payload.Items : [];
const cameras = [];
const seen = new Set();
for (const item of items) {
const camera = a22WebcamToSource(item);
if (!camera || seen.has(camera.id)) continue;
seen.add(camera.id);
cameras.push(camera);
}
// North to south along the motorway, so the picker reads in road order.
cameras.sort((a, b) => b.lat - a.lat);
console.log(`[CCTV] Loaded A22 camera sources: ${cameras.length}`);
return cameras;
} catch (error) {
console.warn('[CCTV] A22 camera download error:', error?.message || error);
return [];
}
}