Skip to content

feat!: a person is the unit — users as subentries, slot-addressed surfaces removed - #1514

Open
raman325 wants to merge 19 commits into
mainfrom
v6
Open

feat!: a person is the unit — users as subentries, slot-addressed surfaces removed#1514
raman325 wants to merge 19 commits into
mainfrom
v6

Conversation

@raman325

@raman325 raman325 commented Aug 28, 2026

Copy link
Copy Markdown
Owner

Breaking change

Four actions removed. They addressed a lock slot or a device instead of a person:

Removed Use instead
lock_code_manager.set_usercode set_credential
lock_code_manager.clear_usercode clear_credential
lock_code_manager.set_slot_condition set_condition
lock_code_manager.clear_slot_condition clear_condition

The lock_code_manager_lock_state_changed event is removed. Use lock_code_manager_credential_used, which names the person rather than the slot number they occupy. Field-by-field mapping is on the wiki.

Users are now config subentries. A v4 → v5 migration moves them on load. Nothing is renamed and nobody is renumbered, so every entity keeps its ID, its settings and its history. The Configure screen now edits only the locks; each user gets their own add, edit and delete dialog on the entry's page, with their entities and device grouped under them. Add user opens on a choice of one user or a pasted block, so bulk adding survives the move.

The Condition Linker blueprint takes a Name instead of a Slot number. An automation already created from it must be reconfigured, or simply recreated — it is a one-shot.

Proposed change

The through-line is that a person is the unit Lock Code Manager works in, and the slot number is internal bookkeeping. Every surface that leaked that number is gone: the four slot-addressed actions, the lock-shaped event, and the slot_assignment side table.

Users move into config subentries, so each person's slot number lives in their own record and the configuration can no longer disagree with itself about who holds which. async_write_entry_config is the single write path, matching users by slot number — not by name, which is what a rename changes.

Verified against a real instance

Deployed to a production instance holding two v4 entries and restored from a full backup afterwards. Both migrated to v5, users landed in subentries with their numbers intact, and all 31 entities plus all 3 slot devices came up bound to the right person — no duplicates, no orphans, no errors.

That run caught two things the suite could not, both fixed here:

  • Home Assistant deprecates letting async_add_entities drag an existing device into a subentry. Every upgrading instance is in that position, since its devices predate subentries; the suite creates devices fresh during setup, already bound, so only a real upgrade reproduces it. The migration now moves each one with async_update_device.
  • A Lovelace card cannot open Home Assistant's subentry dialog: dialog-data-entry-flow ships only in lazily-loaded chunks, and no URL starts a subentry flow. The add-user card keeps its own dialog and links to the page that hosts the native ones.

Adding users in bulk

Setup has always taken a block of users keyed by name, and it still does; after this branch it creates one subentry per user and everything afterwards is per person. The Add user dialog offers the same block, so adding five people is one dialog rather than five, and the earlier options form that could bulk-add is not missed.

It only adds. A block naming somebody already on the entry is refused rather than merged into them, by the same identity rule the rest of the integration uses, so Bob and bob cannot become one person holding two credentials. Editing and removing stay one user at a time, which is what keeps exactly one way to change a user who exists.

The saving is per lock, not per keystroke: one allocation covers the block and one write lands it, and numbers already held are held by tenure and are not read for. A block of two on an entry of one asks each lock about two numbers, where two separate adds would ask twice and settle the entry twice. The block is written through async_write_entry_config rather than by completing the flow, because a finished subentry flow creates exactly one subentry and this creates as many as were pasted.

Defects this exposed

Five bugs that were latent before this branch:

  1. The unmanaged-code sweep ran mid-migration. It asks every entry what it manages; part-way through the chain an entry's users were still in data, so it saw zero managed slots and would have raised a repair for every one of its own codes on any v3 upgrade.
  2. from_entry reads the entry's locks options-first, so writing only data was invisible whenever options were staged — and, showing no diff, never woke the listener that clears them.
  3. The update listener's terminal settle corrupted multi-write sequences. Reconciling subentries and the entry takes several calls, each waking the listener; a pass waking between them wrote back the half it had read.
  4. A rename destroyed the user. Matching subentries by identity — derived from the name — made a rename read as a departure plus an arrival. Harmless until entities were bound; then removing the subentry would take that person's entities, settings and history.
  5. A blueprint called a removed action. condition_linker went on calling set_slot_condition with a green suite, because nothing type-checks a blueprint. There is now a test that every action a blueprint calls exists in services.yaml with its required fields supplied.

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

raman325 and others added 5 commits August 27, 2026 21:19
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* origin/main:
  feat: add_user and delete_user take lists (#1509)

# Conflicts:
#	custom_components/lock_code_manager/__init__.py
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 28, 2026 18:46

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 blueprints Changes to shipped automation blueprints breaking-change Pull requests that break existing functionality labels Aug 28, 2026
@codecov

codecov Bot commented Aug 28, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.22%. Comparing base (4d22f96) to head (e29b23c).
⚠️ Report is 3 commits behind head on main.
✅ All tests successful. No failed tests found.

Additional details and impacted files

Impacted file tree graph

@@           Coverage Diff           @@
##             main    #1514   +/-   ##
=======================================
  Coverage   99.22%   99.22%           
=======================================
  Files          66       66           
  Lines        8739     8814   +75     
  Branches      522      524    +2     
=======================================
+ Hits         8671     8746   +75     
  Misses         68       68           
Flag Coverage Δ
python 100.00% <100.00%> (ø)
typescript 95.65% <100.00%> (+0.01%) ⬆️

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%> (ø)
...stom_components/lock_code_manager/binary_sensor.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%> (ø)
...stom_components/lock_code_manager/domain/config.py 100.00% <100.00%> (ø)
...stom_components/lock_code_manager/domain/models.py 100.00% <100.00%> (ø)
...tom_components/lock_code_manager/domain/queries.py 100.00% <100.00%> (ø)
...om_components/lock_code_manager/domain/services.py 100.00% <100.00%> (ø)
...nents/lock_code_manager/domain/slot_coordinator.py 100.00% <100.00%> (ø)
custom_components/lock_code_manager/event.py 100.00% <100.00%> (ø)
... and 13 more
🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

* origin/main:
  fix: editing an unmanaged code left every PIN on the card in cleartext (#1519)
  build(deps-dev): bump aioesphomeapi in the homeassistant group (#1512)
  build(deps-dev): bump @types/node from 26.2.0 to 26.3.0 (#1513)

# Conflicts:
#	custom_components/lock_code_manager/www/generated/lock-code-manager.js
* origin/main:
  fix: releasing a slot no longer adopts a user LCM never created (#1522)
  fix: a failed write no longer follows you to the next row (#1521)

# Conflicts:
#	custom_components/lock_code_manager/www/generated/lock-code-manager.js
* origin/main:
  fix: a deleted user no longer leaves a ghost card behind (#1524)

# Conflicts:
#	custom_components/lock_code_manager/www/generated/lock-code-manager.js
#1524 fixed the subscription on main, where a user is removed through
the delete_user action. v6 adds a second route with no LCM code in it at
all -- Home Assistant's native delete on the entry's page, which is
where the add-user card's 'Manage users in settings' link sends people.

Same guard covers it, because it is re-checked on every update rather
than keyed on how the removal happened. This pins that.

Entire-Checkpoint: 6c08a5ce1ddb
* origin/main:
  fix: a stalled provider operation is reported instead of staying silent (#1525)

# Conflicts:
#	tests/providers/test_base.py
* origin/main:
  fix: the operation deadline covers every device call, not just the last (#1527)
  fix: a wedged capability probe no longer disables the lock for good (#1526)
Review of #1514. One service call is several entry writes -- a subentry
per user, then the entry -- and Home Assistant schedules an update
listener task per write. They overlap. Only the earliest task finds a
diff, because it caches the new configuration before its first await;
the rest see the cached view, return immediately, and set `settled` on
the way out. So the waiter in _async_write_and_settle was released by a
pass that did nothing while the one building the entities was still
going -- the exact return-before-ready its docstring exists to prevent.

Count passes in flight and settle only when the count returns to zero.

The downstream symptom is a race a test cannot pin: which pass wins
depends on scheduling, and the assertion passes either way. Verified the
overlap with a trace (enter 1, enter 2, exit 2, exit 1) and tested the
guarantee directly: with a slow pass and a fast one overlapping, settled
stays clear until the slow one finishes. Both mutants that break the
count are killed by that test.

Entire-Checkpoint: e28f6f376382
Review of #1514. EntryConfig.from_entry took users from subentries only.
Migration runs when an entry is set up, so an entry that is disabled --
or has not loaded yet -- keeps its pre-v5 shape, with users and their
numbers still on the entry and no subentries at all. Read that way it
holds nothing, and it is still asked: get_managed_slots consults every
entry on a lock for the numbers it holds, so a new entry could be issued
the same numbers the disabled one already has, and the unmanaged-code
sweep would raise a fixable "delete this code" repair for each of them.

Fall back to reading the entry mapping, through the same converter the
migration uses, so the two cannot disagree about what a slot-keyed
entry means.

Told apart by shape rather than entry.version. Shape is what this reads
and the version is a proxy for it -- one the test fixtures do not keep,
since they build subentries onto entries left at version 1 (see
user_subentries in tests/common.py, which says as much). The shape is
unambiguous because migration strips the legacy keys as it moves users
out: an entry with none of them has nothing to fall back to, and an
entry that still has them has not been migrated.

Entire-Checkpoint: 2a25d0d52b56
Review of #1514. When users moved off the options form into their own
subentries, the form lost the capacity check along with them. It kept
the unclaimed-MQTT check and nothing else, so a lock that cannot hold the
entry's existing slot numbers was accepted here and refused later, at
write time, one slot at a time, as a connectivity warning.

The users are not on this form, but the numbers they hold are exactly
what a lock added here has to be able to hold. Reauth already checks
this when it swaps a lock in; the options flow now does the same for the
locks it is given, and reports the refusal the same way.

Entire-Checkpoint: 90f73c003cbe
@raman325

raman325 commented Sep 2, 2026

Copy link
Copy Markdown
Owner Author

Ran a full code review of this branch and pushed fixes for the three findings, each as its own commit with a test that fails without it and passes with it (mutation-checked, not just covered).

6c990741 — a pre-subentry entry read as empty

EntryConfig.from_entry took users from subentries only. Migration runs at setup, so a disabled entry (or one not loaded yet) keeps its v4 shape — users and numbers on the entry, no subentries — and reads as holding nothing. It is still consulted: get_managed_slots asks every entry on a lock which numbers it holds, so a new entry could be issued numbers the disabled one already has, and the unmanaged-code sweep would raise a "delete this code" repair for each.

Now falls back to reading the entry mapping through the migration's own converter. Gated on shape, not entry.version: the fixtures build subentries onto MockConfigEntrys left at version 1 (user_subentries in tests/common.py says so), and shape is what the reader actually consumes. Unambiguous because migration strips the legacy keys as it moves users out.

14f8025e — the options flow lost its capacity check

When users moved off the options form, _allocate_for went with them; only the unclaimed-MQTT check remained. A lock too small for the entry's existing numbers was accepted and then refused at write time, slot by slot, as a connectivity warning. The form now runs async_check_slot_capacity over the entry's slot_numbers for the submitted locks and reports slot_out_of_range the way reauth does.

b6cbe3cdsettled fired on the wrong pass

One service call is several entry writes (a subentry per user, then the entry) and HA schedules a listener task per write. They overlap, and only the first finds a diff — it caches the new config before its first await — so the others return immediately and set settled while the pass building the entities is still running. Traced it:

('enter', 1)  ('enter', 2)  ('exit', 2)  ('exit', 1)

Now counts passes in flight and settles at zero. The downstream symptom is a scheduling race a test can't pin (the assertion passes either way), so the test drives two overlapping passes directly and asserts settled stays clear until the slow one exits.

Verified clean by the review

v4→v5 migration (subentry creation, key stripping, device move), rename-by-slot matching in async_write_entry_config, reconcile tenure on delete, the settle-options recursion guards, websocket renames (TS sources and generated bundle), condition_linker's switch to set_condition, strings.jsontranslations/en.json parity.

Heads-up for the merge from main

#1540 raises the floor to HA 2026.9 and brings two changes this branch will meet on merge: import probatio as vol replacing voluptuous, and device_registry.async_get_deviceasync_get_device_by_identifier(identifier, config_entry_id) (deprecated; test-frame callers raise). This branch has its own call sites for both.

Full suite: 2332 passed, coverage gate held.

🤖 Generated with Claude Code

raman325 and others added 2 commits September 7, 2026 15:51
Fifteen commits from main, including the deadline, queue and allocation
trio (#1551, #1553, #1554), the coordinator-owned pending writes (#1545,
#1547, #1550) and the move to probatio (#1540).

Where the two sides met in the flows, the allocation progress step from
#1554 lands on the setup paths (guided count and YAML block), which still
allocate in bulk; the YAML path creates its entry through the subentry
helper the way v6 does. The options flow stays locks-only and gets no
progress step, since it no longer allocates. The user subentry flow picks
up #1554's tenure rule: the users already here keep their numbers and are
not read for, so adding one user reads one index per lock. Its refusal
for an unforeseen read failure joins the subentry strings, and the
contract test knows about it.

Tests main wrote against users in the options flow are re-expressed as
subentry flow tests; the #1550 zwave_js test builds its entry with
subentries; the zwave-js-ui push tests keep v6's direct assertion on the
event call and main's observe_push.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Entire-Checkpoint: 0435effbae00
Adding a user opened straight onto a single-user form, so putting five
people on an entry meant five dialogs, five lock reads and five entry
settles. Setup has always taken a pasted block; after it, the only bulk
route left was the add_users action, which means writing a script.

"Add user" now opens on a choice. One user is the form that was there.
Several takes the same dictionary the setup flow's YAML path takes,
validated by the same function, so what a person pastes to create an
entry is what they paste to add to it. One allocation covers the block
and one write lands it, and the numbers already held are held by tenure,
so a block of two on an entry of one asks the lock about two numbers.

Adding is all it does. A block naming somebody already here is refused
rather than merged into them, by the same identity rule the rest of the
integration uses, so Bob and "bob " cannot become one person carrying two
credentials. Editing and removing stay one user at a time, which keeps
exactly one way to change a user who exists -- the property the subentry
move was for.

The block is written through async_write_entry_config rather than by
completing the flow: a finished subentry flow creates exactly one
subentry, and this creates as many as were pasted.

The shared block validator now takes the names the submission is being
added alongside, empty from setup where the block is the whole
configuration, and sizes its capacity check to everyone the entry will
hold rather than to the block alone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Entire-Checkpoint: db2fedcc8879
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 python Pull requests that update Python code

Projects

None yet

2 participants