Conversation
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
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ 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
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
- 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
5 tasks
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 answeringnot_supported_255fails the same way, only faster.Lock Code Manager now works out on its own whether a lock answers reads. There is no setting.
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. Withlast_seenoff, 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.add_useraction) refuses withlock_reads_unanswered, which points to the repair.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.unanswered_allowedin 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.access: 7).not_supported_255user 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 asnot_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 theusersor thepin_codeform, 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.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.readsper lock (answered,unanswered, or unknown) andunseen_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:
not_supported_255, each reach the user step, with no repair until the entry exists; a lock that answers nothing getsoccupancy_unknownand no verdict. A zwave-js-ui lock that never answers is never classified. On main the second fails withsearch_limit_reached. The first asks exactly five reads, finishes creating the entry, and the new entry stores the verdict and shows the repair.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.not_supported_2counts as an answer,not_supported_255doesn't; a late reply landing mid-read, or during the lock-state check, keeps that read going through every slot.last_seenis read as ISO 8601 with or without a zone, or as epoch milliseconds, with five seconds of slack.add_useraction 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.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
Additional information
🤖 Generated with Claude Code