Skip to content

ISSUE: Zigbee2MQTT provider never receives lock state — wrong subscribe topic, action vocabulary, and lock_state desync (4 chained bugs) + reload deletes Lovelace strategy resource #699

Description

@kfruechtl

Describe the bug

Four defects in the Zigbee2MQTT lock provider chain, found while setting up a Yale Assure lock. They are sequential: each one hides the next, so the first three produce no visible symptom beyond "keypad usage is never recorded".

I have patched all four locally and keypad events now populate Last Used correctly. Happy to raise a PR.

Bug 1 — base_topic subscribes to the IEEE address, not the friendly name

providers/zigbee2mqtt.py:

python

Extract the original Z2M friendly name from device identifiers to support device renaming

for domain, identifier in device_entry.identifiers:
if domain == MQTT_DOMAIN and identifier.startswith("zigbee2mqtt_"):
friendly_name = identifier[len("zigbee2mqtt_") :]
if friendly_name:
return f"zigbee2mqtt/{friendly_name}"

Zigbee2MQTT always sets identifiers to zigbee2mqtt_, never the friendly name. Mine is zigbee2mqtt_0x000d6f001933df17 with friendly name Door Lock, so Keymaster subscribes to zigbee2mqtt/0x000d6f001933df17 — a topic Z2M never publishes to.

This only works out of the box for a device that has never been renamed in Z2M (default friendly_name == IEEE address). The README's own expose_pin example uses friendly_name: front_door_lock, which hits this.

Writes are unaffected because Z2M accepts the IEEE address on /set and /get, so the integration looks healthy — codes write fine — while Timeout querying slot N repeats every ~71s forever and the users payload is never parsed.

Suggested fix: prefer device_entry.name (populated by HA from the Z2M friendly name via discovery) and keep the identifier suffix only as a fallback.

Bug 2 — keypad action vocabulary too narrow

_async_handle_action matches only keypad_unlock / keypad_lock. My Yale converter emits action: "unlock" with action_source_name: "keypad" and action_user: 1. Neither branch matches, so no event fires even once the topic is correct.

action_source_name looks like the reliable discriminator, since unlock also appears for RF/app-initiated operations.

Bug 3 — entity state overrides an unambiguous event label

coordinator.py, _handle_provider_lock_event:

python
if state_changed:
inferred_action = new_state
elif "unlock" in label_lower:
inferred_action = LockState.UNLOCKED

Z2M delivers the keypad notification in the same payload as the pre-operation lock_state, so at callback time the lock entity still reads locked while the label says Unlocked via Keypad. The state_changed branch wins and routes the event to _lock_locked, so Last Used never fires.

The comment above this block says it is meant to handle "providers that fire events before the entity state updates" — but the state_changed check is evaluated first and defeats exactly that case.

Bug 4 — kmlock.lock_state never updates for push providers

_handle_lock_state_change skips _lock_locked / _lock_unlocked entirely when supports_push_updates is true ("For push providers this listener is autolock-only"), so it never writes kmlock.lock_state. That state is only maintained inside those two methods, reachable only via the provider lock-event path.

But the provider event path requires isinstance(slot_num, int), and Z2M's one-touch/manual lock publishes action_user: null. So a physical lock produces no provider event, kmlock.lock_state is stuck at UNLOCKED indefinitely, and every subsequent keypad unlock is discarded by the guard at the top of _lock_unlocked:

python
if kmlock.lock_state == LockState.UNLOCKED:
... # supersede only when prior_slot == 0
return

Each path assumes the other maintains the state. Suggested fix: sync kmlock.lock_state in _handle_lock_state_change for push providers too (evaluating the autolock condition before the assignment, since it tests the old value).

Bug 5 (separate, user-visible) — reloading the config entry deletes the Lovelace strategy resource

async_setup registers /keymaster_files/keymaster.js, but async_unload_entry calls async_cleanup_strategy_resource when the last entry unloads. A config-entry reload is an unload followed by async_setup_entry — which never re-registers. So clicking Reload on the integration page permanently removes the resource until HA is restarted, and every dashboard using the strategy shows Timeout waiting for strategy element ll-strategy-section-keymaster to be registered.

Suggested fix: skip the cleanup on reload, or re-register in async_setup_entry.

Home Assistant version

2026.8.0b3

Keymaster version

v0.5.3

Home Assistant installation type

Home Assistant OS

Lock provider

zigbee2mqtt

Lock make and model

Yale Assure Lock (YRD226HA2619)

Steps to reproduce

Pair a Zigbee lock in Zigbee2MQTT with expose_pin: true, and give it a friendly_name that is not its IEEE address (e.g. Door Lock).
Configure it in Keymaster with the zigbee2mqtt provider.
Write a PIN to slot 1 — succeeds, so setup appears healthy.
HA log: [Zigbee2MQTTProvider] Timeout querying slot 1 repeats indefinitely (bug 1).
Fix the topic; keypad unlock still fires nothing (bug 2).
Fix the action matching; the event now reaches the coordinator but is routed to _lock_locked (bug 3).
Fix the inference; the event reaches _lock_unlocked and is dropped silently by the lock_state == UNLOCKED guard (bug 4).
Separately: reload the Keymaster config entry, then load any dashboard using the keymaster section strategy (bug 5).

Expected behavior

Keymaster subscribes to zigbee2mqtt/<friendly_name>, populates its usercode cache from the users payload, tracks lock/unlock state correctly for push providers, and fires the keypad-unlock event so event._code_slot_N_last_used updates. Reloading the config entry leaves the Lovelace strategy resource registered.

Logs

Bug 1 — HA, repeating every ~71 seconds:

[Zigbee2MQTTProvider] Timeout querying slot 1
[Zigbee2MQTTProvider] Error querying slot 1: Timeout querying slot 1

Z2M receives the get on the IEEE topic (Z2M accepts it):

debug: z2m:mqtt: Received MQTT message on 'zigbee2mqtt/0x000d6f001933df17/get' with data '{"pin_code": {"user": 1}}'
debug: zh:controller:endpoint: ZCL command 0x000d6f001933df17/1 closuresDoorLock.getPinCode({"userid":1}, ...)

...but publishes the response to the friendly-name topic, which Keymaster is not subscribed to:

info: z2m:mqtt: MQTT publish: topic 'zigbee2mqtt/Door Lock', payload '{"action":null,"action_source_name":null,"action_user":null,"battery":58,"lock_state":"unlocked","state":"UNLOCK","users":{"1":{"pin_code":"REDACTED","status":"enabled"}, ...}}'

Bug 2 — keypad unlock as published by Z2M:

topic 'zigbee2mqtt/Door Lock' -> {"action":"unlock","action_source":0,"action_source_name":"keypad","action_user":1, ...}

Bug 3 — after fixing 1 and 2, with keymaster at DEBUG:

[handle_lock_event_from_provider] Door Lock: event_label: Unlocked via Keypad, new_state: locked, inferred_action: locked, code_slot_num: 1, action_code: 1
[lock_locked] Door Lock: Running. source: event, event_label: Unlocked via Keypad, action_code: 1

Bug 4 — after fixing 3, inference is correct but the event is dropped with no further log line:

[handle_lock_state_change] Door Lock: old_state: unlocked, new_state: locked      <- no [lock_locked] follows
[handle_lock_event_from_provider] Door Lock: event_label: Unlocked via Keypad, new_state: locked, inferred_action: unlocked, code_slot_num: 1, action_code: 1
(no [lock_unlocked] line, no "Throttled", no "Superseding")

After all four patches:

event.door_lock_code_slot_1_last_used = 2026-08-02T00:31:37.219+00:00
  attributes: {"code_slot_num": 1, "code_slot_name": "Family", "event_type": "unlocked", "lockname": "Door Lock"}

Screenshots

No response

Additional context

Zigbee2MQTT 2.13.0-1 (HA add-on). HA device registry for the lock: identifiers {('mqtt', 'zigbee2mqtt_0x000d6f001933df17')}, device name Door Lock, model Assure lock / YRD226HA2619.

Bugs 1-4 are sequential — fixing any one alone produces no visible change, which is likely why this has gone unreported. Bug 5 is independent and probably the most user-visible of the set, since Reload is offered on every integration page.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions