diff --git a/.env.example b/.env.example index 5ddf78383..2fc23f721 100644 --- a/.env.example +++ b/.env.example @@ -237,6 +237,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 diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index d930bcb8f..274a6ae4c 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -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) | @@ -162,6 +163,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **Warendorf webcam.** The Stadt Warendorf Marktplatz webcam, registered from the curated `config/cctv_sources.warendorf.json` (the Kreis Warendorf registration-office cameras from the contributing PR are indoor waiting rooms and were left out); only the municipal image host is proxied, frames are fetched at request time and never stored or redistributed. Disable with `CCTV_WARENDORF_ENABLED=0`. - **Live Traffic NSW cameras.** The camera list is the keyless public `data.livetraffic.com/cameras/traffic-cam.json` feed (Transport for NSW open data, CC BY 4.0); frames are stills on `webcams.transport.nsw.gov.au`, and only that host is registered. That image host answers non-browser clients with an HTML placeholder, so the proxy identifies as a browser for that one host and as itself everywhere else. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_NSW_ENABLED=0`. - **Open Calgary traffic cameras.** Keyless. The camera list is the public Socrata dataset `k7p9-kppz` on `data.calgary.ca`; frames are stills on the city's own `trafficcam.calgary.ca` host, and only that host is registered. Most rows publish an `http://` frame URL and the host redirects those to HTTPS, so URLs are upgraded and pinned to that origin before registration. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `quadrant` field and the quadrant suffix on each camera name are Calgary's address grid, not a bearing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. The licence requires the attribution statement above. Disable with `CCTV_CALGARY_ENABLED=0`. +- **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. diff --git a/README.md b/README.md index c3c3d7ef3..55cf47189 100644 --- a/README.md +++ b/README.md @@ -377,7 +377,7 @@ Twenty layers and map sources. **Eighteen have a keyless path.** Some offer addi | 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 | | ◍ **Air quality** | Live AQHI on the published 1–10+ health-risk scale, with a Weather-panel card showing the band legend and the highest readings in view. **Canada only** — ECCC is the first registered provider | ECCC MSC GeoMet | 🟢 | | 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds | -| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement) and opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | +| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit), 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 | 🟢 | diff --git a/changelog.d/1056-area-tool-breakdowns.md b/changelog.d/1056-area-tool-breakdowns.md new file mode 100644 index 000000000..3ca857a45 --- /dev/null +++ b/changelog.d/1056-area-tool-breakdowns.md @@ -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). diff --git a/changelog.d/605-fl511-florida-cameras.md b/changelog.d/605-fl511-florida-cameras.md new file mode 100644 index 000000000..76f840198 --- /dev/null +++ b/changelog.d/605-fl511-florida-cameras.md @@ -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). diff --git a/changelog.d/880-world-packet-boundary.md b/changelog.d/880-world-packet-boundary.md new file mode 100644 index 000000000..7ee574e35 --- /dev/null +++ b/changelog.d/880-world-packet-boundary.md @@ -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. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 988be74ed..045d6e26b 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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 @@ -3434,7 +3446,7 @@ its criteria cannot be silently ignored. | Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s | | Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min | | Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache | -| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) | +| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + 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 | diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 3d31fe78b..45ee62546 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -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 | diff --git a/package.json b/package.json index 99e35c0c5..a6396db73 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 4668c4f5a..4230c23ae 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -637,6 +637,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", @@ -1029,6 +1030,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", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 7085cfc74..264f58440 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1044,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", @@ -1845,6 +1846,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", @@ -2219,6 +2221,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", @@ -2633,6 +2636,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", @@ -3786,6 +3790,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", @@ -3947,5 +3952,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"] } } diff --git a/server/providers/cctv/catalog.js b/server/providers/cctv/catalog.js index 75e58d2a6..0e69ac3c9 100644 --- a/server/providers/cctv/catalog.js +++ b/server/providers/cctv/catalog.js @@ -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). diff --git a/server/providers/cctv/constants.js b/server/providers/cctv/constants.js index 20b963770..6ac545028 100644 --- a/server/providers/cctv/constants.js +++ b/server/providers/cctv/constants.js @@ -113,6 +113,27 @@ 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 = diff --git a/server/providers/cctv/normalize.js b/server/providers/cctv/normalize.js index 2d4e16e11..37d846c1c 100644 --- a/server/providers/cctv/normalize.js +++ b/server/providers/cctv/normalize.js @@ -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 ( diff --git a/server/providers/cctv/sources.js b/server/providers/cctv/sources.js index c3d11e752..2e818c046 100644 --- a/server/providers/cctv/sources.js +++ b/server/providers/cctv/sources.js @@ -55,6 +55,12 @@ import { DEFAULT_CALGARY_MAX_SOURCES, CALGARY_DOWNTOWN, CALGARY_MAX_CATALOG_BYTES, + 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, @@ -113,6 +119,7 @@ import { isLikelyTexasCoordinate, isLikelyNswCoordinate, isLikelyCalgaryCoordinate, + isLikelyFloridaCoordinate, isLikelyCataloniaCoordinate, decodeNumericEntities, isLikelyNycCoordinate, @@ -2784,3 +2791,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>} 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 []; + } +} diff --git a/src/agent/worldPacket.js b/src/agent/worldPacket.js new file mode 100644 index 000000000..4f8dad144 --- /dev/null +++ b/src/agent/worldPacket.js @@ -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 }, + ), + }; +} diff --git a/src/agent/worldPacket.test.mjs b/src/agent/worldPacket.test.mjs new file mode 100644 index 000000000..063e3652c --- /dev/null +++ b/src/agent/worldPacket.test.mjs @@ -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/); +}); diff --git a/src/data/cctvFlorida.test.mjs b/src/data/cctvFlorida.test.mjs new file mode 100644 index 000000000..6d433a2f2 --- /dev/null +++ b/src/data/cctvFlorida.test.mjs @@ -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:pass@images-dis.divas.cloud/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); + } +}); diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 8d3949351..f3bdd645a 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -263,6 +263,14 @@ export const DATA_CREDITS = [ 'Traffic cameras (Calgary): contains information licensed under the ' + 'Open Government Licence – City of Calgary', }, + { + key: 'florida-cctv', + html: + 'Traffic cameras (Florida): FL511, Florida Department of Transportation. ' + + 'Individual, personal use only under the ' + + 'FL511 terms; ' + + "any other use needs FDOT's express written consent", + }, { key: 'catalonia-cctv', html: diff --git a/src/data/vesselLabels.js b/src/data/vesselLabels.js index 311a238c6..fbf209040 100644 --- a/src/data/vesselLabels.js +++ b/src/data/vesselLabels.js @@ -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; diff --git a/src/data/vesselLabels.test.mjs b/src/data/vesselLabels.test.mjs index f69fffcc7..1573daa75 100644 --- a/src/data/vesselLabels.test.mjs +++ b/src/data/vesselLabels.test.mjs @@ -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), ''); }); diff --git a/src/data/vesselTypes.js b/src/data/vesselTypes.js new file mode 100644 index 000000000..550f4596b --- /dev/null +++ b/src/data/vesselTypes.js @@ -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'; +} diff --git a/src/navigationPolicyFrame.test.mjs b/src/navigationPolicyFrame.test.mjs index a861870cf..5110232f1 100644 --- a/src/navigationPolicyFrame.test.mjs +++ b/src/navigationPolicyFrame.test.mjs @@ -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 diff --git a/src/tools/queries/aviation.js b/src/tools/queries/aviation.js index 235f542e1..a5f4babf0 100644 --- a/src/tools/queries/aviation.js +++ b/src/tools/queries/aviation.js @@ -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), }, }; diff --git a/src/tools/queries/aviation.test.mjs b/src/tools/queries/aviation.test.mjs index d0e979ed6..1af1352c0 100644 --- a/src/tools/queries/aviation.test.mjs +++ b/src/tools/queries/aviation.test.mjs @@ -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); +}); diff --git a/src/tools/queries/maritime.js b/src/tools/queries/maritime.js index 5b64f2826..5798a0936 100644 --- a/src/tools/queries/maritime.js +++ b/src/tools/queries/maritime.js @@ -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), }, }; diff --git a/src/tools/queries/maritime.test.mjs b/src/tools/queries/maritime.test.mjs index 785e38223..d86bdf352 100644 --- a/src/tools/queries/maritime.test.mjs +++ b/src/tools/queries/maritime.test.mjs @@ -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 }, + }); +}); diff --git a/src/tools/results.js b/src/tools/results.js index f9af7af3d..247383757 100644 --- a/src/tools/results.js +++ b/src/tools/results.js @@ -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; diff --git a/src/tools/results.test.mjs b/src/tools/results.test.mjs new file mode 100644 index 000000000..2e480afcd --- /dev/null +++ b/src/tools/results.test.mjs @@ -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'), ''); +}); diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 26974ce60..bf3e5b8b8 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -1,6 +1,5 @@ import { layerSnapshot, - layerSnapshots, feedProvenanceEnvelope, } from '../data/layerSnapshot.js'; import { readLayerLifecycleSummary } from './layerSummary.js'; @@ -27,7 +26,6 @@ import { isContextRecordActive, } from '../data/contextStore.js'; import { CCTV_FOCUS_RESULT } from '../layers/cctv/index.js'; -import { contextModeWord } from '../contextModePolicy.js'; import { createAnalystEngine, normalizeAnalystSpec, @@ -48,6 +46,12 @@ import { unavailablePlaceSearch } from '../search/placeSearch.js'; import * as defaultAnnotationResolver from '../annotations/annotationResolver.js'; import { normalizeRadioCountryInput } from '../data/radioCountry.js'; import { TR3B_CLASS } from '../data/tr3bRegistry.js'; +import { + getWorldPacket, + withContextModeVocabulary, + activeContactsWindow, + TRACKABLE_FAMILIES, +} from '../agent/worldPacket.js'; const ALLOWED_STYLES = new Set([ 'normal', @@ -119,64 +123,6 @@ const CONTEXT_MODE_ALIASES = new Map([ ['space-missions', 'space-missions'], ['missions', 'space-missions'], ]); -/** - * Every model-readable field that carries a context-mode id, and what an - * absent value means for each. - * - * `mode` always names a mode, so nothing is 'off'. `entering` and `priorMode` - * are absent when there is no such mode at all — calling those 'off' would - * assert a state that does not exist. - */ -const CONTEXT_MODE_RESULT_FIELDS = Object.freeze([ - { field: 'mode', emptyAs: 'off' }, - { field: 'entering', emptyAs: null }, - { field: 'priorMode', emptyAs: null }, -]); - -/** Nested results that are themselves context-mode payloads the model reads. */ -const NESTED_CONTEXT_RESULT_FIELDS = Object.freeze([ - 'context', - 'contextRollback', -]); - -/** - * Report a context-mode payload in the tools' own vocabulary. - * - * `set_context_mode` accepts 'contacts' while the mode's internal id is - * 'flights'. Reporting the internal id back made the model read - * `mode:'flights'` as "Contacts is off" and refuse to answer from the Contacts - * window counts sitting in the very same payload (owner field session - * 2026-08-21). Secondary fields and nested transition/rollback results are - * translated too — one leaked internal id is enough to recreate the confusion, - * and a rollback result is exactly what the model reads when something went - * wrong. Each internal id is kept alongside as `Internal` for anything - * reasoning about layers. - * @param {object|null|undefined} state Any payload carrying context-mode fields. - * @returns {object|null|undefined} The same payload, modes translated. - */ -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; -} - const COCKPIT_ACTION_ALIASES = new Map([ ['next', 'next'], ['previous', 'previous'], @@ -246,14 +192,6 @@ const STACK_ALIASES = new Map([ ['road map', 'osm'], ]); -/** Search order for track_entity across entity layer families. */ -const TRACKABLE_FAMILIES = [ - { layerId: 'flights', kind: 'aircraft' }, - { layerId: 'military', kind: 'aircraft' }, - { layerId: 'ais-live-vessels', kind: 'vessel' }, - { layerId: 'satellites', kind: 'satellite' }, -]; - const FRAME_TARGETS = new Map([ ['flights', 'flights'], ['planes', 'flights'], @@ -2553,26 +2491,6 @@ function runManagedVoiceNavigation( }; } -/** Gathers tracked/selected entities across layer families for read-back. */ -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 { - // layer not ready - } - } - return tracked; -} - export async function getBasemapLabelContext( viewer, service = defaultGeospatial, @@ -3210,60 +3128,12 @@ function getCurrentViewState( dataManager, sceneDirector = null, ) { - const cartographic = Cesium.Cartographic.fromCartesian( - viewer.camera.positionWC, - ); - 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: - typeof styleManager.getContextModeState === 'function' - ? { - ...withContextModeVocabulary(styleManager.getContextModeState()), - // The numbers on the operator's Contacts panel, so a window/count - // question can be answered from what they are looking at. - ...(activeContactsWindow(dataManager) - ? { contactsWindow: activeContactsWindow(dataManager) } - : {}), - } - : null, - cockpit: - typeof styleManager.getCockpitState === 'function' - ? styleManager.getCockpitState() - : null, - controls: - typeof styleManager.getControlState === 'function' - ? styleManager.getControlState() - : null, - scenePlayback: sceneDirector?.getPlaybackStatus?.() || null, - tracked: collectTrackedEntities(dataManager), - // Enabled layers only: the full registry cost ~1k tokens per call and - // the model only reasons about what is on. - disabledLayerCount: dataManager.getAll().filter((layer) => !layer.enabled) - .length, - layers: dataManager - .getAll() - .filter((layer) => layer.enabled) - .map((layer) => ({ - id: layer.id, - name: layer.name, - enabled: layer.enabled, - count: layer.stats?.count || 0, - error: layer.stats?.error || null, - feedState: layerSnapshot(layer).feedState, - source: layerSnapshot(layer).source, - lastUpdate: layerSnapshot(layer).lastUpdate, - })), - feedProvenance: feedProvenanceEnvelope( - layerSnapshots(dataManager.getAll()).filter((layer) => layer.enabled), - ), - }; + return getWorldPacket({ + viewer, + styleManager, + dataManager, + sceneDirector, + }); } async function getEntityContext( @@ -4635,23 +4505,6 @@ function clampNumber(value, min, max, fallback) { * ("which of those is closest?") scoped to the session. */ -/** - * The Contacts panel's own counts, or null when Contacts has no subject. - * Read through the awareness snapshot the panel renders, so the two cannot - * diverge no matter which surface asks. - * @returns {object|null} Panel-equivalent window counts. - */ -function activeContactsWindow(dataManager) { - try { - const layer = dataManager?.layers?.get('military-awareness')?.module; - return ( - layer?.contactsWindowFromSnapshot?.(layer.getContextSnapshot?.()) ?? null - ); - } catch { - return null; - } -} - /** * Most records one layer hands a single query. Above every loaded feed seen * so far (flights ~8k, vessels ~12k, a 24 h global FIRMS day ~160k), so