diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index c43ae7728..8e806208d 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`. -- **King County road cameras.** Keyless. The camera list is one public ArcGIS FeatureServer GeoJSON query (King County DOT Road Services, "King County and WSDOT Traffic Cameras"). The `outFields` list is explicit on purpose: the layer also carries asset-administration columns — including staff names — that are never fetched. Only live, active cameras with frames on the county's own `info.kingcounty.gov` image handler are registered (the feed writes `http://`; the handler serves HTTPS, so URLs are upgraded and pinned before registration). WSDOT-owned rows in the layer are skipped so a camera never appears twice when a statewide WSDOT pack is present; the few city-owned cameras the county hosts (Redmond, Sammamish, Newcastle, Snoqualmie) carry their owner as the per-camera credit. The layer publishes no facing, so headings use the shared id-hash fallback at low confidence. Uniquely, the county publishes each camera's hardware `Manufacturer`/`Model` (Cohu 3950/3965, Bosch AUTODOME, Axis PTZ domes); the model string is carried on the source as `model` for identified-hardware consumers. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_KINGCOUNTY_ENABLED=0`. +- **King County road cameras.** Keyless. The camera list is one public ArcGIS FeatureServer GeoJSON query (King County DOT Road Services, "King County and WSDOT Traffic Cameras"). The `outFields` list is explicit on purpose: the layer also carries asset-administration columns — including staff names — that are never fetched. Only live, active cameras with frames on the county's own `info.kingcounty.gov` image handler are registered (the feed writes `http://`; the handler serves HTTPS, so URLs are upgraded and pinned before registration). WSDOT-owned rows in the layer are skipped so a camera never appears twice when a statewide WSDOT pack is present; the few city-owned cameras the county hosts (Redmond, Sammamish, Newcastle, Snoqualmie) carry their owner as the per-camera credit. The layer publishes no facing, so headings use the shared id-hash fallback at low confidence. Uniquely, the county publishes each camera's hardware `Manufacturer`/`Model` (Cohu 3950/3965, Bosch AUTODOME, Axis PTZ domes); both are carried on the source (and served by `/api/cctv/sources`) as `model` and `manufacturer` for identified-hardware consumers. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_KINGCOUNTY_ENABLED=0`. - **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.js b/server/providers/cctv.js index a71e2af4d..5fb15be03 100644 --- a/server/providers/cctv.js +++ b/server/providers/cctv.js @@ -162,6 +162,8 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) { sourceKind: source.sourceKind || (source.url ? 'configured' : 'fallback'), poseSource: source.poseSource, + model: source.model || '', + manufacturer: source.manufacturer || '', license: source.license, credit: source.credit || '', code: source.code || '', diff --git a/server/providers/cctv/normalize.js b/server/providers/cctv/normalize.js index 17903335d..c4020da34 100644 --- a/server/providers/cctv/normalize.js +++ b/server/providers/cctv/normalize.js @@ -524,5 +524,11 @@ export function normalizeSourceItem(item) { typeof item.model === 'string' && item.model.trim() ? item.model.trim() : undefined, + // Bare model strings collide across vendors (e.g. "3950"), so the + // publishing pack's manufacturer rides along for disambiguation. + manufacturer: + typeof item.manufacturer === 'string' && item.manufacturer.trim() + ? item.manufacturer.trim() + : undefined, }; } diff --git a/server/providers/cctv/sources.js b/server/providers/cctv/sources.js index e92a8a265..3996f37f1 100644 --- a/server/providers/cctv/sources.js +++ b/server/providers/cctv/sources.js @@ -1776,6 +1776,9 @@ export async function loadKingCountySourcesFromOpenData() { const model = String(attrs.Model || '') .replace(/\s+/g, ' ') .trim(); + const manufacturer = String(attrs.Manufacturer || '') + .replace(/\s+/g, ' ') + .trim(); const camera = { id: cameraId, name: @@ -1800,6 +1803,7 @@ export async function loadKingCountySourcesFromOpenData() { credit: owner && owner !== 'King County' ? owner : '', }; if (model) camera.model = model; + if (model && manufacturer) camera.manufacturer = manufacturer; cameras.push(camera); } diff --git a/src/data/cctvKingCountySource.test.mjs b/src/data/cctvKingCountySource.test.mjs index 544b4f9ac..a0f765446 100644 --- a/src/data/cctvKingCountySource.test.mjs +++ b/src/data/cctvKingCountySource.test.mjs @@ -1,11 +1,16 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; import { DEFAULT_KING_COUNTY_MAX_SOURCES, KING_COUNTY_CAMERAS_URL, } from '../../server/providers/cctv/constants.js'; import { loadKingCountySourcesFromOpenData } from '../../server/providers/cctv/sources.js'; import { normalizeSourceItem } from '../../server/providers/cctv/normalize.js'; +import { cctvProxy } from '../../server/providers/cctv.js'; +import { createCatalog } from '../layers/cctv/catalog.js'; /** Set (or, for `undefined`, delete) environment variables for one test. */ function withEnv(t, env) { @@ -106,6 +111,7 @@ test('King County loader keeps live county cameras and upgrades frame URLs to HT assert.equal(novelty.feedType, 'image'); // Published hardware model rides along, whitespace-collapsed. assert.equal(novelty.model, 'Q6135-LE'); + assert.equal(novelty.manufacturer, 'Axis'); assert.equal(novelty.credit, ''); // No facing in the layer: id-hash fallback at low confidence. assert.equal(novelty.headingConfidence, 'low'); @@ -115,6 +121,8 @@ test('King County loader keeps live county cameras and upgrades frame URLs to HT const redmond = cameras.find((camera) => camera.id === 'kingcounty-90'); assert.equal(redmond.credit, 'City of Redmond'); assert.equal(redmond.model, undefined); + // A manufacturer without a model identifies nothing, so it is dropped too. + assert.equal(redmond.manufacturer, undefined); }); test('normalizeSourceItem passes a hardware model through and omits it otherwise', () => { @@ -123,8 +131,10 @@ test('normalizeSourceItem passes a hardware model through and omits it otherwise lat: 47.7, lon: -122.0, model: ' Q6135-LE ', + manufacturer: ' Axis ', }); assert.equal(withModel.model, 'Q6135-LE'); + assert.equal(withModel.manufacturer, 'Axis'); const withoutModel = normalizeSourceItem({ id: 'austin-1', @@ -132,9 +142,105 @@ test('normalizeSourceItem passes a hardware model through and omits it otherwise lon: -97.7, }); assert.equal(withoutModel.model, undefined); + assert.equal(withoutModel.manufacturer, undefined); assert.equal(normalizeSourceItem({ id: 'x', model: ' ' }).model, undefined); }); +/** Drive the real `/api/cctv/sources` middleware and return its parsed body. */ +async function requestSources(plugin) { + let handler; + plugin.configureServer({ + middlewares: { + use(_route, fn) { + handler = fn; + }, + }, + }); + const res = { + writeHead(status, headers) { + Object.assign(this, { status, headers }); + }, + end(body) { + this.body = body; + }, + }; + await handler({ url: '/sources', method: 'GET' }, res); + assert.equal(res.status, 200); + return JSON.parse(res.body); +} + +/** The browser catalog with the pose math stubbed out: only field + * passthrough is under test here. */ +function browserCatalog() { + const model = { + safeNumber: (value, fallback = NaN) => + Number.isFinite(Number(value)) ? Number(value) : fallback, + normalizeHeading: (deg) => ((deg % 360) + 360) % 360, + clamp: (value, lo, hi) => Math.min(hi, Math.max(lo, value)), + headingFromId: () => 0, + normalizeFeedType: (type) => type, + ensureCameraPose: () => {}, + }; + return createCatalog({ + state: {}, + services: { locations: { CITY_POIS: {} } }, + parts: { model }, + source: {}, + }); +} + +test('King County model and manufacturer survive the /api/cctv/sources boundary into the browser catalog', async (t) => { + quiet(t); + const root = mkdtempSync(path.join(tmpdir(), 'gev-kingcounty-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + // No file/env catalog, so the live packs load; every pack but King County + // sees an upstream failure and contributes nothing. + withEnv(t, { + CCTV_SOURCES_FILE: undefined, + CCTV_SOURCES_JSON: undefined, + CCTV_FORCE_AUSTIN: undefined, + CCTV_PREFER_AUSTIN: undefined, + CCTV_KINGCOUNTY_ENABLED: undefined, + CCTV_KINGCOUNTY_MAX_SOURCES: undefined, + CCTV_MAX_SOURCES: undefined, + }); + t.mock.method(globalThis, 'fetch', async (url) => { + if (String(url) !== KING_COUNTY_CAMERAS_URL) + return new Response('', { status: 503 }); + return Response.json({ + type: 'FeatureCollection', + features: [ + kingCountyFeature(56, 47.70059, -122.02443, { + properties: { Model: 'Q6135-LE\n', Manufacturer: 'Axis' }, + }), + kingCountyFeature(90, 47.674, -122.119, { + properties: { Model: null, Manufacturer: 'Cohu' }, + }), + ], + }); + }); + + const body = await requestSources(cctvProxy({ sourceRoot: root })); + const served = new Map(body.sources.map((source) => [source.id, source])); + assert.deepEqual([...served.keys()].sort(), [ + 'kingcounty-56', + 'kingcounty-90', + ]); + assert.equal(served.get('kingcounty-56').model, 'Q6135-LE'); + assert.equal(served.get('kingcounty-56').manufacturer, 'Axis'); + // Absent hardware is an explicit empty string on the wire, like + // headingConfidence, so consumers never have to probe for the key. + assert.equal(served.get('kingcounty-90').model, ''); + assert.equal(served.get('kingcounty-90').manufacturer, ''); + + const cameras = browserCatalog().buildCatalogFromSources(body.sources); + const byId = new Map(cameras.map((camera) => [camera.id, camera])); + assert.equal(byId.get('kingcounty-56').model, 'Q6135-LE'); + assert.equal(byId.get('kingcounty-56').manufacturer, 'Axis'); + assert.equal(byId.get('kingcounty-90').model, ''); + assert.equal(byId.get('kingcounty-90').manufacturer, ''); +}); + test('King County loader honors the max-sources cap', async (t) => { quiet(t); withEnv(t, { CCTV_KINGCOUNTY_MAX_SOURCES: '8' }); diff --git a/src/layers/cctv/catalog.js b/src/layers/cctv/catalog.js index 1a6ebe7c6..76d530b11 100644 --- a/src/layers/cctv/catalog.js +++ b/src/layers/cctv/catalog.js @@ -204,6 +204,8 @@ export function createCatalog({ state: layerState, services, parts, source }) { ? source.groundHeights : null, poseSource, + model: String(source.model || ''), + manufacturer: String(source.manufacturer || ''), }; parts.model.ensureCameraPose(camera); catalog.push(camera);