fix(gnss): address review - reference source wiring, honest method, 429 backoff, QA render proof

- Route the adsb.lol GNSS source through createReferenceSources and the
  constructCatalog contract; app layer module is a thin pass-through.
- Describe the method honestly: gpsjam.org's published cell formula and
  bands; per-aircraft thresholds are the 14 CFR 91.227(c) ADS-B Out minima
  (NIC < 7, NACp < 8) plus readsb gpsOkBefore.
- Proxy: reuse the adsb.lol anchor parser, honour upstream Retry-After
  (clamped 5-120 s), serve stale during cooldown, cap the body size.
- Layer: redraw only when a cell id or band changes, treat a settling
  camera as a retry rather than a rejected enable, keep low cells visible,
  skip rows without finite coordinates, state the 3-aircraft rule in the
  legend blurb.
- Voice: gnss-interference in set_layer_visibility / show_data_layers_menu
  plus common aliases; voice pins re-derived.
- qa-gnss: stub the route in-page (request interception stalls Cesium's
  geometry workers), assert every cell is built, cover all bands over land
  and sea, screenshot every state with the Data Layers row open.

Hex project - Crew Leo Agile dev team.

Co-Authored-By: Claude <[email protected]>
This commit is contained in:
Leo PechnickiandClaude authored and Mazeyar Moeini Feizabadi committed 2026-10-13 17:20:03 +02:00
1 parent eefcb338b3
commit 21201c78f0
17 files changed
+514 -139

No files matched your search

+13 -11
View File
@@ -1,6 +1,7 @@
import * as Cesium from 'cesium';
import {
GNSS_LEVEL_COLORS,
GNSS_MIN_AIRCRAFT,
GNSS_WINDOW_MS,
accumulateGnssObservations,
binGnssCells,
@@ -8,7 +9,8 @@ import {
export * from './records.js';
export { createAdsbGnssSource } from './source.js';
const FILL_ALPHA = Object.freeze({ low: 0.14, medium: 0.32, high: 0.45 });
/** Low stays visible over land so coverage reads apart from no data. */
const FILL_ALPHA = Object.freeze({ low: 0.3, medium: 0.42, high: 0.55 });
const LEVEL_LABELS = Object.freeze({
low: 'Under 2% degraded',
medium: '2–10% degraded',
@@ -56,13 +58,9 @@ export function createGnssInterferenceLayer({
const _observations = new Map();
function render() {
// Only the id and band change what is drawn; counts alone do not.
const signature = JSON.stringify(
_cells.map(({ id, level, aircraft, degraded }) => [
id,
level,
aircraft,
degraded,
]),
_cells.map(({ id, level }) => [id, level]),
);
if (signature === _signature) return;
_signature = signature;
@@ -122,7 +120,10 @@ export function createGnssInterferenceLayer({
async update() {
if (!_enabled || !_dataSource) return false;
const anchor = viewAnchor(_viewer);
if (!anchor) return false;
// No anchor yet (camera still settling) is not a failure: returning
// false would make the manager reject the enable. The next interval
// retries.
if (!anchor) return true;
_request?.abort();
const request = new AbortController();
_request = request;
@@ -137,7 +138,7 @@ export function createGnssInterferenceLayer({
});
_cells = binGnssCells(_observations);
render();
_stale = snapshot.stale;
_stale = snapshot.stale === true;
_lastUpdate = now();
_lastError = null;
return true;
@@ -178,8 +179,7 @@ export function createGnssInterferenceLayer({
count: _cells.filter((cell) => cell.level === level).length,
...(index === 0
? {
blurb:
'Share of ADS-B aircraft reporting low navigation integrity (NIC/NACp) over the last 30 minutes, around the views you visit. An inference of jamming or spoofing, not a detection.',
blurb: `Share of ADS-B aircraft reporting low navigation integrity (NIC/NACp) over the last 30 minutes, around the views you visit. Cells with fewer than ${GNSS_MIN_AIRCRAFT} aircraft are not drawn, so blank areas mean no data, not clean GNSS. An inference of jamming or spoofing, not a detection.`,
}
: {}),
})),
@@ -192,6 +192,8 @@ export function createGnssInterferenceLayer({
lastUpdate: _lastUpdate,
error: _lastError,
stale: _stale,
// Load-bearing: without an explicit boolean, layerFeedState reads the
// adsb.lol source name as a flights fallback (src/data/feedState.js).
fallback: false,
};
},
+21 -1
View File
@@ -88,6 +88,7 @@ test('an enabled refresh bins the snapshot into coloured ground cells', async ()
[1, 0, 0],
);
assert.match(legend[0].blurb, /not a detection/);
assert.match(legend[0].blurb, /fewer than 3 aircraft are not drawn/);
});
test('a failed refresh keeps the last cells and reports the error', async () => {
@@ -153,9 +154,28 @@ test('no view anchor means no request, and destroy releases the data source', as
{ anchor: null },
);
layer.enable();
assert.equal(await layer.update(), false);
assert.equal(
await layer.update(),
true,
'a settling camera is not a failure',
);
assert.equal(calls, 0);
assert.equal(layer.getStats().error, null);
layer.destroy();
assert.equal(sources.length, 0);
assert.equal(layer.getStats().count, 0);
});
const threeCells = () => ({
rows: [row('a1', 50.1, true), row('a2', 50.1, true), row('a3', 50.2, false)],
});
test('an identical snapshot leaves the drawn entities untouched', async () => {
const { layer, sources } = harness(async () => threeCells());
layer.enable();
await layer.update();
const [first] = sources[0].entities.values;
await layer.update();
assert.equal(sources[0].entities.values.length, 1);
assert.equal(sources[0].entities.values[0], first);
});
+12 -5
View File
@@ -5,17 +5,20 @@
* their own position: NIC (integrity containment radius) and NACp (position
* accuracy). Both collapse when the GNSS receiver is jammed or spoofed, so a
* cluster of aircraft reporting low values in one area is a well-known public
* proxy for interference (the method popularized by gpsjam.org and the
* readsb/tar1090 `gpsOkBefore` flag). It is an inference, not a detection:
* proxy for interference (popularized by gpsjam.org, whose published cell
* formula and bands this module uses, and by the readsb/tar1090 `gpsOkBefore`
* flag). gpsjam does not publish its per-aircraft thresholds; here an aircraft
* is degraded when it reports below the 14 CFR 91.227(c) ADS-B Out minima
* (NIC < 7 or NACp < 8). It is an inference, not a detection:
* avionics faults and old transponders also report low values, which is why
* cells need several aircraft and the first degraded aircraft is discounted.
*/
/** Direct 1090ES ADS-B only: MLAT, TIS-B and ADS-R positions are not self-reported. */
const QUALIFYING_TYPES = new Set(['adsb_icao']);
/** NIC below 7 means a containment radius above 0.2 NM (RNP 0.3 and worse). */
/** NIC below 7 means a containment radius of 0.2 NM or more (below the 91.227 ADS-B Out minimum). */
export const GNSS_NIC_THRESHOLD = 7;
/** NACp below 8 means an estimated position uncertainty of 93 m or more. */
/** NACp below 8 means an estimated position uncertainty of 93 m or more (below the 91.227 minimum). */
export const GNSS_NACP_THRESHOLD = 8;
/** Positions older than this are not current evidence of the local sky. */
const MAX_POSITION_AGE_S = 60;
@@ -54,6 +57,9 @@ export function normalizeGnssAircraft(payload) {
if (!finite(nic) || !finite(nacp)) continue;
if (finite(aircraft.seen_pos) && aircraft.seen_pos > MAX_POSITION_AGE_S)
continue;
// readsb sets gpsOkBefore when an aircraft's integrity collapses and keeps
// it for roughly 15 minutes after GPS recovers, so it can outlast the
// current NIC/NACp; the rolling window treats that as recent evidence.
const gpsLost = aircraft.gpsOkBefore != null;
seen.add(hex);
rows.push({
@@ -95,6 +101,7 @@ export function accumulateGnssObservations(
{ windowMs = GNSS_WINDOW_MS, cellDeg = GNSS_CELL_DEG } = {},
) {
for (const row of rows || []) {
if (!Number.isFinite(row?.lat) || !Number.isFinite(row?.lon)) continue;
const cell = gnssCellKey(row.lat, row.lon, cellDeg);
const key = `${row.hex}|${cell}`;
const previous = store.get(key);
@@ -111,7 +118,7 @@ export function accumulateGnssObservations(
}
/**
* Interference level of a cell, using gpsjam.org's published formula and
* Interference level of a cell, using gpsjam.org's published cell formula and
* bands: the first degraded aircraft is discounted to limit false positives,
* then under 2% is low, 2–10% medium and over 10% high.
*/
+29 -1
View File
@@ -32,8 +32,11 @@ test('normalization keeps only airborne self-reported ADS-B v1/v2 positions', ()
aircraft({ hex: '000005', seen_pos: 120 }),
aircraft({ hex: '000006', nic: undefined }),
aircraft({ hex: '000007', lat: 95 }),
aircraft({ hex: '000008', version: undefined }),
aircraft({ hex: 'not-hex' }),
aircraft(),
// A non-ICAO (`~`) address still self-reports over 1090ES ADS-B.
aircraft({ hex: '~a1b2c3' }),
null,
],
});
@@ -47,6 +50,15 @@ test('normalization keeps only airborne self-reported ADS-B v1/v2 positions', ()
gpsLost: false,
degraded: false,
},
{
hex: '~a1b2c3',
lat: 50.1,
lon: 19.9,
nic: 8,
nacp: 10,
gpsLost: false,
degraded: false,
},
]);
assert.equal(normalizeGnssAircraft({}), null);
assert.equal(normalizeGnssAircraft(null), null);
@@ -77,9 +89,11 @@ test('cell keys are stable half-degree bins, clamped at the poles and antimeridi
assert.notEqual(gnssCellKey(50.1, 19.9), gnssCellKey(50.6, 19.9));
assert.equal(gnssCellKey(90, 180), gnssCellKey(89.9, 179.9));
assert.equal(gnssCellKey(-90, -180), '0:0');
// Just south-west of the origin falls in the last cell before 0°, not cell 0.
assert.equal(gnssCellKey(-0.1, -0.1), '179:359');
});
test('interference level follows the published gpsjam formula and bands', () => {
test('interference level follows the gpsjam.org published cell formula and bands', () => {
assert.deepEqual(gnssInterferenceLevel(10, 0), { percent: 0, level: 'low' });
// One degraded aircraft is always discounted.
assert.deepEqual(gnssInterferenceLevel(10, 1), { percent: 0, level: 'low' });
@@ -116,6 +130,20 @@ test('observations count each aircraft once per cell and expire after the window
assert.deepEqual(binGnssCells(store), []);
});
test('rows without finite coordinates never key a cell', () => {
const store = new Map();
accumulateGnssObservations(
store,
[
{ hex: 'n1', lat: Number.NaN, lon: 10.1, degraded: true },
{ hex: 'n2', lat: 10.1, lon: '10.1', degraded: true },
null,
],
0,
);
assert.equal(store.size, 0);
});
test('cells below the minimum aircraft count are withheld', () => {
const store = new Map();
accumulateGnssObservations(