mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
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]>
595 lines
20 KiB
JavaScript
595 lines
20 KiB
JavaScript
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);
|
||
}
|
||
}
|