Local-first airspace awareness for Home Assistant — manned aircraft and
drones. ha-airspace polls aircraft.json from one or more local ADS-B
receivers (dump1090, dump1090-fa, readsb, dump978 / UAT) and
Remote ID drone detections from dump3411
(ASTM F3411 over BLE + Wi-Fi), enriches them against reference databases, tags
military / interesting / emergency / privacy aircraft, and publishes everything
to MQTT with Home Assistant MQTT discovery — a working "nearest aircraft"
sensor (and drone alerts) with zero template writing.
Unlike FlightRadar24-based integrations, ha-airspace is local-first: it
reads your own receiver over HTTP, no cloud API, no account. And unlike every
other ADS-B Home Assistant project, it ingests drone Remote ID as a
first-class source. See DESIGN.md for architecture, the full
roadmap, and how it's positioned against the alternatives.
The example dashboard (
docs/dashboard.example.yaml) rendered from the demo scene (docs/demo/) — synthetic traffic around the White House, so the shot carries no real location. Nearest traffic (with map markers and a photo), per-flag alert tables, drone + operator location, and receiver/feed health.
Why another one? The popular HA flight integrations are cloud-API (FlightRadar24); the local-receiver ones don't merge multiple sources and none of them detect drones.
ha-airspaceis a standalone MQTT service with a multi-receiver merger (1090 + 978 + Remote ID), two-database enrichment, and a test suite — built to become infrastructure, not a weekend script.
Status: v1.0.0 — stable. Released as an HA add-on + multi-arch Docker image on GHCR, running against live receivers + a real broker; the published MQTT payload contract is versioned (
schema_version). Shipped: multi-receiver 1090 + 978 merge, drone Remote ID as a first-class source, reference-DB enrichment (Mictronics + ADSBexchange), FAA UAS make/model for drones, declarative flags + stateful alerts (history-aware "first-time/ returning", orbit/loiter detection, predictive inbound closest-approach, and behavioral Remote ID spoof detection), HA MQTT-discovery entities, an optional SQLite journal for durable history, Planespotters photos on alerts, and optional Prometheus metrics.Deferred / not yet built (none required): durable drone sighting history, Tier-2 (kinematic) spoof detection, true AGL via DEM, a custom Lovelace "radar" card, a web status UI, and PyPI / armv7 / Docker-Hub distribution. See
DESIGN.mdfor the full roadmap and rationale.
Keywords: Home Assistant, ADS-B, dump1090, dump978, readsb, tar1090, PiAware, FlightAware, Remote ID, ASTM F3411, drone detection, dump3411, MQTT, MQTT discovery, aircraft tracker, military aircraft, ADSBexchange, Mictronics, 1090 MHz, 978 MHz UAT, local-first, self-hosted.
- Python 3.12+
- A receiver exposing
aircraft.jsonover HTTP (PiAware / dump1090-fa, readsb, tar1090, dump978-fa, …) - An MQTT broker (Mosquitto, or the Home Assistant Mosquitto add-on)
uvfor development (optional for end users)
On Home Assistant OS, install the add-on (see below) — that's the path on HAOS. Elsewhere, run the Docker image (also below). For development from a clone:
# 1. Install dependencies (from a clone)
uv sync
# 2. Create your config from the example
cp config.example.yaml config.yaml
$EDITOR config.yaml # set your receiver URL, broker, and watchpoint
# 3. Run
uv run ha-airspace --config config.yaml
# — or: uv run python -m ha_airspace --config config.yamlA PyPI release (
pip install ha-airspace) is planned but not published yet; for now use the add-on, the Docker image, or a clone.
On startup the service connects to the broker, publishes the HA discovery payloads, and begins polling. Within a few seconds Home Assistant shows the entities below — no YAML templates required.
Bad config fails fast: a missing file or invalid schema prints a clear error (with the offending field path) and exits non-zero before anything connects.
The image is multi-arch (amd64 / arm64) and reads its config from /config
with persistent state in /data:
docker run -d --name ha-airspace \
--restart unless-stopped \
-v ./config.yaml:/config/config.yaml:ro \
-v ha-airspace-data:/data \
ghcr.io/ifnull/ha-airspace:latest--restart unless-stopped makes Docker bring it back on boot and after a
crash. On Home Assistant OS the add-on handles this for you (boot: auto).
Add this repo as an add-on store (Settings → Add-ons → Add-on Store → ⋮ →
Repositories): https://github.com/ifnull/ha-airspace, then install
ha-airspace. Receivers, watchpoints, and MQTT are configured from the
add-on UI; with the Mosquitto add-on installed the MQTT connection auto-fills.
Full reference: addon/DOCS.md.
config.example.yaml is the annotated reference. The
only required sections are watchpoints, mqtt, and receivers; everything
else has sensible defaults. Validation is strict — unknown keys are rejected
with a helpful error rather than silently ignored.
Minimal config:
watchpoints:
- name: home
lat: 38.8977
lon: -77.0365
mqtt:
broker: homeassistant.local
receivers:
- name: home-1090
url: http://piaware.local:8080/skyaware/data/aircraft.json
band: "1090"watchpoints— named locations distance/bearing and "nearest" are measured from. The one namedhomeis the primary (else the first listed).receivers— one or moreaircraft.jsonsources.bandis required (1090|978); the receiver's own location is auto-discovered from itsreceiver.jsonwhen present.mqtt— onlybrokeris required. Per-hex and summary publish rates are throttled (defaults 1/s) so HA never sees a high-cardinality stream.
Created automatically via MQTT discovery (no per-aircraft entities — that would overwhelm HA's registry; power users subscribe to the wildcard topic instead):
| Entity | Source |
|---|---|
sensor.airspace_aircraft_count |
total aircraft currently tracked |
sensor.airspace_nearest_aircraft |
distance to the closest aircraft; full details in its attributes |
sensor.airspace_drone_count |
drones currently tracked (with a Remote ID feed) |
sensor.airspace_nearest_drone |
nearest drone; operator location in its attributes |
sensor.airspace_flag_<flag> |
count of aircraft carrying that flag; a distance-sorted list (alt/dist/type/squawk) in its attributes — one per configured flag |
binary_sensor.airspace_alert_<rule> |
on while any aircraft matches that alert rule |
binary_sensor.airspace_receiver_<name>_status |
per-receiver connectivity |
sensor.airspace_receiver_<name>_aircraft_count |
per-receiver aircraft count |
sensor.airspace_receiver_<name>_message_rate |
per-receiver message rate |
HA derives these entity_ids from the device name ("Airspace") + the entity name,
so they're sensor.airspace_<thing>. (A receiver named home-1090 slugifies to
home_1090.) All carry an availability binding to the service's airspace/status
topic, so they go unavailable when the service stops or crashes rather than
showing stale values.
A ready-to-adapt Lovelace dashboard (map of nearest traffic, overview glance,
nearest-aircraft detail, per-flag "watched aircraft" tables, receiver health,
alert badges) built from these entities with stock cards is in
docs/dashboard.example.yaml. Example HA
automations that turn the alert binary sensors into mobile/persistent
notifications (with photo + flight detail) are in
docs/automations.example.yaml.
Published under base_topic (default airspace):
airspace/status online | offline (LWT + graceful shutdown)
airspace/summary/count total aircraft
airspace/summary/nearest JSON: closest aircraft (full state)
airspace/summary/count_by_flag JSON: counts per flag ({} when no flag rules configured)
airspace/summary/by_flag/<flag> JSON: capped, distance-sorted list of aircraft with that flag
airspace/aircraft/<hex> JSON: per-aircraft state (wildcard; not an HA entity)
airspace/receiver/<name>/status online | unhealthy | offline
airspace/receiver/<name>/stats JSON: count, message rate, health
airspace/receiver/<name>/location JSON: receiver lat/lon
State-bearing topics are retained, so a freshly started consumer sees current state immediately. Aircraft that leave coverage have their retained topic cleared (no zombie aircraft lingering in HA).
As of v1.0 the following are the supported public surface. Breaking changes to
them bump the major version (and the payload schema_version); additive,
optional changes do not.
- MQTT payload contract. Every consumer-facing JSON payload (aircraft, drone,
alert, per-flag feed) carries
schema_version(currently 1) as its first field. A new optional field may be added without a bump; a removed, renamed, or retyped field bumps both the major version andschema_version. Branch onschema_versionbefore reading anything else. - MQTT topic tree under
base_topic(defaultairspace):status;summary/{count,nearest,count_by_flag,drone_count,nearest_drone,by_flag/<flag>};aircraft/<hex>;drone/<id>;alert/<rule>/{active,info,<id>};receiver/<name>/{status,stats,location}. Topic names are stable. - Home Assistant entities created via MQTT discovery (the set under
Home Assistant entities). Entity ids derive from the
device name + entity name;
unique_ids stay stable across renames. - Configuration schema — the native
config.yaml(Pydantic, strict) and the add-on options. New optional keys may be added; removing or retyping a key is breaking. Unknown keys are rejected at startup.
Not part of the contract (may change anytime): internal types in models.py, log
event names, and Prometheus metric names.
Off by default; localhost-bound when enabled. Set prometheus.enabled: true in
config to expose /metrics (receiver poll outcomes, MQTT publish/drop/reconnect
counters, active-aircraft gauge). Bind to 0.0.0.0 only if you want it on the
LAN — the endpoint is unauthenticated.
- Add-on shows an update is available but no Update button appears. A
Supervisor↔frontend desync — update from the CLI instead:
ha addons update <slug>(find the slug withha addons). Non-destructive; no uninstall needed. browser_modpopups (the tap-for-details cards) do nothing. Install browser_mod from HACS and add its integration (Settings → Devices & Services → Add Integration → Browser Mod), then restart HA and hard-refresh the browser. Sanity-check with Developer Tools → Actions →browser_mod.popup.- AGL alerts never fire, or the "Type" column is empty. AGL needs
elevation_mon the watchpoint (without it AGL is computed from MSL and misses low traffic). Aircraft type comes from the reference DB — enabledatabases(most transponders don't broadcast the type designator). - Duplicate HA entities with a
_2suffix after a rename. Use the device's "recreate entity ids" action, or delete the stale entities — ids derive from the device + entity name. - Add-on won't start after editing
extra_config. It's YAML inside an options field: indentation must be exact, and select-all before pasting (the editor appends, silently duplicating lists). Lists (alerts.rules,databases.sources) replace the toggle-generated ones wholesale — list every entry you want. - No aircraft appear. Check the receiver URL is reachable from the host and
the
bandis correct. A flaky receiver is isolated and marked unhealthy (receiver/<name>/status) rather than taking down the service. - 64-bit only. Images are built for
aarch64/amd64; 32-bit (armv7) is not supported.
uv sync # install deps + dev tools
uv run pytest # unit tests (no network, no broker)
uv run pytest -m integration # integration tests (requires Docker — Mosquitto)
uv run mypy src # type check (strict)
uv run ruff check src tests # lint
uv run ruff format --check . # format checkConventions live in CLAUDE.md; architecture and the phase
breakdown in DESIGN.md. Integration tests spin up a real
Mosquitto container via testcontainers and are skipped unless you pass
-m integration.
MIT.
