From ee3e3c7a414158387d6334528f56d1cb1af92b0b Mon Sep 17 00:00:00 2001 From: bassem chagra Date: Thu, 17 Sep 2026 10:36:49 +0200 Subject: [PATCH 1/5] feat(cctv): WSDOT highway camera source pack (Washington State) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Washington State's highway cameras as a keyless source pack, from the Esri JSON layer WSDOT's own Travel Center map consumes (data.wsdot.wa.gov/travelcenter/Cameras.json, ~1,700 point features). Closes #624. - Geometry arrives in EPSG:3857; converted to WGS84 on load and bounded to a Washington bounding box. - Only frames on WSDOT's own images.wsdot.wa.gov host are registered — the catalog also lists ~70 partner cameras (Oregon DOT, City of Seattle, lodges, airports) whose own terms apply; those are skipped rather than proxied. 1,630 cameras survive the filters against the live feed. - CompassDirection letters (N/E/S/W) give a high-confidence heading; the majority `B` ("both directions") rows carry no single facing and use the shared id-hash fallback at low confidence, with the usual two pose personalities. - The feed body carries Windows-1252 bytes (en-dashes in titles) with no charset declaration, so it is decoded explicitly before parsing. - WSDOT's layer metadata marks the feed "low volume": the 15-minute catalog cache and per-request frame fetches respect that, frames are never stored or redistributed, and the WSDOT credit is registered in the attribution popover. A keyed WSDOT Traveler Information API serves the same population for higher-volume use and is deliberately not required. - Defaults: 250 cameras nearest Seattle and Spokane; CCTV_WSDOT_MAX_SOURCES changes the cap, CCTV_WSDOT_ENABLED=0 turns the pack off. - Tests: Mercator conversion against a real layer feature, official-host and malformed-row filtering, compass mapping and both pose personalities, cp1252 decoding, env cap, upstream-failure fallback. --- DATA_SOURCES.md | 2 + server/providers/cctv/catalog.js | 6 ++ server/providers/cctv/constants.js | 18 ++++ server/providers/cctv/sources.js | 134 +++++++++++++++++++++++ src/data/cctvWsdotSource.test.mjs | 167 +++++++++++++++++++++++++++++ src/data/dataCredits.js | 10 ++ 6 files changed, 337 insertions(+) create mode 100644 src/data/cctvWsdotSource.test.mjs diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index b1439230e..73eaf8a1f 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -55,6 +55,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" | +| **WSDOT** (`data.wsdot.wa.gov`) | CCTV camera catalog + frames, Washington State highways | [WSDOT travel information disclaimer](https://wsdot.wa.gov/about/policies/travel-information-disclaimer) — public "low volume" feed; catalog cached, frames on WSDOT's own image host only | "Washington State Department of Transportation (Travel Center)" (courtesy) | | **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) | @@ -89,6 +90,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`. +- **WSDOT highway cameras.** Keyless. The camera list is the Esri JSON layer `data.wsdot.wa.gov/travelcenter/Cameras.json` — the same layer WSDOT's own Travel Center map consumes (~1,700 point features, EPSG:3857 geometry converted to WGS84 on load; the body carries Windows-1252 bytes and is decoded as such). Only frames on WSDOT's own `images.wsdot.wa.gov` host are registered — the catalog also lists ~70 partner cameras (Oregon DOT, City of Seattle, lodges, airports) whose own terms apply, and those are skipped rather than proxied. WSDOT's layer metadata marks the feed for "low volume" use, which the 15-minute catalog cache and per-request frame fetches respect; frames are never stored or redistributed. `CompassDirection` letters (N/E/S/W) give a high-confidence heading; the majority `B` ("both directions") rows carry no single facing and use the shared id-hash fallback at low confidence. By default the 250 cameras nearest Seattle and Spokane load; `CCTV_WSDOT_MAX_SOURCES` changes the cap and `CCTV_WSDOT_ENABLED=0` turns the pack off. A keyed WSDOT Traveler Information API serves the same population for higher-volume use and is deliberately not required here. - **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/server/providers/cctv/catalog.js b/server/providers/cctv/catalog.js index ae0ab9afa..3241cacd5 100644 --- a/server/providers/cctv/catalog.js +++ b/server/providers/cctv/catalog.js @@ -18,6 +18,7 @@ import { loadNswSourcesFromOpenData, loadCalgarySourcesFromOpenData, loadDelDOTSourcesFromOpenData, + loadWsdotSourcesFromOpenData, } 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: 'wsdot', + enabled: () => envEnabled('CCTV_WSDOT_ENABLED'), + load: loadWsdotSourcesFromOpenData, + }, ]; /** * 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..3ab8ce9cb 100644 --- a/server/providers/cctv/constants.js +++ b/server/providers/cctv/constants.js @@ -244,6 +244,24 @@ export const DELDOT_ANCHORS = [ { lat: 39.1582, lon: -75.5244 }, // Dover (Kent) { lat: 38.6906, lon: -75.3877 }, // Georgetown (Sussex) ]; + +/** WSDOT Travel Center cameras (Washington State): keyless Esri JSON on the + * official open-data host — ~1,700 point features in EPSG:3857 (Web Mercator), + * ETag/Last-Modified served. WSDOT's metadata marks the feed "low volume" use, + * which the 15-minute catalog cache respects. */ +export const WSDOT_CAMERAS_URL = + 'https://data.wsdot.wa.gov/travelcenter/Cameras.json'; +/** Only frames on WSDOT's own image host are registered; the catalog also + * lists ~70 partner cameras (Oregon DOT, City of Seattle, lodges, airports) + * whose own terms apply. */ +export const WSDOT_IMAGE_ORIGIN = 'https://images.wsdot.wa.gov/'; +export const DEFAULT_WSDOT_MAX_SOURCES = 250; +/** Prioritization anchors: downtown Seattle and Spokane. */ +export const WSDOT_ANCHORS = [ + { lat: 47.6062, lon: -122.3321 }, // Seattle + { lat: 47.6588, lon: -117.426 }, // Spokane +]; + /** Camera CATALOGS change rarely; 15 min keeps multi-megabyte upstream list refetches (Austin rows.json + 4 Caltrans districts + TfL + Ontario 511) infrequent. Frames are fetched per-request and are unaffected. */ export const CCTV_SOURCE_CACHE_MS = 15 * 60 * 1000; /** Per-provider catalog-fetch timeout. Bounds the worst-case refresh so one diff --git a/server/providers/cctv/sources.js b/server/providers/cctv/sources.js index ad8a883da..1124b187f 100644 --- a/server/providers/cctv/sources.js +++ b/server/providers/cctv/sources.js @@ -59,6 +59,10 @@ import { DELDOT_CCTV_URL, DEFAULT_DELDOT_MAX_SOURCES, DELDOT_ANCHORS, + WSDOT_CAMERAS_URL, + WSDOT_IMAGE_ORIGIN, + DEFAULT_WSDOT_MAX_SOURCES, + WSDOT_ANCHORS, } from './constants.js'; import { toFiniteNumber, @@ -1701,3 +1705,133 @@ export async function loadDelDOTSourcesFromOpenData() { return []; } } + +/** WSDOT compass letters as headings in degrees. `B` (both directions), `O` + * (other) and null carry no single facing and fall through to the id hash. */ +const WSDOT_COMPASS_HEADINGS = Object.freeze({ + N: 0, + E: 90, + S: 180, + W: 270, +}); + +/** + * Converts an EPSG:3857 (Web Mercator) coordinate pair to WGS84 degrees. + * The WSDOT camera layer publishes point geometry in wkid 102100. + * @param {number} x - Web Mercator metres east. + * @param {number} y - Web Mercator metres north. + * @returns {{lat: number, lon: number}} WGS84 degrees. + */ +export function webMercatorToWgs84(x, y) { + const R = 6378137; + const lon = (x / R) * (180 / Math.PI); + const lat = (2 * Math.atan(Math.exp(y / R)) - Math.PI / 2) * (180 / Math.PI); + return { lat, lon }; +} + +/** Rough Washington State bounding box (with the Columbia River border cams). */ +function isLikelyWashingtonCoordinate(lat, lon) { + return ( + Number.isFinite(lat) && + Number.isFinite(lon) && + lat >= 45.3 && + lat <= 49.3 && + lon >= -125 && + lon <= -116.5 + ); +} + +/** + * Fetch WSDOT highway cameras (Washington State). Keyless: one Esri JSON + * layer on WSDOT's official open-data host (the same layer the WSDOT Travel + * Center map consumes; a keyed Traveler Information API serves the identical + * population for higher-volume use). Only cameras with a positive integer id, + * finite in-state coordinates, and a frame on WSDOT's own image host are + * kept — the catalog's ~70 partner cameras (Oregon DOT, City of Seattle, + * lodges, airports) are skipped rather than proxied under someone else's + * terms. `CompassDirection` letters give a high-confidence heading; `B` + * (both directions, the majority) and `O`/null fall back to the id hash. + * The feed carries Windows-1252 bytes (en-dashes in titles) with no charset + * declaration, so the body is decoded explicitly before parsing. + * + * @returns {Promise>} Normalized camera source objects. + */ +export async function loadWsdotSourcesFromOpenData() { + try { + const resp = await fetch(WSDOT_CAMERAS_URL, { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS), + }); + if (!resp.ok) { + console.warn('[CCTV] WSDOT camera download failed:', resp.status); + return []; + } + const payload = JSON.parse( + new TextDecoder('windows-1252').decode(await resp.arrayBuffer()), + ); + const rows = Array.isArray(payload?.features) ? payload.features : []; + + const cameras = []; + for (const row of rows) { + const attrs = row?.attributes; + const rawId = attrs?.CameraID; + if (!Number.isSafeInteger(rawId) || rawId <= 0) continue; + const imageUrl = String(attrs.ImageURL || '').trim(); + if (!imageUrl.startsWith(WSDOT_IMAGE_ORIGIN)) continue; + const x = row?.geometry?.x; + const y = row?.geometry?.y; + if (!Number.isFinite(x) || !Number.isFinite(y)) continue; + const { lat, lon } = webMercatorToWgs84(x, y); + if (!isLikelyWashingtonCoordinate(lat, lon)) continue; + + const cameraId = `wsdot-${rawId}`; + const heading = + WSDOT_COMPASS_HEADINGS[ + String(attrs.CompassDirection || '') + .trim() + .toUpperCase() + ]; + const hasHeading = Number.isFinite(heading); + cameras.push({ + id: cameraId, + name: String(attrs.CameraTitle || '').trim() || `WSDOT camera ${rawId}`, + city: 'Washington', + cityId: 'washington-state', + provider: 'WSDOT', + lat, + lon, + headingDeg: hasHeading ? heading : fallbackHeadingFromId(cameraId), + headingConfidence: hasHeading ? 'high' : 'low', + // Same two pose personalities as the other packs: raw priors that the + // client's ground snap and manual calibration refine. + pitchDeg: hasHeading ? -24 : -18, + fovDeg: hasHeading ? 56 : 44, + rangeM: hasHeading ? 210 : 145, + mountHeightM: hasHeading ? 10 : 8, + feedType: 'image', + url: imageUrl, + snapshotUrl: imageUrl, + sourceKind: 'wsdot-open-data', + license: 'WSDOT Travel Center camera data (public low-volume feed)', + }); + } + + const maxRaw = Number( + process.env.CCTV_WSDOT_MAX_SOURCES || DEFAULT_WSDOT_MAX_SOURCES, + ); + const maxCount = Number.isFinite(maxRaw) + ? Math.max(8, Math.min(1200, Math.floor(maxRaw))) + : DEFAULT_WSDOT_MAX_SOURCES; + const prioritized = prioritizeSources(cameras, maxCount, WSDOT_ANCHORS); + console.log( + `[CCTV] Loaded WSDOT camera sources: ${cameras.length} published (using nearest ${prioritized.length})`, + ); + return prioritized; + } catch (error) { + console.warn( + '[CCTV] WSDOT camera download error:', + error?.message || error, + ); + return []; + } +} diff --git a/src/data/cctvWsdotSource.test.mjs b/src/data/cctvWsdotSource.test.mjs new file mode 100644 index 000000000..0459f3c72 --- /dev/null +++ b/src/data/cctvWsdotSource.test.mjs @@ -0,0 +1,167 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + DEFAULT_WSDOT_MAX_SOURCES, + WSDOT_CAMERAS_URL, +} from '../../server/providers/cctv/constants.js'; +import { + loadWsdotSourcesFromOpenData, + webMercatorToWgs84, +} from '../../server/providers/cctv/sources.js'; + +/** Set (or, for `undefined`, delete) environment variables for one test. */ +function withEnv(t, env) { + for (const [name, value] of Object.entries(env)) { + const previous = process.env[name]; + t.after(() => { + if (previous === undefined) delete process.env[name]; + else process.env[name] = previous; + }); + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } +} + +/** Silence the loaders' progress and failure logging. */ +function quiet(t) { + t.mock.method(console, 'log', () => {}); + t.mock.method(console, 'warn', () => {}); +} + +/** Web Mercator metres for a WGS84 coordinate (inverse of the loader's math). */ +function mercator(lat, lon) { + const R = 6378137; + return { + x: (lon * Math.PI * R) / 180, + y: R * Math.log(Math.tan(Math.PI / 4 + (lat * Math.PI) / 360)), + }; +} + +/** A `Cameras.json` point feature carrying the fields the loader reads. */ +function wsdotFeature(id, lat, lon, overrides = {}) { + const { attributes = {}, geometry } = overrides; + return { + attributes: { + CameraID: id, + CameraTitle: `Camera ${id}`, + CompassDirection: 'N', + ImageURL: `https://images.wsdot.wa.gov/nw/${id}.jpg`, + ...attributes, + }, + geometry: geometry === undefined ? mercator(lat, lon) : geometry, + }; +} + +/** Serialize a payload for the mocked fetch as raw Windows-1252 bytes. */ +function cp1252Response(payload) { + const text = JSON.stringify(payload); + const bytes = new Uint8Array(text.length); + for (let i = 0; i < text.length; i += 1) { + const code = text.charCodeAt(i); + // U+2013 EN DASH is 0x96 in Windows-1252 — the byte the live feed ships. + bytes[i] = code === 0x2013 ? 0x96 : code; + } + return new Response(bytes, { + headers: { 'Content-Type': 'application/json' }, + }); +} + +test('webMercatorToWgs84 recovers WGS84 degrees from layer geometry', () => { + // Real feature from the live layer: I-5 at the Interstate Bridge. + const { lat, lon } = webMercatorToWgs84(-13656007.102, 5719738.545); + assert.ok(Math.abs(lat - 45.62) < 0.05, `lat ${lat}`); + assert.ok(Math.abs(lon - -122.675) < 0.05, `lon ${lon}`); +}); + +test('WSDOT loader keeps official-host cameras and maps compass directions', async (t) => { + quiet(t); + withEnv(t, { CCTV_WSDOT_MAX_SOURCES: undefined }); + const requested = []; + t.mock.method(globalThis, 'fetch', async (url) => { + requested.push(String(url)); + return cp1252Response({ + features: [ + wsdotFeature(1001, 47.6097, -122.3331, { + attributes: { + // The 0x2013 below is serialized as the feed's 0x96 byte. + CameraTitle: 'I-5 – Seattle, looking south', + CompassDirection: 'S', + }, + }), + wsdotFeature(9059, 47.66, -117.43, { + attributes: { CompassDirection: 'B' }, + }), + // Partner-hosted frame (Oregon DOT): skipped, never proxied. + wsdotFeature(1002, 45.62, -122.675, { + attributes: { + ImageURL: 'https://www.tripcheck.com/RoadCams/cams/bridge.jpg', + }, + }), + // Bad rows: non-integer id, missing geometry, out-of-state point. + wsdotFeature('1003', 47.6, -122.3), + wsdotFeature(1004, 47.6, -122.3, { geometry: null }), + wsdotFeature(1005, 34.05, -118.24), + ], + }); + }); + + const cameras = await loadWsdotSourcesFromOpenData(); + + assert.deepEqual(requested, [WSDOT_CAMERAS_URL]); + // Exactly the two valid official-host cameras survive; the partner-hosted + // frame and the malformed rows are dropped. + assert.deepEqual(cameras.map((camera) => camera.id).sort(), [ + 'wsdot-1001', + 'wsdot-9059', + ]); + + const seattle = cameras.find((camera) => camera.id === 'wsdot-1001'); + assert.equal(seattle.name, 'I-5 – Seattle, looking south'); + assert.equal(seattle.provider, 'WSDOT'); + assert.equal(seattle.url, 'https://images.wsdot.wa.gov/nw/1001.jpg'); + assert.equal(seattle.snapshotUrl, seattle.url); + assert.equal(seattle.feedType, 'image'); + assert.equal(seattle.headingDeg, 180); + assert.equal(seattle.headingConfidence, 'high'); + assert.equal(seattle.fovDeg, 56); + assert.ok(Math.abs(seattle.lat - 47.6097) < 0.001); + assert.ok(Math.abs(seattle.lon - -122.3331) < 0.001); + + // "Both directions" carries no single facing: id-hash fallback at low + // confidence, with the low-confidence pose personality. + const spokane = cameras.find((camera) => camera.id === 'wsdot-9059'); + assert.equal(spokane.headingConfidence, 'low'); + assert.equal(spokane.fovDeg, 44); + assert.ok(spokane.headingDeg >= 0 && spokane.headingDeg < 360); +}); + +test('WSDOT loader honors the max-sources cap', async (t) => { + quiet(t); + withEnv(t, { CCTV_WSDOT_MAX_SOURCES: '8' }); + const features = []; + for (let i = 0; i < 24; i += 1) { + features.push(wsdotFeature(2000 + i, 47.0 + i * 0.05, -122.9 + i * 0.05)); + } + t.mock.method(globalThis, 'fetch', async () => cp1252Response({ features })); + + const cameras = await loadWsdotSourcesFromOpenData(); + assert.equal(cameras.length, 8); +}); + +test('WSDOT loader defaults its cap and survives upstream failure', async (t) => { + quiet(t); + withEnv(t, { CCTV_WSDOT_MAX_SOURCES: undefined }); + assert.equal(DEFAULT_WSDOT_MAX_SOURCES, 250); + + t.mock.method( + globalThis, + 'fetch', + async () => new Response('', { status: 503 }), + ); + assert.deepEqual(await loadWsdotSourcesFromOpenData(), []); + + t.mock.method(globalThis, 'fetch', async () => { + throw new Error('network down'); + }); + assert.deepEqual(await loadWsdotSourcesFromOpenData(), []); +}); diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index ab9213c26..ea7adeb78 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -293,6 +293,16 @@ export const DATA_CREDITS = [ 'Open Government Licence – British Columbia. ' + 'Some cameras are supplied by partners (TransLink, the City of Vancouver, the City of Surrey, Parks Canada and others); each names its provider in the CCTV panel.', }, + { + key: 'wsdot-cctv', + html: + 'CCTV cameras & frames (Washington State): ' + + 'Washington State Department of Transportation ' + + '(Travel Center), ' + + 'used per the ' + + 'WSDOT travel information disclaimer ' + + '(low-volume public feed; catalog cached, frames proxied on demand)', + }, { key: 'tallinn-cctv', html: From 481d0c327a36c1c8f2d6c60f44f5de98c79881ea Mon Sep 17 00:00:00 2001 From: bassem chagra Date: Sun, 27 Sep 2026 12:09:27 +0000 Subject: [PATCH 2/5] fix(cctv): map WSDOT diagonal and BW compass codes; document WSDOT env WSDOT's layer metadata defines BW (Bothways) plus the diagonals NE/SE/SW/NW alongside N/E/S/W. Diagonal cameras were being demoted to the low-confidence id-hash fallback; map them to 45/135/225/315 degrees and treat BW (not B) as the no-single-facing code. Add CCTV_WSDOT_ENABLED and CCTV_WSDOT_MAX_SOURCES to .env.example with the other pack controls. --- .env.example | 2 ++ DATA_SOURCES.md | 2 +- server/providers/cctv/sources.js | 15 +++++++--- src/data/cctvWsdotSource.test.mjs | 46 +++++++++++++++++++++++++++++-- 4 files changed, 58 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index e16e85a27..45ae52099 100644 --- a/.env.example +++ b/.env.example @@ -180,6 +180,8 @@ 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_WSDOT_ENABLED=1 # set 0 to disable the WSDOT (Washington State) highway cameras +# CCTV_WSDOT_MAX_SOURCES=250 # cap cameras loaded from the WSDOT catalog, nearest Seattle/Spokane first # 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/DATA_SOURCES.md b/DATA_SOURCES.md index 73eaf8a1f..760ac8429 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -90,7 +90,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`. -- **WSDOT highway cameras.** Keyless. The camera list is the Esri JSON layer `data.wsdot.wa.gov/travelcenter/Cameras.json` — the same layer WSDOT's own Travel Center map consumes (~1,700 point features, EPSG:3857 geometry converted to WGS84 on load; the body carries Windows-1252 bytes and is decoded as such). Only frames on WSDOT's own `images.wsdot.wa.gov` host are registered — the catalog also lists ~70 partner cameras (Oregon DOT, City of Seattle, lodges, airports) whose own terms apply, and those are skipped rather than proxied. WSDOT's layer metadata marks the feed for "low volume" use, which the 15-minute catalog cache and per-request frame fetches respect; frames are never stored or redistributed. `CompassDirection` letters (N/E/S/W) give a high-confidence heading; the majority `B` ("both directions") rows carry no single facing and use the shared id-hash fallback at low confidence. By default the 250 cameras nearest Seattle and Spokane load; `CCTV_WSDOT_MAX_SOURCES` changes the cap and `CCTV_WSDOT_ENABLED=0` turns the pack off. A keyed WSDOT Traveler Information API serves the same population for higher-volume use and is deliberately not required here. +- **WSDOT highway cameras.** Keyless. The camera list is the Esri JSON layer `data.wsdot.wa.gov/travelcenter/Cameras.json` — the same layer WSDOT's own Travel Center map consumes (~1,700 point features, EPSG:3857 geometry converted to WGS84 on load; the body carries Windows-1252 bytes and is decoded as such). Only frames on WSDOT's own `images.wsdot.wa.gov` host are registered — the catalog also lists ~70 partner cameras (Oregon DOT, City of Seattle, lodges, airports) whose own terms apply, and those are skipped rather than proxied. WSDOT's layer metadata marks the feed for "low volume" use, which the 15-minute catalog cache and per-request frame fetches respect; frames are never stored or redistributed. `CompassDirection` codes with a single facing (N/E/S/W and the diagonals NE/SE/SW/NW, per WSDOT's layer metadata) give a high-confidence heading; `BW` ("Bothways") rows carry no single facing and use the shared id-hash fallback at low confidence. By default the 250 cameras nearest Seattle and Spokane load; `CCTV_WSDOT_MAX_SOURCES` changes the cap and `CCTV_WSDOT_ENABLED=0` turns the pack off. A keyed WSDOT Traveler Information API serves the same population for higher-volume use and is deliberately not required here. - **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/server/providers/cctv/sources.js b/server/providers/cctv/sources.js index 1124b187f..b7fc197b0 100644 --- a/server/providers/cctv/sources.js +++ b/server/providers/cctv/sources.js @@ -1706,13 +1706,19 @@ export async function loadDelDOTSourcesFromOpenData() { } } -/** WSDOT compass letters as headings in degrees. `B` (both directions), `O` - * (other) and null carry no single facing and fall through to the id hash. */ +/** WSDOT `CompassDirection` codes as headings in degrees, per the layer's own + * metadata (N/E/S/W, NE/NW/SE/SW, BW = Bothways). `BW` and anything else + * (`O`, null, unknown codes) carry no single facing and fall through to the + * id hash. */ const WSDOT_COMPASS_HEADINGS = Object.freeze({ N: 0, + NE: 45, E: 90, + SE: 135, S: 180, + SW: 225, W: 270, + NW: 315, }); /** @@ -1749,8 +1755,9 @@ function isLikelyWashingtonCoordinate(lat, lon) { * finite in-state coordinates, and a frame on WSDOT's own image host are * kept — the catalog's ~70 partner cameras (Oregon DOT, City of Seattle, * lodges, airports) are skipped rather than proxied under someone else's - * terms. `CompassDirection` letters give a high-confidence heading; `B` - * (both directions, the majority) and `O`/null fall back to the id hash. + * terms. A single-facing `CompassDirection` (cardinal or diagonal) gives a + * high-confidence heading; `BW` (Bothways) and `O`/null fall back to the id + * hash. * The feed carries Windows-1252 bytes (en-dashes in titles) with no charset * declaration, so the body is decoded explicitly before parsing. * diff --git a/src/data/cctvWsdotSource.test.mjs b/src/data/cctvWsdotSource.test.mjs index 0459f3c72..438df7e58 100644 --- a/src/data/cctvWsdotSource.test.mjs +++ b/src/data/cctvWsdotSource.test.mjs @@ -89,7 +89,7 @@ test('WSDOT loader keeps official-host cameras and maps compass directions', asy }, }), wsdotFeature(9059, 47.66, -117.43, { - attributes: { CompassDirection: 'B' }, + attributes: { CompassDirection: 'BW' }, }), // Partner-hosted frame (Oregon DOT): skipped, never proxied. wsdotFeature(1002, 45.62, -122.675, { @@ -127,7 +127,7 @@ test('WSDOT loader keeps official-host cameras and maps compass directions', asy assert.ok(Math.abs(seattle.lat - 47.6097) < 0.001); assert.ok(Math.abs(seattle.lon - -122.3331) < 0.001); - // "Both directions" carries no single facing: id-hash fallback at low + // `BW` (Bothways) carries no single facing: id-hash fallback at low // confidence, with the low-confidence pose personality. const spokane = cameras.find((camera) => camera.id === 'wsdot-9059'); assert.equal(spokane.headingConfidence, 'low'); @@ -135,6 +135,48 @@ test('WSDOT loader keeps official-host cameras and maps compass directions', asy assert.ok(spokane.headingDeg >= 0 && spokane.headingDeg < 360); }); +test('WSDOT loader maps every documented CompassDirection code', async (t) => { + quiet(t); + withEnv(t, { CCTV_WSDOT_MAX_SOURCES: undefined }); + const codes = { + N: 0, + NE: 45, + E: 90, + SE: 135, + S: 180, + SW: 225, + W: 270, + NW: 315, + // Lower-case and padded codes are the same code. + ' nw ': 315, + }; + const noFacing = ['BW', 'O', null]; + const entries = [...Object.keys(codes), ...noFacing]; + t.mock.method(globalThis, 'fetch', async () => + cp1252Response({ + features: entries.map((code, i) => + wsdotFeature(3000 + i, 47.0 + i * 0.05, -122.9 + i * 0.05, { + attributes: { CompassDirection: code }, + }), + ), + }), + ); + + const cameras = await loadWsdotSourcesFromOpenData(); + const byId = new Map(cameras.map((camera) => [camera.id, camera])); + entries.forEach((code, i) => { + const camera = byId.get(`wsdot-${3000 + i}`); + if (code in codes) { + assert.equal(camera.headingDeg, codes[code], `heading for ${code}`); + assert.equal(camera.headingConfidence, 'high', `confidence for ${code}`); + assert.equal(camera.fovDeg, 56, `pose personality for ${code}`); + } else { + assert.equal(camera.headingConfidence, 'low', `confidence for ${code}`); + assert.equal(camera.fovDeg, 44, `pose personality for ${code}`); + } + }); +}); + test('WSDOT loader honors the max-sources cap', async (t) => { quiet(t); withEnv(t, { CCTV_WSDOT_MAX_SOURCES: '8' }); From 5b2e160e54c8259847c34a4915cfd501f6c1bc45 Mon Sep 17 00:00:00 2001 From: bassem chagra Date: Sun, 27 Sep 2026 12:15:13 +0000 Subject: [PATCH 3/5] docs(cctv): add the WSDOT pack to CHANGELOG, CURRENT-STATE and README CONTRIBUTING requires runtime-behavior changes to update docs/CURRENT-STATE.md and CHANGELOG.md in the same PR. The README and CURRENT-STATE table diffs are column realignment from the widened source cell. --- CHANGELOG.md | 10 ++++++++++ README.md | 42 +++++++++++++++++++++--------------------- docs/CURRENT-STATE.md | 42 +++++++++++++++++++++--------------------- 3 files changed, 52 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29dbedfaa..912e64ff4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +- Add WSDOT highway cameras (Washington State) as a keyless CCTV source pack: + WSDOT's official Travel Center camera layer, with frames registered only on + WSDOT's own image host (partner-hosted cameras such as Oregon DOT and City of + Seattle are skipped). Cameras with a single `CompassDirection`, cardinal or + diagonal, get a high-confidence heading; `BW` (Bothways) cameras use the + shared id-hash fallback at low confidence. By default the 250 cameras nearest + Seattle and Spokane load; `CCTV_WSDOT_MAX_SOURCES` sets the cap and + `CCTV_WSDOT_ENABLED=0` turns the pack off (bassem chagra, #644). + - CCTV cameras whose bearing is a guess now say so. Packs mark bearings derived from a hash of the camera id as `headingConfidence: 'low'`, but nothing read the flag, so roughly 70% of a default catalog rendered like surveyed facings. The HUD @@ -235,6 +244,7 @@ Add feed provenance to analyst/view answers and HUD context while retaining existing response fields and runner ownership (Matt Van Horn, #347). Analyst records for loaded satellites, datacenters and dams, with explicit bounded count/rank coverage (Matt Van Horn, #351). + - New Fire Perimeters layer (Events group): live NIFC WFIGS interagency wildfire incident perimeters as ground-clamped polygons with a containment-colored fire line, refreshed every 5 minutes from the public diff --git a/README.md b/README.md index 88690d52f..39daf9e2d 100644 --- a/README.md +++ b/README.md @@ -279,27 +279,27 @@ _Ask for radio near anywhere and the globe starts broadcasting — every station Nineteen layers and map sources. **Seventeen have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.) -| Layer | What you get | Source | Auth | -| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------- | -| 🗺️ **Map Stack** | Esri satellite imagery, Google Photorealistic 3D, OSM, plus additional ion-hosted stacks | Esri / Google / Ion / OSM | 🟢 Esri satellite + OSM · 🟡 ion-hosted Google 3D + world terrain · 🔴 direct Google + place search | -| ✈️ **Live Flights** | 11,000+ live aircraft + route history | OpenSky + adsb.lol | 🟢 (🟡 optional for more polling credits) | -| 🎖️ **Military Flights** | ADS-B military traffic in amber | adsb.lol | 🟢 | -| 🚢 **Live Vessels** | Thousands of ships worldwide | AISStream | 🟡 | -| 🛰️ **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 | 🟢 | -| 📷 **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 | 🟢 | -| 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 | -| 🧭 **Directions** | Click A and B on the globe for a street-following drive, walk or cycle route draped on the terrain with turn-by-turn steps — then FLY the camera along it. No key, no geocoder, no mic | OSRM on FOSSGIS servers (OpenStreetMap) | 🟢 | -| 🔥 **Active Fires** | Live NASA FIRMS detections, trailing 24h | NASA FIRMS | 🟡 | -| 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🟡 optional token raises the allowance) | -| 🎖️ **Mapped Installations** | Viewport-bounded military-site context from community mapping — incomplete by nature, and labeled that way | OpenStreetMap | 🟢 | -| 🌬️ **Wind** | Animated 10 m forecast wind with an optional speed, temperature or pressure color field and a reading at map center | NOAA GFS / ECMWF IFS | 🟢 | -| 🌦️ **Observed Weather** | Rain radar (contiguous US), Satellite clouds (GOES regional or global infrared, Clouds only or Full image) and Lightning density (Americas and Pacific) on one history timeline | NOAA nowCOAST | 🟢 | -| 🌀 **Cyclones** | Advisory positions, forecast tracks and uncertainty cones for the Atlantic and eastern/central North Pacific | NOAA NHC / CPHC | 🟢 | +| Layer | What you get | Source | Auth | +| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------- | +| 🗺️ **Map Stack** | Esri satellite imagery, Google Photorealistic 3D, OSM, plus additional ion-hosted stacks | Esri / Google / Ion / OSM | 🟢 Esri satellite + OSM · 🟡 ion-hosted Google 3D + world terrain · 🔴 direct Google + place search | +| ✈️ **Live Flights** | 11,000+ live aircraft + route history | OpenSky + adsb.lol | 🟢 (🟡 optional for more polling credits) | +| 🎖️ **Military Flights** | ADS-B military traffic in amber | adsb.lol | 🟢 | +| 🚢 **Live Vessels** | Thousands of ships worldwide | AISStream | 🟡 | +| 🛰️ **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,850 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 · Washington (WSDOT). 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 | 🟢 | +| 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 | +| 🧭 **Directions** | Click A and B on the globe for a street-following drive, walk or cycle route draped on the terrain with turn-by-turn steps — then FLY the camera along it. No key, no geocoder, no mic | OSRM on FOSSGIS servers (OpenStreetMap) | 🟢 | +| 🔥 **Active Fires** | Live NASA FIRMS detections, trailing 24h | NASA FIRMS | 🟡 | +| 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🟡 optional token raises the allowance) | +| 🎖️ **Mapped Installations** | Viewport-bounded military-site context from community mapping — incomplete by nature, and labeled that way | OpenStreetMap | 🟢 | +| 🌬️ **Wind** | Animated 10 m forecast wind with an optional speed, temperature or pressure color field and a reading at map center | NOAA GFS / ECMWF IFS | 🟢 | +| 🌦️ **Observed Weather** | Rain radar (contiguous US), Satellite clouds (GOES regional or global infrared, Clouds only or Full image) and Lightning density (Americas and Pacific) on one history timeline | NOAA nowCOAST | 🟢 | +| 🌀 **Cyclones** | Advisory positions, forecast tracks and uncertainty cones for the Atlantic and eastern/central North Pacific | NOAA NHC / CPHC | 🟢 | **Weather, keyless.** **Wind** animates 10 m forecast flow from NOAA GFS or ECMWF IFS, with an optional color field and a reading at map center. **Rain radar**, **Satellite clouds** and **Lightning density** are observations on one history timeline in the **WEATHER** panel (step back, **Play**, **Latest**); **Cyclone advisories** draw NHC and CPHC positions, forecast tracks and cones. On Google 3D Tiles the observed layers float above the city as translucent shells, sharpened around your view. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index fdb42fc0c..edbd44565 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2747,27 +2747,27 @@ nearby place—always use station selection. Unqualified “turn on/start the ra requests use Play; a qualified Play-shaped tool call is normalized to Select so its criteria cannot be silently ignored. -| Layer | Source | File | Proxy | Update Interval | -| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------- | -| Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/opensky` (OAuth + fallback) | 30s | -| Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/adsblol/mil` | 15s | -| Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/ais-live` | 60s (+800ms visibility pass) | -| Mapped Installations ⌖ | OpenStreetMap mapped context; on-demand Google Maps Places supplement | `src/data/militaryInstallations.js` | `/api/military-installations`, `/api/google/text-search` | viewport-driven + user search; while unavailable, auto-retry 30 s → 240 s backoff | -| Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s | -| 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 | OSM Overpass (+ optional TomTom live flow) | `src/data/traffic.js` | `/api/overpass` + `/api/tomtom` | viewport-driven | -| 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) | -| 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 | -| Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change | -| Datacenters ▣ | OSM extract (bundled) | `src/data/localLayers.js` | — | static | -| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/localLayers.js` | — | static | -| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static | -| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) | -| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) | -| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) | +| Layer | Source | File | Proxy | Update Interval | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------- | --------------------------------------------------------- | --------------------------------------------------------------------------------- | +| Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/opensky` (OAuth + fallback) | 30s | +| Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/adsblol/mil` | 15s | +| Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/ais-live` | 60s (+800ms visibility pass) | +| Mapped Installations ⌖ | OpenStreetMap mapped context; on-demand Google Maps Places supplement | `src/data/militaryInstallations.js` | `/api/military-installations`, `/api/google/text-search` | viewport-driven + user search; while unavailable, auto-retry 30 s → 240 s backoff | +| Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s | +| 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 | OSM Overpass (+ optional TomTom live flow) | `src/data/traffic.js` | `/api/overpass` + `/api/tomtom` | viewport-driven | +| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary + WSDOT (WA) Open Data + 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 | +| Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change | +| Datacenters ▣ | OSM extract (bundled) | `src/data/localLayers.js` | — | static | +| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/localLayers.js` | — | static | +| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static | +| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) | +| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) | +| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) | Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment. From c4f18169ebeac81272badaa9c833a4d68be98fd2 Mon Sep 17 00:00:00 2001 From: bassem chagra Date: Sun, 27 Sep 2026 12:20:45 +0000 Subject: [PATCH 4/5] fix(cctv): raise the default catalog cap to 4,500 and enforce it over every pack The per-pack defaults already summed to 4,054 before WSDOT (4,304 with it), past the documented 4,000 cap, so a default install was trimming. The existing sum check in the DriveBC test listed packs by hand and missed Calgary and DelDOT. Replace it with a check in cctvCatalogCap.test.mjs that sums every exported DEFAULT_*_MAX_SOURCES, and drop the Calgary test that pinned the cap at exactly 4,000. --- .env.example | 2 +- CHANGELOG.md | 6 ++++- docs/CURRENT-STATE.md | 2 +- server/providers/cctv/constants.js | 7 +++-- src/data/cctvCalgary.test.mjs | 5 ---- src/data/cctvCatalogCap.test.mjs | 28 +++++++++++++++++--- src/data/cctvDriveBcSource.test.mjs | 40 +---------------------------- 7 files changed, 37 insertions(+), 53 deletions(-) diff --git a/.env.example b/.env.example index 45ae52099..5f153ca40 100644 --- a/.env.example +++ b/.env.example @@ -182,6 +182,6 @@ AISSTREAM_API_KEY= # CCTV_CALGARY_ROWS_URL= # override the Open Calgary catalog URL # CCTV_WSDOT_ENABLED=1 # set 0 to disable the WSDOT (Washington State) highway cameras # CCTV_WSDOT_MAX_SOURCES=250 # cap cameras loaded from the WSDOT catalog, nearest Seattle/Spokane first -# CCTV_MAX_SOURCES=4000 # catalog-wide ceiling, shared round-robin across packs +# CCTV_MAX_SOURCES=4500 # 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 912e64ff4..1b559c1e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,11 @@ diagonal, get a high-confidence heading; `BW` (Bothways) cameras use the shared id-hash fallback at low confidence. By default the 250 cameras nearest Seattle and Spokane load; `CCTV_WSDOT_MAX_SOURCES` sets the cap and - `CCTV_WSDOT_ENABLED=0` turns the pack off (bassem chagra, #644). + `CCTV_WSDOT_ENABLED=0` turns the pack off. The default catalog cap + (`CCTV_MAX_SOURCES`) rises from 4,000 to 4,500: the per-pack defaults already + summed to 4,054 before WSDOT, so a default install was being trimmed. A test + now sums every pack's default and fails if they exceed the cap + (bassem chagra, #644). - CCTV cameras whose bearing is a guess now say so. Packs mark bearings derived from a hash of the camera id as `headingConfidence: 'low'`, but nothing read the diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index edbd44565..892c85976 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3422,7 +3422,7 @@ silently demoting every later lookup for the session. kill switch `CCTV_FINTRAFFIC_ENABLED=0`) are the fourth pack: one keyless GeoJSON station list covering the whole country, where one _preset_ (a station's fixed view) is one camera — 806 GATHERING stations carry 2,256 in-collection presets, prioritized to 300 against seven anchors - on the main road spine. `CCTV_MAX_SOURCES` is a 4,000 catalog-wide ceiling shared round-robin + on the main road spine. `CCTV_MAX_SOURCES` is a 4,500 catalog-wide ceiling (4,000 until the WSDOT pack) shared round-robin across packs, so a lower global cap thins every region instead of starving the last pack. ~1,100 cameras total, all RAW PRIOR poses, stills-first. - **CCTV v3 UX — viewshed + calibration gizmo** (built 2026-07-05 and field diff --git a/server/providers/cctv/constants.js b/server/providers/cctv/constants.js index 3ab8ce9cb..91e684be0 100644 --- a/server/providers/cctv/constants.js +++ b/server/providers/cctv/constants.js @@ -9,9 +9,12 @@ export const DEFAULT_AUSTIN_MAX_SOURCES = 250; * itself (nearest-to-anchor first); this bound only matters when the packs * together exceed it, and it is then filled round-robin across packs (see * cap.js) so no region is silently dropped. Sized above the sum of the - * default per-pack caps so a default install never trims. + * default per-pack caps so a default install never trims (enforced by + * cctvCatalogCap.test.mjs). The per-pack defaults reached 4,304 with WSDOT, + * so this is 4,500: room for one more small pack while staying under + * CCTV_MAX_SOURCES_CEILING, which also sizes the health map. */ -export const DEFAULT_CCTV_MAX_SOURCES = 4000; +export const DEFAULT_CCTV_MAX_SOURCES = 4500; /** Hard upper bound for CCTV_MAX_SOURCES; also sizes the health map. */ export const CCTV_MAX_SOURCES_CEILING = 5000; /** Reference point for Austin camera prioritization (Congress & 6th). */ diff --git a/src/data/cctvCalgary.test.mjs b/src/data/cctvCalgary.test.mjs index 1295c536b..4e7702059 100644 --- a/src/data/cctvCalgary.test.mjs +++ b/src/data/cctvCalgary.test.mjs @@ -12,7 +12,6 @@ import { CALGARY_IMAGE_ORIGIN, CALGARY_MAX_CATALOG_BYTES, DEFAULT_CALGARY_ROWS_URL, - DEFAULT_CCTV_MAX_SOURCES, } from '../../server/providers/cctv/constants.js'; import { CAMERA_CODE_MAX_CHARS } from '../../server/providers/cctv/normalize.js'; import { allocateSourceCap } from '../../server/providers/cctv/cap.js'; @@ -383,7 +382,3 @@ test('CCTV_CALGARY_ENABLED=0 keeps the lane from being loaded at all', async (t) Object.assign(process.env, saved); } }); - -test('the shipped catalog ceiling is not raised to make room for this pack', () => { - assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000); -}); diff --git a/src/data/cctvCatalogCap.test.mjs b/src/data/cctvCatalogCap.test.mjs index 6f1ff6c59..b0a603b4a 100644 --- a/src/data/cctvCatalogCap.test.mjs +++ b/src/data/cctvCatalogCap.test.mjs @@ -4,10 +4,9 @@ import { allocateSourceCap, resolveCatalogCap, } from '../../server/providers/cctv/cap.js'; -import { - CCTV_MAX_SOURCES_CEILING, - DEFAULT_CCTV_MAX_SOURCES, -} from '../../server/providers/cctv/constants.js'; +import * as constants from '../../server/providers/cctv/constants.js'; + +const { CCTV_MAX_SOURCES_CEILING, DEFAULT_CCTV_MAX_SOURCES } = constants; import { createCctvCatalog } from '../../server/providers/cctv/catalog.js'; const pack = (name, count, prefix = name) => ({ @@ -135,3 +134,24 @@ test('the catalog applies the fair cap to configured packs end to end', async () Object.assign(process.env, saved); } }); + +test('default per-pack camera caps fit inside the default catalog cap', () => { + // Every exported DEFAULT__MAX_SOURCES counts, so a new pack cannot + // slip past this check by not being listed (Warendorf ships 1 curated + // camera and has no cap constant). + const packCaps = Object.entries(constants).filter( + ([name]) => + /^DEFAULT_[A-Z_]+_MAX_SOURCES$/.test(name) && + name !== 'DEFAULT_CCTV_MAX_SOURCES', + ); + assert.ok(packCaps.length >= 13, 'every pack cap constant is found'); + const total = packCaps.reduce((sum, [, cap]) => sum + cap, 0) + 1; + assert.ok( + total <= DEFAULT_CCTV_MAX_SOURCES, + `default packs (${total}) would be thinned by the catalog cap (${DEFAULT_CCTV_MAX_SOURCES})`, + ); + assert.ok( + DEFAULT_CCTV_MAX_SOURCES <= CCTV_MAX_SOURCES_CEILING, + 'the default cap must sit inside the CCTV_MAX_SOURCES ceiling', + ); +}); diff --git a/src/data/cctvDriveBcSource.test.mjs b/src/data/cctvDriveBcSource.test.mjs index 617bb1350..080b1edf0 100644 --- a/src/data/cctvDriveBcSource.test.mjs +++ b/src/data/cctvDriveBcSource.test.mjs @@ -4,21 +4,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { createCctvCatalog } from '../../server/providers/cctv/catalog.js'; -import { - DEFAULT_AUSTIN_MAX_SOURCES, - DEFAULT_CALTRANS_MAX_SOURCES, - DEFAULT_CCTV_MAX_SOURCES, - CCTV_MAX_SOURCES_CEILING, - DEFAULT_DRIVEBC_MAX_SOURCES, - DEFAULT_TFL_MAX_SOURCES, - DEFAULT_ONTARIO_MAX_SOURCES, - DEFAULT_FINTRAFFIC_MAX_SOURCES, - DEFAULT_TXDOT_MAX_SOURCES, - DEFAULT_TALLINN_MAX_SOURCES, - DEFAULT_TARKTEE_MAX_SOURCES, - DEFAULT_NSW_MAX_SOURCES, - DRIVEBC_WEBCAMS_URL, -} from '../../server/providers/cctv/constants.js'; +import { DRIVEBC_WEBCAMS_URL } from '../../server/providers/cctv/constants.js'; import { driveBcImageCredit, loadDriveBcSourcesFromOpenData, @@ -221,30 +207,6 @@ test('CCTV catalog merges DriveBC cameras and CCTV_DRIVEBC_ENABLED=0 skips the r ); }); -test('default per-pack camera caps fit inside the default catalog cap', () => { - // Every default pack at its own cap (Warendorf ships 1 curated camera). - const packs = - DEFAULT_AUSTIN_MAX_SOURCES + - DEFAULT_CALTRANS_MAX_SOURCES + - DEFAULT_TFL_MAX_SOURCES + - DEFAULT_ONTARIO_MAX_SOURCES + - DEFAULT_FINTRAFFIC_MAX_SOURCES + - DEFAULT_DRIVEBC_MAX_SOURCES + - DEFAULT_TXDOT_MAX_SOURCES + - DEFAULT_TALLINN_MAX_SOURCES + - DEFAULT_TARKTEE_MAX_SOURCES + - DEFAULT_NSW_MAX_SOURCES + - 1; - assert.ok( - packs <= DEFAULT_CCTV_MAX_SOURCES, - `default packs (${packs}) would be thinned by the catalog cap (${DEFAULT_CCTV_MAX_SOURCES})`, - ); - assert.ok( - DEFAULT_CCTV_MAX_SOURCES <= CCTV_MAX_SOURCES_CEILING, - 'the default cap must sit inside the CCTV_MAX_SOURCES ceiling', - ); -}); - test('DriveBC credit keeps partner attribution and drops operational notes', () => { assert.equal( driveBcImageCredit( From 76bfd74a78666e755e6c01e1f22315ebd3b87534 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 15 Oct 2026 15:17:31 -0700 Subject: [PATCH 5/5] fix(cctv): harden the WSDOT catalog fetch and describe its real direction codes The catalog fetch now refuses redirects, cancels rejected bodies and reads through the shared capped reader before the windows-1252 decode. The feed sends cardinal codes plus B (both), O and null, not BW or diagonals; tests, comments and docs now say so, with unchanged id-hash fallbacks. Documents the 8-1,200 per-pack range, drops the unverified low-volume compliance claim for facts and a link to WSDOT's terms, and stops claiming the per-pack defaults fit under the 4,000 cap: it is filled round-robin. --- .env.example | 2 +- CHANGELOG.md | 11 +-- DATA_SOURCES.md | 2 +- server/providers/cctv/constants.js | 18 +++-- server/providers/cctv/sources.js | 41 ++++++++--- src/data/cctvDriveBcSource.test.mjs | 2 +- src/data/cctvWsdotSource.test.mjs | 101 ++++++++++++++++++++++++++-- 7 files changed, 148 insertions(+), 29 deletions(-) diff --git a/.env.example b/.env.example index c37f540f9..bec981341 100644 --- a/.env.example +++ b/.env.example @@ -220,7 +220,7 @@ AISSTREAM_API_KEY= # CCTV_CALGARY_MAX_SOURCES=220 # cap cameras loaded from the Open Calgary catalog # CCTV_CALGARY_ROWS_URL= # override the Open Calgary catalog URL # CCTV_WSDOT_ENABLED=1 # set 0 to disable the WSDOT (Washington State) highway cameras -# CCTV_WSDOT_MAX_SOURCES=250 # cap cameras loaded from the WSDOT catalog, nearest Seattle/Spokane first +# CCTV_WSDOT_MAX_SOURCES=250 # cap cameras loaded from the WSDOT catalog, nearest Seattle/Spokane first; min 8, max 1200 # 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 22199a67c..13d5633d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,11 +3,12 @@ - Add WSDOT highway cameras (Washington State) as a keyless CCTV source pack: WSDOT's official Travel Center camera layer, with frames registered only on WSDOT's own image host (partner-hosted cameras such as Oregon DOT and City of - Seattle are skipped). Cameras with a single `CompassDirection`, cardinal or - diagonal, get a high-confidence heading; `BW` (Bothways) cameras use the - shared id-hash fallback at low confidence. By default the 250 cameras nearest - Seattle and Spokane load; `CCTV_WSDOT_MAX_SOURCES` sets the cap and - `CCTV_WSDOT_ENABLED=0` turns the pack off (bassem chagra, #644). + Seattle are skipped). Cameras with a single cardinal `CompassDirection` get + a high-confidence heading; `B` (both directions), `O`, and unset directions + use the shared id-hash fallback at low confidence. By default the 250 + cameras nearest Seattle and Spokane load; `CCTV_WSDOT_MAX_SOURCES` sets the + cap (8–1,200) and `CCTV_WSDOT_ENABLED=0` turns the pack off (bassem chagra, + #644). - Stop one retired bikeshare feed from reporting the whole layer as failed. City feeds are fetched independently, but failure was recorded in a single diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index a715784e0..4ce25a4e0 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -143,7 +143,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`. -- **WSDOT highway cameras.** Keyless. The camera list is the Esri JSON layer `data.wsdot.wa.gov/travelcenter/Cameras.json` — the same layer WSDOT's own Travel Center map consumes (~1,700 point features, EPSG:3857 geometry converted to WGS84 on load; the body carries Windows-1252 bytes and is decoded as such). Only frames on WSDOT's own `images.wsdot.wa.gov` host are registered — the catalog also lists ~70 partner cameras (Oregon DOT, City of Seattle, lodges, airports) whose own terms apply, and those are skipped rather than proxied. WSDOT's layer metadata marks the feed for "low volume" use, which the 15-minute catalog cache and per-request frame fetches respect; frames are never stored or redistributed. `CompassDirection` codes with a single facing (N/E/S/W and the diagonals NE/SE/SW/NW, per WSDOT's layer metadata) give a high-confidence heading; `BW` ("Bothways") rows carry no single facing and use the shared id-hash fallback at low confidence. By default the 250 cameras nearest Seattle and Spokane load; `CCTV_WSDOT_MAX_SOURCES` changes the cap and `CCTV_WSDOT_ENABLED=0` turns the pack off. A keyed WSDOT Traveler Information API serves the same population for higher-volume use and is deliberately not required here. +- **WSDOT highway cameras.** Keyless. The camera list is the Esri JSON layer `data.wsdot.wa.gov/travelcenter/Cameras.json` — the same layer WSDOT's own Travel Center map consumes (~1,700 point features, EPSG:3857 geometry converted to WGS84 on load; the body carries Windows-1252 bytes and is decoded as such). Only frames on WSDOT's own `images.wsdot.wa.gov` host are registered — the catalog also lists ~70 partner cameras (Oregon DOT, City of Seattle, lodges, airports) whose own terms apply, and those are skipped rather than proxied. WSDOT's layer metadata marks the feed for "low volume" use (see WSDOT's [travel information disclaimer](https://wsdot.wa.gov/about/policies/travel-information-disclaimer)). The catalog is fetched once per CCTV catalog refresh (every 15 minutes), and a camera's frame is fetched only when that camera is viewed; frames are never stored or redistributed. The live feed's `CompassDirection` is a single cardinal (N/E/S/W), `B` (both directions — most rows), `O`, or null. A single cardinal gives a high-confidence heading (the diagonals NE/SE/SW/NW are mapped too should they appear); `B`, `O`, null, and any other code carry no single facing and use the shared id-hash fallback at low confidence. By default the 250 cameras nearest Seattle and Spokane load; `CCTV_WSDOT_MAX_SOURCES` changes the cap (8–1,200) and `CCTV_WSDOT_ENABLED=0` turns the pack off. A keyed WSDOT Traveler Information API serves the same population for higher-volume use and is deliberately not required here. - **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. - **NYC DOT Traffic Management Center cameras.** Off by default. NYC DOT's [developer data feeds page](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) asks developers who want the camera feed to contact `tmcdot@dot.nyc.gov` and sign a data-sharing agreement. This project does not hold one that covers downstream installations, so the pack loads only when an operator who holds their own agreement sets `CCTV_NYC_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to NYC DOT. The endpoint needs no key. The camera list is the public `webcams.nyctmc.org/api/cameras` catalog (~980 cameras across the five boroughs); frames are stills at `webcams.nyctmc.org/api/cameras//image`, synthesized from a validated UUID rather than taken from the payload, and only that host is registered. Cameras the feed reports as offline are dropped. The catalog publishes no camera facing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. Once enabled, the 250 cameras nearest the borough cores load; `CCTV_NYC_MAX_SOURCES` (8–400) changes the cap. Frames are fetched at request time and never stored or redistributed. NYC DOT asks every application that uses its data to carry its disclaimer; it is registered verbatim 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. diff --git a/server/providers/cctv/constants.js b/server/providers/cctv/constants.js index 12e27fdd6..883fb0b47 100644 --- a/server/providers/cctv/constants.js +++ b/server/providers/cctv/constants.js @@ -8,8 +8,8 @@ export const DEFAULT_AUSTIN_MAX_SOURCES = 250; * Catalog-wide safety ceiling on served cameras. Each pack already caps * itself (nearest-to-anchor first); this bound only matters when the packs * together exceed it, and it is then filled round-robin across packs (see - * cap.js) so no region is silently dropped. Sized above the sum of the - * default per-pack caps so a default install never trims. + * cap.js) so no region is silently dropped. The default per-pack caps + * together can exceed it, so a default install may be thinned evenly here. */ export const DEFAULT_CCTV_MAX_SOURCES = 4000; /** Hard upper bound for CCTV_MAX_SOURCES; also sizes the health map. */ @@ -249,9 +249,9 @@ export const VEGVESEN_IMAGE_ORIGIN = * 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. */ +/** Above the ~850 working cameras so the pack keeps them all on its own. The + * catalog-wide DEFAULT_CCTV_MAX_SOURCES cap still applies across packs and is + * filled round-robin, so a full default install may thin this pack. */ 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; @@ -308,8 +308,8 @@ export const DELDOT_ANCHORS = [ /** WSDOT Travel Center cameras (Washington State): keyless Esri JSON on the * official open-data host — ~1,700 point features in EPSG:3857 (Web Mercator), - * ETag/Last-Modified served. WSDOT's metadata marks the feed "low volume" use, - * which the 15-minute catalog cache respects. */ + * ETag/Last-Modified served. WSDOT's metadata marks the feed "low volume" use; + * the catalog is fetched once per CCTV_SOURCE_CACHE_MS refresh. */ export const WSDOT_CAMERAS_URL = 'https://data.wsdot.wa.gov/travelcenter/Cameras.json'; /** Only frames on WSDOT's own image host are registered; the catalog also @@ -317,6 +317,10 @@ export const WSDOT_CAMERAS_URL = * whose own terms apply. */ export const WSDOT_IMAGE_ORIGIN = 'https://images.wsdot.wa.gov/'; export const DEFAULT_WSDOT_MAX_SOURCES = 250; +/** Hard ceiling on the WSDOT catalog body. The live layer is ~400 KB; this + * only exists so an upstream that streams an unbounded body cannot be + * buffered without limit. */ +export const WSDOT_MAX_CATALOG_BYTES = 4 * 1024 * 1024; /** Prioritization anchors: downtown Seattle and Spokane. */ export const WSDOT_ANCHORS = [ { lat: 47.6062, lon: -122.3321 }, // Seattle diff --git a/server/providers/cctv/sources.js b/server/providers/cctv/sources.js index 04c12c502..56673014e 100644 --- a/server/providers/cctv/sources.js +++ b/server/providers/cctv/sources.js @@ -63,6 +63,7 @@ import { WSDOT_IMAGE_ORIGIN, DEFAULT_WSDOT_MAX_SOURCES, WSDOT_ANCHORS, + WSDOT_MAX_CATALOG_BYTES, DEFAULT_VEGVESEN_CCTV_URL, VEGVESEN_IMAGE_ORIGIN, VEGVESEN_VIDEO_URL, @@ -100,7 +101,10 @@ import { prioritizeSources, } from './normalize.js'; import { directionToHeading } from '../../../src/data/directionText.js'; -import { readResponseJsonCapped } from '../common/http.js'; +import { + readResponseBytesCapped, + readResponseJsonCapped, +} from '../common/http.js'; /** * Fetch and parse Austin traffic camera records from the city Open Data portal. * @@ -1721,10 +1725,10 @@ export async function loadDelDOTSourcesFromOpenData() { } } -/** WSDOT `CompassDirection` codes as headings in degrees, per the layer's own - * metadata (N/E/S/W, NE/NW/SE/SW, BW = Bothways). `BW` and anything else - * (`O`, null, unknown codes) carry no single facing and fall through to the - * id hash. */ +/** WSDOT `CompassDirection` codes as headings in degrees. The live feed sends + * single cardinals (N/E/S/W), `B` (both directions — most rows), `O`, and + * null; the diagonals are mapped in case they appear. `B`, `O`, null, and any + * other code carry no single facing and fall through to the id hash. */ const WSDOT_COMPASS_HEADINGS = Object.freeze({ N: 0, NE: 45, @@ -1770,9 +1774,9 @@ function isLikelyWashingtonCoordinate(lat, lon) { * finite in-state coordinates, and a frame on WSDOT's own image host are * kept — the catalog's ~70 partner cameras (Oregon DOT, City of Seattle, * lodges, airports) are skipped rather than proxied under someone else's - * terms. A single-facing `CompassDirection` (cardinal or diagonal) gives a - * high-confidence heading; `BW` (Bothways) and `O`/null fall back to the id - * hash. + * terms. A single-facing `CompassDirection` (a cardinal) gives a + * high-confidence heading; `B` (both directions), `O`, and null fall back to + * the id hash. * The feed carries Windows-1252 bytes (en-dashes in titles) with no charset * declaration, so the body is decoded explicitly before parsing. * @@ -1782,14 +1786,31 @@ export async function loadWsdotSourcesFromOpenData() { try { const resp = await fetch(WSDOT_CAMERAS_URL, { 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] WSDOT catalog redirected; redirects are not followed', + ); + return discard(); + } if (!resp.ok) { console.warn('[CCTV] WSDOT camera download failed:', resp.status); - return []; + return discard(); } const payload = JSON.parse( - new TextDecoder('windows-1252').decode(await resp.arrayBuffer()), + new TextDecoder('windows-1252').decode( + await readResponseBytesCapped(resp, WSDOT_MAX_CATALOG_BYTES), + ), ); const rows = Array.isArray(payload?.features) ? payload.features : []; diff --git a/src/data/cctvDriveBcSource.test.mjs b/src/data/cctvDriveBcSource.test.mjs index 617bb1350..c8e417e8b 100644 --- a/src/data/cctvDriveBcSource.test.mjs +++ b/src/data/cctvDriveBcSource.test.mjs @@ -221,7 +221,7 @@ test('CCTV catalog merges DriveBC cameras and CCTV_DRIVEBC_ENABLED=0 skips the r ); }); -test('default per-pack camera caps fit inside the default catalog cap', () => { +test('default caps of the original packs fit inside the default catalog cap', () => { // Every default pack at its own cap (Warendorf ships 1 curated camera). const packs = DEFAULT_AUSTIN_MAX_SOURCES + diff --git a/src/data/cctvWsdotSource.test.mjs b/src/data/cctvWsdotSource.test.mjs index 438df7e58..6b2ad3398 100644 --- a/src/data/cctvWsdotSource.test.mjs +++ b/src/data/cctvWsdotSource.test.mjs @@ -1,8 +1,13 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createCctvCatalog } from '../../server/providers/cctv/catalog.js'; import { DEFAULT_WSDOT_MAX_SOURCES, WSDOT_CAMERAS_URL, + WSDOT_MAX_CATALOG_BYTES, } from '../../server/providers/cctv/constants.js'; import { loadWsdotSourcesFromOpenData, @@ -89,7 +94,7 @@ test('WSDOT loader keeps official-host cameras and maps compass directions', asy }, }), wsdotFeature(9059, 47.66, -117.43, { - attributes: { CompassDirection: 'BW' }, + attributes: { CompassDirection: 'B' }, }), // Partner-hosted frame (Oregon DOT): skipped, never proxied. wsdotFeature(1002, 45.62, -122.675, { @@ -127,7 +132,7 @@ test('WSDOT loader keeps official-host cameras and maps compass directions', asy assert.ok(Math.abs(seattle.lat - 47.6097) < 0.001); assert.ok(Math.abs(seattle.lon - -122.3331) < 0.001); - // `BW` (Bothways) carries no single facing: id-hash fallback at low + // `B` (both directions) carries no single facing: id-hash fallback at low // confidence, with the low-confidence pose personality. const spokane = cameras.find((camera) => camera.id === 'wsdot-9059'); assert.equal(spokane.headingConfidence, 'low'); @@ -135,7 +140,7 @@ test('WSDOT loader keeps official-host cameras and maps compass directions', asy assert.ok(spokane.headingDeg >= 0 && spokane.headingDeg < 360); }); -test('WSDOT loader maps every documented CompassDirection code', async (t) => { +test('WSDOT loader maps every CompassDirection code', async (t) => { quiet(t); withEnv(t, { CCTV_WSDOT_MAX_SOURCES: undefined }); const codes = { @@ -150,7 +155,8 @@ test('WSDOT loader maps every documented CompassDirection code', async (t) => { // Lower-case and padded codes are the same code. ' nw ': 315, }; - const noFacing = ['BW', 'O', null]; + // The live feed's no-facing codes, plus an unknown one. + const noFacing = ['B', 'O', null, 'BW']; const entries = [...Object.keys(codes), ...noFacing]; t.mock.method(globalThis, 'fetch', async () => cp1252Response({ @@ -207,3 +213,90 @@ test('WSDOT loader defaults its cap and survives upstream failure', async (t) => }); assert.deepEqual(await loadWsdotSourcesFromOpenData(), []); }); + +test('the catalog fetch refuses a redirect and cancels its body', async (t) => { + quiet(t); + // A redirect is never followed, and its still-open body is cancelled. + const seen = []; + const state = { cancelled: false }; + t.mock.method(globalThis, 'fetch', async (url, init) => { + seen.push([String(url), init.redirect]); + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('{')); + }, + cancel() { + state.cancelled = true; + }, + }); + return new Response(body, { + status: 302, + headers: { location: 'https://evil.example/Cameras.json' }, + }); + }); + assert.deepEqual(await loadWsdotSourcesFromOpenData(), []); + assert.deepEqual(seen, [[WSDOT_CAMERAS_URL, 'manual']]); + assert.equal(state.cancelled, true, 'the redirect body is cancelled'); +}); + +test('the catalog fetch refuses a body over the byte cap', async (t) => { + quiet(t); + // A valid catalog padded past the cap is refused rather than buffered. + t.mock.method(globalThis, 'fetch', async () => { + const text = JSON.stringify({ + features: [wsdotFeature(1001, 47.6097, -122.3331)], + }); + const padded = text + ' '.repeat(WSDOT_MAX_CATALOG_BYTES); + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(padded)); + controller.close(); + }, + }), + { headers: { 'Content-Type': 'application/json' } }, + ); + }); + assert.deepEqual(await loadWsdotSourcesFromOpenData(), []); +}); + +test('CCTV_WSDOT_ENABLED=0 skips the WSDOT request', async (t) => { + quiet(t); + const sourceRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'gev-cctv-catalog-'), + ); + t.after(() => fs.rmSync(sourceRoot, { recursive: true, force: true })); + withEnv(t, { + CCTV_SOURCES_FILE: undefined, + CCTV_SOURCES_JSON: undefined, + CCTV_FORCE_AUSTIN: undefined, + CCTV_PREFER_AUSTIN: undefined, + CCTV_MAX_SOURCES: undefined, + CCTV_WSDOT_ENABLED: undefined, + CCTV_WSDOT_MAX_SOURCES: undefined, + }); + const requested = []; + t.mock.method(globalThis, 'fetch', async (url) => { + requested.push(String(url)); + // Every other live pack is down; only WSDOT answers. + return String(url) === WSDOT_CAMERAS_URL + ? cp1252Response({ features: [wsdotFeature(1001, 47.6097, -122.3331)] }) + : new Response('unavailable', { status: 503 }); + }); + + const sources = await createCctvCatalog({ sourceRoot })(); + assert.deepEqual( + sources.map((source) => source.id), + ['wsdot-1001'], + ); + + process.env.CCTV_WSDOT_ENABLED = '0'; + requested.length = 0; + assert.deepEqual(await createCctvCatalog({ sourceRoot })(), []); + assert.ok(requested.length > 0, 'the other live packs still load'); + assert.equal( + requested.includes(WSDOT_CAMERAS_URL), + false, + 'a disabled pack makes no request', + ); +});