Skip to content

fix: settle staged options on every exit, and stop double-counting a repaired user - #1516

Merged
raman325 merged 2 commits into
v6from
fix/v6-followups
Aug 29, 2026
Merged

fix: settle staged options on every exit, and stop double-counting a repaired user#1516
raman325 merged 2 commits into
v6from
fix/v6-followups

Conversation

@raman325

Copy link
Copy Markdown
Owner

Breaking change

None. Both fixes are to code already on v6.

Proposed change

1. A no-op options submission stranded itself in options

data is where an entry's configuration lives; options is a staging area, because an options flow cannot write data itself. The update listener is what keeps that true — it folds options into data and clears it.

That settle sat at the bottom of the listener, behind an early return for "nothing changed". So opening the options flow and saving without changing anything left the submission staged permanently, across restarts.

Inert today: what the options flow writes is a strict subset of data, and EntryConfig.from_entry reads those keys options-first, so both sides always agreed. It stops being inert the moment an options-flow field exists that isn't also in data — which is what an options flow is normally for. Such a field would read correctly (options-first) and then be silently erased by the next write that touched the entry, since every other write does data=..., options={}.

Settling is now one _async_settle_options helper called from both exits, rather than a block at the bottom that one path skips.

I did not move the settle to the top of the listener, which was the obvious-looking fix: the re-entrant pass would diff against a cache the outer pass had already refreshed, and at setup — which passes EntryConfig.empty() as the old config — it would skip entity creation entirely.

2. The unnumbered-user repair counted that user twice

async_step_reconfigure asked allocation for len(config.users) + 1. The + 1 was carried over from the add flow, where it is right because the newcomer is not in config.users yet. On the edit path they are — EntryConfig.from_entry puts every user subentry into users whether or not it carries a slot; only assignment skips the unnumbered one.

Two consequences, both reproduced:

  • On a lock with exactly enough room, the repair refused. Entry with Alice on slot 1 plus an unnumbered Nomad, lock with 2 slots: {"base": "too_many_users"}, telling the user "3 users will not fit" when there are two. Exactly the failure this path was added to prevent — a dialog that can only fail — reintroduced at the capacity boundary.
  • A repair that also renamed burned a slot. reconcile was handed a dict still containing the old name alongside the new one, so the same person appeared twice under two identities and the new name was numbered around the old one, leaving slot 1 permanently unused.

Both now read from one others dict — everybody except the person being edited — which is what the name-uniqueness check two lines above already used.

The existing tests passed either way: the one test on this path has a single user on an empty lock, where 2 and 1 allocate identically, and nothing renamed during a repair. Both scenarios are now covered, and both fail against the old arithmetic.

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

…repaired user

Two follow-ups to #1515.

An options submission that changes nothing returned before the settle, so
it sat in `options` for good -- read in preference to `data` for as long
as the entry existed, then cleared by the next write that touched the
entry, taking anything that lived only there. Settling is now one helper
called from both exits.

The unnumbered-user repair asked allocation for room for one user more
than exists: the person being edited is already in the entry's users, so
they were counted twice. On a lock with exactly enough room the repair
dialog refused, naming a user count that does not exist -- the very
failure the path was added to prevent. A repair that also renamed issued
the new name a second number and burned the first.

Both come from the same place, so both now read from one `others` dict,
which is what the name check already used.

Entire-Checkpoint: 0c8adf394d8d
Copilot AI lite review requested due to automatic review settings August 28, 2026 19:38

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 breaking-change Pull requests that break existing functionality bug Something isn't working labels Aug 28, 2026
One delete_user call is several entry writes -- a subentry removal per
departing user -- and each wakes the update listener. The listener took
the whole hand-off set on its first pass, so from the second user onward
the credential the caller explicitly asked to leave programmed was wiped
off every lock instead. Consumed per pair now.

Also bind the reclaimed slot device to its user's subentry, so the
pre-2026.8 upgrade path stops taking the deprecated implicit move, and
drop the ZHA operation-source names the event removal left behind.

Entire-Checkpoint: 7121ca62e8d5
@raman325

Copy link
Copy Markdown
Owner Author

Pushed two more, from a second review pass over the full main...v6 diff.

A batch hand-off only honoured the first name. delete_user with clear_credentials: false stages every departing user's (lock, slot) pairs, then makes one call to async_write_entry_config — which is now N writes, one subentry removal per user, each waking the listener eagerly. The listener drained the whole set on its first pass, so from the second user onward the credential the caller explicitly asked to leave programmed was wiped off every lock. Reproduced: deleting two users with clear_credentials: false released slot 2 anyway. Pairs are consumed per-pass now.

The reclaim path created its slot device with no subentry, so the pre-2026.8 upgrade path took the deprecated implicit move — on the exact upgrade v6 is aimed at. Also dropped OPERATION_SOURCE_NAMES in the ZHA provider, dead since the lock_state_changed removal.


Two findings from that pass are not in this PR, and both are worth a decision rather than a rushed fix.

Deleting a user while the entry is unloaded leaves the PIN on the lock. HA's async_remove_subentry has no integration hook, so a user removed from the entry's page while it is unloaded, disabled, or in setup-error is removed without any LCM code running. Nothing notices afterwards — the setup pass diffs against an empty config, where pairs_removed is empty by construction. The PIN keeps opening the door and the slot is burned, since allocation will not reissue a number it can still read a code on. This is the #1453 failure mode through a door v6 opens.

I tried the obvious remedy — release the slots whose device outlived their configuration, which _async_prune_orphaned_slot_devices already computes at setup — and it detects nothing, because HA removes the device along with the subentry. That signal does not exist in v6 precisely because devices are now subentry-bound. I reverted it rather than ship a fix that does nothing.

What is left is a design call: run async_sweep_unmanaged_codes at every setup (raises a repair per unmanaged code, so the user decides rather than having a code cleared out from under them — but reads every lock on every start), or persist the managed slot set and diff it at setup (cheap and exact, but adds stored state).

_async_write_and_settle releases early on batches. settled is set by whichever listener pass finishes first, not the last, so a multi-name add_user/delete_user can return while later passes are still writing. The docstring already concedes a concurrent write can release the wait early — but that was written when one logical operation meant one pass. Both existing tests use a single name. Fixable by having async_write_entry_config report how many entry updates it made so the waiter can count them.

@raman325
raman325 merged commit 2af5002 into v6 Aug 29, 2026
5 checks passed
@raman325
raman325 deleted the fix/v6-followups branch August 29, 2026 02:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking-change Pull requests that break existing functionality bug Something isn't working python Pull requests that update Python code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants