From 51b89d3e4a9bf5edcbea046e91ecaf28c5de67e8 Mon Sep 17 00:00:00 2001 From: Cliff Flood Date: Wed, 16 Sep 2026 18:56:08 -0400 Subject: [PATCH] feat(cctv): add the City of Toronto RESCU camera pack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The province's 511 pack covers Ontario's highways; this adds the municipal network underneath it — roughly 336 cameras across downtown arterials plus the Gardiner, DVP, Allen and Lake Shore, from the City's own open data. The list is keyless and carries no frame URL, so frame URLs are built from the convention the dataset documents (`CameraImages/loc####.jpg`) and a camera number validated as plain digits. The frame proxy is therefore pinned to the portal origin by construction rather than by trusting a field. The list fetch refuses redirects, bounds the body it buffers, and releases the transport on every rejection path. The dataset publishes no camera facing. `D1`-`D4` look like a compass and map trivially onto one, but they describe the *comparison* images — static reference stills captured in 2016 and served from a different path — not the live pan-tilt-zoom frame. A heading derived from them would be confidently wrong for every camera in the city, so every camera takes the shared id-hash fallback at low confidence, as headingless Austin, TfL, Fintraffic and Calgary cameras do. A test pins that down so it is not "fixed" later. Names stay verbatim: the feed's labels are upper-case, and title-casing would mangle MCCOWAN RD and ST HELEN'S AVE more often than it helped. Carries the Open Government Licence – Toronto attribution the licence requires. Disable with `CCTV_TORONTO_ENABLED=0`. --- CHANGELOG.md | 12 + DATA_SOURCES.md | 3 + README.md | 2 +- docs/CURRENT-STATE.md | 2 +- server/providers/cctv/catalog.js | 6 + server/providers/cctv/constants.js | 32 +++ server/providers/cctv/normalize.js | 11 + server/providers/cctv/sources.js | 181 +++++++++++- src/data/cctvToronto.test.mjs | 423 +++++++++++++++++++++++++++++ src/data/dataCredits.js | 6 + 10 files changed, 675 insertions(+), 3 deletions(-) create mode 100644 src/data/cctvToronto.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 032c6fe2a..bc81d10fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +- Add City of Toronto RESCU traffic cameras as a keyless CCTV source pack: the + ~336 municipal cameras the Ontario 511 pack does not carry — downtown + arterials plus the Gardiner, DVP, Allen and Lake Shore. The list is the + City's own open data feed; frames are built from the documented + `loc####.jpg` convention and a digits-only camera number, so the frame proxy + is pinned to the portal origin by construction. The dataset publishes no + camera facing — its `D1`-`D4` fields describe static 2016 comparison images, + not the live pan-tilt-zoom frame — so headings use the shared id-hash + fallback at low confidence and are corrected with the calibration gizmo. + Carries the required Open Government Licence – Toronto attribution. Disable + with `CCTV_TORONTO_ENABLED=0`. + - GBFS v3 station names use English translations when available, then the first usable translation or short name. Empty or malformed names no longer appear as `[object Object]` or interrupt station loading; legacy string names remain diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 040e53afc..47d517763 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -57,6 +57,7 @@ How to read this: | **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" | | **Statens vegvesen** (catalog `ogckart-sn1.atlas.vegvesen.no`, frames `kamera.atlas.vegvesen.no`, HLS `kamera.vegvesen.no`) | CCTV camera catalog + frames + live HLS video, Norway road cameras | [NLOD 2.0](https://data.norge.no/nlod/en/2.0) — attribution REQUIRED | "Contains data under the Norwegian licence for Open Government data (NLOD) distributed by Statens vegvesen" | +| **City of Toronto Open Data** (`opendata.toronto.ca`) | CCTV camera catalog + frames, Toronto city streets | [Open Government Licence – Toronto](https://www.toronto.ca/city-government/data-research-maps/open-data/open-data-licence/) — attribution REQUIRED | "Contains information licensed under the Open Government Licence – Toronto" | | **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) | @@ -92,6 +93,8 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **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`. - **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. +- **City of Toronto RESCU cameras.** Keyless. The camera list is the City's public `tmcearthcameras.json` on `opendata.toronto.ca` (dataset "Traffic Cameras", Transportation Services), served as JSONP and unwrapped with an anchored pattern. This is the municipal network the Ontario 511 pack does not carry: downtown arterials plus the Gardiner, DVP, Allen and Lake Shore. The list publishes no frame URL, so frame URLs are BUILT from the documented `CameraImages/loc####.jpg` convention and a strictly-digits camera number, pinning the frame proxy to the portal origin by construction; the list fetch refuses redirects and bounds the body it buffers. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `D1`-`D4` fields are the directions of the *comparison* images (static reference stills captured in 2016 under a different path), not a bearing for the live pan-tilt-zoom frame, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. A camera that is temporarily out of service still answers 200 with a byte-identical "Image Unavailable" placeholder rather than an error (about 6% of the network at any moment), so such a camera reads as healthy; the frame it shows is the agency's, not a synthesized one. The licence requires the attribution statement above. Disable with `CCTV_TORONTO_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/README.md b/README.md index 9ce8d1ff0..938c7f8a2 100644 --- a/README.md +++ b/README.md @@ -300,7 +300,7 @@ Twenty layers and map sources. **Seventeen have a keyless path.** Some offer add | 🛰️ **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,900 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 · Norway (Statens vegvesen). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | +| 📹 **CCTV Mesh** | 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 · Norway (Statens vegvesen) · Toronto. 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) | 🟢 | | 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer and **FOLLOW**. Mapillary and Google Street View; Panoramax and KartaView planned | Mapillary · Google | 🟡 Mapillary token · 🔴 Google Maps key | | 📻 **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 | 🟢 | diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 0d3ef31e0..eb674d95c 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3071,7 +3071,7 @@ its criteria cannot be silently ignored. | 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 | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache | -| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) | +| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS) + City of Toronto RESCU + 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 | diff --git a/server/providers/cctv/catalog.js b/server/providers/cctv/catalog.js index 9bc3b70ec..e53acba80 100644 --- a/server/providers/cctv/catalog.js +++ b/server/providers/cctv/catalog.js @@ -19,6 +19,7 @@ import { loadCalgarySourcesFromOpenData, loadDelDOTSourcesFromOpenData, loadVegvesenSourcesFromOpenData, + loadTorontoSourcesFromOpenData, } from './sources.js'; /** Env kill switch: unset or anything but "0" means enabled. */ @@ -98,6 +99,11 @@ const LIVE_PACKS = [ enabled: () => envEnabled('CCTV_VEGVESEN_ENABLED'), load: loadVegvesenSourcesFromOpenData, }, + { + name: 'toronto', + enabled: () => envEnabled('CCTV_TORONTO_ENABLED'), + load: loadTorontoSourcesFromOpenData, + }, ]; /** * 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 6298e86a0..6f896be59 100644 --- a/server/providers/cctv/constants.js +++ b/server/providers/cctv/constants.js @@ -275,6 +275,38 @@ export const DELDOT_ANCHORS = [ { lat: 39.1582, lon: -75.5244 }, // Dover (Kent) { lat: 38.6906, lon: -75.3877 }, // Georgetown (Sussex) ]; +/** + * City of Toronto RESCU traffic cameras: the municipal network the province's + * 511 pack does not cover — downtown arterials plus the Gardiner, DVP, Allen + * and Lake Shore. One keyless list for the whole city (~336 cameras), from the + * City's open data portal, refreshed within two minutes of any change. + * + * The list is served as JSONP (`jsonTMCEarthCamerasCallback({...})`); the + * portal publishes the same data as .xml and .csv beside it. + */ +export const TORONTO_RESCU_LIST_URL = + 'https://opendata.toronto.ca/transportation/tmc/rescucameraimages/Data/tmcearthcameras.json'; +/** + * The only origin Toronto camera frames may come from. The list carries no URL + * field: frame URLs are BUILT from this origin and a strictly-digits camera + * number (`loc####.jpg`, the convention the dataset documents), so no upstream + * field can steer the frame proxy off-host. + */ +export const TORONTO_RESCU_IMAGE_ORIGIN = + 'https://opendata.toronto.ca/transportation/tmc/rescucameraimages/CameraImages/'; +/** The whole city fits under this; the cap exists to stay tunable, not to trim. */ +export const DEFAULT_TORONTO_MAX_SOURCES = 336; +/** Yonge / Queen: the prioritization anchor. */ +export const TORONTO_DOWNTOWN = { lat: 43.6532, lon: -79.3832 }; +/** Toronto's lakeshore core sits near 76 m; the ravines and the north of the + * city run higher. This prior only has to be close — the client's one-shot + * ground snap corrects it wherever 3D tiles are loaded. */ +export const TORONTO_GROUND_ELEVATION_M = 90; +/** Hard ceiling on the RESCU list body. The whole city is ~336 rows and under + * 60 KB; this only exists so an upstream that streams an unbounded body cannot + * be buffered without limit. */ +export const TORONTO_MAX_CATALOG_BYTES = 4 * 1024 * 1024; + /** 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/normalize.js b/server/providers/cctv/normalize.js index e62ba33f3..be913bc76 100644 --- a/server/providers/cctv/normalize.js +++ b/server/providers/cctv/normalize.js @@ -553,3 +553,14 @@ export function normalizeSourceItem(item) { poseSource: item.poseSource === 'curated' ? 'curated' : undefined, }; } + +/** City of Toronto bounding box (Steeles to the lake, Etobicoke to Scarborough). */ +export function isLikelyTorontoCoordinate(lat, lon) { + return ( + isPlausibleLatLon(lat, lon) && + lat >= 43.55 && + lat <= 43.9 && + lon >= -79.7 && + lon <= -79.1 + ); +} diff --git a/server/providers/cctv/sources.js b/server/providers/cctv/sources.js index 7ecd3434d..e03701de9 100644 --- a/server/providers/cctv/sources.js +++ b/server/providers/cctv/sources.js @@ -55,6 +55,12 @@ import { DEFAULT_CALGARY_MAX_SOURCES, CALGARY_DOWNTOWN, CALGARY_MAX_CATALOG_BYTES, + TORONTO_RESCU_LIST_URL, + TORONTO_RESCU_IMAGE_ORIGIN, + DEFAULT_TORONTO_MAX_SOURCES, + TORONTO_DOWNTOWN, + TORONTO_GROUND_ELEVATION_M, + TORONTO_MAX_CATALOG_BYTES, CCTV_SOURCE_FETCH_TIMEOUT_MS, DELDOT_CCTV_URL, DEFAULT_DELDOT_MAX_SOURCES, @@ -83,12 +89,16 @@ import { isLikelyNswCoordinate, isLikelyCalgaryCoordinate, isLikelyNorwayCoordinate, + isLikelyTorontoCoordinate, cameraDisplayCode, rowArrayToObject, prioritizeSources, } from './normalize.js'; import { directionToHeading } from '../../../src/data/directionText.js'; -import { readResponseJsonCapped } from '../common/http.js'; +import { + readResponseJsonCapped, + readResponseTextCapped, +} from '../common/http.js'; /** * Fetch and parse Austin traffic camera records from the city Open Data portal. * @@ -1863,3 +1873,172 @@ export async function loadVegvesenSourcesFromOpenData() { return []; } } + +/** + * The JSONP envelope the City of Toronto wraps its camera list in. Anchored at + * both ends, so only that exact callback is unwrapped — a payload that merely + * contains the name somewhere is not treated as the list. + */ +const RESCU_JSONP = /^jsonTMCEarthCamerasCallback\(([\s\S]*)\)\s*;?$/; + +/** + * Unwrap the RESCU camera list. The portal serves the list as JSONP; should it + * ever drop the wrapper, the bare JSON document still parses. Anything else — + * a foreign callback, a truncated body, an error page — yields no cameras + * rather than a thrown loader. + * + * @param {string} text - Raw response body. + * @returns {Array} The `Data` rows, or [] when the body is unusable. + */ +export function parseRescuCameraList(text) { + const body = String(text ?? '').trim(); + if (!body) return []; + const unwrapped = RESCU_JSONP.exec(body)?.[1] ?? body; + try { + const parsed = JSON.parse(unwrapped); + const rows = parsed?.Data ?? parsed?.data; + return Array.isArray(rows) ? rows : []; + } catch { + return []; + } +} + +/** + * Convert one RESCU list row into a camera source. + * + * The row carries no URL: the frame URL is built from the documented + * `loc####.jpg` convention and a camera number validated as plain digits, so + * the frame proxy is pinned to the city origin by construction and no upstream + * field — present or planted — can steer it. + * + * No compass heading exists in this dataset. `D1`-`D4` are the directions of + * the *comparison* images (static reference stills captured in 2016, served + * from a different path); the live frame is one pan-tilt-zoom camera pointing + * wherever the operator left it. Treating those letters as a facing would put + * a confident, wrong bearing on every camera in the city, so every row takes + * the id-hash fallback and the low-confidence pose, as headingless Austin, + * TfL, Fintraffic and Calgary cameras do. + * + * @param {object} row - One entry from the RESCU `Data` array. + * @returns {object|null} Normalized camera source, or null when unusable. + */ +export function torontoCameraToSource(row) { + if (!row || typeof row !== 'object') return null; + const number = String(row.Number ?? row.number ?? '').trim(); + // Plain digits only: a number is about to become a URL path segment. + if (!/^\d{1,6}$/.test(number)) return null; + + const lat = toFiniteNumber(row.Latitude ?? row.latitude); + const lon = toFiniteNumber(row.Longitude ?? row.longitude); + if (!isLikelyTorontoCoordinate(lat, lon)) return null; + + const cameraId = `toronto-${number}`; + // The feed's own label, verbatim. It is upper-case ("MCCOWAN RD", "ST + // HELEN'S AVE"); title-casing it would mangle more names than it fixed. + const name = + String(row.Name ?? row.name ?? '') + .replace(/\s+/g, ' ') + .trim() || `Toronto Camera ${number}`; + + return { + id: cameraId, + name, + city: 'Toronto', + cityId: 'toronto', + provider: 'City of Toronto', + lat, + lon, + headingDeg: fallbackHeadingFromId(cameraId), + headingConfidence: 'low', + pitchDeg: -18, + fovDeg: 44, + rangeM: 145, + mountHeightM: 8, + groundElevationM: TORONTO_GROUND_ELEVATION_M, + feedType: 'image', + url: `${TORONTO_RESCU_IMAGE_ORIGIN}loc${number}.jpg`, + snapshotUrl: `${TORONTO_RESCU_IMAGE_ORIGIN}loc${number}.jpg`, + sourceKind: 'toronto-rescu-open-data', + license: + 'Contains information licensed under the Open Government Licence – Toronto', + // Unselected-label code: the intersection, so a camera at rest reads as a + // place rather than as its id. + code: cameraDisplayCode(name.toUpperCase()), + }; +} + +/** + * Fetch City of Toronto RESCU traffic cameras from the City's open data + * portal, keyless. One JSONP list for the whole city; frames are stills on the + * same portal origin. + * + * The list fetch refuses redirects (`redirect: 'manual'`) so the list host + * cannot be steered, and caps the body it will buffer. + * + * Attribution: Open Government Licence – Toronto, registered in + * src/data/dataCredits.js. + * + * @returns {Promise>} Normalized camera source objects. + */ +export async function loadTorontoSourcesFromOpenData() { + try { + const endpoint = + process.env.CCTV_TORONTO_LIST_URL || TORONTO_RESCU_LIST_URL; + const resp = await fetch(endpoint, { + headers: { Accept: 'application/json, text/javascript' }, + redirect: 'manual', + signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS), + }); + // A response this loader will not read still owns its transport until the + // body is released, so every rejection path cancels before returning. + const discard = async () => { + try { + await resp.body?.cancel(); + } catch { + /* no-op */ + } + return []; + }; + if (resp.status >= 300 && resp.status < 400) { + console.warn( + '[CCTV] Toronto camera list redirected; redirects are not followed', + ); + return discard(); + } + if (!resp.ok) { + console.warn('[CCTV] Toronto camera download failed:', resp.status); + return discard(); + } + const text = await readResponseTextCapped(resp, TORONTO_MAX_CATALOG_BYTES); + const rows = parseRescuCameraList(text); + if (!rows.length) return []; + + const cameras = []; + const seen = new Set(); + for (const record of rows) { + const camera = torontoCameraToSource(record); + if (!camera || seen.has(camera.id)) continue; + seen.add(camera.id); + cameras.push(camera); + } + const maxRaw = Number( + process.env.CCTV_TORONTO_MAX_SOURCES || DEFAULT_TORONTO_MAX_SOURCES, + ); + const maxCount = Number.isFinite(maxRaw) + ? Math.max(8, Math.min(500, Math.floor(maxRaw))) + : DEFAULT_TORONTO_MAX_SOURCES; + const prioritized = prioritizeSources(cameras, maxCount, [ + TORONTO_DOWNTOWN, + ]); + console.log( + `[CCTV] Loaded Toronto camera sources: ${cameras.length} (using nearest ${prioritized.length})`, + ); + return prioritized; + } catch (error) { + console.warn( + '[CCTV] Toronto camera download error:', + error?.message || error, + ); + return []; + } +} diff --git a/src/data/cctvToronto.test.mjs b/src/data/cctvToronto.test.mjs new file mode 100644 index 000000000..c2e3c0e1d --- /dev/null +++ b/src/data/cctvToronto.test.mjs @@ -0,0 +1,423 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + parseRescuCameraList, + torontoCameraToSource, + loadTorontoSourcesFromOpenData, +} from '../../server/providers/cctv/sources.js'; +import { + TORONTO_RESCU_LIST_URL, + TORONTO_RESCU_IMAGE_ORIGIN, + TORONTO_MAX_CATALOG_BYTES, + 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'; +import { createCctvCatalog } from '../../server/providers/cctv/catalog.js'; + +/** + * A response whose body is a live stream, plus a flag that flips when the + * stream is cancelled. A rejection path that returns without cancelling holds + * the transport open, so the flag is what the refusal tests actually assert. + */ +const streamingResponse = (init = {}) => { + const state = { cancelled: false }; + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('j')); + }, + cancel() { + state.cancelled = true; + }, + }); + return { response: new Response(body, init), state }; +}; + +/** One RESCU row, shaped like the live `tmcearthcameras.json` payload. */ +const row = (overrides = {}) => ({ + Number: '8048', + Name: 'BATHURST ST & BLOOR ST W', + Latitude: '43.665249', + Longitude: '-79.41114', + D1: 'n', + D2: 'e', + D3: 's', + D4: 'w', + Group: 'Arterial', + ...overrides, +}); + +/** The city wraps the list in a JSONP callback; this is that envelope. */ +const jsonp = (rows) => + `jsonTMCEarthCamerasCallback(${JSON.stringify({ Data: rows })});`; + +test('a RESCU row maps to a source on the constructed frame URL', () => { + const source = torontoCameraToSource(row()); + assert.equal(source.id, 'toronto-8048'); + assert.equal(source.name, 'BATHURST ST & BLOOR ST W'); + assert.equal(source.city, 'Toronto'); + assert.equal(source.cityId, 'toronto'); + assert.equal(source.provider, 'City of Toronto'); + assert.equal(source.lat, 43.665249); + assert.equal(source.lon, -79.41114); + assert.equal(source.feedType, 'image'); + assert.equal(source.sourceKind, 'toronto-rescu-open-data'); + assert.equal( + source.license, + 'Contains information licensed under the Open Government Licence – Toronto', + ); + assert.equal( + source.url, + `${TORONTO_RESCU_IMAGE_ORIGIN}loc8048.jpg`, + 'the documented loc####.jpg convention', + ); + assert.equal(source.url, source.snapshotUrl); + assert.ok(source.url.startsWith(TORONTO_RESCU_IMAGE_ORIGIN)); +}); + +test('the frame URL is built from the camera number, never read from the payload', () => { + // The list carries no URL field at all, so an added one must stay inert: the + // frame proxy is pinned to the city origin by construction. + const planted = torontoCameraToSource( + row({ + url: 'https://evil.example/x.jpg', + snapshotUrl: 'https://evil.example/x.jpg', + Url: 'https://evil.example/x.jpg', + }), + ); + assert.equal(planted.url, `${TORONTO_RESCU_IMAGE_ORIGIN}loc8048.jpg`); + assert.equal(planted.snapshotUrl, planted.url); + + // A number that is not plain digits cannot reach the URL builder: path + // traversal and absolute URLs are refused outright rather than escaped. + for (const Number_ of [ + '../../etc/passwd', + '8048/../../x', + 'https://evil.example/1', + '80 48', + '8048.jpg', + '-8048', + '', + ' ', + null, + ]) { + assert.equal( + torontoCameraToSource(row({ Number: Number_ })), + null, + `refused: ${String(Number_)}`, + ); + } +}); + +test('the comparison directions never become a camera heading', () => { + // D1-D4 are the city's *comparison image* directions (static 2016 reference + // stills served from ComparisonImages/), not a facing for the live frame: + // these cameras pan. The letters read like a compass and map trivially onto + // one (n -> 0, w -> 270), so the temptation to wire them up is real — and a + // heading derived from them would be confidently wrong for every camera in + // the city. Nobody may "fix" this. + const base = torontoCameraToSource(row()); + for (const dirs of [ + { D1: 'n', D2: 'e', D3: 's', D4: 'w' }, + { D1: '', D2: 'e', D3: '', D4: 'w' }, + { D1: '', D2: '', D3: '', D4: '' }, + { D1: 's', D2: '', D3: '', D4: '' }, + ]) { + const source = torontoCameraToSource(row(dirs)); + assert.equal(source.headingConfidence, 'low'); + assert.ok(Number.isFinite(source.headingDeg)); + // Same id, same fallback heading, whatever the directions say. + assert.equal(source.headingDeg, base.headingDeg); + } +}); + +test('the two cameras at one intersection are kept apart', () => { + // 8047 and 8048 are both "BATHURST ST & BLOOR ST W", five metres apart. They + // are distinct units, so both survive, and the id-hash fallback fans their + // gizmos apart instead of stacking them on one bearing. + const a = torontoCameraToSource( + row({ Number: '8047', Latitude: '43.665223', Longitude: '-79.411083' }), + ); + const b = torontoCameraToSource(row()); + assert.equal(a.id, 'toronto-8047'); + assert.equal(b.id, 'toronto-8048'); + assert.notEqual(a.url, b.url); + assert.notEqual(a.headingDeg, b.headingDeg); +}); + +test('rows outside Toronto and unusable geometry are dropped', () => { + // Calgary: a plausible lat/lon, but not a Toronto camera. + assert.equal( + torontoCameraToSource(row({ Latitude: '51.0453', Longitude: '-114.1413' })), + null, + ); + // Null island, and the empty strings that would become it. + assert.equal( + torontoCameraToSource(row({ Latitude: '0', Longitude: '0' })), + null, + ); + assert.equal( + torontoCameraToSource(row({ Latitude: '', Longitude: '' })), + null, + ); + assert.equal( + torontoCameraToSource(row({ Latitude: 'north', Longitude: 'west' })), + null, + ); + assert.equal(torontoCameraToSource({}), null); + assert.equal(torontoCameraToSource(null), null); +}); + +test('a nameless row still gets a label', () => { + const source = torontoCameraToSource(row({ Name: ' ' })); + assert.equal(source.name, 'Toronto Camera 8048'); +}); + +test('the JSONP envelope is unwrapped, and only that envelope', () => { + assert.deepEqual(parseRescuCameraList(jsonp([row()])), [row()]); + // Whitespace and a missing trailing semicolon are both tolerated. + assert.equal( + parseRescuCameraList(`\n ${jsonp([row()]).replace(/;$/, '')} \n`).length, + 1, + ); + // Should the city ever drop the wrapper, the bare document still parses. + assert.equal( + parseRescuCameraList(JSON.stringify({ Data: [row()] })).length, + 1, + ); + + // Anything else yields no cameras rather than a thrown loader. + for (const text of [ + 'otherCallback({"Data":[]})', + 'jsonTMCEarthCamerasCallback(', + 'jsonTMCEarthCamerasCallback({"Data":', + '{"Data":"not-an-array"}', + '{"nope":[]}', + '503', + '', + null, + ]) { + assert.deepEqual(parseRescuCameraList(text), [], `rejected: ${text}`); + } +}); + +test('the loader reads the keyless list and collapses duplicate numbers', async (t) => { + t.mock.method(console, 'log', () => {}); + const requested = []; + t.mock.method(globalThis, 'fetch', async (url) => { + requested.push(String(url)); + return new Response( + jsonp([ + row(), + // Same camera number twice: one camera, not two. + row({ Name: 'BATHURST ST & BLOOR ST W (duplicate)' }), + row({ + Number: '8001', + Name: 'YORK ST & BREMNER BLVD / RAPTORS WAY', + Latitude: '43.643120', + Longitude: '-79.381386', + }), + row({ Number: '../evil' }), + ]), + ); + }); + const cameras = await loadTorontoSourcesFromOpenData(); + assert.deepEqual(requested, [TORONTO_RESCU_LIST_URL]); + assert.deepEqual( + cameras.map((camera) => camera.id), + // Nearest downtown first: York & Bremner is at the core, Bathurst & Bloor + // is three kilometres out. + ['toronto-8001', 'toronto-8048'], + ); + assert.equal(cameras[1].name, 'BATHURST ST & BLOOR ST W'); +}); + +test('an upstream failure yields an empty pack and releases the response', async (t) => { + t.mock.method(console, 'warn', () => {}); + // A 503 can still arrive with a streaming body; returning without cancelling + // it would hold the connection until the socket times out. + const failed = streamingResponse({ status: 503 }); + t.mock.method(globalThis, 'fetch', async () => failed.response); + assert.deepEqual(await loadTorontoSourcesFromOpenData(), []); + assert.equal(failed.state.cancelled, true, 'the failed body is cancelled'); + + t.mock.restoreAll(); + t.mock.method(console, 'warn', () => {}); + t.mock.method(globalThis, 'fetch', async () => { + throw new Error('network down'); + }); + assert.deepEqual(await loadTorontoSourcesFromOpenData(), []); +}); + +test('the list fetch refuses redirects and oversized bodies', async (t) => { + t.mock.method(console, 'warn', () => {}); + // A redirect is never followed: the list host cannot be steered. Its body is + // a live stream, so the test also proves the refusal releases the transport. + const seen = []; + const redirected = streamingResponse({ + status: 302, + headers: { location: 'https://evil.example/cameras.json' }, + }); + t.mock.method(globalThis, 'fetch', async (url, init) => { + seen.push([String(url), init.redirect]); + return redirected.response; + }); + assert.deepEqual(await loadTorontoSourcesFromOpenData(), []); + assert.deepEqual(seen, [[TORONTO_RESCU_LIST_URL, 'manual']]); + assert.equal( + redirected.state.cancelled, + true, + 'the redirect body is cancelled', + ); + + // A body over the cap is refused rather than buffered. + t.mock.restoreAll(); + t.mock.method(console, 'warn', () => {}); + t.mock.method(globalThis, 'fetch', async () => { + return new Response(jsonp([row()]), { + headers: { + 'content-length': String(TORONTO_MAX_CATALOG_BYTES + 1), + }, + }); + }); + assert.deepEqual(await loadTorontoSourcesFromOpenData(), []); + + // So is a body that only declares its size once it is already too long. + t.mock.restoreAll(); + t.mock.method(console, 'warn', () => {}); + t.mock.method(globalThis, 'fetch', async () => { + const oversized = 'x'.repeat(TORONTO_MAX_CATALOG_BYTES + 1024); + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(oversized)); + controller.close(); + }, + }), + ); + }); + assert.deepEqual(await loadTorontoSourcesFromOpenData(), []); +}); + +test('the unselected label is the intersection, trimmed to the code width', () => { + assert.equal(torontoCameraToSource(row()).code, 'BATHURST ST & BLOOR ST W'); + const long = torontoCameraToSource( + row({ + Name: 'BAYVIEW AVE & POTTERY RD / PRIVATE ACCESS @ 620m SOUTH OF - 550 BAYVIEW AVE (EVERGREEN)', + }), + ); + assert.ok(long.code.length <= CAMERA_CODE_MAX_CHARS); + assert.ok(long.code.endsWith('…')); + assert.ok(long.code.startsWith('BAYVIEW AVE & POTTERY')); +}); + +test('a reduced catalog cap thins every pack instead of dropping Toronto', () => { + // Toronto merges last in LIVE_PACKS, so a positional slice would delete it + // outright. The round-robin allocation must give it its share. + const lane = (name, count) => ({ + name, + sources: Array.from({ length: count }, (_, i) => ({ id: `${name}-${i}` })), + }); + const { sources, packs } = allocateSourceCap( + [lane('austin', 250), lane('calgary', 215), lane('toronto', 336)], + 30, + ); + assert.equal(sources.length, 30); + assert.deepEqual( + packs.map((p) => [p.name, p.kept]), + [ + ['austin', 10], + ['calgary', 10], + ['toronto', 10], + ], + ); + // Toronto contributes its own highest-priority cameras, in its own order. + assert.deepEqual( + sources.filter((s) => s.id.startsWith('toronto-')).map((s) => s.id), + Array.from({ length: 10 }, (_, i) => `toronto-${i}`), + ); +}); + +/** + * Serve the RESCU list to the Toronto endpoint and an empty payload to every + * other pack, so one catalog refresh exercises the registration without + * reaching the network. Returns the URLs that were requested. + */ +const runCatalogWithMockedUpstreams = async (t) => { + const requested = []; + t.mock.method(console, 'log', () => {}); + t.mock.method(console, 'warn', () => {}); + t.mock.method(globalThis, 'fetch', async (url) => { + const href = String(url); + requested.push(href); + if (href === TORONTO_RESCU_LIST_URL) { + return new Response( + jsonp([ + row({ + Number: '8001', + Name: 'YORK ST & BREMNER BLVD / RAPTORS WAY', + Latitude: '43.643120', + Longitude: '-79.381386', + }), + row(), + ]), + ); + } + return Response.json([]); + }); + const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })(); + return { requested, sources }; +}; + +test('the pack is registered, so its cameras reach the served catalog', async (t) => { + const saved = { ...process.env }; + try { + delete process.env.CCTV_SOURCES_FILE; + delete process.env.CCTV_SOURCES_JSON; + delete process.env.CCTV_TORONTO_ENABLED; + const { requested, sources } = await runCatalogWithMockedUpstreams(t); + assert.ok( + requested.includes(TORONTO_RESCU_LIST_URL), + 'the registered lane reaches its upstream', + ); + assert.deepEqual( + sources.filter((s) => s.cityId === 'toronto').map((s) => s.id), + ['toronto-8001', 'toronto-8048'], + 'Toronto cameras reach the served catalog through the registered lane', + ); + } finally { + for (const key of Object.keys(process.env)) { + if (!(key in saved)) delete process.env[key]; + } + Object.assign(process.env, saved); + } +}); + +test('CCTV_TORONTO_ENABLED=0 keeps the lane from being loaded at all', async (t) => { + const saved = { ...process.env }; + try { + delete process.env.CCTV_SOURCES_FILE; + delete process.env.CCTV_SOURCES_JSON; + process.env.CCTV_TORONTO_ENABLED = '0'; + const { requested, sources } = await runCatalogWithMockedUpstreams(t); + assert.equal( + requested.includes(TORONTO_RESCU_LIST_URL), + false, + 'the disabled lane never reaches its upstream', + ); + assert.deepEqual( + sources.filter((s) => s.cityId === 'toronto'), + [], + ); + } finally { + for (const key of Object.keys(process.env)) { + if (!(key in saved)) delete process.env[key]; + } + 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/dataCredits.js b/src/data/dataCredits.js index 2967f0a2b..d6f2f3ad4 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -247,6 +247,12 @@ export const DATA_CREDITS = [ 'Norwegian licence for Open Government data (NLOD) ' + 'distributed by Statens vegvesen', }, + { + key: 'toronto-cctv', + html: + 'Traffic cameras (Toronto): contains information licensed under the ' + + 'Open Government Licence – Toronto', + }, { key: 'gbfs', html: 'Bikeshare availability: GBFS operator feeds (e.g. Austin BCycle)',