Files
gods-eye-view/src/sources/adsbRecords.test.mjs
T
1fc795577a 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]>
2026-09-23 16:00:53 -05:00

327 lines
9.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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',
);
});