mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
feat(sdr): local receivers — browser RTL-SDR, Local ADS-B layer and decoder feeds (#732)
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 <[email protected]> Co-authored-by: Mazeyar Moeini Feizabadi <[email protected]>
This commit is contained in:
78 files changed
+12267
-108
No files matched your search
@@ -129,6 +129,13 @@ AISSTREAM_API_KEY=
|
|||||||
# 0 disables the silence watch entirely.
|
# 0 disables the silence watch entirely.
|
||||||
# AISSTREAM_SILENCE_TIMEOUT_MS=120000
|
# 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
|
# 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
|
# 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:
|
# required. Get a key at https://developer.tomtom.com). SERVER-SIDE ONLY:
|
||||||
|
|||||||
@@ -15,6 +15,60 @@
|
|||||||
(`src/ui/imagerySplit.js`, `src/maps/imageryComparison.js`), and
|
(`src/ui/imagerySplit.js`, `src/maps/imageryComparison.js`), and
|
||||||
`MapSourceController.subscribe()` reports every settled map switch.
|
`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
|
## Unreleased — weather review
|
||||||
|
|
||||||
- On 3D Tiles, draw a 4096×2048 detail window around the view on each
|
- On 3D Tiles, draw a 4096×2048 detail window around the view on each
|
||||||
|
|||||||
@@ -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)" |
|
| **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 |
|
| **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
|
### 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)).
|
- **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)).
|
||||||
|
|||||||
@@ -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.
|
- **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.
|
- **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/<id>` 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.
|
- **Transit fetches registered feeds only.** `/api/transit/vehicles/<id>` 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.
|
- **Response-size caps and timeouts** on proxied responses.
|
||||||
- **Sanitized errors** — internal error details are not echoed back to clients.
|
- **Sanitized errors** — internal error details are not echoed back to clients.
|
||||||
- **Coalesced OAuth refresh** and cached successful responses only (OpenSky).
|
- **Coalesced OAuth refresh** and cached successful responses only (OpenSky).
|
||||||
|
|||||||
@@ -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: <https://github.com/jtarrio/webrtlsdr>
|
||||||
|
- License: Apache License 2.0
|
||||||
|
- License text: <https://www.apache.org/licenses/LICENSE-2.0>
|
||||||
|
|
||||||
|
## Signals
|
||||||
|
|
||||||
|
- Package: `@jtarrio/signals` 0.10.1
|
||||||
|
- Author: Jacobo Tarrio Barreiro
|
||||||
|
- Source: <https://github.com/jtarrio/signals>
|
||||||
|
- License: Apache License 2.0
|
||||||
|
- License text: <https://www.apache.org/licenses/LICENSE-2.0>
|
||||||
@@ -9,10 +9,25 @@ export function createBrowserViteConfig({
|
|||||||
cesiumToken,
|
cesiumToken,
|
||||||
host = 'localhost',
|
host = 'localhost',
|
||||||
port = 4173,
|
port = 4173,
|
||||||
|
command,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
return {
|
return {
|
||||||
plugins: [cesium(), applicationHtmlPlugin(), ...plugins],
|
plugins: [cesium(), applicationHtmlPlugin(), ...plugins],
|
||||||
...(publicDir === undefined ? {} : { publicDir }),
|
...(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: {
|
server: {
|
||||||
host: host || 'localhost',
|
host: host || 'localhost',
|
||||||
port: parseInt(port, 10) || 4173,
|
port: parseInt(port, 10) || 4173,
|
||||||
|
|||||||
@@ -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
|
remain unchanged. Audio connects directly to the broadcaster after an explicit
|
||||||
play action; the source does not relay or record streams.
|
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
|
## Bundled geography and submarine cable components
|
||||||
|
|
||||||
Submarine cables use separate source, geometry, rendering, interaction and
|
Submarine cables use separate source, geometry, rendering, interaction and
|
||||||
|
|||||||
@@ -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 <n>` for dump1090-fa and `--sdr driver=rtlsdr,rtl=<n>` for
|
||||||
|
dump978-fa. `rtl_test` lists the indexes.
|
||||||
|
|
||||||
|
A tar1090 or readsb install on a Raspberry Pi already serves
|
||||||
|
`http://<pi-address>/tar1090/data/aircraft.json`. Use the Pi's private address
|
||||||
|
or its `.local` name.
|
||||||
Generated
+24
@@ -9,6 +9,8 @@
|
|||||||
"version": "0.1.1",
|
"version": "0.1.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@jtarrio/signals": "^0.10.0",
|
||||||
|
"@jtarrio/webrtlsdr": "^3.0.6",
|
||||||
"@mapbox/vector-tile": "^3.0.0",
|
"@mapbox/vector-tile": "^3.0.0",
|
||||||
"@meri-imperiumi/eccodes-wasm": "^2.48.2",
|
"@meri-imperiumi/eccodes-wasm": "^2.48.2",
|
||||||
"cesium": "^1.124.0",
|
"cesium": "^1.124.0",
|
||||||
@@ -1061,6 +1063,22 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"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": {
|
"node_modules/@mapbox/point-geometry": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
|
||||||
@@ -1538,6 +1556,12 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"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": {
|
"node_modules/@zip.js/zip.js": {
|
||||||
"version": "2.8.18",
|
"version": "2.8.18",
|
||||||
"resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.8.18.tgz",
|
"resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.8.18.tgz",
|
||||||
|
|||||||
@@ -47,6 +47,8 @@
|
|||||||
"qa:transit": "node scripts/qa-transit.mjs"
|
"qa:transit": "node scripts/qa-transit.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@jtarrio/signals": "^0.10.0",
|
||||||
|
"@jtarrio/webrtlsdr": "^3.0.6",
|
||||||
"@mapbox/vector-tile": "^3.0.0",
|
"@mapbox/vector-tile": "^3.0.0",
|
||||||
"@meri-imperiumi/eccodes-wasm": "^2.48.2",
|
"@meri-imperiumi/eccodes-wasm": "^2.48.2",
|
||||||
"cesium": "^1.124.0",
|
"cesium": "^1.124.0",
|
||||||
|
|||||||
@@ -89,6 +89,10 @@ export async function checkPackageBoundaries(root) {
|
|||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
await build({
|
await build({
|
||||||
root,
|
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,
|
configFile: false,
|
||||||
envFile: false,
|
envFile: false,
|
||||||
publicDir: false,
|
publicDir: false,
|
||||||
@@ -115,7 +119,12 @@ export async function checkPackageBoundaries(root) {
|
|||||||
assetsInlineLimit: 0,
|
assetsInlineLimit: 0,
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
input,
|
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.
|
// Unused imports must still obey ownership; tree shaking is not a boundary.
|
||||||
treeshake: false,
|
treeshake: false,
|
||||||
preserveEntrySignatures: 'strict',
|
preserveEntrySignatures: 'strict',
|
||||||
|
|||||||
@@ -799,6 +799,21 @@
|
|||||||
"scripts/qa-weather-swap.mjs",
|
"scripts/qa-weather-swap.mjs",
|
||||||
"scripts/qa-weather-teardown.mjs",
|
"scripts/qa-weather-teardown.mjs",
|
||||||
"src/layers/weather/imageryHost.test.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/model.test.mjs",
|
||||||
"src/layers/recentImagery/catalog.test.mjs",
|
"src/layers/recentImagery/catalog.test.mjs",
|
||||||
"src/maps/imageryHost.test.mjs",
|
"src/maps/imageryHost.test.mjs",
|
||||||
@@ -808,6 +823,5 @@
|
|||||||
"src/ui/imageryBoxTool.test.mjs",
|
"src/ui/imageryBoxTool.test.mjs",
|
||||||
"src/ui/recentImagery.test.mjs",
|
"src/ui/recentImagery.test.mjs",
|
||||||
"src/layers/recentImagery/testDoubles.mjs",
|
"src/layers/recentImagery/testDoubles.mjs",
|
||||||
"scripts/qa-recent-imagery.mjs",
|
"scripts/qa-recent-imagery.mjs"
|
||||||
"src/ui/railScrollers.test.mjs"
|
|
||||||
]
|
]
|
||||||
@@ -602,7 +602,10 @@
|
|||||||
"src/layers/weather/imageryHost.js",
|
"src/layers/weather/imageryHost.js",
|
||||||
"src/ui/layerBindings.js",
|
"src/ui/layerBindings.js",
|
||||||
"src/ui/displayBindings.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"]
|
"external": ["cesium"]
|
||||||
},
|
},
|
||||||
@@ -755,7 +758,9 @@
|
|||||||
"src/layers/flights/records.js",
|
"src/layers/flights/records.js",
|
||||||
"src/layers/flights/recordPolicy.js",
|
"src/layers/flights/recordPolicy.js",
|
||||||
"src/layers/flights/snapshotRenderer.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"]
|
"external": ["cesium"]
|
||||||
},
|
},
|
||||||
@@ -790,7 +795,8 @@
|
|||||||
"src/layers/military/records.js",
|
"src/layers/military/records.js",
|
||||||
"src/layers/military/recordPolicy.js",
|
"src/layers/military/recordPolicy.js",
|
||||||
"src/layers/military/snapshotRenderer.js",
|
"src/layers/military/snapshotRenderer.js",
|
||||||
"src/data/inputOwnership.js"
|
"src/data/inputOwnership.js",
|
||||||
|
"src/data/modelEligibility.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium"]
|
"external": ["cesium"]
|
||||||
},
|
},
|
||||||
@@ -1771,6 +1777,7 @@
|
|||||||
"src/layers/flights/records.js",
|
"src/layers/flights/records.js",
|
||||||
"src/layers/flights/recordPolicy.js",
|
"src/layers/flights/recordPolicy.js",
|
||||||
"src/layers/flights/snapshotRenderer.js",
|
"src/layers/flights/snapshotRenderer.js",
|
||||||
|
"src/layers/flights/modelSpec.js",
|
||||||
"src/layers/military/records.js",
|
"src/layers/military/records.js",
|
||||||
"src/layers/military/recordPolicy.js",
|
"src/layers/military/recordPolicy.js",
|
||||||
"src/layers/military/snapshotRenderer.js",
|
"src/layers/military/snapshotRenderer.js",
|
||||||
@@ -1824,6 +1831,7 @@
|
|||||||
"src/data/contactTrailRenderer.js",
|
"src/data/contactTrailRenderer.js",
|
||||||
"src/data/transitProxy.js",
|
"src/data/transitProxy.js",
|
||||||
"src/data/gtfsRealtime.js",
|
"src/data/gtfsRealtime.js",
|
||||||
|
"src/data/modelEligibility.js",
|
||||||
"src/layers/transit/trails.js",
|
"src/layers/transit/trails.js",
|
||||||
"src/layers/transit/movement.js",
|
"src/layers/transit/movement.js",
|
||||||
"src/layers/transit/qaMetrics.js",
|
"src/layers/transit/qaMetrics.js",
|
||||||
@@ -1890,6 +1898,23 @@
|
|||||||
"src/layers/cyclones/rendering.js",
|
"src/layers/cyclones/rendering.js",
|
||||||
"src/layers/cyclones/labels.js",
|
"src/layers/cyclones/labels.js",
|
||||||
"src/layers/cyclones/source.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/app/layers/recentImagery.js",
|
||||||
"src/layers/recentImagery/index.js",
|
"src/layers/recentImagery/index.js",
|
||||||
"src/layers/recentImagery/model.js",
|
"src/layers/recentImagery/model.js",
|
||||||
@@ -1907,7 +1932,8 @@
|
|||||||
"egm96-universal",
|
"egm96-universal",
|
||||||
"mgrs",
|
"mgrs",
|
||||||
"pbf",
|
"pbf",
|
||||||
"satellite.js"
|
"satellite.js",
|
||||||
|
"@jtarrio/webrtlsdr"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"application-catalog": {
|
"application-catalog": {
|
||||||
@@ -2249,6 +2275,7 @@
|
|||||||
"src/layers/flights/records.js",
|
"src/layers/flights/records.js",
|
||||||
"src/layers/flights/recordPolicy.js",
|
"src/layers/flights/recordPolicy.js",
|
||||||
"src/layers/flights/snapshotRenderer.js",
|
"src/layers/flights/snapshotRenderer.js",
|
||||||
|
"src/layers/flights/modelSpec.js",
|
||||||
"src/layers/military/records.js",
|
"src/layers/military/records.js",
|
||||||
"src/layers/military/recordPolicy.js",
|
"src/layers/military/recordPolicy.js",
|
||||||
"src/layers/military/snapshotRenderer.js",
|
"src/layers/military/snapshotRenderer.js",
|
||||||
@@ -2322,6 +2349,25 @@
|
|||||||
"src/layers/cyclones/rendering.js",
|
"src/layers/cyclones/rendering.js",
|
||||||
"src/layers/cyclones/labels.js",
|
"src/layers/cyclones/labels.js",
|
||||||
"src/layers/cyclones/source.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/app/layers/recentImagery.js",
|
||||||
"src/layers/recentImagery/index.js",
|
"src/layers/recentImagery/index.js",
|
||||||
"src/layers/recentImagery/model.js",
|
"src/layers/recentImagery/model.js",
|
||||||
@@ -2337,7 +2383,8 @@
|
|||||||
"satellite.js",
|
"satellite.js",
|
||||||
"@mapbox/vector-tile",
|
"@mapbox/vector-tile",
|
||||||
"pbf",
|
"pbf",
|
||||||
"mgrs"
|
"mgrs",
|
||||||
|
"@jtarrio/webrtlsdr"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"application-operations": {
|
"application-operations": {
|
||||||
|
|||||||
@@ -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<Array<{address:string, family:number}>>}
|
||||||
|
*/
|
||||||
|
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<Response>}
|
||||||
|
*/
|
||||||
|
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<object>} 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import { cctvProxy } from './cctv.js';
|
|||||||
import { defaultSourceRoot } from './common/source-root.js';
|
import { defaultSourceRoot } from './common/source-root.js';
|
||||||
import { radioBrowserProxy } from './radio.js';
|
import { radioBrowserProxy } from './radio.js';
|
||||||
import { gbfsProxy } from './gbfs.js';
|
import { gbfsProxy } from './gbfs.js';
|
||||||
|
import { localReceiversProxy } from './local-receivers.js';
|
||||||
import { transitProxy } from './transit.js';
|
import { transitProxy } from './transit.js';
|
||||||
import { adsbLolProxy } from './aircraft/adsb-lol.js';
|
import { adsbLolProxy } from './aircraft/adsb-lol.js';
|
||||||
import { aisLiveProxy } from './vessels/ais-live.js';
|
import { aisLiveProxy } from './vessels/ais-live.js';
|
||||||
@@ -42,6 +43,7 @@ function localProviderPlugins() {
|
|||||||
cctvProxy({ sourceRoot: defaultSourceRoot }),
|
cctvProxy({ sourceRoot: defaultSourceRoot }),
|
||||||
radioBrowserProxy(),
|
radioBrowserProxy(),
|
||||||
gbfsProxy(),
|
gbfsProxy(),
|
||||||
|
localReceiversProxy(),
|
||||||
transitProxy(),
|
transitProxy(),
|
||||||
adsbLolProxy(),
|
adsbLolProxy(),
|
||||||
aisLiveProxy(),
|
aisLiveProxy(),
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export const ACTION_DESCRIPTIONS = {
|
|||||||
properties: {
|
properties: {
|
||||||
layerId: {
|
layerId: {
|
||||||
description:
|
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,
|
$position: 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { apiNotFoundPlugin } from './api-not-found.js';
|
|||||||
const root = fileURLToPath(new URL('../../', import.meta.url));
|
const root = fileURLToPath(new URL('../../', import.meta.url));
|
||||||
|
|
||||||
/** Load this checkout's configuration and attach its local provider middleware. */
|
/** 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, '');
|
const loaded = loadEnv(mode, root, '');
|
||||||
for (const [key, value] of Object.entries(loaded)) {
|
for (const [key, value] of Object.entries(loaded)) {
|
||||||
if (process.env[key] === undefined) process.env[key] = value;
|
if (process.env[key] === undefined) process.env[key] = value;
|
||||||
@@ -18,5 +18,6 @@ export default defineConfig(({ mode }) => {
|
|||||||
cesiumToken: process.env.CESIUM_ION_TOKEN,
|
cesiumToken: process.env.CESIUM_ION_TOKEN,
|
||||||
host: process.env.HOST,
|
host: process.env.HOST,
|
||||||
port: process.env.PORT,
|
port: process.env.PORT,
|
||||||
|
command,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -11,6 +11,7 @@ const CONTROL_LAYER_IDS = Object.freeze({
|
|||||||
militaryAwarenessLayer: 'military-awareness',
|
militaryAwarenessLayer: 'military-awareness',
|
||||||
militaryInstallationsLayer: 'military-installations',
|
militaryInstallationsLayer: 'military-installations',
|
||||||
rocketLaunchesLayer: 'rocket-launches',
|
rocketLaunchesLayer: 'rocket-launches',
|
||||||
|
localAdsbLayer: 'local-adsb',
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Capture the ordered application instances and their serialization metadata. */
|
/** Capture the ordered application instances and their serialization metadata. */
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ test('controls bind catalog instances rather than similarly named defaults', ()
|
|||||||
'military-awareness',
|
'military-awareness',
|
||||||
'military-installations',
|
'military-installations',
|
||||||
'rocket-launches',
|
'rocket-launches',
|
||||||
|
'local-adsb',
|
||||||
];
|
];
|
||||||
const layers = ids.map((id) => ({ id }));
|
const layers = ids.map((id) => ({ id }));
|
||||||
const catalog = createLayerCatalog(layers, metadata(layers));
|
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.flightsLayer, layers[1]);
|
||||||
assert.equal(services.transitLayer, layers[7]);
|
assert.equal(services.transitLayer, layers[7]);
|
||||||
assert.equal(services.aisLiveVesselsLayer, layers[8]);
|
assert.equal(services.aisLiveVesselsLayer, layers[8]);
|
||||||
|
assert.equal(services.localAdsbLayer, layers[12]);
|
||||||
assert.equal(new Set(Object.values(services)).size, layers.length);
|
assert.equal(new Set(Object.values(services)).size, layers.length);
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => catalogControlServices(createLayerCatalog([], [])),
|
() => catalogControlServices(createLayerCatalog([], [])),
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { createApplicationInstallations } from './layers/militaryInstallations.j
|
|||||||
import { createApplicationSatellites } from './layers/satellites.js';
|
import { createApplicationSatellites } from './layers/satellites.js';
|
||||||
import { createApplicationLaunches } from './layers/rocketLaunches.js';
|
import { createApplicationLaunches } from './layers/rocketLaunches.js';
|
||||||
import { createApplicationAlpr } from './layers/alprCameras.js';
|
import { createApplicationAlpr } from './layers/alprCameras.js';
|
||||||
|
import { createApplicationLocalAdsb } from './layers/localAdsb.js';
|
||||||
import { createApplicationAwareness } from './layers/militaryAwareness.js';
|
import { createApplicationAwareness } from './layers/militaryAwareness.js';
|
||||||
import { createApplicationFirms } from './layers/firms.js';
|
import { createApplicationFirms } from './layers/firms.js';
|
||||||
import { createApplicationEarthquakes } from './layers/earthquakes.js';
|
import { createApplicationEarthquakes } from './layers/earthquakes.js';
|
||||||
@@ -54,6 +55,20 @@ const SOURCE_METHODS = Object.freeze({
|
|||||||
cables: ['fetch'],
|
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.
|
/** Construct the current catalog without choosing any source provider.
|
||||||
* Scene engines remain page-owned; layers and classification have this app's lifetime.
|
* 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.
|
* The manager owns layer destruction, while abort releases classification even if startup fails.
|
||||||
@@ -62,7 +77,7 @@ export function createApplicationCatalog({
|
|||||||
surface,
|
surface,
|
||||||
sources,
|
sources,
|
||||||
signal,
|
signal,
|
||||||
metadata = LAYER_STATE_REGISTRY,
|
metadata = APPLICATION_LAYER_METADATA,
|
||||||
vesselOptions,
|
vesselOptions,
|
||||||
resolveAsset,
|
resolveAsset,
|
||||||
nepalBoundaryResolver,
|
nepalBoundaryResolver,
|
||||||
@@ -120,6 +135,12 @@ export function createApplicationCatalog({
|
|||||||
}),
|
}),
|
||||||
flights,
|
flights,
|
||||||
military,
|
military,
|
||||||
|
createApplicationLocalAdsb({
|
||||||
|
surface,
|
||||||
|
enrichment: sources.flights,
|
||||||
|
displayParams: () => flights.getParams(),
|
||||||
|
...(resolveAsset ? { resolveAsset } : {}),
|
||||||
|
}),
|
||||||
createApplicationEarthquakes({ source: sources.earthquakes }),
|
createApplicationEarthquakes({ source: sources.earthquakes }),
|
||||||
createApplicationAlpr({ surface, source: sources.alpr }),
|
createApplicationAlpr({ surface, source: sources.alpr }),
|
||||||
satellites,
|
satellites,
|
||||||
|
|||||||
@@ -39,7 +39,13 @@ test('catalogs construct distinct layers and classification from their supplied
|
|||||||
signal: b.signal,
|
signal: b.signal,
|
||||||
surface: fixtureSurface(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);
|
assert.notEqual(first.weatherClock, second.weatherClock);
|
||||||
await first.weatherClock.setTarget('2026-09-21T12:00:00.000Z');
|
await first.weatherClock.setTarget('2026-09-21T12:00:00.000Z');
|
||||||
assert.match(
|
assert.match(
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -438,6 +438,20 @@ export function classifyAircraft({ typeCode, category } = {}) {
|
|||||||
return 'airliner';
|
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). */
|
/** Billboard scale multipliers (skylight GLYPH_SCALE, + fastjet). */
|
||||||
export const CLASS_SCALE_2D = {
|
export const CLASS_SCALE_2D = {
|
||||||
light: 0.62,
|
light: 0.62,
|
||||||
|
|||||||
@@ -7,3 +7,16 @@
|
|||||||
(22,980 bytes). Used by offline decode/source tests and the explicit `qa-traffic --fixtures`
|
(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
|
browser mode — it is a point-in-time congestion snapshot, not a bundled
|
||||||
data layer, and is never loaded by ordinary application startup. © TomTom.
|
data layer, and is never loaded by ordinary application startup. © TomTom.
|
||||||
|
- `adsb-austin-frames.txt` — 112 raw Mode S frames (`*<hex>;`, 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.
|
||||||
@@ -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,"8dabe7c55Line truncated
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -16,6 +16,8 @@ const VALID_LAYER_SERIALIZATION_DISPOSITIONS = new Set([
|
|||||||
'enabled-only',
|
'enabled-only',
|
||||||
'enabled+options',
|
'enabled+options',
|
||||||
'enabled+mirrored-options',
|
'enabled+mirrored-options',
|
||||||
|
// Registered but never serialized (for example hardware-local layers).
|
||||||
|
'local-only',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function isAbortError(error) {
|
function isAbortError(error) {
|
||||||
|
|||||||
@@ -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, '<html>');
|
||||||
|
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',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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<string>} 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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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, /keepDistSq/, `${name}: matched the model-eligibility loop`);
|
||||||
assert.match(loop, /if \(isTr3b\(icao\)\) continue;/,
|
assert.match(loop, /if \(isTr3b\(icao\)\) continue;/,
|
||||||
`${name}: converted contacts are dropped BEFORE entering the capped candidate list`);
|
`${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.
|
// ...and the cap really is applied to that list, so a dropped candidate is a
|
||||||
assert.match(source, /modelEligible\.size >= cap/,
|
// 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`);
|
`${name}: the cap bounds the candidate-derived eligible set`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -222,6 +222,7 @@ export function getActiveTrackedReadoutId() {
|
|||||||
const READOUT_CONTEXT_LAYERS = new Set([
|
const READOUT_CONTEXT_LAYERS = new Set([
|
||||||
'military-installations',
|
'military-installations',
|
||||||
'alpr-cameras',
|
'alpr-cameras',
|
||||||
|
'local-adsb',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
|
// ordering while allowing source formatting. Derived from the unchanged
|
||||||
// release schema before formatting (the previous source-byte pin passed).
|
// release schema before formatting (the previous source-byte pin passed).
|
||||||
const block = JSON.stringify(GEV_REALTIME_TOOLS);
|
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(
|
assert.equal(
|
||||||
crypto.createHash('sha256').update(block).digest('hex'),
|
crypto.createHash('sha256').update(block).digest('hex'),
|
||||||
'da5f1bb334d1e882d63098cb35b29afde273947c0807ef6af2ef3d25a0df4918',
|
'1947e9df228b3fd66a874ca462b5e5a29d800191b0180823903628be8888bc52',
|
||||||
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
|
'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');
|
const instructions = fs.readFileSync(new URL('../server/providers/openai/instructions.js', import.meta.url), 'utf8');
|
||||||
|
|||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,14 +1,7 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import {
|
import { selectModelEligible } from '../../data/modelEligibility.js';
|
||||||
CLASS_SCALE_2D,
|
import { civilAircraftModelSpec } from './modelSpec.js';
|
||||||
CLASS_MODEL_REAL,
|
import { CLASS_SCALE_2D } from '../../data/aircraftClass.js';
|
||||||
CLASS_SCALE_3D,
|
|
||||||
CLASS_MODEL_URL,
|
|
||||||
} from '../../data/aircraftClass.js';
|
|
||||||
import {
|
|
||||||
visualCenterForModel,
|
|
||||||
trailAnchorForModel,
|
|
||||||
} from '../../data/modelVisualAnchor.js';
|
|
||||||
import { cockpitContactDotImage } from '../../data/cockpitContactDot.js';
|
import { cockpitContactDotImage } from '../../data/cockpitContactDot.js';
|
||||||
import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js';
|
import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js';
|
||||||
import {
|
import {
|
||||||
@@ -20,11 +13,6 @@ import { limitCourseStep, courseSlewCapDps } from '../../data/motionModel.js';
|
|||||||
import {
|
import {
|
||||||
MIL_TINT,
|
MIL_TINT,
|
||||||
GROUND_SCALE,
|
GROUND_SCALE,
|
||||||
MODEL_COLOR_BLEND_AMOUNT,
|
|
||||||
MODEL_SCALE,
|
|
||||||
PLANE_MODEL_URL,
|
|
||||||
MODEL_NATIVE_RADIUS_M,
|
|
||||||
MODEL_BELLY_OFFSET_NATIVE,
|
|
||||||
COCKPIT_CONTACT_SIZE_PX,
|
COCKPIT_CONTACT_SIZE_PX,
|
||||||
COCKPIT_CIVILIAN_COLOR,
|
COCKPIT_CIVILIAN_COLOR,
|
||||||
MODEL_ALT_CEIL_M,
|
MODEL_ALT_CEIL_M,
|
||||||
@@ -100,31 +88,7 @@ export function createRendering({
|
|||||||
function _modelSpec(klass) {
|
function _modelSpec(klass) {
|
||||||
const cached = flightState._specCache.get(klass);
|
const cached = flightState._specCache.get(klass);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
const real = CLASS_MODEL_REAL[klass];
|
const spec = civilAircraftModelSpec(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),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
flightState._specCache.set(klass, spec);
|
flightState._specCache.set(klass, spec);
|
||||||
return spec;
|
return spec;
|
||||||
}
|
}
|
||||||
@@ -953,31 +917,11 @@ export function createRendering({
|
|||||||
);
|
);
|
||||||
flightState._lastModelCapWarnMs = nowMs;
|
flightState._lastModelCapWarnMs = nowMs;
|
||||||
}
|
}
|
||||||
modelEligible = new Set();
|
modelEligible = selectModelEligible(cand, {
|
||||||
// 1. KEEP on-screen already-modeled (visible retained — no flicker for what you can see).
|
cap,
|
||||||
for (const [icao, , inF] of cand) {
|
addDistSq,
|
||||||
if (modelEligible.size >= cap) break;
|
isModeled: (icao) => flightState._models.has(icao),
|
||||||
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);
|
|
||||||
}
|
|
||||||
const toRelease = [];
|
const toRelease = [];
|
||||||
for (const icao of flightState._models.keys()) {
|
for (const icao of flightState._models.keys()) {
|
||||||
if (icao !== flightState._trackedIcao && !modelEligible.has(icao))
|
if (icao !== flightState._trackedIcao && !modelEligible.has(icao))
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -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<void>}
|
||||||
|
*/
|
||||||
|
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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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<object>} [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,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -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<object>} [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<string, object>} id -> admitted model */
|
||||||
|
const models = new Map();
|
||||||
|
/** @type {Map<string, number>} 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<string, object>} 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<string, object>|Set<string>} 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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
|
});
|
||||||
@@ -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}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
trailAnchorForModel,
|
trailAnchorForModel,
|
||||||
} from '../../data/modelVisualAnchor.js';
|
} from '../../data/modelVisualAnchor.js';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
|
import { selectModelEligible } from '../../data/modelEligibility.js';
|
||||||
import { cockpitContactDotImage } from '../../data/cockpitContactDot.js';
|
import { cockpitContactDotImage } from '../../data/cockpitContactDot.js';
|
||||||
import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js';
|
import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js';
|
||||||
import {
|
import {
|
||||||
@@ -892,25 +893,11 @@ export function createRendering({
|
|||||||
);
|
);
|
||||||
flightState._lastModelCapWarnMs = nowMs;
|
flightState._lastModelCapWarnMs = nowMs;
|
||||||
}
|
}
|
||||||
modelEligible = new Set();
|
modelEligible = selectModelEligible(cand, {
|
||||||
for (const [icao, , inF] of cand) {
|
cap,
|
||||||
if (modelEligible.size >= cap) break;
|
addDistSq,
|
||||||
if (inF && flightState._models.has(icao)) modelEligible.add(icao);
|
isModeled: (icao) => flightState._models.has(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
|
|
||||||
const toRelease = [];
|
const toRelease = [];
|
||||||
for (const icao of flightState._models.keys()) {
|
for (const icao of flightState._models.keys()) {
|
||||||
if (icao !== flightState._trackedIcao && !modelEligible.has(icao))
|
if (icao !== flightState._trackedIcao && !modelEligible.has(icao))
|
||||||
|
|||||||
@@ -183,21 +183,23 @@ test('no unchanged Realtime tool definition drifts silently', () => {
|
|||||||
'analyst_query',
|
'analyst_query',
|
||||||
'next_iss_pass',
|
'next_iss_pass',
|
||||||
'next_satellite_pass',
|
'next_satellite_pass',
|
||||||
|
// Local ADS-B adds one layer enum value and its common-name mapping.
|
||||||
|
'set_layer_visibility',
|
||||||
]);
|
]);
|
||||||
const unchanged = realtimeTools()
|
const unchanged = realtimeTools()
|
||||||
.filter((tool) => !TOUCHED.has(tool.name))
|
.filter((tool) => !TOUCHED.has(tool.name))
|
||||||
.sort((a, b) => a.name.localeCompare(b.name));
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
assert.equal(unchanged.length, 19);
|
assert.equal(unchanged.length, 18);
|
||||||
const digest = createHash('sha256')
|
const digest = createHash('sha256')
|
||||||
.update(JSON.stringify(unchanged))
|
.update(JSON.stringify(unchanged))
|
||||||
.digest('hex')
|
.digest('hex')
|
||||||
.slice(0, 16);
|
.slice(0, 16);
|
||||||
// Analyst additions and ISS wording correction are explicitly excluded above; all other tool definitions retain their pin.
|
// 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', () => {
|
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(
|
assert.match(
|
||||||
html,
|
html,
|
||||||
new RegExp(`id="${id}"[^>]*class="gev-quantitative-slider"[^>]*type="range"`),
|
new RegExp(`id="${id}"[^>]*class="gev-quantitative-slider"[^>]*type="range"`),
|
||||||
|
|||||||
@@ -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<string, object>} 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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<string, Map<string, number>>} [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;
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -16,6 +16,7 @@ import { CockpitCoordinator } from './cockpitCoordinator.js';
|
|||||||
import { ContextControls } from './context.js';
|
import { ContextControls } from './context.js';
|
||||||
import { CctvControls } from './cctv.js';
|
import { CctvControls } from './cctv.js';
|
||||||
import { RadioControls } from './radio.js';
|
import { RadioControls } from './radio.js';
|
||||||
|
import { LocalSdrControls } from './localSdrControls.js';
|
||||||
import { LocationNavigation } from './locationNavigation.js';
|
import { LocationNavigation } from './locationNavigation.js';
|
||||||
import { bindClearLayersControl } from './layers.js';
|
import { bindClearLayersControl } from './layers.js';
|
||||||
import { bindCameraOrientationControls } from './cameraOrientationControls.js';
|
import { bindCameraOrientationControls } from './cameraOrientationControls.js';
|
||||||
@@ -77,6 +78,7 @@ export class StyleManager extends ShellFacade {
|
|||||||
transitLayer,
|
transitLayer,
|
||||||
aisLiveVesselsLayer,
|
aisLiveVesselsLayer,
|
||||||
militaryAwarenessLayer,
|
militaryAwarenessLayer,
|
||||||
|
localAdsbLayer,
|
||||||
} = services;
|
} = services;
|
||||||
this.services = services;
|
this.services = services;
|
||||||
this._lifetime = new UiLifetime();
|
this._lifetime = new UiLifetime();
|
||||||
@@ -449,12 +451,13 @@ export class StyleManager extends ShellFacade {
|
|||||||
trafficLayer,
|
trafficLayer,
|
||||||
flightsLayer,
|
flightsLayer,
|
||||||
militaryFlightsLayer,
|
militaryFlightsLayer,
|
||||||
|
localAdsbLayer,
|
||||||
satellitesLayer,
|
satellitesLayer,
|
||||||
cctvLayer,
|
cctvLayer,
|
||||||
bikeshareLayer,
|
bikeshareLayer,
|
||||||
transitLayer,
|
transitLayer,
|
||||||
aisLiveVesselsLayer,
|
aisLiveVesselsLayer,
|
||||||
],
|
].filter(Boolean),
|
||||||
(modeLabel) => {
|
(modeLabel) => {
|
||||||
this._updateDetectionButton(modeLabel);
|
this._updateDetectionButton(modeLabel);
|
||||||
},
|
},
|
||||||
@@ -860,11 +863,33 @@ export class StyleManager extends ShellFacade {
|
|||||||
isCockpitActive: () => this.cockpitView?.active,
|
isCockpitActive: () => this.cockpitView?.active,
|
||||||
signalUserCollapsed: () => this.cockpitView?.signalUserCollapsed,
|
signalUserCollapsed: () => this.cockpitView?.signalUserCollapsed,
|
||||||
layoutCockpit: () => this.cockpitView?.scheduleContextLayout(),
|
layoutCockpit: () => this.cockpitView?.scheduleContextLayout(),
|
||||||
|
// An open local receiver keeps the shared Radio panel expanded.
|
||||||
preservePanelStateDuringClear: () =>
|
preservePanelStateDuringClear: () =>
|
||||||
this._preservePanelStateDuringLayerClear,
|
this._preservePanelStateDuringLayerClear ||
|
||||||
|
Boolean(this._localSdrControls?.isActive()),
|
||||||
scheduleLayout: () => this._scheduleRightPanelLayout(),
|
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._clearLayersControl?.destroy();
|
||||||
this._cctvControls?.destroy();
|
this._cctvControls?.destroy();
|
||||||
this._radioControls?.destroy();
|
this._radioControls?.destroy();
|
||||||
|
this._localSdrControls?.destroy();
|
||||||
this._cockpitCoordinator.stop();
|
this._cockpitCoordinator.stop();
|
||||||
this._visualSettings.stop();
|
this._visualSettings.stop();
|
||||||
this.shareLinkManager?.destroy();
|
this.shareLinkManager?.destroy();
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const PANEL_GROUPS = [
|
|||||||
'satellites',
|
'satellites',
|
||||||
'flights',
|
'flights',
|
||||||
'military',
|
'military',
|
||||||
|
'local-adsb',
|
||||||
'ais-live-vessels',
|
'ais-live-vessels',
|
||||||
'traffic',
|
'traffic',
|
||||||
'transit',
|
'transit',
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ test('panel presentation places Transit between Street Traffic and Bike Share in
|
|||||||
'satellites',
|
'satellites',
|
||||||
'flights',
|
'flights',
|
||||||
'military',
|
'military',
|
||||||
|
'local-adsb',
|
||||||
'ais-live-vessels',
|
'ais-live-vessels',
|
||||||
'traffic',
|
'traffic',
|
||||||
'transit',
|
'transit',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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('</section>', 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\.</);
|
||||||
|
assert.match(css, /\.sdr-radio-card\s*\{/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the gain control offers AUTO plus every R820T step', () => {
|
||||||
|
const start = html.indexOf('id="sdr-gain-select"');
|
||||||
|
const select = html.slice(start, html.indexOf('</select>', start));
|
||||||
|
const values = [...select.matchAll(/<option value="([^"]+)"/g)].map(
|
||||||
|
(match) => match[1],
|
||||||
|
);
|
||||||
|
assert.deepEqual(values, [
|
||||||
|
'auto',
|
||||||
|
...R820T_GAIN_STEPS_DB.map((step) => step.toFixed(1)),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('worker-reached SDR dependencies are pre-bundled and builds keep a separate cache', () => {
|
||||||
|
const serve = createBrowserViteConfig();
|
||||||
|
for (const dependency of [
|
||||||
|
'@jtarrio/signals/demod/demodulator.js',
|
||||||
|
'@jtarrio/signals/demod/modes.js',
|
||||||
|
'@jtarrio/webrtlsdr/rtlsdr.js',
|
||||||
|
]) {
|
||||||
|
assert.ok(serve.optimizeDeps.include.includes(dependency), dependency);
|
||||||
|
}
|
||||||
|
assert.equal(serve.cacheDir, undefined);
|
||||||
|
assert.equal(
|
||||||
|
createBrowserViteConfig({ command: 'build' }).cacheDir,
|
||||||
|
'node_modules/.vite-build',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { localReceiverFeedName } from '../layers/localAdsb/feedNames.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure presentation of the Local RTL-SDR card in the Radio panel. Receives the
|
||||||
|
* receiver session snapshot and returns what each control should show.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function formatGain(gain) {
|
||||||
|
return gain === 'auto' || !Number.isFinite(gain) ? 'auto' : gain.toFixed(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatIq(state) {
|
||||||
|
return Number.isFinite(state.iqLevelDbfs)
|
||||||
|
? `${state.iqLevelDbfs.toFixed(1)} dBFS`
|
||||||
|
: '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusText(state) {
|
||||||
|
const fmActive = state.mode === 'fm';
|
||||||
|
const iqStatus =
|
||||||
|
state.samplesPerSecond > 0
|
||||||
|
? `${(state.samplesPerSecond / 1_000_000).toFixed(2)} MS/s IQ`
|
||||||
|
: 'waiting for IQ';
|
||||||
|
const device = state.deviceLabel ? ` · ${state.deviceLabel}` : '';
|
||||||
|
if (state.connected && !fmActive) {
|
||||||
|
return `${state.message}${device} · ${iqStatus} · ${state.decodedMessages} messages`;
|
||||||
|
}
|
||||||
|
const detail = state.seeking
|
||||||
|
? state.seekMessage
|
||||||
|
: state.seekMessage || state.message;
|
||||||
|
if (state.connected && fmActive) {
|
||||||
|
const workerStatus =
|
||||||
|
state.workerBlocks > 0
|
||||||
|
? `DSP ${state.workerBlocks} blocks`
|
||||||
|
: 'DSP waiting';
|
||||||
|
const rfStatus = `RF ${formatIq(state)}`;
|
||||||
|
const audioSignal = Number.isFinite(state.audioLevelDbfs)
|
||||||
|
? `${state.audioLevelDbfs.toFixed(1)} dBFS audio`
|
||||||
|
: 'audio signal --';
|
||||||
|
return `${detail}${device} · ${iqStatus} · ${workerStatus} · ${rfStatus} · ${audioSignal} · audio ${state.audioState}`;
|
||||||
|
}
|
||||||
|
return detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the card's control states from one receiver snapshot.
|
||||||
|
* @param {object} state SdrController.getState() snapshot.
|
||||||
|
* @returns {object} Control presentation.
|
||||||
|
*/
|
||||||
|
export function localSdrCardView(state) {
|
||||||
|
const transitional =
|
||||||
|
state.status === 'connecting' || state.status === 'tuning';
|
||||||
|
const fmActive = state.mode === 'fm';
|
||||||
|
const adsbActive = state.mode === 'adsb';
|
||||||
|
const fmInteractive =
|
||||||
|
state.connected && fmActive && state.status === 'streaming';
|
||||||
|
return {
|
||||||
|
connectionLabel: state.connected
|
||||||
|
? adsbActive
|
||||||
|
? `${state.aircraftHeard || 0} HEARD`
|
||||||
|
: 'STREAMING'
|
||||||
|
: String(state.status || 'idle').toUpperCase(),
|
||||||
|
connectionActive: Boolean(state.connected),
|
||||||
|
connectLabel: transitional
|
||||||
|
? String(state.status).toUpperCase()
|
||||||
|
: state.connected
|
||||||
|
? 'DISCONNECT'
|
||||||
|
: 'CONNECT',
|
||||||
|
connectDisabled: transitional || !state.webUsbSupported,
|
||||||
|
connectPressed: Boolean(state.connected),
|
||||||
|
fmActive,
|
||||||
|
adsbActive,
|
||||||
|
modeDisabled: transitional,
|
||||||
|
locateLabel:
|
||||||
|
state.locationStatus === 'requesting'
|
||||||
|
? 'LOCATING…'
|
||||||
|
: state.locationStatus === 'ready'
|
||||||
|
? 'LOCATED'
|
||||||
|
: 'LOCATE',
|
||||||
|
locateDisabled: state.locationStatus === 'requesting',
|
||||||
|
locateActive: state.locationStatus === 'ready',
|
||||||
|
changeDeviceDisabled: transitional || !state.webUsbSupported,
|
||||||
|
gainValue: formatGain(state.gain),
|
||||||
|
gainDisabled: transitional,
|
||||||
|
statsHidden: !(adsbActive && state.connected),
|
||||||
|
stats: {
|
||||||
|
rate: Number.isFinite(state.messagesPerSecond)
|
||||||
|
? state.messagesPerSecond.toFixed(1)
|
||||||
|
: '--',
|
||||||
|
heard: String(state.aircraftHeard || 0),
|
||||||
|
positioned: String(state.aircraftPositioned || 0),
|
||||||
|
iq: formatIq(state),
|
||||||
|
},
|
||||||
|
frequencyDisabled: !fmActive || transitional,
|
||||||
|
frequencyValue: fmActive
|
||||||
|
? (state.frequencyHz / 1_000_000).toFixed(1)
|
||||||
|
: null,
|
||||||
|
tuneDisabled: !state.connected || !fmActive || transitional,
|
||||||
|
seekDisabled: !fmInteractive || Boolean(state.seeking),
|
||||||
|
volumePercent: Math.round((Number(state.volume) || 0) * 100),
|
||||||
|
volumeDisabled: !fmActive,
|
||||||
|
statusText: statusText(state),
|
||||||
|
statusError: state.status === 'error' || state.status === 'unsupported',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the receiver is producing FM audio (the state that must not overlap
|
||||||
|
* internet-radio playback).
|
||||||
|
* @param {object} state SdrController.getState() snapshot.
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function localSdrFmAudioActive(state) {
|
||||||
|
return Boolean(
|
||||||
|
state?.connected && state.mode === 'fm' && state.status === 'streaming',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether internet radio is loading or playing a station.
|
||||||
|
* @param {object} state Radio UI state.
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function internetRadioAudioActive(state) {
|
||||||
|
return ['loading', 'buffering', 'playing'].includes(state?.audioState);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-line decoder-feed summary for the card, or null when the server has no
|
||||||
|
* feed configured. Statuses show while Local ADS-B polls the feeds; otherwise
|
||||||
|
* the line names the configured bands.
|
||||||
|
* @param {object|null} feedState Local receiver feed session snapshot.
|
||||||
|
* @returns {string|null}
|
||||||
|
*/
|
||||||
|
export function localSdrFeedLine(feedState) {
|
||||||
|
const feeds = feedState?.configured === true ? feedState.feeds : null;
|
||||||
|
if (!Array.isArray(feeds) || !feeds.length) return null;
|
||||||
|
const names = feeds.map((feed) => {
|
||||||
|
const name = localReceiverFeedName(feed, feeds);
|
||||||
|
return feedState.polling || feed?.status === 'invalid'
|
||||||
|
? `${name} ${feed?.status || 'unreachable'}`
|
||||||
|
: name;
|
||||||
|
});
|
||||||
|
const suffix = feedState.polling ? '' : ' · read while Local ADS-B is on';
|
||||||
|
return `Decoder feeds: ${names.join(' · ')}${suffix}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import {
|
||||||
|
internetRadioAudioActive,
|
||||||
|
localSdrCardView,
|
||||||
|
localSdrFeedLine,
|
||||||
|
localSdrFmAudioActive,
|
||||||
|
} from './localSdrPresentation.js';
|
||||||
|
|
||||||
|
const idle = Object.freeze({
|
||||||
|
webUsbSupported: true,
|
||||||
|
connected: false,
|
||||||
|
status: 'idle',
|
||||||
|
message: 'Connect an RTL-SDR to begin.',
|
||||||
|
mode: 'fm',
|
||||||
|
frequencyHz: 98_500_000,
|
||||||
|
volume: 0.8,
|
||||||
|
gain: 'auto',
|
||||||
|
seeking: false,
|
||||||
|
seekMessage: '',
|
||||||
|
decodedMessages: 0,
|
||||||
|
messagesPerSecond: null,
|
||||||
|
aircraftHeard: 0,
|
||||||
|
aircraftPositioned: 0,
|
||||||
|
deviceLabel: null,
|
||||||
|
locationStatus: 'unknown',
|
||||||
|
audioState: 'idle',
|
||||||
|
samplesPerSecond: 0,
|
||||||
|
workerBlocks: 0,
|
||||||
|
iqLevelDbfs: null,
|
||||||
|
audioLevelDbfs: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an idle card invites a connection and hides receiver stats', () => {
|
||||||
|
const view = localSdrCardView(idle);
|
||||||
|
assert.equal(view.statusText, 'Connect an RTL-SDR to begin.');
|
||||||
|
assert.equal(view.connectLabel, 'CONNECT');
|
||||||
|
assert.equal(view.connectDisabled, false);
|
||||||
|
assert.equal(view.connectionLabel, 'IDLE');
|
||||||
|
assert.equal(view.gainValue, 'auto');
|
||||||
|
assert.equal(view.statsHidden, true);
|
||||||
|
assert.equal(view.tuneDisabled, true);
|
||||||
|
assert.equal(view.seekDisabled, true);
|
||||||
|
assert.equal(view.frequencyValue, '98.5');
|
||||||
|
const unsupported = localSdrCardView({
|
||||||
|
...idle,
|
||||||
|
webUsbSupported: false,
|
||||||
|
status: 'unsupported',
|
||||||
|
message: 'WebUSB is unavailable in this browser',
|
||||||
|
});
|
||||||
|
assert.equal(unsupported.connectDisabled, true);
|
||||||
|
assert.equal(unsupported.changeDeviceDisabled, true);
|
||||||
|
assert.equal(unsupported.statusError, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('streaming ADS-B shows message rate, heard, positioned and IQ level', () => {
|
||||||
|
const view = localSdrCardView({
|
||||||
|
...idle,
|
||||||
|
connected: true,
|
||||||
|
status: 'streaming',
|
||||||
|
mode: 'adsb',
|
||||||
|
message: 'Receiving 1090 MHz ADS-B',
|
||||||
|
gain: 20.7,
|
||||||
|
messagesPerSecond: 13.8,
|
||||||
|
aircraftHeard: 4,
|
||||||
|
aircraftPositioned: 2,
|
||||||
|
iqLevelDbfs: -21.44,
|
||||||
|
samplesPerSecond: 2_000_000,
|
||||||
|
decodedMessages: 276,
|
||||||
|
deviceLabel: 'FlyCatcher_ADS_B',
|
||||||
|
});
|
||||||
|
assert.equal(view.statsHidden, false);
|
||||||
|
assert.deepEqual(view.stats, {
|
||||||
|
rate: '13.8',
|
||||||
|
heard: '4',
|
||||||
|
positioned: '2',
|
||||||
|
iq: '-21.4 dBFS',
|
||||||
|
});
|
||||||
|
assert.equal(view.gainValue, '20.7');
|
||||||
|
assert.equal(view.connectionLabel, '4 HEARD');
|
||||||
|
assert.equal(view.connectLabel, 'DISCONNECT');
|
||||||
|
assert.equal(view.frequencyDisabled, true);
|
||||||
|
assert.equal(view.volumeDisabled, true);
|
||||||
|
assert.equal(
|
||||||
|
view.statusText,
|
||||||
|
'Receiving 1090 MHz ADS-B · FlyCatcher_ADS_B · 2.00 MS/s IQ · 276 messages',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('local FM and internet radio audio states drive the one-listener rule', () => {
|
||||||
|
assert.equal(localSdrFmAudioActive(idle), false);
|
||||||
|
assert.equal(
|
||||||
|
localSdrFmAudioActive({ ...idle, connected: true, status: 'streaming' }),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
localSdrFmAudioActive({
|
||||||
|
...idle,
|
||||||
|
connected: true,
|
||||||
|
status: 'streaming',
|
||||||
|
mode: 'adsb',
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
'ADS-B produces no audio',
|
||||||
|
);
|
||||||
|
for (const audioState of ['loading', 'buffering', 'playing'])
|
||||||
|
assert.equal(internetRadioAudioActive({ audioState }), true);
|
||||||
|
assert.equal(internetRadioAudioActive({ audioState: 'paused' }), false);
|
||||||
|
assert.equal(internetRadioAudioActive(null), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the decoder-feed line appears only when feeds are configured', () => {
|
||||||
|
assert.equal(localSdrFeedLine(null), null);
|
||||||
|
assert.equal(
|
||||||
|
localSdrFeedLine({ configured: false, feeds: [], polling: false }),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const feeds = [
|
||||||
|
{ band: '1090', label: '1090 MHz', status: 'live' },
|
||||||
|
{ band: '978', label: '978 MHz UAT', status: 'unreachable' },
|
||||||
|
];
|
||||||
|
assert.equal(
|
||||||
|
localSdrFeedLine({ configured: true, polling: true, feeds }),
|
||||||
|
'Decoder feeds: 1090 live · 978 unreachable',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
localSdrFeedLine({ configured: true, polling: false, feeds }),
|
||||||
|
'Decoder feeds: 1090 · 978 · read while Local ADS-B is on',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
localSdrFeedLine({
|
||||||
|
configured: true,
|
||||||
|
polling: false,
|
||||||
|
feeds: [{ band: null, label: 'entry 2', status: 'invalid' }],
|
||||||
|
}),
|
||||||
|
'Decoder feeds: entry 2 invalid · read while Local ADS-B is on',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('two feeds on one band are told apart by their ordinal labels', () => {
|
||||||
|
const feeds = [
|
||||||
|
{ band: '1090', label: '1090 MHz', status: 'live' },
|
||||||
|
{ band: '978', label: '978 MHz UAT #1', status: 'live' },
|
||||||
|
{ band: '978', label: '978 MHz UAT #2', status: 'stale' },
|
||||||
|
];
|
||||||
|
assert.equal(
|
||||||
|
localSdrFeedLine({ configured: true, polling: true, feeds }),
|
||||||
|
'Decoder feeds: 1090 live · 978 MHz UAT #1 live · 978 MHz UAT #2 stale',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
localSdrFeedLine({ configured: true, polling: false, feeds }),
|
||||||
|
'Decoder feeds: 1090 · 978 MHz UAT #1 · 978 MHz UAT #2 · read while Local ADS-B is on',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -899,3 +899,132 @@
|
|||||||
padding-top: 3px;
|
padding-top: 3px;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
/* ── Local RTL-SDR card (FM + Local ADS-B receiver) ── */
|
||||||
|
.sdr-radio-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
margin-top: 3px;
|
||||||
|
padding: 9px;
|
||||||
|
border: 1px solid rgba(255, 170, 60, 0.28);
|
||||||
|
border-radius: 7px;
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
rgba(31, 18, 4, 0.58),
|
||||||
|
rgba(4, 20, 24, 0.72)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
.sdr-radio-heading,
|
||||||
|
.sdr-radio-actions,
|
||||||
|
.sdr-mode-row,
|
||||||
|
.sdr-gain-row,
|
||||||
|
.sdr-frequency-row,
|
||||||
|
.sdr-seek-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.sdr-radio-heading {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.sdr-radio-heading strong {
|
||||||
|
color: #ffd38a;
|
||||||
|
font: 9px/1 var(--font-mono);
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
}
|
||||||
|
#sdr-connection-state {
|
||||||
|
color: #9e865f;
|
||||||
|
font: 7px/1 var(--font-mono);
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
}
|
||||||
|
#sdr-connection-state.active {
|
||||||
|
color: #74f7c4;
|
||||||
|
}
|
||||||
|
.sdr-radio-card > p {
|
||||||
|
margin: 0;
|
||||||
|
color: rgba(213, 230, 225, 0.58);
|
||||||
|
font: 7px/1.4 var(--font-mono);
|
||||||
|
}
|
||||||
|
.sdr-radio-card button,
|
||||||
|
.sdr-radio-card select,
|
||||||
|
.sdr-radio-card input[type='number'] {
|
||||||
|
min-height: 28px;
|
||||||
|
border: 1px solid rgba(255, 183, 82, 0.28);
|
||||||
|
border-radius: 5px;
|
||||||
|
color: #ffd79a;
|
||||||
|
background: rgba(38, 25, 8, 0.52);
|
||||||
|
font: 8px/1 var(--font-mono);
|
||||||
|
}
|
||||||
|
.sdr-radio-card button {
|
||||||
|
padding: 6px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.sdr-radio-card button:hover:not(:disabled),
|
||||||
|
.sdr-radio-card button:focus-visible:not(:disabled),
|
||||||
|
.sdr-radio-card button.active {
|
||||||
|
border-color: #ffd38a;
|
||||||
|
color: #fff;
|
||||||
|
outline: none;
|
||||||
|
background: rgba(130, 83, 18, 0.28);
|
||||||
|
}
|
||||||
|
.sdr-radio-card button:disabled,
|
||||||
|
.sdr-radio-card select:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.sdr-radio-actions > *,
|
||||||
|
.sdr-mode-row > *,
|
||||||
|
.sdr-seek-row > * {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.sdr-gain-row label,
|
||||||
|
.sdr-frequency-row label {
|
||||||
|
color: #9e865f;
|
||||||
|
font: 7px/1 var(--font-mono);
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
.sdr-gain-row select {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 5px 7px;
|
||||||
|
}
|
||||||
|
.sdr-adsb-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 4px 8px;
|
||||||
|
color: #9e865f;
|
||||||
|
font: 7px/1.3 var(--font-mono);
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
.sdr-adsb-stats[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.sdr-adsb-stats output {
|
||||||
|
color: #ffd79a;
|
||||||
|
font: 9px/1 var(--font-mono);
|
||||||
|
}
|
||||||
|
.sdr-frequency-row input[type='number'] {
|
||||||
|
width: 78px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 5px 7px;
|
||||||
|
}
|
||||||
|
.sdr-frequency-row button {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.sdr-radio-card .radio-volume-row {
|
||||||
|
color: #9e865f;
|
||||||
|
}
|
||||||
|
.sdr-radio-card .radio-playback-state {
|
||||||
|
color: #ddbe86;
|
||||||
|
}
|
||||||
|
.sdr-radio-card .radio-playback-state.error {
|
||||||
|
color: #ff9d8f;
|
||||||
|
}
|
||||||
|
.sdr-feed-status {
|
||||||
|
color: #9e865f;
|
||||||
|
font: 8px/1.3 var(--font-mono);
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.sdr-feed-status[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
@@ -109,7 +109,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section id="radio-panel" class="panel-collapsible collapsed" data-panel-id="radio-panel" aria-label="Internet radio companion">
|
<section id="radio-panel" class="panel-collapsible collapsed" data-panel-id="radio-panel" aria-label="Internet radio and local SDR">
|
||||||
<div class="radio-panel-inner">
|
<div class="radio-panel-inner">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<span class="panel-title">RADIO</span>
|
<span class="panel-title">RADIO</span>
|
||||||
@@ -162,6 +162,79 @@
|
|||||||
<a href="https://www.radio-browser.info/" target="_blank" rel="noopener noreferrer">DIRECTORY: RADIO BROWSER</a>
|
<a href="https://www.radio-browser.info/" target="_blank" rel="noopener noreferrer">DIRECTORY: RADIO BROWSER</a>
|
||||||
</div>
|
</div>
|
||||||
<p class="radio-privacy">Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.</p>
|
<p class="radio-privacy">Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.</p>
|
||||||
|
<div id="sdr-radio-card" class="sdr-radio-card" role="group" aria-label="Local RTL-SDR receiver">
|
||||||
|
<div class="sdr-radio-heading">
|
||||||
|
<strong>LOCAL RTL-SDR</strong>
|
||||||
|
<span id="sdr-connection-state">NO USB</span>
|
||||||
|
</div>
|
||||||
|
<p>Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.</p>
|
||||||
|
<div class="sdr-radio-actions">
|
||||||
|
<button id="sdr-connect-btn" class="panel-layer-toggle" type="button" aria-pressed="false">CONNECT</button>
|
||||||
|
<button id="sdr-locate-btn" type="button" title="Use your location for faster ADS-B position decoding">LOCATE</button>
|
||||||
|
<button id="sdr-change-device-btn" type="button" title="Choose a different RTL-SDR or receiver channel">CHANGE DEVICE</button>
|
||||||
|
</div>
|
||||||
|
<div class="sdr-mode-row" role="group" aria-label="RTL-SDR receiver mode">
|
||||||
|
<button id="sdr-mode-fm-btn" class="active" type="button" aria-pressed="true">FM</button>
|
||||||
|
<button id="sdr-mode-adsb-btn" type="button" aria-pressed="false">ADS-B · 1090</button>
|
||||||
|
</div>
|
||||||
|
<div class="sdr-gain-row">
|
||||||
|
<label for="sdr-gain-select">GAIN</label>
|
||||||
|
<select id="sdr-gain-select" aria-label="RTL-SDR tuner gain for the current mode">
|
||||||
|
<option value="auto">AUTO</option>
|
||||||
|
<option value="0.0">0.0 dB</option>
|
||||||
|
<option value="0.9">0.9 dB</option>
|
||||||
|
<option value="1.4">1.4 dB</option>
|
||||||
|
<option value="2.7">2.7 dB</option>
|
||||||
|
<option value="3.7">3.7 dB</option>
|
||||||
|
<option value="7.7">7.7 dB</option>
|
||||||
|
<option value="8.7">8.7 dB</option>
|
||||||
|
<option value="12.5">12.5 dB</option>
|
||||||
|
<option value="14.4">14.4 dB</option>
|
||||||
|
<option value="15.7">15.7 dB</option>
|
||||||
|
<option value="16.6">16.6 dB</option>
|
||||||
|
<option value="19.7">19.7 dB</option>
|
||||||
|
<option value="20.7">20.7 dB</option>
|
||||||
|
<option value="22.9">22.9 dB</option>
|
||||||
|
<option value="25.4">25.4 dB</option>
|
||||||
|
<option value="28.0">28.0 dB</option>
|
||||||
|
<option value="29.7">29.7 dB</option>
|
||||||
|
<option value="32.8">32.8 dB</option>
|
||||||
|
<option value="33.8">33.8 dB</option>
|
||||||
|
<option value="36.4">36.4 dB</option>
|
||||||
|
<option value="37.2">37.2 dB</option>
|
||||||
|
<option value="38.6">38.6 dB</option>
|
||||||
|
<option value="40.2">40.2 dB</option>
|
||||||
|
<option value="42.1">42.1 dB</option>
|
||||||
|
<option value="43.4">43.4 dB</option>
|
||||||
|
<option value="43.9">43.9 dB</option>
|
||||||
|
<option value="44.5">44.5 dB</option>
|
||||||
|
<option value="48.0">48.0 dB</option>
|
||||||
|
<option value="49.6">49.6 dB</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="sdr-adsb-stats" class="sdr-adsb-stats" role="status" aria-live="off" hidden>
|
||||||
|
<span><output id="sdr-stat-rate">--</output> MSG/S</span>
|
||||||
|
<span><output id="sdr-stat-heard">0</output> HEARD</span>
|
||||||
|
<span><output id="sdr-stat-positioned">0</output> POSITIONED</span>
|
||||||
|
<span>IQ <output id="sdr-stat-iq">--</output></span>
|
||||||
|
</div>
|
||||||
|
<div class="sdr-frequency-row">
|
||||||
|
<label for="sdr-frequency-input">FM MHZ</label>
|
||||||
|
<input id="sdr-frequency-input" type="number" min="87.5" max="108" step="0.1" value="98.5" inputmode="decimal" />
|
||||||
|
<button id="sdr-tune-btn" type="button">TUNE</button>
|
||||||
|
</div>
|
||||||
|
<div class="sdr-seek-row" role="group" aria-label="Seek broadcast FM stations">
|
||||||
|
<button id="sdr-seek-back-btn" type="button" disabled>◀ SEEK</button>
|
||||||
|
<button id="sdr-seek-forward-btn" type="button" disabled>SEEK ▶</button>
|
||||||
|
</div>
|
||||||
|
<label class="radio-volume-row" for="sdr-volume">
|
||||||
|
<span>SDR VOL</span>
|
||||||
|
<input id="sdr-volume" class="gev-quantitative-slider" type="range" min="0" max="100" value="80" aria-label="Local SDR volume" />
|
||||||
|
<output id="sdr-volume-value" class="gev-slider-value" for="sdr-volume">80%</output>
|
||||||
|
</label>
|
||||||
|
<div id="sdr-status" class="radio-playback-state" role="status" aria-live="polite">Connect an RTL-SDR to begin.</div>
|
||||||
|
<div id="sdr-feed-status" class="sdr-feed-status" role="status" aria-live="off" hidden></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ const schemas = [
|
|||||||
'telegeography-submarine-cables',
|
'telegeography-submarine-cables',
|
||||||
'local-firms',
|
'local-firms',
|
||||||
'alpr-cameras',
|
'alpr-cameras',
|
||||||
|
'local-adsb',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
enabled: {
|
enabled: {
|
||||||
|
|||||||
@@ -15,13 +15,14 @@ const stable = (value) =>
|
|||||||
)
|
)
|
||||||
: value;
|
: value;
|
||||||
|
|
||||||
test('the complete Realtime tool payload pins the additive analyst and satellite release', () => {
|
test('the complete Realtime tool payload pins the additive analyst, satellite and Local ADS-B release', () => {
|
||||||
const digest = createHash('sha256')
|
const digest = createHash('sha256')
|
||||||
.update(JSON.stringify(stable(GEV_REALTIME_TOOLS)))
|
.update(JSON.stringify(stable(GEV_REALTIME_TOOLS)))
|
||||||
.digest('hex');
|
.digest('hex');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
digest,
|
digest,
|
||||||
'9e33ac0fa5a860a17bb6d79f2c43646b6c36d0c932590bf114814e891a1c96de',
|
// Re-derived for the additive `local-adsb` set_layer_visibility value.
|
||||||
|
'07c2439e085cce9b9e1b36dfd01e2a688e66957db464c740627dd378536d904b',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -86,6 +87,10 @@ test('all legacy action arguments are byte-identical after removing the delibera
|
|||||||
layers.enum = layers.enum.filter(
|
layers.enum = layers.enum.filter(
|
||||||
(key) => !['satellites', 'local-datacenters', 'local-dams'].includes(key),
|
(key) => !['satellites', 'local-datacenters', 'local-dams'].includes(key),
|
||||||
);
|
);
|
||||||
|
// Local ADS-B is an additive set_layer_visibility enum value.
|
||||||
|
const visibility = legacy.find((tool) => tool.name === 'set_layer_visibility')
|
||||||
|
.parameters.properties.layerId;
|
||||||
|
visibility.enum = visibility.enum.filter((key) => key !== 'local-adsb');
|
||||||
// Independently derived by executing trusted c9f9896 actionSchemas in the restricted container.
|
// Independently derived by executing trusted c9f9896 actionSchemas in the restricted container.
|
||||||
assert.equal(
|
assert.equal(
|
||||||
createHash('sha256').update(JSON.stringify(legacy)).digest('hex'),
|
createHash('sha256').update(JSON.stringify(legacy)).digest('hex'),
|
||||||
|
|||||||
@@ -226,6 +226,12 @@ const LAYER_ALIASES = new Map([
|
|||||||
['license plate readers', 'alpr-cameras'],
|
['license plate readers', 'alpr-cameras'],
|
||||||
['license plate cameras', 'alpr-cameras'],
|
['license plate cameras', 'alpr-cameras'],
|
||||||
['plate readers', 'alpr-cameras'],
|
['plate readers', 'alpr-cameras'],
|
||||||
|
['local-adsb', 'local-adsb'],
|
||||||
|
['local adsb', 'local-adsb'],
|
||||||
|
['local ads-b', 'local-adsb'],
|
||||||
|
['my receiver', 'local-adsb'],
|
||||||
|
['my antenna', 'local-adsb'],
|
||||||
|
['my sdr', 'local-adsb'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const CITY_ALIASES = new Map([
|
const CITY_ALIASES = new Map([
|
||||||
|
|||||||
@@ -3165,6 +3165,30 @@ test('ALPR common names toggle only the registered camera layer through the norm
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Local ADS-B common names toggle only the receiver layer through the normal voice action', async () => {
|
||||||
|
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
|
||||||
|
const viewer = { clock: { onTick: { addEventListener: () => () => {} } },
|
||||||
|
scene: { canvas: { addEventListener() {}, removeEventListener() {} } },
|
||||||
|
camera: { moveEnd: { addEventListener() {} } } };
|
||||||
|
const calls = [];
|
||||||
|
let enabled = false;
|
||||||
|
const dataManager = {
|
||||||
|
layers: new Map([['local-adsb', { module: {} }]]),
|
||||||
|
getAll: () => [{ id: 'local-adsb', name: 'Local ADS-B' }],
|
||||||
|
isEnabled: () => enabled,
|
||||||
|
setEnabled: async (id, value) => { calls.push([id, value]); enabled = value; return true; },
|
||||||
|
};
|
||||||
|
const runner = createGevActionRunner({ viewer, styleManager: {}, dataManager });
|
||||||
|
for (const alias of ['local-adsb', 'local ADS-B', 'Local ADSB', 'my receiver', 'my antenna', 'my SDR']) {
|
||||||
|
for (const value of [true, false]) {
|
||||||
|
const result = await runner('set_layer_visibility', { layerId: alias, enabled: value });
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(result.layerId, 'local-adsb');
|
||||||
|
assert.deepEqual(calls.at(-1), ['local-adsb', value]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('ISS voice lookup uses the registered satellite instance', async () => {
|
test('ISS voice lookup uses the registered satellite instance', async () => {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
const viewer = {
|
const viewer = {
|
||||||
|
|||||||
Reference in new issue
Block a user