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:
authored and GitHub committed 2026-09-23 16:00:53 -05:00
1 parent 143cffd7ca
commit 1fc795577a
78 files changed
+12267 -108

No files matched your search

+326
View File
@@ -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',
);
});