Skip to content
Open
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
43 changes: 36 additions & 7 deletions custom_components/lock_code_manager/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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__)


Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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:
"""
Expand Down Expand Up @@ -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(
Expand Down
32 changes: 32 additions & 0 deletions tests/test_binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Loading