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>
Home Assistant 2026.11 introduces integration categories, which the
Marketplace uses for browsing, and the HACS action warns when a
manifest has none. Declare security_safety.locks_access as the primary
category, since the integration issues and manages guest door codes,
and productivity_information.calendars_todos as the secondary one,
since it is fed by rental iCal calendars and exposes calendar
entities.
The key sits alphabetically after domain and name, as hassfest
expects.
Closes#887
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
hacs.json still advertised Home Assistant 2026.4.0 while
pyproject.toml has required 2026.10.0 since #778, and the test suite
only runs against 2026.10.x. The HA 2026.11 Marketplace uses this
floor to decide which release to offer, so users on older cores were
offered releases the project does not test or support.
Also drop render_readme: it is not one of the keys the Marketplace
reads, so it is dead configuration.
Closes#883
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
The coordinator derived its lockname by slugifying any non-blank
keymaster_entry_id, so a stored "(none)" became the truthy lockname
"none" and created event overrides. The allocator, recovery policy
and __init__ all treated the same value as no lock, so the entry was
simultaneously lock-backed and lockless and never published a code.
Add configured_lock_entry/configured_lock_slug to helpers.py, which
map None, blank, whitespace-only and "(none)" to None, and route
every CONF_LOCK_ENTRY presence check through them: coordinator setup
and config update, allocator recovery (also used by the allocator),
__init__._entry_has_lock and the config-flow save conversion. The
sentinel literal now lives once in const.LOCK_ENTRY_NONE.
The config and options flows already normalise "(none)" to None
before saving, and no current migration touches the lock entry, so
the value is only reachable from entries written before v0.5 and
never re-saved, or from hand-edited storage. This is hardening.
Closes#859
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
The release workflow rewrites VERSION in const.py with sed, but sed
exits 0 when its address matches nothing. If the line ever stops
matching "^VERSION = " (for example after adding a type annotation),
the step silently succeeds and the ZIP ships VERSION = "0.0.0", so
every diagnostics download would misreport the integration version.
After substituting, assert that const.py contains exactly
VERSION = "<version>" and that manifest.json carries the same
version, failing the step loudly otherwise. Shipping an artefact that
misreports its own version is worse than not shipping.
Closes#871
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
Home Assistant 2026.11 makes Store resolve its file through
hass.config.path(). Many tests build a minimal fake hass whose config
is SimpleNamespace(config_dir="."), so against 2026.11.0b1 the suite
fails 158 tests and errors 3 with AttributeError on 'path'. Runtime
code is unaffected because a real hass.config always provides path().
Add a shared tests.fixtures.fake_hass.fake_config() builder that
returns a fake config with config_dir and a path() matching HA's
os.path.join semantics, and use it at every fake hass construction
site. The suite now passes on both 2026.10.x and 2026.11.0b1.
Closes#885
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
Issue #861 made a ghost reservation notify on a shared-code conflict
where it previously stayed silent, which risks reopening the conflict
spam issue #857 closed. Record that it does not: the report fires only
as an identity joins a code that already had owners, and the registry
recording ownership outlives the refresh cycle.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
The divergence scan read "the generator could not have produced this
code" as grounds to rotate it. _resolve_observed_code reads the same
signal as grounds to refuse rotation, labelling the code
manual_observed, so an operator's hand-entered PIN was silently
overwritten on any phoneless last_four entry that opted into updates.
Honour the earlier judgement instead of re-litigating it: replacing a
PIN an operator typed into the lock is a worse failure than leaving a
code the configuration no longer explains. The divergence is still
reported, because an operator may want to know the mismatch exists.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
Dropping an identity's rotation marker once the replacement arrived
made that replacement eligible for rotation again on the following
refresh, because a collision-resolved replacement still differs from
the date-based code the generator prefers. While the preferred code
stayed occupied this moved a future guest's PIN on alternating
refreshes.
The marker now records that the identity is finished with rather
than being removed, so it outlives the replacement it describes.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
Review of #869 found four ways the divergence check misread a code,
and one way a rotation left a programmed code unprotected.
Phone digits present now ends the question: the held code is either
those digits or a collision-resolved substitute for them, and neither
is a feed that dropped phone numbers. A reservation missing from the
feed is skipped, because it carries no event text and so says nothing
about phone numbers either way. A rotation is requested once per
identity: the allocator may answer with a collision-resolved code
rather than the one asked for, and re-reading that as fresh staleness
asked for a rotation on every refresh forever.
Rotating identities now also join the cycle's retention suppression,
so the adoption step reserves the code still programmed in the slot
and the allocator cannot hand it to another booking before the
replacement is confirmed.
Conflict reporting reads the cycle in flight before the coordinator's
reservation cache, which is only refreshed once a cycle completes. A
booking adopted for the first time had no evidence while its own
conflict was reported, and nothing reports it again.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
A reservation issued a phone-derived code before its feed stopped
publishing phone numbers keeps that code forever: the allocator
short-circuits on the identity key, and the identity key does not
change, so the stale code is handed back on every refresh and never
self-corrects. Booking.com dropped phone information in late
September 2026, so this is now the normal state of every booking
issued a last_four code before then.
The real defect is the silence, so every divergence is recorded and
exposed in the integration diagnostics. Rotation is the narrower
promise and is offered only when the operator opted into rotation
with should_update_code and the stay has not started, because
changing a code a guest may already hold is its own failure. The
existing forced re-issue machinery is reused so the replaced code is
held until it is no longer programmed and rolls back on failure.
Fixes#862
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
Shared-code conflict suppression decided that two owners were
explained by the last_four generator from configuration alone. An
entry whose feed carries no phone number publishes date-based codes
while still reporting last_four, and at a code length of four a
date-based code is only start_day plus end_day, so two such entries
colliding was likely and was silenced.
Require each owner to show that its own reservation's phone digits
really are the shared code. Anything undeterminable - a deleted
entry, an unloaded coordinator, a reservation no longer in the feed
- notifies, because silence must rest on positive evidence.
Fixes#861
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
The last_four generator falls back to date-based codes silently
whenever a reservation carries no phone number, so configuration
alone cannot say how a published code was produced. Booking.com
stopped publishing phone information in late September 2026, which
turned that fallback from a corner case into the normal state of
every Booking.com backed entry.
Add one predicate that answers the question from evidence rather
than from configuration, and record the phone digits a feed yields
on the reservation so the answer survives into the reporting and
allocation paths that need it.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
get_slot_name returns an empty string, not None, for a VEVENT with a
missing or blank SUMMARY. The override lookup in calendar parsing and
the calendar sensor's slot read both tested for None, so such a stay
was named by the shared resolver but skipped the Keymaster time
override and could read back no name, leaving the default check-in and
check-out times in effect. Both sites now test the name the way the
shared resolver does.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
Booking.com publishes no guest names, so #863 named an anonymous
reservation after its stay dates. Both slot-matching layers are
name-based, so amending a booking upstream renamed it: the store
rematcher's UID rule is gated on a name baseline it could no longer
satisfy, and find_observed_slot is name-only. The amended stay was
treated as a new reservation and the guest's door code changed
mid-stay.
Name the slot after a hash of the VEVENT UID instead. The UID does
not move when the dates do, so the name is stable and both matchers
keep working unchanged. Hashing with hashlib, never the salted
builtin hash(), keeps the token identical across restarts, and keeps
the reservation number off the lock. A feed with no usable UID falls
back to the date-derived name, so it is no worse off than before.
A UID-derived name is also available on the raw VEVENT, independent of
the dates and of UTC conversion, so calendar parsing can now
synthesize it for the Keymaster time-override lookup without the
circularity that blocked this in #863. The date-derived fallback
stays out of that path, because it keeps the original UTC-shift
hazard.
No migration is needed: #863 is in no published release, so no
date-derived anonymous name exists in the wild.
Fixes#864
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
Entry creation is a single step, so the form renders before the
calendar URL is known and the bookings-only checkbox always draws
unticked. An operator cannot express a deliberate False for a
Booking.com host there, yet that answer was stored, so every newly
added Booking.com calendar walked straight back into the silent
no-codes failure of issue #830. Existing entries were repaired;
new ones were not.
Drop the key instead of storing an answer that carries no intent,
and let the read-time derivation supply True. One place in the
codebase then decides, and a new Booking.com entry behaves exactly
like one created before the option existed. Ticking the box still
records a real True.
Both flows share this save path, so scope the rule to creation by
requiring the creation stamp and the absence of a config entry.
The options form renders a URL-aware default, so a False saved
there is a real choice and survives untouched.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
Reservation identity depends on every slot-name resolution site
agreeing. Mutation testing showed that reverting
_collect_name_windows to call get_slot_name directly, leaving the
other sites on the shared resolver, kept the whole suite green.
Diverged, _collect_name_windows skips anonymous bookings, so their
synthesised key never reaches windows. Each anonymous reservation
is then built with no reserved or ordered date windows, no
require_date_match, and an expected name count of one. For a lone
booking those last two land on the same values as the correct
path, which is why nothing failed.
Cover the sharp cases instead: an anonymous reservation must carry
its date windows, two stays sharing dates must demand a date
match, and a stale observed window must not hand its PIN to a new
anonymous stay.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
The calendar sensors and the check-in sensor resolved slot names with
get_slot_name alone, so an anonymous Booking.com reservation read back
as unnamed even once the coordinator had programmed it. Event sensors
published no slot number or code for a code that was live on the lock,
and check-in tracking skipped active-stay protection and early
checkout expiry.
Move the resolver into the bookings_only module and have all three
consumers call it, so every path derives the same name from the same
UTC-converted CalendarEvent dates.
Refs: #830
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
Add the user-facing label, risk warning and translations for the new
per-entry option, plus a README section explaining why Booking.com
feeds need it and what enabling it on a calendar that publishes
blocked dates would do.
Refs: #830
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
get_slot_name discards any event summarised "Not available", so
Booking.com's anonymous "CLOSED - Not available" reservations never
produced a Reservation and never produced a door code, silently
(issue #830).
When an entry is marked bookings-only, name such a reservation after
its stay instead of dropping it. The name is hashed into the
reservation fingerprint, so both call sites now share one resolver and
the name carries the year, keeping identity stable across refreshes
and distinct between stays that repeat a day and month.
Synthesis stays out of calendar_parsing: a None slot name does not
drop a VEVENT there, and it works from the raw DTSTART rather than the
UTC-converted CalendarEvent, so the two could disagree on the date.
Bookings-only also overrides ignore_non_reserved, which would
otherwise discard the same events before they reach reservation
building.
Fixes: #830
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
Booking.com's iCal export no longer names guests: every event is
titled "CLOSED - Not available", so a real reservation and a host
block are indistinguishable. The Blocked/Not available filter cannot
be relaxed globally, because Airbnb, Google Calendar and several PMS
feeds use those words for genuine blocks that must never receive a
guest door code.
Only the operator knows which kind of feed they have, so record it
per entry. Entries predating the option carry no value, so the
calendar URL host supplies the default: booking.com feeds default on,
everything else defaults off, and anything unparseable defaults off.
Config entry data is a MappingProxyType, so the new reader accepts any
Mapping rather than testing for dict (issue #850).
Refs: #830
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
generate_slot_code uses the guest's phone digits only when the
configured code length is four, and silently falls back to date based
codes otherwise, so an entry set to last_four with a six digit code is
a date based entry in all but name. Suppressing its shared codes hid
a real collision.
Require both the owning entries and the shared code itself to be four
digits before a shared code counts as explained by the generator.
The forced re-issue hold notification also claimed a replacement code
was blocked. A hold outlives the replacement it caused, so the
condition it reports is the old code being kept reserved against
reuse, which is what it now says.
Refs: #857
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
Allocator notifications were written for a maintainer reading a log,
not for the user reading them. A two-owner conflict put roughly two
hundred characters of entry UUIDs and identity fingerprints in front
of the user to say "front door slot 3 and back door slot 5", and the
adoption gate, exhaustion, unreadable-slot, orphan cleanup and
forced-hold reports all quoted raw entry ids or bare reason codes.
Describe each place the way the user knows it, by the entry's own
name, its lock and its slot, and explain each retention reason in
words. The identifiers are not lost: every one of these reports
writes the full entry id, identity key and code_ref to the log, which
is where support needs them. A single masked code_ref stays in the
conflict and mismatch notifications because it is the only handle
correlating a notification with its log lines.
Refs: #857
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
Adoption and the way adoption problems are reported change for
different reasons and are read by different people, and keeping both
in one module pushed it past the complexity gate's file size limit.
Move the conflict and identity-mismatch reports, and the rule deciding
which shared codes are worth reporting, into their own module. No
behaviour changes: the functions are unmodified apart from losing
their leading underscore now that they are called across a module
boundary.
Refs: #857
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
Every adoption conflict was written to one shared persistent
notification id, so a second conflicting code overwrote the first and
only the newest was ever visible. Dismissing that one notification
also hid every other open conflict.
Key the notification on the code_ref so each conflicting code reports
once and is dismissed on its own.
Refs: #857
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
A property that runs one entry per unit with the last_four generator
hands the same guest the same code in two entries whenever that guest
moves between units, because under that generator the code is the
guest's phone digits. The registry reported every such record as an
adoption conflict and raised a notification whose own text says no
code was rotated, so it was pure noise with nothing to act on.
Resolve each owner's configured generator from its config entry and
suppress the report to an INFO log line only when every owner uses
last_four. A record shared with a date_based or static_random entry is
not explained by the generator, so the mixed case still notifies, and
an owner whose generator cannot be determined counts as unknown rather
than benign, so silence is only ever chosen on positive evidence.
Entry data is read as a Mapping, never a dict, because Home Assistant
exposes it as a MappingProxyType.
Refs: #857
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
Adopted standing was recorded per entry and never withdrawn, so two
stale-proof cases let the unobserved-lock safeguard vouch for a lock
nothing had read. A later incomplete cycle, which leaves an unmatched
code unregistered, kept the entry marked observed; and an options
update that points the entry at a different lock changes the lock in
place without re-registering the entry. Either way a lockless cycle
could then issue a code the real lock holds and clear the recovery
flags.
Record the lock each completed cycle actually observed, drop the
record when a cycle comes back incomplete, and compare it with the
lock the entry is currently configured for.
Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>