mirror of
https://github.com/tykeal/homeassistant-rental-control.git
synced 2026-11-06 05:17:23 +00:00
Refactor(allocator): Extract cycle request builder
The functions that turn one entry's observed slots and reservations into the allocator's cycle inputs (the observation, adoption, allocation and rekey requests, the active identity set, planned slots and the observed-slot matcher) made up nearly half of code_allocation.py. Move them into cycle_requests.py so the allocation step only orchestrates the cycle. The observed-slot matcher becomes the public matched_code_slot, since the rotation guard and divergence check receive it from the step. build_cycle_observation is moved verbatim and stays unfiltered by exclusions; the source-level test pinning that now reads the new file. Importers in the allocator package and the tests now use cycle_requests directly. Behaviour is unchanged. Refs #816 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
This commit is contained in:
1 parent
57b82a6792
commit
e94a61543f
13 files changed
+310
-275
No files matched your search
@@ -137,7 +137,7 @@ def _forced_release_holds(allocator: DoorCodeAllocator) -> list[dict[str, Any]]:
|
||||
|
||||
def _collect_observations(allocator: DoorCodeAllocator) -> list[Any]:
|
||||
"""Return current coordinator observations for diagnostics."""
|
||||
from ..coordinator_helpers.code_allocation import build_cycle_observation
|
||||
from ..coordinator_helpers.cycle_requests import build_cycle_observation
|
||||
|
||||
observations = []
|
||||
for entry_id, entry_data in allocator.hass.data.get(DOMAIN, {}).items():
|
||||
|
||||
@@ -147,7 +147,7 @@ async def _guard_refusal(
|
||||
hass: HomeAssistant, target: exclusion_targets.ExclusionTarget, slot: int
|
||||
) -> str | None:
|
||||
"""Return why this slot may not be excluded, or None when it may."""
|
||||
from ..coordinator_helpers.code_allocation import build_cycle_observation
|
||||
from ..coordinator_helpers.cycle_requests import build_cycle_observation
|
||||
from .reissue_preview import observe_managed_slots_without_mutation
|
||||
from .singleton import async_get_or_create_allocator
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from dataclasses import replace
|
||||
from typing import Any
|
||||
|
||||
from ..coordinator_helpers import adoption_gate
|
||||
from ..coordinator_helpers import code_allocation
|
||||
from ..coordinator_helpers import cycle_requests
|
||||
from ..coordinator_helpers import keymaster_observation
|
||||
from ..coordinator_helpers import physical_remap
|
||||
from ..coordinator_helpers import reissue as coordinator_reissue
|
||||
@@ -108,7 +108,7 @@ async def preview_request_and_result(
|
||||
return request, ReissuePreview(reason="recovery_fail_closed")
|
||||
if target.identity_key is not None and request.preferred_code is None:
|
||||
return request, ReissuePreview(reason="no_existing_allocation")
|
||||
observation = code_allocation.build_cycle_observation(
|
||||
observation = cycle_requests.build_cycle_observation(
|
||||
target.entry_id, target.lockname, observed_slots
|
||||
)
|
||||
excluded = frozenset(slot_exclusions.load(coordinator.hass, target.entry_id))
|
||||
@@ -174,7 +174,7 @@ def _adoption_would_complete(
|
||||
else frozenset(),
|
||||
)
|
||||
reservations = preview_reservations(coordinator, target, observed_slots)
|
||||
adoptions = code_allocation.build_adoption_requests(
|
||||
adoptions = cycle_requests.build_adoption_requests(
|
||||
target.entry_id,
|
||||
target.lockname,
|
||||
coordinator.code_length,
|
||||
|
||||
@@ -100,7 +100,7 @@ def _loaded_entry_ids(hass: HomeAssistant) -> set[str]:
|
||||
|
||||
def _collect_observations(hass: HomeAssistant) -> list[CycleObservation]:
|
||||
"""Return current observations from loaded Rental Control coordinators."""
|
||||
from ..coordinator_helpers.code_allocation import build_cycle_observation
|
||||
from ..coordinator_helpers.cycle_requests import build_cycle_observation
|
||||
|
||||
observations: list[CycleObservation] = []
|
||||
for entry_id, entry_data in hass.data.get(DOMAIN, {}).items():
|
||||
|
||||
@@ -7,38 +7,28 @@ from __future__ import annotations
|
||||
from dataclasses import replace
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
import uuid
|
||||
|
||||
from homeassistant.components.persistent_notification import async_create
|
||||
from homeassistant.util import dt
|
||||
|
||||
from ..allocator import code_origin
|
||||
from ..allocator import entry_info
|
||||
from ..allocator import get_allocator
|
||||
from ..allocator.models import AdoptionRequest
|
||||
from ..allocator.models import AllocationOrigin
|
||||
from ..allocator.models import AllocationRequest
|
||||
from ..allocator.models import AllocationResult
|
||||
from ..allocator.models import CycleObservation
|
||||
from ..allocator.models import CycleRequest
|
||||
from ..allocator.models import CycleResult
|
||||
from ..const import DOMAIN
|
||||
from ..const import NAME
|
||||
from ..reconciliation import SlotStatus
|
||||
from ..reconciliation import compute_desired_plan
|
||||
from ..reconciliation.desired import select_eligible_reservations
|
||||
from . import adoption_gate
|
||||
from . import cycle_requests
|
||||
from . import generator_divergence
|
||||
from . import reissue
|
||||
from . import slot_exclusions
|
||||
from . import unreadable_slots
|
||||
from .models import ObservedSlotQuery
|
||||
from .reissue import CodeResolutionRequest
|
||||
from .reissue import CodeResolutionResult
|
||||
from .reissue import ReissueSuppression
|
||||
from .rotation_guard import RotationGuardTarget
|
||||
from .rotation_guard import require_owned_code_for_rotation
|
||||
from .slot_matching import find_observed_slot
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..reconciliation import ManagedSlot
|
||||
@@ -46,10 +36,6 @@ if TYPE_CHECKING:
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_EXHAUSTED_NOTIFICATION_ID = f"{DOMAIN}_code_registry_exhausted"
|
||||
_ISSUED_ORIGINS = {
|
||||
origin.value: origin
|
||||
for origin in (AllocationOrigin.PREFERRED, AllocationOrigin.COLLISION_RESOLVED)
|
||||
}
|
||||
|
||||
|
||||
async def async_resolve_codes(
|
||||
@@ -74,7 +60,9 @@ async def async_resolve_codes(
|
||||
reissue.target_key(directive.identity_key, directive.lockname, directive.slot)
|
||||
for directive in request.forced_reissues
|
||||
)
|
||||
observation = build_cycle_observation(entry_id, lockname, managed_slots)
|
||||
observation = cycle_requests.build_cycle_observation(
|
||||
entry_id, lockname, managed_slots
|
||||
)
|
||||
exclusions = slot_exclusions.revalidate_persisted(
|
||||
hass, entry_id, lockname, observation
|
||||
)
|
||||
@@ -96,13 +84,13 @@ async def async_resolve_codes(
|
||||
require_owned_code_for_rotation(
|
||||
RotationGuardTarget(allocator, entry_id, lockname, managed_slots),
|
||||
reservations,
|
||||
_matched_code_slot,
|
||||
cycle_requests.matched_code_slot,
|
||||
)
|
||||
code_origin.record_cycle_evidence(entry_id, reservations)
|
||||
request = generator_divergence.rotate_stale_codes(
|
||||
request, allocator, _matched_code_slot
|
||||
request, allocator, cycle_requests.matched_code_slot
|
||||
)
|
||||
adoptions = build_adoption_requests(
|
||||
adoptions = cycle_requests.build_adoption_requests(
|
||||
entry_id,
|
||||
lockname,
|
||||
code_length,
|
||||
@@ -120,7 +108,7 @@ async def async_resolve_codes(
|
||||
adoption_gate.report(entry_id, lockname, gate_status)
|
||||
adoption_complete = gate_status.complete
|
||||
allocations = (
|
||||
build_allocation_requests(
|
||||
cycle_requests.build_allocation_requests(
|
||||
entry_id, lockname, code_length, managed_slots, reservations
|
||||
)
|
||||
if adoption_complete
|
||||
@@ -130,9 +118,9 @@ async def async_resolve_codes(
|
||||
CycleRequest(
|
||||
observation=observation,
|
||||
adoptions=adoptions,
|
||||
rekeys=build_rekey_requests(reservations),
|
||||
rekeys=cycle_requests.build_rekey_requests(reservations),
|
||||
allocations=allocations,
|
||||
active_keys=_active_keys(reservations, lockname),
|
||||
active_keys=cycle_requests.active_keys(reservations, lockname),
|
||||
adoption_complete=adoption_complete,
|
||||
forced_reissues=request.forced_reissues,
|
||||
excluded_slots=request.excluded_slots,
|
||||
@@ -186,14 +174,6 @@ async def async_resolve_codes(
|
||||
)
|
||||
|
||||
|
||||
def _active_keys(reservations: list[Reservation], lockname: str | None) -> set[str]:
|
||||
"""Return the identities whose codes the allocator keeps this cycle."""
|
||||
kept = select_eligible_reservations(
|
||||
reservations, include_checked_out=lockname is None
|
||||
)
|
||||
return {reservation.identity_key for reservation in kept}
|
||||
|
||||
|
||||
def _apply_adoption_results(
|
||||
allocator: object,
|
||||
entry_id: str,
|
||||
@@ -238,228 +218,6 @@ def _apply_allocation_results(
|
||||
return exhausted
|
||||
|
||||
|
||||
def build_cycle_observation(
|
||||
entry_id: str,
|
||||
lockname: str | None,
|
||||
managed_slots: list[ManagedSlot],
|
||||
) -> CycleObservation:
|
||||
"""Build the allocator's physical-state observation for this entry."""
|
||||
managed = [slot for slot in managed_slots if slot.managed]
|
||||
observed_codes: dict[str, int] = {}
|
||||
for slot in managed:
|
||||
if slot.actual_code is not None and slot.status is not SlotStatus.UNKNOWN:
|
||||
observed_codes.setdefault(slot.actual_code, slot.slot)
|
||||
return CycleObservation(
|
||||
entry_id=entry_id,
|
||||
lockname=lockname,
|
||||
managed_slots=frozenset(slot.slot for slot in managed),
|
||||
observed_codes=observed_codes,
|
||||
unreadable_slots=frozenset(
|
||||
slot.slot for slot in managed if slot.status is SlotStatus.UNKNOWN
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def build_adoption_requests(
|
||||
entry_id: str,
|
||||
lockname: str | None,
|
||||
code_length: int,
|
||||
managed_slots: list[ManagedSlot],
|
||||
reservations: list[Reservation],
|
||||
suppression: ReissueSuppression | None = None,
|
||||
) -> list[AdoptionRequest]:
|
||||
"""Return adoption requests for reservations with readable physical codes."""
|
||||
suppression = suppression or ReissueSuppression()
|
||||
if lockname is None:
|
||||
return []
|
||||
consumed_slots: set[int] = set()
|
||||
requests = reissue.AdoptionRequests()
|
||||
for reservation in reservations:
|
||||
slot = _matched_code_slot(reservation, managed_slots, consumed_slots)
|
||||
if slot is None or slot.actual_code is None:
|
||||
continue
|
||||
if reservation.identity_key in suppression.identity_keys or (
|
||||
slot.slot in suppression.slots and reservation.protected_active
|
||||
):
|
||||
requests.suppressed_slots.add(slot.slot)
|
||||
_reserve_observed_code(requests, entry_id, lockname, reservation, slot)
|
||||
continue
|
||||
if reservation.code_rotation_pending:
|
||||
_LOGGER.info(
|
||||
"Skipping adoption for %s in slot %d because its date-based "
|
||||
"code must rotate for the updated booking window",
|
||||
reservation.identity_key,
|
||||
slot.slot,
|
||||
)
|
||||
requests.suppressed_slots.add(slot.slot)
|
||||
_reserve_observed_code(requests, entry_id, lockname, reservation, slot)
|
||||
continue
|
||||
if not slot.actual_code.isdecimal() or len(slot.actual_code) != code_length:
|
||||
_LOGGER.warning(
|
||||
"Skipping adoption for %s in slot %d because the observed code "
|
||||
"does not match the configured code length",
|
||||
reservation.identity_key,
|
||||
slot.slot,
|
||||
)
|
||||
requests.suppressed_slots.add(slot.slot)
|
||||
_reserve_observed_code(requests, entry_id, lockname, reservation, slot)
|
||||
continue
|
||||
requests.append(
|
||||
AdoptionRequest(
|
||||
entry_id=entry_id,
|
||||
identity_key=reservation.identity_key,
|
||||
code=slot.actual_code,
|
||||
code_length=code_length,
|
||||
lockname=lockname,
|
||||
slot=slot.slot,
|
||||
fingerprint_history=frozenset(reservation.fingerprint_history),
|
||||
)
|
||||
)
|
||||
return requests
|
||||
|
||||
|
||||
def _reserve_observed_code(
|
||||
requests: reissue.AdoptionRequests,
|
||||
entry_id: str,
|
||||
lockname: str,
|
||||
reservation: Reservation,
|
||||
slot: ManagedSlot,
|
||||
) -> None:
|
||||
"""Hold a suppressed slot's observed code against re-issue elsewhere.
|
||||
|
||||
A suppressed slot is exempt from adoption, so nothing records the code
|
||||
it still holds. Without a reservation the allocator is free to hand
|
||||
that same code to another booking, which would program a duplicate
|
||||
code onto the lock. Non-decimal codes are skipped because the
|
||||
allocator can never produce one, so they cannot collide.
|
||||
"""
|
||||
code = slot.actual_code
|
||||
if not code or not code.isdecimal():
|
||||
return
|
||||
requests.observed_reservations.append(
|
||||
AdoptionRequest(
|
||||
entry_id=entry_id,
|
||||
identity_key=reservation.identity_key,
|
||||
code=code,
|
||||
code_length=len(code),
|
||||
lockname=lockname,
|
||||
slot=slot.slot,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def build_allocation_requests(
|
||||
entry_id: str,
|
||||
lockname: str | None,
|
||||
code_length: int,
|
||||
managed_slots: list[ManagedSlot],
|
||||
reservations: list[Reservation],
|
||||
) -> list[AllocationRequest]:
|
||||
"""Return deterministic allocation requests for selected reservations."""
|
||||
planned_slots = _planned_slots(lockname, managed_slots, reservations)
|
||||
requests: list[AllocationRequest] = []
|
||||
for reservation in sorted(reservations, key=lambda item: item.identity_key):
|
||||
if reservation.identity_key not in planned_slots:
|
||||
continue
|
||||
planned_slot = planned_slots.get(reservation.identity_key)
|
||||
if lockname is not None and planned_slot is None:
|
||||
continue
|
||||
preferred = reservation.slot_code
|
||||
if preferred is None and not reservation.published_once:
|
||||
continue
|
||||
requests.append(
|
||||
AllocationRequest(
|
||||
entry_id=entry_id,
|
||||
identity_key=reservation.identity_key,
|
||||
preferred_code=preferred or ("0" * code_length),
|
||||
code_length=code_length,
|
||||
fingerprint_history=frozenset(reservation.fingerprint_history),
|
||||
previously_published=reservation.published_once,
|
||||
lockname=lockname if planned_slot is not None else None,
|
||||
slot=planned_slot,
|
||||
preferred_code_known=preferred is not None,
|
||||
issued_origin=_ISSUED_ORIGINS.get(reservation.issued_origin or ""),
|
||||
)
|
||||
)
|
||||
return requests
|
||||
|
||||
|
||||
def build_rekey_requests(reservations: list[Reservation]) -> list[tuple[str, str]]:
|
||||
"""Return historical fingerprint moves for active reservations."""
|
||||
requests: list[tuple[str, str]] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for reservation in sorted(reservations, key=lambda item: item.identity_key):
|
||||
if reservation.code_rotation_pending:
|
||||
continue
|
||||
for historical_key in sorted(reservation.fingerprint_history):
|
||||
item = (historical_key, reservation.identity_key)
|
||||
if historical_key != reservation.identity_key and item not in seen:
|
||||
requests.append(item)
|
||||
seen.add(item)
|
||||
return requests
|
||||
|
||||
|
||||
def _planned_slots(
|
||||
lockname: str | None,
|
||||
managed_slots: list[ManagedSlot],
|
||||
reservations: list[Reservation],
|
||||
) -> dict[str, int | None]:
|
||||
"""Return planned physical slots for lock-backed selected reservations."""
|
||||
if lockname is None:
|
||||
return {
|
||||
reservation.identity_key: None
|
||||
for reservation in select_eligible_reservations(
|
||||
reservations, include_checked_out=True
|
||||
)
|
||||
}
|
||||
if not managed_slots:
|
||||
return {}
|
||||
plan = compute_desired_plan(
|
||||
reservations=reservations,
|
||||
managed_slots=managed_slots,
|
||||
max_events=len([slot for slot in managed_slots if slot.managed]),
|
||||
plan_id=str(uuid.uuid4()),
|
||||
generated_at=dt.now(),
|
||||
)
|
||||
return dict(plan.selected)
|
||||
|
||||
|
||||
def _matched_code_slot(
|
||||
reservation: Reservation,
|
||||
managed_slots: list[ManagedSlot],
|
||||
consumed_slots: set[int],
|
||||
) -> ManagedSlot | None:
|
||||
"""Return the readable coded slot that physically matches a reservation."""
|
||||
for slot in managed_slots:
|
||||
if (
|
||||
slot.managed
|
||||
and slot.persisted_identity_key == reservation.identity_key
|
||||
and slot.actual_code
|
||||
and slot.status is not SlotStatus.UNKNOWN
|
||||
and slot.slot not in consumed_slots
|
||||
):
|
||||
consumed_slots.add(slot.slot)
|
||||
return slot
|
||||
matched = find_observed_slot(
|
||||
ObservedSlotQuery(
|
||||
managed_slots=managed_slots,
|
||||
slot_name=reservation.slot_name,
|
||||
display_slot_name=reservation.display_slot_name,
|
||||
consumed_slots=consumed_slots,
|
||||
desired_start=reservation.buffered_start,
|
||||
desired_end=reservation.buffered_end,
|
||||
event_prefix="",
|
||||
)
|
||||
)
|
||||
if (
|
||||
matched is not None
|
||||
and matched.actual_code
|
||||
and matched.status is not SlotStatus.UNKNOWN
|
||||
):
|
||||
return matched
|
||||
return None
|
||||
|
||||
|
||||
def _apply_result(
|
||||
reservations: list[Reservation],
|
||||
identity_key: str,
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
# SPDX-FileCopyrightText: 2026 Andrew Grimberg <tykeal@bardicgrove.org>
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Build the allocator cycle inputs from one entry's observed lock state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
import uuid
|
||||
|
||||
from homeassistant.util import dt
|
||||
|
||||
from ..allocator.models import AdoptionRequest
|
||||
from ..allocator.models import AllocationOrigin
|
||||
from ..allocator.models import AllocationRequest
|
||||
from ..allocator.models import CycleObservation
|
||||
from ..reconciliation import SlotStatus
|
||||
from ..reconciliation import compute_desired_plan
|
||||
from ..reconciliation.desired import select_eligible_reservations
|
||||
from . import reissue
|
||||
from .models import ObservedSlotQuery
|
||||
from .reissue import ReissueSuppression
|
||||
from .slot_matching import find_observed_slot
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..reconciliation import ManagedSlot
|
||||
from ..reconciliation import Reservation
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_ISSUED_ORIGINS = {
|
||||
origin.value: origin
|
||||
for origin in (AllocationOrigin.PREFERRED, AllocationOrigin.COLLISION_RESOLVED)
|
||||
}
|
||||
|
||||
|
||||
def active_keys(reservations: list[Reservation], lockname: str | None) -> set[str]:
|
||||
"""Return the identities whose codes the allocator keeps this cycle."""
|
||||
kept = select_eligible_reservations(
|
||||
reservations, include_checked_out=lockname is None
|
||||
)
|
||||
return {reservation.identity_key for reservation in kept}
|
||||
|
||||
|
||||
def build_cycle_observation(
|
||||
entry_id: str,
|
||||
lockname: str | None,
|
||||
managed_slots: list[ManagedSlot],
|
||||
) -> CycleObservation:
|
||||
"""Build the allocator's physical-state observation for this entry."""
|
||||
managed = [slot for slot in managed_slots if slot.managed]
|
||||
observed_codes: dict[str, int] = {}
|
||||
for slot in managed:
|
||||
if slot.actual_code is not None and slot.status is not SlotStatus.UNKNOWN:
|
||||
observed_codes.setdefault(slot.actual_code, slot.slot)
|
||||
return CycleObservation(
|
||||
entry_id=entry_id,
|
||||
lockname=lockname,
|
||||
managed_slots=frozenset(slot.slot for slot in managed),
|
||||
observed_codes=observed_codes,
|
||||
unreadable_slots=frozenset(
|
||||
slot.slot for slot in managed if slot.status is SlotStatus.UNKNOWN
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def build_adoption_requests(
|
||||
entry_id: str,
|
||||
lockname: str | None,
|
||||
code_length: int,
|
||||
managed_slots: list[ManagedSlot],
|
||||
reservations: list[Reservation],
|
||||
suppression: ReissueSuppression | None = None,
|
||||
) -> list[AdoptionRequest]:
|
||||
"""Return adoption requests for reservations with readable physical codes."""
|
||||
suppression = suppression or ReissueSuppression()
|
||||
if lockname is None:
|
||||
return []
|
||||
consumed_slots: set[int] = set()
|
||||
requests = reissue.AdoptionRequests()
|
||||
for reservation in reservations:
|
||||
slot = matched_code_slot(reservation, managed_slots, consumed_slots)
|
||||
if slot is None or slot.actual_code is None:
|
||||
continue
|
||||
if reservation.identity_key in suppression.identity_keys or (
|
||||
slot.slot in suppression.slots and reservation.protected_active
|
||||
):
|
||||
requests.suppressed_slots.add(slot.slot)
|
||||
_reserve_observed_code(requests, entry_id, lockname, reservation, slot)
|
||||
continue
|
||||
if reservation.code_rotation_pending:
|
||||
_LOGGER.info(
|
||||
"Skipping adoption for %s in slot %d because its date-based "
|
||||
"code must rotate for the updated booking window",
|
||||
reservation.identity_key,
|
||||
slot.slot,
|
||||
)
|
||||
requests.suppressed_slots.add(slot.slot)
|
||||
_reserve_observed_code(requests, entry_id, lockname, reservation, slot)
|
||||
continue
|
||||
if not slot.actual_code.isdecimal() or len(slot.actual_code) != code_length:
|
||||
_LOGGER.warning(
|
||||
"Skipping adoption for %s in slot %d because the observed code "
|
||||
"does not match the configured code length",
|
||||
reservation.identity_key,
|
||||
slot.slot,
|
||||
)
|
||||
requests.suppressed_slots.add(slot.slot)
|
||||
_reserve_observed_code(requests, entry_id, lockname, reservation, slot)
|
||||
continue
|
||||
requests.append(
|
||||
AdoptionRequest(
|
||||
entry_id=entry_id,
|
||||
identity_key=reservation.identity_key,
|
||||
code=slot.actual_code,
|
||||
code_length=code_length,
|
||||
lockname=lockname,
|
||||
slot=slot.slot,
|
||||
fingerprint_history=frozenset(reservation.fingerprint_history),
|
||||
)
|
||||
)
|
||||
return requests
|
||||
|
||||
|
||||
def _reserve_observed_code(
|
||||
requests: reissue.AdoptionRequests,
|
||||
entry_id: str,
|
||||
lockname: str,
|
||||
reservation: Reservation,
|
||||
slot: ManagedSlot,
|
||||
) -> None:
|
||||
"""Hold a suppressed slot's observed code against re-issue elsewhere.
|
||||
|
||||
A suppressed slot is exempt from adoption, so nothing records the code
|
||||
it still holds. Without a reservation the allocator is free to hand
|
||||
that same code to another booking, which would program a duplicate
|
||||
code onto the lock. Non-decimal codes are skipped because the
|
||||
allocator can never produce one, so they cannot collide.
|
||||
"""
|
||||
code = slot.actual_code
|
||||
if not code or not code.isdecimal():
|
||||
return
|
||||
requests.observed_reservations.append(
|
||||
AdoptionRequest(
|
||||
entry_id=entry_id,
|
||||
identity_key=reservation.identity_key,
|
||||
code=code,
|
||||
code_length=len(code),
|
||||
lockname=lockname,
|
||||
slot=slot.slot,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def build_allocation_requests(
|
||||
entry_id: str,
|
||||
lockname: str | None,
|
||||
code_length: int,
|
||||
managed_slots: list[ManagedSlot],
|
||||
reservations: list[Reservation],
|
||||
) -> list[AllocationRequest]:
|
||||
"""Return deterministic allocation requests for selected reservations."""
|
||||
planned_slots = _planned_slots(lockname, managed_slots, reservations)
|
||||
requests: list[AllocationRequest] = []
|
||||
for reservation in sorted(reservations, key=lambda item: item.identity_key):
|
||||
if reservation.identity_key not in planned_slots:
|
||||
continue
|
||||
planned_slot = planned_slots.get(reservation.identity_key)
|
||||
if lockname is not None and planned_slot is None:
|
||||
continue
|
||||
preferred = reservation.slot_code
|
||||
if preferred is None and not reservation.published_once:
|
||||
continue
|
||||
requests.append(
|
||||
AllocationRequest(
|
||||
entry_id=entry_id,
|
||||
identity_key=reservation.identity_key,
|
||||
preferred_code=preferred or ("0" * code_length),
|
||||
code_length=code_length,
|
||||
fingerprint_history=frozenset(reservation.fingerprint_history),
|
||||
previously_published=reservation.published_once,
|
||||
lockname=lockname if planned_slot is not None else None,
|
||||
slot=planned_slot,
|
||||
preferred_code_known=preferred is not None,
|
||||
issued_origin=_ISSUED_ORIGINS.get(reservation.issued_origin or ""),
|
||||
)
|
||||
)
|
||||
return requests
|
||||
|
||||
|
||||
def build_rekey_requests(reservations: list[Reservation]) -> list[tuple[str, str]]:
|
||||
"""Return historical fingerprint moves for active reservations."""
|
||||
requests: list[tuple[str, str]] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for reservation in sorted(reservations, key=lambda item: item.identity_key):
|
||||
if reservation.code_rotation_pending:
|
||||
continue
|
||||
for historical_key in sorted(reservation.fingerprint_history):
|
||||
item = (historical_key, reservation.identity_key)
|
||||
if historical_key != reservation.identity_key and item not in seen:
|
||||
requests.append(item)
|
||||
seen.add(item)
|
||||
return requests
|
||||
|
||||
|
||||
def _planned_slots(
|
||||
lockname: str | None,
|
||||
managed_slots: list[ManagedSlot],
|
||||
reservations: list[Reservation],
|
||||
) -> dict[str, int | None]:
|
||||
"""Return planned physical slots for lock-backed selected reservations."""
|
||||
if lockname is None:
|
||||
return {
|
||||
reservation.identity_key: None
|
||||
for reservation in select_eligible_reservations(
|
||||
reservations, include_checked_out=True
|
||||
)
|
||||
}
|
||||
if not managed_slots:
|
||||
return {}
|
||||
plan = compute_desired_plan(
|
||||
reservations=reservations,
|
||||
managed_slots=managed_slots,
|
||||
max_events=len([slot for slot in managed_slots if slot.managed]),
|
||||
plan_id=str(uuid.uuid4()),
|
||||
generated_at=dt.now(),
|
||||
)
|
||||
return dict(plan.selected)
|
||||
|
||||
|
||||
def matched_code_slot(
|
||||
reservation: Reservation,
|
||||
managed_slots: list[ManagedSlot],
|
||||
consumed_slots: set[int],
|
||||
) -> ManagedSlot | None:
|
||||
"""Return the readable coded slot that physically matches a reservation."""
|
||||
for slot in managed_slots:
|
||||
if (
|
||||
slot.managed
|
||||
and slot.persisted_identity_key == reservation.identity_key
|
||||
and slot.actual_code
|
||||
and slot.status is not SlotStatus.UNKNOWN
|
||||
and slot.slot not in consumed_slots
|
||||
):
|
||||
consumed_slots.add(slot.slot)
|
||||
return slot
|
||||
matched = find_observed_slot(
|
||||
ObservedSlotQuery(
|
||||
managed_slots=managed_slots,
|
||||
slot_name=reservation.slot_name,
|
||||
display_slot_name=reservation.display_slot_name,
|
||||
consumed_slots=consumed_slots,
|
||||
desired_start=reservation.buffered_start,
|
||||
desired_end=reservation.buffered_end,
|
||||
event_prefix="",
|
||||
)
|
||||
)
|
||||
if (
|
||||
matched is not None
|
||||
and matched.actual_code
|
||||
and matched.status is not SlotStatus.UNKNOWN
|
||||
):
|
||||
return matched
|
||||
return None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"active_keys",
|
||||
"build_adoption_requests",
|
||||
"build_allocation_requests",
|
||||
"build_cycle_observation",
|
||||
"build_rekey_requests",
|
||||
"matched_code_slot",
|
||||
]
|
||||
@@ -16,6 +16,7 @@ import pytest
|
||||
|
||||
from custom_components.rental_control.allocator.allocator import DoorCodeAllocator
|
||||
from custom_components.rental_control.allocator.models import AllocationOrigin
|
||||
from custom_components.rental_control.allocator.models import AllocationRequest
|
||||
from custom_components.rental_control.allocator.store import RegistryStore
|
||||
from custom_components.rental_control.coordinator_helpers import code_allocation
|
||||
from custom_components.rental_control.reconciliation import ManagedSlot
|
||||
@@ -82,7 +83,7 @@ async def test_full_code_space_is_shared_not_partitioned() -> None:
|
||||
"""Every entry can use any code not already held by another identity."""
|
||||
allocator = _allocator()
|
||||
first = await allocator.async_allocate(
|
||||
code_allocation.AllocationRequest(
|
||||
AllocationRequest(
|
||||
entry_id="entry-a",
|
||||
identity_key="identity-a",
|
||||
preferred_code="1234",
|
||||
@@ -90,7 +91,7 @@ async def test_full_code_space_is_shared_not_partitioned() -> None:
|
||||
)
|
||||
)
|
||||
second = await allocator.async_allocate(
|
||||
code_allocation.AllocationRequest(
|
||||
AllocationRequest(
|
||||
entry_id="entry-b",
|
||||
identity_key="identity-b",
|
||||
preferred_code="5678",
|
||||
|
||||
@@ -24,6 +24,7 @@ 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 import cycle_requests
|
||||
from custom_components.rental_control.coordinator_helpers import slot_notifications
|
||||
from custom_components.rental_control.coordinator_helpers import unreadable_slots
|
||||
from custom_components.rental_control.coordinator_helpers.coordinator_refresh_shell import (
|
||||
@@ -257,7 +258,7 @@ def test_rekey_requests_are_deterministic() -> None:
|
||||
first = _reservation("identity-a", code="1111")
|
||||
first.fingerprint_history = {"old-a2", "old-a1", "identity-a"}
|
||||
|
||||
requests = code_allocation.build_rekey_requests([second, first])
|
||||
requests = cycle_requests.build_rekey_requests([second, first])
|
||||
|
||||
assert requests == [
|
||||
("old-a1", "identity-a"),
|
||||
@@ -577,7 +578,7 @@ def test_invalid_code_slots_are_recorded_as_suppressed() -> None:
|
||||
for observed in ("22222", "12AB"):
|
||||
requests = cast(
|
||||
AdoptionRequests,
|
||||
code_allocation.build_adoption_requests(
|
||||
cycle_requests.build_adoption_requests(
|
||||
"entry-a",
|
||||
"front",
|
||||
4,
|
||||
|
||||
@@ -39,7 +39,7 @@ def _isolate_preview(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
reissue_preview, "build_preview_request", lambda *_a, **_k: "request"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
reissue_preview.code_allocation,
|
||||
reissue_preview.cycle_requests,
|
||||
"build_cycle_observation",
|
||||
lambda entry_id, lockname, _slots: CycleObservation(
|
||||
entry_id=entry_id,
|
||||
@@ -160,7 +160,7 @@ async def test_preview_forwards_exclusions_to_the_adoption_check(
|
||||
reissue_preview, "build_preview_request", lambda *_a, **_k: "request"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
reissue_preview.code_allocation,
|
||||
reissue_preview.cycle_requests,
|
||||
"build_cycle_observation",
|
||||
lambda entry_id, lockname, _slots: CycleObservation(
|
||||
entry_id=entry_id,
|
||||
@@ -171,7 +171,7 @@ async def test_preview_forwards_exclusions_to_the_adoption_check(
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
reissue_preview.code_allocation, "build_adoption_requests", lambda *_a: []
|
||||
reissue_preview.cycle_requests, "build_adoption_requests", lambda *_a: []
|
||||
)
|
||||
monkeypatch.setattr(reissue_preview, "preview_reservations", lambda *_a: [])
|
||||
monkeypatch.setattr(
|
||||
|
||||
@@ -108,7 +108,7 @@ async def test_orphan_cleanup_retains_an_excluded_slots_code() -> None:
|
||||
def test_observation_builder_knows_nothing_of_exclusions() -> None:
|
||||
"""The shared observation must never be filtered by exclusions."""
|
||||
source = Path(
|
||||
"custom_components/rental_control/coordinator_helpers/code_allocation.py"
|
||||
"custom_components/rental_control/coordinator_helpers/cycle_requests.py"
|
||||
).read_text(encoding="utf-8")
|
||||
builder = source.split("def build_cycle_observation(", 1)[1].split("\ndef ", 1)[0]
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import pytest
|
||||
from custom_components.rental_control.allocator import allocator as allocator_module
|
||||
from custom_components.rental_control.allocator.allocator import DoorCodeAllocator
|
||||
from custom_components.rental_control.coordinator_helpers import code_allocation
|
||||
from custom_components.rental_control.coordinator_helpers import cycle_requests
|
||||
from custom_components.rental_control.coordinator_helpers import slot_notifications
|
||||
from custom_components.rental_control.coordinator_helpers.reissue import (
|
||||
AdoptionRequests,
|
||||
@@ -161,7 +162,7 @@ def test_rotation_pending_slot_reserves_its_observed_code() -> None:
|
||||
|
||||
requests = cast(
|
||||
AdoptionRequests,
|
||||
code_allocation.build_adoption_requests(
|
||||
cycle_requests.build_adoption_requests(
|
||||
"entry-a", "front", 4, [target_slot], [target]
|
||||
),
|
||||
)
|
||||
@@ -178,7 +179,7 @@ def test_non_decimal_invalid_code_slot_reserves_nothing() -> None:
|
||||
"""A non-decimal observed code cannot collide, so it reserves nothing."""
|
||||
requests = cast(
|
||||
AdoptionRequests,
|
||||
code_allocation.build_adoption_requests(
|
||||
cycle_requests.build_adoption_requests(
|
||||
"entry-a",
|
||||
"front",
|
||||
4,
|
||||
@@ -200,7 +201,7 @@ def test_wrong_length_invalid_code_slot_is_reserved() -> None:
|
||||
"""
|
||||
requests = cast(
|
||||
AdoptionRequests,
|
||||
code_allocation.build_adoption_requests(
|
||||
cycle_requests.build_adoption_requests(
|
||||
"entry-a",
|
||||
"front",
|
||||
4,
|
||||
|
||||
@@ -21,6 +21,7 @@ from custom_components.rental_control.allocator.models import AllocationOwner
|
||||
from custom_components.rental_control.allocator.models import AllocationRecord
|
||||
from custom_components.rental_control.allocator.reissue import forced_release_hold_key
|
||||
from custom_components.rental_control.coordinator_helpers import code_allocation
|
||||
from custom_components.rental_control.coordinator_helpers import cycle_requests
|
||||
from custom_components.rental_control.coordinator_helpers import reissue
|
||||
from custom_components.rental_control.coordinator_helpers.checkin_protection import (
|
||||
build_protected_reservation,
|
||||
@@ -121,7 +122,7 @@ def test_adoption_requests_skip_only_suppressed_target() -> None:
|
||||
"""Suppression skips the target's adoption and leaves peers unchanged."""
|
||||
first = _reservation("identity-a", "Target Guest", "1234")
|
||||
second = _reservation("identity-b", "Other Guest", "5678")
|
||||
requests = code_allocation.build_adoption_requests(
|
||||
requests = cycle_requests.build_adoption_requests(
|
||||
"entry-a",
|
||||
"front",
|
||||
4,
|
||||
@@ -208,7 +209,7 @@ def test_slot_suppression_skips_protected_adoption_only() -> None:
|
||||
protected = _reservation("identity-b", "Target Guest", "1234")
|
||||
protected.protected_active = True
|
||||
|
||||
live_requests = code_allocation.build_adoption_requests(
|
||||
live_requests = cycle_requests.build_adoption_requests(
|
||||
"entry-a",
|
||||
"front",
|
||||
4,
|
||||
@@ -216,7 +217,7 @@ def test_slot_suppression_skips_protected_adoption_only() -> None:
|
||||
[live],
|
||||
reissue.ReissueSuppression(frozenset(), frozenset({1})),
|
||||
)
|
||||
protected_requests = code_allocation.build_adoption_requests(
|
||||
protected_requests = cycle_requests.build_adoption_requests(
|
||||
"entry-a",
|
||||
"front",
|
||||
4,
|
||||
@@ -233,7 +234,7 @@ def test_suppressed_target_consumes_slot_before_peer_fallback() -> None:
|
||||
"""A peer cannot adopt the suppressed target's old physical code."""
|
||||
first = _reservation("identity-a", "Same Guest", "1234")
|
||||
second = _reservation("identity-b", "Same Guest", "5678")
|
||||
requests = code_allocation.build_adoption_requests(
|
||||
requests = cycle_requests.build_adoption_requests(
|
||||
"entry-a",
|
||||
"front",
|
||||
4,
|
||||
|
||||
@@ -36,10 +36,10 @@ from freezegun import freeze_time
|
||||
import pytest
|
||||
|
||||
from custom_components.rental_control.codegen import generate_slot_code
|
||||
from custom_components.rental_control.coordinator_helpers.code_allocation import (
|
||||
from custom_components.rental_control.coordinator_helpers.cycle_requests import (
|
||||
build_adoption_requests,
|
||||
)
|
||||
from custom_components.rental_control.coordinator_helpers.code_allocation import (
|
||||
from custom_components.rental_control.coordinator_helpers.cycle_requests import (
|
||||
build_rekey_requests,
|
||||
)
|
||||
from custom_components.rental_control.coordinator_helpers.models import (
|
||||
|
||||
Reference in new issue
Block a user