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

+1
View File
@@ -11,6 +11,7 @@ const CONTROL_LAYER_IDS = Object.freeze({
militaryAwarenessLayer: 'military-awareness',
militaryInstallationsLayer: 'military-installations',
rocketLaunchesLayer: 'rocket-launches',
localAdsbLayer: 'local-adsb',
});
/** Capture the ordered application instances and their serialization metadata. */
+2
View File
@@ -93,6 +93,7 @@ test('controls bind catalog instances rather than similarly named defaults', ()
'military-awareness',
'military-installations',
'rocket-launches',
'local-adsb',
];
const layers = ids.map((id) => ({ id }));
const catalog = createLayerCatalog(layers, metadata(layers));
@@ -100,6 +101,7 @@ test('controls bind catalog instances rather than similarly named defaults', ()
assert.equal(services.flightsLayer, layers[1]);
assert.equal(services.transitLayer, layers[7]);
assert.equal(services.aisLiveVesselsLayer, layers[8]);
assert.equal(services.localAdsbLayer, layers[12]);
assert.equal(new Set(Object.values(services)).size, layers.length);
assert.throws(
() => catalogControlServices(createLayerCatalog([], [])),
+22 -1
View File
@@ -19,6 +19,7 @@ import { createApplicationInstallations } from './layers/militaryInstallations.j
import { createApplicationSatellites } from './layers/satellites.js';
import { createApplicationLaunches } from './layers/rocketLaunches.js';
import { createApplicationAlpr } from './layers/alprCameras.js';
import { createApplicationLocalAdsb } from './layers/localAdsb.js';
import { createApplicationAwareness } from './layers/militaryAwareness.js';
import { createApplicationFirms } from './layers/firms.js';
import { createApplicationEarthquakes } from './layers/earthquakes.js';
@@ -54,6 +55,20 @@ const SOURCE_METHODS = Object.freeze({
cables: ['fetch'],
});
/**
* Hardware-local layers are registered like any other but never enter share
* links or stored layer state: another browser cannot have this receiver.
*/
export const LOCAL_ONLY_LAYER_METADATA = Object.freeze([
Object.freeze({ id: 'local-adsb', disposition: 'local-only' }),
]);
/** Serialization metadata for every layer the application catalog constructs. */
export const APPLICATION_LAYER_METADATA = Object.freeze([
...LAYER_STATE_REGISTRY,
...LOCAL_ONLY_LAYER_METADATA,
]);
/** Construct the current catalog without choosing any source provider.
* Scene engines remain page-owned; layers and classification have this app's lifetime.
* The manager owns layer destruction, while abort releases classification even if startup fails.
@@ -62,7 +77,7 @@ export function createApplicationCatalog({
surface,
sources,
signal,
metadata = LAYER_STATE_REGISTRY,
metadata = APPLICATION_LAYER_METADATA,
vesselOptions,
resolveAsset,
nepalBoundaryResolver,
@@ -120,6 +135,12 @@ export function createApplicationCatalog({
}),
flights,
military,
createApplicationLocalAdsb({
surface,
enrichment: sources.flights,
displayParams: () => flights.getParams(),
...(resolveAsset ? { resolveAsset } : {}),
}),
createApplicationEarthquakes({ source: sources.earthquakes }),
createApplicationAlpr({ surface, source: sources.alpr }),
satellites,
+7 -1
View File
@@ -39,7 +39,13 @@ test('catalogs construct distinct layers and classification from their supplied
signal: b.signal,
surface: fixtureSurface(b.signal),
});
assert.equal(first.layers.length, 27);
assert.equal(first.layers.length, 28);
assert.ok(first.get('local-adsb'), 'Local ADS-B is registered');
assert.deepEqual(
first.metadata.find(({ id }) => id === 'local-adsb'),
{ id: 'local-adsb', disposition: 'local-only' },
'the hardware-local layer is never serialized into links',
);
assert.notEqual(first.weatherClock, second.weatherClock);
await first.weatherClock.setTarget('2026-09-21T12:00:00.000Z');
assert.match(
+48
View File
@@ -0,0 +1,48 @@
import { createLocalAdsbLayer } from '../../layers/localAdsb/index.js';
import { createLocalReceiverFeeds } from '../../layers/localAdsb/feeds.js';
import { SdrController } from '../../sdr/controller.js';
import * as render from '../../renderGovernor.js';
import * as context from '../../data/contextStore.js';
import * as picking from '../../data/pickRegistry.js';
import * as trails from '../../data/trailRenderer.js';
import * as geoid from '../../data/geoid.js';
import { markDetectionSourcesChanged } from '../../data/detection.js';
import { refreshTrackedReadout } from '../../data/trackedReadout.js';
/**
* Construct the Local ADS-B layer, the browser RTL-SDR session it shares with
* the Radio panel, and the decoder-feed session. Construction opens no device
* and makes no request; the SDR starts only from an explicit Connect and the
* feeds are polled only while the layer is enabled.
*
* `displayParams` reads the public Flights layer's DISPLAY-rail 3D preference
* (`{ models3d, models3dMode }`) so local aircraft follow the same toggle;
* `enrichment` is the Flights source whose cached adsbdb proxy they share.
*/
export function createApplicationLocalAdsb({
receiver = new SdrController(),
feeds = createLocalReceiverFeeds(),
surface = null,
enrichment = null,
displayParams = null,
resolveAsset = (url) =>
`${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`,
} = {}) {
return createLocalAdsbLayer({
receiver,
feeds,
resolveAsset,
services: {
render,
context,
picking,
trails,
geoid,
groundSnap: surface?.groundSnap || null,
enrichment,
display: displayParams ? { getParams: displayParams } : null,
detection: { markSourcesChanged: markDetectionSourcesChanged },
overlays: { refreshReadout: refreshTrackedReadout },
},
});
}