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

+128
View File
@@ -4104,6 +4104,134 @@ Existing catalog validation, category filters, tuning and voice playback behavio
remain unchanged. Audio connects directly to the broadcaster after an explicit
play action; the source does not relay or record streams.
## Local RTL-SDR and Local ADS-B
The Radio panel holds a Local RTL-SDR card below internet radio. One browser
WebUSB session (`src/sdr/controller.js`) drives a USB RTL-SDR in desktop Chrome
or Edge on localhost or HTTPS; nothing opens until CONNECT. Demodulation and
Mode S decoding run in a worker; FM audio plays through an audio worklet.
Local FM and internet radio are one listening surface: starting local FM stops
internet-radio playback, and starting internet radio stops local FM. ADS-B
reception does not interrupt internet radio.
- **Modes.** FM (87.5–108 MHz, tune, seek, volume) or ADS-B at 1090 MHz.
With a receiver open, selecting ADS-B enables the Local ADS-B layer;
selecting FM disables it.
Enabling the layer switches the receiver to ADS-B; disabling it leaves the
receiver mode unchanged.
- **Gain.** AUTO (tuner AGC) or a manual R820T step from 0.0 to 49.6 dB,
stored per mode in `gev:sdr:gain:v1`. Defaults: ADS-B 28.0 dB, FM AUTO.
Changes apply to the open receiver without reconnecting.
- **Receiver stats (ADS-B).** CRC-valid messages per second, aircraft heard,
aircraft with a fresh position and the IQ level.
- **Devices.** An already-authorized receiver opens without the WebUSB
picker. ADS-B prefers a device whose product name matches ADS-B or 1090 (the
1090 MHz channel of a dual-channel board); FM avoids ADS-B/UAT channels.
A device chosen in the picker is remembered per mode by vendor, product,
product name and serial in `gev:sdr:device:v1`. CHANGE DEVICE reopens the
picker. LOCATE uses browser location for receiver-relative CPR decoding.
The Local ADS-B layer (`local-adsb`, `src/layers/localAdsb/`) is off by default
and registered as local-only: it never enters share links or stored layer
state. Its aircraft get the public Flights treatment in magenta, alongside
the public Flights layer. Public Flights renders about one poll interval
(~30 s) behind for smooth interpolation while local aircraft are shown as
heard, so a local marker usually leads its public twin; both markers are drawn
on purpose:
- **Class.** Each aircraft is classified with `aircraftClass.js` from its
ADS-B emitter category (dump1090 strings: `A1` light, `A3` large, `A7`
rotorcraft; set A/B/C/D = type code 4/3/2/1) and, once known, its adsbdb
ICAO type. Silhouette and per-class scale come from `aircraftIcons.js`
(×0.8 on the ground), tinted magenta.
- **3D.** The DISPLAY rail's 3D toggle and its proximity/all mode apply: the
layer reads the Flights layer's `models3d` params and uses the same ceiling,
caps, add/keep radii, visible-first eligibility
(`src/data/modelEligibility.js`) and per-class GLB
(`src/layers/flights/modelSpec.js`), tinted magenta through the same MIX
colour blend Flights uses for military amber. Heights are barometric +
geoid N, as Flights renders a contact without a geometric altitude; a
grounded model rides the one-shot ground snap and belly offset.
- **Motion.** No display delay. The marker is extrapolated forward from the
newest fix with `motionModel.js` (arc extrapolation, path-derived course
blended over the reported track, rate-limited slew); a new fix is absorbed
by a correction that decays over 900 ms. Coasting continues while messages
arrive and stops 10 s after the last one (60 s at most).
- **Sanity filter.** For airborne positions the browser decoder prefers a
fresh even/odd pair, then a frame decoded relative to the aircraft's own
position (under 10 minutes old), then the receiver location. Surface
positions are stricter: a single surface frame decodes only against the
aircraft's own recent fix, and only when that fix is provably within 45 NM
(it could not have gone farther since at its speed limit); the receiver
location never decodes a lone surface frame and only picks the quadrant of
a surface pair. Without an existing suitable fix for that aircraft
(airborne or surface, provably within 45 NM), surface acquisition
requires an even/odd pair. Every fix
must be reachable from the last accepted one at 1.5 × reported ground
speed + 50 kt (1,000 kt without a speed, 350 kt for A1/A7/B1/B4) over the
elapsed time + 1 s, plus 500 m.
Refused fixes are counted (`positionsRejected` in the SDR state; the layer
applies the same check to merged feed records and reports the total as
`rejectedPositions` in its stats). Three refusals in a row re-anchor.
- **Trail.** A selected aircraft draws a magenta trail of the positions the
receiver heard (up to 10 minutes / 600 fixes, dropped with the aircraft),
with the tracked-flight trail look and a live head segment. No network
backfill.
- **Enrichment.** Only the selected aircraft (type + route) and aircraft
about to render as models (type) are looked up, through the Flights
source's cached adsbdb proxy with the same once-per-session, four-in-flight,
200 ms drip rules.
A marker drops once its newest position is 60 s old; an aircraft is forgotten
after 60 s without a message. Clicking a marker opens a readout card (ICAO,
callsign, class and category, adsbdb operator/type and a plausible route when
known, altitude, ground speed, track, vertical rate, position age, message
count, "Heard by your receiver"); markers are not camera-followed. Positioned
aircraft join detection boxes, labelled only with a decoded callsign.
Receivers publish one record shape from `src/sources/adsbRecords.js`
(`icao`, `callsign`, `category`, `onGround`, `lat`, `lon`, `altitudeFt`,
`groundSpeedKt`, `trackDeg`, `verticalRateFpm`, `lastPositionAt`,
`lastMessageAt`, `messageCount`, `rssiDbfs`, `band`, `source`). `band` is `1090` or `978`; `source` is
`webusb` (browser SDR) or `feed` (decoder feed).
`normalizeDump1090Aircraft(json, nowMs, { band })` maps a dump1090/readsb/
skyaware978 `aircraft.json` document into the same records. Voice
`set_layer_visibility` accepts `local-adsb` ("local ADS-B", "my receiver",
"my antenna").
**Decoder feeds.** The layer's second input is the server route
`GET /api/local-receivers/aircraft`
(`server/providers/local-receivers.js`), configured only by the
`LOCAL_RECEIVER_FEEDS` environment value (`band=url`, comma-separated, e.g.
`1090=http://localhost:8080/data/aircraft.json`). Every host must pass
`parseTapAddress` (loopback, RFC1918, `localhost`, `*.local`), the scheme must
be http(s) and the path must end in `aircraft.json`; an invalid entry is logged
at startup, reported `invalid` and never fetched. The route reads all feeds in
parallel (2 s timeout, redirects refused, 2 MB cap, single-flight with a 1 s
cache) and returns `{ configured, generatedAt, feeds: [{ band, label, status,
aircraft, ageMs }], records }`, where a feed is `live`, `stale` (its own `now`
is over 10 s old), `unreachable` or `invalid`. Labels name the band only;
addresses and upstream error text stay on the server. Unconfigured, it returns
`{ configured: false, feeds: [], records: [] }` without fetching.
The browser session (`src/layers/localAdsb/feeds.js`) polls the route every
second only while the layer is enabled and only while the route reports
`configured`; the Local RTL-SDR card makes one probe request at startup to show
its read-only "Decoder feeds: 1090 live · 978 live" line. Feed records are
rebased to the browser clock and kept up to 60 s after their last message.
`mergeLocalAdsbRecords` merges both inputs by ICAO, keeping the newest position
(tie: newest message) and the bands/sources heard in the last 60 s; the card's
receiver line names them ("Heard by your receiver · 1090 MHz + 978 MHz UAT ·
browser SDR + decoder feed"). Aircraft heard only on 978 MHz UAT draw with a
thin light-magenta ring. With feeds configured the row status reads "2 feeds
live · 14 heard" when every feed is live. While any input is producing (the
browser receiver streaming, or a feed live) the row stays nominal and names the
other feeds as a trailing note ("3 heard · USB 5.8 msg/s · feed 1090 stale"),
since a decoder stopped so the browser can take the dongle is not a fault. With
nothing producing, every readable feed stale reads STALE and anything else is
an error. Without feeds it keeps the WebUSB statuses. See
`docs/LOCAL-RECEIVERS.md`.
## Bundled geography and submarine cable components
Submarine cables use separate source, geometry, rendering, interaction and