Merge branch 'main' into fix/firms-ipv4-fallback

This commit is contained in:
Said Turksever authored and GitHub committed 2026-10-30 15:08:35 +00:00
commit 3a681accfa
14 files changed
+429 -2

No files matched your search

+6
View File
@@ -28,6 +28,7 @@ import {
loadNycSourcesFromOpenData,
loadTorontoSourcesFromOpenData,
loadFloridaSourcesFromOpenData,
loadA22SourcesFromOpenDataHub,
} from './sources.js';
/** Env kill switch: unset or anything but "0" means enabled. */
@@ -165,6 +166,11 @@ const LIVE_PACKS = [
enabled: () => envEnabled('CCTV_TAIWAN_ENABLED'),
load: loadTaiwanSources,
},
{
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
@@ -443,6 +443,39 @@ export const NYC_ANCHORS = [
{ lat: 40.5795, lon: -74.1502 }, // Staten Island
];
/** 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;
+2
View File
@@ -1,6 +1,7 @@
import { Readable } from 'node:stream';
import { hashSeed, escapeXml, pinnedCameraUrl } from './normalize.js';
import {
A22_IMAGE_ORIGIN,
CALGARY_IMAGE_ORIGIN,
CCTV_FRAME_FETCH_TIMEOUT_MS,
CCTV_FRAME_MAX_BODY_BYTES,
@@ -528,6 +529,7 @@ for (const prefix of [
HK_IMAGE_ORIGIN,
KING_COUNTY_IMAGE_ORIGIN,
WSDOT_IMAGE_ORIGIN,
A22_IMAGE_ORIGIN,
]) {
const host = new URL(prefix).hostname;
CCTV_PINNED_PREFIXES_BY_HOST.set(host, [
+11
View File
@@ -421,6 +421,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
@@ -122,6 +122,11 @@ import {
NYC_MAX_CATALOG_BYTES,
NYC_GROUND_ELEVATION_M,
NYC_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,
@@ -145,6 +150,7 @@ import {
isLikelyHongKongCoordinate,
isLikelyNorwayCoordinate,
isLikelyTorontoCoordinate,
isLikelyA22Coordinate,
cameraDisplayCode,
rowArrayToObject,
prioritizeSources,
@@ -3645,3 +3651,123 @@ export async function loadFloridaSourcesFromOpenData() {
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 [];
}
}