mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24: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
@@ -11,6 +11,7 @@ const CONTROL_LAYER_IDS = Object.freeze({
|
||||
militaryAwarenessLayer: 'military-awareness',
|
||||
militaryInstallationsLayer: 'military-installations',
|
||||
rocketLaunchesLayer: 'rocket-launches',
|
||||
localAdsbLayer: 'local-adsb',
|
||||
});
|
||||
|
||||
/** Capture the ordered application instances and their serialization metadata. */
|
||||
|
||||
@@ -93,6 +93,7 @@ test('controls bind catalog instances rather than similarly named defaults', ()
|
||||
'military-awareness',
|
||||
'military-installations',
|
||||
'rocket-launches',
|
||||
'local-adsb',
|
||||
];
|
||||
const layers = ids.map((id) => ({ id }));
|
||||
const catalog = createLayerCatalog(layers, metadata(layers));
|
||||
@@ -100,6 +101,7 @@ test('controls bind catalog instances rather than similarly named defaults', ()
|
||||
assert.equal(services.flightsLayer, layers[1]);
|
||||
assert.equal(services.transitLayer, layers[7]);
|
||||
assert.equal(services.aisLiveVesselsLayer, layers[8]);
|
||||
assert.equal(services.localAdsbLayer, layers[12]);
|
||||
assert.equal(new Set(Object.values(services)).size, layers.length);
|
||||
assert.throws(
|
||||
() => catalogControlServices(createLayerCatalog([], [])),
|
||||
|
||||
@@ -19,6 +19,7 @@ import { createApplicationInstallations } from './layers/militaryInstallations.j
|
||||
import { createApplicationSatellites } from './layers/satellites.js';
|
||||
import { createApplicationLaunches } from './layers/rocketLaunches.js';
|
||||
import { createApplicationAlpr } from './layers/alprCameras.js';
|
||||
import { createApplicationLocalAdsb } from './layers/localAdsb.js';
|
||||
import { createApplicationAwareness } from './layers/militaryAwareness.js';
|
||||
import { createApplicationFirms } from './layers/firms.js';
|
||||
import { createApplicationEarthquakes } from './layers/earthquakes.js';
|
||||
@@ -54,6 +55,20 @@ const SOURCE_METHODS = Object.freeze({
|
||||
cables: ['fetch'],
|
||||
});
|
||||
|
||||
/**
|
||||
* Hardware-local layers are registered like any other but never enter share
|
||||
* links or stored layer state: another browser cannot have this receiver.
|
||||
*/
|
||||
export const LOCAL_ONLY_LAYER_METADATA = Object.freeze([
|
||||
Object.freeze({ id: 'local-adsb', disposition: 'local-only' }),
|
||||
]);
|
||||
|
||||
/** Serialization metadata for every layer the application catalog constructs. */
|
||||
export const APPLICATION_LAYER_METADATA = Object.freeze([
|
||||
...LAYER_STATE_REGISTRY,
|
||||
...LOCAL_ONLY_LAYER_METADATA,
|
||||
]);
|
||||
|
||||
/** Construct the current catalog without choosing any source provider.
|
||||
* Scene engines remain page-owned; layers and classification have this app's lifetime.
|
||||
* The manager owns layer destruction, while abort releases classification even if startup fails.
|
||||
@@ -62,7 +77,7 @@ export function createApplicationCatalog({
|
||||
surface,
|
||||
sources,
|
||||
signal,
|
||||
metadata = LAYER_STATE_REGISTRY,
|
||||
metadata = APPLICATION_LAYER_METADATA,
|
||||
vesselOptions,
|
||||
resolveAsset,
|
||||
nepalBoundaryResolver,
|
||||
@@ -120,6 +135,12 @@ export function createApplicationCatalog({
|
||||
}),
|
||||
flights,
|
||||
military,
|
||||
createApplicationLocalAdsb({
|
||||
surface,
|
||||
enrichment: sources.flights,
|
||||
displayParams: () => flights.getParams(),
|
||||
...(resolveAsset ? { resolveAsset } : {}),
|
||||
}),
|
||||
createApplicationEarthquakes({ source: sources.earthquakes }),
|
||||
createApplicationAlpr({ surface, source: sources.alpr }),
|
||||
satellites,
|
||||
|
||||
@@ -39,7 +39,13 @@ test('catalogs construct distinct layers and classification from their supplied
|
||||
signal: b.signal,
|
||||
surface: fixtureSurface(b.signal),
|
||||
});
|
||||
assert.equal(first.layers.length, 27);
|
||||
assert.equal(first.layers.length, 28);
|
||||
assert.ok(first.get('local-adsb'), 'Local ADS-B is registered');
|
||||
assert.deepEqual(
|
||||
first.metadata.find(({ id }) => id === 'local-adsb'),
|
||||
{ id: 'local-adsb', disposition: 'local-only' },
|
||||
'the hardware-local layer is never serialized into links',
|
||||
);
|
||||
assert.notEqual(first.weatherClock, second.weatherClock);
|
||||
await first.weatherClock.setTarget('2026-09-21T12:00:00.000Z');
|
||||
assert.match(
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
/** Readable name of each class, for cards and readouts. */
|
||||
export const AIRCRAFT_CLASS_LABELS = Object.freeze({
|
||||
light: 'Light aircraft',
|
||||
glider: 'Glider',
|
||||
turboprop: 'Turboprop',
|
||||
airliner: 'Airliner',
|
||||
widebody: 'Widebody',
|
||||
quadjet: 'Four-engine jet',
|
||||
helicopter: 'Helicopter',
|
||||
fastjet: 'Fast jet',
|
||||
bizjet: 'Business jet',
|
||||
uav: 'Drone',
|
||||
});
|
||||
|
||||
/** Billboard scale multipliers (skylight GLYPH_SCALE, + fastjet). */
|
||||
export const CLASS_SCALE_2D = {
|
||||
light: 0.62,
|
||||
|
||||
@@ -7,3 +7,16 @@
|
||||
(22,980 bytes). Used by offline decode/source tests and the explicit `qa-traffic --fixtures`
|
||||
browser mode — it is a point-in-time congestion snapshot, not a bundled
|
||||
data layer, and is never loaded by ordinary application startup. © TomTom.
|
||||
- `adsb-austin-frames.txt` — 112 raw Mode S frames (`*<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+options',
|
||||
'enabled+mirrored-options',
|
||||
// Registered but never serialized (for example hardware-local layers).
|
||||
'local-only',
|
||||
]);
|
||||
|
||||
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, /if \(isTr3b\(icao\)\) continue;/,
|
||||
`${name}: converted contacts are dropped BEFORE entering the capped candidate list`);
|
||||
// ...and the cap really is applied to that list, so a dropped candidate is a freed slot.
|
||||
assert.match(source, /modelEligible\.size >= cap/,
|
||||
// ...and the cap really is applied to that list, so a dropped candidate is a
|
||||
// freed slot. The four-pass selection itself is the shared
|
||||
// selectModelEligible (behavior-tested in modelEligibility.test.mjs).
|
||||
assert.match(source, /modelEligible\s*=\s*selectModelEligible\(\s*cand,\s*\{\s*cap,/,
|
||||
`${name}: the cap bounds the candidate-derived eligible set`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -222,6 +222,7 @@ export function getActiveTrackedReadoutId() {
|
||||
const READOUT_CONTEXT_LAYERS = new Set([
|
||||
'military-installations',
|
||||
'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
|
||||
// release schema before formatting (the previous source-byte pin passed).
|
||||
const block = JSON.stringify(GEV_REALTIME_TOOLS);
|
||||
assert.equal(block.length, 27271, 'serialized tool schema length drifted');
|
||||
// Re-derived for the additive `local-adsb` set_layer_visibility value and
|
||||
// its common-name mapping; the missions still ride existing tools.
|
||||
assert.equal(block.length, 27378, 'serialized tool schema length drifted');
|
||||
assert.equal(
|
||||
crypto.createHash('sha256').update(block).digest('hex'),
|
||||
'da5f1bb334d1e882d63098cb35b29afde273947c0807ef6af2ef3d25a0df4918',
|
||||
'1947e9df228b3fd66a874ca462b5e5a29d800191b0180823903628be8888bc52',
|
||||
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
|
||||
);
|
||||
const instructions = fs.readFileSync(new URL('../server/providers/openai/instructions.js', import.meta.url), 'utf8');
|
||||
|
||||
@@ -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 {
|
||||
CLASS_SCALE_2D,
|
||||
CLASS_MODEL_REAL,
|
||||
CLASS_SCALE_3D,
|
||||
CLASS_MODEL_URL,
|
||||
} from '../../data/aircraftClass.js';
|
||||
import {
|
||||
visualCenterForModel,
|
||||
trailAnchorForModel,
|
||||
} from '../../data/modelVisualAnchor.js';
|
||||
import { selectModelEligible } from '../../data/modelEligibility.js';
|
||||
import { civilAircraftModelSpec } from './modelSpec.js';
|
||||
import { CLASS_SCALE_2D } from '../../data/aircraftClass.js';
|
||||
import { cockpitContactDotImage } from '../../data/cockpitContactDot.js';
|
||||
import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js';
|
||||
import {
|
||||
@@ -20,11 +13,6 @@ import { limitCourseStep, courseSlewCapDps } from '../../data/motionModel.js';
|
||||
import {
|
||||
MIL_TINT,
|
||||
GROUND_SCALE,
|
||||
MODEL_COLOR_BLEND_AMOUNT,
|
||||
MODEL_SCALE,
|
||||
PLANE_MODEL_URL,
|
||||
MODEL_NATIVE_RADIUS_M,
|
||||
MODEL_BELLY_OFFSET_NATIVE,
|
||||
COCKPIT_CONTACT_SIZE_PX,
|
||||
COCKPIT_CIVILIAN_COLOR,
|
||||
MODEL_ALT_CEIL_M,
|
||||
@@ -100,31 +88,7 @@ export function createRendering({
|
||||
function _modelSpec(klass) {
|
||||
const cached = flightState._specCache.get(klass);
|
||||
if (cached) return cached;
|
||||
const real = CLASS_MODEL_REAL[klass];
|
||||
let spec;
|
||||
if (real) {
|
||||
spec = {
|
||||
url: real.url,
|
||||
scale: 1,
|
||||
nativeRadiusM: real.radiusM,
|
||||
bellyM: real.bellyM,
|
||||
blendAmount: MODEL_COLOR_BLEND_AMOUNT,
|
||||
visualCenterNative: visualCenterForModel(real.url),
|
||||
trailAnchorNative: trailAnchorForModel(real.url),
|
||||
};
|
||||
} else {
|
||||
const scale = MODEL_SCALE * (CLASS_SCALE_3D[klass] || 1);
|
||||
const url = CLASS_MODEL_URL[klass] || PLANE_MODEL_URL;
|
||||
spec = {
|
||||
url,
|
||||
scale,
|
||||
nativeRadiusM: MODEL_NATIVE_RADIUS_M,
|
||||
bellyM: MODEL_BELLY_OFFSET_NATIVE * scale,
|
||||
blendAmount: MODEL_COLOR_BLEND_AMOUNT,
|
||||
visualCenterNative: visualCenterForModel(url),
|
||||
trailAnchorNative: trailAnchorForModel(url),
|
||||
};
|
||||
}
|
||||
const spec = civilAircraftModelSpec(klass);
|
||||
flightState._specCache.set(klass, spec);
|
||||
return spec;
|
||||
}
|
||||
@@ -953,31 +917,11 @@ export function createRendering({
|
||||
);
|
||||
flightState._lastModelCapWarnMs = nowMs;
|
||||
}
|
||||
modelEligible = new Set();
|
||||
// 1. KEEP on-screen already-modeled (visible retained — no flicker for what you can see).
|
||||
for (const [icao, , inF] of cand) {
|
||||
if (modelEligible.size >= cap) break;
|
||||
if (inF && flightState._models.has(icao)) modelEligible.add(icao);
|
||||
}
|
||||
// 2. ADD on-screen NEW inside the add radius, nearest first (visible additions win the cap).
|
||||
for (const [icao, d2, inF] of cand) {
|
||||
if (modelEligible.size >= cap) break;
|
||||
if (inF && d2 <= addDistSq && !modelEligible.has(icao))
|
||||
modelEligible.add(icao);
|
||||
}
|
||||
// 3. KEEP off-screen already-modeled (hysteresis, but LOWER priority than anything visible — so a
|
||||
// retained off-screen model can never starve an on-screen plane that wants one; dropping it is
|
||||
// invisible and it re-adds the moment it's back in view).
|
||||
for (const [icao, , inF] of cand) {
|
||||
if (modelEligible.size >= cap) break;
|
||||
if (!inF && flightState._models.has(icao)) modelEligible.add(icao);
|
||||
}
|
||||
// 4. ADD off-screen NEW inside the add radius with any leftover slots.
|
||||
for (const [icao, d2, inF] of cand) {
|
||||
if (modelEligible.size >= cap) break;
|
||||
if (!inF && d2 <= addDistSq && !modelEligible.has(icao))
|
||||
modelEligible.add(icao);
|
||||
}
|
||||
modelEligible = selectModelEligible(cand, {
|
||||
cap,
|
||||
addDistSq,
|
||||
isModeled: (icao) => flightState._models.has(icao),
|
||||
});
|
||||
const toRelease = [];
|
||||
for (const icao of flightState._models.keys()) {
|
||||
if (icao !== flightState._trackedIcao && !modelEligible.has(icao))
|
||||
|
||||
@@ -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,
|
||||
} from '../../data/modelVisualAnchor.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { selectModelEligible } from '../../data/modelEligibility.js';
|
||||
import { cockpitContactDotImage } from '../../data/cockpitContactDot.js';
|
||||
import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js';
|
||||
import {
|
||||
@@ -892,25 +893,11 @@ export function createRendering({
|
||||
);
|
||||
flightState._lastModelCapWarnMs = nowMs;
|
||||
}
|
||||
modelEligible = new Set();
|
||||
for (const [icao, , inF] of cand) {
|
||||
if (modelEligible.size >= cap) break;
|
||||
if (inF && flightState._models.has(icao)) modelEligible.add(icao);
|
||||
} // 1. KEEP on-screen
|
||||
for (const [icao, d2, inF] of cand) {
|
||||
if (modelEligible.size >= cap) break;
|
||||
if (inF && d2 <= addDistSq && !modelEligible.has(icao))
|
||||
modelEligible.add(icao);
|
||||
} // 2. ADD on-screen
|
||||
for (const [icao, , inF] of cand) {
|
||||
if (modelEligible.size >= cap) break;
|
||||
if (!inF && flightState._models.has(icao)) modelEligible.add(icao);
|
||||
} // 3. KEEP off-screen (can't starve visible)
|
||||
for (const [icao, d2, inF] of cand) {
|
||||
if (modelEligible.size >= cap) break;
|
||||
if (!inF && d2 <= addDistSq && !modelEligible.has(icao))
|
||||
modelEligible.add(icao);
|
||||
} // 4. ADD off-screen leftover
|
||||
modelEligible = selectModelEligible(cand, {
|
||||
cap,
|
||||
addDistSq,
|
||||
isModeled: (icao) => flightState._models.has(icao),
|
||||
});
|
||||
const toRelease = [];
|
||||
for (const icao of flightState._models.keys()) {
|
||||
if (icao !== flightState._trackedIcao && !modelEligible.has(icao))
|
||||
|
||||
@@ -183,21 +183,23 @@ test('no unchanged Realtime tool definition drifts silently', () => {
|
||||
'analyst_query',
|
||||
'next_iss_pass',
|
||||
'next_satellite_pass',
|
||||
// Local ADS-B adds one layer enum value and its common-name mapping.
|
||||
'set_layer_visibility',
|
||||
]);
|
||||
const unchanged = realtimeTools()
|
||||
.filter((tool) => !TOUCHED.has(tool.name))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
assert.equal(unchanged.length, 19);
|
||||
assert.equal(unchanged.length, 18);
|
||||
const digest = createHash('sha256')
|
||||
.update(JSON.stringify(unchanged))
|
||||
.digest('hex')
|
||||
.slice(0, 16);
|
||||
// Analyst additions and ISS wording correction are explicitly excluded above; all other tool definitions retain their pin.
|
||||
assert.equal(digest, '7411ea585e534795', 'an unchanged Realtime tool definition drifted');
|
||||
assert.equal(digest, '8494f344fabb67f5', 'an unchanged Realtime tool definition drifted');
|
||||
});
|
||||
|
||||
test('Radio volume and mission speed share the Sharpen slider visual language', () => {
|
||||
for (const id of ['cockpit-radio-volume', 'context-radio-mini-volume', 'radio-volume']) {
|
||||
for (const id of ['cockpit-radio-volume', 'context-radio-mini-volume', 'radio-volume', 'sdr-volume']) {
|
||||
assert.match(
|
||||
html,
|
||||
new RegExp(`id="${id}"[^>]*class="gev-quantitative-slider"[^>]*type="range"`),
|
||||
|
||||
@@ -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 { CctvControls } from './cctv.js';
|
||||
import { RadioControls } from './radio.js';
|
||||
import { LocalSdrControls } from './localSdrControls.js';
|
||||
import { LocationNavigation } from './locationNavigation.js';
|
||||
import { bindClearLayersControl } from './layers.js';
|
||||
import { bindCameraOrientationControls } from './cameraOrientationControls.js';
|
||||
@@ -77,6 +78,7 @@ export class StyleManager extends ShellFacade {
|
||||
transitLayer,
|
||||
aisLiveVesselsLayer,
|
||||
militaryAwarenessLayer,
|
||||
localAdsbLayer,
|
||||
} = services;
|
||||
this.services = services;
|
||||
this._lifetime = new UiLifetime();
|
||||
@@ -449,12 +451,13 @@ export class StyleManager extends ShellFacade {
|
||||
trafficLayer,
|
||||
flightsLayer,
|
||||
militaryFlightsLayer,
|
||||
localAdsbLayer,
|
||||
satellitesLayer,
|
||||
cctvLayer,
|
||||
bikeshareLayer,
|
||||
transitLayer,
|
||||
aisLiveVesselsLayer,
|
||||
],
|
||||
].filter(Boolean),
|
||||
(modeLabel) => {
|
||||
this._updateDetectionButton(modeLabel);
|
||||
},
|
||||
@@ -860,11 +863,33 @@ export class StyleManager extends ShellFacade {
|
||||
isCockpitActive: () => this.cockpitView?.active,
|
||||
signalUserCollapsed: () => this.cockpitView?.signalUserCollapsed,
|
||||
layoutCockpit: () => this.cockpitView?.scheduleContextLayout(),
|
||||
// An open local receiver keeps the shared Radio panel expanded.
|
||||
preservePanelStateDuringClear: () =>
|
||||
this._preservePanelStateDuringLayerClear,
|
||||
this._preservePanelStateDuringLayerClear ||
|
||||
Boolean(this._localSdrControls?.isActive()),
|
||||
scheduleLayout: () => this._scheduleRightPanelLayout(),
|
||||
},
|
||||
});
|
||||
this._localSdrControls?.destroy();
|
||||
this._localSdrControls = null;
|
||||
const receiver = this.services.localAdsbLayer?.receiver;
|
||||
if (receiver) {
|
||||
this._localSdrControls = new LocalSdrControls({
|
||||
document,
|
||||
receiver,
|
||||
feeds: this.services.localAdsbLayer?.feeds || null,
|
||||
radio: radioLayer,
|
||||
actions: {
|
||||
isLocalAdsbEnabled: () =>
|
||||
Boolean(this._dataManager?.isEnabled('local-adsb')),
|
||||
setLocalAdsbEnabled: (enabled) =>
|
||||
this._dataManager?.setEnabled('local-adsb', enabled, {
|
||||
origin: 'user',
|
||||
}),
|
||||
scheduleLayout: () => this._scheduleRightPanelLayout(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1479,6 +1504,7 @@ export class StyleManager extends ShellFacade {
|
||||
this._clearLayersControl?.destroy();
|
||||
this._cctvControls?.destroy();
|
||||
this._radioControls?.destroy();
|
||||
this._localSdrControls?.destroy();
|
||||
this._cockpitCoordinator.stop();
|
||||
this._visualSettings.stop();
|
||||
this.shareLinkManager?.destroy();
|
||||
|
||||
@@ -23,6 +23,7 @@ const PANEL_GROUPS = [
|
||||
'satellites',
|
||||
'flights',
|
||||
'military',
|
||||
'local-adsb',
|
||||
'ais-live-vessels',
|
||||
'traffic',
|
||||
'transit',
|
||||
|
||||
@@ -21,6 +21,7 @@ test('panel presentation places Transit between Street Traffic and Bike Share in
|
||||
'satellites',
|
||||
'flights',
|
||||
'military',
|
||||
'local-adsb',
|
||||
'ais-live-vessels',
|
||||
'traffic',
|
||||
'transit',
|
||||
|
||||
@@ -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;
|
||||
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>
|
||||
</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="panel-header">
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -138,6 +138,7 @@ const schemas = [
|
||||
'telegeography-submarine-cables',
|
||||
'local-firms',
|
||||
'alpr-cameras',
|
||||
'local-adsb',
|
||||
],
|
||||
},
|
||||
enabled: {
|
||||
|
||||
@@ -15,13 +15,14 @@ const stable = (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')
|
||||
.update(JSON.stringify(stable(GEV_REALTIME_TOOLS)))
|
||||
.digest('hex');
|
||||
assert.equal(
|
||||
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(
|
||||
(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.
|
||||
assert.equal(
|
||||
createHash('sha256').update(JSON.stringify(legacy)).digest('hex'),
|
||||
|
||||
@@ -226,6 +226,12 @@ const LAYER_ALIASES = new Map([
|
||||
['license plate readers', 'alpr-cameras'],
|
||||
['license plate cameras', '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([
|
||||
|
||||
@@ -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 () => {
|
||||
const calls = [];
|
||||
const viewer = {
|
||||
|
||||
Reference in new issue
Block a user