Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,37 @@ The **published MQTT payload contract** is versioned separately via
Additive, optional payload fields are backwards-compatible and do **not** bump
the major version; a removed/renamed/retyped field does.

## [1.1.7] — 2026-09-16
### Added
- Enrichment: a derived **`drone`** flag on every Remote ID track, usable in
alert rules like any other flag (`match: { flags: ["drone"] }`). Alert
`MatchBlock` keys are otherwise **purely geometric** — distance, altitude,
closing vector — so a rule named `drone_nearby` assembled from only those
matched *any* low, close track and pushed light aircraft and helicopters as
drones. Compose `flags: ["drone"]` to make such a rule actually drone-only.
The flag is automatic; it is not declared under `enrichment.flags`.
Backwards-compatible: existing rules keep their current behavior until the
flag is added to them.

**Not every drone-named rule wants it.** `drone_nearby` tracks someone
else's drone, so it does. `drone_conflict` tracks a low *manned* aircraft
inbound over the area your own drone is flying in, so that you can land —
the drone there is yours and is never tracked. Adding the flag to
`drone_conflict` would silence exactly the traffic it exists to warn about.
The example config now spells out which subject each rule watches.

### Fixed
- Alerts: a restart no longer replays the last alert as a new one. Retained
`alert/<rule>/active` outlives the process — a rule that was `on` when the
service exited stayed retained `on` — and `on_connect` published
`status: online` before any evaluation had run. Home Assistant therefore
flipped the `binary_sensor` `unavailable -> on` off a stale value and fired
the notification automation for a detection that never happened. The connect
hook now asserts the genuine current state of **every configured rule** while
still marked offline, before availability flips. It publishes the real active
set rather than a blanket clear, so a mid-run broker reconnect (where rules
are legitimately active) does not flap them off and straight back on.

## [1.1.6] — 2026-09-15
### Fixed
- Alerts: `max_alt_agl_ft` now resolves height above ground from a drone's
Expand Down
2 changes: 1 addition & 1 deletion addon/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
# Display name is "Airspace" (friendly, matches the MQTT device); the slug,
# package, repo, and image names stay ha-airspace as the technical identity.
name: Airspace
version: "1.1.6"
version: "1.1.7"
slug: ha_airspace
description: >-
Multi-source ADS-B + drone Remote ID enrichment to MQTT for Home Assistant.
Expand Down
51 changes: 34 additions & 17 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -185,25 +185,42 @@ receivers:
# # caps how far out these can ever fire, so a generous distance costs
# # little. `uv run scripts/watch_rid.py` prints each drone's real distance
# # and AGL — size against that, not against the numbers below.
# - name: drone_conflict # early warning: a track flying INBOUND
# match: # toward home, low and close (see FEED.md)
# max_closest_approach_nm: 0.5
# # MIND THE SUBJECT — these two rules watch for OPPOSITE things, and the
# # shared "drone" prefix hides it:
# #
# # drone_conflict — a MANNED AIRCRAFT inbound over the area YOUR drone
# # might be flying in, so you can land it. The subject is the plane;
# # the drone is yours and is not being tracked at all. Do NOT add
# # flags: ["drone"] here — it would match only drones and never warn
# # you about the traffic it exists to warn you about.
# # drone_nearby — SOMEONE ELSE'S drone physically near your watchpoint.
# # The subject is the drone, so flags: ["drone"] belongs here and is
# # the only thing keeping low aircraft and helicopters out of it.
# #
# # `drone` is derived automatically for any Remote ID track; you never
# # declare it under `flags:` above.
# - name: drone_conflict # low MANNED traffic inbound over your
# match: # drone's operating area (see FEED.md)
# max_closest_approach_nm: 10.8 # ~20 km: optimistic consumer-drone radio
# # range — the volume you might be in
# max_alt_agl_ft: 1640 # ~500 m: typical consumer max-altitude ceiling
# within_eta_s: 300
# within_eta_s: 600 # only if arriving within 10 min
# watchpoint: home
# - name: drone_nearby # catches a hovering/loitering drone that
# match: # drone_conflict's predictive gate misses:
# max_alt_agl_ft: 1640 # max_closest_approach_nm/within_eta_s need a
# max_distance_nm: 1.5 # sustained closing vector (ground_speed_kt +
# watchpoint: home # track_deg); a drone with near-zero speed or
# # no consistent heading toward the watchpoint
# # never gets a projected ETA, so it can be
# # arbitrarily close/low and still never match
# # drone_conflict alone. This rule is pure
# # presence — no inbound requirement — so pair
# # the two: drone_conflict for early warning on
# # a genuine approach, drone_nearby for "one's
# # just sitting there right now."
# - name: drone_nearby # someone else's drone, close and low
# match: # RIGHT NOW. Pure presence: no inbound
# flags: ["drone"] # requirement, because a hovering or
# max_alt_agl_ft: 1640 # loitering drone is the common case and
# max_distance_nm: 1.5 # has near-zero speed or no consistent
# watchpoint: home # heading, so it never gets a projected
# # ETA and no predictive rule can ever
# # catch it however close it gets.
# #
# # This is NOT "the half of drone_conflict
# # that drone_conflict misses" — the two
# # watch different subjects (see above).
# # Run both: drone_conflict warns you to
# # land yours, drone_nearby tells you
# # someone else's is overhead.

# --------------------------------------------------------------------------
# journal — SQLite history (Phase 2b). Optional; OMIT to disable persistence,
Expand Down
10 changes: 10 additions & 0 deletions docs/automations.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,16 @@
#
# - name: drone_nearby
# match:
# flags: ["drone"] # REQUIRED here — every other key is
# # geometric, so without it a low
# # helicopter or light aircraft trips
# # this too. Derived automatically for
# # Remote ID tracks.
# # NOTE the asymmetry with #5: this rule
# # watches someone else's DRONE, so it
# # wants the flag. #5 watches a manned
# # AIRCRAFT over your own drone's area,
# # so it must NOT have it.
# max_alt_agl_ft: 1640 # ~500 m: typical DJI max-altitude ceiling
# max_distance_nm: 1.5 # ~2.8 km. Deliberately generous: Remote ID
# # reception already caps the real range,
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "ha-airspace"
version = "1.1.6"
version = "1.1.7"
description = "Multi-source ADS-B enrichment service: aircraft.json → MQTT → Home Assistant."
readme = "README.md"
requires-python = ">=3.12"
Expand Down
13 changes: 13 additions & 0 deletions src/ha_airspace/alerts.py
Original file line number Diff line number Diff line change
Expand Up @@ -294,5 +294,18 @@ def active_rules(self) -> set[str]:
per-rule ``binary_sensor`` active topic."""
return {rule for rule, _hex in self._active}

def alert_states(self) -> dict[str, bool]:
"""Every *configured* rule name -> whether it currently has a match.

Unlike ``active_rules`` this names rules that are inactive too, so a
caller can assert the full truth rather than only the positives. Used on
broker (re)connect to overwrite retained ``alert/<rule>/active`` values
that outlived the process: a rule that was ``on`` when we died stays
retained ``on``, and HA replays that as a fresh ENTER when availability
returns. Synchronous and allocation-only, so it is a consistent snapshot
without locking — no await point can interleave a poll into it."""
active = self.active_rules()
return {rule.name: rule.name in active for rule in self._config.rules}


__all__ = ["AlertEvaluator", "AlertEvent", "AlertTransition", "rule_matches"]
5 changes: 4 additions & 1 deletion src/ha_airspace/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,10 @@ async def _on_broker_connect(self) -> None:
"""Republish the full retained set so a broker that lost its
retained state (restart) is brought back to truth: service status
+ discovery, then each receiver's location and current status."""
await self._publisher.on_connect(sw_version=__version__)
await self._publisher.on_connect(
sw_version=__version__,
alert_states=self._tracker.alert_states(),
)
for receiver in self._receivers:
location = self._locations.get(receiver.name)
if location is not None:
Expand Down
21 changes: 20 additions & 1 deletion src/ha_airspace/enrichment.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,18 @@

from ha_airspace.flags import evaluate_flags

DRONE_FLAG = "drone"
"""Derived flag marking a Remote ID track. Planned in the Phase-3 notes
("Flag rules can still apply (e.g. a ``drone`` flag)") and mirroring
``ORBIT_FLAG`` / ``SPOOF_FLAG``: a derived flag composes with the existing
grammar for free, so no new ``MatchBlock`` key is needed.

This exists because ``MatchBlock`` is otherwise **purely geometric** —
distance, altitude, closing vector. A rule named ``drone_nearby`` built from
only those keys matches *any* low, close track: light aircraft and
helicopters trip it exactly as readily as a quadcopter. Compose
``flags: ["drone"]`` into such a rule to make the name true."""

if TYPE_CHECKING:
from ha_airspace.config import EnrichmentConfig
from ha_airspace.databases import DatabaseStore
Expand Down Expand Up @@ -68,6 +80,13 @@ def enrich(self, state: AircraftState) -> None:
if isinstance(db_type, str) and db_type and state.canonical.aircraft_type is None:
state.canonical = replace(state.canonical, aircraft_type=db_type)
state.flags = evaluate_flags(state, self._config.flags)
# Intrinsic, not rule-driven: a Remote ID track *is* a drone. Applied
# here because this is the single owner of the flags assignment
# (evaluate_flags reassigns wholesale, so anything added earlier would be
# dropped), and it needs neither history nor a config toggle — unlike the
# orbit/spoof detectors, which are stateful and opt-in.
if "remoteid" in state.bands:
state.flags.add(DRONE_FLAG)


__all__ = ["Enricher"]
__all__ = ["DRONE_FLAG", "Enricher"]
26 changes: 22 additions & 4 deletions src/ha_airspace/mqtt/publisher.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

import json
import time
from collections.abc import Callable
from collections.abc import Callable, Mapping
from typing import Any

import structlog
Expand Down Expand Up @@ -102,13 +102,29 @@ def __init__(
# On-connect hook (status:online + discovery republish)
# ------------------------------------------------------------------

async def on_connect(self, *, sw_version: str | None = None) -> None:
async def on_connect(
self,
*,
sw_version: str | None = None,
alert_states: Mapping[str, bool] | None = None,
) -> None:
"""Run after every successful broker connect.

Order matters:
1. Publish ``airspace/status: online`` retained, so HA flips the
1. Overwrite every ``alert/<rule>/active`` with the caller's current
truth, while we are still marked offline. Retained alert state
outlives the process: a rule that was ``on`` when we exited stays
retained ``on``, so flipping availability first makes HA replay it
as a brand-new ENTER and fire the automation for a detection that
never happened. ``client.py`` already guards ``status`` this way
("never sees stale ``online``"); the alert topics need it too.
``alert_states`` must be the *genuine* current set, not a blanket
clear — on a mid-run broker reconnect rules are legitimately
active, and asserting ``off`` there would flap them off and
straight back on.
2. Publish ``airspace/status: online`` retained, so HA flips the
availability sensors before discovery payloads land.
2. Publish the full discovery payload set (idempotent on
3. Publish the full discovery payload set (idempotent on
retained topics; protects against the broker losing
retained state on a restart).

Expand All @@ -120,6 +136,8 @@ async def on_connect(self, *, sw_version: str | None = None) -> None:
self._last_receiver_status.clear()
self._nearest_was_empty = False
self._nearest_drone_was_empty = False
for rule, active in (alert_states or {}).items():
await self.publish_alert_active(rule, active=active)
await self._client.publish(
f"{self._base}/status",
b"online",
Expand Down
7 changes: 7 additions & 0 deletions src/ha_airspace/tracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,13 @@ async def _evaluate_alerts(self, purged: list[str]) -> None:
for rule in touched_rules:
await self._publisher.publish_alert_active(rule, active=rule in active)

def alert_states(self) -> dict[str, bool]:
"""Configured rule name -> currently active. Empty when no evaluator is
configured. Synchronous by design: the caller (the on-connect hook) runs
on the MQTT loop, and taking the tracker lock there could deadlock
against a tick that is blocked enqueueing a publish."""
return {} if self._alerts is None else self._alerts.alert_states()

async def _photo_for(self, state: AircraftState) -> PhotoPayload | None:
"""Aircraft photo for an alerting track, when photo enrichment is
configured and the track has an ICAO hex (Planespotters is ICAO-keyed;
Expand Down
66 changes: 66 additions & 0 deletions tests/test_alerts.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,72 @@ def test_max_alt_agl_missing_altitude_no_match(self) -> None:
assert not rule_matches(_state(alt_baro_ft=None), match, elevation_m_for=lambda _n: 200.0)


class TestDroneOnlyRule:
"""Regression: `drone_nearby` built from geometry alone fired on manned
aircraft. Observed in the field on N1629A (hex a0fcb0, a PIAT at 1575 ft
and 3.70 nm) — a light twin pushed as a drone. `flags: ["drone"]` is the
only thing that makes such a rule drone-only."""

_GEOMETRIC_ONLY = MatchBlock(max_alt_agl_ft=1640, max_distance_nm=4.0, watchpoint="home")
_DRONE_ONLY = MatchBlock(
flags=["drone"], max_alt_agl_ft=1640, max_distance_nm=4.0, watchpoint="home"
)

def _aircraft(self) -> AircraftState:
# The real N1629A geometry from the misfire.
return _state(hex_code="a0fcb0", alt_baro_ft=1575, distance_home=3.698)

def _drone(self) -> AircraftState:
st = _drone_state(agl_ft=278.9, distance_home=0.721)
st.flags = {"drone"}
return st

def test_geometric_rule_catches_manned_aircraft(self) -> None:
# Documents *why* the flag is needed — this is the bug, not the fix.
assert rule_matches(self._aircraft(), self._GEOMETRIC_ONLY, elevation_m_for=lambda _n: 0.0)

def test_drone_flag_excludes_manned_aircraft(self) -> None:
assert not rule_matches(self._aircraft(), self._DRONE_ONLY, elevation_m_for=lambda _n: 0.0)

def test_drone_flag_still_matches_a_drone(self) -> None:
assert rule_matches(self._drone(), self._DRONE_ONLY, elevation_m_for=lambda _n: 0.0)


class TestAlertStatesSnapshot:
"""``alert_states`` names every configured rule, not just the matching ones
— the on-connect hook needs to assert ``off`` for rules whose retained
``on`` outlived the process."""

_CONFIG = AlertsConfig(
rules=[
AlertRule(name="drone_nearby", match=MatchBlock(max_distance_nm=0.5)),
AlertRule(name="military_close", match=MatchBlock(flags=["military"])),
]
)

def _evaluator(self) -> AlertEvaluator:
return AlertEvaluator(self._CONFIG, elevation_m_for=_no_elevation)

def test_lists_every_configured_rule_when_nothing_matches(self) -> None:
# Fresh process: nothing tracked yet, so every rule must read False —
# silence here is what leaves a stale retained "on" in place.
assert self._evaluator().alert_states() == {
"drone_nearby": False,
"military_close": False,
}

def test_reflects_the_active_set(self) -> None:
ev = self._evaluator()
ev.evaluate([_state(distance_home=0.1)], [])
assert ev.alert_states() == {"drone_nearby": True, "military_close": False}

def test_returns_to_false_after_exit(self) -> None:
ev = self._evaluator()
ev.evaluate([_state(distance_home=0.1)], [])
ev.evaluate([_state(distance_home=9.0)], [])
assert ev.alert_states()["drone_nearby"] is False


class TestDroneAltitudeGate:
"""A Remote ID track has no barometric altitude, so an AGL-gated rule must
read the natively broadcast AGL. Regression: drone_nearby / drone_conflict
Expand Down
Loading