Files
gods-eye-view/src/sdr/adsbDecoder.js
T
1fc795577a 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]>
2026-09-23 16:00:53 -05:00

595 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
LOCAL_ADSB_POSITION_MARGIN_M,
LOCAL_ADSB_REANCHOR_AFTER,
LOCAL_ADSB_REFERENCE_MAX_AGE_MS,
localAdsbFixIsPlausible,
localAdsbSpeedLimitKt,
} from '../sources/adsbRecords.js';
const MODE_S_POLYNOMIAL = 0xfff409;
/** An aircraft with no CRC-valid message for this long is removed. */
export const LOCAL_ADSB_STALE_MS = 60_000;
const CPR_PAIR_MAX_AGE_MS = 10_000;
/** Surface aircraft move slowly; an even/odd surface pair may be further apart. */
const CPR_SURFACE_PAIR_MAX_AGE_MS = 25_000;
/**
* Half a surface CPR latitude zone (90° / 60 zones / 2 = 0.75°, 45 NM): a
* single surface frame decodes unambiguously only against a reference known
* to be closer than this to the aircraft.
*/
const SURFACE_REFERENCE_RANGE_M = 45 * 1_852;
const KT_TO_MPS = 1_852 / 3_600;
const CPR_SCALE = 131_072;
const CPR_NZ = 15;
const CALLSIGN_CHARSET =
'#ABCDEFGHIJKLMNOPQRSTUVWXYZ#####_###############0123456789######';
const PREAMBLE_SAMPLES = 16;
const FRAME_BITS = 112;
const FRAME_SAMPLES = PREAMBLE_SAMPLES + FRAME_BITS * 2;
const STREAM_CARRY_BYTES = (FRAME_SAMPLES - 1) * 2;
function bit(bytes, index) {
return (bytes[index >> 3] >> (7 - (index & 7))) & 1;
}
function bits(bytes, start, length) {
let value = 0;
for (let index = 0; index < length; index += 1) {
value = value * 2 + bit(bytes, start + index);
}
return value;
}
function positiveModulo(value, divisor) {
return ((value % divisor) + divisor) % divisor;
}
function cprNl(latitude) {
const absolute = Math.abs(latitude);
if (absolute >= 87) return absolute === 87 ? 2 : 1;
const numerator = 1 - Math.cos(Math.PI / (2 * CPR_NZ));
const denominator = Math.cos((Math.PI / 180) * absolute) ** 2;
return Math.floor((2 * Math.PI) / Math.acos(1 - numerator / denominator));
}
function normalizeLongitude(longitude) {
let normalized = longitude;
while (normalized > 180) normalized -= 360;
while (normalized < -180) normalized += 360;
return normalized;
}
function validReference(reference) {
return (
Boolean(reference) &&
Number.isFinite(reference.latitude) &&
Number.isFinite(reference.longitude)
);
}
/** The candidate `value + k * period` nearest `target`. */
function nearestAlias(value, period, target) {
return value + period * Math.round((target - value) / period);
}
/**
* Locally decode one CPR frame against a reference within half a zone.
* Surface frames use quarter-size zones (90° instead of 360°), so their
* reference must be within 45 NM of the aircraft: only the aircraft's own
* recent position qualifies (see `surfaceReference`).
*/
function decodeLocalCpr(frame, reference) {
if (!validReference(reference)) return null;
const span = frame.surface ? 90 : 360;
const parity = frame.odd ? 1 : 0;
const dLat = span / (4 * CPR_NZ - parity);
const latitudeIndex =
Math.floor(reference.latitude / dLat) +
Math.floor(
0.5 +
positiveModulo(reference.latitude, dLat) / dLat -
frame.latitude / CPR_SCALE,
);
let latitude = dLat * (latitudeIndex + frame.latitude / CPR_SCALE);
if (latitude >= 270) latitude -= 360;
if (latitude < -90 || latitude > 90) return null;
const longitudeZones = Math.max(cprNl(latitude) - parity, 1);
const dLon = span / longitudeZones;
const longitudeIndex =
Math.floor(reference.longitude / dLon) +
Math.floor(
0.5 +
positiveModulo(reference.longitude, dLon) / dLon -
frame.longitude / CPR_SCALE,
);
const longitude = normalizeLongitude(
dLon * (longitudeIndex + frame.longitude / CPR_SCALE),
);
return { latitude, longitude };
}
function decodeGlobalCpr(even, odd) {
if (
!even ||
!odd ||
Math.abs(even.receivedAt - odd.receivedAt) > CPR_PAIR_MAX_AGE_MS
)
return null;
const latitudeIndex = Math.floor(
(59 * even.latitude - 60 * odd.latitude) / CPR_SCALE + 0.5,
);
let evenLatitude =
6 * (positiveModulo(latitudeIndex, 60) + even.latitude / CPR_SCALE);
let oddLatitude =
(360 / 59) * (positiveModulo(latitudeIndex, 59) + odd.latitude / CPR_SCALE);
if (evenLatitude >= 270) evenLatitude -= 360;
if (oddLatitude >= 270) oddLatitude -= 360;
if (cprNl(evenLatitude) !== cprNl(oddLatitude)) return null;
const useOdd = odd.receivedAt > even.receivedAt;
const latitude = useOdd ? oddLatitude : evenLatitude;
const longitudeZones = Math.max(cprNl(latitude) - (useOdd ? 1 : 0), 1);
const longitudeIndex = Math.floor(
(even.longitude * (cprNl(latitude) - 1) - odd.longitude * cprNl(latitude)) /
CPR_SCALE +
0.5,
);
const frame = useOdd ? odd : even;
const longitude = normalizeLongitude(
(360 / longitudeZones) *
(positiveModulo(longitudeIndex, longitudeZones) +
frame.longitude / CPR_SCALE),
);
return { latitude, longitude };
}
/**
* Globally decode an even/odd pair of SURFACE position frames. Surface CPR
* encodes a quarter of the globe, so the pair leaves four candidate
* latitudes/longitudes 90° apart; the one nearest `reference` (the aircraft's
* last position or the receiver) is the answer, and without a reference
* there is no answer.
*/
function decodeGlobalSurfaceCpr(even, odd, reference) {
if (
!even ||
!odd ||
!validReference(reference) ||
Math.abs(even.receivedAt - odd.receivedAt) > CPR_SURFACE_PAIR_MAX_AGE_MS
)
return null;
const latitudeIndex = Math.floor(
(59 * even.latitude - 60 * odd.latitude) / CPR_SCALE + 0.5,
);
const evenLatitude = nearestAlias(
(90 / 60) * (positiveModulo(latitudeIndex, 60) + even.latitude / CPR_SCALE),
90,
reference.latitude,
);
const oddLatitude = nearestAlias(
(90 / 59) * (positiveModulo(latitudeIndex, 59) + odd.latitude / CPR_SCALE),
90,
reference.latitude,
);
if (Math.abs(evenLatitude) > 90 || Math.abs(oddLatitude) > 90) return null;
if (cprNl(evenLatitude) !== cprNl(oddLatitude)) return null;
const useOdd = odd.receivedAt > even.receivedAt;
const latitude = useOdd ? oddLatitude : evenLatitude;
const nl = cprNl(latitude);
const longitudeZones = Math.max(nl - (useOdd ? 1 : 0), 1);
const longitudeIndex = Math.floor(
(even.longitude * (nl - 1) - odd.longitude * nl) / CPR_SCALE + 0.5,
);
const frame = useOdd ? odd : even;
const longitude = normalizeLongitude(
nearestAlias(
(90 / longitudeZones) *
(positiveModulo(longitudeIndex, longitudeZones) +
frame.longitude / CPR_SCALE),
90,
reference.longitude,
),
);
return { latitude, longitude };
}
/**
* Ground speed in knots from a surface message's 7-bit movement field
* (DO-260B §2.2.3.2.4.2): null for "no information" and reserved codes.
*/
function decodeSurfaceMovement(movement) {
if (movement === 0 || movement > 124) return null;
if (movement === 1) return 0;
if (movement === 124) return 175;
const bands = [
[2, 0.125, 0.125],
[9, 1, 0.25],
[13, 2, 0.5],
[39, 15, 1],
[94, 70, 2],
[109, 100, 5],
];
let band = bands[0];
for (const candidate of bands) if (movement >= candidate[0]) band = candidate;
const [firstCode, firstKt, stepKt] = band;
return firstKt + (movement - firstCode) * stepKt;
}
function decodeAltitude(bytes) {
if (!bit(bytes, 47)) return null;
const encoded = (bits(bytes, 40, 7) << 4) | bits(bytes, 48, 4);
return encoded * 25 - 1_000;
}
function decodeCallsign(bytes) {
let callsign = '';
for (let index = 0; index < 8; index += 1) {
callsign += CALLSIGN_CHARSET[bits(bytes, 40 + index * 6, 6)] || ' ';
}
return callsign.replace(/[_#]/g, ' ').trim();
}
function decodeVelocity(bytes) {
const subtype = bits(bytes, 37, 3);
if (subtype !== 1 && subtype !== 2) return null;
const multiplier = subtype === 2 ? 4 : 1;
const eastWestRaw = bits(bytes, 46, 10);
const northSouthRaw = bits(bytes, 57, 10);
if (!eastWestRaw || !northSouthRaw) return null;
const eastWest = (eastWestRaw - 1) * multiplier * (bit(bytes, 45) ? -1 : 1);
const northSouth =
(northSouthRaw - 1) * multiplier * (bit(bytes, 56) ? -1 : 1);
const speedKt = Math.hypot(eastWest, northSouth);
const headingDeg = positiveModulo(
(Math.atan2(eastWest, northSouth) * 180) / Math.PI,
360,
);
const verticalRaw = bits(bytes, 69, 9);
const verticalRateFpm = verticalRaw
? (verticalRaw - 1) * 64 * (bit(bytes, 68) ? -1 : 1)
: null;
return { speedKt, headingDeg, verticalRateFpm };
}
/** Compute the 24-bit Mode S parity remainder. Valid extended squitters return zero. */
export function modeSChecksum(bytes) {
let remainder = 0;
for (let index = 0; index < bytes.length * 8; index += 1) {
const feedback = bit(bytes, index) ^ ((remainder >> 23) & 1);
remainder = (remainder << 1) & 0xffffff;
if (feedback) remainder ^= MODE_S_POLYNOMIAL;
}
return remainder;
}
/** Decode one 112-bit DF17/DF18 extended-squitter message. */
export function decodeAdsbMessage(bytes, { receivedAt = Date.now() } = {}) {
if (!(bytes instanceof Uint8Array) || bytes.length !== 14) return null;
const downlinkFormat = bits(bytes, 0, 5);
if (
(downlinkFormat !== 17 && downlinkFormat !== 18) ||
modeSChecksum(bytes) !== 0
)
return null;
const icao = [...bytes.slice(1, 4)]
.map((value) => value.toString(16).padStart(2, '0'))
.join('')
.toUpperCase();
const typeCode = bits(bytes, 32, 5);
const message = { icao, typeCode, receivedAt };
if (typeCode >= 1 && typeCode <= 4) {
message.callsign = decodeCallsign(bytes);
// dump1090 convention: category set A = TC 4, B = TC 3, C = TC 2, D = TC 1,
// followed by the 3-bit emitter category ('A7' rotorcraft, 'A1' light).
message.category = `${String.fromCharCode(69 - typeCode)}${bits(bytes, 37, 3)}`;
} else if (typeCode >= 5 && typeCode <= 8) {
// Surface position: movement, ground track when its status bit is set,
// and a quarter-globe CPR position; there is no altitude on the ground.
message.onGround = true;
message.altitudeFt = null;
const speedKt = decodeSurfaceMovement(bits(bytes, 37, 7));
if (speedKt !== null) message.speedKt = speedKt;
if (bit(bytes, 44)) message.headingDeg = (bits(bytes, 45, 7) * 360) / 128;
message.cpr = {
surface: true,
odd: Boolean(bit(bytes, 53)),
latitude: bits(bytes, 54, 17),
longitude: bits(bytes, 71, 17),
receivedAt,
};
} else if (typeCode >= 9 && typeCode <= 18) {
message.onGround = false;
message.altitudeFt = decodeAltitude(bytes);
message.cpr = {
odd: Boolean(bit(bytes, 53)),
latitude: bits(bytes, 54, 17),
longitude: bits(bytes, 71, 17),
receivedAt,
};
} else if (typeCode === 19) {
Object.assign(message, decodeVelocity(bytes) || {});
}
return message;
}
function recentReference(track, receivedAt) {
if (
!Number.isFinite(track.latitude) ||
!Number.isFinite(track.longitude) ||
!Number.isFinite(track.lastPositionAt) ||
receivedAt - track.lastPositionAt > LOCAL_ADSB_REFERENCE_MAX_AGE_MS
)
return null;
return { latitude: track.latitude, longitude: track.longitude };
}
/**
* The aircraft's own last position as a single-frame SURFACE reference, when
* it is established to be within 45 NM: recent, and the aircraft cannot have
* covered 45 NM since at its speed limit (the speed check's limit). The
* receiver location never qualifies, since how far away a heard aircraft is
* cannot be known from one frame.
*/
function surfaceReference(track, message) {
const reference = recentReference(track, message.receivedAt);
if (!reference) return null;
const groundSpeedKt = Number.isFinite(track.speedKt)
? Math.max(
track.speedKt,
Number.isFinite(message.speedKt) ? message.speedKt : 0,
)
: null;
const limitKt = localAdsbSpeedLimitKt({
groundSpeedKt,
category: track.category,
});
const reachM =
LOCAL_ADSB_POSITION_MARGIN_M +
((message.receivedAt - track.lastPositionAt + 1_000) / 1_000) *
limitKt *
KT_TO_MPS;
return reachM < SURFACE_REFERENCE_RANGE_M ? reference : null;
}
/**
* Merge one decoded message into a stable aircraft record.
*
* Airborne (TC 9–18) and surface (TC 5–8) positions are paired separately.
* A surface pair needs a reference to pick its quadrant (the aircraft's last
* position, else the receiver); surface messages also set `onGround` and
* clear the altitude, and the next airborne position clears `onGround`.
*
* A position comes from a fresh even/odd pair (global CPR), else from one
* frame decoded relative to the aircraft's own last accepted position when it
* is under 10 minutes old, else (airborne only) relative to the receiver
* location. A single surface frame needs the aircraft's own position within
* 45 NM, so a surface track is always seeded by a pair. Every
* candidate then passes the dump1090-style speed check against the last
* accepted fix; a failing fix is counted (`rejectedPositions` on the track and
* `positionsRejected` on the optional `stats`) and not applied. From the
* third consecutive rejection on, a globally decoded fix is accepted as the
* new reference, so one bad accepted fix cannot freeze a track.
* @param {Map<string, object>} tracks Tracks keyed by uppercase ICAO.
* @param {object} message Output of `decodeAdsbMessage`.
* @param {{latitude:number, longitude:number}|null} [receiverLocation]
* @param {{positionsRejected?:number}} [stats] Mutable decoder counters.
* @returns {object|null} The updated track.
*/
export function updateAircraftTrack(
tracks,
message,
receiverLocation = null,
stats = null,
) {
if (!(tracks instanceof Map) || !message?.icao) return null;
const prior = tracks.get(message.icao) || {
icao: message.icao,
callsign: null,
category: null,
altitudeFt: null,
latitude: null,
longitude: null,
speedKt: null,
headingDeg: null,
verticalRateFpm: null,
messages: 0,
lastSeen: 0,
lastPositionAt: null,
onGround: false,
cprEven: null,
cprOdd: null,
cprSurfaceEven: null,
cprSurfaceOdd: null,
rejectedPositions: 0,
rejectStreak: 0,
};
const next = {
...prior,
messages: prior.messages + 1,
lastSeen: message.receivedAt,
};
for (const key of [
'callsign',
'category',
'altitudeFt',
'speedKt',
'headingDeg',
'verticalRateFpm',
]) {
if (message[key] !== undefined && message[key] !== null)
next[key] = message[key];
}
// A position message says where the aircraft is: on the surface (no
// altitude, no climb) or airborne again.
if (typeof message.onGround === 'boolean') {
next.onGround = message.onGround;
if (message.onGround) {
next.altitudeFt = null;
next.verticalRateFpm = null;
}
}
if (message.cpr) {
const surface = Boolean(message.cpr.surface);
// Airborne and surface frames use different zone sizes: never pair them.
if (surface) {
if (message.cpr.odd) next.cprSurfaceOdd = message.cpr;
else next.cprSurfaceEven = message.cpr;
} else if (message.cpr.odd) next.cprOdd = message.cpr;
else next.cprEven = message.cpr;
const reference = recentReference(prior, message.receivedAt);
const global = surface
? decodeGlobalSurfaceCpr(
next.cprSurfaceEven,
next.cprSurfaceOdd,
reference || receiverLocation,
)
: decodeGlobalCpr(next.cprEven, next.cprOdd);
const position =
global ||
(surface
? decodeLocalCpr(message.cpr, surfaceReference(prior, message))
: decodeLocalCpr(message.cpr, reference) ||
decodeLocalCpr(message.cpr, receiverLocation));
if (position) {
const plausible =
!reference ||
localAdsbFixIsPlausible(
{
lat: prior.latitude,
lon: prior.longitude,
at: prior.lastPositionAt,
},
{
lat: position.latitude,
lon: position.longitude,
at: message.receivedAt,
},
{ groundSpeedKt: next.speedKt, category: next.category },
);
const reanchor =
!plausible &&
Boolean(global) &&
(prior.rejectStreak || 0) + 1 >= LOCAL_ADSB_REANCHOR_AFTER;
if (plausible || reanchor) {
Object.assign(next, position);
next.lastPositionAt = message.receivedAt;
next.rejectStreak = 0;
} else {
next.rejectedPositions = (prior.rejectedPositions || 0) + 1;
next.rejectStreak = (prior.rejectStreak || 0) + 1;
if (stats) stats.positionsRejected = (stats.positionsRejected || 0) + 1;
}
}
}
tracks.set(message.icao, next);
return next;
}
/** Remove aircraft that have not transmitted recently. */
export function pruneAircraftTracks(
tracks,
now = Date.now(),
ttlMs = LOCAL_ADSB_STALE_MS,
) {
if (!(tracks instanceof Map)) return 0;
let removed = 0;
for (const [icao, aircraft] of tracks) {
if (now - aircraft.lastSeen < ttlMs) continue;
tracks.delete(icao);
removed += 1;
}
return removed;
}
function preambleContrast(power, offset) {
const pulseOffsets = [0, 2, 7, 9];
// Only the six defined gaps between Mode S preamble pulses establish the
// local floor. Samples 10–15 can contain the trailing shape of the last RF
// pulse on a real 2 Msps receiver and treating them as quiet rejects frames
// that a clean synthetic fixture does not expose.
const quietOffsets = [1, 3, 4, 5, 6, 8];
let pulseMean = 0;
let quietMean = 0;
for (const index of pulseOffsets) pulseMean += power[offset + index];
for (const index of quietOffsets) quietMean += power[offset + index];
pulseMean /= pulseOffsets.length;
quietMean /= quietOffsets.length;
const contrast = pulseMean - quietMean;
if (contrast < 120 || pulseMean < quietMean * 1.8) return 0;
const decisionLevel = quietMean + contrast * 0.35;
if (pulseOffsets.some((index) => power[offset + index] <= decisionLevel))
return 0;
return contrast;
}
/** Extract CRC-valid 112-bit ADS-B messages from unsigned 8-bit interleaved IQ. */
export function extractAdsbMessages(buffer, sampleRate = 2_000_000) {
if (
!(buffer instanceof ArrayBuffer) ||
Math.abs(sampleRate - 2_000_000) > 30_000
)
return [];
const iq = new Uint8Array(buffer);
const sampleCount = Math.floor(iq.length / 2);
const power = new Float32Array(sampleCount);
for (let index = 0; index < sampleCount; index += 1) {
const i = iq[index * 2] - 127.5;
const q = iq[index * 2 + 1] - 127.5;
power[index] = i * i + q * q;
}
const messages = [];
for (let offset = 0; offset <= sampleCount - FRAME_SAMPLES; offset += 1) {
const contrast = preambleContrast(power, offset);
if (!contrast) continue;
const bytes = new Uint8Array(14);
let uncertainBits = 0;
for (let bitIndex = 0; bitIndex < FRAME_BITS; bitIndex += 1) {
const sampleOffset = offset + PREAMBLE_SAMPLES + bitIndex * 2;
const first = power[sampleOffset];
const second = power[sampleOffset + 1];
const total = first + second;
const difference = Math.abs(first - second);
if (
difference < Math.max(20, contrast * 0.02) ||
difference / Math.max(total, 1) < 0.04
)
uncertainBits += 1;
if (first > second) bytes[bitIndex >> 3] |= 1 << (7 - (bitIndex & 7));
}
if (uncertainBits <= 10 && modeSChecksum(bytes) === 0) {
messages.push(bytes);
offset += FRAME_SAMPLES - 1;
}
}
return messages;
}
/** Preserve enough IQ between USB reads to recover frames split at a block edge. */
export class AdsbStreamDecoder {
constructor() {
this._carry = new Uint8Array(0);
}
reset() {
this._carry = new Uint8Array(0);
}
extract(buffer, sampleRate = 2_000_000) {
if (!(buffer instanceof ArrayBuffer)) return [];
const incoming = new Uint8Array(buffer);
let combined = incoming;
if (this._carry.length) {
combined = new Uint8Array(this._carry.length + incoming.length);
combined.set(this._carry);
combined.set(incoming, this._carry.length);
}
this._carry = combined.slice(
Math.max(0, combined.length - STREAM_CARRY_BYTES),
);
return extractAdsbMessages(combined.buffer, sampleRate);
}
}