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

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