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;
}