From 052435473e878deb59b91b4bb0a009474f6d6feb Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Wed, 14 Oct 2026 01:51:25 +0000 Subject: [PATCH] feat(cctv): add Houston TranStar camera pack Register the keyless TranStar snapshot catalog as a live CCTV source, synthesize frame URLs from validated numeric camera ids, and keep the nearest cameras to Houston's metro cores under the existing catalog ceiling. --- .env.example | 3 + CHANGELOG.md | 11 + DATA_SOURCES.md | 2 + README.md | 2 +- docs/CURRENT-STATE.md | 19 +- scripts/format-scope.json | 1 + server/providers/cctv/catalog.js | 6 + server/providers/cctv/constants.js | 31 +++ server/providers/cctv/normalize.js | 12 + server/providers/cctv/sources.js | 143 +++++++++++ src/data/cctvHouston.test.mjs | 379 +++++++++++++++++++++++++++++ src/data/dataCredits.js | 6 + 12 files changed, 613 insertions(+), 2 deletions(-) create mode 100644 src/data/cctvHouston.test.mjs diff --git a/.env.example b/.env.example index a2f8afa24..265377bf7 100644 --- a/.env.example +++ b/.env.example @@ -237,6 +237,9 @@ AISSTREAM_API_KEY= # CCTV_TORONTO_LIST_URL= # override the Toronto RESCU camera list URL # CCTV_LITHUANIA_ENABLED= # off by default; set 1 to load Via Lietuva / Eismoinfo road cameras (photo licence unconfirmed; see DATA_SOURCES.md) # CCTV_LITHUANIA_MAX_SOURCES=400 # cap Lithuanian cameras; no API key required +# CCTV_HOUSTON_ENABLED=1 # set 0 to disable the Houston TranStar cameras +# CCTV_HOUSTON_MAX_SOURCES=250 # cap cameras loaded from the Houston TranStar catalog (nearest metro cores first) +# CCTV_HOUSTON_CAMERAS_URL= # override the Houston TranStar catalog URL # 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/CHANGELOG.md b/CHANGELOG.md index 98d32365f..b4de32107 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +- Add Houston traffic cameras from Houston TranStar as a CCTV pack. The camera + list is one keyless JSON request to + `traffic.houstontranstar.org/data/layers/cctvSnapshots_json.js`; frames are + stills synthesized from each numeric camera id on `www.houstontranstar.org`, + so a catalog field cannot steer the proxy off-host. Cameras without a + snapshot id, and cameras outside Greater Houston, are dropped. By default + the 250 cameras nearest downtown, Uptown, the Medical Center, IAH and + Galveston load. `CCTV_HOUSTON_ENABLED=0` turns the pack off and + `CCTV_HOUSTON_MAX_SOURCES` changes the cap. The catalog-wide ceiling is + unchanged; extra cameras share it round-robin with the other packs. + - The tracked-aircraft descriptor now publishes `verticalRateMps`, on both the civil and military layers, so a consumer reading `getTrackedInfo()` no longer has to reach past it into the raw poll record for climb/descent. Reported, diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 0e04fb39b..913cad6d1 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -65,6 +65,7 @@ How to read this: | **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" | | **NYC DOT Traffic Management Center** (`webcams.nyctmc.org`) | CCTV camera catalog + frames, New York City five boroughs | NYC DOT requires developers to sign a [data-sharing agreement](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) for the camera feed. **Off by default**; enable only under your own agreement | "NYC DOT Traffic Management Center" + NYC DOT's required application disclaimer (in the Data attribution popover) | | **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" | +| **Houston TranStar** (`traffic.houstontranstar.org`, frames `www.houstontranstar.org`) | CCTV camera catalog + frames, Greater Houston | Public Houston TranStar traffic camera stills | "Houston TranStar" (courtesy) | | **GBFS (Lyft / BCycle / Social Bicycles / PBSC)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` | | **Bike Share Toronto** (`toronto.publicbikesystem.net`) | Bikeshare station availability, Toronto | Operator GBFS feed. No licence is published: the City's open-data package declares none and the feed has no `license_url`. Attribution is given to the operator. | "Bike Share Toronto (Toronto Parking Authority)" | | **Bike Itaú / Tembici** (`*.publicbikesystem.net`, seven Brazilian cities, Santiago and Bogotá) | Bikeshare station availability: São Paulo, Rio de Janeiro, Salvador, Recife, Porto Alegre, Brasília, Curitiba, Santiago de Chile and Bogotá | Operator GBFS feeds. No licence is published: the feeds' `system_information.json` carries no `license_url`, operator or attribution fields. Attribution is given to the operator. | "Bike Itaú (Tembici)"; "Tembici Bogotá" | @@ -166,6 +167,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **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. - **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`. +- **Houston TranStar cameras.** Keyless. The camera list is the public `traffic.houstontranstar.org/data/layers/cctvSnapshots_json.js` catalog (~1,000 cameras across Greater Houston); frames are stills at `www.houstontranstar.org/snapshots/cctv/.jpg`, synthesized from a validated numeric id rather than taken from the payload, and only that host is registered. Cameras whose catalog id is `0` (a different, non-snapshot class) and cameras outside Greater Houston are dropped. Dedicated `dir` values (North/South/East/West) become high-confidence headings; `None` uses the shared id-hash fallback at low confidence. By default the 250 cameras nearest downtown, Uptown, the Medical Center, IAH and Galveston load; `CCTV_HOUSTON_MAX_SOURCES` (8–400) changes the cap and `CCTV_HOUSTON_ENABLED=0` turns the pack off. Frames are fetched at request time and never stored or redistributed. - **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 sixteen 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. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Hamilton, Guelph, Burlington and YRT publish route ids that are not the number on the vehicle, so those entries carry a route-name table taken from each operator’s own published GTFS; any id missing from a table renders unchanged rather than blank. A label is never invented: YRT runs a route `9101` that appears nowhere in its published routes, so it stays as the feed sends it. Burlington’s is the one that cannot be shortcut: `351` is route 1 while `3510` is route 10, so the table is a real join rather than a trimmed prefix. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Barrie and YRT/Viva each invite acceptance of their licence through a form, and each licence also states that using the data is itself acceptance; this deployment relies on that clause, which is recorded in both entries. Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. Ottawa’s OC Transpo falls to the same keyless admission rule as GO Transit — its realtime feed requires an API key. London Transit and Thunder Bay Transit publish vehicle positions over plain HTTP with no HTTPS endpoint available. Every other Ontario system — Brampton, Oakville, Niagara, Milton, Windsor, Greater Sudbury, UP Express and a dozen smaller operators — publishes static GTFS schedules only, with no realtime vehicle positions to register. Between the nine included and these exclusions, every Ontario realtime vehicle-position feed catalogued in the Mobility Database is accounted for. - **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 c49c53ece..867f14824 100644 --- a/README.md +++ b/README.md @@ -377,7 +377,7 @@ Twenty layers and map sources. **Eighteen have a keyless path.** Some offer addi | 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 | | ◍ **Air quality** | Live AQHI on the published 1–10+ health-risk scale, with a Weather-panel card showing the band legend and the highest readings in view. **Canada only** — ECCC is the first registered provider | ECCC MSC GeoMet | 🟢 | | 🚗 **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** | ~4,000 public cameras (shared cap) 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 · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement) and opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | +| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) 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 · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit) · Houston (TranStar), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement) and opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed). 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, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 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 8cb7b6d17..99cfc3234 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1072,6 +1072,23 @@ By default the 250 cameras nearest downtown Vancouver and Victoria load. The Open Government Licence – British Columbia attribution is registered in the Data attribution popover. +## Houston TranStar CCTV source pack + +The CCTV catalog adds Greater Houston traffic cameras from Houston TranStar +alongside the other live packs. The server reads the keyless camera list at +`https://traffic.houstontranstar.org/data/layers/cctvSnapshots_json.js` and +keeps cameras with a numeric snapshot id inside the metro bounding box. Frame +URLs are built from each camera id under +`https://www.houstontranstar.org/snapshots/cctv/.jpg`; they are never taken +from the payload. Dedicated `dir` values become high-confidence headings; +`None` uses the shared id-hash fallback at low confidence. + +By default the 250 cameras nearest downtown, Uptown, the Medical Center, IAH +and Galveston load. `CCTV_HOUSTON_MAX_SOURCES` sets the pack cap (8–400) and +`CCTV_HOUSTON_ENABLED=0` turns the pack off. The catalog-wide ceiling stays at +4,000 and is shared round-robin, so adding this pack does not silently drop +another region. + ## Location control ownership City/POI rows, search/reset bindings, location readouts and the orbit indicator @@ -3366,7 +3383,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) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + 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) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Houston TranStar + 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 + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil) and Bicing (Barcelona) + Urban Sharing, Entur, JCDecaux NO + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 8bcc4c04d..84002f2cf 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -552,6 +552,7 @@ "src/data/cctvCataloniaSource.test.mjs", "src/data/cctvVegvesen.test.mjs", "src/data/cctvNyc.test.mjs", + "src/data/cctvHouston.test.mjs", "src/data/cctvNswSource.test.mjs", "src/data/cctvTxdotSource.test.mjs", "src/layers/cctv/source.test.mjs", diff --git a/server/providers/cctv/catalog.js b/server/providers/cctv/catalog.js index 75e58d2a6..0ed194660 100644 --- a/server/providers/cctv/catalog.js +++ b/server/providers/cctv/catalog.js @@ -25,6 +25,7 @@ import { loadVegvesenSourcesFromOpenData, loadNycSourcesFromOpenData, loadTorontoSourcesFromOpenData, + loadHoustonSourcesFromOpenData, } from './sources.js'; /** Env kill switch: unset or anything but "0" means enabled. */ @@ -144,6 +145,11 @@ const LIVE_PACKS = [ enabled: () => envOptIn('CCTV_LITHUANIA_ENABLED'), load: loadLithuaniaSources, }, + { + name: 'houston', + enabled: () => envEnabled('CCTV_HOUSTON_ENABLED'), + load: loadHoustonSourcesFromOpenData, + }, ]; /** * 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 20b963770..43d804963 100644 --- a/server/providers/cctv/constants.js +++ b/server/providers/cctv/constants.js @@ -347,6 +347,37 @@ export const NYC_ANCHORS = [ { lat: 40.5795, lon: -74.1502 }, // Staten Island ]; +/** Houston TranStar cameras: one keyless JSON list for the metro (~1,000 + * cameras with a numeric id). Frames are stills synthesized from that id on + * the city's snapshot host — never taken from the payload. The catalog is + * served as `application/javascript`; asking for `application/json` gets HTTP + * 406, so the loader accepts any media type. */ +export const HOUSTON_CAMERAS_URL = + 'https://traffic.houstontranstar.org/data/layers/cctvSnapshots_json.js'; +/** The only origin Houston camera frames may come from. The catalog's `url` + * is a `showWin(...)` viewer helper and is ignored; the registered path is + * always this origin plus `/snapshots/cctv/.jpg`. */ +export const HOUSTON_IMAGE_ORIGIN = 'https://www.houstontranstar.org/'; +export const HOUSTON_IMAGE_URL = (id) => + `${HOUSTON_IMAGE_ORIGIN}snapshots/cctv/${id}.jpg`; +/** Numeric TranStar snapshot ids: 3–5 digits, no leading zero. Id `0` is a + * different (non-snapshot) camera class in the same list. */ +export const HOUSTON_CAMERA_ID_RE = /^[1-9]\d{2,4}$/; +export const DEFAULT_HOUSTON_MAX_SOURCES = 250; +export const HOUSTON_MAX_CATALOG_BYTES = 4 * 1024 * 1024; +/** Ground-elevation prior in metres. The TranStar payload carries none, and + * Houston sits a few metres above sea level. */ +export const HOUSTON_GROUND_ELEVATION_M = 15; +/** Downtown Houston (Main @ Texas): the primary prioritization anchor. */ +export const HOUSTON_DOWNTOWN = { lat: 29.7604, lon: -95.3698 }; +export const HOUSTON_ANCHORS = [ + { lat: 29.7604, lon: -95.3698 }, // Downtown + { lat: 29.739, lon: -95.461 }, // Uptown / Galleria + { lat: 29.71, lon: -95.398 }, // Texas Medical Center + { lat: 29.9902, lon: -95.3368 }, // IAH + { lat: 29.3013, lon: -94.7977 }, // Galveston +]; + /** DelDOT CCTV: one keyless statewide JSON catalog; live video via RTMP-over-HTTP (rtmpt:80). */ export const DELDOT_CCTV_URL = 'https://tmc.deldot.gov/json/videocamera.json'; export const DEFAULT_DELDOT_MAX_SOURCES = 300; diff --git a/server/providers/cctv/normalize.js b/server/providers/cctv/normalize.js index 2d4e16e11..1b0e79a0c 100644 --- a/server/providers/cctv/normalize.js +++ b/server/providers/cctv/normalize.js @@ -388,6 +388,18 @@ export function isLikelyNycCoordinate(lat, lon) { ); } +/** Greater Houston (the Woodlands to Galveston, Katy to Baytown). Farther + * TranStar partners (Dallas, Columbus) are out of this city pack. */ +export function isLikelyHoustonCoordinate(lat, lon) { + return ( + isPlausibleLatLon(lat, lon) && + lat >= 29.2 && + lat <= 30.35 && + lon >= -96.05 && + lon <= -94.7 + ); +} + /** Mainland Norway, with slack for the coast and the Finnmark border. */ export function isLikelyNorwayCoordinate(lat, lon) { return ( diff --git a/server/providers/cctv/sources.js b/server/providers/cctv/sources.js index c3d11e752..895fe4c4d 100644 --- a/server/providers/cctv/sources.js +++ b/server/providers/cctv/sources.js @@ -96,6 +96,13 @@ import { NYC_MAX_CATALOG_BYTES, NYC_GROUND_ELEVATION_M, NYC_ANCHORS, + HOUSTON_CAMERAS_URL, + HOUSTON_IMAGE_URL, + HOUSTON_CAMERA_ID_RE, + DEFAULT_HOUSTON_MAX_SOURCES, + HOUSTON_MAX_CATALOG_BYTES, + HOUSTON_GROUND_ELEVATION_M, + HOUSTON_ANCHORS, } from './constants.js'; import { toFiniteNumber, @@ -116,6 +123,7 @@ import { isLikelyCataloniaCoordinate, decodeNumericEntities, isLikelyNycCoordinate, + isLikelyHoustonCoordinate, isLikelyNorwayCoordinate, isLikelyTorontoCoordinate, cameraDisplayCode, @@ -2784,3 +2792,138 @@ export async function loadTorontoSourcesFromOpenData() { return []; } } + +/** + * Label for one Houston TranStar camera: the interchange an operator + * recognises ("10 EAST @ SAN JACINTO"). + * + * @param {object} record - Raw TranStar camera row. + * @param {string} cameraId - Derived stable id. + * @returns {string} + */ +export function houstonCameraName(record, cameraId) { + const name = String(record?.location ?? '').trim(); + if (name) return name; + const short = String(cameraId || '').replace(/^houston-/, ''); + return short ? `Houston Camera ${short}` : 'Houston Camera'; +} + +/** + * One Houston TranStar camera row -> one catalog source, or null. + * + * Frame URLs are synthesized from a validated numeric id on the pinned + * snapshot origin. The payload's `url` is a `showWin(...)` viewer helper + * and is ignored so a catalog edit cannot steer the frame proxy off-host. + * Dedicated `dir` values (North/South/East/West) become high-confidence + * headings; `None`/blank fall back to the shared id-hash heading. + * + * @param {object} record - Raw TranStar camera row. + * @returns {?object} + */ +export function houstonCameraToSource(record) { + if (!record || typeof record !== 'object') return null; + + const lat = toFiniteNumber(record.lat); + const lon = toFiniteNumber(record.lng); + if (!isLikelyHoustonCoordinate(lat, lon)) return null; + + const rawId = String(record.id ?? '').trim(); + if (!HOUSTON_CAMERA_ID_RE.test(rawId)) return null; + const cameraId = `houston-${rawId}`; + const imageUrl = HOUSTON_IMAGE_URL(rawId); + const name = houstonCameraName(record, cameraId); + const extractedHeading = directionToHeading(record.dir, true); + const hasHeading = Number.isFinite(extractedHeading); + + return { + id: cameraId, + name, + city: 'Houston', + cityId: 'houston', + provider: 'Houston TranStar', + lat, + lon, + headingDeg: hasHeading ? extractedHeading : fallbackHeadingFromId(cameraId), + headingConfidence: hasHeading ? 'high' : 'low', + pitchDeg: hasHeading ? -24 : -18, + fovDeg: hasHeading ? 56 : 44, + rangeM: hasHeading ? 210 : 145, + mountHeightM: hasHeading ? 10 : 8, + groundElevationM: HOUSTON_GROUND_ELEVATION_M, + feedType: 'image', + url: imageUrl, + snapshotUrl: imageUrl, + sourceKind: 'houston-transtar', + license: 'Public Houston TranStar traffic camera', + code: cameraDisplayCode(name.toUpperCase()), + }; +} + +/** + * Fetch Houston TranStar cameras from the keyless `cctvSnapshots_json.js` + * catalog. Frames are stills on `www.houstontranstar.org`, registered from + * a validated numeric id rather than from the payload URL. The catalog host + * answers `Accept: application/json` with HTTP 406, so this request accepts + * any media type. + * + * @returns {Promise>} Normalized camera source objects. + */ +export async function loadHoustonSourcesFromOpenData() { + try { + const endpoint = + process.env.CCTV_HOUSTON_CAMERAS_URL || HOUSTON_CAMERAS_URL; + const resp = await fetch(endpoint, { + headers: { Accept: '*/*' }, + 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] Houston catalog redirected; redirects are not followed', + ); + return discard(); + } + if (!resp.ok) { + console.warn('[CCTV] Houston camera download failed:', resp.status); + return discard(); + } + const payload = await readResponseJsonCapped( + resp, + HOUSTON_MAX_CATALOG_BYTES, + ); + const rows = Array.isArray(payload?.cameras) ? payload.cameras : []; + const cameras = []; + const seen = new Set(); + for (const record of rows) { + const camera = houstonCameraToSource(record); + if (!camera || seen.has(camera.id)) continue; + seen.add(camera.id); + cameras.push(camera); + } + const maxRaw = Number( + process.env.CCTV_HOUSTON_MAX_SOURCES || DEFAULT_HOUSTON_MAX_SOURCES, + ); + const maxCount = Number.isFinite(maxRaw) + ? Math.max(8, Math.min(400, Math.floor(maxRaw))) + : DEFAULT_HOUSTON_MAX_SOURCES; + const prioritized = prioritizeSources(cameras, maxCount, HOUSTON_ANCHORS); + console.log( + `[CCTV] Loaded Houston camera sources: ${cameras.length} (using nearest ${prioritized.length})`, + ); + return prioritized; + } catch (error) { + console.warn( + '[CCTV] Houston camera download error:', + error?.message || error, + ); + return []; + } +} diff --git a/src/data/cctvHouston.test.mjs b/src/data/cctvHouston.test.mjs new file mode 100644 index 000000000..97de95a84 --- /dev/null +++ b/src/data/cctvHouston.test.mjs @@ -0,0 +1,379 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + houstonCameraName, + houstonCameraToSource, + loadHoustonSourcesFromOpenData, +} from '../../server/providers/cctv/sources.js'; +import { + DEFAULT_CCTV_MAX_SOURCES, + DEFAULT_HOUSTON_MAX_SOURCES, + HOUSTON_CAMERAS_URL, + HOUSTON_GROUND_ELEVATION_M, + HOUSTON_IMAGE_ORIGIN, + HOUSTON_IMAGE_URL, + HOUSTON_MAX_CATALOG_BYTES, +} from '../../server/providers/cctv/constants.js'; +import { + CAMERA_CODE_MAX_CHARS, + isLikelyHoustonCoordinate, +} from '../../server/providers/cctv/normalize.js'; +import { createCctvCatalog } from '../../server/providers/cctv/catalog.js'; +import { directionToHeading } from './directionText.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('{')); + }, + cancel() { + state.cancelled = true; + }, + }); + return { response: new Response(body, init), state }; +}; + +const DOWNTOWN_ID = '1002'; +const GALLERIA_ID = '2001'; + +/** One TranStar camera row, shaped like the live `cctvSnapshots_json.js` payload. */ +const row = (overrides = {}) => ({ + location: '10 EAST @ SAN JACINTO', + id: DOWNTOWN_ID, + lat: '29.76824', + lng: '-95.355711', + dir: 'South', + fc: '6', + fp: '300', + url: "showWin('/layers/gc.aspx?cam=1002&loc=IH-10_East_at_SAN_JACINTO')", + ...overrides, +}); + +test('a Houston row maps to a source on the pinned HTTPS frame host', () => { + const source = houstonCameraToSource(row()); + assert.equal(source.id, `houston-${DOWNTOWN_ID}`); + assert.equal(source.name, '10 EAST @ SAN JACINTO'); + assert.equal(source.city, 'Houston'); + assert.equal(source.cityId, 'houston'); + assert.equal(source.provider, 'Houston TranStar'); + assert.equal(source.lat, 29.76824); + assert.equal(source.lon, -95.355711); + assert.equal(source.feedType, 'image'); + assert.equal(source.sourceKind, 'houston-transtar'); + assert.equal(source.license, 'Public Houston TranStar traffic camera'); + assert.equal(source.groundElevationM, HOUSTON_GROUND_ELEVATION_M); + assert.equal(source.url, HOUSTON_IMAGE_URL(DOWNTOWN_ID)); + assert.equal(source.url, source.snapshotUrl); + assert.ok(source.url.startsWith(HOUSTON_IMAGE_ORIGIN)); + assert.equal(source.headingConfidence, 'high'); + assert.equal(source.headingDeg, 180); +}); + +test('frame URLs are synthesized from the numeric id; the payload URL is ignored', () => { + const source = houstonCameraToSource( + row({ url: 'https://evil.example/cameras/x/image' }), + ); + assert.equal(source.url, HOUSTON_IMAGE_URL(DOWNTOWN_ID)); + assert.equal(source.snapshotUrl, source.url); +}); + +test('zero ids, off-city geometry and unusable ids are dropped', () => { + assert.equal(houstonCameraToSource(row({ id: '0' })), null); + assert.equal(houstonCameraToSource(row({ id: '00' })), null); + assert.equal(houstonCameraToSource(row({ id: '../etc/passwd' })), null); + assert.equal(houstonCameraToSource(row({ id: '12' })), null); + assert.equal(houstonCameraToSource(row({ id: '1002/../x' })), null); + // Dallas: a plausible lat/lon, but not a Houston camera. + assert.equal( + houstonCameraToSource(row({ lat: '32.7767', lng: '-96.797' })), + null, + ); + assert.equal(houstonCameraToSource(row({ lat: '0', lng: '0' })), null); + assert.equal(houstonCameraToSource(null), null); + // 3-digit and 5-digit snapshot ids stay. + assert.equal(houstonCameraToSource(row({ id: '102' })).id, 'houston-102'); + assert.equal(houstonCameraToSource(row({ id: '13711' })).id, 'houston-13711'); +}); + +test('bare cardinal dirs are high-confidence; None uses the id-hash fallback', () => { + assert.equal(directionToHeading('South', true), 180); + assert.equal(directionToHeading('None', true), NaN); + + const south = houstonCameraToSource(row({ dir: 'South' })); + assert.equal(south.headingConfidence, 'high'); + assert.equal(south.headingDeg, 180); + + const none = houstonCameraToSource(row({ dir: 'None' })); + assert.equal(none.headingConfidence, 'low'); + assert.ok(Number.isFinite(none.headingDeg)); + assert.equal( + none.headingDeg, + houstonCameraToSource(row({ dir: '' })).headingDeg, + ); +}); + +test('a nameless row still gets a label', () => { + assert.equal( + houstonCameraName({ location: ' 45 GULF @ WEST DALLAS ' }, 'houston-102'), + '45 GULF @ WEST DALLAS', + ); + assert.equal(houstonCameraName({}, 'houston-102'), 'Houston Camera 102'); + assert.equal(houstonCameraName({}, ''), 'Houston Camera'); +}); + +test('the unselected label is the interchange, trimmed to the code width', () => { + assert.equal(houstonCameraToSource(row()).code, '10 EAST @ SAN JACINTO'); + const long = houstonCameraToSource( + row({ + location: + 'IH-10 Katy Managed Lanes Eastbound at a very long interchange name that must truncate', + }), + ); + assert.equal(long.code.length, CAMERA_CODE_MAX_CHARS); + assert.ok(long.code.endsWith('…')); +}); + +test('the loader reads the keyless catalog and collapses duplicate ids', async (t) => { + t.mock.method(console, 'log', () => {}); + const requested = []; + t.mock.method(globalThis, 'fetch', async (url, init) => { + requested.push([String(url), init.headers.Accept, init.redirect]); + return Response.json({ + cameras: [ + row(), + row({ location: '10 EAST @ SAN JACINTO (duplicate)' }), + row({ + id: GALLERIA_ID, + location: '610 WEST LOOP @ SAN FELIPE', + lat: '29.749', + lng: '-95.461', + dir: 'North', + }), + row({ url: 'https://evil.example/x.jpg', id: '0' }), + ], + }); + }); + const cameras = await loadHoustonSourcesFromOpenData(); + assert.deepEqual(requested, [[HOUSTON_CAMERAS_URL, '*/*', 'manual']]); + assert.deepEqual( + cameras.map((camera) => camera.id), + [`houston-${GALLERIA_ID}`, `houston-${DOWNTOWN_ID}`], + 'nearest-to-anchor first: Uptown sits closer to its pin than downtown does', + ); + assert.equal(cameras[1].name, '10 EAST @ SAN JACINTO'); +}); + +test('an upstream failure yields an empty pack and releases the response', async (t) => { + t.mock.method(console, 'warn', () => {}); + const failed = streamingResponse({ status: 503 }); + t.mock.method(globalThis, 'fetch', async () => failed.response); + assert.deepEqual(await loadHoustonSourcesFromOpenData(), []); + 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 loadHoustonSourcesFromOpenData(), []); +}); + +test('the catalog fetch refuses redirects and oversized bodies', async (t) => { + t.mock.method(console, 'warn', () => {}); + const seen = []; + const redirected = streamingResponse({ + status: 302, + headers: { location: 'https://evil.example/cameras.js' }, + }); + t.mock.method(globalThis, 'fetch', async (url, init) => { + seen.push([String(url), init.redirect]); + return redirected.response; + }); + assert.deepEqual(await loadHoustonSourcesFromOpenData(), []); + assert.deepEqual(seen, [[HOUSTON_CAMERAS_URL, 'manual']]); + assert.equal( + redirected.state.cancelled, + true, + 'the redirect body is cancelled', + ); + + t.mock.restoreAll(); + t.mock.method(console, 'warn', () => {}); + t.mock.method(globalThis, 'fetch', async () => { + const body = JSON.stringify({ cameras: [row()] }); + return new Response(body, { + headers: { + 'Content-Type': 'application/javascript', + 'content-length': String(HOUSTON_MAX_CATALOG_BYTES + 1), + }, + }); + }); + assert.deepEqual(await loadHoustonSourcesFromOpenData(), []); + + t.mock.restoreAll(); + t.mock.method(console, 'warn', () => {}); + t.mock.method(globalThis, 'fetch', async () => { + const oversized = 'x'.repeat(HOUSTON_MAX_CATALOG_BYTES + 1024); + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(oversized)); + controller.close(); + }, + }), + { headers: { 'Content-Type': 'application/javascript' } }, + ); + }); + assert.deepEqual(await loadHoustonSourcesFromOpenData(), []); +}); + +test('a reduced pack cap keeps cameras nearest the metro anchors', async (t) => { + t.mock.method(console, 'log', () => {}); + const saved = process.env.CCTV_HOUSTON_MAX_SOURCES; + t.after(() => { + if (saved === undefined) delete process.env.CCTV_HOUSTON_MAX_SOURCES; + else process.env.CCTV_HOUSTON_MAX_SOURCES = saved; + }); + process.env.CCTV_HOUSTON_MAX_SOURCES = '8'; + const rows = [ + ...[29.32, 29.322, 29.324, 29.326, 29.328, 29.33].map((lat, i) => + row({ + id: String(3000 + i), + lat: String(lat), + lng: '-94.80', + location: `Galveston ${i}`, + dir: 'East', + }), + ), + ...[29.76, 29.759, 29.758, 29.757, 29.756, 29.755].map((lat, i) => + row({ + id: String(4000 + i), + lat: String(lat), + lng: '-95.3698', + location: `Downtown ${i}`, + dir: 'North', + }), + ), + ]; + t.mock.method(globalThis, 'fetch', async () => + Response.json({ cameras: rows }), + ); + const cameras = await loadHoustonSourcesFromOpenData(); + assert.equal(cameras.length, 8); + assert.deepEqual( + cameras.map((camera) => camera.name.slice(0, 8)), + [ + 'Downtown', + 'Downtown', + 'Downtown', + 'Downtown', + 'Downtown', + 'Downtown', + 'Galvesto', + 'Galvesto', + ], + 'downtown fills first; the Galveston anchor still keeps a share', + ); +}); + +/** + * Serve the Houston catalog to the Houston endpoint and an empty payload to + * every other pack, so one catalog refresh exercises the registration without + * reaching the network. + */ +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 === HOUSTON_CAMERAS_URL) { + return Response.json({ + cameras: [ + row(), + row({ + id: GALLERIA_ID, + location: '610 WEST LOOP @ SAN FELIPE', + lat: '29.749', + lng: '-95.461', + dir: 'West', + }), + ], + }); + } + return Response.json([]); + }); + const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })(); + return { requested, sources }; +}; + +test('the Houston lane is wired into the catalog and its loader runs', async (t) => { + const saved = { ...process.env }; + try { + delete process.env.CCTV_SOURCES_FILE; + delete process.env.CCTV_SOURCES_JSON; + delete process.env.CCTV_HOUSTON_ENABLED; + const { requested, sources } = await runCatalogWithMockedUpstreams(t); + assert.ok( + requested.includes(HOUSTON_CAMERAS_URL), + 'the catalog refresh invokes the Houston loader', + ); + assert.deepEqual( + sources.filter((s) => s.cityId === 'houston').map((s) => s.id), + [`houston-${GALLERIA_ID}`, `houston-${DOWNTOWN_ID}`], + 'Houston 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_HOUSTON_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_HOUSTON_ENABLED = '0'; + const { requested, sources } = await runCatalogWithMockedUpstreams(t); + assert.equal( + requested.includes(HOUSTON_CAMERAS_URL), + false, + 'the disabled lane never reaches its upstream', + ); + assert.deepEqual( + sources.filter((s) => s.cityId === 'houston'), + [], + ); + } 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); + assert.equal(DEFAULT_HOUSTON_MAX_SOURCES, 250); +}); + +test('isLikelyHoustonCoordinate spans the metro and rejects the rest', () => { + assert.equal(isLikelyHoustonCoordinate(29.7604, -95.3698), true); // Downtown + assert.equal(isLikelyHoustonCoordinate(29.3013, -94.7977), true); // Galveston + assert.equal(isLikelyHoustonCoordinate(29.9902, -95.3368), true); // IAH + assert.equal(isLikelyHoustonCoordinate(32.7767, -96.797), false); // Dallas + assert.equal(isLikelyHoustonCoordinate(30.2672, -97.7431), false); // Austin + assert.equal(isLikelyHoustonCoordinate(0, 0), false); + assert.equal(isLikelyHoustonCoordinate(NaN, -95.37), false); +}); diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index c13c7dd94..b74e8c061 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -300,6 +300,12 @@ export const DATA_CREDITS = [ 'Traffic cameras (Toronto): contains information licensed under the ' + 'Open Government Licence – Toronto', }, + { + key: 'houston-cctv', + html: + 'CCTV cameras & frames (Houston): ' + + 'Houston TranStar', + }, { key: 'gbfs', html: