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