AiroAlert Admin
Detection
Flight Tracking
Freq-Hopping
Low-SNR
Anti-Spoofing
SDR
Map
Help
What's New
Active Drones
SDR Receivers
/ online
Channels Scanned
Detections (24h)
total flights logged

Detection ThresholdsLive

Signal strength and identification confidence required to flag a detection.

Flight Tracking

Frequency-Hopping Detection

Dual-ZC Pair & CadenceLive

Low-SNR detection features (roadmap Sprints 4–7). All default off; the recommended path is shadow mode first — the feature logs what it would promote without changing behavior — then on once the soak confirms the false-alarm rate. Changes apply live.

Track-Before-Detect & Cued Stare

FH Mask Extensions & CP Comb

Multi-Receiver Fusion

Telemetry Spoofing Detection

Map Defaults

Branding

Customer / Site Name
Shown next to the title on the map and admin pages. Leave empty to hide.

Map Source

Map Tile Source

Alert Settings

Drone Alert Popup
Enabled
Alert Trigger

SDR Settings

SDR Receivers

Gain, band, and enabled changes require a receiver restart to take effect. Click Restart receivers after saving to apply.

+ Add Device
Wide covers 4 channels per tune (faster full-band scan) but 61.44 MSPS is at the PlutoSDR's edge — USRP preferred. Default Narrow.

Scan Frequency Channels

2.4 GHz
5.8 GHz
+ Add Channel

Drone Lists

Whitelist (0)
Empty
Blacklist (0)
Empty

API Keys

Each key is tied to a user. Send it as the X-API-Key header to access endpoints marked Restricted below. The full key is shown only once at creation — copy it before closing the dialog.

+ Create API Key

API Endpoints

Public endpoints are reachable without authentication. Restricted endpoints require a valid API key (or an admin session) on every request.

Method Path Description Visibility

Interface Languages

Users

Create User

Username
Password
Role
Company

Change My Password

Current Password
Required to verify identity
New Password
Min 8 chars, uppercase, lowercase, digit, special char

Two-Factor Authentication (2FA)

Loading…

Companies

Multi-tenant ownership for SDR receivers and users. Members of AdminCompany see every receiver and flight across all companies; members of regular companies see only their own SDRs plus any SDR flagged Public.

Create Company

Name is required; the remaining business requisites are optional. Use Edit on a company below to load it here.

Access Control

Restrict which client IPs can reach /map, /admin and their supporting APIs. Non-matching requests get a 404. Sensor ingest (/api/remote_id, /api/ingest/flight, drone-list import) and the Docker healthcheck are never gated. Leave the whitelist empty to disable the filter — localhost is always allowed regardless. When AiroAlert runs in Docker with default bridge networking + port forwarding, LAN clients are NAT'd to the bridge gateway IP; the gateway is auto-detected at startup and treated like loopback, so the whitelist below should hold your real LAN addresses, not the Docker subnet.

Remote Servers

Push detections to other AiroAlert servers: live Remote ID frames are forwarded as they arrive, and locally-detected flight records are synced in the background (full history first, then incrementally). For each peer, mint an API key on the receiving server's API tab and paste it here. Configure both servers to get two-way sync. If a peer is reachable from the internet, set its /api/remote_id endpoint to restricted on its API tab — pushes always authenticate with the key.

+ Add Server

Sync Settings

TDoA Geolocation

Time-Difference-of-Arrival fixes a drone's position from the same burst observed by 3+ receivers — this station plus two or more TDoA-enabled remote servers, each with a surveyed antenna position. Observations are exchanged automatically; a fix appears as a distinct marker on the map and is recorded to History, with a spoof alert when it diverges from the drone's broadcast GPS.

⚠ Experimental: accurate fixes need GPSDO-disciplined clocks across all receivers. On ordinary host/NTP clocks the result is coarse (clock skew of milliseconds maps to kilometres of error). Every fix is labelled with its HDOP, residual and whether the clock was disciplined — treat undisciplined fixes as an approximate bearing, not a precise point.

Status

This Station's Antenna Position

This receiver's surveyed position, used as one of the points in the solve. Leave at 0 to fall back to the map centre.

Method Parameters

Receivers

Peers from the Remote Servers tab. Give each a surveyed position and tick TDoA receiver to include it in the solve. You need at least two enabled receivers here (plus this station).

Recent Fixes

Audit Log

Timestamp Actor Action Target Detail IP
No log entries.

Technical Log

SDR connection-state events: connects, disconnects (with the last error message), changing capture errors, and admin enable/disable toggles. Heartbeats are suppressed — each row is a state change. 90-day retention.

Timestamp SDR URI Event Detail
No technical log entries.

IQ-File Replay

Stream a recorded IQ capture through the live decoder pipeline. Live SDR capture pauses for the duration of the replay so the consumer doesn't interleave bursts at different centre frequencies. Supported formats: .cf32 (interleaved float32 I/Q), .cs16 (interleaved int16 I/Q, scaled by 1/32768), and .sigmf-data + .sigmf-meta pairs.

Upload capture file
Server directory: · Max upload:  MB

For SigMF files, sample rate and centre frequency are read from the sidecar metadata when available and override the values entered above.

Available files
Empty

What's New

What's New since v2.5.0

Distributed edge→central snippet pipeline — remote Pluto+ sensors over cellular (v2.28.0). AiroAlert can now place remote PlutoSDR+ sensors running F5OEO tezuka_fw (a Maia-SDR FPGA spectrometer + MQTT) behind a metered cellular link without streaming raw IQ. Each edge sensor dwells on the 2.4 GHz DroneID band, uses the Maia power spectrum as a cheap trigger, and — on a hit — shifts the triggered channel to baseband, resamples to the decoder's fixed 30.72 MSPS, packs the short ~2–3 ms burst to 8-bit cs8 (tens of KB), and uploads just that snippet to the central server's new POST /api/ingest/snippet endpoint (X-API-Key gated). Central decodes the snippet back to complex64 and feeds it through the existing, unchanged DSP — one DroneID burst is self-contained, so ZC sync, OFDM demod, turbo decode, and track upsert are byte-for-byte the same path the live SDR uses, with no DSP or database-schema change. Optional MQTT telemetry (per-edge status / event topics) gives a live health view of each sensor; it is off by default and the broker ships behind the mqtt compose profile, so a plain docker compose up starts no new open service. This release ships the central ingest + decode worker, a runnable Python reference prototype of the on-device app (edge/pluto-tezuka/, validated end-to-end without hardware), and the production C-port skeleton for the Pluto+ A9 — on-hardware bring-up of the C edge app is the next phase.

Company business requisites (v2.26.0). The admin Companies tab now records each company's full business identity, not just a name. Eight optional fields land on every company — registration number, VAT number, legal address, phone, email, contact person, bank, and account (IBAN) — entered through a proper two-column add/edit form that replaces the old single-field window.prompt rename. Clicking Edit on a company loads it into the form; the list shows each company's filled-in requisites inline. Registration numbers are enforced unique across companies (non-empty values only, via a partial unique index), with a friendly "registration number already in use" message on a clash. The schema upgrade is idempotent (existing rows read as blank), and all 14 new interface strings are translated across the ten shipped languages. The AdminCompany row stays protected.

Server-to-server sync — new Remote Servers tab (v2.25.0). An admin can now register peer AiroAlert servers (by IP or domain) and have this server push its detections to them, so several sites converge on a shared picture. Two independently-toggleable modes per peer: push live frames forwards each Remote ID frame to the peer in real time as it arrives, and sync flights pushes locally-detected flight records on a periodic cycle — the first pass back-fills the entire local history progressively, then sends only what changed, with a persisted per-peer watermark so a restart resumes cleanly and points never duplicate. The receiving side is the existing ingest API, so any AiroAlert peer works with nothing more than a minted API key; a Test button probes the peer's /health before saving, and the stored key is never shown again (8-character hint only). Loops are structurally impossible: forwarded frames carry a sync: source label that the push hook refuses to re-forward, and flight sync only selects locally-detected (non-ext:) flights, so two servers pointed at each other don't ping-pong. Per-peer push status (last OK / last error, counters, last sync) is shown live.

Weak-signal decode, adaptive detection, and a UX / auth hardening round (v2.19.0 → v2.24.0). A sustained pass on the DJI DroneID decode chain and the operator surfaces. DSP: the matched-filter decimation in the burst detector is now anti-alias filtered (resample_poly) instead of a bare slice — adjacent-channel Wi-Fi that used to fold into the detection band and mask weak bursts no longer does (a real burst that dropped to corr 0.19 under a 15 dB-stronger neighbour now holds 0.67). An opt-in adaptive (CFAR-style) detection threshold tracks each channel's own correlation noise floor and gates at clip(median + margin), so quiet bands gain sensitivity while congested 2.4 GHz sheds false alarms (default off; [cfar] log lines show the effective per-channel threshold). The soft turbo-decode LLRs became noise-adaptive (the int8 magnitude now carries each burst's channel reliability rather than a fixed normalisation), decision-directed per-symbol residual phase tracking corrects the non-linear rotation the ZC-pilot step can't see (±29° wobble at 8 dB dropped from ~200 bit errors to zero), and the per-burst turbo-decode attempts now race concurrently through a persistent worker pool — the four QPSK rotations of each candidate run in parallel with first-CRC-success early exit, cutting weak-burst latency ~4× on multi-core hosts. A synthetic-burst regression harness now derisks every DSP change in CI. UI / auth: all twelve destructive admin confirmations moved from native window.confirm() to a themed, translated, awaitable modal; the operator map's 2 s polling gained AbortController timeouts, a consecutive-failure counter, and a prominent stale/offline banner so a dead backend is obvious; the admin loaders surface failures as toasts instead of failing silently; logout is now server-side (the session token is revoked, not just cleared from the browser); white/blacklist changes are audit-logged; and the infrastructure-sensitive IP-gate config keys are redacted from non-admin reads.

Higher detection rate: decoupled FH detector + opt-in 56 MHz wideband capture (v2.18.0). The frequency-hopping detector now runs on its own thread, so its spectrogram analysis no longer throttles the DJI DroneID decode path — on a single PlutoSDR a fringe-range drone that previously decoded about once every three minutes now lands several times a minute. A new per-receiver Capture bandwidth setting in the admin SDR tab adds an opt-in Wide mode (61.44 MSPS / ~56 MHz): the receiver grabs two DroneID channel-pairs per capture and the pipeline splits each grab into 30.72 MSPS sub-bands, so one tune covers up to four channels and the full band is swept in half as many hops. Wide is opt-in — the default Narrow (30.72 MSPS) keeps the proven capture path byte-for-byte — because 61.44 MSPS is at the PlutoSDR's edge; USRP is preferred for wideband. Scan defaults were also tuned for wide-area monitoring: frequency-hopping detection now defaults off (re-enable it in Detection for non-decodable-link presence — it no longer slows decoding), the per-channel dwell rose to 2 s, and the 5.8 GHz scan now covers the full six-channel grid for newer DJI craft (Mavic 3 / Air 3 / FPV). This release also fixes a bug where the admin SDR Model selector was silently dropped on save.

DJI channel-estimate smoothing for weak-signal bursts (v2.17.0). A new opt-in DSP stage cleans up the DJI DroneID channel estimate before symbol demapping. When DJI Channel Smoothing (the dji_channel_smooth_taps field in the Detection tab) is set above 0, the per-carrier channel response is truncated to its strongest N DFT taps — the channel impulse response is short, so keeping only the first 8–16 taps preserves the real multipath while discarding the per-carrier estimation noise that otherwise corrupts the QPSK demap at low SNR. The default is 0 (disabled — unchanged decode behaviour); raise it only if fringe-range DJI bursts are being detected but failing to decode. This release also brings the admin Help and What's New tabs current and completes a full translation catch-up — every interface string and both long-form tabs are now localised in all ten shipped languages.

Performance pass, internal cleanup, and a mobile / accessibility UI round (v2.16.0). Server-side, two map-feeding queries were rewritten to be set-based: the live-tracks query the operator map polls every 2 s no longer issues two queries per active drone (now two queries total — a DISTINCT ON latest-flight-per-serial select plus one batched point fetch), and the flight-summary query replaced four correlated subqueries per row with three set-based joins. The config read is now served from a short-TTL cache. Internally, the legacy SQLite/JSON flight-migration path (dead since every live deployment migrated long ago) was removed, and four cohesive modules were split out of the airoalert.py monolith — IP-gate logic, track math, the SDR driver classes, and the HTTP handler + route registry — with no change to behaviour or call sites. On the UI: the operator map and the admin page both gained a viewport meta tag and now reflow on phones (the map stacks above the sidebar; the admin save bar spans full width), SDR-form fields are properly <label>-associated, tab bars show :focus-visible rings, and KML-export errors use a non-blocking toast instead of a browser alert(). The 2 s map refresh no longer rebuilds every drone popup on each tick — popups now bind lazily and an open popup is only re-rendered when its underlying data actually changed, so reading a popup no longer fights the refresh (no more flicker, lost Details-toggle state, or text-selection loss), and the live model/serial filter dropdowns only rebuild their <option>s when the distinct set changes — an open dropdown no longer closes mid-selection.

IP-whitelist usability behind Docker, translation catch-up, registry-hostname fix (v2.15.2 → v2.15.6). The opt-in IP whitelist (v2.15.0) used to deny every LAN client when AiroAlert ran under the default docker-compose.yml: bridge networking NATs every client to the container's bridge-gateway IP, so a LAN-IP whitelist matched nothing unless the operator also whitelisted the entire bridge subnet (which defeated the point of the restriction). The bridge gateway is now auto-allowed, so a plain LAN-IP whitelist works as written inside the standard container setup. The admin Companies and Access Control tabs — which had shipped with English-only fallbacks — were fully translated across all nine non-English locales, and the documented Container Registry hostname was corrected to registry.git.radiospektrs.com so docker compose pull resolves on hosts that rely on public DNS.

Companies SDR chip row, opt-in IP whitelist, multi-model SDR via SoapySDR (v2.14.0 → v2.15.1). The capture engine is no longer PlutoSDR-only — a new SoapySDR backend runs alongside the proven pyadi-iio path, adding USRP as a fully capture-capable receiver and letting HackRF, RTL-SDR, AirSpy, and SDRplay be registered in the admin SDR tab as register-only devices (shown on the operator map but unable to sustain the 30.72 MSPS the DJI DroneID decoder needs). A new driver column on sdr_devices (idempotent migration; defaults to pluto), a Model selector in the admin SDR form, and a diagnose_receiver.py --driver flag complete the picture. The admin Companies tab now lists each company's receivers inline as chips under the existing user / SDR-count line — driver in the tooltip, register-only drivers styled with the warn colour and a · R suffix; tenancy is inherited from /api/admin/sdr_devices so regular-company editors only see chips on their own row. A new admin-only Access tab adds an opt-in IP-whitelist filter for /map, /admin, and their supporting APIs: non-matching clients get HTTP 404 (the surface isn't advertised), localhost is always allowed, sensor ingest (/api/remote_id, /api/ingest/flight, drone-list import) and the Docker healthcheck are never gated, and a separate Trusted proxies field controls when X-Forwarded-For is honoured. When XFF is honoured the gate walks the chain right-to-left, skipping further trusted-proxy hops, so a client-supplied prefix cannot be used to spoof a whitelisted source IP. Filter is off by default on upgrade. v2.14.1 + v2.14.2 are Docker-build hotfixes routing around a Debian-vs-pip numpy ABI clash on python:3.11-slim after apt-installing python3-soapysdr — the SoapySDR Python wrapper + compiled extension are now copied directly into pip's site-packages at image build, so /usr/lib/python3/dist-packages never lands on sys.path and pip's numpy is the only one Python ever sees. v2.15.1 is a follow-on security hotfix: v2.15.0 took the leftmost XFF token, which is attacker-controlled under the default append-mode XFF behaviour of nginx ($proxy_add_x_forwarded_for), HAProxy (option forwardfor), and Apache mod_proxy — a remote client could send X-Forwarded-For: <whitelisted-IP> through the proxy and walk past the filter. Right-to-left attribution defeats the bypass: the proxy's appended $remote_addr puts the real attacker on the right, where the gate now reads it. Deployments running v2.15.0 with the whitelist active behind a reverse proxy must take v2.15.1.

i18n catch-up (v2.13.7 + v2.13.8). The admin Help and What's New tabs were refreshed in English to cover multi-tenant Companies, the expanded Users model (roles, profile fields, search), and the v2.13.x SDR work (Technical Log, offline detection, build pipeline). The previous help_content translations for de / ee / es / fi / fr / lt / lv / sv / ua were ~6 versions stale (predated multi-tenant Companies entirely) and whatsnew_content had no translations at all in any non-English locale — every non-English speaker fell back to English for What's New. Both keys now carry the current content in every shipped locale; the HTML tag sequences were validated identical to the English source before injection so the airoalert_html_sanitizer allowlist and live Languages tab edits remain unaffected.

SDR Technical Log, dependable offline detection, build-pipeline fixes (v2.13.0 → v2.13.6). A new admin-only Technical Log tab (in the Hardware group) records every SDR connection-state event — connect, disconnect (with the last error message), changing capture errors, and admin enable/disable toggles — into a Postgres table with 90-day retention, URI/type/date filters, and CSV export. Heartbeats are suppressed so each row is a real state change. The operator-map Site Overview SDR row now shows a live offline-duration counter (Offline · 2h 15m), counted from the last successful connect, or from process start for a receiver that never came up. Receiver URIs (network IP / iio: context) are now hidden from view and editor roles on the operator map — admin-only, since the address is sensitive infrastructure detail. SDR offline detection was made dependable: a Pluto that dropped after connecting used to show Connected forever, because libiio's rx() has no network timeout and the capture thread wedged inside it — the app code is byte-identical to v2.11.0, the regression came from an unpinned, drifting pyadi-iio / pylibiio stack. It is now bounded by a libiio rx timeout (SDR_RX_TIMEOUT_MS, default 10 s) plus a pure-Python staleness watchdog (SDR_STALE_SEC, default 30 s), receivers paused by the RF-detection master switch do a real liveness probe instead of a blind heartbeat, and the SDR bindings are now pinnable via Docker build args to stop the drift. On the build side: GitLab CI bakes the real git describe version into the Docker image (it previously shipped a stale hardcoded 2.4.2), the Docker HEALTHCHECK now probes /health instead of / so it no longer floods the audit log with a page_view row every 30 s, and docker-compose.yml now pulls the CI-published image from the GitLab Container Registry (tag selectable via AIROALERT_IMAGE_TAG) so updating a deployment is a fast docker compose pull instead of a 15–25 min on-host build.

Multi-tenant Companies, RSSI on history, dedicated What's New tab (v2.12.0). Companies are now a first-class entity. A singleton AdminCompany sees every receiver, drone, and flight regardless of role; regular companies see only their own SDRs plus any SDR an admin has flagged Public (cross-company shared receivers). Each SDR carries an explicit visibility (public / restricted) and a company_id; flights are stamped with owner_company_id + visibility at INSERT and stay correctly partitioned even if the source SDR is later deleted or edited. Five new optional profile fields land on every user (email, phone, name, surname, position) along with a client-side user search box. Per-point RSSI is persisted end-to-end and surfaces in the flight-point popup on the operator map. The admin SDR edit form now opens inline directly under the row being edited instead of jumping to the bottom of the list. SDR markers on the operator map gained permanent name labels. The versioned release notes that previously lived at the top of the Help tab moved into this dedicated What's New tab; Help now opens directly on Overview. Schema migrations are idempotent and existing data backfills automatically (admins land in AdminCompany, everyone else in a seeded Default company).

Operator map is login-gated (v2.11.0). The /map page and the read APIs that back it (/api/tracks, /api/geojson, /api/live_tracks, /api/stats, /api/history, /api/sdr_locations, /api/lists, /api/scan_channels) now require a view, editor, or admin session. Anonymous visitors are redirected to /admin?next=/map and bounced back to the map after login. Service accounts keep read access via X-API-Key. The previous "always public" model assumed the whole service ran behind a network-perimeter gate; this brings policy enforcement inside the trust boundary.

Containerised release pipeline + idle-receiver cull fix (v2.10.0). GitLab CI now builds and pushes the AiroAlert Docker image to the project's container registry on every tag (Docker-in-Docker, with the entrypoint auto-configuring TLS so the explicit env-var hand-off no longer fights it). Stale tracks also cull correctly on the wall clock now — the pruner used to piggyback on the SDR queue drain, so a fully idle receiver left ghost tracks visible past their TTL; the housekeeping pass in detector_loop now runs regardless of _capture_q state.

RF master switch, dirty-aware save bar, fully translated drone popup (v2.9.0). Three operator-UX improvements. A new rf_detection_enabled master toggle at the top of the Detection settings tab pauses the SDR capture pipeline without stopping the service: receivers stay connected (re-enable is instant, no Restart receivers round-trip) but capture is skipped, the queue drains, and the detector loop idles. Section save bars on every admin settings tab are now hidden until at least one field in the section has actually changed — removes the dual-Save confusion that used to show both a section Save and an inline + Add Device Save at the same time. The live drone popup on the operator map (labels, section titles, state chips like In Air / Home Set, the Suspicious telemetry warning, the Details summary) is now translated in every supported locale — 37 new pop_* keys per locale × 10 locales.

2.4 GHz coverage for un-decodable DJI bursts (v2.8.0). Two coordinated changes to the DroneID decoder turn previously-lost 2.4 GHz traffic into actionable map coverage. In-band noise gate, universal. The symbol-3 FFT power-concentration check that previously only ran for low-correlation bursts (corr < 0.5) now applies at every correlation level. A real DJI burst concentrates ~0.9 of symbol-3 power in the 600 DroneID data carriers; a WiFi false-positive that happens to correlate with the ZC sequence concentrates ~0.02. The 0.40 threshold cleanly separates the two populations (empirically validated against 59 captured 2.4 GHz near-misses) and silently filters the WiFi alarms before they reach the turbo decoder or the RF-only fallback. Single-burst RF-only promotion. A real-DJI CRC-fail (the matched filter found a DroneID-shaped burst, the in-band gate passed it, but the turbo decoder rejected the bits — almost certainly OcuSync 3/4 firmware that the bundled remove_turbo cannot decode) now surfaces as a DJI DroneID @ X MHz (RF only) map marker on the first hit, rather than waiting for the 3-burst temporal-persistence gate the older RF-only path required. The 3-burst path stays as a fallback for ambiguous low-correlation cases. RF-Only Min Bursts in Detection is therefore now a fallback control, not the primary phantom suppressor — leave at 3 unless you see false markers, which the in-band gate should be preventing.

Multi-receiver band affinity & live receiver reconfig (v2.7.0). Three connected changes for two-Pluto deployments. Per-SDR band assignment. Each receiver registered in the SDR Devices tab now has a Band selector — Full (2.4 + 5.8), 2.4 GHz only, or 5.8 GHz only. Pinning one Pluto to 2.4 GHz and another to 5.8 GHz stops the two receivers from tune-fighting on the same channel and roughly doubles per-channel revisit rate. The setting is persisted in a new band column on sdr_devices (idempotent ALTER auto-runs on first boot). Restart receivers button. Gain, band, and name changes used to require a full docker compose restart airoalert to take effect, because the per-URI configuration was read once at thread start. The SDR tab now has a Restart receivers button next to the device list — saving an edit then clicking the button refreshes the in-memory config from DB and signals each producer thread to reconnect on its next iteration (~1 SDR poll cycle). No container restart needed. Source-tagged decode log. Per-capture summary and ++ DECODED lines now include the originating receiver's name in square brackets — e.g. [Pluto-58] 5776.5 MHz: 1 burst(s), max corr 0.821 (decoded). Trivial when one Pluto is present; essential when two are scanning in parallel. Gain precedence fix. The legacy RX_GAIN_DB environment variable used to silently override the per-SDR mode from the admin UI — an operator who set Slow Attack AGC in the DB still saw the env-var manual gain applied at the chip. Precedence is now explicit: rx_gain_mode from the DB is authoritative, and RX_GAIN_DB is only consulted as the default for manual mode when the dB field is blank. The shipped .env has been updated to comment out the legacy override.

Live-list bulk-select & login affordance (v2.6.3 → v2.6.7). Several follow-on UX passes on the operator-map sidebar. Bulk-select bar. Per-row selection tooltip rewritten to "Select to whitelist or blacklist" so the checkbox's purpose is obvious. The action bar moved from above the list to a position-fixed panel anchored to the bottom-right of the sidebar; it slides up + fades in when selection is non-empty, slides down on clear. Buttons restyled as outlined — ✓ Whitelist (green outline) and ✗ Blacklist (red outline) — with disabled state when no operator session is active (tooltip "Log in to manage lists"). The redundant in-bar "Not logged in" text was removed (the user-menu at the top of the sidebar is the single login affordance). The per-row trail-labels checkbox was removed because operators read it as a duplicate of the selection one. Section Select all. Each group heading carries a small right-aligned Select all chip that ticks every row in its section in one shot; renders as indeterminate when the selection is partial, fades when the operator isn't an editor/admin. Map markers. Real drones now carry a compact <N> m AGL ribbon under the disc, so the operator can read altitude without opening the popup. Login affordance. Top-right button renamed from "Sign in" to "Log in" (operators were misreading Sign in as Sign up). A long-standing CSS bug that ghost-rendered the avatar pill's "?" and "—" placeholders in the logged-out sidebar was fixed.

Navigation + Live-row polish (v2.6.1 → v2.6.2). Help is no longer a sub-tab under Admin; it's now a fourth top-level button in the group nav, alongside Detection / Hardware / Admin. Help is open to every role — viewer, editor, admin — so nesting it under the admin-only management surface was the wrong mental model. The Live-list rows in the operator map regained a compact SEEN entry (now / 5s / 3m / 2h) in the meta line: it stays muted while the drone is actively transmitting and turns red the moment the row's stale threshold (> 30 s) trips, reinforcing the dimmed left-edge accent at the opposite end of the row.

Operator-UX overhaul (v2.6.0). Two big passes — one on the map sidebar, one on the admin settings page. Map: each Live-list row now has a leading-edge selection checkbox; ticking one or more drones reveals a contextual Whitelist / Blacklist / Clear bar above the list so operators can list drones in batches. A new avatar pill + dropdown at the top of the right sidebar holds Admin panel + Logout (replaces the small text link buried at the bottom of the Lists tab). Site Overview Alerts: 0 / Blacklist hits: 0 / Whitelist hits: 0 no longer render in red — zero means no threat, so the colour kicks in only when the count is non-zero (red for alerts/blacklist, green for whitelist matches). Disabled SDR receivers switched from a grey dot to amber + ⚠, so coverage gaps stop hiding in the panel. Long drone names ("OcuSync 3 (~20 MHz) @ 2464.4 MHz") wrap to two lines in the sidebar and the popup title instead of getting ellipsis-truncated. Popup card style and section labels were aligned with the Site Overview panel so the two surfaces read as the same component family. Admin: the 13-tab flat nav is now grouped into three top-level sections — Detection / Hardware / Admin — with sub-tabs revealed on demand, so the strip no longer overflows at smaller viewports. Settings tabs gained an unsaved-changes guard: trying to switch tabs with dirty fields prompts "You have unsaved changes. Leave without saving?" (and the browser-native unload dialog catches close / reload). Save buttons are now consistently placed in a fixed bottom-right bar on every settings tab (Detection / Flight Tracking / Freq-Hopping / Anti-Spoofing / Map / SDR / Frequencies). Numeric config fields no longer follow the OS locale — the EN UI used to render "0.22" as "0,22" on a Latvian / German / French host; now they always use period as the decimal separator, and scientific notation (1e-05) expands to plain decimal (0.00001). The Users tab gained a role-change confirmation step (a dropdown change now shows Save role / Cancel chips and pops "Change <user>'s role from <old> to <new>?" before calling the API). 2FA badge colours were flipped so accounts with 2FA read green ("hardened") and accounts without read amber ("action needed"). Edit / Delete buttons across SDR / Frequencies / Users / API keys / Replay were unified to outlined-secondary / outlined-red so a Delete in one tab looks like a Delete in another.

Map layout + Help readability (v2.5.8 → v2.5.9). The Site Overview panel has been relocated from top-left to top-right of the map so it no longer overlaps Leaflet's zoom controls. The Help-tab body text was previously hardcoded in a dark-theme slate that gave low contrast on light theme; it now uses the theme-aware text colour and reads cleanly on both themes.

i18n catch-up (v2.5.2 → v2.5.7). The admin Branding card, Two-Factor Authentication card, Trusted Devices card, and the map Site Overview panel + SDR status pills (Connected / Offline / Disabled / "none configured") are now translated in every language. Added the long-missing tab_replay key so the admin Replay tab is no longer English-only.

Visual fixes (v2.5.3 → v2.5.6). The admin tab bar wraps to a second row in long-label locales instead of cropping the Help tab off the right edge. The card heading-vs-subtitle layout no longer overlaps in browsers supporting :has(). The Channels Scanned KPI now reads correctly even when /api/scan_channels is flipped to restricted in the API tab.

Map legend rewritten to match what's actually drawn (v2.5.2). A new Threat Tier (dot fill) section explains the green/yellow/red dot inside each marker; the drone-status row shows a circular ring with the real 🛸 emoji; the Signal-source row shows the 📡 emoji rendered for RF/FH detections; the Flight-trails section shows historical replay as a dashed grey rule. The map itself now renders historical-replay polylines + mid-point markers in neutral grey (start/end remain green/red so direction of travel still reads).

Map sidebar polish (v2.5.1). The Lists tab is now a full inline drone-list editor — whitelist, blacklist, filter, and remove drones directly from the operator map without bouncing to /admin#lists. The Site Overview panel gained a Blacklist hits row alongside Whitelist hits. Sidebar tab labels were shortened to Live / History / Lists, with locale-aware short forms so the row no longer overflows in long-label languages.

System Help

Overview

AiroAlert is a real-time drone detection and identification system. It passively monitors the radio spectrum for DJI Drone ID transmissions, ASTM F3411 Remote ID broadcasts, Bluetooth LE advertisements, and frequency-hopping RF emissions from common UAV control links. Detected drones are decoded, geolocated, and displayed live on the map.

Map Page

The map auto-refreshes every 2 seconds. The floating header (top-right, just left of the sidebar) is the Site Overview panel — active drones, whitelist hits, blacklist hits, alerts (red when non-zero), and a live Last update counter. Leaflet's zoom buttons sit at top-left of the map. The right sidebar opens with a user menu at the top (avatar pill + username + role; dropdown holds Admin panel for editors/admins and Logout) — signed-out visitors see a Log in button instead, which routes to the inline login form in the Lists tab. Below the user menu sit three tabs:

  • Live — currently transmitting drones, sorted by recency, split into two group headers: Alerts (blacklisted or high-threat) and Live. Each card carries a status pill (LIVE / ALERT / BLACKLIST) on the right and a coloured left-edge accent (red for alerts, green for live, dimmed for stale >30 s). The badge on the tab title mirrors the active count even when the tab is in the background.
  • History — past flights with date-range, model, and serial filters. The "X of N match" counter shows when filters are narrowing the set; CSV and JSON buttons export the filtered set (ignoring pagination — pagination is just a screen detail). Each row also has a small KML button (under the show point labels checkbox) that downloads the single flight as airoalert_flight_<serial>_<timestamp>.kml: a LineString with absolute altitudes (path floats above the terrain at the recorded MSL, not draped on the ground), Start (green) and End (red) placemarks, and a <gx:Track> with paired <when> + <gx:coord> so Google Earth's time slider plays the flight back at recorded speed. Open with Google Earth Pro (double-click) or Google Earth Web (drag-drop). The tab badge shows the total flight count.
  • Lists — drone Whitelist / Blacklist / Remove management. Click a drone in the Active tab to select it, switch to Lists, and the action buttons enable for that serial. Editing requires editor or admin role; anonymous viewers see the read-only counts plus a login form. The tab badge shows the combined whitelist + blacklist entry count.

Live-list rows are laid out as: a leading-edge selection checkbox (tooltip "Select to whitelist or blacklist"; drives the bulk-action bar pinned to the bottom of the sidebar — tick one or more rows and an outlined ✓ Whitelist / ✗ Blacklist pair slides up with the selected count), a coloured left-edge accent strip (green for live, red for alerts/blacklist, dimmed once the row goes stale), the drone name with a status pill (LIVE / ALERT / BLACKLIST) on the right, and a monospace meta line below with SEEN (compact age — now / 5s / 3m / 2h), SN (short serial), RSSI (dBm or dBFS), and distance from the primary receiver. Each group heading (Alerts · N / Live · N) carries a small right-aligned Select all affordance that ticks every row in its section in one shot (indeterminate while the selection is partial). A row whose drone hasn't been heard for over 30 seconds dims the left-edge accent and turns the SEEN value red.

Markers on the map render as a colored disc inside a colored ring with an arrow above it: the disc fill is the threat tier, the ring is the list status (white-/black-/un-listed), the emoji on the disc identifies the signal source (🛸 = decoded drone, 📡 = FH/RF-only blob, 🧑 / 🏠 = pilot / home pseudo-marker), and the arrow rotates by the drone's GPS course (or body yaw if no course is available). Real drones that report height above ground also carry a small <N> m AGL ribbon under the disc, so the operator can read altitude at a glance without opening the popup; the ribbon is omitted when the drone has no AGL value, and on pilot / home / FH pseudo-markers.

Marker popup is split into Position & Motion, optional Pilot/Home, Signal, state chips (In Air / Motor / GPS / Home Set / UUID Set), and a collapsible Details panel for UUID, version, sequence, correlation, etc. The Details open/closed state survives the 2-second auto-refresh per drone.

Map source / offline tiles: The map base layer can be switched between OpenStreetMap, CARTO Dark/Light, Esri Satellite, a fully-offline local server (the tileserver compose service, populated by scripts/fetch-tiles.sh), or a custom tile URL. The local option avoids leaking the operator's viewport coordinates to third-party CDNs and works air-gapped — required for classified deployments. Configure in Map → Map Tile Source in the admin UI.

Customer / site label: A short free-text label rendered as a pill next to the title in both the operator map header and the admin topbar. Useful when a single team operates several deployments and needs to tell them apart at a glance (e.g. ACME Airfield, Riga HQ, Demo Rig). Set in Map → Branding → Customer / Site Name; clearing the field hides the pill on both pages. The value is served via /api/public_config, so it appears for any user who can load the page.

Threat Levels

The marker disc and live-list dot fill encode an at-a-glance threat tier independent of the list-status border:

  • Low (green): whitelisted (always wins), or known/grounded with GPS lock.
  • Medium (yellow): in-air but not yet meeting "high" criteria, or no GPS lock yet.
  • High (red): blacklisted, suspicious telemetry detected, or in-air and moving faster than 5 m/s.

Whitelist short-circuits the rest — your own asset is never marked critical.

Keyboard Shortcuts

Active on the Map page when no form input has focus:

  • / — jump to Flight History tab and focus the Serial filter
  • Esc — close any open marker popup
  • / — step through the Live list, opening each drone's popup (wraps at edges)
  • 1 / 2 / 3 — switch to Live / History / Lists tab

Users & Roles

Users are created and managed in the admin Users tab (admin role only). Every user belongs to exactly one Company (see below) and carries one of three roles that govern access:

  • admin — full access: change settings, manage users and companies, mint API keys, and read the audit log and the SDR Technical Log.
  • editor — can manage drone whitelists/blacklists, translations, scan frequencies, and the SDR device registry. No access to users, companies, system settings, or the logs.
  • view — read-only access to the operator map and admin dashboards. Can change their own password and enrol 2FA, nothing else.

Profile fields. Beyond the username, each account carries five optional profile fields — email, phone, name, surname, and position. These are informational only (an operator contact roster) and have no effect on access control. The Users tab has a client-side search box that filters the list live across the username and every profile field, so a large roster stays navigable.

Role changes are guarded: choosing a new role from the dropdown reveals Save role / Cancel chips and prompts "Change <user>'s role from <old> to <new>?" before the change is applied. Every create / delete / role-change / password-reset / 2FA-reset action is written to the audit log with actor, timestamp, and IP.

Companies & Multi-Tenancy

AiroAlert is multi-tenant: every user, SDR receiver, and flight belongs to a Company. Companies are created and managed in the admin Companies tab (admin role only). Tenancy decides who can see which receivers and which flights.

  • AdminCompany — a singleton company flagged is_admin_company. Its members see every receiver, drone, and flight across the whole system, regardless of which company owns them. There is exactly one AdminCompany — the schema enforces uniqueness with a partial unique index. On upgrade from a pre-multi-tenant release, existing admin-role users are migrated into it automatically.
  • Regular companies — members see only their own company's SDRs and flights, plus any SDR an admin has explicitly flagged Public. On upgrade, all non-admin users are placed in a seeded Default company; reassign them in the Users tab as needed.

SDR ownership & visibility. Each SDR record carries a company_id (the owning company) and a visibilityrestricted (default: only the owning company plus AdminCompany see it) or public (every company sees it; use this for shared-coverage receivers). Both are set in the SDR edit form. A regular-company editor can only create or edit SDRs within their own company; the API refuses cross-company writes.

Flight tenancy. When the detector records a flight it stamps owner_company_id and visibility onto the flight row at INSERT time, copied from the source SDR. This is a one-time snapshot: if the SDR is later edited, reassigned to another company, or deleted, historic flights stay correctly partitioned and never leak across tenants. A flight from an SDR with no known owner is treated as restricted to AdminCompany.

SDR chip row (v2.15.0+). Each company row on the Companies tab lists its receivers inline as chips under the user / SDR-count line. The driver model is shown in the tooltip; register-only drivers (HackRF / RTL-SDR / AirSpy / SDRplay — the ones that can't sustain the 30.72 MSPS the DJI DroneID decoder needs) are rendered in the warn colour with a · R suffix so the coverage picture is legible at a glance. The chip data comes from the same tenancy-filtered /api/admin/sdr_devices endpoint that drives the SDR tab, so a regular-company editor only sees chips on their own row.

Company requisites (v2.26.0+). Beyond name and notes, each company can record its business-identity details — registration number, VAT number, legal address, phone, email, contact person, bank, and account (IBAN) — all optional free text, shown inline under the company in the list. Use the add/edit form at the bottom of the Companies tab: clicking Edit on a company loads it into the form (the old prompt-based rename is gone), and Add Company creates a new one. Registration numbers are enforced unique across companies (non-empty values only — a duplicate is rejected with "registration number already in use"); the other fields carry no uniqueness constraint. The AdminCompany row stays protected and cannot be edited or deleted. Only the admin role can create, edit, or delete companies.

SDR Devices

Register each physical SDR receiver in the SDR tab. Each record stores the device URI (used by the detection service), its geographic location in ASL/AGL metres, a human-readable name, RX gain mode (Slow Attack AGC / Fast Attack AGC / Manual), and an optional band restriction. Multiple receivers listed in the PLUTO_URIS environment variable (or the sdr_devices DB table) are handled in parallel by the detection pipeline. The first enabled receiver with a valid lat/lon is used as the reference point for the live-list bearing/distance column.

Multi-model support (v2.14.0+). The Model selector in the SDR edit form picks which SDR family the receiver is — PlutoSDR, USRP, HackRF, RTL-SDR, AirSpy, or SDRplay. PlutoSDR keeps the dedicated pyadi-iio / libiio path it has always used; the other five route through a SoapySDR backend, with the URI carrying SoapySDR device-args verbatim (e.g. driver=uhd or driver=uhd,addr=192.168.10.2). Only PlutoSDR and USRP can sustain the 30.72 MSPS the DJI DroneID OFDM decoder requires — HackRF / RTL-SDR / AirSpy / SDRplay are register-only: they show on the SDR tab and operator map with a clear "register-only" badge, but no capture thread is spawned for them. The admin SDR form previews the per-model URI format as you change the Model selector. Use diagnose_receiver.py --driver <model> --uri <...> from a terminal on the host to run the four-step receiver self-test (connect, tune, capture, signal-presence check) against any of the supported models without booting the full service.

Band affinity (v2.7.0+). The Band selector pins a receiver to a subset of the scan plan. Full (2.4 + 5.8) is the default — the producer cycles through every tune in SCAN_PLAN_24 + SCAN_PLAN_58. 2.4 GHz only restricts to SCAN_PLAN_24 (three tunes covering 2399.5–2474.5 MHz), 5.8 GHz only restricts to SCAN_PLAN_58 (three or six tunes depending on ENABLE_5GHZ_WIDE). With two Plutos, assign one to each band so the cycle time per channel halves. Single-Pluto setups should leave it at Full.

Capture bandwidth (v2.18.0+). The Capture bandwidth selector sets the receiver's sample rate. Narrow (30.72 MSPS) is the default and covers two DroneID channels per tune via a ±7.5 MHz DSP shift. Wide (61.44 MSPS / ~56 MHz) captures two channel-pairs at once and the pipeline splits each grab into 30.72 MSPS sub-bands, so one tune covers up to four channels — halving the hops needed to sweep the band and roughly doubling how often any given channel is revisited. Wide is opt-in because 61.44 MSPS is at the AD9361/PlutoSDR's edge (USB-2 throughput-bound); a USRP is the better wideband platform. Decoding is unaffected either way — the detector and turbo decoder always see 30.72 MSPS. Apply a change with Restart receivers.

RX gain mode. Slow Attack AGC is the right default — the AD9361's slow-attack AGC holds gain through a 640 µs DroneID burst and adapts on ~ms timescales, handling the 1 m–5 km path-loss spread automatically. Fast Attack AGC reacts inside a burst and is wrong for sparse signals on 2.4 GHz (it clamps gain down in response to ambient WiFi and never recovers in time for the drone burst). Manual takes a fixed gain (0–71 dB) and is useful for bench testing at a known distance: roughly 10 dB at 1 m, 25–30 dB at 20 m, 40 dB at 100 m, 60 dB at 1 km, 70 dB at 5 km. Per-SDR DB values are authoritative; the legacy RX_GAIN_DB environment variable is only consulted as the default for Manual mode when the dB field is blank.

Restart receivers (v2.7.0+). Gain, band, name, and enabled-flag changes are picked up on a receiver reconnect, not on the in-flight capture cycle. After saving an edit, click the Restart receivers button at the top of the SDR list — the admin route refreshes the in-memory config from DB and signals each producer thread to reconnect on its next iteration (status pill reads Reconnecting receivers for ~4 s). Round-trip is typically one SDR poll cycle. Adding or deleting a Pluto still requires a container restart, since producer threads are spawned at startup per URI; everything else is live.

Ownership & visibility (v2.12.0+). Every SDR record carries a Company (its owning tenant) and a Visibilityrestricted (only the owning company and AdminCompany see it and its flights) or public (every company sees it; use this for shared-coverage receivers). See the Companies & Multi-Tenancy section above. The receiver URI (network IP / iio: context string) is treated as sensitive infrastructure detail: it is shown in the operator-map SDR panel and marker popups only to admin-role users — view and editor users see the receiver name, status, and location but not its address.

Connection status & offline detection. The operator-map Site Overview panel shows one row per receiver: green = connected, red = offline, amber + ⚠ = disabled. An offline receiver also shows how long it has been down — Offline · 5m, Offline · 2h 15m, Offline · 3d 4h — counted from its last successful connect, or from process start for a receiver that never came up. Detection is robust against a silent link drop: libiio's rx() has no network timeout, so a Pluto that vanishes without a TCP reset (cable yanked, PoE reboot, Wi-Fi gap) could otherwise wedge the capture thread and leave the receiver showing Connected forever. Two safeguards prevent that — a libiio rx timeout (SDR_RX_TIMEOUT_MS, default 10 000 ms) that turns a silent drop into a normal reconnect, and a staleness watchdog (SDR_STALE_SEC, default 30 s) that reports a wedged receiver offline regardless. Both are tunable via environment variables for high-latency links.

Technical Log (v2.13.0+). The admin Hardware → Technical Log tab (admin-role only) records every SDR connection-state event into a Postgres table with 90-day retention: connect, disconnect (with the last error message), error (when a reconnect attempt surfaces a new error string), and enabled / disabled (when an admin toggles a receiver). Heartbeats are suppressed, so each row is a real state change. The tab offers URI/name, event-type, and date filters plus a CSV export, mirroring the audit log. Use it to answer "when did this receiver drop, and why" without reading container logs.

Alerts

When the alert popup is enabled (Map tab → Alert Settings), the map page plays a short audible beep and shows a popup whenever a new drone matching the configured trigger condition appears. The popup must be clicked to dismiss. The trigger can be set to any drone, blacklisted only, or unknown or blacklisted. The feature can be toggled per-session on the map page itself.

Languages

Ten interface languages ship by default (en, lv, de, ee, fr, lt, es, fi, sv, ua). Editors can adjust translations live in the Languages tab. Per-config-row labels and descriptions on this admin page are also translatable via the cfg_label_<key> / cfg_desc_<key> entries — labels fall back to the auto-generated Title-Case form and descriptions fall back to the English schema text when a key is missing in a given locale.

Security

All passwords must be at least 8 characters and contain uppercase, lowercase, digit, and special character. Sessions expire after 8 hours. All login attempts, configuration changes, and user management actions are recorded in the audit log with actor, timestamp, and IP address.

Two-Factor Authentication (2FA)

TOTP-based 2FA (RFC 6238) — works with any standards-compliant authenticator (Google Authenticator, Authy, 1Password, Aegis, Bitwarden, ...). Enrol from Account → Two-Factor Authentication: scan the QR, type the 6-digit code, save the printable backup codes (shown once).

  • Backup codes — 10 single-use xxxx-xxxx-xxxx codes generated at enrolment. Type any one in place of a TOTP if you've lost your authenticator. Regenerate the full set from the Security card; old codes are invalidated atomically.
  • Trusted devices ("Remember this browser for 30 days") — optional checkbox on the verify step. The cookie is stored only as a SHA-256 hash (a leaked DB cannot replay it) with HttpOnly; SameSite=Strict — and Secure when the request came in over HTTPS. List + revoke per-device or revoke-all from the Security card. Wiped on password change, 2FA deactivate, and admin reset.
  • Forced 2FA for admin role — flip Config → enforce_mfa_for_admins to true. Admin users without 2FA hit a one-shot enrolment branch on next login and exchange the enrolment token for a real session in the same response.
  • Verify rate limit — 5 wrong codes from the same (IP, username) within 15 minutes installs a 15-minute lockout (HTTP 429 with retry_after_seconds). Successful verification clears the counter.
  • Lockout recovery — if a user loses both their authenticator AND their backup codes, an admin clicks Reset 2FA next to the user in the Users tab. The reset wipes the secret, backup codes, and trusted devices, revokes existing sessions, and is audited.

API Authentication

Three authentication modes coexist:

  • Anonymous — works on endpoints whose visibility is set to public in the API tab. This is the default for the data-read endpoints listed below.
  • API key — pass X-API-Key: aas_<hex> on every request. Mint keys in the admin API tab; each key is tied to a user account and the full key is shown exactly once at creation. Revoke disables the key immediately; delete removes the row entirely.
  • Admin session — the Authorization: Bearer <token> header issued by POST /api/admin/login. Required for everything under /api/admin/ and accepted as a fallback on restricted public endpoints so logged-in operators don't need a personal key to browse them.

The API tab lets an admin flip any data-read endpoint between public and restricted. Restricted endpoints reject anonymous requests with 401 {"error":"API key required"}; an API key or an admin Bearer token gets through.

API Endpoints — read

Default visibility is public; flip in the API tab to require auth.

  • GET /api/geojson — live drone positions as GeoJSON
  • GET /api/stats — system statistics
  • GET /api/history — flight history summary (includes source per row: local or the username that ingested it)
  • GET /api/history/<flight_id> — single flight with all its points
  • GET /api/live_tracks — active track points for the live trail overlay
  • GET /api/tracks — full in-memory track dump (debug)
  • GET /api/sdr_locations — registered SDR receiver positions + connection state
  • GET /api/scan_channels — scan plan, read-only
  • GET /api/public_config — map tile source, alert settings, git version
  • GET /api/translations?lang=<code> — UI translation dictionary for the given locale
  • GET /api/lists — whitelist / blacklist entries

API Endpoints — ingest

  • POST /api/remote_id — ingest Remote ID frames from an external sniffer (BLE / Wi-Fi NAN / BT5 LR). Body: {"messages":["<hex>",...],"source":"ble"|"wifi-beacon"|...}.
  • POST /api/ingest/flightalways requires X-API-Key; an admin Bearer token is not accepted here, because the flights.source column is set from the key's owning username. Used by remote detectors to push live updates and historical bulk dumps into the same DB.

Flight Ingestion Format

Body shape (all numeric fields tolerate strings):

{
  "flight_id": "abc-123",            // optional; auto-generated if omitted
  "serial":    "DJI-XXXX-001",       // required, 1..128 chars
  "model":     "Mavic 3",            // optional
  "first_seen": 1730000000,          // optional unix-seconds; falls back to min(point.ts)
  "last_seen":  1730000600,          // optional unix-seconds; falls back to max(point.ts)
  "points": [
    {"ts":1730000000,"lat":56.95,"lon":24.10,"alt":120,
     "height":100,"speed":12.3,"heading":45,"v_up":0.5,"yaw":50,
     "freq_mhz":2437,"corr":0.85,
     "pilot_lat":56.94,"pilot_lon":24.09,
     "home_lat":56.94,"home_lon":24.09}
  ]
}

Per-point required fields: ts, lat, lon. Everything else is optional. ts must be a unix-seconds value between 2000-01-01 and tomorrow — millisecond timestamps are rejected so a misconfigured client can't pollute the index.

Live vs. Historical

Both modes use the same endpoint. The server upserts the flight row and de-duplicates points by (flight_id, ts), so:

  • Live — call once per tick with one or a few fresh points. Repeating the call is harmless: first_seen stays the minimum, last_seen stays the maximum, max_alt / max_height only grow, and points already present are skipped.
  • Historical — call once per past flight with up to 5000 points. Re-running the same dump produces zero inserts on the second call.

Successful responses report both received_points (in the body you sent) and inserted_points (after dedup), so a client can confirm what actually landed.

Ingestion Limits & Rules

  • Body size — 4 MiB max per request. Larger historical dumps must be chunked.
  • Points per call — 5000 max. Calls exceeding this are rejected wholesale (no partial insert).
  • All-or-nothing validation — every point is validated before any row is written. A single bad ts / lat / lon rejects the whole call with 400; the DB is never left in a partial state.
  • Source attributionflights.source is set on first insert from the API key's owning username and is never rewritten. Re-posting an existing flight_id from a different source returns 400 "flight_id owned by a different source".
  • flight_id namespacing — caller-supplied ids are auto-prefixed ext:<source>:<id> so they cannot collide with locally-generated <serial>_<ts> ids. Omit flight_id entirely and the server derives a deterministic one from (source, serial, first_seen), which keeps repeat calls idempotent.
  • Audit trail — every successful ingest writes a flight_ingest row to the audit log with the API-key owner as the actor and <inserted>/<received> in the detail column.
  • VisibilityPOST /api/ingest/flight is not listed in the API tab and cannot be flipped to public; the X-API-Key requirement is hard-coded.

Remote Servers — Server-to-Server Sync (v2.25.0+)

The admin Remote Servers tab (admin-role only) lets this server push its detections to other AiroAlert servers, so several sites can share a common picture. The receiving side is just the existing ingest API — any AiroAlert peer works with no extra setup beyond minting an API key. Each peer you add does two things, each independently toggleable:

  • Push live frames — every Remote ID frame that arrives at this server's POST /api/remote_id (from a BLE / Wi-Fi sniffer) is forwarded to the peer's /api/remote_id in real time, so the drone shows on the peer's map within a refresh cycle.
  • Sync flights — on a periodic cycle (default 60 s, remote_sync_interval_sec), locally-detected flight records are pushed to the peer's idempotent /api/ingest/flight. The first sync back-fills the entire local flight history progressively (200 flights per cycle); after that it sends only what changed. A per-peer watermark is persisted, so a restart resumes where it left off and points are never duplicated (the receiver de-dupes by (flight_id, ts)).

Setup. On the receiving server, mint an API key in its API tab. On this server, open Remote Servers, click Add Server, enter a name and the peer's address (a bare host, host:port, or a full http(s):// URL — a bare host defaults to http://), paste the key, and choose which of the two push modes to enable. The Test button probes the peer's /health to confirm reachability before you save. To get two-way sync, configure each server to point at the other. The key is stored as an outbound credential and never returned to the browser again — the list shows only an 8-character hint, and submitting the key field blank on an edit keeps the stored value.

Loop prevention is structural. Forwarded frames carry a sync:<origin> source label, and the push hook drops anything already prefixed sync:; flight sync only selects locally-detected flights (those whose id is not ext:-prefixed). Two servers pointed at each other therefore converge without ping-ponging the same detection back and forth. Per-peer push status (last success / last error, frames + flights pushed, last sync time) is shown live in the list. If a peer is reachable from the internet, set its /api/remote_id endpoint to restricted in its API tab — the push always sends the X-API-Key, so authenticated forwarding keeps working while anonymous posts are refused.

Error Responses

  • 400 — malformed JSON, missing required field, point out of range, body too large, or flight_id owned by another source. Body: {"error":"<reason>"}.
  • 401 — missing/expired/revoked API key on a restricted or ingest endpoint.
  • 403 — admin-only endpoint hit with a non-admin session.
  • 404 — unknown path, or /api/history/<flight_id> for a non-existent id.

Other Endpoints

  • GET /health — liveness probe (always anonymous).
  • GET /api/whoami — admin token probe; always returns 200 with {authenticated: bool, ...}. Not gated by the ACL.
  • /api/admin/* — admin-only management endpoints (config, users, API keys, endpoint visibility, audit log, SDR devices, scan channels, translations). Not flippable in the API tab.

Detection Pipeline

SDR (PlutoSDR): The SDR receiver cycles through 2.4 GHz and 5.8 GHz DJI DroneID channels. Each channel is sampled for ~65 ms. A Zadoff-Chu matched filter detects DroneID preambles; confirmed bursts are passed through an OFDM demodulator and turbo decoder to extract the full telemetry frame.

DJI channel smoothing (optional): At low SNR the per-carrier channel estimate used during OFDM equalisation picks up estimation noise that can corrupt the QPSK demapping and make an otherwise-detectable DJI burst fail its CRC. The DJI Channel Smoothing setting (Detection tab, dji_channel_smooth_taps) optionally truncates the channel estimate to its strongest N DFT taps before demapping — the true channel impulse response is short, so keeping the first 8–16 taps retains real multipath while discarding the noise. It is 0 (disabled) by default; raise it only if fringe-range bursts are detected but not decoding, and leave it off in strong-signal environments where it offers nothing.

Frequency-Hopping (FH): An STFT-based spectrogram is computed on each IQ capture. Blobs exceeding a median-adaptive threshold are extracted as candidate hops. Sequences with sufficient hop count and centre-frequency diversity are promoted to FH tracks — this catches OcuSync and Lightbridge links that carry no decodable ID.

Suppressing false-positive OcuSync from WiFi: The FH path is the most likely source of phantom drone markers in WiFi-rich 2.4 GHz environments. If the map shows OcuSync 2/3/4 tracks while no drone is in the area, tighten the gates in Detection → FH: raise FH Threshold Gap (dB) (16-18 trims weaker WiFi blobs), FH Min Hops (8-12), FH Min Distinct Centres (5-6), and FH Min Bin Onset Spread (ms) (20-25 — single wideband WiFi bursts that fragment into adjacent blobs fire at the same instant; real FH visits centres sequentially). Lowering FH Max Time Occupancy to 0.4 also rejects continuous transmitters parked on one channel. For phantom DJI DroneID @ X MHz (RF only) markers, the v2.8.0+ in-band ratio gate filters most WiFi-triggered alarms before they can promote (real DJI carries ~0.9 of symbol-3 FFT power in the 600 DroneID data carriers; WiFi-shaped bursts carry ~0.02). If you still see phantom markers after v2.8.0, bump RF-Only Min Bursts to 3-5 — the older temporal-persistence gate stays as a fallback for ambiguous low-correlation cases.

Method-III Chirp Confirmer: When an FH track is promoted, a half-symbol autocorrelation with frequency shift (Cwalina/Rajchowski/Sadowski, Sensors 2025, 25(15):4552, eq. 7) is run against the same capture. The detector exploits the odd time-frequency symmetry of DJI OcuSync chirp symbols (Zadoff-Chu OFDM with linear sweep) and is independent of the carrier frequency. Three variants are tested: 18 MHz video up-chirp, 9 MHz narrow-channel up-chirp, and DroneID irregular down-chirp. Confirmed tracks have their source label promoted from FHSS (STFT) to FHSS+chirp, raising operator confidence that the link is genuinely OcuSync vs. FH-shaped Wi-Fi/BT noise.

Remote ID / ASTM F3411: The system accepts Remote ID frames via the POST /api/remote_id HTTP endpoint. An external sniffer (Wi-Fi NAN, BT5 Long Range, or BLE) can forward hex-encoded messages for decoding. Two reference sniffers ship with v1.1.0 — ble_remote_id_sniffer.py and wifi_remote_id_sniffer.py under /opt/airoalert-sensor/ — and run unprivileged on a Raspberry Pi with a USB Wi-Fi adapter in monitor mode and the on-board BLE radio. The Location decoder also surfaces per-axis GPS / sensor accuracy ranges (horizontal, vertical, speed, barometric, timestamp) used for threat scoring and spoof detection.

Multi-Receiver TDoA (v1.1.0+)

When three or more time-synchronised receivers observe the same DroneID burst, AiroAlert can independently localise the emitter from the differences in arrival time — no on-board GPS required, and no trust placed in the drone's self-reported position. The hyperbolic solver in tdoa.py runs Gauss-Newton iteration in a local ENU tangent plane and reports both the fix and HDOP (geometric quality — <2 is good, >5 is poor). The same fix is then compared against the drone's broadcast GPS to score spoofing: a TDoA fix that disagrees with the broadcast position by more than the broadcast accuracy budget is flagged as a likely spoof.

The math (solver, fan-in correlator, anti-spoof comparator) ships and is exercised by the unit-test suite. The hardware path — sample-clock discipline via PlutoSDR + GPSDO (10 MHz reference + PPS) and per-burst capture timestamps — is gated on TimingSource.is_disciplined() returning true on every receiver, so TDoA stays dark until the GPSDO is wired and the capture-path integration lands.

IQ Replay (v2.1.0+)

The Replay tab streams a recorded IQ capture through the same decoder pipeline that processes live SDR samples (Zadoff-Chu correlation → OFDM demod → QPSK → turbo decode → DJI frame parse, plus the parallel STFT FH detector). Useful for: regression-testing a captured drone burst against a new threshold, validating a hardware change without a real flight, and reproducing a near-miss saved by IQ_DUMP_NEARMISS_DIR.

Workflow: upload a file via the Upload button, set the centre frequency and sample rate the recording was captured at, then click Replay on the listed file. Live SDR capture pauses for the duration so the consumer thread isn't fed two streams at different centre frequencies; it resumes automatically when the replay finishes or is stopped.

Supported formats: .cf32 (interleaved float32 I/Q — the SDR++/GNU Radio default), .cs16 (interleaved int16 I/Q, scaled by 1/32768 — HackRF / Pluto raw), and SigMF — upload the .sigmf-data file together with its .sigmf-meta sidecar (datatype cf32_le or ci16_le). For SigMF, sample rate and centre frequency from the sidecar override the values entered in the form. The file list flags any .sigmf-data uploaded without its sidecar so it can't be replayed by accident.

File storage: uploads land in the directory shown under the Upload button (default iq_replay/ next to airoalert.py; override with the IQ_REPLAY_DIR environment variable). Per-file size cap is configurable via IQ_REPLAY_MAX_UPLOAD_MB (default 1024 MB ≈ 4 s of cf32 at 30.72 MSPS). Filenames are restricted to alphanumerics, dot, underscore, and hyphen with a leading alphanumeric — quotes, spaces, and HTML-significant characters are rejected as a defence-in-depth against admin-UI XSS.

Audit: upload, delete, start, and stop are recorded in the audit log under actions iq_replay_upload, iq_replay_delete, iq_replay_start, and iq_replay_stop. The Replay tab is admin-only.

Access Control / IP Whitelist (v2.15.0+)

The admin Access tab (admin-role only) restricts which client IPs can reach the operator map, the admin UI, and their supporting APIs. The filter is opt-in: with the whitelist field empty the gate is fully disabled and any client can reach the surface (preserving existing deployments on upgrade). Once Whitelist is populated — one entry per line or comma-separated, accepting single IPs (192.168.1.10) and CIDR blocks (10.0.0.0/8, 2001:db8::/32) — every non-matching request gets an HTTP 404 with an empty body so the existence of /admin isn't advertised, and a single [ip-gate] deny <ip> <METHOD> <path> line lands in the container log. Localhost (127.0.0.0/8, ::1) is always allowed regardless of whitelist contents so an admin inside the host can never lock themselves out with a typo. Sensor ingest endpoints (/api/remote_id, /api/ingest/flight, /api/lists/add, /api/lists/remove, /api/alert/dismiss) and the Docker /health probe are never gated — sensors and the platform healthcheck keep working regardless of the whitelist. Changes apply immediately on save, no container restart needed.

Trusted proxies and XFF attribution. Behind a reverse proxy the socket peer is the proxy's IP, not the real client. Populate the Trusted proxies textarea with the IPs/CIDRs of your reverse-proxy hops, and the gate will honour X-Forwarded-For for requests whose socket peer is in that set. The XFF chain is walked right-to-left, skipping further trusted-proxy hops, and the first non-trusted IP is used as the client identity. The right-to-left walk is mandatory: standard reverse-proxy configurations (nginx $proxy_add_x_forwarded_for, HAProxy option forwardfor, Apache mod_proxy default) PRESERVE a client-supplied XFF prefix and only append their own peer to the right — trusting the leftmost would let any remote attacker spoof a whitelisted source IP just by sending the header through the proxy (the v2.15.1 security hotfix). Requests from peers not in the trusted-proxy list ignore X-Forwarded-For entirely, defeating direct-to-app spoofing.

Versioning

The version label in the map legend (and admin top bar) is resolved at startup with the following priority:

  • AIROALERT_VERSION environment variable. Highest priority. Used by Docker / Compose builds — the Dockerfile takes --build-arg AIROALERT_VERSION and bakes it as ENV, so containers (which strip .git via .dockerignore and don't ship the git binary) still report a real version. Build with: AIROALERT_VERSION=$(git describe --tags --always --dirty) docker compose build.
  • git describe --tags --always --dirty in the script's directory. Used by ordinary clones. Produces 1.2.3, 1.2.3-N-g<hash>, with -dirty appended for uncommitted changes.
  • VERSION file co-located with airoalert.py. Useful for non-Docker deployments that strip .git, run shallow clones, or run under a systemd unit that can't read .git. Operators can seed it manually at install time with git describe --tags --always --dirty > VERSION.
  • unknown as a last resort.

If git describe fails on a non-Docker deploy, the systemd journal / container logs print the failure reason (FileNotFoundError = git not on PATH, CalledProcessError = not a git repo or ownership refusal, etc.) so the cause is recoverable.