mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
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]>
327 lines
9.6 KiB
JavaScript
327 lines
9.6 KiB
JavaScript
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',
|
||
);
|
||
});
|