mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 19:34:01 +00:00
Compare commits
185
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
664c18d97a | ||
|
|
de2138e84c | ||
|
|
f0e0ea42ce | ||
|
|
89ef90af04 | ||
|
|
5597c81e7c | ||
|
|
fb3506cc20 | ||
|
|
f927ef288a | ||
|
|
97630a598a | ||
|
|
3744a97f00 | ||
|
|
d90bae49b6 | ||
|
|
ea09a59877 | ||
|
|
5b4f5ed4a0 | ||
|
|
1bccc295f0 | ||
|
|
4416a491d2 | ||
|
|
4171ebb6a8 | ||
|
|
59646e52f9 | ||
|
|
38e037d3a7 | ||
|
|
ca71b27791 | ||
|
|
b7fe29b5cc | ||
|
|
12b8fd0c1d | ||
|
|
474d44c4db | ||
|
|
05f668dfa7 | ||
|
|
93c1f6cd45 | ||
|
|
3e39bb2cf4 | ||
|
|
194a547955 | ||
|
|
2f171c7d79 | ||
|
|
a1508a386e | ||
|
|
1c727842df | ||
|
|
bd2342dc81 | ||
|
|
5c513684f5 | ||
|
|
ba5a1e25ee | ||
|
|
4e3c1628b2 | ||
|
|
7fe95b76e9 | ||
|
|
2120130997 | ||
|
|
10916227cb | ||
|
|
c808bfe609 | ||
|
|
b66d02df7c | ||
|
|
0561277dcb | ||
|
|
e9a234094b | ||
|
|
29fb756a5c | ||
|
|
35c612c5f6 | ||
|
|
bd087d6096 | ||
|
|
86eca5abf3 | ||
|
|
0f51eb21ba | ||
|
|
9f6e0ef60f | ||
|
|
5fd5d688dc | ||
|
|
d80683c44b | ||
|
|
56c5d03b65 | ||
|
|
b8bfeb7aa2 | ||
|
|
7214f71878 | ||
|
|
8e635c9a3a | ||
|
|
1959b33c48 | ||
|
|
daaaa348b7 | ||
|
|
0f237a32d4 | ||
|
|
4ecbe54997 | ||
|
|
4999f6dc60 | ||
|
|
3d8e1ef00f | ||
|
|
326c41df9c | ||
|
|
beca41c676 | ||
|
|
a29d5a6874 | ||
|
|
839442b20b | ||
|
|
c174742e2c | ||
|
|
4c383214f7 | ||
|
|
5d480a5fc2 | ||
|
|
7712ecf6ed | ||
|
|
7cdaf40fd0 | ||
|
|
256d7c273e | ||
|
|
d921a1b801 | ||
|
|
0a4d520e79 | ||
|
|
695cce967d | ||
|
|
42c95a8a24 | ||
|
|
29d8fb4101 | ||
|
|
7a31ef5e8a | ||
|
|
7dfecd7d98 | ||
|
|
ea06c9fc64 | ||
|
|
cd7e29c635 | ||
|
|
ada7d8852f | ||
|
|
7844e765b4 | ||
|
|
5b12f51e98 | ||
|
|
f747c4828e | ||
|
|
a310cbc2f0 | ||
|
|
ca9dbea060 | ||
|
|
9b400622af | ||
|
|
3792d7377e | ||
|
|
1b66d284c4 | ||
|
|
f26586bb3e | ||
|
|
6b3f7a3ec1 | ||
|
|
83b9940733 | ||
|
|
91461381ee | ||
|
|
cd0956eef7 | ||
|
|
fa085cbd64 | ||
|
|
48c8a64c97 | ||
|
|
59db4ac227 | ||
|
|
15208b5c40 | ||
|
|
80a0954030 | ||
|
|
8d3c929740 | ||
|
|
7688e53424 | ||
|
|
558b0275a4 | ||
|
|
50559723a5 | ||
|
|
d39256e700 | ||
|
|
97816a35de | ||
|
|
d06e485c12 | ||
|
|
7bf268ec42 | ||
|
|
661e1d7fa3 | ||
|
|
aec4f67ca9 | ||
|
|
5a87fb3b15 | ||
|
|
548e7f23d1 | ||
|
|
daff74fe04 | ||
|
|
a1bd7ff37c | ||
|
|
672191497a | ||
|
|
1c4c903ac6 | ||
|
|
c09865e582 | ||
|
|
83fc895442 | ||
|
|
e4f526415d | ||
|
|
f4a4df5044 | ||
|
|
3e23b4cd39 | ||
|
|
cefbad3c2a | ||
|
|
c1dd4171db | ||
|
|
c7ff33ac2e | ||
|
|
77768e9fff | ||
|
|
5d0c367315 | ||
|
|
db73c6908e | ||
|
|
2a6ba1180d | ||
|
|
c6706a5a52 | ||
|
|
c869b248b7 | ||
|
|
2b5a5128fe | ||
|
|
d604b97a4d | ||
|
|
8f6671b6c2 | ||
|
|
5acf254f8b | ||
|
|
60444cca53 | ||
|
|
1d418fd5af | ||
|
|
52863a9f42 | ||
|
|
8abc99aa0a | ||
|
|
4fde920665 | ||
|
|
2bec3d521b | ||
|
|
202a310c7a | ||
|
|
acd75efc7c | ||
|
|
e2dacd92ea | ||
|
|
38b95fb3b1 | ||
|
|
9688f15af7 | ||
|
|
edf0392248 | ||
|
|
c90e212049 | ||
|
|
e312a007ab | ||
|
|
2aa0ae851f | ||
|
|
702ecc38e2 | ||
|
|
f11a532139 | ||
|
|
77dfa718a9 | ||
|
|
436ffbb21c | ||
|
|
6f054408da | ||
|
|
166190b710 | ||
|
|
1a0b9c8b0f | ||
|
|
19372875e2 | ||
|
|
d9e2c8a7e7 | ||
|
|
a15747ca61 | ||
|
|
1371a86713 | ||
|
|
603f428397 | ||
|
|
2110d06e36 | ||
|
|
e38672b45d | ||
|
|
d19e9f8b93 | ||
|
|
ff799d1520 | ||
|
|
939264d30e | ||
|
|
1e1ddfca0f | ||
|
|
34d53edc91 | ||
|
|
c43f58dc3f | ||
|
|
bea76df454 | ||
|
|
2b7f832f45 | ||
|
|
a194da21dd | ||
|
|
abea3f2feb | ||
|
|
914df05346 | ||
|
|
cbd3b13afb | ||
|
|
8acb526d1f | ||
|
|
3c53d63def | ||
|
|
6a6703e0c6 | ||
|
|
58b7d2d95e | ||
|
|
05b13e9a78 | ||
|
|
c55d4d7c25 | ||
|
|
6439575d15 | ||
|
|
3c163d09a1 | ||
|
|
5c044a3d8d | ||
|
|
4fc813e175 | ||
|
|
b22e6d519a | ||
|
|
b79ab84a1a | ||
|
|
a4ce7381d8 | ||
|
|
f0e28a83cb | ||
|
|
be7187cea7 |
No files matched your search
+12
-2
@@ -83,6 +83,12 @@ OPENAI_REALTIME_TRANSCRIBE_MODEL=gpt-4o-mini-transcribe
|
||||
# keep them while debugging; delete the log when you are done.
|
||||
GEV_VOICE_LOG_CONTENT=
|
||||
OPENAI_HUD_SUMMARY_MODEL=gpt-5-nano
|
||||
# Optional: Gemini Live voice, selected in the GEV MIC provider chooser.
|
||||
# Server-only key. Do not prefix with VITE_. Provider Settings can save it.
|
||||
GEMINI_API_KEY=
|
||||
GEMINI_LIVE_MODEL=gemini-3.8-live
|
||||
# Token starts per IP per minute; 0 disables the throttle, not provider quotas.
|
||||
# GEV_RATELIMIT_GEMINI_PER_MIN=30
|
||||
# Per-IP rate limit for the OpenAI cost endpoints
|
||||
# (/api/realtime/token + /api/openai/hud-summary), in requests per minute.
|
||||
# DEFAULTS TO 30, the value the Pinokio build also ships. That clears ordinary
|
||||
@@ -118,8 +124,8 @@ OPENSKY_AUTH_MODE=oauth
|
||||
OPENSKY_CLIENT_ID=
|
||||
OPENSKY_CLIENT_SECRET=
|
||||
|
||||
# Optional: higher Launch Library 2 request allowance. Public access works
|
||||
# without a token.
|
||||
# Launch Library 2: leave blank for standard public access (no key needed).
|
||||
# Optional paid Patreon tokens raise request limits; there is no free API key.
|
||||
LL2_API_TOKEN=
|
||||
|
||||
# Optional: OpenSky credentials JSON file path (alternative to above)
|
||||
@@ -201,6 +207,7 @@ AISSTREAM_API_KEY=
|
||||
# CCTV_TFL_ENABLED=1 # set 0 to disable Transport for London JamCams
|
||||
# CCTV_TFL_MAX_SOURCES=250 # cap cameras loaded from TfL
|
||||
# CCTV_ONTARIO_ENABLED=1 # set 0 to disable Ontario 511 cameras
|
||||
# ONTARIO_511_API_KEY= # free developer key: sign up at 511on.ca/my511/register, then request it at 511on.ca/developers/doc
|
||||
# CCTV_ONTARIO_MAX_SOURCES=1000 # cap cameras loaded from Ontario 511
|
||||
# CCTV_FINTRAFFIC_ENABLED=1 # set 0 to drop the Fintraffic (Finland) pack
|
||||
# CCTV_FINTRAFFIC_MAX_SOURCES=300 # cap cameras loaded from the Fintraffic catalog
|
||||
@@ -228,6 +235,7 @@ AISSTREAM_API_KEY=
|
||||
# CCTV_VEGVESEN_ENABLED=1 # set 0 to disable the Statens vegvesen (Norway) road cameras
|
||||
# CCTV_VEGVESEN_MAX_SOURCES=900 # cap cameras loaded from Statens vegvesen (default loads all ~850 live; max 1000)
|
||||
# CCTV_VEGVESEN_VIDEO=1 # set 0 to show stills instead of live HLS for Statens vegvesen video cameras
|
||||
# CCTV_VEGVESEN_HEIGHTS=1 # set 0 to skip Kartverket ground heights for Statens vegvesen cameras (flat prior instead)
|
||||
# CCTV_VEGVESEN_URL= # override the Statens vegvesen catalog URL
|
||||
# CCTV_NYC_ENABLED= # off by default; set 1 only if you hold a NYC DOT data-sharing agreement ([email protected]) for the camera feed
|
||||
# CCTV_NYC_MAX_SOURCES=250 # cap cameras loaded from the NYC TMC catalog (nearest borough cores first)
|
||||
@@ -237,6 +245,8 @@ 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_FLORIDA_ENABLED= # off by default; set 1 to load FL511 (Florida) cameras (individual, personal use only; any other use, including a public deployment, needs FDOT's express written consent; see DATA_SOURCES.md)
|
||||
# CCTV_FLORIDA_MAX_SOURCES=500 # cap cameras loaded from the FL511 catalog (nearest regional anchors first)
|
||||
# CCTV_MAX_SOURCES=4000 # catalog-wide ceiling, shared round-robin across packs
|
||||
# CCTV_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry
|
||||
# CCTV_FORCE_AUSTIN= # force live default sources when a source-pack JSON is configured
|
||||
|
||||
+5
-1
@@ -48,6 +48,7 @@ How to read this:
|
||||
| **GDELT Project DOC 2.0** | Fail-soft fallback for location-matched cockpit headlines | [GDELT Terms of Use](https://www.gdeltproject.org/about.html#termsofuse): unrestricted academic/commercial/governmental dataset use, with citation and link required; linked articles retain publisher terms | "GDELT Project" plus each article's linked publisher/domain |
|
||||
| **City of Austin Open Data** | CCTV camera catalog + frames | City of Austin Open Data Terms of Use | "City of Austin, TX — data.austintexas.gov" |
|
||||
| **TxDOT ITS (its.txdot.gov)** | CCTV camera catalogs + frames, Texas districts | Public TxDOT traffic camera data | "Texas Department of Transportation" (courtesy) |
|
||||
| **FL511 (Florida DOT)** | CCTV camera catalog + frames, Florida statewide | FL511 content, individual personal use only; any other use needs FDOT's express written consent ([terms](https://fl511.com/privacy#disclaimer)). **Off by default**; set `CCTV_FLORIDA_ENABLED=1` to enable | "Florida DOT — FL511.com" (courtesy) |
|
||||
| **City of Tallinn (ristmikud.tallinn.ee)** | CCTV camera catalog + frames, Tallinn intersections | Public City of Tallinn traffic camera data | "City of Tallinn — ristmikud.tallinn.ee" (courtesy) |
|
||||
| **Transpordiamet / Tarktee** | CCTV camera catalog + frames, Estonia road-weather cameras | Public Transpordiamet / Tarktee road camera data | "Transpordiamet / Tarktee — tarktee.transpordiamet.ee" (courtesy) |
|
||||
| **Stadt Warendorf webcam** | Webcam frames, Marktplatz / Historisches Rathaus (`config/cctv_sources.warendorf.json`) | Public municipal webcam data | "Stadt Warendorf — webcam.warendorf.de" (courtesy) |
|
||||
@@ -63,6 +64,7 @@ How to read this:
|
||||
| **King County Road Services** (`services.arcgis.com`, King County GIS) | CCTV camera catalog + frames, unincorporated King County WA road network | [KCGIS Center terms](https://kingcounty.gov/en/dept/kcit/data-information-services/gis-center/about/terms-conditions-copyrights) — attribution required; public ArcGIS layer | "King County Road Services" (partner-city cameras name their owner) |
|
||||
| **WSDOT** (`data.wsdot.wa.gov`) | CCTV camera catalog + frames, Washington State highways | [WSDOT travel information disclaimer](https://wsdot.wa.gov/about/policies/travel-information-disclaimer) — public "low volume" feed; catalog cached, frames on WSDOT's own image host only | "Washington State Department of Transportation (Travel Center)" (courtesy) |
|
||||
| **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" |
|
||||
| **Kartverket** (`ws.geonorge.no/hoydedata/v1/punkt`) | Terrain heights (NN2000) used as the ground prior for Statens vegvesen cameras | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) — attribution REQUIRED | "© Kartverket", modified (heights over water set to 0 m, rounded to 0.1 m) |
|
||||
| **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" |
|
||||
| **GBFS (Lyft / BCycle / Social Bicycles / PBSC)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` |
|
||||
@@ -152,7 +154,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
|
||||
- **adsbdb enrichment.** `/api/adsbdb` resolves an ICAO hex to aircraft type, model name and registration, and the tracked flight's callsign to an airline and an origin/destination pair. Fan-out is deliberately small because adsbdb is a free community API that publishes no quota: at most 4 requests in flight, dispatches dripped to roughly 5/s, and each key asked for at most once per session, with route lookups reserved for the tracked aircraft. The dev-server proxy caches each key for 24 hours in `.gev-cache/adsbdb.json`, negative answers included, so fresh entries survive server restarts. That gitignored cache remains local to the deployment and is not bundled with this repo. adsbdb publishes no general service license; its README says the route data may not be copied, published, or incorporated into other databases without the explicit permission of David J Taylor, Edinburgh. Deployers are responsible for obtaining any permission their use requires. Enrichment is best-effort and fail-silent — a flight that gets no answer keeps what the flight feed already gave it.
|
||||
- **Launch Library 2.** `/api/launches` makes a server-side rolling-30-day query against the supported v2.3 detailed launch endpoint, caches successful responses for 15 minutes in memory and on disk, and serves the last successful response during a throttle or transient outage. Anonymous access is limited to 15 calls/hour; deployments can provide `LL2_API_TOKEN` for authenticated access. The Space Devs' published terms permit using and sharing the API data in any form, ask users not to forward it without adding value, disclaim complete accuracy, and encourage—but do not require—attribution. This app keeps a courtesy credit. Payload and stage/recovery records are shown only when supplied. Failed launches expose their source status and never receive fallback orbit geometry or a live/estimated marker. LL2 supplies launch context and event timing, not continuous ascent telemetry or live orbital state.
|
||||
- **TfL JamCams.** The camera list comes from the keyless `api.tfl.gov.uk` endpoint (an optional `TFL_APP_KEY` raises its rate limit); frames come from TfL's public S3 bucket. The "Powered by TfL Open Data" attribution is required by TfL's terms and is registered in the Data attribution popover.
|
||||
- **Ontario 511 CCTV.** The camera list comes from the keyless `511on.ca/api/v2/get/cameras` endpoint; frames are server-registered still images under `511on.ca/map/Cctv/`. The source is live-fetched at runtime and attributed under the Open Government Licence - Ontario.
|
||||
- **Ontario 511 CCTV.** The camera list comes from the `511on.ca/api/v2/get/cameras` endpoint, which now requires a free developer key (`ONTARIO_511_API_KEY`: sign up at 511on.ca/my511/register, then request it while logged in at 511on.ca/developers/doc; throttled to 10 calls a minute). Without the key the pack is skipped. Frames are server-registered still images under `511on.ca/map/Cctv/`. The source is live-fetched at runtime and attributed under the Open Government Licence - Ontario. Access is governed by Ontario 511's Developer's Access Agreement (511on.ca/developers/resources): the data stays under the Open Government Licence - Ontario unless otherwise specified; key requests are reviewed case by case and ask for the intended use and any third parties that will receive the data; attribution by link to 511on.ca is encouraged, and the Ontario 511 logo may not be altered or used to imply endorsement (this app links and shows no logo); and the data is provided "as is", a disclaimer distributors should pass on, which the credit does.
|
||||
- **DelDOT live video cameras.** The keyless `tmc.deldot.gov/json/videocamera.json` catalog supplies active cameras and official `https://video.deldot.gov/live/…/playlist.m3u8` links. The local server transiently buffers bounded MPEG-TS segments in memory and deletes them on session release or idle expiry. No recordings are retained. `CCTV_DELDOT_ENABLED=0` disables the pack. Public availability is not a blanket redistribution license; deployment beyond personal local viewing needs confirmation of applicable DelDOT terms.
|
||||
- **Fintraffic weathercams.** The camera list is one keyless GeoJSON request to `tie.digitraffic.fi/api/weathercam/v1/stations` (809 stations / 2,275 presets), sent with the `Digitraffic-User: gods-eye-view` header the service asks clients to identify themselves with and with gzip; frames are stills on `weathercam.digitraffic.fi`, refreshed on each station's 600 s collection interval, which is also the client's ambient-still refresh cadence for this pack (the active camera keeps the usual 10-second refresh). One preset (a fixed camera view) is one camera; stations not `GATHERING` and presets not `inCollection` are dropped. Attribution is required by the CC BY 4.0 licence and is registered in the Data attribution popover. `CCTV_FINTRAFFIC_ENABLED=0` is the kill switch.
|
||||
- **DriveBC highway cameras.** The camera list comes from the keyless `https://www.drivebc.ca/api/webcams/` endpoint served by the DriveBC.ca site ([bcgov/DriveBC.ca](https://github.com/bcgov/DriveBC.ca)); frames come from `https://www.drivebc.ca/images/<id>.jpg`, built from the numeric camera id. DataBC's [DriveBC HighwayCams](https://catalogue.data.gov.bc.ca/dataset/bc-highwaycams) catalogue entry publishes the same cameras under the Open Government Licence – British Columbia, but its CSV still lists the retired `images.drivebc.ca` frame URLs, which now return a placeholder. The licence's attribution statement is required and is registered in the Data attribution popover. By default the 250 cameras nearest Vancouver and Victoria load; `CCTV_DRIVEBC_MAX_SOURCES` changes the cap and `CCTV_DRIVEBC_ENABLED=0` turns the pack off. Some cameras in the feed are supplied by partners (TransLink, the City of Vancouver, the City of Surrey, Parks Canada / Alberta Motor Association and others); the feed's per-camera credit is carried onto the camera record and shown beside the provider in the CCTV panel.
|
||||
@@ -162,10 +164,12 @@ 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`.
|
||||
- **FL511 Florida cameras.** Off by default; set `CCTV_FLORIDA_ENABLED=1` to load them. The camera list is the keyless FL511 traffic camera layer on ArcGIS (`services.arcgis.com/.../FL511_Traffic_Cameras`), published from a Florida Division of Emergency Management account rather than by FL511 directly, and the service carries no licence text of its own. It is paged up to 2,000 rows at a time, ordered by the layer's object id and following ArcGIS's `exceededTransferLimit`, to cover the ~4,000-camera statewide set. Frames are stills on two Divas Cloud hosts (`images-dis.divas.cloud`, `snapshots.divas.cloud`); both are host-pinned and a row whose image resolves elsewhere is dropped. `CCTV_FLORIDA_MAX_SOURCES` caps the pack (default 500, anchored per region across the state). Each catalog page is read under a 2 MiB cap and redirects are refused. Frames are fetched live at request time and never stored or redistributed. FL511's terms (fl511.com/privacy#disclaimer) license its content for individual, personal use only; any other use, including a public deployment, needs FDOT's express written consent.
|
||||
- **Catalonia (Servei Català de Trànsit) cameras.** The camera list is the keyless `https://www.gencat.cat/transit/opendata/cameres.xml` WFS/GML feed, fetched over https with redirects refused and the body read capped at 1 MiB, since the list decides ids, coordinates and which allowlisted frame path is proxied. It carries a `font` field naming the real publisher per camera; this pack keeps SCT's own highway cameras plus hotlinked Barcelona (IMI) and Terrassa municipal cameras (~130 total), each confirmed to return a real frame, so frame URLs are checked against a small host allowlist (`mct.gencat.cat`, `www.bcn.cat`, `emap.terrassa.cat`) rather than one pinned origin (a link carrying `user:pass@` userinfo or an explicit port, or pointing outside that host's frame directory, is rejected even on an allowed host; an SCT `sctidcam` must be a bare `.gif`/`.jpg` filename), and every non-SCT camera carries its real owner as a `credit` shown beside the shared `provider`. The feed carries no compass heading, so every camera loads at low heading confidence (same as TfL and Fintraffic). SCT's own feed link (`RenderService`) needed a workaround: it always 302s to a second endpoint (`TransitCamera`) on a Location the upstream hardcodes as plain http even when requested over https — refused by `fetchWithinHost`'s same-origin (scheme-included) redirect policy — and separately, `mct.gencat.cat`'s TLS setup fails the handshake with Node's fetch outright (`ERR_SSL_WRONG_SIGNATURE_TYPE`) even though other HTTP clients complete it. The loader therefore builds the `TransitCamera` URL itself, over plain http, from `RenderService`'s `sctidcam` id, rather than registering the (broken) upstream link — the only transport this one host actually serves to this runtime. Camera ids are built from the SCT camera file or the partner image path, not the feed's `fid` (which embeds an upstream server-start stamp and changes on every upstream restart), and rows the feed lists more than once collapse to one camera. The feed carries no camera status field, so a camera its publisher has taken offline still registers and shows the publisher's own "image unavailable" still (SCT and Barcelona both serve one with HTTP 200), the same as other packs without a status field. Frames are fetched at request time and never stored or redistributed. The feed itself states no licence; the pack relies on the Generalitat de Catalunya open-data portal's terms for this dataset (the licence and attribution above). Disable with `CCTV_CATALONIA_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 because their frames are WSDOT-hosted, outside the pinned image origin; partner-city cameras hosted on the county image handler (e.g. Newcastle) name 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 (vendor-generic strings such as `PTZ` name no model and are omitted). Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_KINGCOUNTY_ENABLED=0`.
|
||||
- **WSDOT highway cameras.** Keyless. The camera list is the Esri JSON layer `data.wsdot.wa.gov/travelcenter/Cameras.json` — the same layer WSDOT's own Travel Center map consumes (~1,700 point features, EPSG:3857 geometry converted to WGS84 on load; the body carries Windows-1252 bytes and is decoded as such). Only frames on WSDOT's own `images.wsdot.wa.gov` host are registered — the catalog also lists ~70 partner cameras (Oregon DOT, City of Seattle, lodges, airports) whose own terms apply, and those are skipped rather than proxied. WSDOT's layer metadata marks the feed for "low volume" use (see WSDOT's [travel information disclaimer](https://wsdot.wa.gov/about/policies/travel-information-disclaimer)). The catalog is fetched once per CCTV catalog refresh (every 15 minutes), and a camera's frame is fetched only when that camera is viewed; frames are never stored or redistributed. The live feed's `CompassDirection` is a single cardinal (N/E/S/W), `B` (both directions — most rows), `O`, or null. A single cardinal gives a high-confidence heading (the diagonals NE/SE/SW/NW are mapped too should they appear); `B`, `O`, null, and any other code carry no single facing and use the shared id-hash fallback at low confidence. By default the 250 cameras nearest Seattle and Spokane load; `CCTV_WSDOT_MAX_SOURCES` changes the cap (8–1,200) and `CCTV_WSDOT_ENABLED=0` turns the pack off. A keyed WSDOT Traveler Information API serves the same population for higher-volume use and is deliberately not required here.
|
||||
- **Statens vegvesen road cameras.** Keyless. The camera list is one GeoJSON request to the OGC API Features view of the official DATEX 3.1 CCTV site table (`ogckart-sn1.atlas.vegvesen.no/ogc/features/v1/collections/datex_3_1:CctvSimple/items`), so no DATEX account is needed; frames are stills on `kamera.atlas.vegvesen.no/api/images/<cameraId>`, 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/<cameraId>/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.
|
||||
- **Kartverket ground heights for Statens vegvesen cameras.** The camera feed's points are 2D, so the loader asks Kartverket's keyless point-height API (`ws.geonorge.no/hoydedata/v1/punkt`, CC BY 4.0) for the terrain height at each camera. Requests carry 20 points: the API accepts 50, but above ~20 it answers the extra points from contour lines (`hoydekurver`, metres to ~20 m off) or not at all. Eight requests run at a time, about 36 for the whole country: a cold lookup took 6–13 s in testing (single requests 0.6–9.5 s). Answers are matched by the coordinates the API echoes and recorded only for points that request asked about. Over water the API returns the sea-floor depth (`dybdekurver` or `Havflate`), which is replaced by 0 m, the water surface; for the ~14 bridge cameras that is below the deck, but much closer than the sea floor or the flat prior. Heights are cached in memory by position for the life of the server, and points with no height (a few ferry quays and bridges, or a point a 200 answer leaves out) for 6 h, so the 15-minute catalog refresh only asks about new or moved cameras. The first failing request (a 408, 429 or 5xx, a network error, a request over 10 s, or an unreadable or oversized body) stops the lookup, with no retries, and no new lookup starts until the backoff ends (Retry-After when sent, else 30 min, longer than the refresh; bounded to 1 min–1 h). Any other 4xx marks that request's points as having no height and the lookup carries on. The whole lookup has a 30 s deadline; a lookup cut short by it resumes on the next refresh without a backoff. The lane waits at most 12 s, counted from the start of the loader, and only when more than 10% of the cameras have no answer yet; a few new cameras or expired misses are looked up in the background. A slower lookup keeps filling the cache, and the cameras it had not reached keep the flat prior until the next 15-minute refresh (about 190 of 865 on one slow first run). Only coordinates are sent, with the same User-Agent as the other CCTV requests. `CCTV_VEGVESEN_HEIGHTS=0` turns the lookup off. The Kartverket credit, with a note that heights over water are set to 0 m and heights are rounded to 0.1 m, is shown beside the Statens vegvesen entry 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 `[email protected]` 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/<uuid>/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`.
|
||||
- **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.
|
||||
|
||||
@@ -309,6 +309,10 @@ _Why cockpit mode exists: you're riding a real aircraft over real terrain — an
|
||||
|
||||
## 🎙️ Talk to It
|
||||
|
||||
The standalone app offers **OpenAI** and **Gemini** in the GEV MIC provider chooser. OpenAI is the initial default; your choice is saved in this browser. To use Gemini, add `GEMINI_API_KEY` in Provider Settings or your local `.env` file, then select Gemini. `GEMINI_LIVE_MODEL` defaults to `gemini-3.8-live`. The permanent key stays on the local server, which issues a constrained, single-use session credential.
|
||||
|
||||
Changing provider ends the current conversation. If voice is on, the new provider starts with the same open-mic or push-to-talk mode; a held Space key is not carried over. Gemini uses the same map actions and captions, but has no OpenAI cost meter or MINI tier. When Gemini reports that its session has expired, start voice again for a new conversation. The provider chooser does not change the OpenAI-backed AI HUD summary.
|
||||
|
||||
> Voice can use an **OpenAI API key** or, on the same machine, an opt-in signed-in **ChatGPT/Codex OAuth** session. The API-key path remains the default. Selecting ChatGPT OAuth in Provider Settings reuses an existing local sign-in or starts the Codex browser sign-in flow and selects OAuth when it completes. The OpenAI key still drives the **AI HUD summary**: a terse, five-word intelligence-style readout of the current view that regenerates as you move.
|
||||
|
||||
The OAuth option is experimental. OpenAI documents ChatGPT plan usage for the [Responses API](https://developers.openai.com/siwc/token-sharing-open-source/models-and-inference); a supported third-party Realtime contract for reusing Codex tokens has not been confirmed. A successful token mint does not establish billing or support terms.
|
||||
@@ -377,7 +381,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, free developer key) · 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), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed) and opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent). 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 | 🟢 |
|
||||
@@ -385,7 +389,7 @@ Twenty layers and map sources. **Eighteen have a keyless path.** Some offer addi
|
||||
| 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 |
|
||||
| 🧭 **Directions** | Click A and B on the globe for a street-following drive, walk or cycle route draped on the terrain with turn-by-turn steps — then FLY the camera along it. No key, no geocoder, no mic | OSRM on FOSSGIS servers (OpenStreetMap) | 🟢 |
|
||||
| 🔥 **Active Fires** | Live NASA FIRMS detections, trailing 24h | NASA FIRMS | 🟡 |
|
||||
| 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🟡 optional token raises the allowance) |
|
||||
| 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🔴 optional paid token raises the allowance) |
|
||||
| 🎖️ **Mapped Installations** | Viewport-bounded military-site context from community mapping — incomplete by nature, and labeled that way | OpenStreetMap | 🟢 |
|
||||
| 🌬️ **Wind** | Animated 10 m forecast wind with an optional speed, temperature or pressure color field and a reading at map center | NOAA GFS / ECMWF IFS | 🟢 |
|
||||
| 🌦️ **Observed Weather** | Rain radar (contiguous US), Satellite clouds (GOES regional or global infrared, Clouds only or Full image) and Lightning density (Americas and Pacific) on one history timeline | NOAA nowCOAST | 🟢 |
|
||||
@@ -527,9 +531,11 @@ _What the TomTom key buys you: rush-hour density painted on the city — then di
|
||||
| | Key | Why | Get it |
|
||||
| --- | -------------------- | ------------------------------------------------------------- | -------------------------------------------------- |
|
||||
| 🟡 | **OpenSky** | ✈️ More flight-polling credits (🟢 anonymous works without) | [opensky-network.org](https://opensky-network.org) |
|
||||
| 🟡 | **Launch Library 2** | 🚀 Higher space-missions request allowance (🟢 works without) | [thespacedevs.com](https://thespacedevs.com) |
|
||||
| 🔴 | **Launch Library 2** | 🚀 Optional paid token for higher limits (🟢 no key needed for public access) | [thespacedevs.com/supportus](https://thespacedevs.com/supportus) |
|
||||
|
||||
Add these if you need higher polling allowances.
|
||||
Leave `LL2_API_TOKEN` blank for standard public access. LL2 tokens are optional
|
||||
paid Patreon upgrades for higher limits; there is no free API-key signup.
|
||||
|
||||
`npm run doctor` reports Node/npm readiness, the primary provider routes, and
|
||||
where each configured provider was found without printing credential values.
|
||||
|
||||
+1
-1
@@ -73,7 +73,7 @@ The data proxies under `server/providers/` are written so the browser cannot tur
|
||||
- **Sanitized errors** — internal error details are not echoed back to clients.
|
||||
- **Coalesced OAuth refresh** and cached successful responses only (OpenSky).
|
||||
- **Redacted debug logging.** The voice debug log (`.gev-logs/`, gitignored) strips API keys, bearer tokens, client secrets, and image data URLs before writing.
|
||||
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control.
|
||||
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. The keyless terrain route (`/api/terrain/heights`) uses the same cross-site checks, minus the forwarding-header refusal: it guards only the per-client quota and point cache, not a key or a bill, and the map needs it to render behind a reverse proxy.
|
||||
- **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval' blob:` plus the YouTube, Facebook and X scripts the Nepal event's embedded media loads and the Maps JavaScript API that Street View runs on, with a matching `frame-src` for their players and posts: no inline script and no other script origin; `blob:` covers the object URLs Cesium's bundled workers bootstrap through in the built app), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects `<style>` elements.
|
||||
|
||||
Allowing `maps.googleapis.com` in `script-src` also trusts its JSONP endpoints, which can bypass the script allowlist if an HTML-injection bug exists; this is an accepted limitation of loading the Maps JavaScript API, alongside the existing `'unsafe-eval'` allowance.
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
- `aircraft_in_area` and `vessels_in_area` take `group_by` and `stats`, so an
|
||||
MCP client can ask which airlines, countries, aircraft types or ship types
|
||||
dominate an area, or the average altitude or speed, over every match instead
|
||||
of estimating from the returned rows. Airlines come from the ICAO designator
|
||||
in each callsign (SWA1234 counts as SWA), so the breakdown works on feeds
|
||||
that report no operator; operator and type are counted only where the feed
|
||||
reports them. Ship types count by family, so AIS codes 70 to 79 are all
|
||||
cargo; type 0 ("not available") and the reserved codes 100 to 255 count as
|
||||
unknown. Altitude and speed stats cover airborne aircraft only, so parked
|
||||
aircraft do not pull the means down. Every vessel answer, `find_vessel`
|
||||
included, now reads AIS ship-type codes as their family and a destination
|
||||
with no letters as unknown (quanticsoul4772, #1056).
|
||||
@@ -0,0 +1,6 @@
|
||||
- Make CCTV QA ground-sampling checks respect the active terrain regime and
|
||||
frame the actual calibration gizmo for the drag regression, preserving the
|
||||
photoreal completion floor and all drag invariants.
|
||||
- Measure the tracking arrival regression on the first visible frame within
|
||||
250 ms, accounting for the 80 ms fleet cadence while rejecting a stale
|
||||
first-visible orientation instead of retrying until it is correct.
|
||||
@@ -0,0 +1,5 @@
|
||||
- Share Launch Library request cache keys across installations refreshing in the
|
||||
same UTC minute, identify the app to the provider, and explain that standard
|
||||
space-missions access needs no key. Optional higher-rate LL2 tokens are paid
|
||||
upgrades; existing local cache and server-only token handling are unchanged
|
||||
(rudycelekli, #1197).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Keep Space Missions marked STALE when Launch Library 2 falls back to cached
|
||||
launch data, and return to NOMINAL after a fresh response without discarding
|
||||
retained missions (rudycelekli, #1199).
|
||||
@@ -0,0 +1,5 @@
|
||||
- Color CCTV cameras with a video feed violet. Cameras with an HLS, MP4 or
|
||||
WebM feed were the same cyan as stills, so a DelDOT or Statens vegvesen
|
||||
video camera could only be found by opening it. Stills stay cyan and the
|
||||
active camera stays amber, and focus de-emphasis fades the violet the same
|
||||
way (Danielslay86, #1200).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Draw CCTV stills at their source aspect on the monitor plane instead of
|
||||
stretching them to the 16:9 canvas. Most DOT cameras publish 4:3 frames, so
|
||||
every still was widened; they are now centered and pillarboxed on the dark
|
||||
the placeholder already ends on (Danielslay86, #1201).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Avoid starting or queuing a reply when the local channel rejects a typed OpenAI
|
||||
command, while preserving an earlier accepted deferred input
|
||||
(Manjunath, #1220).
|
||||
@@ -0,0 +1,6 @@
|
||||
- Load Ontario 511 cameras again. 511on.ca's camera endpoint now requires a
|
||||
free developer key; set `ONTARIO_511_API_KEY` to enable the pack, which is
|
||||
otherwise skipped with one log line saying where to get a key
|
||||
(quanticsoul4772, #1228).
|
||||
- Refuse Ontario catalog redirects and keep credential-bearing URLs and provider
|
||||
exception text out of failure logs; retain the Ontario licence attribution.
|
||||
@@ -0,0 +1,5 @@
|
||||
- Harden the Tarktee (Estonia) DATEX2 camera catalog fetches: refuse redirects,
|
||||
abort pending siblings and cancel received or late response streams when either
|
||||
request fails before headers or overflows the 4 MB
|
||||
byte cap, retain the shared 30 s dual-XML fetch deadline, and deduplicate camera
|
||||
IDs before per-pack prioritization (yaka0007, #1233).
|
||||
@@ -0,0 +1,5 @@
|
||||
- Serve the camera catalog while it refreshes. An expired catalog made every
|
||||
camera request wait for the slowest pack to reload; it is now answered at
|
||||
once while one background refresh runs, so only the first load waits. Estonia's
|
||||
slow Tarktee feeds get 30 s instead of 15 s, so the pack no longer drops out
|
||||
on about a third of loads (quanticsoul4772, #1234).
|
||||
@@ -0,0 +1,9 @@
|
||||
- Add FL511 (Florida DOT) traffic cameras as an opt-in CCTV source pack:
|
||||
the statewide ArcGIS catalog, read a page at a time under a byte cap with
|
||||
redirects refused, and frames pinned to FL511's two image hosts over HTTPS.
|
||||
FL511's terms limit its content to individual, personal use, and any other
|
||||
use (a public deployment included) needs FDOT's express written consent, so
|
||||
the pack stays off unless `CCTV_FLORIDA_ENABLED=1`. Headings come from the
|
||||
catalog's direction codes, and camera ids come from the image channel, since
|
||||
the catalog's ID column repeats across regions. `CCTV_FLORIDA_MAX_SOURCES`
|
||||
sets the cap (Danielslay86, #605).
|
||||
@@ -0,0 +1,9 @@
|
||||
- Bound what a caller can grow through `/api/terrain/heights`. Every distinct
|
||||
5-decimal point was a permanent in-memory entry, a permanent line in an
|
||||
uncapped `.gev-cache/terrain-heights.json`, and a live upstream call, with
|
||||
nothing limiting the rate. The point cache now holds at most 50,000 entries
|
||||
and evicts oldest-first, a cache file above
|
||||
24 MB is not read back, and the route is limited to 90 requests/minute per
|
||||
client (270 global). Evicting a point costs one refetch, never a wrong
|
||||
answer, since terrain does not move. Ordinary use is unaffected: a full
|
||||
`test:track` run drives 10 requests, peaking at 8/min (daikaginza, #686).
|
||||
@@ -0,0 +1,7 @@
|
||||
- Close the compact Radio disclosure when an adaptive tactical right-rail
|
||||
restore directly expands Context, before Space Missions can be covered.
|
||||
A repeated Context-open request also clears stale disclosure state.
|
||||
Real PanelChrome constructor wiring and the rail reflow path are covered
|
||||
by focused regressions. Credits: kvnloo (#816), raysung-2026's original
|
||||
#804 report, asturksever's review, and quanticsoul4772's independent
|
||||
Windows diagnosis.
|
||||
@@ -0,0 +1,4 @@
|
||||
- Add optional Gemini Live voice to the standalone app, with a provider chooser,
|
||||
server-held API key, microphone streaming, shared map actions and captions.
|
||||
Switching providers ends the old conversation and preserves the input mode
|
||||
(Manjunath, #872).
|
||||
@@ -0,0 +1,6 @@
|
||||
- Extract a reusable model-free World Packet from the existing voice
|
||||
`get_current_view_state` owner without restoring disabled layers to the
|
||||
model context. Keep the enabled-only payload, disabled-layer count, source
|
||||
provenance, action result and view-state contract; read the layer roster once
|
||||
per packet; voice retains the speech/card envelope at its own boundary.
|
||||
Credits: original GEV voice/layer contributors and #880 implementation.
|
||||
@@ -0,0 +1,17 @@
|
||||
- Give each Statens vegvesen camera its own ground height from Kartverket's
|
||||
keyless point-height API (CC BY 4.0) instead of one flat 150 m prior, so
|
||||
mountain-pass cameras no longer sit hundreds of metres below the terrain
|
||||
where neither the point-height prior nor the ground snap resolves. About 850
|
||||
of ~855 cameras get a height; the rest keep the old prior. Requests carry 20
|
||||
points, the most the API answers from its 1 m terrain model, eight at a
|
||||
time; a cold lookup takes about 6–13 s. Cameras over water use the water
|
||||
surface rather than the sea-floor depth the API returns there; for bridge
|
||||
cameras that is still below the deck. Heights are cached for good and misses
|
||||
for 6 h, so a refresh only asks about new or moved cameras. After a
|
||||
failing request (a 408, 429 or 5xx, a network error, a request over 10 s or
|
||||
an unreadable body) the lookup stops and backs off (Retry-After, else
|
||||
30 min, longer than the refresh). The lane waits at most 12 s from its
|
||||
start, and only on a cold cache; a slower first lookup leaves the cameras it
|
||||
had not reached on the flat prior until the next 15-minute refresh, and
|
||||
keeps filling the cache meanwhile.
|
||||
`CCTV_VEGVESEN_HEIGHTS=0` turns it off. Follow-up to #929 (trygvehhh, #945).
|
||||
@@ -0,0 +1,8 @@
|
||||
- Cross-site pages can no longer fill the terrain-height cache or spend the
|
||||
visitor's own `/api/terrain/heights` rate-limit quota through the visitor's
|
||||
browser. The route refuses a cross-site `Sec-Fetch-Site` or a foreign or
|
||||
opaque `Origin` before rate limiting, cache access or upstream work. Unlike
|
||||
the cost-bearing routes it still answers same-origin requests that arrive
|
||||
through a reverse proxy, so terrain keeps rendering behind nginx, Caddy or
|
||||
Cloudflare instead of falling back to the geoid (Sunil56224972, daikaginza,
|
||||
#975).
|
||||
+82
-11
@@ -112,7 +112,14 @@ perimeters, terrain height, military installations and map features, plus a
|
||||
combined situation brief, military awareness around a point, the app's heads-up display caption, and a link that
|
||||
opens the app over an area. Tools reuse the layers' portable source factories, take a
|
||||
shared `area` argument (place name, bounding box, or point and radius) and cap
|
||||
lists at 25 rows by default. Voice offers the same queries next to its app
|
||||
lists at 25 rows by default. `aircraft_in_area` and `vessels_in_area` can also
|
||||
count every match by a field (`group_by`: airline, read from the ICAO
|
||||
designator in the callsign, or operator, country or type for aircraft; type or
|
||||
destination for ships; the ten largest groups, then other and unknown counts;
|
||||
the summary says when a feed reports none of the field) and summarize airborne
|
||||
altitude and speed (`stats`: count, min, median, mean, max).
|
||||
Both cover all matches, not just the rows returned; voice leaves these two
|
||||
tools out, so they reach MCP clients only. Voice offers the same queries next to its app
|
||||
actions: the session lists them, and the browser runs them through the same
|
||||
catalog, loaded on first use. See [tools and the MCP server](TOOLS.md).
|
||||
Aircraft altitude-band queries require a reported barometric or geometric altitude
|
||||
@@ -489,6 +496,11 @@ to layer toggles, fly_to_location, frame_overhead, select_nearest_aircraft,
|
||||
get_entity_context, annotate_map and analyst_query. Analyst speech and the card
|
||||
share one deterministic count/scope headline; lower bounds use "At least" and
|
||||
partial answers name the unanswered layers. `get_current_view_state` lists enabled layers only.
|
||||
The model-free `gods-eye-view/agent/world-packet` export exposes
|
||||
`getWorldPacket({ viewer, styleManager, dataManager, sceneDirector })` to
|
||||
non-voice consumers. It reads the same enabled-only state and feed
|
||||
provenance before voice adds speech/card fields, with no new network requests
|
||||
or mutations.
|
||||
The voice card nested in the mic control (`voiceCard.js`) shows captions, plan
|
||||
steps and the result. `resultDisplay.js` is the one adapter from a result to
|
||||
the card: builder results show as built, and analyst results show a title
|
||||
@@ -606,7 +618,9 @@ Reference feed construction is exported through `sources/reference`; the cable s
|
||||
|
||||
Source factories have dedicated `layers/<family>/source` exports. ALPR and earthquake record normalization and CCTV source policy no longer pull rendering into source consumers. Catalog construction and voice feed reads use their focused owners; compatibility entries remain available. Settings filesystem hardening lives under `server/standalone/`. Import-direction checks complement export ownership checks; source behavior, settings policy and rendering are unchanged.
|
||||
|
||||
Voice controls bind to a protocol-independent session factory. The default WebRTC adapter preserves the existing Realtime connection, push-to-talk, cost controls and radio handoff. Session subscriptions expose state, transcript, action call/progress/result, interruption, completion and turn-metrics events; stopping or replacing a session cancels pending actions. Alternate adapters can use the same controls and action runner.
|
||||
Voice controls bind to a protocol-independent session factory. The default WebRTC adapter preserves the existing Realtime connection, push-to-talk, cost controls and radio handoff. Session subscriptions expose state, transcript, action call/progress/result, interruption, completion and turn-metrics events; stopping or replacing a session cancels pending actions. An active session's normal Stop publishes one idle transition, including adapters that emit their own terminal state; a restart triggered by that transition keeps ownership of its new startup state. Alternate adapters can use the same controls and action runner.
|
||||
|
||||
The standalone app offers OpenAI and Gemini Live through an explicit provider chooser. Gemini keeps its permanent API key on the local server and mints a short-lived, single-use credential with server-owned instructions and the standalone action catalog. Its native WebSocket adapter supports open microphone and push-to-talk input, streaming audio, captions and the existing action runner. In push-to-talk, a tool call that arrives with no input, reply or tool result outstanding is answered with a failure and never runs, and neither does any other call in the reply it starts; open-microphone turns start on Gemini's server-side activity detection, which reports no boundary to check. Switching providers ends the old conversation and releases its microphone and connection; only the input mode carries over. Gemini uses bounded shutdown draining and explicit manual restart, without automatic reconnection or a promise of uninterrupted session duration. Hosts that do not opt into the provider facade retain their existing default or custom adapter.
|
||||
|
||||
Voice action argument schemas have one portable owner under `src/voice/actionSchemas.js`. The Realtime provider builds 30 tools using separate description-only metadata. Description customization cannot replace argument types, enum values or required fields.
|
||||
|
||||
@@ -812,7 +826,11 @@ position stay off the map. Genuine zero coordinates remain valid. Undisclosed
|
||||
stage coordinates, downrange distance and flight numbers remain unknown; the
|
||||
mission panel omits the flight-number label when no number was reported.
|
||||
Pending mission requests abort on disable/destruction and malformed launch
|
||||
snapshots preserve the last accepted display. Catalog groups, propagation cadence,
|
||||
snapshots preserve the last accepted display. A Launch Library stale-cache fallback
|
||||
retains its missions but reports STALE in Data Layers; a fresh snapshot clears that
|
||||
provenance. Hiding retained missions does not relabel them as fresh, and teardown
|
||||
clears the instance state. Injected getLaunches-only sources remain supported.
|
||||
Catalog groups, propagation cadence,
|
||||
tracking intent, replay timing, controls and source attribution remain unchanged.
|
||||
|
||||
The fire layer now exposes `./layers/firms`: an instance factory with separate
|
||||
@@ -1192,6 +1210,10 @@ Automatic collapse remains presentation only, prioritizes the latest explicit
|
||||
panel, and honors keyboard focus on the right. Mobile layout still releases
|
||||
desktop allocation styles. Stable Display allocation avoids unnecessary style
|
||||
writes. This extraction does not change panel positions or layout defaults.
|
||||
Context expansion through the panel owner or adaptive right-rail restoration
|
||||
closes an open compact Radio disclosure before Context/Space Missions becomes
|
||||
visible. The mini remains available while Context is collapsed. This fixes the
|
||||
Radio-overlap path reported in #804 without changing z-index or layout policy.
|
||||
The right rail skips panels hidden by other CSS (computed `display: none`), but
|
||||
keeps the collapsed panels its own exclusive presentation hides, so automatic
|
||||
collapse can still be undone.
|
||||
@@ -1441,6 +1463,18 @@ an empty dock. Explicit zero and caller-supplied numeric fallbacks remain valid.
|
||||
|
||||
## Terrain height cache bound
|
||||
|
||||
The `/api/terrain/heights` route refuses cross-site browser requests before
|
||||
rate limiting, cache access or upstream work: a `Sec-Fetch-Site` other than
|
||||
`same-origin`/`none` (an `<img>` on another page), or a foreign or opaque
|
||||
`Origin`, gets `403`. The per-client limiter keys on the socket address, which
|
||||
for such a request is the visitor's own browser, so without the gate any page
|
||||
could spend the visitor's 90/min quota and leave the app's own lookups on
|
||||
`429`. Unlike the cost-bearing routes, the terrain gate does not refuse
|
||||
reverse-proxy forwarding headers: terrain is keyless and needed for rendering,
|
||||
and a `403` there would send every chunk to the geoid fallback for an instance
|
||||
served behind the documented reverse proxy. Same-origin app requests,
|
||||
non-browser local callers and proxied same-origin requests keep working.
|
||||
|
||||
The terrain proxy parser rejects finite longitudes outside [-180, 180] and
|
||||
latitudes outside [-90, 90] for every client before upstream resolution. One
|
||||
out-of-range point rejects the whole batch; inclusive WGS84 bounds remain valid.
|
||||
@@ -1478,6 +1512,14 @@ The Node-only `gods-eye-view/server/providers/space` export supplies factories;
|
||||
`sources/space` supplies fixed upstream URL builders with no I/O or environment
|
||||
access. Callers retain validation, transport and response policy.
|
||||
|
||||
LL2 recent-launch requests use UTC minute-aligned bounds over the same 30-day
|
||||
window, so installations refreshing in the same minute share an upstream cache
|
||||
key. The upper bound trails the current time by less than one minute. Requests
|
||||
identify the app and repository through User-Agent; the existing 15-minute local
|
||||
cache, stale fallback and optional server-only Authorization remain unchanged.
|
||||
Provider Settings identifies LL2 tokens as optional paid higher-rate upgrades:
|
||||
standard public access needs no key, and its field can remain blank.
|
||||
|
||||
The shared TLE text parser skips incomplete named sets and resumes at the next
|
||||
complete set. A missing orbital line does not discard the later satellites in
|
||||
the catalog. A set whose two lines carry different catalog numbers, or a
|
||||
@@ -2664,6 +2706,17 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
|
||||
> tunings. Ambient overlap includes each sprite's own rendered extent. The
|
||||
> gated AIS, CCTV, and satellite passes retain an active-emphasis count so a
|
||||
> settled dim contact always completes its release after tracking ends.
|
||||
> - **Terrain-height bounds:** because every distinct 5-decimal point is its own
|
||||
> cache key, its own line in `.gev-cache/terrain-heights.json`, and its own
|
||||
> upstream call, four limits bound what a caller can grow. Coordinates outside
|
||||
> WGS-84 range are rejected with `400` at parse (finite is not the same as on
|
||||
> Earth); the point cache holds at most 50,000 entries, evicting oldest-first
|
||||
> — terrain does not move, so an evicted point costs one refetch and never a
|
||||
> wrong answer; a cache file above 24 MB is not read back into memory (the
|
||||
> ceiling `rocketLaunchesProxy` already applies); and the route carries an
|
||||
> always-on limiter of 90 requests/minute/client, 270 global. That cap is an
|
||||
> order of magnitude above measured demand — a full `npm run test:track` run
|
||||
> drove 10 requests, peaking at 8/min.
|
||||
> - **Terrain-height resilience:** `/api/terrain/heights` caches canonical
|
||||
> 5-decimal points individually, reconstructs reordered/overlapping batches
|
||||
> in exact request order, and refreshes only missing or stale points. Upstream
|
||||
@@ -3395,7 +3448,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, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + FL511 (FL, opt-in) + 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), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
|
||||
@@ -4366,7 +4419,7 @@ silently demoting every later lookup for the session.
|
||||
- **FIRMS**: no ground clamping (zero 3D-tiles height sampling), ≤18 screen-decluttered ambient labels, click-to-inspect detail card, 2.5k/3k sprite budgets viewport-clipped by FRP.
|
||||
- **CCTV v2 foundation:** a pitched
|
||||
frustum wireframe (4 corner rays + far-cap rectangle) with a monitor plane at the frustum's
|
||||
far cap, retargeting the existing video/canvas texture pipeline. Manual calibration only —
|
||||
far cap, retargeting the existing video/canvas texture pipeline. Stills are drawn at their source aspect, centered on the placeholder dark (`#01070c`), instead of being stretched to the 16:9 canvas. Manual calibration only —
|
||||
auto-calibration and the drape mesh pipeline are deleted. A one-shot activation obstruction
|
||||
probe (`pickFromRay` on camera activation, clamping the plane short of the first hit) remains;
|
||||
ground placement is superseded by the shared-floor v3 behavior below. Calibration persists to
|
||||
@@ -4384,20 +4437,38 @@ silently demoting every later lookup for the session.
|
||||
ground clamp now lifts the cap _center_ only so the wireframe stays a true pyramid welded to
|
||||
the plane (was a flattened fan / the ~47.5 m divergence — RESOLVED), re-selecting the active
|
||||
camera is a no-op (killed a click-flash), and texture swaps gate on canvas content (killed a
|
||||
periodic white flash). Coverage is now **metro-wide: 250 cameras** (`CCTV_AUSTIN_MAX_SOURCES`
|
||||
periodic white flash). Camera icons are cyan for stills, violet for cameras with a video feed, and amber for the active camera. Coverage is now **metro-wide: 250 cameras** (`CCTV_AUSTIN_MAX_SOURCES`
|
||||
default 36 → 250, hard bound 300), filtered to `camera_status === TURNED_ON` (~815 live of
|
||||
1,003 rows). City packs (2026-07-04): Caltrans (districts 4/7/11/3 — SF, LA, San Diego,
|
||||
Sacramento; cap 300) and TfL London JamCams (cap 250) join Austin (cap 250) as keyless default
|
||||
sources. Ontario 511 (2026-09-12) adds keyless highway cameras including Kitchener-area routes
|
||||
(cap 1,000, all enabled rows from the current ~944-camera catalog). All sources are RAW PRIOR
|
||||
sources. Ontario 511 (2026-09-12) adds highway cameras including Kitchener-area routes
|
||||
(cap 1,000, all enabled rows from the current ~944-camera catalog). The 511on.ca API now
|
||||
requires a developer key, so the pack needs `ONTARIO_511_API_KEY` and is skipped without one
|
||||
(#1228). All sources are RAW PRIOR
|
||||
poses, and the layer is stills-first.
|
||||
sources. Fintraffic Finland road weather cameras (2026-09-13; cap 300, `CCTV_FINTRAFFIC_MAX_SOURCES`,
|
||||
Fintraffic Finland road weather cameras (2026-09-13; cap 300, `CCTV_FINTRAFFIC_MAX_SOURCES`,
|
||||
kill switch `CCTV_FINTRAFFIC_ENABLED=0`) are the fourth pack: one keyless GeoJSON station list
|
||||
covering the whole country, where one _preset_ (a station's fixed view) is one camera — 806
|
||||
GATHERING stations carry 2,256 in-collection presets, prioritized to 300 against seven anchors
|
||||
on the main road spine. `CCTV_MAX_SOURCES` is a 4,000 catalog-wide ceiling shared round-robin
|
||||
across packs, so a lower global cap thins every region instead of starving the last pack.
|
||||
~1,100 cameras total, all RAW PRIOR poses, stills-first.
|
||||
~1,100 cameras total, all RAW PRIOR poses, stills-first. The catalog lives for 15 minutes; once
|
||||
it expires, `/api/cctv` keeps answering from it while one background refresh reloads every
|
||||
pack, so only the very first load waits for the slowest pack (a live first load took about
|
||||
22 s, and is bounded by Tarktee's 30 s). A background refresh that fails or comes back
|
||||
empty keeps the old catalog and is retried after another 15 minutes, not on the next
|
||||
request. Estonia's Tarktee DATEX feeds, which answer in 2 to 16.6 s, get 30 s
|
||||
(`TARKTEE_FETCH_TIMEOUT_MS`) where other packs get 15 s.
|
||||
Ontario catalog requests refuse redirects and log only a fixed diagnostic for
|
||||
transport/JSON failures, so reflected request URLs or keys cannot reach logs.
|
||||
Tarktee owns both XML requests with one 30-second deadline and shared abort;
|
||||
a failed header or body read cancels its sibling, including a response arriving
|
||||
after cleanup.
|
||||
Both Tarktee XML catalogs refuse redirects and have a 4 MiB per-document
|
||||
limit, enforced against declared length and streamed bytes. A rejected fetch,
|
||||
unsuccessful response, or body-read failure releases both available response
|
||||
bodies; read failures also cancel an active sibling reader. Cameras sharing
|
||||
an image number are deduplicated before the Tarktee pack cap is applied.
|
||||
- **CCTV v3 UX — viewshed + calibration gizmo** (built 2026-07-05 and field
|
||||
validated 2026-07-21): the COVERAGE toggle is a
|
||||
tri-state cycle `OFF → ON → VIEWSHED`; viewshed mode renders each visible camera's frustum
|
||||
@@ -4714,7 +4785,7 @@ and unreachable upstream (502/504) separately from road geometry.
|
||||
- **Every selectable layer writes that one slot** (2026-08-21). The tracking layers — flights, military, satellites — publish selection on their own awareness lane (`gev:awareness-subject-selected`, consumed by the readout card and the Contacts panel) and for the life of the voice tools never wrote the shared slot, so `scope:'selected'` silently answered `in_view` with a contact plainly selected on screen. They now call `selectTrackedSubjectContext` / `refreshTrackedSubjectContext` / `clearTrackedSubjectContext` (`src/data/contextStore.js`) on select / poll / deselect. **Invariants: (a)** that write path must NOT dispatch `gev:entity-selected` — tracking layers already own an event lane and a second one makes two surfaces fight over one subject; **(b)** exactly one record per tracking layer, because a frozen snapshot of a moved contact must never reach the visible-entity scan; **(c)** precedence is recency, not layer — one slot, last selection wins, so clicking an overlay entity supersedes a tracked plane as the voice subject while the plane stays tracked. Satellites refresh on the 1 s propagation beat, not per frame.
|
||||
- **Context-mode vocabulary is symmetric** (2026-08-21). `set_context_mode` accepts `contacts`; the internal id is `flights`. Every model-readable field (`mode`, `entering`, `priorMode`, nested `context`/`contextRollback`, and the transition diagnostic text) is reported in the accepted vocabulary, with the internal id preserved as `<field>Internal`; an absent secondary mode stays `null` rather than claiming to be `off`. Mapping lives in `src/contextModePolicy.js` so UI text and voice payloads cannot drift. Reporting the internal id made the model read `mode:'flights'` as "Contacts is off" and refuse to answer from the `contactsWindow` counts in the same payload.
|
||||
- **Analyst → track handoff carries a key, not just a label** (2026-08-21). `analyst_query` items include `icao24`/`mmsi` alongside the display `id`, and contact lookup uses the shared tiered ranking in `src/data/contactMatch.js`: hex exact → callsign exact → registration exact → callsign prefix → registration prefix → callsign substring → registration substring. The tiers keep an exact callsign ahead of a colliding registration regardless of feed order, and registrations compare separator-insensitively (`G-ABCD`/`GABCD`, `05-8152`/`058152`, `N123AB`/`N-123AB`).
|
||||
- **A typed command supersedes the turn it interrupts** (2026-08-21). `sendTextCommand` defers its `response.create` behind an active response instead of colliding with it, marks that response superseded so a late function call from it is refused rather than dispatched, and drops the old turn's queued follow-up. A refused call is still ANSWERED — a terminal `{ok:false, superseded:true}` `function_call_output` — because an unanswered `function_call` strands a pending call and deadlocks the model; the refusal creates no response of its own. A burst of typed commands coalesces into one response while keeping both conversation items.
|
||||
- **A typed command supersedes the turn it interrupts** (2026-08-21; hardened 2026-10-17). `sendTextCommand` defers its `response.create` behind an active response instead of colliding with it, marks that response superseded so a late function call from it is refused rather than dispatched, and drops the old turn's queued follow-up. A refused call is still ANSWERED — a terminal `{ok:false, superseded:true}` `function_call_output` — because an unanswered `function_call` strands a pending call and deadlocks the model; the refusal creates no response of its own. A burst of typed commands coalesces into one response while keeping both conversation items. If the local channel rejects the user item, that command requests no response and adds no deferred answer; earlier accepted deferred input remains queued. Interruption and pointer preparation still happen before the send attempt.
|
||||
- **Subject reconciliation across satellite catalog rebuilds** (2026-08-21). A dense↔core toggle or TLE refresh clears and repopulates the catalog, so the published subject is re-resolved against the new satrec; a subject that did NOT survive releases the slot. The per-frame refresh cannot do this itself — `_getTrackedFramePosition` returns early once the satellite has no catalog entry — so the reconcile runs at rebuild completion. An empty catalog is a rebuild in flight, not a disappearance.
|
||||
- **One aircraft-proximity snapshot** (2026-08-22; hardened 2026-09-30). `collectAircraftProximityWindow` (`src/data/militaryAwareness.js`) computes the Contacts panel cohort once. The voice analyst reads the exact retained snapshot and evaluation timestamp instead of re-scanning live layers, so the panel readout and spoken count for one centre cannot cross snapshots. **Invariant: do not re-derive a proximity count anywhere else.** Each layer retains 20,000 rows and samples one row beyond the cap; a saturated cohort reports `complete:false`, `truncated:true`, and is spoken as a lower bound. They diverged before because the panel read live billboard positions while the analyst used last-fix coordinates over a 2,000-record slice — 111 on screen, 15 spoken. Explicit regions, arbitrary points, and a radius different from the panel radius deliberately keep the general record engine. Entity-centred results carry `window: {engine:'contacts-window', centeredOn, radiusKm, flights, military, aircraft, complete, evaluatedAt}`.
|
||||
- **Centre precedence for nearby asks**: explicit place in the question > Contacts subject (a selected non-contact entity never silently becomes the centre) > an entity the user names > the current view, said aloud. Contacts active with no subject uses the view rather than reading an empty panel.
|
||||
|
||||
+2
-2
@@ -263,11 +263,11 @@ and the development server's internal routes. See SECURITY.md.
|
||||
| `get_earthquakes` | `earthquakes` | USGS M2.5+ events in the last 24 hours, strongest first |
|
||||
| `get_active_fires` | `fires` | NASA FIRMS detections in an area, highest radiative power first |
|
||||
| `get_recent_launches` | `launches` | Launch Library 2 launches in the last 30 days, newest first |
|
||||
| `aircraft_in_area` | `aircraft` | Aircraft in an area, nearest first; `military: true` reads the `military` feed |
|
||||
| `aircraft_in_area` | `aircraft` | Aircraft in an area, nearest first; `military: true` reads the `military` feed; `group_by` and `stats` count and summarize every match |
|
||||
| `find_aircraft` | `aircraft` | Aircraft anywhere by callsign, ICAO address or registration |
|
||||
| `get_aircraft_track` | `aircraft` | Recent positions of one aircraft, thinned to 200 points |
|
||||
| `get_aircraft_info` | `aircraft` | Aircraft type and registration, and flight route, from adsbdb |
|
||||
| `vessels_in_area` | `vessels` | Ships reported by AIS in an area, nearest first, optionally by type |
|
||||
| `vessels_in_area` | `vessels` | Ships reported by AIS in an area, nearest first, optionally by type; `group_by` and `stats` count and summarize every match |
|
||||
| `find_vessel` | `vessels` | Ships anywhere by MMSI, IMO number or name |
|
||||
| `get_vessel_track` | `vessels` | Recent positions of one ship, thinned to 200 points |
|
||||
| `next_satellite_pass` | `satellites` | Next pass over a place or point (default the ISS), with naked-eye visibility |
|
||||
|
||||
+2
-1
@@ -277,6 +277,7 @@
|
||||
"./tools/mcp": "./src/tools/mcp/index.js",
|
||||
"./tools/panel": "./src/tools/panel.js",
|
||||
"./tools/services": "./src/tools/services.js",
|
||||
"./view": "./src/view/index.js"
|
||||
"./view": "./src/view/index.js",
|
||||
"./agent/world-packet": "./src/agent/worldPacket.js"
|
||||
}
|
||||
}
|
||||
@@ -96,6 +96,7 @@
|
||||
"server/providers/space/launch-library.js",
|
||||
"src/data/spaceProviderRequests.js",
|
||||
"src/tooling/spaceProviders.test.mjs",
|
||||
"src/tooling/ll2Integration.test.mjs",
|
||||
"server/providers/terrain.js",
|
||||
"server/providers/traffic.js",
|
||||
"server/providers/firms.js",
|
||||
@@ -446,6 +447,7 @@
|
||||
"src/data/rocketLaunches.js",
|
||||
"src/layers/satellites/source.test.mjs",
|
||||
"src/layers/launches/source.test.mjs",
|
||||
"src/layers/launches/ingestion.test.mjs",
|
||||
"src/layers/installations/lifecycle.js",
|
||||
"src/layers/installations/viewport.js",
|
||||
"src/layers/installations/controls.js",
|
||||
@@ -555,6 +557,8 @@
|
||||
"src/data/cctvNswSource.test.mjs",
|
||||
"src/data/cctvTxdotSource.test.mjs",
|
||||
"src/layers/cctv/source.test.mjs",
|
||||
"src/layers/cctv/frames.test.mjs",
|
||||
"src/layers/cctv/rendering.test.mjs",
|
||||
"src/data/alprCameras.js",
|
||||
"src/data/alprCameras.test.mjs",
|
||||
"src/data/dataCredits.test.mjs",
|
||||
@@ -635,6 +639,7 @@
|
||||
"src/voice/control.js",
|
||||
"src/voice/realtimeBackend.js",
|
||||
"src/voice/realtimeBackend.test.mjs",
|
||||
"src/agent/worldPacket.test.mjs",
|
||||
"src/voice/layerSummary.js",
|
||||
"build/application-html.js",
|
||||
"src/app/sourceSlot.js",
|
||||
@@ -690,6 +695,7 @@
|
||||
"src/ui/shareRestoration.js",
|
||||
"src/ui/visualSettings.js",
|
||||
"src/ui/panelChrome.js",
|
||||
"src/ui/panelChrome.radioOwnership.test.mjs",
|
||||
"src/testSupport/readShellSource.mjs",
|
||||
"src/ui/stateOwners.test.mjs",
|
||||
"src/layers/flights/records.js",
|
||||
@@ -1026,6 +1032,7 @@
|
||||
"scripts/qa-context-arrivals.mjs",
|
||||
"src/tools/catalog.test.mjs",
|
||||
"src/tools/area.test.mjs",
|
||||
"src/tools/results.test.mjs",
|
||||
"src/tools/queries/queries.test.mjs",
|
||||
"src/tools/mcp/mcp.test.mjs",
|
||||
"src/tools/localServer.test.mjs",
|
||||
@@ -1062,5 +1069,6 @@
|
||||
"src/ui/flyToDataSource.test.mjs",
|
||||
"scripts/assemble-changelog.mjs",
|
||||
"src/tooling/changelogFragments.test.mjs",
|
||||
"src/data/gbfsProxyRateLimit.test.mjs"
|
||||
"src/data/gbfsProxyRateLimit.test.mjs",
|
||||
"src/terrainHeightsBounds.test.mjs"
|
||||
]
|
||||
@@ -114,7 +114,11 @@
|
||||
"exports": ["./server/providers/terrain"],
|
||||
"modules": [
|
||||
"server/providers/terrain.js",
|
||||
"src/data/terrainHeightsProxy.js"
|
||||
"src/data/terrainHeightsProxy.js",
|
||||
"server/providers/common/rate-limit.js",
|
||||
"src/sources/rateLimit.js",
|
||||
"server/providers/common/same-site.js",
|
||||
"src/localRequestGate.mjs"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -1040,6 +1044,7 @@
|
||||
"src/layers/vessels/tracking.js",
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/vesselLabels.js",
|
||||
"src/data/vesselTypes.js",
|
||||
"src/layers/vessels/records.js",
|
||||
"src/layers/vessels/recordPolicy.js",
|
||||
"src/layers/vessels/snapshotRenderer.js",
|
||||
@@ -1628,7 +1633,15 @@
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/ui/statusText.js"
|
||||
"src/ui/statusText.js",
|
||||
"src/voice/providerCommands.js",
|
||||
"src/voice/providerCapabilities.js",
|
||||
"src/voice/geminiSession.js",
|
||||
"src/voice/geminiAudio.js",
|
||||
"src/voice/geminiCaptureWorklet.js",
|
||||
"src/sources/httpBody.js",
|
||||
"src/sources/requestDeadline.js",
|
||||
"src/i18n/marker.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -1841,6 +1854,7 @@
|
||||
"src/data/trafficQueue.js",
|
||||
"src/data/trailRenderer.js",
|
||||
"src/data/vesselLabels.js",
|
||||
"src/data/vesselTypes.js",
|
||||
"src/firstRunExperience.js",
|
||||
"src/hud.js",
|
||||
"src/hudLocality.js",
|
||||
@@ -2215,6 +2229,7 @@
|
||||
"src/voice/commands.js",
|
||||
"src/voice/control.js",
|
||||
"src/voice/gevActions.js",
|
||||
"src/agent/worldPacket.js",
|
||||
"src/voice/speech.js",
|
||||
"src/ui/layerNames.js",
|
||||
"src/voice/earcon.js",
|
||||
@@ -2490,7 +2505,12 @@
|
||||
"src/layers/airQuality/index.js",
|
||||
"src/layers/airQuality/model.js",
|
||||
"src/layers/airQuality/source.js",
|
||||
"src/ui/statusText.js"
|
||||
"src/ui/statusText.js",
|
||||
"src/voice/providerCommands.js",
|
||||
"src/voice/providerCapabilities.js",
|
||||
"src/voice/geminiSession.js",
|
||||
"src/voice/geminiAudio.js",
|
||||
"src/voice/geminiCaptureWorklet.js"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
@@ -2629,6 +2649,7 @@
|
||||
"src/data/trafficQueue.js",
|
||||
"src/data/trailRenderer.js",
|
||||
"src/data/vesselLabels.js",
|
||||
"src/data/vesselTypes.js",
|
||||
"src/keylessGeocoder.js",
|
||||
"src/layers/aircraft/classification.js",
|
||||
"src/layers/alpr/index.js",
|
||||
@@ -3377,7 +3398,8 @@
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/ui/statusText.js"
|
||||
"src/ui/statusText.js",
|
||||
"src/voice/providerCapabilities.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -3782,6 +3804,7 @@
|
||||
"src/tools/queries/maritime.js",
|
||||
"src/tools/queries/share.js",
|
||||
"src/data/idSwitches.js",
|
||||
"src/data/vesselTypes.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/sources/gbfsStations.js",
|
||||
@@ -3943,5 +3966,18 @@
|
||||
"src/i18n/marker.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"agent-world-packet": {
|
||||
"exports": ["./agent/world-packet"],
|
||||
"modules": [
|
||||
"src/agent/worldPacket.js",
|
||||
"src/data/layerSnapshot.js",
|
||||
"src/data/feedState.js",
|
||||
"src/contextModePolicy.js",
|
||||
"src/loadingFeedback.js",
|
||||
"src/data/installationFeedback.js",
|
||||
"src/i18n/marker.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
}
|
||||
}
|
||||
@@ -326,8 +326,10 @@ async function main() {
|
||||
'src/data/local_data/cctv_ground_heights/cctv_ground_heights.json',
|
||||
);
|
||||
let sidecar = await loadSidecar(output);
|
||||
// A cold catalog waits for its slowest pack (Tarktee gets 30 s), so allow
|
||||
// more than that.
|
||||
const response = await fetch(`${base}/api/cctv/sources`, {
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Catalog HTTP ${response.status}`);
|
||||
const catalog = await response.json();
|
||||
|
||||
+269
-107
@@ -11,8 +11,10 @@
|
||||
* exactly once per camera ACTIVATION (the §9.1 obstruction probe) and
|
||||
* never again in steady state; shared mesh-floor sampling fires at most
|
||||
* once per coarse cell during the staggered geometry-load drain, and
|
||||
* actually COMPLETES once tiles load (>=1 real sample — the
|
||||
* ceiling alone would pass trivially at Δ=0 if the snap never finished),
|
||||
* actually COMPLETES once tiles load in photoreal mode (>=1 real sample
|
||||
* — the ceiling alone would pass trivially at Δ=0 if the snap never
|
||||
* finished). Globe mode instead proves the rendered mount uses a real
|
||||
* terrain height with zero mesh samples. Sampling
|
||||
* stays flat while idle, and stays flat for non-positional pose edits.
|
||||
* 2. Geometry contract: the 5-polyline frustum wireframe + far-cap plane
|
||||
* exist for the active camera, the plane's corners coincide with the
|
||||
@@ -139,6 +141,56 @@ function record(name, ok, detail) {
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// Each page.evaluate is bounded by puppeteer's `protocolTimeout` (default
|
||||
// 180s). Waits never come close: they poll from Node (below). The headroom is
|
||||
// for the few long evaluates, such as enabling the full ~3,700-camera catalog
|
||||
// on a real GPU, and matches the other qa-*.mjs harnesses.
|
||||
const PROTOCOL_TIMEOUT_MS = 300_000;
|
||||
|
||||
/**
|
||||
* Polls an in-page predicate from Node, one short page.evaluate per tick,
|
||||
* until it holds or the budget runs out. Used instead of page.waitForFunction
|
||||
* for two reasons: its default rAF polling stops in a minimised or hidden
|
||||
* headful window (timers keep running, rAF does not), and its result is one
|
||||
* CDP call that protocolTimeout cuts off, while the headful drain budget
|
||||
* reaches ~50 minutes. A predicate that throws fails the run instead of
|
||||
* silently spending the budget.
|
||||
* @param {import('puppeteer').Page} page
|
||||
* @param {Function} predicate - In-page predicate.
|
||||
* @param {object} opts
|
||||
* @param {number} opts.timeoutMs - Overall budget.
|
||||
* @param {number} [opts.intervalMs=250] - Delay between checks.
|
||||
* @param {unknown[]} [opts.args=[]] - Arguments passed to the predicate.
|
||||
* The predicate must return a boolean: its result crosses CDP by value.
|
||||
* @returns {Promise<boolean>} True if the predicate held within the budget.
|
||||
*/
|
||||
async function waitForPredicate(
|
||||
page,
|
||||
predicate,
|
||||
{ timeoutMs, intervalMs = 250, args = [] },
|
||||
) {
|
||||
// performance.now() is monotonic and doesn't advance while the machine
|
||||
// sleeps, so neither a clock change nor a laptop sleep eats the budget.
|
||||
const deadline = performance.now() + timeoutMs;
|
||||
for (;;) {
|
||||
try {
|
||||
if (await page.evaluate(predicate, ...args)) return true;
|
||||
} catch (err) {
|
||||
// A navigation or Vite reload replaces the execution context: poll again.
|
||||
if (
|
||||
!/Execution context was destroyed|Cannot find context/.test(
|
||||
err?.message ?? '',
|
||||
)
|
||||
) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
const remaining = deadline - performance.now();
|
||||
if (remaining <= 0) return false;
|
||||
await sleep(Math.min(intervalMs, remaining));
|
||||
}
|
||||
}
|
||||
|
||||
// SwiftShader can spend tens of seconds servicing the hover drillPick emitted
|
||||
// by page.mouse.move(), before the LEFT_DOWN under test even runs. Position the
|
||||
// pointer while ADJUST is disabled, then re-enable it and explicitly render a
|
||||
@@ -155,29 +207,58 @@ async function positionPointerForGizmoDrag(page, point, frameCount = 3) {
|
||||
gev.dataManager.layers
|
||||
.get('cctv')
|
||||
.module.setParams({ calibrationMode: true });
|
||||
const scene = gev.viewer.scene;
|
||||
let rendered = 0;
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (complete) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
remove();
|
||||
resolve(complete);
|
||||
};
|
||||
const remove = scene.postRender.addEventListener(() => {
|
||||
rendered += 1;
|
||||
if (rendered >= count) {
|
||||
finish(true);
|
||||
return;
|
||||
}
|
||||
return (await window.__qaRenderFrames(count, 5000)) >= count;
|
||||
}, frameCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves a screenshot for human review. These are never a gate, so a capture
|
||||
* that fails (for example when the renderer stalls in a hidden headful window)
|
||||
* is logged instead of ending the run.
|
||||
* @param {import('puppeteer').Page} page
|
||||
* @param {string} file
|
||||
* @param {string} label
|
||||
*/
|
||||
async function saveReviewShot(page, file, label) {
|
||||
try {
|
||||
await page.screenshot({ path: file });
|
||||
console.log(` screenshot (${label}) → ${path.relative(REPO_ROOT, file)}`);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
` screenshot skipped (${label}): ${path.relative(REPO_ROOT, file)}: ${err?.message ?? err}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs window.__qaRenderFrames(count, capMs) in the page: requests renders
|
||||
* until `count` frames have passed scene.postRender or capMs runs out, and
|
||||
* resolves with the number of frames rendered. The cap keeps a stalled
|
||||
* renderer (headful window asleep or hidden) from hanging the CDP call.
|
||||
* @param {import('puppeteer').Page} page
|
||||
*/
|
||||
function installRenderFramesHelper(page) {
|
||||
return page.evaluate(() => {
|
||||
window.__qaRenderFrames = (count, capMs) =>
|
||||
new Promise((resolve) => {
|
||||
const scene = window.__godsEyeView.viewer.scene;
|
||||
let frames = 0;
|
||||
const finish = () => {
|
||||
clearTimeout(timer);
|
||||
remove();
|
||||
resolve(frames);
|
||||
};
|
||||
const remove = scene.postRender.addEventListener(() => {
|
||||
if (++frames >= count) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
scene.requestRender();
|
||||
});
|
||||
const timer = setTimeout(finish, capMs);
|
||||
scene.requestRender();
|
||||
});
|
||||
const timer = setTimeout(() => finish(false), 5000);
|
||||
scene.requestRender();
|
||||
});
|
||||
}, frameCount);
|
||||
});
|
||||
}
|
||||
|
||||
// In-page helper (stringified and re-defined inside each evaluate that needs
|
||||
@@ -280,23 +361,21 @@ const BEARING_SHIFT_SRC = `
|
||||
* @returns {Promise<boolean>} True if tilesLoaded was observed within the timeout.
|
||||
*/
|
||||
function waitForTilesLoaded(page, timeoutMs = 15000) {
|
||||
return page
|
||||
.waitForFunction(
|
||||
() => {
|
||||
const scene = window.__godsEyeView.viewer.scene;
|
||||
const prims = scene.primitives;
|
||||
for (let i = 0; i < prims.length; i++) {
|
||||
const p = prims.get(i);
|
||||
if (p && p.constructor && p.constructor.name === 'Cesium3DTileset') {
|
||||
return p.tilesLoaded === true;
|
||||
}
|
||||
return waitForPredicate(
|
||||
page,
|
||||
() => {
|
||||
const scene = window.__godsEyeView.viewer.scene;
|
||||
const prims = scene.primitives;
|
||||
for (let i = 0; i < prims.length; i++) {
|
||||
const p = prims.get(i);
|
||||
if (p && p.constructor && p.constructor.name === 'Cesium3DTileset') {
|
||||
return p.tilesLoaded === true;
|
||||
}
|
||||
return true; // no tileset in the scene — nothing to wait for
|
||||
},
|
||||
{ timeout: timeoutMs },
|
||||
)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
}
|
||||
return true; // no tileset in the scene — nothing to wait for
|
||||
},
|
||||
{ timeoutMs },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -364,6 +443,10 @@ async function main() {
|
||||
const chromeExecutable = findChromeExecutable();
|
||||
const browser = await puppeteer.launch({
|
||||
headless: HEADFUL ? false : 'new',
|
||||
protocolTimeout: PROTOCOL_TIMEOUT_MS,
|
||||
// Talk CDP over a pipe, so no debugging port stays open on localhost for
|
||||
// the length of a headful run.
|
||||
pipe: true,
|
||||
...(chromeExecutable ? { executablePath: chromeExecutable } : {}),
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
@@ -397,13 +480,18 @@ async function main() {
|
||||
});
|
||||
console.log('Loading app...');
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.waitForFunction(
|
||||
const appReady = await waitForPredicate(
|
||||
page,
|
||||
() =>
|
||||
window.__godsEyeView &&
|
||||
window.__godsEyeView.viewer &&
|
||||
window.__godsEyeView.dataManager,
|
||||
{ timeout: 60000 },
|
||||
Boolean(
|
||||
window.__godsEyeView &&
|
||||
window.__godsEyeView.viewer &&
|
||||
window.__godsEyeView.dataManager,
|
||||
),
|
||||
{ timeoutMs: 60000 },
|
||||
);
|
||||
if (!appReady) throw new Error('app did not initialise within 60s');
|
||||
await installRenderFramesHelper(page);
|
||||
// Let the initial fly-to Austin and first tiles settle.
|
||||
await sleep(4000);
|
||||
|
||||
@@ -475,18 +563,15 @@ async function main() {
|
||||
console.log(
|
||||
`Waiting for geometry-load queue to drain (N=${camCount}, budget ${Math.round(drainBudgetMs / 1000)}s)...`,
|
||||
);
|
||||
const drained = await page
|
||||
.waitForFunction(
|
||||
() => {
|
||||
const mod =
|
||||
window.__godsEyeView.dataManager.layers.get('cctv').module;
|
||||
const ui = mod.getUIState();
|
||||
return ui.loading && ui.loading.active === false;
|
||||
},
|
||||
{ timeout: drainBudgetMs },
|
||||
)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
const drained = await waitForPredicate(
|
||||
page,
|
||||
() => {
|
||||
const mod = window.__godsEyeView.dataManager.layers.get('cctv').module;
|
||||
const ui = mod.getUIState();
|
||||
return Boolean(ui.loading) && ui.loading.active === false;
|
||||
},
|
||||
{ timeoutMs: drainBudgetMs, intervalMs: 1000 },
|
||||
);
|
||||
record(
|
||||
`geometry-load queue drains within ${Math.round(drainBudgetMs / 1000)}s (N=${camCount})`,
|
||||
drained ? true : HEADFUL ? false : null,
|
||||
@@ -575,27 +660,87 @@ async function main() {
|
||||
// (existence, not just ceiling). The wait below tolerates the update-tick
|
||||
// latency of the completion pass (update interval is 10s).
|
||||
// -----------------------------------------------------------------------
|
||||
console.log('Waiting for tiles + the one-shot completion pass...');
|
||||
const tilesSeenLoaded = await waitForTilesLoaded(page, 45000);
|
||||
let sampleFloorOk = false;
|
||||
if (tilesSeenLoaded) {
|
||||
sampleFloorOk = await page
|
||||
.waitForFunction(
|
||||
(base) => window.__qaCounters.sampleHeight - base >= 1,
|
||||
{ timeout: 30000 },
|
||||
c0.sampleHeight,
|
||||
)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
}
|
||||
const cFloor = await readCounters();
|
||||
record(
|
||||
'>=1 real ground sample once tilesLoaded observed (one-shot snap completed)',
|
||||
tilesSeenLoaded ? sampleFloorOk : null,
|
||||
tilesSeenLoaded
|
||||
? `total real samples since enable=${cFloor.sampleHeight - c0.sampleHeight}`
|
||||
: 'tilesLoaded never observed within 45s — floor not assessable this run',
|
||||
const globeMode = await page.evaluate(
|
||||
() => window.__godsEyeView.viewer.scene.globe?.show !== false,
|
||||
);
|
||||
if (globeMode) {
|
||||
// Globe stacks intentionally perform no scene.sampleHeight query.
|
||||
// Require independently fetched DEM heights and the actual rendered
|
||||
// mount, so a catalog/geoid fallback cannot turn zero samples into a pass.
|
||||
console.log(
|
||||
'Checking terrain-globe completion against live DEM heights...',
|
||||
);
|
||||
const terrain = await page.evaluate(async () => {
|
||||
const camera = window.__godsEyeView.dataManager.layers
|
||||
.get('cctv')
|
||||
.module.getUIState().activeCamera;
|
||||
const exact = `${camera.lon.toFixed(5)},${camera.lat.toFixed(5)}`;
|
||||
const cell = `${camera.lon.toFixed(3)},${camera.lat.toFixed(3)}`;
|
||||
const response = await fetch(
|
||||
`/api/terrain/heights?points=${encodeURIComponent(`${exact};${cell}`)}`,
|
||||
{ signal: AbortSignal.timeout(30000) },
|
||||
);
|
||||
const body = await response.json();
|
||||
return { ok: response.ok, results: body.results };
|
||||
});
|
||||
const heights = terrain.results?.map((result) => result?.ellipsoid);
|
||||
const terrainReady =
|
||||
terrain.ok && heights?.length === 2 && heights.every(Number.isFinite);
|
||||
const mountResolved =
|
||||
terrainReady &&
|
||||
(await waitForPredicate(
|
||||
page,
|
||||
(expected, base) => {
|
||||
const { viewer, dataManager } = window.__godsEyeView;
|
||||
const camera = dataManager.layers
|
||||
.get('cctv')
|
||||
.module.getUIState().activeCamera;
|
||||
const ray = viewer.entities.getById(`cctv-${camera.id}-ray-tl`);
|
||||
const mount = ray?.polyline?.positions.getValue(
|
||||
viewer.clock.currentTime,
|
||||
)?.[0];
|
||||
const height =
|
||||
mount &&
|
||||
viewer.scene.globe.ellipsoid.cartesianToCartographic(mount)
|
||||
.height;
|
||||
return (
|
||||
Number.isFinite(camera.groundPriorM) &&
|
||||
Math.abs(camera.groundPriorM - expected[0]) < 0.01 &&
|
||||
Number.isFinite(height) &&
|
||||
expected.some(
|
||||
(ground) =>
|
||||
Math.abs(height - ground - camera.mountHeightM) < 0.05,
|
||||
) &&
|
||||
window.__qaCounters.sampleHeight === base
|
||||
);
|
||||
},
|
||||
{ timeoutMs: 30000, args: [heights, c0.sampleHeight] },
|
||||
));
|
||||
record(
|
||||
'terrain-globe completion uses a real DEM height at the rendered mount without mesh samples',
|
||||
Boolean(mountResolved),
|
||||
`terrain=${JSON.stringify(heights)}, mesh sample Δ=${(await readCounters()).sampleHeight - c0.sampleHeight}`,
|
||||
);
|
||||
} else {
|
||||
console.log('Waiting for tiles + the one-shot completion pass...');
|
||||
const tilesSeenLoaded = await waitForTilesLoaded(page, 45000);
|
||||
let sampleFloorOk = false;
|
||||
if (tilesSeenLoaded) {
|
||||
sampleFloorOk = await waitForPredicate(
|
||||
page,
|
||||
(base) => window.__qaCounters.sampleHeight - base >= 1,
|
||||
{ timeoutMs: 30000, args: [c0.sampleHeight] },
|
||||
);
|
||||
}
|
||||
const cFloor = await readCounters();
|
||||
record(
|
||||
'>=1 real ground sample once tilesLoaded observed (one-shot snap completed)',
|
||||
tilesSeenLoaded ? sampleFloorOk : null,
|
||||
tilesSeenLoaded
|
||||
? `total real samples since enable=${cFloor.sampleHeight - c0.sampleHeight}`
|
||||
: 'tilesLoaded never observed within 45s — floor not assessable this run',
|
||||
);
|
||||
}
|
||||
|
||||
// Let the completion pass finish before the steady-state window: require
|
||||
// the sampleHeight counter quiet for 12s (> the 10s update interval, so
|
||||
@@ -1358,18 +1503,7 @@ async function main() {
|
||||
},
|
||||
});
|
||||
window.__qaCctvSiblingOwner = owner;
|
||||
await new Promise((resolve) => {
|
||||
let frames = 0;
|
||||
const remove = scene.postRender.addEventListener(() => {
|
||||
if (++frames < 3) {
|
||||
scene.requestRender();
|
||||
return;
|
||||
}
|
||||
remove();
|
||||
resolve();
|
||||
});
|
||||
scene.requestRender();
|
||||
});
|
||||
const framesRendered = await window.__qaRenderFrames(3, 10000);
|
||||
const rect = scene.canvas.getBoundingClientRect();
|
||||
const picked = scene.pick(canvasPoint);
|
||||
const pickedId = picked?.id?.id ?? picked?.id ?? null;
|
||||
@@ -1382,26 +1516,33 @@ async function main() {
|
||||
typeof pickedId === 'string' ? pickedId : (pickedId?.id ?? null),
|
||||
ownerId: owner.id,
|
||||
ownsExactObject: picked?.id === owner,
|
||||
framesRendered,
|
||||
projectionDelta: Math.hypot(
|
||||
projected.x - canvasPoint.x,
|
||||
projected.y - canvasPoint.y,
|
||||
),
|
||||
};
|
||||
}, emptyClickPoint);
|
||||
const siblingOwnsPick =
|
||||
siblingTarget?.pickedId === 'qa-cctv-sibling-owner' &&
|
||||
siblingTarget.ownsExactObject;
|
||||
// No frame at all within the 10s cap points at a stalled renderer rather
|
||||
// than at picking, though the harness can't rule out both. Fail it on the
|
||||
// real-GPU sign-off path; leave it inconclusive under headless GL. A slow
|
||||
// SwiftShader frame (1-2 of 3) still counts as a real miss.
|
||||
const siblingRendererStalled =
|
||||
siblingTarget && !siblingOwnsPick && siblingTarget.framesRendered === 0;
|
||||
record(
|
||||
'sibling test object owns its canvas pick',
|
||||
!siblingTarget
|
||||
? null
|
||||
: siblingOwnsPick ||
|
||||
(siblingRendererStalled && !HEADFUL ? null : false),
|
||||
siblingTarget
|
||||
? siblingTarget.pickedId === 'qa-cctv-sibling-owner' &&
|
||||
siblingTarget.ownsExactObject
|
||||
: null,
|
||||
siblingTarget
|
||||
? `picked=${siblingTarget.pickedId} exactOwner=${siblingTarget.ownsExactObject} projectionΔ=${siblingTarget.projectionDelta}`
|
||||
? `${siblingRendererStalled ? 'renderer stalled: ' : ''}frames=${siblingTarget.framesRendered}/3 picked=${siblingTarget.pickedId} exactOwner=${siblingTarget.ownsExactObject} projectionΔ=${siblingTarget.projectionDelta}`
|
||||
: 'no previously verified empty canvas ray was available',
|
||||
);
|
||||
if (
|
||||
siblingTarget?.pickedId === 'qa-cctv-sibling-owner' &&
|
||||
siblingTarget.ownsExactObject
|
||||
) {
|
||||
if (siblingOwnsPick) {
|
||||
await page.mouse.click(siblingTarget.x, siblingTarget.y);
|
||||
await sleep(250);
|
||||
}
|
||||
@@ -1426,8 +1567,7 @@ async function main() {
|
||||
});
|
||||
record(
|
||||
'sibling canvas click passes through without CCTV selection or deselection',
|
||||
siblingTarget?.pickedId === 'qa-cctv-sibling-owner' &&
|
||||
siblingTarget.ownsExactObject
|
||||
siblingOwnsPick
|
||||
? siblingClick.activeId === clickEvidenceSetup.activeId &&
|
||||
siblingClick.transitions === siblingClick.baselineTransitions &&
|
||||
siblingClick.focusEvents === 0 &&
|
||||
@@ -1626,10 +1766,7 @@ async function main() {
|
||||
);
|
||||
|
||||
const viewshedShot = path.join(SHOTS_DIR, 'cctv-v3-viewshed.png');
|
||||
await page.screenshot({ path: viewshedShot });
|
||||
console.log(
|
||||
` screenshot (visual review only) → ${path.relative(REPO_ROOT, viewshedShot)}`,
|
||||
);
|
||||
await saveReviewShot(page, viewshedShot, 'visual review only');
|
||||
|
||||
// =========================================================================
|
||||
// Group 7: calibration gizmo (v3 design §3c — ADJUST mode + drag)
|
||||
@@ -1660,6 +1797,34 @@ async function main() {
|
||||
gizmoStates.map((s, i) => `${GIZMO_PARTS[i]}=${s}`).join(' '),
|
||||
);
|
||||
|
||||
// Frame the actual gizmo for the drag fixture. Default autofocus can leave
|
||||
// its shaft below the 10px interaction threshold; that distant view is not
|
||||
// the drag-usability scenario this regression exercises.
|
||||
const dragFraming = await page.evaluate(async () => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const e = viewer.entities.getById('cctv-gizmo-move-east');
|
||||
if (!e) return null;
|
||||
const pts = e.polyline.positions.getValue(viewer.clock.currentTime);
|
||||
const target = pts?.[0];
|
||||
if (!target) return null;
|
||||
const measure = () => {
|
||||
const a = viewer.scene.cartesianToCanvasCoordinates(pts[0]);
|
||||
const b = viewer.scene.cartesianToCanvasCoordinates(pts[pts.length - 1]);
|
||||
return {
|
||||
shaftPx: a && b ? Math.hypot(b.x - a.x, b.y - a.y) : null,
|
||||
rangeM: target.constructor.distance(viewer.camera.positionWC, target),
|
||||
};
|
||||
};
|
||||
const before = measure();
|
||||
viewer.camera.cancelFlight();
|
||||
viewer.camera.lookAt(target, new target.constructor(0, -200, 180));
|
||||
viewer.camera.lookAtTransform(viewer.camera.transform.constructor.IDENTITY);
|
||||
viewer.scene.requestRender();
|
||||
const frames = await window.__qaRenderFrames(3, 10000);
|
||||
return { before, after: measure(), frames };
|
||||
});
|
||||
console.log('Drag fixture framing:', JSON.stringify(dragFraming));
|
||||
|
||||
// Synthetic CDP drag on the EAST arrow — the owner's field-test
|
||||
// regression. Sample effective elevation after every mouse move: it must
|
||||
// stay frozen until release, with no transient sampleHeight calls.
|
||||
@@ -1963,10 +2128,7 @@ async function main() {
|
||||
// -----------------------------------------------------------------------
|
||||
await sleep(1000);
|
||||
const shot = path.join(SHOTS_DIR, 'cctv-v2-active.png');
|
||||
await page.screenshot({ path: shot });
|
||||
console.log(
|
||||
` screenshot (visual review only, not a gate) → ${path.relative(REPO_ROOT, shot)}`,
|
||||
);
|
||||
await saveReviewShot(page, shot, 'visual review only, not a gate');
|
||||
|
||||
// No console errors during the whole exercise.
|
||||
record(
|
||||
|
||||
@@ -298,11 +298,12 @@ async function main() {
|
||||
);
|
||||
console.log('PASS: header drag lifts CCTV out of the rail');
|
||||
|
||||
// Park near the top: the voice dock would cover the south corners once
|
||||
// the loop below has grown the window.
|
||||
// Park above and right of the voice dock and its raised provider chooser:
|
||||
// growing every edge must leave the south handles clear of both controls.
|
||||
{
|
||||
const panel = await readPanel();
|
||||
const header = await headerPoint();
|
||||
await drag(header, 0, 120 - header.y);
|
||||
await drag(header, 900 - panel.left, 120 - header.y);
|
||||
}
|
||||
|
||||
const limits = {
|
||||
|
||||
@@ -2696,9 +2696,9 @@ async function main() {
|
||||
// Staged deterministically: park the camera nadir over the synthetic
|
||||
// cluster, let rotations settle, TAMPER a billboard's rotation, then
|
||||
// trigger each path with the camera idle. The idle-drift catch-up
|
||||
// (ROTATION_REFRESH_MS = 1 s) can coincidentally mask a regression in a
|
||||
// ~300 ms SwiftShader frame window, so a regression shows as an
|
||||
// intermittent (not guaranteed) failure — never a false failure.
|
||||
// (ROTATION_REFRESH_MS = 1 s) must not repair a stale first-visible nose
|
||||
// before we inspect it: capture that first postRender, with a 250 ms
|
||||
// deadline that admits the 80 ms fleet cadence without waiting a second.
|
||||
// ============================================================
|
||||
console.log('\nChange 4 — arrival rotation: moveEnd settle pass + horizon-reveal pass');
|
||||
const arrival = await evalPage(async () => {
|
||||
@@ -2762,6 +2762,43 @@ async function main() {
|
||||
});
|
||||
await nextFrames(3);
|
||||
const angDiff = (a, b) => Math.abs(Math.atan2(Math.sin(a - b), Math.cos(a - b)));
|
||||
// Render frames are not fleet ticks: at 60/120 Hz four frames can
|
||||
// precede the next 80 ms fleet pass. Observe elapsed time instead, and
|
||||
// freeze the angle on the FIRST visible frame, even when it is wrong.
|
||||
const observeFirstReveal = (bb, course, budgetMs = 250) => new Promise((resolve) => {
|
||||
const startedAt = performance.now();
|
||||
let frames = 0;
|
||||
let settled = false;
|
||||
const finish = (shown, elapsedMs) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
remove();
|
||||
const expected = shown
|
||||
? screenProjectedRotation(v.scene, bb.position, course, null)
|
||||
: null;
|
||||
resolve({
|
||||
shown,
|
||||
flipFrame: shown ? frames : -1,
|
||||
revealElapsedMs: elapsedMs,
|
||||
revealWithinBudget: shown && elapsedMs <= budgetMs,
|
||||
dReveal: shown && Number.isFinite(expected) ? angDiff(bb.rotation, expected) : null,
|
||||
});
|
||||
};
|
||||
const remove = v.scene.postRender.addEventListener(() => {
|
||||
frames += 1;
|
||||
const elapsedMs = performance.now() - startedAt;
|
||||
if (bb.show) {
|
||||
finish(true, elapsedMs);
|
||||
} else if (elapsedMs >= budgetMs) {
|
||||
finish(false, elapsedMs);
|
||||
} else {
|
||||
v.scene.requestRender();
|
||||
}
|
||||
});
|
||||
const timer = setTimeout(() => finish(false, performance.now() - startedAt), budgetMs);
|
||||
v.scene.requestRender();
|
||||
});
|
||||
const probe = async (id, course) => {
|
||||
const bb = findBB(id);
|
||||
if (!bb || !bb.show) return { error: `${id} missing/hidden` };
|
||||
@@ -2779,25 +2816,17 @@ async function main() {
|
||||
const expectedMoveEnd = screenProjectedRotation(v.scene, bb.position, course, null);
|
||||
// (b) reveal pass: tamper + hide — the next fleet tick must flip it
|
||||
// visible AND correct the nose in that same tick (camera still idle,
|
||||
// no moveEnd raised). Diagnostic fields (round 5): record WHICH frame
|
||||
// the flip landed on (flipFrame; -1 = never within the extended
|
||||
// window) and whether a 3D model owns this id — distinguishes "tick
|
||||
// slow under load" from "occluder says hidden" from "model handoff".
|
||||
// no moveEnd raised). Record frame count as diagnostics only: the
|
||||
// first visible postRender must already have the corrected rotation.
|
||||
bb.rotation = r0 + 1.5;
|
||||
bb.show = false;
|
||||
let flipFrame = -1;
|
||||
for (let f = 1; f <= 6; f++) {
|
||||
await nextFrames(1);
|
||||
if (bb.show && flipFrame === -1) flipFrame = f;
|
||||
if (f >= 2 && flipFrame !== -1) break;
|
||||
}
|
||||
const expectedReveal = screenProjectedRotation(v.scene, bb.position, course, null);
|
||||
const reveal = await observeFirstReveal(bb, course);
|
||||
const hasModel = !!(window.__g3dFindModel && window.__g3dFindModel(id));
|
||||
return {
|
||||
r0,
|
||||
dMoveEnd: angDiff(afterMoveEnd, expectedMoveEnd),
|
||||
dReveal: angDiff(bb.rotation, expectedReveal),
|
||||
shown: bb.show, flipFrame, hasModel,
|
||||
...reveal,
|
||||
hasModel,
|
||||
};
|
||||
};
|
||||
const flightCourse = window.__SYNTH.flights.find((f) => f.icao === 'aaa002')?.track ?? 0;
|
||||
@@ -2808,20 +2837,18 @@ async function main() {
|
||||
};
|
||||
});
|
||||
const fmtArr = (a) => (!a ? `no result (${arrival?.error || 'phase error'})` : a.error ? a.error
|
||||
: `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° reveal-err=${(a.dReveal * 180 / Math.PI).toFixed(1)}° shown=${a.shown} flipFrame=${a.flipFrame} hasModel=${a.hasModel}`);
|
||||
: `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° reveal-err=${a.dReveal === null ? 'unobserved' : (a.dReveal * 180 / Math.PI).toFixed(1) + '°'} shown=${a.shown} flipFrame=${a.flipFrame} revealMs=${a.revealElapsedMs.toFixed(1)} hasModel=${a.hasModel}`);
|
||||
const TAMPER_DEG = 1.5 * 180 / Math.PI; // ≈ 86° — an uncorrected nose stays this far off
|
||||
// Reveal budget re-pinned 2 → 4 frames (round 5): the diagnostic probe
|
||||
// shows the military flip consistently lands on frame 2 — the exact edge
|
||||
// of the old budget — so any load hiccup flaked it (flights lands frame
|
||||
// 1). The regression this protects against is a reveal that waits for
|
||||
// the 1 s idle catch-up (~30-60 frames) or never re-aims; 4 frames still
|
||||
// catches that with a wide margin, with zero-headroom flakes gone.
|
||||
// 250 ms spans multiple 80 ms fleet opportunities while remaining well
|
||||
// below the 1 s idle catch-up. Never retry until the angle becomes correct:
|
||||
// dReveal is the immutable first-visible-frame observation above.
|
||||
const arrOk = (a) => !!a && !a.error && a.shown
|
||||
&& (a.flipFrame === -1 ? false : a.flipFrame <= 4)
|
||||
&& a.revealWithinBudget && a.revealElapsedMs <= 250
|
||||
&& a.dReveal !== null
|
||||
&& a.dMoveEnd * 180 / Math.PI < 10 && a.dReveal * 180 / Math.PI < 10;
|
||||
record(`arrival: moveEnd + reveal both re-aim within 4 frames, flights (tamper was ${TAMPER_DEG.toFixed(0)}°)`,
|
||||
record(`arrival: moveEnd re-aims and first reveal is correct within 250 ms, flights (tamper was ${TAMPER_DEG.toFixed(0)}°)`,
|
||||
arrOk(arrival.flights), fmtArr(arrival.flights));
|
||||
record(`arrival: moveEnd + reveal both re-aim within 4 frames, military (tamper was ${TAMPER_DEG.toFixed(0)}°)`,
|
||||
record(`arrival: moveEnd re-aims and first reveal is correct within 250 ms, military (tamper was ${TAMPER_DEG.toFixed(0)}°)`,
|
||||
arrOk(arrival.military), fmtArr(arrival.military));
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -143,7 +143,7 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
||||
|
||||
if (url.pathname === '/sources') {
|
||||
const body = {
|
||||
trimmedPacks: getCctvSources.trimmedPacks?.() ?? [],
|
||||
trimmedPacks: getCctvSources.trimmedPacks?.(sources) ?? [],
|
||||
sources: sources.map((source) => ({
|
||||
id: source.id,
|
||||
pack: source.pack,
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
loadVegvesenSourcesFromOpenData,
|
||||
loadNycSourcesFromOpenData,
|
||||
loadTorontoSourcesFromOpenData,
|
||||
loadFloridaSourcesFromOpenData,
|
||||
} from './sources.js';
|
||||
|
||||
/** Env kill switch: unset or anything but "0" means enabled. */
|
||||
@@ -144,6 +145,14 @@ const LIVE_PACKS = [
|
||||
enabled: () => envOptIn('CCTV_LITHUANIA_ENABLED'),
|
||||
load: loadLithuaniaSources,
|
||||
},
|
||||
{
|
||||
// FL511's terms limit its content to individual, personal use (other use
|
||||
// needs FDOT's written consent), so the pack is off unless the operator
|
||||
// opts in (see DATA_SOURCES.md).
|
||||
name: 'florida',
|
||||
enabled: () => envOptIn('CCTV_FLORIDA_ENABLED'),
|
||||
load: loadFloridaSourcesFromOpenData,
|
||||
},
|
||||
];
|
||||
/**
|
||||
* Load CCTV sources from a local JSON file (CCTV_SOURCES_FILE env or default).
|
||||
@@ -195,16 +204,19 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
|
||||
/** @type {Promise<Array<object>>|null} In-flight refresh, shared by concurrent
|
||||
* callers so a post-TTL burst launches ONE refetch, not one per request. */
|
||||
let _cctvSourceInflight = null;
|
||||
/** @type {Array<{pack: string, available: number, served: number}>} Packs
|
||||
* that serve fewer cameras than they offer. */
|
||||
let _cctvTrimmedPacks = [];
|
||||
/** @type {WeakMap<Array<object>, Array<{pack: string, available: number, served: number}>>}
|
||||
* Packs that serve fewer cameras than they offer, keyed by the source list
|
||||
* they describe, so a request served the old list during a background
|
||||
* refresh never pairs it with the new list's trim notes. */
|
||||
const _cctvTrimmedPacks = new WeakMap();
|
||||
|
||||
/**
|
||||
* Assemble and cache the merged CCTV source list.
|
||||
*
|
||||
* Merges every source pack (live open-data packs, local file, env
|
||||
* variable), deduplicates by ID, shares the catalog cap fairly across
|
||||
* packs, and caches for CCTV_SOURCE_CACHE_MS.
|
||||
* packs, and caches for CCTV_SOURCE_CACHE_MS. Past that, the cached list is
|
||||
* returned while a background refresh replaces it; only a first load waits.
|
||||
*
|
||||
* @returns {Promise<Array<object>>} Deduplicated, capped source list.
|
||||
*/
|
||||
@@ -219,11 +231,31 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
|
||||
// Single-flight: a burst of requests arriving past the TTL shares ONE refresh
|
||||
// instead of each launching the full multi-provider refetch. The `.finally`
|
||||
// clears the ref so the next post-TTL cycle starts fresh.
|
||||
if (_cctvSourceInflight) return _cctvSourceInflight;
|
||||
_cctvSourceInflight = refreshCctvSources().finally(() => {
|
||||
_cctvSourceInflight = null;
|
||||
});
|
||||
return _cctvSourceInflight;
|
||||
if (!_cctvSourceInflight) {
|
||||
_cctvSourceInflight = refreshCctvSources().finally(() => {
|
||||
_cctvSourceInflight = null;
|
||||
});
|
||||
// A background refresh has no caller waiting on it: report its failure
|
||||
// here, once per refresh rather than once per request it serves. Keep
|
||||
// the old catalog for another TTL, as an empty refresh does, so a refresh
|
||||
// that keeps failing is retried once per TTL, not back to back.
|
||||
if (_cctvSourceCache.length) {
|
||||
_cctvSourceInflight.catch((error) => {
|
||||
_cctvSourceCacheAt = Date.now();
|
||||
console.warn(
|
||||
'[CCTV] background catalog refresh failed; serving',
|
||||
_cctvSourceCache.length,
|
||||
'cameras from the last catalog:',
|
||||
error?.message || error,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
// With a catalog in hand, answer with it while the refresh runs: the
|
||||
// refresh lasts as long as the slowest pack (Tarktee's DATEX feeds take up
|
||||
// to its 30 s timeout), and every /api/cctv request would otherwise wait
|
||||
// for it. Only the first load, with nothing to serve yet, waits.
|
||||
return _cctvSourceCache.length ? _cctvSourceCache : _cctvSourceInflight;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -313,7 +345,7 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
|
||||
}
|
||||
if (capped.length > 0 || _cctvSourceCache.length === 0) {
|
||||
_cctvSourceCache = capped;
|
||||
_cctvTrimmedPacks = trimmedPacks;
|
||||
_cctvTrimmedPacks.set(capped, trimmedPacks);
|
||||
} else {
|
||||
// Every source came back empty (all live packs timed out / upstream outage)
|
||||
// but a good catalog is already cached — serve it stale rather than blanking
|
||||
@@ -327,7 +359,10 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
|
||||
return _cctvSourceCache;
|
||||
}
|
||||
|
||||
/** Packs serving fewer cameras than they offer, as of the last refresh. */
|
||||
getCctvSources.trimmedPacks = () => _cctvTrimmedPacks;
|
||||
/** Packs serving fewer cameras than they offer in `sources` (by default the
|
||||
* current catalog). Pass the list a request was served to keep the two in
|
||||
* step while a background refresh swaps the catalog. */
|
||||
getCctvSources.trimmedPacks = (sources = _cctvSourceCache) =>
|
||||
_cctvTrimmedPacks.get(sources) ?? [];
|
||||
return getCctvSources;
|
||||
}
|
||||
@@ -113,13 +113,34 @@ export const CALTRANS_ANCHORS = [
|
||||
{ lat: 32.7157, lon: -117.1611 }, // San Diego
|
||||
{ lat: 38.5816, lon: -121.4944 }, // Sacramento
|
||||
];
|
||||
export const FLORIDA_CCTV_URL =
|
||||
'https://services.arcgis.com/3wFbqsFPLeKqOlIK/arcgis/rest/services/FL511_Traffic_Cameras/FeatureServer/0/query';
|
||||
export const FLORIDA_IMAGE_HOSTS = [
|
||||
'images-dis.divas.cloud',
|
||||
'snapshots.divas.cloud',
|
||||
];
|
||||
export const DEFAULT_FLORIDA_MAX_SOURCES = 500;
|
||||
/** Byte cap per catalog page (2,000 rows, about 650 KiB measured). */
|
||||
export const FLORIDA_MAX_CATALOG_BYTES = 2 * 1024 * 1024;
|
||||
/** Catalog page ceiling: 40 pages covers 4,000+ cameras even at 100 rows a page. */
|
||||
export const FLORIDA_MAX_CATALOG_PAGES = 40;
|
||||
export const FLORIDA_ANCHORS = [
|
||||
{ lat: 25.7617, lon: -80.1918 }, // Miami
|
||||
{ lat: 28.5384, lon: -81.3789 }, // Orlando
|
||||
{ lat: 27.9506, lon: -82.4572 }, // Tampa
|
||||
{ lat: 30.3322, lon: -81.6557 }, // Jacksonville
|
||||
{ lat: 30.4383, lon: -84.2807 }, // Tallahassee
|
||||
{ lat: 26.1224, lon: -80.1373 }, // Fort Lauderdale
|
||||
{ lat: 26.7153, lon: -80.0534 }, // West Palm Beach
|
||||
{ lat: 30.4213, lon: -87.2169 }, // Pensacola (panhandle)
|
||||
];
|
||||
/** TfL JamCams: one keyless list endpoint; frames live on a public S3 bucket. */
|
||||
export const TFL_JAMCAM_URL = 'https://api.tfl.gov.uk/Place/Type/JamCam';
|
||||
export const TFL_IMAGE_ORIGIN =
|
||||
'https://s3-eu-west-1.amazonaws.com/jamcams.tfl.gov.uk/';
|
||||
export const DEFAULT_TFL_MAX_SOURCES = 250;
|
||||
export const LONDON_CENTER = { lat: 51.5074, lon: -0.1278 };
|
||||
/** Ontario 511: keyless CARS/511 camera catalog; frame URLs are still images. */
|
||||
/** Ontario 511: CARS/511 camera catalog, which needs a developer key (ONTARIO_511_API_KEY); frame URLs are still images. */
|
||||
export const ONTARIO_511_CAMERAS_URL =
|
||||
'https://511on.ca/api/v2/get/cameras?format=json&lang=en';
|
||||
export const ONTARIO_511_IMAGE_ORIGIN = 'https://511on.ca/map/Cctv/';
|
||||
@@ -188,6 +209,8 @@ export const TARKTEE_IMAGES_URL =
|
||||
'https://tarktee.transpordiamet.ee/api/v1/datex/roadCameraImages';
|
||||
export const TARKTEE_IMAGE_ORIGIN = 'https://tarktee.transpordiamet.ee/images/';
|
||||
export const DEFAULT_TARKTEE_MAX_SOURCES = 179;
|
||||
/** Hard ceiling per Tarktee DATEX XML document (~120–165 KB live). */
|
||||
export const TARKTEE_MAX_CATALOG_BYTES = 4 * 1024 * 1024;
|
||||
export const TARKTEE_ANCHORS = [
|
||||
{ lat: 59.437, lon: 24.753 }, // Tallinn
|
||||
{ lat: 58.378, lon: 26.729 }, // Tartu
|
||||
@@ -317,6 +340,49 @@ export const NORWAY_ANCHORS = [
|
||||
{ lat: 69.6492, lon: 18.9553 }, // Tromsø
|
||||
];
|
||||
|
||||
/** Kartverket's keyless point-height API (CC BY 4.0): terrain height above
|
||||
* sea level (NN2000), used as the Vegvesen cameras' ground prior because the
|
||||
* camera feed's points are 2D. */
|
||||
export const KARTVERKET_HEIGHT_URL =
|
||||
'https://ws.geonorge.no/hoydedata/v1/punkt';
|
||||
/** Points per request. The API takes up to 50, but above ~20 it answers the
|
||||
* extra points from contour lines (`hoydekurver`, metres to ~20 m off) or not
|
||||
* at all; at 20 nearly every camera resolves from the 1 m terrain model. */
|
||||
export const KARTVERKET_MAX_POINTS = 20;
|
||||
/** Requests in flight at once. The whole country is ~36 requests of
|
||||
* 0.6-9.5 s each, so a cold lookup takes ~6-13 s. */
|
||||
export const KARTVERKET_CONCURRENCY = 8;
|
||||
/** Deadline for one complete lookup, shared by every request. A lookup cut
|
||||
* short by it is resumed on the next refresh, without a backoff. */
|
||||
export const KARTVERKET_LOOKUP_TIMEOUT_MS = 30 * 1000;
|
||||
/** One request that takes longer than this counts as a failing service. */
|
||||
export const KARTVERKET_REQUEST_TIMEOUT_MS = 10 * 1000;
|
||||
/** The Vegvesen lane's budget, measured from the start of the loader (catalog
|
||||
* fetch included), for waiting on heights. Under the 15 s per-provider fetch
|
||||
* timeout so this pack is never the catalog's slowest lane. A lookup still
|
||||
* running after it keeps filling the cache for the next refresh. */
|
||||
export const KARTVERKET_CATALOG_WAIT_MS = 12 * 1000;
|
||||
/** The lane only waits when more than this share of its cameras has no
|
||||
* answer yet (a cold cache). A few new cameras or expired misses are looked
|
||||
* up in the background instead of holding the refresh. */
|
||||
export const KARTVERKET_WAIT_MISSING_SHARE = 0.1;
|
||||
/** A point Kartverket has no height for (some ferry quays and bridges) is not
|
||||
* asked about again for this long. */
|
||||
export const KARTVERKET_NULL_TTL_MS = 6 * 60 * 60 * 1000;
|
||||
/** After a failing request (408, 429, 5xx, a network error, a request
|
||||
* timeout or an unreadable body), no lookup starts for this long, or for the
|
||||
* Retry-After the service sent, bounded to [1 min, 1 h]. Longer than the
|
||||
* 15-minute catalog refresh (CCTV_SOURCE_CACHE_MS), so a failing service
|
||||
* costs one attempt per backoff, not one per refresh. */
|
||||
export const KARTVERKET_FAILURE_BACKOFF_MS = 30 * 60 * 1000;
|
||||
export const KARTVERKET_MAX_BACKOFF_MS = 60 * 60 * 1000;
|
||||
export const KARTVERKET_MAX_RESPONSE_BYTES = 256 * 1024;
|
||||
/** Plausible Norwegian terrain: below sea level only at the shore, and no road
|
||||
* camera sits above ~1,500 m. Anything outside is treated as no height. */
|
||||
export const KARTVERKET_HEIGHT_RANGE_M = Object.freeze({ min: -20, max: 2500 });
|
||||
/** Same identity the other CCTV upstream requests send. */
|
||||
export const KARTVERKET_USER_AGENT = 'gods-eye-view-cctv-proxy/1.0';
|
||||
|
||||
/** NYC DOT Traffic Management Center cameras: one keyless JSON list for the
|
||||
* five boroughs (~980 cameras). Frames are stills synthesized from each
|
||||
* camera UUID on the city's webcam host — never taken from the payload. */
|
||||
@@ -439,6 +505,10 @@ export const CCTV_SOURCE_CACHE_MS = 15 * 60 * 1000;
|
||||
* pending forever — a hung fetch aborts, the loader returns [], and
|
||||
* serve-stale/other packs take over. */
|
||||
export const CCTV_SOURCE_FETCH_TIMEOUT_MS = 15 * 1000;
|
||||
/** Tarktee's two DATEX feeds (120 and 160 KB) answered in 2 to 16.6 s across
|
||||
* ten requests on 2026-10-18, so the shared 15 s timeout dropped the Estonian
|
||||
* pack on about a third of loads. Both must arrive for any camera to load. */
|
||||
export const TARKTEE_FETCH_TIMEOUT_MS = 30 * 1000;
|
||||
/** Individual CCTV image fetches must settle before the active 10-second
|
||||
* client refresh cadence. A bounded miss can fall through to Street View or
|
||||
* the synthetic frame instead of leaving the browser preview pending. */
|
||||
|
||||
@@ -377,6 +377,17 @@ export function isLikelyCalgaryCoordinate(lat, lon) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Florida statewide, Key West to Georgia state line, with slack at each edge */
|
||||
export function isLikelyFloridaCoordinate(lat, lon) {
|
||||
return (
|
||||
isPlausibleLatLon(lat, lon) &&
|
||||
lat >= 24.3 &&
|
||||
lat <= 31.1 &&
|
||||
lon >= -87.7 &&
|
||||
lon <= -79.8
|
||||
);
|
||||
}
|
||||
|
||||
/** New York City's five boroughs, with slack for the harbor and city line. */
|
||||
export function isLikelyNycCoordinate(lat, lon) {
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
_resetOntarioKeyWarningForTest,
|
||||
loadOntarioSourcesFromOpenData,
|
||||
} from './sources.js';
|
||||
|
||||
const ROW = {
|
||||
Id: '42',
|
||||
Latitude: 43.6532,
|
||||
Longitude: -79.3832,
|
||||
Location: 'Hwy 401 near Yonge St',
|
||||
Direction: 'Eastbound',
|
||||
Views: [
|
||||
{
|
||||
Status: 'Enabled',
|
||||
Url: 'https://511on.ca/map/Cctv/4201',
|
||||
Description: 'Eastbound',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function captureConsole(t) {
|
||||
const lines = [];
|
||||
const capture = (...args) => lines.push(args.join(' '));
|
||||
t.mock.method(console, 'warn', capture);
|
||||
t.mock.method(console, 'log', capture);
|
||||
return lines;
|
||||
}
|
||||
|
||||
test.beforeEach(() => _resetOntarioKeyWarningForTest());
|
||||
|
||||
test('without ONTARIO_511_API_KEY the Ontario pack is skipped, logged once', async (t) => {
|
||||
const lines = captureConsole(t);
|
||||
let fetches = 0;
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
fetches += 1;
|
||||
return Response.json([ROW]);
|
||||
});
|
||||
assert.deepEqual(await loadOntarioSourcesFromOpenData({}, {}), []);
|
||||
assert.deepEqual(
|
||||
await loadOntarioSourcesFromOpenData({}, { ONTARIO_511_API_KEY: ' ' }),
|
||||
[],
|
||||
);
|
||||
assert.equal(fetches, 0);
|
||||
assert.equal(lines.length, 1);
|
||||
assert.match(lines[0], /ONTARIO_511_API_KEY/);
|
||||
});
|
||||
|
||||
test('with a key the camera list is requested with it and parsed', async (t) => {
|
||||
const lines = captureConsole(t);
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
requested.push(String(url));
|
||||
return Response.json([ROW]);
|
||||
});
|
||||
const cameras = await loadOntarioSourcesFromOpenData(
|
||||
{ sourceRoot: '/ignored' },
|
||||
{ ONTARIO_511_API_KEY: ' test-key ' },
|
||||
);
|
||||
assert.equal(requested.length, 1);
|
||||
const url = new URL(requested[0]);
|
||||
assert.equal(url.host, '511on.ca');
|
||||
assert.equal(url.searchParams.get('key'), 'test-key');
|
||||
assert.equal(url.searchParams.get('format'), 'json');
|
||||
assert.equal(cameras.length, 1);
|
||||
assert.equal(cameras[0].id, 'on-42');
|
||||
assert.equal(cameras[0].url, 'https://511on.ca/map/Cctv/4201');
|
||||
// The records go to the browser through /api/cctv; the key must stay on
|
||||
// the server-side catalog request.
|
||||
assert.doesNotMatch(JSON.stringify(cameras), /test-key/);
|
||||
assert.doesNotMatch(lines.join('\n'), /test-key/);
|
||||
});
|
||||
|
||||
test('the source cap is read from the same environment as the key', async (t) => {
|
||||
captureConsole(t);
|
||||
const rows = Array.from({ length: 12 }, (_, i) => ({
|
||||
...ROW,
|
||||
Id: String(100 + i),
|
||||
Latitude: ROW.Latitude + i * 0.01,
|
||||
Views: [{ ...ROW.Views[0], Url: `https://511on.ca/map/Cctv/${100 + i}` }],
|
||||
}));
|
||||
t.mock.method(globalThis, 'fetch', async () => Response.json(rows));
|
||||
const cameras = await loadOntarioSourcesFromOpenData(
|
||||
{},
|
||||
{ ONTARIO_511_API_KEY: 'test-key', CCTV_ONTARIO_MAX_SOURCES: '8' },
|
||||
);
|
||||
assert.equal(cameras.length, 8);
|
||||
});
|
||||
|
||||
for (const status of [400, 401, 403]) {
|
||||
test(`a ${status} points at the key by name, never by value`, async (t) => {
|
||||
const lines = captureConsole(t);
|
||||
t.mock.method(globalThis, 'fetch', async () =>
|
||||
Response.json({ Message: 'Invalid Key' }, { status }),
|
||||
);
|
||||
const cameras = await loadOntarioSourcesFromOpenData(
|
||||
{},
|
||||
{ ONTARIO_511_API_KEY: 'secret-key-value' },
|
||||
);
|
||||
assert.deepEqual(cameras, []);
|
||||
assert.match(
|
||||
lines.join('\n'),
|
||||
new RegExp(`failed: ${status} \\(check ONTARIO_511_API_KEY\\)`),
|
||||
);
|
||||
assert.doesNotMatch(lines.join('\n'), /secret-key-value/);
|
||||
});
|
||||
}
|
||||
|
||||
for (const status of [429, 500, 503]) {
|
||||
test(`a ${status} is reported without blaming the key`, async (t) => {
|
||||
const lines = captureConsole(t);
|
||||
t.mock.method(globalThis, 'fetch', async () =>
|
||||
Response.json({}, { status }),
|
||||
);
|
||||
const cameras = await loadOntarioSourcesFromOpenData(
|
||||
{},
|
||||
{ ONTARIO_511_API_KEY: 'secret-key-value' },
|
||||
);
|
||||
assert.deepEqual(cameras, []);
|
||||
assert.equal(lines.length, 1);
|
||||
assert.equal(
|
||||
lines[0],
|
||||
`[CCTV] Ontario 511 camera download failed: ${status}`,
|
||||
);
|
||||
assert.doesNotMatch(lines[0], /secret-key-value/);
|
||||
});
|
||||
}
|
||||
|
||||
for (const failure of ['transport', 'invalid-json']) {
|
||||
test(`Ontario ${failure} failures cannot log reflected keys or request URLs`, async (t) => {
|
||||
const lines = captureConsole(t);
|
||||
const key = 'synthetic secret&/=%value';
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
if (failure === 'transport')
|
||||
throw new Error(`request failed: ${url} ${key}`);
|
||||
return new Response(`not JSON: ${url} ${key}`);
|
||||
});
|
||||
assert.deepEqual(
|
||||
await loadOntarioSourcesFromOpenData({}, { ONTARIO_511_API_KEY: key }),
|
||||
[],
|
||||
);
|
||||
assert.equal(lines.length, 1);
|
||||
assert.match(lines[0], /Ontario 511 camera download error/);
|
||||
assert.equal(lines[0].includes(key), false);
|
||||
assert.equal(lines[0].includes('synthetic'), false);
|
||||
assert.equal(lines[0].includes('https://'), false);
|
||||
});
|
||||
}
|
||||
|
||||
test('Ontario encodes reserved key characters and refuses credential-bearing redirects', async (t) => {
|
||||
const lines = captureConsole(t);
|
||||
const key = 'synthetic &/=% value';
|
||||
let request;
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
request = { url: new URL(url), init };
|
||||
return Response.json([ROW]);
|
||||
});
|
||||
const cameras = await loadOntarioSourcesFromOpenData(
|
||||
{},
|
||||
{ ONTARIO_511_API_KEY: key },
|
||||
);
|
||||
assert.equal(request.url.searchParams.get('key'), key);
|
||||
assert.equal(request.init.redirect, 'error');
|
||||
assert.equal(cameras.length, 1);
|
||||
assert.equal(JSON.stringify(cameras).includes('synthetic'), false);
|
||||
assert.equal(lines.join(' ').includes('synthetic'), false);
|
||||
});
|
||||
@@ -42,6 +42,8 @@ import {
|
||||
TARKTEE_IMAGES_URL,
|
||||
TARKTEE_IMAGE_ORIGIN,
|
||||
DEFAULT_TARKTEE_MAX_SOURCES,
|
||||
TARKTEE_MAX_CATALOG_BYTES,
|
||||
TARKTEE_FETCH_TIMEOUT_MS,
|
||||
TARKTEE_ANCHORS,
|
||||
DEFAULT_WARENDORF_SOURCE_FILE,
|
||||
WARENDORF_IMAGE_ORIGINS,
|
||||
@@ -55,6 +57,12 @@ import {
|
||||
DEFAULT_CALGARY_MAX_SOURCES,
|
||||
CALGARY_DOWNTOWN,
|
||||
CALGARY_MAX_CATALOG_BYTES,
|
||||
FLORIDA_CCTV_URL,
|
||||
FLORIDA_IMAGE_HOSTS,
|
||||
DEFAULT_FLORIDA_MAX_SOURCES,
|
||||
FLORIDA_ANCHORS,
|
||||
FLORIDA_MAX_CATALOG_BYTES,
|
||||
FLORIDA_MAX_CATALOG_PAGES,
|
||||
CATALONIA_CAMERAS_URL,
|
||||
CATALONIA_MAX_CATALOG_BYTES,
|
||||
CATALONIA_IMAGE_HOSTS,
|
||||
@@ -89,6 +97,19 @@ import {
|
||||
DEFAULT_VEGVESEN_MAX_SOURCES,
|
||||
VEGVESEN_MAX_CATALOG_BYTES,
|
||||
NORWAY_ANCHORS,
|
||||
KARTVERKET_HEIGHT_URL,
|
||||
KARTVERKET_MAX_POINTS,
|
||||
KARTVERKET_CONCURRENCY,
|
||||
KARTVERKET_LOOKUP_TIMEOUT_MS,
|
||||
KARTVERKET_REQUEST_TIMEOUT_MS,
|
||||
KARTVERKET_WAIT_MISSING_SHARE,
|
||||
KARTVERKET_CATALOG_WAIT_MS,
|
||||
KARTVERKET_NULL_TTL_MS,
|
||||
KARTVERKET_FAILURE_BACKOFF_MS,
|
||||
KARTVERKET_MAX_BACKOFF_MS,
|
||||
KARTVERKET_USER_AGENT,
|
||||
KARTVERKET_MAX_RESPONSE_BYTES,
|
||||
KARTVERKET_HEIGHT_RANGE_M,
|
||||
NYC_CAMERAS_URL,
|
||||
NYC_IMAGE_URL,
|
||||
NYC_CAMERA_ID_RE,
|
||||
@@ -113,6 +134,7 @@ import {
|
||||
isLikelyTexasCoordinate,
|
||||
isLikelyNswCoordinate,
|
||||
isLikelyCalgaryCoordinate,
|
||||
isLikelyFloridaCoordinate,
|
||||
isLikelyCataloniaCoordinate,
|
||||
decodeNumericEntities,
|
||||
isLikelyNycCoordinate,
|
||||
@@ -502,22 +524,57 @@ function pickOntarioCctvView(views) {
|
||||
);
|
||||
}
|
||||
|
||||
let ontarioKeyMissingLogged = false;
|
||||
|
||||
/** Test seam: let the next keyless call log the missing-key line again. */
|
||||
export function _resetOntarioKeyWarningForTest() {
|
||||
ontarioKeyMissingLogged = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Ontario 511 CCTV cameras. Keyless: the catalog is exposed by the
|
||||
* public 511 API, while frame URLs are stable still-image endpoints under
|
||||
* 511on.ca/map/Cctv/. Only rows with finite Ontario coords and at least one
|
||||
* enabled official still view are kept.
|
||||
* Fetch Ontario 511 CCTV cameras. The camera list comes from the 511 API,
|
||||
* which now requires a free developer key (511on.ca/developers/doc, "Requires a
|
||||
* developer key"; unkeyed calls answer 400 "Invalid Key"), read from
|
||||
* ONTARIO_511_API_KEY. Without one the pack is skipped with a single log line.
|
||||
* Frame URLs are stable still-image endpoints under 511on.ca/map/Cctv/. Only
|
||||
* rows with finite Ontario coords and at least one enabled official still
|
||||
* view are kept.
|
||||
*
|
||||
* @param {object} [_options] The catalog's loader options (unused).
|
||||
* @param {object} [env] Environment to read the key and the source cap from.
|
||||
* @returns {Promise<Array<object>>} Normalized camera source objects.
|
||||
*/
|
||||
export async function loadOntarioSourcesFromOpenData() {
|
||||
export async function loadOntarioSourcesFromOpenData(
|
||||
_options = {},
|
||||
env = process.env,
|
||||
) {
|
||||
const key = String(env.ONTARIO_511_API_KEY ?? '').trim();
|
||||
if (!key) {
|
||||
if (!ontarioKeyMissingLogged) {
|
||||
ontarioKeyMissingLogged = true;
|
||||
console.warn(
|
||||
'[CCTV] Ontario 511 cameras skipped: the 511on.ca API needs a free developer key (set ONTARIO_511_API_KEY)',
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(ONTARIO_511_CAMERAS_URL, {
|
||||
const url = new URL(ONTARIO_511_CAMERAS_URL);
|
||||
url.searchParams.set('key', key);
|
||||
const resp = await fetch(url, {
|
||||
headers: { Accept: 'application/json' },
|
||||
redirect: 'error',
|
||||
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
console.warn('[CCTV] Ontario 511 camera download failed:', resp.status);
|
||||
// The API answers 400 "Invalid Key" to a wrong or revoked key.
|
||||
const keyHint =
|
||||
resp.status === 400 || resp.status === 401 || resp.status === 403
|
||||
? ' (check ONTARIO_511_API_KEY)'
|
||||
: '';
|
||||
console.warn(
|
||||
`[CCTV] Ontario 511 camera download failed: ${resp.status}${keyHint}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
const rows = await resp.json();
|
||||
@@ -580,7 +637,7 @@ export async function loadOntarioSourcesFromOpenData() {
|
||||
new Map(cameras.map((camera) => [camera.id, camera])).values(),
|
||||
);
|
||||
const maxRaw = Number(
|
||||
process.env.CCTV_ONTARIO_MAX_SOURCES || DEFAULT_ONTARIO_MAX_SOURCES,
|
||||
env.CCTV_ONTARIO_MAX_SOURCES || DEFAULT_ONTARIO_MAX_SOURCES,
|
||||
);
|
||||
const maxCount = Number.isFinite(maxRaw)
|
||||
? Math.max(8, Math.min(1000, Math.floor(maxRaw)))
|
||||
@@ -590,10 +647,11 @@ export async function loadOntarioSourcesFromOpenData() {
|
||||
`[CCTV] Loaded Ontario 511 camera sources: ${unique.length} enabled (using nearest ${prioritized.length})`,
|
||||
);
|
||||
return prioritized;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Transport and JSON errors may reflect the credential-bearing request.
|
||||
// Do not log upstream exception text, response bodies or URLs.
|
||||
console.warn(
|
||||
'[CCTV] Ontario 511 camera download error:',
|
||||
error?.message || error,
|
||||
'[CCTV] Ontario 511 camera download error (network or invalid response)',
|
||||
);
|
||||
return [];
|
||||
}
|
||||
@@ -1190,28 +1248,49 @@ export function parseTarkteeDatexImages(xml) {
|
||||
* @returns {Promise<Array<object>>} Normalized camera source objects.
|
||||
*/
|
||||
export async function loadTarkteeSourcesFromDatex() {
|
||||
// Own both transports before either can reject. A response that arrives
|
||||
// after cleanup must also be discarded instead of escaping Promise.all.
|
||||
const abort = new AbortController();
|
||||
const signal = AbortSignal.any([
|
||||
abort.signal,
|
||||
AbortSignal.timeout(TARKTEE_FETCH_TIMEOUT_MS),
|
||||
]);
|
||||
const responses = [];
|
||||
const discardBoth = async () => {
|
||||
abort.abort();
|
||||
await Promise.allSettled(
|
||||
responses.map((response) => response.body?.cancel()),
|
||||
);
|
||||
return [];
|
||||
};
|
||||
const fetchXml = async (url) => {
|
||||
const response = await fetch(url, {
|
||||
headers: { Accept: 'application/xml,text/xml,*/*' },
|
||||
redirect: 'manual',
|
||||
signal,
|
||||
});
|
||||
if (signal.aborted) {
|
||||
await Promise.allSettled([response.body?.cancel()]);
|
||||
signal.throwIfAborted();
|
||||
}
|
||||
responses.push(response);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
response.status >= 300 && response.status < 400
|
||||
? 'catalog redirected; redirects are not followed'
|
||||
: `catalog HTTP ${response.status}`,
|
||||
);
|
||||
}
|
||||
return response;
|
||||
};
|
||||
try {
|
||||
const [locResp, imgResp] = await Promise.all([
|
||||
fetch(TARKTEE_LOCATIONS_URL, {
|
||||
headers: { Accept: 'application/xml,text/xml,*/*' },
|
||||
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
|
||||
}),
|
||||
fetch(TARKTEE_IMAGES_URL, {
|
||||
headers: { Accept: 'application/xml,text/xml,*/*' },
|
||||
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
|
||||
}),
|
||||
fetchXml(TARKTEE_LOCATIONS_URL),
|
||||
fetchXml(TARKTEE_IMAGES_URL),
|
||||
]);
|
||||
if (!locResp.ok) {
|
||||
console.warn('[CCTV] Tarktee locations download failed:', locResp.status);
|
||||
return [];
|
||||
}
|
||||
if (!imgResp.ok) {
|
||||
console.warn('[CCTV] Tarktee images download failed:', imgResp.status);
|
||||
return [];
|
||||
}
|
||||
const [locXml, imgXml] = await Promise.all([
|
||||
locResp.text(),
|
||||
imgResp.text(),
|
||||
readResponseTextCapped(locResp, TARKTEE_MAX_CATALOG_BYTES, signal),
|
||||
readResponseTextCapped(imgResp, TARKTEE_MAX_CATALOG_BYTES, signal),
|
||||
]);
|
||||
const locations = parseTarkteeDatexLocations(locXml);
|
||||
const images = parseTarkteeDatexImages(imgXml);
|
||||
@@ -1224,6 +1303,7 @@ export async function loadTarkteeSourcesFromDatex() {
|
||||
}
|
||||
|
||||
const cameras = [];
|
||||
const seen = new Set();
|
||||
for (const [locationId, loc] of locations.entries()) {
|
||||
const imageUrl = images.get(locationId);
|
||||
if (!imageUrl) continue;
|
||||
@@ -1235,6 +1315,8 @@ export async function loadTarkteeSourcesFromDatex() {
|
||||
const cameraId = numMatch
|
||||
? `ee-tarktee-${numMatch[1]}`
|
||||
: `ee-tarktee-${locationId}`;
|
||||
if (seen.has(cameraId)) continue;
|
||||
seen.add(cameraId);
|
||||
cameras.push({
|
||||
id: cameraId,
|
||||
name: loc.name,
|
||||
@@ -1270,6 +1352,7 @@ export async function loadTarkteeSourcesFromDatex() {
|
||||
);
|
||||
return prioritized;
|
||||
} catch (error) {
|
||||
await discardBoth();
|
||||
console.warn(
|
||||
'[CCTV] Tarktee DATEX download error:',
|
||||
error?.message || error,
|
||||
@@ -2386,14 +2469,11 @@ export function vegvesenCameraToSource(feature) {
|
||||
fovDeg: 44,
|
||||
rangeM: 145,
|
||||
mountHeightM: 8,
|
||||
// KNOWN LIMITATION: the feed's coordinates are 2D, so this is one flat
|
||||
// prior for the whole country, while cameras run from sea level to
|
||||
// mountain passes near 1,000 m (Haukelifjell, Sjonfjellet, Rugeldalen).
|
||||
// The point-height prior and the client's one-shot ground snap correct it
|
||||
// where they resolve; on a stack where neither does (no 3D tiles), a pass
|
||||
// camera stays hundreds of metres below the terrain, the same risk the
|
||||
// Caltrans pack documents. A per-camera height (e.g. Kartverket's keyless
|
||||
// point-height API) would remove it.
|
||||
// The feed's coordinates are 2D. The loader replaces this flat prior with
|
||||
// Kartverket's terrain height for each camera (applyVegvesenGroundHeights);
|
||||
// it only survives when that lookup fails, and then a mountain-pass camera
|
||||
// can sit below the terrain on a stack without 3D tiles, the same risk the
|
||||
// Caltrans pack documents.
|
||||
groundElevationM: 150,
|
||||
feedType: videoUrl ? 'hls' : 'image',
|
||||
url: videoUrl || imageUrl,
|
||||
@@ -2413,6 +2493,7 @@ export function vegvesenCameraToSource(feature) {
|
||||
* @returns {Promise<Array<object>>} Normalized camera source objects.
|
||||
*/
|
||||
export async function loadVegvesenSourcesFromOpenData() {
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const endpoint = process.env.CCTV_VEGVESEN_URL || DEFAULT_VEGVESEN_CCTV_URL;
|
||||
const resp = await fetch(endpoint, {
|
||||
@@ -2458,6 +2539,12 @@ export async function loadVegvesenSourcesFromOpenData() {
|
||||
? Math.max(8, Math.min(1000, Math.floor(maxRaw)))
|
||||
: DEFAULT_VEGVESEN_MAX_SOURCES;
|
||||
const prioritized = prioritizeSources(cameras, maxCount, NORWAY_ANCHORS);
|
||||
await applyVegvesenGroundHeights(prioritized, {
|
||||
waitMs: Math.max(
|
||||
0,
|
||||
KARTVERKET_CATALOG_WAIT_MS - (Date.now() - startedAt),
|
||||
),
|
||||
});
|
||||
console.log(
|
||||
`[CCTV] Loaded Vegvesen camera sources: ${cameras.length} (using nearest ${prioritized.length})`,
|
||||
);
|
||||
@@ -2471,6 +2558,322 @@ export async function loadVegvesenSourcesFromOpenData() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kartverket answers by rounded position: `{z, expires}`, where z is metres or
|
||||
* null for "no height there". Heights never expire (camera positions barely
|
||||
* change); nulls expire after KARTVERKET_NULL_TTL_MS. The 15-minute catalog
|
||||
* refresh therefore only asks about new or moved cameras. Bounded well above
|
||||
* the ~900 cameras the pack can load.
|
||||
*/
|
||||
const kartverketHeightCache = new Map();
|
||||
const KARTVERKET_CACHE_LIMIT = 5000;
|
||||
/** The lookup in flight, so overlapping catalog loads never start a second. */
|
||||
let kartverketPending = null;
|
||||
/** No lookup starts before this time (epoch ms) after a transport failure. */
|
||||
let kartverketBackoffUntil = 0;
|
||||
/** Bumped by clearKartverketHeightCache, so a lookup started before a clear
|
||||
* can no longer write to the cache, the backoff or the pending slot. */
|
||||
let kartverketGeneration = 0;
|
||||
|
||||
/** Cache key for one position, at the ~0.1 m precision the request sends. */
|
||||
const kartverketKey = (lon, lat) => `${lon.toFixed(6)},${lat.toFixed(6)}`;
|
||||
|
||||
/** Statuses that say the service, not the request, is failing. */
|
||||
const isKartverketTransportStatus = (status) =>
|
||||
status === 408 || status === 429 || status >= 500;
|
||||
|
||||
/** Forget cached heights, the backoff and any lookup in flight. Tests only. */
|
||||
export function clearKartverketHeightCache() {
|
||||
kartverketGeneration += 1;
|
||||
kartverketHeightCache.clear();
|
||||
kartverketPending = null;
|
||||
kartverketBackoffUntil = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* How long to back off after a failed request: the Retry-After the service
|
||||
* sent (seconds or an HTTP date), else the default, bounded to [1 min, 1 h].
|
||||
*
|
||||
* @param {?string} retryAfter - The Retry-After header, if any.
|
||||
* @param {number} now - Epoch ms.
|
||||
* @returns {number} Milliseconds.
|
||||
*/
|
||||
export function kartverketBackoffMs(retryAfter, now = Date.now()) {
|
||||
let ms = KARTVERKET_FAILURE_BACKOFF_MS;
|
||||
const text = String(retryAfter ?? '').trim();
|
||||
if (/^\d+$/.test(text)) ms = Number(text) * 1000;
|
||||
else if (text && Number.isFinite(Date.parse(text))) {
|
||||
ms = Date.parse(text) - now;
|
||||
}
|
||||
return Math.max(60 * 1000, Math.min(KARTVERKET_MAX_BACKOFF_MS, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Terrain heights above sea level (NN2000) from Kartverket's keyless
|
||||
* point-height API, KARTVERKET_MAX_POINTS points per request. Each answer is
|
||||
* matched back by the coordinates the API echoes, and only for points that
|
||||
* request asked about, then reported through `into.set(key, z)` as its
|
||||
* request lands, so a caller that stops waiting keeps what has arrived. `z` is
|
||||
* metres, or null when the API has no usable height there. A point a 200
|
||||
* answer leaves out is reported as null too, so it is not asked again on
|
||||
* every refresh.
|
||||
*
|
||||
* Over water `/punkt` returns the depth of the sea floor (`datakilde:
|
||||
* dybdekurver`, `terreng: Havflate`; −547 m under Nordhordlandsbrua). Either
|
||||
* marker counts as 0 m, the water surface. For the ~14 bridge cameras that is
|
||||
* below the deck, but far closer than the sea floor or a flat prior.
|
||||
*
|
||||
* The first transport failure (a 408, 429 or 5xx, a network error, a request
|
||||
* past KARTVERKET_REQUEST_TIMEOUT_MS, an unreadable or oversized body, or the
|
||||
* shared deadline) stops the lookup: no further requests start, and the
|
||||
* failure is returned so the caller can back off. Points in failed requests
|
||||
* are not reported, so they are asked again later. Any other 4xx rejects that
|
||||
* request's points, not the service: they are reported as null and the lookup
|
||||
* carries on.
|
||||
*
|
||||
* @param {Array<{lon:number, lat:number}>} points
|
||||
* @param {{into?: {set: Function}, timeoutMs?: number, requestTimeoutMs?: number}} [options]
|
||||
* @returns {Promise<{heights: Map<string, ?number>, failure: ?{status: ?number, retryAfter: ?string, deadline: boolean}}>}
|
||||
* `heights` is `into` when it is a Map, else a Map of what was reported.
|
||||
* `failure.deadline` is true when only the shared deadline ran out.
|
||||
*/
|
||||
export async function fetchKartverketHeights(
|
||||
points,
|
||||
{
|
||||
into = new Map(),
|
||||
timeoutMs = KARTVERKET_LOOKUP_TIMEOUT_MS,
|
||||
requestTimeoutMs = KARTVERKET_REQUEST_TIMEOUT_MS,
|
||||
} = {},
|
||||
) {
|
||||
const heights = into instanceof Map ? into : new Map();
|
||||
const report = (key, z) => {
|
||||
if (heights !== into) heights.set(key, z);
|
||||
into.set(key, z);
|
||||
};
|
||||
const unique = Array.from(
|
||||
new Map(
|
||||
(points || [])
|
||||
.filter((p) => isLikelyNorwayCoordinate(p?.lat, p?.lon))
|
||||
.map((p) => [kartverketKey(p.lon, p.lat), p]),
|
||||
).values(),
|
||||
);
|
||||
let failure = null;
|
||||
if (!unique.length) return { heights, failure };
|
||||
const batches = [];
|
||||
for (let i = 0; i < unique.length; i += KARTVERKET_MAX_POINTS) {
|
||||
batches.push(unique.slice(i, i + KARTVERKET_MAX_POINTS));
|
||||
}
|
||||
const deadline = AbortSignal.timeout(timeoutMs);
|
||||
const fetchBatch = async (batch) => {
|
||||
const asked = new Set(batch.map((p) => kartverketKey(p.lon, p.lat)));
|
||||
const url = new URL(KARTVERKET_HEIGHT_URL);
|
||||
url.searchParams.set(
|
||||
'punkter',
|
||||
JSON.stringify(
|
||||
batch.map((p) => [Number(p.lon.toFixed(6)), Number(p.lat.toFixed(6))]),
|
||||
),
|
||||
);
|
||||
url.searchParams.set('koordsys', '4258');
|
||||
const resp = await fetch(url, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'User-Agent': KARTVERKET_USER_AGENT,
|
||||
},
|
||||
redirect: 'error',
|
||||
signal: AbortSignal.any([
|
||||
deadline,
|
||||
AbortSignal.timeout(requestTimeoutMs),
|
||||
]),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
try {
|
||||
await resp.body?.cancel();
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
if (!isKartverketTransportStatus(resp.status)) {
|
||||
console.warn(
|
||||
`[CCTV] Kartverket rejected ${asked.size} points: HTTP ${resp.status}`,
|
||||
);
|
||||
for (const key of asked) report(key, null);
|
||||
return;
|
||||
}
|
||||
const error = new Error(`HTTP ${resp.status}`);
|
||||
error.status = resp.status;
|
||||
error.retryAfter = resp.headers.get('retry-after');
|
||||
throw error;
|
||||
}
|
||||
const payload = await readResponseJsonCapped(
|
||||
resp,
|
||||
KARTVERKET_MAX_RESPONSE_BYTES,
|
||||
);
|
||||
for (const point of Array.isArray(payload?.punkter)
|
||||
? payload.punkter
|
||||
: []) {
|
||||
const x = toFiniteNumber(point?.x);
|
||||
const y = toFiniteNumber(point?.y);
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
|
||||
const key = kartverketKey(x, y);
|
||||
// Only the points this request asked about: an odd answer can neither
|
||||
// grow the cache nor overwrite another camera.
|
||||
if (!asked.has(key)) continue;
|
||||
asked.delete(key);
|
||||
// `z: null` means no height there; Number(null) would read 0.
|
||||
const raw = point?.z;
|
||||
let z =
|
||||
raw === null || raw === undefined || raw === ''
|
||||
? NaN
|
||||
: toFiniteNumber(raw);
|
||||
if (
|
||||
Number.isFinite(z) &&
|
||||
(point?.datakilde === 'dybdekurver' || point?.terreng === 'Havflate')
|
||||
) {
|
||||
z = 0;
|
||||
}
|
||||
const usable =
|
||||
Number.isFinite(z) &&
|
||||
z >= KARTVERKET_HEIGHT_RANGE_M.min &&
|
||||
z <= KARTVERKET_HEIGHT_RANGE_M.max;
|
||||
report(key, usable ? z : null);
|
||||
}
|
||||
// A 200 that leaves a point out has no height for it either.
|
||||
for (const key of asked) report(key, null);
|
||||
};
|
||||
let next = 0;
|
||||
const worker = async () => {
|
||||
while (next < batches.length && !failure) {
|
||||
const batch = batches[next++];
|
||||
try {
|
||||
await fetchBatch(batch);
|
||||
} catch (error) {
|
||||
if (!failure) {
|
||||
failure = {
|
||||
status: Number.isFinite(error?.status) ? error.status : null,
|
||||
retryAfter: error?.retryAfter ?? null,
|
||||
deadline: deadline.aborted,
|
||||
};
|
||||
console.warn(
|
||||
'[CCTV] Kartverket height lookup failed:',
|
||||
error?.message || error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
await Promise.all(
|
||||
Array.from(
|
||||
{ length: Math.min(KARTVERKET_CONCURRENCY, batches.length) },
|
||||
worker,
|
||||
),
|
||||
);
|
||||
return { heights, failure };
|
||||
}
|
||||
|
||||
/**
|
||||
* Give each Vegvesen camera its own terrain height as the ground prior, in
|
||||
* place. Waits at most `waitMs` for positions it has no answer for, and only
|
||||
* when more than KARTVERKET_WAIT_MISSING_SHARE of the cameras are missing
|
||||
* (a cold cache); a few new cameras or expired misses are looked up in the
|
||||
* background. A lookup still running when the wait ends keeps filling the
|
||||
* cache for the next catalog refresh. Cameras without a height keep the flat
|
||||
* prior. After a transport failure no lookup starts until the backoff ends,
|
||||
* which outlasts the 15-minute catalog refresh, so a failing Kartverket costs
|
||||
* one attempt per backoff, not one per refresh. A lookup cut short only by its
|
||||
* shared deadline is resumed on the next refresh without a backoff. Cameras
|
||||
* without finite coordinates are skipped. Never throws.
|
||||
* CCTV_VEGVESEN_HEIGHTS=0 turns it off.
|
||||
*
|
||||
* @param {Array<object>} cameras - Sources from vegvesenCameraToSource().
|
||||
* @param {{waitMs?: number, now?: () => number, timeoutMs?: number}} [options]
|
||||
* `timeoutMs` is the lookup's shared deadline.
|
||||
* @returns {Promise<number>} How many cameras got a terrain height.
|
||||
*/
|
||||
export async function applyVegvesenGroundHeights(
|
||||
cameras,
|
||||
{
|
||||
waitMs = KARTVERKET_CATALOG_WAIT_MS,
|
||||
now = Date.now,
|
||||
timeoutMs = KARTVERKET_LOOKUP_TIMEOUT_MS,
|
||||
} = {},
|
||||
) {
|
||||
if (String(process.env.CCTV_VEGVESEN_HEIGHTS || '1').trim() === '0') return 0;
|
||||
const list = (Array.isArray(cameras) ? cameras : []).filter(
|
||||
(c) => Number.isFinite(c?.lon) && Number.isFinite(c?.lat),
|
||||
);
|
||||
const startedAt = now();
|
||||
const missing = list.filter((c) => {
|
||||
const entry = kartverketHeightCache.get(kartverketKey(c.lon, c.lat));
|
||||
return !entry || entry.expires <= startedAt;
|
||||
});
|
||||
if (
|
||||
missing.length &&
|
||||
!kartverketPending &&
|
||||
startedAt >= kartverketBackoffUntil
|
||||
) {
|
||||
if (kartverketHeightCache.size + missing.length > KARTVERKET_CACHE_LIMIT) {
|
||||
kartverketHeightCache.clear();
|
||||
}
|
||||
const generation = kartverketGeneration;
|
||||
const current = () => generation === kartverketGeneration;
|
||||
const sink = {
|
||||
set: (key, z) => {
|
||||
if (!current()) return;
|
||||
kartverketHeightCache.set(key, {
|
||||
z,
|
||||
expires: z === null ? now() + KARTVERKET_NULL_TTL_MS : Infinity,
|
||||
});
|
||||
},
|
||||
};
|
||||
const pending = fetchKartverketHeights(missing, { into: sink, timeoutMs })
|
||||
.then(({ failure }) => {
|
||||
if (failure && !failure.deadline && current()) {
|
||||
kartverketBackoffUntil =
|
||||
now() + kartverketBackoffMs(failure.retryAfter, now());
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (current()) {
|
||||
kartverketBackoffUntil = now() + KARTVERKET_FAILURE_BACKOFF_MS;
|
||||
}
|
||||
console.warn(
|
||||
'[CCTV] Kartverket height lookup error:',
|
||||
error?.message || error,
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (kartverketPending === pending) kartverketPending = null;
|
||||
});
|
||||
kartverketPending = pending;
|
||||
}
|
||||
const cold = missing.length > list.length * KARTVERKET_WAIT_MISSING_SHARE;
|
||||
if (cold && kartverketPending && waitMs > 0) {
|
||||
let timer;
|
||||
await Promise.race([
|
||||
kartverketPending,
|
||||
new Promise((resolve) => {
|
||||
timer = setTimeout(resolve, waitMs);
|
||||
timer.unref?.();
|
||||
}),
|
||||
]);
|
||||
clearTimeout(timer);
|
||||
}
|
||||
let applied = 0;
|
||||
for (const camera of list) {
|
||||
const z = kartverketHeightCache.get(
|
||||
kartverketKey(camera.lon, camera.lat),
|
||||
)?.z;
|
||||
if (!Number.isFinite(z)) continue;
|
||||
camera.groundElevationM = Math.round(z * 10) / 10;
|
||||
applied += 1;
|
||||
}
|
||||
if (list.length) {
|
||||
console.log(
|
||||
`[CCTV] Kartverket ground heights: ${applied}/${list.length} Vegvesen cameras`,
|
||||
);
|
||||
}
|
||||
return applied;
|
||||
}
|
||||
|
||||
const NYC_BOROUGHS = new Set([
|
||||
'Manhattan',
|
||||
'Brooklyn',
|
||||
@@ -2784,3 +3187,203 @@ export async function loadTorontoSourcesFromOpenData() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** FL511 DIRECTION codes as headings; "NOT DIRECTIONAL" has none. */
|
||||
const FLORIDA_DIRECTION_HEADINGS = Object.freeze({
|
||||
N: 0,
|
||||
E: 90,
|
||||
S: 180,
|
||||
W: 270,
|
||||
});
|
||||
|
||||
/**
|
||||
* Stable camera id from a frame URL. FL511's ID column repeats across regions
|
||||
* (ID 1220 is both an I-75 and an I-95 camera), so the image channel is the
|
||||
* key, as Calgary keys on its frame filename. Falls back to a path slug.
|
||||
*
|
||||
* @param {string} imageUrl - A host-pinned FL511 frame URL.
|
||||
* @returns {?string} Provider-stable id, or null when underivable.
|
||||
*/
|
||||
export function floridaCameraId(imageUrl) {
|
||||
let path;
|
||||
try {
|
||||
path = new URL(String(imageUrl ?? '')).pathname;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const channel = /\/chan-(\d+)_[a-z]+\.jpg$/i.exec(path);
|
||||
if (channel) return `fl-${channel[1]}`;
|
||||
const slug = path
|
||||
.replace(/^\/+|\.[a-z0-9]+$/gi, '')
|
||||
.replace(/[^a-zA-Z0-9]+/g, '-')
|
||||
.toLowerCase();
|
||||
return slug ? `fl-${slug}` : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One FL511 ArcGIS feature -> one catalog source, or null. DIRECTION is a
|
||||
* one-letter code the free-text parser won't read, so a table maps it.
|
||||
*
|
||||
* @param {object} feature - ArcGIS feature from the FL511 camera layer.
|
||||
* @returns {?object}
|
||||
*/
|
||||
export function floridaCameraToSource(feature) {
|
||||
const a = feature?.attributes;
|
||||
if (!a || typeof a !== 'object') return null;
|
||||
const lat = toFiniteNumber(a.LATITUDE);
|
||||
const lon = toFiniteNumber(a.LONGITUDE);
|
||||
if (!isLikelyFloridaCoordinate(lat, lon)) return null;
|
||||
|
||||
const imageUrl = String(a.IMAGE || '');
|
||||
let image;
|
||||
try {
|
||||
image = new URL(imageUrl);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
// Official image hosts only, HTTPS, no embedded credentials.
|
||||
if (
|
||||
image.protocol !== 'https:' ||
|
||||
!FLORIDA_IMAGE_HOSTS.includes(image.host) ||
|
||||
image.username ||
|
||||
image.password
|
||||
)
|
||||
return null;
|
||||
|
||||
const cameraId = floridaCameraId(imageUrl);
|
||||
if (!cameraId) return null;
|
||||
|
||||
const heading =
|
||||
FLORIDA_DIRECTION_HEADINGS[
|
||||
String(a.DIRECTION || '')
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
];
|
||||
const hasHeading = Number.isFinite(heading);
|
||||
|
||||
return {
|
||||
id: cameraId,
|
||||
name: String(a.DESCRIPT || '').trim() || `FL511 ${cameraId.slice(3)}`,
|
||||
city: String(a.COUNTY ? `${a.COUNTY} County` : 'Florida'),
|
||||
cityId: 'florida',
|
||||
provider: 'FL511',
|
||||
lat,
|
||||
lon,
|
||||
headingDeg: hasHeading ? heading : fallbackHeadingFromId(cameraId),
|
||||
headingConfidence: hasHeading ? 'high' : 'low',
|
||||
pitchDeg: hasHeading ? -24 : -18,
|
||||
fovDeg: hasHeading ? 56 : 44,
|
||||
rangeM: hasHeading ? 210 : 145,
|
||||
mountHeightM: hasHeading ? 10 : 8,
|
||||
groundElevationM: 5, // Coastal prior; the client's ground snap corrects.
|
||||
feedType: 'image',
|
||||
url: imageUrl,
|
||||
snapshotUrl: imageUrl,
|
||||
sourceKind: 'fl511-open-data',
|
||||
license:
|
||||
'FL511 (FDOT), individual personal use only; other use needs FDOT written consent',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch FL511 (Florida DOT) cameras, keyless: an ArcGIS FeatureServer paged
|
||||
* up to 2,000 rows at a time, ordered by the layer's object id so offsets are
|
||||
* stable. Paging follows `exceededTransferLimit` and advances by the rows each
|
||||
* page returned, so a lower server-side max record count still reads the whole
|
||||
* layer. Frames are stills on the Divas Cloud hosts. A redirect on any page
|
||||
* drops the pack; any other failed page keeps the rows already read.
|
||||
*
|
||||
* @returns {Promise<Array<object>>} Normalized camera source objects.
|
||||
*/
|
||||
export async function loadFloridaSourcesFromOpenData() {
|
||||
try {
|
||||
const cameras = [];
|
||||
const seen = new Set();
|
||||
const pageSize = 2000;
|
||||
let offset = 0;
|
||||
// Page ceiling, in case the service ignores resultOffset.
|
||||
for (let page = 0; ; page += 1) {
|
||||
if (page >= FLORIDA_MAX_CATALOG_PAGES) {
|
||||
console.warn(
|
||||
`[CCTV] Florida catalog stopped at the ${FLORIDA_MAX_CATALOG_PAGES}-page ceiling; the catalog may be incomplete`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
// OBJECTID_1 is this layer's object id field (a plain OBJECTID is a 400).
|
||||
const url = `${FLORIDA_CCTV_URL}?where=1%3D1&outFields=*&orderByFields=OBJECTID_1&f=json&resultRecordCount=${pageSize}&resultOffset=${offset}`;
|
||||
let resp;
|
||||
try {
|
||||
resp = await fetch(url, {
|
||||
headers: { Accept: 'application/json' },
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[CCTV] Florida catalog page failed:',
|
||||
error?.message || error,
|
||||
);
|
||||
break;
|
||||
}
|
||||
// Release the body on every path that will not read it.
|
||||
const discard = async () => {
|
||||
try {
|
||||
await resp.body?.cancel();
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
};
|
||||
if (resp.status >= 300 && resp.status < 400) {
|
||||
console.warn(
|
||||
'[CCTV] Florida catalog redirected; redirects are not followed',
|
||||
);
|
||||
await discard();
|
||||
return [];
|
||||
}
|
||||
if (!resp.ok) {
|
||||
console.warn('[CCTV] Florida camera download failed:', resp.status);
|
||||
await discard();
|
||||
break;
|
||||
}
|
||||
let body;
|
||||
try {
|
||||
body = await readResponseJsonCapped(resp, FLORIDA_MAX_CATALOG_BYTES);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[CCTV] Florida catalog page unreadable:',
|
||||
error?.message || error,
|
||||
);
|
||||
break;
|
||||
}
|
||||
const feats = Array.isArray(body?.features) ? body.features : [];
|
||||
for (const feature of feats) {
|
||||
const camera = floridaCameraToSource(feature);
|
||||
if (!camera || seen.has(camera.id)) continue;
|
||||
seen.add(camera.id);
|
||||
cameras.push(camera);
|
||||
}
|
||||
// ArcGIS sets exceededTransferLimit while more rows remain. A page may
|
||||
// be shorter than pageSize when the layer's max record count is lower.
|
||||
if (body?.exceededTransferLimit !== true || feats.length === 0) break;
|
||||
offset += feats.length;
|
||||
}
|
||||
|
||||
const maxRaw = Number(
|
||||
process.env.CCTV_FLORIDA_MAX_SOURCES || DEFAULT_FLORIDA_MAX_SOURCES,
|
||||
);
|
||||
const maxCount = Number.isFinite(maxRaw)
|
||||
? Math.max(8, Math.min(4200, Math.floor(maxRaw)))
|
||||
: DEFAULT_FLORIDA_MAX_SOURCES;
|
||||
const prioritized = prioritizeSources(cameras, maxCount, FLORIDA_ANCHORS);
|
||||
console.log(
|
||||
`[CCTV] Loaded Florida camera sources: ${cameras.length} (using nearest ${prioritized.length})`,
|
||||
);
|
||||
return prioritized;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[CCTV] Florida camera download error:',
|
||||
error?.message || error,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -14,16 +14,34 @@ async function readRequestBodyCapped(req, maxBytes) {
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
function readRequestBody(req, maxBytes = 1024 * 1024) {
|
||||
/** Optional cancellation discards buffered data while the request drains. */
|
||||
function readRequestBody(req, maxBytes = 1024 * 1024, signal) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let total = 0;
|
||||
let bodyTooLarge = false;
|
||||
let settled = false;
|
||||
const chunks = [];
|
||||
req.on('data', (chunk) => {
|
||||
const stopReading = () => {
|
||||
if (!signal) return;
|
||||
req.off('data', data);
|
||||
req.off('end', end);
|
||||
signal.removeEventListener('abort', abort);
|
||||
};
|
||||
const fail = (error) => {
|
||||
if (signal) {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
chunks.length = 0;
|
||||
signal.removeEventListener('abort', abort);
|
||||
}
|
||||
reject(error);
|
||||
};
|
||||
const abort = () => fail(signal.reason);
|
||||
const data = (chunk) => {
|
||||
// Keep draining after the cap without retaining more bytes. Destroying
|
||||
// the request also destroys its socket, so the handler cannot deliver
|
||||
// the fixed error response it constructs for BODY_TOO_LARGE.
|
||||
if (bodyTooLarge) return;
|
||||
if (bodyTooLarge || settled) return;
|
||||
total += chunk.length;
|
||||
if (total > maxBytes) {
|
||||
// Same marker readRequestBodyCapped sets, so a caller can tell an
|
||||
@@ -32,15 +50,42 @@ function readRequestBody(req, maxBytes = 1024 * 1024) {
|
||||
chunks.length = 0;
|
||||
const error = new Error(`Request body exceeds ${maxBytes} bytes`);
|
||||
error.code = 'BODY_TOO_LARGE';
|
||||
reject(error);
|
||||
fail(error);
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
req.on('end', () => {
|
||||
};
|
||||
const end = () => {
|
||||
if (settled) {
|
||||
stopReading();
|
||||
return;
|
||||
}
|
||||
if (!bodyTooLarge) resolve(Buffer.concat(chunks).toString('utf8'));
|
||||
});
|
||||
req.on('error', reject);
|
||||
if (signal) {
|
||||
settled = true;
|
||||
chunks.length = 0;
|
||||
stopReading();
|
||||
}
|
||||
};
|
||||
const close = () => {
|
||||
fail(
|
||||
new DOMException('Request closed before its body ended', 'AbortError'),
|
||||
);
|
||||
stopReading();
|
||||
req.off('error', fail);
|
||||
req.off('close', close);
|
||||
};
|
||||
if (signal) {
|
||||
// Abort/overflow release retained bytes but keep draining. Retain an
|
||||
// error handler until close, including a late error after end/abort.
|
||||
req.on('close', close);
|
||||
signal.addEventListener('abort', abort, { once: true });
|
||||
if (signal.aborted) abort();
|
||||
}
|
||||
req.on('data', data);
|
||||
req.on('end', end);
|
||||
req.on('error', fail);
|
||||
if (signal && req.destroyed) close();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter, getEventListeners } from 'node:events';
|
||||
import { readRequestBody } from './request.js';
|
||||
|
||||
function fixture() {
|
||||
const req = new EventEmitter();
|
||||
req.pause = () => assert.fail('The reader must keep draining');
|
||||
req.destroy = () =>
|
||||
assert.fail('The reader must preserve the response socket');
|
||||
const owner = new AbortController();
|
||||
return { req, owner };
|
||||
}
|
||||
|
||||
function finish(req, owner) {
|
||||
assert.doesNotThrow(() => req.emit('error', new Error('late stream error')));
|
||||
req.emit('end');
|
||||
assert.doesNotThrow(() => req.emit('error', new Error('error before close')));
|
||||
req.emit('close');
|
||||
for (const event of ['data', 'end', 'error', 'close'])
|
||||
assert.equal(req.listenerCount(event), 0, event);
|
||||
assert.equal(getEventListeners(owner.signal, 'abort').length, 0);
|
||||
}
|
||||
|
||||
test('default and cancellable readers preserve byte caps and UTF-8 across chunks', async () => {
|
||||
for (const useSignal of [false, true]) {
|
||||
for (const text of ['', 'hello', 'é']) {
|
||||
const { req, owner } = fixture();
|
||||
const bytes = Buffer.from(text);
|
||||
const body = readRequestBody(
|
||||
req,
|
||||
bytes.length,
|
||||
useSignal ? owner.signal : undefined,
|
||||
);
|
||||
for (const byte of bytes) req.emit('data', Buffer.from([byte]));
|
||||
req.emit('end');
|
||||
assert.equal(await body, text);
|
||||
if (useSignal) finish(req, owner);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('default overflow rejects before end and drains without pausing or destroying', async () => {
|
||||
const { req } = fixture();
|
||||
const rejection = assert.rejects(readRequestBody(req, 2), {
|
||||
code: 'BODY_TOO_LARGE',
|
||||
});
|
||||
req.emit('data', Buffer.from('é'));
|
||||
req.emit('data', Buffer.from('!'));
|
||||
await rejection;
|
||||
req.emit('data', Buffer.alloc(4096));
|
||||
assert.doesNotThrow(() => req.emit('error', new Error('late')));
|
||||
req.emit('end');
|
||||
});
|
||||
|
||||
test('partial abort settles before end, discards late data and guards errors until close', async () => {
|
||||
const { req, owner } = fixture();
|
||||
const reason = new Error('request deadline');
|
||||
const rejection = assert.rejects(
|
||||
readRequestBody(req, 1024, owner.signal),
|
||||
(error) => error === reason,
|
||||
);
|
||||
req.emit('data', Buffer.from('partial'));
|
||||
owner.abort(reason);
|
||||
await rejection;
|
||||
assert.equal(getEventListeners(owner.signal, 'abort').length, 0);
|
||||
req.emit('data', Buffer.alloc(4096));
|
||||
finish(req, owner);
|
||||
});
|
||||
|
||||
test('already-aborted and already-closed requests settle and release their listeners', async () => {
|
||||
for (const closed of [false, true]) {
|
||||
const { req, owner } = fixture();
|
||||
if (closed) req.destroyed = true;
|
||||
else owner.abort();
|
||||
await assert.rejects(readRequestBody(req, 1024, owner.signal), {
|
||||
name: 'AbortError',
|
||||
});
|
||||
if (!closed) finish(req, owner);
|
||||
for (const event of ['data', 'end', 'error', 'close'])
|
||||
assert.equal(req.listenerCount(event), 0);
|
||||
assert.equal(getEventListeners(owner.signal, 'abort').length, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('overflow keeps its first failure through later abort and stream errors', async () => {
|
||||
const { req, owner } = fixture();
|
||||
const rejection = assert.rejects(readRequestBody(req, 2, owner.signal), {
|
||||
code: 'BODY_TOO_LARGE',
|
||||
});
|
||||
req.emit('data', Buffer.from('é!'));
|
||||
await rejection;
|
||||
owner.abort(new Error('later deadline'));
|
||||
req.emit('data', Buffer.alloc(4096));
|
||||
finish(req, owner);
|
||||
});
|
||||
|
||||
test('early close and request error reject once; successful end wins over later abort', async () => {
|
||||
for (const event of ['close', 'error', 'end']) {
|
||||
const { req, owner } = fixture();
|
||||
const reason = new Error('request failed');
|
||||
const body = readRequestBody(req, 32, owner.signal);
|
||||
const observed =
|
||||
event === 'end'
|
||||
? body.then((value) => assert.equal(value, 'ok'))
|
||||
: assert.rejects(
|
||||
body,
|
||||
event === 'close'
|
||||
? { name: 'AbortError' }
|
||||
: (error) => error === reason,
|
||||
);
|
||||
req.emit('data', Buffer.from('ok'));
|
||||
req.emit(event, reason);
|
||||
owner.abort(new Error('later abort'));
|
||||
await observed;
|
||||
if (event !== 'close') finish(req, owner);
|
||||
assert.equal(getEventListeners(owner.signal, 'abort').length, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('a prior data observer can abort synchronously without retaining or resolving that chunk', async () => {
|
||||
const { req, owner } = fixture();
|
||||
const reason = new Error('observer stopped request');
|
||||
const external = () => owner.abort(reason);
|
||||
req.on('data', external);
|
||||
const rejection = assert.rejects(
|
||||
readRequestBody(req, 1024, owner.signal),
|
||||
(error) => error === reason,
|
||||
);
|
||||
req.emit('data', Buffer.from('must be discarded'));
|
||||
await rejection;
|
||||
req.emit('end');
|
||||
req.emit('close');
|
||||
assert.deepEqual(req.listeners('data'), [external]);
|
||||
assert.equal(getEventListeners(owner.signal, 'abort').length, 0);
|
||||
req.off('data', external);
|
||||
});
|
||||
@@ -41,3 +41,48 @@ export function sameSiteGated(handler) {
|
||||
return handler(req, res, next);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy header values arrive client-controlled; read one only when it is a
|
||||
* single scheme token, never a comma-separated chain.
|
||||
* @param {import('node:http').IncomingMessage} req
|
||||
* @returns {'http:'|'https:'}
|
||||
*/
|
||||
function forwardedProtocol(req) {
|
||||
if (req.socket?.encrypted) return 'https:';
|
||||
const proto = String(req.headers?.['x-forwarded-proto'] || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return proto === 'https' ? 'https:' : 'http:';
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-site gate for keyless routes the map needs in order to render
|
||||
* (`/api/terrain/heights`). It refuses the same cross-site browser shapes as
|
||||
* `admitSameSite` (a `Sec-Fetch-Site` other than same-origin/none, a foreign
|
||||
* or opaque `Origin`) but does not refuse reverse-proxy forwarding headers:
|
||||
* those protect credentials and spend, which a keyless route has neither of,
|
||||
* and refusing them would break rendering for an instance served behind the
|
||||
* reverse proxy the README documents. A single `X-Forwarded-Proto` supplies
|
||||
* the scheme an `Origin` is compared against. That header cannot be attached
|
||||
* to a cross-site browser request without a CORS preflight this server never
|
||||
* answers, and `Sec-Fetch-Site` is still enforced regardless.
|
||||
* @param {import('node:http').IncomingMessage} req
|
||||
* @param {import('node:http').ServerResponse} res
|
||||
* @returns {boolean} True when a 403 was sent.
|
||||
*/
|
||||
export function admitSameSiteKeyless(req, res) {
|
||||
const verdict = admitSameSiteRequest({
|
||||
hostHeader: req.headers?.host,
|
||||
protocol: forwardedProtocol(req),
|
||||
origin: req.headers?.origin,
|
||||
secFetchSite: req.headers?.['sec-fetch-site'],
|
||||
proxyHeaders: {},
|
||||
});
|
||||
if (verdict.ok) return false;
|
||||
res.statusCode = verdict.status;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.end(JSON.stringify({ error: verdict.error }));
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { createGeminiTokenHandler } from './gemini/realtime.js';
|
||||
|
||||
/** Mint constrained Live API credentials; the permanent key stays on Node. */
|
||||
function geminiLiveProxy({ annotationGuidance, realtime = {} } = {}) {
|
||||
function install(middlewares) {
|
||||
middlewares.use(
|
||||
'/api/gemini/token',
|
||||
createGeminiTokenHandler({ ...realtime, annotationGuidance }),
|
||||
);
|
||||
}
|
||||
return {
|
||||
name: 'gemini-live-proxy',
|
||||
configureServer(server) {
|
||||
install(server.middlewares);
|
||||
},
|
||||
configurePreviewServer(server) {
|
||||
install(server.middlewares);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export { geminiLiveProxy };
|
||||
@@ -0,0 +1,53 @@
|
||||
import { realtimeInstructions } from '../openai/instructions.js';
|
||||
import { GEV_REALTIME_TOOLS } from '../openai/tools.js';
|
||||
|
||||
export const GEMINI_LIVE_MODEL_DEFAULT = 'gemini-3.8-live';
|
||||
export const GEMINI_TOKEN_ENDPOINT =
|
||||
'https://generativelanguage.googleapis.com/v1beta/auth_tokens';
|
||||
export const GEMINI_LIVE_WEBSOCKET_URL =
|
||||
'wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContentConstrained';
|
||||
|
||||
export function normalizeGeminiModel(value) {
|
||||
const model = String(value ?? GEMINI_LIVE_MODEL_DEFAULT)
|
||||
.trim()
|
||||
.replace(/^models\//, '');
|
||||
return /^gemini-[a-z0-9][a-z0-9.-]{0,119}$/.test(model) ? model : null;
|
||||
}
|
||||
|
||||
/** REST BidiGenerateContentSetup fields, excluding its model resource name. */
|
||||
export function createGeminiLiveConfig(
|
||||
annotationGuidance,
|
||||
inputMode = 'open-mic',
|
||||
tools = GEV_REALTIME_TOOLS,
|
||||
) {
|
||||
if (!['open-mic', 'push-to-talk'].includes(inputMode))
|
||||
throw new Error('Invalid Gemini input mode');
|
||||
const config = {
|
||||
generationConfig: { responseModalities: ['AUDIO'] },
|
||||
systemInstruction: {
|
||||
parts: [{ text: realtimeInstructions(annotationGuidance) }],
|
||||
},
|
||||
tools: [
|
||||
{
|
||||
functionDeclarations: tools.map(
|
||||
({ name, description, parameters }) => ({
|
||||
name,
|
||||
description,
|
||||
// GEV's canonical schemas are JSON Schema, not Google's Schema.
|
||||
parametersJsonSchema: structuredClone(parameters),
|
||||
// App actions must finish before the model confirms their result.
|
||||
behavior: 'BLOCKING',
|
||||
}),
|
||||
),
|
||||
},
|
||||
],
|
||||
inputAudioTranscription: {},
|
||||
outputAudioTranscription: {},
|
||||
contextWindowCompression: { slidingWindow: {} },
|
||||
};
|
||||
if (inputMode === 'push-to-talk')
|
||||
config.realtimeInputConfig = {
|
||||
automaticActivityDetection: { disabled: true },
|
||||
};
|
||||
return config;
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { waitForSignal } from '../../../src/sources/requestDeadline.js';
|
||||
import { clientKey, makeCostRateLimiter } from '../common/rate-limit.js';
|
||||
import { readResponseJsonCapped } from '../../../src/sources/httpBody.js';
|
||||
import {
|
||||
GEMINI_TOKEN_ENDPOINT,
|
||||
GEMINI_LIVE_WEBSOCKET_URL,
|
||||
GEMINI_LIVE_MODEL_DEFAULT,
|
||||
createGeminiLiveConfig,
|
||||
normalizeGeminiModel,
|
||||
} from './config.js';
|
||||
import {
|
||||
isGeminiRequestOriginAllowed,
|
||||
readGeminiTokenRequest,
|
||||
} from './request.js';
|
||||
|
||||
const UPSTREAM_RESPONSE_BYTES = 32 * 1024;
|
||||
const MINT_FAILURE = 'Gemini voice could not connect. Try again.';
|
||||
|
||||
export function createGeminiTokenHandler({
|
||||
annotationGuidance,
|
||||
tools,
|
||||
fetchImpl = (...args) => fetch(...args),
|
||||
resolveApiKey = () => process.env.GEMINI_API_KEY,
|
||||
resolveModel = () =>
|
||||
process.env.GEMINI_LIVE_MODEL || GEMINI_LIVE_MODEL_DEFAULT,
|
||||
resolveHost = () => process.env.HOST,
|
||||
resolveAllowedHosts = () => process.env.GEV_ALLOWED_HOSTS,
|
||||
resolveRateLimit = () => process.env.GEV_RATELIMIT_GEMINI_PER_MIN ?? '30',
|
||||
now = Date.now,
|
||||
timeoutMs = 15_000,
|
||||
} = {}) {
|
||||
let limiter;
|
||||
return async (req, res) => {
|
||||
if (req.aborted || res.destroyed || res.writableEnded) return;
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
const reply = (status, errorOrPayload) => {
|
||||
if (res.destroyed || res.writableEnded) return;
|
||||
res.statusCode = status;
|
||||
if (status >= 400) res.setHeader('Connection', 'close');
|
||||
res.end(
|
||||
JSON.stringify(
|
||||
typeof errorOrPayload === 'string'
|
||||
? { error: errorOrPayload }
|
||||
: errorOrPayload,
|
||||
),
|
||||
);
|
||||
};
|
||||
if (req.method !== 'POST') {
|
||||
res.setHeader('Allow', 'POST');
|
||||
return reply(405, 'Method not allowed');
|
||||
}
|
||||
let apiKey;
|
||||
let model;
|
||||
try {
|
||||
if (
|
||||
!isGeminiRequestOriginAllowed(req, resolveHost(), resolveAllowedHosts())
|
||||
)
|
||||
return reply(403, 'Gemini voice requires a same-origin app request');
|
||||
apiKey = String(resolveApiKey() || '').trim();
|
||||
if (!apiKey)
|
||||
return reply(503, 'Add a Gemini API key in Provider Settings');
|
||||
model = normalizeGeminiModel(resolveModel());
|
||||
if (!model)
|
||||
return reply(503, 'The Gemini Live model configuration is invalid');
|
||||
if (limiter === undefined)
|
||||
limiter = makeCostRateLimiter(resolveRateLimit(), 30);
|
||||
if (limiter && !limiter(clientKey(req))) {
|
||||
res.setHeader('Retry-After', '60');
|
||||
return reply(
|
||||
429,
|
||||
'Gemini voice is starting too often. Try again shortly.',
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Connect does not observe the handler's returned promise. A synchronous
|
||||
// configuration resolver must fail with a bounded, fixed response.
|
||||
return reply(503, MINT_FAILURE);
|
||||
}
|
||||
|
||||
const lifetime = new AbortController();
|
||||
let disconnected = false;
|
||||
const disconnect = () => {
|
||||
disconnected = true;
|
||||
lifetime.abort(new DOMException('Request closed', 'AbortError'));
|
||||
};
|
||||
const responseClosed = () => {
|
||||
if (!res.writableEnded) disconnect();
|
||||
};
|
||||
req.once('aborted', disconnect);
|
||||
res.once('close', responseClosed);
|
||||
const deadline = setTimeout(() => {
|
||||
lifetime.abort(
|
||||
new DOMException('Token request timed out', 'TimeoutError'),
|
||||
);
|
||||
}, timeoutMs);
|
||||
deadline.unref?.();
|
||||
let acquiredResponse;
|
||||
try {
|
||||
const inputMode = await readGeminiTokenRequest(req, lifetime.signal);
|
||||
lifetime.signal.throwIfAborted();
|
||||
const config = createGeminiLiveConfig(
|
||||
annotationGuidance,
|
||||
inputMode,
|
||||
tools,
|
||||
);
|
||||
const issuedAt = now();
|
||||
// REST uses bidiGenerateContentSetup. liveConnectConstraints is the SDK
|
||||
// input shape, converted before transport. With no fieldMask the complete
|
||||
// setup is locked to this server-owned configuration.
|
||||
const request = {
|
||||
uses: 1,
|
||||
expireTime: new Date(issuedAt + 30 * 60_000).toISOString(),
|
||||
newSessionExpireTime: new Date(issuedAt + 60_000).toISOString(),
|
||||
bidiGenerateContentSetup: { model: `models/${model}`, ...config },
|
||||
};
|
||||
const acquisition = Promise.resolve(
|
||||
fetchImpl(GEMINI_TOKEN_ENDPOINT, {
|
||||
method: 'POST',
|
||||
redirect: 'error',
|
||||
signal: lifetime.signal,
|
||||
headers: {
|
||||
'x-goog-api-key': apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
}),
|
||||
);
|
||||
// A fetch implementation can ignore abort. Bound this caller's wait and
|
||||
// discard a response that arrives after timeout/disconnection.
|
||||
void acquisition.then(
|
||||
(late) => {
|
||||
acquiredResponse = late;
|
||||
if (lifetime.signal.aborted) void late.body?.cancel().catch(() => {});
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
const response = await waitForSignal(acquisition, lifetime.signal);
|
||||
lifetime.signal.throwIfAborted();
|
||||
if (
|
||||
response.redirected ||
|
||||
(response.url && response.url !== GEMINI_TOKEN_ENDPOINT)
|
||||
) {
|
||||
void response.body?.cancel().catch(() => {});
|
||||
throw new Error('Unexpected token response destination');
|
||||
}
|
||||
if (!response.ok) {
|
||||
// Never forward upstream bodies, headers or exception messages.
|
||||
void response.body?.cancel().catch(() => {});
|
||||
if (response.status === 429) {
|
||||
res.setHeader('Retry-After', '60');
|
||||
return reply(429, 'Gemini quota is unavailable. Try again later.');
|
||||
}
|
||||
if (response.status === 401 || response.status === 403)
|
||||
return reply(
|
||||
503,
|
||||
'Gemini access was refused. Check the API key and model access.',
|
||||
);
|
||||
if (response.status === 400 || response.status === 404)
|
||||
return reply(
|
||||
502,
|
||||
'Gemini Live is unavailable for the configured model.',
|
||||
);
|
||||
return reply(502, MINT_FAILURE);
|
||||
}
|
||||
const data = await readResponseJsonCapped(
|
||||
response,
|
||||
UPSTREAM_RESPONSE_BYTES,
|
||||
lifetime.signal,
|
||||
);
|
||||
lifetime.signal.throwIfAborted();
|
||||
if (
|
||||
!data ||
|
||||
Array.isArray(data) ||
|
||||
typeof data !== 'object' ||
|
||||
typeof data.name !== 'string' ||
|
||||
data.name.length > 8192 ||
|
||||
!/^auth_tokens\/[A-Za-z0-9._~+/=-]+$/.test(data.name) ||
|
||||
data.name.includes(apiKey)
|
||||
)
|
||||
throw new Error('Invalid ephemeral token');
|
||||
reply(200, {
|
||||
token: data.name,
|
||||
model,
|
||||
config,
|
||||
websocketUrl: GEMINI_LIVE_WEBSOCKET_URL,
|
||||
});
|
||||
} catch (error) {
|
||||
if (disconnected || req.aborted) return;
|
||||
if (error?.code === 'BODY_TOO_LARGE')
|
||||
return reply(413, 'Token request is too large');
|
||||
if (error?.code === 'BAD_CONTENT_TYPE')
|
||||
return reply(415, 'Use an empty JSON token request');
|
||||
if (error?.code === 'BAD_BODY')
|
||||
return reply(400, 'Use an empty JSON token request');
|
||||
reply(lifetime.signal.aborted ? 504 : 502, MINT_FAILURE);
|
||||
} finally {
|
||||
// Abort can happen after acquisition resolves but before this handler
|
||||
// resumes and passes ownership to the body reader.
|
||||
if (lifetime.signal.aborted)
|
||||
void acquiredResponse?.body?.cancel().catch(() => {});
|
||||
clearTimeout(deadline);
|
||||
req.off('aborted', disconnect);
|
||||
res.off('close', responseClosed);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { readRequestBody } from '../common/request.js';
|
||||
import { admitSameSiteRequest } from '../../../src/localRequestGate.mjs';
|
||||
import {
|
||||
isAllowedHost,
|
||||
resolveAllowedHosts,
|
||||
} from '../../../build/allowedHosts.js';
|
||||
|
||||
/** Use the app's Host and request policies, with a required browser Origin. */
|
||||
export function isGeminiRequestOriginAllowed(
|
||||
req,
|
||||
configuredHost = '',
|
||||
configuredAllowedHosts,
|
||||
) {
|
||||
const host = req.headers?.host;
|
||||
const origin = req.headers?.origin;
|
||||
if (
|
||||
typeof host !== 'string' ||
|
||||
typeof origin !== 'string' ||
|
||||
!host ||
|
||||
!origin ||
|
||||
/[\s/?#@\\]/.test(host)
|
||||
)
|
||||
return false;
|
||||
const protocol = req.socket?.encrypted ? 'https:' : 'http:';
|
||||
if (
|
||||
!admitSameSiteRequest({
|
||||
hostHeader: host,
|
||||
protocol,
|
||||
origin,
|
||||
secFetchSite: req.headers?.['sec-fetch-site'],
|
||||
proxyHeaders: req.headers || {},
|
||||
}).ok
|
||||
)
|
||||
return false;
|
||||
// The shared gate compares a present Origin exactly with this authority;
|
||||
// this route also requires the Origin and an allowed Host.
|
||||
const boundHost = String(configuredHost || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return isAllowedHost(host, resolveAllowedHosts(configuredAllowedHosts), [
|
||||
boundHost,
|
||||
]);
|
||||
}
|
||||
|
||||
/** Admit only the existing input gesture; callers cannot supply model/setup. */
|
||||
export async function readGeminiTokenRequest(req, signal, maxBytes = 1024) {
|
||||
const declared = Number(req.headers?.['content-length']);
|
||||
const fail = (code) =>
|
||||
Object.assign(new Error('Invalid token request'), { code });
|
||||
const body = await readRequestBody(
|
||||
req,
|
||||
maxBytes,
|
||||
Number.isFinite(declared) && declared > maxBytes
|
||||
? AbortSignal.abort(fail('BODY_TOO_LARGE'))
|
||||
: signal,
|
||||
);
|
||||
if (!body.trim()) return 'open-mic';
|
||||
if (
|
||||
!/^application\/json(?:\s*;|$)/i.test(req.headers?.['content-type'] || '')
|
||||
)
|
||||
throw fail('BAD_CONTENT_TYPE');
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(body);
|
||||
} catch {
|
||||
throw fail('BAD_BODY');
|
||||
}
|
||||
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object')
|
||||
throw fail('BAD_BODY');
|
||||
const keys = Object.keys(parsed);
|
||||
if (!keys.length) return 'open-mic';
|
||||
if (
|
||||
keys.length === 1 &&
|
||||
keys[0] === 'inputMode' &&
|
||||
parsed.inputMode === 'push-to-talk'
|
||||
)
|
||||
return 'push-to-talk';
|
||||
throw fail('BAD_BODY');
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { adsbLolProxy } from './aircraft/adsb-lol.js';
|
||||
import { aisLiveProxy } from './vessels/ais-live.js';
|
||||
import { trackBackfillProxies } from './aircraft/tracks.js';
|
||||
import { openAiRealtimeProxy } from './openai.js';
|
||||
import { geminiLiveProxy } from './gemini.js';
|
||||
import { googlePlacesContextProxy } from './places.js';
|
||||
import { mapillaryProxy } from './mapillary.js';
|
||||
import { keySetupEndpoint } from '../standalone/key-setup.js';
|
||||
@@ -30,9 +31,9 @@ import { windProxy } from './wind.js';
|
||||
|
||||
/**
|
||||
* Construct the local provider plugins in their established order.
|
||||
* `realtime` configures the voice session token endpoint.
|
||||
* `realtime` configures OpenAI; `gemini` explicitly opts into Gemini Live.
|
||||
*/
|
||||
function localProviderPlugins({ realtime } = {}) {
|
||||
function localProviderPlugins({ realtime, gemini } = {}) {
|
||||
return [
|
||||
openSkyProxy(),
|
||||
celestrakProxy(),
|
||||
@@ -55,6 +56,7 @@ function localProviderPlugins({ realtime } = {}) {
|
||||
aisLiveProxy(),
|
||||
trackBackfillProxies(),
|
||||
openAiRealtimeProxy({ realtime }),
|
||||
...(gemini ? [geminiLiveProxy({ realtime: gemini })] : []),
|
||||
googlePlacesContextProxy(),
|
||||
windProxy(),
|
||||
weatherProxy(),
|
||||
|
||||
@@ -13,6 +13,8 @@ export function launchLibraryRequestHeaders(token = process.env.LL2_API_TOKEN) {
|
||||
const normalized = String(token || '').trim();
|
||||
return {
|
||||
Accept: 'application/json',
|
||||
'User-Agent':
|
||||
'gods-eye-view-launch-library-proxy/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)',
|
||||
...(normalized ? { Authorization: `Token ${normalized}` } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
terrainPointKey,
|
||||
validTerrainResult,
|
||||
} from '../../src/data/terrainHeightsProxy.js';
|
||||
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
|
||||
import { admitSameSiteKeyless } from './common/same-site.js';
|
||||
|
||||
/**
|
||||
* Re:Earth terrain point-height proxy: batched lon/lat → ellipsoidal height
|
||||
@@ -20,11 +22,31 @@ import {
|
||||
* Only missing/stale points go upstream; the response is rebuilt in exact
|
||||
* request order. Larger requests are chunked sequentially, and one failing
|
||||
* chunk does not discard the chunks that resolved.
|
||||
*
|
||||
* Bounds, because every distinct 5dp point is its own key: coordinates must be
|
||||
* in WGS-84 range (rejected at parse), the point cache has an entry ceiling
|
||||
* with oldest-first eviction, an over-large cache file is not read back, and
|
||||
* the route carries an always-on per-client limiter. The route also refuses
|
||||
* cross-site browser requests, so another web page cannot use the visitor's
|
||||
* browser to fill the cache or spend the visitor's own rate-limit quota.
|
||||
* Reverse-proxied same-origin requests are admitted: terrain is keyless and
|
||||
* needed for rendering, and a 403 would send every chunk to the geoid fallback.
|
||||
*/
|
||||
export function terrainHeightsProxy() {
|
||||
export function terrainHeightsProxy(options = {}) {
|
||||
// Destructured in the body, not the signature: `proxyErrorResponses.test.mjs`
|
||||
// extracts these proxies by slicing to the first closing brace in column
|
||||
// zero, so a multi-line parameter list would cut the function in half.
|
||||
// Overridable so the ceilings can be exercised as behaviour rather than
|
||||
// asserted as constants; production constructs this with no arguments.
|
||||
const {
|
||||
cacheMaxEntries = 50_000,
|
||||
cacheMaxDiskBytes = 24 * 1024 * 1024,
|
||||
cachePath,
|
||||
} = options;
|
||||
const TTL_MS = 30 * 24 * 3600_000;
|
||||
const CACHE_DIR = path.join(process.cwd(), '.gev-cache');
|
||||
const CACHE_PATH = path.join(CACHE_DIR, 'terrain-heights.json');
|
||||
const CACHE_PATH =
|
||||
cachePath || path.join(process.cwd(), '.gev-cache', 'terrain-heights.json');
|
||||
const CACHE_DIR = path.dirname(CACHE_PATH);
|
||||
// Sized against measured upstream latency, not the documented page cap.
|
||||
// Re:Earth serves 87-186 ms/point depending on load (observed 2026-09-12,
|
||||
// a 2x swing within one hour). 256 points therefore costs 22-48 s and blows
|
||||
@@ -34,9 +56,50 @@ export function terrainHeightsProxy() {
|
||||
// that range.
|
||||
const UPSTREAM_CHUNK = 64;
|
||||
const MAX_POINTS = 2000;
|
||||
/**
|
||||
* Entry ceiling for the point cache, and with it the file written from it.
|
||||
*
|
||||
* Every distinct 5dp point is a separate key, so without a ceiling the map
|
||||
* — and the JSON dumped from it verbatim — grow for as long as a caller
|
||||
* supplies new coordinates. Measured against real use rather than guessed:
|
||||
* this checkout's `.gev-cache/terrain-heights.json` held 6,189 entries in
|
||||
* 1.0 MB after ordinary sessions, i.e. ~168 bytes/entry. 50,000 entries is
|
||||
* eight times that working set and ~8 MB on disk, comfortably inside the
|
||||
* 24 MB ceiling `rocketLaunchesProxy` already applies to its own cache file.
|
||||
*/
|
||||
const CACHE_MAX_ENTRIES = cacheMaxEntries;
|
||||
/** Refuse to load a cache file larger than this (mirrors rocketLaunchesProxy). */
|
||||
const CACHE_MAX_DISK_BYTES = cacheMaxDiskBytes;
|
||||
/**
|
||||
* Requests/min/client. Terrain is keyless and free, so this guards the point
|
||||
* cache and the upstream community service rather than a bill. Sized from
|
||||
* measurement: across a full `npm run test:track` run — the heaviest
|
||||
* exercise this app has — the route served 10 requests, peaking at 8/min.
|
||||
* 90/min is an order of magnitude above that, and the band `/api/overpass`
|
||||
* and `/api/military-installations` already use.
|
||||
*/
|
||||
const RATE_PER_MIN = 90;
|
||||
const allow = makeRateLimiter({
|
||||
windowMs: 60_000,
|
||||
max: RATE_PER_MIN,
|
||||
globalMax: RATE_PER_MIN * 3,
|
||||
});
|
||||
|
||||
/** @type {Map<string, {at:number, result:object}>} keyed by canonical 5dp lon/lat. */
|
||||
const mem = new Map();
|
||||
|
||||
/**
|
||||
* Drop the oldest entries until the map is back inside its ceiling.
|
||||
* Oldest-first by fetch time, the same shape `trackBackfillProxies` uses.
|
||||
* Terrain does not move, so evicting an old point costs one refetch, never
|
||||
* a wrong answer.
|
||||
*/
|
||||
function evictOverflow() {
|
||||
if (mem.size <= CACHE_MAX_ENTRIES) return;
|
||||
const byAge = [...mem.entries()].sort((a, b) => a[1].at - b[1].at);
|
||||
for (let i = 0; i < byAge.length && mem.size > CACHE_MAX_ENTRIES; i += 1)
|
||||
mem.delete(byAge[i][0]);
|
||||
}
|
||||
/** @type {Map<string, Promise<Array<object>>>} single-flight per missing-point subset. */
|
||||
const inflight = new Map();
|
||||
let diskLoaded = false;
|
||||
@@ -47,6 +110,21 @@ export function terrainHeightsProxy() {
|
||||
if (diskLoaded) return;
|
||||
diskLoaded = true;
|
||||
try {
|
||||
// A cache file past the ceiling is not read back into memory: loading it
|
||||
// would reinstate exactly the unbounded map the ceiling exists to stop.
|
||||
// The next flush rewrites a bounded one.
|
||||
//
|
||||
// "Cannot measure it" is not "too large": if stat is unavailable, fall
|
||||
// through and let readFile decide, so an absent file behaves exactly as
|
||||
// it did before this guard existed.
|
||||
let sizeBytes = null;
|
||||
try {
|
||||
sizeBytes = (await fsp.stat(CACHE_PATH)).size;
|
||||
} catch {
|
||||
sizeBytes = null;
|
||||
}
|
||||
if (sizeBytes !== null && sizeBytes > CACHE_MAX_DISK_BYTES)
|
||||
throw new Error('cache file too large');
|
||||
const parsed = JSON.parse(await fsp.readFile(CACHE_PATH, 'utf8'));
|
||||
const pointEntries =
|
||||
parsed?.version === 2 &&
|
||||
@@ -87,6 +165,7 @@ export function terrainHeightsProxy() {
|
||||
}
|
||||
diskDirty = mem.size > 0;
|
||||
}
|
||||
evictOverflow();
|
||||
} catch {
|
||||
/* no disk cache yet */
|
||||
}
|
||||
@@ -157,12 +236,25 @@ export function terrainHeightsProxy() {
|
||||
|
||||
const installMiddleware = (server) => {
|
||||
server.middlewares.use('/api/terrain/heights', async (req, res) => {
|
||||
// Refuse cross-site browser requests (an <img> or fetch from another
|
||||
// site) before the limiter, cache or upstream see them. Same-origin app
|
||||
// requests, non-browser loopback callers (MCP panel_request) and the app
|
||||
// served behind a reverse proxy pass.
|
||||
if (admitSameSiteKeyless(req, res)) return;
|
||||
const send = (status, bodyObj) => {
|
||||
if (res.headersSent) return;
|
||||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(bodyObj));
|
||||
};
|
||||
try {
|
||||
if (!allow(clientKey(req))) {
|
||||
res.writeHead(429, {
|
||||
'Content-Type': 'application/json',
|
||||
'Retry-After': '10',
|
||||
});
|
||||
res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
|
||||
return;
|
||||
}
|
||||
await loadDiskOnce();
|
||||
const parsedUrl = new URL(req.url || '', 'http://internal');
|
||||
const rawPoints = parsedUrl.searchParams.get('points');
|
||||
@@ -187,7 +279,10 @@ export function terrainHeightsProxy() {
|
||||
fetchMissing: fetchMissingSingleFlight,
|
||||
ttlMs: TTL_MS,
|
||||
});
|
||||
if (outcome.cacheChanged) diskDirty = true;
|
||||
if (outcome.cacheChanged) {
|
||||
evictOverflow();
|
||||
diskDirty = true;
|
||||
}
|
||||
if (outcome.upstreamError) {
|
||||
console.warn(
|
||||
'[terrain-heights-proxy] refresh incomplete' +
|
||||
|
||||
@@ -16,9 +16,10 @@ export default defineConfig(({ command, mode }) => {
|
||||
for (const [key, value] of Object.entries(loaded)) {
|
||||
if (process.env[key] === undefined) process.env[key] = value;
|
||||
}
|
||||
const tools = standaloneVoiceTools();
|
||||
return createBrowserViteConfig({
|
||||
plugins: [
|
||||
...localProviderPlugins({ realtime: { tools: standaloneVoiceTools() } }),
|
||||
...localProviderPlugins({ realtime: { tools }, gemini: { tools } }),
|
||||
localMcpPlugin(),
|
||||
apiNotFoundPlugin(),
|
||||
unwatchedRuntimeDirsPlugin(),
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
feedProvenanceEnvelope,
|
||||
layerSnapshot,
|
||||
layerSnapshots,
|
||||
} from '../data/layerSnapshot.js';
|
||||
import { contextModeWord } from '../contextModePolicy.js';
|
||||
|
||||
export const TRACKABLE_FAMILIES = Object.freeze([
|
||||
{ layerId: 'flights', kind: 'aircraft' },
|
||||
{ layerId: 'military', kind: 'aircraft' },
|
||||
{ layerId: 'ais-live-vessels', kind: 'vessel' },
|
||||
{ layerId: 'satellites', kind: 'satellite' },
|
||||
]);
|
||||
|
||||
const CONTEXT_MODE_RESULT_FIELDS = Object.freeze([
|
||||
{ field: 'mode', emptyAs: 'off' },
|
||||
{ field: 'entering', emptyAs: null },
|
||||
{ field: 'priorMode', emptyAs: null },
|
||||
]);
|
||||
|
||||
const NESTED_CONTEXT_RESULT_FIELDS = Object.freeze([
|
||||
'context',
|
||||
'contextRollback',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Translate context-mode payloads into the public tool vocabulary while
|
||||
* retaining the internal id beside each translated field.
|
||||
*/
|
||||
export function withContextModeVocabulary(state) {
|
||||
if (!state || typeof state !== 'object') return state;
|
||||
let out = state;
|
||||
const mutable = () => {
|
||||
if (out === state) out = { ...state };
|
||||
return out;
|
||||
};
|
||||
for (const { field, emptyAs } of CONTEXT_MODE_RESULT_FIELDS) {
|
||||
if (!(field in state)) continue;
|
||||
const internal = state[field] ?? null;
|
||||
const target = mutable();
|
||||
target[field] = contextModeWord(internal, { emptyAs });
|
||||
target[`${field}Internal`] = internal;
|
||||
}
|
||||
for (const field of NESTED_CONTEXT_RESULT_FIELDS) {
|
||||
const nested = state[field];
|
||||
if (!nested || typeof nested !== 'object') continue;
|
||||
const translated = withContextModeVocabulary(nested);
|
||||
if (translated !== nested) mutable()[field] = translated;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Read tracked entities from their canonical layer owners. */
|
||||
export function collectTrackedEntities(dataManager) {
|
||||
const tracked = [];
|
||||
for (const family of TRACKABLE_FAMILIES) {
|
||||
const module = dataManager?.layers?.get(family.layerId)?.module;
|
||||
if (!module) continue;
|
||||
try {
|
||||
const info =
|
||||
family.kind === 'vessel'
|
||||
? module.getSelectedInfo?.()
|
||||
: module.getTrackedInfo?.();
|
||||
if (info)
|
||||
tracked.push({
|
||||
kind: family.kind,
|
||||
layerId: family.layerId,
|
||||
...info,
|
||||
});
|
||||
} catch {
|
||||
// A not-yet-ready layer contributes no tracked observation.
|
||||
}
|
||||
}
|
||||
return tracked;
|
||||
}
|
||||
|
||||
/** Read the active Contacts window from its owning awareness layer. */
|
||||
export function activeContactsWindow(dataManager) {
|
||||
try {
|
||||
const layer = dataManager?.layers?.get('military-awareness')?.module;
|
||||
return (
|
||||
layer?.contactsWindowFromSnapshot?.(layer.getContextSnapshot?.()) ?? null
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the model-free fast World Packet from existing application owners.
|
||||
*
|
||||
* This performs no network requests and intentionally mirrors the historical
|
||||
* get_current_view_state payload so voice can delegate without behavior drift.
|
||||
*/
|
||||
export function getWorldPacket(options = {}) {
|
||||
const {
|
||||
viewer,
|
||||
styleManager,
|
||||
dataManager,
|
||||
sceneDirector = null,
|
||||
now: requestedNow,
|
||||
} = options ?? {};
|
||||
// A default parameter only covers undefined; null or NaN would age every
|
||||
// feed against the epoch and report stale data as "just now".
|
||||
const now = Number.isFinite(requestedNow) ? requestedNow : Date.now();
|
||||
if (!viewer?.camera?.positionWC)
|
||||
throw new TypeError('World Packet requires a viewer camera');
|
||||
if (!dataManager || typeof dataManager.getAll !== 'function')
|
||||
throw new TypeError('World Packet requires a data manager');
|
||||
|
||||
const cartographic = Cesium.Cartographic.fromCartesian(
|
||||
viewer.camera.positionWC,
|
||||
);
|
||||
// Read the roster once. Voice must not rewalk disabled layers: preserving the
|
||||
// current enabled-only view keeps the model's context budget bounded.
|
||||
const rows = dataManager.getAll();
|
||||
const enabledRows = rows.filter((layer) => layer.enabled);
|
||||
const contactsWindow = activeContactsWindow(dataManager);
|
||||
const contextState =
|
||||
typeof styleManager?.getContextModeState === 'function'
|
||||
? withContextModeVocabulary(styleManager.getContextModeState())
|
||||
: null;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
action: 'get_current_view_state',
|
||||
camera: {
|
||||
latitude: Cesium.Math.toDegrees(cartographic.latitude),
|
||||
longitude: Cesium.Math.toDegrees(cartographic.longitude),
|
||||
heightM: cartographic.height,
|
||||
},
|
||||
style: styleManager?.activeStyle || 'normal',
|
||||
context: contextState
|
||||
? {
|
||||
...contextState,
|
||||
...(contactsWindow ? { contactsWindow } : {}),
|
||||
}
|
||||
: null,
|
||||
cockpit:
|
||||
typeof styleManager?.getCockpitState === 'function'
|
||||
? styleManager.getCockpitState()
|
||||
: null,
|
||||
controls:
|
||||
typeof styleManager?.getControlState === 'function'
|
||||
? styleManager.getControlState()
|
||||
: null,
|
||||
scenePlayback: sceneDirector?.getPlaybackStatus?.() || null,
|
||||
tracked: collectTrackedEntities(dataManager),
|
||||
disabledLayerCount: rows.length - enabledRows.length,
|
||||
// Preserve get_current_view_state's existing compact enabled-only shape.
|
||||
layers: enabledRows.map((layer) => {
|
||||
const snapshot = layerSnapshot(layer, { now });
|
||||
return {
|
||||
id: layer.id,
|
||||
name: layer.name,
|
||||
enabled: layer.enabled,
|
||||
count: layer.stats?.count || 0,
|
||||
error: layer.stats?.error || null,
|
||||
feedState: snapshot.feedState,
|
||||
source: snapshot.source,
|
||||
lastUpdate: snapshot.lastUpdate,
|
||||
};
|
||||
}),
|
||||
feedProvenance: feedProvenanceEnvelope(
|
||||
layerSnapshots(rows, { now }).filter((layer) => layer.enabled),
|
||||
{ now },
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
import { getWorldPacket } from './worldPacket.js';
|
||||
import { createGevActionRunner } from '../voice/gevActions.js';
|
||||
|
||||
function withoutVoicePresentation(result) {
|
||||
const packet = { ...result };
|
||||
delete packet.say;
|
||||
delete packet.display;
|
||||
delete packet.schedule;
|
||||
return packet;
|
||||
}
|
||||
|
||||
function harness() {
|
||||
const position = Cesium.Cartesian3.fromDegrees(-97.74, 30.26, 500);
|
||||
const viewer = {
|
||||
trackedEntity: undefined,
|
||||
clock: { onTick: { addEventListener: () => () => {} } },
|
||||
scene: {
|
||||
canvas: {
|
||||
clientWidth: 1200,
|
||||
clientHeight: 800,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
},
|
||||
globe: { getHeight: () => 0 },
|
||||
tweens: [],
|
||||
},
|
||||
camera: {
|
||||
positionWC: position,
|
||||
positionCartographic: Cesium.Cartographic.fromCartesian(position),
|
||||
heading: 0,
|
||||
pitch: Cesium.Math.toRadians(-45),
|
||||
moveEnd: { addEventListener: () => () => {} },
|
||||
cancelFlight() {},
|
||||
lookAtTransform() {},
|
||||
},
|
||||
};
|
||||
const rows = [
|
||||
{
|
||||
id: 'traffic',
|
||||
name: 'Traffic',
|
||||
enabled: true,
|
||||
stats: {
|
||||
count: 3,
|
||||
source: 'fixture',
|
||||
lastUpdate: null,
|
||||
},
|
||||
},
|
||||
];
|
||||
const dataManager = {
|
||||
layers: new Map([['traffic', { module: null }]]),
|
||||
getAll: () => rows,
|
||||
};
|
||||
const styleManager = {
|
||||
activeStyle: 'normal',
|
||||
getContextModeState: () => ({ mode: 'flights' }),
|
||||
getCockpitState: () => null,
|
||||
getControlState: () => ({ hud: { visible: 'auto' } }),
|
||||
};
|
||||
return { viewer, dataManager, styleManager };
|
||||
}
|
||||
|
||||
test('World Packet preserves get_current_view_state parity', async () => {
|
||||
const { viewer, dataManager, styleManager } = harness();
|
||||
const packet = getWorldPacket({ viewer, dataManager, styleManager });
|
||||
const runner = createGevActionRunner({ viewer, dataManager, styleManager });
|
||||
const voice = await runner('get_current_view_state');
|
||||
|
||||
assert.deepEqual(withoutVoicePresentation(voice), packet);
|
||||
assert.equal(packet.context.mode, 'contacts');
|
||||
assert.equal(packet.context.modeInternal, 'flights');
|
||||
assert.equal(packet.layers[0].feedState, 'nominal');
|
||||
assert.equal(packet.feedProvenance.overall, 'nominal');
|
||||
});
|
||||
|
||||
test('World Packet keeps stale/unavailable feed semantics distinct from empty', () => {
|
||||
const { viewer, dataManager, styleManager } = harness();
|
||||
dataManager.getAll = () => [
|
||||
{
|
||||
id: 'traffic',
|
||||
name: 'Traffic',
|
||||
enabled: true,
|
||||
stats: {
|
||||
count: 0,
|
||||
source: 'fixture',
|
||||
error: 'fixture unavailable',
|
||||
lastUpdate: null,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const packet = getWorldPacket({ viewer, dataManager, styleManager });
|
||||
assert.equal(packet.layers[0].count, 0);
|
||||
assert.notEqual(packet.layers[0].feedState, 'nominal');
|
||||
assert.notEqual(packet.feedProvenance.overall, 'nominal');
|
||||
assert.match(packet.feedProvenance.note, /fixture unavailable/i);
|
||||
});
|
||||
|
||||
// The 2026-10 main voice contract omits disabled layers from the model payload.
|
||||
test('World Packet and voice preserve enabled-only payload and count disabled layers', async () => {
|
||||
const { viewer, dataManager, styleManager } = harness();
|
||||
const rows = dataManager.getAll();
|
||||
rows.push({
|
||||
id: 'earthquakes',
|
||||
name: 'Earthquakes',
|
||||
enabled: false,
|
||||
stats: { count: 99, source: 'fixture', error: 'stale' },
|
||||
});
|
||||
let reads = 0;
|
||||
dataManager.getAll = () => {
|
||||
reads += 1;
|
||||
return rows;
|
||||
};
|
||||
const packet = getWorldPacket({ viewer, dataManager, styleManager });
|
||||
assert.equal(reads, 1, 'collect the roster once rather than repeatedly');
|
||||
assert.equal(packet.disabledLayerCount, 1);
|
||||
assert.deepEqual(
|
||||
packet.layers.map((layer) => layer.id),
|
||||
['traffic'],
|
||||
);
|
||||
assert.equal(packet.feedProvenance.overall, 'nominal');
|
||||
|
||||
const runner = createGevActionRunner({ viewer, dataManager, styleManager });
|
||||
const voice = await runner('get_current_view_state');
|
||||
assert.deepEqual(withoutVoicePresentation(voice), packet);
|
||||
});
|
||||
|
||||
const PINNED_NOW = Date.UTC(2026, 9, 17, 12, 0, 0);
|
||||
|
||||
// Exercises every packet field: context vocabulary and Contacts window,
|
||||
// cockpit, controls, scene playback, tracked families (one not ready),
|
||||
// enabled and disabled layers, and an aged and an unavailable feed.
|
||||
function pinnedHarness() {
|
||||
const { viewer } = harness();
|
||||
const rows = [
|
||||
{
|
||||
id: 'flights',
|
||||
name: 'Flights',
|
||||
enabled: true,
|
||||
stats: {
|
||||
count: 12,
|
||||
source: 'fixture-adsb',
|
||||
lastUpdate: PINNED_NOW - 90_000,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'traffic',
|
||||
name: 'Traffic',
|
||||
enabled: true,
|
||||
stats: {
|
||||
count: 0,
|
||||
source: 'fixture',
|
||||
error: 'fixture unavailable',
|
||||
lastUpdate: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'earthquakes',
|
||||
name: 'Earthquakes',
|
||||
enabled: false,
|
||||
stats: { count: 99, source: 'fixture', error: 'stale' },
|
||||
},
|
||||
];
|
||||
const module = (methods) => ({ module: methods });
|
||||
const dataManager = {
|
||||
layers: new Map([
|
||||
[
|
||||
'flights',
|
||||
module({
|
||||
getTrackedInfo: () => ({ icao24: 'abc123', callsign: 'TEST1' }),
|
||||
}),
|
||||
],
|
||||
[
|
||||
'ais-live-vessels',
|
||||
module({ getSelectedInfo: () => ({ mmsi: '366000001' }) }),
|
||||
],
|
||||
[
|
||||
'satellites',
|
||||
module({
|
||||
getTrackedInfo: () => {
|
||||
throw new Error('not ready');
|
||||
},
|
||||
}),
|
||||
],
|
||||
[
|
||||
'military-awareness',
|
||||
module({
|
||||
getContextSnapshot: () => ({ snapshot: true }),
|
||||
contactsWindowFromSnapshot: (snapshot) =>
|
||||
snapshot ? { windowMin: 30, count: 4 } : null,
|
||||
}),
|
||||
],
|
||||
]),
|
||||
getAll: () => rows,
|
||||
};
|
||||
const styleManager = {
|
||||
activeStyle: 'night-vision',
|
||||
getContextModeState: () => ({
|
||||
mode: 'flights',
|
||||
entering: null,
|
||||
priorMode: null,
|
||||
}),
|
||||
getCockpitState: () => ({ active: false }),
|
||||
getControlState: () => ({ hud: { visible: 'auto' } }),
|
||||
};
|
||||
const sceneDirector = {
|
||||
getPlaybackStatus: () => ({ playing: false, sceneId: 'fixture' }),
|
||||
};
|
||||
return { viewer, dataManager, styleManager, sceneDirector };
|
||||
}
|
||||
|
||||
// Captured from main's get_current_view_state before the extraction
|
||||
// (bilawalsidhu/gods-eye-view@3d8e1ef0) with Date.now pinned to PINNED_NOW,
|
||||
// minus the speech envelope. Key order is part of the contract.
|
||||
const PRE_EXTRACTION_PAYLOAD = {
|
||||
ok: true,
|
||||
action: 'get_current_view_state',
|
||||
camera: {
|
||||
latitude: 30.259999999999994,
|
||||
longitude: -97.74,
|
||||
heightM: 499.99999999901877,
|
||||
},
|
||||
style: 'night-vision',
|
||||
context: {
|
||||
mode: 'contacts',
|
||||
entering: null,
|
||||
priorMode: null,
|
||||
modeInternal: 'flights',
|
||||
enteringInternal: null,
|
||||
priorModeInternal: null,
|
||||
contactsWindow: { windowMin: 30, count: 4 },
|
||||
},
|
||||
cockpit: { active: false },
|
||||
controls: { hud: { visible: 'auto' } },
|
||||
scenePlayback: { playing: false, sceneId: 'fixture' },
|
||||
tracked: [
|
||||
{
|
||||
kind: 'aircraft',
|
||||
layerId: 'flights',
|
||||
icao24: 'abc123',
|
||||
callsign: 'TEST1',
|
||||
},
|
||||
{ kind: 'vessel', layerId: 'ais-live-vessels', mmsi: '366000001' },
|
||||
],
|
||||
disabledLayerCount: 1,
|
||||
layers: [
|
||||
{
|
||||
id: 'flights',
|
||||
name: 'Flights',
|
||||
enabled: true,
|
||||
count: 12,
|
||||
error: null,
|
||||
feedState: 'nominal',
|
||||
source: 'fixture-adsb',
|
||||
lastUpdate: 1792238310000,
|
||||
},
|
||||
{
|
||||
id: 'traffic',
|
||||
name: 'Traffic',
|
||||
enabled: true,
|
||||
count: 0,
|
||||
error: 'fixture unavailable',
|
||||
feedState: 'unavailable',
|
||||
source: 'fixture',
|
||||
lastUpdate: 0,
|
||||
},
|
||||
],
|
||||
feedProvenance: {
|
||||
overall: 'unavailable',
|
||||
layers: [
|
||||
{
|
||||
id: 'flights',
|
||||
name: 'Flights',
|
||||
enabled: true,
|
||||
feedState: 'nominal',
|
||||
source: 'fixture-adsb',
|
||||
count: 12,
|
||||
lastUpdate: 1792238310000,
|
||||
ageSec: 90,
|
||||
ageLabel: '1m ago',
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
id: 'traffic',
|
||||
name: 'Traffic',
|
||||
enabled: true,
|
||||
feedState: 'unavailable',
|
||||
source: 'fixture',
|
||||
count: 0,
|
||||
lastUpdate: 0,
|
||||
ageSec: null,
|
||||
ageLabel: null,
|
||||
error: 'fixture unavailable',
|
||||
},
|
||||
],
|
||||
note: 'Traffic is UNAVAILABLE (fixture, fixture unavailable). Narrate that provenance with any count; do not describe this as live.',
|
||||
},
|
||||
};
|
||||
|
||||
function assertPinnedPayload(actual) {
|
||||
assert.deepEqual(actual, PRE_EXTRACTION_PAYLOAD);
|
||||
// deepEqual ignores key order; the serialized form does not.
|
||||
assert.equal(JSON.stringify(actual), JSON.stringify(PRE_EXTRACTION_PAYLOAD));
|
||||
}
|
||||
|
||||
test('World Packet matches the pre-extraction get_current_view_state payload', () => {
|
||||
const packet = getWorldPacket({ ...pinnedHarness(), now: PINNED_NOW });
|
||||
assertPinnedPayload(packet);
|
||||
});
|
||||
|
||||
test('voice get_current_view_state still returns the pre-extraction payload', async (t) => {
|
||||
t.mock.method(Date, 'now', () => PINNED_NOW);
|
||||
const runner = createGevActionRunner(pinnedHarness());
|
||||
const voice = await runner('get_current_view_state');
|
||||
assertPinnedPayload(withoutVoicePresentation(voice));
|
||||
});
|
||||
|
||||
test('World Packet ages feeds against the clock when now is not a finite number', (t) => {
|
||||
t.mock.method(Date, 'now', () => PINNED_NOW);
|
||||
for (const now of [null, Number.NaN, '2026-10-17']) {
|
||||
const packet = getWorldPacket({ ...pinnedHarness(), now });
|
||||
const flights = packet.feedProvenance.layers.find(
|
||||
(layer) => layer.id === 'flights',
|
||||
);
|
||||
assert.equal(flights.ageSec, 90, `now: ${String(now)}`);
|
||||
assert.equal(flights.ageLabel, '1m ago', `now: ${String(now)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('World Packet rejects null options with the camera requirement', () => {
|
||||
assert.throws(() => getWorldPacket(null), /requires a viewer camera/);
|
||||
});
|
||||
@@ -77,6 +77,11 @@ import {
|
||||
CCTV_FOCUS_REQUEST_EVENT,
|
||||
activateCctvCameraFromWorldClick,
|
||||
} from '../cctvFocusRequest.js';
|
||||
import {
|
||||
ACTIVE_CAMERA_COLOR,
|
||||
IDLE_CAMERA_COLOR,
|
||||
LIVE_CAMERA_COLOR,
|
||||
} from '../layers/cctv/policy.js';
|
||||
|
||||
const CCTV_PRESENTATION_SOURCE = fs.readFileSync(
|
||||
path.join(
|
||||
@@ -360,6 +365,37 @@ test('default COVERAGE refresh materializes the active and visible camera frustu
|
||||
}
|
||||
});
|
||||
|
||||
test('camera icons are violet for video feeds, cyan for stills, amber when active', () => {
|
||||
const record = (id, feedType) => ({
|
||||
camera: { ...UNCLAMPED_CAMERA, id, feedType, groundElevationM: 0 },
|
||||
billboard: {},
|
||||
coverageEntities: [],
|
||||
projection: null,
|
||||
});
|
||||
const idleVideo = record('idle-hls', 'hls');
|
||||
const idleStill = record('idle-image', 'image');
|
||||
const activeVideo = record('active-hls', 'hls');
|
||||
const viewer = { entities: { add: (entity) => entity } };
|
||||
|
||||
try {
|
||||
// Disabled keeps the active video camera from opening a real projection;
|
||||
// icon colors are assigned either way.
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer,
|
||||
records: [idleVideo, idleStill, activeVideo],
|
||||
activeCameraId: activeVideo.camera.id,
|
||||
enabled: false,
|
||||
coverageMode: 'off',
|
||||
});
|
||||
refreshCoverageStyles();
|
||||
assert.equal(idleVideo.billboard.color, LIVE_CAMERA_COLOR);
|
||||
assert.equal(idleStill.billboard.color, IDLE_CAMERA_COLOR);
|
||||
assert.equal(activeVideo.billboard.color, ACTIVE_CAMERA_COLOR);
|
||||
} finally {
|
||||
_setCctvCoverageStateForTest({ enabled: false });
|
||||
}
|
||||
});
|
||||
|
||||
test('geometry drain pacing yields to tracked and cockpit camera ownership', () => {
|
||||
assert.deepEqual(cctvGeometryDrainPacing(), { batchSize: 4, delayMs: 120 });
|
||||
assert.deepEqual(
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from '../../server/providers/cctv/cap.js';
|
||||
import {
|
||||
CCTV_MAX_SOURCES_CEILING,
|
||||
CCTV_SOURCE_CACHE_MS,
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
@@ -160,3 +161,224 @@ test('a pack trimmed to its nearest cameras reports how many it offered', async
|
||||
assert.equal(kept.available, 5);
|
||||
assert.deepEqual(kept.region, { west: 0, south: 0, east: 0, north: 4 });
|
||||
});
|
||||
|
||||
/** A CCTV_SOURCES_JSON value with `count` cameras named `<prefix>-<i>`. */
|
||||
const envSources = (prefix, count = 3) =>
|
||||
JSON.stringify(
|
||||
Array.from({ length: count }, (_, i) => ({
|
||||
id: `${prefix}-${i}`,
|
||||
name: `${prefix} ${i}`,
|
||||
lat: 30 + i * 0.01,
|
||||
lon: -97,
|
||||
url: `https://example.test/${prefix}-${i}.jpg`,
|
||||
})),
|
||||
);
|
||||
|
||||
/** Let a background refresh that needs no timers run to completion. */
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
/** Restore process.env to a snapshot taken with `{ ...process.env }`. */
|
||||
const restoreEnv = (saved) => {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in saved)) delete process.env[key];
|
||||
}
|
||||
Object.assign(process.env, saved);
|
||||
};
|
||||
|
||||
test('an expired catalog is served at once while a slow pack refreshes in the background', async (t) => {
|
||||
const saved = { ...process.env };
|
||||
try {
|
||||
// Live packs run alongside the env pack, so one slow upstream can hold
|
||||
// the refresh open.
|
||||
process.env.CCTV_FORCE_AUSTIN = '1';
|
||||
process.env.CCTV_SOURCES_FILE = '/nonexistent/cctv.json';
|
||||
process.env.CCTV_SOURCES_JSON = envSources('old');
|
||||
let now = 1_000_000;
|
||||
t.mock.method(Date, 'now', () => now);
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(console, 'log', () => {});
|
||||
const hung = [];
|
||||
let hang = false;
|
||||
t.mock.method(globalThis, 'fetch', () =>
|
||||
hang
|
||||
? new Promise((_, reject) => {
|
||||
hung.push(() => reject(new Error('upstream gave up')));
|
||||
})
|
||||
: Promise.reject(new Error('offline')),
|
||||
);
|
||||
const getSources = createCctvCatalog({ sourceRoot: '/nonexistent' });
|
||||
assert.deepEqual(
|
||||
(await getSources()).map((s) => s.id),
|
||||
['old-0', 'old-1', 'old-2'],
|
||||
);
|
||||
|
||||
// Past the TTL, with every live pack's upstream hanging.
|
||||
now += CCTV_SOURCE_CACHE_MS + 1;
|
||||
hang = true;
|
||||
process.env.CCTV_SOURCES_JSON = envSources('new');
|
||||
let timer;
|
||||
const served = await Promise.race([
|
||||
getSources(),
|
||||
new Promise((resolve) => {
|
||||
timer = setTimeout(() => resolve('waited'), 200);
|
||||
}),
|
||||
]);
|
||||
clearTimeout(timer);
|
||||
assert.notEqual(
|
||||
served,
|
||||
'waited',
|
||||
'a stale request waited for the background refresh',
|
||||
);
|
||||
assert.deepEqual(
|
||||
served.map((s) => s.id),
|
||||
['old-0', 'old-1', 'old-2'],
|
||||
);
|
||||
|
||||
// Requests during the refresh share it rather than starting another.
|
||||
const pause = () => new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await pause();
|
||||
const inFlight = hung.length;
|
||||
assert.ok(inFlight > 0, 'the refresh reached the hanging upstreams');
|
||||
await getSources();
|
||||
await getSources();
|
||||
await pause();
|
||||
assert.equal(hung.length, inFlight, 'only one refresh ran');
|
||||
|
||||
// When the slow pack gives up, the refreshed catalog replaces the old one.
|
||||
hang = false;
|
||||
for (const giveUp of hung) giveUp();
|
||||
for (let i = 0; i < 50; i += 1) {
|
||||
const ids = (await getSources()).map((s) => s.id);
|
||||
if (ids[0] === 'new-0') break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
assert.deepEqual(
|
||||
(await getSources()).map((s) => s.id),
|
||||
['new-0', 'new-1', 'new-2'],
|
||||
);
|
||||
} finally {
|
||||
restoreEnv(saved);
|
||||
}
|
||||
});
|
||||
|
||||
test('a failed background refresh keeps the catalog, warns once, and waits a TTL to retry', async (t) => {
|
||||
const saved = { ...process.env };
|
||||
const unhandled = [];
|
||||
const onUnhandled = (reason) => unhandled.push(reason);
|
||||
process.on('unhandledRejection', onUnhandled);
|
||||
try {
|
||||
process.env.CCTV_PREFER_AUSTIN = '0';
|
||||
process.env.CCTV_SOURCES_FILE = '/nonexistent/cctv.json';
|
||||
process.env.CCTV_SOURCES_JSON = envSources('old', 9);
|
||||
process.env.CCTV_MAX_SOURCES = '8';
|
||||
let now = 1_000_000;
|
||||
t.mock.method(Date, 'now', () => now);
|
||||
// Nine cameras over a cap of eight make every refresh warn; throwing from
|
||||
// that warning is how this test makes a refresh reject.
|
||||
let failRefresh = false;
|
||||
let failedRefreshes = 0;
|
||||
const warnings = [];
|
||||
t.mock.method(console, 'warn', (...args) => {
|
||||
const text = args.join(' ');
|
||||
if (failRefresh && text.includes('exceeds cap')) {
|
||||
failedRefreshes += 1;
|
||||
throw new Error('refresh broke');
|
||||
}
|
||||
warnings.push(text);
|
||||
});
|
||||
const getSources = createCctvCatalog({ sourceRoot: '/nonexistent' });
|
||||
const old = await getSources();
|
||||
assert.equal(old.length, 8);
|
||||
|
||||
// Past the TTL the refresh throws: the old catalog is still served.
|
||||
now += CCTV_SOURCE_CACHE_MS + 1;
|
||||
failRefresh = true;
|
||||
assert.equal(await getSources(), old);
|
||||
await settle();
|
||||
assert.equal(failedRefreshes, 1);
|
||||
assert.equal(
|
||||
warnings.filter((w) => w.includes('background catalog refresh failed'))
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
|
||||
// The failure is not retried on every request until the next TTL.
|
||||
assert.equal(await getSources(), old);
|
||||
assert.equal(await getSources(), old);
|
||||
await settle();
|
||||
assert.equal(failedRefreshes, 1, 'a failed refresh was retried at once');
|
||||
|
||||
// After it, a refresh that works replaces the catalog. With no live packs
|
||||
// the refresh finishes before the request that started it returns.
|
||||
now += CCTV_SOURCE_CACHE_MS + 1;
|
||||
failRefresh = false;
|
||||
process.env.CCTV_SOURCES_JSON = envSources('new');
|
||||
await getSources();
|
||||
await settle();
|
||||
const fresh = await getSources();
|
||||
assert.deepEqual(
|
||||
fresh.map((s) => s.id),
|
||||
['new-0', 'new-1', 'new-2'],
|
||||
);
|
||||
// Trim notes stay with the list they describe, so a request served the
|
||||
// old list during the refresh never reports the new list's notes.
|
||||
assert.deepEqual(
|
||||
getSources.trimmedPacks(old).map((p) => [p.pack, p.available, p.served]),
|
||||
[['env', 9, 8]],
|
||||
);
|
||||
assert.deepEqual(getSources.trimmedPacks(fresh), []);
|
||||
assert.deepEqual(getSources.trimmedPacks(), []);
|
||||
assert.deepEqual(unhandled, []);
|
||||
} finally {
|
||||
process.off('unhandledRejection', onUnhandled);
|
||||
restoreEnv(saved);
|
||||
}
|
||||
});
|
||||
|
||||
test('an empty background refresh keeps the catalog and waits a TTL before fetching again', async (t) => {
|
||||
const saved = { ...process.env };
|
||||
try {
|
||||
process.env.CCTV_FORCE_AUSTIN = '1';
|
||||
process.env.CCTV_SOURCES_FILE = '/nonexistent/cctv.json';
|
||||
process.env.CCTV_SOURCES_JSON = envSources('old');
|
||||
let now = 1_000_000;
|
||||
t.mock.method(Date, 'now', () => now);
|
||||
t.mock.method(console, 'log', () => {});
|
||||
const warnings = [];
|
||||
t.mock.method(console, 'warn', (...args) => warnings.push(args.join(' ')));
|
||||
let fetches = 0;
|
||||
t.mock.method(globalThis, 'fetch', () => {
|
||||
fetches += 1;
|
||||
return Promise.reject(new Error('offline'));
|
||||
});
|
||||
const getSources = createCctvCatalog({ sourceRoot: '/nonexistent' });
|
||||
const old = await getSources();
|
||||
|
||||
// Past the TTL every pack comes back empty, as in an upstream outage.
|
||||
now += CCTV_SOURCE_CACHE_MS + 1;
|
||||
process.env.CCTV_SOURCES_JSON = '[]';
|
||||
assert.equal(await getSources(), old);
|
||||
for (let i = 0; i < 50; i += 1) {
|
||||
if (warnings.some((w) => w.includes('source refresh returned empty')))
|
||||
break;
|
||||
await settle();
|
||||
}
|
||||
assert.ok(
|
||||
warnings.some((w) => w.includes('serving 3 stale cameras')),
|
||||
'the empty refresh kept the old catalog',
|
||||
);
|
||||
|
||||
// Until the next TTL, requests are served without touching any upstream.
|
||||
const fetchesAfterRefresh = fetches;
|
||||
assert.equal(await getSources(), old);
|
||||
assert.equal(await getSources(), old);
|
||||
await settle();
|
||||
assert.equal(
|
||||
fetches,
|
||||
fetchesAfterRefresh,
|
||||
'an empty refresh reran at once',
|
||||
);
|
||||
} finally {
|
||||
restoreEnv(saved);
|
||||
}
|
||||
});
|
||||
@@ -1,11 +1,24 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
TARKTEE_IMAGES_URL,
|
||||
TARKTEE_LOCATIONS_URL,
|
||||
TARKTEE_MAX_CATALOG_BYTES,
|
||||
TARKTEE_FETCH_TIMEOUT_MS,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import {
|
||||
loadTallinnSourcesFromCatalog,
|
||||
loadTarkteeSourcesFromDatex,
|
||||
parseTarkteeDatexImages,
|
||||
parseTarkteeDatexLocations,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
|
||||
/** Silence the loaders' progress and failure logging. */
|
||||
function quiet(t) {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
}
|
||||
|
||||
const SAMPLE_LOCATIONS_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<d2LogicalModel xmlns="http://datex2.eu/schema/2/2_0">
|
||||
<payloadPublication>
|
||||
@@ -84,3 +97,305 @@ test('Tarktee DATEX image parser rejects non-official hosts', () => {
|
||||
const images = parseTarkteeDatexImages(xml);
|
||||
assert.equal(images.size, 0);
|
||||
});
|
||||
|
||||
test('Tarktee DATEX requests wait 30 s, past the slow answers measured live', async (t) => {
|
||||
const timeouts = [];
|
||||
const timeout = AbortSignal.timeout;
|
||||
t.mock.method(AbortSignal, 'timeout', (ms) => {
|
||||
timeouts.push(ms);
|
||||
return timeout.call(AbortSignal, ms);
|
||||
});
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(
|
||||
globalThis,
|
||||
'fetch',
|
||||
async (url) =>
|
||||
new Response(
|
||||
String(url).includes('roadCameraLocations')
|
||||
? SAMPLE_LOCATIONS_XML
|
||||
: SAMPLE_IMAGES_XML,
|
||||
),
|
||||
);
|
||||
const cameras = await loadTarkteeSourcesFromDatex();
|
||||
assert.deepEqual(timeouts, [30_000]);
|
||||
assert.equal(cameras.length, 1);
|
||||
});
|
||||
|
||||
test('Tarktee loader deduplicates cameras sharing an image number before the cap', async (t) => {
|
||||
quiet(t);
|
||||
const dupLocationsXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<d2LogicalModel xmlns="http://datex2.eu/schema/2/2_0">
|
||||
<payloadPublication>
|
||||
<predefinedLocationContainer id="TRAFFIC_CAMERAS" version="0">
|
||||
<predefinedLocation id="loc-1" version="0">
|
||||
<predefinedLocationName><values><value lang="et">Tallinn Ringtee</value></values></predefinedLocationName>
|
||||
<location><pointByCoordinates><pointCoordinates><latitude>59.4370</latitude><longitude>24.7530</longitude></pointCoordinates></pointByCoordinates></location>
|
||||
</predefinedLocation>
|
||||
<predefinedLocation id="loc-2" version="0">
|
||||
<predefinedLocationName><values><value lang="et">Tallinn Ringtee duplicate</value></values></predefinedLocationName>
|
||||
<location><pointByCoordinates><pointCoordinates><latitude>59.4371</latitude><longitude>24.7531</longitude></pointCoordinates></pointByCoordinates></location>
|
||||
</predefinedLocation>
|
||||
</predefinedLocationContainer>
|
||||
</payloadPublication>
|
||||
</d2LogicalModel>`;
|
||||
const dupImagesXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<d2LogicalModel xmlns="http://datex2.eu/schema/2/2_0">
|
||||
<payloadPublication>
|
||||
<trafficView id="1"><linearTrafficView id="1"><linearPredefinedLocationReference id="loc-1"/><trafficViewRecord id="1-1"><urlLink><urlLinkAddress>https://tarktee.transpordiamet.ee/images/42/42_1.jpg</urlLinkAddress></urlLink></trafficViewRecord></linearTrafficView></trafficView>
|
||||
<trafficView id="2"><linearTrafficView id="2"><linearPredefinedLocationReference id="loc-2"/><trafficViewRecord id="2-1"><urlLink><urlLinkAddress>https://tarktee.transpordiamet.ee/images/42/42_2.jpg</urlLinkAddress></urlLink></trafficViewRecord></linearTrafficView></trafficView>
|
||||
</payloadPublication>
|
||||
</d2LogicalModel>`;
|
||||
|
||||
const seen = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
seen.push([String(url), init?.redirect]);
|
||||
const body =
|
||||
String(url) === TARKTEE_LOCATIONS_URL ? dupLocationsXml : dupImagesXml;
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/xml' },
|
||||
});
|
||||
});
|
||||
|
||||
const cameras = await loadTarkteeSourcesFromDatex();
|
||||
assert.deepEqual(seen, [
|
||||
[TARKTEE_LOCATIONS_URL, 'manual'],
|
||||
[TARKTEE_IMAGES_URL, 'manual'],
|
||||
]);
|
||||
assert.equal(cameras.length, 1);
|
||||
assert.equal(cameras[0].id, 'ee-tarktee-42');
|
||||
assert.equal(cameras[0].name, 'Tallinn Ringtee');
|
||||
});
|
||||
|
||||
test('Tarktee loader refuses a redirect and cancels both response bodies', async (t) => {
|
||||
quiet(t);
|
||||
const cancelled = { locations: false, images: false };
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
const isLocations = String(url) === TARKTEE_LOCATIONS_URL;
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('<xml/>'));
|
||||
},
|
||||
cancel() {
|
||||
if (isLocations) cancelled.locations = true;
|
||||
else cancelled.images = true;
|
||||
},
|
||||
});
|
||||
return new Response(body, {
|
||||
status: isLocations ? 302 : 200,
|
||||
headers: isLocations ? { location: 'https://evil.example/loc.xml' } : {},
|
||||
});
|
||||
});
|
||||
|
||||
assert.deepEqual(await loadTarkteeSourcesFromDatex(), []);
|
||||
assert.equal(cancelled.locations, true, 'redirected body is cancelled');
|
||||
assert.equal(cancelled.images, true, 'sibling 200 body is also cancelled');
|
||||
});
|
||||
|
||||
test('Tarktee loader cancels fulfilled bodies when either fetch rejects', async (t) => {
|
||||
for (const failedUrl of [TARKTEE_LOCATIONS_URL, TARKTEE_IMAGES_URL]) {
|
||||
for (const delayedSibling of [false, true]) {
|
||||
await t.test(
|
||||
`${failedUrl}, delayed sibling: ${delayedSibling}`,
|
||||
async (t) => {
|
||||
quiet(t);
|
||||
let cancelled = false;
|
||||
const response = new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('<xml/>'));
|
||||
},
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
},
|
||||
}),
|
||||
);
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
if (String(url) === failedUrl) throw new TypeError('fetch failed');
|
||||
if (delayedSibling) await new Promise(setImmediate);
|
||||
return response;
|
||||
});
|
||||
|
||||
try {
|
||||
assert.deepEqual(await loadTarkteeSourcesFromDatex(), []);
|
||||
// An aborted mock transport may still deliver its queued response.
|
||||
if (delayedSibling) await new Promise(setImmediate);
|
||||
assert.equal(
|
||||
cancelled,
|
||||
true,
|
||||
'fulfilled sibling body is cancelled',
|
||||
);
|
||||
} finally {
|
||||
await response.body.cancel();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('Tarktee loader cancels the sibling body when one feed fails or overflows the byte cap', async (t) => {
|
||||
quiet(t);
|
||||
let siblingCancelled = false;
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
const isLocations = String(url) === TARKTEE_LOCATIONS_URL;
|
||||
if (isLocations) {
|
||||
return new Response('', { status: 503 });
|
||||
}
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(SAMPLE_IMAGES_XML));
|
||||
},
|
||||
cancel() {
|
||||
siblingCancelled = true;
|
||||
},
|
||||
});
|
||||
return new Response(body, { status: 200 });
|
||||
});
|
||||
|
||||
assert.deepEqual(await loadTarkteeSourcesFromDatex(), []);
|
||||
assert.equal(
|
||||
siblingCancelled,
|
||||
true,
|
||||
'sibling 200 stream is cancelled on 503',
|
||||
);
|
||||
|
||||
let overflowSiblingCancelled = false;
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
const isLocations = String(url) === TARKTEE_LOCATIONS_URL;
|
||||
if (isLocations) {
|
||||
const padded =
|
||||
SAMPLE_LOCATIONS_XML + ' '.repeat(TARKTEE_MAX_CATALOG_BYTES);
|
||||
return new Response(padded, {
|
||||
status: 200,
|
||||
headers: { 'Content-Length': String(padded.length) },
|
||||
});
|
||||
}
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(SAMPLE_IMAGES_XML));
|
||||
},
|
||||
cancel() {
|
||||
overflowSiblingCancelled = true;
|
||||
},
|
||||
});
|
||||
return new Response(body, { status: 200 });
|
||||
});
|
||||
|
||||
assert.deepEqual(await loadTarkteeSourcesFromDatex(), []);
|
||||
assert.equal(
|
||||
overflowSiblingCancelled,
|
||||
true,
|
||||
'sibling stream is cancelled when the other feed exceeds the byte cap',
|
||||
);
|
||||
});
|
||||
|
||||
for (const failingUrl of [TARKTEE_LOCATIONS_URL, TARKTEE_IMAGES_URL]) {
|
||||
for (const lateSibling of [false, true]) {
|
||||
test(`Tarktee cancels a ${lateSibling ? 'late' : 'received'} sibling when ${failingUrl} rejects before headers`, async (t) => {
|
||||
quiet(t);
|
||||
let cancelled = false;
|
||||
let siblingSignal;
|
||||
let release;
|
||||
const gate = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
if (String(url) === failingUrl) throw new Error('headers unavailable');
|
||||
siblingSignal = init.signal;
|
||||
if (lateSibling) await gate;
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('<xml/>'));
|
||||
},
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
assert.deepEqual(await loadTarkteeSourcesFromDatex(), []);
|
||||
// Also exercise a response arriving after abort, as a queued transport
|
||||
// completion can race the rejection owner even when fetch honors abort.
|
||||
if (lateSibling) {
|
||||
release();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
assert.equal(cancelled, true, 'every obtained body must be cancelled');
|
||||
assert.equal(
|
||||
siblingSignal.aborted,
|
||||
true,
|
||||
'a pending transport is aborted immediately',
|
||||
);
|
||||
});
|
||||
}
|
||||
test(`Tarktee aborts a pending sibling when ${failingUrl} rejects`, async (t) => {
|
||||
quiet(t);
|
||||
let aborted = false;
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
if (String(url) === failingUrl) throw new Error('headers unavailable');
|
||||
return new Promise((resolve, reject) => {
|
||||
init.signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
aborted = true;
|
||||
reject(init.signal.reason);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
assert.deepEqual(await loadTarkteeSourcesFromDatex(), []);
|
||||
assert.equal(aborted, true, 'do not wait for the 30-second deadline');
|
||||
});
|
||||
}
|
||||
|
||||
test('Tarktee cancels both lengthless streams when one exceeds the byte cap', async (t) => {
|
||||
quiet(t);
|
||||
const cancelled = [];
|
||||
t.mock.method(
|
||||
globalThis,
|
||||
'fetch',
|
||||
async (url) =>
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
const oversized = String(url) === TARKTEE_LOCATIONS_URL;
|
||||
controller.enqueue(
|
||||
new Uint8Array(oversized ? TARKTEE_MAX_CATALOG_BYTES + 1 : 1),
|
||||
);
|
||||
},
|
||||
cancel() {
|
||||
cancelled.push(String(url));
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
assert.deepEqual(await loadTarkteeSourcesFromDatex(), []);
|
||||
assert.deepEqual(
|
||||
cancelled.sort(),
|
||||
[TARKTEE_LOCATIONS_URL, TARKTEE_IMAGES_URL].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
test('Tarktee aborts pending headers as soon as its sibling returns an HTTP error', async (t) => {
|
||||
quiet(t);
|
||||
let aborted = false;
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
if (String(url) === TARKTEE_LOCATIONS_URL)
|
||||
return new Response('unavailable', { status: 503 });
|
||||
return new Promise((resolve, reject) => {
|
||||
init.signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
aborted = true;
|
||||
reject(init.signal.reason);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
assert.deepEqual(await loadTarkteeSourcesFromDatex(), []);
|
||||
assert.equal(aborted, true);
|
||||
});
|
||||
@@ -0,0 +1,376 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
floridaCameraId,
|
||||
floridaCameraToSource,
|
||||
loadFloridaSourcesFromOpenData,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
import {
|
||||
FLORIDA_CCTV_URL,
|
||||
FLORIDA_MAX_CATALOG_BYTES,
|
||||
FLORIDA_MAX_CATALOG_PAGES,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import {
|
||||
fallbackHeadingFromId,
|
||||
isLikelyFloridaCoordinate,
|
||||
} from '../../server/providers/cctv/normalize.js';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
|
||||
const IMAGE_HOST = 'https://images-dis.divas.cloud/DGI/';
|
||||
|
||||
/** A response with a live body, plus a flag that flips when it is cancelled. */
|
||||
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 };
|
||||
};
|
||||
|
||||
/** One FL511 feature, shaped like the live FeatureServer payload. */
|
||||
const feature = (overrides = {}) => ({
|
||||
attributes: {
|
||||
OBJECTID_1: 6,
|
||||
ID: '1220',
|
||||
DESCRIPT: 'I-75 @ MM 346.5 SB',
|
||||
COUNTY: 'Marion',
|
||||
HIGHWAY: 'I-75',
|
||||
DIRECTION: 'S',
|
||||
LATITUDE: 29.107363,
|
||||
LONGITUDE: -82.188334,
|
||||
IMAGE: `${IMAGE_HOST}chan-9431_h.jpg`,
|
||||
...overrides,
|
||||
},
|
||||
});
|
||||
|
||||
/** One catalog page of `count` distinct cameras, channels from `start`. */
|
||||
const page = (start, count, extra = {}) => ({
|
||||
features: Array.from({ length: count }, (_, i) =>
|
||||
feature({ IMAGE: `${IMAGE_HOST}chan-${start + i}_h.jpg` }),
|
||||
),
|
||||
...extra,
|
||||
});
|
||||
|
||||
const offsetOf = (url) =>
|
||||
Number(new URL(String(url)).searchParams.get('resultOffset'));
|
||||
|
||||
/** Lift the pack cap for one test so every loaded camera comes back. */
|
||||
const uncapped = (t) => {
|
||||
const previous = process.env.CCTV_FLORIDA_MAX_SOURCES;
|
||||
process.env.CCTV_FLORIDA_MAX_SOURCES = '4200';
|
||||
t.after(() => {
|
||||
if (previous === undefined) delete process.env.CCTV_FLORIDA_MAX_SOURCES;
|
||||
else process.env.CCTV_FLORIDA_MAX_SOURCES = previous;
|
||||
});
|
||||
};
|
||||
|
||||
test('an FL511 feature maps to a source on a pinned HTTPS frame host', () => {
|
||||
const source = floridaCameraToSource(feature());
|
||||
assert.equal(source.id, 'fl-9431');
|
||||
assert.equal(source.name, 'I-75 @ MM 346.5 SB');
|
||||
assert.equal(source.city, 'Marion County');
|
||||
assert.equal(source.cityId, 'florida');
|
||||
assert.equal(source.provider, 'FL511');
|
||||
assert.equal(source.lat, 29.107363);
|
||||
assert.equal(source.lon, -82.188334);
|
||||
assert.equal(source.headingDeg, 180);
|
||||
assert.equal(
|
||||
floridaCameraToSource(feature({ DIRECTION: 'E' })).headingDeg,
|
||||
90,
|
||||
);
|
||||
assert.equal(source.headingConfidence, 'high');
|
||||
assert.equal(source.feedType, 'image');
|
||||
assert.equal(source.url, `${IMAGE_HOST}chan-9431_h.jpg`);
|
||||
assert.equal(source.url, source.snapshotUrl);
|
||||
assert.equal(source.sourceKind, 'fl511-open-data');
|
||||
assert.match(source.license, /personal use only/);
|
||||
assert.match(source.license, /written consent/);
|
||||
});
|
||||
|
||||
test('NOT DIRECTIONAL takes the id-hash fallback at low confidence', () => {
|
||||
const source = floridaCameraToSource(
|
||||
feature({ DIRECTION: 'NOT DIRECTIONAL' }),
|
||||
);
|
||||
assert.equal(source.headingConfidence, 'low');
|
||||
assert.equal(source.headingDeg, fallbackHeadingFromId('fl-9431'));
|
||||
});
|
||||
|
||||
test('rows outside Florida, off-host frames and unusable rows are dropped', () => {
|
||||
const dropped = [
|
||||
feature({ LATITUDE: 33.749, LONGITUDE: -84.388 }), // Atlanta
|
||||
feature({ LATITUDE: 0, LONGITUDE: 0 }),
|
||||
feature({ LATITUDE: -82.188334, LONGITUDE: 29.107363 }), // swapped
|
||||
feature({ LATITUDE: '', LONGITUDE: '' }),
|
||||
feature({ IMAGE: 'https://evil.example/DGI/chan-9431_h.jpg' }),
|
||||
feature({ IMAGE: 'http://images-dis.divas.cloud/DGI/chan-9431_h.jpg' }),
|
||||
feature({
|
||||
IMAGE: 'https://user:[email protected]/DGI/chan-9431_h.jpg',
|
||||
}),
|
||||
feature({ IMAGE: 'not a url' }),
|
||||
{ attributes: null },
|
||||
null,
|
||||
];
|
||||
for (const row of dropped) assert.equal(floridaCameraToSource(row), null);
|
||||
});
|
||||
|
||||
test('camera ids come from the image channel, not the repeating ID column', () => {
|
||||
// FL511 reuses ID across regions: 1220 is both an I-75 and an I-95 camera.
|
||||
const marion = floridaCameraToSource(feature());
|
||||
const broward = floridaCameraToSource(
|
||||
feature({
|
||||
DESCRIPT: 'I-95 SB before E Cypress Rd',
|
||||
COUNTY: 'Broward',
|
||||
LATITUDE: 26.22006,
|
||||
LONGITUDE: -80.13676,
|
||||
IMAGE: `${IMAGE_HOST}chan-6351_h.jpg`,
|
||||
}),
|
||||
);
|
||||
assert.equal(marion.id, 'fl-9431');
|
||||
assert.equal(broward.id, 'fl-6351');
|
||||
// Any resolution suffix keys the same way.
|
||||
assert.equal(floridaCameraId(`${IMAGE_HOST}chan-77_l.jpg`), 'fl-77');
|
||||
// A filename without a channel falls back to a stable path slug.
|
||||
assert.equal(
|
||||
floridaCameraId('https://snapshots.divas.cloud/US-1%20at%20SR-5.jpg'),
|
||||
'fl-us-1-20at-20sr-5',
|
||||
);
|
||||
assert.equal(floridaCameraId('not a url'), null);
|
||||
});
|
||||
|
||||
test('isLikelyFloridaCoordinate spans the state and rejects the rest', () => {
|
||||
const inside = [
|
||||
[24.5551, -81.78], // Key West
|
||||
[30.4213, -87.2169], // Pensacola
|
||||
[30.99, -84.0], // Georgia line
|
||||
[25.7617, -80.1918], // Miami
|
||||
];
|
||||
const outside = [
|
||||
[33.749, -84.388], // Atlanta
|
||||
[25.0443, -77.3504], // Nassau
|
||||
[0, 0],
|
||||
[-81.78, 24.5551], // swapped
|
||||
[NaN, -81],
|
||||
];
|
||||
for (const [lat, lon] of inside)
|
||||
assert.equal(isLikelyFloridaCoordinate(lat, lon), true);
|
||||
for (const [lat, lon] of outside)
|
||||
assert.equal(isLikelyFloridaCoordinate(lat, lon), false);
|
||||
});
|
||||
|
||||
test('the loader pages until exceededTransferLimit clears and collapses repeated rows', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
uncapped(t);
|
||||
const calls = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
assert.ok(String(url).startsWith(FLORIDA_CCTV_URL));
|
||||
calls.push([offsetOf(url), init.redirect]);
|
||||
if (offsetOf(url) === 0)
|
||||
return Response.json(page(0, 2000, { exceededTransferLimit: true }));
|
||||
// Last page (no exceededTransferLimit): two new cameras and a repeat.
|
||||
return Response.json(page(1999, 3));
|
||||
});
|
||||
const cameras = await loadFloridaSourcesFromOpenData();
|
||||
assert.deepEqual(calls, [
|
||||
[0, 'manual'],
|
||||
[2000, 'manual'],
|
||||
]);
|
||||
assert.equal(cameras.length, 2002);
|
||||
assert.equal(new Set(cameras.map((camera) => camera.id)).size, 2002);
|
||||
});
|
||||
|
||||
test('pages shorter than requested keep paging while the server says more remain', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
uncapped(t);
|
||||
const offsets = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
const params = new URL(String(url)).searchParams;
|
||||
// Offset paging is only stable over a fixed order on the object id.
|
||||
assert.equal(params.get('orderByFields'), 'OBJECTID_1');
|
||||
const offset = offsetOf(url);
|
||||
offsets.push(offset);
|
||||
// A layer whose max record count was lowered to 1,000.
|
||||
return Response.json(
|
||||
page(offset, offset < 2000 ? 1000 : 500, {
|
||||
exceededTransferLimit: offset < 2000,
|
||||
}),
|
||||
);
|
||||
});
|
||||
const cameras = await loadFloridaSourcesFromOpenData();
|
||||
assert.deepEqual(offsets, [0, 1000, 2000]);
|
||||
assert.equal(cameras.length, 2500);
|
||||
});
|
||||
|
||||
test('a service that ignores resultOffset stops at the page ceiling with a warning', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
const warnings = [];
|
||||
t.mock.method(console, 'warn', (...args) => warnings.push(args.join(' ')));
|
||||
uncapped(t);
|
||||
let calls = 0;
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
calls += 1;
|
||||
return Response.json(page(0, 2000, { exceededTransferLimit: true }));
|
||||
});
|
||||
const cameras = await loadFloridaSourcesFromOpenData();
|
||||
assert.equal(calls, FLORIDA_MAX_CATALOG_PAGES);
|
||||
assert.equal(cameras.length, 2000);
|
||||
assert.ok(warnings.some((line) => /page ceiling/.test(line)));
|
||||
});
|
||||
|
||||
test('a redirect on any page drops the whole pack and releases it', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const redirected = streamingResponse({
|
||||
status: 302,
|
||||
headers: { location: 'https://evil.example/cameras' },
|
||||
});
|
||||
t.mock.method(globalThis, 'fetch', async (url) =>
|
||||
offsetOf(url) === 0
|
||||
? Response.json(page(0, 2000, { exceededTransferLimit: true }))
|
||||
: redirected.response,
|
||||
);
|
||||
assert.deepEqual(await loadFloridaSourcesFromOpenData(), []);
|
||||
assert.equal(
|
||||
redirected.state.cancelled,
|
||||
true,
|
||||
'the redirect body is cancelled',
|
||||
);
|
||||
});
|
||||
|
||||
test('a failed later page keeps the pages already read', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
uncapped(t);
|
||||
const failed = streamingResponse({ status: 503 });
|
||||
t.mock.method(globalThis, 'fetch', async (url) =>
|
||||
offsetOf(url) === 0
|
||||
? Response.json(page(0, 2000, { exceededTransferLimit: true }))
|
||||
: failed.response,
|
||||
);
|
||||
assert.equal((await loadFloridaSourcesFromOpenData()).length, 2000);
|
||||
assert.equal(failed.state.cancelled, true, 'the failed body is cancelled');
|
||||
|
||||
// A network error on a later page keeps them too.
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
if (offsetOf(url) === 0)
|
||||
return Response.json(page(0, 2000, { exceededTransferLimit: true }));
|
||||
throw new Error('network down');
|
||||
});
|
||||
assert.equal((await loadFloridaSourcesFromOpenData()).length, 2000);
|
||||
});
|
||||
|
||||
test('an oversized catalog page is refused', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(
|
||||
globalThis,
|
||||
'fetch',
|
||||
async () =>
|
||||
new Response(JSON.stringify(page(0, 1)), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'content-length': String(FLORIDA_MAX_CATALOG_BYTES + 1),
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert.deepEqual(await loadFloridaSourcesFromOpenData(), []);
|
||||
|
||||
// So is a body that only turns out too long while streaming.
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
const oversized = 'x'.repeat(FLORIDA_MAX_CATALOG_BYTES + 1024);
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(oversized));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{ headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
});
|
||||
assert.deepEqual(await loadFloridaSourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
/**
|
||||
* Serve a two-camera FL511 page to the Florida 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.startsWith(FLORIDA_CCTV_URL)) return Response.json(page(0, 2));
|
||||
return Response.json([]);
|
||||
});
|
||||
// No curated catalogs or ground-height sidecar, so only live lanes are in play.
|
||||
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
|
||||
return { requested, sources };
|
||||
};
|
||||
|
||||
/** Put process.env back exactly as it was. */
|
||||
const restoreEnv = (saved) => {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (key in saved) continue;
|
||||
delete process.env[key];
|
||||
}
|
||||
Object.assign(process.env, saved);
|
||||
};
|
||||
|
||||
test('with CCTV_FLORIDA_ENABLED=1 the Florida lane is wired into the catalog', async (t) => {
|
||||
const saved = { ...process.env };
|
||||
try {
|
||||
delete process.env.CCTV_SOURCES_FILE;
|
||||
delete process.env.CCTV_SOURCES_JSON;
|
||||
process.env.CCTV_FLORIDA_ENABLED = '1';
|
||||
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
|
||||
assert.ok(
|
||||
requested.some((href) => href.startsWith(FLORIDA_CCTV_URL)),
|
||||
'the catalog refresh invokes the Florida loader',
|
||||
);
|
||||
assert.deepEqual(
|
||||
sources
|
||||
.filter((s) => s.cityId === 'florida')
|
||||
.map((s) => s.id)
|
||||
.sort(),
|
||||
['fl-0', 'fl-1'],
|
||||
'Florida cameras reach the served catalog through the registered lane',
|
||||
);
|
||||
} finally {
|
||||
restoreEnv(saved);
|
||||
}
|
||||
});
|
||||
|
||||
test('the Florida lane stays off unless CCTV_FLORIDA_ENABLED=1', async (t) => {
|
||||
const saved = { ...process.env };
|
||||
try {
|
||||
delete process.env.CCTV_SOURCES_FILE;
|
||||
delete process.env.CCTV_SOURCES_JSON;
|
||||
delete process.env.CCTV_FLORIDA_ENABLED;
|
||||
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
|
||||
assert.equal(
|
||||
requested.some((href) => href.startsWith(FLORIDA_CCTV_URL)),
|
||||
false,
|
||||
'the disabled lane never reaches its upstream',
|
||||
);
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'florida'),
|
||||
[],
|
||||
);
|
||||
} finally {
|
||||
restoreEnv(saved);
|
||||
}
|
||||
});
|
||||
@@ -2,11 +2,24 @@ import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
applyVegvesenGroundHeights,
|
||||
clearKartverketHeightCache,
|
||||
fetchKartverketHeights,
|
||||
kartverketBackoffMs,
|
||||
loadVegvesenSourcesFromOpenData,
|
||||
vegvesenCameraToSource,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
import {
|
||||
DEFAULT_VEGVESEN_CCTV_URL,
|
||||
KARTVERKET_HEIGHT_URL,
|
||||
KARTVERKET_CATALOG_WAIT_MS,
|
||||
KARTVERKET_CONCURRENCY,
|
||||
KARTVERKET_FAILURE_BACKOFF_MS,
|
||||
KARTVERKET_MAX_POINTS,
|
||||
KARTVERKET_MAX_RESPONSE_BYTES,
|
||||
KARTVERKET_NULL_TTL_MS,
|
||||
KARTVERKET_USER_AGENT,
|
||||
CCTV_SOURCE_CACHE_MS,
|
||||
VEGVESEN_IMAGE_ORIGIN,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
@@ -104,6 +117,8 @@ test('faulty cameras, off-host frames and bad geometry are dropped', () => {
|
||||
});
|
||||
|
||||
test('the loader dedupes, and failures degrade to an empty pack', async (t) => {
|
||||
clearKartverketHeightCache();
|
||||
t.after(clearKartverketHeightCache);
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const requested = [];
|
||||
@@ -127,7 +142,11 @@ test('the loader dedupes, and failures degrade to an empty pack', async (t) => {
|
||||
});
|
||||
});
|
||||
const cameras = await loadVegvesenSourcesFromOpenData();
|
||||
assert.deepEqual(requested, [DEFAULT_VEGVESEN_CCTV_URL]);
|
||||
// The catalog first; the rest is the Kartverket height lookup.
|
||||
assert.equal(requested[0], DEFAULT_VEGVESEN_CCTV_URL);
|
||||
// Two unique positions: one Kartverket request.
|
||||
assert.equal(requested.length, 2);
|
||||
assert.ok(requested[1].startsWith(KARTVERKET_HEIGHT_URL));
|
||||
// Nearest-to-anchor first: Oslo leads.
|
||||
assert.deepEqual(
|
||||
cameras.map((c) => c.id),
|
||||
@@ -200,3 +219,604 @@ test('CCTV_VEGVESEN_ENABLED=0 keeps the lane from being loaded', async (t) => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/** Answer a Kartverket request the way the live API does: echo every point,
|
||||
* with the height `heightFor(x, y)` returns (null = outside coverage). */
|
||||
const kartverketResponse = (url, heightFor) => {
|
||||
const points = JSON.parse(new URL(url).searchParams.get('punkter'));
|
||||
return Response.json({
|
||||
koordsys: 4258,
|
||||
punkter: points.map(([x, y]) => ({ x, y, z: heightFor(x, y) })),
|
||||
});
|
||||
};
|
||||
|
||||
/** Cameras at distinct Norwegian positions. */
|
||||
const cameraAt = (i) => ({
|
||||
id: `no-vegvesen-${i}`,
|
||||
lat: 60 + i * 0.001,
|
||||
lon: 7 + i * 0.001,
|
||||
groundElevationM: 150,
|
||||
});
|
||||
|
||||
const withHeightsEnv = (t) => {
|
||||
const saved = process.env.CCTV_VEGVESEN_HEIGHTS;
|
||||
delete process.env.CCTV_VEGVESEN_HEIGHTS;
|
||||
clearKartverketHeightCache();
|
||||
t.after(() => {
|
||||
clearKartverketHeightCache();
|
||||
if (saved === undefined) delete process.env.CCTV_VEGVESEN_HEIGHTS;
|
||||
else process.env.CCTV_VEGVESEN_HEIGHTS = saved;
|
||||
});
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
};
|
||||
|
||||
const sentPoints = (url) =>
|
||||
JSON.parse(new URL(url).searchParams.get('punkter'));
|
||||
|
||||
test('Kartverket heights come in batches of 20, matched by echoed coordinates', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
requested.push(String(url));
|
||||
assert.ok(String(url).startsWith(KARTVERKET_HEIGHT_URL));
|
||||
assert.equal(init.headers['User-Agent'], KARTVERKET_USER_AGENT);
|
||||
// Redirects are refused, so a request can never leave the fixed host.
|
||||
assert.equal(init.redirect, 'error');
|
||||
// Reply in reverse order: matching must not depend on list position.
|
||||
const response = kartverketResponse(url, (x) =>
|
||||
x > 7.04 ? null : Math.round((x - 7) * 10000),
|
||||
);
|
||||
const body = await response.json();
|
||||
body.punkter.reverse();
|
||||
return Response.json(body);
|
||||
});
|
||||
const { heights, failure } = await fetchKartverketHeights(
|
||||
Array.from({ length: 50 }, (_, i) => cameraAt(i)),
|
||||
);
|
||||
assert.equal(failure, null);
|
||||
// One pass, no retries: 20 + 20 + 10.
|
||||
assert.deepEqual(
|
||||
requested.map((url) => sentPoints(url).length),
|
||||
[KARTVERKET_MAX_POINTS, 20, 10],
|
||||
);
|
||||
for (const url of requested) {
|
||||
assert.equal(new URL(url).searchParams.get('koordsys'), '4258');
|
||||
}
|
||||
assert.equal(heights.size, 50);
|
||||
assert.equal(heights.get('7.010000,60.010000'), 100);
|
||||
// Past x = 7.04 the API has no height: recorded as null, never as 0.
|
||||
assert.equal(heights.get('7.049000,60.049000'), null);
|
||||
});
|
||||
|
||||
test('only the points a request asked about are recorded', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
const points = sentPoints(url);
|
||||
return Response.json({
|
||||
koordsys: 4258,
|
||||
punkter: [
|
||||
...points.map(([x, y]) => ({ x, y, z: 10 })),
|
||||
{ x: 7.5, y: 60.5, z: 999 }, // never asked
|
||||
],
|
||||
});
|
||||
});
|
||||
const { heights } = await fetchKartverketHeights([cameraAt(0)]);
|
||||
assert.deepEqual([...heights.entries()], [['7.000000,60.000000', 10]]);
|
||||
});
|
||||
|
||||
test('either water marker counts as the surface', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
const [a, b, c] = sentPoints(url);
|
||||
return Response.json({
|
||||
koordsys: 4258,
|
||||
punkter: [
|
||||
{
|
||||
x: a[0],
|
||||
y: a[1],
|
||||
z: -547.1,
|
||||
datakilde: 'dybdekurver',
|
||||
terreng: null,
|
||||
},
|
||||
{ x: b[0], y: b[1], z: -9.2, datakilde: 'dtm1', terreng: 'Havflate' },
|
||||
{
|
||||
x: c[0],
|
||||
y: c[1],
|
||||
z: -0.2,
|
||||
datakilde: 'dtm1',
|
||||
terreng: 'ÅpentOmråde',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
const { heights } = await fetchKartverketHeights([
|
||||
cameraAt(0),
|
||||
cameraAt(1),
|
||||
cameraAt(2),
|
||||
]);
|
||||
assert.equal(heights.get('7.000000,60.000000'), 0);
|
||||
assert.equal(heights.get('7.001000,60.001000'), 0);
|
||||
// A quay just below the datum on land data stays as measured.
|
||||
assert.equal(heights.get('7.002000,60.002000'), -0.2);
|
||||
});
|
||||
|
||||
test('heights outside -20..2500 m and points outside Norway are not used', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
requested.push(String(url));
|
||||
const zs = [-20, -20.5, 2500, 2500.5];
|
||||
const points = sentPoints(url);
|
||||
return Response.json({
|
||||
koordsys: 4258,
|
||||
punkter: points.map(([x, y], i) => ({ x, y, z: zs[i] })),
|
||||
});
|
||||
});
|
||||
const { heights } = await fetchKartverketHeights([
|
||||
cameraAt(0),
|
||||
cameraAt(1),
|
||||
cameraAt(2),
|
||||
cameraAt(3),
|
||||
{ lat: 55.68, lon: 12.57 }, // Copenhagen: never sent
|
||||
]);
|
||||
assert.equal(requested.length, 1);
|
||||
assert.equal(sentPoints(requested[0]).length, 4);
|
||||
assert.deepEqual(
|
||||
[0, 1, 2, 3].map((i) =>
|
||||
heights.get(
|
||||
`${(7 + i * 0.001).toFixed(6)},${(60 + i * 0.001).toFixed(6)}`,
|
||||
),
|
||||
),
|
||||
[-20, null, 2500, null],
|
||||
);
|
||||
});
|
||||
|
||||
test('no more than eight requests are in flight', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
let inFlight = 0;
|
||||
let peak = 0;
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
inFlight += 1;
|
||||
peak = Math.max(peak, inFlight);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
inFlight -= 1;
|
||||
return kartverketResponse(url, () => 1);
|
||||
});
|
||||
const { heights } = await fetchKartverketHeights(
|
||||
Array.from({ length: 400 }, (_, i) => cameraAt(i)),
|
||||
);
|
||||
assert.equal(heights.size, 400);
|
||||
assert.equal(peak, KARTVERKET_CONCURRENCY);
|
||||
});
|
||||
|
||||
test('a transport failure stops the lookup: no retries, no new requests', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
let calls = 0;
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
calls += 1;
|
||||
return new Response('busy', {
|
||||
status: 429,
|
||||
headers: { 'Retry-After': '120' },
|
||||
});
|
||||
});
|
||||
const { heights, failure } = await fetchKartverketHeights(
|
||||
Array.from({ length: 400 }, (_, i) => cameraAt(i)),
|
||||
);
|
||||
// The first wave is already in flight when the first failure lands; nothing
|
||||
// after it starts, and no point is resent.
|
||||
assert.equal(calls, KARTVERKET_CONCURRENCY);
|
||||
assert.equal(heights.size, 0);
|
||||
assert.deepEqual(failure, {
|
||||
status: 429,
|
||||
retryAfter: '120',
|
||||
deadline: false,
|
||||
});
|
||||
});
|
||||
|
||||
/** A fetch that never answers, and rejects only when its signal aborts. */
|
||||
const hangingFetch = (url, init) => {
|
||||
assert.ok(init.signal instanceof AbortSignal);
|
||||
return new Promise((_, reject) => {
|
||||
init.signal.addEventListener('abort', () => reject(init.signal.reason));
|
||||
});
|
||||
};
|
||||
|
||||
test('the lookup deadline bounds a hanging service', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
t.mock.method(globalThis, 'fetch', hangingFetch);
|
||||
const started = Date.now();
|
||||
const { heights, failure } = await fetchKartverketHeights([cameraAt(0)], {
|
||||
timeoutMs: 30,
|
||||
});
|
||||
const elapsed = Date.now() - started;
|
||||
assert.ok(elapsed >= 25 && elapsed < 2000, `elapsed ${elapsed} ms`);
|
||||
assert.equal(heights.size, 0);
|
||||
assert.deepEqual(failure, { status: null, retryAfter: null, deadline: true });
|
||||
});
|
||||
|
||||
test('one hanging request times out on its own, as a failing service', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
t.mock.method(globalThis, 'fetch', hangingFetch);
|
||||
const { failure } = await fetchKartverketHeights([cameraAt(0)], {
|
||||
timeoutMs: 60_000,
|
||||
requestTimeoutMs: 20,
|
||||
});
|
||||
assert.deepEqual(failure, {
|
||||
status: null,
|
||||
retryAfter: null,
|
||||
deadline: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('the deadline is shared: later requests do not get a fresh one', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
let calls = 0;
|
||||
t.mock.method(globalThis, 'fetch', (url, init) => {
|
||||
calls += 1;
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
() => resolve(kartverketResponse(url, () => 1)),
|
||||
40,
|
||||
);
|
||||
init.signal.addEventListener('abort', () => {
|
||||
clearTimeout(timer);
|
||||
reject(init.signal.reason);
|
||||
});
|
||||
});
|
||||
});
|
||||
// Five waves of 40 ms against a 100 ms deadline for the whole lookup.
|
||||
const { heights, failure } = await fetchKartverketHeights(
|
||||
Array.from({ length: 5 * KARTVERKET_CONCURRENCY * 20 }, (_, i) =>
|
||||
cameraAt(i),
|
||||
),
|
||||
{ timeoutMs: 100 },
|
||||
);
|
||||
assert.equal(failure?.deadline, true);
|
||||
assert.ok(heights.size < 5 * KARTVERKET_CONCURRENCY * 20);
|
||||
assert.ok(calls < 5 * KARTVERKET_CONCURRENCY, `calls ${calls}`);
|
||||
});
|
||||
|
||||
test('a 503, an unreadable or an oversized body stops the lookup', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
const answers = {
|
||||
503: () => new Response('down', { status: 503 }),
|
||||
'not json': () =>
|
||||
new Response('<html>maintenance</html>', {
|
||||
headers: { 'Content-Type': 'text/html' },
|
||||
}),
|
||||
oversized: () =>
|
||||
Response.json({
|
||||
punkter: [],
|
||||
pad: 'x'.repeat(KARTVERKET_MAX_RESPONSE_BYTES + 1),
|
||||
}),
|
||||
};
|
||||
for (const [label, answer] of Object.entries(answers)) {
|
||||
clearKartverketHeightCache();
|
||||
let calls = 0;
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
calls += 1;
|
||||
return answer();
|
||||
});
|
||||
const { heights, failure } = await fetchKartverketHeights(
|
||||
Array.from({ length: 400 }, (_, i) => cameraAt(i)),
|
||||
);
|
||||
assert.equal(calls, KARTVERKET_CONCURRENCY, label);
|
||||
assert.equal(heights.size, 0, label);
|
||||
assert.equal(failure?.deadline, false, label);
|
||||
assert.equal(failure?.status, label === '503' ? 503 : null, label);
|
||||
}
|
||||
});
|
||||
|
||||
test('a 4xx rejects that request only: its points get no height, the rest go on', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
t.mock.method(globalThis, 'fetch', async (url) =>
|
||||
sentPoints(url)[0][0] === 7
|
||||
? new Response('bad point', { status: 400 })
|
||||
: kartverketResponse(url, () => 42),
|
||||
);
|
||||
const { heights, failure } = await fetchKartverketHeights(
|
||||
Array.from({ length: 40 }, (_, i) => cameraAt(i)),
|
||||
);
|
||||
assert.equal(failure, null);
|
||||
assert.equal(heights.size, 40);
|
||||
assert.equal(heights.get('7.000000,60.000000'), null);
|
||||
assert.equal(heights.get('7.020000,60.020000'), 42);
|
||||
});
|
||||
|
||||
test('a point a 200 answer leaves out is cached as no height', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
const fetchMock = t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
const points = sentPoints(url).slice(1); // drop the first point
|
||||
return Response.json({
|
||||
koordsys: 4258,
|
||||
punkter: points.map(([x, y]) => ({ x, y, z: 7 })),
|
||||
});
|
||||
});
|
||||
const cameras = [cameraAt(0), cameraAt(1)];
|
||||
assert.equal(await applyVegvesenGroundHeights(cameras), 1);
|
||||
assert.equal(cameras[0].groundElevationM, 150);
|
||||
// The next refresh asks nothing: the left-out point is a cached miss.
|
||||
assert.equal(await applyVegvesenGroundHeights([cameraAt(0), cameraAt(1)]), 1);
|
||||
assert.equal(fetchMock.mock.callCount(), 1);
|
||||
});
|
||||
|
||||
test('Retry-After sets the backoff, bounded to [1 min, 1 h]', () => {
|
||||
const now = Date.parse('2026-10-06T12:00:00Z');
|
||||
assert.equal(kartverketBackoffMs('120', now), 120_000);
|
||||
assert.equal(kartverketBackoffMs('5', now), 60_000);
|
||||
assert.equal(kartverketBackoffMs('86400', now), 3_600_000);
|
||||
assert.equal(
|
||||
kartverketBackoffMs('Mon, 06 Oct 2026 12:05:00 GMT', now),
|
||||
300_000,
|
||||
);
|
||||
assert.equal(kartverketBackoffMs(null, now), KARTVERKET_FAILURE_BACKOFF_MS);
|
||||
assert.equal(kartverketBackoffMs('soon', now), KARTVERKET_FAILURE_BACKOFF_MS);
|
||||
});
|
||||
|
||||
test('heights are cached, and misses are not asked again until they expire', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
let clock = 1_000_000;
|
||||
const now = () => clock;
|
||||
const asked = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
asked.push(sentPoints(url).length);
|
||||
return kartverketResponse(url, (x) => (x < 7.002 ? 1043.26 : null));
|
||||
});
|
||||
const cameras = [cameraAt(0), cameraAt(1), cameraAt(5)];
|
||||
assert.equal(await applyVegvesenGroundHeights(cameras, { now }), 2);
|
||||
assert.deepEqual(
|
||||
cameras.map((c) => c.groundElevationM),
|
||||
[1043.3, 1043.3, 150],
|
||||
);
|
||||
// The next refresh asks nothing: heights and the miss are both cached.
|
||||
assert.equal(
|
||||
await applyVegvesenGroundHeights([cameraAt(0), cameraAt(1), cameraAt(5)], {
|
||||
now,
|
||||
}),
|
||||
2,
|
||||
);
|
||||
assert.deepEqual(asked, [3]);
|
||||
// Once the miss expires, only that point is asked again.
|
||||
clock += KARTVERKET_NULL_TTL_MS + 1;
|
||||
await applyVegvesenGroundHeights([cameraAt(0), cameraAt(5)], { now });
|
||||
assert.deepEqual(asked, [3, 1]);
|
||||
});
|
||||
|
||||
test('after a failure no lookup starts until the backoff ends', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
let clock = 1_000_000;
|
||||
const now = () => clock;
|
||||
const fetchMock = t.mock.method(globalThis, 'fetch', async () => {
|
||||
throw new Error('offline');
|
||||
});
|
||||
const cameras = [cameraAt(0)];
|
||||
assert.equal(await applyVegvesenGroundHeights(cameras, { now }), 0);
|
||||
assert.equal(cameras[0].groundElevationM, 150);
|
||||
assert.equal(fetchMock.mock.callCount(), 1);
|
||||
|
||||
// The default backoff outlasts the catalog refresh, so the next refresh
|
||||
// and every one inside the backoff cost nothing.
|
||||
assert.ok(KARTVERKET_FAILURE_BACKOFF_MS > CCTV_SOURCE_CACHE_MS);
|
||||
clock += CCTV_SOURCE_CACHE_MS;
|
||||
assert.equal(await applyVegvesenGroundHeights([cameraAt(0)], { now }), 0);
|
||||
clock += KARTVERKET_FAILURE_BACKOFF_MS - CCTV_SOURCE_CACHE_MS - 1;
|
||||
assert.equal(await applyVegvesenGroundHeights([cameraAt(0)], { now }), 0);
|
||||
assert.equal(fetchMock.mock.callCount(), 1);
|
||||
|
||||
// After it, the failed point is asked again.
|
||||
clock += 2;
|
||||
fetchMock.mock.mockImplementation(async (url) =>
|
||||
kartverketResponse(url, () => 321),
|
||||
);
|
||||
const later = [cameraAt(0)];
|
||||
assert.equal(await applyVegvesenGroundHeights(later, { now }), 1);
|
||||
assert.equal(later[0].groundElevationM, 321);
|
||||
assert.equal(fetchMock.mock.callCount(), 2);
|
||||
});
|
||||
|
||||
test('a 429 backs off for its Retry-After, not the default', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
let clock = 1_000_000;
|
||||
const now = () => clock;
|
||||
const retryAfterS = 50 * 60;
|
||||
const fetchMock = t.mock.method(
|
||||
globalThis,
|
||||
'fetch',
|
||||
async () =>
|
||||
new Response('busy', {
|
||||
status: 429,
|
||||
headers: { 'Retry-After': String(retryAfterS) },
|
||||
}),
|
||||
);
|
||||
await applyVegvesenGroundHeights([cameraAt(0)], { now });
|
||||
assert.equal(fetchMock.mock.callCount(), 1);
|
||||
clock += KARTVERKET_FAILURE_BACKOFF_MS + 1;
|
||||
await applyVegvesenGroundHeights([cameraAt(0)], { now });
|
||||
assert.equal(fetchMock.mock.callCount(), 1);
|
||||
clock += retryAfterS * 1000 - KARTVERKET_FAILURE_BACKOFF_MS;
|
||||
await applyVegvesenGroundHeights([cameraAt(0)], { now });
|
||||
assert.equal(fetchMock.mock.callCount(), 2);
|
||||
});
|
||||
|
||||
test('a lookup cut short by its deadline resumes on the next refresh', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
let clock = 1_000_000;
|
||||
const now = () => clock;
|
||||
t.mock.method(globalThis, 'fetch', hangingFetch);
|
||||
// The lookup runs out of time; that is not a failing service.
|
||||
assert.equal(
|
||||
await applyVegvesenGroundHeights([cameraAt(0)], {
|
||||
now,
|
||||
timeoutMs: 10,
|
||||
waitMs: 1000,
|
||||
}),
|
||||
0,
|
||||
);
|
||||
const fetchMock = t.mock.method(globalThis, 'fetch', async (url) =>
|
||||
kartverketResponse(url, () => 64),
|
||||
);
|
||||
clock += CCTV_SOURCE_CACHE_MS;
|
||||
const cameras = [cameraAt(0)];
|
||||
assert.equal(await applyVegvesenGroundHeights(cameras, { now }), 1);
|
||||
assert.equal(cameras[0].groundElevationM, 64);
|
||||
assert.equal(fetchMock.mock.callCount(), 1);
|
||||
});
|
||||
|
||||
test('overlapping refreshes share one lookup', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
let release;
|
||||
const gate = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const fetchMock = t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
await gate;
|
||||
return kartverketResponse(url, () => 3);
|
||||
});
|
||||
await Promise.all([
|
||||
applyVegvesenGroundHeights([cameraAt(0)], { waitMs: 5 }),
|
||||
applyVegvesenGroundHeights([cameraAt(0)], { waitMs: 5 }),
|
||||
]);
|
||||
assert.equal(fetchMock.mock.callCount(), 1);
|
||||
release();
|
||||
});
|
||||
|
||||
test('a warm cache does not wait for a few new cameras', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
const cameras = Array.from({ length: 20 }, (_, i) => cameraAt(i));
|
||||
t.mock.method(globalThis, 'fetch', async (url) =>
|
||||
kartverketResponse(url, () => 500),
|
||||
);
|
||||
assert.equal(await applyVegvesenGroundHeights(cameras), 20);
|
||||
let release;
|
||||
const gate = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const fetchMock = t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
await gate;
|
||||
return kartverketResponse(url, () => 600);
|
||||
});
|
||||
const refresh = [...cameras, cameraAt(20)];
|
||||
const started = Date.now();
|
||||
assert.equal(
|
||||
await applyVegvesenGroundHeights(refresh, { waitMs: 5_000 }),
|
||||
20,
|
||||
);
|
||||
assert.ok(Date.now() - started < 1000);
|
||||
assert.equal(fetchMock.mock.callCount(), 1);
|
||||
// The background lookup fills the cache for the next refresh.
|
||||
release();
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
const next = [...cameras, cameraAt(20)];
|
||||
assert.equal(await applyVegvesenGroundHeights(next), 21);
|
||||
assert.equal(next[20].groundElevationM, 600);
|
||||
});
|
||||
|
||||
test('cameras without numeric coordinates are skipped, never thrown on', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
t.mock.method(globalThis, 'fetch', async (url) =>
|
||||
kartverketResponse(url, () => 9),
|
||||
);
|
||||
const cameras = [
|
||||
{ id: 'no-coordinates' },
|
||||
null,
|
||||
{ id: 'strings', lat: '60', lon: '7' },
|
||||
cameraAt(0),
|
||||
];
|
||||
assert.equal(await applyVegvesenGroundHeights(cameras), 1);
|
||||
assert.equal(cameras[3].groundElevationM, 9);
|
||||
});
|
||||
|
||||
test('a lookup started before a cache clear cannot write after it', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
let release;
|
||||
const gate = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
await gate;
|
||||
return kartverketResponse(url, () => 111);
|
||||
});
|
||||
await applyVegvesenGroundHeights([cameraAt(0)], { waitMs: 5 });
|
||||
clearKartverketHeightCache();
|
||||
t.mock.method(globalThis, 'fetch', async (url) =>
|
||||
kartverketResponse(url, () => 222),
|
||||
);
|
||||
const fresh = [cameraAt(0)];
|
||||
assert.equal(await applyVegvesenGroundHeights(fresh), 1);
|
||||
assert.equal(fresh[0].groundElevationM, 222);
|
||||
release();
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
const later = [cameraAt(0)];
|
||||
await applyVegvesenGroundHeights(later, { waitMs: 0 });
|
||||
assert.equal(later[0].groundElevationM, 222);
|
||||
});
|
||||
|
||||
test('CCTV_VEGVESEN_HEIGHTS=0 skips the lookup', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
const fetchMock = t.mock.method(globalThis, 'fetch', async (url) =>
|
||||
kartverketResponse(url, () => 5),
|
||||
);
|
||||
process.env.CCTV_VEGVESEN_HEIGHTS = '0';
|
||||
const cameras = [cameraAt(0)];
|
||||
assert.equal(await applyVegvesenGroundHeights(cameras), 0);
|
||||
assert.equal(cameras[0].groundElevationM, 150);
|
||||
assert.equal(fetchMock.mock.callCount(), 0);
|
||||
});
|
||||
|
||||
test('a slow lookup does not hold the catalog; the next refresh applies it', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
let release;
|
||||
const gate = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
await gate;
|
||||
return kartverketResponse(url, () => 812);
|
||||
});
|
||||
const first = [cameraAt(0)];
|
||||
assert.equal(await applyVegvesenGroundHeights(first, { waitMs: 20 }), 0);
|
||||
assert.equal(first[0].groundElevationM, 150);
|
||||
release();
|
||||
// The lookup kept running and filled the cache for the next refresh.
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
const next = [cameraAt(0)];
|
||||
assert.equal(await applyVegvesenGroundHeights(next, { waitMs: 20 }), 1);
|
||||
assert.equal(next[0].groundElevationM, 812);
|
||||
});
|
||||
|
||||
test('the loader applies Kartverket heights to the cameras it returns', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
if (String(url).startsWith(KARTVERKET_HEIGHT_URL)) {
|
||||
return kartverketResponse(url, () => 987.6);
|
||||
}
|
||||
return Response.json({ type: 'FeatureCollection', features: [feature()] });
|
||||
});
|
||||
const cameras = await loadVegvesenSourcesFromOpenData();
|
||||
assert.deepEqual(
|
||||
cameras.map((c) => [c.id, c.groundElevationM]),
|
||||
[['no-vegvesen-3000063_1', 987.6]],
|
||||
);
|
||||
});
|
||||
|
||||
test('the loader budgets the height wait from its own start', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
const realNow = Date.now.bind(Date);
|
||||
let offset = 0;
|
||||
t.mock.method(Date, 'now', () => realNow() + offset);
|
||||
t.mock.method(globalThis, 'fetch', (url, init) => {
|
||||
if (String(url).startsWith(KARTVERKET_HEIGHT_URL)) {
|
||||
return hangingFetch(url, init);
|
||||
}
|
||||
// The catalog fetch uses up all but 10 ms of the lane's budget.
|
||||
offset += KARTVERKET_CATALOG_WAIT_MS - 10;
|
||||
return Promise.resolve(
|
||||
Response.json({ type: 'FeatureCollection', features: [feature()] }),
|
||||
);
|
||||
});
|
||||
const started = performance.now();
|
||||
const cameras = await loadVegvesenSourcesFromOpenData();
|
||||
assert.ok(performance.now() - started < 1000);
|
||||
assert.equal(cameras[0].groundElevationM, 150);
|
||||
});
|
||||
+15
-3
@@ -238,8 +238,9 @@ export const DATA_CREDITS = [
|
||||
key: 'ontario-511-cctv',
|
||||
html:
|
||||
'CCTV cameras & frames (Ontario): ' +
|
||||
'<a href="https://511on.ca/" target="_blank" rel="noopener">Ontario 511</a> ' +
|
||||
'(<a href="https://www.ontario.ca/page/open-government-licence-ontario" target="_blank" rel="noopener">Open Government Licence - Ontario</a>)',
|
||||
'<a href="https://511on.ca/" target="_blank" rel="noopener">Ontario 511</a>. ' +
|
||||
'Contains information licensed under the <a href="https://www.ontario.ca/page/open-government-licence-ontario" target="_blank" rel="noopener">Open Government Licence – Ontario</a>. ' +
|
||||
'Provided as is, without warranty; actual road conditions may differ.',
|
||||
},
|
||||
{
|
||||
key: 'fintraffic-cctv',
|
||||
@@ -263,6 +264,14 @@ export const DATA_CREDITS = [
|
||||
'Traffic cameras (Calgary): contains information licensed under the ' +
|
||||
'<a href="https://data.calgary.ca/stories/s/Open-Calgary-Terms-of-Use/u45n-7awa" target="_blank" rel="noopener">Open Government Licence – City of Calgary</a>',
|
||||
},
|
||||
{
|
||||
key: 'florida-cctv',
|
||||
html:
|
||||
'Traffic cameras (Florida): FL511, Florida Department of Transportation. ' +
|
||||
'Individual, personal use only under the ' +
|
||||
'<a href="https://fl511.com/privacy#disclaimer" target="_blank" rel="noopener">FL511 terms</a>; ' +
|
||||
"any other use needs FDOT's express written consent",
|
||||
},
|
||||
{
|
||||
key: 'catalonia-cctv',
|
||||
html:
|
||||
@@ -285,7 +294,10 @@ export const DATA_CREDITS = [
|
||||
html:
|
||||
'Road cameras (Norway): contains data under the ' +
|
||||
'<a href="https://data.norge.no/nlod/en/2.0" target="_blank" rel="noopener">Norwegian licence for Open Government data (NLOD)</a> ' +
|
||||
'distributed by Statens vegvesen',
|
||||
'distributed by Statens vegvesen · camera ground heights © ' +
|
||||
'<a href="https://kartverket.no/en/api-and-data/terrengdata" target="_blank" rel="noopener">Kartverket</a> ' +
|
||||
'(<a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener">CC BY 4.0</a>); ' +
|
||||
'modified: heights over water set to 0 m and rounded to 0.1 m',
|
||||
},
|
||||
{
|
||||
key: 'nyc-cctv',
|
||||
|
||||
@@ -59,9 +59,13 @@ test('new data proxies install the same routes in dev and preview servers', () =
|
||||
|
||||
test('Launch Library uses a 15-minute cache and optional server-side token header', () => {
|
||||
assert.equal(LL2_CACHE_TTL_MS, 15 * 60_000);
|
||||
assert.deepEqual(launchLibraryRequestHeaders(''), { Accept: 'application/json' });
|
||||
assert.deepEqual(launchLibraryRequestHeaders(' secret '), {
|
||||
const headers = {
|
||||
Accept: 'application/json',
|
||||
'User-Agent': 'gods-eye-view-launch-library-proxy/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)',
|
||||
};
|
||||
assert.deepEqual(launchLibraryRequestHeaders(''), headers);
|
||||
assert.deepEqual(launchLibraryRequestHeaders(' secret '), {
|
||||
...headers,
|
||||
Authorization: 'Token secret',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,10 +7,12 @@ export function celestrakTleUrl(group) {
|
||||
}
|
||||
|
||||
export function launchLibraryRecentUrl(end) {
|
||||
const start = new Date(end.getTime() - 30 * 86400000);
|
||||
// Stable minute boundaries let different installations share LL2's cache.
|
||||
const roundedEnd = new Date(Math.floor(end.getTime() / 60000) * 60000);
|
||||
const start = new Date(roundedEnd.getTime() - 30 * 86400000);
|
||||
const url = new URL('https://ll.thespacedevs.com/2.3.0/launches/');
|
||||
url.searchParams.set('net__gte', start.toISOString());
|
||||
url.searchParams.set('net__lte', end.toISOString());
|
||||
url.searchParams.set('net__lte', roundedEnd.toISOString());
|
||||
url.searchParams.set('limit', '100');
|
||||
url.searchParams.set('mode', 'detailed');
|
||||
return url;
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
*/
|
||||
|
||||
import { t, msg } from '../i18n/index.js';
|
||||
import { normalizeVesselType } from './vesselTypes.js';
|
||||
|
||||
export { normalizeVesselType };
|
||||
|
||||
export const VESSEL_OVERLAY_SOURCE_ID = 'ais-live-vessels';
|
||||
/** Existing selector grid size; one ambient winner is retained per cell. */
|
||||
@@ -43,31 +46,6 @@ const TYPE_STYLES = [
|
||||
];
|
||||
const DEFAULT_STYLE = { css: '#39d5ff', accent: '57, 213, 255' };
|
||||
|
||||
const NUMERIC_TYPE_SPECIALS = {
|
||||
30: 'FISHING',
|
||||
31: 'TOWING',
|
||||
32: 'TOWING',
|
||||
33: 'DREDGER',
|
||||
34: 'DIVE OPS',
|
||||
35: 'MILITARY',
|
||||
36: 'SAILING',
|
||||
37: 'PLEASURE',
|
||||
50: 'PILOT',
|
||||
51: 'SAR',
|
||||
52: 'TUG',
|
||||
53: 'PORT TENDER',
|
||||
54: 'ANTI-POLLUTION',
|
||||
55: 'LAW ENFORCE',
|
||||
58: 'MEDICAL',
|
||||
};
|
||||
const NUMERIC_TYPE_FAMILIES = {
|
||||
4: 'HIGH-SPEED',
|
||||
6: 'PASSENGER',
|
||||
7: 'CARGO',
|
||||
8: 'TANKER',
|
||||
9: 'OTHER',
|
||||
};
|
||||
|
||||
// Card text for the type tokens above. The tokens stay English: TYPE_STYLES
|
||||
// and tests match on them.
|
||||
const TYPE_DISPLAY_KEYS = Object.freeze({
|
||||
@@ -104,21 +82,6 @@ export function displayVesselType(type) {
|
||||
return key ? t(key) : normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an AIS type to display text: bare numeric ship-type codes map to
|
||||
* family names ("71" → "CARGO"); text types pass through unchanged.
|
||||
* @param {string} type Raw AIS type.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function normalizeVesselType(type) {
|
||||
const text = String(type || '').trim();
|
||||
if (!text || !/^\d{1,2}$/.test(text)) return text;
|
||||
const code = Number(text);
|
||||
if (code <= 0) return '';
|
||||
if (NUMERIC_TYPE_SPECIALS[code]) return NUMERIC_TYPE_SPECIALS[code];
|
||||
return NUMERIC_TYPE_FAMILIES[Math.floor(code / 10)] || 'OTHER';
|
||||
}
|
||||
|
||||
/** AIS ship type → CSS hex hue for the billboard chevron. */
|
||||
export function vesselTypeCss(type) {
|
||||
return styleForType(type).css;
|
||||
|
||||
@@ -29,6 +29,8 @@ test('normalizeVesselType preserves text and degrades unknown codes', () => {
|
||||
assert.equal(normalizeVesselType('Crude Oil Tanker'), 'Crude Oil Tanker');
|
||||
assert.equal(normalizeVesselType('0'), '');
|
||||
assert.equal(normalizeVesselType('25'), 'OTHER');
|
||||
assert.equal(normalizeVesselType('100'), '');
|
||||
assert.equal(normalizeVesselType('255'), '');
|
||||
assert.equal(normalizeVesselType(undefined), '');
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* @module vesselTypes
|
||||
* @description AIS ship-type codes (ITU-R M.1371) to family names, with no
|
||||
* browser or i18n dependencies so the server-side query tools can use it.
|
||||
*/
|
||||
|
||||
const NUMERIC_TYPE_SPECIALS = {
|
||||
30: 'FISHING',
|
||||
31: 'TOWING',
|
||||
32: 'TOWING',
|
||||
33: 'DREDGER',
|
||||
34: 'DIVE OPS',
|
||||
35: 'MILITARY',
|
||||
36: 'SAILING',
|
||||
37: 'PLEASURE',
|
||||
50: 'PILOT',
|
||||
51: 'SAR',
|
||||
52: 'TUG',
|
||||
53: 'PORT TENDER',
|
||||
54: 'ANTI-POLLUTION',
|
||||
55: 'LAW ENFORCE',
|
||||
58: 'MEDICAL',
|
||||
};
|
||||
const NUMERIC_TYPE_FAMILIES = {
|
||||
4: 'HIGH-SPEED',
|
||||
6: 'PASSENGER',
|
||||
7: 'CARGO',
|
||||
8: 'TANKER',
|
||||
9: 'OTHER',
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve an AIS type to display text: bare numeric ship-type codes map to
|
||||
* family names ("71" → "CARGO"); text types pass through unchanged. Code 0
|
||||
* ("not available") and the reserved codes 100 to 255 name no type and
|
||||
* resolve to "".
|
||||
* @param {string} type Raw AIS type.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function normalizeVesselType(type) {
|
||||
const text = String(type || '').trim();
|
||||
if (!text || !/^\d+$/.test(text)) return text;
|
||||
const code = Number(text);
|
||||
if (code <= 0 || code >= 100) return '';
|
||||
if (NUMERIC_TYPE_SPECIALS[code]) return NUMERIC_TYPE_SPECIALS[code];
|
||||
return NUMERIC_TYPE_FAMILIES[Math.floor(code / 10)] || 'OTHER';
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { PassThrough } from 'node:stream';
|
||||
import { localProviderPlugins } from '../server/providers/local.js';
|
||||
import { standaloneVoiceTools } from '../server/standalone/voiceTools.js';
|
||||
import {
|
||||
KEY_SETUP_KEYS,
|
||||
keySetupStatus,
|
||||
validateKeySetupUpdates,
|
||||
upsertDotenvValues,
|
||||
} from './keySetupCore.mjs';
|
||||
|
||||
test('local hosts opt into Gemini explicitly and retain the default plugin order', (t) => {
|
||||
t.mock.method(globalThis, 'fetch', () => {
|
||||
throw Error('construction must not fetch');
|
||||
});
|
||||
const defaults = localProviderPlugins().map((plugin) => plugin.name);
|
||||
assert.ok(!defaults.includes('gemini-live-proxy'));
|
||||
const enabled = localProviderPlugins({ gemini: {} }).map(
|
||||
(plugin) => plugin.name,
|
||||
);
|
||||
assert.equal(
|
||||
enabled.filter((name) => name === 'gemini-live-proxy').length,
|
||||
1,
|
||||
);
|
||||
assert.deepEqual(
|
||||
enabled.filter((name) => name !== 'gemini-live-proxy'),
|
||||
defaults,
|
||||
);
|
||||
});
|
||||
|
||||
for (const hook of ['configureServer', 'configurePreviewServer']) {
|
||||
test(`opted-in ${hook} locks the standalone catalog into the Gemini token`, async () => {
|
||||
const tools = standaloneVoiceTools();
|
||||
const calls = [];
|
||||
const plugin = localProviderPlugins({
|
||||
gemini: {
|
||||
tools,
|
||||
resolveApiKey: () => 'fixture-key',
|
||||
resolveModel: () => 'gemini-3.8-live',
|
||||
resolveHost: () => '',
|
||||
resolveAllowedHosts: () => '',
|
||||
resolveRateLimit: () => '0',
|
||||
fetchImpl: async (url, options) => {
|
||||
calls.push({ url, options });
|
||||
return Response.json({ name: 'auth_tokens/fixture' });
|
||||
},
|
||||
},
|
||||
}).find((entry) => entry.name === 'gemini-live-proxy');
|
||||
let handler;
|
||||
plugin[hook]({
|
||||
middlewares: {
|
||||
use(route, value) {
|
||||
assert.equal(route, '/api/gemini/token');
|
||||
handler = value;
|
||||
},
|
||||
},
|
||||
});
|
||||
const req = new PassThrough();
|
||||
req.method = 'POST';
|
||||
req.headers = {
|
||||
host: 'localhost:5173',
|
||||
origin: 'http://localhost:5173',
|
||||
'content-type': 'application/json',
|
||||
};
|
||||
req.socket = { remoteAddress: '127.0.0.1' };
|
||||
const res = new EventEmitter();
|
||||
res.setHeader = () => {};
|
||||
res.end = (body) => {
|
||||
res.body = JSON.parse(body);
|
||||
res.writableEnded = true;
|
||||
};
|
||||
req.end('{}');
|
||||
await handler(req, res);
|
||||
assert.equal(res.statusCode, 200);
|
||||
assert.equal(calls.length, 1);
|
||||
const locked = JSON.parse(calls[0].options.body).bidiGenerateContentSetup
|
||||
.tools[0].functionDeclarations;
|
||||
assert.deepEqual(
|
||||
locked.map(({ name }) => name),
|
||||
tools.map(({ name }) => name),
|
||||
);
|
||||
assert.deepEqual(
|
||||
locked.map(({ parametersJsonSchema }) => parametersJsonSchema),
|
||||
tools.map(({ parameters }) => parameters),
|
||||
);
|
||||
assert.ok(locked.every(({ behavior }) => behavior === 'BLOCKING'));
|
||||
assert.deepEqual(res.body.config.tools[0].functionDeclarations, locked);
|
||||
assert.ok(!JSON.stringify(res.body).includes('fixture-key'));
|
||||
});
|
||||
}
|
||||
|
||||
test('Gemini key setup uses the existing server-only status and save contract', () => {
|
||||
const entry = KEY_SETUP_KEYS.find(({ id }) => id === 'gemini');
|
||||
assert.deepEqual(entry.envVars, ['GEMINI_API_KEY']);
|
||||
assert.ok(!entry.clientExposed);
|
||||
assert.equal(
|
||||
keySetupStatus({}).keys.find(({ id }) => id === 'gemini').set,
|
||||
false,
|
||||
);
|
||||
const env = { GEMINI_API_KEY: 'fixture-private-key' };
|
||||
const status = keySetupStatus(env);
|
||||
assert.equal(status.keys.find(({ id }) => id === 'gemini').set, true);
|
||||
assert.ok(!JSON.stringify(status).includes(env.GEMINI_API_KEY));
|
||||
assert.equal(validateKeySetupUpdates(env).ok, true);
|
||||
assert.match(
|
||||
upsertDotenvValues('GEMINI_API_KEY=old\n', env),
|
||||
/^GEMINI_API_KEY=fixture-private-key\n$/,
|
||||
);
|
||||
assert.equal(
|
||||
validateKeySetupUpdates({ VITE_GEMINI_API_KEY: 'fixture-private-key' }).ok,
|
||||
false,
|
||||
);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -842,7 +842,8 @@
|
||||
"Real live traffic (keyless runs a simulation)": "Tráfico real en vivo (sin clave se usa una simulación)",
|
||||
"Bing imagery map stacks + world terrain": "Imágenes de Bing + terreno mundial",
|
||||
"More flight-polling credits (anonymous works without)": "Más créditos de consulta de vuelos (sin clave funciona el modo anónimo)",
|
||||
"Higher space-missions request allowance": "Mayor límite de peticiones para misiones espaciales",
|
||||
"Public space-missions access needs no key. Leave blank for standard use; paid tokens only raise request limits.": "El acceso público a misiones espaciales no necesita clave. Déjalo en blanco para el uso estándar; los tokens de pago solo aumentan los límites de solicitudes.",
|
||||
"Optional paid token — public access works without one": "Token opcional de pago — el acceso público funciona sin él",
|
||||
"Scene action failed": "La acción de escena falló",
|
||||
"Project exported": "Proyecto exportado",
|
||||
"Next session: {pendingId} — this session stays on {modelId}": "Sesión siguiente: {pendingId} — esta sesión sigue en {modelId}",
|
||||
@@ -2889,5 +2890,56 @@
|
||||
"USB work abandoned after a stalled transfer": "Trabajo USB cancelado tras una transferencia bloqueada",
|
||||
"SDR decoder did not start; receiver stopped": "El decodificador SDR no arrancó; receptor detenido",
|
||||
"SDR decoder stopped responding; receiver stopped": "El decodificador SDR dejó de responder; receptor detenido",
|
||||
"SDR decoder fell behind; receiver stopped": "El decodificador SDR se quedó atrás; receptor detenido"
|
||||
"SDR decoder fell behind; receiver stopped": "El decodificador SDR se quedó atrás; receptor detenido",
|
||||
"Gemini returned an oversized audio chunk": "Gemini devolvió un fragmento de audio demasiado grande",
|
||||
"Gemini returned an unsupported audio format": "Gemini devolvió un formato de audio no compatible",
|
||||
"Gemini returned an invalid audio sample rate": "Gemini devolvió una frecuencia de muestreo de audio no válida",
|
||||
"Gemini returned incomplete PCM audio": "Gemini devolvió audio PCM incompleto",
|
||||
"This browser does not support Gemini microphone audio": "Este navegador no admite audio del micrófono para Gemini",
|
||||
"Microphone audio processing failed": "Falló el procesamiento del audio del micrófono",
|
||||
"Gemini audio playback fell behind. Reconnect to continue.": "La reproducción de audio de Gemini se ha retrasado. Vuelve a conectar para continuar.",
|
||||
"Gemini token response did not include an ephemeral credential": "La respuesta de Gemini no incluyó una credencial temporal",
|
||||
"Invalid Gemini connection endpoint": "El destino de conexión de Gemini no es válido",
|
||||
"Voice command context is too large": "El contexto del comando de voz es demasiado grande",
|
||||
"Gemini reliable transport exceeded its queue limit. Start voice again.": "La cola de envío fiable de Gemini superó su límite. Vuelve a iniciar la voz.",
|
||||
"Gemini reliable transport did not drain. Start voice again.": "La cola de envío fiable de Gemini no se vació. Vuelve a iniciar la voz.",
|
||||
"Gemini token request failed (HTTP {status})": "Falló la solicitud de credencial de Gemini (HTTP {status})",
|
||||
"Gemini token response did not include a valid model": "La respuesta de Gemini no incluyó un modelo válido",
|
||||
"Gemini token response did not include session settings": "La respuesta de Gemini no incluyó la configuración de la sesión",
|
||||
"Gemini session settings do not match the input mode": "La configuración de la sesión de Gemini no coincide con el modo de entrada",
|
||||
"Gemini could not submit this input. Start voice again.": "Gemini no pudo enviar esta entrada. Vuelve a iniciar la voz.",
|
||||
"Connecting to Gemini": "Conectando con Gemini",
|
||||
"Gemini returned an oversized message": "Gemini devolvió un mensaje demasiado grande",
|
||||
"Gemini Live connection failed. Check network access, then start voice again.": "Falló la conexión con Gemini Live. Comprueba el acceso a la red y vuelve a iniciar la voz.",
|
||||
"Gemini Live connection ended. Start voice again for a new conversation.": "La conexión con Gemini Live terminó. Vuelve a iniciar la voz para una nueva conversación.",
|
||||
"Gemini Live could not start. Start voice again to request a fresh session.": "Gemini Live no pudo iniciarse. Vuelve a iniciar la voz para solicitar una nueva sesión.",
|
||||
"Start voice again for a fresh token and a new conversation.": "Vuelve a iniciar la voz para obtener una credencial nueva y comenzar otra conversación.",
|
||||
"Start voice again to request a fresh session credential.": "Vuelve a iniciar la voz para solicitar una nueva credencial de sesión.",
|
||||
"Microphone permission was denied. Allow microphone access and try again.": "Se denegó el permiso del micrófono. Permite el acceso al micrófono e inténtalo de nuevo.",
|
||||
"Gemini connection timed out. Try again.": "Se agotó el tiempo de conexión con Gemini. Inténtalo de nuevo.",
|
||||
"Gemini voice could not start": "No se pudo iniciar la voz de Gemini",
|
||||
"Gemini did not finish the interrupted reply. Start voice again.": "Gemini no terminó la respuesta interrumpida. Vuelve a iniciar la voz.",
|
||||
"Gemini session expiring; finishing the current reply": "La sesión de Gemini va a caducar; terminando la respuesta actual",
|
||||
"Gemini rejected the voice session. Start voice again to request a fresh session.": "Gemini rechazó la sesión de voz. Vuelve a iniciar la voz para solicitar una nueva sesión.",
|
||||
"Start voice again for a fresh session credential. If it repeats, check Gemini model and API access.": "Vuelve a iniciar la voz para obtener una nueva credencial de sesión. Si se repite, comprueba el modelo de Gemini y el acceso a la API.",
|
||||
"Gemini action history is full. Start voice again for a fresh session.": "El historial de acciones de Gemini está lleno. Vuelve a iniciar la voz para una nueva sesión.",
|
||||
"Gemini requested too many simultaneous actions": "Gemini solicitó demasiadas acciones simultáneas",
|
||||
"Voice text command is too long": "El comando de voz escrito es demasiado largo",
|
||||
"Gemini voice is not connected": "La voz de Gemini no está conectada",
|
||||
"Gemini interrupted input exceeded its buffer or wait limit. Start voice again.": "La entrada interrumpida de Gemini superó el límite de búfer o de espera. Vuelve a iniciar la voz.",
|
||||
"Gemini Live connection is expiring. Start voice again for a new conversation.": "La conexión con Gemini Live va a caducar. Vuelve a iniciar la voz para una nueva conversación.",
|
||||
"Gemini voice sessions have a time limit. When a session expires, start voice again for a new conversation.": "Las sesiones de voz de Gemini tienen un límite de tiempo. Cuando caduque la sesión, vuelve a iniciar la voz para una nueva conversación.",
|
||||
"Gemini Live voice control": "Control por voz con Gemini Live",
|
||||
"Voice provider": "Proveedor de voz",
|
||||
"Changing provider reconnects voice if it is on.": "Cambiar de proveedor vuelve a conectar la voz si está activada.",
|
||||
"Gemini voice could not connect. Try again.": "No se pudo conectar la voz de Gemini. Inténtalo de nuevo.",
|
||||
"Gemini voice requires a same-origin app request": "La voz de Gemini requiere una solicitud del mismo origen que la aplicación",
|
||||
"Add a Gemini API key in Provider Settings": "Añade una clave de API de Gemini en los ajustes de proveedores",
|
||||
"The Gemini Live model configuration is invalid": "La configuración del modelo de Gemini Live no es válida",
|
||||
"Gemini voice is starting too often. Try again shortly.": "La voz de Gemini se está iniciando con demasiada frecuencia. Vuelve a intentarlo en breve.",
|
||||
"Gemini quota is unavailable. Try again later.": "La cuota de Gemini no está disponible. Inténtalo más tarde.",
|
||||
"Gemini access was refused. Check the API key and model access.": "Se denegó el acceso a Gemini. Comprueba la clave de API y el acceso al modelo.",
|
||||
"Gemini Live is unavailable for the configured model.": "Gemini Live no está disponible para el modelo configurado.",
|
||||
"Token request is too large": "La solicitud de credencial es demasiado grande",
|
||||
"Use an empty JSON token request": "Usa una solicitud de credencial con un objeto JSON vacío"
|
||||
}
|
||||
@@ -74,7 +74,8 @@
|
||||
"Removed from {store}. Restarting — this page reloads itself.": "Удалено: {store}. Перезапуск — страница перезагрузится сама.",
|
||||
"More flight-polling credits (anonymous works without)": "Повышенный лимит запросов рейсов (анонимно работает и без ключа)",
|
||||
"Street-level imagery and coverage in the Street Level layer. Free: register an app in the Mapillary developer dashboard and paste its Client Token": "Уличные снимки и покрытие в слое «Уличные снимки». Бесплатно: зарегистрируйте приложение в панели разработчика Mapillary и вставьте его клиентский токен (Client Token).",
|
||||
"Higher space-missions request allowance": "Повышенный лимит запросов для космических миссий",
|
||||
"Public space-missions access needs no key. Leave blank for standard use; paid tokens only raise request limits.": "Общедоступные данные космических миссий не требуют ключа. Для обычного использования оставьте поле пустым; платные токены только повышают лимиты запросов.",
|
||||
"Optional paid token — public access works without one": "Необязательный платный токен — публичный доступ работает без него",
|
||||
"Places context + Street View fallback; optional separate key": "Контекст Places + резерв: Просмотр улиц; отдельный ключ — по желанию",
|
||||
"Voice control — API key or ChatGPT OAuth": "Голосовое управление — ключ API или ChatGPT OAuth",
|
||||
"Live ships, worldwide": "Суда в реальном времени по всему миру",
|
||||
@@ -2956,5 +2957,56 @@
|
||||
"USB work abandoned after a stalled transfer": "Работа с USB прервана: передача данных зависла",
|
||||
"SDR decoder did not start; receiver stopped": "Декодер SDR не запустился; приёмник остановлен",
|
||||
"SDR decoder stopped responding; receiver stopped": "Декодер SDR перестал отвечать; приёмник остановлен",
|
||||
"SDR decoder fell behind; receiver stopped": "Декодер SDR не успевает; приёмник остановлен"
|
||||
"SDR decoder fell behind; receiver stopped": "Декодер SDR не успевает; приёмник остановлен",
|
||||
"Gemini returned an oversized audio chunk": "Gemini вернул слишком большой фрагмент аудио",
|
||||
"Gemini returned an unsupported audio format": "Gemini вернул неподдерживаемый формат аудио",
|
||||
"Gemini returned an invalid audio sample rate": "Gemini вернул недопустимую частоту дискретизации аудио",
|
||||
"Gemini returned incomplete PCM audio": "Gemini вернул неполные аудиоданные PCM",
|
||||
"This browser does not support Gemini microphone audio": "Этот браузер не поддерживает звук с микрофона для Gemini",
|
||||
"Microphone audio processing failed": "Не удалось обработать звук с микрофона",
|
||||
"Gemini audio playback fell behind. Reconnect to continue.": "Воспроизведение звука Gemini отстаёт. Подключитесь заново, чтобы продолжить.",
|
||||
"Gemini token response did not include an ephemeral credential": "В ответе Gemini нет временных учётных данных",
|
||||
"Invalid Gemini connection endpoint": "Недопустимый адрес подключения к Gemini",
|
||||
"Voice command context is too large": "Контекст голосовой команды слишком велик",
|
||||
"Gemini reliable transport exceeded its queue limit. Start voice again.": "Очередь надёжной передачи Gemini превысила лимит. Запустите голосовой режим заново.",
|
||||
"Gemini reliable transport did not drain. Start voice again.": "Очередь надёжной передачи Gemini не освободилась. Запустите голосовой режим заново.",
|
||||
"Gemini token request failed (HTTP {status})": "Не удалось запросить токен Gemini (HTTP {status})",
|
||||
"Gemini token response did not include a valid model": "В ответе Gemini нет допустимой модели",
|
||||
"Gemini token response did not include session settings": "В ответе Gemini нет настроек сеанса",
|
||||
"Gemini session settings do not match the input mode": "Настройки сеанса Gemini не соответствуют режиму ввода",
|
||||
"Gemini could not submit this input. Start voice again.": "Gemini не удалось отправить эти данные. Запустите голосовой режим заново.",
|
||||
"Connecting to Gemini": "Подключение к Gemini",
|
||||
"Gemini returned an oversized message": "Gemini вернул слишком большое сообщение",
|
||||
"Gemini Live connection failed. Check network access, then start voice again.": "Не удалось подключиться к Gemini Live. Проверьте доступ к сети и запустите голосовой режим заново.",
|
||||
"Gemini Live connection ended. Start voice again for a new conversation.": "Соединение с Gemini Live завершено. Запустите голосовой режим заново для нового разговора.",
|
||||
"Gemini Live could not start. Start voice again to request a fresh session.": "Не удалось запустить Gemini Live. Запустите голосовой режим заново, чтобы запросить новый сеанс.",
|
||||
"Start voice again for a fresh token and a new conversation.": "Запустите голосовой режим заново, чтобы получить новый токен и начать новый разговор.",
|
||||
"Start voice again to request a fresh session credential.": "Запустите голосовой режим заново, чтобы запросить новые учётные данные сеанса.",
|
||||
"Microphone permission was denied. Allow microphone access and try again.": "Доступ к микрофону запрещён. Разрешите доступ и повторите попытку.",
|
||||
"Gemini connection timed out. Try again.": "Время ожидания подключения к Gemini истекло. Повторите попытку.",
|
||||
"Gemini voice could not start": "Не удалось запустить голосовой режим Gemini",
|
||||
"Gemini did not finish the interrupted reply. Start voice again.": "Gemini не завершил прерванный ответ. Запустите голосовой режим заново.",
|
||||
"Gemini session expiring; finishing the current reply": "Сеанс Gemini истекает; завершается текущий ответ",
|
||||
"Gemini rejected the voice session. Start voice again to request a fresh session.": "Gemini отклонил голосовой сеанс. Запустите голосовой режим заново, чтобы запросить новый сеанс.",
|
||||
"Start voice again for a fresh session credential. If it repeats, check Gemini model and API access.": "Запустите голосовой режим заново для получения новых учётных данных. Если ошибка повторится, проверьте модель Gemini и доступ к API.",
|
||||
"Gemini action history is full. Start voice again for a fresh session.": "История действий Gemini заполнена. Запустите голосовой режим заново для нового сеанса.",
|
||||
"Gemini requested too many simultaneous actions": "Gemini запросил слишком много одновременных действий",
|
||||
"Voice text command is too long": "Текст голосовой команды слишком длинный",
|
||||
"Gemini voice is not connected": "Голосовой режим Gemini не подключён",
|
||||
"Gemini interrupted input exceeded its buffer or wait limit. Start voice again.": "Прерванный ввод Gemini превысил лимит буфера или ожидания. Запустите голосовой режим заново.",
|
||||
"Gemini Live connection is expiring. Start voice again for a new conversation.": "Соединение с Gemini Live истекает. Запустите голосовой режим заново для нового разговора.",
|
||||
"Gemini voice sessions have a time limit. When a session expires, start voice again for a new conversation.": "Время голосовых сеансов Gemini ограничено. Когда сеанс истечёт, запустите голосовой режим заново для нового разговора.",
|
||||
"Gemini Live voice control": "Голосовое управление через Gemini Live",
|
||||
"Voice provider": "Поставщик голосового сервиса",
|
||||
"Changing provider reconnects voice if it is on.": "Смена поставщика переподключает голосовой режим, если он включён.",
|
||||
"Gemini voice could not connect. Try again.": "Не удалось подключить голосовой режим Gemini. Повторите попытку.",
|
||||
"Gemini voice requires a same-origin app request": "Голосовой режим Gemini требует запрос из того же источника, что и приложение",
|
||||
"Add a Gemini API key in Provider Settings": "Добавьте ключ API Gemini в настройках поставщиков",
|
||||
"The Gemini Live model configuration is invalid": "Недопустимая конфигурация модели Gemini Live",
|
||||
"Gemini voice is starting too often. Try again shortly.": "Голосовой режим Gemini запускается слишком часто. Повторите попытку чуть позже.",
|
||||
"Gemini quota is unavailable. Try again later.": "Квота Gemini недоступна. Повторите попытку позже.",
|
||||
"Gemini access was refused. Check the API key and model access.": "Доступ к Gemini запрещён. Проверьте ключ API и доступ к модели.",
|
||||
"Gemini Live is unavailable for the configured model.": "Gemini Live недоступен для выбранной модели.",
|
||||
"Token request is too large": "Запрос токена слишком велик",
|
||||
"Use an empty JSON token request": "Используйте запрос токена с пустым объектом JSON"
|
||||
}
|
||||
+4
-2
@@ -215,7 +215,7 @@ export function stripKeylessBasemapFromHash(hash) {
|
||||
}
|
||||
}
|
||||
|
||||
const TIER_DOTS = Object.freeze({ metered: '🔴', free: '🟡' });
|
||||
const TIER_DOTS = Object.freeze({ metered: '🔴', paid: '🔴', free: '🟡' });
|
||||
|
||||
function updateCloudVoiceAuthControl(auth, busy = false) {
|
||||
const mode = readStoredCloudVoiceAuthMode();
|
||||
@@ -259,7 +259,9 @@ function buildRow(documentRef, key) {
|
||||
tier.title =
|
||||
key.tier === 'metered'
|
||||
? t('Metered — a billing-enabled account')
|
||||
: t('Free key — register, paste, done');
|
||||
: key.tier === 'paid'
|
||||
? t('Optional paid token — public access works without one')
|
||||
: t('Free key — register, paste, done');
|
||||
head.append(led, title, tier);
|
||||
if (key.clientExposed) {
|
||||
const exposed = documentRef.createElement('span');
|
||||
|
||||
+16
-5
@@ -19,7 +19,7 @@ import { hasProxySignals } from './localRequestGate.mjs';
|
||||
/** Longest accepted key/token value. Real provider keys are all far shorter. */
|
||||
export const KEY_SETUP_VALUE_LIMIT = 512;
|
||||
|
||||
/** Most env vars accepted in one save. The registry defines ten. */
|
||||
/** Most env vars accepted in one save. */
|
||||
export const KEY_SETUP_UPDATE_LIMIT = 16;
|
||||
|
||||
/** Header line written above keys the panel appends to a .env file. */
|
||||
@@ -29,7 +29,8 @@ export const KEY_SETUP_APPEND_HEADER =
|
||||
/**
|
||||
* Provider credentials, in display order — most magic per
|
||||
* minute first. `tier` mirrors the README's color legend: 'metered' (🔴) is a
|
||||
* billing-enabled account, 'free' (🟡) is a register-and-paste key.
|
||||
* billing-enabled account, 'free' (🟡) is a register-and-paste key, and
|
||||
* 'paid' (🔴) is an optional paid higher-rate token.
|
||||
* `clientExposed` marks the keys that are injected into the browser
|
||||
* bundle by design (restrict them at the provider, per SECURITY.md).
|
||||
* `hidden` keeps advanced configuration out of the panel and missing-key count.
|
||||
@@ -64,6 +65,14 @@ export const KEY_SETUP_KEYS = Object.freeze([
|
||||
envVars: Object.freeze(['OPENAI_API_KEY']),
|
||||
tier: 'metered',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'gemini',
|
||||
title: 'GEMINI', // i18n-ignore: provider product name
|
||||
unlocks: msg('Gemini Live voice control'),
|
||||
getUrl: 'https://aistudio.google.com/apikey',
|
||||
envVars: Object.freeze(['GEMINI_API_KEY']),
|
||||
tier: 'metered',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'aisstream',
|
||||
title: 'AISSTREAM', // i18n-ignore: provider product name
|
||||
@@ -119,10 +128,12 @@ export const KEY_SETUP_KEYS = Object.freeze([
|
||||
Object.freeze({
|
||||
id: 'launch-library',
|
||||
title: 'LAUNCH LIBRARY', // i18n-ignore: provider product name
|
||||
unlocks: msg('Higher space-missions request allowance'),
|
||||
getUrl: 'https://thespacedevs.com',
|
||||
unlocks: msg(
|
||||
'Public space-missions access needs no key. Leave blank for standard use; paid tokens only raise request limits.',
|
||||
),
|
||||
getUrl: 'https://thespacedevs.com/supportus',
|
||||
envVars: Object.freeze(['LL2_API_TOKEN']),
|
||||
tier: 'free',
|
||||
tier: 'paid',
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
+44
-10
@@ -3,6 +3,7 @@ import {
|
||||
FRAME_SIGNATURE_H,
|
||||
PROJECTION_CANVAS_WIDTH,
|
||||
PROJECTION_CANVAS_HEIGHT,
|
||||
PROJECTION_MATTE_COLOR,
|
||||
PROJECTION_TEXTURE_SWAP_MS,
|
||||
ACTIVE_FRAME_REFRESH_MS,
|
||||
FRAME_ENDPOINT,
|
||||
@@ -12,6 +13,37 @@ import {
|
||||
PLACEHOLDER_REPAINT_MS,
|
||||
} from './policy.js';
|
||||
|
||||
/**
|
||||
* Contain-fits a source frame into the projection canvas: scaled to the
|
||||
* largest size that fits, centered, with the margins left for the matte fill.
|
||||
* Falls back to the full canvas when either dimension is missing or zero.
|
||||
*
|
||||
* @param {number} sourceW - Source width in pixels.
|
||||
* @param {number} sourceH - Source height in pixels.
|
||||
* @returns {{x:number,y:number,w:number,h:number}} Draw rectangle in canvas pixels.
|
||||
*/
|
||||
export function fitToProjection(sourceW, sourceH) {
|
||||
if (!sourceW || !sourceH)
|
||||
return {
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: PROJECTION_CANVAS_WIDTH,
|
||||
h: PROJECTION_CANVAS_HEIGHT,
|
||||
};
|
||||
const scale = Math.min(
|
||||
PROJECTION_CANVAS_WIDTH / sourceW,
|
||||
PROJECTION_CANVAS_HEIGHT / sourceH,
|
||||
);
|
||||
const w = Math.round(sourceW * scale);
|
||||
const h = Math.round(sourceH * scale);
|
||||
return {
|
||||
x: Math.round((PROJECTION_CANVAS_WIDTH - w) / 2),
|
||||
y: Math.round((PROJECTION_CANVAS_HEIGHT - h) / 2),
|
||||
w,
|
||||
h,
|
||||
};
|
||||
}
|
||||
|
||||
export function createFrames({ state: layerState, services, parts, source }) {
|
||||
/**
|
||||
* FNV-1a over the RGB channels of a downsampled frame. Pure (takes the raw
|
||||
@@ -182,7 +214,7 @@ export function createFrames({ state: layerState, services, parts, source }) {
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
const g = ctx.createLinearGradient(0, 0, w, h);
|
||||
g.addColorStop(0, '#05111a');
|
||||
g.addColorStop(1, '#01070c');
|
||||
g.addColorStop(1, PROJECTION_MATTE_COLOR);
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
@@ -320,7 +352,14 @@ export function createFrames({ state: layerState, services, parts, source }) {
|
||||
// base color (planeMaterial color = WHITE, alpha .95) for the frame or
|
||||
// two Cesium needs to rebind, which IS the periodic white flash from the
|
||||
// owner field tests (2026-07-04 and 2026-07-30).
|
||||
const signature = projectionFrameSignature(runtime);
|
||||
const fit = fitToProjection(
|
||||
runtime.image.naturalWidth,
|
||||
runtime.image.naturalHeight,
|
||||
);
|
||||
// Pixels are hashed at a fixed size, so the fit carries the aspect.
|
||||
const pixels = projectionFrameSignature(runtime);
|
||||
const signature =
|
||||
pixels === null ? null : `${pixels}:${fit.w}x${fit.h}`;
|
||||
runtime.drawnImageStamp = runtime.imageStamp;
|
||||
if (signature !== null && signature === runtime.lastFrameSignature) {
|
||||
// Identical pixels — leave the canvas, and therefore the bound
|
||||
@@ -328,19 +367,14 @@ export function createFrames({ state: layerState, services, parts, source }) {
|
||||
return;
|
||||
}
|
||||
runtime.lastFrameSignature = signature;
|
||||
runtime.ctx.clearRect(
|
||||
0,
|
||||
0,
|
||||
PROJECTION_CANVAS_WIDTH,
|
||||
PROJECTION_CANVAS_HEIGHT,
|
||||
);
|
||||
runtime.ctx.drawImage(
|
||||
runtime.image,
|
||||
runtime.ctx.fillStyle = PROJECTION_MATTE_COLOR;
|
||||
runtime.ctx.fillRect(
|
||||
0,
|
||||
0,
|
||||
PROJECTION_CANVAS_WIDTH,
|
||||
PROJECTION_CANVAS_HEIGHT,
|
||||
);
|
||||
runtime.ctx.drawImage(runtime.image, fit.x, fit.y, fit.w, fit.h);
|
||||
runtime.canvasStamp = (runtime.canvasStamp || 0) + 1;
|
||||
runtime.lastPlaceholderPaintAt = 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
// src/layers/cctv/frames.test.mjs
|
||||
// Still-frame fit (#1201): stills draw at their source aspect, centered on the
|
||||
// 1920x1080 projection canvas, instead of being stretched to fill it.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createFrames, fitToProjection } from './frames.js';
|
||||
import {
|
||||
FRAME_SIGNATURE_W,
|
||||
FRAME_SIGNATURE_H,
|
||||
PROJECTION_CANVAS_WIDTH,
|
||||
PROJECTION_CANVAS_HEIGHT,
|
||||
PROJECTION_MATTE_COLOR,
|
||||
} from './policy.js';
|
||||
|
||||
// The expected rectangles below are written for a 1920x1080 canvas.
|
||||
assert.equal(PROJECTION_CANVAS_WIDTH, 1920);
|
||||
assert.equal(PROJECTION_CANVAS_HEIGHT, 1080);
|
||||
|
||||
const FULL_CANVAS = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: PROJECTION_CANVAS_WIDTH,
|
||||
h: PROJECTION_CANVAS_HEIGHT,
|
||||
};
|
||||
|
||||
test('a 4:3 still is pillarboxed at full height', () => {
|
||||
assert.deepEqual(fitToProjection(640, 480), {
|
||||
x: 240,
|
||||
y: 0,
|
||||
w: 1440,
|
||||
h: 1080,
|
||||
});
|
||||
});
|
||||
|
||||
test('a source wider than 16:9 is letterboxed at full width', () => {
|
||||
assert.deepEqual(fitToProjection(3840, 1080), {
|
||||
x: 0,
|
||||
y: 270,
|
||||
w: 1920,
|
||||
h: 540,
|
||||
});
|
||||
});
|
||||
|
||||
test('a 16:9 still fills the canvas', () => {
|
||||
assert.deepEqual(fitToProjection(1920, 1080), FULL_CANVAS);
|
||||
});
|
||||
|
||||
test('a missing or zero dimension falls back to the full canvas', () => {
|
||||
assert.deepEqual(fitToProjection(0, 480), FULL_CANVAS);
|
||||
assert.deepEqual(fitToProjection(640, 0), FULL_CANVAS);
|
||||
assert.deepEqual(fitToProjection(640, undefined), FULL_CANVAS);
|
||||
assert.deepEqual(fitToProjection(undefined, undefined), FULL_CANVAS);
|
||||
});
|
||||
|
||||
// Draw path: the fit reaches drawImage, and the unchanged-frame skip keys on the
|
||||
// fitted size as well as the pixels (the 64x36 hash alone drops aspect).
|
||||
function stillHarness() {
|
||||
const calls = [];
|
||||
const ctx = {
|
||||
fillStyle: null,
|
||||
fillRect: (...rect) => calls.push(['fillRect', ctx.fillStyle, ...rect]),
|
||||
clearRect: () => {},
|
||||
drawImage: (_image, ...rect) => calls.push(['drawImage', ...rect]),
|
||||
};
|
||||
// Every frame downsamples to the same pixels, so only the size differs.
|
||||
const pixels = new Uint8ClampedArray(
|
||||
FRAME_SIGNATURE_W * FRAME_SIGNATURE_H * 4,
|
||||
).fill(7);
|
||||
const runtime = {
|
||||
mode: 'image',
|
||||
ctx,
|
||||
image: { naturalWidth: 0, naturalHeight: 0 },
|
||||
imageReady: true,
|
||||
// In flight, so refreshProjectionImage returns before touching image.src.
|
||||
imageLoading: true,
|
||||
imageStamp: 0,
|
||||
drawnImageStamp: -1,
|
||||
lastFrameSignature: null,
|
||||
canvasStamp: 0,
|
||||
signatureCtx: {
|
||||
clearRect: () => {},
|
||||
drawImage: () => {},
|
||||
getImageData: () => ({ data: pixels }),
|
||||
},
|
||||
};
|
||||
const record = { camera: { id: 'still' }, projection: runtime };
|
||||
const { drawProjectionFrame } = createFrames({
|
||||
state: { _healthById: new Map(), _activeCameraId: null },
|
||||
services: {},
|
||||
parts: {},
|
||||
source: {},
|
||||
});
|
||||
const load = (width, height) => {
|
||||
runtime.image = { naturalWidth: width, naturalHeight: height };
|
||||
runtime.imageStamp += 1;
|
||||
calls.length = 0;
|
||||
drawProjectionFrame(record);
|
||||
return calls.slice();
|
||||
};
|
||||
return { runtime, load };
|
||||
}
|
||||
|
||||
test('a still is drawn into its fitted rectangle over a full matte', () => {
|
||||
const { runtime, load } = stillHarness();
|
||||
assert.deepEqual(load(640, 480), [
|
||||
[
|
||||
'fillRect',
|
||||
PROJECTION_MATTE_COLOR,
|
||||
0,
|
||||
0,
|
||||
PROJECTION_CANVAS_WIDTH,
|
||||
PROJECTION_CANVAS_HEIGHT,
|
||||
],
|
||||
['drawImage', 240, 0, 1440, 1080],
|
||||
]);
|
||||
assert.equal(runtime.canvasStamp, 1);
|
||||
});
|
||||
|
||||
test('same pixels at the same size skip the redraw', () => {
|
||||
const { runtime, load } = stillHarness();
|
||||
load(640, 480);
|
||||
assert.deepEqual(load(640, 480), []);
|
||||
assert.equal(runtime.canvasStamp, 1);
|
||||
});
|
||||
|
||||
test('same pixels at a new aspect redraw at the new fit', () => {
|
||||
const { runtime, load } = stillHarness();
|
||||
load(640, 480);
|
||||
assert.deepEqual(load(1920, 1080), [
|
||||
[
|
||||
'fillRect',
|
||||
PROJECTION_MATTE_COLOR,
|
||||
0,
|
||||
0,
|
||||
PROJECTION_CANVAS_WIDTH,
|
||||
PROJECTION_CANVAS_HEIGHT,
|
||||
],
|
||||
['drawImage', 0, 0, 1920, 1080],
|
||||
]);
|
||||
assert.equal(runtime.canvasStamp, 2);
|
||||
});
|
||||
|
||||
test('same pixels at a new resolution with the same fit skip the redraw', () => {
|
||||
const { runtime, load } = stillHarness();
|
||||
load(640, 480);
|
||||
assert.deepEqual(load(1280, 960), []);
|
||||
assert.equal(runtime.canvasStamp, 1);
|
||||
assert.equal(runtime.drawnImageStamp, 2);
|
||||
});
|
||||
|
||||
test('changed pixels redraw even when the fitted rectangle is unchanged', () => {
|
||||
const { runtime, load } = stillHarness();
|
||||
load(640, 480);
|
||||
const changedPixels = new Uint8ClampedArray(
|
||||
FRAME_SIGNATURE_W * FRAME_SIGNATURE_H * 4,
|
||||
).fill(90);
|
||||
runtime.signatureCtx.getImageData = () => ({ data: changedPixels });
|
||||
const calls = load(640, 480);
|
||||
assert.equal(calls[0][0], 'fillRect');
|
||||
assert.deepEqual(calls[1], ['drawImage', 240, 0, 1440, 1080]);
|
||||
assert.equal(runtime.canvasStamp, 2);
|
||||
});
|
||||
|
||||
test('a failed pixel signature redraws rather than assuming equal content', () => {
|
||||
const { runtime, load } = stillHarness();
|
||||
load(640, 480);
|
||||
runtime.signatureCtx.getImageData = () => {
|
||||
throw new Error('tainted canvas');
|
||||
};
|
||||
assert.deepEqual(load(640, 480).at(-1), ['drawImage', 240, 0, 1440, 1080]);
|
||||
assert.equal(runtime.canvasStamp, 2);
|
||||
});
|
||||
@@ -35,6 +35,9 @@ export const PROJECTION_CANVAS_WIDTH = 1920;
|
||||
|
||||
export const PROJECTION_CANVAS_HEIGHT = 1080;
|
||||
|
||||
/** Matte behind fitted stills; the placeholder gradient's dark end. */
|
||||
export const PROJECTION_MATTE_COLOR = '#01070c';
|
||||
|
||||
// Downsample grid for the unchanged-frame signature (drawProjectionFrame).
|
||||
// 64x36 keeps the 16:9 aspect and reads ~9 KB per check versus the 8.3 MB a
|
||||
// full-resolution compare would touch.
|
||||
@@ -410,6 +413,9 @@ export const IDLE_CAMERA_COLOR =
|
||||
export const ACTIVE_CAMERA_COLOR =
|
||||
Cesium.Color.fromCssColorString('#ffd97a').withAlpha(0.95);
|
||||
|
||||
export const LIVE_CAMERA_COLOR =
|
||||
Cesium.Color.fromCssColorString('#b48cff').withAlpha(0.88);
|
||||
|
||||
export const IDLE_COVERAGE_COLOR =
|
||||
Cesium.Color.fromCssColorString('#2fe0ff').withAlpha(0.24);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { isHeadingEstimated } from './headingConfidence.js';
|
||||
import {
|
||||
ACTIVE_CAMERA_COLOR,
|
||||
IDLE_CAMERA_COLOR,
|
||||
LIVE_CAMERA_COLOR,
|
||||
ACTIVE_COVERAGE_CENTER,
|
||||
IDLE_COVERAGE_CENTER_MUTED,
|
||||
ACTIVE_COVERAGE_CENTER_DEPTHFAIL,
|
||||
@@ -18,6 +19,15 @@ export function createRendering({
|
||||
parts,
|
||||
source,
|
||||
}) {
|
||||
/** Icon color: active wins, then live-capable video feeds, then idle stills. */
|
||||
function cameraColorFor(record) {
|
||||
if (record.camera.id === layerState._activeCameraId)
|
||||
return ACTIVE_CAMERA_COLOR;
|
||||
if (parts.model.isVideoFeedType(record.camera.feedType))
|
||||
return LIVE_CAMERA_COLOR;
|
||||
return IDLE_CAMERA_COLOR;
|
||||
}
|
||||
|
||||
const {
|
||||
focusNowMs,
|
||||
getFocusTarget,
|
||||
@@ -58,10 +68,7 @@ export function createRendering({
|
||||
),
|
||||
cameraDistanceFor: (position) =>
|
||||
Cesium.Cartesian3.distance(camera.positionWC, position),
|
||||
baseColorFor: (record) =>
|
||||
record.camera.id === layerState._activeCameraId
|
||||
? ACTIVE_CAMERA_COLOR
|
||||
: IDLE_CAMERA_COLOR,
|
||||
baseColorFor: cameraColorFor,
|
||||
});
|
||||
layerState._activeFocusStyleCount = result.activeCount;
|
||||
}
|
||||
@@ -208,9 +215,7 @@ export function createRendering({
|
||||
for (const record of layerState._records) {
|
||||
const isActive = record.camera.id === activeId;
|
||||
if (record.billboard) {
|
||||
record.billboard.color = isActive
|
||||
? ACTIVE_CAMERA_COLOR
|
||||
: IDLE_CAMERA_COLOR;
|
||||
record.billboard.color = cameraColorFor(record);
|
||||
record.billboard.scale = isActive ? 1.25 : 1.0;
|
||||
// disableDepthTestDistance stays POSITIVE_INFINITY for every billboard
|
||||
// (set at creation) — see the field-test far-zoom submerge fix there.
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
// src/layers/cctv/rendering.test.mjs
|
||||
// Icon colors under focus de-emphasis (#1200): the focus pass fades each icon
|
||||
// from the same base color the coverage refresh assigns, so a video-feed camera
|
||||
// stays violet while it dims instead of falling back to the idle cyan.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRendering } from './rendering.js';
|
||||
import {
|
||||
ACTIVE_CAMERA_COLOR,
|
||||
IDLE_CAMERA_COLOR,
|
||||
LIVE_CAMERA_COLOR,
|
||||
} from './policy.js';
|
||||
import { isVideoFeedType } from '../../sources/cctvTypes.js';
|
||||
|
||||
const FOCUS_FACTOR = 0.5;
|
||||
|
||||
function assertFadedFrom(actual, base, label) {
|
||||
assert.ok(actual, `${label}: color written`);
|
||||
assert.deepEqual(
|
||||
[actual.red, actual.green, actual.blue],
|
||||
[base.red, base.green, base.blue],
|
||||
`${label}: hue`,
|
||||
);
|
||||
assert.equal(actual.alpha, base.alpha * FOCUS_FACTOR, `${label}: alpha`);
|
||||
}
|
||||
|
||||
test('focus de-emphasis fades each icon from its own color', () => {
|
||||
const record = (id, feedType) => ({
|
||||
camera: { id, feedType },
|
||||
// No position, so the pass skips screen projection; the focus stub below
|
||||
// decides the factor on its own.
|
||||
billboard: { show: true },
|
||||
});
|
||||
const idleVideo = record('idle-hls', 'hls');
|
||||
const idleStill = record('idle-image', 'image');
|
||||
const activeVideo = record('active-hls', 'hls');
|
||||
const state = {
|
||||
_enabled: true,
|
||||
_viewer: { scene: {}, camera: {} },
|
||||
_records: [idleVideo, idleStill, activeVideo],
|
||||
_activeCameraId: activeVideo.camera.id,
|
||||
_activeFocusStyleCount: 0,
|
||||
_lastFocusStyleAt: -Infinity,
|
||||
};
|
||||
const focus = {
|
||||
focusNowMs: (nowMs) => nowMs,
|
||||
getFocusTarget: () => ({}),
|
||||
focusPassIsNeeded: () => true,
|
||||
advanceSpriteFocus: () => ({
|
||||
factor: FOCUS_FACTOR,
|
||||
active: true,
|
||||
transitioning: false,
|
||||
}),
|
||||
focusAlphaNeedsWrite: () => true,
|
||||
};
|
||||
const { refreshCctvFocusStyles } = createRendering({
|
||||
state,
|
||||
services: { focus },
|
||||
parts: { model: { isVideoFeedType } },
|
||||
source: {},
|
||||
});
|
||||
|
||||
refreshCctvFocusStyles(1000);
|
||||
|
||||
assertFadedFrom(idleVideo.billboard.color, LIVE_CAMERA_COLOR, 'idle video');
|
||||
assertFadedFrom(idleStill.billboard.color, IDLE_CAMERA_COLOR, 'idle still');
|
||||
assertFadedFrom(
|
||||
activeVideo.billboard.color,
|
||||
ACTIVE_CAMERA_COLOR,
|
||||
'active video',
|
||||
);
|
||||
assert.equal(state._activeFocusStyleCount, 3);
|
||||
});
|
||||
|
||||
test('focus release restores video hue and alpha after the style throttle', () => {
|
||||
let target = {};
|
||||
const records = ['mp4', 'webm'].map((feedType) => ({
|
||||
camera: { id: feedType, feedType },
|
||||
billboard: { show: true, color: LIVE_CAMERA_COLOR },
|
||||
}));
|
||||
const state = {
|
||||
_enabled: true,
|
||||
_viewer: { scene: {}, camera: {} },
|
||||
_records: records,
|
||||
_activeCameraId: null,
|
||||
_activeFocusStyleCount: 0,
|
||||
_lastFocusStyleAt: -Infinity,
|
||||
};
|
||||
const focus = {
|
||||
focusNowMs: (nowMs) => nowMs,
|
||||
getFocusTarget: () => target,
|
||||
focusPassIsNeeded: (value, count) => !!value || count > 0,
|
||||
advanceSpriteFocus: () => ({
|
||||
factor: target ? FOCUS_FACTOR : 1,
|
||||
active: !!target,
|
||||
transitioning: false,
|
||||
}),
|
||||
focusAlphaNeedsWrite: (before, after) => before !== after,
|
||||
};
|
||||
const { refreshCctvFocusStyles } = createRendering({
|
||||
state,
|
||||
services: { focus },
|
||||
parts: { model: { isVideoFeedType } },
|
||||
source: {},
|
||||
});
|
||||
|
||||
refreshCctvFocusStyles(1000);
|
||||
for (const record of records) {
|
||||
assertFadedFrom(
|
||||
record.billboard.color,
|
||||
LIVE_CAMERA_COLOR,
|
||||
record.camera.id,
|
||||
);
|
||||
}
|
||||
const dimmed = records.map((record) => record.billboard.color);
|
||||
target = null;
|
||||
refreshCctvFocusStyles(1050);
|
||||
for (let i = 0; i < records.length; i += 1) {
|
||||
assert.equal(records[i].billboard.color, dimmed[i]);
|
||||
}
|
||||
refreshCctvFocusStyles(1100);
|
||||
for (const record of records) {
|
||||
assert.deepEqual(record.billboard.color, LIVE_CAMERA_COLOR);
|
||||
}
|
||||
assert.equal(state._activeFocusStyleCount, 0);
|
||||
});
|
||||
Vendored
+1
@@ -57,6 +57,7 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
orbitMatches: layerState._orbitMatches,
|
||||
lastUpdate: layerState._lastUpdate,
|
||||
error: layerState._lastError,
|
||||
stale: layerState._stale,
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -168,9 +168,16 @@ export function createIngestion({
|
||||
async function performMissionUpdate(token) {
|
||||
try {
|
||||
ensureActiveTleLookup(token);
|
||||
const payload = await source.getLaunches({
|
||||
signal: layerState._sourceController.signal,
|
||||
});
|
||||
const { payload, stale } = source.getLaunchSnapshot
|
||||
? await source.getLaunchSnapshot({
|
||||
signal: layerState._sourceController.signal,
|
||||
})
|
||||
: {
|
||||
payload: await source.getLaunches({
|
||||
signal: layerState._sourceController.signal,
|
||||
}),
|
||||
stale: false,
|
||||
};
|
||||
const launches = parts.model.normalizeRocketLaunches(payload);
|
||||
if (
|
||||
!layerState._enabled ||
|
||||
@@ -214,6 +221,7 @@ export function createIngestion({
|
||||
parts.panel.renderMissionPanel();
|
||||
layerState._lastUpdate = Date.now();
|
||||
layerState._lastError = null;
|
||||
layerState._stale = stale === true;
|
||||
} catch (error) {
|
||||
if (layerState._enabled && token === layerState._lifecycleToken) {
|
||||
layerState._lastError = error.message;
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import http from 'node:http';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createLaunchSource } from './source.js';
|
||||
import { createState } from './state.js';
|
||||
import { createModel } from './model.js';
|
||||
import { createIngestion } from './ingestion.js';
|
||||
import { createControls } from './controls.js';
|
||||
import { createLifecycle } from './lifecycle.js';
|
||||
import { layerFeedState } from '../../data/feedState.js';
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
async function fixture({ legacySource = false } = {}) {
|
||||
const launch = {
|
||||
id: 'owned-launch',
|
||||
name: 'Owned mission fixture',
|
||||
net: new Date(Date.now() - 3600000).toISOString(),
|
||||
status: { name: 'Launch Successful' },
|
||||
pad: { name: 'Owned pad', latitude: 28.6, longitude: -80.6 },
|
||||
rocket: { payloads: [] },
|
||||
};
|
||||
const mode = { cache: 'HIT', results: [launch], hold: false };
|
||||
let arrival;
|
||||
let release;
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.url === '/api/celestrak/active') {
|
||||
res.end('');
|
||||
return;
|
||||
}
|
||||
assert.equal(req.url, '/api/launches');
|
||||
const send = () => {
|
||||
if (res.destroyed) return;
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'X-GEV-Cache': mode.cache,
|
||||
});
|
||||
res.end(JSON.stringify({ results: mode.results }));
|
||||
};
|
||||
if (mode.hold) {
|
||||
release = send;
|
||||
arrival?.();
|
||||
} else send();
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const origin = `http://127.0.0.1:${server.address().port}`;
|
||||
const launchSource = createLaunchSource({
|
||||
fetchImpl: (url, options) => {
|
||||
assert.ok(['/api/launches', '/api/celestrak/active'].includes(url));
|
||||
return fetch(new URL(url, origin), options);
|
||||
},
|
||||
});
|
||||
const source = legacySource
|
||||
? {
|
||||
getLaunches: launchSource.getLaunches,
|
||||
getActiveTle: launchSource.getActiveTle,
|
||||
}
|
||||
: launchSource;
|
||||
const services = {
|
||||
overlays: {
|
||||
setOverlayEntries: noop,
|
||||
setOverlaySourceVisible: noop,
|
||||
clearOverlaySource: noop,
|
||||
},
|
||||
render: { holdContinuousRender: noop, releaseContinuousRender: noop },
|
||||
};
|
||||
const state = createState({ services });
|
||||
state._enabled = true;
|
||||
state._lifecycleToken = 1;
|
||||
state._dataSource = new Cesium.CustomDataSource('owned-qualification');
|
||||
const canvas = Object.assign(new EventTarget(), {
|
||||
disableRootEvents: true,
|
||||
onwheel: null,
|
||||
style: {},
|
||||
});
|
||||
const viewer = {
|
||||
dataSources: new Cesium.DataSourceCollection(),
|
||||
scene: { canvas, preRender: new Cesium.Event(), drillPick: () => [] },
|
||||
};
|
||||
await viewer.dataSources.add(state._dataSource);
|
||||
// Only presentation boundaries are inert. Source, normalization, state,
|
||||
// ingestion, lifecycle, stats and feed-state policy execute unchanged.
|
||||
const parts = {
|
||||
panel: {
|
||||
createMissionPanel: noop,
|
||||
renderMissionRoster: noop,
|
||||
renderMissionPanel: noop,
|
||||
clearMissionRosterHover: noop,
|
||||
},
|
||||
replay: { stopMissionReplay: noop },
|
||||
selection: {
|
||||
setSelectedMission: noop,
|
||||
focusMission: noop,
|
||||
stopMissionZoomAnchor: noop,
|
||||
},
|
||||
orbitRendering: {
|
||||
removeMissionOrbitPrimitives: noop,
|
||||
syncMissionOrbitPrimitiveVisibility: noop,
|
||||
},
|
||||
overlays: {
|
||||
createReplayVehicleOverlay: noop,
|
||||
clearMissionOverlaySources: noop,
|
||||
hideReplayVehicleOverlay: noop,
|
||||
destroyReplayVehicleOverlay: noop,
|
||||
},
|
||||
launchPad: {
|
||||
initLaunchPadZonePrimitive: noop,
|
||||
hideLaunchPadZone: noop,
|
||||
destroyLaunchPadZonePrimitive: noop,
|
||||
},
|
||||
rendering: {
|
||||
updateMissionFrame: noop,
|
||||
addLaunchEntity(record) {
|
||||
state._dataSource.entities.add({
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
position: Cesium.Cartesian3.fromDegrees(record.lon, record.lat),
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
const context = { state, services, parts, source };
|
||||
parts.model = createModel(context);
|
||||
parts.ingestion = createIngestion(context);
|
||||
parts.controls = createControls(context);
|
||||
parts.lifecycle = createLifecycle(context);
|
||||
const current = () => {
|
||||
const stats = parts.controls.methods.getStats();
|
||||
return {
|
||||
stats,
|
||||
feed: layerFeedState(stats),
|
||||
rendered: state._dataSource?.entities.values.map((x) => x.id) || [],
|
||||
enabled: state._enabled,
|
||||
};
|
||||
};
|
||||
return {
|
||||
mode,
|
||||
state,
|
||||
source,
|
||||
current,
|
||||
viewer,
|
||||
init: () => parts.lifecycle.methods.init(viewer),
|
||||
update: () => parts.ingestion.methods.update(),
|
||||
disable: () => parts.lifecycle.methods.disable(),
|
||||
destroy: () => parts.lifecycle.methods.destroy(viewer),
|
||||
holdArrival: () =>
|
||||
new Promise((resolve) => {
|
||||
arrival = resolve;
|
||||
}),
|
||||
release: () => release?.(),
|
||||
async close() {
|
||||
await parts.lifecycle.methods.destroy(viewer);
|
||||
server.closeAllConnections();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('fresh launches remain nominal through ingestion and the layer stats', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
await f.update();
|
||||
const value = f.current();
|
||||
assert.equal(value.feed, 'nominal');
|
||||
assert.equal(value.stats.count, 1);
|
||||
assert.deepEqual(value.rendered, ['owned-launch']);
|
||||
assert.equal(value.stats.error, null);
|
||||
assert.ok(value.stats.lastUpdate);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('STALE-ERROR provenance reaches the layer feed state without dropping records', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
f.mode.cache = 'STALE-ERROR';
|
||||
assert.equal((await f.source.getLaunchSnapshot()).stale, true);
|
||||
await f.update();
|
||||
const value = f.current();
|
||||
assert.equal(value.stats.count, 1);
|
||||
assert.deepEqual(value.rendered, ['owned-launch']);
|
||||
assert.equal(value.feed, 'stale');
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('a fresh recovery clears stale launch provenance', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
f.mode.cache = 'STALE-ERROR';
|
||||
await f.update();
|
||||
const stale = f.current();
|
||||
f.mode.cache = 'MISS';
|
||||
await f.update();
|
||||
const recovered = f.current();
|
||||
assert.equal(recovered.feed, 'nominal');
|
||||
assert.equal(recovered.stats.count, 1);
|
||||
assert.equal(recovered.stats.error, null);
|
||||
assert.equal(stale.feed, 'stale');
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('an empty stale snapshot still identifies its fallback provenance', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
f.mode.cache = 'STALE-ERROR';
|
||||
f.mode.results = [];
|
||||
await f.update();
|
||||
assert.equal(f.current().stats.count, 0);
|
||||
assert.equal(f.current().feed, 'stale');
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('getLaunches-only injected sources remain supported', async () => {
|
||||
const f = await fixture({ legacySource: true });
|
||||
try {
|
||||
await f.update();
|
||||
assert.equal(f.current().feed, 'nominal');
|
||||
assert.deepEqual(f.current().rendered, ['owned-launch']);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('disable cancels a pending stale response without committing it', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
await f.update();
|
||||
const healthy = f.current();
|
||||
f.mode.cache = 'STALE-ERROR';
|
||||
f.mode.hold = true;
|
||||
const arrived = f.holdArrival();
|
||||
const pending = f.update();
|
||||
await arrived;
|
||||
await f.disable();
|
||||
f.release();
|
||||
await pending;
|
||||
const value = f.current();
|
||||
assert.equal(value.enabled, false);
|
||||
assert.equal(value.stats.lastUpdate, healthy.stats.lastUpdate);
|
||||
assert.equal(value.feed, 'nominal');
|
||||
assert.equal(f.state._sourceController.signal.aborted, true);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('hiding retained stale records does not relabel them as fresh', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
f.mode.cache = 'STALE-ERROR';
|
||||
await f.update();
|
||||
await f.disable();
|
||||
assert.equal(f.current().feed, 'stale');
|
||||
assert.equal(f.current().stats.count, 1);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('destroy clears the previous feed state and records', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
f.mode.cache = 'STALE-ERROR';
|
||||
await f.update();
|
||||
await f.destroy();
|
||||
const value = f.current();
|
||||
assert.equal(value.stats.count, 0);
|
||||
assert.equal(value.stats.lastUpdate, null);
|
||||
assert.equal(value.feed, 'nominal');
|
||||
assert.equal(value.enabled, false);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('destroy followed by actual init starts a clean instance with owned handlers', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
f.mode.cache = 'STALE-ERROR';
|
||||
await f.update();
|
||||
await f.destroy();
|
||||
await f.init();
|
||||
const value = f.current();
|
||||
assert.equal(value.feed, 'nominal');
|
||||
assert.equal(value.stats.count, 0);
|
||||
assert.equal(value.stats.lastUpdate, null);
|
||||
assert.equal(value.stats.error, null);
|
||||
assert.equal(value.enabled, false);
|
||||
assert.equal(f.viewer.dataSources.length, 1);
|
||||
assert.equal(f.viewer.scene.preRender.numberOfListeners, 1);
|
||||
const click = f.state._clickHandler;
|
||||
const move = f.state._moveHandler;
|
||||
assert.ok(click instanceof Cesium.ScreenSpaceEventHandler);
|
||||
assert.ok(move instanceof Cesium.ScreenSpaceEventHandler);
|
||||
assert.equal(
|
||||
typeof click.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK),
|
||||
'function',
|
||||
);
|
||||
await f.destroy();
|
||||
assert.equal(click.isDestroyed(), true);
|
||||
assert.equal(move.isDestroyed(), true);
|
||||
assert.equal(f.viewer.dataSources.length, 0);
|
||||
assert.equal(f.viewer.scene.preRender.numberOfListeners, 0);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
@@ -20,6 +20,7 @@ export function createLifecycle({
|
||||
viewer.dataSources.add(layerState._dataSource);
|
||||
layerState._count = 0;
|
||||
layerState._lastUpdate = null;
|
||||
layerState._stale = false;
|
||||
layerState._lastError = null;
|
||||
layerState._orbitMatches = 0;
|
||||
parts.overlays.clearMissionOverlaySources();
|
||||
@@ -160,6 +161,7 @@ export function createLifecycle({
|
||||
layerState._count = 0;
|
||||
layerState._orbitMatches = 0;
|
||||
layerState._lastUpdate = null;
|
||||
layerState._stale = false;
|
||||
layerState._activeTleText = null;
|
||||
layerState._activeTlePromise = null;
|
||||
layerState._activeTlePromiseToken = 0;
|
||||
|
||||
@@ -19,6 +19,8 @@ export function createState({ services }) {
|
||||
|
||||
state._lastError = null;
|
||||
|
||||
state._stale = false;
|
||||
|
||||
state._orbitMatches = 0;
|
||||
|
||||
state._clickHandler = null;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join, relative } from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { TRACKABLE_FAMILIES } from './agent/worldPacket.js';
|
||||
import { setLocale } from './i18n/index.js';
|
||||
import {
|
||||
COCKPIT_REFUSAL_NOUNS,
|
||||
@@ -68,10 +69,8 @@ const NOUN_LITERAL =
|
||||
/\bnoun\s*=\s*'([^']+)'|\bconst noun\s*=\s*[^?;]*\?\s*'([^']+)'\s*:\s*'([^']+)'/g;
|
||||
|
||||
function trackableKinds() {
|
||||
const source = readFileSync(join(SRC, 'voice/gevActions.js'), 'utf8');
|
||||
const block = source.match(/const TRACKABLE_FAMILIES = \[([\s\S]*?)\];/);
|
||||
assert.ok(block, 'TRACKABLE_FAMILIES is still in gevActions.js');
|
||||
return [...block[1].matchAll(/kind:\s*'([^']+)'/g)].map((m) => m[1]);
|
||||
// This is shared with voice; test the exported owner, not its file position.
|
||||
return TRACKABLE_FAMILIES.map(({ kind }) => kind);
|
||||
}
|
||||
|
||||
// Non-literal arguments: what each one can be. A wrapper's own `noun` (or all
|
||||
|
||||
@@ -4,6 +4,7 @@ import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { parseTerrainPoints } from './data/terrainHeightsProxy.js';
|
||||
import { readResponseTextCapped, coalesceProxyRequest } from './sources/httpBody.js';
|
||||
import { makeRateLimiter, clientKey } from '../server/providers/common/rate-limit.js';
|
||||
|
||||
const source = ['local.js', 'common/http.js', 'aircraft/enrichment.js', 'terrain.js', 'space/celestrak.js', 'space/launch-library.js', '../../src/data/spaceProviderRequests.js']
|
||||
.map(file => readFileSync(new URL(`../server/providers/${file}`, import.meta.url), 'utf8'))
|
||||
@@ -23,6 +24,12 @@ function fixture(name, overrides = {}, preview = false) {
|
||||
const logs = [];
|
||||
const deps = {
|
||||
readResponseTextCapped, coalesceProxyRequest,
|
||||
// The rate-limited proxies reach for these; give them the real ones so a
|
||||
// fixture request is throttled exactly as production would be.
|
||||
makeRateLimiter, clientKey,
|
||||
// terrain.js gates cross-site requests first; fixture requests carry no
|
||||
// browser headers, so admit them like a loopback tool.
|
||||
admitSameSiteKeyless: () => false,
|
||||
path, process: { cwd: () => '/fixture', env: {} },
|
||||
fsp: {
|
||||
readFile: async () => { throw new Error('cache absent'); },
|
||||
|
||||
@@ -17,6 +17,10 @@ export function createStandaloneTools(options) {
|
||||
},
|
||||
},
|
||||
...options,
|
||||
voice: { toolCatalog: loadToolCatalog, ...options?.voice },
|
||||
voice: {
|
||||
supportedProviders: ['openai', 'gemini'],
|
||||
toolCatalog: loadToolCatalog,
|
||||
...options?.voice,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
// TERRAIN HEIGHTS — the bounds on a cache keyed by caller-chosen coordinates.
|
||||
//
|
||||
// Every distinct 5dp lon/lat is its own cache key, its own permanent line in
|
||||
// `.gev-cache/terrain-heights.json`, and its own upstream call. Four things
|
||||
// bound that: WGS-84 range at parse, an entry ceiling on the map, a size
|
||||
// ceiling on the file that is read back, and a per-client request limiter.
|
||||
//
|
||||
// These cases drive the real exported route handler. `fetch` is stubbed per
|
||||
// test and restored after, so a case that reaches upstream fails loudly.
|
||||
//
|
||||
// Run with: npm test
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { parseTerrainPoints } from './data/terrainHeightsProxy.js';
|
||||
import { terrainHeightsProxy } from '../server/providers/terrain.js';
|
||||
import { admitSameSite } from '../server/providers/common/same-site.js';
|
||||
|
||||
function install(plugin) {
|
||||
const routes = new Map();
|
||||
plugin.configureServer({
|
||||
middlewares: {
|
||||
use(path, handler) {
|
||||
routes.set(path, handler);
|
||||
},
|
||||
},
|
||||
});
|
||||
return routes;
|
||||
}
|
||||
|
||||
function request(
|
||||
handler,
|
||||
{ url = '/', remoteAddress = '127.0.0.1', reqHeaders = {} } = {},
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const headers = new Map();
|
||||
const req = {
|
||||
method: 'GET',
|
||||
url,
|
||||
headers: reqHeaders,
|
||||
socket: { remoteAddress },
|
||||
};
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
headersSent: false,
|
||||
setHeader(name, value) {
|
||||
headers.set(String(name).toLowerCase(), String(value));
|
||||
},
|
||||
writeHead(status, hdrs = {}) {
|
||||
this.statusCode = status;
|
||||
this.headersSent = true;
|
||||
for (const [name, value] of Object.entries(hdrs))
|
||||
headers.set(String(name).toLowerCase(), String(value));
|
||||
},
|
||||
end(body = '') {
|
||||
let parsed = null;
|
||||
try {
|
||||
parsed = body ? JSON.parse(String(body)) : null;
|
||||
} catch {
|
||||
parsed = String(body);
|
||||
}
|
||||
resolve({
|
||||
statusCode: this.statusCode,
|
||||
headers: Object.fromEntries(headers),
|
||||
body: parsed,
|
||||
});
|
||||
},
|
||||
};
|
||||
Promise.resolve(handler(req, res)).catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
function stubFetch(t, impl) {
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = impl;
|
||||
t.after(() => {
|
||||
globalThis.fetch = original;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Answer like Re:Earth does: one `{ellipsoid}` per requested point, in order.
|
||||
* Records which point strings were actually asked for, so a test can assert on
|
||||
* cache misses rather than on internal state.
|
||||
*/
|
||||
function stubTerrainUpstream(t) {
|
||||
const asked = [];
|
||||
stubFetch(t, async (url) => {
|
||||
const points = decodeURIComponent(
|
||||
new URL(String(url)).searchParams.get('points') || '',
|
||||
)
|
||||
.split(';')
|
||||
.filter(Boolean);
|
||||
asked.push(...points);
|
||||
return Response.json({ results: points.map(() => ({ ellipsoid: 150 })) });
|
||||
});
|
||||
return asked;
|
||||
}
|
||||
|
||||
test('a coordinate off the globe is not a cache key', () => {
|
||||
// Finite but not on Earth — the old guard accepted every one of these.
|
||||
for (const raw of [
|
||||
'0,91',
|
||||
'0,-91',
|
||||
'181,0',
|
||||
'-181,0',
|
||||
'1e9,1e9',
|
||||
'0,90.00001',
|
||||
])
|
||||
assert.equal(parseTerrainPoints(raw), null, raw);
|
||||
|
||||
// The poles and the antimeridian are real places and must still parse.
|
||||
for (const raw of ['0,90', '0,-90', '180,0', '-180,0', '-97.74,30.26'])
|
||||
assert.ok(parseTerrainPoints(raw), raw);
|
||||
});
|
||||
|
||||
test('an out-of-range point is refused before it reaches upstream or the cache', async (t) => {
|
||||
let upstreamCalls = 0;
|
||||
stubFetch(t, async () => {
|
||||
upstreamCalls += 1;
|
||||
return new Response('[]', { status: 200 });
|
||||
});
|
||||
const route = install(terrainHeightsProxy()).get('/api/terrain/heights');
|
||||
|
||||
const refused = await request(route, { url: '/?points=0,91' });
|
||||
assert.equal(refused.statusCode, 400);
|
||||
assert.equal(
|
||||
upstreamCalls,
|
||||
0,
|
||||
'an impossible coordinate must not cost an upstream call',
|
||||
);
|
||||
});
|
||||
|
||||
test('one out-of-range point rejects the batch it is hidden in', async (t) => {
|
||||
stubFetch(t, async () => new Response('[]', { status: 200 }));
|
||||
const route = install(terrainHeightsProxy()).get('/api/terrain/heights');
|
||||
const mixed = await request(route, {
|
||||
url: '/?points=-97.74,30.26;0,999;-97.75,30.27',
|
||||
});
|
||||
assert.equal(mixed.statusCode, 400);
|
||||
});
|
||||
|
||||
test('the point cache stops growing, and an evicted point is simply refetched', async (t) => {
|
||||
const asked = stubTerrainUpstream(t);
|
||||
// A ceiling of 3 exercises the same code path 50,000 does, in four requests.
|
||||
const route = install(terrainHeightsProxy({ cacheMaxEntries: 3 })).get(
|
||||
'/api/terrain/heights',
|
||||
);
|
||||
|
||||
const point = (i) => `-97.7${i}000,30.26000`;
|
||||
for (let i = 1; i <= 4; i += 1)
|
||||
await request(route, { url: `/?points=${point(i)}` });
|
||||
assert.equal(asked.length, 4, 'four distinct points, four upstream lookups');
|
||||
|
||||
// The newest point is still cached...
|
||||
await request(route, { url: `/?points=${point(4)}` });
|
||||
assert.equal(asked.length, 4, 'a cached point costs nothing');
|
||||
|
||||
// ...and the oldest was evicted, so it costs one refetch — never a wrong
|
||||
// answer, because terrain does not move.
|
||||
await request(route, { url: `/?points=${point(1)}` });
|
||||
assert.equal(asked.length, 5, 'the evicted point was asked for again');
|
||||
assert.equal(asked[4], point(1));
|
||||
});
|
||||
|
||||
test('an over-large cache file is not read back into memory', async (t) => {
|
||||
const asked = stubTerrainUpstream(t);
|
||||
const point = '-97.74000,30.26000';
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'gev-terrain-'));
|
||||
const cachePath = path.join(dir, 'terrain-heights.json');
|
||||
t.after(() => rm(dir, { recursive: true, force: true }));
|
||||
|
||||
// A cache file holding the answer, but larger than the ceiling allows.
|
||||
const padded = {
|
||||
version: 2,
|
||||
points: { [point]: { at: Date.now(), result: { ellipsoid: 150 } } },
|
||||
_pad: 'x'.repeat(4096),
|
||||
};
|
||||
await writeFile(cachePath, JSON.stringify(padded), 'utf8');
|
||||
|
||||
const route = install(
|
||||
terrainHeightsProxy({ cachePath, cacheMaxDiskBytes: 1024 }),
|
||||
).get('/api/terrain/heights');
|
||||
await request(route, { url: `/?points=${point}` });
|
||||
assert.deepEqual(
|
||||
asked,
|
||||
[point],
|
||||
'the oversized file was skipped, so the point was a miss',
|
||||
);
|
||||
|
||||
// The same file under a ceiling that admits it IS used, which is what proves
|
||||
// the previous assertion came from the size guard and not a broken path.
|
||||
const admitted = install(
|
||||
terrainHeightsProxy({ cachePath, cacheMaxDiskBytes: 1024 * 1024 }),
|
||||
).get('/api/terrain/heights');
|
||||
await request(admitted, { url: `/?points=${point}` });
|
||||
assert.deepEqual(asked, [point], 'a cache within the ceiling is still read');
|
||||
});
|
||||
|
||||
test('the route refuses a flood of requests from one client', async (t) => {
|
||||
stubTerrainUpstream(t);
|
||||
const route = install(terrainHeightsProxy()).get('/api/terrain/heights');
|
||||
|
||||
let refusedAt = 0;
|
||||
for (let i = 1; i <= 95; i += 1) {
|
||||
// A different point each time: distinct keys are the growth path.
|
||||
const lon = (-97.7 - i / 1000).toFixed(5);
|
||||
const answer = await request(route, { url: `/?points=${lon},30.26` });
|
||||
if (answer.statusCode === 429) {
|
||||
refusedAt = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert.equal(refusedAt, 91, 'the 91st request in a minute is refused');
|
||||
});
|
||||
|
||||
test('a cross-site page cannot fill the cache or spend the visitor quota', async (t) => {
|
||||
const asked = stubTerrainUpstream(t);
|
||||
const route = install(terrainHeightsProxy()).get('/api/terrain/heights');
|
||||
const host = 'localhost:5173';
|
||||
const refused = [
|
||||
{ host, 'sec-fetch-site': 'cross-site' }, // <img src> from any web page
|
||||
{ host, 'sec-fetch-site': 'same-site' },
|
||||
{ host, origin: 'https://evil.example' }, // fetch from another page
|
||||
{ host, origin: 'null' }, // sandboxed frame
|
||||
// Behind a reverse proxy the browser's own Sec-Fetch-Site still decides.
|
||||
{ host, 'x-forwarded-for': '203.0.113.9', 'sec-fetch-site': 'cross-site' },
|
||||
];
|
||||
// More refused requests than the 90/min quota, each a distinct new key.
|
||||
for (let i = 0; i < 100; i += 1) {
|
||||
const reqHeaders = refused[i % refused.length];
|
||||
const answer = await request(route, {
|
||||
url: `/?points=${(-97 - i / 1000).toFixed(5)},30.26`,
|
||||
reqHeaders,
|
||||
});
|
||||
assert.equal(answer.statusCode, 403, JSON.stringify(reqHeaders));
|
||||
}
|
||||
assert.deepEqual(asked, [], 'nothing reached upstream or the cache');
|
||||
|
||||
// The visitor's own app still has its full quota afterwards, and a
|
||||
// loopback tool that sends no browser headers is admitted too.
|
||||
const app = await request(route, {
|
||||
url: '/?points=-97.74,30.26',
|
||||
reqHeaders: { host, 'sec-fetch-site': 'same-origin' },
|
||||
});
|
||||
assert.equal(app.statusCode, 200);
|
||||
const tool = await request(route, { url: '/?points=-97.75,30.26' });
|
||||
assert.equal(tool.statusCode, 200);
|
||||
assert.equal(asked.length, 2);
|
||||
});
|
||||
|
||||
test('the app served behind a reverse proxy still gets terrain', async (t) => {
|
||||
const asked = stubTerrainUpstream(t);
|
||||
const route = install(terrainHeightsProxy()).get('/api/terrain/heights');
|
||||
const host = 'globe.example.lan';
|
||||
// What nginx/Caddy/Cloudflare add in front of a same-origin app fetch. The
|
||||
// cost-bearing routes refuse these; terrain is keyless and needed to render,
|
||||
// so a 403 here would drop every chunk to the geoid fallback.
|
||||
const proxied = [
|
||||
{
|
||||
host,
|
||||
'sec-fetch-site': 'same-origin',
|
||||
'x-forwarded-for': '203.0.113.9',
|
||||
},
|
||||
{
|
||||
host,
|
||||
'sec-fetch-site': 'same-origin',
|
||||
'cf-connecting-ip': '203.0.113.9',
|
||||
},
|
||||
{
|
||||
host,
|
||||
'sec-fetch-site': 'same-origin',
|
||||
forwarded: 'for=203.0.113.9;proto=https',
|
||||
via: '1.1 caddy',
|
||||
},
|
||||
// TLS terminated at the proxy: the page origin is https, the hop is http.
|
||||
{
|
||||
host,
|
||||
origin: `https://${host}`,
|
||||
'sec-fetch-site': 'same-origin',
|
||||
'x-forwarded-proto': 'https',
|
||||
'x-forwarded-for': '203.0.113.9',
|
||||
},
|
||||
];
|
||||
for (const [i, reqHeaders] of proxied.entries()) {
|
||||
const answer = await request(route, {
|
||||
url: `/?points=${(-97.8 - i / 1000).toFixed(5)},30.26`,
|
||||
reqHeaders,
|
||||
});
|
||||
assert.equal(answer.statusCode, 200, JSON.stringify(reqHeaders));
|
||||
}
|
||||
assert.equal(asked.length, proxied.length);
|
||||
|
||||
// Proxied or not, a cross-site page is still refused before any work.
|
||||
for (const reqHeaders of [
|
||||
{ host, 'x-forwarded-for': '203.0.113.9', 'sec-fetch-site': 'cross-site' },
|
||||
{
|
||||
host,
|
||||
'x-forwarded-proto': 'https',
|
||||
origin: 'https://evil.example',
|
||||
},
|
||||
// A forwarded scheme does not make an http Origin match an https page.
|
||||
{ host, 'x-forwarded-proto': 'https', origin: `http://${host}` },
|
||||
]) {
|
||||
const answer = await request(route, {
|
||||
url: '/?points=-97.9,30.26',
|
||||
reqHeaders,
|
||||
});
|
||||
assert.equal(answer.statusCode, 403, JSON.stringify(reqHeaders));
|
||||
}
|
||||
assert.equal(asked.length, proxied.length, 'refusals cost no upstream call');
|
||||
});
|
||||
|
||||
test('cost-bearing routes still refuse proxied requests', () => {
|
||||
// The keyless exemption is scoped to terrain; the shared gate is unchanged.
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
setHeader(name, value) {
|
||||
this.headers[name.toLowerCase()] = value;
|
||||
},
|
||||
end(body) {
|
||||
this.body = body;
|
||||
},
|
||||
};
|
||||
const refused = admitSameSite(
|
||||
{
|
||||
headers: {
|
||||
host: 'globe.example.lan',
|
||||
'sec-fetch-site': 'same-origin',
|
||||
'x-forwarded-for': '203.0.113.9',
|
||||
},
|
||||
socket: {},
|
||||
},
|
||||
res,
|
||||
);
|
||||
assert.equal(refused, true);
|
||||
assert.equal(res.statusCode, 403);
|
||||
assert.deepEqual(JSON.parse(res.body), {
|
||||
error: 'Proxied requests are not accepted',
|
||||
});
|
||||
});
|
||||
|
||||
test('a second client keeps its own quota', async (t) => {
|
||||
stubFetch(t, async () =>
|
||||
Response.json([{ lng: -97.74, lat: 30.26, height: 150 }]),
|
||||
);
|
||||
const route = install(terrainHeightsProxy()).get('/api/terrain/heights');
|
||||
|
||||
for (let i = 0; i < 90; i += 1)
|
||||
await request(route, {
|
||||
url: `/?points=${(-98 - i / 1000).toFixed(5)},30.26`,
|
||||
remoteAddress: '198.51.100.7',
|
||||
});
|
||||
const exhausted = await request(route, {
|
||||
url: '/?points=-99.5,30.26',
|
||||
remoteAddress: '198.51.100.7',
|
||||
});
|
||||
assert.equal(exhausted.statusCode, 429);
|
||||
|
||||
const neighbour = await request(route, {
|
||||
url: '/?points=-99.6,30.26',
|
||||
remoteAddress: '198.51.100.8',
|
||||
});
|
||||
assert.notEqual(neighbour.statusCode, 429);
|
||||
});
|
||||
@@ -0,0 +1,364 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createVoiceSession } from '../voice/session.js';
|
||||
import { createGevActionRunner } from '../voice/gevActions.js';
|
||||
import { createGeminiSession } from '../voice/geminiSession.js';
|
||||
import { createNarrationScheduler } from '../voice/narration.js';
|
||||
|
||||
export const turnOfEventLoop = () =>
|
||||
new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
const promise = new Promise((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function ownedClock() {
|
||||
let now = 0;
|
||||
let nextId = 0;
|
||||
const timers = new Map();
|
||||
return {
|
||||
timers,
|
||||
now: () => now,
|
||||
setTimer(fn, delay = 0) {
|
||||
const id = ++nextId;
|
||||
timers.set(id, { fn, at: now + Math.max(0, delay) });
|
||||
return id;
|
||||
},
|
||||
clearTimer: (id) => timers.delete(id),
|
||||
advance(ms) {
|
||||
const until = now + ms;
|
||||
let callbacks = 0;
|
||||
for (;;) {
|
||||
const due = [...timers]
|
||||
.filter(([, timer]) => timer.at <= until)
|
||||
.sort((a, b) => a[1].at - b[1].at || a[0] - b[0])[0];
|
||||
if (!due) break;
|
||||
assert.ok(
|
||||
++callbacks <= 1000,
|
||||
'fixture timer queue must remain bounded',
|
||||
);
|
||||
timers.delete(due[0]);
|
||||
now = due[1].at;
|
||||
due[1].fn();
|
||||
}
|
||||
now = until;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Real common session -> real Gemini adapter -> real action runner.
|
||||
* Only devices, transport, passive UI and wall-clock facilities are fakes.
|
||||
* The optional result gate delays return AFTER the actual action commits.
|
||||
*/
|
||||
export function nativeIntegrationFixture(t) {
|
||||
const clock = ownedClock();
|
||||
const events = [];
|
||||
const sockets = [];
|
||||
const tracks = [];
|
||||
const audios = [];
|
||||
const requests = [];
|
||||
const runnerCalls = [];
|
||||
const runnerJobs = [];
|
||||
const resultGates = new Map();
|
||||
const acquisitions = { microphone: 0, audio: 0, token: 0, socket: 0 };
|
||||
const state = { height: 1000, renders: 0, zooms: [], earconStarts: 0 };
|
||||
const position = { x: 6379137, y: 0, z: 0 };
|
||||
const viewer = {
|
||||
clock: { onTick: { addEventListener: () => () => {} } },
|
||||
camera: {
|
||||
positionWC: position,
|
||||
positionCartographic: { height: state.height },
|
||||
moveEnd: { addEventListener() {} },
|
||||
cancelFlight() {},
|
||||
zoomIn(metres) {
|
||||
position.x -= metres;
|
||||
state.height -= metres;
|
||||
this.positionCartographic.height = state.height;
|
||||
state.zooms.push({ direction: 'in', metres });
|
||||
},
|
||||
zoomOut(metres) {
|
||||
position.x += metres;
|
||||
state.height += metres;
|
||||
this.positionCartographic.height = state.height;
|
||||
state.zooms.push({ direction: 'out', metres });
|
||||
},
|
||||
},
|
||||
scene: {
|
||||
canvas: {
|
||||
clientWidth: 800,
|
||||
clientHeight: 600,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
},
|
||||
requestRender() {
|
||||
state.renders++;
|
||||
},
|
||||
},
|
||||
};
|
||||
const dataManager = { layers: new Map() };
|
||||
const actualRunner = createGevActionRunner({
|
||||
viewer,
|
||||
dataManager,
|
||||
styleManager: {},
|
||||
});
|
||||
const runner = (name, args, options) => {
|
||||
runnerCalls.push({ name, args, options });
|
||||
const job = (async () => {
|
||||
// No fake action result: the camera and envelope come from production.
|
||||
const result = await actualRunner(name, args, options);
|
||||
const gate = resultGates.get(options.callId);
|
||||
if (gate) {
|
||||
gate.entered.resolve(result);
|
||||
await gate.release.promise;
|
||||
}
|
||||
return result;
|
||||
})();
|
||||
runnerJobs.push(job);
|
||||
return job;
|
||||
};
|
||||
class Socket {
|
||||
constructor(url) {
|
||||
acquisitions.socket++;
|
||||
this.url = url;
|
||||
this.readyState = 0;
|
||||
this.bufferedAmount = 0;
|
||||
this.sent = [];
|
||||
sockets.push(this);
|
||||
queueMicrotask(() => {
|
||||
if (this.closed) return;
|
||||
this.readyState = 1;
|
||||
this.onopen?.();
|
||||
});
|
||||
}
|
||||
send(raw) {
|
||||
assert.equal(
|
||||
this.readyState,
|
||||
1,
|
||||
'sending requires the owned open socket',
|
||||
);
|
||||
const message = JSON.parse(raw);
|
||||
this.sent.push(message);
|
||||
if (message.setup)
|
||||
queueMicrotask(() => this.message({ setupComplete: {} }));
|
||||
}
|
||||
message(message) {
|
||||
this.onmessage?.({ data: JSON.stringify(message) });
|
||||
}
|
||||
close() {
|
||||
this.closed = true;
|
||||
this.readyState = 3;
|
||||
}
|
||||
}
|
||||
const session = createVoiceSession({
|
||||
runner,
|
||||
createAdapter: (hooks) =>
|
||||
createGeminiSession({
|
||||
...hooks,
|
||||
ui: { root: { remove() {} } },
|
||||
dataManager,
|
||||
now: clock.now,
|
||||
setTimer: clock.setTimer,
|
||||
clearTimer: clock.clearTimer,
|
||||
WebSocketClass: Socket,
|
||||
getUserMedia: async () => {
|
||||
acquisitions.microphone++;
|
||||
const track = {
|
||||
enabled: true,
|
||||
readyState: 'live',
|
||||
stopCalls: 0,
|
||||
stop() {
|
||||
this.stopCalls++;
|
||||
this.readyState = 'ended';
|
||||
},
|
||||
};
|
||||
tracks.push(track);
|
||||
return { getTracks: () => [track], getAudioTracks: () => [track] };
|
||||
},
|
||||
fetchImpl: async (url, init) => {
|
||||
acquisitions.token++;
|
||||
requests.push({ url, init });
|
||||
const manual = JSON.parse(init.body).inputMode === 'push-to-talk';
|
||||
return Response.json({
|
||||
token: 'auth_tokens/fixture-not-a-credential',
|
||||
model: 'gemini-3.8-live',
|
||||
websocketUrl:
|
||||
'wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContentConstrained',
|
||||
config: {
|
||||
generationConfig: { responseModalities: ['AUDIO'] },
|
||||
...(manual
|
||||
? {
|
||||
realtimeInputConfig: {
|
||||
automaticActivityDetection: { disabled: true },
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
},
|
||||
createInput: ({ readStream, operations }) => ({
|
||||
...operations,
|
||||
setMicrophoneEnabled(enabled) {
|
||||
for (const track of readStream()?.getAudioTracks() || [])
|
||||
track.enabled = enabled;
|
||||
},
|
||||
updateVoiceButtonLabel() {},
|
||||
setVoiceSpeaker() {},
|
||||
startVoiceVisualizer() {},
|
||||
startAssistantVoiceVisualizer() {},
|
||||
cancelPushToTalkHold() {},
|
||||
stopVoiceVisualizer() {},
|
||||
detachBindings() {},
|
||||
bindPushToTalkShortcut() {},
|
||||
resetSession() {
|
||||
this.pushToTalkMode =
|
||||
this.pushToTalkKeyHeld =
|
||||
this.spaceKeyHeld =
|
||||
false;
|
||||
},
|
||||
}),
|
||||
createAudio: (callbacks) => {
|
||||
acquisitions.audio++;
|
||||
const audio = {
|
||||
pending: false,
|
||||
closed: false,
|
||||
outputStream: {},
|
||||
async initialize() {},
|
||||
connectMicrophone(stream, enabled, metadata) {
|
||||
this.stream = stream;
|
||||
this.setMicrophoneEnabled(enabled, metadata);
|
||||
},
|
||||
setMicrophoneEnabled(enabled, metadata) {
|
||||
this.enabled = enabled;
|
||||
this.metadata = metadata;
|
||||
},
|
||||
play() {
|
||||
this.pending = true;
|
||||
},
|
||||
clearPlayback() {
|
||||
this.pending = false;
|
||||
},
|
||||
drain() {
|
||||
this.pending = false;
|
||||
callbacks.onDrain();
|
||||
},
|
||||
diagnostics: () => ({ outputChunks: 0 }),
|
||||
async close() {
|
||||
this.closed = true;
|
||||
this.pending = false;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
audios.push(audio);
|
||||
return audio;
|
||||
},
|
||||
createNarration: (options) =>
|
||||
createNarrationScheduler({
|
||||
...options,
|
||||
now: clock.now,
|
||||
setTimer: clock.setTimer,
|
||||
clearTimer: clock.clearTimer,
|
||||
}),
|
||||
createProgressEarcon: () => ({
|
||||
available: () => true,
|
||||
start() {
|
||||
state.earconStarts++;
|
||||
},
|
||||
stop() {},
|
||||
dispose() {},
|
||||
}),
|
||||
captureViewport: async () => null,
|
||||
}),
|
||||
});
|
||||
session.subscribe((event) => events.push(event));
|
||||
const controller = session.adapter.controller;
|
||||
t.after(async () => {
|
||||
session.destroy();
|
||||
for (const gate of resultGates.values()) gate.release.resolve();
|
||||
await Promise.allSettled(runnerJobs);
|
||||
actualRunner.dispose();
|
||||
assert.equal(clock.timers.size, 0, 'fixture must leave no owned timer');
|
||||
assert.ok(audios.every((audio) => audio.closed));
|
||||
assert.ok(tracks.every((track) => track.readyState === 'ended'));
|
||||
assert.ok(sockets.every((socket) => socket.closed));
|
||||
});
|
||||
return {
|
||||
session,
|
||||
controller,
|
||||
state,
|
||||
events,
|
||||
sockets,
|
||||
tracks,
|
||||
audios,
|
||||
requests,
|
||||
acquisitions,
|
||||
runnerCalls,
|
||||
clock,
|
||||
holdResult(callId) {
|
||||
const gate = { entered: deferred(), release: deferred() };
|
||||
resultGates.set(callId, gate);
|
||||
return { ready: gate.entered.promise, release: gate.release.resolve };
|
||||
},
|
||||
async start() {
|
||||
await session.start();
|
||||
assert.equal(session.state, 'listening');
|
||||
assert.equal(controller.getDiagnostics().connected, true);
|
||||
return sockets.at(-1);
|
||||
},
|
||||
activity(socket = sockets.at(-1)) {
|
||||
socket.message({ voiceActivity: { type: 'ACTIVITY_START' } });
|
||||
socket.message({ voiceActivity: { type: 'ACTIVITY_END' } });
|
||||
},
|
||||
tool(callId, socket = sockets.at(-1)) {
|
||||
socket.message({
|
||||
toolCall: {
|
||||
functionCalls: [
|
||||
{
|
||||
id: callId,
|
||||
name: 'adjust_camera_zoom',
|
||||
args: { direction: 'in', amount: 'little' },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
// A deterministic settlement boundary, not a fake action dispatcher.
|
||||
return controller.toolQueue;
|
||||
},
|
||||
outputs(socket = sockets.at(-1)) {
|
||||
return socket.sent.flatMap(
|
||||
(message) => message.toolResponse?.functionResponses || [],
|
||||
);
|
||||
},
|
||||
complete(socket = sockets.at(-1)) {
|
||||
socket.message({ serverContent: { turnComplete: true } });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function assertSuccessfulZoom(h, socket, callId, beforeHeight) {
|
||||
assert.equal(h.state.height, beforeHeight * 0.75);
|
||||
assert.ok(h.state.renders > 0);
|
||||
const calls = h.events.filter(
|
||||
(event) => event.type === 'action-call' && event.callId === callId,
|
||||
);
|
||||
const results = h.events.filter(
|
||||
(event) => event.type === 'action-result' && event.callId === callId,
|
||||
);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(results.length, 1);
|
||||
assert.equal(calls[0].name, 'adjust_camera_zoom');
|
||||
assert.deepEqual(calls[0].arguments, { direction: 'in', amount: 'little' });
|
||||
assert.equal(results[0].result.ok, true);
|
||||
assert.equal(results[0].result.action, 'adjust_camera_zoom');
|
||||
assert.equal(results[0].result.beforeHeightM, Math.round(beforeHeight));
|
||||
assert.equal(results[0].result.afterHeightM, Math.round(h.state.height));
|
||||
const outputs = h.outputs(socket).filter((output) => output.id === callId);
|
||||
assert.equal(outputs.length, 1);
|
||||
assert.equal(outputs[0].name, 'adjust_camera_zoom');
|
||||
assert.equal(outputs[0].response.ok, true);
|
||||
assert.equal(outputs[0].response.action, 'adjust_camera_zoom');
|
||||
assert.equal(outputs[0].response.afterHeightM, Math.round(h.state.height));
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import { GeminiSessionController } from '../voice/geminiSession.js';
|
||||
import { createNarrationScheduler } from '../voice/narration.js';
|
||||
|
||||
const endpoint =
|
||||
'wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContentConstrained';
|
||||
const credential = {
|
||||
token: 'auth_tokens/test',
|
||||
model: 'gemini-3.8-live',
|
||||
websocketUrl: endpoint,
|
||||
config: { generationConfig: { responseModalities: ['AUDIO'] } },
|
||||
};
|
||||
const manualCredential = {
|
||||
...credential,
|
||||
config: {
|
||||
...credential.config,
|
||||
realtimeInputConfig: { automaticActivityDetection: { disabled: true } },
|
||||
},
|
||||
};
|
||||
const tick = () => new Promise((resolve) => setImmediate(resolve));
|
||||
function deferred() {
|
||||
let resolve;
|
||||
const promise = new Promise((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
function harness(overrides = {}) {
|
||||
const { autoSetup = true, ...controllerOverrides } = overrides;
|
||||
const events = [];
|
||||
const sockets = [];
|
||||
const media = [];
|
||||
const tracks = [];
|
||||
const captures = [];
|
||||
const calls = [];
|
||||
const requests = [];
|
||||
const state = { playing: 0, ducked: false, stopped: 0 };
|
||||
let clock = 0;
|
||||
let nextTimer = 0;
|
||||
const timers = new Map();
|
||||
const earcon = { playing: false, starts: 0, disposals: 0 };
|
||||
const advance = (ms) => {
|
||||
const target = clock + ms;
|
||||
for (;;) {
|
||||
const due = [...timers]
|
||||
.filter(([, timer]) => timer.at <= target)
|
||||
.sort((a, b) => a[1].at - b[1].at)[0];
|
||||
if (!due) break;
|
||||
timers.delete(due[0]);
|
||||
clock = due[1].at;
|
||||
due[1].fn();
|
||||
}
|
||||
clock = target;
|
||||
};
|
||||
let radioControl;
|
||||
const radio = {
|
||||
setVoiceDucked(value) {
|
||||
state.ducked = value;
|
||||
},
|
||||
pause() {},
|
||||
subscribePlaybackControls(callback) {
|
||||
radioControl = callback;
|
||||
return () => {
|
||||
radioControl = null;
|
||||
};
|
||||
},
|
||||
async playForVoice() {
|
||||
state.playing++;
|
||||
return true;
|
||||
},
|
||||
stopPlayback() {
|
||||
state.stopped++;
|
||||
},
|
||||
};
|
||||
class Socket {
|
||||
constructor(url) {
|
||||
this.url = url;
|
||||
this.readyState = 0;
|
||||
this.bufferedAmount = 0;
|
||||
this.sent = [];
|
||||
sockets.push(this);
|
||||
queueMicrotask(() => {
|
||||
this.readyState = 1;
|
||||
this.onopen?.();
|
||||
});
|
||||
}
|
||||
send(raw) {
|
||||
const value = JSON.parse(raw);
|
||||
this.sent.push(value);
|
||||
if (value.setup && autoSetup)
|
||||
queueMicrotask(() => this.message({ setupComplete: {} }));
|
||||
}
|
||||
message(value) {
|
||||
this.onmessage?.({ data: JSON.stringify(value) });
|
||||
}
|
||||
error() {
|
||||
this.onerror?.();
|
||||
}
|
||||
remoteClose() {
|
||||
this.readyState = 3;
|
||||
this.onclose?.();
|
||||
}
|
||||
close() {
|
||||
this.readyState = 3;
|
||||
this.closed = true;
|
||||
}
|
||||
}
|
||||
const makeStream = () => {
|
||||
const track = {
|
||||
enabled: true,
|
||||
readyState: 'live',
|
||||
stop() {
|
||||
this.readyState = 'ended';
|
||||
},
|
||||
};
|
||||
tracks.push(track);
|
||||
return { getTracks: () => [track], getAudioTracks: () => [track] };
|
||||
};
|
||||
const c = new GeminiSessionController({
|
||||
emit: (event) => events.push(event),
|
||||
runAction: async (name, args, options) => {
|
||||
calls.push({ name, args, options });
|
||||
return { ok: true, action: name };
|
||||
},
|
||||
ui: { root: { remove() {} } },
|
||||
radioLayer: radio,
|
||||
now: () => clock,
|
||||
setTimer: (fn, ms) => {
|
||||
const id = ++nextTimer;
|
||||
timers.set(id, { fn, at: clock + ms });
|
||||
return id;
|
||||
},
|
||||
clearTimer: (id) => timers.delete(id),
|
||||
WebSocketClass: Socket,
|
||||
fetchImpl: async (_url, init) => {
|
||||
requests.push(init);
|
||||
return Response.json(
|
||||
JSON.parse(init.body).inputMode === 'push-to-talk'
|
||||
? manualCredential
|
||||
: credential,
|
||||
);
|
||||
},
|
||||
getUserMedia: async () => makeStream(),
|
||||
createInput: ({ readStream, operations }) => ({
|
||||
...operations,
|
||||
setMicrophoneEnabled(enabled) {
|
||||
for (const track of readStream()?.getAudioTracks() || [])
|
||||
track.enabled = enabled;
|
||||
},
|
||||
updateVoiceButtonLabel() {},
|
||||
setVoiceSpeaker() {},
|
||||
startVoiceVisualizer() {},
|
||||
startAssistantVoiceVisualizer() {},
|
||||
cancelPushToTalkHold() {},
|
||||
stopVoiceVisualizer() {},
|
||||
detachBindings() {},
|
||||
bindPushToTalkShortcut() {},
|
||||
resetSession() {
|
||||
this.pushToTalkMode = false;
|
||||
this.pushToTalkKeyHeld = false;
|
||||
this.spaceKeyHeld = false;
|
||||
},
|
||||
}),
|
||||
createNarration: (options) =>
|
||||
createNarrationScheduler({
|
||||
...options,
|
||||
now: () => clock,
|
||||
setTimer: (fn, ms) => {
|
||||
const id = ++nextTimer;
|
||||
timers.set(id, { fn, at: clock + ms });
|
||||
return id;
|
||||
},
|
||||
clearTimer: (id) => timers.delete(id),
|
||||
}),
|
||||
createProgressEarcon: ({ isMicrophoneMuted }) => ({
|
||||
available: isMicrophoneMuted,
|
||||
start() {
|
||||
earcon.playing = true;
|
||||
earcon.starts++;
|
||||
},
|
||||
stop() {
|
||||
earcon.playing = false;
|
||||
},
|
||||
dispose() {
|
||||
this.stop();
|
||||
earcon.disposals++;
|
||||
},
|
||||
}),
|
||||
createAudio: (callbacks) => {
|
||||
const audio = {
|
||||
callbacks,
|
||||
pending: false,
|
||||
closed: false,
|
||||
played: [],
|
||||
async initialize() {},
|
||||
connectMicrophone(stream, enabled, metadata) {
|
||||
media.push({ stream, enabled });
|
||||
this.connected = true;
|
||||
this.setMicrophoneEnabled(enabled, metadata);
|
||||
},
|
||||
setMicrophoneEnabled(value, metadata) {
|
||||
if (!this.connected) return;
|
||||
if (value && !this.enabled) {
|
||||
this.metadata = metadata;
|
||||
callbacks.onAudioStart(metadata);
|
||||
}
|
||||
if (!value && this.enabled) callbacks.onAudioEnd(this.metadata);
|
||||
this.enabled = value;
|
||||
},
|
||||
play(data, mime) {
|
||||
this.pending = true;
|
||||
this.played.push({ data, mime });
|
||||
},
|
||||
drain() {
|
||||
this.pending = false;
|
||||
callbacks.onDrain();
|
||||
},
|
||||
clearPlayback() {
|
||||
this.pending = false;
|
||||
this.cleared = true;
|
||||
},
|
||||
diagnostics() {
|
||||
return { outputChunks: this.played.length };
|
||||
},
|
||||
close() {
|
||||
this.closed = true;
|
||||
},
|
||||
};
|
||||
captures.push(audio);
|
||||
return audio;
|
||||
},
|
||||
...controllerOverrides,
|
||||
});
|
||||
return {
|
||||
c,
|
||||
sockets,
|
||||
tracks,
|
||||
makeStream,
|
||||
events,
|
||||
media,
|
||||
captures,
|
||||
calls,
|
||||
requests,
|
||||
state,
|
||||
advance,
|
||||
timers,
|
||||
earcon,
|
||||
radioControl: (value) => radioControl?.(value),
|
||||
};
|
||||
}
|
||||
|
||||
export { harness, tick, deferred };
|
||||
@@ -0,0 +1,232 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { promises as fsp } from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import { launchLibraryRecentUrl } from '../data/spaceProviderRequests.js';
|
||||
import {
|
||||
launchLibraryRequestHeaders,
|
||||
rocketLaunchesProxy,
|
||||
} from '../../server/providers/space/launch-library.js';
|
||||
import { keySetupStatus } from '../keySetupCore.mjs';
|
||||
import { initKeySetup } from '../keySetup.js';
|
||||
|
||||
test('LL2 requests from the same UTC minute share a cache key without mutating callers', () => {
|
||||
const first = new Date('2026-10-17T16:20:00.001Z');
|
||||
const last = new Date('2026-10-17T16:20:59.999Z');
|
||||
assert.equal(
|
||||
launchLibraryRecentUrl(first).href,
|
||||
launchLibraryRecentUrl(last).href,
|
||||
);
|
||||
assert.equal(first.toISOString(), '2026-10-17T16:20:00.001Z');
|
||||
assert.equal(last.toISOString(), '2026-10-17T16:20:59.999Z');
|
||||
});
|
||||
|
||||
for (const [date, end] of [
|
||||
['2026-10-17T16:20:00.001Z', '2026-10-17T16:20:00.000Z'],
|
||||
['2026-10-17T16:20:59.999Z', '2026-10-17T16:20:00.000Z'],
|
||||
['2026-10-17T16:21:00.000Z', '2026-10-17T16:21:00.000Z'],
|
||||
['2026-01-01T00:00:00.001Z', '2026-01-01T00:00:00.000Z'],
|
||||
]) {
|
||||
test(`LL2 bounds retain the 30-day UTC window at ${date}`, () => {
|
||||
const url = launchLibraryRecentUrl(new Date(date));
|
||||
assert.equal(url.origin, 'https://ll.thespacedevs.com');
|
||||
assert.equal(url.pathname, '/2.3.0/launches/');
|
||||
assert.equal(url.searchParams.get('net__lte'), end);
|
||||
assert.equal(
|
||||
Date.parse(end) - Date.parse(url.searchParams.get('net__gte')),
|
||||
30 * 86400000,
|
||||
);
|
||||
assert.equal(url.searchParams.get('limit'), '100');
|
||||
assert.equal(url.searchParams.get('mode'), 'detailed');
|
||||
});
|
||||
}
|
||||
|
||||
test('LL2 headers identify the app and preserve optional server-only authorization', () => {
|
||||
const publicHeaders = launchLibraryRequestHeaders('');
|
||||
assert.match(publicHeaders['User-Agent'], /^gods-eye-view.*\/1\.0 /);
|
||||
assert.ok(
|
||||
publicHeaders['User-Agent'].includes(
|
||||
'https://github.com/bilawalsidhu/gods-eye-view',
|
||||
),
|
||||
);
|
||||
assert.equal(publicHeaders.Accept, 'application/json');
|
||||
assert.equal(publicHeaders.Authorization, undefined);
|
||||
const paidHeaders = launchLibraryRequestHeaders(' owned-fixture-token ');
|
||||
assert.equal(paidHeaders.Authorization, 'Token owned-fixture-token');
|
||||
assert.equal(paidHeaders['User-Agent'], publicHeaders['User-Agent']);
|
||||
});
|
||||
|
||||
test('actual setup rendering distinguishes optional paid LL2 from unrelated free keys', async () => {
|
||||
const created = [];
|
||||
const node = () => {
|
||||
const value = {
|
||||
dataset: {},
|
||||
children: [],
|
||||
append(...children) {
|
||||
this.children.push(...children);
|
||||
},
|
||||
querySelector() {
|
||||
return null;
|
||||
},
|
||||
querySelectorAll() {
|
||||
return [];
|
||||
},
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
setAttribute() {},
|
||||
remove() {},
|
||||
};
|
||||
created.push(value);
|
||||
return value;
|
||||
};
|
||||
const rows = node();
|
||||
const root = node();
|
||||
root.querySelector = (selector) =>
|
||||
selector === '[data-key-setup-rows]' ? rows : null;
|
||||
const chip = node();
|
||||
const status = keySetupStatus({});
|
||||
status.keys = status.keys.filter((key) =>
|
||||
['launch-library', 'mapillary'].includes(key.id),
|
||||
);
|
||||
const setup = await initKeySetup({
|
||||
documentRef: {
|
||||
getElementById: (id) => (id === 'key-setup-chip' ? chip : root),
|
||||
createElement: node,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
},
|
||||
fetchImpl: async () => ({ ok: true, json: async () => status }),
|
||||
});
|
||||
assert.ok(setup);
|
||||
try {
|
||||
const ll2 = rows.children.find(
|
||||
(row) => row.dataset.keyId === 'launch-library',
|
||||
);
|
||||
const free = rows.children.find((row) => row.dataset.keyId === 'mapillary');
|
||||
const tier = (row) =>
|
||||
row.children[0].children.find(
|
||||
(child) => child.className === 'key-setup-tier',
|
||||
);
|
||||
assert.match(tier(ll2).title, /optional paid token/i);
|
||||
assert.match(ll2.children[1].textContent, /leave blank/i);
|
||||
assert.match(ll2.children[1].textContent, /no key/i);
|
||||
assert.equal(tier(free).title, 'Free key — register, paste, done');
|
||||
const serialized = JSON.stringify(status);
|
||||
assert.ok(!serialized.includes('owned-fixture-token'));
|
||||
} finally {
|
||||
setup.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('real LL2 proxy sends cacheable headers and bounds, retaining its memory cache', async (t) => {
|
||||
for (const method of ['readFile', 'stat'])
|
||||
t.mock.method(fsp, method, async () => {
|
||||
throw new Error('owned fixture has no disk cache');
|
||||
});
|
||||
t.mock.method(fsp, 'mkdir', async () => {});
|
||||
t.mock.method(fsp, 'writeFile', async () => {});
|
||||
const calls = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url, options) => {
|
||||
calls.push({ url: new URL(url), options });
|
||||
return new Response(
|
||||
JSON.stringify({ results: [{ id: 'owned-launch-fixture' }] }),
|
||||
);
|
||||
});
|
||||
let handler;
|
||||
rocketLaunchesProxy().configureServer({
|
||||
middlewares: {
|
||||
use(route, value) {
|
||||
assert.equal(route, '/api/launches');
|
||||
handler = value;
|
||||
},
|
||||
},
|
||||
});
|
||||
async function request() {
|
||||
const res = {
|
||||
writeHead(status, headers) {
|
||||
this.status = status;
|
||||
this.headers = headers;
|
||||
},
|
||||
end(body) {
|
||||
this.body = body;
|
||||
},
|
||||
};
|
||||
await handler({ method: 'GET' }, res);
|
||||
return res;
|
||||
}
|
||||
const first = await request();
|
||||
const second = await request();
|
||||
assert.equal(first.status, 200);
|
||||
assert.equal(second.body, first.body);
|
||||
assert.equal(second.headers['X-GEV-Cache'], 'HIT');
|
||||
assert.equal(calls.length, 1);
|
||||
assert.match(calls[0].options.headers['User-Agent'], /gods-eye-view/);
|
||||
assert.match(calls[0].url.searchParams.get('net__lte'), /:00\.000Z$/);
|
||||
assert.match(calls[0].url.searchParams.get('net__gte'), /:00\.000Z$/);
|
||||
});
|
||||
|
||||
for (const preview of [false, true]) {
|
||||
test(`LL2 proxy sends its identifying headers over real owned HTTP in ${preview ? 'preview' : 'development'}`, async (t) => {
|
||||
for (const method of ['readFile', 'stat'])
|
||||
t.mock.method(fsp, method, async () => {
|
||||
throw new Error('no owned cache');
|
||||
});
|
||||
t.mock.method(fsp, 'mkdir', async () => {});
|
||||
t.mock.method(fsp, 'writeFile', async () => {});
|
||||
const received = [];
|
||||
const upstream = http.createServer((req, res) => {
|
||||
received.push({
|
||||
url: new URL(req.url, 'http://fixture.invalid'),
|
||||
headers: req.headers,
|
||||
});
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ results: [{ id: 'owned-wire-fixture' }] }));
|
||||
});
|
||||
await new Promise((resolve) => upstream.listen(0, '127.0.0.1', resolve));
|
||||
const originalFetch = globalThis.fetch;
|
||||
t.mock.method(globalThis, 'fetch', (url, options) => {
|
||||
const parsed = new URL(url);
|
||||
assert.equal(parsed.origin, 'https://ll.thespacedevs.com');
|
||||
return originalFetch(
|
||||
`http://127.0.0.1:${upstream.address().port}${parsed.pathname}${parsed.search}`,
|
||||
options,
|
||||
);
|
||||
});
|
||||
let handler;
|
||||
const plugin = rocketLaunchesProxy();
|
||||
plugin[preview ? 'configurePreviewServer' : 'configureServer']({
|
||||
middlewares: {
|
||||
use(_route, value) {
|
||||
handler = value;
|
||||
},
|
||||
},
|
||||
});
|
||||
const proxy = http.createServer((req, res) => {
|
||||
void handler(req, res);
|
||||
});
|
||||
await new Promise((resolve) => proxy.listen(0, '127.0.0.1', resolve));
|
||||
try {
|
||||
const result = await originalFetch(
|
||||
`http://127.0.0.1:${proxy.address().port}/api/launches`,
|
||||
);
|
||||
assert.equal(result.status, 200);
|
||||
assert.deepEqual(await result.json(), {
|
||||
results: [{ id: 'owned-wire-fixture' }],
|
||||
});
|
||||
assert.equal(received.length, 1);
|
||||
assert.match(
|
||||
received[0].headers['user-agent'],
|
||||
/^gods-eye-view-launch-library-proxy\/1\.0 /,
|
||||
);
|
||||
assert.equal(received[0].headers.authorization, undefined);
|
||||
assert.match(received[0].url.searchParams.get('net__lte'), /:00\.000Z$/);
|
||||
} finally {
|
||||
proxy.closeAllConnections();
|
||||
upstream.closeAllConnections();
|
||||
await Promise.all([
|
||||
new Promise((resolve) => proxy.close(resolve)),
|
||||
new Promise((resolve) => upstream.close(resolve)),
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -81,8 +81,8 @@ test('portable requests keep fixed origins, encode group data and preserve the 3
|
||||
assert.equal(url.origin, 'https://ll.thespacedevs.com');
|
||||
assert.equal(url.pathname, '/2.3.0/launches/');
|
||||
assert.deepEqual(Object.fromEntries(url.searchParams), {
|
||||
net__gte: '2026-01-30T12:34:56.000Z',
|
||||
net__lte: '2026-03-01T12:34:56.000Z',
|
||||
net__gte: '2026-01-30T12:34:00.000Z',
|
||||
net__lte: '2026-03-01T12:34:00.000Z',
|
||||
limit: '100',
|
||||
mode: 'detailed',
|
||||
});
|
||||
|
||||
@@ -88,8 +88,19 @@ test('root config retains existing named exports and standalone provider order',
|
||||
for (const [name, value] of Object.entries(providers))
|
||||
assert.equal(compatibility[name], value, name);
|
||||
const config = standaloneConfig({ mode: 'test' });
|
||||
const standaloneProviders = config.plugins
|
||||
.slice(3, -5)
|
||||
.map((plugin) => plugin.name);
|
||||
assert.equal(
|
||||
standaloneProviders.filter((name) => name === 'gemini-live-proxy').length,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
standaloneProviders.indexOf('gemini-live-proxy'),
|
||||
standaloneProviders.indexOf('openai-realtime-proxy') + 1,
|
||||
);
|
||||
assert.deepEqual(
|
||||
config.plugins.slice(3, -5).map((plugin) => plugin.name),
|
||||
standaloneProviders.filter((name) => name !== 'gemini-live-proxy'),
|
||||
providers.localProviderPlugins().map((plugin) => plugin.name),
|
||||
);
|
||||
assert.equal(config.plugins.at(-6).name, 'gev-key-setup');
|
||||
|
||||
@@ -11,9 +11,12 @@ import {
|
||||
} from '../area.js';
|
||||
import {
|
||||
LIMIT_SCHEMA,
|
||||
breakdown,
|
||||
breakdownPhrase,
|
||||
capRows,
|
||||
countNoun,
|
||||
isoTime,
|
||||
numericSummary,
|
||||
thinEvenly,
|
||||
} from '../results.js';
|
||||
|
||||
@@ -32,6 +35,31 @@ const MILITARY_SCHEMA = Object.freeze({
|
||||
description: 'Use the military aircraft feed instead of the general feed.',
|
||||
});
|
||||
const MAX_TRACK_POINTS = 200;
|
||||
const GROUP_LABELS = Object.freeze({
|
||||
airline: 'airline',
|
||||
operator: 'operator',
|
||||
origin_country: 'country',
|
||||
type_code: 'type',
|
||||
});
|
||||
|
||||
/**
|
||||
* The ICAO airline designator a callsign flies under: three letters, then a
|
||||
* flight number that starts with a digit, seven characters at most (SWA1234,
|
||||
* UAL12A, BAW1). Registrations used as callsigns (N123AB, GABCD) have no
|
||||
* designator; Philippine RP-C1234 and Cuban CU-T1240, which read like one
|
||||
* without their hyphen, are excluded too.
|
||||
*/
|
||||
export function airlineDesignator(callsign) {
|
||||
const text = String(callsign ?? '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
if (/^(?:RPC|CUT)\d{4}$/.test(text)) return null;
|
||||
const match = /^([A-Z]{3})\d[0-9A-Z]{0,3}$/.exec(text);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
const groupValue = (field) =>
|
||||
field === 'airline' ? (row) => airlineDesignator(row.callsign) : undefined;
|
||||
|
||||
const round = (value, digits) =>
|
||||
Number.isFinite(value) ? Number(value.toFixed(digits)) : null;
|
||||
@@ -82,7 +110,11 @@ export const aircraftInArea = defineTool({
|
||||
title: 'Aircraft in an area',
|
||||
description:
|
||||
'Aircraft currently reported inside an area, nearest the center first. ' +
|
||||
'Optionally military aircraft only, airborne only, or within an altitude band.',
|
||||
'Optionally military aircraft only, airborne only, or within an altitude band. ' +
|
||||
'group_by counts every matched aircraft by airline (the ICAO designator ' +
|
||||
'in its callsign), operator, country or type, and ' +
|
||||
'stats summarizes airborne altitude and speed; both cover all matches, not ' +
|
||||
'just the rows returned.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -91,6 +123,18 @@ export const aircraftInArea = defineTool({
|
||||
airborne_only: { type: 'boolean' },
|
||||
min_altitude_m: { type: 'number', minimum: -500, maximum: 30000 },
|
||||
max_altitude_m: { type: 'number', minimum: -500, maximum: 30000 },
|
||||
group_by: {
|
||||
type: 'string',
|
||||
enum: Object.keys(GROUP_LABELS),
|
||||
description:
|
||||
'Count matched aircraft by this field, largest first. Feeds that do not ' +
|
||||
'report the field count those aircraft as unknown.',
|
||||
},
|
||||
stats: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Summarize airborne altitude and speed over matched aircraft.',
|
||||
},
|
||||
limit: LIMIT_SCHEMA,
|
||||
},
|
||||
required: ['area'],
|
||||
@@ -128,9 +172,14 @@ export const aircraftInArea = defineTool({
|
||||
...(regional ? [`regional feed: ${snapshot.coverage}`] : []),
|
||||
...(snapshot.freshness === 'stale' ? ['data may be stale'] : []),
|
||||
];
|
||||
const groups = args.group_by
|
||||
? breakdown(rows, args.group_by, groupValue(args.group_by))
|
||||
: null;
|
||||
const airborne = rows.filter((row) => !row.on_ground);
|
||||
return {
|
||||
summary:
|
||||
`${rows.length} ${kind} in ${area.label}` +
|
||||
(groups ? breakdownPhrase(groups, GROUP_LABELS[args.group_by]) : '') +
|
||||
(notes.length ? ` (${notes.join('; ')}).` : '.'),
|
||||
data: {
|
||||
view: suggestView(services, {
|
||||
@@ -138,6 +187,16 @@ export const aircraftInArea = defineTool({
|
||||
layers: [args.military ? 'military' : 'flights'],
|
||||
}),
|
||||
...capRows(rows, args.limit),
|
||||
...(groups ? { breakdown: groups } : {}),
|
||||
...(args.stats
|
||||
? {
|
||||
// Parked aircraft would pull both figures toward zero.
|
||||
stats: {
|
||||
altitude_m: numericSummary(airborne, 'altitude_m'),
|
||||
speed_mps: numericSummary(airborne, 'speed_mps'),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...snapshotInfo(snapshot),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { composeCatalog, coreTools } from '../index.js';
|
||||
import { airlineDesignator } from './aviation.js';
|
||||
import { LiveSourceError } from '../../sources/live/contract.js';
|
||||
|
||||
const record = (id, latitude, longitude, extra = {}) => ({
|
||||
@@ -389,6 +390,96 @@ test('the real OpenSky source marks an hour-old snapshot stale for searches', as
|
||||
assert.match(result.summary, /may be stale\.$/);
|
||||
});
|
||||
|
||||
test('aircraft in an area can be counted by operator over every match', async () => {
|
||||
const fleet = [
|
||||
record('a1', 37.6, -122.3, { operator: 'United' }),
|
||||
record('a2', 37.61, -122.3, { operator: 'Delta' }),
|
||||
record('a3', 37.62, -122.3, { operator: 'United', baroAltitudeM: 3000 }),
|
||||
record('a4', 37.63, -122.3, {
|
||||
onGround: true,
|
||||
baroAltitudeM: 0,
|
||||
speedMps: 0,
|
||||
}),
|
||||
record('a5', 51.5, -0.1, { operator: 'British Airways' }),
|
||||
];
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { aircraft: feed(fleet) },
|
||||
});
|
||||
const result = await catalog.call('aircraft_in_area', {
|
||||
area: bay,
|
||||
group_by: 'operator',
|
||||
stats: true,
|
||||
limit: 1,
|
||||
});
|
||||
assert.equal(
|
||||
result.summary,
|
||||
'4 aircraft in 60 km around 37.600, -122.300; most by operator: United (2), Delta (1); 1 unknown.',
|
||||
);
|
||||
assert.equal(result.data.returned, 1);
|
||||
assert.deepEqual(result.data.breakdown, {
|
||||
field: 'operator',
|
||||
groups: [
|
||||
{ value: 'United', count: 2 },
|
||||
{ value: 'Delta', count: 1 },
|
||||
],
|
||||
other_count: 0,
|
||||
unknown_count: 1,
|
||||
});
|
||||
// Both figures cover airborne aircraft only: the parked a4 stands at 0 m
|
||||
// and 0 m/s and would pull each mean down.
|
||||
assert.deepEqual(result.data.stats, {
|
||||
altitude_m: {
|
||||
count: 3,
|
||||
min: 3000,
|
||||
median: 10000,
|
||||
mean: 7666.7,
|
||||
max: 10000,
|
||||
},
|
||||
speed_mps: { count: 3, min: 230, median: 230, mean: 230, max: 230 },
|
||||
});
|
||||
});
|
||||
|
||||
test('aircraft stats are empty when every match is on the ground', async () => {
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: {
|
||||
aircraft: feed([
|
||||
record('g1', 37.6, -122.3, {
|
||||
onGround: true,
|
||||
baroAltitudeM: 0,
|
||||
speedMps: 0,
|
||||
}),
|
||||
record('g2', 37.61, -122.3, {
|
||||
onGround: true,
|
||||
baroAltitudeM: 0,
|
||||
speedMps: 4,
|
||||
}),
|
||||
]),
|
||||
},
|
||||
});
|
||||
const result = await catalog.call('aircraft_in_area', {
|
||||
area: bay,
|
||||
stats: true,
|
||||
});
|
||||
assert.match(result.summary, /^2 aircraft in /);
|
||||
assert.deepEqual(result.data.stats, { altitude_m: null, speed_mps: null });
|
||||
});
|
||||
|
||||
test('aircraft answers carry no breakdown or stats unless asked', async () => {
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { aircraft: feed(civil) },
|
||||
});
|
||||
const result = await catalog.call('aircraft_in_area', { area: bay });
|
||||
assert.equal('breakdown' in result.data, false);
|
||||
assert.equal('stats' in result.data, false);
|
||||
await assert.rejects(
|
||||
catalog.call('aircraft_in_area', { area: bay, group_by: 'callsign' }),
|
||||
/must be one of/,
|
||||
);
|
||||
});
|
||||
|
||||
test('altitude bands require a reported altitude from normalized OpenSky records', async () => {
|
||||
const { normalizeOpenSkyAircraft } =
|
||||
await import('../../sources/live/aircraft.js');
|
||||
@@ -447,3 +538,47 @@ test('altitude bands require a reported altitude from normalized OpenSky records
|
||||
});
|
||||
assert.equal(minimum.data.total, 3);
|
||||
});
|
||||
|
||||
test('an airline designator is read from an ICAO flight callsign only', () => {
|
||||
for (const [callsign, designator] of [
|
||||
['SWA1234', 'SWA'],
|
||||
['ual12a', 'UAL'],
|
||||
[' BAW1 ', 'BAW'],
|
||||
['RCH123', 'RCH'],
|
||||
['N123AB', null],
|
||||
['GABCD', null],
|
||||
['SWA', null],
|
||||
['SWAX123', null],
|
||||
['SWA12345', null],
|
||||
['RPC1234', null],
|
||||
['CUT1240', null],
|
||||
['CUB123', 'CUB'],
|
||||
['', null],
|
||||
[null, null],
|
||||
])
|
||||
assert.equal(airlineDesignator(callsign), designator, String(callsign));
|
||||
});
|
||||
|
||||
test('aircraft in an area can be counted by airline from their callsigns', async () => {
|
||||
const fleet = [
|
||||
record('a1', 37.6, -122.3, { callsign: 'SWA100' }),
|
||||
record('a2', 37.61, -122.3, { callsign: 'SWA2' }),
|
||||
record('a3', 37.62, -122.3, { callsign: 'UAL9' }),
|
||||
record('a4', 37.63, -122.3, { callsign: 'N123AB' }),
|
||||
];
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { aircraft: feed(fleet) },
|
||||
});
|
||||
const result = await catalog.call('aircraft_in_area', {
|
||||
area: bay,
|
||||
group_by: 'airline',
|
||||
});
|
||||
assert.equal(
|
||||
result.summary,
|
||||
'4 aircraft in 60 km around 37.600, -122.300; most by airline: SWA (2), UAL (1); 1 unknown.',
|
||||
);
|
||||
assert.equal(result.data.breakdown.field, 'airline');
|
||||
// Rows are unchanged: the designator is derived, not added to each row.
|
||||
assert.equal('airline' in result.data.rows[0], false);
|
||||
});
|
||||
@@ -12,11 +12,15 @@ import {
|
||||
} from '../area.js';
|
||||
import {
|
||||
LIMIT_SCHEMA,
|
||||
breakdown,
|
||||
breakdownPhrase,
|
||||
capRows,
|
||||
countNoun,
|
||||
isoTime,
|
||||
numericSummary,
|
||||
thinEvenly,
|
||||
} from '../results.js';
|
||||
import { normalizeVesselType } from '../../data/vesselTypes.js';
|
||||
|
||||
const MMSI_SCHEMA = Object.freeze({
|
||||
type: 'string',
|
||||
@@ -29,6 +33,18 @@ const ALL_VESSELS = 1_000_000;
|
||||
const round = (value, digits) =>
|
||||
Number.isFinite(value) ? Number(value.toFixed(digits)) : null;
|
||||
const text = (value) => (typeof value === 'string' && value ? value : null);
|
||||
// AIS sends ship types as ITU-R M.1371 codes ("70" to "79" are all cargo), so
|
||||
// a code reads as its family ("CARGO"), which is what group_by counts and the
|
||||
// type filter matches; text types pass through. Type 0 is "not available" and
|
||||
// reads as unknown.
|
||||
const vesselType = (value) => normalizeVesselType(text(value) ?? '') || null;
|
||||
|
||||
// Crews type the destination by hand; one with no letters ("0", "---") names
|
||||
// no port and reads as unknown.
|
||||
const vesselDestination = (value) => {
|
||||
const destination = text(value);
|
||||
return destination && /\p{L}/u.test(destination) ? destination : null;
|
||||
};
|
||||
|
||||
function vesselRow(record, from) {
|
||||
const point = { lat: record.latitude, lon: record.longitude };
|
||||
@@ -36,8 +52,8 @@ function vesselRow(record, from) {
|
||||
mmsi: record.id,
|
||||
name: text(record.name),
|
||||
imo: text(record.imo),
|
||||
type: text(record.type),
|
||||
destination: text(record.destination),
|
||||
type: vesselType(record.type),
|
||||
destination: vesselDestination(record.destination),
|
||||
lat: round(record.latitude, 5),
|
||||
lon: round(record.longitude, 5),
|
||||
speed_mps: round(record.speedMps, 1),
|
||||
@@ -88,7 +104,9 @@ export const vesselsInArea = defineTool({
|
||||
title: 'Vessels in an area',
|
||||
description:
|
||||
'Ships currently reported by AIS inside an area, nearest the center ' +
|
||||
'first, optionally filtered by vessel type.',
|
||||
'first, optionally filtered by vessel type. group_by counts every matched ' +
|
||||
'ship by type or destination, and stats summarizes their speed; both ' +
|
||||
'cover all matches, not just the rows returned.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -99,6 +117,15 @@ export const vesselsInArea = defineTool({
|
||||
maxLength: 40,
|
||||
description: 'Vessel type to match, such as "tanker" or "cargo".',
|
||||
},
|
||||
group_by: {
|
||||
type: 'string',
|
||||
enum: ['type', 'destination'],
|
||||
description: 'Count matched vessels by this field, largest first.',
|
||||
},
|
||||
stats: {
|
||||
type: 'boolean',
|
||||
description: 'Summarize speed over matched vessels.',
|
||||
},
|
||||
limit: LIMIT_SCHEMA,
|
||||
},
|
||||
required: ['area'],
|
||||
@@ -114,18 +141,25 @@ export const vesselsInArea = defineTool({
|
||||
.filter(
|
||||
(record) =>
|
||||
areaContains(area, { lat: record.latitude, lon: record.longitude }) &&
|
||||
(!wanted || String(record.type).toLowerCase().includes(wanted)),
|
||||
(!wanted ||
|
||||
(vesselType(record.type) ?? '').toLowerCase().includes(wanted)),
|
||||
)
|
||||
.map((record) => vesselRow(record, center))
|
||||
.sort((a, b) => a.distance_km - b.distance_km);
|
||||
const kind = args.type ? `${args.type} vessel` : 'vessel';
|
||||
const groups = args.group_by ? breakdown(rows, args.group_by) : null;
|
||||
return {
|
||||
summary:
|
||||
`${countNoun(rows.length, kind)} in ${area.label}` +
|
||||
(groups ? breakdownPhrase(groups, args.group_by) : '') +
|
||||
(snapshot.freshness === 'stale' ? ' (data may be stale).' : '.'),
|
||||
data: {
|
||||
view: suggestView(services, { area, layers: ['ais-live-vessels'] }),
|
||||
...capRows(rows, args.limit),
|
||||
...(groups ? { breakdown: groups } : {}),
|
||||
...(args.stats
|
||||
? { stats: { speed_mps: numericSummary(rows, 'speed_mps') } }
|
||||
: {}),
|
||||
...snapshotInfo(snapshot),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -224,3 +224,177 @@ test('an area search names its area to the server; a vessel lookup asks for ever
|
||||
assert.equal(lookup.has('lat'), false);
|
||||
assert.equal(lookup.has('radius_km'), false);
|
||||
});
|
||||
|
||||
test('AIS type 0 (not available) is counted as unknown, not as a type', async () => {
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: {
|
||||
vessels: vessels({
|
||||
records: [
|
||||
vessel('366000001', 37.78, -122.4, { type: '0' }),
|
||||
vessel('366000002', 37.79, -122.41, { type: '0' }),
|
||||
vessel('366000003', 37.77, -122.39, { type: 'Cargo' }),
|
||||
vessel('366000004', 37.76, -122.38, { type: 'Tanker' }),
|
||||
],
|
||||
}),
|
||||
},
|
||||
});
|
||||
const result = await catalog.call('vessels_in_area', {
|
||||
area: bay,
|
||||
group_by: 'type',
|
||||
});
|
||||
assert.deepEqual(result.data.breakdown, {
|
||||
field: 'type',
|
||||
groups: [
|
||||
{ value: 'Cargo', count: 1 },
|
||||
{ value: 'Tanker', count: 1 },
|
||||
],
|
||||
other_count: 0,
|
||||
unknown_count: 2,
|
||||
});
|
||||
assert.match(
|
||||
result.summary,
|
||||
/most by type: Cargo \(1\), Tanker \(1\); 2 unknown\./,
|
||||
);
|
||||
assert.doesNotMatch(result.summary, /0 \(/);
|
||||
assert.equal(
|
||||
result.data.rows.find((row) => row.mmsi === '366000001').type,
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('numeric AIS ship-type codes group and filter by their family', async () => {
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: {
|
||||
vessels: vessels({
|
||||
records: [
|
||||
vessel('366000001', 37.78, -122.4, { type: '70' }),
|
||||
vessel('366000002', 37.79, -122.41, { type: '74' }),
|
||||
vessel('366000003', 37.77, -122.39, { type: '79' }),
|
||||
vessel('366000004', 37.76, -122.38, { type: '80' }),
|
||||
vessel('366000005', 37.75, -122.37, { type: '52' }),
|
||||
],
|
||||
}),
|
||||
},
|
||||
});
|
||||
const grouped = await catalog.call('vessels_in_area', {
|
||||
area: bay,
|
||||
group_by: 'type',
|
||||
});
|
||||
assert.deepEqual(grouped.data.breakdown.groups, [
|
||||
{ value: 'CARGO', count: 3 },
|
||||
{ value: 'TANKER', count: 1 },
|
||||
{ value: 'TUG', count: 1 },
|
||||
]);
|
||||
const tankers = await catalog.call('vessels_in_area', {
|
||||
area: bay,
|
||||
type: 'tanker',
|
||||
});
|
||||
assert.deepEqual(
|
||||
tankers.data.rows.map((row) => [row.mmsi, row.type]),
|
||||
[['366000004', 'TANKER']],
|
||||
);
|
||||
});
|
||||
|
||||
test('reserved AIS ship-type codes 100 to 255 count as unknown', async () => {
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: {
|
||||
vessels: vessels({
|
||||
records: [
|
||||
vessel('366000001', 37.78, -122.4, { type: '100' }),
|
||||
vessel('366000002', 37.79, -122.41, { type: '255' }),
|
||||
vessel('366000003', 37.77, -122.39, { type: '70' }),
|
||||
],
|
||||
}),
|
||||
},
|
||||
});
|
||||
const result = await catalog.call('vessels_in_area', {
|
||||
area: bay,
|
||||
group_by: 'type',
|
||||
});
|
||||
assert.deepEqual(result.data.breakdown.groups, [
|
||||
{ value: 'CARGO', count: 1 },
|
||||
]);
|
||||
assert.equal(result.data.breakdown.unknown_count, 2);
|
||||
assert.doesNotMatch(result.summary, /\b(100|255) \(/);
|
||||
});
|
||||
|
||||
test('find_vessel answers carry normalized types and destinations', async () => {
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: {
|
||||
vessels: vessels({
|
||||
records: [
|
||||
vessel('366000001', 37.78, -122.4, {
|
||||
type: '71',
|
||||
destination: '---',
|
||||
}),
|
||||
vessel('366000002', 37.79, -122.41, {
|
||||
type: '0',
|
||||
destination: 'NLRTM',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
},
|
||||
});
|
||||
const cargo = await catalog.call('find_vessel', { mmsi: '366000001' });
|
||||
assert.deepEqual(
|
||||
cargo.data.rows.map((row) => [row.type, row.destination]),
|
||||
[['CARGO', null]],
|
||||
);
|
||||
const unknown = await catalog.call('find_vessel', { mmsi: '366000002' });
|
||||
assert.deepEqual(
|
||||
unknown.data.rows.map((row) => [row.type, row.destination]),
|
||||
[[null, 'NLRTM']],
|
||||
);
|
||||
});
|
||||
|
||||
test('a destination with no letters counts as unknown', async () => {
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: {
|
||||
vessels: vessels({
|
||||
records: [
|
||||
vessel('366000001', 37.78, -122.4, { destination: '0' }),
|
||||
vessel('366000002', 37.79, -122.41, { destination: '---' }),
|
||||
vessel('366000003', 37.77, -122.39, { destination: 'NLRTM' }),
|
||||
],
|
||||
}),
|
||||
},
|
||||
});
|
||||
const result = await catalog.call('vessels_in_area', {
|
||||
area: bay,
|
||||
group_by: 'destination',
|
||||
});
|
||||
assert.deepEqual(result.data.breakdown.groups, [
|
||||
{ value: 'NLRTM', count: 1 },
|
||||
]);
|
||||
assert.equal(result.data.breakdown.unknown_count, 2);
|
||||
});
|
||||
|
||||
test('vessels in an area can be counted by destination with a speed summary', async () => {
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { vessels: vessels() },
|
||||
});
|
||||
const result = await catalog.call('vessels_in_area', {
|
||||
area: bay,
|
||||
group_by: 'destination',
|
||||
stats: true,
|
||||
});
|
||||
assert.equal(
|
||||
result.summary,
|
||||
'2 vessels in 20 km around 37.780, -122.400; most by destination: OAKLAND (1); 1 unknown.',
|
||||
);
|
||||
assert.deepEqual(result.data.breakdown, {
|
||||
field: 'destination',
|
||||
groups: [{ value: 'OAKLAND', count: 1 }],
|
||||
other_count: 0,
|
||||
unknown_count: 1,
|
||||
});
|
||||
assert.deepEqual(result.data.stats, {
|
||||
speed_mps: { count: 2, min: 6.2, median: 6.2, mean: 6.2, max: 6.2 },
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,84 @@ export function capRows(rows, limit = DEFAULT_LIMIT) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Most groups a breakdown lists before counting the rest as other. */
|
||||
export const MAX_GROUPS = 10;
|
||||
|
||||
/**
|
||||
* Count rows by one text field over every row, not just those returned.
|
||||
* Values group case-insensitively and keep their first spelling; rows
|
||||
* without a value are counted as unknown. `valueOf` derives the value when
|
||||
* it is not a row field as it stands.
|
||||
*/
|
||||
export function breakdown(rows, field, valueOf = (row) => row[field]) {
|
||||
const groups = new Map();
|
||||
let unknown = 0;
|
||||
for (const row of rows) {
|
||||
const raw = valueOf(row);
|
||||
const value = typeof raw === 'string' ? raw.trim() : '';
|
||||
if (!value) {
|
||||
unknown += 1;
|
||||
continue;
|
||||
}
|
||||
const key = value.toLowerCase();
|
||||
const group = groups.get(key);
|
||||
if (group) group.count += 1;
|
||||
else groups.set(key, { value, count: 1 });
|
||||
}
|
||||
const sorted = [...groups.values()].sort(
|
||||
(a, b) => b.count - a.count || a.value.localeCompare(b.value),
|
||||
);
|
||||
const listed = sorted.slice(0, MAX_GROUPS);
|
||||
return {
|
||||
field,
|
||||
groups: listed,
|
||||
other_count: rows.length - unknown - sum(listed.map((g) => g.count)),
|
||||
unknown_count: unknown,
|
||||
};
|
||||
}
|
||||
|
||||
/** Minimum, median, mean and maximum of one numeric field, or null. */
|
||||
export function numericSummary(rows, field) {
|
||||
const values = rows
|
||||
.map((row) => row[field])
|
||||
.filter(Number.isFinite)
|
||||
.sort((a, b) => a - b);
|
||||
if (!values.length) return null;
|
||||
const middle = values.length >> 1;
|
||||
const median =
|
||||
values.length % 2
|
||||
? values[middle]
|
||||
: (values[middle - 1] + values[middle]) / 2;
|
||||
const round1 = (value) => Number(value.toFixed(1));
|
||||
return {
|
||||
count: values.length,
|
||||
min: values[0],
|
||||
median: round1(median),
|
||||
mean: round1(sum(values) / values.length),
|
||||
max: values.at(-1),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* "; most by operator: Delta (12), United (9), American (7); 4 unknown", or
|
||||
* "; no operator reported" when no row carries the field.
|
||||
*/
|
||||
export function breakdownPhrase(result, label) {
|
||||
if (!result.groups.length)
|
||||
return result.unknown_count ? `; no ${label} reported` : '';
|
||||
const top = result.groups
|
||||
.slice(0, 3)
|
||||
.map((group) => `${group.value} (${group.count})`);
|
||||
const unknown = result.unknown_count
|
||||
? `; ${result.unknown_count} unknown`
|
||||
: '';
|
||||
return `; most by ${label}: ${top.join(', ')}${unknown}`;
|
||||
}
|
||||
|
||||
function sum(values) {
|
||||
return values.reduce((total, value) => total + value, 0);
|
||||
}
|
||||
|
||||
/** Format an epoch millisecond time as ISO 8601, or null. */
|
||||
export function isoTime(ms) {
|
||||
if (!Number.isFinite(ms)) return null;
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
MAX_GROUPS,
|
||||
breakdown,
|
||||
breakdownPhrase,
|
||||
numericSummary,
|
||||
} from './results.js';
|
||||
|
||||
test('a breakdown counts every row, case-insensitively, largest first', () => {
|
||||
const rows = [
|
||||
{ operator: 'Delta' },
|
||||
{ operator: 'United' },
|
||||
{ operator: ' delta ' },
|
||||
{ operator: null },
|
||||
{ operator: '' },
|
||||
{ operator: 'American' },
|
||||
{ operator: 'United' },
|
||||
{ operator: 'DELTA' },
|
||||
];
|
||||
assert.deepEqual(breakdown(rows, 'operator'), {
|
||||
field: 'operator',
|
||||
groups: [
|
||||
{ value: 'Delta', count: 3 },
|
||||
{ value: 'United', count: 2 },
|
||||
{ value: 'American', count: 1 },
|
||||
],
|
||||
other_count: 0,
|
||||
unknown_count: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test('groups past the limit are counted as other', () => {
|
||||
const rows = Array.from({ length: MAX_GROUPS + 3 }, (_, index) => ({
|
||||
type: `T${String(index).padStart(2, '0')}`,
|
||||
}));
|
||||
rows.push({ type: 'T00' });
|
||||
const result = breakdown(rows, 'type');
|
||||
assert.equal(result.groups.length, MAX_GROUPS);
|
||||
assert.deepEqual(result.groups[0], { value: 'T00', count: 2 });
|
||||
assert.equal(result.other_count, 3);
|
||||
assert.equal(
|
||||
result.groups.reduce((total, group) => total + group.count, 0) +
|
||||
result.other_count +
|
||||
result.unknown_count,
|
||||
rows.length,
|
||||
);
|
||||
});
|
||||
|
||||
test('a numeric summary skips missing values', () => {
|
||||
const rows = [{ v: 4 }, { v: null }, { v: 1 }, { v: 10 }, { v: 3 }, {}];
|
||||
assert.deepEqual(numericSummary(rows, 'v'), {
|
||||
count: 4,
|
||||
min: 1,
|
||||
median: 3.5,
|
||||
mean: 4.5,
|
||||
max: 10,
|
||||
});
|
||||
assert.equal(numericSummary([{ v: 2 }, { v: 7 }, { v: 5 }], 'v').median, 5);
|
||||
assert.equal(numericSummary([{ v: null }], 'v'), null);
|
||||
assert.equal(numericSummary([], 'v'), null);
|
||||
});
|
||||
|
||||
test('the breakdown phrase names the three largest groups', () => {
|
||||
const result = {
|
||||
unknown_count: 0,
|
||||
groups: [
|
||||
{ value: 'A', count: 4 },
|
||||
{ value: 'B', count: 3 },
|
||||
{ value: 'C', count: 2 },
|
||||
{ value: 'D', count: 1 },
|
||||
],
|
||||
};
|
||||
assert.equal(
|
||||
breakdownPhrase(result, 'type'),
|
||||
'; most by type: A (4), B (3), C (2)',
|
||||
);
|
||||
assert.equal(
|
||||
breakdownPhrase({ ...result, unknown_count: 5 }, 'type'),
|
||||
'; most by type: A (4), B (3), C (2); 5 unknown',
|
||||
);
|
||||
// A feed that never reports the field says so instead of staying silent.
|
||||
assert.equal(
|
||||
breakdownPhrase({ groups: [], unknown_count: 236 }, 'operator'),
|
||||
'; no operator reported',
|
||||
);
|
||||
assert.equal(breakdownPhrase({ groups: [], unknown_count: 0 }, 'type'), '');
|
||||
});
|
||||
+24
-1
@@ -72,7 +72,8 @@ export class PanelChrome {
|
||||
variant: this.hud.getVariant(),
|
||||
}),
|
||||
scheduleCockpitLayout: () => this.cockpitView?.scheduleContextLayout(),
|
||||
syncPanelCollapseButton: (panel) => this._syncPanelCollapseButton(panel),
|
||||
syncPanelCollapseButton: (panel) =>
|
||||
this._syncRailPanelCollapseButton(panel),
|
||||
readDisplayScrollTop: () =>
|
||||
this._displayPortalScrollRestoreOwner === 'standard'
|
||||
? this._standardDisplayScrollTop
|
||||
@@ -287,6 +288,19 @@ export class PanelChrome {
|
||||
return this._panelLayout._scheduleLeftPanelLayout(options0);
|
||||
}
|
||||
|
||||
_syncRailPanelCollapseButton(panelEl) {
|
||||
// Adaptive rail layout may directly restore an auto-collapsed Context
|
||||
// without invoking setPanelCollapsed(). Enforce Radio ownership there too.
|
||||
if (
|
||||
panelEl?.id === 'global-context-panel' &&
|
||||
!panelEl.classList.contains('collapsed') &&
|
||||
this._contextRadioDock?.classList.contains('disclosure-open')
|
||||
) {
|
||||
this._setRadioDisclosure?.(false);
|
||||
}
|
||||
this._syncPanelCollapseButton(panelEl);
|
||||
}
|
||||
|
||||
_syncPanelCollapseButton(panelEl) {
|
||||
const isRightRail = [
|
||||
'pp-toggles',
|
||||
@@ -501,6 +515,15 @@ export class PanelChrome {
|
||||
syncShare,
|
||||
});
|
||||
}
|
||||
// A repeated Context-open request must clear a stale compact Radio mini
|
||||
// even when no collapsed class changes.
|
||||
if (
|
||||
!nextCollapsed &&
|
||||
panelId === 'global-context-panel' &&
|
||||
this._contextRadioDock?.classList.contains('disclosure-open')
|
||||
) {
|
||||
this._setRadioDisclosure?.(false);
|
||||
}
|
||||
if (
|
||||
panelEl.classList.contains('collapsed') === nextCollapsed &&
|
||||
!wasAutoCollapsed
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { PanelChrome } from './panelChrome.js';
|
||||
import { PanelLayoutController } from './panelLayoutController.js';
|
||||
|
||||
function classList(initial = []) {
|
||||
const values = new Set(initial);
|
||||
return {
|
||||
contains: (value) => values.has(value),
|
||||
add: (...items) => items.forEach((item) => values.add(item)),
|
||||
remove: (...items) => items.forEach((item) => values.delete(item)),
|
||||
toggle(value, force) {
|
||||
if (force === true) values.add(value);
|
||||
else if (force === false) values.delete(value);
|
||||
else if (values.has(value)) values.delete(value);
|
||||
else values.add(value);
|
||||
return values.has(value);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('opening an already-open Context still closes a stale compact Radio disclosure', (t) => {
|
||||
const priorDocument = globalThis.document;
|
||||
const contextPanel = {
|
||||
id: 'global-context-panel',
|
||||
classList: classList(),
|
||||
};
|
||||
globalThis.document = {
|
||||
documentElement: { dataset: { uiTheme: 'tactical' } },
|
||||
getElementById(id) {
|
||||
return id === 'global-context-panel' ? contextPanel : null;
|
||||
},
|
||||
};
|
||||
t.after(() => {
|
||||
globalThis.document = priorDocument;
|
||||
});
|
||||
|
||||
const radioDock = { classList: classList(['disclosure-open']) };
|
||||
const calls = [];
|
||||
const owner = {
|
||||
_contextRadioDock: radioDock,
|
||||
_setRadioDisclosure(open) {
|
||||
calls.push(open);
|
||||
radioDock.classList.toggle('disclosure-open', open);
|
||||
},
|
||||
_leftPanelStack: null,
|
||||
_rightPanelStack: null,
|
||||
_panelLayout: {
|
||||
_leftStackPreferredPanelId: null,
|
||||
_rightStackPreferredPanelId: null,
|
||||
},
|
||||
_syncPanelCollapseButton() {},
|
||||
_scheduleLeftPanelLayout() {},
|
||||
_scheduleRightPanelLayout() {},
|
||||
shareLinkManager: null,
|
||||
cockpitView: null,
|
||||
};
|
||||
|
||||
PanelChrome.prototype.setPanelCollapsed.call(
|
||||
owner,
|
||||
'global-context-panel',
|
||||
false,
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, [false]);
|
||||
assert.equal(radioDock.classList.contains('disclosure-open'), false);
|
||||
});
|
||||
|
||||
test('adaptive rail restore hides compact Radio before revealing Context', (t) => {
|
||||
const priorRaf = globalThis.requestAnimationFrame;
|
||||
let nextFrame;
|
||||
globalThis.requestAnimationFrame = (callback) => {
|
||||
nextFrame = callback;
|
||||
return 1;
|
||||
};
|
||||
t.after(() => {
|
||||
globalThis.requestAnimationFrame = priorRaf;
|
||||
});
|
||||
|
||||
// Valid starting state: the tactical rail automatically collapsed Context,
|
||||
// and the user opened the compact Radio controls from its header.
|
||||
const contextPanel = {
|
||||
id: 'global-context-panel',
|
||||
classList: classList(['collapsed', 'layout-auto-collapsed']),
|
||||
};
|
||||
const radioDock = { classList: classList(['disclosure-open']) };
|
||||
const radioMini = { hidden: false };
|
||||
const calls = [];
|
||||
const chrome = {
|
||||
_contextRadioDock: radioDock,
|
||||
_setRadioDisclosure(open) {
|
||||
calls.push(`disclosure:${open}`);
|
||||
radioDock.classList.toggle('disclosure-open', open);
|
||||
radioMini.hidden = !open;
|
||||
},
|
||||
_syncPanelCollapseButton(panel) {
|
||||
calls.push(`button:${panel.id}`);
|
||||
},
|
||||
};
|
||||
const layout = {
|
||||
destroyed: false,
|
||||
_rightStackLayoutFrame: null,
|
||||
_rightStackReconsiderAutoCollapse: false,
|
||||
_rightPanelStack: {
|
||||
querySelectorAll: (selector) =>
|
||||
selector === '.layout-auto-collapsed' ? [contextPanel] : [],
|
||||
},
|
||||
_syncPanelCollapseButton(panel) {
|
||||
PanelChrome.prototype._syncRailPanelCollapseButton.call(chrome, panel);
|
||||
},
|
||||
_syncRightPanelAdaptiveLayout() {
|
||||
calls.push('layout');
|
||||
},
|
||||
};
|
||||
|
||||
PanelLayoutController.prototype._scheduleRightPanelLayout.call(layout, {
|
||||
reconsiderAutoCollapse: true,
|
||||
});
|
||||
assert.equal(contextPanel.classList.contains('collapsed'), true);
|
||||
assert.equal(radioMini.hidden, false);
|
||||
nextFrame();
|
||||
|
||||
assert.equal(contextPanel.classList.contains('collapsed'), false);
|
||||
assert.equal(contextPanel.classList.contains('layout-auto-collapsed'), false);
|
||||
assert.equal(radioDock.classList.contains('disclosure-open'), false);
|
||||
assert.equal(radioMini.hidden, true);
|
||||
assert.deepEqual(calls, [
|
||||
'disclosure:false',
|
||||
'button:global-context-panel',
|
||||
'layout',
|
||||
]);
|
||||
});
|
||||
|
||||
test('real PanelChrome constructor wires rail restore to compact Radio owner', (t) => {
|
||||
const priorDocument = globalThis.document;
|
||||
const priorRaf = globalThis.requestAnimationFrame;
|
||||
let nextFrame;
|
||||
const contextPanel = {
|
||||
id: 'global-context-panel',
|
||||
classList: classList(['collapsed', 'layout-auto-collapsed']),
|
||||
};
|
||||
const rightRail = {
|
||||
querySelectorAll(selector) {
|
||||
return selector === '.layout-auto-collapsed' ? [contextPanel] : [];
|
||||
},
|
||||
};
|
||||
globalThis.document = {
|
||||
getElementById(id) {
|
||||
return id === 'right-context-rail' ? rightRail : null;
|
||||
},
|
||||
};
|
||||
globalThis.requestAnimationFrame = (callback) => {
|
||||
nextFrame = callback;
|
||||
return 1;
|
||||
};
|
||||
t.after(() => {
|
||||
globalThis.document = priorDocument;
|
||||
globalThis.requestAnimationFrame = priorRaf;
|
||||
});
|
||||
|
||||
const dock = { classList: classList(['disclosure-open']) };
|
||||
const calls = [];
|
||||
const chrome = new PanelChrome({
|
||||
elements: { _contextRadioDock: dock },
|
||||
operations: {
|
||||
_setRadioDisclosure(open) {
|
||||
calls.push(`disclosure:${open}`);
|
||||
dock.classList.toggle('disclosure-open', open);
|
||||
},
|
||||
_syncPanelCollapseButton(panel) {
|
||||
calls.push(`button:${panel.id}`);
|
||||
},
|
||||
},
|
||||
readHud: () => ({ visible: true, getVariant: () => 'tactical' }),
|
||||
readCockpit: () => null,
|
||||
readShareLinks: () => null,
|
||||
readInitialShare: () => null,
|
||||
readScrollRestoreOwner: () => null,
|
||||
readDisplayScrollTop: () => 0,
|
||||
});
|
||||
chrome._panelLayout._syncRightPanelAdaptiveLayout = () => {
|
||||
calls.push('layout');
|
||||
};
|
||||
|
||||
chrome._panelLayout._scheduleRightPanelLayout({
|
||||
reconsiderAutoCollapse: true,
|
||||
});
|
||||
assert.equal(typeof nextFrame, 'function');
|
||||
assert.equal(dock.classList.contains('disclosure-open'), true);
|
||||
nextFrame();
|
||||
|
||||
assert.equal(contextPanel.classList.contains('collapsed'), false);
|
||||
assert.equal(dock.classList.contains('disclosure-open'), false);
|
||||
assert.deepEqual(calls, [
|
||||
'disclosure:false',
|
||||
'button:global-context-panel',
|
||||
'layout',
|
||||
]);
|
||||
});
|
||||
@@ -140,3 +140,95 @@ html:not([lang='en'])
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
#gev-voice-control[data-provider-choice='true'] {
|
||||
--gev-provider-lift: 0px;
|
||||
}
|
||||
|
||||
/* Provider choice belongs to the existing voice control, not a separate panel. */
|
||||
.gev-voice-provider-field {
|
||||
position: absolute;
|
||||
bottom: calc(100% + var(--gev-provider-lift, 0px));
|
||||
z-index: 9;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 8px 6px;
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-panel, #09121b);
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.5rem;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.gev-voice-provider-field[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.gev-voice-provider-field select {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
min-height: 30px;
|
||||
color: var(--text-primary);
|
||||
background: #09121b;
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 4px;
|
||||
font: inherit;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
#gev-voice-control:hover .gev-voice-provider-field,
|
||||
#gev-voice-control:focus-within .gev-voice-provider-field {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
#command-dock
|
||||
#gev-voice-control[data-provider-choice='true']
|
||||
.gev-voice-help-tray,
|
||||
#command-dock #gev-voice-control[data-provider-choice='true'] .gev-voice-card,
|
||||
#command-dock
|
||||
#gev-voice-control[data-provider-choice='true']
|
||||
.gev-voice-error-tray {
|
||||
bottom: calc(100% + 4.8rem + var(--gev-provider-lift));
|
||||
}
|
||||
#command-dock.dock-has-pinned-tray
|
||||
#gev-voice-control[data-provider-choice='true'] {
|
||||
--gev-provider-lift: calc(1.7rem + var(--dock-pinned-stack-height));
|
||||
}
|
||||
/* Pinned trays separate the chooser from its hover owner. Keep it reachable
|
||||
across that gap without requiring keyboard focus to hold it open. */
|
||||
#command-dock.dock-has-pinned-tray .gev-voice-provider-field {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
/* The attribution clearance also creates a gap for mouse users in a
|
||||
narrow window, so the raised chooser must not depend on root hover. */
|
||||
.gev-voice-provider-field {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
#command-dock #gev-voice-control[data-provider-choice='true'] {
|
||||
--gev-provider-lift: 3rem;
|
||||
}
|
||||
#command-dock.dock-has-pinned-tray
|
||||
#gev-voice-control[data-provider-choice='true'] {
|
||||
--gev-provider-lift: calc(3.5rem + var(--dock-pinned-stack-height));
|
||||
}
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
#command-dock #gev-voice-control[data-provider-choice='true'] {
|
||||
--gev-provider-lift: calc(2vh + 2.5rem);
|
||||
}
|
||||
#command-dock.dock-has-pinned-tray
|
||||
#gev-voice-control[data-provider-choice='true'] {
|
||||
--gev-provider-lift: calc(2vh + 3rem + var(--dock-pinned-stack-height));
|
||||
}
|
||||
}
|
||||
@media (hover: none) {
|
||||
.gev-voice-provider-field {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,16 @@
|
||||
import { createVoiceCommands as bindVoiceCommands } from './sessionCommands.js';
|
||||
import { createRealtimeSession } from './realtimeSession.js';
|
||||
import { createGeminiSession } from './geminiSession.js';
|
||||
import { createProviderCommands } from './providerCommands.js';
|
||||
|
||||
/** Default composition; callers may supply another session adapter factory. */
|
||||
export function createVoiceCommands(options) {
|
||||
export function createVoiceCommands(options = {}) {
|
||||
// Generic hosts retain the existing default; standalone opts in explicitly.
|
||||
if (!options.createSession && options.supportedProviders !== undefined)
|
||||
return createProviderCommands(options, {
|
||||
bind: bindVoiceCommands,
|
||||
factories: { openai: createRealtimeSession, gemini: createGeminiSession },
|
||||
});
|
||||
return bindVoiceCommands({
|
||||
createSession: createRealtimeSession,
|
||||
...options,
|
||||
|
||||
@@ -39,6 +39,10 @@ export function createVoiceControl({ reset = false } = {}) {
|
||||
root.dataset.status = 'idle';
|
||||
root.dataset.speaker = 'idle';
|
||||
root.innerHTML = `
|
||||
<label id="gev-voice-provider-field" class="gev-voice-provider-field" hidden>
|
||||
<span>${t('Voice provider')}</span>
|
||||
<select id="gev-voice-provider" aria-label="${t('Voice provider')}" title="${t('Changing provider reconnects voice if it is on.')}"></select>
|
||||
</label>
|
||||
<div class="gev-voice-heading">
|
||||
<div class="gev-voice-kicker">${t('AI AGENT')}</div>
|
||||
<div id="gev-voice-status">${voiceStatusLabel('idle')}</div>
|
||||
@@ -110,6 +114,8 @@ export function createVoiceControl({ reset = false } = {}) {
|
||||
}
|
||||
return {
|
||||
root,
|
||||
providerField: root.querySelector('#gev-voice-provider-field'),
|
||||
providerSelect: root.querySelector('#gev-voice-provider'),
|
||||
button: root.querySelector('#gev-voice-button'),
|
||||
buttonLabel: root.querySelector('.gev-mic-label'),
|
||||
status: root.querySelector('#gev-voice-status'),
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
harness,
|
||||
deferred,
|
||||
tick,
|
||||
} from '../testSupport/geminiNativeHarness.mjs';
|
||||
|
||||
function unreadResponse(bytes = null) {
|
||||
const state = { reads: 0, cancelled: 0 };
|
||||
const response = new Response(
|
||||
new ReadableStream(
|
||||
{
|
||||
pull(controller) {
|
||||
state.reads++;
|
||||
if (bytes) controller.enqueue(bytes);
|
||||
},
|
||||
cancel() {
|
||||
state.cancelled++;
|
||||
},
|
||||
},
|
||||
{ highWaterMark: 0 },
|
||||
),
|
||||
);
|
||||
return { response, state };
|
||||
}
|
||||
|
||||
test('already aborted token request does not acquire or read a response', async (t) => {
|
||||
let fetches = 0;
|
||||
const h = harness({
|
||||
fetchImpl: () => {
|
||||
fetches++;
|
||||
throw Error('must not fetch');
|
||||
},
|
||||
});
|
||||
t.after(() => h.c.stop());
|
||||
const abort = new AbortController();
|
||||
abort.abort();
|
||||
await assert.rejects(h.c.requestToken(abort.signal), { name: 'AbortError' });
|
||||
assert.equal(fetches, 0);
|
||||
});
|
||||
|
||||
for (const phase of [
|
||||
'before response',
|
||||
'between resolution and continuation',
|
||||
]) {
|
||||
test(`aborted token acquisition closes the unread body ${phase}`, async (t) => {
|
||||
const gate = deferred();
|
||||
const h = harness({ fetchImpl: () => gate.promise });
|
||||
t.after(() => h.c.stop());
|
||||
const abort = new AbortController();
|
||||
const { response, state } = unreadResponse();
|
||||
const result = assert.rejects(h.c.requestToken(abort.signal), {
|
||||
name: 'AbortError',
|
||||
});
|
||||
if (phase === 'before response') abort.abort();
|
||||
gate.resolve(response);
|
||||
if (phase !== 'before response') queueMicrotask(() => abort.abort());
|
||||
await result;
|
||||
await tick();
|
||||
assert.deepEqual(state, { reads: 0, cancelled: 1 });
|
||||
});
|
||||
}
|
||||
|
||||
test('token body cap cancels a native response stream', async (t) => {
|
||||
const { response, state } = unreadResponse(new Uint8Array(256 * 1024 + 1));
|
||||
const h = harness({ fetchImpl: async () => response });
|
||||
t.after(() => h.c.stop());
|
||||
await assert.rejects(
|
||||
h.c.requestToken(new AbortController().signal),
|
||||
/too large|exceed|limit/i,
|
||||
);
|
||||
assert.equal(state.cancelled, 1);
|
||||
assert.equal(state.reads, 1);
|
||||
});
|
||||
|
||||
test('token body read ends promptly on caller abort and releases the reader', async (t) => {
|
||||
const { response, state } = unreadResponse();
|
||||
const h = harness({ fetchImpl: async () => response });
|
||||
t.after(() => h.c.stop());
|
||||
const abort = new AbortController();
|
||||
const result = assert.rejects(h.c.requestToken(abort.signal), {
|
||||
name: 'AbortError',
|
||||
});
|
||||
await tick();
|
||||
assert.equal(state.reads, 1);
|
||||
abort.abort();
|
||||
await result;
|
||||
assert.equal(state.cancelled, 1);
|
||||
assert.equal(response.body.locked, false);
|
||||
});
|
||||
|
||||
test('permission grant resolved just before Stop releases its tracks and preserves a fresh start', async (t) => {
|
||||
const gate = deferred();
|
||||
const h = harness({ getUserMedia: () => gate.promise });
|
||||
t.after(() => h.c.stop());
|
||||
const stream = h.makeStream();
|
||||
const start = h.c.start();
|
||||
gate.resolve(stream);
|
||||
queueMicrotask(() => h.c.stop());
|
||||
await start;
|
||||
await tick();
|
||||
assert.equal(h.tracks[0].readyState, 'ended');
|
||||
assert.equal(h.requests.length, 0);
|
||||
assert.equal(h.sockets.length, 0);
|
||||
assert.equal(h.captures[0].closed, true);
|
||||
h.c.getUserMedia = async () => h.makeStream();
|
||||
await h.c.start();
|
||||
assert.equal(h.c.status, 'listening');
|
||||
assert.equal(h.tracks[1].readyState, 'live');
|
||||
assert.equal(h.captures[1].closed, false);
|
||||
});
|
||||
|
||||
test('late audio initialization cannot connect a retired microphone or close successor audio', async (t) => {
|
||||
const gate = deferred();
|
||||
const h = harness();
|
||||
t.after(() => h.c.stop());
|
||||
const createAudio = h.c.createAudio;
|
||||
let first = true;
|
||||
h.c.createAudio = (options) => {
|
||||
const audio = createAudio(options);
|
||||
if (first) {
|
||||
first = false;
|
||||
audio.initialize = () => gate.promise;
|
||||
audio.connectMicrophone = () =>
|
||||
assert.fail('retired initialization must not connect');
|
||||
}
|
||||
return audio;
|
||||
};
|
||||
const oldStart = h.c.start();
|
||||
await tick();
|
||||
h.c.stop();
|
||||
await h.c.start();
|
||||
gate.resolve();
|
||||
await oldStart;
|
||||
await tick();
|
||||
assert.equal(h.c.status, 'listening');
|
||||
assert.equal(h.captures[0].closed, true);
|
||||
assert.equal(h.captures[1].closed, false);
|
||||
assert.equal(h.tracks[0].readyState, 'ended');
|
||||
assert.equal(h.tracks[1].readyState, 'live');
|
||||
});
|
||||
@@ -0,0 +1,380 @@
|
||||
import { msg } from '../i18n/marker.js';
|
||||
const MAX_AUDIO_BYTES = 2 * 1024 * 1024;
|
||||
const MAX_QUEUED_SECONDS = 60;
|
||||
const MAX_QUEUED_SOURCES = 256;
|
||||
const PTT_WINDOW_SAMPLES = 160; // 10 ms at the worklet's fixed 16 kHz.
|
||||
const PTT_ENERGY_FLOOR = 0.002;
|
||||
const PTT_PRE_ROLL_BYTES = 16000 * 2 * 0.4;
|
||||
|
||||
/** Admit manual input only after sustained energy; not an utterance detector. */
|
||||
function pttCapture(captureId) {
|
||||
return {
|
||||
captureId,
|
||||
admitted: false,
|
||||
buffers: [],
|
||||
bytes: 0,
|
||||
samples: 0,
|
||||
energy: 0,
|
||||
aboveFloor: 0,
|
||||
voicedWindows: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function hasPttEnergy(state, pcm) {
|
||||
for (let index = 0; index + 1 < pcm.byteLength; index += 2) {
|
||||
const sample = pcm.getInt16(index, true) / 32768;
|
||||
state.energy += sample * sample;
|
||||
if (Math.abs(sample) >= PTT_ENERGY_FLOOR) state.aboveFloor++;
|
||||
if (++state.samples !== PTT_WINDOW_SAMPLES) continue;
|
||||
// Requiring occupancy as well as RMS rejects isolated high-peak key clicks.
|
||||
const voiced =
|
||||
state.energy / state.samples >= PTT_ENERGY_FLOOR ** 2 &&
|
||||
state.aboveFloor >= PTT_WINDOW_SAMPLES / 8;
|
||||
state.voicedWindows = voiced ? state.voicedWindows + 1 : 0;
|
||||
state.samples = state.energy = state.aboveFloor = 0;
|
||||
if (state.voicedWindows >= 2) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function pcmBytesToBase64(buffer) {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
for (let offset = 0; offset < bytes.length; offset += 8192) {
|
||||
binary += String.fromCharCode(...bytes.subarray(offset, offset + 8192));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
export function decodeGeminiPcm(data, mimeType = 'audio/pcm;rate=24000') {
|
||||
if (
|
||||
typeof data !== 'string' ||
|
||||
data.length > 4 * Math.ceil(MAX_AUDIO_BYTES / 3)
|
||||
)
|
||||
throw new Error(msg('Gemini returned an oversized audio chunk'));
|
||||
const match = /^audio\/pcm(?:;rate=(\d+))?$/.exec(mimeType);
|
||||
if (!match)
|
||||
throw new Error(msg('Gemini returned an unsupported audio format'));
|
||||
const sampleRate = Number(match[1] || 24000);
|
||||
if (!Number.isInteger(sampleRate) || sampleRate < 8000 || sampleRate > 96000)
|
||||
throw new Error(msg('Gemini returned an invalid audio sample rate'));
|
||||
const binary = atob(data);
|
||||
if (binary.length > MAX_AUDIO_BYTES)
|
||||
throw new Error(msg('Gemini returned an oversized audio chunk'));
|
||||
if (!binary.length || binary.length % 2)
|
||||
throw new Error(msg('Gemini returned incomplete PCM audio'));
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index++)
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
const view = new DataView(bytes.buffer);
|
||||
const samples = new Float32Array(bytes.length / 2);
|
||||
for (let index = 0; index < samples.length; index++)
|
||||
samples[index] = view.getInt16(index * 2, true) / 32768;
|
||||
return { samples, sampleRate };
|
||||
}
|
||||
|
||||
/** Own capture and playback independently from the Live protocol. */
|
||||
export function createGeminiAudio({
|
||||
pushToTalk = false,
|
||||
onAudioStart,
|
||||
onAudio,
|
||||
onAudioEnd,
|
||||
onAudioDiscard,
|
||||
onDrain,
|
||||
onError,
|
||||
AudioContextClass = globalThis.AudioContext || globalThis.webkitAudioContext,
|
||||
AudioWorkletNodeClass = globalThis.AudioWorkletNode,
|
||||
} = {}) {
|
||||
if (!AudioContextClass || !AudioWorkletNodeClass)
|
||||
throw new Error(
|
||||
msg('This browser does not support Gemini microphone audio'),
|
||||
);
|
||||
let captureContext;
|
||||
let playbackContext;
|
||||
let output;
|
||||
let meter;
|
||||
let resumed;
|
||||
const closeContext = (context) => {
|
||||
try {
|
||||
return Promise.resolve(context?.close());
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
};
|
||||
try {
|
||||
// Capture at the device rate; the worklet explicitly resamples to 16 kHz.
|
||||
captureContext = new AudioContextClass();
|
||||
playbackContext = new AudioContextClass({ sampleRate: 24000 });
|
||||
// Resume both synchronously within the mic gesture, before any awaits.
|
||||
const resume = (context) => {
|
||||
try {
|
||||
return Promise.resolve(context.resume());
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
};
|
||||
resumed = Promise.all([resume(captureContext), resume(playbackContext)]);
|
||||
// Permission may remain pending before initialize observes this failure.
|
||||
void resumed.catch(() => {});
|
||||
output = playbackContext.createGain();
|
||||
meter = playbackContext.createMediaStreamDestination();
|
||||
output.connect(playbackContext.destination);
|
||||
output.connect(meter);
|
||||
} catch (error) {
|
||||
output?.disconnect();
|
||||
meter?.disconnect();
|
||||
meter?.stream.getTracks?.().forEach((track) => track.stop());
|
||||
void Promise.allSettled([
|
||||
closeContext(captureContext),
|
||||
closeContext(playbackContext),
|
||||
]);
|
||||
throw error;
|
||||
}
|
||||
let closed = false;
|
||||
let closeResult;
|
||||
let capture = null;
|
||||
let microphone = null;
|
||||
let mute = null;
|
||||
let nextTime = 0;
|
||||
let inputChunks = 0;
|
||||
let inputPeak = 0;
|
||||
let nonSilentInputChunks = 0;
|
||||
let outputChunks = 0;
|
||||
let outputSamples = 0;
|
||||
let completedOutputSamples = 0;
|
||||
let discardedOutputSamples = 0;
|
||||
let pendingPtt = null;
|
||||
let latestPttCaptureId = 0;
|
||||
let suppressedPttCaptures = 0;
|
||||
let admittedPttCaptures = 0;
|
||||
const sources = new Map();
|
||||
|
||||
const emitAudio = (buffer, metadata) =>
|
||||
onAudio?.(
|
||||
{ data: pcmBytesToBase64(buffer), mimeType: 'audio/pcm;rate=16000' },
|
||||
metadata,
|
||||
);
|
||||
|
||||
function clearPlayback() {
|
||||
const oldSources = [...sources];
|
||||
sources.clear();
|
||||
nextTime = playbackContext.currentTime;
|
||||
for (const [source, sampleCount] of oldSources) {
|
||||
discardedOutputSamples += sampleCount;
|
||||
source.onended = null;
|
||||
try {
|
||||
source.stop();
|
||||
} catch {
|
||||
/* Already ended. */
|
||||
}
|
||||
source.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
outputStream: meter.stream,
|
||||
get pending() {
|
||||
return sources.size > 0;
|
||||
},
|
||||
async initialize(signal) {
|
||||
try {
|
||||
await Promise.all([
|
||||
resumed,
|
||||
captureContext.audioWorklet.addModule(
|
||||
new URL('./geminiCaptureWorklet.js', import.meta.url),
|
||||
),
|
||||
]);
|
||||
signal?.throwIfAborted();
|
||||
if (closed) throw new DOMException('Voice stopped', 'AbortError');
|
||||
} catch (error) {
|
||||
await this.close();
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
connectMicrophone(stream, enabled, metadata) {
|
||||
if (closed) throw new DOMException('Voice stopped', 'AbortError');
|
||||
capture = new AudioWorkletNodeClass(
|
||||
captureContext,
|
||||
'gev-gemini-capture',
|
||||
{
|
||||
numberOfInputs: 1,
|
||||
numberOfOutputs: 1,
|
||||
outputChannelCount: [1],
|
||||
},
|
||||
);
|
||||
capture.port.onmessage = ({ data }) => {
|
||||
if (closed) return;
|
||||
// Capture ownership is transport metadata, never part of Live audio.
|
||||
if (!Number.isSafeInteger(data?.captureId) || data.captureId <= 0)
|
||||
return;
|
||||
const metadata = { captureId: data.captureId };
|
||||
try {
|
||||
if (data?.type === 'audio' && data.buffer instanceof ArrayBuffer) {
|
||||
inputChunks++;
|
||||
const pcm = new DataView(data.buffer);
|
||||
let peak = 0;
|
||||
for (let index = 0; index + 1 < pcm.byteLength; index += 2)
|
||||
peak = Math.max(
|
||||
peak,
|
||||
Math.abs(pcm.getInt16(index, true)) / 32768,
|
||||
);
|
||||
inputPeak = Math.max(inputPeak, peak);
|
||||
if (peak >= 0.01) nonSilentInputChunks++;
|
||||
if (!pushToTalk) emitAudio(data.buffer, metadata);
|
||||
else {
|
||||
const state = pendingPtt;
|
||||
if (state?.captureId !== data.captureId) return;
|
||||
if (state.admitted) emitAudio(data.buffer, metadata);
|
||||
else {
|
||||
state.buffers.push(data.buffer);
|
||||
state.bytes += data.buffer.byteLength;
|
||||
if (hasPttEnergy(state, pcm)) {
|
||||
state.admitted = true;
|
||||
admittedPttCaptures++;
|
||||
// Context and activityStart are gated together with PCM.
|
||||
onAudioStart?.(metadata);
|
||||
for (const buffer of state.buffers) {
|
||||
if (closed || pendingPtt !== state) break;
|
||||
emitAudio(buffer, metadata);
|
||||
}
|
||||
state.buffers = [];
|
||||
state.bytes = 0;
|
||||
} else {
|
||||
while (state.bytes > PTT_PRE_ROLL_BYTES) {
|
||||
state.bytes -= state.buffers.shift().byteLength;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (data?.type === 'start') {
|
||||
if (!pushToTalk) onAudioStart?.(metadata);
|
||||
else if (data.captureId > latestPttCaptureId) {
|
||||
latestPttCaptureId = data.captureId;
|
||||
pendingPtt = pttCapture(data.captureId);
|
||||
}
|
||||
} else if (data?.type === 'end') {
|
||||
if (!pushToTalk) onAudioEnd?.(metadata);
|
||||
else if (pendingPtt?.captureId === data.captureId) {
|
||||
const admitted = pendingPtt.admitted;
|
||||
pendingPtt = null;
|
||||
if (admitted) onAudioEnd?.(metadata);
|
||||
else {
|
||||
suppressedPttCaptures++;
|
||||
onAudioDiscard?.(metadata);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
onError?.(error);
|
||||
}
|
||||
};
|
||||
capture.onprocessorerror = () => {
|
||||
if (!closed)
|
||||
onError?.(new Error(msg('Microphone audio processing failed')));
|
||||
};
|
||||
microphone = captureContext.createMediaStreamSource(stream);
|
||||
mute = captureContext.createGain();
|
||||
mute.gain.value = 0;
|
||||
capture.port.postMessage({
|
||||
enabled: Boolean(enabled),
|
||||
captureId: metadata?.captureId,
|
||||
});
|
||||
microphone.connect(capture);
|
||||
capture.connect(mute);
|
||||
mute.connect(captureContext.destination);
|
||||
},
|
||||
setMicrophoneEnabled(enabled, metadata) {
|
||||
if (closed) return;
|
||||
capture?.port.postMessage({
|
||||
enabled: Boolean(enabled),
|
||||
captureId: metadata?.captureId,
|
||||
});
|
||||
},
|
||||
play(data, mimeType) {
|
||||
if (closed) return;
|
||||
// Tiny chunks can exhaust source nodes long before the duration limit.
|
||||
if (sources.size >= MAX_QUEUED_SOURCES)
|
||||
throw new Error(
|
||||
msg('Gemini audio playback fell behind. Reconnect to continue.'),
|
||||
);
|
||||
const { samples, sampleRate } = decodeGeminiPcm(data, mimeType);
|
||||
const start = Math.max(playbackContext.currentTime + 0.02, nextTime);
|
||||
if (
|
||||
start + samples.length / sampleRate - playbackContext.currentTime >
|
||||
MAX_QUEUED_SECONDS
|
||||
)
|
||||
throw new Error(
|
||||
msg('Gemini audio playback fell behind. Reconnect to continue.'),
|
||||
);
|
||||
const buffer = playbackContext.createBuffer(
|
||||
1,
|
||||
samples.length,
|
||||
sampleRate,
|
||||
);
|
||||
buffer.copyToChannel(samples, 0);
|
||||
const source = playbackContext.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
source.connect(output);
|
||||
sources.set(source, samples.length);
|
||||
source.onended = () => {
|
||||
if (!sources.delete(source)) return;
|
||||
completedOutputSamples += samples.length;
|
||||
source.disconnect();
|
||||
if (!closed && !sources.size) onDrain?.();
|
||||
};
|
||||
nextTime = start + buffer.duration;
|
||||
outputChunks++;
|
||||
outputSamples += samples.length;
|
||||
source.start(start);
|
||||
},
|
||||
clearPlayback,
|
||||
diagnostics() {
|
||||
return {
|
||||
inputChunks,
|
||||
inputPeak,
|
||||
nonSilentInputChunks,
|
||||
outputChunks,
|
||||
outputSamples,
|
||||
completedOutputSamples,
|
||||
discardedOutputSamples,
|
||||
queuedSeconds: Math.max(0, nextTime - playbackContext.currentTime),
|
||||
contextState:
|
||||
captureContext.state === playbackContext.state
|
||||
? captureContext.state
|
||||
: 'suspended',
|
||||
captureContextState: captureContext.state,
|
||||
playbackContextState: playbackContext.state,
|
||||
captureSampleRate: captureContext.sampleRate,
|
||||
playbackSampleRate: playbackContext.sampleRate,
|
||||
inputSampleRate: 16000,
|
||||
suppressedPttCaptures,
|
||||
admittedPttCaptures,
|
||||
pttBufferedBytes: pendingPtt?.bytes || 0,
|
||||
};
|
||||
},
|
||||
close() {
|
||||
if (closed) return closeResult;
|
||||
closed = true;
|
||||
pendingPtt = null;
|
||||
clearPlayback();
|
||||
if (capture) {
|
||||
capture.port.onmessage = null;
|
||||
capture.onprocessorerror = null;
|
||||
capture.port.close();
|
||||
capture.disconnect();
|
||||
}
|
||||
microphone?.disconnect();
|
||||
mute?.disconnect();
|
||||
output.disconnect();
|
||||
meter.disconnect();
|
||||
meter.stream.getTracks?.().forEach((track) => track.stop());
|
||||
closeResult = Promise.allSettled([
|
||||
closeContext(captureContext),
|
||||
closeContext(playbackContext),
|
||||
]).then((results) =>
|
||||
results.every((result) => result.status === 'fulfilled'),
|
||||
);
|
||||
return closeResult;
|
||||
},
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,791 @@
|
||||
// Native automatic activity, reply ownership and expiry lifecycle regressions.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
harness,
|
||||
tick,
|
||||
deferred,
|
||||
} from '../testSupport/geminiNativeHarness.mjs';
|
||||
const vad = (ws, type) => ws.message({ voiceActivity: { type } });
|
||||
const onset = (ws) => {
|
||||
vad(ws, 'ACTIVITY_START');
|
||||
vad(ws, 'ACTIVITY_END');
|
||||
};
|
||||
const split = (ws) => {
|
||||
onset(ws);
|
||||
onset(ws);
|
||||
};
|
||||
const audio = {
|
||||
serverContent: {
|
||||
modelTurn: {
|
||||
parts: [
|
||||
{ inlineData: { data: 'AAAA', mimeType: 'audio/pcm;rate=24000' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const done = (ws) => ws.message({ serverContent: { turnComplete: true } });
|
||||
const tools = (ws, id) =>
|
||||
ws.message({
|
||||
toolCall: {
|
||||
functionCalls: [
|
||||
{
|
||||
id,
|
||||
name: 'adjust_camera_zoom',
|
||||
args: { direction: 'out', amount: 'little' },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const responses = (ws) =>
|
||||
ws.sent.flatMap((m) => m.toolResponse?.functionResponses || []);
|
||||
function close(h) {
|
||||
h.c.stop();
|
||||
assert.equal(h.timers.size, 0, 'owned timers released');
|
||||
}
|
||||
test('N1 VAD-only split admits the first real answer and completes after playback', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
await h.c.start();
|
||||
const ws = h.sockets[0];
|
||||
split(ws);
|
||||
ws.message(audio);
|
||||
ws.message({ serverContent: { outputTranscription: { text: 'Answer' } } });
|
||||
done(ws);
|
||||
assert.equal(h.captures[0].played.length, 1);
|
||||
h.captures[0].drain();
|
||||
assert.equal(
|
||||
h.events.findLast((e) => e.type === 'completion')?.status,
|
||||
'completed',
|
||||
);
|
||||
assert.equal(h.c.retiredReplyPending, false);
|
||||
} finally {
|
||||
close(h);
|
||||
}
|
||||
});
|
||||
test('N2 VAD-only split does not arm a nonexistent reply timeout', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
await h.c.start();
|
||||
split(h.sockets[0]);
|
||||
h.advance(5001);
|
||||
assert.equal(h.c.status, 'listening');
|
||||
assert.equal(h.c.retiredReplyPending, false);
|
||||
assert.equal(h.c.retiredBoundaryWait, null);
|
||||
} finally {
|
||||
close(h);
|
||||
}
|
||||
});
|
||||
test('N3 server interruption before output still fences until ordered turnComplete', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
await h.c.start();
|
||||
const ws = h.sockets[0];
|
||||
split(ws);
|
||||
ws.message({ serverContent: { interrupted: true } });
|
||||
assert.equal(h.c.retiredReplyPending, true);
|
||||
ws.message(audio);
|
||||
tools(ws, 'retired-before-boundary');
|
||||
await h.c.toolQueue;
|
||||
assert.equal(h.captures[0].played.length, 0);
|
||||
assert.equal(h.calls.length, 0);
|
||||
assert.equal(responses(ws).length, 0);
|
||||
ws.message({
|
||||
serverContent: { ...audio.serverContent, turnComplete: true },
|
||||
});
|
||||
assert.equal(
|
||||
h.captures[0].played.length,
|
||||
0,
|
||||
'coalesced retired content is discarded',
|
||||
);
|
||||
ws.message(audio);
|
||||
assert.equal(h.captures[0].played.length, 1);
|
||||
done(ws);
|
||||
h.captures[0].drain();
|
||||
assert.equal(
|
||||
h.events.findLast((e) => e.type === 'completion')?.status,
|
||||
'completed',
|
||||
);
|
||||
} finally {
|
||||
close(h);
|
||||
}
|
||||
});
|
||||
test('N4 generationComplete is server evidence and next onset retires it', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
await h.c.start();
|
||||
const ws = h.sockets[0];
|
||||
onset(ws);
|
||||
ws.message({ serverContent: { generationComplete: true } });
|
||||
onset(ws);
|
||||
assert.equal(h.c.retiredReplyPending, true);
|
||||
assert.ok(h.c.retiredBoundaryWait);
|
||||
ws.message(audio);
|
||||
tools(ws, 'old-generation');
|
||||
await h.c.toolQueue;
|
||||
assert.equal(h.captures[0].played.length, 0);
|
||||
assert.equal(h.calls.length, 0);
|
||||
done(ws);
|
||||
assert.equal(h.c.retiredReplyPending, false);
|
||||
} finally {
|
||||
close(h);
|
||||
}
|
||||
});
|
||||
test('N5 goAway during VAD-only activity waits for actual reply and playback', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
await h.c.start();
|
||||
const ws = h.sockets[0];
|
||||
split(ws);
|
||||
ws.message({ goAway: { timeLeft: '30s' } });
|
||||
assert.equal(h.c.isActive(), true);
|
||||
assert.ok(h.c.goAway);
|
||||
assert.equal(ws.closed, undefined);
|
||||
h.advance(5001);
|
||||
assert.equal(h.c.isActive(), true);
|
||||
ws.message(audio);
|
||||
done(ws);
|
||||
assert.equal(
|
||||
h.c.isActive(),
|
||||
true,
|
||||
'pending actual PCM drains before expiry',
|
||||
);
|
||||
h.captures[0].drain();
|
||||
assert.equal(h.c.status, 'error');
|
||||
assert.equal(ws.closed, true);
|
||||
} finally {
|
||||
close(h);
|
||||
}
|
||||
});
|
||||
test('N6 first server tool after VAD-only split executes exactly once', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
await h.c.start();
|
||||
const ws = h.sockets[0];
|
||||
split(ws);
|
||||
tools(ws, 'new-generation');
|
||||
await h.c.toolQueue;
|
||||
assert.equal(h.calls.length, 1);
|
||||
assert.equal(
|
||||
responses(ws).filter((x) => x.id === 'new-generation').length,
|
||||
1,
|
||||
);
|
||||
assert.equal(responses(ws).at(-1).response.ok, true);
|
||||
tools(ws, 'new-generation');
|
||||
await h.c.toolQueue;
|
||||
assert.equal(h.calls.length, 1);
|
||||
assert.equal(
|
||||
responses(ws).filter((x) => x.id === 'new-generation').length,
|
||||
1,
|
||||
);
|
||||
} finally {
|
||||
close(h);
|
||||
}
|
||||
});
|
||||
test('C1 actual old audio followed by onset fences late audio and tool until boundary', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
await h.c.start();
|
||||
const ws = h.sockets[0];
|
||||
ws.message(audio);
|
||||
onset(ws);
|
||||
assert.equal(h.c.retiredReplyPending, true);
|
||||
ws.message(audio);
|
||||
tools(ws, 'late-old');
|
||||
await h.c.toolQueue;
|
||||
assert.equal(h.captures[0].played.length, 1);
|
||||
assert.equal(h.calls.length, 0);
|
||||
ws.message({ serverContent: { interrupted: true } });
|
||||
ws.message(audio);
|
||||
assert.equal(h.captures[0].played.length, 1);
|
||||
done(ws);
|
||||
ws.message(audio);
|
||||
assert.equal(h.captures[0].played.length, 2);
|
||||
} finally {
|
||||
close(h);
|
||||
}
|
||||
});
|
||||
test('C2 first server tool in flight is aborted and never answered after new onset', async () => {
|
||||
const gate = deferred(),
|
||||
entered = [];
|
||||
const h = harness({
|
||||
runAction: async (_n, _a, o) => {
|
||||
entered.push(o);
|
||||
await gate.promise;
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
try {
|
||||
await h.c.start();
|
||||
const ws = h.sockets[0];
|
||||
onset(ws);
|
||||
tools(ws, 'old-running');
|
||||
await tick();
|
||||
assert.equal(entered.length, 1);
|
||||
onset(ws);
|
||||
assert.equal(entered[0].signal.aborted, true);
|
||||
gate.resolve();
|
||||
await h.c.toolQueue;
|
||||
assert.equal(responses(ws).filter((x) => x.id === 'old-running').length, 0);
|
||||
assert.equal(h.c.retiredReplyPending, true);
|
||||
} finally {
|
||||
gate.resolve();
|
||||
close(h);
|
||||
}
|
||||
});
|
||||
test('C5 typed request before first server output still retires on next onset', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
await h.c.start();
|
||||
const ws = h.sockets[0];
|
||||
assert.equal(h.c.sendTextCommand('Zoom in'), true);
|
||||
onset(ws);
|
||||
assert.equal(h.c.retiredReplyPending, true);
|
||||
ws.message(audio);
|
||||
tools(ws, 'late-typed');
|
||||
await h.c.toolQueue;
|
||||
assert.equal(h.captures[0].played.length, 0);
|
||||
assert.equal(h.calls.length, 0);
|
||||
} finally {
|
||||
close(h);
|
||||
}
|
||||
});
|
||||
test('N7 old retired boundary preserves the newer automatic owner for goAway', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
await h.c.start();
|
||||
const ws = h.sockets[0];
|
||||
onset(ws);
|
||||
const oldOwner = h.c.automaticActivityOwner;
|
||||
ws.message(audio);
|
||||
onset(ws);
|
||||
const successor = h.c.automaticActivityOwner;
|
||||
assert.notEqual(successor, oldOwner);
|
||||
assert.equal(h.c.retiredAutomaticActivityOwner, oldOwner);
|
||||
ws.message({ serverContent: { interrupted: true, turnComplete: true } });
|
||||
assert.equal(h.c.automaticActivityOwner, successor);
|
||||
assert.equal(h.c.automaticTurnOpen, true);
|
||||
ws.message({ goAway: { timeLeft: '30s' } });
|
||||
assert.equal(h.c.isActive(), true);
|
||||
ws.message(audio);
|
||||
done(ws);
|
||||
h.captures[0].drain();
|
||||
assert.equal(h.c.status, 'error');
|
||||
} finally {
|
||||
close(h);
|
||||
}
|
||||
});
|
||||
test('N8 old automatic owner with no successor is cleared by its retired boundary', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
await h.c.start();
|
||||
const ws = h.sockets[0];
|
||||
onset(ws);
|
||||
const owner = h.c.automaticActivityOwner;
|
||||
ws.message(audio);
|
||||
ws.message({ serverContent: { interrupted: true } });
|
||||
assert.equal(h.c.retiredAutomaticActivityOwner, owner);
|
||||
done(ws);
|
||||
assert.equal(h.c.automaticTurnOpen, false);
|
||||
assert.equal(h.c.automaticActivityOwner, null);
|
||||
assert.equal(h.c.retiredAutomaticActivityOwner, null);
|
||||
ws.message({ goAway: { timeLeft: '30s' } });
|
||||
assert.equal(h.c.isActive(), false, 'no invented successor wait');
|
||||
} finally {
|
||||
close(h);
|
||||
}
|
||||
});
|
||||
test('N9 coalesced old completion and new VAD preserves the new owner', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
await h.c.start();
|
||||
const ws = h.sockets[0];
|
||||
onset(ws);
|
||||
ws.message(audio);
|
||||
vad(ws, 'ACTIVITY_END');
|
||||
ws.message({
|
||||
voiceActivity: { type: 'ACTIVITY_START' },
|
||||
serverContent: { interrupted: true, turnComplete: true },
|
||||
});
|
||||
assert.equal(h.c.automaticTurnOpen, true);
|
||||
assert.ok(h.c.automaticActivityOwner);
|
||||
assert.equal(h.c.retiredAutomaticActivityOwner, null);
|
||||
ws.message({ goAway: { timeLeft: '30s' } });
|
||||
assert.equal(h.c.isActive(), true);
|
||||
} finally {
|
||||
close(h);
|
||||
}
|
||||
});
|
||||
test('N10 stop, restart and destroy release automatic owner identities and timers', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
await h.c.start();
|
||||
onset(h.sockets[0]);
|
||||
const first = h.c.automaticActivityOwner;
|
||||
h.c.stop();
|
||||
assert.equal(h.c.automaticTurnOpen, false);
|
||||
assert.equal(h.c.automaticActivityOwner, null);
|
||||
assert.equal(h.c.retiredAutomaticActivityOwner, null);
|
||||
assert.equal(h.c.automaticActivitySequence, 0);
|
||||
await h.c.start();
|
||||
onset(h.sockets[1]);
|
||||
assert.notEqual(h.c.automaticActivityOwner, first);
|
||||
assert.notEqual(h.c.automaticActivityOwner.epoch, first.epoch);
|
||||
h.c.stop({ removeUi: true });
|
||||
assert.equal(h.c.disposed, true);
|
||||
assert.equal(h.c.automaticTurnOpen, false);
|
||||
assert.equal(h.c.automaticActivityOwner, null);
|
||||
assert.equal(h.c.retiredAutomaticActivityOwner, null);
|
||||
assert.equal(h.timers.size, 0);
|
||||
} finally {
|
||||
close(h);
|
||||
}
|
||||
});
|
||||
test('N11 deferred typed ownership counts discarded open-mic PCM without queuing or replay', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
await h.c.start();
|
||||
const ws = h.sockets[0];
|
||||
ws.message(audio);
|
||||
assert.equal(h.c.sendTextCommand('Zoom in'), true);
|
||||
assert.equal(h.c.sendTextCommand('Zoom out'), true);
|
||||
assert.equal(h.c.deferredInput.kind, 'user-text');
|
||||
const before = ws.sent.filter((x) => x.realtimeInput?.audio).length;
|
||||
const metadata = { captureId: h.c.captureOwner.captureId };
|
||||
h.captures[0].callbacks.onAudio(
|
||||
{ data: 'AAAA', mimeType: 'audio/pcm;rate=16000' },
|
||||
metadata,
|
||||
);
|
||||
assert.equal(ws.sent.filter((x) => x.realtimeInput?.audio).length, before);
|
||||
assert.equal(
|
||||
h.c.deferredInput.messages.some((x) => x.realtimeInput?.audio),
|
||||
false,
|
||||
);
|
||||
assert.equal(h.c.getDiagnostics().droppedAudioChunks, 1);
|
||||
assert.deepEqual(h.c.getDiagnostics().deferredAudioDrops, {
|
||||
nonPttOwner: 1,
|
||||
closedActivity: 0,
|
||||
});
|
||||
ws.message({ serverContent: { interrupted: true, turnComplete: true } });
|
||||
done(ws);
|
||||
assert.equal(
|
||||
ws.sent.filter((x) => x.realtimeInput?.audio).length,
|
||||
before,
|
||||
'no wrong-owner PCM replay after boundaries',
|
||||
);
|
||||
} finally {
|
||||
close(h);
|
||||
}
|
||||
});
|
||||
test('N12 goAway before old completion still waits for the newer automatic owner', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
await h.c.start();
|
||||
const ws = h.sockets[0];
|
||||
onset(ws);
|
||||
ws.message(audio);
|
||||
onset(ws);
|
||||
const successor = h.c.automaticActivityOwner;
|
||||
ws.message({ goAway: { timeLeft: '30s' } });
|
||||
assert.equal(h.c.isActive(), true);
|
||||
ws.message({ serverContent: { interrupted: true, turnComplete: true } });
|
||||
assert.equal(h.c.automaticActivityOwner, successor);
|
||||
assert.equal(
|
||||
h.c.isActive(),
|
||||
true,
|
||||
'old boundary during goAway cannot end successor wait',
|
||||
);
|
||||
ws.message(audio);
|
||||
done(ws);
|
||||
h.captures[0].drain();
|
||||
assert.equal(h.c.status, 'error');
|
||||
} finally {
|
||||
close(h);
|
||||
}
|
||||
});
|
||||
for (const interruptedBeforeExpiry of [false, true])
|
||||
test(`N13 no successor clears retired ownership with goAway ${interruptedBeforeExpiry ? 'after interruption' : 'before interruption'}`, async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
await h.c.start();
|
||||
const ws = h.sockets[0];
|
||||
onset(ws);
|
||||
ws.message(audio);
|
||||
if (interruptedBeforeExpiry)
|
||||
ws.message({ serverContent: { interrupted: true } });
|
||||
ws.message({ goAway: { timeLeft: '30s' } });
|
||||
assert.equal(h.c.isActive(), true);
|
||||
ws.message({ serverContent: { interrupted: true, turnComplete: true } });
|
||||
assert.equal(h.c.automaticTurnOpen, false);
|
||||
assert.equal(h.c.automaticActivityOwner, null);
|
||||
assert.equal(
|
||||
h.c.isActive(),
|
||||
false,
|
||||
'no successor finishes at its actual boundary',
|
||||
);
|
||||
} finally {
|
||||
close(h);
|
||||
}
|
||||
});
|
||||
test('N14 native interruption during goAway aborts pending old tool and suppresses late response', async () => {
|
||||
const gate = deferred(),
|
||||
entered = [];
|
||||
const h = harness({
|
||||
runAction: async (_n, _a, o) => {
|
||||
entered.push(o);
|
||||
await gate.promise;
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
try {
|
||||
await h.c.start();
|
||||
const ws = h.sockets[0];
|
||||
onset(ws);
|
||||
tools(ws, 'expiring-old-tool');
|
||||
await tick();
|
||||
assert.equal(entered.length, 1);
|
||||
ws.message({ goAway: { timeLeft: '30s' } });
|
||||
assert.equal(h.c.isActive(), true);
|
||||
ws.message({ serverContent: { interrupted: true, turnComplete: true } });
|
||||
assert.equal(entered[0].signal.aborted, true);
|
||||
gate.resolve();
|
||||
await h.c.toolQueue;
|
||||
assert.equal(
|
||||
responses(ws).filter((x) => x.id === 'expiring-old-tool').length,
|
||||
0,
|
||||
);
|
||||
assert.equal(h.c.isActive(), false);
|
||||
} finally {
|
||||
gate.resolve();
|
||||
close(h);
|
||||
}
|
||||
});
|
||||
test('N15 goAway still refuses local PTT and typed replacement without reopening microphone', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
await h.c.start();
|
||||
const ws = h.sockets[0];
|
||||
onset(ws);
|
||||
ws.message(audio);
|
||||
ws.message({ goAway: { timeLeft: '30s' } });
|
||||
const turn = h.c.turn,
|
||||
sent = ws.sent.length;
|
||||
assert.equal(h.c.bargeIn(), false);
|
||||
assert.equal(h.c.sendTextCommand('Zoom out'), false);
|
||||
h.c.interrupt('push-to-talk');
|
||||
assert.equal(h.c.turn, turn);
|
||||
assert.equal(ws.sent.length, sent);
|
||||
assert.equal(
|
||||
h.tracks.every((t) => !t.enabled),
|
||||
true,
|
||||
);
|
||||
ws.message({ serverContent: { interrupted: true, turnComplete: true } });
|
||||
assert.equal(h.c.isActive(), false);
|
||||
assert.equal(
|
||||
h.tracks.every((t) => t.readyState === 'ended'),
|
||||
true,
|
||||
);
|
||||
} finally {
|
||||
close(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('T-F1a PTT successor PCM survives retired boundary and a second input fences its late output', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
await h.c.start({ pushToTalk: true });
|
||||
const ws = h.sockets[0],
|
||||
a = h.captures[0];
|
||||
ws.message(audio);
|
||||
h.c.input.pushToTalkKeyHeld = true;
|
||||
assert.equal(h.c.input.bargeIn(), true);
|
||||
h.c.input.setMicrophoneEnabled(true);
|
||||
a.callbacks.onAudio(
|
||||
{ data: 'AAAB', mimeType: 'audio/pcm;rate=16000' },
|
||||
a.metadata,
|
||||
);
|
||||
h.c.input.setMicrophoneEnabled(false);
|
||||
assert.equal(h.c.replyPending, true);
|
||||
ws.message({ serverContent: { interrupted: true, turnComplete: true } });
|
||||
assert.equal(
|
||||
h.c.replyPending,
|
||||
true,
|
||||
'old boundary cannot clear submitted successor',
|
||||
);
|
||||
assert.equal(h.c.sendTextCommand('Zoom out second request'), true);
|
||||
assert.ok(h.c.currentTypedInput());
|
||||
const played = a.played.length;
|
||||
ws.message(audio);
|
||||
tools(ws, 'late-ptt-predecessor');
|
||||
await h.c.toolQueue;
|
||||
assert.equal(a.played.length, played);
|
||||
assert.equal(
|
||||
h.calls.length,
|
||||
0,
|
||||
'no old tool under newer local typed input',
|
||||
);
|
||||
done(ws);
|
||||
tools(ws, 'current-typed');
|
||||
await h.c.toolQueue;
|
||||
assert.equal(h.calls.length, 1);
|
||||
assert.equal(h.calls[0].options.userTurn, undefined);
|
||||
assert.equal(h.calls[0].options.isCurrent(), true);
|
||||
assert.equal(h.c.currentTypedInput().text, 'Zoom out second request');
|
||||
} finally {
|
||||
close(h);
|
||||
}
|
||||
});
|
||||
for (const expiryBeforeBoundary of [false, true])
|
||||
test(`T-F1b VAD then submitted text survives goAway ${expiryBeforeBoundary ? 'before' : 'after'} retired boundary`, async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
await h.c.start();
|
||||
const ws = h.sockets[0];
|
||||
ws.message(audio);
|
||||
onset(ws);
|
||||
assert.equal(h.c.sendTextCommand('Zoom out successor'), true);
|
||||
const typed = h.c.currentTypedInput();
|
||||
assert.ok(typed);
|
||||
assert.equal(h.c.replyPending, true);
|
||||
if (expiryBeforeBoundary) ws.message({ goAway: { timeLeft: '30s' } });
|
||||
ws.message({ serverContent: { interrupted: true, turnComplete: true } });
|
||||
assert.equal(h.c.replyPending, true);
|
||||
if (!expiryBeforeBoundary) ws.message({ goAway: { timeLeft: '30s' } });
|
||||
assert.equal(h.c.isActive(), true, 'submitted text must finish');
|
||||
tools(ws, 'valid-successor');
|
||||
await h.c.toolQueue;
|
||||
assert.equal(h.calls.length, 1);
|
||||
assert.equal(h.calls[0].options.userTurn, undefined);
|
||||
assert.equal(h.calls[0].options.isCurrent(), true);
|
||||
assert.equal(h.c.currentTypedInput(), typed);
|
||||
ws.message(audio);
|
||||
done(ws);
|
||||
assert.equal(h.c.isActive(), true);
|
||||
h.captures[0].drain();
|
||||
assert.equal(h.c.isActive(), false);
|
||||
} finally {
|
||||
close(h);
|
||||
}
|
||||
});
|
||||
for (const repeat of [false, true])
|
||||
test(`T-F2 forwarded A superseded by deferred B cannot run after goAway${repeat ? ' shortened twice' : ''}`, async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
await h.c.start();
|
||||
const ws = h.sockets[0];
|
||||
ws.message(audio);
|
||||
assert.equal(h.c.sendTextCommand('Zoom out A'), true);
|
||||
assert.equal(h.c.replyPending, true);
|
||||
assert.equal(h.c.sendTextCommand('Zoom out B'), true);
|
||||
assert.equal(h.c.deferredInput.kind, 'user-text');
|
||||
assert.ok(h.c.currentTypedInput());
|
||||
onset(ws);
|
||||
assert.equal(h.c.automaticTurnOpen, true);
|
||||
ws.message({ goAway: { timeLeft: '30s' } });
|
||||
assert.equal(h.c.deferredInput, null);
|
||||
if (repeat) {
|
||||
ws.message({ goAway: { timeLeft: '20s' } });
|
||||
ws.message({ goAway: { timeLeft: '10s' } });
|
||||
}
|
||||
assert.equal(h.c.goAway.supersededSuccessor, true);
|
||||
assert.equal(h.c.isActive(), true);
|
||||
ws.message({ serverContent: { interrupted: true, turnComplete: true } });
|
||||
tools(ws, 'superseded-A');
|
||||
await h.c.toolQueue;
|
||||
assert.equal(h.calls.length, 0, 'A cannot run with dropped B authority');
|
||||
assert.equal(
|
||||
h.c.isActive(),
|
||||
false,
|
||||
'ordered boundary closes superseded forwarded predecessor',
|
||||
);
|
||||
} finally {
|
||||
close(h);
|
||||
}
|
||||
});
|
||||
test('T-F3 VAD-only no-generation expiry waits to bounded native deadline and releases resources', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
await h.c.start();
|
||||
onset(h.sockets[0]);
|
||||
h.sockets[0].message({ goAway: { timeLeft: '30s' } });
|
||||
h.advance(29999);
|
||||
assert.equal(h.c.isActive(), true);
|
||||
h.advance(1);
|
||||
assert.equal(h.c.isActive(), false);
|
||||
assert.equal(h.timers.size, 0);
|
||||
assert.equal(
|
||||
h.tracks.every((t) => t.readyState === 'ended'),
|
||||
true,
|
||||
);
|
||||
} finally {
|
||||
close(h);
|
||||
}
|
||||
});
|
||||
test('T-C1 coalesced new START and natural old completion preserve newer automatic activity', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
await h.c.start();
|
||||
const ws = h.sockets[0];
|
||||
onset(ws);
|
||||
ws.message(audio);
|
||||
ws.message({
|
||||
voiceActivity: { type: 'ACTIVITY_START' },
|
||||
serverContent: { turnComplete: true },
|
||||
});
|
||||
assert.equal(h.c.retiredReplyPending, false);
|
||||
assert.equal(h.c.automaticTurnOpen, true);
|
||||
ws.message({ goAway: { timeLeft: '30s' } });
|
||||
assert.equal(h.c.isActive(), true);
|
||||
} finally {
|
||||
close(h);
|
||||
}
|
||||
});
|
||||
test('T-C2 generationComplete alone followed by natural completion emits completion with no retirement timer', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
await h.c.start();
|
||||
const ws = h.sockets[0];
|
||||
onset(ws);
|
||||
ws.message({ serverContent: { generationComplete: true } });
|
||||
assert.equal(h.c.replyPending, true);
|
||||
done(ws);
|
||||
assert.equal(
|
||||
h.events.findLast((e) => e.type === 'completion')?.status,
|
||||
'completed',
|
||||
);
|
||||
assert.equal(h.c.replyPending, false);
|
||||
assert.equal(h.c.automaticTurnOpen, false);
|
||||
assert.equal(h.c.retiredBoundaryWait, null);
|
||||
assert.equal(h.timers.size, 0);
|
||||
} finally {
|
||||
close(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('T-C3 silent PTT terminal completion emits spoken false without fabricated audio', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
await h.c.start({ pushToTalk: true });
|
||||
const ws = h.sockets[0],
|
||||
a = h.captures[0];
|
||||
h.c.input.setMicrophoneEnabled(true);
|
||||
a.callbacks.onAudio(
|
||||
{ data: 'AAAB', mimeType: 'audio/pcm;rate=16000' },
|
||||
a.metadata,
|
||||
);
|
||||
h.c.input.setMicrophoneEnabled(false);
|
||||
assert.equal(h.c.userTurnPending, true);
|
||||
ws.message({ serverContent: { generationComplete: true } });
|
||||
done(ws);
|
||||
const event = h.events.findLast((e) => e.type === 'completion');
|
||||
assert.equal(event.status, 'completed');
|
||||
assert.equal(event.spoken, false);
|
||||
assert.equal(a.played.length, 0);
|
||||
assert.equal(h.c.userTurnPending, false);
|
||||
} finally {
|
||||
close(h);
|
||||
}
|
||||
});
|
||||
test('T-C4 silent terminal boundary waits for pending tool and native follow-up completion', async () => {
|
||||
const gate = deferred(),
|
||||
entered = [];
|
||||
const h = harness({
|
||||
runAction: async (_n, _a, o) => {
|
||||
entered.push(o);
|
||||
await gate.promise;
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
try {
|
||||
await h.c.start();
|
||||
const ws = h.sockets[0];
|
||||
onset(ws);
|
||||
tools(ws, 'pending-terminal-tool');
|
||||
await tick();
|
||||
assert.equal(entered.length, 1);
|
||||
done(ws);
|
||||
assert.equal(
|
||||
h.events.some((e) => e.type === 'completion'),
|
||||
false,
|
||||
);
|
||||
assert.equal(h.c.userTurnPending, false);
|
||||
gate.resolve();
|
||||
await h.c.toolQueue;
|
||||
h.c.finishPlayback();
|
||||
assert.equal(
|
||||
h.events.some((e) => e.type === 'completion'),
|
||||
false,
|
||||
);
|
||||
assert.equal(h.c.replyPending, true, 'tool result awaits native follow-up');
|
||||
done(ws);
|
||||
assert.equal(
|
||||
h.events.findLast((e) => e.type === 'completion')?.status,
|
||||
'completed',
|
||||
);
|
||||
} finally {
|
||||
gate.resolve();
|
||||
close(h);
|
||||
}
|
||||
});
|
||||
test('T-C5 terminal boundary with queued PCM waits for drain and preserves real spoken state', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
await h.c.start();
|
||||
const ws = h.sockets[0];
|
||||
onset(ws);
|
||||
ws.message(audio);
|
||||
ws.message({ serverContent: { generationComplete: true } });
|
||||
done(ws);
|
||||
assert.equal(
|
||||
h.events.some((e) => e.type === 'completion'),
|
||||
false,
|
||||
);
|
||||
h.captures[0].drain();
|
||||
const event = h.events.findLast((e) => e.type === 'completion');
|
||||
assert.equal(event.status, 'completed');
|
||||
assert.equal(event.spoken, true);
|
||||
} finally {
|
||||
close(h);
|
||||
}
|
||||
});
|
||||
test('T-C6 old retired terminal boundary cannot clear successor userTurnPending', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
await h.c.start();
|
||||
const ws = h.sockets[0];
|
||||
ws.message(audio);
|
||||
assert.equal(h.c.sendTextCommand('Zoom out successor'), true);
|
||||
assert.equal(h.c.userTurnPending, true);
|
||||
done(ws);
|
||||
assert.equal(h.c.userTurnPending, true);
|
||||
assert.equal(
|
||||
h.events.some((e) => e.type === 'completion'),
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
close(h);
|
||||
}
|
||||
});
|
||||
test('T-C7 reentrant silent completion observer keeps new input pending', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
await h.c.start();
|
||||
const ws = h.sockets[0];
|
||||
const emit = h.c.emit.bind(h.c);
|
||||
h.c.emit = (event) => {
|
||||
emit(event);
|
||||
if (event.type === 'completion')
|
||||
h.c.sendTextCommand('Zoom out reentrant');
|
||||
};
|
||||
onset(ws);
|
||||
ws.message({ serverContent: { generationComplete: true } });
|
||||
done(ws);
|
||||
assert.equal(h.events.filter((e) => e.type === 'completion').length, 1);
|
||||
assert.equal(h.c.replyPending, true);
|
||||
assert.equal(h.c.userTurnPending, true);
|
||||
assert.ok(h.c.currentTypedInput());
|
||||
assert.equal(h.c.modelComplete, false);
|
||||
} finally {
|
||||
close(h);
|
||||
}
|
||||
});
|
||||
Loaded 100 of 126 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user