AirRadar is a personal dark-mode ADS-B radar for a local readsb receiver. It keeps live aircraft state in RAM, samples history to PostgreSQL, and streams snapshots to the browser over Server-Sent Events (SSE). The map remains useful when optional data sources are unavailable.
The bilingual project wiki provides a practical entry
point in English and Czech. The technical source of truth remains in the
versioned documents below. A Czech localization is available in
docs/cs/.
The compact agent entry point is AGENTS.md. Read it first and
then only the linked document relevant to the task; do not assume the whole
docs/ tree is required.
docs/ARCHITECTURE.md— ownership and boundariesdocs/DATA-FLOWS.md— live, history, statistics, and enrichment flowsdocs/RUNTIME-INVARIANTS.md— behavior and safety contractsdocs/DEVELOPMENT.md— local work, tests, worktrees, and agentsdocs/RELEASE.md— authoritative production release proceduredocs/DATA-SOURCES.md— source provenance, security, and licensingdocs/FEATURES.md— routes/API and production statusdocs/features.registry.json— machine-checked feature ownership and changelog scopesdocs/VISUAL-SYSTEM.md— design tokens, shared primitives, visual debt budget, and CI screenshots
The chart tracks non-empty physical source lines and separates production code
from test code. Production excludes documentation, migrations, generated output,
and public/vendor assets; test code includes tests/**, *.test.*,
*.spec.*, __tests__, and test-runner configuration.
The timeline is reconstructed from the repository's first available commit
using the last first-parent main commit of each day. After the initial
backfill, the workflow keeps at most one point per day and replaces that day's
point with the newest measured state, so the graph stays readable as the
project grows.
The history is stored in
docs/metrics/code-history.json. After a
successful main CI run, the unified Repository Metadata workflow refreshes
both the changelog and codebase metrics on automation/repository-metadata,
opens a single audit PR, and merges that generated metadata automatically.
Metadata-only merges are excluded from production deployment. Run
npm run metrics:code for the current snapshot or
npm run metrics:code:backfill to rebuild the daily timeline from Git
history.
Demo mode is automatic when READSB_BASE_URL is empty or absent.
cd /var/www/airradar
cp .env.example .env
npm install
npm run prisma:generate
npm run devOpen http://localhost:3000. The mock provider generates moving sample traffic around the configured receiver position. PostgreSQL is optional in demo mode; history uses the in-memory trail when it is not configured.
Set the base URL of the readsb/tar1090 web root. AirRadar appends the data
paths; do not include /data/aircraft.json in the variable.
READSB_BASE_URL=http://192.168.1.50:8080
RECEIVER_LAT=50.0755
RECEIVER_LON=14.4378The adapter reads /data/aircraft.json and optionally /data/receiver.json,
preserves raw barometric/geometric fields, and derives altitude/vertical rate,
distance, and bearing. Polling retries with bounded backoff; a receiver outage
does not take down the UI or API.
AirRadar can optionally add live network coverage from the authorized raw
streams out.adsb.lol:1365 (BEAST) and out.adsb.lol:1366 (SBS/MLAT). The
public https://api.adsb.lol geographic API is retained as a fallback; raw
and HTTP snapshots are never summed.
The raw output is experimental, feeder-only, and available only to authorized feeder public IPs. AirRadar makes outbound connections only, never connects the stream to local readsb, and never forwards it to any feeder.
Enable it explicitly in the server environment:
ADSBLOL_ENABLED=true
ADSBLOL_RAW_ENABLED=true
ADSBLOL_BEAST_HOST=out.adsb.lol
ADSBLOL_BEAST_PORT=1365
ADSBLOL_MLAT_HOST=out.adsb.lol
ADSBLOL_MLAT_PORT=1366
ADSBLOL_NETWORK_RADIUS_NM=500
ADSBLOL_RAW_MAX_TRACKS=10000
ADSBLOL_HTTP_FALLBACK_ENABLED=true
ADSBLOL_BASE_URL=https://api.adsb.lol
ADSBLOL_RADIUS_NM=250
ADSBLOL_POLL_INTERVAL_MS=10000
ADSBLOL_REQUEST_TIMEOUT_MS=4000
ADSBLOL_STALE_AFTER_MS=30000
ADSBLOL_MAX_RETRY_INTERVAL_MS=60000
ADSBLOL_MAX_AIRCRAFT=3000
ADSBHUB_ENABLED=false
ADSBHUB_HOST=data.adsbhub.org
ADSBHUB_PORT=5002
ADSBHUB_RADIUS_NM=500
ADSBHUB_STALE_MS=15000
ADSBHUB_RECONNECT_MAX_MS=30000
ADSBHUB_MAX_TRACKS=20000The default is conservative: one server-side poll every 10 seconds, bounded to 3,000 aircraft, with a 4-second timeout. The server uses the precise receiver coordinates for the upstream query, while the existing public receiver-coordinate privacy mode remains unchanged.
The radar defaults to LOCAL, showing only observations from the local
receiver. EXTENDED combines local and network observations by normalized
ICAO identity, shows a single aircraft marker, and labels the selected
position source. Freshness is considered before source priority, so a fresh
local observation wins when appropriate and a fresh ADSB.lol position can
temporarily replace a stale local position. Network trails remain bounded and
in memory.
In EXTENDED, merged aircraft are classified from provenance as
LOCAL_ONLY, NETWORK_ONLY, or OVERLAP. Counters use LOCAL = LOCAL_ONLY + OVERLAP, NETWORK = NETWORK_ONLY + OVERLAP, and TOTAL = LOCAL_ONLY + NETWORK_ONLY + OVERLAP; the map source filter uses the same
classification and is persisted in the browser.
Network observations are not local receiver evidence: they are never written
to FlightPosition, never create local Flight history, never affect daily
receiver statistics or reception records, and never use network RSSI/message
counts as local measurements. Existing local enrichment, ATC matching, and
default push-alert semantics remain local-only; network-only aircraft do not
trigger an enrichment fan-out or push notification.
When enabled, network priority is ADSBHub TCP SBS/30003, ADSB.lol raw, then
ADSB.lol HTTP. The ADSBHub feeder contribution is external to this repository:
192.168.1.50:30002 → data.adsbhub.org:5001; AirRadar consumes
data.adsbhub.org:5002 and never manages the feeder service.
The public API is dynamically rate-limited. AirRadar sends no per-browser
requests, never overlaps ADSB.lol requests, honors Retry-After on HTTP 429,
and otherwise uses bounded backoff up to ADSBLOL_MAX_RETRY_INTERVAL_MS.
Failed polls keep the last valid network snapshot until the stale timeout;
local radar, history, and statistics continue independently. /system shows
the network provider status, last attempt/success, latency, aircraft and MLAT
counts, polling interval, radius, and rate-limit state.
Smoke test the public endpoint with operator-supplied coordinates (do not put the private receiver location in documentation):
curl -sS 'https://api.adsb.lol/v2/lat/LAT/lon/LON/dist/100' | jq '.total'ADSB.lol publishes its API and public data under ODbL 1.0. Keep the in-app ADSB.lol attribution and comply with the current ODbL terms.
OGN is an optional, server-side APRS-IS live feed. It is disabled by default;
when enabled, one process connects to aprs.glidernet.org:14580, sends a
receive-only pass -1 login, and applies a radius filter around the existing
canonical receiver coordinates. It sends only the documented #keepalive
comment at the configured interval; it never sends aircraft telemetry. The
browser uses a separate /api/ogn/state snapshot and /api/ogn/stream SSE
channel; it never opens a TCP connection.
OGN targets stay in a bounded RAM-only state map. They do not enter the local
ADS-B state service, FlightPosition, Flight history, daily statistics,
reception records, alerts, enrichment, or receiver health. The OGN map layer
is separate, off by default, and uses dedicated MapLibre DOM markers; the OGN
list and detail panel are separate from ADS-B selection and filters.
Before publishing a target, AirRadar fail-closes on an unresolved or stale
per-device OGN DDB resolution. Packet no-tracking and DDB tracked=N are
dropped. A DDB miss, DDB identified=N, or packet stealth flag is anonymous:
identity fields are removed at the server serialization boundary. Identified
metadata is shown only when both the packet and DDB permit it. OGN timestamps
are nearest-day UTC timestamps, packets older than 120 seconds are dropped,
and identity is deduplicated by addressType + address, never by callsign.
APRS CSE/SPD uses CCC/SSS where course is degrees and speed is knots.
AirRadar stores that value directly as groundSpeedKt; it does not apply a
FANET source-specific conversion after OGN infrastructure has emitted APRS.
The runtime does not require a full DDB download at startup. Each active OGN
device is resolved through the official targeted JSON request
?j=1&t=1&device_id=<comma-separated-ids>, with bounded batching, debounce,
minimum request spacing, one in-flight request, and global 429 backoff. A
rich targeted failure may use the same batch with the official ?j=1 base
representation; it never falls back to a full table download. The resolver
keeps positive and short-lived negative resolutions in bounded RAM. An empty
targeted devices array is a valid DDB miss, while HTTP/network/schema
failures remain unresolved and fail closed. Exact device_type:device_id
matching prevents a same-ID record under another device type from being used.
The fallback is accepted only when every device record still contains the
privacy-critical device_type, device_id, tracked, and identified
fields; missing or invalid fields keep privacy fail-closed. aircraft_type is
optional enrichment. A validated FOUND or MISSING resolution is also
stored in the versioned local cache
/var/lib/airradar/ogn-ddb-cache-v1.json (configurable with
OGN_DDB_CACHE_FILE), using bounded debounced atomic writes and a final
shutdown flush. The cache preserves the original resolvedAt; it never
extends the positive 24-hour privacy stale limit or the negative 30-minute
TTL, and corrupted/expired entries remain fail-closed. /system reports the
targeted strategy, queue/cache counts, batch/request counters, HTTP status,
backoff, aircraft-type availability, and cache persistence diagnostics.
The persistent official cache cannot refresh or bootstrap a new primary DDB snapshot while upstreams are unavailable. Without the separately enabled SoftRF whitelist, a new device therefore remains unresolved and hidden until a valid targeted DDB response has been received.
As an explicitly enabled emergency fallback, OGN_SOFTRF_DDB_PATH may point
to the local SQLite ogn.db generated by the SoftRF project. The adjacent
ogn.db.meta.json sidecar is required and must contain the trusted
generatedAt, source: "SoftRF", sourceRunId, and lowercase SHA-256 sha256
fields. The hash binds the metadata to the exact SQLite file. AirRadar opens the
database read-only, validates the devices schema and metadata age, and indexes
only rows with track=1 and ident=1 into an in-memory whitelist. The
fallback is below the current official DDB and its persistent cache, expires
after OGN_SOFTRF_DDB_MAX_AGE_HOURS (168 hours by default), and is never used
as a metadata replacement. Invalid, absent, or expired data remains
fail-closed (UNKNOWN = HIDE). Set OGN_SOFTRF_DDB_ENABLED=false to disable
it.
The snapshot is maintained by the separate
airradar-ogn-softrf-update.service/timer, not by the Next.js runtime. The
updater selects only a successful lyusupov/SoftRF aircraft-database workflow,
uses the artifact's created_at as generatedAt, validates the archive and
SQLite schema in a temporary directory, and performs a same-filesystem atomic
replacement. See deploy/README.md for bootstrap,
verification, and rollback commands. Set OGN_SOFTRF_UPDATE_ENABLED=false to
disable the updater; OGN_SOFTRF_GITHUB_TOKEN is only needed when GitHub
requires authentication for artifact downloads.
The implementation accepts the current v1 FLARM, OGN tracker, FANET, SafeSky,
PilotAware, and ADS-L TOCALL variants that have a safe airborne interpretation;
OGADSB, ground/weather/status, delayed, and unknown variants are counted and
dropped. Official OGN source data and privacy choices are documented in
docs/DATA-SOURCES.md. Configure the complete bounded
set of OGN_* variables from .env.example; keep OGN_ENABLED=false in demo
and release-gate environments.
The schema source is prisma/contract.prisma; checked-in forward migrations
are under migrations/app/. Live state is not written for every ADS-B update:
the service samples positions and stores Aircraft, Flight, and
FlightPosition records. Daily receiver statistics, airport/ATC reference
data, and the optional aircraft metadata catalog have separate tables. See
docs/DATA-FLOWS.md and
docs/RUNTIME-INVARIANTS.md for persistence
semantics.
createdb airradar
# Set DATABASE_URL in .env
npm run prisma:generate
npm run prisma:deploy
npm run devWithout DATABASE_URL, live radar remains available and /api/health reports
the database as not_configured.
All optional providers are server-side, bounded, and isolated from readsb
polling. Configure them in the server-only .env; never use
NEXT_PUBLIC_* for credentials.
| Capability | Configuration | Default |
|---|---|---|
| ADSBDB metadata/routes | ADSBDB_ENABLED=true |
Disabled |
| tar1090 aircraft catalog | AIRCRAFT_METADATA_URL when using a tar1090 root |
Best effort, daily conditional sync |
| FlightAware flight plans | FLIGHTAWARE_API_KEY |
Disabled; commercial/possibly billable |
| AviationWeather.gov METAR/TAF/SIGMET | No key; server-side AWC integration | Disabled; opt-in |
| Planespotters aircraft photos | AIRCRAFT_PHOTOS_ENABLED=true |
Disabled |
| Server alerts/Pushover | /var/lib/airradar/alerts.json in production, PUSHOVER_ENABLED=true plus server credentials |
Rules/no-op notifier until explicitly configured |
Source, licensing, URL allowlists, cache behavior, and operational limits are
in docs/DATA-SOURCES.md. Route airport metadata is
resolved through the PostgreSQL catalog, then valid provider coordinates, then
the small bundled fallback catalog.
The optional Aviation Weather integration is server-side only and uses the official Aviation Weather Center APIs. Enable it explicitly:
AVIATION_WEATHER_ENABLED=true
AVIATION_WEATHER_USER_AGENT="AirRadar/<version> (+https://example.invalid/contact)"/api/weather/airport/:icao and the bounded batch form
/api/weather/airport?icao=ICAO1,ICAO2 expose canonical-ICAO METAR/TAF data;
/api/weather/sigmet exposes current worldwide international SIGMETs plus the
CONUS domestic dataset. The radar SIGMET layer is off by default and loads only
after the operator enables it. Flight-detail weather requests the destination
first and then the origin in one batch request.
The provider uses HTTPS AWC endpoints, a custom User-Agent, bounded timeout,
per-product TTLs (METAR 5 minutes, TAF 10 minutes, SIGMET 5 minutes), bounded
RAM-only caches, negative caching, in-flight coalescing, stale-if-error,
Retry-After backoff, and safe handling of 204 No Content. It never writes
weather to PostgreSQL, changes aircraft SSE payloads, delays readsb, or feeds
history/statistics. Public responses contain canonical normalized fields only;
upstream errors and credentials are not serialized. The provider is subject to
AWC's published request and result limits, so production UI fetches are
on-demand and the SIGMET layer refreshes at a bounded cadence.
Operational counters and cache/provider state are visible on /system. See
docs/DATA-SOURCES.md for provenance and the complete
configuration list.
Sample ATC is automatic only in demo mode. With a real receiver, imported
PostgreSQL data is used unless ATC_SAMPLE_ENABLED=true is explicitly set.
ATC assignments are probable position/altitude/time matches and never claim
the aircraft's actual tuned frequency.
npm run airports:import -- --dry-run
npm run airports:import
npm run airports:sync -- --dry-run
npm run airports:sync
# reproducible local input:
npm run airports:sync -- --dir ./data/ourairports --dry-run
npm run atc:import -- --dry-run data/atc/cz-atc.json
npm run atc:import -- data/atc/cz-atc.json
npm run atc:sync:cz -- --dry-run
npm run atc:sync:cz
npm run atc:status:czairports:sync downloads the current official OurAirports open-data files
(airports.csv, runways.csv, airport-frequencies.csv, and navaids.csv)
with a timeout and AirRadar User-Agent, validates all headers and rows before
opening one database transaction, and supports --dry-run. Runways and
frequencies are limited to the selected AirRadar airport catalog; navaids are
kept worldwide. A failed or undersized feed cannot prune existing
infrastructure. The existing airports:import remains a backward-compatible
core-only importer and never deletes airport rows.
Airport infrastructure data: OurAirports, Public Domain. The data is community-sourced and has no guarantee of accuracy or fitness for use. AirRadar is an informational/reference display, not a certified navigation database; verify operational aviation data with official sources.
The import and Czech eAIP boundary rules are documented in
data/atc/README.md and
docs/DATA-SOURCES.md. AIM/eAIP, ČÚZK Data50, and BKG
VG25 are sync-time inputs; the live service has no dependency on those hosts.
The /watchlist page and /api/watchlist manage shared server alert rules in
/var/lib/airradar/alerts.json in production (data/alerts.json locally);
updates are validated and atomically written. Rules support
ICAO hex, registration, callsign, callsign pattern, aircraft type, airline,
and optional maximum distance. Alert transitions use one server-wide cooldown
and bounded asynchronous notification delivery.
The live map's browser watchlist is a separate localStorage filter. It is
not a shared server rule and does not send notifications.
npm run dev
npm run lint
npm run typecheck
npm run test:targeted -- tests/aircraft-state.test.ts
npm run test:changed
npm test # complete Vitest suite
npm run build # local/isolated checkout only
npm run prisma:generate
npm run prisma:migrate
npm run prisma:deploy
npm run prisma:verify
npm run startUse docs/DEVELOPMENT.md for targeted/changed/full
testing and docs/RELEASE.md for production validation.
The configured production hostname is https://airradar.pomykal.cz.
The public API is same-origin; no wildcard CORS policy is enabled. Exact
receiver coordinates remain server-side by default. Set
PUBLIC_RECEIVER_POSITION_MODE to approximate (default), hidden, or
exact only when deliberately publishing the location. Public DTOs replace
raw provider errors and never expose secrets.
Request/response APIs use bounded fixed-window limiting; /api/stream is
excluded so SSE heartbeat/coalescing is not interrupted. The proxy must use
HTTP/1.1, disable buffering/cache for SSE, and set a long read timeout. See
deploy/README.md for systemd installation and the full
Nginx Proxy Manager configuration.
The service runs as the unprivileged airradar user. The systemd unit starts
scripts/start-production.mjs directly so systemd tracks the actual Node/Next
process; the wrapper registers graceful shutdown ownership and waits for a
complete build. Do not use npm run start as a replacement for the production
unit without understanding that lifecycle contract.
Normal releases use deploy/release.sh, whose
authoritative procedure is docs/RELEASE.md. It performs
the full quality gates, migrations, restart, local/public health checks, and
only then creates the automatic version tag. During the release it also
generates CHANGELOG.md from commits since the previous
release tag and commits the generated section. It never runs as part of
ordinary development or documentation work.
RTL-SDR / readsb → LocalReadsbProvider → AircraftStateService RAM
→ SSE → Next.js / MapLibre UI
→ sampled positions → PostgreSQL
For the complete current implementation, read
docs/ARCHITECTURE.md and
docs/RUNTIME-INVARIANTS.md.