From 1fc795577ae180ece1eb1ce39cb78c9aa647108f Mon Sep 17 00:00:00 2001 From: Bilawal Sidhu <106619546+bilawalsidhu@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:00:53 -0500 Subject: [PATCH] =?UTF-8?q?feat(sdr):=20local=20receivers=20=E2=80=94=20br?= =?UTF-8?q?owser=20RTL-SDR,=20Local=20ADS-B=20layer=20and=20decoder=20feed?= =?UTF-8?q?s=20(#732)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Local ADS-B layer fed by your own receivers: a browser WebUSB RTL-SDR (with FM listening) and local 1090/978 MHz decoder feeds (LOCAL_RECEIVER_FEEDS, local and private addresses only). Local aircraft draw in magenta with class, silhouettes, 3D models, trails and a position sanity filter; the click card names the band and input that heard them. Builds on Sameh Khamis's SDR prototype and the initial GEV port by Mazeyar Moeini Feizabadi (#134); the decoder-feed design follows the notes on #57. Closes #57. Co-authored-by: Sameh Khamis Co-authored-by: Mazeyar Moeini Feizabadi --- .env.example | 7 + CHANGELOG.md | 54 + DATA_SOURCES.md | 4 + SECURITY.md | 1 + THIRD_PARTY_NOTICES.md | 21 + build/vite.js | 15 + docs/CURRENT-STATE.md | 128 ++ docs/LOCAL-RECEIVERS.md | 82 + package-lock.json | 24 + package.json | 2 + scripts/check-package-boundaries.mjs | 11 +- scripts/format-scope.json | 18 +- scripts/package-boundaries.json | 57 +- server/providers/local-receivers.js | 521 +++++++ server/providers/local.js | 2 + server/providers/openai/toolDescriptions.js | 2 +- server/standalone/vite.config.js | 3 +- src/app/catalog.js | 1 + src/app/catalog.test.mjs | 2 + src/app/constructCatalog.js | 23 +- src/app/constructCatalog.test.mjs | 8 +- src/app/layers/localAdsb.js | 48 + src/data/aircraftClass.js | 14 + src/data/fixtures/README.md | 13 + .../fixtures/adsb-austin-capture-timed.json | 1 + .../adsb-austin-dump1090-aircraft.json | 53 + src/data/fixtures/adsb-austin-frames.txt | 112 ++ src/data/lifecycle.js | 2 + src/data/localReceiversProxy.test.mjs | 459 ++++++ src/data/modelEligibility.js | 45 + src/data/modelEligibility.test.mjs | 48 + src/data/tr3bRegistry.test.mjs | 6 +- src/data/trackedReadout.js | 1 + src/firstRunExperience.test.mjs | 6 +- src/layers/flights/modelSpec.js | 52 + src/layers/flights/rendering.js | 74 +- src/layers/localAdsb/card.js | 143 ++ src/layers/localAdsb/enrichment.js | 166 ++ src/layers/localAdsb/feedNames.js | 21 + src/layers/localAdsb/feeds.js | 253 +++ src/layers/localAdsb/feeds.test.mjs | 256 +++ src/layers/localAdsb/index.js | 846 ++++++++++ src/layers/localAdsb/index.test.mjs | 1167 ++++++++++++++ src/layers/localAdsb/models.js | 282 ++++ src/layers/localAdsb/motion.js | 427 +++++ src/layers/localAdsb/motion.test.mjs | 209 +++ src/layers/localAdsb/policy.js | 21 + src/layers/localAdsb/status.js | 141 ++ src/layers/military/rendering.js | 25 +- src/radioMarkup.test.mjs | 8 +- src/sdr/adsbDecoder.js | 594 +++++++ src/sdr/adsbDecoder.test.mjs | 624 ++++++++ src/sdr/controller.js | 1367 +++++++++++++++++ src/sdr/controller.test.mjs | 1021 ++++++++++++ src/sdr/gain.js | 93 ++ src/sdr/gain.test.mjs | 78 + src/sdr/sdr.worker.js | 168 ++ src/sdr/sdrAudioWorklet.js | 48 + src/sdr/spectrum.js | 147 ++ src/sdr/spectrum.test.mjs | 43 + src/sdr/usbDevices.js | 203 +++ src/sdr/usbDevices.test.mjs | 222 +++ src/sources/adsbRecords.js | 400 +++++ src/sources/adsbRecords.test.mjs | 326 ++++ src/ui/applicationShell.js | 30 +- src/ui/layerPanel.js | 1 + src/ui/layerPanel.test.mjs | 1 + src/ui/localSdrControls.js | 297 ++++ src/ui/localSdrControls.test.mjs | 207 +++ src/ui/localSdrMarkup.test.mjs | 75 + src/ui/localSdrPresentation.js | 146 ++ src/ui/localSdrPresentation.test.mjs | 155 ++ src/ui/styles/radio.css | 129 ++ src/ui/templates/context.html | 75 +- src/voice/actionSchemas.js | 1 + src/voice/actionSchemas.test.mjs | 9 +- src/voice/gevActions.js | 6 + src/voice/gevActions.test.mjs | 24 + 78 files changed, 12267 insertions(+), 108 deletions(-) create mode 100644 THIRD_PARTY_NOTICES.md create mode 100644 docs/LOCAL-RECEIVERS.md create mode 100644 server/providers/local-receivers.js create mode 100644 src/app/layers/localAdsb.js create mode 100644 src/data/fixtures/adsb-austin-capture-timed.json create mode 100644 src/data/fixtures/adsb-austin-dump1090-aircraft.json create mode 100644 src/data/fixtures/adsb-austin-frames.txt create mode 100644 src/data/localReceiversProxy.test.mjs create mode 100644 src/data/modelEligibility.js create mode 100644 src/data/modelEligibility.test.mjs create mode 100644 src/layers/flights/modelSpec.js create mode 100644 src/layers/localAdsb/card.js create mode 100644 src/layers/localAdsb/enrichment.js create mode 100644 src/layers/localAdsb/feedNames.js create mode 100644 src/layers/localAdsb/feeds.js create mode 100644 src/layers/localAdsb/feeds.test.mjs create mode 100644 src/layers/localAdsb/index.js create mode 100644 src/layers/localAdsb/index.test.mjs create mode 100644 src/layers/localAdsb/models.js create mode 100644 src/layers/localAdsb/motion.js create mode 100644 src/layers/localAdsb/motion.test.mjs create mode 100644 src/layers/localAdsb/policy.js create mode 100644 src/layers/localAdsb/status.js create mode 100644 src/sdr/adsbDecoder.js create mode 100644 src/sdr/adsbDecoder.test.mjs create mode 100644 src/sdr/controller.js create mode 100644 src/sdr/controller.test.mjs create mode 100644 src/sdr/gain.js create mode 100644 src/sdr/gain.test.mjs create mode 100644 src/sdr/sdr.worker.js create mode 100644 src/sdr/sdrAudioWorklet.js create mode 100644 src/sdr/spectrum.js create mode 100644 src/sdr/spectrum.test.mjs create mode 100644 src/sdr/usbDevices.js create mode 100644 src/sdr/usbDevices.test.mjs create mode 100644 src/sources/adsbRecords.js create mode 100644 src/sources/adsbRecords.test.mjs create mode 100644 src/ui/localSdrControls.js create mode 100644 src/ui/localSdrControls.test.mjs create mode 100644 src/ui/localSdrMarkup.test.mjs create mode 100644 src/ui/localSdrPresentation.js create mode 100644 src/ui/localSdrPresentation.test.mjs diff --git a/.env.example b/.env.example index 1949c48f4..e16e85a27 100644 --- a/.env.example +++ b/.env.example @@ -129,6 +129,13 @@ AISSTREAM_API_KEY= # 0 disables the silence watch entirely. # AISSTREAM_SILENCE_TIMEOUT_MS=120000 +# Optional: local ADS-B decoder feeds for the Local ADS-B layer. SERVER-SIDE +# ONLY. Comma-separated band=url entries; band is 1090 or 978 (UAT). Each host +# must be loopback, a private (RFC1918) address, localhost or a *.local name; +# the path must end in aircraft.json. Invalid entries are logged and skipped. +# See docs/LOCAL-RECEIVERS.md. +# LOCAL_RECEIVER_FEEDS=1090=http://localhost:8080/data/aircraft.json,978=http://localhost:8978/data/aircraft.json + # Optional: TomTom live traffic flow (BYOK freemium — the free tier is 200,000 # Traffic Flow & Incidents tile requests per MONTH, not per day; no credit card # required. Get a key at https://developer.tomtom.com). SERVER-SIDE ONLY: diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bcdebf70..544f3780b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,60 @@ (`src/ui/imagerySplit.js`, `src/maps/imageryComparison.js`), and `MapSourceController.subscribe()` reports every settled map switch. +## Unreleased — local receiver feeds + +- The Local ADS-B layer also reads local 1090 MHz and 978 MHz UAT decoder + feeds: the `aircraft.json` that dump1090-fa, readsb, tar1090 or skyaware978 + serves. Configure them server-side with `LOCAL_RECEIVER_FEEDS` + (`band=url`, comma-separated); there is no feed editing in the browser. + Hosts must be loopback, RFC1918, `localhost` or `*.local`, the scheme http or + https and the path must end in `aircraft.json`; any other entry is logged at + startup, reported `invalid` and never fetched. +- Add `GET /api/local-receivers/aircraft`. It reads every configured feed in + parallel (2 s timeout, redirects refused, 2 MB body cap, about 1 s of shared + cache) and reports each feed `live`, `stale` (its own `now` is over 10 s old), + `unreachable` or `invalid`. Unconfigured, it answers + `{ configured: false }` and fetches nothing. Upstream error text is never + returned. +- The layer merges browser-SDR and feed aircraft by ICAO, keeping the newest + position, polls the route every second only while it is enabled, and remembers + which bands and sources heard each aircraft in the last 60 s. The click card + names them (for example "Heard by your receiver · 978 MHz UAT · decoder + feed"); aircraft heard only on 978 MHz carry a thin ring. The row status + covers both inputs ("2 feeds live · 14 heard", "feed 978 unreachable"), and + the Local RTL-SDR card shows a read-only decoder-feed line. +- While any input is producing aircraft (the browser receiver streaming, or a + feed live), the Local ADS-B row stays ON and lists feeds that are not live as + a trailing note ("3 heard · USB 5.8 msg/s · feed 1090 stale") instead of + showing DEGRADED. +- Records carry `band` (`1090`/`978`) and `source` (`webusb`/`feed`). +- See `docs/LOCAL-RECEIVERS.md`. + +## Unreleased — local RTL-SDR and Local ADS-B + +- Add a Local RTL-SDR card to the Radio panel. It connects a USB RTL-SDR in + desktop Chrome or Edge through WebUSB and receives broadcast FM (tune, seek, + volume) or 1090 MHz ADS-B. Local FM and internet-radio playback never play + together: starting one stops the other. +- Add a gain control: AUTO or a manual R820T step, remembered per mode. ADS-B + defaults to 28.0 dB (the earlier 20.7 dB gave about 1 msg/s against about 9 + at 28.0 dB on the same antenna; a stored choice still wins), FM to AUTO; + changes apply without reconnecting. In + ADS-B mode the card shows CRC-valid messages per second, aircraft heard, + aircraft positioned and the IQ level. +- Prefer the ADS-B/1090 MHz channel of a dual-channel receiver, remember an + explicitly chosen device per mode, and add CHANGE DEVICE to reopen the + WebUSB picker. +- Add the Local ADS-B layer (`local-adsb`, off by default, not part of share + links). Aircraft heard by the receiver draw in magenta beside public Flights; + a marker drops when its position is 60 s old and the aircraft is forgotten + after 60 s without a message. Clicking one opens a card; markers are not + camera-followed. Voice `set_layer_visibility` accepts `local-adsb`. +- Normalize local ADS-B into one record shape, with a pure adapter for + dump1090/readsb `aircraft.json` documents. +- Add `@jtarrio/webrtlsdr` and `@jtarrio/signals` (Apache-2.0); see + `THIRD_PARTY_NOTICES.md`. + ## Unreleased — weather review - On 3D Tiles, draw a 4096×2048 detail window around the view on each diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index a9cf38145..3a547d6d0 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -65,6 +65,10 @@ How to read this: | **Re:Earth Terrain** (Mapterhorn) | Terrain (keyless globe stacks — OSM etc. — + `/api/terrain/heights` ellipsoidal-height lookups) | Terrain mesh: CC BY 4.0; geoid: EGM2008 (NGA, public domain) | "Terrain (keyless globe stacks): Re:Earth Terrain / Mapterhorn (CC BY 4.0) / EGM2008 (NGA)" | | **OSRM on the FOSSGIS routing servers** (`routing.openstreetmap.de`) | Street-following routes for the Directions layer and voice route annotations, via `/api/route` | [FOSSGIS routing usage policy](https://routing.openstreetmap.de/about.html): "Display the required attribution and display a link to 'fix the map'", "Use a valid user agent and, if applicable, a correct referrer", "One request per second max", "No scraping, no heavy usage". The full policy is the German [FOSSGIS Nutzungsbedingungen](https://www.fossgis.de/arbeitsgruppen/osm-server/nutzungsbedingungen/); FOSSGIS also states that the server may be embedded in your own pages but "eine gewerbliche Nutzung ist nur mit Einschränkungen erlaubt" (commercial use only with restrictions). Route data derives from OpenStreetMap (ODbL 1.0) | "Routing: OSRM on the FOSSGIS servers" + "© OpenStreetMap contributors" + a "fix the map" link — shown in the Data attribution popover | +### Local receiver input (not fetched from a service) + +The optional Local ADS-B layer and the broadcast-FM receiver use radio you receive yourself: a USB RTL-SDR opened in the browser through WebUSB, or the `aircraft.json` of a decoder you run on your own machine or network (`LOCAL_RECEIVER_FEEDS`). Radio samples, audio and the optional receiver location stay in the browser. Decoder feeds are read by your local GEV server and relayed only to your browser, with about a second of in-memory caching. When you select a local aircraft, its ICAO address (and an airline-style callsign) is looked up, and aircraft that qualify for a 3D model are looked up by ICAO address, through the same adsbdb proxy the public flight layers use; your server caches those answers on disk and treats them as fresh for 24 hours. Received radio data is not stored or redistributed. ADS-B is an unauthenticated public broadcast and may be incomplete, delayed, spoofed or decoded in error; it is not suitable for navigation or operational use. + ### Notes on the live sources - **Google Maps Platform.** You supply your own API key and are bound by [Google's ToS](https://cloud.google.com/maps-platform/terms). Google Maps Content (tiles, geocodes, places) **may not be cached, stored, rehosted, or committed** — this app only ever uses it live, which is the compliant pattern. The "Google" attribution is displayed on the globe and must stay visible. Restrict your key (see [SECURITY.md](SECURITY.md)). diff --git a/SECURITY.md b/SECURITY.md index 9a46e2cdd..718c43778 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -66,6 +66,7 @@ The data proxies under `server/providers/` are written so the browser cannot tur - **Radio is not an audio relay.** `/api/radio/stations` contacts only allowlisted Radio Browser HTTPS hosts and paths, rejects redirects, rejects any hostname with a loopback/private/link-local/metadata/non-public A or AAAA result, and pins each TLS connection to a validated address. It returns normalized public HTTPS stream URLs; `/api/radio/click/:uuid` applies the same destination policy and accepts only station IDs from the current bounded catalog. The browser then connects directly to the broadcaster after an explicit playback action, so the broadcaster sees the listener's IP address. GEV never proxies, caches, records, or redistributes audio. - **No verbatim client headers upstream.** The CCTV media route is the one route that relays a request header (`Range`, for video seeking). It is parsed and canonicalized before it is forwarded: one `bytes=` range only, with every accepted form — explicit span, open-ended and suffix — bounded to the same 64 MiB ceiling the relay applies to a declared response body. Multi-range, malformed, inverted and non-`bytes` values are dropped and the request proceeds without a `Range`, as RFC 7233 §3.1 prescribes. Bounding the request bounds what is asked for: a response that declares no length — live streamed media, or a chunked body from an upstream that ignores the `Range` — has no ceiling. An upstream request the browser has stopped waiting for is cancelled rather than left running, whether the viewer leaves before the headers arrive or during the body. - **Transit fetches registered feeds only.** `/api/transit/vehicles/` resolves the id against `src/data/transitFeeds.js`; the browser never supplies a URL, and a feed that is registered but disabled does not resolve at all. Redirects are followed manually and each hop is validated against the feed's own https origin before it is requested, so an off-origin or downgraded hop is refused rather than contacted. Bodies are capped at 8 MB, the protobuf is decoded server-side under entity-count and string-length ceilings, a differential feed is refused, and snapshots live 15 s in memory with no disk cache. A per-feed admission limiter and a failure cooldown ladder bound what this process can ask of any operator. +- **Local receiver feeds are operator-configured and local-only.** `/api/local-receivers/aircraft` reads only the `aircraft.json` URLs in the server's `LOCAL_RECEIVER_FEEDS`; the browser never supplies an address. Each host must pass the shared tap address rule (`src/data/tapAddress.js`: loopback, RFC1918, `localhost`, `*.local`; no link-local, IPv6 or other names), the scheme must be http(s), the path must end in `aircraft.json`, and credentials, queries and fragments are refused. Invalid entries are logged at startup and never fetched. Reads use a 2 s timeout, refuse redirects, cap bodies at 2 MB and share one read per second; responses name each feed by band only and never carry upstream error text. - **Response-size caps and timeouts** on proxied responses. - **Sanitized errors** — internal error details are not echoed back to clients. - **Coalesced OAuth refresh** and cached successful responses only (OpenSky). diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 000000000..071299fba --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,21 @@ +# Third-party software notices + +The project depends on third-party npm packages whose own licenses apply. This +file records the packages added for the browser-local SDR feature; the complete +resolved dependency inventory remains in `package-lock.json`. + +## Web RTL-SDR + +- Package: `@jtarrio/webrtlsdr` 3.0.6 +- Author: Jacobo Tarrio Barreiro; portions copyright Google Inc. +- Source: +- License: Apache License 2.0 +- License text: + +## Signals + +- Package: `@jtarrio/signals` 0.10.1 +- Author: Jacobo Tarrio Barreiro +- Source: +- License: Apache License 2.0 +- License text: diff --git a/build/vite.js b/build/vite.js index 82a1c3d68..cdff4a27c 100644 --- a/build/vite.js +++ b/build/vite.js @@ -9,10 +9,25 @@ export function createBrowserViteConfig({ cesiumToken, host = 'localhost', port = 4173, + command, } = {}) { return { plugins: [cesium(), applicationHtmlPlugin(), ...plugins], ...(publicDir === undefined ? {} : { publicDir }), + // A production build must not clean the dependency cache a running dev + // server is still serving optimized module URLs from. + ...(command === 'build' ? { cacheDir: 'node_modules/.vite-build' } : {}), + optimizeDeps: { + // First reached through the SDR worker or a dynamic import. Pre-bundle + // them at startup so first use cannot invalidate already-transformed + // URLs with Vite's "Outdated Optimize Dep" 504 response. + include: [ + '@jtarrio/signals/demod/demodulator.js', + '@jtarrio/signals/demod/modes.js', + '@jtarrio/webrtlsdr/rtlsdr.js', + 'egm96-universal', + ], + }, server: { host: host || 'localhost', port: parseInt(port, 10) || 4173, diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 67acfffce..47ef461ce 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4104,6 +4104,134 @@ Existing catalog validation, category filters, tuning and voice playback behavio remain unchanged. Audio connects directly to the broadcaster after an explicit play action; the source does not relay or record streams. +## Local RTL-SDR and Local ADS-B + +The Radio panel holds a Local RTL-SDR card below internet radio. One browser +WebUSB session (`src/sdr/controller.js`) drives a USB RTL-SDR in desktop Chrome +or Edge on localhost or HTTPS; nothing opens until CONNECT. Demodulation and +Mode S decoding run in a worker; FM audio plays through an audio worklet. +Local FM and internet radio are one listening surface: starting local FM stops +internet-radio playback, and starting internet radio stops local FM. ADS-B +reception does not interrupt internet radio. + +- **Modes.** FM (87.5–108 MHz, tune, seek, volume) or ADS-B at 1090 MHz. + With a receiver open, selecting ADS-B enables the Local ADS-B layer; + selecting FM disables it. + Enabling the layer switches the receiver to ADS-B; disabling it leaves the + receiver mode unchanged. +- **Gain.** AUTO (tuner AGC) or a manual R820T step from 0.0 to 49.6 dB, + stored per mode in `gev:sdr:gain:v1`. Defaults: ADS-B 28.0 dB, FM AUTO. + Changes apply to the open receiver without reconnecting. +- **Receiver stats (ADS-B).** CRC-valid messages per second, aircraft heard, + aircraft with a fresh position and the IQ level. +- **Devices.** An already-authorized receiver opens without the WebUSB + picker. ADS-B prefers a device whose product name matches ADS-B or 1090 (the + 1090 MHz channel of a dual-channel board); FM avoids ADS-B/UAT channels. + A device chosen in the picker is remembered per mode by vendor, product, + product name and serial in `gev:sdr:device:v1`. CHANGE DEVICE reopens the + picker. LOCATE uses browser location for receiver-relative CPR decoding. + +The Local ADS-B layer (`local-adsb`, `src/layers/localAdsb/`) is off by default +and registered as local-only: it never enters share links or stored layer +state. Its aircraft get the public Flights treatment in magenta, alongside +the public Flights layer. Public Flights renders about one poll interval +(~30 s) behind for smooth interpolation while local aircraft are shown as +heard, so a local marker usually leads its public twin; both markers are drawn +on purpose: + +- **Class.** Each aircraft is classified with `aircraftClass.js` from its + ADS-B emitter category (dump1090 strings: `A1` light, `A3` large, `A7` + rotorcraft; set A/B/C/D = type code 4/3/2/1) and, once known, its adsbdb + ICAO type. Silhouette and per-class scale come from `aircraftIcons.js` + (×0.8 on the ground), tinted magenta. +- **3D.** The DISPLAY rail's 3D toggle and its proximity/all mode apply: the + layer reads the Flights layer's `models3d` params and uses the same ceiling, + caps, add/keep radii, visible-first eligibility + (`src/data/modelEligibility.js`) and per-class GLB + (`src/layers/flights/modelSpec.js`), tinted magenta through the same MIX + colour blend Flights uses for military amber. Heights are barometric + + geoid N, as Flights renders a contact without a geometric altitude; a + grounded model rides the one-shot ground snap and belly offset. +- **Motion.** No display delay. The marker is extrapolated forward from the + newest fix with `motionModel.js` (arc extrapolation, path-derived course + blended over the reported track, rate-limited slew); a new fix is absorbed + by a correction that decays over 900 ms. Coasting continues while messages + arrive and stops 10 s after the last one (60 s at most). +- **Sanity filter.** For airborne positions the browser decoder prefers a + fresh even/odd pair, then a frame decoded relative to the aircraft's own + position (under 10 minutes old), then the receiver location. Surface + positions are stricter: a single surface frame decodes only against the + aircraft's own recent fix, and only when that fix is provably within 45 NM + (it could not have gone farther since at its speed limit); the receiver + location never decodes a lone surface frame and only picks the quadrant of + a surface pair. Without an existing suitable fix for that aircraft + (airborne or surface, provably within 45 NM), surface acquisition + requires an even/odd pair. Every fix + must be reachable from the last accepted one at 1.5 × reported ground + speed + 50 kt (1,000 kt without a speed, 350 kt for A1/A7/B1/B4) over the + elapsed time + 1 s, plus 500 m. + Refused fixes are counted (`positionsRejected` in the SDR state; the layer + applies the same check to merged feed records and reports the total as + `rejectedPositions` in its stats). Three refusals in a row re-anchor. +- **Trail.** A selected aircraft draws a magenta trail of the positions the + receiver heard (up to 10 minutes / 600 fixes, dropped with the aircraft), + with the tracked-flight trail look and a live head segment. No network + backfill. +- **Enrichment.** Only the selected aircraft (type + route) and aircraft + about to render as models (type) are looked up, through the Flights + source's cached adsbdb proxy with the same once-per-session, four-in-flight, + 200 ms drip rules. + +A marker drops once its newest position is 60 s old; an aircraft is forgotten +after 60 s without a message. Clicking a marker opens a readout card (ICAO, +callsign, class and category, adsbdb operator/type and a plausible route when +known, altitude, ground speed, track, vertical rate, position age, message +count, "Heard by your receiver"); markers are not camera-followed. Positioned +aircraft join detection boxes, labelled only with a decoded callsign. + +Receivers publish one record shape from `src/sources/adsbRecords.js` +(`icao`, `callsign`, `category`, `onGround`, `lat`, `lon`, `altitudeFt`, +`groundSpeedKt`, `trackDeg`, `verticalRateFpm`, `lastPositionAt`, +`lastMessageAt`, `messageCount`, `rssiDbfs`, `band`, `source`). `band` is `1090` or `978`; `source` is +`webusb` (browser SDR) or `feed` (decoder feed). +`normalizeDump1090Aircraft(json, nowMs, { band })` maps a dump1090/readsb/ +skyaware978 `aircraft.json` document into the same records. Voice +`set_layer_visibility` accepts `local-adsb` ("local ADS-B", "my receiver", +"my antenna"). + +**Decoder feeds.** The layer's second input is the server route +`GET /api/local-receivers/aircraft` +(`server/providers/local-receivers.js`), configured only by the +`LOCAL_RECEIVER_FEEDS` environment value (`band=url`, comma-separated, e.g. +`1090=http://localhost:8080/data/aircraft.json`). Every host must pass +`parseTapAddress` (loopback, RFC1918, `localhost`, `*.local`), the scheme must +be http(s) and the path must end in `aircraft.json`; an invalid entry is logged +at startup, reported `invalid` and never fetched. The route reads all feeds in +parallel (2 s timeout, redirects refused, 2 MB cap, single-flight with a 1 s +cache) and returns `{ configured, generatedAt, feeds: [{ band, label, status, +aircraft, ageMs }], records }`, where a feed is `live`, `stale` (its own `now` +is over 10 s old), `unreachable` or `invalid`. Labels name the band only; +addresses and upstream error text stay on the server. Unconfigured, it returns +`{ configured: false, feeds: [], records: [] }` without fetching. + +The browser session (`src/layers/localAdsb/feeds.js`) polls the route every +second only while the layer is enabled and only while the route reports +`configured`; the Local RTL-SDR card makes one probe request at startup to show +its read-only "Decoder feeds: 1090 live · 978 live" line. Feed records are +rebased to the browser clock and kept up to 60 s after their last message. +`mergeLocalAdsbRecords` merges both inputs by ICAO, keeping the newest position +(tie: newest message) and the bands/sources heard in the last 60 s; the card's +receiver line names them ("Heard by your receiver · 1090 MHz + 978 MHz UAT · +browser SDR + decoder feed"). Aircraft heard only on 978 MHz UAT draw with a +thin light-magenta ring. With feeds configured the row status reads "2 feeds +live · 14 heard" when every feed is live. While any input is producing (the +browser receiver streaming, or a feed live) the row stays nominal and names the +other feeds as a trailing note ("3 heard · USB 5.8 msg/s · feed 1090 stale"), +since a decoder stopped so the browser can take the dongle is not a fault. With +nothing producing, every readable feed stale reads STALE and anything else is +an error. Without feeds it keeps the WebUSB statuses. See +`docs/LOCAL-RECEIVERS.md`. + ## Bundled geography and submarine cable components Submarine cables use separate source, geometry, rendering, interaction and diff --git a/docs/LOCAL-RECEIVERS.md b/docs/LOCAL-RECEIVERS.md new file mode 100644 index 000000000..ee75d9a70 --- /dev/null +++ b/docs/LOCAL-RECEIVERS.md @@ -0,0 +1,82 @@ +# Local receivers + +The Local ADS-B layer draws aircraft heard by your own receivers. It has two +inputs, and it can use both at once. + +| Input | What it is | Bands | +| ------------ | ----------------------------------------------------------------------------------------------------------------------------------- | -------------------- | +| Browser SDR | A USB RTL-SDR opened by the browser through WebUSB (Radio panel, Local RTL-SDR card). Decoding runs in the page. | 1090 MHz | +| Decoder feed | The `aircraft.json` served by a decoder you run: dump1090-fa, readsb or tar1090 (1090 MHz), or dump978-fa + skyaware978 (978 MHz). | 1090 MHz, 978 MHz UAT | + +Aircraft from both inputs are merged by ICAO address. The newest position +wins, and the click card names every band and source that heard the aircraft +in the last 60 s. Aircraft heard only on 978 MHz UAT draw with a thin ring. + +Local aircraft usually lead their public twins. The public Flights layer +renders positions about one poll interval (~30 s) behind so it can interpolate +smoothly between fixes, while local receiver aircraft are shown as heard. An +aircraft in both layers therefore draws twice, the magenta local marker ahead +of the public one; both markers are drawn on purpose. + +## Which to use + +- **One plain RTL-SDR dongle, desktop Chrome or Edge:** use the browser SDR. + Nothing else to install. +- **A dual-band board, a Raspberry Pi feeder, or a dongle already used by a + decoder:** use a decoder feed. A USB dongle can be opened by only one program + at a time, so a dongle that dump1090 or dump978 owns cannot also be opened by + the browser. + +## Configuring feeds + +Feeds are set in the server environment only (`.env`), never in the browser: + +```sh +LOCAL_RECEIVER_FEEDS=1090=http://localhost:8080/data/aircraft.json,978=http://localhost:8978/data/aircraft.json +``` + +Each entry is `band=url`, where `band` is `1090` or `978`. The URL must: + +- use `http` or `https`; +- point at loopback, a private (RFC1918) address, `localhost` or a `*.local` + name. Public addresses, link-local addresses (including `169.254.169.254`), + IPv6 and other host names are refused; +- have a path ending in `aircraft.json`, with no credentials, query or + fragment. + +An entry that breaks a rule is logged when the server starts, shown as +`invalid` and never fetched. Restart the server after changing the value. + +The server reads every feed about once a second while the Local ADS-B layer is +on. A feed is `live`, `stale` (its own `now` is more than 10 s old, usually a +stopped decoder), `unreachable` or `invalid`. The Layers panel row and the +Local RTL-SDR card show these states. + +## Example: dump1090-fa and dump978-fa on macOS or Linux + +Write each decoder's JSON to a directory, then serve the directory on +localhost. Each command keeps running, so start each in its own terminal: + +```sh +# 1090 MHz +dump1090-fa --device-index 0 --write-json /tmp/adsb1090 +python3 -m http.server 8080 --bind 127.0.0.1 --directory /tmp/adsb1090 + +# 978 MHz UAT: dump978-fa decodes, skyaware978 writes aircraft.json +dump978-fa --sdr driver=rtlsdr,rtl=1 --raw-port 30978 +skyaware978 --connect localhost:30978 --json-dir /tmp/adsb978 +python3 -m http.server 8978 --bind 127.0.0.1 --directory /tmp/adsb978 +``` + +```sh +LOCAL_RECEIVER_FEEDS=1090=http://localhost:8080/aircraft.json,978=http://localhost:8978/aircraft.json +``` + +A board with two identical-serial channels, such as the Nooelec FlyCatcher, +cannot be told apart by serial number. Select each channel by index: +`--device-index ` for dump1090-fa and `--sdr driver=rtlsdr,rtl=` for +dump978-fa. `rtl_test` lists the indexes. + +A tar1090 or readsb install on a Raspberry Pi already serves +`http:///tar1090/data/aircraft.json`. Use the Pi's private address +or its `.local` name. diff --git a/package-lock.json b/package-lock.json index d98116fb4..17bb0691e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,8 @@ "version": "0.1.1", "license": "MIT", "dependencies": { + "@jtarrio/signals": "^0.10.0", + "@jtarrio/webrtlsdr": "^3.0.6", "@mapbox/vector-tile": "^3.0.0", "@meri-imperiumi/eccodes-wasm": "^2.48.2", "cesium": "^1.124.0", @@ -1061,6 +1063,22 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@jtarrio/signals": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@jtarrio/signals/-/signals-0.10.1.tgz", + "integrity": "sha512-X6tJOugEATYH9KJy6j2feEBVCUmRv1U9SSbg9ITQZCx/Qruw2fE2zsS1HKH3yOsxrZBE+eQTGM+d0roX4xuDdw==", + "license": "Apache-2.0" + }, + "node_modules/@jtarrio/webrtlsdr": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@jtarrio/webrtlsdr/-/webrtlsdr-3.0.6.tgz", + "integrity": "sha512-+P8dsbXQ/6Mwru/vV613yHH68nHFyUJ9zPK68hE/Hh2F3jwyirUAjjBtIAfN/MlJbFioMJ3NS6dMGrRj9deFvw==", + "license": "Apache-2.0", + "dependencies": { + "@jtarrio/signals": "^0.10.0", + "@types/w3c-web-usb": "^1.0.10" + } + }, "node_modules/@mapbox/point-geometry": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", @@ -1538,6 +1556,12 @@ "license": "MIT", "optional": true }, + "node_modules/@types/w3c-web-usb": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/@types/w3c-web-usb/-/w3c-web-usb-1.0.14.tgz", + "integrity": "sha512-Qu3Nn6JFuF4+sHKYl+IcX9vYiI40ogleXzFFSxoE1W94rG98o/kXs8uJ0QSfFzuwBCZWlGfUGpPkgwuuX4PchA==", + "license": "MIT" + }, "node_modules/@zip.js/zip.js": { "version": "2.8.18", "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.8.18.tgz", diff --git a/package.json b/package.json index 429c13aa7..cd1a90695 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,8 @@ "qa:transit": "node scripts/qa-transit.mjs" }, "dependencies": { + "@jtarrio/signals": "^0.10.0", + "@jtarrio/webrtlsdr": "^3.0.6", "@mapbox/vector-tile": "^3.0.0", "@meri-imperiumi/eccodes-wasm": "^2.48.2", "cesium": "^1.124.0", diff --git a/scripts/check-package-boundaries.mjs b/scripts/check-package-boundaries.mjs index 3655cd998..28627e4ac 100644 --- a/scripts/check-package-boundaries.mjs +++ b/scripts/check-package-boundaries.mjs @@ -89,6 +89,10 @@ export async function checkPackageBoundaries(root) { const seen = new Set(); await build({ root, + // Keep this gate out of a running dev server's dependency cache + // (node_modules/.vite): a build there re-optimizes dependencies and the + // dev server's workers then fail with 504 "Outdated Optimize Dep". + cacheDir: path.join(root, 'node_modules/.vite-boundaries'), configFile: false, envFile: false, publicDir: false, @@ -115,7 +119,12 @@ export async function checkPackageBoundaries(root) { assetsInlineLimit: 0, rollupOptions: { input, - external: group.external, + // A declared dependency also owns its package subpath exports + // (for example `@jtarrio/webrtlsdr/rtlsdr.js`). + external: (id) => + group.external.some( + (name) => id === name || id.startsWith(`${name}/`), + ), // Unused imports must still obey ownership; tree shaking is not a boundary. treeshake: false, preserveEntrySignatures: 'strict', diff --git a/scripts/format-scope.json b/scripts/format-scope.json index c4b1ce72d..d8ebb6fe3 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -799,6 +799,21 @@ "scripts/qa-weather-swap.mjs", "scripts/qa-weather-teardown.mjs", "src/layers/weather/imageryHost.test.mjs", + "src/sdr/adsbDecoder.test.mjs", + "src/sdr/controller.test.mjs", + "src/sdr/gain.test.mjs", + "src/sdr/spectrum.test.mjs", + "src/sdr/usbDevices.test.mjs", + "src/sources/adsbRecords.test.mjs", + "src/layers/localAdsb/index.test.mjs", + "src/ui/localSdrControls.test.mjs", + "src/ui/localSdrMarkup.test.mjs", + "src/ui/localSdrPresentation.test.mjs", + "src/data/localReceiversProxy.test.mjs", + "src/layers/localAdsb/feeds.test.mjs", + "src/ui/railScrollers.test.mjs", + "src/layers/localAdsb/motion.test.mjs", + "src/data/modelEligibility.test.mjs", "src/layers/recentImagery/model.test.mjs", "src/layers/recentImagery/catalog.test.mjs", "src/maps/imageryHost.test.mjs", @@ -808,6 +823,5 @@ "src/ui/imageryBoxTool.test.mjs", "src/ui/recentImagery.test.mjs", "src/layers/recentImagery/testDoubles.mjs", - "scripts/qa-recent-imagery.mjs", - "src/ui/railScrollers.test.mjs" + "scripts/qa-recent-imagery.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 5273abbf8..199d948c1 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -602,7 +602,10 @@ "src/layers/weather/imageryHost.js", "src/ui/layerBindings.js", "src/ui/displayBindings.js", - "src/ui/shellFacade.js" + "src/ui/shellFacade.js", + "src/ui/localSdrControls.js", + "src/layers/localAdsb/feedNames.js", + "src/ui/localSdrPresentation.js" ], "external": ["cesium"] }, @@ -755,7 +758,9 @@ "src/layers/flights/records.js", "src/layers/flights/recordPolicy.js", "src/layers/flights/snapshotRenderer.js", - "src/data/inputOwnership.js" + "src/layers/flights/modelSpec.js", + "src/data/inputOwnership.js", + "src/data/modelEligibility.js" ], "external": ["cesium"] }, @@ -790,7 +795,8 @@ "src/layers/military/records.js", "src/layers/military/recordPolicy.js", "src/layers/military/snapshotRenderer.js", - "src/data/inputOwnership.js" + "src/data/inputOwnership.js", + "src/data/modelEligibility.js" ], "external": ["cesium"] }, @@ -1771,6 +1777,7 @@ "src/layers/flights/records.js", "src/layers/flights/recordPolicy.js", "src/layers/flights/snapshotRenderer.js", + "src/layers/flights/modelSpec.js", "src/layers/military/records.js", "src/layers/military/recordPolicy.js", "src/layers/military/snapshotRenderer.js", @@ -1824,6 +1831,7 @@ "src/data/contactTrailRenderer.js", "src/data/transitProxy.js", "src/data/gtfsRealtime.js", + "src/data/modelEligibility.js", "src/layers/transit/trails.js", "src/layers/transit/movement.js", "src/layers/transit/qaMetrics.js", @@ -1890,6 +1898,23 @@ "src/layers/cyclones/rendering.js", "src/layers/cyclones/labels.js", "src/layers/cyclones/source.js", + "src/app/layers/localAdsb.js", + "src/layers/localAdsb/index.js", + "src/layers/localAdsb/policy.js", + "src/layers/localAdsb/card.js", + "src/layers/localAdsb/feeds.js", + "src/layers/localAdsb/status.js", + "src/layers/localAdsb/feedNames.js", + "src/layers/localAdsb/motion.js", + "src/layers/localAdsb/models.js", + "src/layers/localAdsb/enrichment.js", + "src/sources/adsbRecords.js", + "src/sdr/controller.js", + "src/sdr/spectrum.js", + "src/sdr/gain.js", + "src/sdr/usbDevices.js", + "src/ui/localSdrControls.js", + "src/ui/localSdrPresentation.js", "src/app/layers/recentImagery.js", "src/layers/recentImagery/index.js", "src/layers/recentImagery/model.js", @@ -1907,7 +1932,8 @@ "egm96-universal", "mgrs", "pbf", - "satellite.js" + "satellite.js", + "@jtarrio/webrtlsdr" ] }, "application-catalog": { @@ -2249,6 +2275,7 @@ "src/layers/flights/records.js", "src/layers/flights/recordPolicy.js", "src/layers/flights/snapshotRenderer.js", + "src/layers/flights/modelSpec.js", "src/layers/military/records.js", "src/layers/military/recordPolicy.js", "src/layers/military/snapshotRenderer.js", @@ -2322,6 +2349,25 @@ "src/layers/cyclones/rendering.js", "src/layers/cyclones/labels.js", "src/layers/cyclones/source.js", + "src/app/layers/localAdsb.js", + "src/layers/localAdsb/index.js", + "src/layers/localAdsb/policy.js", + "src/layers/localAdsb/card.js", + "src/layers/localAdsb/feeds.js", + "src/layers/localAdsb/status.js", + "src/layers/localAdsb/feedNames.js", + "src/layers/localAdsb/motion.js", + "src/layers/localAdsb/models.js", + "src/layers/localAdsb/enrichment.js", + "src/sources/adsbRecords.js", + "src/sdr/controller.js", + "src/sdr/spectrum.js", + "src/sdr/gain.js", + "src/sdr/usbDevices.js", + "src/data/detection.js", + "src/data/detectionRenderDemand.js", + "src/data/detectionPresentation.js", + "src/data/modelEligibility.js", "src/app/layers/recentImagery.js", "src/layers/recentImagery/index.js", "src/layers/recentImagery/model.js", @@ -2337,7 +2383,8 @@ "satellite.js", "@mapbox/vector-tile", "pbf", - "mgrs" + "mgrs", + "@jtarrio/webrtlsdr" ] }, "application-operations": { diff --git a/server/providers/local-receivers.js b/server/providers/local-receivers.js new file mode 100644 index 000000000..994da7bd8 --- /dev/null +++ b/server/providers/local-receivers.js @@ -0,0 +1,521 @@ +import { lookup as dnsLookup } from 'node:dns/promises'; +import http from 'node:http'; +import https from 'node:https'; +import { Readable } from 'node:stream'; +import { coalesceProxyRequest, readResponseTextCapped } from './common/http.js'; +import { isLocalIpv4, parseTapAddress } from '../../src/data/tapAddress.js'; +import { normalizeDump1090Aircraft } from '../../src/sources/adsbRecords.js'; + +/** + * Local decoder feeds for the Local ADS-B layer. + * + * A user who runs dump1090 / readsb / tar1090 (1090 MHz) or dump978-fa + + * skyaware978 (978 MHz UAT) exposes an `aircraft.json` over HTTP. This route + * reads those documents and returns them as Local ADS-B records. + * + * The feed list comes only from the operator's `LOCAL_RECEIVER_FEEDS` + * environment value; the browser never supplies an address. Every host must + * pass the shared receiver-tap address rule (`src/data/tapAddress.js`: + * loopback, RFC1918, `localhost`, `*.local`), the scheme must be http(s) and + * the path must end in `aircraft.json`. Invalid entries are logged at startup, + * reported as `invalid` and never fetched. + * + * A name (`localhost`, `*.local`) is resolved before every read; each address + * it resolves to must itself be loopback or RFC1918, and the connection is + * pinned to exactly those validated addresses, so a later re-resolution + * cannot redirect it anywhere else. + */ + +export const LOCAL_RECEIVERS_ROUTE = '/api/local-receivers/aircraft'; +export const LOCAL_RECEIVER_FEEDS_ENV = 'LOCAL_RECEIVER_FEEDS'; +export const LOCAL_RECEIVER_BANDS = Object.freeze(['1090', '978']); +export const LOCAL_RECEIVER_TIMEOUT_MS = 2_000; +export const LOCAL_RECEIVER_MAX_BODY_BYTES = 2 * 1024 * 1024; +export const LOCAL_RECEIVER_CACHE_MS = 1_000; +/** A feed whose own `now` is older than this is reported stale. */ +export const LOCAL_RECEIVER_STALE_MS = 10_000; +const MAX_FEEDS = 8; +const LOG_PREFIX = '[local-receivers]'; + +const BAND_LABELS = Object.freeze({ 1090: '1090 MHz', 978: '978 MHz UAT' }); +/** Statuses whose `Response` must be constructed without a body. */ +const NULL_BODY_STATUSES = new Set([101, 103, 204, 205, 304]); + +function defaultPort(protocol) { + return protocol === 'https:' ? 443 : 80; +} + +/** + * Validate one `band=url` entry. + * @param {string} entry Raw entry text. + * @returns {{band:string, url:string}|{band:string|null, reason:string}} + */ +function parseEntry(entry) { + const separator = entry.indexOf('='); + if (separator <= 0) return { band: null, reason: 'expected band=url' }; + const band = entry.slice(0, separator).trim(); + const rawUrl = entry.slice(separator + 1).trim(); + if (!LOCAL_RECEIVER_BANDS.includes(band)) + return { band: null, reason: 'band must be 1090 or 978' }; + let url; + try { + url = new URL(rawUrl); + } catch { + return { band, reason: 'not a valid URL' }; + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') + return { band, reason: 'scheme must be http or https' }; + if (url.username || url.password) + return { band, reason: 'credentials are not allowed in the URL' }; + if (url.search || url.hash || rawUrl.includes('?') || rawUrl.includes('#')) + return { band, reason: 'query and fragment are not allowed' }; + const port = url.port ? Number(url.port) : defaultPort(url.protocol); + const address = parseTapAddress(`${url.hostname}:${port}`); + if (!address) + return { + band, + reason: 'host must be loopback, RFC1918, localhost or *.local', + }; + if (!/(?:^|\/)aircraft\.json$/.test(url.pathname)) + return { band, reason: 'path must end in aircraft.json' }; + return { + band, + url: `${url.protocol}//${address.host}:${address.port}${url.pathname}`, + }; +} + +/** + * Parse `LOCAL_RECEIVER_FEEDS` (`band=url[,band=url…]`). + * + * Labels name the band only (plus an ordinal when a band repeats), so feed + * addresses never reach the browser. + * @param {string|undefined} value Environment value. + * @returns {{configured:boolean, feeds:object[]}} Each feed is + * `{ id, band, label, url }` when valid or `{ id, band, label, reason }` + * when invalid (never fetched). + */ +export function parseLocalReceiverFeeds(value) { + const entries = String(value ?? '') + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean); + const feeds = []; + const perBand = new Map(); + entries.forEach((entry, index) => { + const parsed = + index < MAX_FEEDS + ? parseEntry(entry) + : { band: null, reason: `at most ${MAX_FEEDS} feeds are read` }; + const band = parsed.band; + const ordinal = (perBand.get(band) || 0) + 1; + perBand.set(band, ordinal); + const base = band ? BAND_LABELS[band] : `entry ${index + 1}`; + const label = band && ordinal > 1 ? `${base} #${ordinal}` : base; + feeds.push({ + id: `feed-${index + 1}`, + band, + label, + ...(parsed.url ? { url: parsed.url } : { reason: parsed.reason }), + }); + }); + // Ordinals are only needed when a band actually repeats. + for (const feed of feeds) { + if (feed.band && perBand.get(feed.band) > 1 && !feed.label.includes('#')) + feed.label = `${feed.label} #1`; + } + return { configured: feeds.length > 0, feeds }; +} + +class FeedError extends Error { + constructor(code) { + super(code); + this.code = code; + } +} + +/** + * Whether a resolved address satisfies the receiver-tap rule: loopback or + * RFC1918 IPv4, the IPv6 loopback, or an IPv4-mapped form of the former. + * @param {string} address Resolved IP address. + * @returns {boolean} + */ +export function isLocalReceiverAddress(address) { + const value = String(address || '').toLowerCase(); + if (value === '::1') return true; + const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/.exec(value); + return isLocalIpv4(mapped ? mapped[1] : value); +} + +/** + * Resolve a feed host name and require every address to be local. + * @param {string} hostname `localhost` or a `*.local` name. + * @param {typeof dnsLookup} lookupImpl `dns.promises.lookup`-compatible. + * @returns {Promise>} + */ +export async function resolveLocalReceiverAddresses(hostname, lookupImpl) { + let resolved; + try { + resolved = await lookupImpl(hostname, { all: true, verbatim: true }); + } catch { + throw new FeedError('UNRESOLVED'); + } + const addresses = (Array.isArray(resolved) ? resolved : [resolved]) + .map((row) => ({ + address: String(row?.address || ''), + family: + Number(row?.family) || (String(row?.address).includes(':') ? 6 : 4), + })) + .filter((row) => row.address); + if (!addresses.length) throw new FeedError('UNRESOLVED'); + if (addresses.some((row) => !isLocalReceiverAddress(row.address))) + throw new FeedError('FORBIDDEN_ADDRESS'); + return addresses; +} + +/** + * Settle with `promise`, or reject with an AbortError as soon as `signal` + * aborts; a later settlement of `promise` is ignored. + */ +function untilAborted(promise, signal) { + return new Promise((resolve, reject) => { + const onAbort = () => { + const error = new Error('aborted'); + error.name = 'AbortError'; + reject(error); + }; + // Handlers are attached first so a late rejection is never unhandled. + promise.then( + (value) => { + signal.removeEventListener('abort', onAbort); + resolve(value); + }, + (error) => { + signal.removeEventListener('abort', onAbort); + reject(error); + }, + ); + if (signal.aborted) onAbort(); + else signal.addEventListener('abort', onAbort, { once: true }); + }); +} + +/** A `net`-style lookup that only ever answers the validated addresses. */ +function pinnedLookup(addresses) { + return (_hostname, options, callback) => { + const done = typeof options === 'function' ? options : callback; + if (options?.all) + done( + null, + addresses.map((row) => ({ ...row })), + ); + else done(null, addresses[0].address, addresses[0].family); + }; +} + +/** + * Default transport. With `options.lookup` (a pinned feed name) the request + * is made with node:http(s) so the socket connects only to the validated + * addresses (TLS still verifies the name); otherwise it is a plain fetch of + * an IP-literal URL. Redirects are never followed either way. + * @param {string} url + * @param {object} options + * @returns {Promise} + */ +function fetchLocalReceiverFeed(url, options) { + if (typeof options?.lookup !== 'function') return fetch(url, options); + const client = new URL(url).protocol === 'https:' ? https : http; + return new Promise((resolve, reject) => { + const request = client.request( + url, + { + method: 'GET', + headers: options.headers, + signal: options.signal, + lookup: options.lookup, + // A fresh socket per read: a pooled one could outlive the pin. + agent: false, + }, + (response) => { + // Nothing thrown here may escape: this callback runs outside the + // promise, so an exception would be uncaught and crash the server. + try { + const status = response.statusCode; + // A `Response` only represents final statuses 200–599. + if (!Number.isInteger(status) || status < 200 || status > 599) + throw new FeedError('HTTP_STATUS'); + const headers = new Headers(); + for (const [name, value] of Object.entries(response.headers)) { + if (Array.isArray(value)) + value.forEach((item) => headers.append(name, item)); + else if (value !== undefined) headers.set(name, String(value)); + } + const nullBody = NULL_BODY_STATUSES.has(status); + const upstream = new Response( + nullBody ? null : Readable.toWeb(response), + { status, statusText: response.statusMessage || '', headers }, + ); + if (nullBody) response.destroy(); + resolve(upstream); + } catch (error) { + response.destroy(); + reject(error); + } + }, + ); + request.on('error', reject); + request.end(); + }); +} + +/** + * Fetch one validated feed document without following redirects and with the + * body cap enforced while it streams. + * @param {string} url Validated feed URL. + * @param {object} options + * @returns {Promise} Parsed JSON document. + */ +export async function fetchLocalReceiverDocument( + url, + { + fetchImpl = fetchLocalReceiverFeed, + lookupImpl = dnsLookup, + timeoutMs = LOCAL_RECEIVER_TIMEOUT_MS, + maxBytes = LOCAL_RECEIVER_MAX_BODY_BYTES, + } = {}, +) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + let upstream; + try { + const { hostname } = new URL(url); + // IP literals were validated when the feed list was parsed; a name is + // validated by what it resolves to now, and the socket is pinned there. + // Resolution runs against the same deadline as the read: a stalled + // lookup fails this feed instead of holding the whole snapshot. + const lookup = isLocalIpv4(hostname) + ? undefined + : pinnedLookup( + await untilAborted( + resolveLocalReceiverAddresses(hostname, lookupImpl), + controller.signal, + ), + ); + upstream = await fetchImpl(url, { + method: 'GET', + headers: { Accept: 'application/json' }, + redirect: 'manual', + signal: controller.signal, + ...(lookup ? { lookup } : {}), + }); + if (upstream.status >= 300 && upstream.status < 400) + throw new FeedError('REDIRECT_REFUSED'); + // Opaque redirect responses from `redirect: 'manual'` report status 0. + if (upstream.type === 'opaqueredirect') + throw new FeedError('REDIRECT_REFUSED'); + if (!upstream.ok) throw new FeedError('HTTP_STATUS'); + const text = await readResponseTextCapped( + upstream, + maxBytes, + controller.signal, + ); + let json; + try { + json = JSON.parse(text); + } catch { + throw new FeedError('BAD_JSON'); + } + if (!json || typeof json !== 'object' || !Array.isArray(json.aircraft)) + throw new FeedError('BAD_DOCUMENT'); + return json; + } catch (error) { + controller.abort(); + if (upstream?.body && !upstream.body.locked) + void upstream.body.cancel().catch(() => {}); + if (error instanceof FeedError) throw error; + if (error?.code === 'RESPONSE_TOO_LARGE') + throw new FeedError('RESPONSE_TOO_LARGE'); + if (error?.name === 'AbortError' || controller.signal.aborted) + throw new FeedError('TIMEOUT'); + throw new FeedError('UNREACHABLE'); + } finally { + clearTimeout(timeoutId); + } +} + +/** + * Read one feed and describe it. + * @returns {Promise<{status:object, records:object[]}>} + */ +async function readFeed( + feed, + { fetchImpl, lookupImpl, now, timeoutMs, maxBytes, log }, +) { + const summary = { band: feed.band, label: feed.label }; + if (!feed.url) + return { + status: { ...summary, status: 'invalid', aircraft: 0, ageMs: null }, + records: [], + }; + let json; + try { + json = await fetchLocalReceiverDocument(feed.url, { + fetchImpl, + lookupImpl, + timeoutMs, + maxBytes, + }); + } catch (error) { + log(feed, 'unreachable', error.code); + return { + status: { ...summary, status: 'unreachable', aircraft: 0, ageMs: null }, + records: [], + }; + } + const receivedAt = now(); + const feedNowS = Number(json.now); + // The feed's own clock decides staleness. A clock ahead of ours reads as + // age 0; a document without `now` is treated as current. + const ageMs = Number.isFinite(feedNowS) + ? Math.max(0, Math.round(receivedAt - feedNowS * 1000)) + : 0; + const status = ageMs > LOCAL_RECEIVER_STALE_MS ? 'stale' : 'live'; + log(feed, status); + // Anchor record ages at the document time so a stale feed ages out + // naturally, without trusting a receiver clock that runs ahead of ours. + const records = normalizeDump1090Aircraft(json, receivedAt - ageMs, { + band: feed.band, + }); + return { + status: { ...summary, status, aircraft: records.length, ageMs }, + records, + }; +} + +/** + * Build the route handler. Exposed for tests; the plugin wraps it. + * @param {object} [options] + * @param {string} [options.feedsValue] `LOCAL_RECEIVER_FEEDS` value. + * @param {typeof fetch} [options.fetchImpl] Receives `options.lookup` when + * the feed host is a name; the connection must resolve only through it. + * @param {Function} [options.lookupImpl] `dns.promises.lookup`-compatible. + * @param {() => number} [options.now] + * @param {{warn:Function, info?:Function}} [options.logger] + * @returns {{handle:Function, config:object}} + */ +export function createLocalReceiversHandler({ + feedsValue = process.env[LOCAL_RECEIVER_FEEDS_ENV], + fetchImpl = fetchLocalReceiverFeed, + lookupImpl = dnsLookup, + now = Date.now, + logger = console, + timeoutMs = LOCAL_RECEIVER_TIMEOUT_MS, + maxBytes = LOCAL_RECEIVER_MAX_BODY_BYTES, + cacheMs = LOCAL_RECEIVER_CACHE_MS, +} = {}) { + const config = parseLocalReceiverFeeds(feedsValue); + for (const feed of config.feeds) { + if (feed.reason) + logger.warn( + `${LOG_PREFIX} ${LOCAL_RECEIVER_FEEDS_ENV} ${feed.id} (${feed.label}) rejected: ${feed.reason}. It will not be fetched.`, + ); + } + const lastStatus = new Map(); + // Log a feed only when its status changes, so a feed that is down does not + // write a line every second. + const log = (feed, status, code) => { + if (lastStatus.get(feed.id) === status) return; + lastStatus.set(feed.id, status); + if (status === 'live') logger.info?.(`${LOG_PREFIX} ${feed.label} live`); + else + logger.warn( + `${LOG_PREFIX} ${feed.label} ${status}${code ? ` (${code})` : ''}`, + ); + }; + const inFlight = new Map(); + let cached = null; + + async function snapshot() { + const results = await Promise.all( + config.feeds.map((feed) => + readFeed(feed, { + fetchImpl, + lookupImpl, + now, + timeoutMs, + maxBytes, + log, + }), + ), + ); + return { + configured: true, + generatedAt: now(), + feeds: results.map((result) => result.status), + records: results.flatMap((result) => result.records), + }; + } + + async function payload() { + if (!config.configured) + return { configured: false, feeds: [], records: [] }; + if (cached && now() - cached.at < cacheMs) return cached.payload; + const { promise } = coalesceProxyRequest(inFlight, 'all', snapshot); + const result = await promise; + cached = { at: now(), payload: result }; + return result; + } + + async function handle(req, res) { + if (req.method !== 'GET' && req.method !== 'HEAD') { + res.writeHead(405, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + res.end(JSON.stringify({ error: 'Method Not Allowed' })); + return; + } + try { + const body = await payload(); + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + res.end(JSON.stringify(body)); + } catch { + res.writeHead(502, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + res.end(JSON.stringify({ error: 'Local receiver feeds unavailable' })); + } + } + + return { handle, config }; +} + +/** + * Vite plugin mounting `GET /api/local-receivers/aircraft`. + * + * The environment is read when the server starts, so construction alone + * neither parses configuration nor fetches anything. + * @param {object} [options] Passed to {@link createLocalReceiversHandler}. + * @returns {import('vite').Plugin} + */ +export function localReceiversProxy(options = {}) { + const install = (server) => { + const { handle } = createLocalReceiversHandler(options); + server.middlewares.use(LOCAL_RECEIVERS_ROUTE, (req, res, next) => { + const path = new URL(req.url || '/', 'http://localhost').pathname; + if (path !== '/' && path !== '') { + if (typeof next === 'function') return next(); + return undefined; + } + return handle(req, res); + }); + }; + return { + name: 'local-receivers-proxy', + configureServer: install, + configurePreviewServer: install, + }; +} diff --git a/server/providers/local.js b/server/providers/local.js index 36eb6b22b..65ced9551 100644 --- a/server/providers/local.js +++ b/server/providers/local.js @@ -13,6 +13,7 @@ import { cctvProxy } from './cctv.js'; import { defaultSourceRoot } from './common/source-root.js'; import { radioBrowserProxy } from './radio.js'; import { gbfsProxy } from './gbfs.js'; +import { localReceiversProxy } from './local-receivers.js'; import { transitProxy } from './transit.js'; import { adsbLolProxy } from './aircraft/adsb-lol.js'; import { aisLiveProxy } from './vessels/ais-live.js'; @@ -42,6 +43,7 @@ function localProviderPlugins() { cctvProxy({ sourceRoot: defaultSourceRoot }), radioBrowserProxy(), gbfsProxy(), + localReceiversProxy(), transitProxy(), adsbLolProxy(), aisLiveProxy(), diff --git a/server/providers/openai/toolDescriptions.js b/server/providers/openai/toolDescriptions.js index f4e328b7f..82f747171 100644 --- a/server/providers/openai/toolDescriptions.js +++ b/server/providers/openai/toolDescriptions.js @@ -82,7 +82,7 @@ export const ACTION_DESCRIPTIONS = { properties: { layerId: { description: - 'Common-name mapping for the non-obvious ids: space mission(s) → rocket-launches; fires/wildfires/active fires → local-firms (NASA FIRMS); ships/vessels/boats → ais-live-vessels; undersea/submarine cables → telegeography-submarine-cables; datacenters → local-datacenters; dams → local-dams; bikes/bike share → bikeshare; street traffic/congestion → traffic; traffic cameras → cctv; internet radio/stations → radio; ALPR/license plate readers/Flock cameras → alpr-cameras.', + 'Common-name mapping for the non-obvious ids: space mission(s) → rocket-launches; fires/wildfires/active fires → local-firms (NASA FIRMS); ships/vessels/boats → ais-live-vessels; undersea/submarine cables → telegeography-submarine-cables; datacenters → local-datacenters; dams → local-dams; bikes/bike share → bikeshare; street traffic/congestion → traffic; traffic cameras → cctv; internet radio/stations → radio; ALPR/license plate readers/Flock cameras → alpr-cameras; local ADS-B/my receiver/my antenna (aircraft heard by a local RTL-SDR receiver) → local-adsb.', $position: 1, }, }, diff --git a/server/standalone/vite.config.js b/server/standalone/vite.config.js index 5479a1c14..ffa5a1e27 100644 --- a/server/standalone/vite.config.js +++ b/server/standalone/vite.config.js @@ -7,7 +7,7 @@ import { apiNotFoundPlugin } from './api-not-found.js'; const root = fileURLToPath(new URL('../../', import.meta.url)); /** Load this checkout's configuration and attach its local provider middleware. */ -export default defineConfig(({ mode }) => { +export default defineConfig(({ command, mode }) => { const loaded = loadEnv(mode, root, ''); for (const [key, value] of Object.entries(loaded)) { if (process.env[key] === undefined) process.env[key] = value; @@ -18,5 +18,6 @@ export default defineConfig(({ mode }) => { cesiumToken: process.env.CESIUM_ION_TOKEN, host: process.env.HOST, port: process.env.PORT, + command, }); }); diff --git a/src/app/catalog.js b/src/app/catalog.js index 0d22ec9a1..70b96ffbf 100644 --- a/src/app/catalog.js +++ b/src/app/catalog.js @@ -11,6 +11,7 @@ const CONTROL_LAYER_IDS = Object.freeze({ militaryAwarenessLayer: 'military-awareness', militaryInstallationsLayer: 'military-installations', rocketLaunchesLayer: 'rocket-launches', + localAdsbLayer: 'local-adsb', }); /** Capture the ordered application instances and their serialization metadata. */ diff --git a/src/app/catalog.test.mjs b/src/app/catalog.test.mjs index 820943049..71f87d50a 100644 --- a/src/app/catalog.test.mjs +++ b/src/app/catalog.test.mjs @@ -93,6 +93,7 @@ test('controls bind catalog instances rather than similarly named defaults', () 'military-awareness', 'military-installations', 'rocket-launches', + 'local-adsb', ]; const layers = ids.map((id) => ({ id })); const catalog = createLayerCatalog(layers, metadata(layers)); @@ -100,6 +101,7 @@ test('controls bind catalog instances rather than similarly named defaults', () assert.equal(services.flightsLayer, layers[1]); assert.equal(services.transitLayer, layers[7]); assert.equal(services.aisLiveVesselsLayer, layers[8]); + assert.equal(services.localAdsbLayer, layers[12]); assert.equal(new Set(Object.values(services)).size, layers.length); assert.throws( () => catalogControlServices(createLayerCatalog([], [])), diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 26222c6cb..b6bb44c3b 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -19,6 +19,7 @@ import { createApplicationInstallations } from './layers/militaryInstallations.j import { createApplicationSatellites } from './layers/satellites.js'; import { createApplicationLaunches } from './layers/rocketLaunches.js'; import { createApplicationAlpr } from './layers/alprCameras.js'; +import { createApplicationLocalAdsb } from './layers/localAdsb.js'; import { createApplicationAwareness } from './layers/militaryAwareness.js'; import { createApplicationFirms } from './layers/firms.js'; import { createApplicationEarthquakes } from './layers/earthquakes.js'; @@ -54,6 +55,20 @@ const SOURCE_METHODS = Object.freeze({ cables: ['fetch'], }); +/** + * Hardware-local layers are registered like any other but never enter share + * links or stored layer state: another browser cannot have this receiver. + */ +export const LOCAL_ONLY_LAYER_METADATA = Object.freeze([ + Object.freeze({ id: 'local-adsb', disposition: 'local-only' }), +]); + +/** Serialization metadata for every layer the application catalog constructs. */ +export const APPLICATION_LAYER_METADATA = Object.freeze([ + ...LAYER_STATE_REGISTRY, + ...LOCAL_ONLY_LAYER_METADATA, +]); + /** Construct the current catalog without choosing any source provider. * Scene engines remain page-owned; layers and classification have this app's lifetime. * The manager owns layer destruction, while abort releases classification even if startup fails. @@ -62,7 +77,7 @@ export function createApplicationCatalog({ surface, sources, signal, - metadata = LAYER_STATE_REGISTRY, + metadata = APPLICATION_LAYER_METADATA, vesselOptions, resolveAsset, nepalBoundaryResolver, @@ -120,6 +135,12 @@ export function createApplicationCatalog({ }), flights, military, + createApplicationLocalAdsb({ + surface, + enrichment: sources.flights, + displayParams: () => flights.getParams(), + ...(resolveAsset ? { resolveAsset } : {}), + }), createApplicationEarthquakes({ source: sources.earthquakes }), createApplicationAlpr({ surface, source: sources.alpr }), satellites, diff --git a/src/app/constructCatalog.test.mjs b/src/app/constructCatalog.test.mjs index 1d8d33f80..f667ab022 100644 --- a/src/app/constructCatalog.test.mjs +++ b/src/app/constructCatalog.test.mjs @@ -39,7 +39,13 @@ test('catalogs construct distinct layers and classification from their supplied signal: b.signal, surface: fixtureSurface(b.signal), }); - assert.equal(first.layers.length, 27); + assert.equal(first.layers.length, 28); + assert.ok(first.get('local-adsb'), 'Local ADS-B is registered'); + assert.deepEqual( + first.metadata.find(({ id }) => id === 'local-adsb'), + { id: 'local-adsb', disposition: 'local-only' }, + 'the hardware-local layer is never serialized into links', + ); assert.notEqual(first.weatherClock, second.weatherClock); await first.weatherClock.setTarget('2026-09-21T12:00:00.000Z'); assert.match( diff --git a/src/app/layers/localAdsb.js b/src/app/layers/localAdsb.js new file mode 100644 index 000000000..c6e57b3a9 --- /dev/null +++ b/src/app/layers/localAdsb.js @@ -0,0 +1,48 @@ +import { createLocalAdsbLayer } from '../../layers/localAdsb/index.js'; +import { createLocalReceiverFeeds } from '../../layers/localAdsb/feeds.js'; +import { SdrController } from '../../sdr/controller.js'; +import * as render from '../../renderGovernor.js'; +import * as context from '../../data/contextStore.js'; +import * as picking from '../../data/pickRegistry.js'; +import * as trails from '../../data/trailRenderer.js'; +import * as geoid from '../../data/geoid.js'; +import { markDetectionSourcesChanged } from '../../data/detection.js'; +import { refreshTrackedReadout } from '../../data/trackedReadout.js'; + +/** + * Construct the Local ADS-B layer, the browser RTL-SDR session it shares with + * the Radio panel, and the decoder-feed session. Construction opens no device + * and makes no request; the SDR starts only from an explicit Connect and the + * feeds are polled only while the layer is enabled. + * + * `displayParams` reads the public Flights layer's DISPLAY-rail 3D preference + * (`{ models3d, models3dMode }`) so local aircraft follow the same toggle; + * `enrichment` is the Flights source whose cached adsbdb proxy they share. + */ +export function createApplicationLocalAdsb({ + receiver = new SdrController(), + feeds = createLocalReceiverFeeds(), + surface = null, + enrichment = null, + displayParams = null, + resolveAsset = (url) => + `${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`, +} = {}) { + return createLocalAdsbLayer({ + receiver, + feeds, + resolveAsset, + services: { + render, + context, + picking, + trails, + geoid, + groundSnap: surface?.groundSnap || null, + enrichment, + display: displayParams ? { getParams: displayParams } : null, + detection: { markSourcesChanged: markDetectionSourcesChanged }, + overlays: { refreshReadout: refreshTrackedReadout }, + }, + }); +} diff --git a/src/data/aircraftClass.js b/src/data/aircraftClass.js index ca52ea484..a6bbe3c70 100644 --- a/src/data/aircraftClass.js +++ b/src/data/aircraftClass.js @@ -438,6 +438,20 @@ export function classifyAircraft({ typeCode, category } = {}) { return 'airliner'; } +/** Readable name of each class, for cards and readouts. */ +export const AIRCRAFT_CLASS_LABELS = Object.freeze({ + light: 'Light aircraft', + glider: 'Glider', + turboprop: 'Turboprop', + airliner: 'Airliner', + widebody: 'Widebody', + quadjet: 'Four-engine jet', + helicopter: 'Helicopter', + fastjet: 'Fast jet', + bizjet: 'Business jet', + uav: 'Drone', +}); + /** Billboard scale multipliers (skylight GLYPH_SCALE, + fastjet). */ export const CLASS_SCALE_2D = { light: 0.62, diff --git a/src/data/fixtures/README.md b/src/data/fixtures/README.md index 7f22e94b0..b251475ce 100644 --- a/src/data/fixtures/README.md +++ b/src/data/fixtures/README.md @@ -7,3 +7,16 @@ (22,980 bytes). Used by offline decode/source tests and the explicit `qa-traffic --fixtures` browser mode — it is a point-in-time congestion snapshot, not a bundled data layer, and is never loaded by ordinary application startup. © TomTom. +- `adsb-austin-frames.txt` — 112 raw Mode S frames (`*;`, one per line, + in receive order) received over Austin, TX, and + `adsb-austin-dump1090-aircraft.json` — dump1090-fa's `aircraft.json` decode + of the same frames, trimmed to the fields the tests assert on. Used by the + Local ADS-B decoder and record-adapter tests; never loaded at runtime. +- `adsb-austin-capture-timed.json` — a second ~90 s receive over Austin, TX: + 846 extended squitters from six aircraft (`frames`: `[ms, hex]`), with + dump1090-fa's decoded positions and emitter categories for the same + interval (`dump1090`: per ICAO `flight`, `category` and `fixes` as + `[ms, lat, lon]`). The capture has no per-frame timestamps; each frame's + time is interpolated between the frames whose decoded position equals a + dump1090 fix, timed as dump1090's `now − seen_pos`. Used by the decoder's + category, speed-check and track-accuracy tests; never loaded at runtime. diff --git a/src/data/fixtures/adsb-austin-capture-timed.json b/src/data/fixtures/adsb-austin-capture-timed.json new file mode 100644 index 000000000..fba542822 --- /dev/null +++ b/src/data/fixtures/adsb-austin-capture-timed.json @@ -0,0 +1 @@ +{"receiver":{"latitude":30.27,"longitude":-97.8},"frames":[[10,"8d0d0c07501d0030aa4c0c2140c6"],[80,"8da5b3a758b99408f8dda679bb00"],[150,"8da0b702e1150d0000000004eb56"],[220,"8d0d0c0722594377e37820b79a0d"],[290,"8d0d0c07990c111d483c0c92e5f3"],[360,"8da0b702590d40253c4af26e47d9"],[430,"8da0b70299105d833818018770a8"],[500,"8d0d0c07501bf7da9cd71ab9e124"],[570,"8da5b3a7ea466864013c085e68e2"],[640,"8da3627d58a580665464f23579f2"],[780,"8d0d0c07990c111d483c0c92e5f3"],[850,"8da0b702590d47cf46d60b223463"],[920,"8da0b70299105d83380c015f9ea8"],[990,"8d0d0c07f8200002004ab89cec0b"],[1060,"8da0b702f8122006005ab8b0ce08"],[1130,"8d0d0c07501bf030d84c0b00e5d5"],[1200,"8da5b3a758b9905ff8528e447e65"],[1275,"8d0d0c07990c111d483c0c92e5f3"],[1350,"8da0b702590d5025384afb963e58"],[1425,"8da0b70299105d83300481465660"],[1500,"8da3627d58a5840ef0ef82e7f39f"],[1600,"8d0d0c07501bf030f64c0af8367b"],[1675,"8da5b3a7990da30b30085d4a3fb6"],[1750,"8d0d0c07990c111d483c0b6d35d7"],[1825,"8da5b3a723041333c31c2045cc84"],[1900,"8da0b702590d4025364affe4f100"],[1963,"8da0b70299105c833008010a006e"],[2088,"8dabe7c59915ad0d380456abc760"],[2150,"8dabe7c558af87ce8ccac01d62c7"],[2213,"8d0d0c07501be031124c090e5e08"],[2275,"8d0d0c07e1058a00000000c4d44e"],[2338,"8da3627de106290000000030ef86"],[2400,"8da5b3a758b994091cdd51bae36d"],[2460,"8d0d0c07990c111d483c0c92e5f3"],[2580,"8da0b702590d47cf40d6170e6567"],[2640,"8da0b70299105c83100c01774a47"],[2700,"8d0d0c07501be031244c08456bbe"],[2800,"8da5b3a7990da30b30085d4a3fb6"],[2900,"8da27e81592147de34ccbfe1e71f"],[3000,"8d0d0c07990c111d683c0b2849fe"],[3100,"8da0b702590d57cf3cd61d759697"],[3133,"8da0b70299105c83101081d8ce87"],[3200,"8da5b3a7ea466864013c085e68e2"],[3233,"8d0d0c07501be7db16d7163df6ea"],[3267,"8da3627d991417bb100854ccc1a2"],[3300,"8da27e81592147de3cccc18db35e"],[3333,"8da27e819910390f18080b8ece70"],[3367,"8d0d0c07990c111d683c0cd799da"],[3400,"8da5b3a758b990601e52360713ad"],[3450,"8da0b702f8122006005ab8b0ce08"],[3500,"8d0d0c07f8200002004ab89cec0b"],[3550,"8da0b702590d50252e4b0d6c0eab"],[3600,"8da0b70299105b83101481e9cced"],[3650,"8da5b3a7f82300060049b8e08700"],[3700,"8d0d0c07501be7db32d715b033d5"],[3700,"8da3627d58a580652c64ebca78d9"],[3860,"8da5b3a7990da30b30085eb5d7a4"],[3940,"8d0d0c07990c111d68380b107ffe"],[4020,"8dabe7c59915ad0d380456abc760"],[4100,"8da0b702590d57cf38d625439697"],[4140,"8da0b70299105b83101801a6902d"],[4180,"8d0d0c07501bd031724c05906709"],[4220,"8dabe7c558af87ceb8ca66b4e8a7"],[4260,"8d0d0c07990c111d68380cefafda"],[4300,"8da5b3a758b9906030520dfe8d1c"],[4417,"8d0d0c07501bd031844c05445a49"],[4533,"8da0b702590d57cf36d62b019958"],[4650,"8da0b70299105b83101881a196ed"],[4767,"8da5b3a7990da30b30085d4a3fb6"],[4883,"8d0d0c07990c111d88380b35ff28"],[5000,"8da0b702590d57cf34d62ee5fde5"],[5000,"8da0b70299105b82f01481e7b168"],[5000,"8da0b702e1150d0000000004eb56"],[5000,"8d0d0c07501bd7db74d71347e30c"],[5150,"8da0b70227c74d5462082004597a"],[5300,"8da5b3a758b990604051e7fb7390"],[5333,"8d0d0c0722594377e37820b79a0d"],[5367,"8d0d0c07990c111d88380cca2f0c"],[5400,"8dabe7c558af8024c23ee7c8e962"],[5460,"8dabe7c5f8230006004ab8db4fc2"],[5520,"8da0b702590d5025264b1f025060"],[5580,"8da0b70299105b82f01401e0b7a8"],[5640,"8da5b3a7ea466864013c085e68e2"],[5700,"8d0d0c07501bd031b44c03dc465d"],[5760,"8da5b3a7990da30b30085eb5d7a4"],[5820,"8da0b702f8122006005ab8b0ce08"],[5880,"8d0d0c07990c111d88380b35ff28"],[5940,"8dabe7c59915ad0d300456c56568"],[6000,"8da0b702590d57cf30d6372dc85c"],[6050,"8da0b70299105b82f01401e0b7a8"],[6100,"8d0d0c07501bc031c64c021a765d"],[6250,"8d0d0c07f8200002004ab89cec0b"],[6400,"8dabe7c558af8024d83eba9c7813"],[6500,"8d0d0c07501bc7dbb4d71117af08"],[6600,"8d0d0c07990c111da83c0b48b501"],[6700,"8da5b3a7990da30b30085d4a3fb6"],[6800,"8dabe7c59915ad0d300456c56568"],[6900,"8d0d0c07501bc7dbc8d710a36866"],[7050,"8d0d0c07990c111da83c0cb76525"],[7200,"8da5b3a758b990606451947414a7"],[7300,"8dabe7c558af87cef8c9e42b1cae"],[7360,"8dabe7c5ea428867215c08fe0874"],[7420,"8d0d0c07e1058a00000000c4d44e"],[7540,"8d0d0c07501bc0320a4c005f6240"],[7600,"8d0d0c07990c111da83c0b48b501"],[7660,"8da0b702590d67cf28d645cd6577"],[7720,"8da0b70299105a82f01081dc8da6"],[7780,"8da5b3a7990da30b30085eb5d7a4"],[7840,"8dabe7c59915ad0d300456c56568"],[7900,"8d0d0c07501bb032264bff008fc5"],[7933,"8d0d0c07990c111da83c0cb76525"],[7967,"8da5b3a7ea466864013c085e68e2"],[8000,"8da0b702590d67cf26d6487082aa"],[8150,"8da0b70299105a82d01481a1c78f"],[8300,"8da5b3a758b9906076516b48fcb5"],[8300,"8dabe7c558af8025003e6a60fadf"],[8364,"8da5b3a7f82300060049b8e08700"],[8427,"8da0b702f8122006005ab8b0ce08"],[8491,"8d0d0c07f8200002004ab89cec0b"],[8555,"8d0d0c07990c111dc83c0b87317a"],[8618,"8d0d0c07501bb032444bfe2b5735"],[8682,"8da5b3a7e10d2800000000aefd57"],[8745,"8da0b702590d67cf24d64d94e617"],[8809,"8da0b70299105a82d01481a1c78f"],[8873,"8dabe7c59915ad0d300456c56568"],[8936,"8d0d0c07990c111dc83c0c78e15e"],[9000,"8d0d0c07501bb032564bfeedbba7"],[9200,"8da0b702590d6025164b3fca5cc9"],[9267,"8da0b70299105a82d010019ef74f"],[9333,"8da5b3a758b9940996dc34d66e25"],[9400,"8dabe7c558af87cf24c98d196cab"],[9475,"8d0d0c07501ba7dc42d70c4cd63a"],[9550,"8d0d0c07990c111dc83c0b87317a"],[9625,"8da0b702590d5025144b4382eb39"],[9700,"8da0b70299105b82d00c01357f81"],[9775,"8dabe7c59915ad0d300456c56568"],[9850,"8dabe7c5ea428867215c08fe0874"],[9925,"8da5b3a7990da30b30085eb5d7a4"],[10000,"8da0b702590d57cf1ed65ad71178"],[10000,"8da0b70299105b82b00c01fafbfa"],[10000,"8d0d0c07501ba032864bfc60b3b3"],[10067,"8da0b702e1150d0000000004eb56"],[10133,"8da0b70227c74d5462082004597a"],[10200,"8da5b3a758b99409a6dc0fb0ef0a"],[10300,"8dabe7c558af80252a3e13adaa11"],[10357,"8d0d0c0722594377e37820b79a0d"],[10414,"8d0d0c07990c111de8380bfa7b53"],[10471,"8dabe7c5f8230006004ab8db4fc2"],[10529,"8da0b702590d6025104b4be43f71"],[10586,"8da0b70299105b82b00c81fdfd3a"],[10643,"8da5b3a7ea466864013c085e68e2"],[10700,"8d0d0c07501ba7dc72d70ad4ca2e"],[10814,"8dabe7c5e1122e00000000bdd208"],[10871,"8dabe7c59915ad0d300456c56568"],[10929,"8da0b702f8122006005ab8b0ce08"],[10986,"8d0d0c07990c111de8380bfa7b53"],[11043,"8d0d0c07f8200002004ab89cec0b"],[11100,"8d0d0c07501ba7dc8ed7098a15f6"],[11100,"8da5b3a7990da30b30085d4a3fb6"],[11100,"8da0b702590d60250e4b517bac40"],[11186,"8da0b70299105b82b00c81fdfd3a"],[11271,"8da5b3a758b99060a850f2417029"],[11357,"8da3627dea3e9860015f888cbc76"],[11443,"8d0d0c07990c111de8380bfa7b53"],[11529,"8da0b702590d60250c4b549fc8fd"],[11614,"8d0d0c07501b9032d44bf9829e6c"],[11700,"8da3627d58a580623264da78409f"],[11760,"8d0d0c07990c111de8380bfa7b53"],[11820,"8dabe7c59915ad0d300456c56568"],[11880,"8da5b3a723041333c31c2045cc84"],[12000,"8da0b702590d50250a4b58e28c01"],[12033,"8da0b70299105b82b80401e435f2"],[12067,"8da5b3a7990da30b30085eb5d7a4"],[12100,"8d0d0c07501b97dcbed708bc2ce6"],[12145,"8da5b3a758b99409cadbbef56b5c"],[12191,"8d0d0c07990c111de8380bfa7b53"],[12236,"8da27e8199103a0f58080b012970"],[12327,"8d0d0c07e1058a00000000c4d44e"],[12373,"8dabe7c5ea428867215c08fe0874"],[12418,"8da0b702590d5025084b5d06e8bc"],[12464,"8da0b70299105b82b80801ac6ff2"],[12509,"8da3627df8230002004ab8760258"],[12555,"8d0d0c07501b97dcdad707bb59d1"],[12600,"8da3627d58a5840adcef6a51bd8a"],[12700,"8d0d0c07990c111e08380ba3fc70"],[12800,"8da0b702590d57cf12d6748f7785"],[12900,"8da0b70299105b82b80c019459f2"],[13000,"8da5b3a7ea466864013c085e68e2"],[13100,"8da5b3a7990da30b30085eb5d7a4"],[13200,"8d0d0c07501b9033224bf77d0a3e"],[13250,"8da5b3a7f82300060049b8e08700"],[13300,"8da5b3a758b99060ce509b5fcb61"],[13350,"8d0d0c07990c111e08380ba3fc70"],[13400,"8da0b702590d57cf10d6776b3715"],[13475,"8da0b70299105b82980481a64f1b"],[13550,"8da0b702f8122006005ab8b0ce08"],[13625,"8d0d0c07f8200002004ab89cec0b"],[13775,"8da3627dea3e9860015f888cbc76"],[13850,"8d0d0c07990c121e08380ba6e322"],[13925,"8d0d0c07501b87dd0ad7051dac96"],[14000,"8da0b702590d5025024b6a8d137a"],[14075,"8da0b70299105c82980881e72171"],[14150,"8da5b3a7990da30b30085d4a3fb6"],[14225,"8d0d0c07990c121e08380ba6e322"],[14300,"8d0d0c07501b87dd26d704fed7ba"],[14433,"8da0b702590d6025004b6f3882e7"],[14567,"8da0b70299105c82980881e72171"],[14700,"8da3627d58a5840a22ef66eb6a98"],[14756,"8d0d0c07501b87dd3ad7047a60a6"],[14811,"8d0d0c07990c121e083c0b9ed522"],[14867,"8dabe7c5ea428867215c08fe0874"],[14922,"8da0b702590d57cf0ad68639294e"],[14978,"8da0b70299105c82980c01d811b1"],[15033,"8da5b3a7990da30b30085d4a3fb6"],[15089,"8da0b70227c74d5462082004597a"],[15144,"8dabe7c59915ad0d380456abc760"],[15200,"8da5b3a758b99409fedb445fcade"],[15225,"8d0d0c0722594377e37820b79a0d"],[15250,"8d0d0c07990c121e083c0b9ed522"],[15275,"8da0b702e1150d0000000004eb56"],[15300,"8d0d0c07501b77dd56d703fa125c"],[15400,"8da0b702590d57cf0ad689c68906"],[15500,"8da5b3a7ea466864013c085e68e2"],[15600,"8da3627d58a58409ceef64eb02ac"],[15688,"8d0d0c07501b7033a04bf355e76e"],[15775,"8d0d0c07990c121e28400b31e710"],[15862,"8d0d0c07f8200002004ab89cec0b"],[15950,"8da5b3a758b9a0610250231a9b25"],[16037,"8da5b3a7990da30b30085d4a3fb6"],[16125,"8da3627dea3e9860015f888cbc76"],[16212,"8d0d0c07990c121e28400b31e710"],[16300,"8d0d0c07501b7033be4bf2350cf9"],[16400,"8da0b702590d6024f84b8180092b"],[16514,"8da0b70299105c829004823e316b"],[16629,"8d0d0c07501b7033d04bf1473b1e"],[16743,"8d0d0c07990c121e28400b31e710"],[16857,"8da0b702590d5024f64b85939fb3"],[16971,"8da0b70299105c82900881898379"],[17086,"8dabe7c59915ad0d380456abc760"],[17200,"8da5b3a758b9a40a20daf285d56f"],[17250,"8da5b3a7990da30b30085eb5d7a4"],[17300,"8d0d0c07501b67ddb6d6ffe4df78"],[17350,"8d0d0c07990c121e28400b31e710"],[17400,"8da0b702590d57cf02d69ba8d7cd"],[17450,"8da0b70299105d82900481c2d3b7"],[17500,"8dabe7c558af8025c23ce400e723"],[17578,"8dabe7c5ea428867215c08fe0874"],[17656,"8d0d0c07e1058a00000000c4d44e"],[17733,"8da0b702590d57cf00d69e4cb370"],[17811,"8da0b70299105d82980881e42bbf"],[17889,"8d0d0c07501b67ddd2d6fee3fe0e"],[17967,"8d0d0c07990c121e483c0b142d70"],[18044,"8da3627d991417baf00854c2bc27"],[18122,"8dabe7c59915ad0d380456abc760"],[18200,"8da0b702590d57cefed6a2dcb859"],[18289,"8da0b70299105d82980c81dc1dbf"],[18378,"8d0d0c07501b60341e4bee7982a6"],[18467,"8da0b702f8122006005ab8b0ce08"],[18556,"8d0d0c07990c121e483c0b142d70"],[18644,"8dabe7c558af8025d63cb8e9d9d5"],[18733,"8d0d0c07f8200002004ab89cec0b"],[18822,"8da0b702590d47cefcd6a7087004"],[18911,"8da0b70299105d8298180103f57f"],[19000,"8d0d0c07501b50343c4bed894b3f"],[19125,"8d0d0c07990c121e483c0b142d70"],[19250,"8dabe7c59915ad0d380456abc760"],[19375,"8da0b702590d4024ee4b9b1061d2"],[19500,"8da0b70299105d82981c013bc37f"],[19625,"8da0b702590d57cef8d6b0f0bd1c"],[19750,"8da0b70299105e82981c8139daed"],[19875,"8da0b70227c74d5462082004597a"],[20000,"8d0d0c07501b50346c4bec210374"],[20050,"8da3627d991417bad00454cf9a0e"],[20100,"8dabe7c59915ae0d380456aed832"],[20150,"8d0d0c07990c121e483c0b142d70"],[20200,"8da0b702590d47cef8d6b4c029ca"],[20333,"8da0b702e1150d0000000004eb56"],[20600,"8da0b702590d4024e84ba8c2512e"],[20733,"8da0b70299105e82b82001ed660d"],[20867,"8d0d0c07501b40349e4beaf2e7fd"],[21000,"8da0b702590d5024e64bab4f4e52"],[21133,"8da3627dea3e9860015f888cbc76"],[21267,"8d0d0c07990c121e483c0b142d70"],[21400,"8da3627d58a58407a6ef57ecfcde"],[21500,"8d0d0c07501b47de80d6f8b290d6"],[21500,"8da0b702590d47cef2d6c1484d77"],[21600,"8dabe7c558af80261a3c32abbb64"],[21700,"8d0d0c07990c131e283c0bd8a3c5"],[21900,"8da0b702590d47cef0d6c4ac29ca"],[22000,"8dabe7c59915ae0d380456aed832"],[22100,"8d0d0c07501b47de9cd6f7c98782"],[22200,"8d0d0c07990c131e283c0bd8a3c5"],[22300,"8da5b3a758b9a0616e4f25365cff"],[22400,"8da0b702590d47ceeed6c8337e0e"],[22467,"8da5b3a7990da30b30045efd8da4"],[22533,"8dabe7c5ea428867215c08fe0874"],[22600,"8dabe7c558af87d034c76b592c85"],[22633,"8d0d0c07e1058a00000000c4d44e"],[22667,"8d0d0c07990c131e283c0bd8a3c5"],[22700,"8d0d0c07501b37deb8d6f6d71806"],[22750,"8dabe7c5234d7071e74c209887e7"],[22800,"8da0b702590d4024e04bbcac2bc8"],[22886,"8da5b3a7f82300060049b8e08700"],[22971,"8da5b3a7ea466864013c085e68e2"],[23057,"8d0d0c07990c131e283c0bd8a3c5"],[23143,"8da5b3a758b9a40a8ad9fe4df9bb"],[23229,"8da3627d991417bad00454cf9a0e"],[23314,"8d0d0c07501b37ded6d6f5a52fe1"],[23400,"8dabe7c558af87d046c74751cd86"],[23500,"8d0d0c07990c131e283c0bd8a3c5"],[23700,"8d0d0c07501b37dee8d6f57f4c56"],[23860,"8d0d0c07f8200002004ab89cec0b"],[24020,"8dabe7c59915ae0d300456c07a3a"],[24180,"8d0d0c07990c131e283c0bd8a3c5"],[24340,"8d0d0c07501b27df06d6f4fc32b4"],[24500,"8dabe7c558af87d05cc71a055cf7"],[24650,"8d0d0c07990c131e28380be095c5"],[24800,"8d0d0c07501b2035564be38b2a7c"],[24900,"8dabe7c59915ae0d300456c07a3a"],[25000,"8dabe7c5ea428867215c08fe0874"],[25100,"8d0d0c07990c131e28380be095c5"],[25200,"8da5b3a758b9a40aacd9ac2703b7"],[25267,"8dabe7c5f8230006004ab8db4fc2"],[25333,"8d0d0c0722594377e37820b79a0d"],[25400,"8dabe7c558af87d06ec6f772398f"],[25525,"8d0d0c07501b2035744be22a0ade"],[25650,"8da5b3a7ea466864013c085e68e2"],[25775,"8d0d0c07990c131e28380be095c5"],[25900,"8da0b702590d47cee2d6e794ecfa"],[25900,"8dabe7c59915ae0d300456c07a3a"],[25900,"8d0d0c07501b27df52d6f16313cd"],[26000,"8da0b702f8122006005ab8b0ce08"],[26100,"8da5b3a758b9a40abcd988fbbe21"],[26160,"8d0d0c07990c131e28380be095c5"],[26220,"8da0b702590d4024d24bdbd22987"],[26280,"8da0b70299105e82780801c3b2fb"],[26340,"8d0d0c07f8200002004ab89cec0b"],[26400,"8d0d0c07501b1035a44be0c65b0a"],[26500,"8dabe7c558af87d086c6c8c79a71"],[26600,"8da5b3a7990da30b30045d0265b6"],[26700,"8da0b702590d47ceded6f0aa1733"],[26789,"8da0b70299105e827804818cee3b"],[26878,"8d0d0c07990c131e083c0b9ddfec"],[26967,"8dabe7c59915ae0d5004560ffe41"],[27056,"8d0d0c07501b1035c04bdfc00f55"],[27144,"8da0b702590d4024ce4be4a81fbb"],[27233,"8da0b70299105e8258088181c812"],[27322,"8d0d0c07990c131e083c0b9ddfec"],[27411,"8dabe7c5ea428867215c08fe0874"],[27500,"8d0d0c07501b17df9ed6eef4a98e"],[27600,"8dabe7c558af87d09cc69d937b6c"],[27750,"8d0d0c07e1058a00000000c4d44e"],[27900,"8da0b702590d47cedad6f89d365b"],[27967,"8da0b70299105d8278048189f169"],[28033,"8d0d0c07990c131e08400b7791f7"],[28100,"8d0d0c07501b0035f24bde8cc707"],[28167,"8da0b702590d47ced8d6fc86a6ef"],[28233,"8da0b70299105d82700481e75361"],[28300,"8d0d0c07990c131e08400b7791f7"],[28367,"8d0d0c07501b0036064bddc0bd22"],[28433,"8da0b702f8122006005ab8b0ce08"],[28500,"8dabe7c558af87d0b0c6738ae418"],[28700,"8da0b702590d4024ca4bf1606258"],[28740,"8da0b70299105d8258088184d740"],[28780,"8dabe7c59915ad0d5004560ae113"],[28820,"8d0d0c07f8200002004ab89cec0b"],[28860,"8d0d0c07990c131e08400b7791f7"],[28900,"8d0d0c07501b07dfe0d6ec948290"],[29020,"8da0b702590d4024c84bf57bf2ec"],[29140,"8da0b70299105d82780801c6ada9"],[29260,"8d0d0c07990c131e08400b7791f7"],[29380,"8d0d0c075019f7dffed6eb0820ae"],[29500,"8da0b702590d4024c64bf8c61531"],[29500,"8da0b70299105d82780881c1ab69"],[29500,"8dabe7c558af87d0c6c647b5c4ab"],[29600,"8d0d0c07990c131e08400a8865fe"],[29700,"8dabe7c59915ad0d5004560ae113"],[29800,"8da0b70227c74d5462082004597a"],[29900,"8d0d0c075019f7e010d6eadc4ca3"],[29960,"8dabe7c5ea428867215c08fe0874"],[30020,"8da0b702590d4024c44bfedd999e"],[30080,"8da0b70299105d82780881c1ab69"],[30140,"8dabe7c5f8230006004ab8db4fc2"],[30200,"8da5b3a758b9a40b04d8e13f16db"],[30250,"8d0d0c07990c131e08400b7791f7"],[30300,"8d0d0c075019f036664bd9f36ceb"],[30350,"8d0d0c0722594377e37820b79a0d"],[30400,"8da27e81592147e0c8cd52d39b19"],[30450,"8da0b702e1150d0000000004eb56"],[30500,"8dabe7c5e1122e00000000bdd208"],[30550,"8dabe7c59915ad0d58045664431b"],[30600,"8da0b702590d47ced0d712e31936"],[30633,"8da0b70299105d82780881c1ab69"],[30667,"8da5b3a7ea466864013c085e68e2"],[30700,"8dabe7c558af87d0dcc61b1ea1d3"],[30700,"8da3627d58a584044cef443f79bd"],[30800,"8d0d0c07990c131de83c0a3bacc6"],[30900,"8da0b702f8122006005ab8b0ce08"],[31000,"8d0d0c075019f036824bd81e4930"],[31088,"8da0b702590d47ceced71783ca97"],[31175,"8da0b70299105d82700881af0961"],[31263,"8d0d0c07f8200002004ab89cec0b"],[31350,"8d0d0c07990c131de83c0bc458cf"],[31438,"8d0d0c075019e7e05cd6e7c0574e"],[31525,"8da5b3a723041333c31c2045cc84"],[31613,"8dabe7c59915ad0d58045664431b"],[31700,"8da0b702590d3024be4c0df97985"],[31733,"8da0b70299105c82700401e35f6f"],[31767,"8da5b3a7990da30b30045efd8da4"],[31800,"8dabe7c558af87d0f4c5ecdd1d9e"],[31867,"8d0d0c07990c131de8380a039ac6"],[32000,"8d0d0c075019e036b24bd7497da1"],[32200,"8da5b3a758b9a40b28d88f218f6f"],[32275,"8da0b702590d37cecad720d92c7f"],[32350,"8d0d0c07990c131dc8380a46e6ef"],[32425,"8dabe7c5ea428867215c08fe0874"],[32500,"8d0d0c075019e036d04bd662a551"],[32575,"8d0d0c07e1058a00000000c4d44e"],[32650,"8da0b70299105c82980881e72171"],[32725,"8dabe7c59915ad0d58045664431b"],[32800,"8dabe7c558af8027063a57dd52c4"],[32883,"8da5b3a7f82300060049b8e08700"],[32967,"8d0d0c07990c131dc8380bb912e6"],[33050,"8dabe7c5234d7071e74c209887e7"],[33133,"8da5b3a758b9a062304d60bfe899"],[33217,"8d0d0c075019d036ec4bd4f2875f"],[33300,"8da0b702590d47cec6d729ec1db6"],[33383,"8da0b70299105c82b0048184a550"],[33467,"8da0b702f8122006005ab8b0ce08"],[33550,"8da5b3a7990da30b30045efd8da4"],[33633,"8d0d0c07990c121dc83c0a7dda21"],[33800,"8d0d0c075019d7e0c4d6e437a8c9"],[33800,"8d0d0c07f8200002004ab89cec0b"],[33800,"8da0b702590d47cec4d72e086510"],[33850,"8da0b70299105b82b004818d913a"],[33900,"8dabe7c558af87d120c595812f4b"],[34000,"8dabe7c59915ad0d58045664431b"],[34100,"8d0d0c07990c121dc83c0b822e28"],[34200,"8da5b3a758b9a062424d374a33ec"],[34286,"8da0b70299105b82d00401451381"],[34371,"8d0d0c075019d7e0e0d6e3ba55c0"],[34457,"8da5b3a7990da30b30045d0265b6"],[34543,"8d0d0c07990c131dc83c0a7ed0ef"],[34629,"8da0b70227c74d5462082004597a"],[34714,"8da0b70299105b82d00481421541"],[34800,"8dabe7c558af8027303a049518d1"],[34850,"8dabe7c59915ad0d58045664431b"],[34900,"8d0d0c075019c7e0fcd6e2f1ba35"],[34950,"8dabe7c5ea428867215c08fe0874"],[35000,"8d0d0c07990c131dc83c0b8124e6"],[35050,"8dabe7c5f8230006004ab8db4fc2"],[35100,"8da5b3a758b9a062544d0dbac365"],[35186,"8da0b70299105b82d008010d4981"],[35271,"8d0d0c075019c7e110d6e159dcae"],[35357,"8da5b3a7990da30b30045d0265b6"],[35443,"8d0d0c07990c131da83c0ab15494"],[35529,"8d0d0c0722594377e37820b79a0d"],[35614,"8da5b3a7ea466864013c085e68e2"],[35700,"8d0d0c075019c7e122d6e025b81c"],[35800,"8dabe7c558af80274439dea69631"],[35950,"8dabe7c59915ad0d58045664431b"],[36100,"8da5b3a758b9a062664ce4cd9e2b"],[36220,"8d0d0c075019c7e13ed6df5f8e20"],[36340,"8d0d0c07f8200002004ab89cec0b"],[36580,"8d0d0c07990c131da83c0ab15494"],[36700,"8d0d0c075019b7e152d6dea54bfe"],[36800,"8dabe7c558af87d15cc51dcd6a80"],[36980,"8dabe7c59915ad0d58045664431b"],[37160,"8d0d0c07990c131da83c0b4ea09d"],[37340,"8dabe7c5ea428867215c08fe0874"],[37520,"8dabe7c59915ad0d58045664431b"],[37700,"8d0d0c075019b7e180d6ddfcb381"],[37700,"8dabe7c558af80276e39876a074f"],[38200,"8d0d0c07990c131d88380acc1ebd"],[38700,"8d0d0c075019a037ee4bcbaed4be"],[38880,"8dabe7c59915ad0d5004560ae113"],[39060,"8dabe7c558af802784395d3e841c"],[39240,"8d0d0c07f8200002004ab89cec0b"],[39420,"8dabe7c59915ad0d5004560ae113"],[39600,"8d0d0c075019a0381e4bc925e04b"],[39750,"8d0d0c07990c131d68340aa1c46b"],[39900,"8dabe7c558af87d19ec49a40e599"],[40017,"8dabe7c5ea428867215c08fe0874"],[40133,"8dabe7c5f8230006004ab8db4fc2"],[40250,"8dabe7c59915ad0d5004560ae113"],[40367,"8d0d0c07990c131d682c0a31706b"],[40483,"8dabe7c5e1122e00000000bdd208"],[40600,"8d0d0c07501997e20cd6d7527662"],[40700,"8da0b70299105882b020818efc61"],[40800,"8dabe7c558af8027b039046d0afc"],[41025,"8da0b702f8122006005ab8b0ce08"],[41250,"8d0d0c07990c131d682c0a31706b"],[41475,"8dabe7c59915ad0d58045664431b"],[41700,"8d0d0c07501997e23ad6d61943d4"],[41860,"8dabe7c558af87d1c6c4498276b9"],[42020,"8dabe7c59915ad0d58045664431b"],[42180,"8dabe7c5ea428867215c08fe0874"],[42340,"8d0d0c07990c141d48240a0d5428"],[42500,"8d0d0c0750199038ac4bc4e23d2e"],[42650,"8d0d0c07e1058a00000000c4d44e"],[42800,"8dabe7c558af8027da38b0df9613"],[42950,"8da5b3a7f82300060049b8e08700"],[43100,"8dabe7c5234d7071e74c209887e7"],[43250,"8dabe7c59915ad0d58045664431b"],[43400,"8d0d0c07990c141d48240a0d5428"],[43550,"8da27e819910370e58100c52c781"],[43700,"8d0d0c07501987e298d6d29d0704"],[43800,"8dabe7c558af8027ee38898e5a23"],[43929,"8da27e81ea0ad870015c00df9121"],[44057,"8d0d0c07f8200002004ab89cec0b"],[44186,"8dabe7c59915ad0d58045664431b"],[44314,"8d0d0c07990c141d281c0a6c205a"],[44443,"8da27e81592127e20acd9ba7de47"],[44571,"8da27e819910370e58100c52c781"],[44700,"8d0d0c07501987e2c6d6d088fcd3"],[44800,"8dabe7c558af87d204c3cfa8275c"],[44877,"8dabe7c5ea428867215c08fe0874"],[44954,"8d0d0c07990c141d081c0a295c73"],[45031,"8dabe7c5f8230006004ab8db4fc2"],[45108,"8da27e81ea0ad870015c00df9121"],[45185,"8dabe7c59915ad0d58045664431b"],[45262,"8da27e81592127e218cd9e9efeea"],[45338,"8da27e819910370e58140c6af181"],[45415,"8d0d0c07990c141d081c0a295c73"],[45492,"8da27e81e116260000000095bccf"],[45569,"8d0d0c0750198039424bbe9c79ba"],[45646,"8da27e81592127e226cda1ba1c7d"],[45723,"8da27e819910360e58100c51cd4f"],[45800,"8dabe7c558af80281a3832cb9c1f"],[45900,"8d0d0c07990c141ce8180a1f17f6"],[46000,"8d0d0c0722594377e37820b79a0d"],[46100,"8da27e81213b2d960a082039f0c9"],[46200,"8dabe7c59915ad0d5808562c191b"],[46300,"8d0d0c07501987e30ed6cd5203fc"],[46400,"8d0d0c07990c141ce8180a1f17f6"],[46600,"8d0d0c07f8200002004ab89cec0b"],[46700,"8dabe7c558af87d22cc3807fda55"],[46800,"8d0d0c0750198039704bbc1ff51a"],[47083,"8dabe7c59915ad0d5808562c191b"],[47367,"8dabe7c5ea428867215c08fe0874"],[47650,"8dabe7c558af80284237e2acabad"],[47933,"8dabe7c59915ad0d5808562c191b"],[48217,"8da0b702590d60247e4c9c93880b"],[48500,"8da0b702590d50247c4ca0d8bc9b"],[48550,"8da0b702991059821804014d58ea"],[48600,"8dabe7c558af87d254c332058385"],[49050,"8dabe7c59915ad0d5808562c191b"],[49500,"8dabe7c558af87d266c30a87423a"],[49620,"8da0b70227c74d5462082004597a"],[49740,"8dabe7c5ea428867215c08fe0874"],[49860,"8dabe7c59915ad0d780856696532"],[49980,"8dabe7c5f8230006004ab8db4fc2"],[50100,"8da0b702590d6024784caebe4cfe"],[50250,"8d0d0c07990c141ca8100ae583a4"],[50400,"8dabe7c558af87d27ac2e5f71cfe"],[50675,"8dabe7c5e1122e00000000bdd208"],[50813,"8dabe7c59915ad0d780456213f32"],[50950,"8da0b702590d6024744cb718db4f"],[51088,"8da0b70299105a823804810a3d51"],[51225,"8da0b702f8122006005ab8b0ce08"],[51363,"8da5b3a7990da30b18045d29bb97"],[51500,"8dabe7c558af8028923741e83c20"],[51500,"8da0b702590d57ce82d7c69bfc58"],[51667,"8da0b70299105a82380801456191"],[52000,"8da5b3a758b9a0637c4a5393f76e"],[52050,"8dabe7c59915ad0d780456213f32"],[52100,"8da0b702590d5024724cbf65a785"],[52200,"8da0b70299105a82380c817a5151"],[52300,"8d0d0c075019703a6a4bb2223d9d"],[52400,"8dabe7c5ea428867215c08fe0874"],[52500,"8dabe7c558af87d2a4c2910a65a1"],[52580,"8d0d0c07990c141ca8080a7537a4"],[52660,"8da5b3a7990da30b18045ed65385"],[52740,"8da0b70299105a825810811d572a"],[52820,"8d0d0c07501977e432d6c2564f91"],[52900,"8da5b3a758b9a0638e4a29725555"],[52971,"8da5b3a7f82300060049b8e08700"],[53043,"8dabe7c59915ad0d780456213f32"],[53114,"8da0b702590d57ce7ed7d43a3751"],[53186,"8da0b70299105a825810811d572a"],[53257,"8da5b3a7ea466864013c085e68e2"],[53329,"8dabe7c5234d7071e74c209887e7"],[53400,"8dabe7c558af87d2b8c26b8b1a02"],[53500,"8da27e819910370e38100c9d43fa"],[53600,"8da5b3a7990da30b18045ed65385"],[53700,"8da0b702590d50246c4cce07ee1a"],[53775,"8da0b70299105a827818012f41c3"],[53850,"8d0d0c07f8200002004ab89cec0b"],[53925,"8da27e81ea0ad870015c00df9121"],[54000,"8da5b3a758b99063a249fcd69097"],[54050,"8da0b702590d50246a4cd1d4570c"],[54100,"8da0b70299105b8278200182bb04"],[54150,"8d0d0c07990c131c88040a7125e7"],[54200,"8dabe7c59915ad0d7004564f9d3a"],[54250,"8d0d0c07501977e47ad6bf4f0b90"],[54300,"8dabe7c558af8028d036c97017dc"],[54340,"8da27e81592107e2e6cdce465bda"],[54380,"8da27e819910370e38100c9d43fa"],[54420,"8da5b3a7990da40b18045d208ffd"],[54460,"8d0d0c07990c131c80040a1f87ef"],[54500,"8da0b702590d47ce78d7e1d88f60"],[54700,"8d0d0c07501977e48cd6be64c2d9"],[54767,"8dabe7c5ea428867215c08fe0874"],[54833,"8da0b70227c74d5462082004597a"],[54900,"8da27e81592100393c4292bac70c"],[54933,"8da27e819910370e38100c9d43fa"],[54967,"8dabe7c5f8230006004ab8db4fc2"],[55000,"8da5b3a758b9a40cb6d4ea32b20a"],[55133,"8dabe7c59915ad0d7004564f9d3a"],[55267,"8da0b702590d57ce76d7e655a82a"],[55400,"8dabe7c558af8028e6369a385dc9"],[55450,"8da5b3a7990da40b10045d4e2df5"],[55500,"8da27e8159210039484295608647"],[55550,"8da27e819910370e38100c9d43fa"],[55600,"8da3627d991417bab00854484475"],[55650,"8d0d0c07990c141c80080a5ee985"],[55700,"8da0b702590d5024644cdf9658c3"],[55744,"8da0b70299105b82b81c828c6f20"],[55789,"8da0b702e1150d0000000004eb56"],[55833,"8da27e81213b2d960a082039f0c9"],[55878,"8da5b3a7ea466864013c085e68e2"],[55922,"8da27e81f8132006005ab848315e"],[55967,"8da27e81592107e30acdd61145e7"],[56011,"8da27e819910380e38100b73f1c4"],[56056,"8da3627d2314e673cf4d2011090e"],[56100,"8da3627d58a58051c0647ade432a"],[56129,"8dabe7c59915ad0d7004564f9d3a"],[56157,"8da0b702f8122006005ab8b0ce08"],[56186,"8da3627dea3e9860015f888cbc76"],[56214,"8d0d0c0722594377e37820b79a0d"],[56243,"8da0b702590d57ce72d7ef9d7d4b"],[56271,"8da0b70299105b82d81881843549"],[56300,"8d0d0c07501977e4d4d6bba210ac"],[56300,"8dabe7c558af87d2f6c1ef5691a8"],[56500,"8da27e81ea0ad870015c00df9121"],[56600,"8da27e815921003960429b4b0c27"],[56700,"8d0d0c07990c141c60080a7b6953"],[56800,"8da0b702590d57ce70d7f479ad6f"],[56875,"8da0b70299105b82d81481cc6f49"],[56950,"8da27e81592107e320cddc215f33"],[57025,"8da27e819910380e18100b368ded"],[57100,"8d0d0c07501977e4f8d6ba416b80"],[57100,"8da3627d58a587fab0ef0dfe55ff"],[57133,"8dabe7c59915ad0d7004564f9d3a"],[57167,"8dabe7c5ea428867215c08fe0874"],[57200,"8da0b702590d40245e4ceeb513f1"],[57250,"8da0b70299105b82f81881c14960"],[57300,"8dabe7c558af87d30ac1c7ddeaed"],[57600,"8da27e81592100397642a04408a7"],[57900,"8da27e81592107e336cde12e7f9e"],[57971,"8d0d0c07501977e526d6b89569a9"],[58043,"8dabe7c59915ad0d7004564f9d3a"],[58114,"8d0d0c07990c141c60080a7b6953"],[58186,"8dabe7c558af802924361f903e18"],[58257,"8da27e81f8132006005ab848315e"],[58329,"8da0b702590d47ce68d802a52bf2"],[58400,"8da27e81592100398c42a6c9e2c6"],[58471,"8da27e819910380e18100cc95dc9"],[58543,"8da3627d991417bab00854484475"],[58614,"8d0d0c075019703b904ba684d66c"],[58686,"8da3627dea3e9860015f888cbc76"],[58900,"8da27e81592100399642a861f81d"],[58900,"8da27e819910380e18100cc95dc9"],[58900,"8da27e81ea0ad870015c00df9121"],[58900,"8da0b702590d4024564cfcdb4d3a"],[58967,"8da0b70299105b83181081bf58e5"],[59033,"8d0d0c07f8200002004ab89cec0b"],[59100,"8d0d0c075019703ba24ba60746d7"],[59200,"8dabe7c558af80293835f903ffb9"],[59300,"8dabe7c59915ad0d780456213f32"],[59400,"8da27e819910380e18100cc95dc9"],[59500,"8da0b70299105b83381481c212cc"],[59600,"8d0d0c07990c141c60080a7b6953"],[59700,"8da3627d991417bab00854484475"],[59800,"8dabe7c5ea428867215c08fe0874"],[59900,"8da3627d58a587f9acef07068961"],[59950,"8dabe7c5f8230006004ab8db4fc2"],[60000,"8d0d0c075019703bd04ba40e3225"],[60200,"8dabe7c558af80294c35d2d83718"],[60500,"8da27e81592107e36ccdeef3691e"],[60800,"8da27e81592107e374cdf0403b9f"],[61000,"8da3627d58a5804ffa6470d4c5be"],[61200,"8dabe7c5e1122e00000000bdd208"],[61400,"8dabe7c558af87d35cc125a3f722"],[61600,"8da3627dea3e9860015f888cbc76"],[61800,"8dabe7c59915ad0d780456213f32"],[62000,"8da3627d58a587f8ecef0258405f"],[62100,"8dabe7c558af802978357c71cd14"],[62260,"8da3627df8230002004ab8760258"],[62420,"8da3627d991417bab00454001e75"],[62580,"8da27e819910380df80c0c3858ea"],[62740,"8dabe7c59915ad0d780456213f32"],[62900,"8da3627d58a5804f3c646c986845"],[63057,"8da3627de106290000000030ef86"],[63214,"8da3627d991417bab00454001e75"],[63371,"8dabe7c5234d7071e74c209887e7"],[63529,"8dabe7c59915ad0d7004564f9d3a"],[63686,"8da3627dea3e9860015f888cbc76"],[63843,"8da27e81ea0ad870015c00df9121"],[64000,"8da27e815921003a0642c3d765fb"],[64180,"8da3627d991417bab00454001e75"],[64360,"8da27e819910380dd0080c2bb0cb"],[64540,"8dabe7c59915ad0d7004564f9d3a"],[64720,"8dabe7c5ea428867215c08fe0874"],[64900,"8da5b3a758b9940d60d35af5fc38"],[65000,"8da3627d58a5804e7864680e0476"],[65000,"8da27e81592107e3d0ce07307073"],[65100,"8dabe7c558af8029b834fe1826b0"],[65200,"8da27e81ea0ad870015c00df9121"],[65300,"8da3627d58a5804e526467c1d29d"],[65400,"8da27e81592107e3d8ce095e863a"],[65588,"8da27e819910380db00c0cdc02b0"],[65775,"8da3627d991417bab00854484475"],[65963,"8dabe7c59915ad0d7004564f9d3a"],[66150,"8da3627dea3e9860015f888cbc76"],[66338,"8da3627d2314e673cf4d2011090e"],[66525,"8da3627d991417bab00854484475"],[66713,"8dabe7c59915ad0d7004564f9d3a"],[66900,"8da3627d58a5804dc06463910561"],[67200,"8dabe7c558af87d3dac02c9483ec"],[67300,"8da3627df8230002004ab8760258"],[67400,"8dabe7c5ea428867215c08fe0874"],[67500,"8da3627d991417bab00854484475"],[67600,"8da0b702590d3024264d49ad2de6"],[67900,"8d0d0c07501977e6e0d6a75a2bb5"],[68000,"8da3627d58a587f6baeef670859e"],[68100,"8da0b70299105883d808014d0988"],[68200,"8dabe7c558af8029fa347b71bc9f"],[68333,"8d0d0c07990c121c60080a7157f7"],[68467,"8da3627d991417bab00854484475"],[68600,"8da3627dea3e9860015f888cbc76"],[68733,"8da27e81ea0ad870015c00df9121"],[68867,"8dabe7c59915ad0d7004564f9d3a"],[69000,"8da3627d58a5804d02645feb382e"],[69225,"8dabe7c558af87d40abfd0077db3"],[69450,"8da3627d991417bab00454001e75"],[69675,"8dabe7c5ea428867215c08fe0874"],[69900,"8da3627d58a587f608eef21929be"],[70000,"8dabe7c5f8230006004ab8db4fc2"],[70100,"8dabe7c59915ad0d7004564f9d3a"],[70200,"8dabe7c558af87d41cbfad0bde7e"],[70400,"8da3627d991417bab00454001e75"],[70600,"8da3627d58a5804c46645b7d541d"],[70800,"8dabe7c5e1122e00000000bdd208"],[71000,"8dabe7c59915ad0d7004564f9d3a"],[71200,"8dabe7c558af87d430bf84e9148e"],[71343,"8da3627dea3e9860015f888cbc76"],[71486,"8da27e81ea0ad870015c00df9121"],[71629,"8da3627d991417bab8085426e67d"],[71771,"8da27e819910370db0040cbd0caa"],[71914,"8da3627d58a5804bfa6459857527"],[72057,"8dabe7c59915ad0d780456213f32"],[72200,"8da27e815921003ab842f019cb9b"],[72233,"8da27e819910370db0040cbd0caa"],[72267,"8da3627df8230002004ab8760258"],[72300,"8dabe7c558af802a5033d1fb42e3"],[72375,"8dabe7c5ea428867215c08fe0874"],[72450,"8da3627d991417bad80854e96206"],[72525,"8da5b3a7990da40b38045d65f3d4"],[72600,"8da27e81592107e474ce2f7a1a96"],[72700,"8da3627d58a587f502eeec109058"],[72800,"8da3627de106290000000030ef86"],[72900,"8da5b3a758b9940dead2146728be"],[73000,"8dabe7c59915ad0d780456213f32"],[73100,"8dabe7c5234d7071e74c209887e7"],[73200,"8dabe7c558af802a6433a756adfb"],[73371,"8da3627d991417bad80854e96206"],[73543,"8da5b3a7990da40b38045d65f3d4"],[73714,"8da3627d58a5804b3c6455c93804"],[73886,"8da3627dea3e9860015f888cbc76"],[74057,"8da3627d58a5804b16645406baae"],[74229,"8dabe7c59915ad0d780456213f32"],[74400,"8dabe7c558af87d472bf007f7aa8"],[74550,"8da3627d991417bad80854e96206"],[74700,"8da3627d58a587f448eee8c4a7e5"],[74829,"8dabe7c5ea428867215c08fe0874"],[74957,"8da5b3a7990da40b18045edf67ef"],[75086,"8dabe7c5f8230006004ab8db4fc2"],[75214,"8dabe7c59915ad0d58045664431b"],[75343,"8dabe7c558af87d486bedb451fec"],[75471,"8da3627d991417bad80854e96206"],[75600,"8da3627d58a587f3f6eee6276607"],[75775,"8da3627dea3e9860015f888cbc76"],[75950,"8dabe7c59915ad0d58045664431b"],[76125,"8da3627d991417bad80854e96206"],[76300,"8dabe7c558af87d49cbeae103f41"],[76450,"8da3627d2314e673cf4d2011090e"],[76600,"8da3627d58a587f38eeee3a4c85b"],[76760,"8da3627df8230002004ab8760258"],[76920,"8dabe7c59915ad0d58045664431b"],[77080,"8da3627d991417bad80854e96206"],[77240,"8dabe7c5ea428867215c08fe0874"],[77400,"8dabe7c558af87d4b2be83e96505"],[77733,"8dabe7c59915ad0d5004560ae113"],[78067,"8da3627d991417bad80854e96206"],[78400,"8dabe7c558af802ad232cd044bce"],[78500,"8da5b3a7990da40b18045d208ffd"],[78600,"8da3627dea3e9860015f888cbc76"],[78700,"8da5b3a758b990655645f8a96889"],[78750,"8da5b3a7e10d2800000000aefd57"],[78800,"8da3627d58a5804950644af7f545"],[79000,"8dabe7c59915ad0d58045664431b"],[79200,"8dabe7c558af802ae632a656f85d"],[79567,"8dabe7c5ea428867215c08fe0874"],[79933,"8dabe7c5f8230006004ab8db4fc2"],[80300,"8dabe7c558af87d4eebe0e1f90d8"],[80633,"8dabe7c5e1122e00000000bdd208"],[80967,"8dabe7c59915ad0d780456213f32"],[81300,"8dabe7c558af87d504bde18c150f"],[81750,"8dabe7c5ea428867215c08fe0874"],[82200,"8dabe7c558af802b243229fef7fb"],[82733,"8dabe7c5234d7071e74c209887e7"],[83267,"8dabe7c59915ad0d780456213f32"],[83800,"8dabe7c59915ad0d780456213f32"],[84333,"8dabe7c5ea428867215c08fe0874"],[84867,"8dabe7c5f8230006004ab8db4fc2"],[85400,"8dabe7c558af87d558bd39864f55"],[85850,"8dabe7c59915ad0d7004564f9d3a"],[86300,"8dabe7c558af87d56ebd0f338baf"],[86850,"8dabe7c59915ad0d780456213f32"],[87400,"8dabe7c5ea428867215c08fe0874"],[87950,"8dabe7c59915ad0d780456213f32"],[88500,"8dabe7c558af802baa312344d22f"],[88950,"8dabe7c59915ad0d780456213f32"],[89400,"8dabe7c558af802bbe30fa5b2ba6"],[89700,"8da5b3a758b99066164431aa9b6b"],[89900,"8da5b3a7990da40b18045edf67ef"],[90100,"8dabe7c5ea428867215c08fe0874"],[90300,"8dabe7c59915ad0d780456213f32"],[90500,"8dabe7c558af87d5c6bc6201a220"],[90600,"8dabe7c5e1122e00000000bdd208"],[90700,"8da5b3a758b9940f1ecf3da78d8f"],[90800,"8dabe7c59915ad0d780456213f32"],[90900,"8da5b3a7990da40b18045edf67ef"],[91000,"8dabe7c558af802bec309feab189"],[91200,"8dabe7c59915ad0d780456213f32"],[91300,"8da5b3a723041333c31c2045cc84"],[91400,"8dabe7c5ea428867215c08fe0874"],[91500,"8da5b3a7990da40b10045eb1c5e7"],[91600,"8dabe7c558af802c0230735b9422"],[91700,"8da5b3a758b990664c43b05affea"],[91800,"8dabe7c5234d7071e74c209887e7"],[91900,"8dabe7c59915ad0d780456213f32"],[92000,"8da5b3a7f82300060049b8e08700"],[92100,"8da5b3a7ea466864013c085e68e2"],[92200,"8da3627de106290000000030ef86"]],"dump1090":{"0d0c07":{"flight":"VTM787","category":"A2","fixes":[[500,30.285676,-97.775134],[1600,30.28688,-97.775197],[2700,30.287933,-97.775304],[3700,30.289167,-97.775409],[5000,30.290703,-97.775519],[6100,30.291641,-97.775628],[6900,30.292658,-97.775684],[7900,30.293839,-97.775789],[9000,30.294937,-97.775843],[10000,30.296036,-97.775951],[11100,30.297267,-97.776068],[12100,30.298384,-97.776123],[13200,30.299606,-97.77622],[14300,30.300805,-97.776343],[15300,30.301922,-97.776398],[16300,30.303177,-97.776489],[17300,30.304157,-97.776617],[18400,30.305374,-97.776705],[19000,30.306061,-97.776759],[20000,30.307159,-97.776812],[21500,30.308859,-97.777002],[22700,30.310162,-97.777112],[23700,30.311279,-97.777167],[24800,30.312515,-97.777297],[25900,30.313747,-97.777386],[26400,30.314301,-97.777459],[27500,30.315516,-97.777551],[28900,30.317052,-97.777661],[29900,30.318169,-97.777771],[31000,30.319382,-97.777889],[32000,30.32048,-97.777943],[32500,30.321167,-97.777997],[33800,30.322359,-97.778101],[34900,30.323662,-97.77821],[35700,30.324547,-97.77832],[36700,30.325664,-97.77843],[37700,30.326735,-97.778485],[38700,30.327713,-97.77859],[39600,30.328812,-97.778697],[40600,30.329993,-97.778815],[41700,30.331064,-97.77887],[42500,30.332062,-97.778967],[43700,30.333252,-97.779089],[44700,30.334323,-97.779199],[46300,30.335999,-97.779364],[46900,30.336548,-97.779397],[52800,30.342795,-97.779968],[54700,30.34489,-97.780188],[56300,30.346566,-97.780353],[57100,30.347404,-97.780408],[59100,30.349411,-97.780582],[60000,30.350464,-97.78069],[67900,30.358763,-97.781451]]},"a3627d":{"flight":"ENY3344","category":"A3","fixes":[[1500,30.597482,-97.431921],[3700,30.592804,-97.432197],[11700,30.575363,-97.433113],[12600,30.573182,-97.43324],[14700,30.568853,-97.433459],[15600,30.566898,-97.433569],[21500,30.554049,-97.434283],[30800,30.534078,-97.435327],[56100,30.479004,-97.438283],[57100,30.476819,-97.438348],[59900,30.470767,-97.438678],[61000,30.468613,-97.438821],[62000,30.466298,-97.438953],[62900,30.464264,-97.439037],[65000,30.459778,-97.439252],[65300,30.458908,-97.439306],[66900,30.455566,-97.439521],[68000,30.453217,-97.439612],[69000,30.451218,-97.439737],[69900,30.449074,-97.439832],[70800,30.446915,-97.439952],[71800,30.445175,-97.44006],[72700,30.442976,-97.440161],[74700,30.438646,-97.440381],[75600,30.436738,-97.440491],[76600,30.434317,-97.440656],[78900,30.429565,-97.440868]]},"a0b702":{"flight":"145TX","category":"A7","fixes":[[1900,30.218033,-97.789576],[3100,30.217896,-97.789032],[4100,30.217802,-97.788593],[5000,30.217709,-97.788098],[6000,30.217616,-97.787604],[8000,30.217383,-97.78667],[9200,30.2173,-97.786129],[10000,30.217197,-97.785681],[11100,30.217117,-97.78516],[12000,30.217026,-97.784783],[13400,30.216871,-97.784088],[14000,30.216843,-97.783813],[15400,30.216732,-97.783099],[16400,30.216614,-97.782575],[17400,30.216545,-97.782111],[18200,30.216452,-97.781726],[19300,30.216385,-97.781175],[20200,30.216313,-97.780737],[21500,30.216173,-97.780023],[22400,30.21608,-97.779639],[22900,30.216064,-97.779397],[25900,30.215801,-97.777936],[26700,30.215708,-97.777441],[27900,30.215614,-97.777002],[28700,30.215561,-97.776543],[29500,30.215469,-97.776166],[30600,30.215382,-97.775574],[31700,30.215286,-97.775035],[32200,30.215242,-97.774805],[33800,30.215102,-97.774036],[48500,30.213776,-97.767119],[50100,30.213684,-97.766365],[51500,30.213566,-97.765686],[52100,30.213547,-97.765449],[53700,30.213409,-97.764641],[54500,30.213333,-97.764203],[55700,30.213226,-97.763726],[56800,30.213147,-97.763159],[57200,30.213089,-97.762918],[59000,30.212906,-97.762164],[67600,30.211807,-97.758017]]},"a5b3a7":{"flight":"AAL3010","category":"A3","fixes":[[1200,30.562317,-97.685367],[2400,30.562754,-97.687738],[3400,30.563187,-97.690107],[4300,30.563599,-97.692315],[5300,30.563965,-97.694361],[7200,30.564789,-97.698831],[8300,30.565201,-97.701039],[10200,30.565967,-97.705426],[11200,30.566345,-97.707555],[12200,30.566805,-97.709875],[13200,30.567215,-97.712241],[15200,30.568015,-97.716577],[16100,30.568405,-97.718703],[17200,30.568806,-97.721082],[22300,30.570877,-97.732382],[23200,30.571274,-97.734485],[25200,30.572065,-97.738989],[26100,30.572437,-97.740967],[30200,30.574113,-97.75014],[32200,30.574951,-97.754645],[34200,30.575729,-97.758987],[35100,30.576141,-97.761248],[36200,30.576553,-97.763456],[52000,30.582916,-97.798839],[52900,30.583328,-97.801101],[54000,30.583786,-97.803524],[55100,30.584215,-97.805896],[64900,30.588172,-97.827869],[72900,30.591384,-97.845776],[78700,30.593765,-97.858887],[89700,30.59816,-97.883391],[90700,30.598553,-97.885712]]},"abe7c5":{"flight":"SWA1940","category":"A3","fixes":[[5400,30.215378,-97.95631],[6400,30.215881,-97.958733],[7300,30.216313,-97.960913],[8300,30.216797,-97.963041],[9400,30.217337,-97.965692],[10400,30.217758,-97.967727],[17500,30.221237,-97.984045],[18500,30.221695,-97.986414],[21600,30.223251,-97.993631],[22600,30.223668,-97.995685],[23400,30.224087,-97.997662],[24500,30.224599,-98.000134],[25400,30.225018,-98.002057],[26500,30.225577,-98.004639],[27600,30.226089,-98.007001],[28500,30.226554,-98.009308],[29500,30.227066,-98.011725],[30700,30.227578,-98.014142],[31800,30.228137,-98.016724],[32800,30.228653,-98.019212],[33900,30.229161,-98.021503],[34800,30.229614,-98.023682],[35800,30.230072,-98.025728],[36800,30.230558,-98.028094],[37700,30.231033,-98.030413],[38800,30.231537,-98.032675],[39900,30.232094,-98.035291],[40800,30.232544,-98.037468],[41800,30.233025,-98.03974],[42800,30.233505,-98.041992],[43800,30.233963,-98.044093],[44800,30.234468,-98.046442],[45800,30.23497,-98.048778],[46700,30.235399,-98.050781],[48600,30.23633,-98.055066],[49500,30.236749,-98.057263],[50400,30.237215,-98.059296],[51500,30.237717,-98.061757],[52500,30.238192,-98.06391],[53400,30.238658,-98.065997],[54300,30.239136,-98.068219],[55400,30.239639,-98.070751],[56300,30.240101,-98.072809],[57300,30.240566,-98.075006],[58300,30.241058,-98.077375],[59200,30.241516,-98.079421],[60200,30.241974,-98.081521],[62200,30.242981,-98.086153],[65100,30.244446,-98.092939],[67200,30.245408,-98.097583],[68200,30.245956,-98.099994],[69400,30.246525,-98.102637],[70200,30.246944,-98.104559],[71200,30.24741,-98.106812],[72300,30.247925,-98.109149],[73200,30.248383,-98.111411],[74400,30.248946,-98.114062],[75300,30.249411,-98.116095],[76300,30.249923,-98.118567],[77400,30.250436,-98.120929],[78400,30.2509,-98.123151],[79200,30.251358,-98.125251],[80300,30.251832,-98.127356],[81300,30.252344,-98.129828],[82200,30.252777,-98.131983],[85400,30.254299,-98.139056],[86400,30.254811,-98.141364],[88500,30.255844,-98.146093],[89400,30.256302,-98.148301],[90500,30.25686,-98.150867],[91500,30.257355,-98.153202]]},"a27e81":{"flight":"N26VB","category":"A1","fixes":[[54900,30.335358,-97.90574],[55500,30.335632,-97.905579],[56500,30.336182,-97.905255],[57900,30.33693,-97.904828],[58900,30.337418,-97.904555],[60900,30.338373,-97.904004],[64000,30.339981,-97.903101],[65000,30.340514,-97.90274],[65400,30.3407,-97.902631],[72200,30.344055,-97.900678],[72700,30.344331,-97.900543]]}}} \ No newline at end of file diff --git a/src/data/fixtures/adsb-austin-dump1090-aircraft.json b/src/data/fixtures/adsb-austin-dump1090-aircraft.json new file mode 100644 index 000000000..88d2011e3 --- /dev/null +++ b/src/data/fixtures/adsb-austin-dump1090-aircraft.json @@ -0,0 +1,53 @@ +{ + "now": 1790127097.5, + "messages": 112, + "aircraft": [ + { + "hex": "a15c54", + "flight": "SKW3301 ", + "alt_baro": 5050, + "gs": 287.5, + "track": 291.2, + "baro_rate": 3072, + "seen": 31.5, + "messages": 14, + "rssi": -28.2 + }, + { + "hex": "ae5d8a", + "flight": "SHINR42 ", + "lat": 30.269662, + "lon": -97.793262, + "alt_baro": 1600, + "gs": 129.9, + "track": 330.5, + "baro_rate": 64, + "seen": 30.5, + "seen_pos": 34.6, + "messages": 74, + "rssi": -30.0 + }, + { + "hex": "a3c775", + "lat": 30.360625, + "lon": -97.919659, + "alt_baro": 34000, + "gs": 434.4, + "track": 283.7, + "baro_rate": 0, + "seen": 38.2, + "seen_pos": 43.4, + "messages": 20, + "rssi": -30.5 + }, + { + "hex": "a46e6a", + "gs": 319.8, + "track": 351.2, + "baro_rate": 2688, + "seen": 32.0, + "messages": 4, + "rssi": -37.1 + } + ] +} diff --git a/src/data/fixtures/adsb-austin-frames.txt b/src/data/fixtures/adsb-austin-frames.txt new file mode 100644 index 000000000..089695868 --- /dev/null +++ b/src/data/fixtures/adsb-austin-frames.txt @@ -0,0 +1,112 @@ +*8da46e6a99143227b0ac21405d5c; +*8da46e6aea428858015c080bb810; +*5da46e6ae86d20; +*8da3c775990da70d180457361e57; +*8dae5d8aea00085ee53e004546c3; +*8dae5d8a580d77d658d63e32c82f; +*5dae5d8a54a93d; +*8da3c77558af803cce430dad950f; +*200015b8f3156e; +*02e615b82df0c7; +*02e615b82df0c7; +*8da3c775990da70d180458c9be1f; +*8dae5d8a99a4400e70088202ff80; +*5dae5d8a54a93d; +*200015b8f3156e; +*200015b8f3156e; +*8dae5d8a580d702c844b24b6523e; +*8dae5d8af80300020049bc1cd8f3; +*200015b8f3156e; +*20000197d80069; +*5dae5d8a54a93d; +*020001970f9c90; +*8dae5d8a580d77d688d63170d088; +*8da3c77558af87e6a0cdf2c8bbf8; +*8dae5d8aea00085ee53e004546c3; +*8dae5d8a274c824e4b4ca06f9608; +*5dae5d8a54a93d; +*8dae5d8a99a4400e700c823ac980; +*020001970f9c90; +*5da15c54111a4b; +*8dae5d8a580d77d6a0d62aa4760f; +*5da3c7758f35d4; +*8dae5d8aea00085ee53e004546c3; +*8dae5d8a99a4410e500c827cbf67; +*8da15c54581b8019e24c96ebec6d; +*8dae5d8a580d77d6ccd61fccec2f; +*5dae5d8a54a93d; +*8dae5d8a99a4410e500c827cbf67; +*8dae5d8a580d77d6d8d61cd91129; +*8dae5d8a99a4410e500c827cbf67; +*8dae5d8a580d77d6e2d619cbefa5; +*8dae5d8af80300020049bc1cd8f3; +*20000197d80069; +*80000197580d77d6e8d6172cc0e3; +*8dae5d8a580d702d044b0189a64f; +*020001970f9c90; +*8dae5d8a99a4410e500882448967; +*8dae5d8a580d77d6fad6128791c3; +*5dae5d8a54a93d; +*8dae5d8a99a4410e500882448967; +*020001970f9c90; +*8da15c54581bc01a424c2f038351; +*8dae5d8ae1080800000000e6168f; +*8da3c77558af87e730ccd3243e2f; +*8dae5d8a580d87d728d6051c99a8; +*5dae5d8a54a93d; +*8dae5d8aea00085ee53e004546c3; +*8dae5d8a99a4410e500882448967; +*8dae5d8a580d802d584aea9aed46; +*5da15c54111a4b; +*8dae5d8a99a4410e500c827cbf67; +*2000019827a021; +*8dae5d8af80300020049bc1cd8f3; +*8dae5d8a580d87d754d5f94050dd; +*8dae5d8a99a4410e500c827cbf67; +*5dae5d8a54a93d; +*8dae5d8a99a4410e500c827cbf67; +*02000198f03cd8; +*8dae5d8a580d87d76ed5f252fa10; +*8dae5d8aea00085ee53e004546c3; +*8da15c5499150a0df0ac0d713d8d; +*200015b8f3156e; +*8da3c775990da70d100458a71c17; +*8dae5d8a580d87d778d5ef5c1b0d; +*8dae5d8a99a4410e500882448967; +*5dae5d8a54a93d; +*02818393027eb0; +*5da3c7758f35d5; +*5da3c7758f35d5; +*5da3c7758f35d4; +*200015b8f3156e; +*8dae5d8a580d87d782d5ec2e3d53; +*8da15c54ea178858015e88a53d08; +*5dae5d8a54a93d; +*8dae5d8a99a4410e500882448967; +*02000198f03cd8; +*8dae5d8ae1080800000000e6168f; +*200015b8f3156e; +*8dae5d8a580d802db04ad2d09e9c; +*02e195b8f5cd24; +*8da15c5499150b0db0b40d687b11; +*8dae5d8aea00085ee53e004546c3; +*8dae5d8a99a4410e500882448967; +*8dae5d8a580d802dc64accee13e8; +*2000019827a021; +*8dae5d8a99a4410e500881bb6175; +*8dae5d8a580d802ddc4ac5b9d917; +*8dae5d8af80300020049bc1cd8f3; +*80000198580d87d7cad5d9283036; +*02000198f03cd8; +*5da15c54111a4b; +*8dae5d8a580d87d7ecd5d0a28b94; +*8dae5d8a99a4410e500881bb6175; +*8da15c54234cb5f3cf0c6055934a; +*5dae5d8a54a93d; +*8da15c5499150d0d50c40e1a396a; +*8da15c5499150d0d30c40ed5bd11; +*5da15c54111a4b; +*5da46e6ae86d29; +*8da15c54581f201b484af27089b0; +*02000198f03cd8; +*5dae5d8a54a93d; diff --git a/src/data/lifecycle.js b/src/data/lifecycle.js index fed812d47..e083f1677 100644 --- a/src/data/lifecycle.js +++ b/src/data/lifecycle.js @@ -16,6 +16,8 @@ const VALID_LAYER_SERIALIZATION_DISPOSITIONS = new Set([ 'enabled-only', 'enabled+options', 'enabled+mirrored-options', + // Registered but never serialized (for example hardware-local layers). + 'local-only', ]); function isAbortError(error) { diff --git a/src/data/localReceiversProxy.test.mjs b/src/data/localReceiversProxy.test.mjs new file mode 100644 index 000000000..79ea9a558 --- /dev/null +++ b/src/data/localReceiversProxy.test.mjs @@ -0,0 +1,459 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { + LOCAL_RECEIVERS_ROUTE, + createLocalReceiversHandler, + localReceiversProxy, + parseLocalReceiverFeeds, +} from '../../server/providers/local-receivers.js'; +import { localProviderPlugins } from '../../server/providers/local.js'; + +const FIXTURE = readFileSync( + new URL('./fixtures/adsb-austin-dump1090-aircraft.json', import.meta.url), + 'utf8', +); +const FIXTURE_NOW_MS = JSON.parse(FIXTURE).now * 1000; +const FEED_1090 = 'http://localhost:8080/data/aircraft.json'; +const FEED_978 = 'http://127.0.0.1:8978/data/aircraft.json'; + +function quietLogger() { + const lines = []; + return { + lines, + warn: (line) => lines.push(line), + info: (line) => lines.push(line), + }; +} + +function respond(status = 200, body = FIXTURE, headers = {}) { + return new Response(body, { + status, + headers: { 'content-type': 'application/json', ...headers }, + }); +} + +async function call(handler, method = 'GET') { + const res = { + writeHead(status, headers) { + Object.assign(this, { status, headers }); + }, + end(body) { + this.body = body; + }, + }; + await handler.handle({ url: '/', method }, res); + return { ...res, json: JSON.parse(res.body) }; +} + +test('LOCAL_RECEIVER_FEEDS accepts local http(s) aircraft.json feeds per band', () => { + const { configured, feeds } = parseLocalReceiverFeeds( + ` 1090=${FEED_1090} , 978=http://skyaware.local/skyaware978/data/aircraft.json,1090=https://192.168.1.20:8443/tar1090/data/aircraft.json`, + ); + assert.equal(configured, true); + assert.deepEqual( + feeds.map(({ band, label, url }) => ({ band, label, url })), + [ + { band: '1090', label: '1090 MHz #1', url: FEED_1090 }, + { + band: '978', + label: '978 MHz UAT', + url: 'http://skyaware.local:80/skyaware978/data/aircraft.json', + }, + { + band: '1090', + label: '1090 MHz #2', + url: 'https://192.168.1.20:8443/tar1090/data/aircraft.json', + }, + ], + ); + assert.deepEqual(parseLocalReceiverFeeds(undefined), { + configured: false, + feeds: [], + }); + assert.equal(parseLocalReceiverFeeds(' , ').configured, false); +}); + +test('LOCAL_RECEIVER_FEEDS rejects public, link-local and named hosts, bad paths and bands', () => { + const cases = { + '1090=http://8.8.8.8/data/aircraft.json': 'host must be', + '1090=http://169.254.169.254/latest/aircraft.json': 'host must be', + '1090=http://example.com/data/aircraft.json': 'host must be', + '1090=http://[::1]:8080/data/aircraft.json': 'host must be', + '1090=http://localhost:8080/data/receiver.json': 'path must end', + '1090=http://localhost:8080/data/aircraft.json.bak': 'path must end', + '1090=http://localhost:8080/data/aircraft.json?x=1': 'query', + '1090=http://user:pw@localhost:8080/data/aircraft.json': 'credentials', + '1090=ftp://localhost/data/aircraft.json': 'scheme', + '1090=not a url': 'not a valid URL', + '868=http://localhost:8080/data/aircraft.json': 'band must be', + 'http://localhost:8080/data/aircraft.json': 'expected band=url', + }; + for (const [entry, reason] of Object.entries(cases)) { + const [feed] = parseLocalReceiverFeeds(entry).feeds; + assert.equal(feed.url, undefined, `${entry} must not be fetchable`); + assert.match(feed.reason, new RegExp(reason), entry); + } + const [badBand] = parseLocalReceiverFeeds( + '868=http://localhost/aircraft.json', + ).feeds; + assert.equal(badBand.band, null); + assert.equal(badBand.label, 'entry 1'); +}); + +test('an unconfigured route answers 200 without fetching anything', async (t) => { + t.mock.method(globalThis, 'fetch', () => { + throw Error('must not fetch'); + }); + const handler = createLocalReceiversHandler({ + feedsValue: undefined, + logger: quietLogger(), + }); + const res = await call(handler); + assert.equal(res.status, 200); + assert.deepEqual(res.json, { configured: false, feeds: [], records: [] }); +}); + +test('construction mounts the route once and neither parses nor fetches', (t) => { + t.mock.method(globalThis, 'fetch', () => { + throw Error('construction must not fetch'); + }); + const plugins = localProviderPlugins(); + assert.equal( + plugins.filter((plugin) => plugin.name === 'local-receivers-proxy').length, + 1, + ); + const routes = []; + localReceiversProxy({ + feedsValue: '', + logger: quietLogger(), + }).configureServer({ middlewares: { use: (route) => routes.push(route) } }); + assert.deepEqual(routes, [LOCAL_RECEIVERS_ROUTE]); +}); + +test('live feeds are read in parallel and labelled with their band', async () => { + const calls = []; + const handler = createLocalReceiversHandler({ + feedsValue: `1090=${FEED_1090},978=${FEED_978}`, + now: () => FIXTURE_NOW_MS + 2_000, + logger: quietLogger(), + fetchImpl: async (url, options) => { + calls.push({ url, redirect: options.redirect }); + return respond(); + }, + }); + const { status, json } = await call(handler); + assert.equal(status, 200); + assert.deepEqual( + calls.sort((a, b) => (a.url < b.url ? 1 : -1)), + [ + { url: FEED_1090, redirect: 'manual' }, + { url: FEED_978, redirect: 'manual' }, + ], + ); + assert.equal(json.configured, true); + assert.deepEqual(json.feeds, [ + { + band: '1090', + label: '1090 MHz', + status: 'live', + aircraft: 4, + ageMs: 2_000, + }, + { + band: '978', + label: '978 MHz UAT', + status: 'live', + aircraft: 4, + ageMs: 2_000, + }, + ]); + assert.equal(json.records.length, 8); + const shinr = json.records.filter((record) => record.icao === 'ae5d8a'); + assert.deepEqual( + shinr.map(({ band, source }) => ({ band, source })), + [ + { band: '1090', source: 'feed' }, + { band: '978', source: 'feed' }, + ], + ); + // Ages are anchored at the feed's own `now`. + assert.equal(shinr[0].lastPositionAt, FIXTURE_NOW_MS - 34_600); + assert.equal(shinr[0].lat, 30.269662); +}); + +test('a feed whose own now is over 10 s old is stale; failures are unreachable', async () => { + const logger = quietLogger(); + const handler = createLocalReceiversHandler({ + feedsValue: [ + `1090=${FEED_1090}`, + `978=${FEED_978}`, + '1090=http://10.0.0.5/redirect/aircraft.json', + '1090=http://10.0.0.6/big/aircraft.json', + '1090=http://10.0.0.7/error/aircraft.json', + '978=http://10.0.0.8/garbage/aircraft.json', + '1090=http://8.8.8.8/aircraft.json', + ].join(','), + now: () => FIXTURE_NOW_MS + 10_001, + logger, + maxBytes: 4_096, + fetchImpl: async (url) => { + if (url === FEED_978) throw new TypeError('connect ECONNREFUSED secret'); + if (url.includes('/redirect/')) + return respond(302, '', { location: 'http://169.254.169.254/' }); + if (url.includes('/big/')) + return respond(200, `{"aircraft":[${' '.repeat(5_000)}]}`); + if (url.includes('/error/')) + return respond(500, 'upstream internal detail'); + if (url.includes('/garbage/')) return respond(200, ''); + if (url.includes('8.8.8.8')) throw Error('invalid feed was fetched'); + return respond(); + }, + }); + const { json, body } = await call(handler); + assert.deepEqual( + json.feeds.map(({ band, status }) => `${band} ${status}`), + [ + '1090 stale', + '978 unreachable', + '1090 unreachable', + '1090 unreachable', + '1090 unreachable', + '978 unreachable', + '1090 invalid', + ], + ); + assert.equal(json.feeds[0].ageMs, 10_001); + assert.equal(json.records.length, 4, 'stale feed records still flow'); + for (const secret of ['ECONNREFUSED', 'secret', 'internal detail', '169.254']) + assert.equal(body.includes(secret), false, `${secret} leaked`); + assert.equal( + body.includes('10.0.0'), + false, + 'feed addresses stay server-side', + ); + assert.ok( + logger.lines.some((line) => + /LOCAL_RECEIVER_FEEDS feed-7 .* rejected: host must be/.test(line), + ), + ); + assert.ok(logger.lines.some((line) => /REDIRECT_REFUSED/.test(line))); + assert.ok(logger.lines.some((line) => /RESPONSE_TOO_LARGE/.test(line))); +}); + +test('a feed that exceeds the timeout is unreachable', async () => { + const handler = createLocalReceiversHandler({ + feedsValue: `1090=${FEED_1090}`, + logger: quietLogger(), + timeoutMs: 5, + fetchImpl: (url, { signal }) => + new Promise((resolve, reject) => { + signal.addEventListener('abort', () => + reject(Object.assign(new Error('aborted'), { name: 'AbortError' })), + ); + }), + }); + const { json } = await call(handler); + assert.equal(json.feeds[0].status, 'unreachable'); +}); + +test('requests within a second share one read of every feed', async () => { + let clock = FIXTURE_NOW_MS; + let fetches = 0; + const handler = createLocalReceiversHandler({ + feedsValue: `1090=${FEED_1090}`, + now: () => clock, + logger: quietLogger(), + fetchImpl: async () => { + fetches += 1; + return respond(); + }, + }); + await Promise.all([call(handler), call(handler), call(handler)]); + assert.equal(fetches, 1, 'concurrent requests are single-flight'); + clock += 500; + await call(handler); + assert.equal(fetches, 1, 'cached for about a second'); + clock += 1_000; + await call(handler); + assert.equal(fetches, 2); + const res = await call(handler, 'POST'); + assert.equal(res.status, 405); +}); + +test('a feed name is resolved, every address checked, and the connection pinned to it', async () => { + const lookups = []; + const answers = { + 'good.local': [{ address: '10.0.0.9', family: 4 }], + 'public.local': [{ address: '93.184.216.34', family: 4 }], + 'linklocal.local': [{ address: '169.254.169.254', family: 4 }], + 'mixed.local': [ + { address: '192.168.1.4', family: 4 }, + { address: '8.8.8.8', family: 4 }, + ], + }; + const fetched = []; + const logger = quietLogger(); + const handler = createLocalReceiversHandler({ + feedsValue: [ + '1090=http://good.local:8080/data/aircraft.json', + '1090=http://public.local:8080/data/aircraft.json', + '978=http://linklocal.local/data/aircraft.json', + '978=http://mixed.local/data/aircraft.json', + `1090=${FEED_978}`, + ].join(','), + now: () => FIXTURE_NOW_MS, + logger, + lookupImpl: async (hostname, options) => { + lookups.push({ hostname, all: options?.all }); + return answers[hostname]; + }, + fetchImpl: async (url, options) => { + fetched.push({ url, lookup: options.lookup }); + return respond(); + }, + }); + const { json } = await call(handler); + assert.deepEqual( + json.feeds.map(({ status }) => status), + ['live', 'unreachable', 'unreachable', 'unreachable', 'live'], + ); + assert.deepEqual( + lookups.map(({ hostname }) => hostname).sort(), + ['good.local', 'linklocal.local', 'mixed.local', 'public.local'], + 'IP literals are not resolved', + ); + assert.ok( + lookups.every(({ all }) => all === true), + 'every address', + ); + assert.deepEqual( + fetched.map(({ url }) => url).sort(), + [FEED_978, 'http://good.local:8080/data/aircraft.json'], + 'a name resolving outside loopback/RFC1918 is never contacted', + ); + // The connection resolves only to the validated address, whatever the + // resolver would answer later. + answers['good.local'] = [{ address: '93.184.216.34', family: 4 }]; + const pinned = fetched.find(({ url }) => url.includes('good.local')).lookup; + assert.equal(typeof pinned, 'function'); + const one = await new Promise((resolve) => + pinned('good.local', {}, (error, address, family) => + resolve({ error, address, family }), + ), + ); + assert.deepEqual(one, { error: null, address: '10.0.0.9', family: 4 }); + const all = await new Promise((resolve) => + pinned('good.local', { all: true }, (error, addresses) => + resolve(addresses), + ), + ); + assert.deepEqual(all, [{ address: '10.0.0.9', family: 4 }]); + assert.ok(logger.lines.some((line) => /FORBIDDEN_ADDRESS/.test(line))); +}); + +test('the default transport connects to the validated address, not a re-resolution', async (t) => { + const { createServer } = await import('node:http'); + const server = createServer((req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ now: FIXTURE_NOW_MS / 1000, aircraft: [] })); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + t.after(() => server.close()); + const { port } = server.address(); + // `receiver-pin-test.local` has no real DNS entry: only the pinned lookup + // can reach the loopback server. + const handler = createLocalReceiversHandler({ + feedsValue: `1090=http://receiver-pin-test.local:${port}/data/aircraft.json`, + now: () => FIXTURE_NOW_MS, + logger: quietLogger(), + lookupImpl: async () => [{ address: '127.0.0.1', family: 4 }], + }); + const { json } = await call(handler); + assert.equal(json.feeds[0].status, 'live'); +}); + +test('null-body statuses (204, 205, 304) from a named feed are a clean feed error, never an uncaught exception', async (t) => { + const { createServer } = await import('node:http'); + const server = createServer((req, res) => { + const status = Number(req.url.split('/')[1]); + res.writeHead(status, { 'content-type': 'application/json' }); + res.end(); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + t.after(() => server.close()); + const { port } = server.address(); + const uncaught = []; + const onUncaught = (error) => uncaught.push(error); + process.on('uncaughtException', onUncaught); + t.after(() => process.off('uncaughtException', onUncaught)); + const handler = createLocalReceiversHandler({ + feedsValue: [204, 205, 304] + .map( + (status) => + `1090=http://receiver-null-body.local:${port}/${status}/aircraft.json`, + ) + .join(','), + now: () => FIXTURE_NOW_MS, + logger: quietLogger(), + timeoutMs: 300, + lookupImpl: async () => [{ address: '127.0.0.1', family: 4 }], + }); + const { status, json } = await call(handler); + // Let any exception thrown inside the response callback surface. + await new Promise((resolve) => setTimeout(resolve, 20)); + assert.deepEqual(uncaught, [], 'no uncaught exception'); + assert.equal(status, 200); + assert.deepEqual( + json.feeds.map((feed) => feed.status), + ['unreachable', 'unreachable', 'unreachable'], + ); +}); + +test('a stalled DNS lookup fails its feed at the deadline without holding other feeds or later polls', async () => { + let clock = FIXTURE_NOW_MS; + const lookups = [ + // Never answers. + () => new Promise(() => {}), + // Answers only after the deadline: the late answer is ignored. + () => + new Promise((resolve) => + setTimeout(() => resolve([{ address: '127.0.0.1', family: 4 }]), 80), + ), + ]; + const fetched = []; + const handler = createLocalReceiversHandler({ + feedsValue: `1090=http://stalled.local/data/aircraft.json,978=${FEED_978}`, + now: () => clock, + logger: quietLogger(), + timeoutMs: 20, + lookupImpl: () => lookups.shift()(), + fetchImpl: async (url) => { + fetched.push(url); + return respond(); + }, + }); + const within = (promise) => + Promise.race([ + promise, + new Promise((resolve) => setTimeout(() => resolve('timed out'), 60)), + ]); + const first = await within(call(handler)); + assert.notEqual(first, 'timed out', 'the snapshot is not held by DNS'); + assert.deepEqual( + first.json.feeds.map((feed) => feed.status), + ['unreachable', 'live'], + ); + clock += 2_000; + const second = await within(call(handler)); + assert.notEqual(second, 'timed out', 'a later poll is not held either'); + assert.deepEqual( + second.json.feeds.map((feed) => feed.status), + ['unreachable', 'live'], + ); + await new Promise((resolve) => setTimeout(resolve, 120)); + assert.ok( + fetched.every((url) => !url.includes('stalled.local')), + 'a late DNS answer is never fetched', + ); +}); diff --git a/src/data/modelEligibility.js b/src/data/modelEligibility.js new file mode 100644 index 000000000..c757435f7 --- /dev/null +++ b/src/data/modelEligibility.js @@ -0,0 +1,45 @@ +/** + * Shared 3D-model eligibility for the aircraft layers. + * + * Eligibility is by DISTANCE (the mode's add/keep band) with ON-SCREEN + * PRIORITY under the cap, in four visible-first passes so the slots are spent + * on what the operator can see: + * 1. KEEP on-screen contacts that already have a model (hysteresis for + * visible planes); + * 2. ADD on-screen new contacts inside the add radius, nearest first; + * 3. KEEP off-screen contacts that already have a model; + * 4. ADD off-screen new contacts inside the add radius with leftover slots. + * KEEP is split by frustum so an off-screen retained model can never starve + * an on-screen contact that wants one. + * + * Portable: no Cesium, DOM or layer state. + */ + +/** + * @param {Array<[string, number, boolean]>} candidates `[id, distanceSq, + * onScreen]` for every contact inside the KEEP radius, sorted nearest first. + * @param {object} options + * @param {number} options.cap Maximum number of eligible contacts. + * @param {number} options.addDistSq Squared ADD radius (m²). + * @param {(id: string) => boolean} options.isModeled Whether the contact + * already has a model (live, not merely pending). + * @returns {Set} Contacts that may hold a model this tick. + */ +export function selectModelEligible(candidates, { cap, addDistSq, isModeled }) { + const eligible = new Set(); + const passes = [ + (onScreen, modeled) => onScreen && modeled, + (onScreen, modeled, inAdd) => onScreen && inAdd, + (onScreen, modeled) => !onScreen && modeled, + (onScreen, modeled, inAdd) => !onScreen && inAdd, + ]; + for (const admit of passes) { + for (const [id, distanceSq, onScreen] of candidates) { + if (eligible.size >= cap) return eligible; + if (eligible.has(id)) continue; + if (admit(onScreen, isModeled(id), distanceSq <= addDistSq)) + eligible.add(id); + } + } + return eligible; +} diff --git a/src/data/modelEligibility.test.mjs b/src/data/modelEligibility.test.mjs new file mode 100644 index 000000000..d29ae81e9 --- /dev/null +++ b/src/data/modelEligibility.test.mjs @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { selectModelEligible } from './modelEligibility.js'; + +const ADD_SQ = 100 * 100; + +test('visible contacts win the cap before off-screen retained models', () => { + // Nearest first: an off-screen retained model, then two on-screen contacts. + const candidates = [ + ['off-kept', 10, false], + ['on-new', 20, true], + ['on-kept', 30, true], + ]; + const modeled = new Set(['off-kept', 'on-kept']); + const eligible = selectModelEligible(candidates, { + cap: 2, + addDistSq: ADD_SQ, + isModeled: (id) => modeled.has(id), + }); + assert.deepEqual([...eligible], ['on-kept', 'on-new']); +}); + +test('retained models keep their slot beyond the add radius; new ones do not', () => { + const candidates = [ + ['near-new', 50, true], + ['far-kept', ADD_SQ + 1, true], + ['far-new', ADD_SQ + 2, true], + ['off-new', 60, false], + ]; + const eligible = selectModelEligible(candidates, { + cap: 10, + addDistSq: ADD_SQ, + isModeled: (id) => id === 'far-kept', + }); + assert.deepEqual([...eligible], ['far-kept', 'near-new', 'off-new']); +}); + +test('an empty cap admits nothing', () => { + assert.equal( + selectModelEligible([['a', 1, true]], { + cap: 0, + addDistSq: ADD_SQ, + isModeled: () => true, + }).size, + 0, + ); +}); diff --git a/src/data/tr3bRegistry.test.mjs b/src/data/tr3bRegistry.test.mjs index c081206bd..0fb5f3623 100644 --- a/src/data/tr3bRegistry.test.mjs +++ b/src/data/tr3bRegistry.test.mjs @@ -415,8 +415,10 @@ test('a converted contact never consumes a 3D model CAP SLOT', async () => { assert.match(loop, /keepDistSq/, `${name}: matched the model-eligibility loop`); assert.match(loop, /if \(isTr3b\(icao\)\) continue;/, `${name}: converted contacts are dropped BEFORE entering the capped candidate list`); - // ...and the cap really is applied to that list, so a dropped candidate is a freed slot. - assert.match(source, /modelEligible\.size >= cap/, + // ...and the cap really is applied to that list, so a dropped candidate is a + // freed slot. The four-pass selection itself is the shared + // selectModelEligible (behavior-tested in modelEligibility.test.mjs). + assert.match(source, /modelEligible\s*=\s*selectModelEligible\(\s*cand,\s*\{\s*cap,/, `${name}: the cap bounds the candidate-derived eligible set`); } }); diff --git a/src/data/trackedReadout.js b/src/data/trackedReadout.js index 8e24cb9d9..dfb4ee858 100644 --- a/src/data/trackedReadout.js +++ b/src/data/trackedReadout.js @@ -222,6 +222,7 @@ export function getActiveTrackedReadoutId() { const READOUT_CONTEXT_LAYERS = new Set([ 'military-installations', 'alpr-cameras', + 'local-adsb', ]); /** diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index 99026d10a..3a2d91047 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -662,10 +662,12 @@ test('the voice TOOL SCHEMA matches the pinned release — the mission mapping i // ordering while allowing source formatting. Derived from the unchanged // release schema before formatting (the previous source-byte pin passed). const block = JSON.stringify(GEV_REALTIME_TOOLS); - assert.equal(block.length, 27271, 'serialized tool schema length drifted'); + // Re-derived for the additive `local-adsb` set_layer_visibility value and + // its common-name mapping; the missions still ride existing tools. + assert.equal(block.length, 27378, 'serialized tool schema length drifted'); assert.equal( crypto.createHash('sha256').update(block).digest('hex'), - 'da5f1bb334d1e882d63098cb35b29afde273947c0807ef6af2ef3d25a0df4918', + '1947e9df228b3fd66a874ca462b5e5a29d800191b0180823903628be8888bc52', 'the first-run missions must ride EXISTING tools: no schema edit, no cache bust', ); const instructions = fs.readFileSync(new URL('../server/providers/openai/instructions.js', import.meta.url), 'utf8'); diff --git a/src/layers/flights/modelSpec.js b/src/layers/flights/modelSpec.js new file mode 100644 index 000000000..850433824 --- /dev/null +++ b/src/layers/flights/modelSpec.js @@ -0,0 +1,52 @@ +import { + CLASS_MODEL_REAL, + CLASS_MODEL_URL, + CLASS_SCALE_3D, +} from '../../data/aircraftClass.js'; +import { + trailAnchorForModel, + visualCenterForModel, +} from '../../data/modelVisualAnchor.js'; +import { + MODEL_BELLY_OFFSET_NATIVE, + MODEL_COLOR_BLEND_AMOUNT, + MODEL_NATIVE_RADIUS_M, + MODEL_SCALE, + PLANE_MODEL_URL, +} from './policy.js'; + +/** + * The glTF asset, scale and measured geometry a civil aircraft class renders + * with. Real per-class GLBs are baked to real-world meters (scale 1, measured + * belly and radius); classes without one share airplane.glb at the class's + * 3D scale. Shared by the Flights layer and the Local ADS-B layer so both draw + * the same model for the same class. + * @param {string} klass Aircraft class from `classifyAircraft`. + * @returns {{url:string, scale:number, nativeRadiusM:number, bellyM:number, + * blendAmount:number, visualCenterNative:object, trailAnchorNative:object}} + */ +export function civilAircraftModelSpec(klass) { + const real = CLASS_MODEL_REAL[klass]; + if (real) { + return { + url: real.url, + scale: 1, + nativeRadiusM: real.radiusM, + bellyM: real.bellyM, + blendAmount: MODEL_COLOR_BLEND_AMOUNT, + visualCenterNative: visualCenterForModel(real.url), + trailAnchorNative: trailAnchorForModel(real.url), + }; + } + const scale = MODEL_SCALE * (CLASS_SCALE_3D[klass] || 1); + const url = CLASS_MODEL_URL[klass] || PLANE_MODEL_URL; + return { + url, + scale, + nativeRadiusM: MODEL_NATIVE_RADIUS_M, + bellyM: MODEL_BELLY_OFFSET_NATIVE * scale, + blendAmount: MODEL_COLOR_BLEND_AMOUNT, + visualCenterNative: visualCenterForModel(url), + trailAnchorNative: trailAnchorForModel(url), + }; +} diff --git a/src/layers/flights/rendering.js b/src/layers/flights/rendering.js index 74ee4541b..222dd0888 100644 --- a/src/layers/flights/rendering.js +++ b/src/layers/flights/rendering.js @@ -1,14 +1,7 @@ import * as Cesium from 'cesium'; -import { - CLASS_SCALE_2D, - CLASS_MODEL_REAL, - CLASS_SCALE_3D, - CLASS_MODEL_URL, -} from '../../data/aircraftClass.js'; -import { - visualCenterForModel, - trailAnchorForModel, -} from '../../data/modelVisualAnchor.js'; +import { selectModelEligible } from '../../data/modelEligibility.js'; +import { civilAircraftModelSpec } from './modelSpec.js'; +import { CLASS_SCALE_2D } from '../../data/aircraftClass.js'; import { cockpitContactDotImage } from '../../data/cockpitContactDot.js'; import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js'; import { @@ -20,11 +13,6 @@ import { limitCourseStep, courseSlewCapDps } from '../../data/motionModel.js'; import { MIL_TINT, GROUND_SCALE, - MODEL_COLOR_BLEND_AMOUNT, - MODEL_SCALE, - PLANE_MODEL_URL, - MODEL_NATIVE_RADIUS_M, - MODEL_BELLY_OFFSET_NATIVE, COCKPIT_CONTACT_SIZE_PX, COCKPIT_CIVILIAN_COLOR, MODEL_ALT_CEIL_M, @@ -100,31 +88,7 @@ export function createRendering({ function _modelSpec(klass) { const cached = flightState._specCache.get(klass); if (cached) return cached; - const real = CLASS_MODEL_REAL[klass]; - let spec; - if (real) { - spec = { - url: real.url, - scale: 1, - nativeRadiusM: real.radiusM, - bellyM: real.bellyM, - blendAmount: MODEL_COLOR_BLEND_AMOUNT, - visualCenterNative: visualCenterForModel(real.url), - trailAnchorNative: trailAnchorForModel(real.url), - }; - } else { - const scale = MODEL_SCALE * (CLASS_SCALE_3D[klass] || 1); - const url = CLASS_MODEL_URL[klass] || PLANE_MODEL_URL; - spec = { - url, - scale, - nativeRadiusM: MODEL_NATIVE_RADIUS_M, - bellyM: MODEL_BELLY_OFFSET_NATIVE * scale, - blendAmount: MODEL_COLOR_BLEND_AMOUNT, - visualCenterNative: visualCenterForModel(url), - trailAnchorNative: trailAnchorForModel(url), - }; - } + const spec = civilAircraftModelSpec(klass); flightState._specCache.set(klass, spec); return spec; } @@ -953,31 +917,11 @@ export function createRendering({ ); flightState._lastModelCapWarnMs = nowMs; } - modelEligible = new Set(); - // 1. KEEP on-screen already-modeled (visible retained — no flicker for what you can see). - for (const [icao, , inF] of cand) { - if (modelEligible.size >= cap) break; - if (inF && flightState._models.has(icao)) modelEligible.add(icao); - } - // 2. ADD on-screen NEW inside the add radius, nearest first (visible additions win the cap). - for (const [icao, d2, inF] of cand) { - if (modelEligible.size >= cap) break; - if (inF && d2 <= addDistSq && !modelEligible.has(icao)) - modelEligible.add(icao); - } - // 3. KEEP off-screen already-modeled (hysteresis, but LOWER priority than anything visible — so a - // retained off-screen model can never starve an on-screen plane that wants one; dropping it is - // invisible and it re-adds the moment it's back in view). - for (const [icao, , inF] of cand) { - if (modelEligible.size >= cap) break; - if (!inF && flightState._models.has(icao)) modelEligible.add(icao); - } - // 4. ADD off-screen NEW inside the add radius with any leftover slots. - for (const [icao, d2, inF] of cand) { - if (modelEligible.size >= cap) break; - if (!inF && d2 <= addDistSq && !modelEligible.has(icao)) - modelEligible.add(icao); - } + modelEligible = selectModelEligible(cand, { + cap, + addDistSq, + isModeled: (icao) => flightState._models.has(icao), + }); const toRelease = []; for (const icao of flightState._models.keys()) { if (icao !== flightState._trackedIcao && !modelEligible.has(icao)) diff --git a/src/layers/localAdsb/card.js b/src/layers/localAdsb/card.js new file mode 100644 index 000000000..c656bd9fe --- /dev/null +++ b/src/layers/localAdsb/card.js @@ -0,0 +1,143 @@ +import { AIRCRAFT_CLASS_LABELS } from '../../data/aircraftClass.js'; +import { + BAND_LABELS, + HEARD_BY_RECEIVER, + LOCAL_ADSB_COLOR, + SOURCE_LABELS, +} from './policy.js'; + +const DASH = '—'; + +function rounded(value, digits = 0) { + if (!Number.isFinite(value)) return null; + return Number(value.toFixed(digits)); +} + +function grouped(value) { + return value === null ? DASH : value.toLocaleString('en-US'); +} + +/** + * Title of a locally heard aircraft: callsign when decoded, else ICAO hex. + * @param {object} record Local ADS-B record. + * @returns {string} + */ +export function localAdsbTitle(record) { + return record?.callsign || String(record?.icao || '').toUpperCase(); +} + +function recordBands(record) { + if (Array.isArray(record?.bands) && record.bands.length) return record.bands; + return record?.band ? [record.band] : []; +} + +function recordSources(record) { + if (Array.isArray(record?.sources) && record.sources.length) + return record.sources; + return record?.source ? [record.source] : []; +} + +/** + * Whether an aircraft was heard only on 978 MHz UAT (its marker gets the + * UAT ring). One also heard on 1090 MHz draws as a 1090 aircraft. + * @param {object} record Local ADS-B record. + * @returns {boolean} + */ +export function localAdsbIsUat(record) { + const bands = recordBands(record); + return bands.includes('978') && !bands.includes('1090'); +} + +/** + * Receiver line of the click card, e.g. "Heard by your receiver · 1090 MHz · + * browser SDR" or "… · 1090 MHz + 978 MHz UAT · browser SDR + decoder feed". + * @param {object} record Local ADS-B record (merged: `bands`, `sources`). + * @returns {string} + */ +export function localAdsbReceiverLine(record) { + const bands = recordBands(record) + .map((band) => BAND_LABELS[band]) + .filter(Boolean); + const sources = recordSources(record) + .map((source) => SOURCE_LABELS[source]) + .filter(Boolean); + return [HEARD_BY_RECEIVER, bands.join(' + '), sources.join(' + ')] + .filter(Boolean) + .join(' · '); +} + +/** + * Where a heard aircraft came from, for the entity context's source field. + * @param {object} record Local ADS-B record. + * @returns {string} + */ +export function localAdsbSourceText(record) { + const sources = recordSources(record); + if (sources.includes('webusb') && sources.includes('feed')) + return 'Your RTL-SDR receiver and decoder feed'; + if (sources.includes('feed')) return 'Your decoder feed'; + return 'Your RTL-SDR receiver'; +} + +/** + * Class line of the click card, e.g. "Helicopter · A7". Empty when neither an + * ICAO type nor an emitter category backs the class. + * @param {object} record Local ADS-B record. + * @param {{klass?:string, evidence?:boolean}} [aircraftClass] + * @returns {string} + */ +export function localAdsbClassLine(record, aircraftClass = {}) { + if (!aircraftClass.evidence) return ''; + const label = AIRCRAFT_CLASS_LABELS[aircraftClass.klass]; + return [label, record?.category].filter(Boolean).join(' · '); +} + +/** + * Click-card presentation for one locally heard aircraft. + * @param {object} record Local ADS-B record. + * @param {number} nowMs Current epoch ms, for the position age. + * @param {object} [extras] + * @param {{klass:string, evidence:boolean}} [extras.aircraftClass] Class and + * whether a type or category backs it. + * @param {object|null} [extras.meta] adsbdb metadata (typeCode, typeName, + * airline). + * @param {object|null} [extras.route] A route already judged plausible. + * @returns {{title:string, details:string[], accent:string}} + */ +export function localAdsbCardModel( + record, + nowMs, + { aircraftClass = null, meta = null, route = null } = {}, +) { + const icao = String(record?.icao || '').toUpperCase(); + const altitude = rounded(record?.altitudeFt); + const speed = rounded(record?.groundSpeedKt); + const track = rounded(record?.trackDeg); + const vertical = rounded(record?.verticalRateFpm); + const ageS = Number.isFinite(record?.lastPositionAt) + ? Math.max(0, Math.round((nowMs - record.lastPositionAt) / 1000)) + : null; + const messages = Math.max(0, Math.trunc(Number(record?.messageCount) || 0)); + // Same identity and route lines the public flight card shows. + const identity = [meta?.airline, meta?.typeName || meta?.typeCode] + .filter(Boolean) + .join(' · '); + const routeLine = + route?.origin?.code && route?.destination?.code + ? `${route.origin.code} → ${route.destination.code}` + : ''; + return { + title: localAdsbTitle(record), + details: [ + `ICAO ${icao} · ${record?.callsign || 'NO CALLSIGN'}`, + localAdsbClassLine(record, aircraftClass || {}), + identity, + routeLine, + `ALT ${grouped(altitude)} FT · GS ${speed === null ? DASH : speed} KT · TRK ${track === null ? DASH : `${track}°`}`, + `V/S ${vertical === null ? DASH : `${vertical > 0 ? '+' : ''}${grouped(vertical)} FPM`}`, + `POSITION ${ageS === null ? DASH : `${ageS} S AGO`} · ${messages} ${messages === 1 ? 'MSG' : 'MSGS'}`, + localAdsbReceiverLine(record), + ].filter(Boolean), + accent: LOCAL_ADSB_COLOR, + }; +} diff --git a/src/layers/localAdsb/enrichment.js b/src/layers/localAdsb/enrichment.js new file mode 100644 index 000000000..4fb4673a8 --- /dev/null +++ b/src/layers/localAdsb/enrichment.js @@ -0,0 +1,166 @@ +import { classifyAircraft } from '../../data/aircraftClass.js'; +import { stickyText } from '../../data/aircraftMeta.js'; +import { + ENRICH_DISPATCH_GAP_MS, + ENRICH_MAX_INFLIGHT, +} from '../flights/policy.js'; + +const ICAO = /^[0-9a-f]{6}$/i; +/** Airline-style callsigns only (LLL + digit); GA tails do not resolve. */ +const AIRLINE_CALLSIGN = /^[A-Z]{3}\d/; +/** Bound on the per-session metadata map. */ +const META_LIMIT = 2_000; + +/** + * Class of a locally heard aircraft: the adsbdb ICAO type when known, else + * the emitter category it broadcasts. `evidence` is false when neither is + * known, so the card never presents the classifier's default as fact. + * @param {{category?:string|null}} record + * @param {{typeCode?:string|null}} [meta] + * @returns {{klass:string, evidence:boolean}} + */ +export function localAdsbClass(record, meta = null) { + const typeCode = meta?.typeCode || null; + const category = record?.category || null; + return { + klass: classifyAircraft({ typeCode, category }), + evidence: Boolean(typeCode) || /^(?:A[1-7]|B1)$/.test(category || ''), + }; +} + +/** + * adsbdb enrichment for Local ADS-B through the same cached proxy and the same + * rules as the public Flights layer: each key is asked for at most once per + * session, at most four requests are in flight and dispatches are dripped + * 200 ms apart. Only the selected aircraft (type + route) and aircraft that + * are about to render as 3D models (type) are ever looked up — never one + * request per contact per tick. + * @param {object} options + * @param {{getEnrichment?: Function}|null} options.source Flights source. + * @param {() => void} options.onChange Called when metadata arrives. + * @param {() => number} [options.now] + * @returns {object} + */ +export function createLocalAdsbEnrichment({ + source, + onChange, + now = Date.now, +}) { + const meta = new Map(); + const seen = new Set(); + const queue = []; + let active = 0; + let lastDispatchAt = -Infinity; + let dripTimer = null; + let lifetime = new AbortController(); + + function entry(icao) { + let value = meta.get(icao); + if (!value) { + value = {}; + meta.set(icao, value); + if (meta.size > META_LIMIT) meta.delete(meta.keys().next().value); + } + return value; + } + + function enqueue(key, query, apply, priority) { + if (typeof source?.getEnrichment !== 'function' || seen.has(key)) return; + seen.add(key); + const job = { query, apply }; + if (priority) queue.unshift(job); + else queue.push(job); + drain(); + } + + function drain() { + const signal = lifetime.signal; + while (!signal.aborted && active < ENRICH_MAX_INFLIGHT && queue.length) { + const wait = ENRICH_DISPATCH_GAP_MS - (now() - lastDispatchAt); + if (wait > 0) { + if (!dripTimer) + dripTimer = setTimeout(() => { + dripTimer = null; + drain(); + }, wait); + return; + } + lastDispatchAt = now(); + const job = queue.shift(); + active += 1; + Promise.resolve() + .then(() => source.getEnrichment(job.query, { signal })) + .then((data) => { + if (!signal.aborted && data?.found) { + job.apply(data); + onChange?.(); + } + }) + .catch(() => { + /* enrichment never surfaces errors */ + }) + .finally(() => { + if (signal.aborted) return; + active -= 1; + drain(); + }); + } + } + + return { + /** @param {string} icao @returns {object|null} */ + get(icao) { + return meta.get(String(icao || '').toLowerCase()) || null; + }, + + /** + * Ask for the aircraft's type (and, when selected, its route). + * @param {object} record Local ADS-B record. + * @param {{selected?: boolean}} [options] + */ + request(record, { selected = false } = {}) { + const icao = String(record?.icao || '').toLowerCase(); + if (!ICAO.test(icao)) return; + enqueue( + `t:${icao}`, + { kind: 'type', id: icao }, + (data) => { + const value = entry(icao); + value.typeCode = stickyText(data.typeCode, value.typeCode) || null; + value.typeName = stickyText(data.typeName, value.typeName) || null; + value.registration = + stickyText(data.registration, value.registration) || null; + }, + selected, + ); + const callsign = String(record.callsign || '') + .trim() + .toUpperCase(); + if (!selected || !AIRLINE_CALLSIGN.test(callsign)) return; + enqueue( + `r:${callsign}`, + { kind: 'route', id: callsign }, + (data) => { + const value = entry(icao); + value.airline = stickyText(data.airline, value.airline) || null; + if (data.origin && data.destination) + value.route = { + callsign, + origin: data.origin, + destination: data.destination, + }; + }, + true, + ); + }, + + destroy() { + lifetime.abort(); + lifetime = new AbortController(); + clearTimeout(dripTimer); + dripTimer = null; + queue.length = 0; + active = 0; + }, + }; +} diff --git a/src/layers/localAdsb/feedNames.js b/src/layers/localAdsb/feedNames.js new file mode 100644 index 000000000..cb816f503 --- /dev/null +++ b/src/layers/localAdsb/feedNames.js @@ -0,0 +1,21 @@ +/** + * Names for the server's decoder feeds, shared by the Local ADS-B row status + * and the Radio card. Pure; no imports, so any surface may use it. + */ + +/** + * Display name of one feed: its band, or its ordinal label ("978 MHz UAT #2") + * when another configured feed shares the band. + * @param {object} feed Feed status from the route. + * @param {object[]} [feeds] Every configured feed. + * @returns {string} + */ +export function localReceiverFeedName(feed, feeds = []) { + const band = feed?.band; + if (!band) return feed?.label || 'feed'; + const sharing = feeds.filter((other) => other?.band === band); + if (sharing.length < 2) return band; + if (typeof feed.label === 'string' && feed.label.includes('#')) + return feed.label; + return `${feed.label || band} #${sharing.indexOf(feed) + 1}`; +} diff --git a/src/layers/localAdsb/feeds.js b/src/layers/localAdsb/feeds.js new file mode 100644 index 000000000..4bb4bced7 --- /dev/null +++ b/src/layers/localAdsb/feeds.js @@ -0,0 +1,253 @@ +import { + localAdsbRecordIsLive, + localAdsbRecordIsNewer, +} from '../../sources/adsbRecords.js'; + +/** Same-origin route served by `server/providers/local-receivers.js`. */ +export const LOCAL_RECEIVERS_URL = '/api/local-receivers/aircraft'; +export const LOCAL_RECEIVER_POLL_MS = 1_000; +/** + * Browser-side deadline for one route request, body included. The server + * bounds each upstream read at 2 s; this bounds the browser-to-server leg. + */ +export const LOCAL_RECEIVER_REQUEST_TIMEOUT_MS = 5_000; + +function abortError() { + const error = new Error('Local receiver request aborted'); + error.name = 'AbortError'; + return error; +} + +/** Reject with an AbortError as soon as `signal` aborts. */ +function untilAborted(promise, signal) { + if (signal.aborted) return Promise.reject(abortError()); + let onAbort = null; + const aborted = new Promise((_, reject) => { + onAbort = () => reject(abortError()); + signal.addEventListener('abort', onAbort, { once: true }); + }); + return Promise.race([promise, aborted]).finally(() => + signal.removeEventListener('abort', onAbort), + ); +} + +function shiftTime(value, offset) { + return Number.isFinite(value) ? value + offset : value; +} + +/** + * Browser poller for the server's local decoder feeds. + * + * Polls only between `start()` and `stop()` (the Local ADS-B layer calls them + * on enable/disable), and only while the route reports `configured`: an + * unconfigured server, or a build without the route, stops the loop after one + * request. `probe()` makes that one request without polling, so the Radio card + * can show which feeds exist before the layer is turned on. + * + * Records are rebased from the server clock to this browser's clock and kept + * by ICAO and band for up to 60 s after their last message, so a feed that + * drops an aircraft or goes briefly unreachable does not erase it at once. + * @param {object} [options] + * @param {typeof fetch} [options.fetchImpl] + * @param {string} [options.url] + * @param {number} [options.intervalMs] + * @param {number} [options.requestTimeoutMs] Deadline per request. + * @param {() => number} [options.now] + * @returns {object} Feed session: start, stop, probe, getState, subscribe. + */ +export function createLocalReceiverFeeds({ + fetchImpl = (...args) => globalThis.fetch(...args), + url = LOCAL_RECEIVERS_URL, + intervalMs = LOCAL_RECEIVER_POLL_MS, + requestTimeoutMs = LOCAL_RECEIVER_REQUEST_TIMEOUT_MS, + now = Date.now, +} = {}) { + const listeners = new Set(); + const retained = new Map(); + /** Abort controllers of requests still in flight. */ + const inFlight = new Set(); + let state = { + configured: null, + polling: false, + feeds: [], + records: [], + error: null, + updatedAt: null, + }; + let generation = 0; + let timer = null; + let probing = null; + + function emit(patch) { + state = { ...state, ...patch }; + for (const listener of listeners) listener(state); + } + + function retain(records, at) { + for (const record of records) { + if (!record?.icao) continue; + // Two feeds on one band both report an aircraft: keep the fresher + // record, never simply the one listed (or polled) last. + const key = `${record.icao}|${record.band}`; + const current = retained.get(key); + if (!current || !localAdsbRecordIsNewer(current, record)) + retained.set(key, record); + } + for (const [key, record] of retained) { + if (!localAdsbRecordIsLive(record, at)) retained.delete(key); + } + return [...retained.values()]; + } + + /** One route request with a deadline through the body, abortable by stop. */ + async function request() { + const controller = new AbortController(); + inFlight.add(controller); + const timer = setTimeout(() => controller.abort(), requestTimeoutMs); + try { + const response = await untilAborted( + fetchImpl(url, { + cache: 'no-store', + headers: { Accept: 'application/json' }, + signal: controller.signal, + }), + controller.signal, + ); + if (!response?.ok) throw new Error('local receiver route unavailable'); + return await untilAborted(response.json(), controller.signal); + } finally { + clearTimeout(timer); + inFlight.delete(controller); + } + } + + function abortInFlight() { + for (const controller of inFlight) controller.abort(); + inFlight.clear(); + } + + function apply(body) { + if (body?.configured !== true) { + retained.clear(); + emit({ + configured: false, + polling: false, + feeds: [], + records: [], + error: null, + }); + return false; + } + const at = now(); + const offset = Number.isFinite(body.generatedAt) + ? at - body.generatedAt + : 0; + const records = (Array.isArray(body.records) ? body.records : []).map( + (record) => ({ + ...record, + lastPositionAt: shiftTime(record.lastPositionAt, offset), + lastMessageAt: shiftTime(record.lastMessageAt, offset), + }), + ); + emit({ + configured: true, + feeds: Array.isArray(body.feeds) ? body.feeds : [], + records: retain(records, at), + error: null, + updatedAt: at, + }); + return true; + } + + function fail() { + // A route that never answered means there is nothing to poll; a route + // that answered before is reported unreachable and polled again. + if (state.configured !== true) { + emit({ configured: false, polling: false, error: 'unavailable' }); + return false; + } + emit({ + feeds: state.feeds.map((feed) => + feed.status === 'invalid' + ? feed + : { ...feed, status: 'unreachable', aircraft: 0 }, + ), + records: retain([], now()), + error: 'unavailable', + }); + return true; + } + + async function tick(current) { + timer = null; + let keepPolling; + try { + const body = await request(); + if (current !== generation) return; + keepPolling = apply(body); + } catch { + if (current !== generation) return; + keepPolling = fail(); + } + if (!keepPolling) { + generation += 1; + return; + } + timer = setTimeout(() => void tick(current), intervalMs); + } + + return { + /** Start polling (no-op while already polling). */ + start() { + if (state.polling) return; + generation += 1; + emit({ polling: true }); + void tick(generation); + }, + + /** Stop polling; the last feed statuses stay readable. */ + stop() { + generation += 1; + clearTimeout(timer); + timer = null; + abortInFlight(); + if (state.polling) emit({ polling: false }); + }, + + /** + * Ask the route once whether feeds are configured, without polling. + * @returns {Promise} + */ + probe() { + if (state.polling || state.configured !== null) return Promise.resolve(); + if (!probing) { + const current = generation; + probing = request() + .then((body) => { + if (current === generation && !state.polling) apply(body); + }) + .catch(() => { + if (current === generation && !state.polling) + emit({ configured: false, error: 'unavailable' }); + }) + .finally(() => { + probing = null; + }); + } + return probing; + }, + + getState: () => state, + + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + + destroy() { + this.stop(); + listeners.clear(); + retained.clear(); + }, + }; +} diff --git a/src/layers/localAdsb/feeds.test.mjs b/src/layers/localAdsb/feeds.test.mjs new file mode 100644 index 000000000..e1ad540d4 --- /dev/null +++ b/src/layers/localAdsb/feeds.test.mjs @@ -0,0 +1,256 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { LOCAL_RECEIVERS_URL, createLocalReceiverFeeds } from './feeds.js'; + +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +function route(bodies) { + const calls = []; + const fetchImpl = async (url) => { + calls.push(url); + const next = bodies.length > 1 ? bodies.shift() : bodies[0]; + if (next instanceof Error) throw next; + if (next === 404) return { ok: false, status: 404 }; + return { ok: true, json: async () => structuredClone(next) }; + }; + return { calls, fetchImpl }; +} + +function payload(records, generatedAt = 1_000_000) { + return { + configured: true, + generatedAt, + feeds: [ + { band: '1090', label: '1090 MHz', status: 'live', aircraft: 1 }, + { band: '978', label: '978 MHz UAT', status: 'live', aircraft: 1 }, + ], + records, + }; +} + +function aircraft(overrides = {}) { + return { + icao: 'ae5d8a', + lat: 30.27, + lon: -97.79, + lastPositionAt: 999_000, + lastMessageAt: 999_500, + band: '1090', + source: 'feed', + ...overrides, + }; +} + +test('an unconfigured server is asked once and never polled', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const { calls, fetchImpl } = route([ + { configured: false, feeds: [], records: [] }, + ]); + const feeds = createLocalReceiverFeeds({ fetchImpl }); + feeds.start(); + await flush(); + assert.deepEqual(calls, [LOCAL_RECEIVERS_URL]); + assert.equal(feeds.getState().configured, false); + assert.equal(feeds.getState().polling, false); + t.mock.timers.tick(5_000); + await flush(); + assert.equal(calls.length, 1); +}); + +test('a build without the route stops after one failed request', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const { calls, fetchImpl } = route([404]); + const feeds = createLocalReceiverFeeds({ fetchImpl }); + feeds.start(); + await flush(); + t.mock.timers.tick(5_000); + await flush(); + assert.equal(calls.length, 1); + assert.equal(feeds.getState().configured, false); +}); + +test('configured feeds poll every second between start and stop, on the browser clock', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const { calls, fetchImpl } = route([payload([aircraft()])]); + const clock = 1_000_250; + const feeds = createLocalReceiverFeeds({ fetchImpl, now: () => clock }); + const seen = []; + feeds.subscribe((state) => seen.push(state.polling)); + feeds.start(); + await flush(); + const state = feeds.getState(); + assert.equal(state.configured, true); + assert.equal(state.polling, true); + assert.equal(state.feeds.length, 2); + // Server generated at 1_000_000; received at 1_000_250: rebased +250 ms. + assert.equal(state.records[0].lastPositionAt, 999_250); + assert.equal(state.records[0].lastMessageAt, 999_750); + + t.mock.timers.tick(1_000); + await flush(); + t.mock.timers.tick(1_000); + await flush(); + assert.equal(calls.length, 3); + feeds.stop(); + t.mock.timers.tick(5_000); + await flush(); + assert.equal(calls.length, 3, 'no polling once stopped'); + assert.equal(feeds.getState().polling, false); + assert.equal(seen.at(-1), false); +}); + +test('an aircraft a feed drops is kept until 60 s after its last message', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const bodies = [ + payload([aircraft(), aircraft({ band: '978' })], 1_000_000), + payload([aircraft()], 1_010_000), + payload([], 1_060_000), + ]; + let clock = 1_000_000; + const { fetchImpl } = route(bodies); + const feeds = createLocalReceiverFeeds({ fetchImpl, now: () => clock }); + feeds.start(); + await flush(); + assert.equal(feeds.getState().records.length, 2); + clock = 1_010_000; + t.mock.timers.tick(1_000); + await flush(); + assert.deepEqual( + feeds.getState().records.map((record) => record.band), + ['1090', '978'], + 'the 978 reception is remembered', + ); + clock = 1_060_000; + t.mock.timers.tick(1_000); + await flush(); + assert.deepEqual(feeds.getState().records, [], 'forgotten after 60 s'); + feeds.stop(); +}); + +test('a route that answered before is reported unreachable and polled again', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const { calls, fetchImpl } = route([ + payload([aircraft()]), + new TypeError('network down'), + payload([aircraft()]), + ]); + const feeds = createLocalReceiverFeeds({ fetchImpl, now: () => 1_000_000 }); + feeds.start(); + await flush(); + t.mock.timers.tick(1_000); + await flush(); + assert.deepEqual( + feeds.getState().feeds.map((feed) => feed.status), + ['unreachable', 'unreachable'], + ); + assert.equal(feeds.getState().records.length, 1, 'recent records remain'); + t.mock.timers.tick(1_000); + await flush(); + assert.equal(calls.length, 3); + assert.equal(feeds.getState().feeds[0].status, 'live'); + feeds.stop(); +}); + +test('probe asks once without polling so the Radio card can list feeds', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const { calls, fetchImpl } = route([payload([aircraft()])]); + const feeds = createLocalReceiverFeeds({ fetchImpl, now: () => 1_000_000 }); + await Promise.all([feeds.probe(), feeds.probe()]); + assert.equal(calls.length, 1); + assert.equal(feeds.getState().configured, true); + assert.equal(feeds.getState().polling, false); + await feeds.probe(); + t.mock.timers.tick(5_000); + await flush(); + assert.equal(calls.length, 1); +}); + +test('two feeds on one band keep the fresher record, whichever is listed last', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const fresh = aircraft({ lastPositionAt: 999_000, lastMessageAt: 999_500 }); + // A second 1090 feed that heard the aircraft earlier and has no position. + const older = aircraft({ + lat: null, + lon: null, + lastPositionAt: null, + lastMessageAt: 990_000, + }); + const { fetchImpl } = route([ + payload([fresh, older], 1_000_000), + payload([older], 1_000_000), + ]); + const feeds = createLocalReceiverFeeds({ fetchImpl, now: () => 1_000_000 }); + feeds.start(); + await flush(); + let [kept] = feeds.getState().records; + assert.equal(feeds.getState().records.length, 1); + assert.equal(kept.lastPositionAt, 999_000, 'same response'); + t.mock.timers.tick(1_000); + await flush(); + [kept] = feeds.getState().records; + assert.equal(kept.lastPositionAt, 999_000, 'next poll'); + assert.equal(kept.lat, 30.27); + feeds.stop(); +}); + +/** A route whose requests never answer until their signal aborts. */ +function stalledRoute({ stallBody = false } = {}) { + const signals = []; + const hang = (signal) => + new Promise((_, reject) => { + signal?.addEventListener('abort', () => + reject(new DOMException('aborted', 'AbortError')), + ); + }); + const fetchImpl = async (url, options = {}) => { + signals.push(options.signal); + if (!stallBody) return hang(options.signal); + return { ok: true, json: () => hang(options.signal) }; + }; + return { signals, fetchImpl }; +} + +test('a stalled feed request is abandoned at its deadline and polling continues', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + for (const stallBody of [false, true]) { + const { signals, fetchImpl } = stalledRoute({ stallBody }); + const feeds = createLocalReceiverFeeds({ + fetchImpl, + now: () => 1_000_000, + requestTimeoutMs: 3_000, + }); + feeds.start(); + await flush(); + assert.equal(signals.length, 1); + assert.ok(signals[0], 'every request carries an abort signal'); + t.mock.timers.tick(3_000); + await flush(); + assert.equal(signals[0].aborted, true, `aborted (stallBody ${stallBody})`); + assert.equal(feeds.getState().configured, false, 'never answered'); + feeds.destroy(); + } +}); + +test('stop() aborts the request in flight so start() is never blocked by it', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const { signals, fetchImpl } = stalledRoute(); + const feeds = createLocalReceiverFeeds({ fetchImpl, now: () => 1_000_000 }); + feeds.start(); + await flush(); + feeds.stop(); + assert.equal(signals[0].aborted, true); + feeds.start(); + await flush(); + feeds.stop(); + feeds.start(); + await flush(); + assert.equal(signals.length, 3); + assert.deepEqual( + signals.map((signal) => signal.aborted), + [true, true, false], + 'no disable/enable cycle leaves an old request outstanding', + ); + feeds.destroy(); + assert.equal(signals[2].aborted, true); +}); diff --git a/src/layers/localAdsb/index.js b/src/layers/localAdsb/index.js new file mode 100644 index 000000000..9bfffb722 --- /dev/null +++ b/src/layers/localAdsb/index.js @@ -0,0 +1,846 @@ +import * as Cesium from 'cesium'; +import { CLASS_SCALE_2D } from '../../data/aircraftClass.js'; +import { aircraftIcon } from '../../data/aircraftIcons.js'; +import { + horizonOccluder, + screenProjectedRotation, + stabilizeScreenRotation, +} from '../../data/iconOrientation.js'; +import { isPointerFree } from '../../data/inputOwnership.js'; +import { pickRenderAltitudeM } from '../../data/renderAltitude.js'; +import { routePlausible } from '../../data/routePlausible.js'; +import { + localAdsbPositionIsFresh, + mergeLocalAdsbRecords, + summarizeLocalAdsb, +} from '../../sources/adsbRecords.js'; +import { GROUND_SCALE } from '../flights/policy.js'; +import { + localAdsbCardModel, + localAdsbIsUat, + localAdsbSourceText, + localAdsbTitle, +} from './card.js'; +import { createLocalAdsbEnrichment, localAdsbClass } from './enrichment.js'; +import { createLocalAdsbModels } from './models.js'; +import { LocalAdsbMotion } from './motion.js'; +import { + ENTITY_PREFIX, + LAYER_ID, + LAYER_NAME, + LAYER_SOURCE, + LOCAL_ADSB_COLOR, + LOCAL_ADSB_SYNC_MS, + LOCAL_ADSB_TICK_MS, + LOCAL_ADSB_UAT_RING_COLOR, +} from './policy.js'; +import { localAdsbStatus } from './status.js'; + +export { + LAYER_ID as LOCAL_ADSB_LAYER_ID, + HEARD_BY_RECEIVER, +} from './policy.js'; +export { + localAdsbCardModel, + localAdsbClassLine, + localAdsbIsUat, + localAdsbReceiverLine, + localAdsbTitle, +} from './card.js'; +export { localAdsbClass } from './enrichment.js'; +export { localAdsbStatus } from './status.js'; + +const FEET_TO_METERS = 0.3048; +const RENDER_HOLD_ID = 'local-adsb'; +/** Picks looked through under a click for a local aircraft. */ +const CLICK_DRILL_LIMIT = 8; +/** Trail lines pass through their aircraft; a click never stops on one. */ +const TRAIL_PICK_PREFIX = 'gev-trail:'; +/** The DISPLAY rail's first-run 3D preference, used when no reader is wired. */ +const DEFAULT_DISPLAY = Object.freeze({ + models3d: true, + models3dMode: 'proximity', +}); + +/** + * Local ADS-B layer: draws aircraft heard by the user's own receivers. + * + * Two inputs feed it. The receiver is any object with `getState()` returning + * `{ connected, status, message, mode, webUsbSupported, aircraft, messagesPerSecond }`, + * `subscribe(listener)` and `setMode(mode)`; `aircraft` holds records from + * `src/sources/adsbRecords.js`. The browser WebUSB session is one such + * receiver. The optional `feeds` session (`./feeds.js`) supplies records read + * by the server from local 1090/978 MHz decoders; it polls only while this + * layer is enabled. Records are merged by ICAO (`mergeLocalAdsbRecords`). + * Aircraft heard only on 978 MHz UAT carry a thin ring around the marker. + * + * Aircraft get the public Flights treatment in magenta: a class from the + * emitter category or adsbdb type (`aircraftClass.js`), the class silhouette + * and scale, the Flights 3D models under the DISPLAY rail's 3D toggle, smooth + * real-time motion (`./motion.js`, no display delay) and, while selected, a + * trail of the positions the receiver heard. + * + * Markers are never followed by the camera. A marker disappears once its + * position is older than 60 s; a record without any message for 60 s is gone. + * @param {object} options + * @param {object} options.receiver Local receiver session. + * @param {object} [options.feeds] Decoder-feed session (start, stop, + * getState, subscribe). + * @param {object} options.services Render, context, picking, detection and + * readout operations supplied by the application; optionally `display` + * (`getParams()` → `{ models3d, models3dMode }`), `enrichment` (a source + * with `getEnrichment`), `trails` (`createTrail`), `geoid` + * (`ensureGeoidReady`, `geoidHeight`) and `groundSnap` + * (`createGroundSnap`). + * @param {() => number} [options.now] Clock, injectable for tests. + * @param {(url: string) => string} [options.resolveAsset] Model URL resolver. + * @param {(options: object) => Promise} [options.loadModel] Model + * loader, injectable for tests. + * @param {(canvas: HTMLCanvasElement) => object} [options.createInputHandler] + * Click-handler factory, injectable for tests. + * @returns {object} Data-layer module. + */ +export function createLocalAdsbLayer({ + receiver, + feeds = null, + services, + now = Date.now, + resolveAsset = (url) => url, + loadModel, + createInputHandler = (canvas) => new Cesium.ScreenSpaceEventHandler(canvas), +}) { + if (typeof receiver?.getState !== 'function') + throw new TypeError('Local ADS-B requires a receiver session'); + const { + governorRequestRender, + holdContinuousRender = () => {}, + releaseContinuousRender = () => {}, + } = services.render; + const { + registerEntityContext, + selectEntityContext, + clearSelectedEntityContextForLayer, + getSelectedEntityContext, + removeEntityContextsForLayer, + } = services.context; + const { registerPickOwner, unregisterPickOwner } = services.picking; + const color = Cesium.Color.fromCssColorString(LOCAL_ADSB_COLOR); + const uatRingColor = Cesium.Color.fromCssColorString( + LOCAL_ADSB_UAT_RING_COLOR, + ); + + let viewer = null; + let dataSource = null; + let enabled = false; + let unsubscribe = null; + let unsubscribeFeeds = null; + let tickTimer = null; + let syncTimer = null; + let clickHandler = null; + let removePreRender = null; + let renderHeld = false; + let selectedId = null; + let selectedTrail = null; + let trailHeadSeq = 0; + let models = null; + let geoidReady = false; + let rejectedFixes = 0; + let state = receiver.getState(); + const markers = new Map(); + // Per-ICAO receptions (band|source -> last message) across merges. + const heardBy = new Map(); + const enrichment = createLocalAdsbEnrichment({ + source: services.enrichment || null, + onChange: () => { + for (const marker of markers.values()) refreshClass(marker); + refreshSelectedCard(); + governorRequestRender('local-adsb-enrichment'); + }, + }); + + function displayPreferences() { + return services.display?.getParams?.() || DEFAULT_DISPLAY; + } + + function markSourcesChanged(reason) { + services.detection?.markSourcesChanged?.(reason); + } + + function mergedRecords(at = now(), receiverState = state) { + return mergeLocalAdsbRecords( + [receiverState.aircraft || [], feeds?.getState?.()?.records || []], + at, + heardBy, + ); + } + + function freshRecords(at = now()) { + return mergedRecords(at).filter((record) => + localAdsbPositionIsFresh(record, at), + ); + } + + function markerVisible(marker) { + if (!viewer?.camera?.positionWC || !marker.position) return true; + return horizonOccluder(viewer.camera).isPointVisible(marker.position); + } + + function markerRotation(marker) { + if (!viewer?.scene || !marker.position) return marker.lastRotation; + const projected = screenProjectedRotation( + viewer.scene, + marker.position, + marker.courseDeg ?? 0, + marker.lastRotation, + ); + const stable = stabilizeScreenRotation(marker.lastRotation, projected); + if (stable !== null) marker.lastRotation = stable; + return marker.lastRotation; + } + + function geoidN(lat, lon) { + if (!geoidReady || typeof services.geoid?.geoidHeight !== 'function') + return null; + try { + const value = services.geoid.geoidHeight(lat, lon); + return Number.isFinite(value) ? value : null; + } catch { + return null; + } + } + + /** Barometric feet → ellipsoidal render metres, as the Flights layer does + * for a contact without a geometric altitude: baro + geoid N. */ + function renderHeightM(marker, altitudeFt, lat, lon) { + if (marker.geoidN === null) marker.geoidN = geoidN(lat, lon); + const height = pickRenderAltitudeM({ + geoAltM: null, + baroAltM: Number.isFinite(altitudeFt) + ? altitudeFt * FEET_TO_METERS + : null, + onGround: false, + surfaceM: null, + geoidN: marker.geoidN, + }); + return Number.isFinite(height) ? height : (marker.geoidN ?? 0); + } + + function fixPosition(marker, fix) { + return Cesium.Cartesian3.fromDegrees( + fix.lon, + fix.lat, + renderHeightM(marker, fix.altitudeFt, fix.lat, fix.lon), + ); + } + + function updateDisplay(marker, at) { + const display = marker.motion.displayAt(at); + if (!display) return; + marker.lat = display.lat; + marker.lon = display.lon; + marker.position = Cesium.Cartesian3.fromDegrees( + display.lon, + display.lat, + renderHeightM(marker, display.altitudeFt, display.lat, display.lon), + Cesium.Ellipsoid.WGS84, + marker.position || new Cesium.Cartesian3(), + ); + if (display.courseDeg !== null) marker.courseDeg = display.courseDeg; + } + + function billboardScale(marker) { + return ( + (CLASS_SCALE_2D[marker.klass] || 1) * (marker.onGround ? GROUND_SCALE : 1) + ); + } + + /** Class silhouette and per-class scale (×0.8 on the ground), magenta. */ + function refreshClass(marker) { + const { klass, evidence } = localAdsbClass( + marker.record, + enrichment.get(marker.record.icao), + ); + marker.evidence = evidence; + marker.klass = klass; + const billboard = marker.entity?.billboard; + if (!billboard) return; + if (marker.iconKind !== klass) { + marker.iconKind = klass; + billboard.image = aircraftIcon(klass); + } + const scale = billboardScale(marker); + if (marker.iconScale !== scale) { + marker.iconScale = scale; + billboard.scale = scale; + } + } + + function plausibleRoute(marker, meta) { + const route = meta?.route; + if (!route || route.callsign !== marker.record.callsign) return null; + const record = marker.record; + const ok = routePlausible({ + latDeg: marker.lat ?? record.lat, + lonDeg: marker.lon ?? record.lon, + altitudeM: Number.isFinite(record.altitudeFt) + ? record.altitudeFt * FEET_TO_METERS + : null, + verticalRateMps: Number.isFinite(record.verticalRateFpm) + ? (record.verticalRateFpm * FEET_TO_METERS) / 60 + : null, + origin: route.origin, + destination: route.destination, + }); + return ok ? route : null; + } + + function cardModel(marker, at) { + const meta = enrichment.get(marker.record.icao); + return localAdsbCardModel(marker.record, at, { + aircraftClass: { klass: marker.klass, evidence: marker.evidence }, + meta, + route: plausibleRoute(marker, meta), + }); + } + + function contextMetadata(id, marker) { + const record = marker.record; + const meta = enrichment.get(record.icao); + return { + id, + layerId: LAYER_ID, + dataSource, + layerName: LAYER_NAME, + source: localAdsbSourceText(record), + label: localAdsbTitle(record), + latitude: marker.lat ?? record.lat, + longitude: marker.lon ?? record.lon, + properties: { + icao: record.icao, + callsign: record.callsign, + category: record.category ?? null, + aircraftClass: marker.evidence ? marker.klass : null, + typeCode: meta?.typeCode ?? null, + altitudeFt: record.altitudeFt, + groundSpeedKt: record.groundSpeedKt, + trackDeg: record.trackDeg, + verticalRateFpm: record.verticalRateFpm, + lastPositionAt: record.lastPositionAt, + messageCount: record.messageCount, + receiverSource: record.source, + bands: record.bands, + sources: record.sources, + }, + }; + } + + function uatRing(marker) { + return new Cesium.PointGraphics({ + pixelSize: 24, + color: Cesium.Color.TRANSPARENT, + outlineColor: uatRingColor, + outlineWidth: 1.5, + scaleByDistance: new Cesium.NearFarScalar(1000, 3.0, 8_000_000, 0.5), + show: new Cesium.CallbackProperty(() => markerVisible(marker), false), + disableDepthTestDistance: Number.POSITIVE_INFINITY, + distanceDisplayCondition: new Cesium.DistanceDisplayCondition( + 0, + 2_000_000, + ), + }); + } + + // ── Trail of the selected aircraft ────────────────────────────────────── + + function releaseTrail() { + if (!selectedTrail) return; + selectedTrail.trail?.destroy(); + if (selectedTrail.head && viewer && !viewer.isDestroyed?.()) { + try { + viewer.entities.remove(selectedTrail.head); + } catch { + /* torn down */ + } + } + selectedTrail = null; + } + + function refreshTrail() { + const marker = selectedId ? markers.get(selectedId) : null; + if (!marker || !selectedTrail?.trail) return; + const positions = marker.motion.fixes.map((fix) => + fixPosition(marker, fix), + ); + selectedTrail.lastFix = positions.at(-1) || null; + selectedTrail.trail.setPositions(positions); + } + + function startTrail(marker) { + releaseTrail(); + const createTrail = services.trails?.createTrail; + if (typeof createTrail !== 'function' || !viewer?.entities) return; + selectedTrail = { + id: marker.id, + trail: createTrail(viewer, { color: LOCAL_ADSB_COLOR, width: 2.5 }), + head: null, + lastFix: null, + }; + // Live head: last heard fix → the extrapolated marker, every frame. + selectedTrail.head = viewer.entities.add({ + // 'gev-trail' namespace: claimed by the trail pick owner so a click on + // the head segment never reads as empty space. + id: `gev-trail:local-adsb-head-${++trailHeadSeq}`, + polyline: { + positions: new Cesium.CallbackProperty(() => { + const current = markers.get(selectedTrail?.id); + const from = selectedTrail?.lastFix; + if (!current?.position || !from) return []; + return [from, Cesium.Cartesian3.clone(current.position)]; + }, false), + width: 2.5, + material: color.withAlpha(0.9), + depthFailMaterial: color.withAlpha(0.45), + arcType: Cesium.ArcType.GEODESIC, + }, + }); + refreshTrail(); + } + + // ── Markers ───────────────────────────────────────────────────────────── + + function upsertMarker(record, at) { + const id = `${ENTITY_PREFIX}${record.icao}`; + let marker = markers.get(id); + if (!marker) { + marker = { + id, + entity: null, + record, + motion: new LocalAdsbMotion(), + position: null, + lat: null, + lon: null, + courseDeg: Number.isFinite(record.trackDeg) ? record.trackDeg : null, + lastRotation: 0, + klass: null, + evidence: false, + iconKind: null, + iconScale: null, + onGround: Boolean(record.onGround), + geoidN: null, + modelOwnsVisual: false, + }; + markers.set(id, marker); + } + marker.record = record; + marker.onGround = Boolean(record.onGround); + const rejectedBefore = marker.motion.rejectedFixes; + const newFix = marker.motion.observe(record, at); + rejectedFixes += marker.motion.rejectedFixes - rejectedBefore; + updateDisplay(marker, at); + if (!marker.entity) { + marker.entity = dataSource.entities.add({ + id, + position: new Cesium.CallbackProperty(() => marker.position, false), + billboard: { + image: aircraftIcon('airliner'), + width: 20, + height: 20, + scale: 1, + color, + sizeInMeters: false, + scaleByDistance: new Cesium.NearFarScalar(1000, 3.0, 8_000_000, 0.5), + alignedAxis: Cesium.Cartesian3.ZERO, + rotation: new Cesium.CallbackProperty( + () => markerRotation(marker), + false, + ), + show: new Cesium.CallbackProperty( + () => !marker.modelOwnsVisual && markerVisible(marker), + false, + ), + disableDepthTestDistance: Number.POSITIVE_INFINITY, + distanceDisplayCondition: new Cesium.DistanceDisplayCondition( + 0, + 2_000_000, + ), + }, + }); + marker.entity.gevTrackedId = id; + marker.entity.gevDisplayPosition = () => marker.position; + } + refreshClass(marker); + const uat = localAdsbIsUat(record); + if (uat !== Boolean(marker.entity.point)) + marker.entity.point = uat ? uatRing(marker) : undefined; + marker.entity.gevLabelModel = cardModel(marker, at); + registerEntityContext(marker.entity, contextMetadata(id, marker)); + if (newFix && id === selectedId) refreshTrail(); + return id; + } + + function clearSelection({ evicted = false } = {}) { + if (!selectedId) return; + selectedId = null; + releaseTrail(); + clearSelectedEntityContextForLayer(LAYER_ID, { evicted }); + } + + function refreshSelectedCard() { + const marker = selectedId ? markers.get(selectedId) : null; + if (!marker) return; + marker.entity.gevLabelModel = cardModel(marker, now()); + services.overlays?.refreshReadout?.(marker.entity); + } + + function updateRenderHold() { + const want = enabled && markers.size > 0; + if (want === renderHeld) return; + renderHeld = want; + if (want) holdContinuousRender(RENDER_HOLD_ID); + else releaseContinuousRender(RENDER_HOLD_ID); + } + + function requestEnrichment() { + const selected = selectedId ? markers.get(selectedId) : null; + if (selected) enrichment.request(selected.record, { selected: true }); + for (const id of models?.eligible || []) { + const marker = markers.get(id); + if (marker) enrichment.request(marker.record); + } + } + + function sync() { + clearTimeout(syncTimer); + syncTimer = null; + if (!dataSource) return; + const at = now(); + const live = new Set(); + let added = false; + if (enabled) { + for (const record of freshRecords(at)) { + const id = `${ENTITY_PREFIX}${record.icao}`; + if (!markers.has(id)) added = true; + live.add(upsertMarker(record, at)); + } + } + let removed = false; + for (const [id, marker] of markers) { + if (live.has(id)) continue; + if (marker.entity) dataSource.entities.remove(marker.entity); + markers.delete(id); + removed = true; + } + // Expired aircraft take their 3D models (and pending loads) with them. + if (removed) models?.retain(markers); + if (selectedId && !live.has(selectedId)) + clearSelection({ evicted: enabled }); + removeEntityContextsForLayer(LAYER_ID, { retainIds: live }); + if (selectedId) { + const selected = markers.get(selectedId); + if (getSelectedEntityContext()?.id !== selectedId) { + selectedId = null; + releaseTrail(); + } else services.overlays?.refreshReadout?.(selected.entity); + } + requestEnrichment(); + updateRenderHold(); + if (live.size || removed) governorRequestRender('local-adsb-update'); + // Detection re-solves labels only when the set of contacts changes, not + // on every position report. + if (added || removed) markSourcesChanged('local-adsb-update'); + } + + /** Per-frame pass: smooth positions and courses, then 3D models. */ + function frame() { + if (!enabled || !markers.size) return; + const at = now(); + for (const marker of markers.values()) updateDisplay(marker, at); + models?.frame(markers, displayPreferences(), at); + } + + function scheduleSync(nextState) { + state = nextState; + requestSync(); + } + + function requestSync() { + if (!enabled || syncTimer) return; + syncTimer = setTimeout(sync, LOCAL_ADSB_SYNC_MS); + } + + function selectMarker(id) { + const marker = markers.get(id); + if (!marker) return false; + const reselect = id === selectedId && selectedTrail?.id === id; + selectedId = id; + enrichment.request(marker.record, { selected: true }); + marker.entity.gevLabelModel = cardModel(marker, now()); + selectEntityContext(marker.entity); + if (!reselect) startTrail(marker); + governorRequestRender('local-adsb-selection'); + return true; + } + + function pickedId(picked) { + const resolved = services.picking.resolvePickId?.(picked); + if (resolved !== undefined) return resolved; + if (typeof picked?.id === 'string') return picked.id; + if (typeof picked?.id?.id === 'string') return picked.id.id; + return typeof picked?.primitive?.id === 'string' + ? picked.primitive.id + : null; + } + + function ownedElsewhere(id) { + return Boolean(id) && services.picking.isOwnedByOtherLayer?.(LAYER_ID, id); + } + + /** + * What a click at `position` means for this layer: a local aircraft id, + * `'other'` when a sibling layer's contact is under the pointer, `'trail'` + * for a trail line, or null for empty map. + * + * The frontmost pick decides, except that something no layer owns (a + * photoreal tile, a ring) or a trail line drawn over or through a local + * aircraft does not hide it: the click looks underneath for the aircraft. + */ + function clickTarget(position) { + const scene = viewer.scene; + const top = pickedId(scene.pick(position)); + if (top && markers.has(top)) return top; + const trail = Boolean(top) && String(top).startsWith(TRAIL_PICK_PREFIX); + if (ownedElsewhere(top) && !trail) return 'other'; + let hits = []; + try { + hits = scene.drillPick?.(position, CLICK_DRILL_LIMIT) || []; + } catch { + hits = []; + } + for (const hit of hits) { + const id = pickedId(hit); + if (id && markers.has(id)) return id; + if (id && String(id).startsWith(TRAIL_PICK_PREFIX)) continue; + // A sibling layer's contact above ours takes the click. + if (ownedElsewhere(id)) return 'other'; + } + return trail ? 'trail' : null; + } + + function installInteraction() { + if (clickHandler || !viewer?.scene?.canvas) return; + clickHandler = createInputHandler(viewer.scene.canvas); + clickHandler.setInputAction((click) => { + // A tool owns the pointer (src/data/inputOwnership.js): yield the click. + if (!isPointerFree() || !enabled) return; + const target = clickTarget(click.position); + // Clicking a local aircraft always (re)selects it and republishes its + // card, including the one already selected: its card may have been + // displaced, and a click on a marker must never read as "deselect". + if (target && markers.has(target)) { + selectMarker(target); + return; + } + // A click on a trail line is not empty map. + if (target === 'trail' || !selectedId) return; + // Another contact or empty map releases only this layer's selection, + // leaving a sibling layer's new selection intact. + clearSelection(); + governorRequestRender('local-adsb-selection'); + }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + } + + function releaseInteraction() { + clickHandler?.destroy(); + clickHandler = null; + } + + function installFrame() { + if (removePreRender || !viewer?.scene?.preRender?.addEventListener) return; + removePreRender = viewer.scene.preRender.addEventListener(frame); + } + + function releaseFrame() { + removePreRender?.(); + removePreRender = null; + } + + function positionRows(maxCount = 500) { + if (!enabled) return []; + const rows = []; + for (const marker of markers.values()) { + if (rows.length >= maxCount) break; + if (!marker.position) continue; + const carto = Cesium.Cartographic.fromCartesian(marker.position); + rows.push({ + id: marker.record.icao, + label: localAdsbTitle(marker.record), + callsign: marker.record.callsign, + position: Cesium.Cartesian3.clone(marker.position), + latitude: marker.lat, + longitude: marker.lon, + altitudeM: carto?.height ?? 0, + }); + } + return rows; + } + + return { + id: LAYER_ID, + name: LAYER_NAME, + icon: '📡', + source: LAYER_SOURCE, + updateInterval: 0, + statsRefreshInterval: LOCAL_ADSB_TICK_MS, + /** The shared receiver session, also driven by the Radio panel card. */ + receiver, + /** Decoder-feed session; the Radio card shows its one-line summary. */ + feeds, + + init(nextViewer) { + viewer = nextViewer; + dataSource = new Cesium.CustomDataSource(LAYER_ID); + dataSource.show = false; + viewer.dataSources.add(dataSource); + if (viewer.scene?.primitives) { + models = createLocalAdsbModels({ + viewer, + color, + resolveAsset, + groundSnap: services.groundSnap?.createGroundSnap?.() || null, + ...(loadModel ? { loadModel } : {}), + }); + } + services.geoid + ?.ensureGeoidReady?.() + ?.then(() => { + geoidReady = true; + for (const marker of markers.values()) marker.geoidN = null; + }) + ?.catch(() => { + /* the baro path stays un-geoid-corrected */ + }); + unsubscribe = receiver.subscribe?.(scheduleSync) || null; + unsubscribeFeeds = feeds?.subscribe?.(requestSync) || null; + return true; + }, + + async enable() { + enabled = true; + if (dataSource) dataSource.show = true; + registerPickOwner(LAYER_ID, (pickedValue) => markers.has(pickedValue)); + installInteraction(); + installFrame(); + clearInterval(tickTimer); + tickTimer = setInterval(sync, LOCAL_ADSB_TICK_MS); + feeds?.start?.(); + // The layer asks the shared tuner for 1090 MHz. Disabling it leaves the + // receiver in whatever mode the Radio card shows, so turning the layer + // off never starts FM audio on its own. + if (receiver.getState().mode !== 'adsb') await receiver.setMode('adsb'); + state = receiver.getState(); + sync(); + return true; + }, + + async disable() { + enabled = false; + feeds?.stop?.(); + clearInterval(tickTimer); + tickTimer = null; + releaseInteraction(); + releaseFrame(); + unregisterPickOwner(LAYER_ID); + sync(); + models?.clear(); + if (dataSource) dataSource.show = false; + markSourcesChanged('local-adsb-disabled'); + return true; + }, + + async update() { + state = receiver.getState(); + sync(); + frame(); + return true; + }, + + destroy() { + enabled = false; + clearInterval(tickTimer); + clearTimeout(syncTimer); + tickTimer = null; + syncTimer = null; + releaseInteraction(); + releaseFrame(); + unregisterPickOwner(LAYER_ID); + unsubscribe?.(); + unsubscribe = null; + unsubscribeFeeds?.(); + unsubscribeFeeds = null; + feeds?.destroy?.(); + enrichment.destroy(); + clearSelection(); + removeEntityContextsForLayer(LAYER_ID); + markers.clear(); + updateRenderHold(); + models?.destroy(); + models = null; + if (viewer && dataSource) viewer.dataSources.remove(dataSource, true); + dataSource = null; + viewer = null; + void receiver.destroy?.(); + }, + + getStats() { + const current = receiver.getState(); + const at = now(); + const records = mergedRecords(at, current); + const { heard, positioned } = summarizeLocalAdsb(records, at); + const lastUpdate = + records.reduce( + (latest, record) => Math.max(latest, record.lastMessageAt || 0), + 0, + ) || null; + return { + count: positioned, + lastUpdate, + source: LAYER_SOURCE, + // Fixes refused by the speed check: the browser decoder's own count + // plus the layer's check on every merged record. + rejectedPositions: + Math.max(0, Number(current.positionsRejected) || 0) + rejectedFixes, + ...localAdsbStatus({ + receiver: current, + feedState: feeds?.getState?.() || null, + heard, + }), + }; + }, + + /** + * Select a heard aircraft's marker and publish its readout card, exactly + * as a click does. The camera does not follow it. + * @param {string} icao Lowercase ICAO hex. + * @returns {boolean} Whether a fresh marker was selected. + */ + selectAircraft(icao) { + return enabled && selectMarker(`${ENTITY_PREFIX}${icao}`); + }, + + getAllPositions(maxCount = 500) { + return positionRows(maxCount); + }, + + getDetectableObjects(options = {}) { + return positionRows(options.maxCount).map((row) => ({ + position: row.position, + sourceId: row.id, + // Label only a decoded callsign; never substitute the raw ICAO hex. + id: row.callsign || '', + type: 'AIR', + skipLabel: false, + })); + }, + }; +} diff --git a/src/layers/localAdsb/index.test.mjs b/src/layers/localAdsb/index.test.mjs new file mode 100644 index 000000000..263c21210 --- /dev/null +++ b/src/layers/localAdsb/index.test.mjs @@ -0,0 +1,1167 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as Cesium from 'cesium'; + +import { CLASS_SCALE_2D } from '../../data/aircraftClass.js'; +import { layerFeedState } from '../../data/feedState.js'; +import { aircraftIcon } from '../../data/aircraftIcons.js'; +import { + createLocalAdsbLayer, + HEARD_BY_RECEIVER, + localAdsbCardModel, + localAdsbReceiverLine, + localAdsbStatus, +} from './index.js'; + +function fakeReceiver(initial = {}) { + const state = { + webUsbSupported: true, + connected: false, + mode: 'fm', + status: 'idle', + message: 'Ready', + aircraft: [], + messagesPerSecond: null, + ...initial, + }; + const modeCalls = []; + const subscribers = new Set(); + const snapshot = () => ({ ...state, aircraft: [...state.aircraft] }); + return { + modeCalls, + getState: snapshot, + subscribe(listener) { + subscribers.add(listener); + return () => subscribers.delete(listener); + }, + set(patch) { + Object.assign(state, patch); + for (const listener of subscribers) listener(snapshot()); + }, + async setMode(mode) { + modeCalls.push(mode); + state.mode = mode; + return true; + }, + }; +} + +function fakeServices() { + const calls = { registered: [], selected: [], cleared: [], removed: [] }; + let selected = null; + return { + calls, + services: { + render: { governorRequestRender() {} }, + context: { + registerEntityContext(entity, metadata) { + entity.__gevContextId = metadata.id; + calls.registered.push(metadata); + }, + selectEntityContext(entity) { + selected = { id: entity.__gevContextId, entity }; + calls.selected.push(selected.id); + return selected; + }, + clearSelectedEntityContextForLayer(layerId, options) { + calls.cleared.push({ layerId, ...options }); + selected = null; + }, + getSelectedEntityContext: () => selected, + removeEntityContextsForLayer(layerId, options) { + calls.removed.push({ layerId, retainIds: options?.retainIds }); + }, + }, + picking: { registerPickOwner() {}, unregisterPickOwner() {} }, + detection: { markSourcesChanged() {} }, + overlays: { refreshReadout() {} }, + }, + }; +} + +function fakeViewer() { + const sources = []; + const camera = { + rightWC: new Cesium.Cartesian3(0, 1, 0), + upWC: new Cesium.Cartesian3(0, 0, 1), + }; + return { + sources, + viewer: { + camera, + scene: { camera }, + entities: { + add: (entity) => entity, + remove() {}, + }, + dataSources: { + add(source) { + sources.push(source); + }, + remove() {}, + }, + }, + }; +} + +function record(overrides = {}) { + return { + icao: 'abc123', + callsign: 'LOCAL1', + lat: 0, + lon: 0, + altitudeFt: 10_000, + groundSpeedKt: 180, + trackDeg: 0, + verticalRateFpm: 0, + lastPositionAt: 100_000, + lastMessageAt: 100_000, + messageCount: 12, + rssiDbfs: null, + band: '1090', + source: 'webusb', + ...overrides, + }; +} + +function fakeFeeds(initial = {}) { + let state = { + configured: null, + polling: false, + feeds: [], + records: [], + ...initial, + }; + const listeners = new Set(); + const calls = []; + return { + calls, + getState: () => state, + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + set(patch) { + state = { ...state, ...patch }; + for (const listener of listeners) listener(state); + }, + start() { + calls.push('start'); + state = { ...state, polling: true }; + }, + stop() { + calls.push('stop'); + state = { ...state, polling: false }; + }, + }; +} + +/** A viewer whose camera looks down on (0°, 0°) from 50 km, with primitives. */ +function modelViewer(primitives) { + const base = fakeViewer(); + const camera = { + ...base.viewer.camera, + positionWC: new Cesium.Cartesian3(6_378_137 + 50_000, 0, 0), + positionCartographic: new Cesium.Cartographic(0, 0, 50_000), + directionWC: new Cesium.Cartesian3(-1, 0, 0), + frustum: { + computeCullingVolume: () => ({ + computeVisibility: () => Cesium.Intersect.INSIDE, + }), + }, + }; + return { + ...base, + viewer: { + ...base.viewer, + camera, + scene: { + camera, + primitives: { + add(collection) { + primitives.push(collection); + return collection; + }, + remove() {}, + }, + }, + }, + }; +} + +async function enabledLayer(receiver, clock, feeds = null, options = {}) { + const { services, calls } = fakeServices(); + const { sources, viewer } = options.viewer || fakeViewer(); + const layer = createLocalAdsbLayer({ + receiver, + feeds, + services: { ...services, ...options.services }, + now: () => clock.now, + loadModel: options.loadModel, + ...(options.createInputHandler + ? { createInputHandler: options.createInputHandler } + : {}), + }); + layer.init(viewer); + await layer.enable(); + return { layer, calls, sources }; +} + +test('enabling Local ADS-B asks the shared tuner for 1090 MHz; disabling leaves it alone', async (t) => { + const receiver = fakeReceiver({ mode: 'fm' }); + const clock = { now: 0 }; + const { layer } = await enabledLayer(receiver, clock); + t.after(() => layer.destroy()); + assert.equal(layer.id, 'local-adsb'); + assert.equal(layer.name, 'Local ADS-B'); + assert.deepEqual(receiver.modeCalls, ['adsb']); + assert.equal(await layer.disable(), true); + assert.deepEqual( + receiver.modeCalls, + ['adsb'], + 'turning the layer off never starts FM audio on its own', + ); +}); + +test('local aircraft use their class silhouette and scale in magenta', async (t) => { + const receiver = fakeReceiver({ mode: 'adsb', connected: true }); + const clock = { now: 100_000 }; + const { layer, sources } = await enabledLayer(receiver, clock); + t.after(() => layer.destroy()); + receiver.set({ + aircraft: [record(), record({ icao: 'a0b702', category: 'A7' })], + }); + await layer.update(); + + const entity = sources[0].entities.getById('local-adsb:abc123'); + assert.ok(entity); + assert.equal(entity.point, undefined); + assert.equal(entity.label, undefined); + assert.equal(entity.billboard.image.getValue(), aircraftIcon('airliner')); + assert.equal(entity.billboard.width.getValue(), 20); + assert.ok( + Cesium.Color.equals( + entity.billboard.color.getValue(), + Cesium.Color.fromCssColorString('#ff4fd8'), + ), + ); + assert.ok( + Cesium.Cartesian3.equals( + entity.billboard.alignedAxis.getValue(), + Cesium.Cartesian3.ZERO, + ), + ); + assert.equal(entity.billboard.rotation.getValue(), 0); + assert.equal(entity.gevLabelModel.title, 'LOCAL1'); + assert.equal(typeof entity.gevDisplayPosition, 'function'); + + const heli = sources[0].entities.getById('local-adsb:a0b702'); + assert.equal(heli.billboard.image.getValue(), aircraftIcon('helicopter')); + assert.equal(heli.billboard.scale.getValue(), CLASS_SCALE_2D.helicopter); + assert.ok( + Cesium.Color.equals( + heli.billboard.color.getValue(), + Cesium.Color.fromCssColorString('#ff4fd8'), + ), + 'the helicopter keeps the local magenta', + ); + assert.equal(heli.gevLabelModel.details[1], 'Helicopter · A7'); + + // On the ground the silhouette shrinks like a grounded public flight. + receiver.set({ + aircraft: [ + record({ + icao: 'a0b702', + category: 'A7', + onGround: true, + lastPositionAt: 100_500, + }), + ], + }); + await layer.update(); + assert.equal( + heli.billboard.scale.getValue(), + CLASS_SCALE_2D.helicopter * 0.8, + ); + + const [detectable] = layer.getDetectableObjects(); + assert.equal(detectable.sourceId, 'a0b702'); + assert.equal(detectable.type, 'AIR'); +}); + +test('the heading slews toward a new track instead of snapping', async (t) => { + const receiver = fakeReceiver({ mode: 'adsb', connected: true }); + const clock = { now: 100_000 }; + const { layer, sources } = await enabledLayer(receiver, clock); + t.after(() => layer.destroy()); + receiver.set({ aircraft: [record()] }); + await layer.update(); + const entity = sources[0].entities.getById('local-adsb:abc123'); + assert.equal(entity.billboard.rotation.getValue(), 0); + + clock.now = 101_000; + receiver.set({ + aircraft: [ + record({ + lat: 0.0008, + trackDeg: 90, + lastPositionAt: 101_000, + lastMessageAt: 101_000, + }), + ], + }); + await layer.update(); + const first = entity.billboard.rotation.getValue(); + assert.ok( + first > -Cesium.Math.PI_OVER_TWO && first <= 0, + 'one frame does not swing the nose 90°', + ); + for (let frame = 0; frame < 12; frame += 1) { + clock.now += 250; + await layer.update(); + } + assert.ok( + Math.abs(entity.billboard.rotation.getValue() + Cesium.Math.PI_OVER_TWO) < + 1e-6, + 'the nose settles on the new track', + ); +}); + +test('markers move in real time between fixes and never jump on a new fix', async (t) => { + const receiver = fakeReceiver({ mode: 'adsb', connected: true }); + const clock = { now: 100_000 }; + const { layer, sources } = await enabledLayer(receiver, clock); + t.after(() => layer.destroy()); + const latitude = () => + Cesium.Math.toDegrees( + Cesium.Cartographic.fromCartesian( + sources[0].entities.getById('local-adsb:abc123').position.getValue(), + ).latitude, + ); + // 180 kt due north: 1 s covers 92.6 m, 0.000833° of latitude. + const degPerSecond = (180 * 1852) / 3600 / 111_195; + receiver.set({ aircraft: [record({ lastPositionAt: 99_000 })] }); + await layer.update(); + assert.ok( + Math.abs(latitude() - degPerSecond) < 1e-5, + 'drawn where the aircraft is now, not at its last fix (no display delay)', + ); + clock.now = 100_500; + await layer.update(); + assert.ok(Math.abs(latitude() - 1.5 * degPerSecond) < 1e-5); + + // The next fix is 150 m behind the extrapolation; the marker slides. + const before = latitude(); + receiver.set({ + aircraft: [ + record({ + lat: before - 150 / 111_195, + lastPositionAt: 100_500, + lastMessageAt: 100_500, + }), + ], + }); + await layer.update(); + assert.ok(Math.abs(latitude() - before) < 1e-7, 'no jump at the new fix'); + clock.now = 101_500; + await layer.update(); + assert.ok( + Math.abs(latitude() - (before - 150 / 111_195 + degPerSecond)) < 1e-5, + 'the correction has decayed onto the new fix', + ); +}); + +test('an implausible jump in a feed record is ignored by the display', async (t) => { + const receiver = fakeReceiver({ mode: 'adsb', connected: true }); + const clock = { now: 100_000 }; + const { layer, sources } = await enabledLayer(receiver, clock); + t.after(() => layer.destroy()); + receiver.set({ aircraft: [record({ groundSpeedKt: 120, trackDeg: 0 })] }); + await layer.update(); + clock.now = 101_000; + receiver.set({ + aircraft: [ + record({ + lat: 0.05, + groundSpeedKt: 120, + lastPositionAt: 101_000, + lastMessageAt: 101_000, + }), + ], + }); + await layer.update(); + const lat = Cesium.Math.toDegrees( + Cesium.Cartographic.fromCartesian( + sources[0].entities.getById('local-adsb:abc123').position.getValue(), + ).latitude, + ); + assert.ok(lat < 0.001, `stayed on its track (${lat})`); + assert.equal(layer.getStats().rejectedPositions, 1); +}); + +test('selecting an aircraft draws a magenta trail of the fixes the receiver heard', async (t) => { + const receiver = fakeReceiver({ mode: 'adsb', connected: true }); + const clock = { now: 100_000 }; + const trails = []; + const { layer } = await enabledLayer(receiver, clock, null, { + services: { + trails: { + createTrail(viewer, options) { + const trail = { options, positions: [], destroyed: false }; + trails.push(trail); + return { + setPositions: (positions) => (trail.positions = positions), + destroy: () => (trail.destroyed = true), + }; + }, + }, + }, + }); + t.after(() => layer.destroy()); + for (let second = 0; second < 4; second += 1) { + clock.now = 100_000 + second * 1_000; + receiver.set({ + aircraft: [ + record({ + lat: second * 0.0008, + lastPositionAt: clock.now, + lastMessageAt: clock.now, + }), + ], + }); + await layer.update(); + if (second === 1) assert.equal(layer.selectAircraft('abc123'), true); + } + assert.equal(trails.length, 1); + assert.equal(trails[0].options.color, '#ff4fd8'); + assert.equal(trails[0].options.width, 2.5); + assert.equal(trails[0].positions.length, 4, 'every heard fix, oldest first'); + assert.ok( + Cesium.Cartographic.fromCartesian(trails[0].positions[0]).latitude < + Cesium.Cartographic.fromCartesian(trails[0].positions[3]).latitude, + ); + clock.now = 200_000; + await layer.update(); + assert.equal(trails[0].destroyed, true, 'the trail leaves with the aircraft'); +}); + +test('adsbdb type, operator and route reach the card for the selected aircraft only', async (t) => { + const receiver = fakeReceiver({ mode: 'adsb', connected: true }); + const clock = { now: 100_000 }; + const queries = []; + const { layer, sources } = await enabledLayer(receiver, clock, null, { + services: { + enrichment: { + async getEnrichment(query) { + queries.push(`${query.kind}:${query.id}`); + if (query.kind === 'type') + return { found: true, typeCode: 'B407', typeName: 'Bell 407' }; + return { + found: true, + airline: 'Air Evac', + origin: { code: 'AUS', lat: 0.1, lon: 0 }, + destination: { code: 'SAT', lat: -0.1, lon: 0 }, + }; + }, + }, + }, + }); + t.after(() => layer.destroy()); + receiver.set({ + aircraft: [ + record({ icao: 'a0b702', callsign: 'EVC145', category: 'A7' }), + record({ icao: 'def456', callsign: 'OTHER1' }), + ], + }); + await layer.update(); + assert.deepEqual(queries, [], 'no request per contact'); + assert.equal(layer.selectAircraft('a0b702'), true); + await new Promise((resolve) => setTimeout(resolve, 400)); + assert.deepEqual([...queries].sort(), ['route:EVC145', 'type:a0b702']); + const entity = sources[0].entities.getById('local-adsb:a0b702'); + assert.deepEqual(entity.gevLabelModel.details.slice(0, 4), [ + 'ICAO A0B702 · EVC145', + 'Helicopter · A7', + 'Air Evac · Bell 407', + 'AUS → SAT', + ]); + assert.equal( + entity.gevLabelModel.details.at(-1), + 'Heard by your receiver · 1090 MHz · browser SDR', + ); + layer.selectAircraft('a0b702'); + await layer.update(); + assert.equal(queries.length, 2, 'each key is asked for once'); +}); + +test('the DISPLAY 3D toggle gives local aircraft magenta class models', async (t) => { + const receiver = fakeReceiver({ mode: 'adsb', connected: true }); + const clock = { now: 100_000 }; + const loads = []; + const display = { models3d: true, models3dMode: 'proximity' }; + const primitives = []; + const { layer, sources } = await enabledLayer(receiver, clock, null, { + services: { display: { getParams: () => display } }, + viewer: modelViewer(primitives), + loadModel: async (options) => { + const model = { + ...options, + ready: true, + show: true, + modelMatrix: new Cesium.Matrix4(), + destroyed: false, + destroy() { + this.destroyed = true; + }, + isDestroyed() { + return this.destroyed; + }, + update() {}, + }; + loads.push(model); + return model; + }, + }); + t.after(() => layer.destroy()); + receiver.set({ aircraft: [record({ icao: 'a0b702', category: 'A7' })] }); + await layer.update(); + await new Promise((resolve) => setImmediate(resolve)); + await layer.update(); + assert.equal(loads.length, 1); + const [model] = loads; + assert.equal( + model.url, + '/models/bell206.glb', + 'the Flights helicopter model', + ); + assert.equal(model.scale, 1); + assert.equal(model.id, 'local-adsb:a0b702'); + assert.ok( + Cesium.Color.equals( + model.color, + Cesium.Color.fromCssColorString('#ff4fd8'), + ), + ); + assert.equal(model.colorBlendMode, Cesium.ColorBlendMode.MIX); + assert.equal(model.colorBlendAmount, 0.94); + assert.equal(model.show, true); + const heli = sources[0].entities.getById('local-adsb:a0b702'); + assert.equal( + heli.billboard.show.getValue(), + false, + 'the model is the visual', + ); + const origin = Cesium.Matrix4.getTranslation( + model.modelMatrix, + new Cesium.Cartesian3(), + ); + assert.ok(Cesium.Cartesian3.distance(origin, heli.position.getValue()) < 1); + + display.models3d = false; + await layer.update(); + assert.equal(model.destroyed || !primitives[0].contains(model), true); + assert.equal(heli.billboard.show.getValue(), true, 'the billboard is back'); +}); + +test('markers drop when the position is 60 s old and records when silent 60 s', async (t) => { + const receiver = fakeReceiver({ mode: 'adsb', connected: true }); + const clock = { now: 100_000 }; + const { layer, sources } = await enabledLayer(receiver, clock); + t.after(() => layer.destroy()); + receiver.set({ + aircraft: [ + record(), + record({ + icao: 'def456', + callsign: ' ', + lat: null, + lon: null, + lastPositionAt: null, + }), + ], + }); + await layer.update(); + const entities = () => sources[0].entities.values.map((entity) => entity.id); + assert.deepEqual( + entities(), + ['local-adsb:abc123'], + 'only positioned aircraft draw', + ); + assert.equal(layer.getStats().count, 1); + + // Still transmitting (no position) but the last position is 60 s old. + receiver.set({ aircraft: [record({ lastMessageAt: 150_000 })] }); + clock.now = 159_999; + await layer.update(); + assert.deepEqual(entities(), ['local-adsb:abc123']); + clock.now = 160_000; + await layer.update(); + assert.deepEqual(entities(), [], 'a stale position is not drawn'); + assert.deepEqual(layer.getDetectableObjects(), []); +}); + +test('selecting a marker publishes the readout card and eviction clears it', async (t) => { + const receiver = fakeReceiver({ mode: 'adsb', connected: true }); + const clock = { now: 100_000 }; + const { layer, calls } = await enabledLayer(receiver, clock); + t.after(() => layer.destroy()); + receiver.set({ aircraft: [record()] }); + await layer.update(); + const metadata = calls.registered.at(-1); + assert.equal(metadata.id, 'local-adsb:abc123'); + assert.equal(metadata.layerId, 'local-adsb'); + assert.equal(metadata.properties.icao, 'abc123'); + assert.equal(layer.selectAircraft('abc123'), true); + assert.deepEqual(calls.selected, ['local-adsb:abc123']); + assert.equal(layer.selectAircraft('ffffff'), false); + + clock.now = 200_000; + await layer.update(); + assert.deepEqual(calls.cleared, [{ layerId: 'local-adsb', evicted: true }]); + assert.equal(calls.removed.at(-1).layerId, 'local-adsb'); + assert.equal(calls.removed.at(-1).retainIds.size, 0); +}); + +test('the click card lists identity, kinematics, freshness and the receiver line', () => { + const card = localAdsbCardModel( + record({ + icao: 'ae5d8a', + callsign: 'SHINR42', + altitudeFt: 1600, + groundSpeedKt: 129.9, + trackDeg: 330.5, + verticalRateFpm: 64, + lastPositionAt: 96_000, + messageCount: 48, + }), + 100_000, + ); + assert.equal(card.title, 'SHINR42'); + assert.equal(card.accent, '#ff4fd8'); + assert.deepEqual(card.details, [ + 'ICAO AE5D8A · SHINR42', + 'ALT 1,600 FT · GS 130 KT · TRK 331°', + 'V/S +64 FPM', + 'POSITION 4 S AGO · 48 MSGS', + 'Heard by your receiver · 1090 MHz · browser SDR', + ]); + assert.equal(HEARD_BY_RECEIVER, 'Heard by your receiver'); + const bare = localAdsbCardModel( + record({ + callsign: null, + altitudeFt: null, + groundSpeedKt: null, + trackDeg: null, + verticalRateFpm: -1_280, + messageCount: 1, + }), + 100_000, + ); + assert.equal(bare.title, 'ABC123'); + assert.deepEqual(bare.details.slice(0, 4), [ + 'ICAO ABC123 · NO CALLSIGN', + 'ALT — FT · GS — KT · TRK —', + 'V/S -1,280 FPM', + 'POSITION 0 S AGO · 1 MSG', + ]); +}); + +test('layer stats guide the operator to the Radio card until ADS-B is streaming', () => { + const clock = { now: 100_000 }; + const { services } = fakeServices(); + const receiver = fakeReceiver(); + const layer = createLocalAdsbLayer({ + receiver, + services, + now: () => clock.now, + }); + assert.equal(layer.getStats().statusMessage, 'connect a receiver in Radio'); + receiver.set({ connected: true, status: 'streaming', mode: 'fm' }); + assert.equal(layer.getStats().statusMessage, 'receiver is in FM mode'); + receiver.set({ + mode: 'adsb', + messagesPerSecond: 3.5, + aircraft: [record(), record({ icao: 'def456', lat: null, lon: null })], + }); + const stats = layer.getStats(); + assert.equal(stats.count, 1); + assert.equal(stats.loadingLabel, '2 heard · 3.5 msg/s'); + receiver.set({ status: 'error', message: 'RTL-SDR sample stream stopped' }); + assert.equal(layer.getStats().error, 'RTL-SDR sample stream stopped'); + receiver.set({ status: 'idle', webUsbSupported: false, connected: false }); + assert.equal( + layer.getStats().statusMessage, + 'WebUSB needs desktop Chrome or Edge', + ); +}); + +test('the receiver line names every band and source that heard the aircraft', () => { + const line = (overrides) => localAdsbReceiverLine(record(overrides)); + assert.equal(line({}), 'Heard by your receiver · 1090 MHz · browser SDR'); + assert.equal( + line({ band: '1090', source: 'feed' }), + 'Heard by your receiver · 1090 MHz · decoder feed', + ); + assert.equal( + line({ band: '978', source: 'feed' }), + 'Heard by your receiver · 978 MHz UAT · decoder feed', + ); + assert.equal( + line({ bands: ['1090', '978'], sources: ['feed'] }), + 'Heard by your receiver · 1090 MHz + 978 MHz UAT · decoder feed', + ); + assert.equal( + line({ bands: ['1090'], sources: ['webusb', 'feed'] }), + 'Heard by your receiver · 1090 MHz · browser SDR + decoder feed', + ); + assert.equal( + line({ bands: ['1090', '978'], sources: ['webusb', 'feed'] }), + 'Heard by your receiver · 1090 MHz + 978 MHz UAT · browser SDR + decoder feed', + ); + assert.equal( + localAdsbReceiverLine({ icao: 'abc123' }), + HEARD_BY_RECEIVER, + 'a record without band or source keeps the plain line', + ); +}); + +test('feed records merge with browser SDR records by ICAO and UAT-only aircraft get a ring', async (t) => { + const receiver = fakeReceiver({ mode: 'adsb', connected: true }); + const feeds = fakeFeeds(); + const clock = { now: 100_000 }; + const { layer, sources } = await enabledLayer(receiver, clock, feeds); + t.after(() => layer.destroy()); + assert.deepEqual(feeds.calls, ['start'], 'the feeds poll while enabled'); + + receiver.set({ aircraft: [record({ lastPositionAt: 98_000 })] }); + feeds.set({ + configured: true, + feeds: [{ band: '978', label: '978 MHz UAT', status: 'live' }], + records: [ + record({ lat: 1, band: '978', source: 'feed', lastPositionAt: 99_000 }), + record({ + icao: 'a1b2c3', + callsign: 'N978UA', + band: '978', + source: 'feed', + }), + ], + }); + await layer.update(); + const shared = sources[0].entities.getById('local-adsb:abc123'); + const uat = sources[0].entities.getById('local-adsb:a1b2c3'); + assert.equal(sources[0].entities.values.length, 2); + assert.equal( + Cesium.Cartographic.fromCartesian( + shared.position.getValue(), + ).latitude.toFixed(4), + Cesium.Math.toRadians(1).toFixed(4), + 'the newer feed position wins', + ); + assert.equal(shared.point, undefined, 'heard on 1090 too: no UAT ring'); + assert.equal( + shared.gevLabelModel.details.at(-1), + 'Heard by your receiver · 1090 MHz + 978 MHz UAT · browser SDR + decoder feed', + ); + assert.ok(uat.point, 'a UAT-only aircraft carries the ring'); + assert.equal(uat.point.outlineWidth.getValue(), 1.5); + assert.ok( + Cesium.Color.equals(uat.point.color.getValue(), Cesium.Color.TRANSPARENT), + ); + assert.ok( + Cesium.Color.equals( + uat.billboard.color.getValue(), + Cesium.Color.fromCssColorString('#ff4fd8'), + ), + 'same magenta marker family', + ); + assert.equal( + uat.gevLabelModel.details.at(-1), + 'Heard by your receiver · 978 MHz UAT · decoder feed', + ); + const stats = layer.getStats(); + assert.equal(stats.source, 'WebUSB + decoder feeds'); + assert.equal(stats.loadingLabel, '1 feed live · 2 heard'); + assert.equal(stats.count, 2); + + await layer.disable(); + assert.deepEqual(feeds.calls, ['start', 'stop']); +}); + +test('row status reflects decoder feeds and the browser SDR together', () => { + const idle = { + webUsbSupported: true, + connected: false, + mode: 'fm', + status: 'idle', + }; + const usb = { + ...idle, + connected: true, + mode: 'adsb', + status: 'streaming', + messagesPerSecond: 3.5, + }; + const feedState = (...statuses) => ({ + configured: true, + polling: true, + feeds: statuses.map(([band, status]) => ({ + band, + label: band, + status, + })), + }); + const status = (receiver, feeds, heard = 14) => + localAdsbStatus({ receiver, feedState: feeds, heard }); + + // No feeds configured: the WebUSB statuses are unchanged. + assert.deepEqual(status(idle, null), { + source: 'RTL-SDR · WebUSB', + status: 'idle', + statusMessage: 'connect a receiver in Radio', + }); + assert.deepEqual( + status(idle, { configured: false, feeds: [], polling: false }), + status(idle, null), + ); + assert.equal(status(usb, null).loadingLabel, '14 heard · 3.5 msg/s'); + assert.equal( + status(idle, { configured: null, polling: true, feeds: [] }).loadingLabel, + 'checking decoder feeds', + ); + + let stats = status(idle, feedState(['1090', 'live'], ['978', 'live'])); + assert.deepEqual(stats, { + source: 'Decoder feeds', + status: 'streaming', + loadingLabel: '2 feeds live · 14 heard', + }); + stats = status(usb, feedState(['978', 'live'])); + assert.equal(stats.source, 'WebUSB + decoder feeds'); + assert.equal(stats.loadingLabel, '1 feed live · 14 heard · USB 3.5 msg/s'); + + // A live feed keeps the row nominal; the unreachable one is a trailing note. + stats = status(idle, feedState(['1090', 'live'], ['978', 'unreachable']), 9); + assert.deepEqual(stats, { + source: 'Decoder feeds', + status: 'streaming', + loadingLabel: '9 heard · feed 978 unreachable', + }); + assert.equal(layerFeedState(stats), 'nominal'); + + stats = status(idle, feedState(['978', 'unreachable']), 0); + assert.equal(stats.status, 'error'); + assert.equal(stats.error, 'feed 978 unreachable'); + stats = status( + usb, + feedState(['1090', 'stale'], ['978', 'stale'], ['1090', 'invalid']), + ); + assert.equal(stats.status, 'streaming'); + assert.equal(stats.degraded, undefined); + assert.equal(layerFeedState(stats), 'nominal'); + assert.equal( + stats.loadingLabel, + '14 heard · USB 3.5 msg/s · feeds 1090 #1, 978 stale · feed 1090 #2 invalid', + ); +}); + +test('a stale decoder feed does not mark a streaming browser receiver degraded', () => { + // Owner field test: dump1090 stopped so the browser could take the dongle. + const stats = localAdsbStatus({ + receiver: { + webUsbSupported: true, + connected: true, + mode: 'adsb', + status: 'streaming', + messagesPerSecond: 5.8, + }, + feedState: { + configured: true, + polling: true, + feeds: [{ band: '1090', label: '1090 MHz', status: 'stale' }], + }, + heard: 3, + }); + assert.deepEqual(stats, { + source: 'WebUSB + decoder feeds', + status: 'streaming', + loadingLabel: '3 heard · USB 5.8 msg/s · feed 1090 stale', + }); + assert.equal(layerFeedState(stats), 'nominal'); +}); + +test('a browser receiver error beside a live feed is a note on a nominal row', () => { + const stats = localAdsbStatus({ + receiver: { + webUsbSupported: true, + connected: true, + mode: 'adsb', + status: 'error', + message: 'RTL-SDR sample stream stopped', + }, + feedState: { + configured: true, + polling: true, + feeds: [{ band: '978', label: '978 MHz UAT', status: 'live' }], + }, + heard: 2, + }); + assert.equal(stats.status, 'streaming'); + assert.equal(layerFeedState(stats), 'nominal'); + assert.equal(stats.loadingLabel, '2 heard · USB error'); +}); + +test('every feed stale reads STALE, not an error; unreachable stays an error', () => { + const idle = { + webUsbSupported: true, + connected: false, + mode: 'fm', + status: 'idle', + }; + const feedState = (...feeds) => ({ + configured: true, + polling: true, + feeds: feeds.map(([band, status, label = band]) => ({ + band, + label, + status, + })), + }); + const status = (feeds, heard = 3) => + localAdsbStatus({ receiver: idle, feedState: feeds, heard }); + + // A decoder that stopped but still serves its last aircraft.json. + let stats = status(feedState(['1090', 'stale'], ['978', 'stale'])); + assert.equal(stats.status, 'stale'); + assert.equal(stats.error, undefined); + assert.equal(layerFeedState(stats), 'stale'); + assert.equal(stats.statusMessage, 'feeds 1090, 978 stale · 3 heard'); + + // An invalid entry is a configuration note, not a reason to fail the row. + stats = status(feedState(['1090', 'stale'], ['978', 'invalid']), 0); + assert.equal(layerFeedState(stats), 'stale'); + + stats = status(feedState(['1090', 'stale'], ['978', 'unreachable']), 0); + assert.equal(stats.status, 'error'); + assert.equal(layerFeedState(stats), 'unavailable'); + stats = status(feedState(['978', 'invalid']), 0); + assert.equal(stats.status, 'error'); +}); + +test('row status names two same-band feeds by their ordinal labels', () => { + const stats = localAdsbStatus({ + receiver: { + webUsbSupported: true, + connected: false, + mode: 'fm', + status: 'idle', + }, + feedState: { + configured: true, + polling: true, + feeds: [ + { band: '978', label: '978 MHz UAT #1', status: 'live' }, + { band: '978', label: '978 MHz UAT #2', status: 'unreachable' }, + { band: '1090', label: '1090 MHz', status: 'unreachable' }, + ], + }, + heard: 4, + }); + assert.equal( + stats.loadingLabel, + '4 heard · feeds 978 MHz UAT #2, 1090 unreachable', + ); +}); + +function fakeModel(options) { + return { + ...options, + ready: true, + show: true, + modelMatrix: new Cesium.Matrix4(), + destroyed: false, + destroy() { + this.destroyed = true; + }, + isDestroyed() { + return this.destroyed; + }, + update() {}, + }; +} + +test('the last aircraft expiring removes its 3D model and cancels a pending load', async (t) => { + const receiver = fakeReceiver({ mode: 'adsb', connected: true }); + const clock = { now: 100_000 }; + const primitives = []; + const loads = []; + const deferred = []; + const { layer } = await enabledLayer(receiver, clock, null, { + services: { + display: { + getParams: () => ({ models3d: true, models3dMode: 'proximity' }), + }, + }, + viewer: modelViewer(primitives), + loadModel: (options) => { + const model = fakeModel(options); + loads.push(model); + if (options.id === 'local-adsb:abc123') return Promise.resolve(model); + return new Promise((resolve) => deferred.push(() => resolve(model))); + }, + }); + t.after(() => layer.destroy()); + receiver.set({ aircraft: [record({ category: 'A7' })] }); + await layer.update(); + await new Promise((resolve) => setImmediate(resolve)); + await layer.update(); + const [model] = loads; + assert.equal(model.show, true, 'the model is the visual'); + assert.ok(primitives[0].contains(model)); + + // Its position ages out: no markers remain, so no per-frame model pass. + clock.now = 160_000; + await layer.update(); + assert.equal( + model.destroyed || !primitives[0].contains(model), + true, + 'the expired aircraft leaves no model behind', + ); + + // A load still in flight when its aircraft expires is never admitted. + clock.now = 200_000; + receiver.set({ + aircraft: [ + record({ + icao: 'def456', + lastPositionAt: 200_000, + lastMessageAt: 200_000, + }), + ], + }); + await layer.update(); + clock.now = 260_000; + await layer.update(); + assert.equal(deferred.length, 1, 'a load was in flight'); + deferred[0](); + await new Promise((resolve) => setImmediate(resolve)); + const late = loads.at(-1); + assert.equal(late.id, 'local-adsb:def456'); + assert.equal(primitives[0].contains(late), false); + assert.equal(late.destroyed, true); +}); + +/** A viewer with a canvas and scripted picks, plus the layer's click action. */ +function clickHarness() { + const base = fakeViewer(); + const scene = { + ...base.viewer.scene, + canvas: {}, + pickResult: null, + drillResult: [], + pick() { + return scene.pickResult; + }, + drillPick() { + return scene.drillResult; + }, + }; + const handler = { actions: new Map() }; + return { + scene, + viewer: { ...base, viewer: { ...base.viewer, scene } }, + createInputHandler: () => ({ + setInputAction(action, type) { + handler.actions.set(type, action); + }, + destroy() {}, + }), + click() { + handler.actions.get(Cesium.ScreenSpaceEventType.LEFT_CLICK)({ + position: new Cesium.Cartesian2(10, 10), + }); + }, + }; +} + +test('clicking the selected aircraft keeps it selected and republishes its card', async (t) => { + const receiver = fakeReceiver({ mode: 'adsb', connected: true }); + const clock = { now: 100_000 }; + const harness = clickHarness(); + const { layer, calls } = await enabledLayer(receiver, clock, null, { + viewer: harness.viewer, + createInputHandler: harness.createInputHandler, + }); + t.after(() => layer.destroy()); + receiver.set({ aircraft: [record()] }); + await layer.update(); + const entity = + harness.viewer.sources[0].entities.getById('local-adsb:abc123'); + harness.scene.pickResult = { id: entity, primitive: {} }; + harness.click(); + assert.deepEqual(calls.selected, ['local-adsb:abc123']); + // Its card may have been displaced (another readout, a camera move): a + // second click on the same marker must show it again, never deselect it. + harness.click(); + assert.deepEqual(calls.selected, ['local-adsb:abc123', 'local-adsb:abc123']); + assert.deepEqual(calls.cleared, []); +}); + +test('a click on a local aircraft under an unowned or trail pick still selects it', async (t) => { + const receiver = fakeReceiver({ mode: 'adsb', connected: true }); + const clock = { now: 100_000 }; + const harness = clickHarness(); + const owners = new Map(); + const { resolvePickId } = await import('../../data/pickRegistry.js'); + const { layer, calls } = await enabledLayer(receiver, clock, null, { + viewer: harness.viewer, + createInputHandler: harness.createInputHandler, + services: { + picking: { + registerPickOwner: (id, predicate) => owners.set(id, predicate), + unregisterPickOwner: (id) => owners.delete(id), + resolvePickId, + isOwnedByOtherLayer: (layerId, pickedId) => + [...owners].some( + ([owner, predicate]) => owner !== layerId && predicate(pickedId), + ), + }, + }, + }); + owners.set('trails', (id) => String(id).startsWith('gev-trail:')); + owners.set('flights', (id) => id === 'abc123'); + t.after(() => layer.destroy()); + receiver.set({ + aircraft: [record(), record({ icao: 'def456', lat: 0.01 })], + }); + await layer.update(); + const [first, second] = ['abc123', 'def456'].map((icao) => + harness.viewer.sources[0].entities.getById(`local-adsb:${icao}`), + ); + // A photoreal tile (no pick id) drawn over the 3D model at this pixel. + harness.scene.pickResult = { primitive: { tileset: true } }; + harness.scene.drillResult = [ + harness.scene.pickResult, + { primitive: { id: 'local-adsb:abc123' }, id: 'local-adsb:abc123' }, + ]; + harness.click(); + assert.deepEqual(calls.selected, ['local-adsb:abc123']); + + // The selected aircraft's trail crosses another local aircraft's marker. + harness.scene.pickResult = { id: { id: 'gev-trail:local-adsb-head-1' } }; + harness.scene.drillResult = [ + harness.scene.pickResult, + { id: second, primitive: {} }, + ]; + harness.click(); + assert.deepEqual(calls.selected, ['local-adsb:abc123', 'local-adsb:def456']); + + // Another layer's own contact on top is that layer's click. + harness.scene.pickResult = { id: 'abc123', primitive: {} }; + harness.scene.drillResult = [ + harness.scene.pickResult, + { id: first, primitive: {} }, + ]; + harness.click(); + assert.equal(calls.selected.length, 2, 'the Flights contact is not taken'); +}); diff --git a/src/layers/localAdsb/models.js b/src/layers/localAdsb/models.js new file mode 100644 index 000000000..50cebe07e --- /dev/null +++ b/src/layers/localAdsb/models.js @@ -0,0 +1,282 @@ +import * as Cesium from 'cesium'; +import { horizonOccluder } from '../../data/iconOrientation.js'; +import { selectModelEligible } from '../../data/modelEligibility.js'; +import { civilAircraftModelSpec } from '../flights/modelSpec.js'; +import { + FLEET_DR_INTERVAL_MS, + MODEL_ALL_ADD_M, + MODEL_ALL_KEEP_M, + MODEL_ALT_CEIL_M, + MODEL_HEADING_OFFSET_DEG, + MODEL_MAX, + MODEL_MAX_ALL, + MODEL_MIN_PX, + MODEL_PROX_ADD_M, + MODEL_PROX_KEEP_M, +} from '../flights/policy.js'; + +const specKey = (spec) => `${spec.url}@${spec.scale}`; + +/** + * 3D models for Local ADS-B aircraft under the DISPLAY rail's 3D toggle. + * + * Mirrors the public Flights fleet: models only below the altitude ceiling, + * the same proximity/all caps and add/keep radii, the same visible-first + * eligibility (`selectModelEligible`), the same per-class asset and scale + * (`civilAircraftModelSpec`) and the same tint path (a MIX colour blend, here + * magenta, as Flights tints military amber). Each model owns its matrix. A + * billboard stays the visual until its model is loaded AND placed; a grounded + * aircraft's model rides the one-shot ground snap plus the class belly + * offset, and without a resolved ground it stays a billboard. + * @param {object} options + * @param {object} options.viewer Cesium viewer. + * @param {Cesium.Color} options.color Model tint. + * @param {(url: string) => string} options.resolveAsset + * @param {object|null} [options.groundSnap] `createGroundSnap()` instance. + * @param {(options: object) => Promise} [options.loadModel] + * @returns {object} + */ +export function createLocalAdsbModels({ + viewer, + color, + resolveAsset, + groundSnap = null, + loadModel = (options) => Cesium.Model.fromGltfAsync(options), +}) { + const collection = new Cesium.PrimitiveCollection(); + viewer.scene.primitives.add(collection); + /** @type {Map} id -> admitted model */ + const models = new Map(); + /** @type {Map} id -> generation of an in-flight load */ + const pending = new Map(); + let generation = 0; + let destroyed = false; + let lastEligibilityAt = 0; + let eligible = new Set(); + const scratchHpr = new Cesium.HeadingPitchRoll(); + const scratchSphere = new Cesium.BoundingSphere(Cesium.Cartesian3.ZERO, 1); + const scratchCarto = new Cesium.Cartographic(); + const scratchGround = new Cesium.Cartesian3(); + + function release(id) { + pending.delete(id); + const model = models.get(id); + if (!model) return; + models.delete(id); + if (!collection.isDestroyed()) collection.remove(model); + groundSnap?.forget?.(id); + } + + function releaseAll() { + for (const id of [...models.keys()]) release(id); + pending.clear(); + generation += 1; + } + + async function ensure(id, marker, cap) { + const spec = civilAircraftModelSpec(marker.klass); + const key = specKey(spec); + const current = models.get(id); + if (current && current._gevSpecKey === key) return; + if (current) release(id); + if (pending.has(id) || models.size + pending.size >= cap) return; + const loadGeneration = generation; + pending.set(id, loadGeneration); + let model; + try { + model = await loadModel({ + url: resolveAsset(spec.url), + asynchronous: false, + minimumPixelSize: MODEL_MIN_PX, + scale: spec.scale, + color, + colorBlendMode: Cesium.ColorBlendMode.MIX, + colorBlendAmount: spec.blendAmount, + id, + }); + } catch { + if (pending.get(id) === loadGeneration) pending.delete(id); + return; + } + const stale = + destroyed || + pending.get(id) !== loadGeneration || + generation !== loadGeneration || + !eligible.has(id) || + models.has(id) || + specKey(civilAircraftModelSpec(marker.klass)) !== key; + if (pending.get(id) === loadGeneration) pending.delete(id); + if (stale) { + model.destroy?.(); + return; + } + model.id = id; + model._gevSpecKey = key; + model._gevBellyM = spec.bellyM; + // Admitted, not yet the visual: placement turns it on. + model.show = false; + collection.add(model); + models.set(id, model); + } + + function regime(preferences) { + if (!preferences?.models3d) return false; + const height = viewer.camera?.positionCartographic?.height ?? Infinity; + return height < MODEL_ALT_CEIL_M; + } + + function refreshEligibility(markers, preferences) { + const all = preferences.models3dMode === 'all'; + const cap = all ? MODEL_MAX_ALL : MODEL_MAX; + const addM = all ? MODEL_ALL_ADD_M : MODEL_PROX_ADD_M; + const keepM = all ? MODEL_ALL_KEEP_M : MODEL_PROX_KEEP_M; + const camera = viewer.camera; + const cameraPosition = camera.positionWC; + const cull = camera.frustum.computeCullingVolume( + cameraPosition, + camera.directionWC, + camera.upWC, + ); + const candidates = []; + for (const [id, marker] of markers) { + if (!marker.position) continue; + const distanceSq = Cesium.Cartesian3.distanceSquared( + cameraPosition, + marker.position, + ); + if (distanceSq > keepM * keepM) continue; + Cesium.Cartesian3.clone(marker.position, scratchSphere.center); + candidates.push([ + id, + distanceSq, + cull.computeVisibility(scratchSphere) !== Cesium.Intersect.OUTSIDE, + ]); + } + candidates.sort((a, b) => a[1] - b[1]); + eligible = selectModelEligible(candidates, { + cap, + addDistSq: addM * addM, + isModeled: (id) => models.has(id), + }); + for (const id of [...models.keys(), ...pending.keys()]) + if (!eligible.has(id)) release(id); + for (const id of eligible) void ensure(id, markers.get(id), cap); + } + + function placement(id, marker) { + if (!marker.onGround) return marker.position; + const model = models.get(id); + // The vertical sample must hit the tile skin, not this aircraft's own + // billboard or model. + const height = groundSnap?.heightFor?.(viewer, id, marker.position, () => + [marker.entity, model].filter(Boolean), + ); + if (!Number.isFinite(height)) return null; + const carto = Cesium.Cartographic.fromCartesian( + marker.position, + Cesium.Ellipsoid.WGS84, + scratchCarto, + ); + return Cesium.Cartesian3.fromRadians( + carto.longitude, + carto.latitude, + height + (model?._gevBellyM || 0), + Cesium.Ellipsoid.WGS84, + scratchGround, + ); + } + + return { + /** Ids whose model admission is current; the layer enriches these. */ + get eligible() { + return eligible; + }, + + /** + * Per-frame pass: re-derive eligibility at the fleet cadence, then place + * every admitted model and hand each marker's visual to its model only + * once the model is ready and placed. + * @param {Map} markers Layer markers (position, courseDeg, + * klass, onGround); `modelOwnsVisual` is written back. + * @param {object} preferences `{ models3d, models3dMode }`. + * @param {number} nowMs + */ + frame(markers, preferences, nowMs) { + if (destroyed) return; + if (!regime(preferences)) { + if (models.size || pending.size) releaseAll(); + eligible = new Set(); + for (const marker of markers.values()) marker.modelOwnsVisual = false; + return; + } + if (nowMs - lastEligibilityAt >= FLEET_DR_INTERVAL_MS) { + lastEligibilityAt = nowMs; + refreshEligibility(markers, preferences); + } + const occluder = horizonOccluder(viewer.camera); + for (const [id, marker] of markers) { + const model = models.get(id); + marker.modelOwnsVisual = false; + if (!model || !marker.position) continue; + const position = placement(id, marker); + if (!position || !occluder.isPointVisible(marker.position)) { + model.show = false; + continue; + } + scratchHpr.heading = Cesium.Math.toRadians( + (marker.courseDeg || 0) + MODEL_HEADING_OFFSET_DEG, + ); + scratchHpr.pitch = 0; + scratchHpr.roll = 0; + Cesium.Transforms.headingPitchRollToFixedFrame( + position, + scratchHpr, + Cesium.Ellipsoid.WGS84, + undefined, + model.modelMatrix, + ); + if (!model.ready) { + model.show = false; + continue; + } + model.show = true; + marker.modelOwnsVisual = true; + } + for (const id of [...models.keys()]) if (!markers.has(id)) release(id); + }, + + /** + * Drop the model, and any load still in flight, of every aircraft that + * is no longer a marker. The layer calls this whenever markers expire: + * the per-frame pass does not run once the last marker is gone. + * @param {Map|Set} live Current marker ids. + */ + retain(live) { + if (destroyed) return; + let dropped = false; + for (const id of [...models.keys(), ...pending.keys()]) { + if (live.has(id)) continue; + release(id); + eligible.delete(id); + dropped = true; + } + if (dropped) eligible = new Set(eligible); + }, + + /** Drop every model (3D off, layer disabled). */ + clear: releaseAll, + + /** Number of admitted models (tests and diagnostics). */ + get size() { + return models.size; + }, + + destroy() { + destroyed = true; + releaseAll(); + groundSnap?.clear?.(); + if (!viewer.isDestroyed?.() && !collection.isDestroyed()) + viewer.scene.primitives.remove(collection); + }, + }; +} diff --git a/src/layers/localAdsb/motion.js b/src/layers/localAdsb/motion.js new file mode 100644 index 000000000..279bef6d3 --- /dev/null +++ b/src/layers/localAdsb/motion.js @@ -0,0 +1,427 @@ +import * as Cesium from 'cesium'; +import { + COURSE_HOLD_SPEED_MPS, + TURN_MIN_SPEED_MPS, + courseBetweenCartesians, + courseSlewCapDps, + estimateTurnRateDps, + lerpAngleDeg, + limitCourseStep, + norm360, + projectGroundArcLatLon, + speedRamp, + staleCoastLimitSeconds, +} from '../../data/motionModel.js'; +import { + LOCAL_ADSB_REANCHOR_AFTER, + localAdsbFixIsPlausible, +} from '../../sources/adsbRecords.js'; +import { + COURSE_MAX_DPS, + COURSE_SLEW_DT_MAX_SEC, + DR_CORRECTION_MS, +} from '../flights/policy.js'; + +/** Heard positions kept per aircraft for its trail: 10 minutes, 600 fixes. */ +export const LOCAL_ADSB_HISTORY_MS = 10 * 60_000; +export const LOCAL_ADSB_HISTORY_POINTS = 600; +/** A display correction larger than this snaps instead of sliding. */ +export const LOCAL_ADSB_SNAP_M = 3_000; +/** Chord course uses the newest fix at least this much older than the anchor. */ +const CHORD_BASELINE_MS = 2_000; +/** Turn rate is estimated over this window of heard fixes. */ +const TURN_WINDOW_MS = 30_000; +/** Coast at least this long past a fix; longer while messages keep coming. */ +const COAST_MINIMUM_SEC = 10; +const COAST_CONTACT_GRACE_SEC = 10; +const COAST_MAXIMUM_SEC = 60; +const KT_TO_MPS = 1852 / 3600; +const EARTH_RADIUS_M = 6_371_008.8; +const DEG = Math.PI / 180; +const _chordFrom = new Cesium.Cartesian3(); +const _chordTo = new Cesium.Cartesian3(); + +function finite(value) { + return Number.isFinite(value) ? value : null; +} + +/** + * When a record's telemetry (altitude, speed, track, climb) was last heard: + * its newest message, which velocity-only and altitude-only messages advance + * without a new position. + */ +function telemetryTime(record) { + const messageAt = finite(record?.lastMessageAt); + const positionAt = finite(record?.lastPositionAt); + if (messageAt === null) return positionAt; + return positionAt === null ? messageAt : Math.max(messageAt, positionAt); +} + +function chordCourse(from, to) { + Cesium.Cartesian3.fromDegrees(from.lon, from.lat, 0, undefined, _chordFrom); + Cesium.Cartesian3.fromDegrees(to.lon, to.lat, 0, undefined, _chordTo); + return courseBetweenCartesians(_chordFrom, _chordTo); +} + +/** + * Real-time display motion for one locally heard aircraft. + * + * Unlike the public flight layers there is no deliberate one-poll display + * delay: local fixes arrive about once a second, so the marker is + * extrapolated FORWARD from the newest fix with the shared motion model + * (constant-rate-turn arc, path-derived course blended over the reported + * track by speed, rate-limited slew) and a new fix is absorbed by a + * correction offset that decays over the same window the tracked public + * flight uses, so the marker never snaps. Every fix also passes the same + * speed check the decoder applies, which covers decoder-feed records too. + * + * Positions are geodetic (degrees, barometric feet); callers add the render + * datum. The heard history doubles as the selected aircraft's trail. + */ +export class LocalAdsbMotion { + constructor() { + /** @type {Array<{at:number, lat:number, lon:number, altitudeFt:number|null, + * trackDeg:number|null, speedMps:number|null}>} */ + this.fixes = []; + this.anchor = null; + this.correction = null; + this.courseDeg = null; + this.lastCourseAt = null; + this.lastContactAt = null; + this.rejectStreak = 0; + this.rejectedFixes = 0; + // The newest position evaluated, accepted or refused. Re-reading it (the + // layer re-syncs the same record several times a second) is not a new + // observation and must not count toward a re-anchor. + this.lastCandidate = null; + // When the anchor's telemetry was heard. Telemetry has its own freshness: + // position freshness only decides trail points and re-anchoring. + this.telemetryAt = null; + } + + /** + * Take a record's newest position. Returns true when it became the new + * anchor; a repeated, out-of-order or implausible fix returns false. + * @param {object} record Local ADS-B record. + * @param {number} nowMs Current epoch ms (display clock). + * @returns {boolean} + */ + observe(record, nowMs) { + if (Number.isFinite(record?.lastMessageAt)) + this.lastContactAt = Math.max( + this.lastContactAt ?? -Infinity, + record.lastMessageAt, + ); + const at = finite(record?.lastPositionAt); + const lat = finite(record?.lat); + const lon = finite(record?.lon); + if (at === null || lat === null || lon === null) return false; + const newest = this.fixes.at(-1); + const seen = this.lastCandidate; + // No new position, but velocity and altitude messages arrive between + // position messages: newer telemetry still reaches the anchor. + if ( + (newest && at <= newest.at) || + (seen && at <= seen.at) || + // A decoder feed re-reads the same fix every poll and its rebased time + // jitters with `seen_pos` rounding: the same position is not a new fix. + (seen && !seen.accepted && lat === seen.lat && lon === seen.lon) + ) { + this._refreshTelemetry(record, nowMs); + return false; + } + if (newest && lat === newest.lat && lon === newest.lon) { + // Same coordinates, but altitude and velocity may still be newer (a + // hovering helicopter climbing, an aircraft coming to a stop): update + // the motion telemetry without adding a trail point. + this.lastCandidate = { at, lat, lon, accepted: true }; + this.rejectStreak = 0; + this._refreshTelemetry(record, nowMs); + return false; + } + const speedMps = Number.isFinite(record.groundSpeedKt) + ? Math.max(0, record.groundSpeedKt) * KT_TO_MPS + : (newest?.speedMps ?? null); + const fix = { + at, + lat, + lon, + altitudeFt: finite(record.altitudeFt), + trackDeg: Number.isFinite(record.trackDeg) + ? norm360(record.trackDeg) + : null, + speedMps, + }; + if ( + newest && + !localAdsbFixIsPlausible(newest, fix, { + // The last known speed stands in while a record carries none. + groundSpeedKt: Number.isFinite(speedMps) ? speedMps / KT_TO_MPS : null, + category: record.category, + }) + ) { + this.rejectedFixes += 1; + this.rejectStreak += 1; + this.lastCandidate = { at, lat, lon, accepted: false }; + if (this.rejectStreak < LOCAL_ADSB_REANCHOR_AFTER) { + // The position is refused, not the record's telemetry. + this._refreshTelemetry(record, nowMs); + return false; + } + // Three refusals in a row: the stream moved on and the old anchor was + // the outlier. Restart the history from the new fix. + this.fixes = []; + this.correction = null; + } + this.rejectStreak = 0; + this.lastCandidate = { at, lat, lon, accepted: true }; + + const prior = this.anchor; + const before = prior ? this.displayAt(nowMs, { slew: false }) : null; + this.fixes.push(fix); + this._trimHistory(at); + // Position and telemetry are accepted independently: merged receivers + // can deliver a newer position in a record whose last message is OLDER + // than the telemetry the anchor already holds. The trail point keeps the + // record's values as heard; the anchor keeps the newer telemetry. + const recordTelemetryAt = telemetryTime(record); + const keepTelemetry = + prior && + this.telemetryAt !== null && + (recordTelemetryAt === null || recordTelemetryAt < this.telemetryAt); + if (keepTelemetry) { + this.anchor = { + ...this._anchorFrom( + { + ...fix, + altitudeFt: prior.altitudeFt, + speedMps: prior.speedMps, + trackDeg: prior.trackDeg, + }, + { verticalRateFpm: prior.verticalRateFpm }, + ), + altitudeAt: Number.isFinite(prior.altitudeAt) + ? prior.altitudeAt + : prior.at, + }; + } else { + this.anchor = this._anchorFrom(fix, record); + this.telemetryAt = recordTelemetryAt; + } + this._absorb(before, nowMs); + return true; + } + + /** + * Apply a record's altitude, speed, track and vertical rate to the current + * anchor when they were heard after the anchor's telemetry, whether or not + * its position is new. + */ + _refreshTelemetry(record, nowMs) { + const anchor = this.anchor; + const newest = this.fixes.at(-1); + if (!anchor || !newest) return; + const at = telemetryTime(record); + if (at === null || (this.telemetryAt !== null && at <= this.telemetryAt)) + return; + this.telemetryAt = at; + const altitudeFt = finite(record.altitudeFt) ?? anchor.altitudeFt; + const speedMps = Number.isFinite(record.groundSpeedKt) + ? Math.max(0, record.groundSpeedKt) * KT_TO_MPS + : anchor.speedMps; + const trackDeg = Number.isFinite(record.trackDeg) + ? norm360(record.trackDeg) + : anchor.trackDeg; + const verticalRateFpm = finite(record.verticalRateFpm); + if ( + altitudeFt === anchor.altitudeFt && + speedMps === anchor.speedMps && + trackDeg === anchor.trackDeg && + verticalRateFpm === anchor.verticalRateFpm + ) + return; + const before = this.displayAt(nowMs, { slew: false }); + // The trail point keeps its heard position; the anchor takes the newer + // telemetry, with altitude extrapolated from when it was reported. + this.anchor = { + ...this._anchorFrom( + { ...newest, altitudeFt, speedMps, trackDeg }, + { verticalRateFpm }, + ), + altitudeAt: at, + }; + this._absorb(before, nowMs); + } + + /** Slide from where the marker was drawn to the new anchor, or snap. */ + _absorb(before, nowMs) { + if (before) { + const after = this._raw(nowMs); + const cosLat = Math.cos(after.lat * DEG); + const north = (before.lat - after.lat) * DEG * EARTH_RADIUS_M; + const east = (before.lon - after.lon) * DEG * EARTH_RADIUS_M * cosLat; + const up = + Number.isFinite(before.altitudeFt) && Number.isFinite(after.altitudeFt) + ? before.altitudeFt - after.altitudeFt + : 0; + this.correction = + Math.hypot(north, east) > LOCAL_ADSB_SNAP_M + ? null + : { east, north, upFt: up, startAt: nowMs }; + } + } + + _trimHistory(newestAt) { + let drop = 0; + while ( + drop < this.fixes.length - 1 && + (newestAt - this.fixes[drop].at > LOCAL_ADSB_HISTORY_MS || + this.fixes.length - drop > LOCAL_ADSB_HISTORY_POINTS) + ) + drop += 1; + if (drop) this.fixes.splice(0, drop); + } + + _anchorFrom(fix, record) { + let chord = null; + for (let index = this.fixes.length - 2; index >= 0; index -= 1) { + if (fix.at - this.fixes[index].at < CHORD_BASELINE_MS) continue; + chord = chordCourse(this.fixes[index], fix); + break; + } + let courseDeg = fix.trackDeg; + if (chord !== null) + courseDeg = + courseDeg === null + ? chord + : lerpAngleDeg(courseDeg, chord, speedRamp(fix.speedMps)); + const samples = []; + let lastSampleAt = -Infinity; + for (const sample of this.fixes) { + if (fix.at - sample.at > TURN_WINDOW_MS) continue; + if (sample.at - lastSampleAt < CHORD_BASELINE_MS) continue; + lastSampleAt = sample.at; + samples.push({ + tSec: sample.at / 1000, + trackDeg: sample.trackDeg, + speedMps: sample.speedMps, + }); + } + return { + ...fix, + courseDeg, + turnRateDps: estimateTurnRateDps( + samples, + undefined, + undefined, + TURN_MIN_SPEED_MPS, + ), + verticalRateFpm: finite(record.verticalRateFpm), + }; + } + + _coastSeconds(nowMs) { + const anchor = this.anchor; + const limit = staleCoastLimitSeconds({ + fixEpochMs: anchor.at, + lastContactEpochMs: this.lastContactAt, + minimumSec: COAST_MINIMUM_SEC, + contactGraceSec: COAST_CONTACT_GRACE_SEC, + maximumSec: COAST_MAXIMUM_SEC, + }); + return Math.min(limit, Math.max(0, (nowMs - anchor.at) / 1000)); + } + + _raw(nowMs) { + const anchor = this.anchor; + const dtSec = this._coastSeconds(nowMs); + const moving = + Number.isFinite(anchor.speedMps) && + anchor.speedMps >= COURSE_HOLD_SPEED_MPS && + Number.isFinite(anchor.courseDeg); + const projected = moving + ? projectGroundArcLatLon( + anchor.lat, + anchor.lon, + anchor.courseDeg, + anchor.speedMps, + anchor.turnRateDps, + dtSec, + ) + : { lat: anchor.lat, lon: anchor.lon }; + const altitudeDtSec = Number.isFinite(anchor.altitudeAt) + ? Math.min(dtSec, Math.max(0, (nowMs - anchor.altitudeAt) / 1000)) + : dtSec; + const altitudeFt = + Number.isFinite(anchor.altitudeFt) && + Number.isFinite(anchor.verticalRateFpm) + ? anchor.altitudeFt + (anchor.verticalRateFpm * altitudeDtSec) / 60 + : anchor.altitudeFt; + return { + lat: projected.lat, + lon: projected.lon, + altitudeFt, + courseDeg: moving + ? norm360(anchor.courseDeg + anchor.turnRateDps * dtSec) + : null, + speedMps: anchor.speedMps, + }; + } + + /** + * Where to draw the aircraft now. + * @param {number} nowMs Current epoch ms. + * @param {{slew?: boolean}} [options] `slew: false` reads without advancing + * the rate-limited course. + * @returns {{lat:number, lon:number, altitudeFt:number|null, + * courseDeg:number|null}|null} + */ + displayAt(nowMs, { slew = true } = {}) { + if (!this.anchor) return null; + const raw = this._raw(nowMs); + const correction = this.correction; + if (correction) { + const weight = 1 - (nowMs - correction.startAt) / DR_CORRECTION_MS; + if (weight <= 0) this.correction = null; + else { + const w = Math.min(1, weight); + raw.lat += (correction.north * w) / EARTH_RADIUS_M / DEG; + raw.lon += + (correction.east * w) / + (EARTH_RADIUS_M * Math.max(1e-6, Math.cos(raw.lat * DEG))) / + DEG; + if (Number.isFinite(raw.altitudeFt)) + raw.altitudeFt += correction.upFt * w; + } + } + let courseDeg = this.courseDeg; + // Hover and taxi noise: below the hold speed the course is not updated. + const target = + raw.courseDeg ?? (courseDeg === null ? this.anchor.trackDeg : null); + if (slew && target !== null) { + const dtSec = + this.lastCourseAt === null + ? 0 + : Math.min( + COURSE_SLEW_DT_MAX_SEC, + Math.max(0, (nowMs - this.lastCourseAt) / 1000), + ); + courseDeg = limitCourseStep( + courseDeg, + target, + courseSlewCapDps(raw.speedMps, COURSE_MAX_DPS), + dtSec, + ); + this.courseDeg = courseDeg; + this.lastCourseAt = nowMs; + } else if (courseDeg === null && target !== null) { + courseDeg = target; + } + return { + lat: raw.lat, + lon: raw.lon, + altitudeFt: raw.altitudeFt, + courseDeg, + }; + } +} diff --git a/src/layers/localAdsb/motion.test.mjs b/src/layers/localAdsb/motion.test.mjs new file mode 100644 index 000000000..d4096db8c --- /dev/null +++ b/src/layers/localAdsb/motion.test.mjs @@ -0,0 +1,209 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + LOCAL_ADSB_HISTORY_MS, + LOCAL_ADSB_HISTORY_POINTS, + LocalAdsbMotion, +} from './motion.js'; + +function fix(at, lat, overrides = {}) { + return { + icao: 'abc123', + lat, + lon: 0, + altitudeFt: 5_000, + groundSpeedKt: 120, + trackDeg: 0, + verticalRateFpm: 0, + lastPositionAt: at, + lastMessageAt: at, + ...overrides, + }; +} + +// 120 kt north: degrees of latitude per second. +const STEP = (120 * 1852) / 3600 / 111_195; + +test('the heard history is bounded to 600 fixes and 10 minutes', () => { + const motion = new LocalAdsbMotion(); + for (let second = 0; second < 700; second += 1) + motion.observe(fix(second * 1_000, second * STEP), second * 1_000); + assert.equal(motion.fixes.length, LOCAL_ADSB_HISTORY_POINTS); + assert.equal(motion.fixes[0].at, 100_000); + + const sparse = new LocalAdsbMotion(); + for (let minute = 0; minute < 15; minute += 1) { + const at = minute * 60_000; + sparse.observe(fix(at, minute * 60 * STEP), at); + } + assert.ok( + sparse.fixes.at(-1).at - sparse.fixes[0].at <= LOCAL_ADSB_HISTORY_MS, + ); + assert.equal(sparse.fixes.length, 11); +}); + +test('repeated and out-of-order fixes do not move the anchor', () => { + const motion = new LocalAdsbMotion(); + assert.equal(motion.observe(fix(10_000, 0), 10_000), true); + assert.equal(motion.observe(fix(10_000, 0), 10_500), false); + assert.equal(motion.observe(fix(9_000, 0), 10_500), false); + // A feed re-reading the same fix with a jittered, later time. + assert.equal(motion.observe(fix(10_080, 0), 11_000), false); + assert.equal(motion.fixes.length, 1); +}); + +test('coasting stops 10 s after the last message', () => { + const motion = new LocalAdsbMotion(); + motion.observe(fix(0, 0), 0); + const coasted = motion.displayAt(30_000).lat; + assert.ok(Math.abs(coasted - 10 * STEP) < 1e-5, `${coasted}`); +}); + +test('three refused fixes in a row restart the track from the new stream', () => { + const motion = new LocalAdsbMotion(); + motion.observe(fix(0, 0), 0); + for (let index = 1; index <= 2; index += 1) + assert.equal( + motion.observe(fix(index * 1_000, 1 + index * STEP), index * 1_000), + false, + ); + assert.equal(motion.rejectedFixes, 2); + assert.equal(motion.observe(fix(3_000, 1 + 3 * STEP), 3_000), true); + assert.equal(motion.rejectedFixes, 3); + assert.equal( + motion.fixes.length, + 1, + 'the history restarts at the new stream', + ); + assert.ok(Math.abs(motion.displayAt(3_000).lat - (1 + 3 * STEP)) < 1e-9); +}); + +test('the last known speed bounds a fix whose record carries none', () => { + const motion = new LocalAdsbMotion(); + motion.observe(fix(0, 0), 0); + // 1.2 nm in 10 s (432 kt): under the 1,000 kt unknown-speed cap but far + // beyond the 120 kt the aircraft last reported. + assert.equal( + motion.observe(fix(10_000, 1.2 / 60, { groundSpeedKt: null }), 10_000), + false, + ); + assert.equal(motion.rejectedFixes, 1); +}); + +test('re-observing one refused fix never re-anchors on it', () => { + const motion = new LocalAdsbMotion(); + motion.observe(fix(0, 0), 0); + const outlier = fix(1_000, 1); + // The layer re-syncs the same record several times a second. + for (let sync = 0; sync < 6; sync += 1) + assert.equal(motion.observe(outlier, 1_000 + sync * 250), false); + assert.equal(motion.rejectedFixes, 1, 'one fix is refused once'); + assert.equal(motion.rejectStreak, 1); + assert.ok(motion.displayAt(2_000).lat < 0.01, 'still on the old track'); + // A jittered re-read of the same refused position is the same fix too. + assert.equal(motion.observe(fix(1_080, 1), 2_500), false); + assert.equal(motion.rejectStreak, 1); +}); + +test('an unchanged position still delivers newer altitude and velocity', () => { + const motion = new LocalAdsbMotion(); + motion.observe(fix(0, 0, { altitudeFt: 1_000, groundSpeedKt: 120 }), 0); + // A hovering helicopter: same coordinates, climbing, now stationary. + motion.observe( + fix(5_000, 0, { + altitudeFt: 1_500, + groundSpeedKt: 0, + verticalRateFpm: 0, + trackDeg: 90, + }), + 5_000, + ); + assert.equal(motion.fixes.length, 1, 'no duplicate trail point'); + assert.equal(motion.anchor.speedMps, 0); + assert.equal(motion.anchor.trackDeg, 90); + // Past the correction window the display settles on the new telemetry. + const display = motion.displayAt(20_000); + assert.ok( + Math.abs(display.altitudeFt - 1_500) < 1e-6, + `${display.altitudeFt}`, + ); + assert.ok(Math.abs(display.lat) < 1e-9, 'a stopped aircraft does not coast'); +}); + +test('a delayed position never rolls back newer telemetry', () => { + const motion = new LocalAdsbMotion(); + const step100 = (100 * 1852) / 3600 / 111_195; + assert.equal( + motion.observe( + fix(10_000, 0, { altitudeFt: 1_000, groundSpeedKt: 100 }), + 10_000, + ), + true, + ); + // Velocity and altitude heard at 20 s, no new position. + motion.observe( + fix(10_000, 0, { + altitudeFt: 1_500, + groundSpeedKt: 0, + lastMessageAt: 20_000, + }), + 20_000, + ); + assert.equal(motion.anchor.speedMps, 0); + assert.equal(motion.telemetryAt, 20_000); + // Another receiver's record: a newer position (15 s) whose telemetry is + // older than what the anchor already holds. + const delayed = fix(15_000, 5 * step100, { + altitudeFt: 1_000, + groundSpeedKt: 100, + }); + assert.equal(motion.observe(delayed, 21_000), true, 'the position is new'); + assert.equal(motion.fixes.length, 2); + assert.equal(motion.anchor.lat, 5 * step100, 'the anchor moves'); + assert.equal(motion.anchor.speedMps, 0, 'telemetry stays 0 kt'); + assert.equal(motion.anchor.altitudeFt, 1_500, 'telemetry stays 1,500 ft'); + assert.equal(motion.telemetryAt, 20_000, 'telemetryAt never moves back'); + assert.equal(motion.fixes.at(-1).altitudeFt, 1_000, 'trail point as heard'); + const display = motion.displayAt(40_000); + assert.ok( + Math.abs(display.altitudeFt - 1_500) < 1e-6, + `${display.altitudeFt}`, + ); + assert.ok( + Math.abs(display.lat - 5 * step100) < 1e-9, + 'a stopped aircraft does not coast', + ); +}); + +test('a velocity/altitude-only update with the same position time still refreshes telemetry', () => { + const motion = new LocalAdsbMotion(); + motion.observe(fix(0, 0, { altitudeFt: 1_000, groundSpeedKt: 100 }), 0); + // No new position message: only velocity and altitude are newer. + const newer = fix(0, 0, { + altitudeFt: 1_500, + groundSpeedKt: 0, + trackDeg: 90, + lastMessageAt: 5_000, + }); + assert.equal(motion.observe(newer, 5_000), false, 'no new trail point'); + assert.equal(motion.fixes.length, 1); + assert.equal(motion.anchor.speedMps, 0); + assert.equal(motion.anchor.altitudeFt, 1_500); + assert.equal(motion.anchor.trackDeg, 90); + // Re-reading that record is not newer telemetry, and an older message + // never rolls it back. + motion.observe(newer, 5_500); + motion.observe( + fix(0, 0, { altitudeFt: 900, groundSpeedKt: 300, lastMessageAt: 4_000 }), + 6_000, + ); + assert.equal(motion.anchor.altitudeFt, 1_500); + assert.equal(motion.anchor.speedMps, 0); + const display = motion.displayAt(20_000); + assert.ok( + Math.abs(display.altitudeFt - 1_500) < 1e-6, + `${display.altitudeFt}`, + ); + assert.ok(Math.abs(display.lat) < 1e-9, 'a stopped aircraft does not coast'); +}); diff --git a/src/layers/localAdsb/policy.js b/src/layers/localAdsb/policy.js new file mode 100644 index 000000000..5e6ae7a9f --- /dev/null +++ b/src/layers/localAdsb/policy.js @@ -0,0 +1,21 @@ +export const LAYER_ID = 'local-adsb'; +export const LAYER_NAME = 'Local ADS-B'; +export const LAYER_SOURCE = 'RTL-SDR · WebUSB'; +/** Distinct from the public Flights palette so both can be drawn together. */ +export const LOCAL_ADSB_COLOR = '#ff4fd8'; +/** Ring drawn around 978 MHz UAT aircraft: same magenta family, lighter. */ +export const LOCAL_ADSB_UAT_RING_COLOR = '#ffb3ef'; +export const ENTITY_PREFIX = `${LAYER_ID}:`; +/** Re-evaluate marker freshness and the card's position age this often. */ +export const LOCAL_ADSB_TICK_MS = 1_000; +/** Coalesce receiver bursts into at most one scene sync per interval. */ +export const LOCAL_ADSB_SYNC_MS = 200; +export const HEARD_BY_RECEIVER = 'Heard by your receiver'; +export const BAND_LABELS = Object.freeze({ + 1090: '1090 MHz', + 978: '978 MHz UAT', +}); +export const SOURCE_LABELS = Object.freeze({ + webusb: 'browser SDR', + feed: 'decoder feed', +}); diff --git a/src/layers/localAdsb/status.js b/src/layers/localAdsb/status.js new file mode 100644 index 000000000..6d5c523e2 --- /dev/null +++ b/src/layers/localAdsb/status.js @@ -0,0 +1,141 @@ +import { localReceiverFeedName } from './feedNames.js'; +import { LAYER_SOURCE } from './policy.js'; + +/** + * Layers-panel status for Local ADS-B, from its two inputs: the browser + * WebUSB receiver and the server's decoder feeds. Pure; no DOM or Cesium. + */ + +export const FEED_SOURCE = 'Decoder feeds'; +export const COMBINED_SOURCE = 'WebUSB + decoder feeds'; + +function plural(count, one, many) { + return count === 1 ? one : many; +} + +/** + * Describe the feeds that are not live, grouped by status, e.g. + * "feed 978 unreachable" or "feeds 1090, 978 stale". + * @param {object[]} feeds Feed statuses from the route. + * @returns {string} + */ +export function describeFeedProblems(feeds) { + const byStatus = new Map(); + for (const feed of feeds) { + if (feed?.status === 'live') continue; + const status = feed?.status || 'unreachable'; + if (!byStatus.has(status)) byStatus.set(status, new Set()); + byStatus.get(status).add(localReceiverFeedName(feed, feeds)); + } + return [...byStatus] + .map(([status, names]) => { + const list = [...names]; + return `${plural(list.length, 'feed', 'feeds')} ${list.join(', ')} ${status}`; + }) + .join(' · '); +} + +/** Whether the server reported at least one configured feed. */ +export function feedsConfigured(feedState) { + return Boolean( + feedState?.configured === true && + Array.isArray(feedState.feeds) && + feedState.feeds.length, + ); +} + +function webUsbStatus(receiver, heard, feedState) { + if (receiver.status === 'error') + return { status: 'error', error: receiver.message }; + if (receiver.status === 'connecting' || receiver.status === 'tuning') + return { loading: true, loadingLabel: 'opening receiver' }; + if ( + feedState?.polling && + feedState.configured === null && + !receiver.connected + ) + return { loading: true, loadingLabel: 'checking decoder feeds' }; + if (!receiver.webUsbSupported) + return { + status: 'idle', + statusMessage: 'WebUSB needs desktop Chrome or Edge', + }; + if (!receiver.connected) + return { status: 'idle', statusMessage: 'connect a receiver in Radio' }; + if (receiver.mode !== 'adsb') + return { status: 'idle', statusMessage: 'receiver is in FM mode' }; + const rate = Number.isFinite(receiver.messagesPerSecond) + ? `${receiver.messagesPerSecond} msg/s` + : 'listening'; + return { status: 'streaming', loadingLabel: `${heard} heard · ${rate}` }; +} + +/** + * Status fields of `getStats()` for the Local ADS-B row. + * + * Without configured feeds this is exactly the WebUSB status. With feeds: + * "2 feeds live · 14 heard" when every feed is live. While any input is + * producing (the browser receiver streaming, or a feed live) the row stays + * nominal and names the other feeds as a trailing note, e.g. "3 heard · USB + * 5.8 msg/s · feed 1090 stale": a decoder stopped so the browser can take the + * dongle is not a fault. With nothing producing, every readable feed stale is + * STALE and anything else is an error. + * @param {object} options + * @param {object} options.receiver WebUSB receiver snapshot. + * @param {object|null} options.feedState Feed poller snapshot. + * @param {number} options.heard Merged aircraft heard in the last 60 s. + * @returns {object} status / statusMessage / loading / loadingLabel / error / + * stale / source. + */ +export function localAdsbStatus({ receiver, feedState, heard }) { + if (!feedsConfigured(feedState)) + return { + source: LAYER_SOURCE, + ...webUsbStatus(receiver, heard, feedState), + }; + const usbActive = Boolean(receiver.connected && receiver.mode === 'adsb'); + const source = usbActive ? COMBINED_SOURCE : FEED_SOURCE; + const live = feedState.feeds.filter((feed) => feed.status === 'live'); + const problems = [describeFeedProblems(feedState.feeds)].filter(Boolean); + if (usbActive && receiver.status === 'error') problems.push('USB error'); + const rate = + usbActive && + receiver.status !== 'error' && + Number.isFinite(receiver.messagesPerSecond) + ? ` · USB ${receiver.messagesPerSecond} msg/s` + : ''; + const heardText = `${heard} heard`; + if (!problems.length) { + return { + source, + status: 'streaming', + loadingLabel: `${live.length} ${plural(live.length, 'feed', 'feeds')} live · ${heardText}${rate}`, + }; + } + const problemText = problems.join(' · '); + const usbProducing = usbActive && receiver.status !== 'error'; + if (!live.length && !usbProducing) { + // Decoders that stopped but still serve their last aircraft.json are + // stale, not down: only an unreachable or unusable feed is an error. + const readable = feedState.feeds.filter( + (feed) => feed.status !== 'invalid', + ); + if (readable.length && readable.every((feed) => feed.status === 'stale')) + return { + source, + status: 'stale', + stale: true, + statusMessage: `${problemText} · ${heardText}`, + }; + return { + source, + status: 'error', + error: heard > 0 ? `${problemText} · ${heardText}` : problemText, + }; + } + return { + source, + status: 'streaming', + loadingLabel: `${heardText}${rate} · ${problemText}`, + }; +} diff --git a/src/layers/military/rendering.js b/src/layers/military/rendering.js index 79ba10b5b..6af91c792 100644 --- a/src/layers/military/rendering.js +++ b/src/layers/military/rendering.js @@ -8,6 +8,7 @@ import { trailAnchorForModel, } from '../../data/modelVisualAnchor.js'; import * as Cesium from 'cesium'; +import { selectModelEligible } from '../../data/modelEligibility.js'; import { cockpitContactDotImage } from '../../data/cockpitContactDot.js'; import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js'; import { @@ -892,25 +893,11 @@ export function createRendering({ ); flightState._lastModelCapWarnMs = nowMs; } - modelEligible = new Set(); - for (const [icao, , inF] of cand) { - if (modelEligible.size >= cap) break; - if (inF && flightState._models.has(icao)) modelEligible.add(icao); - } // 1. KEEP on-screen - for (const [icao, d2, inF] of cand) { - if (modelEligible.size >= cap) break; - if (inF && d2 <= addDistSq && !modelEligible.has(icao)) - modelEligible.add(icao); - } // 2. ADD on-screen - for (const [icao, , inF] of cand) { - if (modelEligible.size >= cap) break; - if (!inF && flightState._models.has(icao)) modelEligible.add(icao); - } // 3. KEEP off-screen (can't starve visible) - for (const [icao, d2, inF] of cand) { - if (modelEligible.size >= cap) break; - if (!inF && d2 <= addDistSq && !modelEligible.has(icao)) - modelEligible.add(icao); - } // 4. ADD off-screen leftover + modelEligible = selectModelEligible(cand, { + cap, + addDistSq, + isModeled: (icao) => flightState._models.has(icao), + }); const toRelease = []; for (const icao of flightState._models.keys()) { if (icao !== flightState._trackedIcao && !modelEligible.has(icao)) diff --git a/src/radioMarkup.test.mjs b/src/radioMarkup.test.mjs index c56c16762..be71997cb 100644 --- a/src/radioMarkup.test.mjs +++ b/src/radioMarkup.test.mjs @@ -183,21 +183,23 @@ test('no unchanged Realtime tool definition drifts silently', () => { 'analyst_query', 'next_iss_pass', 'next_satellite_pass', + // Local ADS-B adds one layer enum value and its common-name mapping. + 'set_layer_visibility', ]); const unchanged = realtimeTools() .filter((tool) => !TOUCHED.has(tool.name)) .sort((a, b) => a.name.localeCompare(b.name)); - assert.equal(unchanged.length, 19); + assert.equal(unchanged.length, 18); const digest = createHash('sha256') .update(JSON.stringify(unchanged)) .digest('hex') .slice(0, 16); // Analyst additions and ISS wording correction are explicitly excluded above; all other tool definitions retain their pin. - assert.equal(digest, '7411ea585e534795', 'an unchanged Realtime tool definition drifted'); + assert.equal(digest, '8494f344fabb67f5', 'an unchanged Realtime tool definition drifted'); }); test('Radio volume and mission speed share the Sharpen slider visual language', () => { - for (const id of ['cockpit-radio-volume', 'context-radio-mini-volume', 'radio-volume']) { + for (const id of ['cockpit-radio-volume', 'context-radio-mini-volume', 'radio-volume', 'sdr-volume']) { assert.match( html, new RegExp(`id="${id}"[^>]*class="gev-quantitative-slider"[^>]*type="range"`), diff --git a/src/sdr/adsbDecoder.js b/src/sdr/adsbDecoder.js new file mode 100644 index 000000000..f6172d354 --- /dev/null +++ b/src/sdr/adsbDecoder.js @@ -0,0 +1,594 @@ +import { + LOCAL_ADSB_POSITION_MARGIN_M, + LOCAL_ADSB_REANCHOR_AFTER, + LOCAL_ADSB_REFERENCE_MAX_AGE_MS, + localAdsbFixIsPlausible, + localAdsbSpeedLimitKt, +} from '../sources/adsbRecords.js'; + +const MODE_S_POLYNOMIAL = 0xfff409; +/** An aircraft with no CRC-valid message for this long is removed. */ +export const LOCAL_ADSB_STALE_MS = 60_000; +const CPR_PAIR_MAX_AGE_MS = 10_000; +/** Surface aircraft move slowly; an even/odd surface pair may be further apart. */ +const CPR_SURFACE_PAIR_MAX_AGE_MS = 25_000; +/** + * Half a surface CPR latitude zone (90° / 60 zones / 2 = 0.75°, 45 NM): a + * single surface frame decodes unambiguously only against a reference known + * to be closer than this to the aircraft. + */ +const SURFACE_REFERENCE_RANGE_M = 45 * 1_852; +const KT_TO_MPS = 1_852 / 3_600; +const CPR_SCALE = 131_072; +const CPR_NZ = 15; +const CALLSIGN_CHARSET = + '#ABCDEFGHIJKLMNOPQRSTUVWXYZ#####_###############0123456789######'; +const PREAMBLE_SAMPLES = 16; +const FRAME_BITS = 112; +const FRAME_SAMPLES = PREAMBLE_SAMPLES + FRAME_BITS * 2; +const STREAM_CARRY_BYTES = (FRAME_SAMPLES - 1) * 2; + +function bit(bytes, index) { + return (bytes[index >> 3] >> (7 - (index & 7))) & 1; +} + +function bits(bytes, start, length) { + let value = 0; + for (let index = 0; index < length; index += 1) { + value = value * 2 + bit(bytes, start + index); + } + return value; +} + +function positiveModulo(value, divisor) { + return ((value % divisor) + divisor) % divisor; +} + +function cprNl(latitude) { + const absolute = Math.abs(latitude); + if (absolute >= 87) return absolute === 87 ? 2 : 1; + const numerator = 1 - Math.cos(Math.PI / (2 * CPR_NZ)); + const denominator = Math.cos((Math.PI / 180) * absolute) ** 2; + return Math.floor((2 * Math.PI) / Math.acos(1 - numerator / denominator)); +} + +function normalizeLongitude(longitude) { + let normalized = longitude; + while (normalized > 180) normalized -= 360; + while (normalized < -180) normalized += 360; + return normalized; +} + +function validReference(reference) { + return ( + Boolean(reference) && + Number.isFinite(reference.latitude) && + Number.isFinite(reference.longitude) + ); +} + +/** The candidate `value + k * period` nearest `target`. */ +function nearestAlias(value, period, target) { + return value + period * Math.round((target - value) / period); +} + +/** + * Locally decode one CPR frame against a reference within half a zone. + * Surface frames use quarter-size zones (90° instead of 360°), so their + * reference must be within 45 NM of the aircraft: only the aircraft's own + * recent position qualifies (see `surfaceReference`). + */ +function decodeLocalCpr(frame, reference) { + if (!validReference(reference)) return null; + const span = frame.surface ? 90 : 360; + const parity = frame.odd ? 1 : 0; + const dLat = span / (4 * CPR_NZ - parity); + const latitudeIndex = + Math.floor(reference.latitude / dLat) + + Math.floor( + 0.5 + + positiveModulo(reference.latitude, dLat) / dLat - + frame.latitude / CPR_SCALE, + ); + let latitude = dLat * (latitudeIndex + frame.latitude / CPR_SCALE); + if (latitude >= 270) latitude -= 360; + if (latitude < -90 || latitude > 90) return null; + + const longitudeZones = Math.max(cprNl(latitude) - parity, 1); + const dLon = span / longitudeZones; + const longitudeIndex = + Math.floor(reference.longitude / dLon) + + Math.floor( + 0.5 + + positiveModulo(reference.longitude, dLon) / dLon - + frame.longitude / CPR_SCALE, + ); + const longitude = normalizeLongitude( + dLon * (longitudeIndex + frame.longitude / CPR_SCALE), + ); + return { latitude, longitude }; +} + +function decodeGlobalCpr(even, odd) { + if ( + !even || + !odd || + Math.abs(even.receivedAt - odd.receivedAt) > CPR_PAIR_MAX_AGE_MS + ) + return null; + const latitudeIndex = Math.floor( + (59 * even.latitude - 60 * odd.latitude) / CPR_SCALE + 0.5, + ); + let evenLatitude = + 6 * (positiveModulo(latitudeIndex, 60) + even.latitude / CPR_SCALE); + let oddLatitude = + (360 / 59) * (positiveModulo(latitudeIndex, 59) + odd.latitude / CPR_SCALE); + if (evenLatitude >= 270) evenLatitude -= 360; + if (oddLatitude >= 270) oddLatitude -= 360; + if (cprNl(evenLatitude) !== cprNl(oddLatitude)) return null; + + const useOdd = odd.receivedAt > even.receivedAt; + const latitude = useOdd ? oddLatitude : evenLatitude; + const longitudeZones = Math.max(cprNl(latitude) - (useOdd ? 1 : 0), 1); + const longitudeIndex = Math.floor( + (even.longitude * (cprNl(latitude) - 1) - odd.longitude * cprNl(latitude)) / + CPR_SCALE + + 0.5, + ); + const frame = useOdd ? odd : even; + const longitude = normalizeLongitude( + (360 / longitudeZones) * + (positiveModulo(longitudeIndex, longitudeZones) + + frame.longitude / CPR_SCALE), + ); + return { latitude, longitude }; +} + +/** + * Globally decode an even/odd pair of SURFACE position frames. Surface CPR + * encodes a quarter of the globe, so the pair leaves four candidate + * latitudes/longitudes 90° apart; the one nearest `reference` (the aircraft's + * last position or the receiver) is the answer, and without a reference + * there is no answer. + */ +function decodeGlobalSurfaceCpr(even, odd, reference) { + if ( + !even || + !odd || + !validReference(reference) || + Math.abs(even.receivedAt - odd.receivedAt) > CPR_SURFACE_PAIR_MAX_AGE_MS + ) + return null; + const latitudeIndex = Math.floor( + (59 * even.latitude - 60 * odd.latitude) / CPR_SCALE + 0.5, + ); + const evenLatitude = nearestAlias( + (90 / 60) * (positiveModulo(latitudeIndex, 60) + even.latitude / CPR_SCALE), + 90, + reference.latitude, + ); + const oddLatitude = nearestAlias( + (90 / 59) * (positiveModulo(latitudeIndex, 59) + odd.latitude / CPR_SCALE), + 90, + reference.latitude, + ); + if (Math.abs(evenLatitude) > 90 || Math.abs(oddLatitude) > 90) return null; + if (cprNl(evenLatitude) !== cprNl(oddLatitude)) return null; + + const useOdd = odd.receivedAt > even.receivedAt; + const latitude = useOdd ? oddLatitude : evenLatitude; + const nl = cprNl(latitude); + const longitudeZones = Math.max(nl - (useOdd ? 1 : 0), 1); + const longitudeIndex = Math.floor( + (even.longitude * (nl - 1) - odd.longitude * nl) / CPR_SCALE + 0.5, + ); + const frame = useOdd ? odd : even; + const longitude = normalizeLongitude( + nearestAlias( + (90 / longitudeZones) * + (positiveModulo(longitudeIndex, longitudeZones) + + frame.longitude / CPR_SCALE), + 90, + reference.longitude, + ), + ); + return { latitude, longitude }; +} + +/** + * Ground speed in knots from a surface message's 7-bit movement field + * (DO-260B §2.2.3.2.4.2): null for "no information" and reserved codes. + */ +function decodeSurfaceMovement(movement) { + if (movement === 0 || movement > 124) return null; + if (movement === 1) return 0; + if (movement === 124) return 175; + const bands = [ + [2, 0.125, 0.125], + [9, 1, 0.25], + [13, 2, 0.5], + [39, 15, 1], + [94, 70, 2], + [109, 100, 5], + ]; + let band = bands[0]; + for (const candidate of bands) if (movement >= candidate[0]) band = candidate; + const [firstCode, firstKt, stepKt] = band; + return firstKt + (movement - firstCode) * stepKt; +} + +function decodeAltitude(bytes) { + if (!bit(bytes, 47)) return null; + const encoded = (bits(bytes, 40, 7) << 4) | bits(bytes, 48, 4); + return encoded * 25 - 1_000; +} + +function decodeCallsign(bytes) { + let callsign = ''; + for (let index = 0; index < 8; index += 1) { + callsign += CALLSIGN_CHARSET[bits(bytes, 40 + index * 6, 6)] || ' '; + } + return callsign.replace(/[_#]/g, ' ').trim(); +} + +function decodeVelocity(bytes) { + const subtype = bits(bytes, 37, 3); + if (subtype !== 1 && subtype !== 2) return null; + const multiplier = subtype === 2 ? 4 : 1; + const eastWestRaw = bits(bytes, 46, 10); + const northSouthRaw = bits(bytes, 57, 10); + if (!eastWestRaw || !northSouthRaw) return null; + const eastWest = (eastWestRaw - 1) * multiplier * (bit(bytes, 45) ? -1 : 1); + const northSouth = + (northSouthRaw - 1) * multiplier * (bit(bytes, 56) ? -1 : 1); + const speedKt = Math.hypot(eastWest, northSouth); + const headingDeg = positiveModulo( + (Math.atan2(eastWest, northSouth) * 180) / Math.PI, + 360, + ); + const verticalRaw = bits(bytes, 69, 9); + const verticalRateFpm = verticalRaw + ? (verticalRaw - 1) * 64 * (bit(bytes, 68) ? -1 : 1) + : null; + return { speedKt, headingDeg, verticalRateFpm }; +} + +/** Compute the 24-bit Mode S parity remainder. Valid extended squitters return zero. */ +export function modeSChecksum(bytes) { + let remainder = 0; + for (let index = 0; index < bytes.length * 8; index += 1) { + const feedback = bit(bytes, index) ^ ((remainder >> 23) & 1); + remainder = (remainder << 1) & 0xffffff; + if (feedback) remainder ^= MODE_S_POLYNOMIAL; + } + return remainder; +} + +/** Decode one 112-bit DF17/DF18 extended-squitter message. */ +export function decodeAdsbMessage(bytes, { receivedAt = Date.now() } = {}) { + if (!(bytes instanceof Uint8Array) || bytes.length !== 14) return null; + const downlinkFormat = bits(bytes, 0, 5); + if ( + (downlinkFormat !== 17 && downlinkFormat !== 18) || + modeSChecksum(bytes) !== 0 + ) + return null; + const icao = [...bytes.slice(1, 4)] + .map((value) => value.toString(16).padStart(2, '0')) + .join('') + .toUpperCase(); + const typeCode = bits(bytes, 32, 5); + const message = { icao, typeCode, receivedAt }; + if (typeCode >= 1 && typeCode <= 4) { + message.callsign = decodeCallsign(bytes); + // dump1090 convention: category set A = TC 4, B = TC 3, C = TC 2, D = TC 1, + // followed by the 3-bit emitter category ('A7' rotorcraft, 'A1' light). + message.category = `${String.fromCharCode(69 - typeCode)}${bits(bytes, 37, 3)}`; + } else if (typeCode >= 5 && typeCode <= 8) { + // Surface position: movement, ground track when its status bit is set, + // and a quarter-globe CPR position; there is no altitude on the ground. + message.onGround = true; + message.altitudeFt = null; + const speedKt = decodeSurfaceMovement(bits(bytes, 37, 7)); + if (speedKt !== null) message.speedKt = speedKt; + if (bit(bytes, 44)) message.headingDeg = (bits(bytes, 45, 7) * 360) / 128; + message.cpr = { + surface: true, + odd: Boolean(bit(bytes, 53)), + latitude: bits(bytes, 54, 17), + longitude: bits(bytes, 71, 17), + receivedAt, + }; + } else if (typeCode >= 9 && typeCode <= 18) { + message.onGround = false; + message.altitudeFt = decodeAltitude(bytes); + message.cpr = { + odd: Boolean(bit(bytes, 53)), + latitude: bits(bytes, 54, 17), + longitude: bits(bytes, 71, 17), + receivedAt, + }; + } else if (typeCode === 19) { + Object.assign(message, decodeVelocity(bytes) || {}); + } + return message; +} + +function recentReference(track, receivedAt) { + if ( + !Number.isFinite(track.latitude) || + !Number.isFinite(track.longitude) || + !Number.isFinite(track.lastPositionAt) || + receivedAt - track.lastPositionAt > LOCAL_ADSB_REFERENCE_MAX_AGE_MS + ) + return null; + return { latitude: track.latitude, longitude: track.longitude }; +} + +/** + * The aircraft's own last position as a single-frame SURFACE reference, when + * it is established to be within 45 NM: recent, and the aircraft cannot have + * covered 45 NM since at its speed limit (the speed check's limit). The + * receiver location never qualifies, since how far away a heard aircraft is + * cannot be known from one frame. + */ +function surfaceReference(track, message) { + const reference = recentReference(track, message.receivedAt); + if (!reference) return null; + const groundSpeedKt = Number.isFinite(track.speedKt) + ? Math.max( + track.speedKt, + Number.isFinite(message.speedKt) ? message.speedKt : 0, + ) + : null; + const limitKt = localAdsbSpeedLimitKt({ + groundSpeedKt, + category: track.category, + }); + const reachM = + LOCAL_ADSB_POSITION_MARGIN_M + + ((message.receivedAt - track.lastPositionAt + 1_000) / 1_000) * + limitKt * + KT_TO_MPS; + return reachM < SURFACE_REFERENCE_RANGE_M ? reference : null; +} + +/** + * Merge one decoded message into a stable aircraft record. + * + * Airborne (TC 9–18) and surface (TC 5–8) positions are paired separately. + * A surface pair needs a reference to pick its quadrant (the aircraft's last + * position, else the receiver); surface messages also set `onGround` and + * clear the altitude, and the next airborne position clears `onGround`. + * + * A position comes from a fresh even/odd pair (global CPR), else from one + * frame decoded relative to the aircraft's own last accepted position when it + * is under 10 minutes old, else (airborne only) relative to the receiver + * location. A single surface frame needs the aircraft's own position within + * 45 NM, so a surface track is always seeded by a pair. Every + * candidate then passes the dump1090-style speed check against the last + * accepted fix; a failing fix is counted (`rejectedPositions` on the track and + * `positionsRejected` on the optional `stats`) and not applied. From the + * third consecutive rejection on, a globally decoded fix is accepted as the + * new reference, so one bad accepted fix cannot freeze a track. + * @param {Map} tracks Tracks keyed by uppercase ICAO. + * @param {object} message Output of `decodeAdsbMessage`. + * @param {{latitude:number, longitude:number}|null} [receiverLocation] + * @param {{positionsRejected?:number}} [stats] Mutable decoder counters. + * @returns {object|null} The updated track. + */ +export function updateAircraftTrack( + tracks, + message, + receiverLocation = null, + stats = null, +) { + if (!(tracks instanceof Map) || !message?.icao) return null; + const prior = tracks.get(message.icao) || { + icao: message.icao, + callsign: null, + category: null, + altitudeFt: null, + latitude: null, + longitude: null, + speedKt: null, + headingDeg: null, + verticalRateFpm: null, + messages: 0, + lastSeen: 0, + lastPositionAt: null, + onGround: false, + cprEven: null, + cprOdd: null, + cprSurfaceEven: null, + cprSurfaceOdd: null, + rejectedPositions: 0, + rejectStreak: 0, + }; + const next = { + ...prior, + messages: prior.messages + 1, + lastSeen: message.receivedAt, + }; + for (const key of [ + 'callsign', + 'category', + 'altitudeFt', + 'speedKt', + 'headingDeg', + 'verticalRateFpm', + ]) { + if (message[key] !== undefined && message[key] !== null) + next[key] = message[key]; + } + // A position message says where the aircraft is: on the surface (no + // altitude, no climb) or airborne again. + if (typeof message.onGround === 'boolean') { + next.onGround = message.onGround; + if (message.onGround) { + next.altitudeFt = null; + next.verticalRateFpm = null; + } + } + if (message.cpr) { + const surface = Boolean(message.cpr.surface); + // Airborne and surface frames use different zone sizes: never pair them. + if (surface) { + if (message.cpr.odd) next.cprSurfaceOdd = message.cpr; + else next.cprSurfaceEven = message.cpr; + } else if (message.cpr.odd) next.cprOdd = message.cpr; + else next.cprEven = message.cpr; + const reference = recentReference(prior, message.receivedAt); + const global = surface + ? decodeGlobalSurfaceCpr( + next.cprSurfaceEven, + next.cprSurfaceOdd, + reference || receiverLocation, + ) + : decodeGlobalCpr(next.cprEven, next.cprOdd); + const position = + global || + (surface + ? decodeLocalCpr(message.cpr, surfaceReference(prior, message)) + : decodeLocalCpr(message.cpr, reference) || + decodeLocalCpr(message.cpr, receiverLocation)); + if (position) { + const plausible = + !reference || + localAdsbFixIsPlausible( + { + lat: prior.latitude, + lon: prior.longitude, + at: prior.lastPositionAt, + }, + { + lat: position.latitude, + lon: position.longitude, + at: message.receivedAt, + }, + { groundSpeedKt: next.speedKt, category: next.category }, + ); + const reanchor = + !plausible && + Boolean(global) && + (prior.rejectStreak || 0) + 1 >= LOCAL_ADSB_REANCHOR_AFTER; + if (plausible || reanchor) { + Object.assign(next, position); + next.lastPositionAt = message.receivedAt; + next.rejectStreak = 0; + } else { + next.rejectedPositions = (prior.rejectedPositions || 0) + 1; + next.rejectStreak = (prior.rejectStreak || 0) + 1; + if (stats) stats.positionsRejected = (stats.positionsRejected || 0) + 1; + } + } + } + tracks.set(message.icao, next); + return next; +} + +/** Remove aircraft that have not transmitted recently. */ +export function pruneAircraftTracks( + tracks, + now = Date.now(), + ttlMs = LOCAL_ADSB_STALE_MS, +) { + if (!(tracks instanceof Map)) return 0; + let removed = 0; + for (const [icao, aircraft] of tracks) { + if (now - aircraft.lastSeen < ttlMs) continue; + tracks.delete(icao); + removed += 1; + } + return removed; +} + +function preambleContrast(power, offset) { + const pulseOffsets = [0, 2, 7, 9]; + // Only the six defined gaps between Mode S preamble pulses establish the + // local floor. Samples 10–15 can contain the trailing shape of the last RF + // pulse on a real 2 Msps receiver and treating them as quiet rejects frames + // that a clean synthetic fixture does not expose. + const quietOffsets = [1, 3, 4, 5, 6, 8]; + let pulseMean = 0; + let quietMean = 0; + for (const index of pulseOffsets) pulseMean += power[offset + index]; + for (const index of quietOffsets) quietMean += power[offset + index]; + pulseMean /= pulseOffsets.length; + quietMean /= quietOffsets.length; + const contrast = pulseMean - quietMean; + if (contrast < 120 || pulseMean < quietMean * 1.8) return 0; + const decisionLevel = quietMean + contrast * 0.35; + if (pulseOffsets.some((index) => power[offset + index] <= decisionLevel)) + return 0; + return contrast; +} + +/** Extract CRC-valid 112-bit ADS-B messages from unsigned 8-bit interleaved IQ. */ +export function extractAdsbMessages(buffer, sampleRate = 2_000_000) { + if ( + !(buffer instanceof ArrayBuffer) || + Math.abs(sampleRate - 2_000_000) > 30_000 + ) + return []; + const iq = new Uint8Array(buffer); + const sampleCount = Math.floor(iq.length / 2); + const power = new Float32Array(sampleCount); + for (let index = 0; index < sampleCount; index += 1) { + const i = iq[index * 2] - 127.5; + const q = iq[index * 2 + 1] - 127.5; + power[index] = i * i + q * q; + } + + const messages = []; + for (let offset = 0; offset <= sampleCount - FRAME_SAMPLES; offset += 1) { + const contrast = preambleContrast(power, offset); + if (!contrast) continue; + const bytes = new Uint8Array(14); + let uncertainBits = 0; + for (let bitIndex = 0; bitIndex < FRAME_BITS; bitIndex += 1) { + const sampleOffset = offset + PREAMBLE_SAMPLES + bitIndex * 2; + const first = power[sampleOffset]; + const second = power[sampleOffset + 1]; + const total = first + second; + const difference = Math.abs(first - second); + if ( + difference < Math.max(20, contrast * 0.02) || + difference / Math.max(total, 1) < 0.04 + ) + uncertainBits += 1; + if (first > second) bytes[bitIndex >> 3] |= 1 << (7 - (bitIndex & 7)); + } + if (uncertainBits <= 10 && modeSChecksum(bytes) === 0) { + messages.push(bytes); + offset += FRAME_SAMPLES - 1; + } + } + return messages; +} + +/** Preserve enough IQ between USB reads to recover frames split at a block edge. */ +export class AdsbStreamDecoder { + constructor() { + this._carry = new Uint8Array(0); + } + + reset() { + this._carry = new Uint8Array(0); + } + + extract(buffer, sampleRate = 2_000_000) { + if (!(buffer instanceof ArrayBuffer)) return []; + const incoming = new Uint8Array(buffer); + let combined = incoming; + if (this._carry.length) { + combined = new Uint8Array(this._carry.length + incoming.length); + combined.set(this._carry); + combined.set(incoming, this._carry.length); + } + this._carry = combined.slice( + Math.max(0, combined.length - STREAM_CARRY_BYTES), + ); + return extractAdsbMessages(combined.buffer, sampleRate); + } +} diff --git a/src/sdr/adsbDecoder.test.mjs b/src/sdr/adsbDecoder.test.mjs new file mode 100644 index 000000000..3b963f942 --- /dev/null +++ b/src/sdr/adsbDecoder.test.mjs @@ -0,0 +1,624 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +import { classifyAircraft } from '../data/aircraftClass.js'; +import { recordFromDecoderTrack } from '../sources/adsbRecords.js'; +import { + AdsbStreamDecoder, + LOCAL_ADSB_STALE_MS, + decodeAdsbMessage, + extractAdsbMessages, + modeSChecksum, + pruneAircraftTracks, + updateAircraftTrack, +} from './adsbDecoder.js'; + +function fromHex(value) { + return Uint8Array.from(value.match(/../g), (pair) => + Number.parseInt(pair, 16), + ); +} + +function synthesizeIq( + bytes, + { floor = 128, high = 255, preambleTail = null } = {}, +) { + const sampleCount = 16 + 112 * 2 + 8; + const iq = new Uint8Array(sampleCount * 2).fill(128); + for (let sample = 0; sample < sampleCount; sample += 1) + iq[sample * 2] = floor; + const pulse = (sample) => { + iq[sample * 2] = high; + }; + for (const offset of [0, 2, 7, 9]) pulse(offset); + if (Number.isFinite(preambleTail)) { + for (let sample = 10; sample < 16; sample += 1) + iq[sample * 2] = preambleTail; + } + for (let index = 0; index < 112; index += 1) { + const value = (bytes[index >> 3] >> (7 - (index & 7))) & 1; + pulse(16 + index * 2 + (value ? 0 : 1)); + } + return iq.buffer; +} + +test('decodes standard callsign, velocity, and altitude examples', () => { + const callsign = decodeAdsbMessage(fromHex('8D4840D6202CC371C32CE0576098')); + assert.equal(callsign.callsign, 'KLM1023'); + assert.equal(callsign.icao, '4840D6'); + + const velocity = decodeAdsbMessage(fromHex('8D485020994409940838175B284F')); + assert.ok(Math.abs(velocity.speedKt - 159.2) < 0.1); + assert.ok(Math.abs(velocity.headingDeg - 182.88) < 0.1); + assert.equal(velocity.verticalRateFpm, -832); + + const position = decodeAdsbMessage(fromHex('8D40621D58C382D690C8AC2863A7')); + assert.equal(position.altitudeFt, 38_000); + assert.equal(position.cpr.odd, false); +}); + +test('globally decodes a valid even/odd CPR pair and expires tracks at 60 seconds', () => { + const tracks = new Map(); + const even = decodeAdsbMessage(fromHex('8D40621D58C382D690C8AC2863A7'), { + receivedAt: 1_000, + }); + const odd = decodeAdsbMessage(fromHex('8D40621D58C386435CC412692AD6'), { + receivedAt: 1_500, + }); + updateAircraftTrack(tracks, even); + const aircraft = updateAircraftTrack(tracks, odd); + assert.ok(Math.abs(aircraft.latitude - 52.26578) < 0.0001); + assert.ok(Math.abs(aircraft.longitude - 3.93891) < 0.0001); + assert.equal(aircraft.lastPositionAt, 1_500); + assert.equal(LOCAL_ADSB_STALE_MS, 60_000); + assert.equal( + pruneAircraftTracks(tracks, 61_499), + 0, + 'contact survives below 60 seconds', + ); + assert.equal( + pruneAircraftTracks(tracks, 61_500), + 1, + 'contact expires at 60 seconds', + ); + assert.equal(tracks.size, 0); +}); + +test('extracts a CRC-valid Mode S frame from synthetic 2 Msps IQ', () => { + const bytes = fromHex('8D4840D6202CC371C32CE0576098'); + assert.equal(modeSChecksum(bytes), 0); + const frames = extractAdsbMessages(synthesizeIq(bytes), 2_000_000); + assert.equal(frames.length, 1); + assert.deepEqual(frames[0], bytes); +}); + +test('accepts a valid weak frame above an elevated local noise floor', () => { + const bytes = fromHex('8D4840D6202CC371C32CE0576098'); + const frames = extractAdsbMessages( + synthesizeIq(bytes, { floor: 140, high: 150 }), + 2_000_000, + ); + assert.equal(frames.length, 1); + assert.deepEqual(frames[0], bytes); +}); + +test('accepts a real-shaped preamble with energy trailing its final pulse', () => { + const bytes = fromHex('8D4840D6202CC371C32CE0576098'); + const frames = extractAdsbMessages( + synthesizeIq(bytes, { + floor: 138, + high: 148, + preambleTail: 145, + }), + 2_000_000, + ); + assert.equal(frames.length, 1); + assert.deepEqual(frames[0], bytes); +}); + +test('preserves a Mode S frame split across consecutive USB blocks', () => { + const bytes = fromHex('8D4840D6202CC371C32CE0576098'); + const iq = new Uint8Array(synthesizeIq(bytes)); + const decoder = new AdsbStreamDecoder(); + assert.deepEqual(decoder.extract(iq.slice(0, 300).buffer, 2_000_000), []); + assert.deepEqual(decoder.extract(iq.slice(300).buffer, 2_000_000), [bytes]); + decoder.reset(); + assert.deepEqual(decoder.extract(iq.slice(300).buffer, 2_000_000), []); +}); + +test('rejects corrupt frames and unsupported sample rates', () => { + const bytes = fromHex('8D4840D6202CC371C32CE0576098'); + bytes[5] ^= 0x01; + assert.notEqual(modeSChecksum(bytes), 0); + assert.equal(decodeAdsbMessage(bytes), null); + assert.deepEqual(extractAdsbMessages(synthesizeIq(bytes), 1_024_000), []); +}); + +const AUSTIN_RECEIVER = Object.freeze({ latitude: 30.27, longitude: -97.8 }); +const fixtureFrames = readFileSync( + new URL('../data/fixtures/adsb-austin-frames.txt', import.meta.url), + 'utf8', +) + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => fromHex(line.replace(/^\*|;$/g, ''))); +const dump1090 = JSON.parse( + readFileSync( + new URL( + '../data/fixtures/adsb-austin-dump1090-aircraft.json', + import.meta.url, + ), + 'utf8', + ), +); + +// The capture keeps receive order but not receive times, so frames are spaced +// 100 ms apart: every even/odd pair then falls inside the 10 s CPR window. +function decodeFixture(receiverLocation) { + const tracks = new Map(); + fixtureFrames.forEach((bytes, index) => { + const message = decodeAdsbMessage(bytes, { receivedAt: (index + 1) * 100 }); + if (message) updateAircraftTrack(tracks, message, receiverLocation); + }); + return tracks; +} + +test('real Austin frames decode to the same aircraft, callsigns and positions as dump1090', () => { + assert.equal(fixtureFrames.length, 112); + const tracks = decodeFixture(AUSTIN_RECEIVER); + const heard = [...tracks.keys()].map((icao) => icao.toLowerCase()).sort(); + assert.deepEqual(heard, dump1090.aircraft.map((entry) => entry.hex).sort()); + for (const oracle of dump1090.aircraft) { + const track = tracks.get(oracle.hex.toUpperCase()); + if (oracle.flight) + assert.equal(track.callsign, oracle.flight.trim(), oracle.hex); + if (Number.isFinite(oracle.alt_baro)) + assert.equal(track.altitudeFt, oracle.alt_baro, oracle.hex); + if (Number.isFinite(oracle.gs)) + assert.ok(Math.abs(track.speedKt - oracle.gs) < 0.5, oracle.hex); + if (Number.isFinite(oracle.track)) + assert.ok(Math.abs(track.headingDeg - oracle.track) < 0.5, oracle.hex); + if (Number.isFinite(oracle.baro_rate)) + assert.equal(track.verticalRateFpm, oracle.baro_rate, oracle.hex); + if (!Number.isFinite(oracle.lat)) continue; + assert.ok( + Math.abs(track.latitude - oracle.lat) < 0.01, + `${oracle.hex} latitude`, + ); + assert.ok( + Math.abs(track.longitude - oracle.lon) < 0.01, + `${oracle.hex} longitude`, + ); + } +}); + +test('receiver-relative CPR positions an even-only aircraft that dump1090 left unpositioned', () => { + // SKW3301 (a15c54) sent three airborne positions, all even-parity, so no + // global even/odd pair exists. dump1090 ran without a receiver location and + // could not position it; with one, the local decode places it near AUS. + const skywest = fixtureFrames.filter( + (bytes) => + decodeAdsbMessage(bytes)?.icao === 'A15C54' && + decodeAdsbMessage(bytes).cpr, + ); + assert.equal(skywest.length, 3); + assert.ok( + skywest.every((bytes) => decodeAdsbMessage(bytes).cpr.odd === false), + ); + + const located = decodeFixture(AUSTIN_RECEIVER).get('A15C54'); + assert.ok(Math.abs(located.latitude - 30.1599) < 0.01); + assert.ok(Math.abs(located.longitude + 97.7903) < 0.01); + + const unlocated = decodeFixture(null); + assert.equal(unlocated.get('A15C54').latitude, null); + const positioned = [...unlocated.values()] + .filter((track) => Number.isFinite(track.latitude)) + .map((track) => track.icao.toLowerCase()) + .sort(); + assert.deepEqual( + positioned, + dump1090.aircraft + .filter((entry) => Number.isFinite(entry.lat)) + .map((entry) => entry.hex) + .sort(), + 'without a receiver location the positioned set matches dump1090 exactly', + ); +}); + +// A second real capture over Austin with receive times: 846 extended +// squitters from six aircraft, each frame timed by interpolating between the +// frames whose decoded position matches a dump1090 fix (dump1090's `t - +// seen_pos`), plus dump1090's own positions for the same interval. +const timed = JSON.parse( + readFileSync( + new URL('../data/fixtures/adsb-austin-capture-timed.json', import.meta.url), + 'utf8', + ), +); + +function nauticalMiles(a, b) { + const toRad = Math.PI / 180; + const dLat = (b.lat - a.lat) * toRad; + const dLon = (b.lon - a.lon) * toRad; + const h = + Math.sin(dLat / 2) ** 2 + + Math.cos(a.lat * toRad) * Math.cos(b.lat * toRad) * Math.sin(dLon / 2) ** 2; + return 2 * 3440.065 * Math.asin(Math.sqrt(h)); +} + +function replayTimed({ inject = null, receiver = timed.receiver } = {}) { + const tracks = new Map(); + const stats = {}; + const accepted = new Map(); + for (const [receivedAt, hex] of timed.frames) { + const message = decodeAdsbMessage(fromHex(hex), { receivedAt }); + if (!message) continue; + for (const extra of inject?.(message) || []) { + updateAircraftTrack(tracks, extra, receiver, stats); + } + const track = updateAircraftTrack(tracks, message, receiver, stats); + if (message.cpr && track.lastPositionAt === receivedAt) { + const key = message.icao.toLowerCase(); + if (!accepted.has(key)) accepted.set(key, []); + accepted.get(key).push({ + at: receivedAt, + lat: track.latitude, + lon: track.longitude, + }); + } + } + return { tracks, stats, accepted }; +} + +function dump1090Final(hex) { + const [, lat, lon] = timed.dump1090[hex].fixes.at(-1); + return { lat, lon }; +} + +test('identification messages decode to the dump1090 emitter category strings', () => { + const { tracks } = replayTimed(); + const decoded = Object.fromEntries( + Object.keys(timed.dump1090).map((hex) => [ + hex, + tracks.get(hex.toUpperCase()).category, + ]), + ); + assert.deepEqual(decoded, { + '0d0c07': 'A2', + a3627d: 'A3', + a0b702: 'A7', + a5b3a7: 'A3', + abe7c5: 'A3', + a27e81: 'A1', + }); + for (const [hex, oracle] of Object.entries(timed.dump1090)) + assert.equal(decoded[hex], oracle.category, hex); + // Category set D (type code 1) and C (type code 2) follow the same rule. + const message = decodeAdsbMessage(fromHex('8D4840D6202CC371C32CE0576098')); + assert.equal(message.typeCode, 4); + assert.equal(message.category, 'A0'); +}); + +test('145TX, a rotorcraft (A7), classifies as a helicopter', () => { + const { tracks } = replayTimed(); + const heli = tracks.get('A0B702'); + assert.equal(heli.callsign, '145TX'); + assert.equal(classifyAircraft({ category: heli.category }), 'helicopter'); + assert.equal( + classifyAircraft({ category: tracks.get('A27E81').category }), + 'light', + ); +}); + +test('the speed check refuses none of the capture and every track ends on dump1090', () => { + const { tracks, stats, accepted } = replayTimed(); + assert.equal(stats.positionsRejected || 0, 0); + for (const hex of Object.keys(timed.dump1090)) { + const track = tracks.get(hex.toUpperCase()); + assert.equal(track.rejectedPositions, 0, `${hex} rejected a fix`); + const fixes = accepted.get(hex); + assert.ok(fixes.length >= 20, `${hex}: ${fixes.length} fixes`); + const delta = nauticalMiles(fixes.at(-1), dump1090Final(hex)); + assert.ok(delta < 0.3, `${hex} ends ${delta.toFixed(3)} nm from dump1090`); + } +}); + +test("N26VB's 1.0 nm step spans a 27 s reception gap at its reported speed", () => { + // The field report read this as a bad decode. The step follows single odd + // frames decoded against the receiver location, which dump1090 (run without + // one) never positions; the implied speed matches the reported 134 kt. + const { tracks, accepted } = replayTimed(); + const fixes = accepted.get('a27e81'); + let widest = null; + for (let index = 1; index < fixes.length; index += 1) { + const step = nauticalMiles(fixes[index - 1], fixes[index]); + if (!widest || step > widest.step) + widest = { + step, + seconds: (fixes[index].at - fixes[index - 1].at) / 1000, + }; + } + assert.ok(Math.abs(widest.step - 1.0) < 0.05, `${widest.step} nm`); + assert.ok(widest.seconds > 25, `${widest.seconds} s`); + const impliedKt = widest.step / (widest.seconds / 3600); + assert.ok(Math.abs(impliedKt - 134) < 10, `${impliedKt.toFixed(0)} kt`); + assert.equal(tracks.get('A27E81').rejectedPositions, 0); +}); + +test('a corrupt even/odd pair is refused and the track stays on dump1090', () => { + // Pair a foreign odd frame (ENY3344, 27 nm away) with N26VB's fresh even + // frame: the global decode lands far off the track, as a mixed-up pair does. + const foreign = timed.frames + .map(([, hex]) => decodeAdsbMessage(fromHex(hex))) + .find((message) => message?.icao === 'A3627D' && message.cpr?.odd); + let injected = 0; + const { tracks, stats, accepted } = replayTimed({ + inject(message) { + if (message.icao !== 'A27E81' || message.cpr?.odd !== false) return []; + if (injected >= 2) return []; + injected += 1; + return [ + { + ...foreign, + icao: 'A27E81', + receivedAt: message.receivedAt - 1, + cpr: { ...foreign.cpr, receivedAt: message.receivedAt - 1 }, + }, + ]; + }, + }); + const track = tracks.get('A27E81'); + assert.ok(track.rejectedPositions >= 1); + assert.equal(stats.positionsRejected, track.rejectedPositions); + for (const fix of accepted.get('a27e81')) + assert.ok( + nauticalMiles(fix, { lat: 30.32, lon: -97.91 }) < 3, + 'no accepted fix leaves the track', + ); + assert.ok( + nauticalMiles(accepted.get('a27e81').at(-1), dump1090Final('a27e81')) < 0.3, + ); +}); + +test('a lone frame decodes relative to the aircraft own recent position', () => { + const tracks = new Map(); + const even = decodeAdsbMessage(fromHex('8D40621D58C382D690C8AC2863A7'), { + receivedAt: 1_000, + }); + const odd = decodeAdsbMessage(fromHex('8D40621D58C386435CC412692AD6'), { + receivedAt: 1_500, + }); + updateAircraftTrack(tracks, even); + updateAircraftTrack(tracks, odd); + // 20 s later the pair has expired and there is no receiver location. + const later = decodeAdsbMessage(fromHex('8D40621D58C386435CC412692AD6'), { + receivedAt: 20_000, + }); + const track = updateAircraftTrack(tracks, later, null); + assert.equal(track.lastPositionAt, 20_000); + assert.ok(Math.abs(track.latitude - 52.26578) < 0.0001); + assert.ok(Math.abs(track.longitude - 3.93891) < 0.0001); +}); + +test('three refused global fixes in a row re-anchor the track', () => { + const tracks = new Map(); + const stats = {}; + updateAircraftTrack( + tracks, + decodeAdsbMessage(fromHex('8D40621D58C382D690C8AC2863A7'), { + receivedAt: 1_000, + }), + ); + updateAircraftTrack( + tracks, + decodeAdsbMessage(fromHex('8D40621D58C386435CC412692AD6'), { + receivedAt: 1_500, + }), + ); + // Austin pairs relabelled as this aircraft: global decodes 8,000 km away. + const austin = timed.frames + .map(([, hex]) => decodeAdsbMessage(fromHex(hex))) + .filter((message) => message?.icao === 'A0B702' && message.cpr); + const firstOdd = austin.find((message) => message.cpr.odd); + const firstEven = austin.find((message) => !message.cpr.odd); + let at = 2_000; + const feed = (message) => { + at += 500; + return updateAircraftTrack( + tracks, + { + ...message, + icao: '40621D', + receivedAt: at, + cpr: { ...message.cpr, receivedAt: at }, + }, + null, + stats, + ); + }; + feed(firstEven); + let track = feed(firstOdd); + assert.equal(track.rejectedPositions, 2); + assert.ok(Math.abs(track.latitude - 52.26578) < 0.0001, 'still anchored'); + track = feed(firstEven); + assert.equal(stats.positionsRejected, 2, 'the third global fix re-anchors'); + assert.ok(Math.abs(track.latitude - 30.2) < 0.2, 'moved to the new stream'); + assert.equal(track.rejectStreak, 0); +}); + +// Surface position examples from "The 1090MHz Riddle" (J. Sun), surface +// position chapter: ICAO C8200A, even 8CC8200A3AC8F009BCDEF2..., odd +// 8FC8200A3AB8F5F893096B..., receiver (-43.496, 172.558); published result +// (-43.48564, 172.53942). The book prints the parity field as zeros, so the +// 24-bit parity is recomputed here to make the frames CRC-valid. +function withParity(hex) { + const bytes = fromHex(hex); + // The Mode S parity is the CRC remainder of the first 88 bits. + const parity = modeSChecksum(bytes.slice(0, 11)); + bytes[11] = (parity >> 16) & 0xff; + bytes[12] = (parity >> 8) & 0xff; + bytes[13] = parity & 0xff; + assert.equal(modeSChecksum(bytes), 0); + return bytes; +} +const SURFACE_EVEN = withParity('8CC8200A3AC8F009BCDEF2000000'); +const SURFACE_ODD = withParity('8FC8200A3AB8F5F893096B000000'); +const SURFACE_RECEIVER = { latitude: -43.496, longitude: 172.558 }; + +test('surface position messages decode movement, track and ground state', () => { + // Odd frame ME field, read by hand: TC 00111 (7), MOV 0101011 (43), + // S 1, TRK 0001111 (15), T 0, F 1. MOV 39–93 is 15 kt + 1 kt per step + // (43 → 19 kt); TRK is in 360/128° steps (15 → 42.1875°). + const odd = decodeAdsbMessage(SURFACE_ODD, { receivedAt: 2_000 }); + assert.equal(odd.icao, 'C8200A'); + assert.equal(odd.typeCode, 7); + assert.equal(odd.onGround, true); + assert.equal(odd.speedKt, 19); + assert.equal(odd.headingDeg, 42.1875); + assert.equal(odd.altitudeFt, null, 'no altitude on the surface'); + assert.equal(odd.cpr.surface, true); + assert.equal(odd.cpr.odd, true); + // Even frame: MOV 0101100 (44 → 20 kt), same track. + const even = decodeAdsbMessage(SURFACE_EVEN); + assert.equal(even.speedKt, 20); + assert.equal(even.cpr.odd, false); + + // A real Schiphol frame with a valid parity (Riddle ground-speed example): + // MOV 0101010 (42 → 18 kt), S 1, TRK 0110010 (50 → 140.625°). + const schiphol = decodeAdsbMessage(fromHex('8C4841753AAB238733C8CD4020B1')); + assert.equal(schiphol.icao, '484175'); + assert.equal(schiphol.speedKt, 18); + assert.equal(schiphol.headingDeg, 140.625); + + // MOV 1 is "stopped"; MOV 0 and a track status of 0 carry no value. + const stopped = withParity('8CC8200A3810F009BCDEF2000000'); + const still = decodeAdsbMessage(stopped); + assert.equal(still.speedKt, 0); + assert.equal(still.headingDeg, undefined, 'track status 0: no track'); +}); + +test('a surface even/odd pair decodes to the published position and marks the aircraft on the ground', () => { + const tracks = new Map(); + // Airborne first: the landing must clear the old altitude. + const airborne = decodeAdsbMessage(fromHex('8D40621D58C382D690C8AC2863A7')); + updateAircraftTrack( + tracks, + // Its altitude only: the CPR belongs to another aircraft. + { ...airborne, icao: 'C8200A', receivedAt: 0, cpr: null }, + SURFACE_RECEIVER, + ); + updateAircraftTrack( + tracks, + decodeAdsbMessage(SURFACE_EVEN, { receivedAt: 1_000 }), + SURFACE_RECEIVER, + ); + const track = updateAircraftTrack( + tracks, + decodeAdsbMessage(SURFACE_ODD, { receivedAt: 2_000 }), + SURFACE_RECEIVER, + ); + assert.ok( + Math.abs(track.latitude - -43.48564) < 0.00001, + `${track.latitude}`, + ); + assert.ok( + Math.abs(track.longitude - 172.53942) < 0.00001, + `${track.longitude}`, + ); + assert.equal(track.lastPositionAt, 2_000); + assert.equal(track.onGround, true); + assert.equal(track.altitudeFt, null); + assert.equal(track.speedKt, 19); + const record = recordFromDecoderTrack(track); + assert.equal(record.onGround, true); + assert.equal(record.altitudeFt, null); + assert.ok(Math.abs(record.lat - -43.48564) < 0.00001); + + // An airborne position afterwards clears the ground state. + const departed = updateAircraftTrack( + tracks, + { ...airborne, icao: 'C8200A', receivedAt: 3_000, cpr: null }, + SURFACE_RECEIVER, + ); + assert.equal(departed.onGround, false); + assert.equal(departed.altitudeFt, 38_000); +}); + +test('a surface track is seeded by a pair; a single frame then decodes against its own fix', () => { + // The receiver is not established to be within 45 NM of the aircraft, so a + // lone surface frame does not decode against it (even though this one is). + const receiver = { latitude: -43.5, longitude: 172.5 }; + const tracks = new Map(); + let track = updateAircraftTrack( + tracks, + decodeAdsbMessage(SURFACE_EVEN, { receivedAt: 1_000 }), + receiver, + ); + assert.equal(track.lastPositionAt, null, 'no single-frame receiver fix'); + // The even/odd pair seeds the track at the published position. + track = updateAircraftTrack( + tracks, + decodeAdsbMessage(SURFACE_ODD, { receivedAt: 2_000 }), + receiver, + ); + assert.ok(Math.abs(track.latitude - -43.48564) < 0.00001); + assert.ok(Math.abs(track.longitude - 172.53942) < 0.00001); + // With no receiver location, the aircraft's own recent fix is the reference + // (the other frame of the pair is too old to pair with). + track = updateAircraftTrack( + tracks, + decodeAdsbMessage(SURFACE_EVEN, { receivedAt: 40_000 }), + null, + ); + assert.equal(track.lastPositionAt, 40_000); + assert.ok(Math.abs(track.latitude - -43.4856) < 0.001); + assert.ok(Math.abs(track.longitude - 172.5394) < 0.001); + // An own fix the aircraft may have left by 45 NM or more (unknown speed, + // four minutes old) is no single-frame reference either. + tracks.set('C8200A', { ...track, speedKt: null, category: null }); + track = updateAircraftTrack( + tracks, + decodeAdsbMessage(SURFACE_ODD, { receivedAt: 280_000 }), + null, + ); + assert.equal(track.lastPositionAt, 40_000, 'reference too old to trust'); + // Without any reference a lone surface frame is ambiguous: no position. + const blind = updateAircraftTrack( + new Map(), + decodeAdsbMessage(SURFACE_ODD, { receivedAt: 1_000 }), + null, + ); + assert.equal(blind.lastPositionAt, null); + assert.equal(blind.onGround, true); +}); + +// "The 1090MHz Riddle", surface position chapter: Schiphol pair of ICAO +// 484175, published position about (52.3206, 4.7347). +const SCHIPHOL_EVEN = fromHex('8C4841753AAB238733C8CD4020B1'); +const SCHIPHOL_ODD = fromHex('8C4841753A9A153237AEF0F275BE'); + +test('a far receiver never places a lone surface frame; the next valid pair seeds the track', () => { + // About 80 NM from the airport: beyond half a surface zone (45 NM). + const receiver = { latitude: 51, longitude: 4.375 }; + const tracks = new Map(); + let track = updateAircraftTrack( + tracks, + decodeAdsbMessage(SCHIPHOL_EVEN, { receivedAt: 1_000 }), + receiver, + ); + assert.equal(track.lastPositionAt, null, 'no position from one frame'); + assert.equal(track.latitude, null, 'never (50.823040, 4.602622)'); + track = updateAircraftTrack( + tracks, + decodeAdsbMessage(SCHIPHOL_ODD, { receivedAt: 2_000 }), + receiver, + ); + assert.equal(track.lastPositionAt, 2_000); + assert.equal(track.rejectedPositions, 0, 'the valid pair is accepted'); + assert.ok(Math.abs(track.latitude - 52.3206) < 0.002, `${track.latitude}`); + assert.ok(Math.abs(track.longitude - 4.7347) < 0.002, `${track.longitude}`); + assert.equal(track.onGround, true); +}); diff --git a/src/sdr/controller.js b/src/sdr/controller.js new file mode 100644 index 000000000..1c86658ec --- /dev/null +++ b/src/sdr/controller.js @@ -0,0 +1,1367 @@ +import { RTL2832U_Provider } from '@jtarrio/webrtlsdr/rtlsdr.js'; +import { findDirectionalFmPeak } from './spectrum.js'; +import { + SDR_GAIN_DEFAULTS, + normalizeSdrGain, + readSdrGainSettings, + tunerGainValue, + writeSdrGainSettings, +} from './gain.js'; +import { + RTL_SDR_USB_FILTERS, + createRememberingWebUsb, + createSdrDeviceMemory, + sameSdrDevice, + sdrDeviceIdentity, + sdrDeviceLabel, + selectAuthorizedSdrDevice, +} from './usbDevices.js'; +import { summarizeLocalAdsb } from '../sources/adsbRecords.js'; + +export { createRememberingWebUsb } from './usbDevices.js'; + +const FM_MIN_HZ = 87_500_000; +const FM_MAX_HZ = 108_000_000; +const READ_SAMPLE_COUNT = 32_768; +const RATE_WINDOW_MS = 1_000; +// A bulk read that has not completed in this long is treated as a stalled +// USB transfer: the session is torn down instead of waiting forever. +export const SDR_READ_STALL_MS = 5_000; +// Upper bound on a graceful close queued behind other USB work; past it the +// raw USB device is closed directly, which aborts any pending transfer. The +// raw close is bounded by the same deadline, so a teardown settles within +// twice this even when `USBDevice.close()` itself never settles. +export const SDR_CLOSE_TIMEOUT_MS = 2_000; + +/** Tuning presets per receiver mode; gain is a separate per-mode setting. */ +export const SDR_RECEIVER_PRESETS = Object.freeze({ + fm: Object.freeze({ + frequencyHz: 98_500_000, + sampleRate: 2_048_000, + ppm: 0, + rtlAgc: false, + }), + adsb: Object.freeze({ + frequencyHz: 1_090_000_000, + sampleRate: 2_000_000, + ppm: 0, + rtlAgc: false, + }), +}); + +const FM_DEFAULT_HZ = SDR_RECEIVER_PRESETS.fm.frequencyHz; +const FM_SAMPLE_RATE = SDR_RECEIVER_PRESETS.fm.sampleRate; +const ADSB_FREQUENCY_HZ = SDR_RECEIVER_PRESETS.adsb.frequencyHz; +const ADSB_SAMPLE_RATE = SDR_RECEIVER_PRESETS.adsb.sampleRate; + +function normalizeMode(mode) { + return mode === 'adsb' ? 'adsb' : 'fm'; +} + +function defaultStorage() { + try { + return globalThis.localStorage || null; + } catch { + return null; + } +} + +async function setRtlAgc(device, enabled) { + const method = device?._enableRtlAgc; + if (typeof method === 'function') await method.call(device, enabled); +} + +// Guarded stand-in -> the WebUSB `USBDevice` it forwards to. Ownership checks +// compare real devices, never the per-acquisition stand-ins. +const GUARDED_USB_DEVICES = new WeakMap(); + +/** The real WebUSB `USBDevice` behind a guarded stand-in (or itself). */ +function unwrapUsb(usbDevice) { + return (usbDevice && GUARDED_USB_DEVICES.get(usbDevice)) || usbDevice; +} + +/** The WebUSB `USBDevice` under an opened RTL2832U, when reachable. */ +function rawUsbDevice(device, provider) { + return unwrapUsb(device?.com?.device || provider?.device || null); +} + +/** Close a raw USB device, ignoring an already-closed or vanished one. */ +async function forceCloseUsb(usbDevice) { + if (!usbDevice || typeof usbDevice.close !== 'function') return; + try { + await usbDevice.close(); + } catch { + /* already closed or unplugged */ + } +} + +function closedUsbError() { + return Promise.reject(new Error('RTL-SDR session was closed')); +} + +/** + * A stand-in for `usbDevice` whose methods reject once `isFenced()` holds, + * so an abandoned acquisition that is still running can never open, claim, + * write to or close the device a newer session has opened since (WebUSB + * hands every opener the same `USBDevice`). + */ +function guardUsbDevice(usbDevice, isFenced) { + const guarded = new Proxy(usbDevice, { + get(target, property) { + const value = Reflect.get(target, property, target); + if (typeof value !== 'function') return value; + return (...args) => + isFenced() ? closedUsbError() : value.apply(target, args); + }, + }); + GUARDED_USB_DEVICES.set(guarded, usbDevice); + return guarded; +} + +/** + * Wrap a WebUSB entry point for one acquisition: every `USBDevice` it hands + * out is reported to `onDevice` and handed on guarded by `isFenced`. The + * RTL-SDR provider opens and initializes the device inside `get()`, so this + * is the only way to reach a device whose initialization failed before + * `get()` returned it, and the only way to stop a selection that resolves + * after the acquisition was abandoned from initializing that device. + */ +function captureRequestedUsb(webUsb, { onDevice, isFenced }) { + if (!webUsb || typeof webUsb.requestDevice !== 'function') return webUsb; + return new Proxy(webUsb, { + get(target, property) { + if (property === 'requestDevice') { + return async (...args) => { + const usbDevice = await target.requestDevice(...args); + if (!usbDevice) return usbDevice; + // A late selection belongs to no session: the device it names may + // already be open for a newer one. + if (isFenced()) + throw new Error('RTL-SDR device selection was abandoned'); + onDevice(usbDevice); + return guardUsbDevice(usbDevice, isFenced); + }; + } + const value = Reflect.get(target, property, target); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); +} + +/** + * Stand-in for the WebUSB device of an abandoned session: every method + * rejects, so nothing still running for that session reaches the hardware. + */ +const CLOSED_USB_DEVICE = new Proxy( + {}, + { + get: (_target, property) => + property === 'then' ? undefined : closedUsbError, + }, +); + +/** + * Detach an abandoned RTL2832U from its WebUSB device. WebUSB hands a later + * session the SAME `USBDevice` object, so a graceful close (or any other + * transfer) of the old session that resumes after we gave up on it would + * otherwise act on the new session's open device. + */ +function fenceDevice(device) { + const com = device?.com; + if (com && typeof com === 'object' && 'device' in com) { + try { + com.device = CLOSED_USB_DEVICE; + } catch { + /* read-only: nothing to fence */ + } + } +} + +/** Resolve with the operation's outcome, or reject once `ms` elapses. */ +function withDeadline(promise, ms, message) { + let timer = null; + const deadline = new Promise((_, reject) => { + timer = setTimeout(() => { + const error = new Error(message); + error.name = 'TimeoutError'; + reject(error); + }, ms); + }); + return Promise.race([promise, deadline]).finally(() => clearTimeout(timer)); +} + +function publicAircraft(aircraft) { + return Array.isArray(aircraft) ? aircraft.map((entry) => ({ ...entry })) : []; +} + +function clampFmFrequency(value) { + return Math.max( + FM_MIN_HZ, + Math.min(FM_MAX_HZ, Math.round(Number(value) / 100_000) * 100_000), + ); +} + +function describeConnectionError(error) { + const message = String(error?.message || error || '').trim(); + if (/No device was selected|NotFoundError/i.test(message)) + return 'No RTL-SDR device selected'; + if ( + /does not support the WebUSB|usb/i.test(message) && + /support/i.test(message) + ) { + return 'WebUSB requires desktop Chrome or Edge on localhost/HTTPS'; + } + if (/access|permission|claim|busy/i.test(message)) { + return 'RTL-SDR access failed; close other SDR apps and check USB permissions'; + } + return message || 'RTL-SDR connection failed'; +} + +function streamingMessage(mode) { + return mode === 'adsb' + ? 'Receiving 1090 MHz ADS-B' + : 'Receiving broadcast FM'; +} + +/** + * Browser-local RTL-SDR session shared by the FM controls and the Local ADS-B + * layer. `state.aircraft` holds source-agnostic records from + * `src/sources/adsbRecords.js`. + */ +export class SdrController { + /** + * @param {object} [options] + * @param {(webusb: USB) => {get(): Promise}} [options.providerFactory] + * Builds the RTL-SDR provider around the selecting WebUSB wrapper. + * @param {Storage|null} [options.storage] Gain and device preferences. + * @param {USB|null} [options.webUsb] WebUSB entry point. + * @param {number} [options.readStallMs] Deadline for one bulk sample read. + * @param {number} [options.closeTimeoutMs] Deadline for a graceful close. + */ + constructor({ + providerFactory = null, + readStallMs = SDR_READ_STALL_MS, + closeTimeoutMs = SDR_CLOSE_TIMEOUT_MS, + storage = defaultStorage(), + webUsb = typeof navigator !== 'undefined' ? navigator.usb : null, + } = {}) { + const webUsbSupported = Boolean(webUsb); + this._webUsb = webUsb || null; + this._storage = storage; + this._gainByMode = readSdrGainSettings(storage); + this._deviceMemory = createSdrDeviceMemory(storage); + this.state = { + webUsbSupported, + connected: false, + status: webUsbSupported ? 'idle' : 'unsupported', + message: webUsbSupported + ? 'Connect an RTL-SDR to begin.' + : 'WebUSB is unavailable in this browser', + mode: 'fm', + frequencyHz: FM_DEFAULT_HZ, + sampleRate: FM_SAMPLE_RATE, + volume: 0.8, + gain: this._gainByMode.fm, + seeking: false, + seekMessage: '', + snr: null, + aircraft: [], + decodedMessages: 0, + messagesPerSecond: null, + aircraftHeard: 0, + aircraftPositioned: 0, + positionsRejected: 0, + deviceLabel: null, + receiverLocation: null, + locationStatus: 'unknown', + audioState: 'idle', + samplesPerSecond: 0, + workerBlocks: 0, + iqLevelDbfs: null, + audioLevelDbfs: null, + }; + this._listeners = new Set(); + this._provider = null; + this._device = null; + this._deviceIdentity = null; + this._forcePicker = false; + this._worker = null; + this._audioContext = null; + this._audioNode = null; + this._readStallMs = readStallMs; + this._closeTimeoutMs = closeTimeoutMs; + this._usbTail = Promise.resolve(); + // Bumped whenever a stalled queue is abandoned; work queued in an older + // epoch refuses to touch whatever device is current when it finally runs. + this._usbEpoch = 0; + this._readGeneration = 0; + // Bumped by every connect() and stop(); a connect, mode change or + // configuration whose token is stale after an await has been superseded + // (stopped, destroyed or restarted) and must not publish its result. + this._sessionToken = 0; + // The device teardown in progress, if any. Single-flight: a stop() during + // it joins it, and connect() waits for it before opening a device, so an + // old session's close can never race a new session's open. + this._teardown = null; + // Device acquisitions (`provider.get()` plus the cleanup of whatever it + // opened) that have not handed their device to a session yet. A connect + // waits for older ones, so an old acquisition can never close the WebUSB + // device a newer session opened. + this._acquisitions = new Set(); + this._acquisitionSequence = 0; + this._destroyed = false; + this._fmFrequencyHz = FM_DEFAULT_HZ; + this._spectrum = null; + this._spectrumSequence = 0; + this._spectrumWaiters = new Set(); + this._seekToken = 0; + // Every provider opens its device through the selecting WebUSB wrapper. + const createProvider = + providerFactory || ((webusb) => new RTL2832U_Provider({ webusb })); + this._providerFactory = ({ + onUsbDevice = () => {}, + isFenced = () => false, + } = {}) => + createProvider( + captureRequestedUsb( + createRememberingWebUsb(this._webUsb, { + getMode: () => this.state.mode, + memory: this._deviceMemory, + consumeForcePicker: () => { + const force = this._forcePicker; + this._forcePicker = false; + return force; + }, + onSelected: (device) => { + if (!isFenced()) this._deviceIdentity = sdrDeviceIdentity(device); + }, + }), + { onDevice: onUsbDevice, isFenced }, + ), + ); + this._sampleWindowStartedAt = 0; + this._sampleWindowSamples = 0; + this._messageWindowStartedAt = 0; + this._messageWindowCount = 0; + } + + getState() { + return { ...this.state, aircraft: publicAircraft(this.state.aircraft) }; + } + + subscribe(listener) { + if (typeof listener !== 'function') return () => {}; + this._listeners.add(listener); + listener(this.getState()); + return () => this._listeners.delete(listener); + } + + _setState(patch) { + Object.assign(this.state, patch); + const snapshot = this.getState(); + for (const listener of this._listeners) { + try { + listener(snapshot); + } catch (error) { + console.warn('[SDR] State listener failed:', error); + } + } + } + + _queueUsb(operation) { + const epoch = this._usbEpoch; + const run = () => { + if (epoch !== this._usbEpoch) + throw new Error('USB work abandoned after a stalled transfer'); + return operation(); + }; + const queued = this._usbTail.then(run, run); + this._usbTail = queued.catch(() => {}); + return queued; + } + + /** Abandon a queue that a stalled transfer is blocking. */ + _resetUsbQueue() { + this._usbEpoch += 1; + this._usbTail = Promise.resolve(); + } + + _ensureWorker() { + if (this._worker) return this._worker; + const worker = new Worker(new URL('./sdr.worker.js', import.meta.url), { + type: 'module', + }); + this._worker = worker; + worker.onmessage = (event) => { + if (this._worker !== worker) return; + this._handleWorkerMessage(event.data || {}); + }; + worker.onerror = (event) => { + if (this._worker !== worker) return; + console.warn('[SDR] Decoder worker error:', event?.message || event); + // A failed worker (e.g. its module did not load) never recovers: drop + // it so the next session builds and configures a fresh one. + this._discardWorker(); + this._setState({ status: 'error', message: 'SDR decoder worker failed' }); + if (this._device) void this.stop({ preserveMessage: true }); + }; + return worker; + } + + _discardWorker() { + const worker = this._worker; + this._worker = null; + if (!worker) return; + worker.onmessage = null; + worker.onerror = null; + try { + worker.terminate(); + } catch { + /* already gone */ + } + } + + _configureWorker() { + this._ensureWorker().postMessage({ + type: 'configure', + mode: this.state.mode, + sampleRate: this.state.sampleRate, + location: this.state.receiverLocation, + }); + } + + async _ensureAudio() { + if (typeof AudioContext === 'undefined') return false; + if (!this._audioContext) { + this._audioContext = new AudioContext({ + sampleRate: 48_000, + latencyHint: 'interactive', + }); + this._audioContext.onstatechange = () => { + this._setState({ audioState: this._audioContext?.state || 'closed' }); + }; + } + if (!this._audioNode) { + await this._audioContext.audioWorklet.addModule( + new URL('./sdrAudioWorklet.js', import.meta.url), + ); + this._audioNode = new AudioWorkletNode( + this._audioContext, + 'gev-sdr-audio-player', + { + outputChannelCount: [1], + }, + ); + this._audioNode.connect(this._audioContext.destination); + this._audioNode.port.postMessage({ + type: 'volume', + value: this.state.volume, + }); + } + if (this._audioContext.state !== 'running') + await this._audioContext.resume(); + const audioState = this._audioContext.state; + this._setState({ audioState }); + return audioState === 'running'; + } + + _handleWorkerMessage(message) { + const diagnostics = message.diagnostics; + if (diagnostics && Number.isFinite(diagnostics.workerBlocks)) { + this._setState({ + workerBlocks: diagnostics.workerBlocks, + iqLevelDbfs: Number.isFinite(diagnostics.iqLevelDbfs) + ? diagnostics.iqLevelDbfs + : this.state.iqLevelDbfs, + audioLevelDbfs: Number.isFinite(diagnostics.audioLevelDbfs) + ? diagnostics.audioLevelDbfs + : this.state.audioLevelDbfs, + }); + } + if (message.type === 'fm') { + if ( + message.samples instanceof Float32Array && + this._audioNode && + !this.state.seeking + ) { + this._audioNode.port.postMessage( + { type: 'samples', samples: message.samples }, + [message.samples.buffer], + ); + } + if (message.bins instanceof Float32Array) { + this._spectrum = message.bins; + this._spectrumSequence += 1; + for (const waiter of this._spectrumWaiters) + waiter(this._spectrumSequence); + this._spectrumWaiters.clear(); + } + if (Number.isFinite(message.snr)) this._setState({ snr: message.snr }); + return; + } + if (message.type === 'adsb') { + this._applyAdsbUpdate(message, Date.now()); + } + } + + /** Merge one decoder update: records, cumulative and per-second counts. */ + _applyAdsbUpdate(message, now) { + const decodedCount = Math.max(0, Number(message.decodedCount) || 0); + if (!this._messageWindowStartedAt) this._messageWindowStartedAt = now; + this._messageWindowCount += decodedCount; + const elapsed = now - this._messageWindowStartedAt; + const patch = { + aircraft: publicAircraft(message.aircraft), + decodedMessages: this.state.decodedMessages + decodedCount, + }; + // Cumulative count of fixes the decoder's speed check refused. + if (Number.isFinite(message.positionsRejected)) + patch.positionsRejected = Math.max(0, message.positionsRejected); + if (elapsed >= RATE_WINDOW_MS) { + patch.messagesPerSecond = + Math.round(((this._messageWindowCount * 1_000) / elapsed) * 10) / 10; + this._messageWindowStartedAt = now; + this._messageWindowCount = 0; + } + const { heard, positioned } = summarizeLocalAdsb(patch.aircraft, now); + patch.aircraftHeard = heard; + patch.aircraftPositioned = positioned; + this._setState(patch); + } + + _resetAdsbCounters() { + this._messageWindowStartedAt = 0; + this._messageWindowCount = 0; + return { messagesPerSecond: null }; + } + + /** + * Whether the session identified by `token` still owns `device`. + * @param {number} token Session token captured before an await. + * @param {object} device The device that session was configuring. + */ + _owns(token, device) { + return ( + !this._destroyed && + token === this._sessionToken && + Boolean(device) && + device === this._device + ); + } + + /** + * Tune the open device for a mode and configure the decoder worker. + * @returns {Promise} False when the session was superseded (stopped, + * torn down after a stall, or replaced) during configuration; nothing is + * published then. + */ + async _configureDevice(mode, frequencyHz, token = this._sessionToken) { + const preset = + mode === 'adsb' ? SDR_RECEIVER_PRESETS.adsb : SDR_RECEIVER_PRESETS.fm; + const sampleRate = preset.sampleRate; + const tunedFrequency = + mode === 'adsb' ? preset.frequencyHz : clampFmFrequency(frequencyHz); + const gain = this._gainByMode[mode]; + const device = this._device; + if (!this._owns(token, device)) return false; + const owned = await this._queueUsb(async () => { + const steps = [ + () => device.setSampleRate(sampleRate), + () => device.setFrequencyCorrection(preset.ppm), + () => device.setCenterFrequency(tunedFrequency), + () => device.setGain(tunerGainValue(gain)), + () => setRtlAgc(device, preset.rtlAgc), + () => device.resetBuffer(), + ]; + const results = []; + for (const step of steps) { + if (!this._owns(token, device)) return false; + results.push(await step()); + } + if (!this._owns(token, device)) return false; + const [actualRate, , actualFrequency] = results; + this._setState({ + sampleRate: Number.isFinite(actualRate) ? actualRate : sampleRate, + frequencyHz: Number.isFinite(actualFrequency) + ? actualFrequency + : tunedFrequency, + gain, + }); + return true; + }); + if (!owned || !this._owns(token, device)) return false; + this._configureWorker(); + this._audioNode?.port.postMessage({ type: 'clear' }); + return true; + } + + async connect(mode = this.state.mode) { + if (this._destroyed) return false; + if (!this.state.webUsbSupported) { + this._setState({ + status: 'unsupported', + message: 'WebUSB requires desktop Chrome or Edge', + }); + return false; + } + if (this._device) return this.setMode(mode); + const token = ++this._sessionToken; + const superseded = () => this._destroyed || token !== this._sessionToken; + this._setState({ + status: 'connecting', + message: 'Waiting for an RTL-SDR device…', + }); + let acquisition = null; + try { + const nextMode = normalizeMode(mode); + this._setState({ mode: nextMode, gain: this._gainByMode[nextMode] }); + // Resume audio completely before opening the USB chooser. A context that + // has only been constructed can still be suspended after the chooser + // consumes the click activation. + if (nextMode === 'fm' && !(await this._ensureAudio())) { + throw new Error( + 'Browser audio is suspended; press Connect again to resume it', + ); + } + if (superseded()) return false; + // An old session's device may still be closing, or an older connect may + // still be opening or cleaning up the same WebUSB device: opening it now + // would let that close act on this session. + await this._awaitUsbOwnership(superseded); + if (superseded()) return false; + acquisition = this._beginAcquisition(); + const device = await acquisition.provider.get(); + acquisition.device = device || null; + // stop() or destroy() ran while the picker/open was pending: the late + // device belongs to no session, so close it without publishing state. + if (superseded()) { + await this._discardAcquisition(acquisition, device); + return false; + } + this._provider = acquisition.provider; + this._device = device; + // The session owns the device now; teardown takes over from here. + this._endAcquisition(acquisition); + acquisition = null; + const configured = await this._configureDevice( + this.state.mode, + this._fmFrequencyHz, + token, + ); + // A stop() during configuration already closed this device. + if (!configured || superseded()) return false; + this._readGeneration += 1; + this._sampleWindowStartedAt = Date.now(); + this._sampleWindowSamples = 0; + this._setState({ + connected: true, + status: 'streaming', + message: streamingMessage(this.state.mode), + deviceLabel: this._deviceIdentity + ? sdrDeviceLabel(this._deviceIdentity) + : null, + workerBlocks: 0, + iqLevelDbfs: null, + audioLevelDbfs: null, + ...this._resetAdsbCounters(), + }); + void this._readLoop(this._readGeneration); + return true; + } catch (error) { + // provider.get() failed after it may already have opened and claimed + // the raw device (a failed initialization transfer): close whatever it + // opened, whether or not this connect was superseded meanwhile. + if (acquisition) await this._discardAcquisition(acquisition, null); + if (superseded()) { + // An installed device was closed by the stop() that superseded us. + return false; + } + console.warn('[SDR] Connection failed:', error); + const failedDevice = this._device; + const failedProvider = this._provider; + this._device = null; + this._provider = null; + // Best-effort failed-open cleanup, bounded and fenced like stop(). + if (failedDevice) + await this._teardownDevice(failedDevice, failedProvider); + if (superseded()) return false; + this._setState({ + connected: false, + status: 'error', + message: describeConnectionError(error), + }); + return false; + } + } + + /** + * Start one device acquisition. Its provider reports every WebUSB device + * it is handed, so a device opened by a `get()` that later fails can still + * be closed. + */ + _beginAcquisition() { + let settle = null; + const acquisition = { + sequence: ++this._acquisitionSequence, + usbDevices: new Set(), + provider: null, + // The RTL2832U its get() returned, once it has. + device: null, + ended: false, + // Superseded: from here on its provider cannot reach the hardware. + abandoned: false, + // A newer connect stopped waiting for it to settle. + waitedOut: false, + settled: new Promise((resolve) => { + settle = resolve; + }), + settle, + }; + acquisition.provider = this._providerFactory({ + onUsbDevice: (usbDevice) => { + acquisition.usbDevices.add(usbDevice); + }, + isFenced: () => acquisition.abandoned, + }); + this._acquisitions.add(acquisition); + return acquisition; + } + + _endAcquisition(acquisition) { + if (acquisition.ended) return; + acquisition.ended = true; + this._acquisitions.delete(acquisition); + acquisition.settle(); + } + + /** Every raw WebUSB device an acquisition opened or may have opened. */ + _acquisitionUsbDevices(acquisition, device = null) { + const usbDevices = new Set(acquisition.usbDevices); + const provider = acquisition.provider; + for (const usbDevice of [ + rawUsbDevice(device || acquisition.device, provider), + unwrapUsb(provider?.device), + ]) { + if (usbDevice && typeof usbDevice === 'object') usbDevices.add(usbDevice); + } + return usbDevices; + } + + /** + * Whether a raw WebUSB device belongs to someone other than `acquisition`: + * the installed session, or a newer acquisition that reopened it (WebUSB + * hands every opener the same `USBDevice`). + */ + _usbClaimedElsewhere(usbDevice, acquisition) { + if (!usbDevice) return false; + const installed = this._device + ? [ + rawUsbDevice(this._device, this._provider), + unwrapUsb(this._provider?.device), + ] + : []; + if (installed.includes(usbDevice)) return true; + for (const other of this._acquisitions) { + if ( + other !== acquisition && + other.sequence > acquisition.sequence && + this._acquisitionUsbDevices(other).has(usbDevice) + ) + return true; + } + return false; + } + + /** Close raw WebUSB devices, each bounded by the close deadline. */ + async _forceCloseUsbDevices(usbDevices) { + await Promise.all( + [...usbDevices].map((usbDevice) => + withDeadline( + forceCloseUsb(usbDevice), + this._closeTimeoutMs, + 'RTL-SDR USB close timed out', + ).catch((error) => { + console.warn('[SDR] USB close did not settle:', error); + }), + ), + ); + } + + /** + * Close everything an acquisition that will not become a session opened: + * the RTL2832U it returned (a superseded connect), or only the raw WebUSB + * device a failed `get()` left open. A raw device that another session + * has reopened meanwhile is left alone. Bounded; never throws. + */ + async _discardAcquisition(acquisition, device) { + if (acquisition.ended) return; + try { + const usbDevices = this._acquisitionUsbDevices(acquisition, device); + const ownRaw = rawUsbDevice(device, acquisition.provider); + const claimedElsewhere = (usbDevice) => + this._usbClaimedElsewhere(usbDevice, acquisition); + if (device) { + // A newer connect that stopped waiting may be opening this device + // right now: never let this old handle reach it gracefully. + if (acquisition.waitedOut) fenceDevice(device); + if (claimedElsewhere(ownRaw)) fenceDevice(device); + else + await this._closeDevice(device, acquisition.provider, { + queued: false, + // Re-checked when the fallback comes due: a newer session may + // have opened the device while the graceful close stalled. + mayForceClose: (usbDevice) => !claimedElsewhere(usbDevice), + }); + } + // Without an RTL2832U (a failed get()), every raw device is closed + // directly; with one, its own raw device was handled above. + const remaining = [...usbDevices].filter( + (usbDevice) => + !(device && usbDevice === ownRaw) && + !this._usbClaimedElsewhere(usbDevice, acquisition), + ); + await this._forceCloseUsbDevices(remaining); + } catch (error) { + console.warn('[SDR] Acquisition cleanup failed:', error); + } finally { + this._endAcquisition(acquisition); + } + } + + /** + * Abort a superseded acquisition that is still initializing: closing the + * raw device it opened fails its pending transfers, so its `get()` settles + * (and cleans up) promptly instead of holding up the next session. + */ + _abandonAcquisitions() { + for (const acquisition of this._acquisitions) { + if (acquisition.abandoned) continue; + acquisition.abandoned = true; + const usbDevices = [...this._acquisitionUsbDevices(acquisition)].filter( + (usbDevice) => !this._usbClaimedElsewhere(usbDevice, acquisition), + ); + if (usbDevices.length) void this._forceCloseUsbDevices(usbDevices); + } + } + + /** + * Wait until no teardown is closing a device and no older acquisition can + * still open or close one. An older acquisition is abandoned first and + * waited for within the close deadlines; one that still has not settled + * (a `get()` that ignores the closed device) stays fenced by + * `_usbClaimedElsewhere`, so it cannot close this session's device later. + */ + async _awaitUsbOwnership(superseded) { + const waitedOut = new Set(); + for (;;) { + if (this._teardown) { + await this._teardown; + if (superseded()) return; + continue; + } + const older = [...this._acquisitions].filter( + (acquisition) => !waitedOut.has(acquisition), + ); + if (!older.length) return; + this._abandonAcquisitions(); + try { + await withDeadline( + Promise.all(older.map((acquisition) => acquisition.settled)), + this._closeTimeoutMs * 3, + 'RTL-SDR acquisition did not settle', + ); + } catch { + for (const acquisition of older) { + waitedOut.add(acquisition); + this._fenceWaitedOut(acquisition); + } + } + if (superseded()) return; + } + } + + /** + * Cut an acquisition that a newer connect stopped waiting for off the + * hardware: its provider's WebUSB calls already reject (it was abandoned), + * and an RTL2832U it returned is detached from the shared `USBDevice`, so + * a graceful close still running for it cannot close the newer session's + * device. Its cleanup still closes the device unless someone else owns it. + */ + _fenceWaitedOut(acquisition) { + acquisition.abandoned = true; + acquisition.waitedOut = true; + if (acquisition.device) fenceDevice(acquisition.device); + } + + /** + * Start the single-flight teardown of a detached device. A teardown + * requested while another runs waits for it first. + * @returns {Promise} Settles within the close deadlines. + */ + _teardownDevice(device, provider) { + const prior = this._teardown || Promise.resolve(); + const teardown = prior + .then(() => this._closeDevice(device, provider, { queued: true })) + .finally(() => { + if (this._teardown === teardown) this._teardown = null; + }); + this._teardown = teardown; + return teardown; + } + + /** + * Close one device: gracefully when that finishes in time, else by closing + * the raw WebUSB device. Before the raw close, queued USB work is abandoned + * (so a graceful close still waiting in the queue never starts) and the old + * device is fenced from the hardware (so a graceful close that is already + * running cannot reach a device a later session reopens). `mayForceClose` + * is asked immediately before the raw close, so a device that someone else + * opened while the graceful close stalled is left alone. The raw close is + * bounded too; this never throws. + */ + async _closeDevice(device, provider, { queued, mayForceClose = () => true }) { + const usbDevice = rawUsbDevice(device, provider); + try { + // Graceful close waits behind queued USB work, but only so long: a + // stalled bulk read would otherwise wedge stop, mode and device + // changes behind it. + const graceful = queued + ? this._queueUsb(() => device.close()) + : Promise.resolve().then(() => device?.close()); + await withDeadline( + graceful, + this._closeTimeoutMs, + 'RTL-SDR close timed out', + ); + return; + } catch (error) { + console.warn('[SDR] Device close failed:', error); + } + if (queued) this._resetUsbQueue(); + fenceDevice(device); + if (!mayForceClose(usbDevice)) return; + try { + // Closing the raw USB device aborts any pending transfer. + await withDeadline( + forceCloseUsb(usbDevice), + this._closeTimeoutMs, + 'RTL-SDR USB close timed out', + ); + } catch (error) { + console.warn('[SDR] USB close did not settle:', error); + } + } + + /** + * Open the WebUSB picker on the next connection so the user can choose a + * different receiver (or the other channel of a dual-channel board). Call + * from a click handler: the picker needs user activation. + * @returns {Promise} Whether a receiver is streaming afterwards. + */ + async changeDevice() { + if (!this.state.webUsbSupported) return this.connect(); + this._forcePicker = true; + if (this._device) await this.stop({ preserveMessage: true }); + const connected = await this.connect(this.state.mode); + this._forcePicker = false; + return connected; + } + + async _readLoop(generation) { + while (this._device && generation === this._readGeneration) { + try { + const device = this._device; + // A transfer that never settles would hold the shared USB queue + // forever; past the deadline the session is torn down (stop() closes + // the raw USB device, which aborts the transfer). + const block = await this._queueUsb(() => + withDeadline( + device.readSamples(READ_SAMPLE_COUNT), + this._readStallMs, + 'RTL-SDR sample read stalled', + ), + ); + if (!this._device || generation !== this._readGeneration) break; + const buffer = block.data; + this._sampleWindowSamples += buffer.byteLength / 2; + const now = Date.now(); + const elapsed = now - this._sampleWindowStartedAt; + if (elapsed >= RATE_WINDOW_MS) { + this._setState({ + samplesPerSecond: Math.round( + (this._sampleWindowSamples * 1_000) / elapsed, + ), + }); + this._sampleWindowStartedAt = now; + this._sampleWindowSamples = 0; + } + this._ensureWorker().postMessage({ type: 'samples', buffer }, [buffer]); + } catch (error) { + if (generation !== this._readGeneration) break; + console.warn('[SDR] Sample read failed:', error); + this._setState({ + connected: false, + status: 'error', + message: 'RTL-SDR sample stream stopped', + }); + await this.stop({ preserveMessage: true }); + break; + } + } + } + + async setMode(mode) { + const nextMode = normalizeMode(mode); + this.cancelSeek(); + const token = this._sessionToken; + const device = this._device; + if (!device) { + this._setState({ + mode: nextMode, + gain: this._gainByMode[nextMode], + frequencyHz: + nextMode === 'adsb' ? ADSB_FREQUENCY_HZ : this._fmFrequencyHz, + sampleRate: nextMode === 'adsb' ? ADSB_SAMPLE_RATE : FM_SAMPLE_RATE, + }); + return true; + } + // A different authorized receiver preferred for this mode (the 1090 MHz + // channel of a dual-channel board, or a device remembered for the mode) + // takes over: reopen through selection instead of retuning this one. + if (nextMode !== this.state.mode && this._deviceIdentity) { + const preferred = await this._preferredDevice(nextMode); + if (!this._owns(token, device)) return false; + if (preferred && !sameSdrDevice(preferred, this._deviceIdentity)) { + await this.stop({ preserveMessage: true }); + return this.connect(nextMode); + } + } + this._setState({ + status: 'tuning', + mode: nextMode, + gain: this._gainByMode[nextMode], + message: `Switching to ${nextMode === 'adsb' ? 'ADS-B' : 'FM'}…`, + }); + try { + if (nextMode === 'fm' && !(await this._ensureAudio())) { + throw new Error( + 'Browser audio is suspended; select FM again to resume it', + ); + } + // A stall teardown, stop() or reconnect during the switch supersedes + // it: that path owns the published state, never "streaming" from here. + if (!this._owns(token, device)) return false; + const configured = await this._configureDevice( + nextMode, + this._fmFrequencyHz, + token, + ); + if (!configured || !this._owns(token, device)) return false; + this._setState({ + status: 'streaming', + message: streamingMessage(nextMode), + snr: null, + workerBlocks: 0, + iqLevelDbfs: null, + audioLevelDbfs: null, + ...this._resetAdsbCounters(), + }); + return true; + } catch (error) { + if (!this._owns(token, device)) return false; + console.warn('[SDR] Mode switch failed:', error); + this._setState({ + status: 'error', + message: describeConnectionError(error), + }); + return false; + } + } + + /** The authorized receiver selection would open for a mode, if any. */ + async _preferredDevice(mode) { + try { + return selectAuthorizedSdrDevice(await this._webUsb?.getDevices?.(), { + filters: RTL_SDR_USB_FILTERS, + mode, + remembered: this._deviceMemory.get(mode), + }); + } catch { + return null; + } + } + + /** + * Set the tuner gain for a mode and apply it live when that mode is active. + * @param {'auto'|number|string} value `'auto'` or a gain in dB. + * @param {object} [options] + * @param {'fm'|'adsb'} [options.mode] Mode to configure; defaults to current. + * @returns {Promise<'auto'|number|null>} Applied setting, or null if invalid. + */ + async setGain(value, { mode = this.state.mode } = {}) { + const targetMode = normalizeMode(mode); + const setting = normalizeSdrGain(value); + if (setting === null) return null; + this._gainByMode = { ...this._gainByMode, [targetMode]: setting }; + writeSdrGainSettings(this._storage, this._gainByMode); + if (targetMode !== this.state.mode) return setting; + this._setState({ gain: setting }); + if (!this._device) return setting; + try { + await this._queueUsb(() => + this._device?.setGain(tunerGainValue(setting)), + ); + if (targetMode === 'adsb') this._setState(this._resetAdsbCounters()); + } catch (error) { + console.warn('[SDR] Gain change failed:', error); + this._setState({ + status: 'error', + message: 'RTL-SDR gain change failed', + }); + return null; + } + return setting; + } + + /** + * Current gain setting for a mode. + * @param {'fm'|'adsb'} [mode] + * @returns {'auto'|number} + */ + getGain(mode = this.state.mode) { + const target = normalizeMode(mode); + return this._gainByMode[target] ?? SDR_GAIN_DEFAULTS[target]; + } + + async tuneFm(frequencyHz, { quiet = false } = {}) { + const frequency = clampFmFrequency(frequencyHz); + this._fmFrequencyHz = frequency; + if (!this._device) { + this._setState({ + mode: 'fm', + gain: this._gainByMode.fm, + frequencyHz: frequency, + sampleRate: FM_SAMPLE_RATE, + }); + return true; + } + if (this.state.mode !== 'fm') await this.setMode('fm'); + try { + let actualFrequency = frequency; + await this._queueUsb(async () => { + const tuned = await this._device.setCenterFrequency(frequency); + if (Number.isFinite(tuned)) actualFrequency = tuned; + await this._device.resetBuffer(); + }); + this._worker?.postMessage({ type: 'reset' }); + this._audioNode?.port.postMessage({ type: 'clear' }); + this._setState({ + frequencyHz: actualFrequency, + status: 'streaming', + ...(quiet + ? {} + : { + message: `Tuned to ${(frequency / 1_000_000).toFixed(1)} MHz FM`, + }), + }); + return true; + } catch (error) { + this._setState({ + status: 'error', + message: describeConnectionError(error), + }); + return false; + } + } + + _waitForSpectrum(afterSequence, timeoutMs = 900) { + if (this._spectrumSequence > afterSequence) return Promise.resolve(true); + return new Promise((resolve) => { + const finish = (result) => { + clearTimeout(timer); + this._spectrumWaiters.delete(waiter); + resolve(result); + }; + const waiter = (sequence) => finish(sequence > afterSequence); + const timer = setTimeout(() => finish(false), timeoutMs); + this._spectrumWaiters.add(waiter); + }); + } + + async seekFm(direction = 1) { + if (!this._device || this.state.mode !== 'fm' || this.state.seeking) + return null; + const seekDirection = direction < 0 ? -1 : 1; + const origin = this.state.frequencyHz; + const token = ++this._seekToken; + this._setState({ + seeking: true, + seekMessage: `Seeking ${seekDirection > 0 ? 'up' : 'down'}…`, + }); + this._audioNode?.port.postMessage({ type: 'volume', value: 0 }); + try { + for (let scan = 0; scan < 18 && token === this._seekToken; scan += 1) { + const span = FM_MAX_HZ - FM_MIN_HZ; + const rawCenter = origin + seekDirection * scan * 1_200_000; + const center = FM_MIN_HZ + positiveModulo(rawCenter - FM_MIN_HZ, span); + const sequence = this._spectrumSequence; + await this.tuneFm(center, { quiet: true }); + this._setState({ + seekMessage: `Scanning ${(center / 1_000_000).toFixed(1)} MHz…`, + }); + if ( + !(await this._waitForSpectrum(sequence)) || + token !== this._seekToken + ) + continue; + const peak = findDirectionalFmPeak( + this._spectrum, + this.state.frequencyHz, + this.state.sampleRate, + seekDirection, + origin, + ); + if (!peak) continue; + await this.tuneFm(peak.frequency, { quiet: true }); + this._setState({ + seeking: false, + seekMessage: `Found ${(peak.frequency / 1_000_000).toFixed(1)} MHz · ${peak.snr.toFixed(1)} dB`, + message: `FM signal found at ${(peak.frequency / 1_000_000).toFixed(1)} MHz`, + }); + return peak; + } + if (token === this._seekToken) { + await this.tuneFm(origin, { quiet: true }); + this._setState({ seeking: false, seekMessage: 'No FM signal found' }); + } + return null; + } finally { + if (token === this._seekToken) { + this._audioNode?.port.postMessage({ + type: 'volume', + value: this.state.volume, + }); + } + } + } + + cancelSeek() { + this._seekToken += 1; + if (!this.state.seeking) return false; + this._setState({ seeking: false, seekMessage: 'Seek stopped' }); + this._audioNode?.port.postMessage({ + type: 'volume', + value: this.state.volume, + }); + return true; + } + + setVolume(value) { + const volume = Math.max(0, Math.min(1, Number(value) || 0)); + this._setState({ volume }); + if (!this.state.seeking) + this._audioNode?.port.postMessage({ type: 'volume', value: volume }); + return volume; + } + + async requestReceiverLocation() { + if (typeof navigator === 'undefined' || !navigator.geolocation) { + this._setState({ + locationStatus: 'unavailable', + message: 'Browser location is unavailable', + }); + return false; + } + this._setState({ locationStatus: 'requesting' }); + try { + const position = await new Promise((resolve, reject) => { + navigator.geolocation.getCurrentPosition(resolve, reject, { + enableHighAccuracy: false, + timeout: 10_000, + maximumAge: 300_000, + }); + }); + const receiverLocation = { + latitude: position.coords.latitude, + longitude: position.coords.longitude, + }; + this._setState({ receiverLocation, locationStatus: 'ready' }); + this._worker?.postMessage({ + type: 'location', + location: receiverLocation, + }); + return true; + } catch (error) { + console.warn('[SDR] Receiver location failed:', error); + this._setState({ + locationStatus: 'denied', + message: 'Receiver location was not granted', + }); + return false; + } + } + + /** + * Close the receiver. + * @param {object} [options] + * @param {boolean} [options.preserveMessage] Keep the current status text. + * @param {string} [options.message] Status text explaining the stop. + * @returns {Promise} + */ + async stop({ preserveMessage = false, message = null } = {}) { + this.cancelSeek(); + this._readGeneration += 1; + const token = ++this._sessionToken; + const device = this._device; + const provider = this._provider; + this._device = null; + this._provider = null; + // A connect still opening a device is superseded: abort its + // initialization so it settles and closes what it opened. + this._abandonAcquisitions(); + this._audioNode?.port.postMessage({ type: 'clear' }); + // Single-flight: a stop() while another teardown runs joins it. + const teardown = device + ? this._teardownDevice(device, provider) + : this._teardown; + if (teardown) await teardown; + // A connect() that started meanwhile owns the published state now. + if (token !== this._sessionToken) return true; + const idleStatus = this.state.webUsbSupported ? 'idle' : 'unsupported'; + this._setState({ + connected: false, + status: preserveMessage ? this.state.status : idleStatus, + ...(preserveMessage + ? {} + : { message: message || 'RTL-SDR disconnected' }), + snr: null, + samplesPerSecond: 0, + workerBlocks: 0, + iqLevelDbfs: null, + audioLevelDbfs: null, + ...this._resetAdsbCounters(), + }); + return true; + } + + async destroy() { + this._destroyed = true; + await this.stop(); + this._discardWorker(); + await this._audioContext?.close(); + this._audioContext = null; + this._audioNode = null; + this.state.audioState = 'closed'; + this._listeners.clear(); + } +} + +function positiveModulo(value, divisor) { + return ((value % divisor) + divisor) % divisor; +} + +export const SDR_FM_BAND = Object.freeze({ + minHz: FM_MIN_HZ, + maxHz: FM_MAX_HZ, +}); +export const SDR_ADSB_FREQUENCY_HZ = ADSB_FREQUENCY_HZ; diff --git a/src/sdr/controller.test.mjs b/src/sdr/controller.test.mjs new file mode 100644 index 000000000..95b1eb5f2 --- /dev/null +++ b/src/sdr/controller.test.mjs @@ -0,0 +1,1021 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + createRememberingWebUsb, + SDR_RECEIVER_PRESETS, + SdrController, +} from './controller.js'; +import { RTL2832U_Provider } from '@jtarrio/webrtlsdr/rtlsdr.js'; +import { RTL_SDR_USB_FILTERS } from './usbDevices.js'; +import { SDR_GAIN_STORAGE_KEY } from './gain.js'; + +function replaceGlobal(name, value) { + const prior = Object.getOwnPropertyDescriptor(globalThis, name); + Object.defineProperty(globalThis, name, { + configurable: true, + writable: true, + value, + }); + return () => { + if (prior) Object.defineProperty(globalThis, name, prior); + else delete globalThis[name]; + }; +} + +function memoryStorage(initial = {}) { + const values = new Map(Object.entries(initial)); + return { + values, + getItem: (key) => (values.has(key) ? values.get(key) : null), + setItem: (key, value) => values.set(key, String(value)), + }; +} + +class FakeWorker { + postMessage() {} + terminate() {} +} + +function fakeDevice(events = [], name = 'device') { + return { + setSampleRate: async (rate) => rate, + setFrequencyCorrection: async () => {}, + setCenterFrequency: async (frequency) => { + events.push(`${name}:tune:${frequency}`); + return frequency; + }, + setGain: async (gain) => { + events.push(`${name}:gain:${gain}`); + }, + _enableRtlAgc: async (enabled) => { + events.push(`rtl-agc:${enabled}`); + }, + resetBuffer: async () => {}, + // Resolve like hardware so queued USB work can run between blocks. + readSamples: () => + new Promise((resolve) => + setTimeout(() => resolve({ data: new ArrayBuffer(8) }), 2), + ), + close: async () => { + events.push(`${name}:close`); + }, + }; +} + +test('FM resumes audio before opening WebUSB and reports the running context', async (t) => { + const events = []; + class FakeAudioContext { + constructor() { + events.push('audio:create'); + this.state = 'suspended'; + this.destination = {}; + this.audioWorklet = { + addModule: async () => { + events.push('audio:module'); + }, + }; + this.onstatechange = null; + } + + async resume() { + events.push('audio:resume'); + this.state = 'running'; + this.onstatechange?.(); + } + + async close() { + this.state = 'closed'; + } + } + class FakeAudioWorkletNode { + constructor() { + this.port = { postMessage() {} }; + } + + connect() {} + } + const device = fakeDevice(events); + const restore = [ + replaceGlobal('navigator', { usb: {} }), + replaceGlobal('AudioContext', FakeAudioContext), + replaceGlobal('AudioWorkletNode', FakeAudioWorkletNode), + replaceGlobal('Worker', FakeWorker), + ]; + t.after(() => restore.reverse().forEach((callback) => callback())); + + const controller = new SdrController({ + storage: memoryStorage(), + providerFactory: () => ({ + async get() { + events.push('usb:get'); + return device; + }, + }), + }); + t.after(() => controller.stop()); + + assert.equal(await controller.connect('fm'), true); + assert.ok(events.indexOf('audio:resume') < events.indexOf('usb:get')); + assert.equal(controller.getState().audioState, 'running'); + assert.equal(controller.getState().connected, true); + assert.ok(events.includes('rtl-agc:false')); + assert.ok(events.includes('device:gain:null'), 'FM defaults to tuner AGC'); + assert.equal(controller.getState().gain, 'auto'); +}); + +test('receiver presets keep the known-working tuning settings', () => { + assert.deepEqual(SDR_RECEIVER_PRESETS.fm, { + frequencyHz: 98_500_000, + sampleRate: 2_048_000, + ppm: 0, + rtlAgc: false, + }); + assert.deepEqual(SDR_RECEIVER_PRESETS.adsb, { + frequencyHz: 1_090_000_000, + sampleRate: 2_000_000, + ppm: 0, + rtlAgc: false, + }); +}); + +test('ADS-B opens at manual 28.0 dB and gain changes apply live without reconnecting', async (t) => { + const events = []; + let opens = 0; + const storage = memoryStorage(); + t.after(replaceGlobal('Worker', FakeWorker)); + const controller = new SdrController({ + storage, + webUsb: {}, + providerFactory: () => ({ + async get() { + opens += 1; + return fakeDevice(events); + }, + }), + }); + t.after(() => controller.stop()); + assert.equal(controller.getState().gain, 'auto'); + assert.equal(await controller.connect('adsb'), true); + assert.ok(events.includes('device:gain:28')); + assert.equal(controller.getState().gain, 28); + + assert.equal(await controller.setGain('36.4'), 36.4); + assert.equal(events.at(-1), 'device:gain:36.4'); + assert.equal(await controller.setGain('auto'), 'auto'); + assert.equal(events.at(-1), 'device:gain:null'); + assert.equal(opens, 1, 'gain changes never reopen the receiver'); + assert.deepEqual(JSON.parse(storage.values.get(SDR_GAIN_STORAGE_KEY)), { + fm: 'auto', + adsb: 'auto', + }); + + const before = events.length; + assert.equal(await controller.setGain(28, { mode: 'fm' }), 28); + assert.equal(events.length, before, 'another mode is stored, not applied'); + assert.equal(controller.getGain('fm'), 28); + assert.equal(controller.getState().gain, 'auto'); + assert.equal(await controller.setGain('very loud'), null); +}); + +test('stored per-mode gain is restored on the next session', async (t) => { + const events = []; + t.after(replaceGlobal('Worker', FakeWorker)); + const controller = new SdrController({ + storage: memoryStorage({ + [SDR_GAIN_STORAGE_KEY]: JSON.stringify({ fm: 'auto', adsb: 12.5 }), + }), + webUsb: {}, + providerFactory: () => ({ get: async () => fakeDevice(events) }), + }); + t.after(() => controller.stop()); + await controller.connect('adsb'); + assert.ok(events.includes('device:gain:12.5')); +}); + +test('ADS-B updates report CRC-valid messages per second, heard and positioned', () => { + const controller = new SdrController({ + storage: memoryStorage(), + webUsb: {}, + }); + const heardOnly = { + icao: 'a15c54', + lat: null, + lon: null, + lastPositionAt: null, + lastMessageAt: 10_000, + }; + const positioned = { + icao: 'ae5d8a', + lat: 30.27, + lon: -97.79, + lastPositionAt: 10_000, + lastMessageAt: 10_000, + }; + controller._applyAdsbUpdate( + { aircraft: [heardOnly, positioned], decodedCount: 12 }, + 10_000, + ); + assert.equal(controller.getState().messagesPerSecond, null); + controller._applyAdsbUpdate( + { aircraft: [heardOnly, positioned], decodedCount: 13 }, + 12_000, + ); + const state = controller.getState(); + assert.equal(state.messagesPerSecond, 12.5); + assert.equal(state.decodedMessages, 25); + assert.equal(state.aircraftHeard, 2); + assert.equal(state.aircraftPositioned, 1); +}); + +test('switching to ADS-B moves to the authorized 1090 MHz channel of a dual board', async (t) => { + const events = []; + t.after(replaceGlobal('Worker', FakeWorker)); + const uat = { + vendorId: 0x0bda, + productId: 0x2838, + productName: 'FlyCatcher_UAT', + serialNumber: '00000001', + }; + const adsb = { ...uat, productName: 'FlyCatcher_ADS_B' }; + const webUsb = { + async getDevices() { + return [uat, adsb]; + }, + async requestDevice() { + throw new Error('picker should not open'); + }, + }; + const controller = new SdrController({ + storage: memoryStorage(), + webUsb, + providerFactory: (selectingUsb) => ({ + async get() { + const chosen = await selectingUsb.requestDevice({ + filters: RTL_SDR_USB_FILTERS, + }); + return fakeDevice(events, chosen.productName); + }, + }), + }); + t.after(() => controller.stop()); + // FM on a FlyCatcher has no preferred channel, so the first one opens. + controller.state.mode = 'fm'; + controller._ensureAudio = async () => true; + assert.equal(await controller.connect('fm'), true); + assert.equal(controller.getState().deviceLabel, 'FlyCatcher_UAT'); + + assert.equal(await controller.setMode('adsb'), true); + assert.ok(events.includes('FlyCatcher_UAT:close')); + assert.ok(events.includes('FlyCatcher_ADS_B:tune:1090000000')); + assert.equal(controller.getState().deviceLabel, 'FlyCatcher_ADS_B'); + assert.equal(controller.getState().mode, 'adsb'); + assert.equal(controller.getState().connected, true); +}); + +test('CHANGE DEVICE reopens through the WebUSB picker', async (t) => { + t.after(replaceGlobal('Worker', FakeWorker)); + let pickerCalls = 0; + const device = { + vendorId: 0x0bda, + productId: 0x2838, + productName: 'RTL2838UHIDIR', + serialNumber: '1', + }; + const controller = new SdrController({ + storage: memoryStorage(), + webUsb: { + async getDevices() { + return [device]; + }, + async requestDevice() { + pickerCalls += 1; + return device; + }, + }, + providerFactory: (selectingUsb) => ({ + async get() { + await selectingUsb.requestDevice({ filters: RTL_SDR_USB_FILTERS }); + return fakeDevice(); + }, + }), + }); + t.after(() => controller.stop()); + controller.state.mode = 'adsb'; + assert.equal(await controller.connect('adsb'), true); + assert.equal(pickerCalls, 0, 'an authorized receiver opens without a picker'); + assert.equal(await controller.changeDevice(), true); + assert.equal(pickerCalls, 1); + assert.equal(controller.getState().connected, true); +}); + +test('remembered WebUSB receiver is reused without opening the picker', async () => { + const remembered = { + vendorId: 0x0bda, + productId: 0x2838, + serialNumber: 'known', + }; + let pickerCalls = 0; + const webUsb = createRememberingWebUsb({ + async getDevices() { + return [remembered]; + }, + async requestDevice() { + pickerCalls += 1; + return null; + }, + }); + + const result = await webUsb.requestDevice({ + filters: [{ vendorId: 0x0bda, productId: 0x2838 }], + }); + + assert.equal(result, remembered); + assert.equal(pickerCalls, 0); +}); + +test('WebUSB picker remains available when no authorized receiver matches', async () => { + const selected = { vendorId: 0x0bda, productId: 0x2832 }; + let pickerCalls = 0; + const webUsb = createRememberingWebUsb({ + async getDevices() { + return [{ vendorId: 0x1234, productId: 0x5678 }]; + }, + async requestDevice() { + pickerCalls += 1; + return selected; + }, + }); + + const result = await webUsb.requestDevice({ + filters: [{ vendorId: 0x0bda, productId: 0x2832 }], + }); + + assert.equal(result, selected); + assert.equal(pickerCalls, 1); +}); + +/** Resolve with the promise's value, or 'timed out' after `ms`. */ +function within(promise, ms = 1_000) { + return Promise.race([ + promise, + new Promise((resolve) => setTimeout(() => resolve('timed out'), ms)), + ]); +} + +/** + * A device whose bulk reads hang until the raw WebUSB device is closed, + * the way a stalled `transferIn` behaves: `USBDevice.close()` aborts it. + */ +function stallingDevice(events, name = 'stalled') { + const pendingReads = new Set(); + const usb = { + opened: true, + async close() { + events.push(`${name}:usb-close`); + usb.opened = false; + for (const reject of pendingReads) reject(new Error('AbortError')); + pendingReads.clear(); + }, + }; + const base = fakeDevice(events, name); + return { + ...base, + com: { device: usb }, + readSamples: () => + new Promise((_, reject) => { + events.push(`${name}:read`); + pendingReads.add(reject); + }), + // The graceful close needs the bus, which the stalled transfer holds. + close: () => new Promise(() => events.push(`${name}:graceful-close`)), + }; +} + +test('a stalled USB read cannot wedge stop(): the raw device is closed and the queue replaced', async (t) => { + const events = []; + t.after(replaceGlobal('Worker', FakeWorker)); + const devices = [stallingDevice(events), fakeDevice(events, 'fresh')]; + const controller = new SdrController({ + storage: memoryStorage(), + webUsb: {}, + readStallMs: 60_000, + closeTimeoutMs: 50, + providerFactory: () => ({ get: async () => devices.shift() }), + }); + t.after(() => controller.destroy()); + assert.equal(await controller.connect('adsb'), true); + await new Promise((resolve) => setTimeout(resolve, 5)); + assert.ok(events.includes('stalled:read'), 'a read is in flight'); + + assert.equal(await within(controller.stop()), true, 'stop() returns'); + assert.ok(events.includes('stalled:usb-close'), 'raw USB device closed'); + assert.equal(controller.getState().connected, false); + + // The next session is not queued behind the abandoned transfer. + assert.equal(await within(controller.connect('adsb')), true); + assert.ok(events.includes('fresh:tune:1090000000')); +}); + +test('a stalled USB read times out and tears the session down on its own', async (t) => { + const events = []; + t.after(replaceGlobal('Worker', FakeWorker)); + const controller = new SdrController({ + storage: memoryStorage(), + webUsb: {}, + readStallMs: 30, + closeTimeoutMs: 30, + providerFactory: () => ({ get: async () => stallingDevice(events) }), + }); + t.after(() => controller.destroy()); + assert.equal(await controller.connect('adsb'), true); + await new Promise((resolve) => setTimeout(resolve, 150)); + const state = controller.getState(); + assert.equal(state.connected, false); + assert.equal(state.status, 'error'); + assert.equal(state.message, 'RTL-SDR sample stream stopped'); + assert.ok(events.includes('stalled:usb-close')); + // Mode changes work again instead of queuing behind the dead read. + assert.equal(await within(controller.setMode('fm')), true); +}); + +test('destroy() during a pending connect closes the late device instead of resuming the session', async (t) => { + const events = []; + const workers = []; + class CountingWorker extends FakeWorker { + constructor() { + super(); + workers.push(this); + } + } + t.after(replaceGlobal('Worker', CountingWorker)); + let deliver = null; + const controller = new SdrController({ + storage: memoryStorage(), + webUsb: {}, + providerFactory: () => ({ + get: () => + new Promise((resolve) => { + deliver = resolve; + }), + }), + }); + const connecting = controller.connect('adsb'); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(typeof deliver, 'function', 'device acquisition is pending'); + await controller.destroy(); + deliver(fakeDevice(events, 'late')); + assert.equal(await within(connecting), false); + assert.ok(events.includes('late:close'), 'late device is closed'); + assert.ok(!events.includes('late:tune:1090000000'), 'never configured'); + assert.equal(controller.getState().connected, false); + assert.notEqual(controller.getState().status, 'streaming'); + assert.equal(workers.length, 0, 'no decoder worker is created'); +}); + +test('a failed decoder worker is discarded and reconnect builds a fresh one', async (t) => { + const events = []; + const workers = []; + class RecordingWorker { + constructor() { + this.messages = []; + this.terminated = false; + workers.push(this); + } + + postMessage(message) { + this.messages.push(message.type); + } + + terminate() { + this.terminated = true; + } + } + t.after(replaceGlobal('Worker', RecordingWorker)); + const controller = new SdrController({ + storage: memoryStorage(), + webUsb: {}, + providerFactory: () => ({ get: async () => fakeDevice(events) }), + }); + t.after(() => controller.destroy()); + assert.equal(await controller.connect('adsb'), true); + assert.equal(workers.length, 1); + workers[0].onerror?.({ message: 'module failed to load' }); + await new Promise((resolve) => setTimeout(resolve, 10)); + assert.equal(controller.getState().status, 'error'); + assert.equal(workers[0].terminated, true, 'failed worker terminated'); + + await controller.stop(); + assert.equal(await controller.connect('adsb'), true); + assert.equal(workers.length, 2, 'reconnect creates a fresh worker'); + assert.ok(workers[1].messages.includes('configure')); + assert.equal(controller.getState().status, 'streaming'); +}); + +/** + * One WebUSB device shared by every session: WebUSB hands a reopened + * receiver back as the SAME `USBDevice` object. Like the real one, close() + * aborts pending transfers at once and resolves a little later. + */ +function sharedUsb(events, { closeNeverSettles = false } = {}) { + const usb = { + opened: false, + pendingReads: new Set(), + async open() { + events.push('usb-open'); + usb.opened = true; + }, + close() { + events.push('usb-close'); + usb.opened = false; + for (const reject of usb.pendingReads) reject(new Error('AbortError')); + usb.pendingReads.clear(); + if (closeNeverSettles) return new Promise(() => {}); + return new Promise((resolve) => setTimeout(resolve, 5)); + }, + }; + return usb; +} + +/** + * An RTL2832U over `usb` that, like the library, reaches the hardware + * through `com.device`; its graceful close can be held open by `closeGate`. + */ +function rtlOver(usb, events, name, { stallReads = false, closeGate } = {}) { + const device = { + ...fakeDevice(events, name), + com: { device: usb }, + readSamples() { + if (stallReads) + return new Promise((_, reject) => { + events.push(`${name}:read`); + usb.pendingReads.add(reject); + }); + return new Promise((resolve, reject) => + setTimeout( + () => + usb.opened + ? resolve({ data: new ArrayBuffer(8) }) + : reject(new Error('device closed')), + 2, + ), + ); + }, + async close() { + events.push(`${name}:graceful-close`); + if (closeGate) await closeGate; + await device.com.device.close(); + }, + }; + return device; +} + +function gate() { + let open; + const promise = new Promise((resolve) => { + open = resolve; + }); + return { promise, open }; +} + +const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +function sharedUsbController(usb, devices) { + return new SdrController({ + storage: memoryStorage(), + webUsb: {}, + readStallMs: 60_000, + closeTimeoutMs: 30, + providerFactory: () => ({ + async get() { + await usb.open(); + return devices.shift(); + }, + }), + }); +} + +test('a graceful close queued behind a stalled read never runs once the raw device is closed', async (t) => { + const events = []; + t.after(replaceGlobal('Worker', FakeWorker)); + const usb = sharedUsb(events); + const held = gate(); + const controller = sharedUsbController(usb, [ + rtlOver(usb, events, 'old', { stallReads: true, closeGate: held.promise }), + rtlOver(usb, events, 'new'), + ]); + t.after(() => controller.destroy()); + assert.equal(await controller.connect('adsb'), true); + await pause(5); + assert.equal(await within(controller.stop()), true); + assert.equal(await within(controller.connect('adsb')), true); + // Whatever the old session still had queued is released now. + held.open(); + await pause(30); + assert.ok( + !events.includes('old:graceful-close'), + 'queued close abandoned before the raw close', + ); + assert.equal(usb.opened, true, 'the reopened device stays open'); + const state = controller.getState(); + assert.equal(state.status, 'streaming'); + assert.equal(state.connected, true); +}); + +test('a graceful close already running when abandoned cannot close the reopened device', async (t) => { + const events = []; + t.after(replaceGlobal('Worker', FakeWorker)); + const usb = sharedUsb(events); + const held = gate(); + const controller = sharedUsbController(usb, [ + rtlOver(usb, events, 'old', { closeGate: held.promise }), + rtlOver(usb, events, 'new'), + ]); + t.after(() => controller.destroy()); + assert.equal(await controller.connect('adsb'), true); + await pause(5); + assert.equal(await within(controller.stop()), true); + assert.ok(events.includes('old:graceful-close'), 'graceful close started'); + assert.equal(await within(controller.connect('adsb')), true); + // The old close resumes, as a stuck control transfer finally completes. + held.open(); + await pause(30); + assert.equal(usb.opened, true, 'the reopened device stays open'); + const state = controller.getState(); + assert.equal(state.status, 'streaming'); + assert.equal(state.connected, true); +}); + +test('teardown is single-flight: a second stop() and a connect() wait for the device close', async (t) => { + const events = []; + t.after(replaceGlobal('Worker', FakeWorker)); + const usb = sharedUsb(events); + const controller = sharedUsbController(usb, [ + // The graceful close never finishes: only the raw close ends it. + rtlOver(usb, events, 'old', { closeGate: new Promise(() => {}) }), + rtlOver(usb, events, 'new'), + ]); + t.after(() => controller.destroy()); + assert.equal(await controller.connect('adsb'), true); + const first = controller.stop(); + let secondDone = false; + const second = controller.stop().then(() => { + secondDone = true; + events.push('second-stop-done'); + }); + const reconnect = controller.connect('adsb'); + await pause(5); + assert.equal(secondDone, false, 'the second stop joins the teardown'); + await within(Promise.all([first, second])); + assert.equal(await within(reconnect), true); + assert.ok( + events.indexOf('usb-close') < events.indexOf('second-stop-done'), + 'second stop returns after the device is closed', + ); + assert.ok( + events.indexOf('usb-close') < events.lastIndexOf('usb-open'), + 'the device is reopened only after the old session closed it', + ); + await pause(30); + assert.equal(usb.opened, true); + assert.equal(controller.getState().status, 'streaming'); + assert.equal(controller.getState().connected, true); +}); + +test('stop() settles within its deadline even when the raw USB close never settles', async (t) => { + const events = []; + t.after(replaceGlobal('Worker', FakeWorker)); + const usb = sharedUsb(events, { closeNeverSettles: true }); + const controller = sharedUsbController(usb, [ + rtlOver(usb, events, 'old', { stallReads: true }), + ]); + t.after(() => controller.destroy()); + assert.equal(await controller.connect('adsb'), true); + await pause(5); + const startedAt = Date.now(); + assert.equal(await within(controller.stop(), 500), true); + // Graceful close deadline plus raw close deadline, with scheduling slack. + assert.ok(Date.now() - startedAt < 250, `${Date.now() - startedAt} ms`); + assert.ok(events.includes('usb-close'), 'raw close attempted'); + assert.equal(controller.getState().connected, false); +}); + +test('a mode change queued behind a stalled read never reports streaming after the stall teardown', async (t) => { + const events = []; + t.after(replaceGlobal('Worker', FakeWorker)); + const controller = new SdrController({ + storage: memoryStorage(), + webUsb: {}, + readStallMs: 30, + closeTimeoutMs: 30, + providerFactory: () => ({ get: async () => stallingDevice(events) }), + }); + t.after(() => controller.destroy()); + assert.equal(await controller.connect('adsb'), true); + const switched = await within(controller.setMode('adsb')); + assert.equal(switched, false, 'the superseded switch fails'); + await pause(100); + const state = controller.getState(); + assert.notEqual(state.status, 'streaming'); + assert.equal(state.connected, false); + assert.equal(controller._device, null); +}); + +/** + * A raw WebUSB RTL-SDR for the installed `RTL2832U_Provider`: open and claim + * succeed, and the first initialization transfer fails, either at once or + * (with `hold`) only when the device is closed, like a stuck control transfer. + */ +function failingInitUsb(events, { hold = false } = {}) { + const pending = new Set(); + const usb = { + vendorId: 0x0bda, + productId: 0x2838, + productName: 'RTL2838UHIDIR', + serialNumber: 'init-fails', + opened: false, + async open() { + events.push('open'); + usb.opened = true; + }, + async claimInterface() { + events.push('claim'); + }, + async releaseInterface() {}, + controlTransferOut() { + events.push('init-transfer'); + if (!hold) return Promise.reject(new Error('transfer error')); + return new Promise((_, reject) => pending.add(reject)); + }, + controlTransferIn() { + return Promise.reject(new Error('transfer error')); + }, + async close() { + events.push('close'); + usb.opened = false; + for (const reject of pending) reject(new Error('AbortError')); + pending.clear(); + }, + }; + return usb; +} + +function failingInitController(usb) { + return new SdrController({ + storage: memoryStorage(), + closeTimeoutMs: 30, + // No providerFactory: the installed RTL2832U_Provider opens the device. + webUsb: { + async getDevices() { + return [usb]; + }, + async requestDevice() { + return usb; + }, + }, + }); +} + +test('a receiver whose initialization fails is closed and destroy() settles', async (t) => { + const events = []; + t.after(replaceGlobal('Worker', FakeWorker)); + const usb = failingInitUsb(events); + const controller = failingInitController(usb); + assert.equal(await within(controller.connect('adsb')), false); + assert.deepEqual(events, ['open', 'claim', 'init-transfer', 'close']); + assert.equal(usb.opened, false, 'the raw device is not left open'); + assert.equal(controller.getState().status, 'error'); + assert.equal(controller.getState().connected, false); + assert.notEqual(await within(controller.destroy()), 'timed out'); + assert.equal(controller._acquisitions.size, 0); +}); + +test('a superseded receiver initialization that then fails still closes the device', async (t) => { + const events = []; + t.after(replaceGlobal('Worker', FakeWorker)); + const usb = failingInitUsb(events, { hold: true }); + const controller = failingInitController(usb); + const connecting = controller.connect('adsb'); + await pause(5); + assert.ok(events.includes('init-transfer'), 'initialization is in flight'); + assert.notEqual(await within(controller.destroy()), 'timed out'); + assert.equal(await within(connecting), false); + assert.ok(events.includes('close')); + assert.equal(usb.opened, false, 'the raw device is not left open'); + assert.notEqual(controller.getState().status, 'streaming'); + assert.equal(controller._acquisitions.size, 0); +}); + +/** + * A provider shaped like the library's: it records the raw device before + * opening it, and its initialization can be held open by `initGate`. + */ +function heldProvider(usb, events, name, initGate) { + return { + device: undefined, + async get() { + this.device = usb; + await usb.open(); + events.push(`${name}:init`); + if (initGate) await initGate; + return rtlOver(usb, events, name); + }, + }; +} + +function acquisitionController(providers, closeTimeoutMs = 30) { + return new SdrController({ + storage: memoryStorage(), + webUsb: {}, + readStallMs: 60_000, + closeTimeoutMs, + providerFactory: () => providers.shift(), + }); +} + +test('a newer connect waits for an older acquisition to finish closing its device', async (t) => { + const events = []; + t.after(replaceGlobal('Worker', FakeWorker)); + const usb = sharedUsb(events); + const heldA = gate(); + const controller = acquisitionController( + [ + heldProvider(usb, events, 'A', heldA.promise), + heldProvider(usb, events, 'B'), + ], + // Long enough that B does not give up on A while it is held. + 1_000, + ); + t.after(() => controller.destroy()); + const connectA = controller.connect('adsb'); + await pause(5); + const connectB = controller.connect('adsb'); + await pause(5); + assert.ok(!events.includes('B:init'), 'B waits for A'); + heldA.open(); + assert.equal(await within(connectA), false, 'A was superseded'); + assert.equal(await within(connectB), true); + assert.ok( + events.lastIndexOf('usb-close') < events.indexOf('B:init'), + 'A closed its device before B opened it', + ); + await pause(30); + assert.equal(usb.opened, true, "B's device stays open"); + assert.equal(controller.getState().status, 'streaming'); + assert.equal(controller.getState().connected, true); +}); + +test("a held older acquisition released after a newer session starts never closes that session's device", async (t) => { + const events = []; + t.after(replaceGlobal('Worker', FakeWorker)); + const usb = sharedUsb(events); + const heldA = gate(); + const controller = acquisitionController([ + heldProvider(usb, events, 'A', heldA.promise), + heldProvider(usb, events, 'B'), + ]); + t.after(() => controller.destroy()); + const connectA = controller.connect('adsb'); + await pause(5); + // B gives up waiting on the stuck acquisition A and completes. + assert.equal(await within(controller.connect('adsb')), true); + assert.equal(controller.getState().status, 'streaming'); + const closes = () => events.filter((event) => event === 'usb-close').length; + const closesBeforeRelease = closes(); + heldA.open(); + assert.equal(await within(connectA), false); + await pause(30); + assert.equal( + closes(), + closesBeforeRelease, + "A's cleanup does not close the device B reopened", + ); + assert.ok(!events.includes('A:graceful-close')); + assert.equal(usb.opened, true); + const state = controller.getState(); + assert.equal(state.status, 'streaming'); + assert.equal(state.connected, true); + assert.equal(controller._acquisitions.size, 0); +}); + +test("an older acquisition's cleanup that outlives the wait never closes the newer session's device", async (t) => { + const events = []; + t.after(replaceGlobal('Worker', FakeWorker)); + const usb = sharedUsb(events); + const heldA = gate(); + // A's graceful close never finishes: only its fallback raw close ends it. + const providerA = { + device: undefined, + async get() { + this.device = usb; + await usb.open(); + events.push('A:init'); + await heldA.promise; + return rtlOver(usb, events, 'A', { closeGate: new Promise(() => {}) }); + }, + }; + const controller = acquisitionController( + [providerA, heldProvider(usb, events, 'B')], + 40, + ); + t.after(() => controller.destroy()); + const connectA = controller.connect('adsb'); + await pause(5); + const connectB = controller.connect('adsb'); + // A's cleanup starts shortly before B stops waiting for it (3 x 40 ms), so + // its fallback raw close comes due after B is already streaming. + await pause(100); + heldA.open(); + assert.equal(await within(connectA), false, 'A was superseded'); + assert.ok(events.includes('A:graceful-close'), "A's cleanup is running"); + assert.equal(await within(connectB), true); + const closesAfterB = events.filter((event) => event === 'usb-close').length; + await pause(80); + assert.equal( + events.filter((event) => event === 'usb-close').length, + closesAfterB, + "A's fallback close does not close B's device", + ); + assert.equal(usb.opened, true, "B's device stays open"); + const state = controller.getState(); + assert.equal(state.status, 'streaming'); + assert.equal(state.connected, true); +}); + +test("a device selection that resolves after its acquisition was superseded cannot touch the newer session's device", async (t) => { + const events = []; + t.after(replaceGlobal('Worker', FakeWorker)); + const usb = Object.assign(sharedUsb(events), { + vendorId: 0x0bda, + productId: 0x2838, + productName: 'RTL2838UHIDIR', + serialNumber: 'shared', + async claimInterface() { + events.push('usb-claim'); + }, + async releaseInterface() { + events.push('usb-release'); + }, + async controlTransferOut() { + events.push('usb-write'); + throw new Error('transfer error'); + }, + async controlTransferIn() { + events.push('usb-read'); + throw new Error('transfer error'); + }, + }); + const heldSelection = gate(); + let getDevicesCalls = 0; + const webUsb = { + async getDevices() { + getDevicesCalls += 1; + // A's selection is held; B's resolves at once. + if (getDevicesCalls === 1) await heldSelection.promise; + return [usb]; + }, + async requestDevice() { + return usb; + }, + }; + let providers = 0; + const controller = new SdrController({ + storage: memoryStorage(), + webUsb, + readStallMs: 60_000, + closeTimeoutMs: 20, + providerFactory: (webusb) => { + providers += 1; + // A is the installed library provider; B opens the same device + // through the same selecting wrapper and streams. + if (providers === 1) return new RTL2832U_Provider({ webusb }); + return { + device: undefined, + async get() { + this.device = await webusb.requestDevice({ + filters: RTL_SDR_USB_FILTERS, + }); + await this.device.open(); + return rtlOver(this.device, events, 'B'); + }, + }; + }, + }); + t.after(() => controller.destroy()); + const connectA = controller.connect('adsb'); + await pause(5); + assert.equal(await within(controller.connect('adsb')), true); + assert.equal(controller.getState().status, 'streaming'); + const eventsBeforeRelease = events.length; + heldSelection.open(); + assert.equal(await within(connectA), false, 'A was superseded'); + await pause(30); + const late = events + .slice(eventsBeforeRelease) + .filter((event) => event.startsWith('usb-')); + assert.deepEqual(late, [], "A's late selection never reaches the device"); + assert.equal(usb.opened, true, "B's device stays open"); + const state = controller.getState(); + assert.equal(state.status, 'streaming'); + assert.equal(state.connected, true); +}); diff --git a/src/sdr/gain.js b/src/sdr/gain.js new file mode 100644 index 000000000..761edf013 --- /dev/null +++ b/src/sdr/gain.js @@ -0,0 +1,93 @@ +/** + * Tuner gain settings for the local RTL-SDR, remembered per receiver mode. + * + * `'auto'` hands gain to the tuner's AGC. Manual values are the R820T gain + * steps librtlsdr and dump1090 expose, in dB. Receivers with an onboard LNA + * (for example a Nooelec FlyCatcher with its LNA on) overload at AGC or high + * gain, so the ADS-B default is a fixed step. It is 28.0 dB rather than the + * lower steps dump1090 users pick because the WebUSB library maps dB to tuner + * gain stages differently from librtlsdr: on a FlyCatcher, 20.7 dB here gave + * about 1 msg/s and 28.0 dB about 9 msg/s on the same antenna. + */ + +export const SDR_GAIN_AUTO = 'auto'; + +/** R820T tuner gain steps in dB, as listed by librtlsdr. */ +export const R820T_GAIN_STEPS_DB = Object.freeze([ + 0.0, 0.9, 1.4, 2.7, 3.7, 7.7, 8.7, 12.5, 14.4, 15.7, 16.6, 19.7, 20.7, 22.9, + 25.4, 28.0, 29.7, 32.8, 33.8, 36.4, 37.2, 38.6, 40.2, 42.1, 43.4, 43.9, 44.5, + 48.0, 49.6, +]); + +export const SDR_GAIN_DEFAULTS = Object.freeze({ + fm: SDR_GAIN_AUTO, + adsb: 28.0, +}); + +export const SDR_GAIN_STORAGE_KEY = 'gev:sdr:gain:v1'; + +/** + * Normalize a requested gain to `'auto'` or the nearest listed R820T step. + * @param {*} value `'auto'`, a dB number or a numeric string. + * @returns {'auto'|number|null} Normalized setting, or null when invalid. + */ +export function normalizeSdrGain(value) { + if (value === null || value === undefined || value === '') return null; + if (String(value).trim().toLowerCase() === SDR_GAIN_AUTO) + return SDR_GAIN_AUTO; + const requested = Number(value); + if (!Number.isFinite(requested)) return null; + let nearest = R820T_GAIN_STEPS_DB[0]; + for (const step of R820T_GAIN_STEPS_DB) { + if (Math.abs(step - requested) < Math.abs(nearest - requested)) + nearest = step; + } + return nearest; +} + +/** + * Read per-mode gain settings, falling back to the defaults. + * @param {Storage|null} storage Browser storage or a test double. + * @returns {{fm:'auto'|number, adsb:'auto'|number}} + */ +export function readSdrGainSettings(storage) { + let stored = null; + try { + stored = JSON.parse(storage?.getItem?.(SDR_GAIN_STORAGE_KEY) || 'null'); + } catch { + stored = null; + } + return Object.fromEntries( + Object.entries(SDR_GAIN_DEFAULTS).map(([mode, fallback]) => [ + mode, + normalizeSdrGain(stored?.[mode]) ?? fallback, + ]), + ); +} + +/** + * Persist per-mode gain settings. Storage failures are ignored. + * @param {Storage|null} storage Browser storage or a test double. + * @param {{fm:'auto'|number, adsb:'auto'|number}} settings Settings to save. + * @returns {boolean} Whether the write succeeded. + */ +export function writeSdrGainSettings(storage, settings) { + try { + storage?.setItem?.( + SDR_GAIN_STORAGE_KEY, + JSON.stringify({ fm: settings.fm, adsb: settings.adsb }), + ); + return Boolean(storage?.setItem); + } catch { + return false; + } +} + +/** + * The value handed to the tuner: null selects AGC. + * @param {'auto'|number} setting Normalized gain setting. + * @returns {number|null} + */ +export function tunerGainValue(setting) { + return setting === SDR_GAIN_AUTO ? null : setting; +} diff --git a/src/sdr/gain.test.mjs b/src/sdr/gain.test.mjs new file mode 100644 index 000000000..60f21c6be --- /dev/null +++ b/src/sdr/gain.test.mjs @@ -0,0 +1,78 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + R820T_GAIN_STEPS_DB, + SDR_GAIN_DEFAULTS, + SDR_GAIN_STORAGE_KEY, + normalizeSdrGain, + readSdrGainSettings, + tunerGainValue, + writeSdrGainSettings, +} from './gain.js'; + +function memoryStorage(initial = {}) { + const values = new Map(Object.entries(initial)); + return { + values, + getItem: (key) => (values.has(key) ? values.get(key) : null), + setItem: (key, value) => values.set(key, String(value)), + }; +} + +test('gain steps are the R820T table and ADS-B defaults to manual 28.0 dB', () => { + assert.equal(R820T_GAIN_STEPS_DB.length, 29); + assert.equal(R820T_GAIN_STEPS_DB[0], 0); + assert.equal(R820T_GAIN_STEPS_DB.at(-1), 49.6); + assert.ok(R820T_GAIN_STEPS_DB.includes(20.7)); + assert.ok(R820T_GAIN_STEPS_DB.includes(28.0)); + assert.deepEqual(SDR_GAIN_DEFAULTS, { fm: 'auto', adsb: 28.0 }); + assert.equal(tunerGainValue('auto'), null, 'AUTO hands gain to tuner AGC'); + assert.equal(tunerGainValue(20.7), 20.7); +}); + +test('requested gains normalize to AUTO or the nearest listed step', () => { + assert.equal(normalizeSdrGain('AUTO'), 'auto'); + assert.equal(normalizeSdrGain('36.4'), 36.4); + assert.equal(normalizeSdrGain(21), 20.7); + assert.equal(normalizeSdrGain(100), 49.6); + assert.equal(normalizeSdrGain(-3), 0); + assert.equal(normalizeSdrGain('loud'), null); + assert.equal(normalizeSdrGain(null), null); +}); + +test('per-mode gain persists in storage and survives unavailable storage', () => { + const storage = memoryStorage(); + assert.deepEqual(readSdrGainSettings(storage), SDR_GAIN_DEFAULTS); + assert.equal(writeSdrGainSettings(storage, { fm: 28, adsb: 'auto' }), true); + assert.equal( + storage.values.get(SDR_GAIN_STORAGE_KEY), + '{"fm":28,"adsb":"auto"}', + ); + assert.deepEqual(readSdrGainSettings(storage), { fm: 28, adsb: 'auto' }); + + const corrupt = memoryStorage({ [SDR_GAIN_STORAGE_KEY]: '{not json' }); + assert.deepEqual(readSdrGainSettings(corrupt), SDR_GAIN_DEFAULTS); + const throwing = { + getItem() { + throw new Error('blocked'); + }, + setItem() { + throw new Error('blocked'); + }, + }; + assert.deepEqual(readSdrGainSettings(throwing), SDR_GAIN_DEFAULTS); + assert.equal(writeSdrGainSettings(throwing, SDR_GAIN_DEFAULTS), false); + assert.deepEqual(readSdrGainSettings(null), SDR_GAIN_DEFAULTS); +}); + +test('a stored ADS-B gain wins over the 28.0 dB default', () => { + const storage = memoryStorage({ + [SDR_GAIN_STORAGE_KEY]: JSON.stringify({ fm: 'auto', adsb: 20.7 }), + }); + assert.deepEqual(readSdrGainSettings(storage), { fm: 'auto', adsb: 20.7 }); + const onlyFm = memoryStorage({ + [SDR_GAIN_STORAGE_KEY]: JSON.stringify({ fm: 36.4 }), + }); + assert.deepEqual(readSdrGainSettings(onlyFm), { fm: 36.4, adsb: 28.0 }); +}); diff --git a/src/sdr/sdr.worker.js b/src/sdr/sdr.worker.js new file mode 100644 index 000000000..07f3c5bc6 --- /dev/null +++ b/src/sdr/sdr.worker.js @@ -0,0 +1,168 @@ +import '@jtarrio/signals/demod/demodulator.js'; +import { getDemod, getMode } from '@jtarrio/signals/demod/modes.js'; +import { + AdsbStreamDecoder, + decodeAdsbMessage, + pruneAircraftTracks, + updateAircraftTrack, +} from './adsbDecoder.js'; +import { analyzeSpectrum } from './spectrum.js'; +import { recordFromDecoderTrack } from '../sources/adsbRecords.js'; + +let mode = 'fm'; +let sampleRate = 2_048_000; +let receiverLocation = null; +let demodulator = null; +let blockCount = 0; +const aircraft = new Map(); +// Positions refused by the decoder's speed check since the last clear. +const decoderStats = { positionsRejected: 0 }; +const adsbStream = new AdsbStreamDecoder(); + +function configure(nextMode, nextSampleRate, location) { + const modeChanged = mode !== (nextMode === 'adsb' ? 'adsb' : 'fm'); + mode = nextMode === 'adsb' ? 'adsb' : 'fm'; + sampleRate = + Number(nextSampleRate) || (mode === 'adsb' ? 2_000_000 : 2_048_000); + receiverLocation = location || receiverLocation; + blockCount = 0; + demodulator = + mode === 'fm' + ? getDemod(sampleRate, 48_000, { ...getMode('WBFM'), stereo: false }) + : null; + if (modeChanged) { + adsbStream.reset(); + aircraft.clear(); + } +} + +function normalizedIq(buffer) { + const bytes = new Uint8Array(buffer); + const length = Math.floor(bytes.length / 2); + const i = new Float32Array(length); + const q = new Float32Array(length); + let power = 0; + for (let index = 0; index < length; index += 1) { + i[index] = (bytes[index * 2] - 127.5) / 128; + q[index] = (bytes[index * 2 + 1] - 127.5) / 128; + power += i[index] * i[index] + q[index] * q[index]; + } + return { + i, + q, + iqLevelDbfs: 10 * Math.log10(Math.max(power / Math.max(length, 1), 1e-12)), + }; +} + +function sampledIqLevel(buffer) { + const bytes = new Uint8Array(buffer); + let power = 0; + let count = 0; + for (let index = 0; index + 1 < bytes.length; index += 32) { + const i = (bytes[index] - 127.5) / 128; + const q = (bytes[index + 1] - 127.5) / 128; + power += i * i + q * q; + count += 1; + } + return 10 * Math.log10(Math.max(power / Math.max(count, 1), 1e-12)); +} + +function audioLevel(samples) { + let power = 0; + for (const sample of samples) power += sample * sample; + return ( + 20 * + Math.log10(Math.max(Math.sqrt(power / Math.max(samples.length, 1)), 1e-12)) + ); +} + +function publicAircraft() { + const records = []; + for (const track of aircraft.values()) { + const record = recordFromDecoderTrack(track); + if (record) records.push(record); + } + return records; +} + +self.onmessage = (event) => { + const message = event.data || {}; + if (message.type === 'configure') { + configure(message.mode, message.sampleRate, message.location); + return; + } + if (message.type === 'location') { + receiverLocation = message.location || null; + return; + } + if (message.type === 'reset') { + blockCount = 0; + adsbStream.reset(); + if (message.clearAircraft) { + aircraft.clear(); + decoderStats.positionsRejected = 0; + } + if (mode === 'fm') configure(mode, sampleRate, receiverLocation); + return; + } + if (message.type !== 'samples' || !(message.buffer instanceof ArrayBuffer)) + return; + + blockCount += 1; + if (mode === 'fm') { + const bins = blockCount % 3 === 0 ? analyzeSpectrum(message.buffer) : null; + const { i, q, iqLevelDbfs } = normalizedIq(message.buffer); + const decoded = demodulator.demodulate(i, q, 0); + // The demodulator owns pooled output arrays. Transfer a copy so detaching + // the message buffer cannot invalidate the pool used by the next block. + const samples = new Float32Array(decoded.left); + self.postMessage( + { + type: 'fm', + samples, + bins, + snr: + Number.isFinite(decoded.snr) && decoded.snr > 0 + ? 10 * Math.log10(decoded.snr) + : null, + diagnostics: + blockCount % 15 === 0 + ? { + workerBlocks: blockCount, + iqLevelDbfs, + audioLevelDbfs: audioLevel(samples), + } + : null, + }, + [samples.buffer, ...(bins ? [bins.buffer] : [])], + ); + return; + } + + const now = Date.now(); + let decodedCount = 0; + for (const bytes of adsbStream.extract(message.buffer, sampleRate)) { + const decoded = decodeAdsbMessage(bytes, { receivedAt: now }); + if (!decoded) continue; + updateAircraftTrack(aircraft, decoded, receiverLocation, decoderStats); + decodedCount += 1; + } + const removed = pruneAircraftTracks(aircraft, now); + if (decodedCount || removed || blockCount % 30 === 0) { + self.postMessage({ + type: 'adsb', + aircraft: publicAircraft(), + decodedCount, + positionsRejected: decoderStats.positionsRejected, + diagnostics: + blockCount % 30 === 0 + ? { + workerBlocks: blockCount, + iqLevelDbfs: sampledIqLevel(message.buffer), + } + : null, + }); + } +}; + +configure(mode, sampleRate, receiverLocation); diff --git a/src/sdr/sdrAudioWorklet.js b/src/sdr/sdrAudioWorklet.js new file mode 100644 index 000000000..4983e43bf --- /dev/null +++ b/src/sdr/sdrAudioWorklet.js @@ -0,0 +1,48 @@ +class GevSdrAudioPlayer extends AudioWorkletProcessor { + constructor() { + super(); + this.queue = []; + this.offset = 0; + this.volume = 0.8; + this.port.onmessage = (event) => { + if ( + event.data?.type === 'samples' && + event.data.samples instanceof Float32Array + ) { + this.queue.push(event.data.samples); + if (this.queue.length > 48) + this.queue.splice(0, this.queue.length - 48); + } else if (event.data?.type === 'volume') { + this.volume = Math.max(0, Math.min(1, Number(event.data.value) || 0)); + } else if (event.data?.type === 'clear') { + this.queue.length = 0; + this.offset = 0; + } + }; + } + + process(_inputs, outputs) { + const output = outputs[0]?.[0]; + if (!output) return true; + output.fill(0); + let outputOffset = 0; + while (outputOffset < output.length && this.queue.length) { + const samples = this.queue[0]; + const available = samples.length - this.offset; + const count = Math.min(output.length - outputOffset, available); + for (let index = 0; index < count; index += 1) { + output[outputOffset + index] = + samples[this.offset + index] * this.volume; + } + outputOffset += count; + this.offset += count; + if (this.offset >= samples.length) { + this.queue.shift(); + this.offset = 0; + } + } + return true; + } +} + +registerProcessor('gev-sdr-audio-player', GevSdrAudioPlayer); diff --git a/src/sdr/spectrum.js b/src/sdr/spectrum.js new file mode 100644 index 000000000..acec025c4 --- /dev/null +++ b/src/sdr/spectrum.js @@ -0,0 +1,147 @@ +function nextPowerOfTwo(value) { + return 2 ** Math.floor(Math.log2(Math.max(2, value))); +} + +function fft(real, imag) { + const length = real.length; + for (let index = 1, reversed = 0; index < length; index += 1) { + let bit = length >> 1; + while (reversed & bit) { + reversed ^= bit; + bit >>= 1; + } + reversed ^= bit; + if (index >= reversed) continue; + [real[index], real[reversed]] = [real[reversed], real[index]]; + [imag[index], imag[reversed]] = [imag[reversed], imag[index]]; + } + for (let size = 2; size <= length; size <<= 1) { + const angle = (-2 * Math.PI) / size; + const stepReal = Math.cos(angle); + const stepImag = Math.sin(angle); + for (let start = 0; start < length; start += size) { + let twiddleReal = 1; + let twiddleImag = 0; + for (let index = 0; index < size / 2; index += 1) { + const even = start + index; + const odd = even + size / 2; + const oddReal = real[odd] * twiddleReal - imag[odd] * twiddleImag; + const oddImag = real[odd] * twiddleImag + imag[odd] * twiddleReal; + real[odd] = real[even] - oddReal; + imag[odd] = imag[even] - oddImag; + real[even] += oddReal; + imag[even] += oddImag; + const nextReal = twiddleReal * stepReal - twiddleImag * stepImag; + twiddleImag = twiddleReal * stepImag + twiddleImag * stepReal; + twiddleReal = nextReal; + } + } + } +} + +/** Create an FFT-shifted dB power spectrum from interleaved unsigned 8-bit IQ. */ +export function analyzeSpectrum(buffer, maxBins = 2_048) { + const iq = new Uint8Array(buffer); + const length = Math.min(nextPowerOfTwo(Math.floor(iq.length / 2)), maxBins); + const real = new Float32Array(length); + const imag = new Float32Array(length); + const sampleOffset = Math.max(0, Math.floor((iq.length / 2 - length) / 2)); + for (let index = 0; index < length; index += 1) { + const window = 0.5 - 0.5 * Math.cos((2 * Math.PI * index) / (length - 1)); + real[index] = ((iq[(sampleOffset + index) * 2] - 127.5) / 128) * window; + imag[index] = ((iq[(sampleOffset + index) * 2 + 1] - 127.5) / 128) * window; + } + fft(real, imag); + const bins = new Float32Array(length); + for (let index = 0; index < length; index += 1) { + const shifted = (index + length / 2) % length; + const power = (real[shifted] ** 2 + imag[shifted] ** 2) / length ** 2; + bins[index] = 10 * Math.log10(power + 1e-12); + } + return bins; +} + +function median(values) { + const sorted = [...values].sort((a, b) => a - b); + return sorted[Math.floor(sorted.length / 2)] ?? -120; +} + +function channelPower(bins, center, radius) { + let total = 0; + let count = 0; + for ( + let index = Math.max(0, center - radius); + index <= Math.min(bins.length - 1, center + radius); + index += 1 + ) { + total += 10 ** (bins[index] / 10); + count += 1; + } + return 10 * Math.log10(total / Math.max(count, 1) + 1e-12); +} + +/** Find the nearest directional broadcast-FM channel peak in one spectrum frame. */ +export function findDirectionalFmPeak( + bins, + centerFrequency, + sampleRate, + direction, + afterFrequency, + { + minFrequency = 87_500_000, + maxFrequency = 108_000_000, + thresholdDb = 6, + } = {}, +) { + if ( + !(bins instanceof Float32Array) || + bins.length < 16 || + !Number.isFinite(sampleRate) + ) + return null; + const binWidth = sampleRate / bins.length; + const channelRadius = Math.max(1, Math.round(55_000 / binWidth)); + const comparisonDistance = Math.max( + channelRadius + 1, + Math.round(150_000 / binWidth), + ); + const powers = new Float32Array(bins.length); + for (let index = 0; index < bins.length; index += 1) { + powers[index] = channelPower(bins, index, channelRadius); + } + const noiseFloor = median(powers); + const candidates = []; + const frameMin = centerFrequency - sampleRate / 2; + const frameMax = centerFrequency + sampleRate / 2; + const firstChannel = + Math.ceil(Math.max(minFrequency, frameMin) / 100_000) * 100_000; + const lastChannel = + Math.floor(Math.min(maxFrequency, frameMax) / 100_000) * 100_000; + for ( + let frequency = firstChannel; + frequency <= lastChannel; + frequency += 100_000 + ) { + if (direction > 0 && frequency <= afterFrequency + 75_000) continue; + if (direction < 0 && frequency >= afterFrequency - 75_000) continue; + const index = Math.round( + (frequency - centerFrequency) / binWidth + bins.length / 2, + ); + if (index < comparisonDistance || index >= bins.length - comparisonDistance) + continue; + const power = powers[index]; + if (power - noiseFloor < thresholdDb) continue; + if ( + power < powers[index - comparisonDistance] || + power < powers[index + comparisonDistance] + ) + continue; + candidates.push({ frequency, snr: power - noiseFloor, power }); + } + candidates.sort( + (a, b) => + Math.abs(a.frequency - afterFrequency) - + Math.abs(b.frequency - afterFrequency) || b.snr - a.snr, + ); + return candidates[0] || null; +} diff --git a/src/sdr/spectrum.test.mjs b/src/sdr/spectrum.test.mjs new file mode 100644 index 000000000..551b3f824 --- /dev/null +++ b/src/sdr/spectrum.test.mjs @@ -0,0 +1,43 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { analyzeSpectrum, findDirectionalFmPeak } from './spectrum.js'; + +function toneIq(frequencyOffset, sampleRate, samples = 2_048) { + const iq = new Uint8Array(samples * 2); + for (let index = 0; index < samples; index += 1) { + const phase = (2 * Math.PI * frequencyOffset * index) / sampleRate; + iq[index * 2] = Math.round(127.5 + 100 * Math.cos(phase)); + iq[index * 2 + 1] = Math.round(127.5 + 100 * Math.sin(phase)); + } + return iq.buffer; +} + +test('FFT-shifted spectrum preserves the sign and offset of a complex tone', () => { + const sampleRate = 2_048_000; + const bins = analyzeSpectrum(toneIq(300_000, sampleRate)); + let peakIndex = 0; + for (let index = 1; index < bins.length; index += 1) { + if (bins[index] > bins[peakIndex]) peakIndex = index; + } + const offset = (peakIndex - bins.length / 2) * (sampleRate / bins.length); + assert.ok(Math.abs(offset - 300_000) < 2_000); +}); + +test('directional FM seek chooses the nearest rounded station in-band', () => { + const sampleRate = 2_048_000; + const bins = analyzeSpectrum(toneIq(300_000, sampleRate)); + const forward = findDirectionalFmPeak( + bins, + 98_500_000, + sampleRate, + 1, + 98_500_000, + ); + assert.equal(forward.frequency, 98_800_000); + assert.ok(forward.snr > 6); + assert.equal( + findDirectionalFmPeak(bins, 98_500_000, sampleRate, -1, 98_500_000), + null, + ); +}); diff --git a/src/sdr/usbDevices.js b/src/sdr/usbDevices.js new file mode 100644 index 000000000..4a4cd1ab4 --- /dev/null +++ b/src/sdr/usbDevices.js @@ -0,0 +1,203 @@ +/** + * WebUSB receiver selection for the local RTL-SDR. + * + * Dual-channel boards such as the Nooelec FlyCatcher enumerate as two USB + * devices (an ADS-B 1090 MHz channel and a UAT 978 MHz channel) that can share + * a serial number. Selection therefore remembers the full identity + * (vendorId, productId, productName, serialNumber) per receiver mode and, for + * ADS-B, prefers a channel whose product name names ADS-B or 1090 MHz. + */ + +export const SDR_DEVICE_STORAGE_KEY = 'gev:sdr:device:v1'; +/** RTL2832U vendor/product pairs requested by the WebUSB RTL-SDR provider. */ +export const RTL_SDR_USB_FILTERS = Object.freeze([ + Object.freeze({ vendorId: 0x0bda, productId: 0x2832 }), + Object.freeze({ vendorId: 0x0bda, productId: 0x2838 }), +]); +const ADSB_PRODUCT_PATTERN = /ads.?b|1090/i; +const NON_FM_PRODUCT_PATTERN = /ads.?b|1090|uat|978/i; + +function text(value) { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +/** + * Plain identity of a WebUSB device (or a stored identity). + * @param {object|null} device USBDevice-like object. + * @returns {{vendorId:number|null, productId:number|null, productName:string|null, serialNumber:string|null}|null} + */ +export function sdrDeviceIdentity(device) { + if (!device || typeof device !== 'object') return null; + return { + vendorId: Number.isInteger(device.vendorId) ? device.vendorId : null, + productId: Number.isInteger(device.productId) ? device.productId : null, + productName: text(device.productName), + serialNumber: text(device.serialNumber), + }; +} + +/** + * Whether two devices or identities name the same receiver channel. + * @param {object|null} a Device or identity. + * @param {object|null} b Device or identity. + * @returns {boolean} + */ +export function sameSdrDevice(a, b) { + const left = sdrDeviceIdentity(a); + const right = sdrDeviceIdentity(b); + return Boolean( + left && + right && + left.vendorId === right.vendorId && + left.productId === right.productId && + left.productName === right.productName && + left.serialNumber === right.serialNumber, + ); +} + +/** + * Human-readable receiver label for status text. + * @param {object|null} device Device or identity. + * @returns {string} + */ +export function sdrDeviceLabel(device) { + const identity = sdrDeviceIdentity(device); + if (!identity) return 'RTL-SDR'; + return identity.productName || 'RTL-SDR'; +} + +function filterMatchesDevice(filter, device) { + return ( + (filter.vendorId === undefined || filter.vendorId === device.vendorId) && + (filter.productId === undefined || filter.productId === device.productId) && + (filter.serialNumber === undefined || + filter.serialNumber === device.serialNumber) + ); +} + +/** + * Choose an already-authorized receiver without opening the WebUSB picker. + * Order: the device remembered for this mode; then, for ADS-B, a channel + * named for ADS-B/1090 MHz, or for FM a channel not named for ADS-B/UAT; then + * the first matching device. + * @param {object[]} devices Authorized devices from `navigator.usb.getDevices()`. + * @param {object} [options] + * @param {object[]} [options.filters] WebUSB request filters. + * @param {'fm'|'adsb'} [options.mode] Receiver mode being connected. + * @param {object|null} [options.remembered] Identity remembered for this mode. + * @returns {object|null} Chosen device, or null when the picker is needed. + */ +export function selectAuthorizedSdrDevice( + devices, + { filters = [], mode = 'fm', remembered = null } = {}, +) { + const candidates = (Array.isArray(devices) ? devices : []).filter( + (device) => + device && + (filters.length === 0 || + filters.some((filter) => filterMatchesDevice(filter, device))), + ); + if (!candidates.length) return null; + if (remembered) { + const match = candidates.find((device) => + sameSdrDevice(device, remembered), + ); + if (match) return match; + } + const preferred = + mode === 'adsb' + ? candidates.find((device) => + ADSB_PRODUCT_PATTERN.test(device.productName || ''), + ) + : candidates.find( + (device) => !NON_FM_PRODUCT_PATTERN.test(device.productName || ''), + ); + return preferred || candidates[0]; +} + +/** + * Per-mode remembered receiver identities in browser storage. + * @param {Storage|null} storage Browser storage or a test double. + * @returns {{get(mode:string):object|null, set(mode:string, device:object):void}} + */ +export function createSdrDeviceMemory(storage) { + const read = () => { + try { + const value = JSON.parse( + storage?.getItem?.(SDR_DEVICE_STORAGE_KEY) || 'null', + ); + return value && typeof value === 'object' ? value : {}; + } catch { + return {}; + } + }; + return { + get(mode) { + return sdrDeviceIdentity(read()[mode]); + }, + set(mode, device) { + const identity = sdrDeviceIdentity(device); + if (!identity) return; + try { + storage?.setItem?.( + SDR_DEVICE_STORAGE_KEY, + JSON.stringify({ ...read(), [mode]: identity }), + ); + } catch { + /* storage can be unavailable or quota-limited */ + } + }, + }; +} + +/** + * Wrap `navigator.usb` so the RTL-SDR provider reuses an authorized receiver + * for the current mode before asking WebUSB to show its picker. + * @param {USB} webUsb Browser WebUSB object. + * @param {object} [options] + * @param {() => 'fm'|'adsb'} [options.getMode] Mode being connected. + * @param {{get:Function,set:Function}} [options.memory] Remembered identities. + * @param {() => boolean} [options.consumeForcePicker] Returns true once when + * the user asked to choose a different device. + * @param {(device:object) => void} [options.onSelected] Selected device hook. + * @returns {USB} Proxy exposing the same WebUSB surface. + */ +export function createRememberingWebUsb( + webUsb, + { + getMode = () => 'fm', + memory = null, + consumeForcePicker = () => false, + onSelected = () => {}, + } = {}, +) { + if (!webUsb?.getDevices || !webUsb?.requestDevice) return webUsb; + return new Proxy(webUsb, { + get(target, property) { + if (property === 'requestDevice') { + return async (options = {}) => { + const mode = getMode(); + const filters = Array.isArray(options.filters) ? options.filters : []; + let device = null; + if (!consumeForcePicker()) { + device = selectAuthorizedSdrDevice(await target.getDevices(), { + filters, + mode, + remembered: memory?.get(mode) || null, + }); + } + if (!device) { + // Only an explicit picker choice is remembered; automatic + // selection is re-derived from the preference on every connect. + device = await target.requestDevice(options); + if (device) memory?.set(mode, device); + } + if (device) onSelected(device); + return device; + }; + } + const value = Reflect.get(target, property, target); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); +} diff --git a/src/sdr/usbDevices.test.mjs b/src/sdr/usbDevices.test.mjs new file mode 100644 index 000000000..70ba67d0e --- /dev/null +++ b/src/sdr/usbDevices.test.mjs @@ -0,0 +1,222 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + SDR_DEVICE_STORAGE_KEY, + createRememberingWebUsb, + createSdrDeviceMemory, + sameSdrDevice, + selectAuthorizedSdrDevice, +} from './usbDevices.js'; + +const RTL_FILTERS = [ + { vendorId: 0x0bda, productId: 0x2832 }, + { vendorId: 0x0bda, productId: 0x2838 }, +]; + +// A FlyCatcher enumerates as two RTL2832U devices that may share a serial. +const flyCatcherUat = { + vendorId: 0x0bda, + productId: 0x2838, + productName: 'FlyCatcher_UAT', + serialNumber: '00000001', +}; +const flyCatcherAdsb = { + vendorId: 0x0bda, + productId: 0x2838, + productName: 'FlyCatcher_ADS_B', + serialNumber: '00000001', +}; +const genericDongle = { + vendorId: 0x0bda, + productId: 0x2838, + productName: 'RTL2838UHIDIR', + serialNumber: '00000001', +}; +const unrelated = { vendorId: 0x1234, productId: 0x5678, productName: 'Other' }; + +function memoryStorage() { + const values = new Map(); + return { + values, + getItem: (key) => (values.has(key) ? values.get(key) : null), + setItem: (key, value) => values.set(key, String(value)), + }; +} + +test('ADS-B mode prefers the 1090 MHz channel of a dual-channel board', () => { + const devices = [unrelated, flyCatcherUat, flyCatcherAdsb]; + assert.equal( + selectAuthorizedSdrDevice(devices, { filters: RTL_FILTERS, mode: 'adsb' }), + flyCatcherAdsb, + ); + assert.equal( + selectAuthorizedSdrDevice( + [genericDongle, { ...flyCatcherAdsb, productName: 'Receiver 1090' }], + { + filters: RTL_FILTERS, + mode: 'adsb', + }, + ).productName, + 'Receiver 1090', + ); + assert.equal( + selectAuthorizedSdrDevice([genericDongle], { + filters: RTL_FILTERS, + mode: 'adsb', + }), + genericDongle, + 'a single generic dongle is still used for ADS-B', + ); +}); + +test('FM mode avoids ADS-B/UAT channels, and nothing matches unrelated devices', () => { + assert.equal( + selectAuthorizedSdrDevice([flyCatcherAdsb, flyCatcherUat, genericDongle], { + filters: RTL_FILTERS, + mode: 'fm', + }), + genericDongle, + ); + assert.equal( + selectAuthorizedSdrDevice([unrelated], { + filters: RTL_FILTERS, + mode: 'fm', + }), + null, + ); + assert.equal(selectAuthorizedSdrDevice([], { mode: 'adsb' }), null); +}); + +test('a remembered device wins, matched by the full identity rather than serial alone', () => { + const devices = [flyCatcherAdsb, flyCatcherUat]; + assert.equal( + selectAuthorizedSdrDevice(devices, { + filters: RTL_FILTERS, + mode: 'adsb', + remembered: { ...flyCatcherUat }, + }), + flyCatcherUat, + 'an explicit earlier choice is honored even for ADS-B', + ); + assert.equal(sameSdrDevice(flyCatcherAdsb, flyCatcherUat), false); + assert.equal(sameSdrDevice(flyCatcherAdsb, { ...flyCatcherAdsb }), true); + assert.equal( + selectAuthorizedSdrDevice(devices, { + filters: RTL_FILTERS, + mode: 'adsb', + remembered: { ...flyCatcherAdsb, serialNumber: 'unplugged' }, + }), + flyCatcherAdsb, + 'an absent remembered device falls back to the ADS-B preference', + ); +}); + +test('device memory is per mode and tolerates unavailable storage', () => { + const storage = memoryStorage(); + const memory = createSdrDeviceMemory(storage); + memory.set('adsb', flyCatcherAdsb); + memory.set('fm', genericDongle); + assert.deepEqual(memory.get('adsb'), flyCatcherAdsb); + assert.deepEqual(memory.get('fm'), genericDongle); + assert.ok( + storage.values.get(SDR_DEVICE_STORAGE_KEY).includes('FlyCatcher_ADS_B'), + ); + const blocked = createSdrDeviceMemory({ + getItem() { + throw new Error('blocked'); + }, + setItem() { + throw new Error('blocked'); + }, + }); + assert.equal(blocked.get('adsb'), null); + assert.doesNotThrow(() => blocked.set('adsb', flyCatcherAdsb)); +}); + +test('the WebUSB wrapper reuses the remembered channel without the picker', async () => { + const memory = createSdrDeviceMemory(memoryStorage()); + memory.set('adsb', flyCatcherAdsb); + let pickerCalls = 0; + const selected = []; + const webUsb = createRememberingWebUsb( + { + async getDevices() { + return [flyCatcherUat, flyCatcherAdsb]; + }, + async requestDevice() { + pickerCalls += 1; + return null; + }, + }, + { + getMode: () => 'adsb', + memory, + onSelected: (device) => selected.push(device), + }, + ); + assert.equal( + await webUsb.requestDevice({ filters: RTL_FILTERS }), + flyCatcherAdsb, + ); + assert.equal(pickerCalls, 0); + assert.deepEqual(selected, [flyCatcherAdsb]); +}); + +test('CHANGE DEVICE forces the picker once and remembers the choice for the mode', async () => { + const memory = createSdrDeviceMemory(memoryStorage()); + memory.set('adsb', flyCatcherAdsb); + let force = true; + let pickerCalls = 0; + const webUsb = createRememberingWebUsb( + { + async getDevices() { + return [flyCatcherUat, flyCatcherAdsb]; + }, + async requestDevice() { + pickerCalls += 1; + return flyCatcherUat; + }, + }, + { + getMode: () => 'adsb', + memory, + consumeForcePicker: () => { + const value = force; + force = false; + return value; + }, + }, + ); + assert.equal( + await webUsb.requestDevice({ filters: RTL_FILTERS }), + flyCatcherUat, + ); + assert.equal(pickerCalls, 1); + assert.deepEqual(memory.get('adsb'), flyCatcherUat); + assert.equal( + await webUsb.requestDevice({ filters: RTL_FILTERS }), + flyCatcherUat, + ); + assert.equal(pickerCalls, 1, 'the next connection reuses the new choice'); +}); + +test('automatic selection is not remembered, so a fallback never becomes sticky', async () => { + const memory = createSdrDeviceMemory(memoryStorage()); + const webUsb = createRememberingWebUsb( + { + async getDevices() { + return [flyCatcherUat]; + }, + async requestDevice() { + throw new Error('picker should not open'); + }, + }, + { getMode: () => 'adsb', memory }, + ); + assert.equal( + await webUsb.requestDevice({ filters: RTL_FILTERS }), + flyCatcherUat, + ); + assert.equal(memory.get('adsb'), null); +}); diff --git a/src/sources/adsbRecords.js b/src/sources/adsbRecords.js new file mode 100644 index 000000000..9c0082700 --- /dev/null +++ b/src/sources/adsbRecords.js @@ -0,0 +1,400 @@ +/** + * Source-agnostic records for locally received ADS-B aircraft. + * + * Every local receiver path produces the same plain record, so the Local ADS-B + * renderer does not care whether the frames were demodulated in this browser + * from a WebUSB RTL-SDR or decoded by a dump1090/readsb/dump978 process whose + * `aircraft.json` the server reads as a decoder feed: + * + * { + * icao: string, // lowercase 24-bit hex address + * callsign: string|null, + * lat: number|null, // degrees, null until a position decodes + * lon: number|null, + * altitudeFt: number|null, // barometric altitude + * groundSpeedKt: number|null, + * trackDeg: number|null, // true track over ground, [0, 360) + * verticalRateFpm: number|null, + * category: string|null, // ADS-B emitter category, dump1090 style + * // ('A1' light, 'A3' large, 'A7' rotorcraft…) + * onGround: boolean, // the receiver reported the aircraft on the + * // surface (dump1090 `alt_baro: "ground"`) + * lastPositionAt: number|null, // epoch ms of the newest decoded position + * lastMessageAt: number, // epoch ms of the newest CRC-valid message + * messageCount: number, + * rssiDbfs: number|null, + * band: '1090'|'978', // 1090 MHz Mode S/ES or 978 MHz UAT + * source: 'webusb'|'feed', // browser SDR or decoder feed + * } + * + * This module is portable: no Cesium, DOM, network or storage access. + */ + +/** A marker is dropped once its newest position is this old. */ +export const LOCAL_ADSB_POSITION_STALE_MS = 60_000; +/** An aircraft is forgotten once no message has arrived for this long. */ +export const LOCAL_ADSB_MESSAGE_STALE_MS = 60_000; + +const ICAO_PATTERN = /^[0-9a-f]{6}$/; +const CATEGORY_PATTERN = /^[A-D][0-7]$/; + +/** A previous accepted fix older than this is no reference for a new one. */ +export const LOCAL_ADSB_REFERENCE_MAX_AGE_MS = 10 * 60_000; +/** Speed limit (kt) for an aircraft that has not reported a ground speed. */ +export const LOCAL_ADSB_UNKNOWN_SPEED_LIMIT_KT = 1_000; +/** Tighter limit (kt) for light aircraft and rotorcraft without a speed. */ +export const LOCAL_ADSB_SLOW_CATEGORY_LIMIT_KT = 350; +/** Reported ground speed is multiplied by this, plus the margin below. */ +export const LOCAL_ADSB_SPEED_FACTOR = 1.5; +export const LOCAL_ADSB_SPEED_MARGIN_KT = 50; +/** Distance always allowed between two fixes (CPR and reception error). */ +export const LOCAL_ADSB_POSITION_MARGIN_M = 500; +/** + * After this many consecutive rejected fixes the newest one becomes the new + * reference: the previously accepted fix was the outlier, not the stream. + */ +export const LOCAL_ADSB_REANCHOR_AFTER = 3; +const SLOW_CATEGORIES = new Set(['A1', 'A7', 'B1', 'B4']); +const KT_TO_MPS = 1852 / 3600; +const EARTH_RADIUS_M = 6_371_008.8; + +function finiteOrNull(value) { + const number = typeof value === 'string' ? Number.NaN : Number(value); + return value !== null && value !== undefined && Number.isFinite(number) + ? number + : null; +} + +function normalizeIcao(value) { + const icao = String(value ?? '') + .trim() + .toLowerCase(); + return ICAO_PATTERN.test(icao) ? icao : null; +} + +function normalizeCallsign(value) { + const callsign = String(value ?? '') + .replace(/[^0-9A-Za-z]/g, ' ') + .trim() + .toUpperCase(); + return callsign || null; +} + +function normalizeTrack(value) { + const track = finiteOrNull(value); + return track === null ? null : ((track % 360) + 360) % 360; +} + +/** + * Normalize an ADS-B emitter category to the dump1090 string ('A7'). + * @param {unknown} value + * @returns {string|null} + */ +export function normalizeAdsbCategory(value) { + const category = String(value ?? '') + .trim() + .toUpperCase(); + return CATEGORY_PATTERN.test(category) ? category : null; +} + +function surfaceDistanceM(lat1, lon1, lat2, lon2) { + const toRad = Math.PI / 180; + const dLat = (lat2 - lat1) * toRad; + const dLon = (lon2 - lon1) * toRad; + const a = + Math.sin(dLat / 2) ** 2 + + Math.cos(lat1 * toRad) * Math.cos(lat2 * toRad) * Math.sin(dLon / 2) ** 2; + return 2 * EARTH_RADIUS_M * Math.asin(Math.min(1, Math.sqrt(a))); +} + +/** + * The fastest an aircraft is assumed to move, in knots: 1.5 × its reported + * ground speed + 50 kt, or 1,000 kt (350 kt for light aircraft, gliders and + * rotorcraft) when no speed is known. + * @param {{groundSpeedKt?:number|null, category?:string|null}} [options] + * @returns {number} + */ +export function localAdsbSpeedLimitKt({ + groundSpeedKt = null, + category = null, +} = {}) { + if (Number.isFinite(groundSpeedKt)) + return ( + Math.max(0, groundSpeedKt) * LOCAL_ADSB_SPEED_FACTOR + + LOCAL_ADSB_SPEED_MARGIN_KT + ); + return SLOW_CATEGORIES.has(normalizeAdsbCategory(category)) + ? LOCAL_ADSB_SLOW_CATEGORY_LIMIT_KT + : LOCAL_ADSB_UNKNOWN_SPEED_LIMIT_KT; +} + +/** + * dump1090-style position sanity check: whether `next` is reachable from the + * previous accepted fix at a plausible speed. The allowance is a fixed + * reception margin plus the distance covered in the elapsed time + 1 s at + * 1.5 × the reported ground speed + 50 kt, or at 1,000 kt (350 kt for light + * aircraft, gliders and rotorcraft) when no speed is known. Without a + * reference younger than 10 minutes every fix is plausible. + * @param {{lat:number, lon:number, at:number}|null} previous Last accepted fix. + * @param {{lat:number, lon:number, at:number}} next Candidate fix. + * @param {{groundSpeedKt?:number|null, category?:string|null}} [options] + * @returns {boolean} + */ +export function localAdsbFixIsPlausible( + previous, + next, + { groundSpeedKt = null, category = null } = {}, +) { + if ( + !previous || + !Number.isFinite(previous.lat) || + !Number.isFinite(previous.lon) || + !Number.isFinite(previous.at) || + !Number.isFinite(next?.at) + ) + return true; + const elapsedMs = next.at - previous.at; + if (elapsedMs < 0 || elapsedMs > LOCAL_ADSB_REFERENCE_MAX_AGE_MS) return true; + const limitKt = localAdsbSpeedLimitKt({ groundSpeedKt, category }); + const allowedM = + LOCAL_ADSB_POSITION_MARGIN_M + + ((elapsedMs + 1_000) / 1_000) * limitKt * KT_TO_MPS; + return ( + surfaceDistanceM(previous.lat, previous.lon, next.lat, next.lon) <= allowedM + ); +} + +function normalizePosition(lat, lon) { + const latitude = finiteOrNull(lat); + const longitude = finiteOrNull(lon); + if ( + latitude === null || + longitude === null || + Math.abs(latitude) > 90 || + Math.abs(longitude) > 180 + ) + return { lat: null, lon: null }; + return { lat: latitude, lon: longitude }; +} + +/** + * Map one track from the browser Mode S decoder into a local ADS-B record. + * @param {object} track Decoder track (icao, callsign, latitude, longitude, + * altitudeFt, speedKt, headingDeg, verticalRateFpm, messages, lastSeen, + * lastPositionAt, onGround from surface position messages). + * @returns {object|null} Normalized record, or null without a valid address. + */ +export function recordFromDecoderTrack(track) { + const icao = normalizeIcao(track?.icao); + if (!icao) return null; + const { lat, lon } = normalizePosition(track.latitude, track.longitude); + return { + icao, + callsign: normalizeCallsign(track.callsign), + category: normalizeAdsbCategory(track.category), + onGround: track.onGround === true, + lat, + lon, + altitudeFt: finiteOrNull(track.altitudeFt), + groundSpeedKt: finiteOrNull(track.speedKt), + trackDeg: normalizeTrack(track.headingDeg), + verticalRateFpm: finiteOrNull(track.verticalRateFpm), + lastPositionAt: lat === null ? null : finiteOrNull(track.lastPositionAt), + lastMessageAt: finiteOrNull(track.lastSeen) ?? 0, + messageCount: Math.max(0, Math.trunc(finiteOrNull(track.messages) ?? 0)), + rssiDbfs: null, + band: '1090', + source: 'webusb', + }; +} + +/** + * Map a dump1090/readsb `aircraft.json` document into local ADS-B records. + * + * Ages (`seen`, `seen_pos`) are relative to the document's own `now`, so they + * are anchored to the caller's receipt clock rather than trusting the + * receiver host's wall clock. Entries without a valid 24-bit address (for + * example non-ICAO TIS-B `~` addresses) are skipped. + * skyaware978 writes the same document shape for 978 MHz UAT, so one adapter + * serves both bands; the caller names the band. + * @param {object} json Parsed aircraft.json document. + * @param {number} nowMs Local epoch ms at which the document was received. + * @param {object} [options] + * @param {'1090'|'978'} [options.band='1090'] Band the feed decodes. + * @returns {object[]} Normalized records. + */ +export function normalizeDump1090Aircraft(json, nowMs, { band = '1090' } = {}) { + const recordBand = band === '978' ? '978' : '1090'; + const receivedAt = finiteOrNull(nowMs); + if (receivedAt === null || !Array.isArray(json?.aircraft)) return []; + const records = []; + for (const entry of json.aircraft) { + const icao = normalizeIcao(entry?.hex); + if (!icao) continue; + const seenS = Math.max(0, finiteOrNull(entry.seen) ?? 0); + const seenPosS = finiteOrNull(entry.seen_pos); + const position = normalizePosition(entry.lat, entry.lon); + const hasPosition = position.lat !== null && seenPosS !== null; + const altitude = + entry.alt_baro === 'ground' ? 0 : finiteOrNull(entry.alt_baro); + records.push({ + icao, + callsign: normalizeCallsign(entry.flight), + category: normalizeAdsbCategory(entry.category), + onGround: entry.alt_baro === 'ground', + lat: hasPosition ? position.lat : null, + lon: hasPosition ? position.lon : null, + altitudeFt: altitude, + groundSpeedKt: finiteOrNull(entry.gs), + trackDeg: normalizeTrack(entry.track), + verticalRateFpm: finiteOrNull(entry.baro_rate), + lastPositionAt: hasPosition + ? receivedAt - Math.max(0, seenPosS) * 1000 + : null, + lastMessageAt: receivedAt - seenS * 1000, + messageCount: Math.max(0, Math.trunc(finiteOrNull(entry.messages) ?? 0)), + rssiDbfs: finiteOrNull(entry.rssi), + band: recordBand, + source: 'feed', + }); + } + return records; +} + +/** + * Whether a record has been heard recently enough to keep. + * @param {object} record Local ADS-B record. + * @param {number} nowMs Current epoch ms. + * @returns {boolean} + */ +export function localAdsbRecordIsLive(record, nowMs) { + return ( + Number.isFinite(record?.lastMessageAt) && + nowMs - record.lastMessageAt < LOCAL_ADSB_MESSAGE_STALE_MS + ); +} + +/** + * Whether a record carries a position fresh enough to draw a marker. + * @param {object} record Local ADS-B record. + * @param {number} nowMs Current epoch ms. + * @returns {boolean} + */ +export function localAdsbPositionIsFresh(record, nowMs) { + return ( + localAdsbRecordIsLive(record, nowMs) && + Number.isFinite(record.lat) && + Number.isFinite(record.lon) && + Number.isFinite(record.lastPositionAt) && + nowMs - record.lastPositionAt < LOCAL_ADSB_POSITION_STALE_MS + ); +} + +/** + * Receiver counts shown beside the gain control. + * @param {object[]} records Local ADS-B records. + * @param {number} nowMs Current epoch ms. + * @returns {{heard:number, positioned:number}} + */ +export function summarizeLocalAdsb(records, nowMs) { + let heard = 0; + let positioned = 0; + for (const record of Array.isArray(records) ? records : []) { + if (!localAdsbRecordIsLive(record, nowMs)) continue; + heard += 1; + if (localAdsbPositionIsFresh(record, nowMs)) positioned += 1; + } + return { heard, positioned }; +} + +const BAND_ORDER = Object.freeze(['1090', '978']); +const SOURCE_ORDER = Object.freeze(['webusb', 'feed']); + +function ordered(values, order) { + return order.filter((value) => values.has(value)); +} + +function positionTime(record) { + return Number.isFinite(record?.lastPositionAt) + ? record.lastPositionAt + : Number.NEGATIVE_INFINITY; +} + +function messageTime(record) { + return Number.isFinite(record?.lastMessageAt) + ? record.lastMessageAt + : Number.NEGATIVE_INFINITY; +} + +/** + * Whether `candidate` should replace `current` for the same ICAO: the most + * recent position wins, and on a tie the most recent message. + * @param {object} candidate + * @param {object} current + * @returns {boolean} + */ +export function localAdsbRecordIsNewer(candidate, current) { + const byPosition = positionTime(candidate) - positionTime(current); + if (byPosition !== 0 && !Number.isNaN(byPosition)) return byPosition > 0; + return messageTime(candidate) > messageTime(current); +} + +/** + * Merge records from every local input (browser SDR, decoder feeds) into one + * record per ICAO. + * + * Keeps the record with the most recent position (tie: most recent message) + * and annotates it with every band and source that heard the aircraft within + * the last 60 s. A category missing from the winning record is taken from + * another input that decoded it. `memory` (a Map owned by the caller) carries those + * receptions across calls, so an input that drops an aircraft does not erase + * that it was heard there moments ago. + * @param {object[][]} inputs Record lists; each record has `band`/`source`. + * @param {number} nowMs Current epoch ms. + * @param {Map>} [memory] Per-ICAO reception log + * keyed `band|source`, mutated in place and pruned to 60 s. + * @returns {object[]} Merged records with `bands` and `sources` arrays. + */ +export function mergeLocalAdsbRecords(inputs, nowMs, memory = new Map()) { + const best = new Map(); + const categories = new Map(); + for (const list of Array.isArray(inputs) ? inputs : []) { + for (const record of Array.isArray(list) ? list : []) { + if (!record?.icao || !localAdsbRecordIsLive(record, nowMs)) continue; + if (record.category && !categories.has(record.icao)) + categories.set(record.icao, record.category); + const current = best.get(record.icao); + if (!current || localAdsbRecordIsNewer(record, current)) + best.set(record.icao, record); + let heard = memory.get(record.icao); + if (!heard) { + heard = new Map(); + memory.set(record.icao, heard); + } + const key = `${record.band || '1090'}|${record.source || 'webusb'}`; + heard.set(key, Math.max(heard.get(key) ?? 0, record.lastMessageAt)); + } + } + for (const [icao, heard] of memory) { + for (const [key, at] of heard) + if (nowMs - at >= LOCAL_ADSB_MESSAGE_STALE_MS) heard.delete(key); + if (!heard.size) memory.delete(icao); + } + const merged = []; + for (const [icao, record] of best) { + const bands = new Set(); + const sources = new Set(); + for (const key of memory.get(icao)?.keys() || []) { + const [band, source] = key.split('|'); + bands.add(band); + sources.add(source); + } + merged.push({ + ...record, + category: record.category || categories.get(icao) || null, + bands: ordered(bands, BAND_ORDER), + sources: ordered(sources, SOURCE_ORDER), + }); + } + return merged; +} diff --git a/src/sources/adsbRecords.test.mjs b/src/sources/adsbRecords.test.mjs new file mode 100644 index 000000000..12070d47b --- /dev/null +++ b/src/sources/adsbRecords.test.mjs @@ -0,0 +1,326 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +import { + LOCAL_ADSB_MESSAGE_STALE_MS, + LOCAL_ADSB_POSITION_STALE_MS, + LOCAL_ADSB_REFERENCE_MAX_AGE_MS, + localAdsbFixIsPlausible, + localAdsbPositionIsFresh, + localAdsbRecordIsLive, + normalizeAdsbCategory, + mergeLocalAdsbRecords, + normalizeDump1090Aircraft, + recordFromDecoderTrack, + summarizeLocalAdsb, +} from './adsbRecords.js'; + +const dump1090 = JSON.parse( + readFileSync( + new URL( + '../data/fixtures/adsb-austin-dump1090-aircraft.json', + import.meta.url, + ), + 'utf8', + ), +); + +test('dump1090 aircraft.json maps to the shared local ADS-B records', () => { + const now = 1_790_127_100_000; + const records = normalizeDump1090Aircraft(dump1090, now); + assert.equal(records.length, 4); + const byIcao = new Map(records.map((record) => [record.icao, record])); + + assert.deepEqual(byIcao.get('ae5d8a'), { + icao: 'ae5d8a', + callsign: 'SHINR42', + category: null, + onGround: false, + lat: 30.269662, + lon: -97.793262, + altitudeFt: 1600, + groundSpeedKt: 129.9, + trackDeg: 330.5, + verticalRateFpm: 64, + lastPositionAt: now - 34_600, + lastMessageAt: now - 30_500, + messageCount: 74, + rssiDbfs: -30, + band: '1090', + source: 'feed', + }); + const cruise = byIcao.get('a3c775'); + assert.equal(cruise.callsign, null); + assert.equal(cruise.altitudeFt, 34_000); + assert.equal(cruise.lat, 30.360625); + assert.equal(cruise.lon, -97.919659); + assert.equal(cruise.lastPositionAt, now - 43_400); + + const unpositioned = byIcao.get('a15c54'); + assert.equal(unpositioned.callsign, 'SKW3301'); + assert.equal(unpositioned.lat, null); + assert.equal(unpositioned.lon, null); + assert.equal(unpositioned.lastPositionAt, null); + assert.equal(byIcao.get('a46e6a').altitudeFt, null); + + const summary = summarizeLocalAdsb(records, now); + assert.deepEqual(summary, { heard: 4, positioned: 2 }); +}); + +test('dump1090 adapter skips invalid addresses and tolerates missing fields', () => { + const records = normalizeDump1090Aircraft( + { + now: 1, + aircraft: [ + { hex: '~12ab34', seen: 1 }, + { hex: 'zzzzzz' }, + { + hex: 'ABC123', + alt_baro: 'ground', + lat: 95, + lon: 10, + seen_pos: 1, + category: 'a7', + }, + { hex: 'def456', category: 'E9', seen: 1 }, + ], + }, + 10_000, + ); + assert.equal(records.length, 2); + assert.equal(records[0].icao, 'abc123'); + assert.equal(records[0].altitudeFt, 0); + assert.equal(records[0].onGround, true, 'dump1090 "ground" is kept'); + assert.equal(records[0].category, 'A7'); + assert.equal(records[1].category, null, 'an invalid category is dropped'); + assert.equal(records[1].onGround, false); + assert.equal(records[0].lat, null, 'out-of-range latitude is rejected'); + assert.equal(records[0].lastMessageAt, 10_000); + assert.deepEqual(normalizeDump1090Aircraft(null, 1), []); + assert.deepEqual(normalizeDump1090Aircraft({ aircraft: [] }, Number.NaN), []); +}); + +test('decoder tracks map to the same record shape', () => { + const record = recordFromDecoderTrack({ + icao: 'AE5D8A', + callsign: 'SHINR42', + category: 'A1', + latitude: 30.27, + longitude: -97.79, + altitudeFt: 1600, + speedKt: 129.9, + headingDeg: -29.5, + verticalRateFpm: 64, + messages: 48, + lastSeen: 5_000, + lastPositionAt: 4_000, + cprEven: {}, + }); + assert.deepEqual(record, { + icao: 'ae5d8a', + callsign: 'SHINR42', + category: 'A1', + onGround: false, + lat: 30.27, + lon: -97.79, + altitudeFt: 1600, + groundSpeedKt: 129.9, + trackDeg: 330.5, + verticalRateFpm: 64, + lastPositionAt: 4_000, + lastMessageAt: 5_000, + messageCount: 48, + rssiDbfs: null, + band: '1090', + source: 'webusb', + }); + assert.equal(recordFromDecoderTrack({ icao: 'nothex' }), null); +}); + +test('markers need a position newer than 60 s; records need a message newer than 60 s', () => { + assert.equal(LOCAL_ADSB_POSITION_STALE_MS, 60_000); + assert.equal(LOCAL_ADSB_MESSAGE_STALE_MS, 60_000); + const record = { + lat: 30, + lon: -97, + lastPositionAt: 0, + lastMessageAt: 50_000, + }; + assert.equal(localAdsbPositionIsFresh(record, 59_999), true); + assert.equal( + localAdsbPositionIsFresh(record, 60_000), + false, + 'still heard, but the position is too old to draw', + ); + assert.equal(localAdsbRecordIsLive(record, 60_000), true); + assert.equal(localAdsbRecordIsLive(record, 110_000), false); + assert.equal( + localAdsbPositionIsFresh({ ...record, lat: null }, 1_000), + false, + ); +}); + +test('a feed document names its band on every record', () => { + const now = 1_790_127_100_000; + const uat = normalizeDump1090Aircraft(dump1090, now, { band: '978' }); + assert.equal(uat.length, 4); + assert.ok(uat.every((record) => record.band === '978')); + assert.ok(uat.every((record) => record.source === 'feed')); +}); + +function heard(overrides = {}) { + return { + icao: 'abc123', + lat: 30, + lon: -97, + lastPositionAt: 10_000, + lastMessageAt: 10_000, + band: '1090', + source: 'webusb', + ...overrides, + }; +} + +test('merge keeps the most recent position per ICAO, then the most recent message', () => { + const memory = new Map(); + const webusb = heard({ lastPositionAt: 9_000, lastMessageAt: 11_000 }); + const feed = heard({ + lat: 31, + source: 'feed', + lastPositionAt: 10_000, + lastMessageAt: 10_000, + }); + let [merged] = mergeLocalAdsbRecords([[webusb], [feed]], 12_000, memory); + assert.equal(merged.lat, 31, 'the newer position wins over a newer message'); + assert.deepEqual(merged.bands, ['1090']); + assert.deepEqual(merged.sources, ['webusb', 'feed']); + + // Equal positions: the newer message breaks the tie, in either input order. + const tieA = heard({ lat: 1, lastMessageAt: 10_500 }); + const tieB = heard({ lat: 2, source: 'feed', lastMessageAt: 10_900 }); + for (const inputs of [ + [[tieA], [tieB]], + [[tieB], [tieA]], + ]) + assert.equal(mergeLocalAdsbRecords(inputs, 12_000)[0].lat, 2); + + // A positioned record beats one that has never decoded a position. + const bare = heard({ lat: null, lon: null, lastPositionAt: null }); + [merged] = mergeLocalAdsbRecords( + [ + [{ ...bare, lastMessageAt: 11_900 }], + [heard({ band: '978', source: 'feed' })], + ], + 12_000, + ); + assert.equal(merged.lat, 30); + assert.equal(merged.band, '978'); + assert.deepEqual(merged.bands, ['1090', '978']); +}); + +test('merge remembers the bands and sources that heard an aircraft for 60 s', () => { + const memory = new Map(); + mergeLocalAdsbRecords( + [ + [heard()], + [heard({ band: '978', source: 'feed', lastMessageAt: 20_000 })], + ], + 20_000, + memory, + ); + // The 978 feed drops the aircraft; the 1090 browser SDR still hears it. + let [merged] = mergeLocalAdsbRecords( + [[heard({ lastMessageAt: 60_000, lastPositionAt: 60_000 })], []], + 60_000, + memory, + ); + assert.deepEqual(merged.bands, ['1090', '978']); + assert.deepEqual(merged.sources, ['webusb', 'feed']); + [merged] = mergeLocalAdsbRecords( + [[heard({ lastMessageAt: 80_000, lastPositionAt: 80_000 })], []], + 80_000, + memory, + ); + assert.deepEqual(merged.bands, ['1090'], '978 reception expired after 60 s'); + assert.deepEqual(merged.sources, ['webusb']); + assert.deepEqual(mergeLocalAdsbRecords([[], []], 200_000, memory), []); + assert.equal(memory.size, 0, 'the reception log is pruned'); +}); + +test('emitter categories normalize to the dump1090 strings', () => { + assert.equal(normalizeAdsbCategory('a7'), 'A7'); + assert.equal(normalizeAdsbCategory(' B1 '), 'B1'); + assert.equal(normalizeAdsbCategory('A8'), null); + assert.equal(normalizeAdsbCategory(7), null); + assert.equal(normalizeAdsbCategory(null), null); +}); + +test('merging keeps a category decoded by any input', () => { + const base = { + icao: 'a0b702', + lat: 30.2, + lon: -97.8, + lastMessageAt: 10_000, + band: '1090', + }; + const [merged] = mergeLocalAdsbRecords( + [ + [{ ...base, category: null, lastPositionAt: 9_900, source: 'webusb' }], + [{ ...base, category: 'A7', lastPositionAt: 9_000, source: 'feed' }], + ], + 10_000, + ); + assert.equal(merged.source, 'webusb', 'the newest position still wins'); + assert.equal(merged.category, 'A7'); +}); + +test('the position sanity check follows dump1090: speed × 1.5 + margin over elapsed + 1 s', () => { + const from = { lat: 30, lon: -97, at: 0 }; + // 1 nm north in 10 s: 360 kt. + const next = { lat: 30 + 1 / 60, lon: -97, at: 10_000 }; + assert.equal(localAdsbFixIsPlausible(null, next), true, 'no reference'); + assert.equal( + localAdsbFixIsPlausible(from, next, { groundSpeedKt: 300 }), + true, + '300 kt reported allows 500 kt', + ); + assert.equal( + localAdsbFixIsPlausible(from, next, { groundSpeedKt: 120 }), + false, + '120 kt reported allows 230 kt', + ); + assert.equal( + localAdsbFixIsPlausible(from, next), + true, + 'unknown speed allows 1,000 kt', + ); + // 2 nm in 10 s: 720 kt. + const fast = { lat: 30 + 2 / 60, lon: -97, at: 10_000 }; + assert.equal(localAdsbFixIsPlausible(from, fast), true); + assert.equal( + localAdsbFixIsPlausible(from, fast, { category: 'A7' }), + false, + 'rotorcraft without a speed are capped at 350 kt', + ); + assert.equal( + localAdsbFixIsPlausible( + from, + { ...next, lat: 31, at: LOCAL_ADSB_REFERENCE_MAX_AGE_MS + 1 }, + { groundSpeedKt: 100 }, + ), + true, + 'a reference older than 10 minutes proves nothing', + ); + assert.equal( + localAdsbFixIsPlausible( + from, + { lat: 30.004, lon: -97, at: 0 }, + { + groundSpeedKt: 0, + }, + ), + true, + 'the fixed 500 m margin absorbs CPR and reception error', + ); +}); diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index c394232d0..7477b2280 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -16,6 +16,7 @@ import { CockpitCoordinator } from './cockpitCoordinator.js'; import { ContextControls } from './context.js'; import { CctvControls } from './cctv.js'; import { RadioControls } from './radio.js'; +import { LocalSdrControls } from './localSdrControls.js'; import { LocationNavigation } from './locationNavigation.js'; import { bindClearLayersControl } from './layers.js'; import { bindCameraOrientationControls } from './cameraOrientationControls.js'; @@ -77,6 +78,7 @@ export class StyleManager extends ShellFacade { transitLayer, aisLiveVesselsLayer, militaryAwarenessLayer, + localAdsbLayer, } = services; this.services = services; this._lifetime = new UiLifetime(); @@ -449,12 +451,13 @@ export class StyleManager extends ShellFacade { trafficLayer, flightsLayer, militaryFlightsLayer, + localAdsbLayer, satellitesLayer, cctvLayer, bikeshareLayer, transitLayer, aisLiveVesselsLayer, - ], + ].filter(Boolean), (modeLabel) => { this._updateDetectionButton(modeLabel); }, @@ -860,11 +863,33 @@ export class StyleManager extends ShellFacade { isCockpitActive: () => this.cockpitView?.active, signalUserCollapsed: () => this.cockpitView?.signalUserCollapsed, layoutCockpit: () => this.cockpitView?.scheduleContextLayout(), + // An open local receiver keeps the shared Radio panel expanded. preservePanelStateDuringClear: () => - this._preservePanelStateDuringLayerClear, + this._preservePanelStateDuringLayerClear || + Boolean(this._localSdrControls?.isActive()), scheduleLayout: () => this._scheduleRightPanelLayout(), }, }); + this._localSdrControls?.destroy(); + this._localSdrControls = null; + const receiver = this.services.localAdsbLayer?.receiver; + if (receiver) { + this._localSdrControls = new LocalSdrControls({ + document, + receiver, + feeds: this.services.localAdsbLayer?.feeds || null, + radio: radioLayer, + actions: { + isLocalAdsbEnabled: () => + Boolean(this._dataManager?.isEnabled('local-adsb')), + setLocalAdsbEnabled: (enabled) => + this._dataManager?.setEnabled('local-adsb', enabled, { + origin: 'user', + }), + scheduleLayout: () => this._scheduleRightPanelLayout(), + }, + }); + } } /** @@ -1479,6 +1504,7 @@ export class StyleManager extends ShellFacade { this._clearLayersControl?.destroy(); this._cctvControls?.destroy(); this._radioControls?.destroy(); + this._localSdrControls?.destroy(); this._cockpitCoordinator.stop(); this._visualSettings.stop(); this.shareLinkManager?.destroy(); diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index c06f27dea..a2861f87e 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -23,6 +23,7 @@ const PANEL_GROUPS = [ 'satellites', 'flights', 'military', + 'local-adsb', 'ais-live-vessels', 'traffic', 'transit', diff --git a/src/ui/layerPanel.test.mjs b/src/ui/layerPanel.test.mjs index dcc6d116d..b1c58f747 100644 --- a/src/ui/layerPanel.test.mjs +++ b/src/ui/layerPanel.test.mjs @@ -21,6 +21,7 @@ test('panel presentation places Transit between Street Traffic and Bike Share in 'satellites', 'flights', 'military', + 'local-adsb', 'ais-live-vessels', 'traffic', 'transit', diff --git a/src/ui/localSdrControls.js b/src/ui/localSdrControls.js new file mode 100644 index 000000000..b4845ec00 --- /dev/null +++ b/src/ui/localSdrControls.js @@ -0,0 +1,297 @@ +import { + internetRadioAudioActive, + localSdrCardView, + localSdrFeedLine, + localSdrFmAudioActive, +} from './localSdrPresentation.js'; + +const ELEMENT_IDS = Object.freeze({ + card: 'sdr-radio-card', + connectionState: 'sdr-connection-state', + connect: 'sdr-connect-btn', + locate: 'sdr-locate-btn', + changeDevice: 'sdr-change-device-btn', + modeFm: 'sdr-mode-fm-btn', + modeAdsb: 'sdr-mode-adsb-btn', + gain: 'sdr-gain-select', + stats: 'sdr-adsb-stats', + statRate: 'sdr-stat-rate', + statHeard: 'sdr-stat-heard', + statPositioned: 'sdr-stat-positioned', + statIq: 'sdr-stat-iq', + frequency: 'sdr-frequency-input', + tune: 'sdr-tune-btn', + seekBack: 'sdr-seek-back-btn', + seekForward: 'sdr-seek-forward-btn', + volume: 'sdr-volume', + volumeValue: 'sdr-volume-value', + status: 'sdr-status', + feedStatus: 'sdr-feed-status', +}); + +function setText(element, text) { + if (element && element.textContent !== text) element.textContent = text; +} + +/** + * Local RTL-SDR card in the Radio panel. + * + * Owns the card's listeners and its receiver/radio subscriptions. The Radio + * panel is one listening surface: starting local FM stops internet radio, and + * starting internet radio stops local FM. + */ +export class LocalSdrControls { + /** + * @param {object} options + * @param {Document} options.document Document containing the card. + * @param {object} options.receiver Local RTL-SDR session. + * @param {object} [options.feeds] Decoder-feed session (getState, + * subscribe, probe); the card shows one read-only line when the server + * has feeds configured. + * @param {object} [options.radio] Internet-radio port (subscribe, stopPlayback). + * @param {object} [options.actions] Application actions: + * setLocalAdsbEnabled(enabled), isLocalAdsbEnabled(), scheduleLayout(). + */ + constructor({ + document: doc, + receiver, + feeds = null, + radio = null, + actions = {}, + }) { + this.receiver = receiver; + this.feeds = feeds; + this.radio = radio; + this.actions = actions; + this.elements = Object.fromEntries( + Object.entries(ELEMENT_IDS).map(([key, id]) => [ + key, + doc?.getElementById?.(id) || null, + ]), + ); + this._listeners = new AbortController(); + this._state = receiver.getState(); + this._radioState = null; + this._fmAudioActive = localSdrFmAudioActive(this._state); + this._radioAudioActive = false; + this._renderFrame = null; + this._destroyed = false; + if (!this.elements.card) return; + this._bind(); + this._receiverUnsubscribe = receiver.subscribe((state) => + this._onReceiverState(state), + ); + this._radioUnsubscribe = + radio?.subscribe?.((state) => this._onRadioState(state)) || null; + this._feedsUnsubscribe = + feeds?.subscribe?.(() => this._scheduleRender()) || null; + // One request, no polling: learn whether the server has decoder feeds. + void feeds?.probe?.()?.catch?.(() => {}); + this._render(); + } + + /** Whether the receiver session is open (keeps the Radio panel expanded). */ + isActive() { + return Boolean(this._state?.connected); + } + + _listen(element, type, handler) { + element?.addEventListener(type, handler, { + signal: this._listeners.signal, + }); + } + + _stopInternetRadio() { + if (internetRadioAudioActive(this._radioState)) + this.radio?.stopPlayback?.({ origin: 'user' }); + } + + async _enableLayerWhenReceiving() { + const state = this.receiver.getState(); + if (state.connected && state.mode === 'adsb') + await this.actions.setLocalAdsbEnabled?.(true); + } + + _bind() { + const el = this.elements; + this._listen(el.connect, 'click', async () => { + const before = this.receiver.getState(); + if (before.connected) { + await this.receiver.stop(); + return; + } + if (before.mode === 'fm') this._stopInternetRadio(); + if (await this.receiver.connect(before.mode)) + await this._enableLayerWhenReceiving(); + }); + this._listen(el.locate, 'click', () => { + void this.receiver.requestReceiverLocation(); + }); + this._listen(el.changeDevice, 'click', async () => { + if (this.receiver.getState().mode === 'fm') this._stopInternetRadio(); + if (await this.receiver.changeDevice()) + await this._enableLayerWhenReceiving(); + }); + this._listen(el.modeFm, 'click', async () => { + this._stopInternetRadio(); + if (this.actions.isLocalAdsbEnabled?.()) + await this.actions.setLocalAdsbEnabled?.(false); + await this.receiver.setMode('fm'); + }); + this._listen(el.modeAdsb, 'click', async () => { + if (await this.receiver.setMode('adsb')) + await this._enableLayerWhenReceiving(); + }); + this._listen(el.gain, 'change', () => { + void this.receiver.setGain(el.gain.value); + }); + const tune = () => { + const mhz = Number(el.frequency?.value); + if (!Number.isFinite(mhz)) return; + this._stopInternetRadio(); + void this.receiver.tuneFm(mhz * 1_000_000); + }; + this._listen(el.tune, 'click', tune); + this._listen(el.frequency, 'keydown', (event) => { + if (event.key !== 'Enter') return; + event.preventDefault(); + tune(); + }); + for (const [button, direction] of [ + [el.seekBack, -1], + [el.seekForward, 1], + ]) { + this._listen(button, 'click', () => { + this._stopInternetRadio(); + void this.receiver.seekFm(direction); + }); + } + this._listen(el.volume, 'input', () => { + const value = Number(el.volume.value); + this.receiver.setVolume(value / 100); + setText(el.volumeValue, `${value}%`); + }); + } + + _onReceiverState(state) { + this._state = state; + const fmAudioActive = localSdrFmAudioActive(state); + // Local FM just started: it takes over from internet radio. + if (fmAudioActive && !this._fmAudioActive) this._stopInternetRadio(); + this._fmAudioActive = fmAudioActive; + this._scheduleRender(); + } + + _onRadioState(state) { + this._radioState = state; + const radioAudioActive = internetRadioAudioActive(state); + // Internet radio just started: it takes over from local FM. + if (radioAudioActive && !this._radioAudioActive && this._fmAudioActive) { + void this.receiver.stop({ + message: 'Local FM stopped: internet radio started', + }); + } + this._radioAudioActive = radioAudioActive; + } + + _scheduleRender() { + if (this._destroyed || this._renderFrame !== null) return; + const schedule = + typeof requestAnimationFrame === 'function' + ? requestAnimationFrame + : (callback) => setTimeout(callback, 16); + this._renderFrame = schedule(() => { + this._renderFrame = null; + if (!this._destroyed) this._render(); + }); + } + + _render() { + const el = this.elements; + const view = localSdrCardView(this._state); + const doc = el.card?.ownerDocument; + setText(el.connectionState, view.connectionLabel); + el.connectionState?.classList.toggle('active', view.connectionActive); + if (el.connect) { + setText(el.connect, view.connectLabel); + el.connect.disabled = view.connectDisabled; + el.connect.classList.toggle('active', view.connectPressed); + el.connect.setAttribute('aria-pressed', String(view.connectPressed)); + } + for (const [button, active] of [ + [el.modeFm, view.fmActive], + [el.modeAdsb, view.adsbActive], + ]) { + if (!button) continue; + button.classList.toggle('active', active); + button.setAttribute('aria-pressed', String(active)); + button.disabled = view.modeDisabled; + } + if (el.locate) { + setText(el.locate, view.locateLabel); + el.locate.disabled = view.locateDisabled; + el.locate.classList.toggle('active', view.locateActive); + } + if (el.changeDevice) el.changeDevice.disabled = view.changeDeviceDisabled; + if (el.gain) { + if (doc?.activeElement !== el.gain && el.gain.value !== view.gainValue) + el.gain.value = view.gainValue; + el.gain.disabled = view.gainDisabled; + } + if (el.stats) el.stats.hidden = view.statsHidden; + setText(el.statRate, view.stats.rate); + setText(el.statHeard, view.stats.heard); + setText(el.statPositioned, view.stats.positioned); + setText(el.statIq, view.stats.iq); + if (el.frequency) { + el.frequency.disabled = view.frequencyDisabled; + if (doc?.activeElement !== el.frequency && view.frequencyValue !== null) + el.frequency.value = view.frequencyValue; + } + if (el.tune) el.tune.disabled = view.tuneDisabled; + if (el.seekBack) el.seekBack.disabled = view.seekDisabled; + if (el.seekForward) el.seekForward.disabled = view.seekDisabled; + if (el.volume) { + if (doc?.activeElement !== el.volume) + el.volume.value = String(view.volumePercent); + el.volume.disabled = view.volumeDisabled; + } + setText(el.volumeValue, `${view.volumePercent}%`); + if (el.status) { + setText(el.status, view.statusText); + el.status.classList.toggle('error', view.statusError); + } + const feedLine = localSdrFeedLine(this.feeds?.getState?.() || null); + if (el.feedStatus) { + el.feedStatus.hidden = feedLine === null; + setText(el.feedStatus, feedLine || ''); + } + el.card + ?.closest?.('#radio-panel') + ?.classList.toggle('sdr-connected', view.connectionActive); + // Only structural changes move the rail; per-block text updates do not. + const layoutKey = `${view.statsHidden}|${view.connectionActive}|${feedLine === null}`; + if (layoutKey !== this._layoutKey) { + this._layoutKey = layoutKey; + this.actions.scheduleLayout?.(); + } + } + + /** Release listeners and subscriptions; the receiver session is not closed. */ + destroy() { + this._destroyed = true; + this._listeners.abort(); + this._receiverUnsubscribe?.(); + this._radioUnsubscribe?.(); + this._feedsUnsubscribe?.(); + this._feedsUnsubscribe = null; + this._receiverUnsubscribe = null; + this._radioUnsubscribe = null; + if (this._renderFrame !== null) { + if (typeof cancelAnimationFrame === 'function') + cancelAnimationFrame(this._renderFrame); + else clearTimeout(this._renderFrame); + this._renderFrame = null; + } + } +} diff --git a/src/ui/localSdrControls.test.mjs b/src/ui/localSdrControls.test.mjs new file mode 100644 index 000000000..77d44af7a --- /dev/null +++ b/src/ui/localSdrControls.test.mjs @@ -0,0 +1,207 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { LocalSdrControls } from './localSdrControls.js'; + +function stubDocument() { + const elements = new Map(); + const doc = { + activeElement: null, + getElementById(id) { + if (!elements.has(id)) { + const listeners = new Map(); + elements.set(id, { + id, + value: '', + hidden: false, + disabled: false, + textContent: '', + ownerDocument: doc, + classList: { toggle() {} }, + setAttribute() {}, + closest: () => null, + addEventListener(type, handler) { + listeners.set(type, handler); + }, + fire: (type, event = {}) => listeners.get(type)?.(event), + }); + } + return elements.get(id); + }, + }; + return doc; +} + +function stubReceiver() { + const listeners = new Set(); + const calls = []; + let state = { + webUsbSupported: true, + connected: false, + status: 'idle', + message: 'Connect an RTL-SDR to begin.', + mode: 'fm', + frequencyHz: 98_500_000, + volume: 0.8, + gain: 'auto', + locationStatus: 'unknown', + }; + return { + calls, + getState: () => ({ ...state }), + subscribe(listener) { + listeners.add(listener); + listener({ ...state }); + return () => listeners.delete(listener); + }, + emit(patch) { + state = { ...state, ...patch }; + for (const listener of listeners) listener({ ...state }); + }, + async stop(options) { + calls.push(['stop', options]); + this.emit({ connected: false, status: 'idle' }); + return true; + }, + async setMode(mode) { + calls.push(['setMode', mode]); + this.emit({ mode }); + return true; + }, + async setGain(value) { + calls.push(['setGain', value]); + return value; + }, + async tuneFm(hz) { + calls.push(['tuneFm', hz]); + return true; + }, + }; +} + +function stubRadio() { + const listeners = new Set(); + const calls = []; + return { + calls, + subscribe(listener) { + listeners.add(listener); + listener({ audioState: 'idle' }); + return () => listeners.delete(listener); + }, + emit(state) { + for (const listener of listeners) listener(state); + }, + stopPlayback(options) { + calls.push(options); + this.emit({ audioState: 'idle' }); + }, + }; +} + +test('starting internet radio stops local FM, and local FM stops internet radio', async (t) => { + const receiver = stubReceiver(); + const radio = stubRadio(); + const controls = new LocalSdrControls({ + document: stubDocument(), + receiver, + radio, + }); + t.after(() => controls.destroy()); + + receiver.emit({ connected: true, status: 'streaming', mode: 'fm' }); + assert.equal(radio.calls.length, 0, 'nothing to stop while radio is silent'); + radio.emit({ audioState: 'loading' }); + assert.deepEqual(receiver.calls.at(-1), [ + 'stop', + { message: 'Local FM stopped: internet radio started' }, + ]); + + radio.emit({ audioState: 'playing' }); + receiver.emit({ connected: true, status: 'streaming', mode: 'fm' }); + assert.deepEqual(radio.calls, [{ origin: 'user' }]); +}); + +test('ADS-B reception and internet radio play together', async (t) => { + const receiver = stubReceiver(); + const radio = stubRadio(); + const controls = new LocalSdrControls({ + document: stubDocument(), + receiver, + radio, + }); + t.after(() => controls.destroy()); + receiver.emit({ connected: true, status: 'streaming', mode: 'adsb' }); + radio.emit({ audioState: 'playing' }); + assert.equal(receiver.calls.length, 0); + assert.equal(radio.calls.length, 0); +}); + +test('card controls drive gain, FM mode and the Local ADS-B layer', async (t) => { + const doc = stubDocument(); + const receiver = stubReceiver(); + const layerCalls = []; + const controls = new LocalSdrControls({ + document: doc, + receiver, + actions: { + isLocalAdsbEnabled: () => true, + setLocalAdsbEnabled: async (enabled) => layerCalls.push(enabled), + }, + }); + t.after(() => controls.destroy()); + const gain = doc.getElementById('sdr-gain-select'); + gain.value = '20.7'; + gain.fire('change'); + assert.deepEqual(receiver.calls.at(-1), ['setGain', '20.7']); + + receiver.emit({ connected: true, status: 'streaming', mode: 'adsb' }); + await doc.getElementById('sdr-mode-fm-btn').fire('click'); + assert.deepEqual(layerCalls, [false]); + assert.deepEqual(receiver.calls.at(-1), ['setMode', 'fm']); + + await doc.getElementById('sdr-mode-adsb-btn').fire('click'); + assert.deepEqual(layerCalls, [false, true]); + assert.equal(controls.isActive(), true); +}); + +test('the card shows one read-only decoder-feed line after a single probe', async (t) => { + const doc = stubDocument(); + const listeners = new Set(); + let probes = 0; + let state = { configured: null, polling: false, feeds: [] }; + const feeds = { + getState: () => state, + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + async probe() { + probes += 1; + }, + set(patch) { + state = { ...state, ...patch }; + for (const listener of listeners) listener(state); + }, + }; + const controls = new LocalSdrControls({ + document: doc, + receiver: stubReceiver(), + feeds, + }); + t.after(() => controls.destroy()); + const line = doc.getElementById('sdr-feed-status'); + assert.equal(probes, 1); + assert.equal(line.hidden, true); + feeds.set({ + configured: true, + polling: true, + feeds: [ + { band: '1090', status: 'live' }, + { band: '978', status: 'live' }, + ], + }); + await new Promise((resolve) => setTimeout(resolve, 30)); + assert.equal(line.hidden, false); + assert.equal(line.textContent, 'Decoder feeds: 1090 live · 978 live'); +}); diff --git a/src/ui/localSdrMarkup.test.mjs b/src/ui/localSdrMarkup.test.mjs new file mode 100644 index 000000000..fa2f8a464 --- /dev/null +++ b/src/ui/localSdrMarkup.test.mjs @@ -0,0 +1,75 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +import { expandApplicationHtml } from '../../build/application-html.js'; +import { createBrowserViteConfig } from '../../build/vite.js'; +import { R820T_GAIN_STEPS_DB } from '../sdr/gain.js'; + +const html = expandApplicationHtml( + readFileSync(new URL('../../index.html', import.meta.url), 'utf8'), +); +const css = readFileSync( + new URL('./styles/radio.css', import.meta.url), + 'utf8', +); + +test('the Local RTL-SDR card lives inside the Radio panel with every control', () => { + const radioStart = html.indexOf('id="radio-panel"'); + const cardStart = html.indexOf('id="sdr-radio-card"'); + const radioEnd = html.indexOf('', radioStart); + assert.ok(radioStart >= 0 && cardStart > radioStart && cardStart < radioEnd); + for (const id of [ + 'sdr-connection-state', + 'sdr-connect-btn', + 'sdr-locate-btn', + 'sdr-change-device-btn', + 'sdr-mode-fm-btn', + 'sdr-mode-adsb-btn', + 'sdr-gain-select', + 'sdr-adsb-stats', + 'sdr-stat-rate', + 'sdr-stat-heard', + 'sdr-stat-positioned', + 'sdr-stat-iq', + 'sdr-frequency-input', + 'sdr-tune-btn', + 'sdr-seek-back-btn', + 'sdr-seek-forward-btn', + 'sdr-volume', + 'sdr-status', + 'sdr-feed-status', + ]) { + assert.match(html, new RegExp(`id="${id}"`), `${id} is missing`); + } + assert.match(html, /id="sdr-status"[^>]*>Connect an RTL-SDR to begin\. { + const start = html.indexOf('id="sdr-gain-select"'); + const select = html.slice(start, html.indexOf('', start)); + const values = [...select.matchAll(/