Skip to content

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

Open
raman325 wants to merge 17 commits into
mainfrom
v6
Open

feat!: a person is the unit — users as subentries, slot-addressed surfaces removed#1514
raman325 wants to merge 17 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.

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.

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.20%. Comparing base (2ba8c51) to head (14f8025).
⚠️ 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.19%   99.20%           
=======================================
  Files          66       66           
  Lines        8499     8555   +56     
  Branches      522      524    +2     
=======================================
+ Hits         8431     8487   +56     
  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

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