Skip to content

fix: a lock that never answers reads is found out and still usable - #1576

Open
raman325 wants to merge 17 commits into
mainfrom
fix/unanswered-reads
Open

raman325 wants to merge 17 commits into
mainfrom
fix/unanswered-reads

Conversation

@raman325

@raman325 raman325 commented Sep 16, 2026

Copy link
Copy Markdown
Owner

Proposed change

Some locks never answer a request to read their codes. The report that prompted this is a Papaiz PSL200 on Zigbee2MQTT (same base as some Yale/Assa Abloy locks): it implements PIN Set but not PIN Get, so every read is silent. Each unanswered slot read waited 10 seconds and came back unreadable, and allocation counts unreadable as occupied. Adding a user walked every slot on the lock, about 41 minutes on a 250-slot lock, and then failed with search_limit_reached. A converter answering not_supported_255 fails the same way, only faster.

Lock Code Manager now works out on its own whether a lock answers reads. There is no setting.

  • Learned per lock. Any read that comes back with something marks the lock as answering, for good. After five silent reads in a row from a lock never seen to answer, the read stops and the lock is asked something it can always answer. On Zigbee2MQTT that is its lock state, because an out-of-range lock's entity stays available unless availability tracking is on. Zigbee2MQTT also republishes cached state without asking the device, so a retained replay doesn't count as a reply, and neither does a payload whose last_seen (when that setting is on) is no newer than the last one the lock sent. Until the lock has sent one, Home Assistant's clock stands in, allowing five seconds of skew, so a Zigbee2MQTT host with a clock further behind costs one look that reads the lock as out of reach, not every look. With last_seen off, the one republish that can't be told apart is the one Zigbee2MQTT makes 30 seconds after Home Assistant comes online, and only if it lands inside the 10-second wait. If the lock answers, the last slot is asked once more, since the silences may have come while the path was down (Zigbee2MQTT restarting mid-read, say); if that code read is silent too, the lock is recorded as not answering code reads. If it answers nothing, it is out of reach: the read fails as a disconnect, and allocation reports it could not read the lock. One silence isn't enough to judge: a link that drops half its replies ([ISSUE] Sync attempts exceeded #1397) would be misjudged half the time, and a misjudged lock can have its keypad codes overwritten. With five it's one time in thirty-two, and the first answer undoes it. A read naming fewer slots asks again until it has heard five silences.
  • Only where it can be told apart. The classification is off by default on MQTT providers and on for Zigbee2MQTT, whose door-lock cluster allows PIN Set without PIN Get and which can ask the device for its lock state. Z-Wave requires locks to answer User Code Get, so a silent zwave-js-ui lock is always out of reach and keeps today's behavior.
  • Then left alone. A lock recorded as not answering is only probed one slot at a time, at most hourly. The probe time is kept per lock, not per provider instance (allocation and the config flow build their own), and classifying a lock counts as its first probe. If the probe is answered, the lock is marked as answering and read normally. Every slot reads unreadable, which sync already judges by the last code it wrote (the path Matter uses).
  • Allocation, with consent. Detecting such a lock doesn't change allocation by itself: its slots all read unreadable, which counts as taken. Treating them as free could overwrite a code set at the keypad, so that is the user's call:
    • For a managed lock, a fixable repair asks. Submitting it allows slots the lock can't report to be assigned; until then, adding a user (options, subentry flows or the add_user action) refuses with lock_reads_unanswered, which points to the repair.
    • The flow creating an entry asks in a step of its own (allow_unseen_slots), because Home Assistant only offers an integration's fix flow once the integration is loaded, which it isn't before its first entry exists. The flow then places the users.
    • The answer is kept with the verdict (unanswered_allowed in the same internal record), survives restarts and the lock being added to another entry, and goes with the verdict if the lock ever answers; a later classification asks again.
    • Zigbee2MQTT gives no deterministic signal for this: every lock definition, including the Papaiz's external converter, declares its PIN as readable (access: 7).
  • Zigbee2MQTT. A not_supported_255 user status in reply to a read counts as silence, since 0xFF is the lock declining to say. The converter reports any status outside its map as not_supported_<n>, and any other number is a status the lock did send, so it counts as an answer. A reply that arrives after its read stopped waiting, in either the users or the pin_code form, still shows the lock answers, only slowly. Zigbee2MQTT resends its whole cache with every message, so a late reply only counts when it is news: an entry that changed, or that an earlier message didn't have (not the first message's entries, and never a replay). Recording a lock as answering is permanent, while misjudging a slow lock as silent is recoverable and now needs consent before it costs anything; a lock doesn't get classified because it is slow. The read checks for such a reply as it counts silences, and again after the lock-state check, so a read already under way carries on through every slot instead of stopping at five. That's why, on a lock known to answer, a read where every slot is silent also checks the lock state before it is called a disconnect: a lock declining every slot it was asked about is still talking. The per-slot wait is now a class attribute, slot_read_timeout.
  • Where it's kept. In an internal section of each managing entry's data, keyed by the lock's entity registry id so a rename doesn't lose it. It is also kept in memory for the config flow, which reads locks before its entry exists; setup copies it in. "Answered" in any entry wins. Removing a lock forgets it, and removing it from the last entry that manages it drops what this run learned too. The same applies when the entry itself is deleted. A lock added to an existing entry gets every verdict known about it copied in, with its repair, just as setup does. That includes one stored only on another entry, which after a restart is the only place it is known, because a lock that doesn't answer isn't read again. Without that, a lock classified by an abandoned flow would be treated as not answering with nothing stored and no repair. Setup drops records for locks the entry no longer has. Releasing a lock clears its repair even when the entry isn't loaded, as when reauth swaps out a lock whose entity is gone. The repair is raised only for a lock an enabled entry manages (a disabled entry keeps its stored verdict until it is enabled again, and disabling the only entry that manages the lock clears the repair), so an abandoned config flow leaves none; setup raises it for a new entry.
  • It doesn't disturb pending changes. Storing a verdict updates only the internal section of the entry's cached view, which is also what the next update pass diffs against.
  • Setup initializes its own data even when a config flow or a migration created the integration's data first (the flow's cache does now; a migration already could).
  • Writers can't erase it. Every entry write takes the internal section from the live entry (with_live_internal), and staged options never override it. Otherwise the options form, which stages the entry's other keys, would put back the copy it had when it was submitted.
  • Diagnostics show reads per lock (answered, unanswered, or unknown) and unseen_slots_allowed.

This replaces BaseMqttLock._reads_have_succeeded, which was in memory only. The rule it guarded (an all-silent read of two or more slots raises a disconnect) now applies once the lock is known to answer, including after a restart.

Tests:

  • The reported case end to end through the setup flow: a lock that answers only its lock state, and one that answers not_supported_255, each reach the user step, with no repair until the entry exists; a lock that answers nothing gets occupancy_unknown and no verdict. A zwave-js-ui lock that never answers is never classified. On main the second fails with search_limit_reached. The first asks exactly five reads, finishes creating the entry, and the new entry stores the verdict and shows the repair.
  • The read path: a single slot takes five silences, then nothing is asked until the probe is due; a 250-slot read stops at five; one answer before the verdict means the lock answers; an answered probe reads the rest; answered is final, so later silence still disconnects when the lock answers nothing else either, but a lock that answers yet declines every slot is not disconnected (on this branch before the fix, the flow reported occupancy_unknown); a lock answering nothing at all raises instead of being classified; the lock-state check covers a reply, silence, a failed publish and no topic; allocation sees free indices on an unanswering lock and keeps unreadable indices taken on one that answers.
  • Persistence: stored on the entry with its repair and cleared when the lock answers; survives a restart; follows a rename; answered anywhere wins; a write from an older view keeps it; a verdict recorded while options are staged survives the fold; removing the lock forgets it and clears the repair; setup drops stale records; a verdict stored while a change waits for its pass doesn't hide the change; setup after a flow still sets its own data; diagnostics show it; a verdict held only in memory is stored and repaired when its lock is added to an entry; deleting an entry forgets what only it knew and keeps what another entry still uses; a lock added to a second entry after a restart takes the verdict stored on the first; releasing a lock from an unloaded entry clears its repair; a lock only a disabled entry manages raises no repair, and disabling that entry clears it and enabling it raises it again.
  • A lock whose replies each arrive after the read stopped waiting is recorded as answering, in either reply form, and a late decline is not; not_supported_2 counts as an answer, not_supported_255 doesn't; a late reply landing mid-read, or during the lock-state check, keeps that read going through every slot.
  • A retained replay, or a state dated before the request, doesn't count as the lock answering its lock state (the flow reports it couldn't read the lock); last_seen is read as ISO 8601 with or without a zone, or as epoch milliseconds, with five seconds of slack.
  • Consent: the flow asks before placing users on such a lock and places them once allowed (guided and YAML routes); the repair's fix flow shows its question before allowing; the add_user action refuses until allowed; the allowance survives the lock staying silent (also when only in memory), reaches a second entry, and goes when the lock answers anywhere; allowing a lock that answers does nothing.
  • A bridge that was down for the five silences and back for the lock-state check doesn't produce a verdict: the code read asked once more is answered and the read goes on. A late reply landing during that last read counts too.
  • Mutations: removing the live merge on write, reading the internal section from staged options, not forgetting on removal, refreshing the whole cached view, initializing the data only when absent, enabling the classification for zwave-js-ui, keeping the in-memory verdict after removal, raising the repair for unmanaged locks, skipping the lock-state check before a disconnect, applying that check to zwave-js-ui, not copying the verdict in when a lock is added, not forgetting it (or forgetting it despite another entry) on entry deletion, copying in only what this run learned, clearing the repair only for a loaded entry, ignoring late replies (or counting late declines), not rechecking for one while counting silences or after the lock-state check, treating every not_supported_* as silence, raising the repair for a disabled entry, not re-checking it when an entry unloads, counting retained or stale replies (or dropping the slack), taking a cached, replayed or first-message entry for a late answer, or classifying without asking once more (or ignoring a late reply during it) each fail a test. So does every part of the consent: treating unseen slots as free without it, allocating without waiting for it, the fix flow or the config flow (either route) not recording it, the fix flow skipping its question, losing it on a silent read or at setup, keeping the repair after it, allowing an answering lock, or honouring it once the lock answered elsewhere.

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

Some locks never answer a request to read their codes: a Zigbee lock with
PIN Set but no PIN Get, a converter that exposes the code as write-only.
Every slot read waited ten seconds and came back unreadable, and allocation
counts unreadable as occupied, so adding a user walked every slot on the
lock (about 41 minutes on a 250-slot lock) and then refused for want of a
free number.

Whether a lock answers reads is now learned per lock and remembered in an
internal section of each managing entry's data, keyed by the lock's entity
registry id. Any answer marks the lock as answering, for good. Five silent
reads in a row from a lock never seen to answer mark it as not answering;
the read stops there, and afterwards the lock is only probed one slot at a
time, at most hourly. On such a lock allocation treats unreadable slots as
free, and a repair explains that codes set at the keypad may be
overwritten. A Zigbee2MQTT not_supported status counts as silence. Sync
already judges unreadable slots by the last code it wrote.

Every entry write now takes the internal section from the live entry, and
staged options never override it, so a writer holding an older view cannot
erase a verdict.

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

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 documentation Documentation changes bug Something isn't working labels Sep 16, 2026
@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.26%. Comparing base (dbdaa80) to head (74403ae).
✅ All tests successful. No failed tests found.

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #1576      +/-   ##
==========================================
+ Coverage   99.23%   99.26%   +0.02%     
==========================================
  Files          66       67       +1     
  Lines        8935     9247     +312     
  Branches      530      530              
==========================================
+ Hits         8867     9179     +312     
  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/__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% <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/read_health.py 100.00% <100.00%> (ø)
custom_components/lock_code_manager/domain/util.py 100.00% <100.00%> (ø)
...om_components/lock_code_manager/providers/_base.py 100.00% <100.00%> (ø)
...om_components/lock_code_manager/providers/_mqtt.py 100.00% <100.00%> (ø)
... and 2 more
🚀 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 2 commits September 16, 2026 16:26
- Storing a verdict replaces only the internal section of the entry's
  cached view. Refreshing the whole view from the entry hid a change the
  next update pass had not applied yet.
- Setup sets its resources key even when a config flow or a migration
  created the integration's data first; unload read it unconditionally.
- A lock is only recorded as not answering code reads after it answers
  something else. Zigbee2MQTT keeps an out-of-range lock's entity
  available by default, so it is asked for its lock state; a lock that
  answers nothing is out of reach, and the read fails as a disconnect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 1fe648be572f
- Only a provider whose protocol allows a lock without code reads, and
  that can tell such a lock from one out of reach, classifies at all. Off
  by default; on for Zigbee2MQTT. Z-Wave requires User Code Get, so a
  silent zwave-js-ui lock is never taken for one that cannot answer.
- Removing a lock from the last entry that manages it drops what this run
  learned about it, so adding it again starts from nothing.
- The repair is only raised for a lock an entry manages; a config flow
  that is abandoned leaves none, and setup raises it for a new entry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: faf5c5fdd5d4
raman325 and others added 14 commits September 16, 2026 17:48
A Zigbee2MQTT lock known to answer that declines every slot a read asks
about is still talking, so the all-silent disconnect now asks it for its
lock state first. A verdict held only in memory is copied into an entry
when its lock is added there, with its repair, and deleting an entry
forgets what no other entry manages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: f348cbb0a301
A lock added to an entry takes every verdict known about it, including
one stored only on another entry, which after a restart is the only
place it is known. Releasing a lock clears its repair even when the
entry is not loaded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 8fa07712d91a
Only not_supported_255 is the lock declining; any other number outside
the converter's map is a status it sent. A reply arriving after its read
stopped waiting now counts as an answer, so a slow lock is not taken for
one that cannot report its codes. A lock only a disabled entry manages
raises no repair.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 0a7d3dcade31
The read looked the verdict up once, so a reply that arrived late and
showed the lock answers still stopped the read at five silences and
left the rest of its slots unread. It now checks again as it counts
silences and after the lock-state check.

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

The repair was only re-checked when a verdict changed or an entry was set
up, so a disabled entry left it up until a restart. It is re-checked
when an entry unloads, which disabling does. The sync helper is renamed
to say which repair it syncs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 1962c997450c
Zigbee2MQTT republishes cached state without asking the device, and a
retained replay or a payload dated before the request could let an
out-of-range lock pass the reachability check -- and be recorded as one
that cannot report its codes. Retained messages no longer count, and
neither does a last_seen older than the request.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 16091b261cf4
Only the users form was checked against the reads that stopped waiting,
so a slow lock whose bridge answers with pin_code was still classified
as one that cannot report its codes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: e144e34b8773
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: f4193ee59b1e
…llows it

Detection stays automatic, but a wrong classification must not overwrite
keypad codes on its own. The repair becomes a question with a fix flow,
the entry-creation flow asks it itself (the integration is not loaded
there), and allocation refuses with lock_reads_unanswered until it is
answered. The answer is kept with the verdict and goes with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: c33fa43e25bc
Comparing Zigbee2MQTT's last_seen with Home Assistant's clock made every
reply look stale when the two clocks disagreed by more than a few
seconds, so a lock that does not report its codes failed each poll as a
disconnect. A reply now counts when its last_seen is newer than the last
one the device sent; the clock stands in only before there is one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 9b73f65eea04
Allocation and the config flow build a provider instance for every read,
so each one probed a lock that does not answer and waited its full ten
seconds. The probe time is now kept per lock, and classifying a lock
counts as having asked it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: eb62753bd8d3
A bridge that was down for the five silences and back for the
lock-state check made a working lock look like one that cannot report
its codes. With the path known to work, the last slot is asked again;
only a silence then is taken as the lock's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 50a02f7f0af7
Zigbee2MQTT sends its cached users with every message, so an old entry
for a timed-out slot passed for a late answer and permanently recorded a
lock that cannot report its codes as one that does. An entry now counts
only if it changed, or an earlier message did not have it; the first
message's entries and replayed ones never do.

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: b5d218b3271d
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working documentation Documentation changes python Pull requests that update Python code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants