From b390e7dfe624918e9a640c7a27e335df36d76e96 Mon Sep 17 00:00:00 2001 From: terafin Date: Sat, 27 Jun 2026 09:12:05 +0000 Subject: [PATCH] perf(entity): scope availability subscription to the lock entities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The availability binary_sensor registered its state-change handler with `async_track_state_change_filtered(hass, TrackStates(True, set(), set()), ...)`. `all_states=True` subscribes to EVERY state change in the Home Assistant instance; the handler then filters in Python and early-returns for any entity that isn't one of the slot's locks. On a production instance with ~6-7 of these entities, cProfile measured the handler running ~86,580 times / 30s (~2,900/sec) and its inner generator ~640,100 times / 30s — the single largest application-level CPU consumer, doing no useful work. Scope the subscription to just the lock entities via `TrackStates(False, {lock.lock.entity_id for lock in self.locks}, set())`. Because `self.locks` is mutated at runtime by `_handle_add_locks` / `_handle_remove_lock`, the tracked set is re-scoped with `async_update_listeners` whenever locks change, and availability is recomputed at that point (previously the all-states firehose recomputed it implicitly). Pure performance fix; no behavior change. Adds a regression test asserting the subscription is scoped to the locks and re-scopes on lock add/remove. Co-Authored-By: Claude Opus 4.8 (1M context) --- custom_components/lock_code_manager/entity.py | 43 ++++++++++++++++--- tests/test_binary_sensor.py | 32 ++++++++++++++ 2 files changed, 68 insertions(+), 7 deletions(-) diff --git a/custom_components/lock_code_manager/entity.py b/custom_components/lock_code_manager/entity.py index 28ebc9bcc..a5d9b3f98 100644 --- a/custom_components/lock_code_manager/entity.py +++ b/custom_components/lock_code_manager/entity.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import Any, final +from typing import TYPE_CHECKING, Any, final from homeassistant.components.lock import LockState from homeassistant.config_entries import ConfigEntry @@ -31,6 +31,9 @@ from .domain.slot_coordinator import SlotEntityCoordinator from .providers import BaseLock +if TYPE_CHECKING: + from homeassistant.helpers.event import _TrackStateChangeFiltered + _LOGGER = logging.getLogger(__name__) @@ -82,6 +85,12 @@ def __init__( # bound to the new coordinator. See ``SlotEntityCoordinator``. self._slot_coordinator: SlotEntityCoordinator | None = None + # Availability is driven by a state-change subscription scoped to just + # the lock entities (re-scoped when locks are added/removed), rather + # than every state in the instance. Created in ``async_added_to_hass``; + # ``None`` until then. + self._available_state_tracker: _TrackStateChangeFiltered | None = None + @final @property def _state(self) -> Any: @@ -132,6 +141,7 @@ def _handle_remove_lock(self, lock_entity_id: str) -> None: self.locks = [ lock for lock in self.locks if lock.lock.entity_id != lock_entity_id ] + self._async_handle_locks_updated() @callback def _handle_add_locks(self, locks: list[BaseLock]) -> None: @@ -141,6 +151,26 @@ def _handle_add_locks(self, locks: list[BaseLock]) -> None: Can be overwritten by platforms. """ self.locks.extend(locks) + self._async_handle_locks_updated() + + @callback + def _async_handle_locks_updated(self) -> None: + """ + Re-scope the availability subscription after the lock set changes. + + ``self.locks`` is mutated at runtime by ``_handle_add_locks`` and + ``_handle_remove_lock``. The availability subscription tracks only the + lock entities (not every state change in the instance), so it is + re-scoped to the new set here. Availability is recomputed immediately + because the added/removed lock's own state change will not otherwise + trigger an update. + """ + if (tracker := self._available_state_tracker) is None: + return + tracker.async_update_listeners( + TrackStates(False, {lock.lock.entity_id for lock in self.locks}, set()) + ) + self._handle_available_state_update() def _get_removal_uid(self) -> str: """ @@ -240,13 +270,12 @@ async def async_added_to_hass(self) -> None: ) self._register_callbacks() - self.async_on_remove( - async_track_state_change_filtered( - self.hass, - TrackStates(True, set(), set()), - self._handle_available_state_update, - ).async_remove + self._available_state_tracker = async_track_state_change_filtered( + self.hass, + TrackStates(False, {lock.lock.entity_id for lock in self.locks}, set()), + self._handle_available_state_update, ) + self.async_on_remove(self._available_state_tracker.async_remove) self._handle_available_state_update() _LOGGER.debug( diff --git a/tests/test_binary_sensor.py b/tests/test_binary_sensor.py index 11a232e5f..3adb1a9cb 100644 --- a/tests/test_binary_sensor.py +++ b/tests/test_binary_sensor.py @@ -2050,3 +2050,35 @@ async def test_rapid_coordinator_updates_coalesce( assert set_calls[0] == (1, "1234", "test1"), ( "Sync should restore the configured PIN" ) + + +async def test_availability_subscription_scoped_to_locks( + hass: HomeAssistant, + mock_lock_config_entry, + lock_code_manager_config_entry, +): + """Availability subscription is scoped to the lock entities, and re-scopes on lock changes. + + Regression guard for the previous ``TrackStates(True, ...)`` all-states + subscription, which fired the availability handler on every state change in + the entire instance. The subscription must track only the lock entities and + be re-scoped when locks are added/removed at runtime. + """ + entity = get_in_sync_entity_obj(hass, SLOT_1_ACTIVE_ENTITY) + tracker = entity._available_state_tracker + assert tracker is not None + + # Scoped to the configured locks, NOT every state in the instance. + assert tracker._last_track_states.all_states is False + assert tracker._last_track_states.entities == {LOCK_1_ENTITY_ID, LOCK_2_ENTITY_ID} + + # Removing a lock re-scopes the subscription to the remaining locks. + entity._handle_remove_lock(LOCK_1_ENTITY_ID) + assert tracker._last_track_states.all_states is False + assert tracker._last_track_states.entities == {LOCK_2_ENTITY_ID} + + # Re-adding the lock re-scopes the subscription to include it again. + re_added_lock = lock_code_manager_config_entry.runtime_data.locks[LOCK_1_ENTITY_ID] + entity._handle_add_locks([re_added_lock]) + assert tracker._last_track_states.all_states is False + assert tracker._last_track_states.entities == {LOCK_1_ENTITY_ID, LOCK_2_ENTITY_ID}