Skip to content

feat: users, not slot numbers (5.0.0) - #1462

Merged
raman325 merged 32 commits into
mainfrom
v3
Aug 21, 2026
Merged

feat: users, not slot numbers (5.0.0)#1462
raman325 merged 32 commits into
mainfrom
v3

Conversation

@raman325

Copy link
Copy Markdown
Owner

Breaking change

Lock Code Manager is configured by user now, not by slot number. A slot
number is internal bookkeeping; the person holding the code is what you name,
see and address.

Upgrading migrates a config entry from version 3 to version 4 and cannot be
undone from within the integration
— going back means restoring a Home
Assistant backup. What changes on the way through:

  • Every slot must name someone. Slots holding a PIN but no name are given
    one; slots with neither a name nor a PIN are dropped, since they held no
    credential and named nobody.
  • Entity IDs are re-slugged onto the user's name..._code_slot_1_pin
    becomes ..._raman_pin. Recorder history follows the rename, but nothing
    rewrites an entity ID stored inside an automation or script, so the mapping
    is handed to you in a persistent notification rather than left to be
    discovered. Automations built from the Slot Usage Limiter and Slot Usage
    Notifier blueprints hold these IDs directly.
  • A removed user's PIN is now cleared from the lock. Earlier versions left
    it programmed and the code went on working. Because a code left behind is
    indistinguishable from one set at the keypad, the upgrade offers up every
    code it cannot account for once, as a repair per slot: clear it, or keep
    it and stop being asked.
  • The config entry's own device is gone. It held no entities and existed
    only so the per-user devices had a parent to hang from.
  • number_of_uses was removed in an earlier release; the Slot Usage Limiter
    blueprint replaces it.

Proposed change

31 commits, moving the whole integration from a slot-keyed model to a
user-keyed one:

  • Configuration is users: {name: {...}} with slot assignment kept
    separately. Slot numbers stay in unique IDs, so renaming a user costs
    nothing — no entity loses its registry entry, its settings or its history.
  • Slot numbers are chosen, not asked for. Allocation reads what each lock
    actually holds, and each provider says where its slot numbers stop.
  • The dashboard is user-centric: a user card that never shows a slot
    number, with add and remove actions, a condition picker and PIN generation,
    so an entry is manageable without going back to the config flow.
  • New actions: add_user, delete_user. Every action is now catalogued
    in services.yaml, strings.json and icons.json — four were missing.
  • The event entity reports a credential being used, not a PIN, ahead of
    credential types beyond PINs.

Type of change

  • Dependency upgrade
  • Bugfix (non-breaking change which fixes an issue)
  • New feature (which adds functionality)
  • Breaking change (fix/feature causing existing functionality to break)
  • Code quality improvements to existing code or addition of tests

Additional information

  • This PR fixes or closes issue: fixes #
  • This PR is related to issue:

Migrated live twice against a production install: 6 slots became 2 users, 93
entities became 31, and the second run verified the four defects the first one
surfaced. Recorder history is covered by a test that records under the
pre-upgrade entity ID, migrates, and asserts it reads back under the new one.

manifest.json stays at the 0.0.0 placeholder; the release workflow stamps
it from the tag.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XDxiHpQJkRKWctS9BfQmbY

raman325 and others added 30 commits August 18, 2026 09:57
…1417)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat: convert slot-keyed configuration to name-keyed users

The shape change the version 3 release exists for:

  slots:                      users:
    1:                 ->       Raman:
      name: Raman                 pin: "1234"
      pin: "1234"

The slot number is demoted to internal bookkeeping rather than removed. On
most providers it IS the lock's credential index, so it stays bounded by the
lock's advertised capacity and stays reusable -- deleting a user frees their
number for the next one, exactly as today, because the physical credential
slot is genuinely being reused.

Entity and device identifiers keep keying on that number, so the migration
performs no registry writes and a rename moves nothing. That is the whole
reason this lands as one small module instead of the identifier-rewriting
machinery it replaces: nothing has to move, so nothing has to be moved
atomically, and there is no permutation to resolve.

This supersedes the approach on feat/v3-name-unique-ids, which put the user's
name in the identifier and needed 1,158 lines to move identifiers on rename.
That branch produced a high-severity review finding in four separate rounds.
The cause was an inversion: the mutable value, the name, sat in the immutable
position, the identifier, while the immutable value, the slot number, sat in
the mutable position, the display name. Users read "Code slot 1" throughout.

A monotonically increasing handle was tried in between and is also wrong,
for a reason worth recording: a number that is never reused climbs with every
user ever created and eventually leaves the lock's credential range. Reuse is
not a compromise here, it is the requirement. test_slot_assignment.py states
that as a property -- numbers stay inside the high-water mark of concurrent
users -- and it is the property a never-reused number cannot satisfy.

One property was wrong when first written and is worth noting rather than
quietly fixing: bounding slots by the CURRENT user count contradicts keeping
a survivor's number when someone below them leaves. Hypothesis produced
[['Raman', 'Alice'], ['Alice']] immediately. The bound that holds is the most
users ever configured at once, which is also what capacity is sized against.

Verified against mutations: renumbering everyone on each edit falsifies the
never-renumber property, and dropping a field falsifies both the field and
round-trip properties.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 8e5f8bf45f9b

* fix: coerce slot keys, repair names in place, and keep renames free

First review round on #1425. Four findings, all real.

The high-severity one is a lost int() coercion, which is the same defect
review found in #1415. Storage represents slot keys as strings, so a
migration reading stored data handed strings straight through into the
assignment. `candidate in taken` then compared an int against a string,
never matched, and issued a slot that was already occupied:

  users_from_slots({'1': {...}})  -> {'Raman': '1'}
  .assign(['Raman', 'Alice'])     -> {'Raman': '1', 'Alice': 1}

On providers where the slot IS the credential index, that writes Alice's
code over Raman's on every lock.

The name repair is now performed by users_from_slots rather than documented
as a precondition. The name is optional in a version 2 entry, so both hazard
inputs are reachable from real data: a nameless slot raised a bare KeyError
mid-migration, and two slots sharing a name collapsed into one user --
losing a user's code AND renumbering the survivor, which is exactly what
test_conversion_renumbers_nobody forbids. A precondition is not good enough
on a path with no rollback, and the repair being called correctly today does
not stop a later caller getting it wrong.

with_renames is new. assign cannot see a rename -- it looks like a deletion
plus an addition, so it frees the old name's slot and reissues it in
iteration order. Two renames in one submission landed each user on the
other's index. Re-keying first is what makes a rename free, and building a
fresh mapping rather than moving keys resolves a swap without needing an
order.

The reason all three survived the first round of properties is the fourth
finding, and it is the one worth remembering: every assignment property
started from SlotAssignment.empty(), and the clean strategy only ever
emitted int keys with unique names. Production reaches assign from a
MIGRATED assignment built from storage, and that composition was never
exercised. There is now a strategy shaped like storage -- string keys,
missing and duplicated names -- and properties that run the two halves
together. Each of the three fixes is verified by a mutation that its
property catches.

One correction to the review: normalize_slot_names does have a production
caller, in async_migrate_entry. The finding stands anyway, for the reason
above.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 8f379638a343

* fix: resolve chained renames, and make name normalization an invariant

Second review round on #1425. Eight findings; the high-severity one is a
defect the PREVIOUS round's fix introduced.

with_renames re-keyed in place, so a rename target that was still a live key
collided and last-write-wins decided the outcome. For the chain A -> B,
B -> C with C deleted -- a legal submission, since the final names {B, C} are
unique and the name rules accept it -- that moved the user formerly named B
from credential index 2 to index 3, rewriting their code on every lock and
orphaning their entities. Reversing the mapping's insertion order produced a
different answer, so the docstring's claim that it "resolves without needing
an order" was wrong for chains even though it held for swaps.

A rename target being a name somebody still holds is legal rather than a
conflict: the holder must be departing in the same submission, because two
users cannot share a name in the result. So the renamed entry wins and the
entry sitting on the target is dropped. The two halves are now built over
disjoint key sets, which is what makes the result order-independent rather
than a comment claiming it is.

Names are normalized in __post_init__ rather than at each method, making it
an invariant of the type: a name can only ever be stored one way, so it
cannot match on one path and miss on another. Missing means looking like a
different user and being renumbered. Normalizing only the incoming names was
not enough -- a stored key in its raw form still missed -- which the first
attempt at this fix demonstrated by leaving 'Raman ' at index 3 while
'Raman' was issued index 1.

Also: __post_init__ freezes the mapping the plain constructor accepts, which
was the one path that could hand back a live dict for a caller to mutate
under assign's identity check; __hash__ keeps the promise frozen=True makes
instead of raising; with_renames returns self when nothing applies, matching
assign's identity contract; and from_mapping documents that it takes the
already-merged mapping, so there is no second options-over-data precedence
rule to fall out of step with EntryConfig's.

Two test gaps, and they are why the above got through. The rename property
assumed away exactly the shape that fails, justified as "a collision the name
rules reject upstream" -- they do not, as the chain above shows. And nothing
pinned field-to-name pairing THROUGH the repair, so a migration that swapped
two repaired slots' codes would have satisfied every count-based property.
Both are now covered, along with an order-independence property and a
whitespace-variant property; that last one was added only because the
mutation check showed nothing failed when key normalization was removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: ea528d875b30

* fix: honour the start slot, and identify users the way names.py does

Third review round on #1425. Six findings, two high-severity, all
reproduced against the branch before fixing.

Renaming vacated a target whether or not anything moved into it. A map
naming a source that holds no slot -- a stale replay, or a name already
renamed -- therefore deleted whoever was sitting on the target, and the
following assign renumbered them onto a different credential index. It also
made the operation destroy its own result on a second application. A target
is now displaced only when a source actually surrenders a slot.

assign allocated from 1 regardless of the entry's configured start slot and
of slots another entry owns on a shared lock. Both constraints exist today
-- CONF_START_SLOT in the config flow, and _check_common_slots, which
refuses overlapping ranges -- and the start slot is usually chosen precisely
because the numbers below it hold codes programmed by hand. Since the slot
IS the credential index on most providers, a user added after migrating an
entry that starts at 5 was issued slot 1, writing their code over one Lock
Code Manager does not manage. start and unavailable are now parameters, so
the policy lives with the caller that knows it rather than being hardcoded
here.

Users are now identified the way names.py already identifies them:
whitespace normalized AND casefolded. deduplicate and validate_slot_names
both casefold, so Bob and BOB are one user everywhere else while this module
gave them two credential indices, and a case-only rename read as a deletion
plus an addition. slot() was also the one accessor not normalizing its
argument, so a caller passing an unstripped name saw the user as holding no
slot -- the invariant the previous round's docstring claimed.

__post_init__ now coerces slot values to int as well as canonicalizing
names, closing the last path that could reintroduce string slot numbers:
the plain constructor, which a caller reading entry.data directly rather
than through from_mapping would use. Two names reducing to one identity keep
the lower slot, deterministically; raising would make an already-inconsistent
entry unloadable, which is worse than choosing.

Four properties added for behaviour that had none. One of them was wrong
when first written, and is recorded rather than quietly corrected: replaying
a rename map is NOT a no-op, because a swap map is its own inverse and
legitimately flips back. A map is computed for one transition and is not
meaningful against its own result. What must never happen is a user
disappearing, so that is what the property now states.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: c9abc2b009cb

* fix: collapse rename and allocation into one reconcile

Fourth review round on #1425, and the first that caught me pushing a RED
branch while reporting it green. Two properties were failing on the pushed
commit.

Why the local run said otherwise: the dev Hypothesis profile runs 15
examples. My pre-push run genuinely passed -- the random search had not yet
found the counterexample -- and later runs found and cached it. A single
green Hypothesis run is not evidence of anything. Verification now runs
HYPOTHESIS_PROFILE=ci, which is 200 examples, against a cleared .hypothesis
database, twice.

The substantive finding is that with_renames and assign were the wrong shape.
Splitting a rename from an allocation puts the ORDER in the caller's hands,
and the order is the entire difficulty: a rename is indistinguishable from a
deletion plus an addition unless you already know who survives. I got that
sequence wrong in three consecutive rounds, each fix creating the next
round's high-severity defect -- a user deleted outright, then a user
renumbered onto another user's credential index.

They are now one operation. reconcile() takes the new name set, so the
ambiguity is resolved by data rather than by sequencing: a rename target
absent from the new names is departing, and one present names the renamed
user. There is no order left for a caller to get wrong.

start is required rather than defaulting to 1. The previous signature
defaulted to precisely the hazard its own docstring described -- the start
slot is usually chosen because the numbers below it hold hand-programmed
codes -- so a forgotten argument would have written a new user's code over
one of those, silently, on a real door. Required makes it a type error.

__post_init__ coerces keys as well as values; from_mapping had lost its
str() coercion when the canonicalization moved, so a non-string key raised
AttributeError and aborted setup instead of normalizing.

The test findings matter more than any of the above. Casefolding the keys in
round 3 silently made both rename properties VACUOUS: every generated name is
capitalized, so `if old in before.slots` never matched, the rename map was
always empty, and replacing the implementation with `return self` passed all
of them. A fix in the code disabled the tests guarding an earlier fix, and
nothing reported it.

Fixing the filter was not enough either. Ignoring renames outright still
passed, because with a single rename and no other churn the newcomer is
handed the very slot the departed user just freed, so the two paths coincide.
The property now adds users in a shuffled order alongside the rename, which
is the case where they diverge -- and it is the case that reorders two
people's credential indices on a real lock. Verified: ignoring renames now
fails it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 9a5589cd36c9

* fix: make contradictory rename input inert, and pin what was unpinned

Fifth review round on #1425. Eight findings, none high-severity -- the first
round without one. Half were test gaps rather than defects, which is itself
the signal that the implementation is settling.

A rename whose target is absent from the new names contradicts the name set,
and was handled halfway: the source was excluded from the survivors while
nothing took their place, so a user who came through the edit under their own
name was reallocated from start. Their credential moved on every lock and
their entities were orphaned -- the failure the module exists to prevent,
reached through input that should simply have been ignored. Such a move is
now dropped before it can do anything.

Two sources renaming onto one target is likewise contradictory, and was
decided by whichever entry the stored mapping iterated first, so the same
input gave different credential indices on different runs. Now resolved in
sorted order.

Allocation was order-dependent on `names`, which is typed as an iterable --
a caller handing over a set or a dict view would get a different assignment
each run. New numbers are now issued in sorted order.

A duplicate slot NUMBER in stored bookkeeping survived reconcile untouched.
The type already repairs a duplicate key, which it can only get from
inconsistent storage; the duplicate value is the more dangerous of the two,
because both users write over each other on the lock, and nothing else in the
system would ever fix it.

Documented rather than changed: a user keeps their slot even when start rises
above it or unavailable comes to include it. Never renumbering somebody is
the stronger guarantee, and the trade was implied by the code without being
stated.

The test work matters more. Three behaviours were covered but unpinned --
mutating min to max, dropping the int() coercion on slot values, and dropping
the str() coercion on keys each passed the whole suite. The last one is the
fix from the previous round, which had no test at all.

And the property that motivates the entire design -- that numbers stay inside
the high-water mark of concurrent users, which is exactly what a
monotonically increasing handle cannot satisfy -- did not exist. It was lost
in the previous round's rewrite while the pull request description went on
citing it by name. Restored, along with properties for order-independence,
inert contradictory renames, and duplicate repair.

Every fix is verified by a mutation that a test now catches: eight mutations,
eight failures. Verified under HYPOTHESIS_PROFILE=ci against a cleared
database, twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: b7de0b297963

* fix: canonicalize the rename map, and survive corrupt bookkeeping

Sixth review round on #1425. Four findings, one medium and three low, no
high-severity. Three fixed, one declined with a reason.

The rename map arrived from a caller and got none of the canonicalization the
stored names get. Two keys meaning one user therefore each took a turn: the
later overwrote the earlier in the move table while the earlier's target
stayed CLAIMED, so a third user's legitimate rename onto that abandoned
target was refused and they were renumbered onto a different credential
index. Reducing to identity form before claiming targets fixes it, and
splitting the two loops is what removes the phantom claim.

"No two users share a number" is now an invariant of the TYPE rather than of
one method. A duplicate slot value could previously be persisted straight out
of a migration -- '01' and '1' are distinct JSON keys and the same number
after coercion -- and both users would write over each other on the lock
every sync. The loser is dropped at construction rather than renumbered,
because the type has no idea what the entry's start slot is and reissuing
from 1 could put somebody below it, on a code programmed by hand. reconcile
knows, and gives them a number that respects it.

Corrupt bookkeeping now degrades instead of aborting entry setup. A
non-mapping under the storage key and a non-numeric slot value both raised
and took the whole entry down -- the same threat model the str() key coercion
already existed for, defended asymmetrically.

DECLINED: surfacing a conflict between tenure and `unavailable`. The review
is right that the trade is clean for `start` and not for `unavailable` -- a
tenured user on a number another entry owns means two entries writing one
credential index, which this type cannot repair because it cannot see the
other entry. It stands because the alternative is renumbering somebody
unasked, and because _check_common_slots is what stops the overlap arising.
The docstring said this more confidently than the code earned; it now states
the limit. If a consumer needs the conflict surfaced, that belongs at the
seam that knows about other entries.

One mutation survived and is worth recording as a NON-finding: swapping
setdefault for direct assignment in the move table changes which arbitrary
winner is chosen for self-contradictory input, and both are deterministic, so
there is nothing to pin. The test does catch the real defect -- reverting to
the previous single-loop shape fails it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 7e2e3268b1a2

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
integration.yaml triggered on `branches: [main]` only, so every pull request
into the long-lived v3 release branch merged with no test run at all. The
checks that did report -- and passed -- were the two labellers, the release
drafter, and pre-commit.ci, none of which execute pytest.

Six pull requests have gone into v3 that way. One of them, 5f9d750 on
feat/v3-user-config-shape, was pushed with two failing property tests and
nothing reported it; it was found later by running the suite by hand.

frontend-checks and python-checks are `workflow_call` only and run through
integration.yaml, so adding the branch here covers both.

Entire-Checkpoint: a505e5ee2b2a
The separator rule existed for one reason: entity and device identifiers were
delimited by "|" and, under the abandoned design, keyed by the user's name. A
name carrying one would have split into the wrong fields coming back out.

Identifiers are keyed by the slot number, so nothing parses a name any more
and the restriction has no reason left. Keeping it would mean shipping a
rejection users cannot be given a reason for.

Removed: the constant, the name_has_separator error and its two translation
entries, and the repair pass that rewrote "|" to a space. That last one
mattered most -- it was a silent rename of the user on every lock that stores
names, for a character that was never a problem.

"Present" and "unique within the entry" are the rules that remain, and both
have a reason: the name becomes the mapping key in version 3, so an absent one
has nothing to key on and a duplicate cannot be represented.

Tests are inverted rather than deleted, so the removal is pinned: a "|" name
is now accepted by the config flow, survives the migration untouched, and
passes name_error. Removing the rule also left the config flow's error branch
with no test at all -- name_required was only ever reached through the
separator case -- so that gained one.

Version 3 is unreleased, so this ships as part of it rather than as a change
to behaviour anyone has seen.


Entire-Checkpoint: e497440dce42

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Merged rather than rebased. v3 now carries squash-merged pull requests, and
rewriting their commits would break the links GitHub shows against them.

* origin/main:
  test(zigbee2mqtt): cover every unresolvable-topic path (#1430)
Brings the device-ownership fixes (#1435, #1436) and the reclear fix
(#1437) onto v3.

#1437 is the gate: the occupancy work maps a slot the lock reports as
occupied-but-unreadable to `unreadable` rather than `empty`, which without
this merge would leave a cleared slot on such a lock never converging.

Both conflicts were the two branches appending tests to the end of the
same file. Both sets are kept.

Entire-Checkpoint: 17858a9900c4
* feat: read what a lock holds, not just what we manage

Allocation needs to know which credential indices a lock already holds so
it never issues one over a code somebody programmed by hand. The read it
had could not tell it: `async_get_users` is scoped to `managed_slots` --
the slots existing Lock Code Manager entries claim -- because it also
decides where writes land.

During a first-time config flow that set is empty, so ZHA, Zigbee2MQTT,
Schlage and Akuvox returned no users at all, and an empty answer is
indistinguishable from an empty lock. The same hole reappears when adding
a lock to an existing entry: the read covers the slots already claimed and
nothing else, so a code at any other index stays invisible.

`async_get_occupied_indices(limit)` asks the separate question -- which
indices does this DEVICE hold, whoever put them there -- and returns None
when the lock cannot say, which callers must treat as unknown rather than
free. The base returns None so a provider that cannot answer refuses by
default rather than by omission.

`limit` bounds the work. ZHA and Zigbee2MQTT can only be asked about one
index per round trip, so walking a 250-slot lock to place three users is
not acceptable; the caller asks about a window and widens it if it turns
out too full.

Per provider:

- zwave_js, virtual already read the whole lock, so occupancy falls out.
- zha, zigbee2mqtt probe the window one index at a time.
- schlage, akuvox address a code by its own identifier and keep the slot
  number in the code's NAME, so an untagged code occupies no index at all.
  What the read does find is a tag left behind by a configuration since
  removed, which claims a number nothing else knows about.
- matter allocates its own credential index, so occupancy cannot
  constrain it and the base default stands.

Two understatements fixed on the way, both of which would have reported an
occupied index as free:

- ZHA required the PIN value to come back before counting a slot. An
  enabled slot on a lock that will not return codes read as empty.
- Zigbee2MQTT used `SlotCredential.unreadable()` for both "this slot holds
  a write-only code" and "the lock never answered". `_async_read_slot`
  now returns None for the second, which `async_get_users` maps straight
  back to unreadable so its behaviour is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDxiHpQJkRKWctS9BfQmbY
Entire-Checkpoint: 84153daec116

* refactor: read occupancy through the projection, not beside it

Review found the separate read reporting a User Code CC lock as empty while
the projection reported two slots occupied. `async_get_occupied_indices`
sourced from `async_get_users`, which is the read node-zwave-js leaves blind
when a lock reports a slot occupied but withholds the code -- exactly what
`_overlay_uc_occupancy` exists to repair, and it repairs the projection, not
the user list. A second read path has to repeat every such repair, and the
one it misses reports an occupied slot as free.

So there is no second read path. `async_get_users` and `async_get_usercodes`
take an optional scope; occupancy is one derivation in BaseLock over the
projection they already produce. Providers lose their occupancy overrides
entirely. Polling is unchanged: the default scope is still the slots this
integration manages.

An index whose value could not be read counts as occupied. Over-reserving
costs a user a slot number; under-reserving costs them the code on their
door. That also removes the need to signal "no answer" separately, so
zigbee2mqtt's `_async_read_slot` goes back to returning a credential --
the extraction stays, the extra state does not.

ZHA had drifted into two answers for one question, which is what the split
invited: the managed read called an enabled slot with no readable code
`empty` -- confirmed cleared -- while the occupancy read called it occupied.
One loop now, and `unreadable` in both roles, matching zigbee2mqtt and
zwave_js. `_parse_pin_response` no longer answers "Available" for a response
shape it could not parse.

Akuvox raises rather than reporting an empty device when its response
carries no user list at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDxiHpQJkRKWctS9BfQmbY
Entire-Checkpoint: 5dc4a9511b8a

* test: drive the occupancy seam through a real read

Second review round.

Every base test patched ``async_get_usercodes``, which proves the filtering
and nothing about the seam under it -- and the one mock that could have
supplied a real read had never been given the scope parameter, so calling
the derivation on it raised TypeError. Inert only because nothing calls it
yet. The mock now takes a scope and answers about exactly what it was asked,
and a test drives the derivation end to end, failing if the signature drifts
again.

"The projection bounds the answer" was false: the projection seeds the scope
and then overlays everything the provider reports, which is what keeps an
unmanaged occupied slot visible on the default path. Since that is
load-bearing, the docstrings now say the result may exceed the scope and the
caller must bound it -- which the occupancy derivation already does.

Matter gains the scoped read test it was missing. It is the provider where
the slot number is not the device's credential index, and the untagged user
its fallback covers is exactly what a code added at the keypad looks like.

Also renames a ZHA test whose name claimed the opposite of its assertions,
and drops a paragraph zigbee2mqtt's read said twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDxiHpQJkRKWctS9BfQmbY
Entire-Checkpoint: 0c8fc51415de

* fix: only AVAILABLE means a ZHA slot is empty

Third review round found the read reporting a live credential's index as
free. The ZCL has four user statuses -- AVAILABLE, ENABLED, DISABLED,
NOT_SUPPORTED -- and only AVAILABLE means the slot holds nothing. DISABLED
is the spec's occupied-but-inactive: there is a code there, the lock just
will not accept it right now. Testing for `!= ENABLED` collapsed DISABLED
and NOT_SUPPORTED into `empty`, so a slot holding a PIN read as confirmed
cleared -- which tells sync to reprogram over it and tells allocation the
index is free to hand out. The collapse predates this branch; making
occupancy depend on the same read is what turns it into an overwrite.

The end-to-end base test could not have caught anything. It used the same
window for the scope and for the filter, so no fixture data could make it
discriminate -- removing the scope threading entirely left it passing. It
now drives a provider that can only answer about the indices it was given,
which is the shape ZHA and Zigbee2MQTT have and the shape where losing the
scope changes the answer rather than the cost. The mock's scoped read also
stopped answering with exactly the scope, since three of seven providers
report more than they were asked about.

Akuvox checked for its `users` key but not its type, so a wrongly typed
value escaped as a bare TypeError past every handler that knows this
integration's own errors.

Also corrects the base docstring, which said a Matter slot number always
comes from a user's tag -- for a user this integration never tagged it is
the raw credential index -- and asserts the stored-slot-outside-the-scope
case the virtual test had set up and never checked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDxiHpQJkRKWctS9BfQmbY
Entire-Checkpoint: 3f95eee6b82c

* fix: only an accepted code is a known one

Fourth review round. Both findings are the same shape as the ZHA one
before them: a provider reporting a credential index as free, or as
holding a value the lock is not honouring.

Zigbee2MQTT mapped `disabled` alongside `available` to `empty`, in both
the pushed user state and the answer to a GET -- and the GET reply
carries the code, so it was discarding a PIN it had been handed and
calling the slot cleared. `available` is now the only status that means
nothing is there.

ZHA required only a readable value to call a slot `known`, whatever the
status said, so a code the lock is refusing compared equal to the
configured one and the slot read in sync while the door stayed shut.
Under v3 the same reply produced `empty`, which at least forced a rewrite
after a restart. `known` now means ENABLED with a value; everything
between AVAILABLE and that is `unreadable`.

Neither was pinned. The ZHA test asserted only that a DISABLED slot was
present, so `known` and `unreadable` both satisfied it, and every
Zigbee2MQTT occupancy test stubs the read that does the mapping. Two
tests asserted the old Zigbee2MQTT behaviour outright.

Also pins three mutations that survived elsewhere: the lower bound on the
occupancy window, akuvox's missing-`users`-key case, and that every error
this integration raises -- not only LockDisconnected -- makes occupancy
unknown rather than escaping.

AGENTS.md described a provider interface that no longer exists, and never
described SlotCredential at all. Since choosing wrongly between its three
states is what both of these findings were, it now says what each one
claims and which way to err.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDxiHpQJkRKWctS9BfQmbY
Entire-Checkpoint: e7afee205623

* fix: an enabled slot with no code reported is withheld, not empty

Round five. The previous commit fixed the branch of the GET reply where a
value came back and left the one where none did, so an enabled slot whose
code the broker hides -- `expose_pin` off -- still read as confirmed
cleared. The comment justifying it was wrong on its own terms: the reply
does carry `user_enabled`, and the next branch already used it.

The users object has answered this exact state correctly all along,
telling an absent `pin_code` key from an explicit null. The reply handler
now makes the same distinction, so the two paths agree about the same
physical lock state.

AGENTS.md named two methods no provider implements. `async_set_usercode`
and `async_clear_usercode` are BaseLock orchestration -- they own creating
a user before its first credential and the readable-PIN checks -- and
providers implement `async_set_credential` / `async_delete_credential`.
The optimistic-update template taught the wrong ones, returned the wrong
type, and pushed a raw PIN string where every consumer calls `.is_present`
or `.matches` on a SlotCredential.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDxiHpQJkRKWctS9BfQmbY
Entire-Checkpoint: 14ed98485df2

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat: model which slot numbers allocation may use

The first half of removing the start slot. Nothing consumes this yet.

On most providers the slot number IS the lock's credential index, so issuing
an occupied one writes a user's code over a credential already on the door.
Occupancy is read from the locks instead of asked for, which is what makes a
start slot unnecessary.

A failed read is therefore not an empty one. _async_get_all_codes currently
cannot tell them apart -- it skips a lock it could not query and skips one
that returned nothing, and the caller sees the same thing either way.

But refusing on every unreadable lock would be wrong, and reading
_LockQuerySkipped is what shows why: it fires for an unsupported platform, a
missing registry entry, and a missing parent config entry. Lock Code Manager
writes credentials to none of those, so their contents cannot collide with
anything it issues. Neither can a lock where credential_index_follows_slot is
False, which allocates its own index. Refusing on those would block
allocation permanently for any setup that includes one -- a worse failure
than the one being prevented.

So a lock constrains the numbering only when Lock Code Manager writes to it
AND addresses it by slot number, and only those can make occupancy unknown.

The properties are stated in BOTH directions deliberately. The forward
direction alone -- "if occupancy is unknown, some constraining lock was
unreadable" -- is satisfied by an implementation that never reports anything
as unknown, which is precisely the defect it exists to catch. Verified: four
mutations, four failures.

Entire-Checkpoint: d7b6de645f6a

* feat: choose slot numbers instead of asking for them

The UI setup path asked for a start slot and a slot count. Both existed
only because the slot number was configuration; it no longer is, so the
flow asks how many users there are and allocates the numbers itself.

Allocation reads every configured lock first and takes the lowest numbers
no lock already holds a credential at and no other entry manages. A lock
that does not answer is not treated as empty: issuing a number against an
unread lock could overwrite a credential programmed by hand, so setup
aborts with `occupancy_unknown` naming the locks that stayed silent.

Only locks that address credentials BY slot number constrain the choice.
Reserving a number because a Matter lock happens to hold something at that
index would push real users past the capacity of the locks that do follow
the slot number.

`reconcile` gets its first production caller here.

Behaviour change worth calling out: the existing-codes confirmation no
longer appears during setup. It existed because the user picked a range
and might have picked a slot already in use; now an occupied number is
simply never offered, so there is nothing to confirm. The gate survives
for the YAML path and for adding a lock to an existing entry, where codes
can still be found at numbers already assigned.

Capacity refusal on the UI path aborts with `too_many_users` rather than
the YAML path's `slot_out_of_range`, which was defined only as a form
error and told the user to renumber slots they never chose.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDxiHpQJkRKWctS9BfQmbY
Entire-Checkpoint: 9094e0ffaa04

* fix: refuse where the answer can still be changed

Review of the allocation path found three flow-layer defects.

A lock whose provider could not be built was reported as unmanaged, which
took it out of the occupancy check entirely -- allocation then issued
numbers against a lock it had never read. Only an unsupported platform
means Lock Code Manager will not write to a lock; an entity missing from
the registry, or an integration entry that has gone, is a lock this entry
still owns and merely could not reach. `_LockQuerySkipped` now carries
which of the two happened, and the unreachable cases make occupancy
unknown rather than empty.

Both refusals also ran too late. Occupancy is settled before the count is
even asked for, and which users get configured does not change which
numbers allocation issues -- it always takes the lowest free ones -- so
the count alone decides whether they fit. Both were being decided after
the last user form, ending the flow and discarding every name and PIN
already typed. The unreadable-lock refusal now aborts on the way in to
the count form, and a count that will not fit is a form error on the form
that asks for it, which the user can correct.

That removes the terminal `too_many_users` abort, so the message can say
what the user needs: not the lock's raw capacity, but how much of it the
codes already on the lock leave them.

Also restores the condition-entity explanation dropped from the code_slot
step -- it was the only place the UI said what that entity does -- and
fixes the ui step title, which still said "How Many Slots?".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDxiHpQJkRKWctS9BfQmbY
Entire-Checkpoint: c935c6732a92

* feat: read only as far as allocation has to

Allocation now consumes the occupancy read, and reads a window rather than
a lock. It starts at the number of users asked for and widens only by what
turned out to be in the way, so a lock that answers one index per round trip
is never walked to its advertised capacity to place a handful of users.
Placing two users on a lock whose first three numbers are taken asks about
2, then 4, then 5.

Widening stops when the numbers it would need are past what a lock can
hold, which is the same refusal as asking for too many users -- on a full
lock that is what it is. The count is still settled before a single name is
collected, because which users get configured never changes which numbers
are issued.

There is no ceiling constant. The window is bounded by demand from below and
by the lock's advertised capacity from above, both of which are real
numbers; anything else would be invented.

`_occupancy_from` goes with the read it served.

Several UI tests were configuring a lock entity that does not exist, which
the flow now correctly refuses -- a lock it cannot build a provider for is
unread, not empty. They set the lock up. Two capacity tests were also
reading their scenario from a stub of the old read; they now state what the
lock holds, and one of them turned out to be asserting room on a lock whose
every slot was occupied.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDxiHpQJkRKWctS9BfQmbY
Entire-Checkpoint: 8842ff5cdbb9

* fix: say what the refusal knows, and refuse before reading

Review of the allocation flow.

Both name errors rendered as a translation error rather than an
explanation. `name_required` and `name_not_unique` interpolate a slot
number, and the step that collects one user has none to give -- nor does
the YAML schema failure that reports the same key, which supplied no
placeholders even before this branch. The shared wording no longer asks
for a slot number, and the one path that does know which slot failed uses
messages that name it. A test now walks every name error the flow can
raise and fails if a message asks for a placeholder nothing supplies.

The window opened at the number of users and read before anything checked
it, so a mistyped 500 was 500 sequential round trips -- each holding the
operation lock -- before the flow said the lock has three slots. The count
is checked first now, against the same capacity, and an impossible one is
refused without asking a lock about it.

The refusal also promised a maximum it could not compute: occupancy was
counted only as far as the window read, while capacity was the lock's
full count, so the number it offered could be too high and the user who
followed it was refused again. It now states the capacity and the count
and offers no maximum, which is what it can stand behind.

Only errors this integration defines were caught while reading a lock, so
anything else -- a provider raising TimeoutError -- escaped the flow
entirely instead of becoming the unknown-occupancy refusal this branch
exists for. Locks that allocate their own credential index are no longer
read at all: what they hold cannot constrain the numbering, so the round
trip bought an answer nothing reads.

Drops a no-progress guard that cannot fire, with the termination argument
written down instead, and a capacity re-check after allocation that
cannot fail because every window was checked before it was used.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDxiHpQJkRKWctS9BfQmbY
Entire-Checkpoint: 3be354c0469d

* fix: read every index once, and let a sleeping lock be retried

Review round three.

Each pass re-read the whole window from the beginning, so widening cost a
lock holding low numbers several times its own capacity: placing two users
on a lock holding 1-29 took 271 index reads across 16 passes, against 31
for reading each index once. On the providers that answer one index per
round trip that is 271 sequential trips holding the operation lock -- the
cost this approach exists to avoid. Each pass now asks only about the
numbers no earlier pass covered, and `async_internal_get_occupied_indices`
takes the indices to ask about rather than a ceiling.

An unreadable lock ended setup outright while the user's own mistake, too
many users, came back as a form they could correct. That had the two the
wrong way round: a battery lock that happens to be asleep is the transient
one. It is a form error now, on the step that asked.

Nothing pinned the mechanism that replaced the old loud refusal for slots
another entry already manages: cutting the wiring entirely left every test
green. A test now walks a neighbouring entry holding the low numbers on a
shared lock and asserts the new users step over them.

`_LockQuery` carried two fields nothing read, under a docstring saying
they were what stops allocation issuing a number it could not verify.
Allocation reads occupancy itself; this query feeds the existing-codes
confirmation and nothing else, and now says so and carries only what that
needs.

Also covers the YAML path's own name error, drops a key the options flow
no longer produces, and stops asking a lock about an empty range.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDxiHpQJkRKWctS9BfQmbY
Entire-Checkpoint: ec4d72203e48

* fix: pin where allocation starts, and hand the count back

Review round four.

Nothing held allocation to starting at one. Changing it to start at two, or
at three, left the whole suite green: every test that asserts an assignment
has codes or a neighbouring entry holding the low numbers, so none of them
ever sees a user land on 1. That is the branch's own safety property going
unwatched -- starting higher issues numbers no lock was read for. An empty
lock now has to number its users from one, and the two widening tests
assert no user lands past the highest index actually read.

A refused count came back as the default, so someone who asked for eight
was handed three and had to remember what they had typed. The form returns
holding what was refused.

Also drops "alongside the codes already on it" from the refusal, which is
also emitted before anything has been read, and rewrites a comment that
described the managed case while sitting on the line that handles both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDxiHpQJkRKWctS9BfQmbY
Entire-Checkpoint: c95c8ec2949b

* fix: two refusals that say different things get different words

Review round five, on the previous round's own fix.

`too_many_users` had two emitters. Dropping "alongside the codes already on
it" made the count refusal true and the widening refusal false: a four-slot
lock holding two codes, asked for three users, said "lock has 4 PIN code
slots, so 3 users will not fit" -- and then advised re-interviewing a lock
whose interview is correct. The removed clause had been the only thing
making that sentence true.

They are different statements. The count refusal is about a count larger
than the lock; the widening one is about a count that fits while the
numbers it would have to reach around existing codes do not. The second has
its own message now, naming the number the last user would need.

Which count that message reports was a surviving mutant -- reporting the
needed number in the user count's place left the suite green -- and it is
the field the wording turns on, so both are asserted.

Also states what the widening tests were reaching for: every number issued
was one that was actually read, rather than merely below the highest read,
which would let a gap through if the read ever stops being contiguous. One
of the two assertions was a tautology between two exact-equality checks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDxiHpQJkRKWctS9BfQmbY
Entire-Checkpoint: 0b2f0f887d1f

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat: let each lock say where its slot numbers stop

A search for free slot numbers has to stop somewhere, and only the lock
knows where. Past its last slot a lock cannot report a slot it does not
have, so on the providers that answer one index at a time every index
beyond the end comes back occupied and the search walks upward forever.
The providers that read the whole lock have the opposite failure: indices
past the end are seeded empty, so the search settles on numbers the lock
cannot hold and the write fails later.

`async_get_max_slot` asks the question, and every provider answers:

- zwave_js already had the best answer and needed no change. Its advertised
  capability comes from the driver, and when that reports zero slots the
  provider re-queries the User Code CC `getUsersCount` device query before
  concluding anything -- the fallback here is reached only in the state it
  already calls unusable.
- ZHA had no capability path at all and now reads the Zigbee Cluster
  Library's own `num_of_pin_users_supported` off the DoorLock cluster. The
  attribute is 16 bits, so an implausible answer is clamped: this provider
  spends a round trip per index, and believing 65535 would mean tens of
  thousands of them before the flow could refuse.
- Zigbee2MQTT has neither, and takes the fallback. Its bridge publishes
  device definitions that may carry the range, but this provider subscribes
  only to the device topic; reading them wants a real payload to work from
  rather than a guessed shape. It is the provider where this costs the
  most, so it is the one most worth teaching next.
- Schlage, Akuvox and virtual have no device range to look up, because
  their slot number is this integration's own name tag rather than an index
  on the lock.
- Matter is never asked; it allocates its own credential index, so what it
  holds cannot constrain the numbering.

Distinct from `bounded_slot_count`, whose None means "I could not read a
capacity, so do not refuse a write over it". That answer is deliberately
permissive and must not be read as permission to search past the end of a
lock, so this one always answers.

Allocation takes the smallest answer across the locks it must satisfy, and
keeps the lock that gave it: a number past any single lock's range is one
that lock cannot hold, and a refusal that names the wrong lock -- or names
this integration's own limit as a lock's capacity -- sends the user to
re-interview a lock over a number it never reported.

The count itself is checked against the bound before the first read, not
only before each widening. A count that already runs past the range walks
off the end of the lock on the way in, and on a lock that reads past-end
as free it would be handed every one of those numbers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDxiHpQJkRKWctS9BfQmbY
Entire-Checkpoint: 318dda2e05f4

* fix: a lock with no opinion is not a lock that answered

Re-review of the fix from the last round, which broke its own rule.

`async_get_max_slot` returned the fallback limit when it could not say, so
the caller recorded a lock name beside a number no lock had given. The
refusal then read "Lock lock.front_door has 255 PIN code slots ... if the
lock really does have more slots than that, re-interview it" about a lock
that reported nothing -- which is precisely what the helper's own docstring
said a message must never do. Every Zigbee2MQTT lock hit it, as did any ZHA
lock that declined, and any provider without an advertised capacity. The
message written for that case was reachable only when no provider could be
built at all.

The fallback was in the wrong place. It is a search policy, not a fact
about a lock, so it moves to `const.MAX_SEARCHED_SLOT` and providers answer
`None` for "no opinion". Only a real answer earns a name, so only a lock
that spoke is blamed.

That also settles what the base should catch. It promised to always answer
while catching only this integration's own errors, so a provider raising
something else changed which refusal the user saw. Having no opinion is an
answer; an exception escaping is not.

Ties are ordinary -- two locks of a kind answer alike -- so the entity id
breaks them and the same lock is named every time. Nothing pinned that
before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDxiHpQJkRKWctS9BfQmbY
Entire-Checkpoint: 93510af32ce0

* fix: carry the split refusal into the bound

The base branch split `too_many_users` in two, because a count that fits
the lock being told "N users will not fit" reads as a bug. The bound
refusal has both shapes as well -- a count larger than the range, and a
count that fits while the numbers it must reach around existing codes do
not -- so it makes the same distinction, naming the number the last user
would land on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDxiHpQJkRKWctS9BfQmbY
Entire-Checkpoint: 3f475316c62b

* test: pin migration against a shape taken from production

Rehearsing the v3 migration against a copy of a live config entry turned
up three things a hand-written case had not covered: a slot whose name is
the empty string, a slot with no name key at all, and a disabled slot
still holding a PIN. All three have to keep their slot number, because
that number is what their entities are keyed on.

Also asserts the entity registry is reused rather than rebuilt, which is
what keeps a user's entity IDs and their history across the upgrade.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDxiHpQJkRKWctS9BfQmbY
Entire-Checkpoint: 0aa746f300d8

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat: name a slot's device after the user who holds it

The slot number is internal bookkeeping, so it should not be the thing a
user reads on their dashboard. The per-slot device becomes "Raman" rather
than "All Locks Code slot 3", and its model becomes User.

The name is looked up inside build_slot_device_info rather than passed in,
so no caller can name a device something the configuration disagrees with.

Renames need explicit handling: DeviceInfo only names a device as it is
created, and the rename that matters most -- the name text entity -- writes
to data with empty options, so it returns from the update listener before
any entity work happens. _async_rename_slot_devices therefore runs above
that early return. A device the user renamed themselves keeps their name,
because name_by_user is untouched.

Entity IDs follow for NEWLY created entities only (text.raman_pin rather
than text.mock_title_code_slot_1_pin). Existing installs keep theirs: the
registry keys on unique ID, which still carries the slot number, as the
production-shape migration test asserts.

Tests that spelled an entity ID out now resolve it by unique ID, since the
slug is derived from whoever holds the slot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDxiHpQJkRKWctS9BfQmbY
Entire-Checkpoint: f179f6519e05

* fix: let the blueprints find our entities without guessing their IDs

Three blueprint lookups matched Lock Code Manager's own entities with a
regular expression over the entity ID. That coupling breaks the moment the
device is named after the user, and it was already broken in two ways:

- ``calendar_condition`` looked for an ``event.*_pin_used`` suffix. The
  event entity has no name of its own, so its ID has only ever been the
  device slug. That regex has never matched.
- The event entity could not be found by slot either. Its
  ``extra_state_attributes`` property shadows the base class outright, so
  building a fresh dict there dropped ``code_slot`` -- leaving the one
  entity a template had no way to identify.

Entities now publish ``slot_field`` alongside ``code_slot``, saying which
property of the slot they represent, and the lookups select on those two
attributes. Nothing has to know how an entity ID is spelled -- which also
sidesteps that the ID is built from the user's name in whatever language
the entity was first created in.

None of these templates were rendered by any test, which is how the
``_pin_used`` regex shipped. They are now read out of the YAML on disk and
rendered against a real setup, so the test cannot drift from what users
run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDxiHpQJkRKWctS9BfQmbY
Entire-Checkpoint: ac5708808904

* feat: re-slug entity ids onto the user during migration

Without this an upgraded install would keep slot-shaped entity ids forever
while a fresh install got name-shaped ones, leaving two shapes to document
and support.

The registry ENTRY is kept, not rebuilt -- the unique id still carries the
slot number -- so settings, area, and recorder history follow the entity.
Home Assistant repoints history and long-term statistics itself: the
registry emits old_entity_id and recorder.entity_registry acts on it.

Only the device-slug PREFIX is swapped. The rest of an entity id comes from
the entity's own name, which is translated, so an install first created in
another language does not spell its suffixes the way this code would. An id
that does not begin with the prefix this integration would have generated
cannot be taken apart, so it is left alone.

Updates the production-shape migration test, which seeded ids that no
longer resemble what a released version produces -- it was passing through
the leave-alone path while claiming to prove reuse. It now seeds real
old-format ids and asserts the id moves while the registry row does not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDxiHpQJkRKWctS9BfQmbY
Entire-Checkpoint: c53b19c9961b

* feat: tell the user which entity ids moved under them

Home Assistant repoints recorder history on a rename, but nothing rewrites
an entity id stored inside an automation or script -- only the frontend's
own rename dialog offers that, and a migration does not go through it.
Automations built from the Slot Usage Limiter and Slot Usage Notifier
blueprints hold these ids directly, so the mapping is surfaced as a
dismissible repair rather than left for the user to discover when an
automation quietly stops firing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDxiHpQJkRKWctS9BfQmbY
Entire-Checkpoint: f02b8e4d529e

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
#1442)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…1443)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat: the event reports a credential being used, not a PIN

A PIN is one kind of credential, and the providers already model others.
Naming the event after the only kind exercised today was going to read as
wrong the moment a second arrived, so it becomes credential_used and the
event data says which kind it was.

The key is the last part of the entity's unique id, so the migration
rewrites the stored ones. Leaving them would orphan every existing event
entity and build a fresh one beside it, losing the entity id and the
history attached to that registry row.

The calendar condition blueprint selects the event entity by the
slot_field it publishes, so that moves with it. The Slot Usage blueprints
keep their input keys untouched -- renaming one breaks every automation
already built from it -- but their descriptions no longer name an entity id
shape that stopped being true when entity ids moved onto user names.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDxiHpQJkRKWctS9BfQmbY
Entire-Checkpoint: 466422130226

* feat: the blueprints talk about credentials, keeping their input keys

The event no longer reports a PIN specifically, so the blueprints should
not either. What a user reads is the input's name and description; the key
underneath is never shown to them.

The keys stay exactly as they are. A blueprint input without a default is
required, and Home Assistant raises MissingInput for any declared input an
automation does not supply -- so renaming a key makes every automation
already built from that blueprint fail to load. Adding a second, preferred
key does not avoid it either: the input feeds a state trigger's entity_id,
which is resolved at render time, and an unset one renders as null and
makes the trigger invalid.

Also drops descriptions that named entities as "Code slot X PIN used".
Entity ids moved onto user names, and the event entity never had that
suffix to begin with.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDxiHpQJkRKWctS9BfQmbY
Entire-Checkpoint: 77b403f8bd76

* fix: carry the rename into the dashboard and survive a duplicate

Two findings from reviewing the rename.

The dashboard strategy picks entities out of the websocket payload by
comparing entity.key against constants it declares itself, and one of them
still said "pin_used". Nothing fails when those drift -- the strategy simply
stops finding the event entity, and the exclusion further down then renders
it as an ordinary one. Both sides now agree, and a test asserts they do,
because the frontend's 714 tests all use its own constant and so cannot
notice it disagreeing with the backend.

Re-keying onto a unique id the registry already holds raises, and a raise
inside a migration leaves the ENTIRE entry unloadable -- every lock and
every PIN, over one stray registry row. Confirmed: the entry came up
MIGRATION_ERROR. It is now logged and skipped.

The shipped bundle is rebuilt. The TypeScript diagnostic about
pinActiveEntity is pre-existing; it is on unmodified v3 too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDxiHpQJkRKWctS9BfQmbY

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat: the editor names users, and never a slot number

Setup's guided path stopped asking for slot numbers, but the yaml path and
the options flow -- the one people use for the rest of the entry's life --
still did. The release's promise held only for the first five minutes of
ownership.

Both now take users keyed by name and allocate the numbers afterwards,
through the same code the guided path uses. Nobody picks a number on any
route, so no route can land on one a lock already holds.

That makes the existing-codes confirmation unreachable, and it goes: the
mixin that carried it now carries allocation instead, and the lock-query
helpers it needed go with it. _check_common_slots stays, because reauth
swaps a lock and an existing slot set can still collide there.

The per-user ``entity_id`` field becomes ``condition``. It names the entity
whose state gates the user's credential, which the old name said nothing
about while colliding with every other entity id in the configuration. The
migration renames it, dropping the old key rather than leaving both.

Tests follow in the next commit; this one is the shape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDxiHpQJkRKWctS9BfQmbY
Entire-Checkpoint: 52cee0833eb3

* test: follow the editor onto users, and drop what the flow no longer has

Eighteen tests went with the code they exercised: the existing-codes
confirmation and the lock-query helpers that fed it. What remains was
converted rather than deleted -- a slot-keyed block becomes users keyed by
name, and the tests that submit one now allocate, so they read the locks
the way the guided path always did.

Three behaviours changed shape rather than disappearing, and their tests
say so now:

- picking a number another entry manages was an error; allocation now
  simply does not issue one
- a name problem names the user rather than a slot number, because there
  is no slot number to name
- capacity is about how many users a lock holds, not which number somebody
  typed

Restores coverage of _async_build_lock_instance's three skip paths, which
were only reached through the deleted tests but are still live code, and
covers the refusal both flows now share when a lock cannot be read.

Also uses Home Assistant's own CONF_CONDITION rather than defining a second
constant with the same value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDxiHpQJkRKWctS9BfQmbY
Entire-Checkpoint: d3fe08f4e43a

* fix: an entry's own numbers do not constrain itself

Two from reviewing this branch.

Editing an entry counted its OWN slots among those other entries had
claimed, so a submission that swapped one user for another was told the
number it was releasing in that very submission was taken. Every
replacement landed one higher, and an entry edited enough times would run
out of room on a lock with plenty left. The numbers it keeps are held by
tenure; the ones it releases are free.

The condition rename preferred the legacy field when a user carried both,
so a value written as ``condition`` was overwritten by a stale
``entity_id`` beside it. Only reachable from a configuration written
part-way through this release, but the direction was backwards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDxiHpQJkRKWctS9BfQmbY
Entire-Checkpoint: 1465e0d0bd35

* fix: an edit keeps what the form never asked about

Building the saved data by hand dropped every top-level key the editor does
not know about, on the first edit, silently. EntryConfig carries them for
exactly this reason and the flow it replaced went through it.

Found by reviewing this branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDxiHpQJkRKWctS9BfQmbY
Entire-Checkpoint: 20bd7b083e5d

* fix: one condition rule, whichever route writes it

The guided path refuses a condition entity from an integration whose
switches and binary sensors do not describe access at all. The editor did
not, so it was a way around the check -- and the entity would then gate a
credential on a state that means something else entirely.

Pre-existing: the yaml path never checked either. Moved into the shared
validator so both routes answer the same way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDxiHpQJkRKWctS9BfQmbY
Entire-Checkpoint: baabd86ca079

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* origin/main:
  fix: name and describe the four services that had neither (#1450)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ixin (#1452)

An audit over main...v3 counting production versus test references for
every symbol the branch introduced, and matching every translation key
against anything that could produce it.

Dead, all of it debris from the existing-codes confirmation flow that
#1447 removed when allocation started routing around occupied slots
instead of asking permission to clear them:

- its ten strings, across four key groups in both string files
- EntryConfigDiff.pairs_added, computed on every diff for nobody; its
  only consumer was that flow's hazard check. pairs_removed stays, the
  update listener releases slots with it
- the docstring naming the consumer that no longer exists
- domain.names.validate_slot_names, superseded by validate_user_names

That last one is worth noting: it is part of the repository's 100%
coverage. Its own test executes every line, so a dead function with a
live test looks exactly like a healthy one. Coverage cannot tell
"exercised" from "needed".

_AllocatesSlotsMixin no longer earns being a mixin. Once allocation
moved to domain.allocation it held two attributes existing only to pass
two arguments implicitly, a twelve-line try/except, and _create_entry --
which only the config flow called, the options flow using
async_create_entry directly. It is now a module function taking its
arguments explicitly; both flows create their entry the same way, and
the set-this-attribute-first protocol is gone. The config flow's copy of
the lock list was redundant with self.data[CONF_LOCKS].


Claude-Session: https://claude.ai/code/session_01XDxiHpQJkRKWctS9BfQmbY
Entire-Checkpoint: e791ea41d58f

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* origin/main:
  feat: ask what to do about codes the integration does not manage (#1454)
  fix: clear a slot's code off the lock when the slot is removed (#1453)

# Conflicts:
#	custom_components/lock_code_manager/__init__.py
#	custom_components/lock_code_manager/repairs.py
#	custom_components/lock_code_manager/strings.json
#	custom_components/lock_code_manager/translations/en.json
#	tests/test_init.py
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* origin/main:
  build(deps-dev): bump aioesphomeapi in the homeassistant group (#1456)
  build(deps): update hypothesis requirement from >=6.165.9 to >=6.165.10 (#1457)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat: address a slot by the user holding it, over the websocket

The three commands a card sends -- subscribe_code_slot,
set_slot_condition, clear_slot_condition -- took a slot number and
nothing else. They now take a name as well, which is what every other
surface moved to. The slot number stays so existing callers keep
working, and is expected to go.

Names are matched slugified, the way a config entry title already is: a
caller holding only the slug an entity id was built from can still name
its user.

Slugifying collapses more than the name rules do, though -- "Ada-Lovelace"
and "Ada Lovelace" are two users under those rules and one slug under
this. Where that happens the command is refused and names both, rather
than picking one and writing somebody else's credential. A user the
allocator has not numbered yet is refused for the same reason: there is
nothing on any lock to address.

Resolution happens at the websocket boundary rather than deeper down: a
slot number is still what the coordinator, the entities and the provider
key on, so this is where the two vocabularies meet.

clear_slot_condition gained a failure path it did not have before, so it
gained the error handling to match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDxiHpQJkRKWctS9BfQmbY
Entire-Checkpoint: 0f359e698aca

* feat: the card shows a user, and says so

The slot card was the last place a slot number faced the user. It is
registered as custom:lcm-user now, takes `name` instead of `slot`, and
the editor asks for a user rather than a number.

Not a second card. The old one was 1,450 lines of which `slot` appeared
in three -- all message payloads, none of them rendering -- so a separate
implementation would have been three payloads' worth of difference and
3,500 lines of duplication to maintain. custom:lcm-slot stays registered
as a subclass that warns and defers, so dashboards holding one keep
working until it is removed.

A card carrying both a name and a slot number would keep showing whoever
held that number regardless of the name, so the editor drops the number
when a name is set, and the card sends one or the other from a single
place rather than deciding per command.

The strategy passes the user's name down to the section, which passes it
to the card; where a user has no name yet the slot number still goes
through, because something has to address them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDxiHpQJkRKWctS9BfQmbY
Entire-Checkpoint: cf120f3d01cf

* fix: review findings on the user card

subscribe_code_slot was the only one of the three commands without the
service-error wrapper, so a name that did not resolve escaped as an
ERROR log line rather than an answer. The editor dispatches on every
keystroke, so typing a name produced one per character.

set_slot_condition sniffed its own message text for "not found", which
none of the resolver's wordings contain, so the same input came back as
an unknown error there and a not-found from its sibling. It now catches
the resolver explicitly. clear_slot_condition's try was dead -- the
decorator above it already did exactly that.

The card picker was broken: the stub config named a real entry with an
empty name, and only the literal id "stub" suppressed subscribing, so
adding the card subscribed for nobody and rendered an error where the
preview goes. A card with no addressee is a stub now, whatever entry it
names, and naming nobody is no longer an error at all -- that belongs in
the editor, not where a preview belongs. A name that is not a string
still is one.

_addressee tested `!== undefined` where both other producers test
truthiness, so an empty or null name sent an unusable addressee and
ignored a perfectly good slot number. Clearing the name in the editor no
longer takes the slot number with it, leaving the card addressing
nothing.

Also drops the lcm-slot-editor registration: the deprecated card
inherits getConfigElement from the user card, so nothing ever asked for
it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDxiHpQJkRKWctS9BfQmbY
Entire-Checkpoint: f74d3b7f7a74

* fix: address a card by an entity, which a rename does not move

The card was addressed by the user's name, and the card has an inline
name editor. Renaming a user on the card left its own stored config
naming somebody who no longer exists: the live subscription survived,
having resolved the slot once, so nothing looked wrong until the next
page load, when the card came back dead. Strategy-generated cards heal
themselves on every render; a hand-placed one does not, and the editor
had deliberately dropped the slot number that used to hold it together.

That is the mistake this project has made before, one layer up. A stored
identifier must not be a value the user can change, and the name is now
the most changeable value there is.

So a card is addressed by an entity of its user. Entity IDs are unique
by construction and this integration's do not move on a rename -- unique
IDs keep the slot number, which is the whole reason a rename costs
nothing. The commands take user_entity_id, named apart from entity_id
because on the condition commands that already means the condition
entity itself.

It also settles the ambiguity the name matching introduced: two users
whose names collapse to one slug broke both their generated cards, since
the resolver refuses rather than guess. Entity IDs cannot collide, so
the generated dashboard no longer has the problem to have.

The name stays for anyone writing a card by hand, where it is far easier
to type than an entity id, and the editor still clears the other
addressees when one is set -- a card carrying two would show whoever the
other one pointed at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDxiHpQJkRKWctS9BfQmbY
Entire-Checkpoint: 2ef94448e4a4

* test: cover the entity map the view strategy builds

The loop that picks an entity per slot had no test reaching it: every
fixture passed an empty `entities` list, so the map was always empty and
sections went out addressed only by name. It dropped this file from 98.6%
line coverage to 94.9% and nothing failed, because the fallback is the
name and the name still worked.

Covers both branches: a slot with entities gets the first one seen, which
is what keeps the choice stable across renders rather than dependent on
registry order, and a slot with none is left to its name and number --
reachable while a slot is being set up, when the configuration knows the
user before any entity for them exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDxiHpQJkRKWctS9BfQmbY
Entire-Checkpoint: 709ca09d8ddc

* feat: the card is titled by its user, and loses a bar of chrome

The header read "Slot 1 · All Locks", which was the last place a slot
number faced the user -- and once the card began addressing itself by an
entity the number was not even set, so it rendered "Slot undefined".

Taking the title out left an icon and a state chip alone in a bordered
bar, saying the same thing twice: the card already tints its background
by state and the chip already names it. So the bar is gone. The icon
moved to the head of the row that carries the user's name, where it reads
as the card's avatar rather than a second state indicator, and the chip
sits at the end of that row.

The name was already there, already editable, already larger than the
title above it. It is the card's heading now, in the literal sense too --
dissolving the bar took the <h2> with it, and a screen reader lost the
card's title until it was put back on the name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDxiHpQJkRKWctS9BfQmbY
Entire-Checkpoint: 344fb26915cd

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@raman325 raman325 added breaking-change Pull requests that break existing functionality lcm-major Major version bump labels Aug 21, 2026
Copilot AI lite review requested due to automatic review settings August 21, 2026 03:03
@raman325 raman325 added breaking-change Pull requests that break existing functionality lcm-major Major version bump labels Aug 21, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions github-actions Bot added python Pull requests that update Python code javascript Pull requests that update javascript code documentation Documentation changes github-config Changes to .github/ configuration files blueprints Changes to shipped automation blueprints labels Aug 21, 2026
@codecov

codecov Bot commented Aug 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.75248% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 99.07%. Comparing base (d16a6f6) to head (511b9f8).
⚠️ Report is 2 commits behind head on main.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
ts/slot-card-editor.ts 81.81% 2 Missing ⚠️
ts/slot-card.ts 98.80% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #1462      +/-   ##
==========================================
+ Coverage   98.99%   99.07%   +0.08%     
==========================================
  Files          54       62       +8     
  Lines        6837     7680     +843     
  Branches      470      520      +50     
==========================================
+ Hits         6768     7609     +841     
- Misses         69       71       +2     
Flag Coverage Δ
python 100.00% <100.00%> (ø)
typescript 95.45% <98.76%> (+0.45%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
custom_components/lock_code_manager/__init__.py 100.00% <100.00%> (ø)
custom_components/lock_code_manager/config_flow.py 100.00% <100.00%> (ø)
custom_components/lock_code_manager/const.py 100.00% <100.00%> (ø)
custom_components/lock_code_manager/diagnostics.py 100.00% <ø> (ø)
..._components/lock_code_manager/domain/allocation.py 100.00% <100.00%> (ø)
...stom_components/lock_code_manager/domain/config.py 100.00% <100.00%> (ø)
...components/lock_code_manager/domain/credentials.py 100.00% <ø> (ø)
...stom_components/lock_code_manager/domain/models.py 100.00% <100.00%> (ø)
...ustom_components/lock_code_manager/domain/names.py 100.00% <100.00%> (ø)
...m_components/lock_code_manager/domain/occupancy.py 100.00% <100.00%> (ø)
... and 29 more
🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

v3 was added to the triggers because pull requests merged into it ran no
tests at all -- the only checks on them were the labellers, the release
drafter, and lint. It is about to reach main and stop existing, so the
branch it names would just be dead configuration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDxiHpQJkRKWctS9BfQmbY
Entire-Checkpoint: 9b8b7a9c94f1
@github-actions github-actions Bot removed the github-config Changes to .github/ configuration files label Aug 21, 2026
@raman325
raman325 merged commit 7cadf02 into main Aug 21, 2026
19 checks passed
@raman325
raman325 deleted the v3 branch August 21, 2026 03:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

blueprints Changes to shipped automation blueprints breaking-change Pull requests that break existing functionality documentation Documentation changes javascript Pull requests that update javascript code lcm-major Major version bump python Pull requests that update Python code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants