From 61531396a0d5e92a0f68baad2ebfb44bd975da17 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 13 Mar 2026 07:40:38 -0700 Subject: [PATCH] fix: Home Assistant event filtering now closed by default (#1169) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, when no watch_domains or watch_entities were configured, ALL state_changed events passed through to the agent, causing users to be flooded with notifications for every HA entity change. Now events are dropped by default unless the user explicitly configures: - watch_domains: list of domains to monitor (e.g. climate, light) - watch_entities: list of specific entity IDs to monitor - watch_all: true (new option — opt-in to receive all events) A warning is logged at connect time if no filters are configured, guiding users to set up their HA platform config. All 49 gateway HA tests + 52 HA tool tests pass. --- gateway/platforms/homeassistant.py | 16 +++++++++++- tests/gateway/test_homeassistant.py | 40 +++++++++++++++++++++-------- 2 files changed, 44 insertions(+), 12 deletions(-) diff --git a/gateway/platforms/homeassistant.py b/gateway/platforms/homeassistant.py index 930470608..49636e524 100644 --- a/gateway/platforms/homeassistant.py +++ b/gateway/platforms/homeassistant.py @@ -83,6 +83,7 @@ class HomeAssistantAdapter(BasePlatformAdapter): self._watch_domains: Set[str] = set(extra.get("watch_domains", [])) self._watch_entities: Set[str] = set(extra.get("watch_entities", [])) self._ignore_entities: Set[str] = set(extra.get("ignore_entities", [])) + self._watch_all: bool = bool(extra.get("watch_all", False)) self._cooldown_seconds: int = int(extra.get("cooldown_seconds", 30)) # Cooldown tracking: entity_id -> last_event_timestamp @@ -115,6 +116,15 @@ class HomeAssistantAdapter(BasePlatformAdapter): # Dedicated REST session for send() calls self._rest_session = aiohttp.ClientSession() + # Warn if no event filters are configured + if not self._watch_domains and not self._watch_entities and not self._watch_all: + logger.warning( + "[%s] No watch_domains, watch_entities, or watch_all configured. " + "All state_changed events will be dropped. Configure filters in " + "your HA platform config to receive events.", + self.name, + ) + # Start background listener self._listen_task = asyncio.create_task(self._listen_loop()) self._running = True @@ -257,13 +267,17 @@ class HomeAssistantAdapter(BasePlatformAdapter): if entity_id in self._ignore_entities: return - # Apply domain/entity watch filters + # Apply domain/entity watch filters (closed by default — require + # explicit watch_domains, watch_entities, or watch_all to forward) domain = entity_id.split(".")[0] if "." in entity_id else "" if self._watch_domains or self._watch_entities: domain_match = domain in self._watch_domains if self._watch_domains else False entity_match = entity_id in self._watch_entities if self._watch_entities else False if not domain_match and not entity_match: return + elif not self._watch_all: + # No filters configured and watch_all is off — drop the event + return # Apply cooldown now = time.time() diff --git a/tests/gateway/test_homeassistant.py b/tests/gateway/test_homeassistant.py index 8701ef14a..f92da0039 100644 --- a/tests/gateway/test_homeassistant.py +++ b/tests/gateway/test_homeassistant.py @@ -208,7 +208,7 @@ class TestAdapterInit: def test_watch_filters_parsed(self): config = PlatformConfig( - enabled=True, token="t", + enabled=True, token="***", extra={ "watch_domains": ["climate", "binary_sensor"], "watch_entities": ["sensor.special"], @@ -220,15 +220,25 @@ class TestAdapterInit: assert adapter._watch_domains == {"climate", "binary_sensor"} assert adapter._watch_entities == {"sensor.special"} assert adapter._ignore_entities == {"sensor.uptime", "sensor.cpu"} + assert adapter._watch_all is False assert adapter._cooldown_seconds == 120 + def test_watch_all_parsed(self): + config = PlatformConfig( + enabled=True, token="***", + extra={"watch_all": True}, + ) + adapter = HomeAssistantAdapter(config) + assert adapter._watch_all is True + def test_defaults_when_no_extra(self, monkeypatch): monkeypatch.setenv("HASS_TOKEN", "tok") - config = PlatformConfig(enabled=True, token="tok") + config = PlatformConfig(enabled=True, token="***") adapter = HomeAssistantAdapter(config) assert adapter._watch_domains == set() assert adapter._watch_entities == set() assert adapter._ignore_entities == set() + assert adapter._watch_all is False assert adapter._cooldown_seconds == 30 @@ -260,7 +270,7 @@ def _make_event(entity_id, old_state, new_state, old_attrs=None, new_attrs=None) class TestEventFilteringPipeline: @pytest.mark.asyncio async def test_ignored_entity_not_forwarded(self): - adapter = _make_adapter(ignore_entities=["sensor.uptime"]) + adapter = _make_adapter(watch_all=True, ignore_entities=["sensor.uptime"]) await adapter._handle_ha_event(_make_event("sensor.uptime", "100", "101")) adapter.handle_message.assert_not_called() @@ -298,26 +308,34 @@ class TestEventFilteringPipeline: assert "10W" in msg_event.text and "20W" in msg_event.text @pytest.mark.asyncio - async def test_no_filters_passes_everything(self): + async def test_no_filters_blocks_everything(self): + """Without watch_domains, watch_entities, or watch_all, events are dropped.""" adapter = _make_adapter(cooldown_seconds=0) await adapter._handle_ha_event(_make_event("cover.blinds", "closed", "open")) + adapter.handle_message.assert_not_called() + + @pytest.mark.asyncio + async def test_watch_all_passes_everything(self): + """With watch_all=True and no specific filters, all events pass through.""" + adapter = _make_adapter(watch_all=True, cooldown_seconds=0) + await adapter._handle_ha_event(_make_event("cover.blinds", "closed", "open")) adapter.handle_message.assert_called_once() @pytest.mark.asyncio async def test_same_state_not_forwarded(self): - adapter = _make_adapter(cooldown_seconds=0) + adapter = _make_adapter(watch_all=True, cooldown_seconds=0) await adapter._handle_ha_event(_make_event("light.x", "on", "on")) adapter.handle_message.assert_not_called() @pytest.mark.asyncio async def test_empty_entity_id_skipped(self): - adapter = _make_adapter() + adapter = _make_adapter(watch_all=True) await adapter._handle_ha_event({"data": {"entity_id": ""}}) adapter.handle_message.assert_not_called() @pytest.mark.asyncio async def test_message_event_has_correct_source(self): - adapter = _make_adapter(cooldown_seconds=0) + adapter = _make_adapter(watch_all=True, cooldown_seconds=0) await adapter._handle_ha_event( _make_event("light.test", "off", "on", new_attrs={"friendly_name": "Test Light"}) @@ -336,7 +354,7 @@ class TestEventFilteringPipeline: class TestCooldown: @pytest.mark.asyncio async def test_cooldown_blocks_rapid_events(self): - adapter = _make_adapter(cooldown_seconds=60) + adapter = _make_adapter(watch_all=True, cooldown_seconds=60) event = _make_event("sensor.temp", "20", "21", new_attrs={"friendly_name": "Temp"}) @@ -351,7 +369,7 @@ class TestCooldown: @pytest.mark.asyncio async def test_cooldown_expires(self): - adapter = _make_adapter(cooldown_seconds=1) + adapter = _make_adapter(watch_all=True, cooldown_seconds=1) event = _make_event("sensor.temp", "20", "21", new_attrs={"friendly_name": "Temp"}) @@ -368,7 +386,7 @@ class TestCooldown: @pytest.mark.asyncio async def test_different_entities_independent_cooldowns(self): - adapter = _make_adapter(cooldown_seconds=60) + adapter = _make_adapter(watch_all=True, cooldown_seconds=60) await adapter._handle_ha_event( _make_event("sensor.a", "1", "2", new_attrs={"friendly_name": "A"}) @@ -387,7 +405,7 @@ class TestCooldown: @pytest.mark.asyncio async def test_zero_cooldown_passes_all(self): - adapter = _make_adapter(cooldown_seconds=0) + adapter = _make_adapter(watch_all=True, cooldown_seconds=0) for i in range(5): await adapter._handle_ha_event(