mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
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:
1 parent
eefcb338b3
commit
21201c78f0
17 files changed
+514
-139
No files matched your search
@@ -121,7 +121,9 @@ export function createApplicationCatalog({
|
||||
createApplicationFirePerimeters({
|
||||
source: sources['fire-perimeters'],
|
||||
}),
|
||||
createApplicationGnssInterference(),
|
||||
createApplicationGnssInterference({
|
||||
source: sources['gnss-interference'],
|
||||
}),
|
||||
createApplicationAlpr({ surface, source: sources.alpr }),
|
||||
createApplicationStreetLevel({
|
||||
surface,
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import {
|
||||
createAdsbGnssSource,
|
||||
createGnssInterferenceLayer,
|
||||
} from '../../layers/gnss/index.js';
|
||||
import { createGnssInterferenceLayer } from '../../layers/gnss/index.js';
|
||||
|
||||
/** Wire the adsb.lol GNSS interference layer into the application catalog. */
|
||||
export function createApplicationGnssInterference(options = {}) {
|
||||
return createGnssInterferenceLayer({
|
||||
source: createAdsbGnssSource(),
|
||||
...options,
|
||||
});
|
||||
export function createApplicationGnssInterference(options) {
|
||||
return createGnssInterferenceLayer(options);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
|
||||
import {
|
||||
gnssIntegrityAnchor,
|
||||
gnssIntegrityProxy,
|
||||
gnssRetryCooldownMs,
|
||||
} from '../../server/providers/gnssIntegrity.js';
|
||||
|
||||
const snapshot = {
|
||||
@@ -154,19 +155,81 @@ test('an upstream 429 starts a cooldown with no further upstream calls', async (
|
||||
calls += 1;
|
||||
return new Response('slow down', {
|
||||
status: 429,
|
||||
headers: { 'retry-after': '120' },
|
||||
headers: { 'retry-after': '90' },
|
||||
});
|
||||
},
|
||||
});
|
||||
assert.equal((await request()).status, 502);
|
||||
const first = await request();
|
||||
assert.equal(first.status, 429);
|
||||
assert.equal(first.headers['Retry-After'], '90');
|
||||
clock = 30_500;
|
||||
const cooling = await request('/?lat=1&lon=1');
|
||||
assert.equal(cooling.status, 429);
|
||||
// The browser is told the remaining shared cooldown, rounded up.
|
||||
assert.equal(cooling.headers['Retry-After'], '60');
|
||||
assert.equal(calls, 1);
|
||||
clock = 121_000;
|
||||
clock = 91_000;
|
||||
await request('/?lat=2&lon=2');
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
test('Retry-After accepts seconds or an HTTP date, clamped to 5 s – 120 s', () => {
|
||||
const nowMs = Date.parse('2026-09-29T12:00:00Z');
|
||||
assert.equal(gnssRetryCooldownMs('30', nowMs), 30_000);
|
||||
assert.equal(gnssRetryCooldownMs('1', nowMs), 5_000);
|
||||
assert.equal(gnssRetryCooldownMs('3600', nowMs), 120_000);
|
||||
assert.equal(
|
||||
gnssRetryCooldownMs('Tue, 29 Sep 2026 12:00:45 GMT', nowMs),
|
||||
45_000,
|
||||
);
|
||||
// Garbage, a past date or no header fall back to the default minute.
|
||||
assert.equal(gnssRetryCooldownMs('soon', nowMs), 60_000);
|
||||
assert.equal(
|
||||
gnssRetryCooldownMs('Tue, 29 Sep 2026 11:00:00 GMT', nowMs),
|
||||
60_000,
|
||||
);
|
||||
assert.equal(gnssRetryCooldownMs(null, nowMs), 60_000);
|
||||
});
|
||||
|
||||
test('a cached anchor keeps serving its last snapshot as stale during a cooldown', async () => {
|
||||
let clock = 0;
|
||||
let limited = false;
|
||||
let calls = 0;
|
||||
const request = install({
|
||||
now: () => clock,
|
||||
fetchImpl: async () => {
|
||||
calls += 1;
|
||||
return limited
|
||||
? new Response('slow down', { status: 429 })
|
||||
: Response.json(snapshot);
|
||||
},
|
||||
});
|
||||
await request();
|
||||
limited = true;
|
||||
clock = 61_000;
|
||||
// Another anchor trips the shared cooldown…
|
||||
assert.equal((await request('/?lat=5&lon=5')).status, 429);
|
||||
clock = 62_000;
|
||||
// …and the cached anchor is answered from cache without an upstream call.
|
||||
const stale = await request();
|
||||
assert.equal(stale.status, 200);
|
||||
assert.equal(stale.body.stale, true);
|
||||
assert.equal(stale.body.rows.length, 1);
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
test('an oversized upstream body is refused as 502 without being parsed', async () => {
|
||||
const request = install({
|
||||
fetchImpl: async () =>
|
||||
new Response('{}', {
|
||||
headers: { 'content-length': String(9 * 1024 * 1024) },
|
||||
}),
|
||||
});
|
||||
const res = await request();
|
||||
assert.equal(res.status, 502);
|
||||
assert.deepEqual(res.body, { error: 'gnss_integrity_unavailable' });
|
||||
});
|
||||
|
||||
test('the route rejects other methods, paths, bad anchors, a malformed feed and floods', async () => {
|
||||
const request = install({
|
||||
fetchImpl: async () => Response.json({ nope: true }),
|
||||
|
||||
+13
-11
@@ -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,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createUsgsEarthquakeSource } from '../layers/earthquakes/source.js';
|
||||
import { createWfigsPerimeterSource } from '../layers/perimeters/source.js';
|
||||
import { createBundledCableSource } from '../layers/submarineCables/bundledSource.js';
|
||||
import { createAdsbGnssSource } from '../layers/gnss/source.js';
|
||||
|
||||
/** Construct the existing reference feeds independently of application setup. */
|
||||
export function createReferenceSources() {
|
||||
@@ -8,5 +9,6 @@ export function createReferenceSources() {
|
||||
earthquakes: createUsgsEarthquakeSource(),
|
||||
'fire-perimeters': createWfigsPerimeterSource(),
|
||||
cables: createBundledCableSource(),
|
||||
'gnss-interference': createAdsbGnssSource(),
|
||||
};
|
||||
}
|
||||
@@ -16,12 +16,15 @@ test('reference factories retain compatibility without starting acquisition or s
|
||||
'earthquakes',
|
||||
'fire-perimeters',
|
||||
'cables',
|
||||
'gnss-interference',
|
||||
]);
|
||||
assert.notEqual(first.earthquakes, second.earthquakes);
|
||||
assert.notEqual(first['fire-perimeters'], second['fire-perimeters']);
|
||||
assert.notEqual(first.cables, second.cables);
|
||||
assert.notEqual(first['gnss-interference'], second['gnss-interference']);
|
||||
assert.equal(typeof first.earthquakes.getSnapshot, 'function');
|
||||
assert.equal(typeof first['fire-perimeters'].getSnapshot, 'function');
|
||||
assert.equal(typeof first.cables.fetch, 'function');
|
||||
assert.equal(typeof first['gnss-interference'].getSnapshot, 'function');
|
||||
assert.equal(requests, 0);
|
||||
});
|
||||
@@ -3263,6 +3263,30 @@ test('ALPR common names toggle only the registered camera layer through the norm
|
||||
}
|
||||
});
|
||||
|
||||
test('GNSS interference common names toggle only the GNSS layer through the normal voice action', async () => {
|
||||
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
|
||||
const viewer = { clock: { onTick: { addEventListener: () => () => {} } },
|
||||
scene: { canvas: { addEventListener() {}, removeEventListener() {} } },
|
||||
camera: { moveEnd: { addEventListener() {} } } };
|
||||
const calls = [];
|
||||
let enabled = false;
|
||||
const dataManager = {
|
||||
layers: new Map([['gnss-interference', { module: {} }]]),
|
||||
getAll: () => [{ id: 'gnss-interference', name: 'GNSS Interference' }],
|
||||
isEnabled: () => enabled,
|
||||
setEnabled: async (id, value) => { calls.push([id, value]); enabled = value; return true; },
|
||||
};
|
||||
const runner = createGevActionRunner({ viewer, styleManager: {}, dataManager });
|
||||
for (const alias of ['gnss-interference', 'gnss interference', 'gps interference', 'gps jamming', 'gnss']) {
|
||||
for (const value of [true, false]) {
|
||||
const result = await runner('set_layer_visibility', { layerId: alias, enabled: value });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.layerId, 'gnss-interference');
|
||||
assert.deepEqual(calls.at(-1), ['gnss-interference', value]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('Local ADS-B common names toggle only the receiver layer through the normal voice action', async () => {
|
||||
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
|
||||
const viewer = { clock: { onTick: { addEventListener: () => () => {} } },
|
||||
|
||||
Reference in new issue
Block a user