diff --git a/CHANGELOG.md b/CHANGELOG.md index 966969183..72bf0da97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ## [Unreleased] -- Split military flights into instance-owned state, ingestion, motion, rendering, tracking and query components. Share the existing aircraft calculations and give military classification an explicit source and cleanup lifecycle. +- Split military flights into instance-owned state, ingestion, motion, rendering, tracking and query components. Share the existing aircraft calculations and give military classification an explicit source and cleanup lifecycle. Preserve known military identities even when the source has no position for them. - Split civil flights into instance-owned state, ingestion, motion, rendering, tracking and query components. Cancel enrichment on teardown and resolve model assets through the application. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b193fa8d9..04efe7509 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -15,7 +15,10 @@ behavior remains unchanged. metadata, motion, altitude, model-anchor, proximity and selection calculations. It also exports `createMilitaryRegistry`, an explicitly constructed owner for known military identities and active-layer transitions. Its optional background -poll consumes normalized records from the supplied source. Source replacement +poll consumes the optional `getIdentities` capability, falling back to normalized +positioned records when a source has no identity-only capability. The adsb.lol +adapter preserves known identities without requiring positions; those entries +still cannot enter the renderer. Source replacement and disposal abort pending work and clear retained identities; construction starts no network request. Both standalone aircraft layers use one registry. diff --git a/src/layers/aircraft/classification.js b/src/layers/aircraft/classification.js index 2db7ee9aa..09f136912 100644 --- a/src/layers/aircraft/classification.js +++ b/src/layers/aircraft/classification.js @@ -129,10 +129,17 @@ export function createMilitaryRegistry({ source, now = Date.now } = {}) { const signal = AbortSignal.any([owner.signal, AbortSignal.timeout(10000)]); _polling = true; try { - const snapshot = await requestSource.getSnapshot({}, { signal }); + const ids = + typeof requestSource.getIdentities === 'function' + ? await requestSource.getIdentities({}, { signal }) + : (await requestSource.getSnapshot({}, { signal })).records.map( + (record) => record.id, + ); signal.throwIfAborted(); if (lifetime !== owner) return; - registerMilitaryIcaos(snapshot.records.map((record) => record.id)); + if (!Array.isArray(ids)) + throw new TypeError('Expected aircraft identities'); + registerMilitaryIcaos(ids); } catch { // Keep the existing classification through source outages. } finally { diff --git a/src/layers/aircraft/classification.test.mjs b/src/layers/aircraft/classification.test.mjs index 0958fb10e..c0416b466 100644 --- a/src/layers/aircraft/classification.test.mjs +++ b/src/layers/aircraft/classification.test.mjs @@ -121,3 +121,21 @@ test('disposing a registry fed directly by its layer also clears classification' registry.dispose(); assert.equal(registry.isMilitaryIcao('abc123'), false); }); + +test('classification prefers an explicit identity capability over positioned observations', async () => { + const registry = createMilitaryRegistry({ + source: { + getSnapshot() { + assert.fail( + 'position snapshots must not replace an available identity capability', + ); + }, + async getIdentities() { + return ['abc123']; + }, + }, + }); + await registry.refreshMilitaryRegistryIfStale(); + assert.equal(registry.isMilitaryIcao('abc123'), true); + registry.dispose(); +}); diff --git a/src/sources/live/aircraft.js b/src/sources/live/aircraft.js index 6b05ac528..9534a5c3c 100644 --- a/src/sources/live/aircraft.js +++ b/src/sources/live/aircraft.js @@ -165,3 +165,16 @@ export function normalizeAircraftTrack( ]; }); } + +/** Known source identities are useful for classification even without positions. */ +export function readsbIdentities(payload) { + const admitted = admitRecords( + payload?.ac, + (row) => { + const id = cleanText(row?.hex).toLowerCase(); + return id ? { id } : null; + }, + 'aircraft identities', + ); + return admitted.records.map((record) => record.id); +} diff --git a/src/sources/live/contract.test.mjs b/src/sources/live/contract.test.mjs index 76cbae24c..671a2e5e4 100644 --- a/src/sources/live/contract.test.mjs +++ b/src/sources/live/contract.test.mjs @@ -271,3 +271,45 @@ test('out-of-range source epochs do not reach Date or globe time constructors', assert.equal(snapshot.records[0].positionTimeMs, null); assert.equal(snapshot.records[0].contactTimeMs, null); }); + +test('classification identities include positionless aircraft without admitting them to rendering', async () => { + const source = createAdsbLolSource({ + fetchImpl: async () => + response({ + ac: [ + { hex: ' ABC123 ' }, + { hex: 'abc123' }, + { hex: 'def456', lat: 30, lon: -97 }, + ], + }), + }); + assert.deepEqual(await source.getIdentities(), ['abc123', 'def456']); + const snapshot = await source.getSnapshot(); + assert.deepEqual( + snapshot.records.map((record) => record.id), + ['def456'], + ); + assert.equal(snapshot.complete, false); +}); + +test('identity lookup validates its response and honors body-parse cancellation', async () => { + const malformed = createAdsbLolSource({ + fetchImpl: async () => response({ ac: [{}] }), + }); + await assert.rejects(malformed.getIdentities(), /Malformed/); + const abort = new AbortController(); + const source = createAdsbLolSource({ + fetchImpl: async () => ({ + ok: true, + status: 200, + headers: new Headers(), + async json() { + abort.abort(); + return { ac: [{ hex: 'abc123' }] }; + }, + }), + }); + await assert.rejects(source.getIdentities({}, { signal: abort.signal }), { + name: 'AbortError', + }); +}); diff --git a/src/sources/live/index.js b/src/sources/live/index.js index d19890111..73f492500 100644 --- a/src/sources/live/index.js +++ b/src/sources/live/index.js @@ -5,6 +5,7 @@ export { normalizeAircraftTrack, openSkySnapshot, readsbSnapshot, + readsbIdentities, } from './aircraft.js'; export { normalizeVesselObservation, diff --git a/src/sources/live/standalone.js b/src/sources/live/standalone.js index 5e407f447..ecee2a405 100644 --- a/src/sources/live/standalone.js +++ b/src/sources/live/standalone.js @@ -9,6 +9,7 @@ import { normalizeAircraftTrack, openSkySnapshot, readsbSnapshot, + readsbIdentities, } from './aircraft.js'; import { normalizeVesselTrack, vesselSnapshot } from './vessels.js'; @@ -113,6 +114,16 @@ export function createAdsbLolSource({ } = {}) { return { label: 'adsb.lol', + async getIdentities(_query = {}, { signal } = {}) { + const { response, payload } = await readResponse( + fetchImpl, + '/api/adsblol/mil', + { signal }, + 'adsb.lol', + ); + if (!response.ok) throw httpError(response, 'adsb.lol'); + return readsbIdentities(payload); + }, async getSnapshot(_query = {}, { signal } = {}) { const { response, payload } = await readResponse( fetchImpl,