mirror of
https://github.com/tykeal/homeassistant-rental-control.git
synced 2026-11-06 05:17:23 +00:00
Feat(allocator): Notify on unreadable lock slots
Feat(allocator): Notify on unreadable lock slots
This commit is contained in:
6 files changed
+391
-1
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user