diff --git a/CHANGELOG.md b/CHANGELOG.md index cbc3e84..ea97044 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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//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 diff --git a/addon/config.yaml b/addon/config.yaml index d36be41..96b7875 100644 --- a/addon/config.yaml +++ b/addon/config.yaml @@ -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. diff --git a/config.example.yaml b/config.example.yaml index b84422a..e7f2063 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -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, diff --git a/docs/automations.example.yaml b/docs/automations.example.yaml index 84e7d8f..63f8b2f 100644 --- a/docs/automations.example.yaml +++ b/docs/automations.example.yaml @@ -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, diff --git a/pyproject.toml b/pyproject.toml index 5a62c5d..3921d51 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/ha_airspace/alerts.py b/src/ha_airspace/alerts.py index 9eed87f..6795b93 100644 --- a/src/ha_airspace/alerts.py +++ b/src/ha_airspace/alerts.py @@ -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//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"] diff --git a/src/ha_airspace/app.py b/src/ha_airspace/app.py index 21af628..33953cc 100644 --- a/src/ha_airspace/app.py +++ b/src/ha_airspace/app.py @@ -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: diff --git a/src/ha_airspace/enrichment.py b/src/ha_airspace/enrichment.py index f7383ee..0129df6 100644 --- a/src/ha_airspace/enrichment.py +++ b/src/ha_airspace/enrichment.py @@ -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 @@ -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"] diff --git a/src/ha_airspace/mqtt/publisher.py b/src/ha_airspace/mqtt/publisher.py index 7a11203..0b36216 100644 --- a/src/ha_airspace/mqtt/publisher.py +++ b/src/ha_airspace/mqtt/publisher.py @@ -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 @@ -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//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). @@ -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", diff --git a/src/ha_airspace/tracker.py b/src/ha_airspace/tracker.py index f64bf1a..6249510 100644 --- a/src/ha_airspace/tracker.py +++ b/src/ha_airspace/tracker.py @@ -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; diff --git a/tests/test_alerts.py b/tests/test_alerts.py index 7130b68..12c6bcc 100644 --- a/tests/test_alerts.py +++ b/tests/test_alerts.py @@ -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 diff --git a/tests/test_enrichment.py b/tests/test_enrichment.py index eb2d3aa..a60ed94 100644 --- a/tests/test_enrichment.py +++ b/tests/test_enrichment.py @@ -6,8 +6,8 @@ from ha_airspace.config import EnrichmentConfig, FlagConfig from ha_airspace.databases import DatabaseStore -from ha_airspace.enrichment import Enricher -from ha_airspace.models import AircraftObservation, AircraftState +from ha_airspace.enrichment import DRONE_FLAG, Enricher +from ha_airspace.models import AircraftObservation, AircraftState, DroneInfo _T0 = datetime(2026, 1, 1, 12, 0, 0, tzinfo=UTC) @@ -138,3 +138,52 @@ def test_db_metadata_reassigned_each_pass() -> None: store.swap({}) enricher.enrich(state) assert state.db_metadata == {} + + +# --------------------------------------------------------------------------- +# Derived `drone` flag +# --------------------------------------------------------------------------- + + +def _drone_state() -> AircraftState: + obs = AircraftObservation( + track_id="Sim_Serial_0001", + hex=None, + non_icao=True, + observed_at=_T0, + seen_by="dump3411", + band="remoteid", + drone=DroneInfo(id_type="serial", ua_type="multirotor", agl_ft=250.0), + ) + return AircraftState.from_first_observation(obs) + + +def test_remoteid_track_gets_drone_flag() -> None: + enricher = Enricher(EnrichmentConfig()) + state = _drone_state() + enricher.enrich(state) + assert DRONE_FLAG in state.flags + + +def test_adsb_track_does_not_get_drone_flag() -> None: + enricher = Enricher(EnrichmentConfig()) + state = _state() + enricher.enrich(state) + assert DRONE_FLAG not in state.flags + + +def test_drone_flag_survives_configured_flag_evaluation() -> None: + # evaluate_flags reassigns state.flags wholesale, so the derived flag has to + # be applied after it — this pins that ordering. + enricher = Enricher(EnrichmentConfig(flags={"emergency": FlagConfig(squawks=["7700"])})) + state = _drone_state() + enricher.enrich(state) + assert DRONE_FLAG in state.flags + + +def test_drone_flag_is_reapplied_each_pass() -> None: + enricher = Enricher(EnrichmentConfig()) + state = _drone_state() + enricher.enrich(state) + enricher.enrich(state) + assert state.flags == {DRONE_FLAG} diff --git a/tests/test_mqtt_publisher.py b/tests/test_mqtt_publisher.py index 9e5104f..33b6e21 100644 --- a/tests/test_mqtt_publisher.py +++ b/tests/test_mqtt_publisher.py @@ -159,6 +159,66 @@ def _make_publisher( # --------------------------------------------------------------------------- +class TestOnConnectAlertState: + """Retained alert state outlives the process, so on reconnect it must be + overwritten with the truth BEFORE availability flips — otherwise HA replays + a stale ``on`` as a fresh ENTER and fires the notification automation for a + detection that never happened.""" + + @staticmethod + def _topics(fake_client: FakeMqttClient) -> list[str]: + return [c["topic"] for c in fake_client.publishes] + + async def test_alert_state_published_before_status_online( + self, fake_client: FakeMqttClient + ) -> None: + pub = _make_publisher(fake_client) + await pub.on_connect(alert_states={"drone_nearby": False}) + + topics = self._topics(fake_client) + assert topics.index("airspace/alert/drone_nearby/active") < topics.index( + "airspace/status" + ), "alert truth must land while still offline, or HA replays the stale value" + + async def test_inactive_rule_overwrites_stale_retained_on( + self, fake_client: FakeMqttClient + ) -> None: + pub = _make_publisher(fake_client) + await pub.on_connect(alert_states={"drone_nearby": False}) + + active = next( + c for c in fake_client.publishes if c["topic"] == "airspace/alert/drone_nearby/active" + ) + assert active["payload"] == b"off" + assert active["retain"] is True + # The attribute topic is cleared too, so a departed aircraft's details + # don't linger on the entity. + info = next( + c for c in fake_client.publishes if c["topic"] == "airspace/alert/drone_nearby/info" + ) + assert info["payload"] == b"" + + async def test_active_rule_is_reasserted_not_cleared(self, fake_client: FakeMqttClient) -> None: + # Mid-run broker reconnect: the rule is genuinely active. Blanket- + # clearing here would flap the binary_sensor off and straight back on, + # which is the very duplicate-notification bug this fixes. + pub = _make_publisher(fake_client) + await pub.on_connect(alert_states={"drone_nearby": True}) + + active = next( + c for c in fake_client.publishes if c["topic"] == "airspace/alert/drone_nearby/active" + ) + assert active["payload"] == b"on" + assert "airspace/alert/drone_nearby/info" not in self._topics(fake_client) + + async def test_omitted_alert_states_changes_nothing(self, fake_client: FakeMqttClient) -> None: + pub = _make_publisher(fake_client) + await pub.on_connect() + + assert not [t for t in self._topics(fake_client) if "/alert/" in t] + assert fake_client.publishes[0]["topic"] == "airspace/status" + + class TestOnConnect: async def test_publishes_status_online_first(self, fake_client: FakeMqttClient) -> None: pub = _make_publisher(fake_client) diff --git a/tests/test_tracker.py b/tests/test_tracker.py index 6abf981..0e10816 100644 --- a/tests/test_tracker.py +++ b/tests/test_tracker.py @@ -198,6 +198,27 @@ def _make_tracker( # --------------------------------------------------------------------------- +class TestAlertStatesDelegation: + """The on-connect hook reads alert truth through the tracker, so it must + work with and without an evaluator configured.""" + + def test_empty_without_evaluator(self) -> None: + tracker = _make_tracker(FakePublisher(), Clock(_T0)) + assert tracker.alert_states() == {} + + def test_delegates_to_the_evaluator(self) -> None: + config = AlertsConfig( + rules=[AlertRule(name="drone_nearby", match=MatchBlock(max_distance_nm=0.5))] + ) + tracker = AircraftTracker( + FakePublisher(), # type: ignore[arg-type] + [_HOME], + clock=Clock(_T0), + alerts=AlertEvaluator(config, elevation_m_for=lambda _n: None), + ) + assert tracker.alert_states() == {"drone_nearby": False} + + class TestIngest: async def test_new_hex_creates_state(self, publisher: FakePublisher, clock: Clock) -> None: tracker = _make_tracker(publisher, clock) diff --git a/uv.lock b/uv.lock index 130d5a5..7de14c2 100644 --- a/uv.lock +++ b/uv.lock @@ -290,7 +290,7 @@ wheels = [ [[package]] name = "ha-airspace" -version = "1.1.6" +version = "1.1.7" source = { editable = "." } dependencies = [ { name = "aiomqtt" },