Home Assistant 2026.11 is now the minimum, so the README no longer
needs the HACS install path and restart note for older releases.
Point the install button at the documented marketplace_repository My
Home Assistant redirect instead of the legacy hacs_repository one,
and drop the matching sentence from the unreleased Marketplace note.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
The in-place rename test was skipped, and the timer assertions were
guarded, on Home Assistant releases without async_entity_id_changed.
2026.11 is now the minimum, so drop the capability check and always
run them.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
async_chain_entity_id_changed only existed to skip the parent
async_entity_id_changed call on Home Assistant releases that lack it.
With 2026.11 as the minimum the hook always exists, so call it through
super() directly and remove the helper module. Each class still
defines the hook itself, which is what opts it in to in-place
entity_id changes.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
The test suite now runs only against Home Assistant 2026.11 (the
pytest-homeassistant plugin pins it), and Rental Control relies on
2026.11 behaviour such as in-place entity_id changes. Raise the
declared minimum to match: hacs.json, which HACS and the Marketplace
use to choose which release to offer, and the remaining dev floors
in pyproject.toml.
Users on older Home Assistant releases keep being offered v3.9.x.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
pytest-homeassistant-custom-component pulled pytest-cov in
transitively; pytest-homeassistant does not, so a clean install of the
test extra could no longer run the README's coverage command. Declare
it explicitly and list it with the other declared dependencies.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
pytest-homeassistant-custom-component is deprecated upstream and stuck
at a release pinning Home Assistant 2026.10.4. Home Assistant core now
publishes the centrally managed pytest-homeassistant package, versioned
with (and pinning) core itself.
Replace the test extra with pytest-homeassistant>=2026.11.0, rename the
pytest_homeassistant_custom_component imports, and drop the explicit
pytest_plugins registration since the new plugin loads through its
entry point. Add "-p no:respx" to addopts as its README requires, so
Home Assistant can install its httpx alias before respx is imported.
The new plugin pins homeassistant==2026.11.0, so the test extra's core
floor moves to 2026.11.0 to match and the lock now resolves 2026.11.0.
The runtime minimum (hacs.json, dev floors) is raised separately.
The autouse enable_custom_integrations wrapper is kept: custom
integrations are now enabled by default, but the wrapper also gives
every test a running hass instance that some unit tests rely on.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
Nothing stopped functions from growing back after the #816 splits.
Enable mccabe C901 and pylint PLR0912, PLR0913 and PLR0915 with each
limit set to the worst case measured in custom_components/ on the
merged tree, so the current code passes and any growth fails:
- C901 complexity: 16 (store_sync.py:208)
- PLR0912 branches: 15 (store_sync.py:208)
- PLR0913 arguments: 7 (checkin_protection.py, codegen.py,
bookings_only.py)
- PLR0915 statements: 46 (coordinator_setup_shell.py:119)
tests/ also passes C901 (max 8) and PLR0912 (max 7), so those stay
gated there. PLR0913 (max 12, pytest fixtures) and PLR0915 (max 105,
integration scenarios) are ignored for tests/** because fixture
arguments and long scenarios are idiomatic and would only loosen the
production limits. PLR0913 does not conflict with Home Assistant
callback signatures at 7, so it stays enabled.
Refs #816
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
_ghost_from_mapping interleaved defensive reads of corrupt persisted
fields, the physical-name fence, the stay-date fence and the
Reservation construction in one function.
Extract _dict_field and _str_field for the corrupt-tolerant reads
and _ghost_stay for the slot-name and stay-date fence. Each fence
still runs in the same order, logs the same message and returns None
on the same inputs, so ghost reservations are unchanged.
Refs #816
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
resolve_cycle carried the whole refresh cycle inline: staging forced
re-issues, accounting for the observed lock, allocating every
requested identity and splicing rolled-back re-issues into the
outcome list.
Extract _stage_forced_reissues, _account_for_observation and
_allocate_cycle in issuance.py, and move the rollback splice to
reissue.replace_rolled_back beside rollback_blocked_reissues, which
also keeps issuance.py under the 441-line module gate. Every step
still runs under the allocator lock in the same order with the same
inputs, so guest-code allocation is unchanged.
Refs #816
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
async_setup_entry mixed one-shot config cleanup, coordinator
construction, first refresh and platform setup, and duplicated its
rollback across separate CancelledError and Exception handlers.
Extract _consume_generate_flag, _async_prepare_coordinator and
_async_start_coordinator, and merge the identical rollback handlers
into a single except clause. Ordering of every setup step and the
rollback behaviour are unchanged.
Refs #816
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
_build_one_reservation resolved an event's stay, matched it to an
observed lock slot, chose between the generated and observed code,
and built the Reservation, all in one function of nearly a hundred
lines. Split those steps into _event_stay, _match_observed_slot,
_choose_code and _new_reservation, carrying the resolved stay and the
code decision in two small frozen dataclasses.
Behaviour is unchanged. The rotation origin is still recorded only
when a matched slot with a code must rotate. Alias and phone
extraction stay outside the ValueError guard that skips invalid
windows.
Refs #816
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
async_resolve_codes ran every stage of the allocation step inline in
one function of over a hundred lines, unpacking the request into
locals along the way. Split it into named steps that read in cycle
order: re-validate exclusions, withhold when no allocator exists,
guard and rotate owned codes, build the gated cycle request, then
record the outcome and report exhaustion. The two reservation-clearing
loops move to cycle_results as withhold_codes and clear_unresolved.
Each step reads the request fields it needs. Only forced_reissues and
suppression can change after rotation, and the original already used
the rotated values for both, so every step sees the same inputs as
before. Side effects keep their original order, so behaviour is
unchanged.
Refs #816
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
Writing the allocator's adoption and allocation results back onto the
in-cycle reservations, including the origin bookkeeping and the masked
code_ref logging, is a separate concern from orchestrating the cycle.
Move those helpers from code_allocation.py into cycle_results.py and
expose the two entry points the allocation step calls.
Behaviour is unchanged.
Refs #816
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
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>
code_allocation._adoption_complete was a thin boolean wrapper over
adoption_gate.evaluate, yet the forced re-issue preview reached into
code_allocation for it as a private name. Move it into adoption_gate
as the public is_complete predicate, beside the evaluation it wraps,
and point the preview and the tests at it there.
Behaviour is unchanged.
Refs #816
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
Re-deriving an entry's persisted slot exclusions from the cycle's
observation, logging every cleared exclusion and persisting the
survivors, is exclusion bookkeeping rather than code allocation. Move
it from code_allocation._revalidated_exclusions into slot_exclusions
as revalidate_persisted, next to the load, revalidate and persist
helpers it composes.
The allocation step calls it through the module attribute, so tests
patch slot_exclusions.revalidate_persisted where it is looked up.
Behaviour is unchanged.
Refs #816
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
code_allocation.py mixed the per-cycle allocation step with the
operator notification for unreadable managed slots. Move the
unreadable-slot notification id, dismiss and report functions into
their own unreadable_slots module so the allocation step only decides
when to report, not how.
Importers, including entry teardown in __init__.py and the tests,
now import the functions from their new home. Behaviour is
unchanged.
Refs #816
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
The excluded-slot notification in slot_exclusions.py and the
unreadable-slot notification in code_allocation.py each carried an
identical trio: a per-entry notification id, a bus-guarded dismiss,
and a bus-guarded create with a fixed title. Extract that scaffolding
into a SlotNotice value in slot_notifications.py so both notices share
one implementation and only own their message text.
Behaviour is unchanged: ids, titles, messages and the no-bus guard are
identical. Tests now patch the notification platform where it is
looked up, in slot_notifications.
Refs #816
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
The options listener changes an entry's lock or range in place, so an
adopted entry moved to another lock or widened kept counting as read
once the registry recovered, and other entries could be issued a code
sitting in a newly managed slot. Re-register such an entry, as a
reload would, so the adoption gate waits for a cycle to read it.
Part of #893 (#851)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
A trusted rebuild cycle that declined one identity but issued another
code scheduled no save, so a restart before the lock was read back
lost the issued code. Save the trusted registry regardless, and keep
only the clearing of the loss flags conditional on nothing having
been declined; a declined generator-only identity stays bound to its
proven preference by continuity_required once the flags clear.
Part of #893 (#851)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
A single allocation issued while a corrupt registry was trusted was
never saved, so a restart could lose it before a cycle persisted the
registry; save it, and clear the loss flags, as a sound cycle would.
An adopted lock returning to the gate after a notice had already gone
out for another entry left that notice unchanged, so the newly
unreachable lock was never named. Re-arm the notice when an entry
newly rejoins the pending set.
Part of #893 (#851)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
A corrupt (not missing) registry kept every unpublished request
withheld until a lockless cycle cleared the loss flags, so a site with
only lock-backed entries stayed wedged and an excluded slot could hold
recovery forever. Per the maintainer's decision, trust the rebuilt
registry once every configured lock-backed entry has positively
adopted its current lock and slot range, with operator-excluded slots
counting as observed. An unreachable lock still withholds issuance;
the adoption notification now says the registry is being rebuilt and
labels a lock none of whose slots can be read, and an adopted lock that
becomes unsound while the registry is lost returns to the gate so the
notification reappears. Diagnostics report entries whose lock or range
is not yet adopted.
Closes#893 (#851)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
A pending lock read with an excluded unreadable slot stayed trusted
after the operator removed the exclusion, so until its next cycle
other entries could be issued a code that slot might hold. Keep the
unreadable slots with the cached read and honour it only while the
entry's current exclusions still cover every one of them.
Part of #893 (#846)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
The options update listener changes an entry's lock or slot range in
place without re-registering it, so a read cached by entry id alone
kept freeing other entries after the pending entry moved to a lock or
range nobody had read. Store the observed lock and slot range with the
read codes and honour them only while the current configuration is
still covered, as adoption already does (#856).
Part of #893 (#846)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
The adoption gate was global: while any lock-backed entry waited for
adoption, no entry issued a code, so one entry stuck on a code that
matches no reservation withheld codes from every unrelated entry,
including generator-only ones (#846).
Every entry shares one door code space, so a lock whose codes are not
known must still withhold issuance from everyone: any code could
already sit in it. But a pending lock whose every managed slot was
read holds only known codes. Such an entry now withholds issuance for
itself alone, and the codes read from it that the registry does not
hold are excluded from selection for every other entry, in real
allocations and in re-issue previews. A lock not read in full since
its entry was registered, a lock with an unread slot, and a lost
registry all keep the gate global. Diagnostics list the pending
entries whose lock was read in full.
Part of #893 (#846).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
Adoption was recorded against the lock a complete cycle read but not
against the slots it read. Widening or moving an entry's start_slot
or max_events brings slots under management that may hold codes the
registry never saw, yet the entry still counted as having observed
its lock, so lockless recovery after a registry loss could issue one
of those codes before a cycle read them (#856).
Record the lock and the managed slots a sound cycle read, and treat
an entry as observed only while its configured lock matches and its
configured range is covered by those slots. Narrowing the range
exposes nothing new and keeps the entry observed. Diagnostics now
list each entry's adopted lock and slots.
The pending-entry description moves from the allocator class into
recovery.py, beside the other gate policy, to keep allocator.py
within the file size limit.
Part of #893 (#856).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
Recovery depends on the recorded origin, not on when a code was
issued. Describe the first-refresh migration window and the
preferred-only recovery rule.
Part of #893.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
Deleting a disabled lock-backed entry releases the adoption gate but
leaves its codes programmed in the lock. With the registry lost
nothing records them, so following the notification's advice could
resume issuance of a code that still opens that lock. Tell the
operator to clear the entry's codes from the lock before deleting
it, matching the README.
Part of #893 (#854).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
Recovery flags clear on the first sound cycle of any entry. A
published generator-only booking missing from the rebuilt registry,
because it had not refreshed yet or because recovery refused it, then
fell back to normal issuance on its next cycle and could be handed
its free preference although its guest holds a collision-resolved
code. Bind such a booking to its proven preference on every cycle,
not only while the flags are set; a deliberate forced re-issue stays
exempt because the operator asked for a new code.
Also keep the recorded origin through a feed miss: ghosts are now
hydrated with it, and a cycle with no known preference keeps the
origin instead of erasing it. A malformed stored origin such as a
list is scrubbed to unknown instead of raising and discarding the
whole per-entry cache.
Part of #893 (#855).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
A disabled config entry runs no cycles, but disabling it clears no
lock slot, so its codes stay programmed. While the shared registry is
intact it still records those codes. Once the registry is lost or
corrupt nothing does, yet the adoption gate skipped disabled entries,
so other entries could issue a code that already opens that lock
(#854).
Enroll disabled lock-backed entries in the adoption gate when the
registry is lost, keep such an entry pending when it is unloaded
before it adopts, and release it only when it is enabled and adopts
or is deleted. The adoption-pending notification marks the entry as
disabled and says how to clear it. An intact registry keeps the old
behaviour, since it still accounts for the disabled lock's codes.
Part of #893 (#854).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
Lockless recovery after registry loss re-issued a previously
published code by re-deriving the preferred code for the booking.
A guest whose code was collision-resolved would silently get a
different code (#855). Persist the allocation origin (preferred or
collision_resolved) in the lockless store mappings and refuse
recovery for a previously published identity unless its recorded
origin is preferred, so continuity is proven rather than assumed.
A kept code is recorded as preferred only while it still equals the
generator's current preference, since the registry keeps handing an
identity its first code after that preference drifts.
Bump STORE_SCHEMA_VERSION to 2 with a migration that marks existing
mappings as having an unknown origin, which recovery treats as
unproven. The HA Store envelope version stays at 1 via a separate
STORE_FILE_VERSION constant, since changing it without a migrate
hook makes Store refuse to load the cached data.
Part of #893 (#855).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
The lockless door code fix from PR #901 merged before reno tooling
existed, so it has no release note. Add one so the fix appears in the
next release: checked-out stays on entries without a lock keep their
code through checkout day, and dateless checked-out stays no longer
claim a future stay by guest name alone.
Refs #847
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
The amended-stay fallback could pick a reservation that already has
its own Store mapping, for example a calendar-written mapping for a
slot that was not observed this refresh. The rebuild then deleted that
mapping and put the stale adopted one in its place, losing its slot,
snapshot and history. The exact remap keeps its existing behaviour;
the name-only fallback now skips any reservation already in the
Store.
Refs #867
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
When two mappings both matched one reservation exactly, the uniqueness
filter dropped both claims before the amended-stay fallback ran. An
adopted slot with the same name but stale dates could then take that
reservation even though the exact matches conflict. Exclude every
exact target from the fallback, and apply uniqueness only to the
remaps that are kept.
Refs #867
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
Fresh slot observations never carry a persisted identity key, and
adoption skipped any slot the Store already maps. An adopted mapping
saved on an earlier refresh was therefore missing from the observed
mapping keys: the physical remap never saw it and its snapshot was
never refreshed, so it became a ghost even when the lock matched a
current stay exactly. A stay amended after the adoption refresh hit
the same dead end.
Attach a readable coded slot to its existing adopted mapping, under
the same conditions that adopt a new one, so later refreshes treat it
exactly like the adoption refresh did.
Refs #867
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
find_reservation_rematch() and its rematch_* helper modules have had
no production caller since the Store became the authority for slot
mappings. #868 deferred their removal because the UID-alias rule with
date_shifted=True looked like the fix for amended stays, and PR #898
kept the code for #867.
The #867 fix re-keys amended adopted slots inside the physical remap
instead: adopted mappings carry no UID aliases, so the rematcher could
not have matched them. Nothing uses the rematcher now, so remove it,
its exports and the tests that covered only it. The fingerprint
stability and date-shift code rotation tests that lived alongside the
rematch tests exercise production code and are kept.
Refs #867
Refs #868
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
An adopted mapping is written from the lock, so it carries the lock's
programmed window and no calendar UID. When a booking's dates change
upstream, the lock keeps the old window until it is reprogrammed. The
physical remap required the observed window to equal the reservation's
buffered window, so the mapping kept its adopted key and
build_ghost_reservations() turned it into a ghost holding the slot
alongside the live, amended stay.
The UID-alias rule in find_reservation_rematch() cannot help here:
adopted mappings have no UID aliases, and wiring the rematcher back in
was rejected in favour of the Store being the authority. Instead the
remap gains a second pass for adopted mappings that matched no
reservation exactly. Such a mapping follows the one current reservation
carrying the slot's physical name, provided the lock window is known,
no current reservation still owns that window (a renamed stay keeps its
slot), no exact match already claimed the target and no other mapping
claims it. Normal planning then keeps the slot and writes the amended
dates.
Calendar-written mappings are excluded: an amended stay keeps its key
through its UID, so an unmatched one means the stay left the feed and
must stay on the ghost path instead of passing its slot and PIN to the
same guest's next booking.
The remap moves to physical_remap.py so slot_matching stays under the
module size limit; both callers are updated. The adopted key prefix
becomes a shared constant.
Closes#867
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
The formation report judged a shared code over every owner, so a
phoneless observed alias or forced-release hold beside two bookings
that share one guest's phone digits raised a false conflict, and the
verdict it cached disagreed with later re-judgement.
When two or more bookings share the code, only they are judged and
the verdict cached. A code shared by one booking and an alias or hold
is still one code presented by two lock slots, so it is announced as
it forms, as before, but not cached or re-judged.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
Shared-code conflicts were judged only on the edge where a new owner
joined a code. The registry outlives the refresh cycle, so a record
that was already shared was never judged again: a conflict silenced
at formation under #857's configuration-only rule kept that silence
after the #861 fix, and a record whose phone evidence later decayed
was never reported.
Every cycle now re-judges the shared records its entry holds, after
code_origin.record_cycle_evidence has published that cycle's phone
evidence. The last verdict (issuance owners and whether the sharing
is explained) is kept per code_ref and a report is raised only when
it changes, so a stable record is reported once instead of on every
refresh. Observed aliases take no part. A record is not judged while
an enabled owner entry has not yet run a cycle, which would otherwise
raise false reports during startup. Verdicts live in memory, so an
unexplained conflict is reported once on the first cycle after an
upgrade or restart. Diagnostics list the reported code_refs.
Closes#842Closes#870
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
reserve_observed_code skipped a suppressed slot whose observed code
already had a registry record. When that record's owner was released
after its own slot cleared, the code looked free while the suppressed
slot still presented it, so a third booking could be issued a code
that opens that slot.
The alias is now recorded alongside any existing owner, unless an
owner already stands for the same physical slot. To make that safe,
observed aliases are no longer treated as issuance owners: the
release guard (via reissue._conflict_exempt) does not report an
adoption_conflict when only one booking actually holds the code, or
when the owner being released is itself an alias, and
AllocationRegistry.conflicts() counts issuance owners only. The alias
still keeps the code reserved until the lock stops presenting it, and
a record shared by two real bookings remains a conflict.
A forced re-issue of an aliased slot turns the alias into a
forced-release hold, so holds are not issuance owners either: the
hold's booking already has its replacement code. A hold is still
released only through its named forced-release exemption.
Part 1 of #842.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
Home Assistant has validated schemas with Probatio since 2026.9;
'voluptuous' is now only a compatibility shim that core installs over
the real package at import time. Use 'import probatio' and
'probatio.X' in every shipped module, matching core's own style, so
the integration no longer depends on the shim surviving.
The shim aliases probatio's own classes, and Home Assistant's cv
validators raise probatio.Invalid, so schemas, error handling and
messages are unchanged. Probatio is a Home Assistant core
requirement, so it is not added to the manifest.
The guard test now also forbids importing voluptuous in shipped code,
and a new test pins that probatio.Invalid catches cv validator errors.
Closes#884
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
The autolabeler adds the breaking-change label with GITHUB_TOKEN,
which does not trigger a new workflow run, so a 'Type!:' pull request
could pass the release note check unlabelled. Apply the autolabeler's
title rule directly and re-run on 'edited' events.
Also drop scripts/__init__.py: a top-level 'scripts' package in the
workspace shadows the one the Grype audit action runs with
'python -m scripts.collect_bypasses', which broke that job. The
renderer tests now load the script by path.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
Marking a reservation checked out removes it from slot planning, and
on a lock-backed entry that blanks its sensor code, yet nothing was
logged. That silence made field reports of missing codes hard to
trace back to the check-in sensor. Log the transition at debug level
with the entry and the reservation's identity key.
Refs #847
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
The draft release body is rebuilt from .github/release-drafter.yml on
every push to main, so hand-written upgrade guidance was wiped by the
next merge and operator-facing changes had nowhere to live.
Adopt reno (dev dependency, in its own 'releasenotes' group so CI can
install it without Home Assistant). Notes live in releasenotes/notes/
and are written in Markdown. scripts/render_release_notes.py uses
reno's loader to select the notes added since the latest release tag
and renders them as Markdown, because 'reno report' only emits RST.
The release-drafter workflow now checks out full history, renders the
notes into a generated copy of the config and loads it with the file:
scheme. The downloads badge moves from 'template' to 'header' so the
notes land between the badge and the changelog categories; with no
notes the body is byte-for-byte what it was before.
A new Release Notes PR check runs 'reno lint' when releasenotes/
changes and fails a 'breaking-change' pull request that adds no note.
The first note covers the HA 2026.11 calendar trigger offset change
and Marketplace readiness for v3.10.0.
Closes#819
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
When the check-in sensor reports checked_out without start and end
attributes, select_checkin_match fell back to any uniquely named
reservation, including one that has not started. A stale sensor
could therefore flag an upcoming booking as checked out and keep it
out of slot planning, so it never received a slot or a code.
The name-only fallback now takes a started_by time and ignores stays
that begin after it. The coordinator passes the current time only
for the checked_out state; checked_in matching is unchanged. The
T043-3 test pinned the old behaviour with a future stay and now uses
a stay already in progress.
Refs #847
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
A stay that the check-in sensor reports as checked out is dropped
from slot planning so a lock-backed entry frees its slot. The
lockless path reused that planning filter for its allocator active
set, its allocation requests and its published reservation map, so
on an entry without a lock the allocator released the stay's code
and the event sensor blanked slot_code while the event was still
listed for the rest of the checkout day.
An entry without a lock has no slot to free, so checkout should not
change what it publishes. select_eligible_reservations gains an
include_checked_out flag that the lockless path sets. Ghosts that
have missed three refreshes stay excluded, so a stay that leaves the
feed is still pruned from the store as before. The lock-backed path
is unchanged: its sensors publish only codes observed on the lock.
The README contract now states that slot_code stays published while
the event is listed, including after checkout.
Closes#847
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
Home Assistant 2026.11 renames an entity in place and calls
async_entity_id_changed instead of removing and re-adding it. Custom
classes that implement async_added_to_hass or
async_will_remove_from_hass keep the old remove/re-add fallback until
they opt in, and that fallback goes away in 2027.11.
Our two switches, the event sensor and the check-in sensor implement
these hooks. Nothing in them is keyed on their own entity_id: the
switches store object references, the check-in timer is time-based
and entity_id is only read at call time. So each class now defines
async_entity_id_changed and only chains to the parent hook.
HA only counts the hook when it is defined on the class that owns the
add/remove hooks, or on a subclass of it. A mixin earlier in the MRO
is a sibling and would not opt in, so each class defines the method
itself and delegates to a shared helper. The helper guards the parent
lookup because 2026.10 lacks the hook and never calls it.
Renaming in place also keeps the check-in sensor's pending timer,
which the remove/re-add fallback cancels.
Closes#890
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
The first-upgrade adoption entry point async_adopt_keymaster_slots,
its _async_adopt_keymaster_slots_impl shell, and the pure
plan_adoption/_adoption_mapping helpers had no production caller.
Adoption of coded slots without a Store mapping now happens on every
refresh through _adopt_observed_coded_slots, so the old bootstrap
path only survived in tests. AdoptionMappingDecision existed solely
for plan_adoption and is removed with it.
Tests that exercised only the dead path are removed. Two regression
tests that used the old entry point merely to seed adopted mappings
now seed them through the live _adopt_observed_coded_slots path.
find_reservation_rematch is intentionally kept; its future is
deferred to #867.
Closes#868
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
Review found three gaps in the lockless contract section. The
calendar entity's current-event attributes advance at the stay's end,
so only its event list shares the checkout-day retention guarantee.
The example automation reacted only to slot_code, missing amendments
that keep the code but move the dates. And slot_name alone is not a
unique reservation key, since two stays can share a guest name.
Scope the retention clause to the event sensors and the calendar
event list, trigger the sketch on start and end as well, and
recommend keying lock actions on uid or on name plus dates.
Refs #872
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
Entries without a Keymaster lock are now used to drive locks that
Keymaster cannot, such as TTLock, through automations that read the
event sensor attributes. For those users the attributes are the only
interface to the integration, yet nothing stated what they promise.
Add a "Using Door Codes Without Keymaster" section covering the
attributes and their formats, when slot_code is present or absent,
that an empty slot_code is absence of information rather than a
removal instruction, code and slot name stability, and retention of
an event through its whole checkout day. Warn that a late checkout
plus a deletion delay can cross midnight, after which the event is
gone, and include an example automation sketch.
Closes#872
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
Lockless consumers delete door codes from automations that fire hours
after a stay ends, so an event must stay published for its whole
checkout day. That holds only because the calendar fetch window opens
at local midnight rather than now(); nothing in the suite guarded it,
and swapping in now() would silently break downstream code deletion.
Drive the real fetch and parse path under frozen time with a 12:00
checkout: the event is present at 08:00, 15:00 and 23:59 on checkout
day and gone at 00:30 the next day. Also assert the parser receives
start-of-local-day as from_date. Changing the window start to now()
fails three of these tests.
Refs #872
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
async_apply_plan applies actions in sequence without rollback, so a
reconciliation failure can arrive after some slots were already
programmed or cleared. The notification claimed the lock kept the
last successful cycle's codes; say instead that the update may be
partial and ask the operator to check the codes on the lock.
Refs #852
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
_run_reconciliation only logged its traceback, so a lock-backed entry
whose reconciliation failed every cycle silently stopped programming
its lock. #850 added user-facing reporting for the lockless path
only.
Generalise allocation_failure with a FailureKind, keyed per kind and
entry, and wire reconciliation into it: a failure raises a
throttled persistent notification with its own id
(rental_control_reconciliation_failed_<entry>), a successful cycle
resolves it, and diagnostics gain a reconciliation_failure block.
The lockless kind keeps its existing id, text and diagnostics, so
the two kinds never overwrite each other.
forget_entry now clears every kind, so unload and removal clean up
both. Switching between lockless and lock-backed modes drops the
other mode's open report, because that mode's cycle no longer runs
to resolve it.
The notification text is English-only, matching the existing
lockless notification, which does not use translations.
Closes#852
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
Home Assistant 2026.11 replaces HACS with a built-in Marketplace, and
Rental Control is already in its default catalog. The Installation
section still documented a manual copy into custom_components, told
users to install HACS and restart, and called Keymaster a hard
dependency.
Make the Marketplace the primary path: install from Settings >
Marketplace and continue straight to Add Integration with no restart.
Keep the hacs_repository My-link badge, which opens HACS before 2026.11
and the Marketplace from 2026.11 on, and fix its category to the
lowercase integration value. Note that users on older releases install
through HACS and still restart before setup. Drop the manual
installation subsection.
Describe Keymaster as optional: it is needed only to program lock
slots, while calendars, sensors and door codes work without it. Add a
note on the 2026.11 calendar trigger change, where the offset sign now
sets the direction, so users check YAML automations that use an offset
without offset_type. Update the Table of Contents to match.
Closes#886
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
In Home Assistant 2026.11 DeviceInfo becomes a dataclass instead of a
TypedDict, and the dict-style compatibility layer is slated for
deprecation and removal. Build the coordinator's device info with the
DeviceInfo constructor rather than a dict literal. The constructor
call works on the 2026.10 floor, where DeviceInfo is a callable
TypedDict, and on 2026.11+, so the minimum version need not move.
No production code, diagnostics or tests read device info with
dict-style access, so nothing else needs to change.
Closes#889
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
Since Home Assistant 2026.3 a custom integration can ship its own
brand images in custom_components/<domain>/brand/. Local images take
precedence over the brands repository, and the 2026.11 Marketplace
shows them on the project page before install. The HACS action
currently warns that they are missing and falls back to the brands
repository.
Copy icon.png, icon@2x.png, logo.png and logo@2x.png unchanged from
custom_integrations/rental_control/ in home-assistant/brands. They
are the project's own logo, so license them under the project's
copyright and Apache-2.0 via .license sidecars, matching how
manifest.json and strings.json are licensed. The release workflow
zips the whole integration directory, so brand/ ships in
rental_control.zip without workflow changes.
Closes#888
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>