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

+594
View File
@@ -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);
}
}
+624
View File
@@ -0,0 +1,624 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { classifyAircraft } from '../data/aircraftClass.js';
import { recordFromDecoderTrack } from '../sources/adsbRecords.js';
import {
AdsbStreamDecoder,
LOCAL_ADSB_STALE_MS,
decodeAdsbMessage,
extractAdsbMessages,
modeSChecksum,
pruneAircraftTracks,
updateAircraftTrack,
} from './adsbDecoder.js';
function fromHex(value) {
return Uint8Array.from(value.match(/../g), (pair) =>
Number.parseInt(pair, 16),
);
}
function synthesizeIq(
bytes,
{ floor = 128, high = 255, preambleTail = null } = {},
) {
const sampleCount = 16 + 112 * 2 + 8;
const iq = new Uint8Array(sampleCount * 2).fill(128);
for (let sample = 0; sample < sampleCount; sample += 1)
iq[sample * 2] = floor;
const pulse = (sample) => {
iq[sample * 2] = high;
};
for (const offset of [0, 2, 7, 9]) pulse(offset);
if (Number.isFinite(preambleTail)) {
for (let sample = 10; sample < 16; sample += 1)
iq[sample * 2] = preambleTail;
}
for (let index = 0; index < 112; index += 1) {
const value = (bytes[index >> 3] >> (7 - (index & 7))) & 1;
pulse(16 + index * 2 + (value ? 0 : 1));
}
return iq.buffer;
}
test('decodes standard callsign, velocity, and altitude examples', () => {
const callsign = decodeAdsbMessage(fromHex('8D4840D6202CC371C32CE0576098'));
assert.equal(callsign.callsign, 'KLM1023');
assert.equal(callsign.icao, '4840D6');
const velocity = decodeAdsbMessage(fromHex('8D485020994409940838175B284F'));
assert.ok(Math.abs(velocity.speedKt - 159.2) < 0.1);
assert.ok(Math.abs(velocity.headingDeg - 182.88) < 0.1);
assert.equal(velocity.verticalRateFpm, -832);
const position = decodeAdsbMessage(fromHex('8D40621D58C382D690C8AC2863A7'));
assert.equal(position.altitudeFt, 38_000);
assert.equal(position.cpr.odd, false);
});
test('globally decodes a valid even/odd CPR pair and expires tracks at 60 seconds', () => {
const tracks = new Map();
const even = decodeAdsbMessage(fromHex('8D40621D58C382D690C8AC2863A7'), {
receivedAt: 1_000,
});
const odd = decodeAdsbMessage(fromHex('8D40621D58C386435CC412692AD6'), {
receivedAt: 1_500,
});
updateAircraftTrack(tracks, even);
const aircraft = updateAircraftTrack(tracks, odd);
assert.ok(Math.abs(aircraft.latitude - 52.26578) < 0.0001);
assert.ok(Math.abs(aircraft.longitude - 3.93891) < 0.0001);
assert.equal(aircraft.lastPositionAt, 1_500);
assert.equal(LOCAL_ADSB_STALE_MS, 60_000);
assert.equal(
pruneAircraftTracks(tracks, 61_499),
0,
'contact survives below 60 seconds',
);
assert.equal(
pruneAircraftTracks(tracks, 61_500),
1,
'contact expires at 60 seconds',
);
assert.equal(tracks.size, 0);
});
test('extracts a CRC-valid Mode S frame from synthetic 2 Msps IQ', () => {
const bytes = fromHex('8D4840D6202CC371C32CE0576098');
assert.equal(modeSChecksum(bytes), 0);
const frames = extractAdsbMessages(synthesizeIq(bytes), 2_000_000);
assert.equal(frames.length, 1);
assert.deepEqual(frames[0], bytes);
});
test('accepts a valid weak frame above an elevated local noise floor', () => {
const bytes = fromHex('8D4840D6202CC371C32CE0576098');
const frames = extractAdsbMessages(
synthesizeIq(bytes, { floor: 140, high: 150 }),
2_000_000,
);
assert.equal(frames.length, 1);
assert.deepEqual(frames[0], bytes);
});
test('accepts a real-shaped preamble with energy trailing its final pulse', () => {
const bytes = fromHex('8D4840D6202CC371C32CE0576098');
const frames = extractAdsbMessages(
synthesizeIq(bytes, {
floor: 138,
high: 148,
preambleTail: 145,
}),
2_000_000,
);
assert.equal(frames.length, 1);
assert.deepEqual(frames[0], bytes);
});
test('preserves a Mode S frame split across consecutive USB blocks', () => {
const bytes = fromHex('8D4840D6202CC371C32CE0576098');
const iq = new Uint8Array(synthesizeIq(bytes));
const decoder = new AdsbStreamDecoder();
assert.deepEqual(decoder.extract(iq.slice(0, 300).buffer, 2_000_000), []);
assert.deepEqual(decoder.extract(iq.slice(300).buffer, 2_000_000), [bytes]);
decoder.reset();
assert.deepEqual(decoder.extract(iq.slice(300).buffer, 2_000_000), []);
});
test('rejects corrupt frames and unsupported sample rates', () => {
const bytes = fromHex('8D4840D6202CC371C32CE0576098');
bytes[5] ^= 0x01;
assert.notEqual(modeSChecksum(bytes), 0);
assert.equal(decodeAdsbMessage(bytes), null);
assert.deepEqual(extractAdsbMessages(synthesizeIq(bytes), 1_024_000), []);
});
const AUSTIN_RECEIVER = Object.freeze({ latitude: 30.27, longitude: -97.8 });
const fixtureFrames = readFileSync(
new URL('../data/fixtures/adsb-austin-frames.txt', import.meta.url),
'utf8',
)
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => fromHex(line.replace(/^\*|;$/g, '')));
const dump1090 = JSON.parse(
readFileSync(
new URL(
'../data/fixtures/adsb-austin-dump1090-aircraft.json',
import.meta.url,
),
'utf8',
),
);
// The capture keeps receive order but not receive times, so frames are spaced
// 100 ms apart: every even/odd pair then falls inside the 10 s CPR window.
function decodeFixture(receiverLocation) {
const tracks = new Map();
fixtureFrames.forEach((bytes, index) => {
const message = decodeAdsbMessage(bytes, { receivedAt: (index + 1) * 100 });
if (message) updateAircraftTrack(tracks, message, receiverLocation);
});
return tracks;
}
test('real Austin frames decode to the same aircraft, callsigns and positions as dump1090', () => {
assert.equal(fixtureFrames.length, 112);
const tracks = decodeFixture(AUSTIN_RECEIVER);
const heard = [...tracks.keys()].map((icao) => icao.toLowerCase()).sort();
assert.deepEqual(heard, dump1090.aircraft.map((entry) => entry.hex).sort());
for (const oracle of dump1090.aircraft) {
const track = tracks.get(oracle.hex.toUpperCase());
if (oracle.flight)
assert.equal(track.callsign, oracle.flight.trim(), oracle.hex);
if (Number.isFinite(oracle.alt_baro))
assert.equal(track.altitudeFt, oracle.alt_baro, oracle.hex);
if (Number.isFinite(oracle.gs))
assert.ok(Math.abs(track.speedKt - oracle.gs) < 0.5, oracle.hex);
if (Number.isFinite(oracle.track))
assert.ok(Math.abs(track.headingDeg - oracle.track) < 0.5, oracle.hex);
if (Number.isFinite(oracle.baro_rate))
assert.equal(track.verticalRateFpm, oracle.baro_rate, oracle.hex);
if (!Number.isFinite(oracle.lat)) continue;
assert.ok(
Math.abs(track.latitude - oracle.lat) < 0.01,
`${oracle.hex} latitude`,
);
assert.ok(
Math.abs(track.longitude - oracle.lon) < 0.01,
`${oracle.hex} longitude`,
);
}
});
test('receiver-relative CPR positions an even-only aircraft that dump1090 left unpositioned', () => {
// SKW3301 (a15c54) sent three airborne positions, all even-parity, so no
// global even/odd pair exists. dump1090 ran without a receiver location and
// could not position it; with one, the local decode places it near AUS.
const skywest = fixtureFrames.filter(
(bytes) =>
decodeAdsbMessage(bytes)?.icao === 'A15C54' &&
decodeAdsbMessage(bytes).cpr,
);
assert.equal(skywest.length, 3);
assert.ok(
skywest.every((bytes) => decodeAdsbMessage(bytes).cpr.odd === false),
);
const located = decodeFixture(AUSTIN_RECEIVER).get('A15C54');
assert.ok(Math.abs(located.latitude - 30.1599) < 0.01);
assert.ok(Math.abs(located.longitude + 97.7903) < 0.01);
const unlocated = decodeFixture(null);
assert.equal(unlocated.get('A15C54').latitude, null);
const positioned = [...unlocated.values()]
.filter((track) => Number.isFinite(track.latitude))
.map((track) => track.icao.toLowerCase())
.sort();
assert.deepEqual(
positioned,
dump1090.aircraft
.filter((entry) => Number.isFinite(entry.lat))
.map((entry) => entry.hex)
.sort(),
'without a receiver location the positioned set matches dump1090 exactly',
);
});
// A second real capture over Austin with receive times: 846 extended
// squitters from six aircraft, each frame timed by interpolating between the
// frames whose decoded position matches a dump1090 fix (dump1090's `t -
// seen_pos`), plus dump1090's own positions for the same interval.
const timed = JSON.parse(
readFileSync(
new URL('../data/fixtures/adsb-austin-capture-timed.json', import.meta.url),
'utf8',
),
);
function nauticalMiles(a, b) {
const toRad = Math.PI / 180;
const dLat = (b.lat - a.lat) * toRad;
const dLon = (b.lon - a.lon) * toRad;
const h =
Math.sin(dLat / 2) ** 2 +
Math.cos(a.lat * toRad) * Math.cos(b.lat * toRad) * Math.sin(dLon / 2) ** 2;
return 2 * 3440.065 * Math.asin(Math.sqrt(h));
}
function replayTimed({ inject = null, receiver = timed.receiver } = {}) {
const tracks = new Map();
const stats = {};
const accepted = new Map();
for (const [receivedAt, hex] of timed.frames) {
const message = decodeAdsbMessage(fromHex(hex), { receivedAt });
if (!message) continue;
for (const extra of inject?.(message) || []) {
updateAircraftTrack(tracks, extra, receiver, stats);
}
const track = updateAircraftTrack(tracks, message, receiver, stats);
if (message.cpr && track.lastPositionAt === receivedAt) {
const key = message.icao.toLowerCase();
if (!accepted.has(key)) accepted.set(key, []);
accepted.get(key).push({
at: receivedAt,
lat: track.latitude,
lon: track.longitude,
});
}
}
return { tracks, stats, accepted };
}
function dump1090Final(hex) {
const [, lat, lon] = timed.dump1090[hex].fixes.at(-1);
return { lat, lon };
}
test('identification messages decode to the dump1090 emitter category strings', () => {
const { tracks } = replayTimed();
const decoded = Object.fromEntries(
Object.keys(timed.dump1090).map((hex) => [
hex,
tracks.get(hex.toUpperCase()).category,
]),
);
assert.deepEqual(decoded, {
'0d0c07': 'A2',
a3627d: 'A3',
a0b702: 'A7',
a5b3a7: 'A3',
abe7c5: 'A3',
a27e81: 'A1',
});
for (const [hex, oracle] of Object.entries(timed.dump1090))
assert.equal(decoded[hex], oracle.category, hex);
// Category set D (type code 1) and C (type code 2) follow the same rule.
const message = decodeAdsbMessage(fromHex('8D4840D6202CC371C32CE0576098'));
assert.equal(message.typeCode, 4);
assert.equal(message.category, 'A0');
});
test('145TX, a rotorcraft (A7), classifies as a helicopter', () => {
const { tracks } = replayTimed();
const heli = tracks.get('A0B702');
assert.equal(heli.callsign, '145TX');
assert.equal(classifyAircraft({ category: heli.category }), 'helicopter');
assert.equal(
classifyAircraft({ category: tracks.get('A27E81').category }),
'light',
);
});
test('the speed check refuses none of the capture and every track ends on dump1090', () => {
const { tracks, stats, accepted } = replayTimed();
assert.equal(stats.positionsRejected || 0, 0);
for (const hex of Object.keys(timed.dump1090)) {
const track = tracks.get(hex.toUpperCase());
assert.equal(track.rejectedPositions, 0, `${hex} rejected a fix`);
const fixes = accepted.get(hex);
assert.ok(fixes.length >= 20, `${hex}: ${fixes.length} fixes`);
const delta = nauticalMiles(fixes.at(-1), dump1090Final(hex));
assert.ok(delta < 0.3, `${hex} ends ${delta.toFixed(3)} nm from dump1090`);
}
});
test("N26VB's 1.0 nm step spans a 27 s reception gap at its reported speed", () => {
// The field report read this as a bad decode. The step follows single odd
// frames decoded against the receiver location, which dump1090 (run without
// one) never positions; the implied speed matches the reported 134 kt.
const { tracks, accepted } = replayTimed();
const fixes = accepted.get('a27e81');
let widest = null;
for (let index = 1; index < fixes.length; index += 1) {
const step = nauticalMiles(fixes[index - 1], fixes[index]);
if (!widest || step > widest.step)
widest = {
step,
seconds: (fixes[index].at - fixes[index - 1].at) / 1000,
};
}
assert.ok(Math.abs(widest.step - 1.0) < 0.05, `${widest.step} nm`);
assert.ok(widest.seconds > 25, `${widest.seconds} s`);
const impliedKt = widest.step / (widest.seconds / 3600);
assert.ok(Math.abs(impliedKt - 134) < 10, `${impliedKt.toFixed(0)} kt`);
assert.equal(tracks.get('A27E81').rejectedPositions, 0);
});
test('a corrupt even/odd pair is refused and the track stays on dump1090', () => {
// Pair a foreign odd frame (ENY3344, 27 nm away) with N26VB's fresh even
// frame: the global decode lands far off the track, as a mixed-up pair does.
const foreign = timed.frames
.map(([, hex]) => decodeAdsbMessage(fromHex(hex)))
.find((message) => message?.icao === 'A3627D' && message.cpr?.odd);
let injected = 0;
const { tracks, stats, accepted } = replayTimed({
inject(message) {
if (message.icao !== 'A27E81' || message.cpr?.odd !== false) return [];
if (injected >= 2) return [];
injected += 1;
return [
{
...foreign,
icao: 'A27E81',
receivedAt: message.receivedAt - 1,
cpr: { ...foreign.cpr, receivedAt: message.receivedAt - 1 },
},
];
},
});
const track = tracks.get('A27E81');
assert.ok(track.rejectedPositions >= 1);
assert.equal(stats.positionsRejected, track.rejectedPositions);
for (const fix of accepted.get('a27e81'))
assert.ok(
nauticalMiles(fix, { lat: 30.32, lon: -97.91 }) < 3,
'no accepted fix leaves the track',
);
assert.ok(
nauticalMiles(accepted.get('a27e81').at(-1), dump1090Final('a27e81')) < 0.3,
);
});
test('a lone frame decodes relative to the aircraft own recent position', () => {
const tracks = new Map();
const even = decodeAdsbMessage(fromHex('8D40621D58C382D690C8AC2863A7'), {
receivedAt: 1_000,
});
const odd = decodeAdsbMessage(fromHex('8D40621D58C386435CC412692AD6'), {
receivedAt: 1_500,
});
updateAircraftTrack(tracks, even);
updateAircraftTrack(tracks, odd);
// 20 s later the pair has expired and there is no receiver location.
const later = decodeAdsbMessage(fromHex('8D40621D58C386435CC412692AD6'), {
receivedAt: 20_000,
});
const track = updateAircraftTrack(tracks, later, null);
assert.equal(track.lastPositionAt, 20_000);
assert.ok(Math.abs(track.latitude - 52.26578) < 0.0001);
assert.ok(Math.abs(track.longitude - 3.93891) < 0.0001);
});
test('three refused global fixes in a row re-anchor the track', () => {
const tracks = new Map();
const stats = {};
updateAircraftTrack(
tracks,
decodeAdsbMessage(fromHex('8D40621D58C382D690C8AC2863A7'), {
receivedAt: 1_000,
}),
);
updateAircraftTrack(
tracks,
decodeAdsbMessage(fromHex('8D40621D58C386435CC412692AD6'), {
receivedAt: 1_500,
}),
);
// Austin pairs relabelled as this aircraft: global decodes 8,000 km away.
const austin = timed.frames
.map(([, hex]) => decodeAdsbMessage(fromHex(hex)))
.filter((message) => message?.icao === 'A0B702' && message.cpr);
const firstOdd = austin.find((message) => message.cpr.odd);
const firstEven = austin.find((message) => !message.cpr.odd);
let at = 2_000;
const feed = (message) => {
at += 500;
return updateAircraftTrack(
tracks,
{
...message,
icao: '40621D',
receivedAt: at,
cpr: { ...message.cpr, receivedAt: at },
},
null,
stats,
);
};
feed(firstEven);
let track = feed(firstOdd);
assert.equal(track.rejectedPositions, 2);
assert.ok(Math.abs(track.latitude - 52.26578) < 0.0001, 'still anchored');
track = feed(firstEven);
assert.equal(stats.positionsRejected, 2, 'the third global fix re-anchors');
assert.ok(Math.abs(track.latitude - 30.2) < 0.2, 'moved to the new stream');
assert.equal(track.rejectStreak, 0);
});
// Surface position examples from "The 1090MHz Riddle" (J. Sun), surface
// position chapter: ICAO C8200A, even 8CC8200A3AC8F009BCDEF2..., odd
// 8FC8200A3AB8F5F893096B..., receiver (-43.496, 172.558); published result
// (-43.48564, 172.53942). The book prints the parity field as zeros, so the
// 24-bit parity is recomputed here to make the frames CRC-valid.
function withParity(hex) {
const bytes = fromHex(hex);
// The Mode S parity is the CRC remainder of the first 88 bits.
const parity = modeSChecksum(bytes.slice(0, 11));
bytes[11] = (parity >> 16) & 0xff;
bytes[12] = (parity >> 8) & 0xff;
bytes[13] = parity & 0xff;
assert.equal(modeSChecksum(bytes), 0);
return bytes;
}
const SURFACE_EVEN = withParity('8CC8200A3AC8F009BCDEF2000000');
const SURFACE_ODD = withParity('8FC8200A3AB8F5F893096B000000');
const SURFACE_RECEIVER = { latitude: -43.496, longitude: 172.558 };
test('surface position messages decode movement, track and ground state', () => {
// Odd frame ME field, read by hand: TC 00111 (7), MOV 0101011 (43),
// S 1, TRK 0001111 (15), T 0, F 1. MOV 39–93 is 15 kt + 1 kt per step
// (43 → 19 kt); TRK is in 360/128° steps (15 → 42.1875°).
const odd = decodeAdsbMessage(SURFACE_ODD, { receivedAt: 2_000 });
assert.equal(odd.icao, 'C8200A');
assert.equal(odd.typeCode, 7);
assert.equal(odd.onGround, true);
assert.equal(odd.speedKt, 19);
assert.equal(odd.headingDeg, 42.1875);
assert.equal(odd.altitudeFt, null, 'no altitude on the surface');
assert.equal(odd.cpr.surface, true);
assert.equal(odd.cpr.odd, true);
// Even frame: MOV 0101100 (44 → 20 kt), same track.
const even = decodeAdsbMessage(SURFACE_EVEN);
assert.equal(even.speedKt, 20);
assert.equal(even.cpr.odd, false);
// A real Schiphol frame with a valid parity (Riddle ground-speed example):
// MOV 0101010 (42 → 18 kt), S 1, TRK 0110010 (50 → 140.625°).
const schiphol = decodeAdsbMessage(fromHex('8C4841753AAB238733C8CD4020B1'));
assert.equal(schiphol.icao, '484175');
assert.equal(schiphol.speedKt, 18);
assert.equal(schiphol.headingDeg, 140.625);
// MOV 1 is "stopped"; MOV 0 and a track status of 0 carry no value.
const stopped = withParity('8CC8200A3810F009BCDEF2000000');
const still = decodeAdsbMessage(stopped);
assert.equal(still.speedKt, 0);
assert.equal(still.headingDeg, undefined, 'track status 0: no track');
});
test('a surface even/odd pair decodes to the published position and marks the aircraft on the ground', () => {
const tracks = new Map();
// Airborne first: the landing must clear the old altitude.
const airborne = decodeAdsbMessage(fromHex('8D40621D58C382D690C8AC2863A7'));
updateAircraftTrack(
tracks,
// Its altitude only: the CPR belongs to another aircraft.
{ ...airborne, icao: 'C8200A', receivedAt: 0, cpr: null },
SURFACE_RECEIVER,
);
updateAircraftTrack(
tracks,
decodeAdsbMessage(SURFACE_EVEN, { receivedAt: 1_000 }),
SURFACE_RECEIVER,
);
const track = updateAircraftTrack(
tracks,
decodeAdsbMessage(SURFACE_ODD, { receivedAt: 2_000 }),
SURFACE_RECEIVER,
);
assert.ok(
Math.abs(track.latitude - -43.48564) < 0.00001,
`${track.latitude}`,
);
assert.ok(
Math.abs(track.longitude - 172.53942) < 0.00001,
`${track.longitude}`,
);
assert.equal(track.lastPositionAt, 2_000);
assert.equal(track.onGround, true);
assert.equal(track.altitudeFt, null);
assert.equal(track.speedKt, 19);
const record = recordFromDecoderTrack(track);
assert.equal(record.onGround, true);
assert.equal(record.altitudeFt, null);
assert.ok(Math.abs(record.lat - -43.48564) < 0.00001);
// An airborne position afterwards clears the ground state.
const departed = updateAircraftTrack(
tracks,
{ ...airborne, icao: 'C8200A', receivedAt: 3_000, cpr: null },
SURFACE_RECEIVER,
);
assert.equal(departed.onGround, false);
assert.equal(departed.altitudeFt, 38_000);
});
test('a surface track is seeded by a pair; a single frame then decodes against its own fix', () => {
// The receiver is not established to be within 45 NM of the aircraft, so a
// lone surface frame does not decode against it (even though this one is).
const receiver = { latitude: -43.5, longitude: 172.5 };
const tracks = new Map();
let track = updateAircraftTrack(
tracks,
decodeAdsbMessage(SURFACE_EVEN, { receivedAt: 1_000 }),
receiver,
);
assert.equal(track.lastPositionAt, null, 'no single-frame receiver fix');
// The even/odd pair seeds the track at the published position.
track = updateAircraftTrack(
tracks,
decodeAdsbMessage(SURFACE_ODD, { receivedAt: 2_000 }),
receiver,
);
assert.ok(Math.abs(track.latitude - -43.48564) < 0.00001);
assert.ok(Math.abs(track.longitude - 172.53942) < 0.00001);
// With no receiver location, the aircraft's own recent fix is the reference
// (the other frame of the pair is too old to pair with).
track = updateAircraftTrack(
tracks,
decodeAdsbMessage(SURFACE_EVEN, { receivedAt: 40_000 }),
null,
);
assert.equal(track.lastPositionAt, 40_000);
assert.ok(Math.abs(track.latitude - -43.4856) < 0.001);
assert.ok(Math.abs(track.longitude - 172.5394) < 0.001);
// An own fix the aircraft may have left by 45 NM or more (unknown speed,
// four minutes old) is no single-frame reference either.
tracks.set('C8200A', { ...track, speedKt: null, category: null });
track = updateAircraftTrack(
tracks,
decodeAdsbMessage(SURFACE_ODD, { receivedAt: 280_000 }),
null,
);
assert.equal(track.lastPositionAt, 40_000, 'reference too old to trust');
// Without any reference a lone surface frame is ambiguous: no position.
const blind = updateAircraftTrack(
new Map(),
decodeAdsbMessage(SURFACE_ODD, { receivedAt: 1_000 }),
null,
);
assert.equal(blind.lastPositionAt, null);
assert.equal(blind.onGround, true);
});
// "The 1090MHz Riddle", surface position chapter: Schiphol pair of ICAO
// 484175, published position about (52.3206, 4.7347).
const SCHIPHOL_EVEN = fromHex('8C4841753AAB238733C8CD4020B1');
const SCHIPHOL_ODD = fromHex('8C4841753A9A153237AEF0F275BE');
test('a far receiver never places a lone surface frame; the next valid pair seeds the track', () => {
// About 80 NM from the airport: beyond half a surface zone (45 NM).
const receiver = { latitude: 51, longitude: 4.375 };
const tracks = new Map();
let track = updateAircraftTrack(
tracks,
decodeAdsbMessage(SCHIPHOL_EVEN, { receivedAt: 1_000 }),
receiver,
);
assert.equal(track.lastPositionAt, null, 'no position from one frame');
assert.equal(track.latitude, null, 'never (50.823040, 4.602622)');
track = updateAircraftTrack(
tracks,
decodeAdsbMessage(SCHIPHOL_ODD, { receivedAt: 2_000 }),
receiver,
);
assert.equal(track.lastPositionAt, 2_000);
assert.equal(track.rejectedPositions, 0, 'the valid pair is accepted');
assert.ok(Math.abs(track.latitude - 52.3206) < 0.002, `${track.latitude}`);
assert.ok(Math.abs(track.longitude - 4.7347) < 0.002, `${track.longitude}`);
assert.equal(track.onGround, true);
});
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+93
View File
@@ -0,0 +1,93 @@
/**
* Tuner gain settings for the local RTL-SDR, remembered per receiver mode.
*
* `'auto'` hands gain to the tuner's AGC. Manual values are the R820T gain
* steps librtlsdr and dump1090 expose, in dB. Receivers with an onboard LNA
* (for example a Nooelec FlyCatcher with its LNA on) overload at AGC or high
* gain, so the ADS-B default is a fixed step. It is 28.0 dB rather than the
* lower steps dump1090 users pick because the WebUSB library maps dB to tuner
* gain stages differently from librtlsdr: on a FlyCatcher, 20.7 dB here gave
* about 1 msg/s and 28.0 dB about 9 msg/s on the same antenna.
*/
export const SDR_GAIN_AUTO = 'auto';
/** R820T tuner gain steps in dB, as listed by librtlsdr. */
export const R820T_GAIN_STEPS_DB = Object.freeze([
0.0, 0.9, 1.4, 2.7, 3.7, 7.7, 8.7, 12.5, 14.4, 15.7, 16.6, 19.7, 20.7, 22.9,
25.4, 28.0, 29.7, 32.8, 33.8, 36.4, 37.2, 38.6, 40.2, 42.1, 43.4, 43.9, 44.5,
48.0, 49.6,
]);
export const SDR_GAIN_DEFAULTS = Object.freeze({
fm: SDR_GAIN_AUTO,
adsb: 28.0,
});
export const SDR_GAIN_STORAGE_KEY = 'gev:sdr:gain:v1';
/**
* Normalize a requested gain to `'auto'` or the nearest listed R820T step.
* @param {*} value `'auto'`, a dB number or a numeric string.
* @returns {'auto'|number|null} Normalized setting, or null when invalid.
*/
export function normalizeSdrGain(value) {
if (value === null || value === undefined || value === '') return null;
if (String(value).trim().toLowerCase() === SDR_GAIN_AUTO)
return SDR_GAIN_AUTO;
const requested = Number(value);
if (!Number.isFinite(requested)) return null;
let nearest = R820T_GAIN_STEPS_DB[0];
for (const step of R820T_GAIN_STEPS_DB) {
if (Math.abs(step - requested) < Math.abs(nearest - requested))
nearest = step;
}
return nearest;
}
/**
* Read per-mode gain settings, falling back to the defaults.
* @param {Storage|null} storage Browser storage or a test double.
* @returns {{fm:'auto'|number, adsb:'auto'|number}}
*/
export function readSdrGainSettings(storage) {
let stored = null;
try {
stored = JSON.parse(storage?.getItem?.(SDR_GAIN_STORAGE_KEY) || 'null');
} catch {
stored = null;
}
return Object.fromEntries(
Object.entries(SDR_GAIN_DEFAULTS).map(([mode, fallback]) => [
mode,
normalizeSdrGain(stored?.[mode]) ?? fallback,
]),
);
}
/**
* Persist per-mode gain settings. Storage failures are ignored.
* @param {Storage|null} storage Browser storage or a test double.
* @param {{fm:'auto'|number, adsb:'auto'|number}} settings Settings to save.
* @returns {boolean} Whether the write succeeded.
*/
export function writeSdrGainSettings(storage, settings) {
try {
storage?.setItem?.(
SDR_GAIN_STORAGE_KEY,
JSON.stringify({ fm: settings.fm, adsb: settings.adsb }),
);
return Boolean(storage?.setItem);
} catch {
return false;
}
}
/**
* The value handed to the tuner: null selects AGC.
* @param {'auto'|number} setting Normalized gain setting.
* @returns {number|null}
*/
export function tunerGainValue(setting) {
return setting === SDR_GAIN_AUTO ? null : setting;
}
+78
View File
@@ -0,0 +1,78 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
R820T_GAIN_STEPS_DB,
SDR_GAIN_DEFAULTS,
SDR_GAIN_STORAGE_KEY,
normalizeSdrGain,
readSdrGainSettings,
tunerGainValue,
writeSdrGainSettings,
} from './gain.js';
function memoryStorage(initial = {}) {
const values = new Map(Object.entries(initial));
return {
values,
getItem: (key) => (values.has(key) ? values.get(key) : null),
setItem: (key, value) => values.set(key, String(value)),
};
}
test('gain steps are the R820T table and ADS-B defaults to manual 28.0 dB', () => {
assert.equal(R820T_GAIN_STEPS_DB.length, 29);
assert.equal(R820T_GAIN_STEPS_DB[0], 0);
assert.equal(R820T_GAIN_STEPS_DB.at(-1), 49.6);
assert.ok(R820T_GAIN_STEPS_DB.includes(20.7));
assert.ok(R820T_GAIN_STEPS_DB.includes(28.0));
assert.deepEqual(SDR_GAIN_DEFAULTS, { fm: 'auto', adsb: 28.0 });
assert.equal(tunerGainValue('auto'), null, 'AUTO hands gain to tuner AGC');
assert.equal(tunerGainValue(20.7), 20.7);
});
test('requested gains normalize to AUTO or the nearest listed step', () => {
assert.equal(normalizeSdrGain('AUTO'), 'auto');
assert.equal(normalizeSdrGain('36.4'), 36.4);
assert.equal(normalizeSdrGain(21), 20.7);
assert.equal(normalizeSdrGain(100), 49.6);
assert.equal(normalizeSdrGain(-3), 0);
assert.equal(normalizeSdrGain('loud'), null);
assert.equal(normalizeSdrGain(null), null);
});
test('per-mode gain persists in storage and survives unavailable storage', () => {
const storage = memoryStorage();
assert.deepEqual(readSdrGainSettings(storage), SDR_GAIN_DEFAULTS);
assert.equal(writeSdrGainSettings(storage, { fm: 28, adsb: 'auto' }), true);
assert.equal(
storage.values.get(SDR_GAIN_STORAGE_KEY),
'{"fm":28,"adsb":"auto"}',
);
assert.deepEqual(readSdrGainSettings(storage), { fm: 28, adsb: 'auto' });
const corrupt = memoryStorage({ [SDR_GAIN_STORAGE_KEY]: '{not json' });
assert.deepEqual(readSdrGainSettings(corrupt), SDR_GAIN_DEFAULTS);
const throwing = {
getItem() {
throw new Error('blocked');
},
setItem() {
throw new Error('blocked');
},
};
assert.deepEqual(readSdrGainSettings(throwing), SDR_GAIN_DEFAULTS);
assert.equal(writeSdrGainSettings(throwing, SDR_GAIN_DEFAULTS), false);
assert.deepEqual(readSdrGainSettings(null), SDR_GAIN_DEFAULTS);
});
test('a stored ADS-B gain wins over the 28.0 dB default', () => {
const storage = memoryStorage({
[SDR_GAIN_STORAGE_KEY]: JSON.stringify({ fm: 'auto', adsb: 20.7 }),
});
assert.deepEqual(readSdrGainSettings(storage), { fm: 'auto', adsb: 20.7 });
const onlyFm = memoryStorage({
[SDR_GAIN_STORAGE_KEY]: JSON.stringify({ fm: 36.4 }),
});
assert.deepEqual(readSdrGainSettings(onlyFm), { fm: 36.4, adsb: 28.0 });
});
+168
View File
@@ -0,0 +1,168 @@
import '@jtarrio/signals/demod/demodulator.js';
import { getDemod, getMode } from '@jtarrio/signals/demod/modes.js';
import {
AdsbStreamDecoder,
decodeAdsbMessage,
pruneAircraftTracks,
updateAircraftTrack,
} from './adsbDecoder.js';
import { analyzeSpectrum } from './spectrum.js';
import { recordFromDecoderTrack } from '../sources/adsbRecords.js';
let mode = 'fm';
let sampleRate = 2_048_000;
let receiverLocation = null;
let demodulator = null;
let blockCount = 0;
const aircraft = new Map();
// Positions refused by the decoder's speed check since the last clear.
const decoderStats = { positionsRejected: 0 };
const adsbStream = new AdsbStreamDecoder();
function configure(nextMode, nextSampleRate, location) {
const modeChanged = mode !== (nextMode === 'adsb' ? 'adsb' : 'fm');
mode = nextMode === 'adsb' ? 'adsb' : 'fm';
sampleRate =
Number(nextSampleRate) || (mode === 'adsb' ? 2_000_000 : 2_048_000);
receiverLocation = location || receiverLocation;
blockCount = 0;
demodulator =
mode === 'fm'
? getDemod(sampleRate, 48_000, { ...getMode('WBFM'), stereo: false })
: null;
if (modeChanged) {
adsbStream.reset();
aircraft.clear();
}
}
function normalizedIq(buffer) {
const bytes = new Uint8Array(buffer);
const length = Math.floor(bytes.length / 2);
const i = new Float32Array(length);
const q = new Float32Array(length);
let power = 0;
for (let index = 0; index < length; index += 1) {
i[index] = (bytes[index * 2] - 127.5) / 128;
q[index] = (bytes[index * 2 + 1] - 127.5) / 128;
power += i[index] * i[index] + q[index] * q[index];
}
return {
i,
q,
iqLevelDbfs: 10 * Math.log10(Math.max(power / Math.max(length, 1), 1e-12)),
};
}
function sampledIqLevel(buffer) {
const bytes = new Uint8Array(buffer);
let power = 0;
let count = 0;
for (let index = 0; index + 1 < bytes.length; index += 32) {
const i = (bytes[index] - 127.5) / 128;
const q = (bytes[index + 1] - 127.5) / 128;
power += i * i + q * q;
count += 1;
}
return 10 * Math.log10(Math.max(power / Math.max(count, 1), 1e-12));
}
function audioLevel(samples) {
let power = 0;
for (const sample of samples) power += sample * sample;
return (
20 *
Math.log10(Math.max(Math.sqrt(power / Math.max(samples.length, 1)), 1e-12))
);
}
function publicAircraft() {
const records = [];
for (const track of aircraft.values()) {
const record = recordFromDecoderTrack(track);
if (record) records.push(record);
}
return records;
}
self.onmessage = (event) => {
const message = event.data || {};
if (message.type === 'configure') {
configure(message.mode, message.sampleRate, message.location);
return;
}
if (message.type === 'location') {
receiverLocation = message.location || null;
return;
}
if (message.type === 'reset') {
blockCount = 0;
adsbStream.reset();
if (message.clearAircraft) {
aircraft.clear();
decoderStats.positionsRejected = 0;
}
if (mode === 'fm') configure(mode, sampleRate, receiverLocation);
return;
}
if (message.type !== 'samples' || !(message.buffer instanceof ArrayBuffer))
return;
blockCount += 1;
if (mode === 'fm') {
const bins = blockCount % 3 === 0 ? analyzeSpectrum(message.buffer) : null;
const { i, q, iqLevelDbfs } = normalizedIq(message.buffer);
const decoded = demodulator.demodulate(i, q, 0);
// The demodulator owns pooled output arrays. Transfer a copy so detaching
// the message buffer cannot invalidate the pool used by the next block.
const samples = new Float32Array(decoded.left);
self.postMessage(
{
type: 'fm',
samples,
bins,
snr:
Number.isFinite(decoded.snr) && decoded.snr > 0
? 10 * Math.log10(decoded.snr)
: null,
diagnostics:
blockCount % 15 === 0
? {
workerBlocks: blockCount,
iqLevelDbfs,
audioLevelDbfs: audioLevel(samples),
}
: null,
},
[samples.buffer, ...(bins ? [bins.buffer] : [])],
);
return;
}
const now = Date.now();
let decodedCount = 0;
for (const bytes of adsbStream.extract(message.buffer, sampleRate)) {
const decoded = decodeAdsbMessage(bytes, { receivedAt: now });
if (!decoded) continue;
updateAircraftTrack(aircraft, decoded, receiverLocation, decoderStats);
decodedCount += 1;
}
const removed = pruneAircraftTracks(aircraft, now);
if (decodedCount || removed || blockCount % 30 === 0) {
self.postMessage({
type: 'adsb',
aircraft: publicAircraft(),
decodedCount,
positionsRejected: decoderStats.positionsRejected,
diagnostics:
blockCount % 30 === 0
? {
workerBlocks: blockCount,
iqLevelDbfs: sampledIqLevel(message.buffer),
}
: null,
});
}
};
configure(mode, sampleRate, receiverLocation);
+48
View File
@@ -0,0 +1,48 @@
class GevSdrAudioPlayer extends AudioWorkletProcessor {
constructor() {
super();
this.queue = [];
this.offset = 0;
this.volume = 0.8;
this.port.onmessage = (event) => {
if (
event.data?.type === 'samples' &&
event.data.samples instanceof Float32Array
) {
this.queue.push(event.data.samples);
if (this.queue.length > 48)
this.queue.splice(0, this.queue.length - 48);
} else if (event.data?.type === 'volume') {
this.volume = Math.max(0, Math.min(1, Number(event.data.value) || 0));
} else if (event.data?.type === 'clear') {
this.queue.length = 0;
this.offset = 0;
}
};
}
process(_inputs, outputs) {
const output = outputs[0]?.[0];
if (!output) return true;
output.fill(0);
let outputOffset = 0;
while (outputOffset < output.length && this.queue.length) {
const samples = this.queue[0];
const available = samples.length - this.offset;
const count = Math.min(output.length - outputOffset, available);
for (let index = 0; index < count; index += 1) {
output[outputOffset + index] =
samples[this.offset + index] * this.volume;
}
outputOffset += count;
this.offset += count;
if (this.offset >= samples.length) {
this.queue.shift();
this.offset = 0;
}
}
return true;
}
}
registerProcessor('gev-sdr-audio-player', GevSdrAudioPlayer);
+147
View File
@@ -0,0 +1,147 @@
function nextPowerOfTwo(value) {
return 2 ** Math.floor(Math.log2(Math.max(2, value)));
}
function fft(real, imag) {
const length = real.length;
for (let index = 1, reversed = 0; index < length; index += 1) {
let bit = length >> 1;
while (reversed & bit) {
reversed ^= bit;
bit >>= 1;
}
reversed ^= bit;
if (index >= reversed) continue;
[real[index], real[reversed]] = [real[reversed], real[index]];
[imag[index], imag[reversed]] = [imag[reversed], imag[index]];
}
for (let size = 2; size <= length; size <<= 1) {
const angle = (-2 * Math.PI) / size;
const stepReal = Math.cos(angle);
const stepImag = Math.sin(angle);
for (let start = 0; start < length; start += size) {
let twiddleReal = 1;
let twiddleImag = 0;
for (let index = 0; index < size / 2; index += 1) {
const even = start + index;
const odd = even + size / 2;
const oddReal = real[odd] * twiddleReal - imag[odd] * twiddleImag;
const oddImag = real[odd] * twiddleImag + imag[odd] * twiddleReal;
real[odd] = real[even] - oddReal;
imag[odd] = imag[even] - oddImag;
real[even] += oddReal;
imag[even] += oddImag;
const nextReal = twiddleReal * stepReal - twiddleImag * stepImag;
twiddleImag = twiddleReal * stepImag + twiddleImag * stepReal;
twiddleReal = nextReal;
}
}
}
}
/** Create an FFT-shifted dB power spectrum from interleaved unsigned 8-bit IQ. */
export function analyzeSpectrum(buffer, maxBins = 2_048) {
const iq = new Uint8Array(buffer);
const length = Math.min(nextPowerOfTwo(Math.floor(iq.length / 2)), maxBins);
const real = new Float32Array(length);
const imag = new Float32Array(length);
const sampleOffset = Math.max(0, Math.floor((iq.length / 2 - length) / 2));
for (let index = 0; index < length; index += 1) {
const window = 0.5 - 0.5 * Math.cos((2 * Math.PI * index) / (length - 1));
real[index] = ((iq[(sampleOffset + index) * 2] - 127.5) / 128) * window;
imag[index] = ((iq[(sampleOffset + index) * 2 + 1] - 127.5) / 128) * window;
}
fft(real, imag);
const bins = new Float32Array(length);
for (let index = 0; index < length; index += 1) {
const shifted = (index + length / 2) % length;
const power = (real[shifted] ** 2 + imag[shifted] ** 2) / length ** 2;
bins[index] = 10 * Math.log10(power + 1e-12);
}
return bins;
}
function median(values) {
const sorted = [...values].sort((a, b) => a - b);
return sorted[Math.floor(sorted.length / 2)] ?? -120;
}
function channelPower(bins, center, radius) {
let total = 0;
let count = 0;
for (
let index = Math.max(0, center - radius);
index <= Math.min(bins.length - 1, center + radius);
index += 1
) {
total += 10 ** (bins[index] / 10);
count += 1;
}
return 10 * Math.log10(total / Math.max(count, 1) + 1e-12);
}
/** Find the nearest directional broadcast-FM channel peak in one spectrum frame. */
export function findDirectionalFmPeak(
bins,
centerFrequency,
sampleRate,
direction,
afterFrequency,
{
minFrequency = 87_500_000,
maxFrequency = 108_000_000,
thresholdDb = 6,
} = {},
) {
if (
!(bins instanceof Float32Array) ||
bins.length < 16 ||
!Number.isFinite(sampleRate)
)
return null;
const binWidth = sampleRate / bins.length;
const channelRadius = Math.max(1, Math.round(55_000 / binWidth));
const comparisonDistance = Math.max(
channelRadius + 1,
Math.round(150_000 / binWidth),
);
const powers = new Float32Array(bins.length);
for (let index = 0; index < bins.length; index += 1) {
powers[index] = channelPower(bins, index, channelRadius);
}
const noiseFloor = median(powers);
const candidates = [];
const frameMin = centerFrequency - sampleRate / 2;
const frameMax = centerFrequency + sampleRate / 2;
const firstChannel =
Math.ceil(Math.max(minFrequency, frameMin) / 100_000) * 100_000;
const lastChannel =
Math.floor(Math.min(maxFrequency, frameMax) / 100_000) * 100_000;
for (
let frequency = firstChannel;
frequency <= lastChannel;
frequency += 100_000
) {
if (direction > 0 && frequency <= afterFrequency + 75_000) continue;
if (direction < 0 && frequency >= afterFrequency - 75_000) continue;
const index = Math.round(
(frequency - centerFrequency) / binWidth + bins.length / 2,
);
if (index < comparisonDistance || index >= bins.length - comparisonDistance)
continue;
const power = powers[index];
if (power - noiseFloor < thresholdDb) continue;
if (
power < powers[index - comparisonDistance] ||
power < powers[index + comparisonDistance]
)
continue;
candidates.push({ frequency, snr: power - noiseFloor, power });
}
candidates.sort(
(a, b) =>
Math.abs(a.frequency - afterFrequency) -
Math.abs(b.frequency - afterFrequency) || b.snr - a.snr,
);
return candidates[0] || null;
}
+43
View File
@@ -0,0 +1,43 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { analyzeSpectrum, findDirectionalFmPeak } from './spectrum.js';
function toneIq(frequencyOffset, sampleRate, samples = 2_048) {
const iq = new Uint8Array(samples * 2);
for (let index = 0; index < samples; index += 1) {
const phase = (2 * Math.PI * frequencyOffset * index) / sampleRate;
iq[index * 2] = Math.round(127.5 + 100 * Math.cos(phase));
iq[index * 2 + 1] = Math.round(127.5 + 100 * Math.sin(phase));
}
return iq.buffer;
}
test('FFT-shifted spectrum preserves the sign and offset of a complex tone', () => {
const sampleRate = 2_048_000;
const bins = analyzeSpectrum(toneIq(300_000, sampleRate));
let peakIndex = 0;
for (let index = 1; index < bins.length; index += 1) {
if (bins[index] > bins[peakIndex]) peakIndex = index;
}
const offset = (peakIndex - bins.length / 2) * (sampleRate / bins.length);
assert.ok(Math.abs(offset - 300_000) < 2_000);
});
test('directional FM seek chooses the nearest rounded station in-band', () => {
const sampleRate = 2_048_000;
const bins = analyzeSpectrum(toneIq(300_000, sampleRate));
const forward = findDirectionalFmPeak(
bins,
98_500_000,
sampleRate,
1,
98_500_000,
);
assert.equal(forward.frequency, 98_800_000);
assert.ok(forward.snr > 6);
assert.equal(
findDirectionalFmPeak(bins, 98_500_000, sampleRate, -1, 98_500_000),
null,
);
});
+203
View File
@@ -0,0 +1,203 @@
/**
* WebUSB receiver selection for the local RTL-SDR.
*
* Dual-channel boards such as the Nooelec FlyCatcher enumerate as two USB
* devices (an ADS-B 1090 MHz channel and a UAT 978 MHz channel) that can share
* a serial number. Selection therefore remembers the full identity
* (vendorId, productId, productName, serialNumber) per receiver mode and, for
* ADS-B, prefers a channel whose product name names ADS-B or 1090 MHz.
*/
export const SDR_DEVICE_STORAGE_KEY = 'gev:sdr:device:v1';
/** RTL2832U vendor/product pairs requested by the WebUSB RTL-SDR provider. */
export const RTL_SDR_USB_FILTERS = Object.freeze([
Object.freeze({ vendorId: 0x0bda, productId: 0x2832 }),
Object.freeze({ vendorId: 0x0bda, productId: 0x2838 }),
]);
const ADSB_PRODUCT_PATTERN = /ads.?b|1090/i;
const NON_FM_PRODUCT_PATTERN = /ads.?b|1090|uat|978/i;
function text(value) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
/**
* Plain identity of a WebUSB device (or a stored identity).
* @param {object|null} device USBDevice-like object.
* @returns {{vendorId:number|null, productId:number|null, productName:string|null, serialNumber:string|null}|null}
*/
export function sdrDeviceIdentity(device) {
if (!device || typeof device !== 'object') return null;
return {
vendorId: Number.isInteger(device.vendorId) ? device.vendorId : null,
productId: Number.isInteger(device.productId) ? device.productId : null,
productName: text(device.productName),
serialNumber: text(device.serialNumber),
};
}
/**
* Whether two devices or identities name the same receiver channel.
* @param {object|null} a Device or identity.
* @param {object|null} b Device or identity.
* @returns {boolean}
*/
export function sameSdrDevice(a, b) {
const left = sdrDeviceIdentity(a);
const right = sdrDeviceIdentity(b);
return Boolean(
left &&
right &&
left.vendorId === right.vendorId &&
left.productId === right.productId &&
left.productName === right.productName &&
left.serialNumber === right.serialNumber,
);
}
/**
* Human-readable receiver label for status text.
* @param {object|null} device Device or identity.
* @returns {string}
*/
export function sdrDeviceLabel(device) {
const identity = sdrDeviceIdentity(device);
if (!identity) return 'RTL-SDR';
return identity.productName || 'RTL-SDR';
}
function filterMatchesDevice(filter, device) {
return (
(filter.vendorId === undefined || filter.vendorId === device.vendorId) &&
(filter.productId === undefined || filter.productId === device.productId) &&
(filter.serialNumber === undefined ||
filter.serialNumber === device.serialNumber)
);
}
/**
* Choose an already-authorized receiver without opening the WebUSB picker.
* Order: the device remembered for this mode; then, for ADS-B, a channel
* named for ADS-B/1090 MHz, or for FM a channel not named for ADS-B/UAT; then
* the first matching device.
* @param {object[]} devices Authorized devices from `navigator.usb.getDevices()`.
* @param {object} [options]
* @param {object[]} [options.filters] WebUSB request filters.
* @param {'fm'|'adsb'} [options.mode] Receiver mode being connected.
* @param {object|null} [options.remembered] Identity remembered for this mode.
* @returns {object|null} Chosen device, or null when the picker is needed.
*/
export function selectAuthorizedSdrDevice(
devices,
{ filters = [], mode = 'fm', remembered = null } = {},
) {
const candidates = (Array.isArray(devices) ? devices : []).filter(
(device) =>
device &&
(filters.length === 0 ||
filters.some((filter) => filterMatchesDevice(filter, device))),
);
if (!candidates.length) return null;
if (remembered) {
const match = candidates.find((device) =>
sameSdrDevice(device, remembered),
);
if (match) return match;
}
const preferred =
mode === 'adsb'
? candidates.find((device) =>
ADSB_PRODUCT_PATTERN.test(device.productName || ''),
)
: candidates.find(
(device) => !NON_FM_PRODUCT_PATTERN.test(device.productName || ''),
);
return preferred || candidates[0];
}
/**
* Per-mode remembered receiver identities in browser storage.
* @param {Storage|null} storage Browser storage or a test double.
* @returns {{get(mode:string):object|null, set(mode:string, device:object):void}}
*/
export function createSdrDeviceMemory(storage) {
const read = () => {
try {
const value = JSON.parse(
storage?.getItem?.(SDR_DEVICE_STORAGE_KEY) || 'null',
);
return value && typeof value === 'object' ? value : {};
} catch {
return {};
}
};
return {
get(mode) {
return sdrDeviceIdentity(read()[mode]);
},
set(mode, device) {
const identity = sdrDeviceIdentity(device);
if (!identity) return;
try {
storage?.setItem?.(
SDR_DEVICE_STORAGE_KEY,
JSON.stringify({ ...read(), [mode]: identity }),
);
} catch {
/* storage can be unavailable or quota-limited */
}
},
};
}
/**
* Wrap `navigator.usb` so the RTL-SDR provider reuses an authorized receiver
* for the current mode before asking WebUSB to show its picker.
* @param {USB} webUsb Browser WebUSB object.
* @param {object} [options]
* @param {() => 'fm'|'adsb'} [options.getMode] Mode being connected.
* @param {{get:Function,set:Function}} [options.memory] Remembered identities.
* @param {() => boolean} [options.consumeForcePicker] Returns true once when
* the user asked to choose a different device.
* @param {(device:object) => void} [options.onSelected] Selected device hook.
* @returns {USB} Proxy exposing the same WebUSB surface.
*/
export function createRememberingWebUsb(
webUsb,
{
getMode = () => 'fm',
memory = null,
consumeForcePicker = () => false,
onSelected = () => {},
} = {},
) {
if (!webUsb?.getDevices || !webUsb?.requestDevice) return webUsb;
return new Proxy(webUsb, {
get(target, property) {
if (property === 'requestDevice') {
return async (options = {}) => {
const mode = getMode();
const filters = Array.isArray(options.filters) ? options.filters : [];
let device = null;
if (!consumeForcePicker()) {
device = selectAuthorizedSdrDevice(await target.getDevices(), {
filters,
mode,
remembered: memory?.get(mode) || null,
});
}
if (!device) {
// Only an explicit picker choice is remembered; automatic
// selection is re-derived from the preference on every connect.
device = await target.requestDevice(options);
if (device) memory?.set(mode, device);
}
if (device) onSelected(device);
return device;
};
}
const value = Reflect.get(target, property, target);
return typeof value === 'function' ? value.bind(target) : value;
},
});
}
+222
View File
@@ -0,0 +1,222 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
SDR_DEVICE_STORAGE_KEY,
createRememberingWebUsb,
createSdrDeviceMemory,
sameSdrDevice,
selectAuthorizedSdrDevice,
} from './usbDevices.js';
const RTL_FILTERS = [
{ vendorId: 0x0bda, productId: 0x2832 },
{ vendorId: 0x0bda, productId: 0x2838 },
];
// A FlyCatcher enumerates as two RTL2832U devices that may share a serial.
const flyCatcherUat = {
vendorId: 0x0bda,
productId: 0x2838,
productName: 'FlyCatcher_UAT',
serialNumber: '00000001',
};
const flyCatcherAdsb = {
vendorId: 0x0bda,
productId: 0x2838,
productName: 'FlyCatcher_ADS_B',
serialNumber: '00000001',
};
const genericDongle = {
vendorId: 0x0bda,
productId: 0x2838,
productName: 'RTL2838UHIDIR',
serialNumber: '00000001',
};
const unrelated = { vendorId: 0x1234, productId: 0x5678, productName: 'Other' };
function memoryStorage() {
const values = new Map();
return {
values,
getItem: (key) => (values.has(key) ? values.get(key) : null),
setItem: (key, value) => values.set(key, String(value)),
};
}
test('ADS-B mode prefers the 1090 MHz channel of a dual-channel board', () => {
const devices = [unrelated, flyCatcherUat, flyCatcherAdsb];
assert.equal(
selectAuthorizedSdrDevice(devices, { filters: RTL_FILTERS, mode: 'adsb' }),
flyCatcherAdsb,
);
assert.equal(
selectAuthorizedSdrDevice(
[genericDongle, { ...flyCatcherAdsb, productName: 'Receiver 1090' }],
{
filters: RTL_FILTERS,
mode: 'adsb',
},
).productName,
'Receiver 1090',
);
assert.equal(
selectAuthorizedSdrDevice([genericDongle], {
filters: RTL_FILTERS,
mode: 'adsb',
}),
genericDongle,
'a single generic dongle is still used for ADS-B',
);
});
test('FM mode avoids ADS-B/UAT channels, and nothing matches unrelated devices', () => {
assert.equal(
selectAuthorizedSdrDevice([flyCatcherAdsb, flyCatcherUat, genericDongle], {
filters: RTL_FILTERS,
mode: 'fm',
}),
genericDongle,
);
assert.equal(
selectAuthorizedSdrDevice([unrelated], {
filters: RTL_FILTERS,
mode: 'fm',
}),
null,
);
assert.equal(selectAuthorizedSdrDevice([], { mode: 'adsb' }), null);
});
test('a remembered device wins, matched by the full identity rather than serial alone', () => {
const devices = [flyCatcherAdsb, flyCatcherUat];
assert.equal(
selectAuthorizedSdrDevice(devices, {
filters: RTL_FILTERS,
mode: 'adsb',
remembered: { ...flyCatcherUat },
}),
flyCatcherUat,
'an explicit earlier choice is honored even for ADS-B',
);
assert.equal(sameSdrDevice(flyCatcherAdsb, flyCatcherUat), false);
assert.equal(sameSdrDevice(flyCatcherAdsb, { ...flyCatcherAdsb }), true);
assert.equal(
selectAuthorizedSdrDevice(devices, {
filters: RTL_FILTERS,
mode: 'adsb',
remembered: { ...flyCatcherAdsb, serialNumber: 'unplugged' },
}),
flyCatcherAdsb,
'an absent remembered device falls back to the ADS-B preference',
);
});
test('device memory is per mode and tolerates unavailable storage', () => {
const storage = memoryStorage();
const memory = createSdrDeviceMemory(storage);
memory.set('adsb', flyCatcherAdsb);
memory.set('fm', genericDongle);
assert.deepEqual(memory.get('adsb'), flyCatcherAdsb);
assert.deepEqual(memory.get('fm'), genericDongle);
assert.ok(
storage.values.get(SDR_DEVICE_STORAGE_KEY).includes('FlyCatcher_ADS_B'),
);
const blocked = createSdrDeviceMemory({
getItem() {
throw new Error('blocked');
},
setItem() {
throw new Error('blocked');
},
});
assert.equal(blocked.get('adsb'), null);
assert.doesNotThrow(() => blocked.set('adsb', flyCatcherAdsb));
});
test('the WebUSB wrapper reuses the remembered channel without the picker', async () => {
const memory = createSdrDeviceMemory(memoryStorage());
memory.set('adsb', flyCatcherAdsb);
let pickerCalls = 0;
const selected = [];
const webUsb = createRememberingWebUsb(
{
async getDevices() {
return [flyCatcherUat, flyCatcherAdsb];
},
async requestDevice() {
pickerCalls += 1;
return null;
},
},
{
getMode: () => 'adsb',
memory,
onSelected: (device) => selected.push(device),
},
);
assert.equal(
await webUsb.requestDevice({ filters: RTL_FILTERS }),
flyCatcherAdsb,
);
assert.equal(pickerCalls, 0);
assert.deepEqual(selected, [flyCatcherAdsb]);
});
test('CHANGE DEVICE forces the picker once and remembers the choice for the mode', async () => {
const memory = createSdrDeviceMemory(memoryStorage());
memory.set('adsb', flyCatcherAdsb);
let force = true;
let pickerCalls = 0;
const webUsb = createRememberingWebUsb(
{
async getDevices() {
return [flyCatcherUat, flyCatcherAdsb];
},
async requestDevice() {
pickerCalls += 1;
return flyCatcherUat;
},
},
{
getMode: () => 'adsb',
memory,
consumeForcePicker: () => {
const value = force;
force = false;
return value;
},
},
);
assert.equal(
await webUsb.requestDevice({ filters: RTL_FILTERS }),
flyCatcherUat,
);
assert.equal(pickerCalls, 1);
assert.deepEqual(memory.get('adsb'), flyCatcherUat);
assert.equal(
await webUsb.requestDevice({ filters: RTL_FILTERS }),
flyCatcherUat,
);
assert.equal(pickerCalls, 1, 'the next connection reuses the new choice');
});
test('automatic selection is not remembered, so a fallback never becomes sticky', async () => {
const memory = createSdrDeviceMemory(memoryStorage());
const webUsb = createRememberingWebUsb(
{
async getDevices() {
return [flyCatcherUat];
},
async requestDevice() {
throw new Error('picker should not open');
},
},
{ getMode: () => 'adsb', memory },
);
assert.equal(
await webUsb.requestDevice({ filters: RTL_FILTERS }),
flyCatcherUat,
);
assert.equal(memory.get('adsb'), null);
});