Skip to content

fix: a write the lock could not verify is trusted, not retried to suspension - #1575

Closed
raman325 wants to merge 11 commits into
mainfrom
fix/unconfirmable-writes
Closed

raman325 wants to merge 11 commits into
mainfrom
fix/unconfirmable-writes

Conversation

@raman325

@raman325 raman325 commented Sep 16, 2026

Copy link
Copy Markdown
Owner

Proposed change

A Z-Wave lock without the Supervision command class is verified by the driver reading the code back after each write. On a lossy link that read often times out. The driver then reports the outcome as unknown (credential_rejected_unknown, mapped to WriteResult.OPTIMISTIC), and its value database keeps showing the slot as available. Every later read goes through the same path and shows the same nothing. The coordinator gave the write up at its deadline, sync rewrote it, and after three laps the slot was suspended, while the PIN worked at the keypad. That is the loop in #1307 (and #1397 before it).

An empty read after an unverifiable write is not evidence the write failed: it is the same read the driver already could not complete. So:

  • A write the stack reported as unverifiable (believed) that is not seen by its deadline is kept as unconfirmed instead of counted failed. It reads as SlotCredential.unreadable(), which sync already judges by the last PIN it wrote, the same way it treats a lock that never shows its codes (Matter).
  • Later reads that still show nothing leave it standing. So do a slot the lock says holds something it won't show, and a read still showing the code the slot held before the write: a cache that never saw the write keeps answering with the old code, which is no more evidence than an empty slot. A read showing the old code likewise doesn't fail the write while it is still pending. Writing back the code the slot held is the exception: a read showing it is the write. "Before" is what the lock was last read or pushed as, tracked apart from the stored data, which also carries the values of writes nobody has seen yet. So a write replaced while still pending doesn't become the "old code" for the one after it.
  • It ends when a read shows any other real value (confirming or contradicting the write), when the lock itself pushes anything but a code it won't show, or when the slot is written or cleared again.
  • Changing or disabling a user while such a write is pending keeps the doubt. Sync used to drop the pending write, which also erased what was known about the lock. It now supersedes it: a write the stack couldn't verify stays unconfirmed until the next write or clear replaces it, or a read or push settles it.
  • Writes the stack confirmed (a polled lock's cloud acceptance) are still counted failed when no read shows them, as before.
  • Keeping a write after a read that failed outright updates the stored data without marking the read a success, so a lock that is unreachable stays so and its recovery probe is not cancelled.
  • A clear the driver reports as unknown is trusted the same way. Deletes hit the same verification read-back. The clear now counts as a change, and the slot reads empty until a read shows a code other than the one it held before the clear. Before, the delete raised, the cache kept showing the old code, and sync repeated the clear until the slot was suspended. The user the code went to is kept so that a later clear of the slot, such as releasing it, can reach it again. That user is used only while the slot is still in doubt, whatever the cache shows: the cache may still show the old code, or show nothing. Once a read or push has settled the slot, that user is a stale guess and the code there may be someone else's, which releasing a slot leaves alone.
  • A clear the stack verified shows the slot empty when this integration was standing in with a value for one of its own writes. Nothing else would replace that stand-in on a lock that is only read when told to. A verified clear with nothing of ours standing in leaves the value to the lock's next word, as before, since for a polled lock the service accepting a clear isn't the lock reporting it.
  • A PIN kept as unconfirmed still counts in the duplicate check, although its slot reads unreadable, even when a read leaves that slot out.
  • Clearing such a slot reaches the lock. The stack's cache doesn't show the code, so a clear that looked up the credential's owner there found nothing and sent nothing, and a disabled user's code could keep working. The lock user an unverified write went to is now kept per slot, and a clear that finds no owner in the cache deletes the credential through that user. This also closes that hole for locks on main, where the slot silently read as cleared.
  • Diagnostics list each lock's unconfirmed_writes, so a report shows which slots are trusted on the strength of their write.

This reverses one earlier choice on purpose: a confirmation read that fails outright past the deadline used to fail a believed write too, so that a lock whose reads never return ended in a visible suspension. For an unverifiable write that read is exactly the one the stack could not complete, so it is now treated the same way. Only Z-Wave returns OPTIMISTIC today, and only for ERROR_UNKNOWN, which the driver reports when its own read-back did not arrive.

Trade-offs:

  • A code the lock silently refused while its reads were failing would read as in sync. The same goes for a PIN change or clear the lock refused, as long as reads keep showing the old code. The driver reports definitive rejections as typed errors, which still suspend.
  • What is trusted is kept in memory, so it lasts for one run of Home Assistant. After a restart, the first read that still shows nothing (or the old code) makes sync write once more, and the result is trusted again. That is one extra write per restart, not a loop. Persisting it belongs with the per-lock internal section that fix: a lock that never answers reads is found out and still usable #1576 introduces, and is left for a follow-up.

Tests:

  • End to end through the Z-Wave provider: the driver reports every write as unknown and its cache shows the slots free. On main the slot rewrote on every lap and never settled; now it reaches in sync after one write. Disabling that user then sends exactly one delete, to the user the write went to, and the slot settles again (without the owner fallback, no delete is sent).
  • End to end, a PIN change whose cache keeps showing the old code, and a clear the driver reports as unknown whose cache keeps showing the code: each sends one command and settles in sync, including after a refresh reads the stale cache. Releasing a slot whose write and clear were both unknown deletes through the same user again, unless a read or push settled the slot first, even if the cache has since lost sight of that code. It also deletes through the same user when the cache still shows the old code under a user the release may not adopt. Disabling a user whose write is still pending, which sync does by dropping the pending write first, still deletes through the user the write went to.
  • Coordinator: an unverifiable write absent at the deadline is kept unconfirmed and stays so on empty reads; a readable value ends it either way; a pushed empty slot ends it and a masked push does not; a new write or a clear ends it. The two existing give-up tests now cover both kinds of write.
  • A failed read that keeps a write leaves last_update_success as it was; diagnostics name the unconfirmed slots.
  • Mutations: disabling the unconfirmed branch fails 6 tests; letting an empty read end the trust fails 1. Each of these is killed by a test: the old-code rule (for pending writes, for unconfirmed writes, and for clears), the Z-Wave unknown-delete branch, the base's unconfirmed-clear hook, the duplicate check, keeping the owner after an unverified clear, using it only while the slot is in doubt, superseding (not dropping) a believed write, writing back the old code, a masked push keeping the trust, the old code surviving a write replaced before it settled, a verified clear replacing only a value of ours, and the duplicate check seeing kept PINs outside the stored data.

Full suite green at 100% coverage.

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

🤖 Generated with Claude Code

…pension

A Z-Wave lock without Supervision is verified by the driver reading the
code back. When that read times out the driver reports the outcome as
unknown, and its cache keeps showing the slot empty. Every later read goes
through the same path and shows the same nothing, so the coordinator gave
the write up at its deadline, the sync rewrote it, and the slot was
suspended while the PIN worked at the keypad (#1307).

A write the stack reported as unverifiable is now kept as unconfirmed when
it is not seen by its deadline, and reads as unreadable, which sync judges
by the last PIN it wrote. A readable value, an empty slot pushed by the lock
itself, or a new write or clear ends it. Writes the stack confirmed are
still counted failed when a read never shows them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 00091a8fc90e
Copilot AI lite review requested due to automatic review settings September 16, 2026 19:51

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 bug Something isn't working labels Sep 16, 2026
…uccess

Setting the data through the coordinator marked the failed read a success
and cancelled the refresh queued to probe for recovery.

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

codecov Bot commented Sep 16, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.24%. Comparing base (dbdaa80) to head (16526dd).
✅ All tests successful. No failed tests found.

Additional details and impacted files

Impacted file tree graph

@@           Coverage Diff            @@
##             main    #1575    +/-   ##
========================================
  Coverage   99.23%   99.24%            
========================================
  Files          66       66            
  Lines        8935     9041   +106     
  Branches      530      530            
========================================
+ Hits         8867     8973   +106     
  Misses         68       68            
Flag Coverage Δ
python 100.00% <100.00%> (ø)

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/diagnostics.py 100.00% <ø> (ø)
...components/lock_code_manager/domain/coordinator.py 100.00% <100.00%> (ø)
custom_components/lock_code_manager/domain/sync.py 100.00% <100.00%> (ø)
...om_components/lock_code_manager/providers/_base.py 100.00% <100.00%> (ø)
...components/lock_code_manager/providers/zwave_js.py 100.00% <100.00%> (ø)
🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

raman325 and others added 9 commits September 16, 2026 16:15
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: db951e15fc74
A write the stack could not verify may be on the lock while the stack's
cache does not show it. A clear resolved the credential's owner from that
cache, found none, and sent nothing: a disabled user's code could keep
opening the door, and with the write trusted the slot never settled.

The lock user an unverified write went to is kept per slot, and a clear
that finds no owner in the cache deletes the credential through that user.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 440e3e13a767
…not verify

A cache that never saw an unverifiable write keeps answering with the
code the slot held before, which is no more evidence than an empty slot;
the coordinator now remembers that value per write and treats a read
repeating it as no answer. Z-Wave deletes reported as unknown are
trusted empty the same way instead of raising into a clear loop, the
unconfirmed PIN still counts in the duplicate check, and the owner an
unverified clear went through is kept for a later clear.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 57b27123fbce
The lock saying the slot holds a code it will not show is what the write
would look like, so it is no reason to stop trusting it; the test now
reads the stale cache after the push to tell the two apart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 46c836050ebe
A read that settles the slot makes the remembered user a stale guess,
and releasing the slot then would delete a code that may be someone
else's. Writing back the code the slot held also no longer treats a read
of that code as a stale cache.

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

Gating on the coordinator's doubt missed a disable while the write was
still pending: sync drops the pending write before it clears, so the
clear found no owner and sent nothing. What protects another person's
code is whether the cache shows one at the slot, so the fallback now
asks exactly that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: fcd7a3a2b49b
The code a stale cache answers with is the last one read or pushed, not
whatever data shows: a believed write that sync replaced while pending,
or a write-back that kept no record, made the next write or clear take
the wrong old code and charge or repeat once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: b37a1297b7de
…owner on doubt

Sync dropping a pending write when the configuration changed also erased
what was known about the lock, which is why the owner fallback kept
needing a different guard. Superseding keeps an unverified write standing
as unconfirmed, so the fallback can ask the one question that matters --
is the slot still in doubt -- whatever the stack's cache shows.

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

The unreadable value kept for a write the stack could not verify stayed
in the data after a clear the driver did verify, so the slot showed a
code for a disabled user. The duplicate check also reads kept PINs on
their own, since a read may leave their slot out.

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

Copy link
Copy Markdown
Owner Author

Superseded by #1577. This approach inferred what the lock held from a read path that is itself broken, and needed a growing set of rules to do it. #1577 stops charging unverifiable writes and clears toward suspension, shows them as unconfirmed, and retries on a backoff, without inferring anything.

@raman325 raman325 closed this Sep 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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