diff --git a/custom_components/lock_code_manager/entity.py b/custom_components/lock_code_manager/entity.py index 28ebc9bc..a5d9b3f9 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 11a232e5..3adb1a9c 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}