mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +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
@@ -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',
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user