fix(zigbee2mqtt): read PIN responses from the users payload; gate cached republications#1336
Conversation
…hed republications Zigbee2MQTT answers GetPinCode through the users object (fz.lock_pin_code_response), not a pin_code response payload, so every slot read timed out. Resolve pending reads from users entries, project enabled-but-withheld and unrecognized statuses to unreadable instead of empty, and only push entries that changed since the previous payload -- Zigbee2MQTT republishes its full cached state on every attribute change, and applying stale entries verbatim reprograms slots forever (#1335). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015tvvgVhy5F12XVyv6RQBv7 Entire-Checkpoint: dfff3689de8a
There was a problem hiding this comment.
Pull request overview
This PR fixes a Zigbee2MQTT provider sync loop where PIN reads were timing out (causing repeated out_of_sync reprogramming) by resolving pending reads from the users payload, improving status-to-credential projection (including unreadable/withheld PIN cases), and gating cached-state republications so stale cached entries don’t overwrite optimistic writes.
Changes:
- Resolve pending GET PIN reads from Zigbee2MQTT
userspayload entries (while keeping legacypin_coderesponse handling). - Project
usersstatus/PIN combinations intoSlotCredentialwith explicit handling for “enabled but PIN withheld” and unknown statuses. - Delta-gate
usersrepublishes so only changed slot states are confirmed/pushed, preventing stale cache churn.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| custom_components/lock_code_manager/providers/zigbee2mqtt.py | Updates payload handling to resolve pending reads from users, improve state projection, and gate/confirm push updates to avoid stale-cache loops. |
| tests/providers/zigbee2mqtt/test_payload.py | Adds/updates tests covering unreadable projections, delta-gating behavior, and pending-read resolution via users. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #1336 +/- ##
=======================================
Coverage 96.97% 96.97%
=======================================
Files 53 53
Lines 6441 6454 +13
Branches 473 473
=======================================
+ Hits 6246 6259 +13
Misses 195 195
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
…e coordinator attach Retained/live MQTT messages can arrive between async_setup's subscription and coordinator attach; recording that snapshot would gate out the first post-attach republication that should seed initial state. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015tvvgVhy5F12XVyv6RQBv7
int() coerces True/False to 1/0, so a malformed boolean slot identifier in a payload would silently address slot 1 or 0. Hoisted into the shared helper so every provider parsing external slot identifiers gets the guard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015tvvgVhy5F12XVyv6RQBv7 Entire-Checkpoint: 37e07409d865
Proposed change
Fixes the read-timeout / out_of_sync reprogramming loop reported in #1335 (Yale YMF40A RL). Verified against the zigbee-herdsman-converters source: the Yale
lockExtend()definition answers GetPinCode viafz.lock_pin_code_response, which publishes the result inside theusersobject — there is no code path in current Zigbee2MQTT that emits the{"pin_code": {user, user_enabled, pin_code}}response shape the provider's pending-read futures were waiting on. Three coupled fixes:Resolve pending slot reads from
userspayloads. Each GET is answered by a full-state publish containing the freshly merged entry for the queried user; the pending future now resolves from it instead of burning a guaranteed 10-second timeout per slot (the 40-second refreshes in the issue's log). At most one read is pending at a time (async_get_usersqueries sequentially), so cached entries for other slots can't satisfy a future they don't belong to. The legacypin_coderesponse branch is kept.Fix the status projection (shared by reads and pushes, per the
lockUserStatusvocabularyavailable/enabled/disabled):enabledwithout a usable PIN value →unreadable()(occupied-but-withheld —expose_pinoff hides the code). Previously the push path skipped these entirely, and the read path had no way to represent them. Treating occupancy-with-hidden-code as anything but unreadable makes sync reprogram slots that already hold the right code — the same universal masked projection the Z-Wave and Matter providers use.pin_code: nullon an enabled user still projects toempty()(the broker exposes the field and reports no code — existing behavior).not_supported_*) →unreadable(), notempty().Delta-gate
userspushes. Zigbee2MQTT republishes its entire cached state on every attribute change, so mostuserspayloads restate stale entries rather than report changes. Applying them verbatim let a stale cache entry overwrite the optimistic push from a write the device had already accepted — flipping the slot back to its pre-write state and reprogramming it forever (the loop in [ISSUE] Yale YMF40A RL with Zigbee2MQTT constantly polls PIN codes and keeps slots out_of_sync #1335). Entries now reach the coordinator (via_confirm_slot, matching the other push providers) only when they differ from the previous payload; fresh GetPinCode responses always differ when state actually changed, and the read path stays authoritative regardless of the gate.One aspect intentionally awaits the reporter's raw payload (requested on the issue): which exact status string their lock reports. The projection above is safe for every value in the converter's vocabulary plus its
not_supported_*fallback, so the fix does not depend on the answer.Type of change
Additional information
Checklist
Full suite passes locally (1258 passed, 3 skipped);
prekclean.🤖 Generated with Claude Code
https://claude.ai/code/session_015tvvgVhy5F12XVyv6RQBv7