hackrf_sweep ┐
hackrf_transfer├─ stdout / JSON / files ─▶ Flask + Socket.IO ─ Socket.IO ─▶ Browser
rtl_433 │ (one-job mutex, canvas + map
readsb-hackrf ┘ orchestration) + audio + log
- One device, one job. Every HackRF-claiming job (sweep, decode, capture,
ADS-B, radio, scanner, auto-scan) is mutually exclusive through one current
job on
jobs.JobBoard, guarded by a reentrant lock. The job carries its status label and whether it uses the device. IQ replay, IQ listen, and file decode read a file and use no device, so a device probe can still run. - Device poller probes
hackrf_infoevery 2.5 s to show live device status, and pauses while a job owns the HackRF so it does not contend with the running subprocess. - DSP in numpy/scipy. FM/NFM/AM demodulation, channel-power (
signal_dbfs), snap-to-peak tuning (tuning.py), and replay FFT frames are computed in the Python process. The sweep assembler rebuilds one coherent spectrum row perhackrf_sweepcycle. - Telemetry. Subprocess stderr (drops/overruns) plus server counters (sweeps computed vs emitted, subprocess deaths) feed the Diagnostics panel.
- Auth boundary.
auth.pyis the deep module behind who may drive the SDR: oneAuthPolicy(bind host + optionalAETHERSCOPE_TOKEN) decides whether auth is required, refuses a non-loopback bind without a token at startup, checks tokens in constant time, and issues the HttpOnly signed cookie the browser exchanges its token for. Loopback with no token keeps the historical no-auth behaviour. Enforcement has two seams over that one interface: a Flaskbefore_requestgate for HTTP (capture routes included) and the Socket.IOconnecthandler, which every socket handler sits behind. - Data layer.
store.pyis a small stdlib-sqlite3module (single file atAETHERSCOPE_DATA_DIR, default~/.local/share/aetherscope/) holding bookmarks, persisted UI settings, capture annotations, and a persistent contact inventory (ADS-B aircraft + rtl_433 ISM devices, with first/last seen, count, info, location). It is thread-safe (one connection, WAL, a lock), parameterized throughout, anduser_version-migrated. - GPS geotagging (optional, opt-in).
gps.pyreads a local gpsd over a raw socket (AETHERSCOPE_GPSD_HOST/PORT, default 127.0.0.1:2947), only while thegps_enabledtoggle is on (AETHERSCOPE_GPS=0hard-disables it). When a fresh fix exists, captures are stamped with ageolocationin their sidecar at the stored precision (gps_precision, default full -- the operator's own files keep their choice); a per-capture redaction action scrubs it. Position is pushed live asgps_status, but broadcast and stored precision are split at the emission seam ingps.py(broadcast_position/broadcast_geolocation): everygps_status, emitted capture geolocation, and ISM-contact location crosses at the broadcast precision (gps_broadcast_precision, default ~1 km, failing closed to coarse on unknown values), and full-precision broadcast is an explicit opt-in only. ADS-B contact locations are the aircraft's, not the receiver's, and are not coarsened. lat/lon never reach the logs. Capture timestamps stay on the system clock (the puck has no PPS). - SigMF export. Alongside each capture's
.iq+ UI sidecar,sigmf.pywrites a companion<base>.sigmf-meta(SigMF 1.0.0) referencing the.iqviacore:dataset, so recordings are portable to other SDR tools. Any geotag rides incore:geolocation(GeoJSON Point) and is scrubbed by redaction. - Contact sightings.
contacts.pyis the deep module behind a contact sighting: it derives aContactSighting(key, kind, label, ident, info, position) from a decoded rtl_433 event or an ADS-B aircraft dict, and owns dedup. Two keys are named explicitly, because they are genuinely different: the durableinventory_key(ism:{model}:{ident},adsb:{hex}-- persisted as the contacts primary key, byte-identical so stored history is not orphaned) and the ephemeralscan_key({model}/{id|hex|channel}, or hex alone for ADS-B -- one scan phase only, never persisted). Dedup likewise: a 2 s per-keySightingThrottle(anchored at the last passed sighting) gates live inventory writes, whileScanSightingsdedupes for a whole scan phase with no TTL (first rtl_433 event wins and counts repeats; latest ADS-B aircraft per hex wins). Destination is not this module's decision, but it is now decided (INTENT #15): live sightings go to the persistent inventory, and Auto-Scan sightings are promoted there too. Promotion is at phase end -- one upsert per deduped sighting, not per raw event and not a full-inventory rewrite (eachrecord_contactis a primary-key upsert) -- through the same app_record_contact->store.record_contactwrite path as a live find, wired as scan.py'son_sightingso scan.py stays store-free and noscan_keyis ever persisted. The ephemeral scan findings and their live display are unchanged. A promoted record carries no source field, so the inventory cannot tell a scan find from a live one.store.pykeeps only persistence. - Capture catalogue.
catalogue.pyis the deep module behind the capture list: typedIqCaptureEntry/AudioCaptureEntryrecords (two shapes — IQ has sample rate/format and a SigMF companion, audio has demod/audio rate and none), one SigMF-name derivation, one slug, and sidecar listing/lookup/delete/redact. Callers get a capture ready to replay, play, or decode; the raw sidecar dict crosses no seam. Itsto_dict()reproduces the sidecar exactly; the only server-added field in the enriched list is the derivedsigmf_name. Client payloads use its wire shape (to_wire_dict/wire_capture_dict), which drops the absolutepath/sidecar— the browser downloads/plays byname/sigmf_name— andresolve_capture_fileis the one name->path resolution for serving a file: flat capture names whose resolved file stays directly inside the captures directory. - Mode-config knowledge. Field lists have exactly one authoritative
home per side and no code generation joins them: Python dataclasses
(
SweepConfig,DecodeConfig,CaptureConfig,AdsbConfig,RadioConfig,ScanRadioConfig,ScanConfig, bounds in their constructors) define the server fields, and the hand-writtenread*/apply*ConfigToInputsmirrors in the per-modeaether-*.jsmodules define the browser fields — deliberately no build step, no deriving JS from Python, in keeping with the vendored/offline design. The one home for the correspondence between them is the static guardtests/test_mode_config_guard.py: it parses both sides and fails on drift, with intentional exceptions named there (decodelabeland radio gains/sample-rate are Python-only with no UI inputs; scan-radio's wire{freqs, demod, squelch_dbfs}is a named rename tofreqs_mhz, not a dataclass mirror). The demod vocabulary itself has one Python home,config.py(DEMODS, plus the bookmark-only subsetBOOKMARK_DEMODSthat also allowsNone), consumed byradio.py,app.py, andstore.py; the same guard checks the HTML demod controls against it. - Closing the loop. Live radio audio can be recorded to WAV
(
audio_record.py). A saved.iqcan be listened to (iq_playback.py: mix an offset within the band to baseband, resample to the demod rate, reuseradio.demodulate, stream real-time audio) or decoded (decode_file.py: resample to 1 MSps, convert cs8 to cu8, runrtl_433 -r). Both reuse existing paths (AudioWorklet, the Decode panel) and are device-free exclusive jobs.
Flask + Flask-SocketIO (threading async), numpy + scipy, a vanilla-JS canvas
front end with the Web Audio API (AudioWorklet) and Leaflet. Managed with uv.
Front-end dependencies are vendored locally and served under a strict
Content-Security-Policy, so the app shell works fully offline.
backend/: Flask app (app.py), the mode-config vocabulary the auth-boundary module (auth.py), the mode-config vocabulary (config.py: the demod set and its bookmark subset — field lists themselves stay with the per-mode dataclasses), the single job board (jobs.py), the one worker lifecycle (worker.py: start/stop/is_running, terminate->wait->kill, and completed-vs-died outcome mapping behind one interface), the mode-launch module (launch.py: one declarativeLaunchSpecper mode consumed by a singleLauncher, owning the started/finish toast/status derivation in one place — JobBoard still owns job state and WorkerLifecycle still owns each worker's lifecycle), per-mode subprocess wrappers (sdr.py,radio.py,decoders.py,adsb.py,capture.py,scan.py), the capture catalogue (catalogue.py), the contact-sighting module (contacts.py), offlinereplay.py, snap-to-peaktuning.py, SQLitestore.py, optional gpsd geotagginggps.py, SigMF exportsigmf.py, WAV audio recordingaudio_record.py, IQ-capture playbackiq_playback.pyand file decodedecode_file.py, ADS-B enrichmentadsb_enrich.py,telemetry.py,device.py.frontend/:templates/index.html,static/(canvas/UI JS, AudioWorklet, CSS, vendored deps). The browser JS is plain classic scripts with no build step:aether-core.js(socket, shared state, status/toast plumbing behind thewindow.Aetherseam) loads first, oneaether-<mode>.jsmodule per mode (sweep, decode, capture, adsb, scan, radio, inventory) registers its socket handlers and config hooks with the core, andaether-boot.jsloads last.deploy/: installers, launchd template,restart.sh,Caddyfile.example.tests/: synthetic, hardware-free unit tests (run withuv run python tests/test_*.py).