Feat(allocator): Notify on unreadable lock slots

Feat(allocator): Notify on unreadable lock slots
This commit is contained in:
Andrew Grimberg authored and GitHub committed 2026-10-15 10:05:48 -07:00
commit 6a6602ba80
6 files changed
+391 -1

No files matched your search

+37
View File
@@ -193,6 +193,43 @@ consumes it, the in-memory request lapses; invoke the service again.
Any already-created replaced-code hold remains in the registry, and each
refresh retries it until the allocator can safely release it.
### Unreadable Lock Slots
The integration refuses to issue new door codes while any managed Keymaster
slot stays unreadable — a slot whose Keymaster PIN or name entity reports
`unavailable` or `unknown`, or no longer exists at all. Such a slot may still
hold a working guest credential that the integration cannot see, so issuing into that uncertainty risks a code collision or
clobbers a credential a guest relies on. The behaviour deliberately fails
closed.
In that situation the integration raises a persistent notification naming the
config entry, the lock, and the offending slot numbers, and logs a matching
warning. The integration dismisses that notification as soon as every managed
slot reports its state again.
Watch the scope:
- When a slot goes unreadable before Home Assistant starts, or before the
affected entry finishes one clean adoption cycle, the integration withholds
codes for **all** Rental Control entries, not just the affected one. Every
entry shares one door code space, so the integration must account for every
entry before it can safely issue any code. You may then also see a
`code adoption pending` notification that lists the entries still waiting,
annotated with their unreadable slot numbers.
- When a slot goes unreadable later, after that entry already adopted its
codes cleanly, only that entry stops receiving codes.
**Remedy:** restore readable Keymaster entities for the named slot, so that
its PIN and name entities report a real state again. Deleting those Keymaster
entities does **not** clear the condition: Rental Control still watches every
slot in its configured range and treats a slot with missing entities as
unknown, which keeps the freeze in place. When the slot disappears permanently,
reconfigure this entry's start slot or event count so that the managed range
no longer covers it. Once the slot reads correctly, or leaves the
managed range, the next refresh resumes issuance and clears the notification.
Home Assistant currently offers no control that excludes a permanently dead
slot from an otherwise unchanged managed range.
### Home Assistant Events
The integration fires events on the Home Assistant event bus for use in
@@ -41,6 +41,7 @@ from .const import NAME
from .const import PLATFORMS
from .const import UNSUB_LISTENERS
from .coordinator import RentalControlCoordinator
from .coordinator_helpers.code_allocation import dismiss_unreadable_slots
from .listeners import (
async_register_keymaster_listener as async_register_keymaster_listener,
)
@@ -199,6 +200,8 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry):
):
await allocator.async_unregister_entry(config_entry.entry_id)
dismiss_unreadable_slots(hass, config_entry.entry_id)
hass.data[DOMAIN].pop(config_entry.entry_id)
async_dismiss(hass, notification_id)
@@ -208,6 +211,7 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry):
async def async_remove_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
"""Handle permanent removal of a Rental Control entry."""
dismiss_unreadable_slots(hass, config_entry.entry_id)
allocator = get_allocator(hass)
if allocator is None:
return
@@ -66,6 +66,7 @@ class DoorCodeAllocator:
self._store = RegistryStore(hass)
self._lock = asyncio.Lock()
self._pending_adoption = self._seed_pending_adoption()
self._pending_adoption_slots: dict[str, frozenset[int]] = {}
self._gate_deadline = (
monotonic() + _ADOPTION_GATE_WARNING_SECONDS
if self._pending_adoption
@@ -104,6 +105,7 @@ class DoorCodeAllocator:
"""Remove an entry from the pending adoption gate."""
async with self._lock:
self._pending_adoption.discard(entry_id)
self._pending_adoption_slots.pop(entry_id, None)
if not self._pending_adoption:
self._gate_deadline = 0.0
self._gate_warning_sent = False
@@ -221,7 +223,10 @@ class DoorCodeAllocator:
or monotonic() < self._gate_deadline
):
return
pending = ", ".join(sorted(self._pending_adoption))
pending = ", ".join(
self._describe_pending_entry(entry_id)
for entry_id in sorted(self._pending_adoption)
)
message = (
"Shared code allocator is still waiting for adoption from "
f"entries: {pending}. New code issuance remains disabled."
@@ -235,6 +240,14 @@ class DoorCodeAllocator:
)
self._gate_warning_sent = True
def _describe_pending_entry(self, entry_id: str) -> str:
"""Return a pending entry label annotated with its blocking slots."""
slots = sorted(self._pending_adoption_slots.get(entry_id, ()))
if not slots:
return entry_id
label = "slots" if len(slots) > 1 else "slot"
return f"{entry_id} (unreadable {label} {', '.join(map(str, slots))})"
async def async_sweep(
self, observation: CycleObservation, active_keys: set[str]
) -> ReleaseReport:
@@ -284,6 +297,7 @@ class DoorCodeAllocator:
"""Release lockless removed owners and retain unverifiable lock owners."""
async with self._lock:
self._pending_adoption.discard(entry_id)
self._pending_adoption_slots.pop(entry_id, None)
if not self._pending_adoption:
self._gate_deadline = 0.0
self._gate_warning_sent = False
@@ -155,6 +155,11 @@ async def resolve_cycle(
allocator._rekey_unlocked(old_key, new_key)
unaccounted = unaccounted_slots(allocator, request.observation)
_track_pending_adoption_slots(
allocator,
request.observation.entry_id,
request.observation.unreadable_slots,
)
if request.adoption_complete and not unaccounted:
allocator._pending_adoption.discard(request.observation.entry_id)
if not allocator._pending_adoption:
@@ -218,6 +223,19 @@ async def resolve_cycle(
)
def _track_pending_adoption_slots(
allocator: DoorCodeAllocator, entry_id: str, unreadable: frozenset[int]
) -> None:
"""Record the unreadable slots blocking an entry's adoption, if any."""
tracked = getattr(allocator, "_pending_adoption_slots", None)
if tracked is None:
return
if unreadable:
tracked[entry_id] = unreadable
else:
tracked.pop(entry_id, None)
def unaccounted_slots(
allocator: DoorCodeAllocator, observation: CycleObservation
) -> frozenset[int]:
@@ -9,6 +9,7 @@ from typing import TYPE_CHECKING
import uuid
from homeassistant.components.persistent_notification import async_create
from homeassistant.components.persistent_notification import async_dismiss
from homeassistant.util import dt
from ..allocator import get_allocator
@@ -39,6 +40,7 @@ if TYPE_CHECKING:
_LOGGER = logging.getLogger(__name__)
_EXHAUSTED_NOTIFICATION_ID = f"{DOMAIN}_code_registry_exhausted"
_UNREADABLE_NOTIFICATION_ID = f"{DOMAIN}_unreadable_slots"
async def async_resolve_codes(
@@ -115,6 +117,9 @@ async def async_resolve_codes(
entry_id,
sorted(result.unaccounted_slots),
)
report_unreadable_slots(
hass, entry_id, lockname, sorted(observation.unreadable_slots)
)
if _apply_allocation_results(allocator, entry_id, reservations, result):
message = (
f"Shared code allocator exhausted the configured code space for "
@@ -140,6 +145,55 @@ async def async_resolve_codes(
)
def unreadable_slots_notification_id(entry_id: str) -> str:
"""Return the persistent notification id for an entry's unreadable slots."""
return f"{_UNREADABLE_NOTIFICATION_ID}_{entry_id}"
def dismiss_unreadable_slots(hass: object, entry_id: str) -> None:
"""Clear an entry's unreadable-slot notification."""
if not hasattr(hass, "bus"):
return
async_dismiss(hass, unreadable_slots_notification_id(entry_id))
def report_unreadable_slots(
hass: object,
entry_id: str,
lockname: str | None,
slots: list[int],
) -> None:
"""Reconcile the persistent notification for unreadable managed slots."""
if not hasattr(hass, "bus"):
return
if not slots:
dismiss_unreadable_slots(hass, entry_id)
return
slot_list = ", ".join(str(slot) for slot in slots)
label = "slots" if len(slots) > 1 else "slot"
lock_label = lockname or "unknown lock"
message = (
f"Rental Control entry {entry_id} cannot read managed {label} "
f"{slot_list} on lock {lock_label}. Door code issuance is withheld "
"while those slots stay unreadable, because an unreadable slot may "
"still hold a code the integration cannot see. If this condition is "
"present when Home Assistant starts, issuance is withheld for ALL "
"Rental Control entries, not just this one, because every entry "
"shares one door code space. To recover, restore readable Keymaster "
f"entities for {label} {slot_list}; deleting those entities does not "
"help, because the slots stay in the managed range and report as "
"unknown. If the slots are gone for good, reconfigure this entry's "
"start slot or event count so that the managed range no longer "
"covers them."
)
async_create(
hass,
message,
title=f"{NAME} unreadable lock slots",
notification_id=unreadable_slots_notification_id(entry_id),
)
def _apply_adoption_results(
allocator: object,
entry_id: str,
+263
View File
@@ -20,6 +20,7 @@ from custom_components.rental_control.allocator.models import AllocationOrigin
from custom_components.rental_control.allocator.models import AllocationRequest
from custom_components.rental_control.allocator.models import CycleObservation
from custom_components.rental_control.allocator.models import CycleRequest
from custom_components.rental_control.const import DOMAIN
from custom_components.rental_control.coordinator_helpers import code_allocation
from custom_components.rental_control.coordinator_helpers.coordinator_refresh_shell import (
CoordinatorRefreshMixin,
@@ -1231,3 +1232,265 @@ def _allocator() -> DoorCodeAllocator:
allocator = DoorCodeAllocator(_fake_hass())
allocator._store = cast(Any, SimpleNamespace(async_save=lambda _registry: None))
return allocator
class _UnaccountedAllocator(FakeAllocator):
"""Allocator double that reports a fixed unaccounted slot set."""
def __init__(self, unaccounted: frozenset[int]) -> None:
"""Initialize with the slots to report as unaccounted."""
super().__init__()
self.unaccounted = unaccounted
async def async_resolve_cycle(self, request: CycleRequest) -> Any:
"""Echo the base result with the configured unaccounted slots."""
result = await super().async_resolve_cycle(request)
return SimpleNamespace(
adopted=result.adopted,
allocated=result.allocated,
unaccounted_slots=self.unaccounted,
)
def _notifying_hass() -> Any:
"""Build a hass double that looks capable of persistent notifications."""
hass = _fake_hass()
hass.bus = SimpleNamespace()
return hass
def _unreadable_slot(slot: int) -> ManagedSlot:
"""Build a managed slot that reports no readable state."""
return ManagedSlot(
slot=slot,
managed=True,
status=SlotStatus.UNKNOWN,
actual_name=None,
actual_code=None,
actual_code_present=False,
)
async def test_unreadable_slots_raise_notification(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Unreadable managed slots notify the operator by slot number."""
created: list[tuple[str, dict[str, Any]]] = []
monkeypatch.setattr(
code_allocation,
"async_create",
lambda _hass, message, **kwargs: created.append((message, kwargs)),
)
monkeypatch.setattr(
code_allocation,
"get_allocator",
lambda _hass: _UnaccountedAllocator(frozenset({5, 3})),
)
await code_allocation.async_resolve_codes(
_notifying_hass(),
"entry-a",
"front",
4,
[_unreadable_slot(3), _unreadable_slot(5)],
[_reservation("identity-a", code="1111")],
)
assert len(created) == 1
message, kwargs = created[0]
assert "slots 3, 5" in message
assert "entry-a" in message
assert "front" in message
assert "withheld" in message
assert "ALL Rental Control entries" in message
assert "restore readable Keymaster entities" in message
assert kwargs["notification_id"] == f"{DOMAIN}_unreadable_slots_entry-a"
async def test_owned_unreadable_slot_keeps_notification(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""An unreadable slot owned in the registry still blocks and still warns."""
created: list[tuple[str, dict[str, Any]]] = []
dismissed: list[str] = []
monkeypatch.setattr(
code_allocation,
"async_create",
lambda _hass, message, **kwargs: created.append((message, kwargs)),
)
monkeypatch.setattr(
code_allocation,
"async_dismiss",
lambda _hass, notification_id: dismissed.append(notification_id),
)
allocator = _allocator()
monkeypatch.setattr(code_allocation, "get_allocator", lambda _hass: allocator)
await allocator.async_allocate(
AllocationRequest(
entry_id="entry-a",
identity_key="identity-a",
preferred_code="1111",
code_length=4,
lockname="front",
slot=1,
)
)
await code_allocation.async_resolve_codes(
_notifying_hass(),
"entry-a",
"front",
4,
[_unreadable_slot(1)],
[_reservation("identity-a", code="1111")],
)
assert dismissed == []
assert len(created) == 1
assert "slot 1" in created[0][0]
assert allocator._pending_adoption_slots == {"entry-a": frozenset({1})}
async def test_unreadable_notification_dismissed_when_clear(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A repaired slot dismisses the stale unreadable-slot notification."""
dismissed: list[str] = []
monkeypatch.setattr(
code_allocation,
"async_dismiss",
lambda _hass, notification_id: dismissed.append(notification_id),
)
monkeypatch.setattr(
code_allocation,
"get_allocator",
lambda _hass: _UnaccountedAllocator(frozenset()),
)
await code_allocation.async_resolve_codes(
_notifying_hass(),
"entry-a",
"front",
4,
[],
[_reservation("identity-a", code="1111")],
)
assert dismissed == [f"{DOMAIN}_unreadable_slots_entry-a"]
async def test_unreadable_notification_skipped_without_bus(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Non-Home-Assistant callers never touch the notification platform."""
calls: list[str] = []
monkeypatch.setattr(
code_allocation,
"async_create",
lambda *_args, **_kwargs: calls.append("create"),
)
monkeypatch.setattr(
code_allocation,
"async_dismiss",
lambda *_args, **_kwargs: calls.append("dismiss"),
)
monkeypatch.setattr(
code_allocation,
"get_allocator",
lambda _hass: _UnaccountedAllocator(frozenset({1})),
)
await code_allocation.async_resolve_codes(
SimpleNamespace(),
"entry-a",
"front",
4,
[_unreadable_slot(1)],
[_reservation("identity-a", code="1111")],
)
assert calls == []
def test_dismiss_unreadable_slots_uses_entry_notification_id(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Entry teardown clears the entry's own unreadable-slot notification."""
dismissed: list[str] = []
monkeypatch.setattr(
code_allocation,
"async_dismiss",
lambda _hass, notification_id: dismissed.append(notification_id),
)
code_allocation.dismiss_unreadable_slots(_notifying_hass(), "entry-a")
code_allocation.dismiss_unreadable_slots(SimpleNamespace(), "entry-b")
assert dismissed == [f"{DOMAIN}_unreadable_slots_entry-a"]
async def test_adoption_gate_message_names_blocking_slots(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The adoption gate warning reports the slots blocking each entry."""
notifications: list[str] = []
monkeypatch.setattr(
allocator_module,
"async_create",
lambda _hass, message, **_kwargs: notifications.append(message),
)
allocator = _allocator()
await allocator.async_register_entry("entry-a")
await allocator.async_resolve_cycle(
CycleRequest(
observation=CycleObservation(
entry_id="entry-a",
lockname="front",
managed_slots=frozenset({1, 2}),
observed_codes={},
unreadable_slots=frozenset({2, 1}),
),
adoptions=[],
rekeys=[],
allocations=[],
active_keys=set(),
)
)
assert allocator._pending_adoption_slots == {"entry-a": frozenset({1, 2})}
allocator._gate_deadline = 1.0
allocator._gate_warning_sent = False
allocator._warn_if_gate_expired()
assert notifications == [
"Shared code allocator is still waiting for adoption from entries: "
"entry-a (unreadable slots 1, 2). New code issuance remains disabled."
]
async def test_adopted_entry_clears_blocking_slot_tracking() -> None:
"""A clean cycle drops the entry's recorded blocking slots."""
allocator = _allocator()
allocator._pending_adoption_slots["entry-a"] = frozenset({1})
await allocator.async_resolve_cycle(
CycleRequest(
observation=CycleObservation(
entry_id="entry-a",
lockname="front",
managed_slots=frozenset({1}),
observed_codes={},
unreadable_slots=frozenset(),
),
adoptions=[],
rekeys=[],
allocations=[],
active_keys=set(),
adoption_complete=True,
)
)
assert allocator._pending_adoption_slots == {}
assert allocator._describe_pending_entry("entry-a") == "entry-a"