mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
Merge remote-tracking branch 'origin/main' into german/hal-623-hud-summary-locale
# Conflicts: # src/hud.js # src/hudAltitudeDatum.test.mjs
This commit is contained in:
commit
1a6687632d
660 files changed
+45113
-19572
No files matched your search
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
+138
-6
@@ -1,9 +1,132 @@
|
||||
import path from 'node:path';
|
||||
import https from 'node:https';
|
||||
import { promises as fsp } from 'node:fs';
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
import { filterTrailing24h, parseFirmsCsv } from '../../src/data/firmsCsv.js';
|
||||
import { readResponseTextCapped } from './common/http.js';
|
||||
|
||||
const NULL_BODY_STATUSES = new Set([204, 205, 304]);
|
||||
|
||||
/**
|
||||
* The per-address connection errors behind a fetch failure, or null. Node 24
|
||||
* puts the AggregateError directly on `err.cause`; Node 26's fetch wraps it in
|
||||
* a ConnectTimeoutError one level deeper.
|
||||
* @param {unknown} err
|
||||
* @returns {?Array<unknown>}
|
||||
*/
|
||||
function connectAttemptErrors(err) {
|
||||
let cause = err?.cause;
|
||||
for (let depth = 0; cause && depth < 3; depth++) {
|
||||
if (Array.isArray(cause.errors)) return cause.errors;
|
||||
cause = cause.cause;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Describe a fetch failure with its socket-level cause. Node reports every
|
||||
* connection failure as `fetch failed`; the cause carries the error code and
|
||||
* the address that failed (never the URL, which embeds the MAP_KEY).
|
||||
* @param {unknown} err
|
||||
* @returns {string}
|
||||
*/
|
||||
export function describeFetchError(err) {
|
||||
const message = err?.message || String(err);
|
||||
const cause = err?.cause;
|
||||
if (!cause) return message;
|
||||
const attempts = connectAttemptErrors(err);
|
||||
const parts = attempts?.length
|
||||
? attempts.map((inner) => inner?.message || inner?.code || String(inner))
|
||||
: [cause.message || cause.code || String(cause)];
|
||||
return `${message} (${parts.join('; ')})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a fetch failed because every address it tried failed to connect.
|
||||
* Node's network-family auto-selection gives each address a short attempt
|
||||
* timeout, so a slow IPv4 handshake is abandoned in favour of an unreachable
|
||||
* IPv6 address and the whole request fails (#68).
|
||||
* @param {unknown} err
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isAddressSelectionFailure(err) {
|
||||
return err instanceof TypeError && connectAttemptErrors(err)?.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET over IPv4 only, with one connection attempt bounded by the caller's
|
||||
* signal rather than by the auto-selection attempt timeout.
|
||||
* @param {string} url
|
||||
* @param {{signal?: AbortSignal}} [options]
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
export function fetchIpv4(url, { signal } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = https.request(
|
||||
url,
|
||||
{
|
||||
method: 'GET',
|
||||
signal,
|
||||
family: 4,
|
||||
autoSelectFamily: false,
|
||||
agent: false,
|
||||
},
|
||||
(response) => {
|
||||
// Nothing thrown here may escape: this callback runs outside the
|
||||
// promise, so an exception would be uncaught and crash the server.
|
||||
try {
|
||||
const status = response.statusCode || 500;
|
||||
// A `Response` only represents statuses up to 599; Node never hands
|
||||
// this callback a 1xx.
|
||||
if (status > 599)
|
||||
throw new Error(`FIRMS returned unsupported status ${status}`);
|
||||
const headers = new Headers();
|
||||
for (const [name, value] of Object.entries(response.headers)) {
|
||||
if (Array.isArray(value))
|
||||
value.forEach((item) => headers.append(name, item));
|
||||
else if (value !== undefined) headers.set(name, String(value));
|
||||
}
|
||||
const nullBody = NULL_BODY_STATUSES.has(status);
|
||||
const upstream = new Response(
|
||||
nullBody ? null : Readable.toWeb(response),
|
||||
{ status, statusText: response.statusMessage || '', headers },
|
||||
);
|
||||
if (nullBody) response.resume();
|
||||
resolve(upstream);
|
||||
} catch (error) {
|
||||
response.destroy();
|
||||
reject(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
request.on('error', reject);
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a FIRMS URL, retrying once over IPv4 when address selection fails.
|
||||
* Other failures (HTTP status, timeout, DNS) are returned or thrown as is.
|
||||
* @param {string} url
|
||||
* @param {{signal?: AbortSignal}} options
|
||||
* @param {{fetchImpl?: typeof fetch, fetchIpv4Impl?: typeof fetchIpv4, warn?: Function}} [deps]
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
export async function fetchFirms(
|
||||
url,
|
||||
options,
|
||||
{ fetchImpl = fetch, fetchIpv4Impl = fetchIpv4, warn = console.warn } = {},
|
||||
) {
|
||||
try {
|
||||
return await fetchImpl(url, options);
|
||||
} catch (err) {
|
||||
if (!isAddressSelectionFailure(err) || options?.signal?.aborted) throw err;
|
||||
warn(`[firms-proxy] ${describeFetchError(err)} — retrying over IPv4`);
|
||||
return fetchIpv4Impl(url, options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NASA FIRMS live active-fire proxy with a memory + disk cache.
|
||||
* Upstream: https://firms.modaps.eosdis.nasa.gov/api/area/csv/{KEY}/{SOURCE}/world/2
|
||||
@@ -91,8 +214,12 @@ export function firmsProxy() {
|
||||
async function fetchSource(key, source) {
|
||||
const url = `https://firms.modaps.eosdis.nasa.gov/api/area/csv/${encodeURIComponent(key)}/${source}/world/2`;
|
||||
const signal = AbortSignal.timeout(60_000);
|
||||
const res = await fetch(url, { signal });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const res = await fetchFirms(url, { signal });
|
||||
if (!res.ok) {
|
||||
// Release the connection now rather than when the deadline fires.
|
||||
await res.body?.cancel().catch(() => {});
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
const records = parseFirmsCsv(
|
||||
await readResponseTextCapped(res, FIRMS_MAX_RESPONSE_BYTES, signal),
|
||||
);
|
||||
@@ -121,7 +248,7 @@ export function firmsProxy() {
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[firms-proxy] ${source} fetch failed:`,
|
||||
err?.message || err,
|
||||
describeFetchError(err),
|
||||
);
|
||||
sources.push({ source, count: 0, ok: false });
|
||||
}
|
||||
@@ -156,8 +283,13 @@ export function firmsProxy() {
|
||||
statusInflight = (async () => {
|
||||
try {
|
||||
const url = `https://firms.modaps.eosdis.nasa.gov/mapserver/mapkey_status/?MAP_KEY=${encodeURIComponent(key)}`;
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const res = await fetchFirms(url, {
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
await res.body?.cancel().catch(() => {});
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
const body = await res.json();
|
||||
const used = Number(body?.current_transactions);
|
||||
const limit = Number(body?.transaction_limit);
|
||||
@@ -167,7 +299,7 @@ export function firmsProxy() {
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[firms-proxy] mapkey status failed:',
|
||||
err?.message || err,
|
||||
describeFetchError(err),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user