Skip to content

feat: Z-Wave JS UI (MQTT) lock provider - #1466

Merged
raman325 merged 49 commits into
mainfrom
zwave-js-ui-mqtt-provider
Aug 24, 2026
Merged

feat: Z-Wave JS UI (MQTT) lock provider#1466
raman325 merged 49 commits into
mainfrom
zwave-js-ui-mqtt-provider

Conversation

@raman325

@raman325 raman325 commented Aug 24, 2026

Copy link
Copy Markdown
Owner

Proposed change

Adds a provider for Z-Wave locks bridged into Home Assistant through zwave-js-ui's MQTT discovery gateway (locks that appear as mqtt integration entities), requested in #1145 — plus a hardening pass informed by @erocm123's live-hardware branch, applied to both MQTT providers.

Design: API-driven, not value-topic scraping. All reads/writes go through zwave-js-ui's MQTT api (<prefix>/_CLIENTS/ZWAVE_GATEWAY-<name>/api/sendCommand → User Code CC), correlated by a nonce echoed in the response's origin. Value topics are consumed only as opportunistic push. Key mechanics:

  • Gateway binding from the entity itself: zwave-js-ui embeds its own status topic in every discovered entity's availability list, so the provider binds its gateway straight from the discovery payload — no scan, works per-lock across multiple gateways (including same-prefix primary/secondary setups). A retained-status scan with getInfo home-id disambiguation remains as fallback; gateways whose retained status (or LWT) reports offline are never bound.
  • API-only mode for MANUAL-gateway custom topics: locks whose state topic can't be shape-parsed still get full read/write/sync through the api, with supports_push derived (push + events when the node topic resolves; coordinator polling otherwise). Custom UID_DISCOVERY_PREFIX identifiers are accepted.
  • Provider dispatch for the shared mqtt platform is centralized (resolve_provider_class, device-identifier keyed). Unclaimed MQTT locks are rejected at config-flow submit time instead of being accepted and sitting permanently disconnected — an improvement for the existing Zigbee2MQTT provider too.
  • Subscribe-debounce race: HA's MQTT async_subscribe is not live on return; the provider holds a persistent prefix-scoped response subscription established during setup, drift-aware across gateway prefix changes, with mutation-verified regression tests. Mocked-broker tests structurally cannot catch this class of bug.
  • Push + keypad events: node-topic subscription classifies User Code publications (NAMED and VALUEID shapes) and Notification CC keypad lock/unlock events.

Hardening (from comparing against the live-tested zwave-mqtt branch — credit @erocm123):

  • fix(coordinator): a push provider whose initial refresh fails now retries instead of stranding until reload (core-layer; benefits Zigbee2MQTT too).
  • FLiRS-appropriate timeouts: 60s api budget (sub-wake timeouts stack duplicate commands until zwave-js-ui's queue jams — live-verified failure mode), 5s for gateway-local getInfo, 5s inter-operation pacing.
  • Masked user codes ("****") project unreadable, not known — prevents a permanent reprogramming storm on masking locks. Applied to both MQTT providers.
  • All-slots-failed reads raise LockDisconnected instead of returning an all-unreadable list that masquerades as a healthy poll and resets the breaker. Applied to both MQTT providers (operation refusals on zui; dead-broker silence on z2m).
  • Shared MQTT hardening hoisted into a new BaseMqttLock intermediate base (providers/_mqtt.py): the operational-guard preamble, the transport-distinguishing read loop, and availability — so the invariants exist once. Both providers' behavior provably unchanged (mutations in the shared loop fail tests in both directories).

Also fixes two released bugs surfaced along the way: the unmanaged-codes sweep never tore down the throwaway providers it builds (duplicate events after migration until restart), and the coordinator initial-load stranding above.

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

  • This PR is related to issue: Question: Does Lock Code Manager support Z-Wave JS over MQTT, or only the native Z-Wave JS integration (WS)? #1145
  • Full suite: 2096 passed / 3 skipped in ~84s; total coverage 100%; extensive mutation testing throughout review (subscribe ordering, unload release, guard grids, projection semantics, prefix drift, nonce correlation, and the shared read loop are all pinned by tests that fail under targeted mutations).
  • Deliberately out of scope for v1: User Credential (U3C) locks over MQTT.
  • Live testing against a real zwave-js-ui gateway is still pending before release; remaining live-verify items: serialized keypad-report shape (source says {userId}) and raw-payload string quoting (source says always JSON-quoted).

🤖 Generated with Claude Code

Review round (post-hardening)

An eight-angle review of the full PR surfaced four merge blockers — all fixed here with fail-first tests: fresh provider instances' first api call could never succeed (which made zui locks unconfigurable through the UI and is why the flow now runs through allocation in tests), throwaway providers built for config-flow/allocation questions leaked subscriptions (now context-managed via borrowed_lock_instance), a sole gateway candidate was bound without home-id verification (cross-network write hazard), and CancelledError escaped the provider contract. Also fixed: the all-slots-failed raise now needs ≥2 slots (single-user entries on lossy meshes keep main's behavior), users upgrading with an unclaimed MQTT lock get a lock_dropped repair issue instead of a silent pop, deferred provider setup retries from the connection check (cold-boot discovery race), plus an efficiency/dedup/docs cleanup tier (shared per-gateway scan cache, parallel getInfo probes, one discovery walk per question, CodeSlotStatus enum, is_masked_code at all sites, BaseMqttLock absorbing the remaining duplication, AGENTS.md + README refreshed). Known deferral, documented in code: a lock that gains push support from late-arriving discovery keeps a redundant poll until reload (push itself works).

raman325 and others added 21 commits August 23, 2026 15:06
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Entire-Checkpoint: c4b6ca3bd017
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…S rename

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Entire-Checkpoint: d8eecebeaa5b
Give the zwave-js-ui provider its addressing: the device registry
identifier yields (home id, node id), and the lock entity's MQTT
discovery state_topic yields the gateway prefix and the node topic.

Door Lock state values carry no propertyKey, so the state topic always
ends with exactly cc/endpoint/property; anything shorter is a
MANUAL-gateway custom topic and stays unresolvable rather than guessed.
There is no command_topic fallback because the command topic addresses
targetMode, not currentMode.

Adds the provider test package's conftest -- discovery payload builder,
discovery helper, and an LCM-config-entry fixture -- that the remaining
zwave-js-ui tasks build on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds pure MQTT value-payload unwrapping (raw/{time,value}/full-valueId
shapes) and User Code CC get-result projection to SlotCredential, mirroring
the reasoning already used by the zigbee2mqtt provider so unreadable codes
never get misread as empty and reprogrammed forever.
Resolve the gateway api base by listening for retained ZWAVE_GATEWAY-*
client statuses under the lock's own topic prefix, disambiguating several
gateways on one broker by asking each for its home id. A primary and a
secondary controller reporting the same home id fail loud rather than
tiebreak, so nothing is ever programmed through a controller the user did
not ask about.

The api client correlates responses by adding a nonce to the request
payload, which zwave-js-ui echoes verbatim under the response's origin --
the api response topic is shared with the zwave-js-ui UI and any other
automation, so an uncorrelated read would cross wires with them.

Also tighten the value-topic threshold: a gateway-built topic needs five
segments (prefix, node, and the three value segments), so a four-segment
topic is a MANUAL custom topic and no longer yields a guessed prefix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ient

Home Assistant registers an MQTT subscription locally on the spot but defers
the wire SUBSCRIBE behind a ~0.1s debouncer, while a publish goes out
immediately. Subscribing per api call and publishing in the same breath
therefore lost every zwave-js-ui response that arrived before the broker had
been told to route the topic -- and those responses are unretained, so they
were gone for good. Tests could not see it: the mocked broker dispatches
locally and synchronously.

Establish one persistent <prefix>/_CLIENTS/+/api/+ subscription at the start
of gateway resolution, before the discovery window, which is long enough to
outlast the debounce. Calls now register a nonce in a pending-call registry
that the single response handler resolves, and refuse outright if the
subscription is not live rather than subscribing and racing.

Also: route subscribe refusals to LockDisconnected so a reloading MQTT
integration does not escape as a bare HomeAssistantError; drop the cached
gateway base on a disconnect so a renamed or replaced gateway is
rediscovered instead of sticking until reload; and reject a non-integer
homeid during disambiguation, since True == 1 would let a JSON boolean match
a home id of 1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…scribe ordering

The api response subscription was registered in the base class's push-unsub
bucket, which is released only when supports_push is true. This provider
inherits supports_push=False, so async_unload skipped it and every reload
orphaned a wildcard subscription holding a dead provider. The base's own
_register_push_unsub docstring says listeners with a different lifecycle
must be tracked separately, and this one is the api transport, alive for as
long as the provider is.

Move it to a dedicated field released by an idempotent helper, called from
both teardown_push_subscription (keeping the reconnect re-resolve) and an
async_unload override, mirroring zwave_js.py's handling of its own
provider-lifetime listeners.

Also pin the ordering the previous commit relied on: a test now asserts the
response subscription is already live while the discovery window is open, so
moving the subscribe block after the window fails loudly instead of silently
reopening the race it was written to close.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reads, writes, clears, and the advertised User Code capacity, all built
on the correlated api client: one sendCommand round trip per operation
on the node's User Code Command Class.

The three connectivity guards every public operation shares are one
helper rather than the inline repetition Zigbee2MQTT carries. A read
that the api refuses becomes unreadable rather than empty, so a
transient refusal cannot tell sync a slot was cleared; a disconnect
propagates instead, because only reaching the caller runs the reconnect
path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Subscribe to the node's whole value tree and classify User Code CC value
publications and Access Control keypad notifications out of it, in both
the VALUEID and NAMED topic spellings the gateway can be configured for.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The capability probe is api traffic like any other, so a disabled MQTT
integration was surfacing as a call timeout rather than a disconnect.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
zwave_js_ui's _resolve_state_topic was a near-verbatim copy of
zigbee2mqtt's _resolve_device_topic, and async_is_device_available was
byte-identical between the two. Both move to providers/_util.py as
resolve_discovery_payload() and entity_state_is_available().

The walk stops at the payload; the topic-field extraction stays in each
provider, because that is where the two genuinely disagree -- zigbee2mqtt
falls back to command_topic minus /set, which names the same device,
while zwave-js-ui's command topic addresses targetMode rather than
currentMode and must never be stripped into a state topic. Each provider
now carries a comment saying so.

async_is_device_available goes to _util.py rather than becoming the
BaseLock default: the base default is `return True`, and seam relies on
it (tests/providers/test_seam.py documents that choice explicitly), so
changing the default would silently alter every provider that doesn't
override. A free function called by the two providers that want this
behavior has no blast radius outside them.

Also corrects stale prose in both providers: usercode_scan_interval is
inert while supports_push is true (coordinator.py sets update_interval
to None and nothing else reads the property), so the recurring visit that
re-ensures a drifted push subscription is the hourly hard refresh, not a
5-minute poll. The property stays -- it is the cadence the coordinator
would use if push were ever unsupported or disabled.

No behavior change. Both providers' full test directories pass unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
test_unusable_discovery_data_resolves_nothing took an `entities: object`
param that meant "the whole entities list" for one case and "the payload
value" for the other four, leaving a reader to infer which from the
runtime type. Split into an entity-absent test and a payload test
parametrized on `discovery_data_override`, so each case says what it is.

Adds the combined-errors options-flow test: one submission carrying both
an unclaimed mqtt lock and invalid users data must render both errors
together. The accumulation contract (different keys, errors.update twice
rather than a short-circuit) was only verifiable by reading the flow.
Verified by mutation -- short-circuiting after the lock check fails the
new test and nothing else.

Also corrects test_push's prose about which recurring visit re-ensures a
drifted subscription, matching the provider comment it describes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Drop the leading underscores from the three module constants that had
them; nothing else in the file is spelled that way. Keep the
``_published_code`` docstring on one word for the thing it returns, and
hoist the "already subscribed to the right topic" predicate the two
subscribe paths were each spelling out.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The one-time unmanaged-code sweep builds a throwaway provider per lock
and reads it. An MQTT provider subscribes to its lock's topics on the
way to answering that read, and nothing ever tore the throwaway down --
so after the migration that instance went on firing code slot events
next to the entry's real provider, doubling every keypad event until
Home Assistant restarted.

Found by the zwave-js-ui end-to-end test, which saw one keypad unlock
arrive twice.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Drive a full LCM config entry over a discovered zwave-js-ui lock: the
gateway resolves through the real retained-status path, the configured
codes are programmed and read back through a stand-in User Code table,
and pushes, keypad events, disabling a slot, and unload all go through
the same seams a user's setup would.

The api-driven design lets these assert the exact sendCommand envelope
each operation arrives as, which the Zigbee2MQTT equivalent cannot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 24, 2026 01:38

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 enhancement New feature or request labels Aug 24, 2026
@codecov

codecov Bot commented Aug 24, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.13%. Comparing base (0420a65) to head (cfb570a).
⚠️ Report is 4 commits behind head on main.
✅ All tests successful. No failed tests found.

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #1466      +/-   ##
==========================================
+ Coverage   99.07%   99.13%   +0.05%     
==========================================
  Files          62       64       +2     
  Lines        7714     8222     +508     
  Branches      520      520              
==========================================
+ Hits         7643     8151     +508     
  Misses         71       71              
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%> (ø)
..._components/lock_code_manager/domain/allocation.py 100.00% <100.00%> (ø)
...components/lock_code_manager/domain/coordinator.py 100.00% <100.00%> (ø)
...ustom_components/lock_code_manager/domain/locks.py 100.00% <100.00%> (ø)
...m_components/lock_code_manager/domain/unmanaged.py 100.00% <100.00%> (ø)
custom_components/lock_code_manager/domain/util.py 100.00% <100.00%> (ø)
...components/lock_code_manager/providers/__init__.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 5 more

... and 1 file with indirect coverage changes

🚀 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 4 commits August 23, 2026 21:57
A push provider's coordinator is constructed with no update interval, on
the premise that the device tells us when something changes. That premise
does not cover the first load: the coordinator still has to poll once to
seed itself, and if that poll raises, `async_get_usercodes` turns it into
`UpdateFailed` and nothing ever schedules another one. The recovery probe
that `_apply_backoff` already has cannot help -- it only starts once the
breaker trips, and tripping it takes repeated polls that, with no timer,
never happen. The lock's entities stay unavailable until the integration
is reloaded, and the reload re-runs the same first load into the same
wall.

A lock that is merely asleep at startup hits this routinely: FLiRS battery
locks answer on their own wake schedule, and Home Assistant's own startup
is exactly when everything else is competing for the mesh. zigbee2mqtt is
affected identically.

So while `_original_update_interval` is None and no refresh has ever
succeeded, a failure schedules one at the base backoff cadence. It is an
`elif` on the breaker's arm so the escalating backoff wins the moment the
breaker trips, and `_reset_backoff` restores the push cadence on the first
success.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A FLiRS (battery) lock answers on its own wake schedule, so a single
command can legitimately take the better part of a minute. Abandoning it
at ten seconds does not cancel it -- zwave-js-ui keeps working the queued
command, and LCM's retry stacks a duplicate behind it. On live Kwikset
FLiRS meshes that was already fatal at fifteen seconds: the gateway's
queue filled faster than the lock could drain it and stopped answering
anything. So the mesh budget goes to 60s.

Widening it uniformly would be a regression for gateway discovery, where
each candidate is asked getInfo and a dead client would now cost a minute
apiece. getInfo is assembled from zwave-js-ui's own cached driver state
and puts nothing on the mesh (ZwaveClient.getInfo), so it gets its own
five-second budget, threaded in as an optional argument on
`_async_api_call_at`.

`getUsersCount` does NOT qualify despite the name: it is a User Code
Command Class UsersNumberGet addressed to the node
(node-zwave-js UserCodeCCAPI.getUsersCount), so it waits on the same wake
schedule as a code read and keeps the mesh budget.

Also widens this provider's inter-operation pacing to 5s. Every api call
lands on zwave-js-ui's single command queue, which it shares with its own
UI and every other MQTT client; pacing our own operations wider costs
nothing on a healthy mesh and keeps LCM from being the client that fills
it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The status collector recorded any `_CLIENTS/ZWAVE_GATEWAY-*` topic segment
it saw, without reading the payload. zwave-js-ui publishes that topic
retained (`MqttClient.updateClientStatus`) AND registers a retained
`{"value": false}` on it as its last will (`MqttClient._init`), so a
gateway that crashed or was decommissioned leaves a status behind forever.

Being the only status on the prefix is the worst case, not the safest one:
the single-candidate path binds it outright, with no getInfo to expose
that nobody is home, so every api call times out, drops the cached base,
and rediscovers the same corpse.

The payload is the ordinary value shape, so `_unwrap_mqtt_value` handles
the wrapped and raw forms alike, and a truthiness test is all that is
needed. This is the one place in the module that does not also reject a
boolean: here `True` is the online signal itself rather than a JSON type
confusion, and the comment says so.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A lock configured to withhold its user codes answers a read with one
asterisk per digit instead of the digits. Both paths that turn a published
`userCode` into a credential took that at face value: the api projection
made it `known("****")` and the push classifier confirmed the slot with
it.

That is not a wrong value, it is a value that can never be right. Sync
compares the coordinator's credential against the configured Personal
Identification Number, so a masked code is a mismatch on every tick
forever -- the slot is reprogrammed, the lock answers with the mask again,
and the loop never converges.

Withheld belongs in the state that already means "occupied, contents
unknown": unreadable from the api projection, and nothing at all from the
push classifier. Only an all-asterisk code counts; asterisks mixed with
digits are not a shape any lock produces, and guessing at partial masking
would discard a code that is really there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
raman325 and others added 9 commits August 23, 2026 22:11
zwave-js-ui builds its Home Assistant device identifier as
`UID_DISCOVERY_PREFIX + homeHex + '_node' + node.id`, and that prefix is
`process.env.UID_DISCOVERY_PREFIX` -- `zwavejs2mqtt_` is only its default
(Gateway.ts:36). Anchoring the pattern on the default rejected every lock
behind a gateway whose operator had renamed it, and rejected it at the
config flow: an identifier nobody claims resolves to no provider at all,
so the lock could not be added, with nothing on screen to explain why.

The head is now unconstrained and the tail carries the whole burden of not
over-claiming -- a hex home id, `_node`, digits, end of string -- so
`somebridge_1`, a bare `_node5`, and an identifier with anything trailing
the node id all still fail to match.

That makes the dispatch order in `resolve_provider_class` load-bearing
rather than incidental: Zigbee2MQTT's prefix is fixed while ours is only a
tail, so a Zigbee2MQTT address ending in a zwave-js-ui-shaped tail now
matches both rules and only the ordering keeps it with the right provider.
Commented, and pinned by a test that fails if the two checks are swapped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ng as data

A poll where the lock refused every single slot returned a list of
unreadable credentials, which the coordinator cannot tell from a poll that
succeeded: it resets the connectivity breaker, un-suspends every slot, and
lets them all re-fail on the next tick. That is a seconds-scale
oscillation flipping every managed slot in and out of sync. The
coordinator already guards the shape where the read *raises*
(async_get_usercodes), but it has nothing to guard this one with.

The distinction that matters is not "did the credential come back
readable" -- a lock configured to withhold codes answers every request and
is perfectly healthy, and so is a slot whose status is Disabled. It is
"did the lock answer at all". So `_async_read_slot` now returns None for
an operation-level refusal and a credential for anything the lock actually
described; both still reach the coordinator as unreadable, but only the
refusals decide whether the read was worth anything. All refusals raises
LockDisconnected and leaves the backoff timer in charge of the next
attempt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…opic

zwave-js-ui writes its own client status topic into the `availability`
list of every entity it discovers (Gateway.ts `setDiscoveryAvailability`),
so the payload a lock arrives on already says which gateway owns it. That
makes it the authoritative binding, and it is now the primary path: no
traffic, no discovery window, and -- unlike the retained-status scan -- it
is per lock, so two gateways sharing a topic prefix resolve correctly
instead of forcing a getInfo interrogation of each (or an outright refusal
when both answer for the same home id). The list is scanned for a topic of
that shape rather than indexed; the other two entries are the node's status
and the driver's, and their order is the gateway's business.

The prefix comes from that topic too, deliberately not from the state
topic: a MANUAL gateway publishes wherever the user pointed it, so the
state topic's first segment is a naming choice rather than a prefix.

The scan stays as the fallback for a payload carrying no such entry.

Moving the api response subscription:

Establishing it inside `_async_resolve_api_base` only ever worked because
resolution held a multi-second discovery window, which happened to outlast
Home Assistant's MQTT subscribe debouncer. The fast path has no window, so
that placement would race again -- a publish goes out immediately, the wire
SUBSCRIBE does not, and zwave-js-ui sends api responses unretained. It now
happens in `async_setup`, seconds before the coordinator's first poll.
Resolution refuses to proceed without a live subscription, arming one for
the next attempt so the coordinator's backoff carries the retry.

Tests: the discovery payload builder now emits the real three-entry
availability list (and `async_discover_zui_lock` replays the availability
burst, without which `availability_mode: all` leaves the entity
unavailable), so the whole suite exercises the path production takes. The
scan tests move to a `zui_scan_lock_provider` fixture built from a payload
with no availability entry, which is the shape that fallback is for. The
e2e suite no longer needs its retained-status replay fixture at all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A lock whose discovery state topic is a MANUAL gateway's custom shape
gives up no node address, so it cannot be subscribed to. It was refused
outright -- `async_is_integration_connected` required the node topic --
which cost the whole lock to save the half of it that does not work. With
the gateway now bound from the availability topic, every api operation on
such a lock works: reads, writes, capacity.

Worse, `supports_push` was a constant True. Had connectivity simply been
loosened, the coordinator would have dropped its update interval for a
lock that has no push either, leaving it with no data path at all. So it
is derived: true exactly when the node topic resolves, and
`supports_code_slot_events` follows it (keypad events are Notification
Command Class publications on the same subscription). The coordinator
reads it once at construction, which happens after `async_setup`, and the
answer comes from Home Assistant's MQTT discovery data -- already loaded.

- Connectivity is now "MQTT is up, the identifier parses, and a gateway
  prefix resolves". The identifier requirement stays: something has to
  prove this is a zwave-js-ui device, and nothing is inferred from a name.
- `async_setup` skips the node subscription instead of raising, at info
  per the log-levels convention -- a MANUAL gateway is a configuration,
  not a fault.
- `async_get_users` skips the drift re-ensure for an api-only lock rather
  than logging a failure it can do nothing about on every poll.
- `async_unload` is overridden to release both subscriptions. The base
  only tears the push subscription down when `supports_push` says so, and
  an api-only lock says no while its api transport is live all the same.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A lock configured to withhold its user codes answers a read with one
asterisk per digit instead of the digits. Zigbee2MQTT publishes that
verbatim, and both paths that turn a published code into a credential took
it at face value: the `users` projection made it `known("****")`, and so did
the `pin_code` response that resolves a pending slot read.

That is not a wrong value, it is a value that can never be right. Sync
compares the coordinator's credential against the configured Personal
Identification Number, so a masked code is a mismatch on every tick forever
-- the slot is reprogrammed, the lock answers with the mask again, and the
loop never converges.

Withheld belongs in the state that already means "occupied, contents
unknown", which is exactly where this provider already puts an `enabled`
user whose `pin_code` field `expose_pin` hides. Masking only decides
anything where the code would otherwise be known: a user the lock is not
accepting is already incomparable, so a disabled masked user lands in the
same place it did before.

The asterisk test is the one the zwave_js_ui provider already applies, so it
moves to providers/_util.py rather than being written twice. Only an
all-asterisk code counts; asterisks mixed with digits are not a shape any
lock produces, and guessing at partial masking would discard a code that is
really there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…asquerading as data

A poll where not one GET reached the lock returned a list of unreadable
credentials, which the coordinator cannot tell from a poll that succeeded:
it resets the connectivity breaker, un-suspends every slot, and lets them
all re-fail on the next tick. That is a seconds-scale oscillation flipping
every managed slot in and out of sync. The coordinator already guards the
shape where the read *raises* (async_get_usercodes), but it has nothing to
guard this one with.

A dead broker reaches this read: every gate above it -- the MQTT config
entry being enabled, the topic resolving from discovery data, the lock
entity's state -- answers from Home Assistant's own configuration rather
than from the wire, so none of them notices that nothing is being carried.
Then every slot fails on publish or times out waiting, and the result reads
as a healthy lock full of unreadable slots.

The distinction that matters is not "did the credential come back readable"
-- a lock configured to withhold codes answers every request and is
perfectly healthy, and so is a user the lock reports as disabled. It is
"did anything come back". So `_async_read_slot` now returns None when the
request never left or nothing answered it, and a credential for anything
the bridge actually described; both still reach the coordinator as
unreadable, but only the silences decide whether the read was worth
anything. All silent raises LockDisconnected and leaves the backoff timer
in charge of the next attempt.

One silent slot among answered ones is unchanged: it stays unreadable, on
purpose. This provider asks one index per round trip, and a lock that drops
one request out of several is a weak link, not a lost transport.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… prefix

`_async_ensure_api_response_subscription` was idempotent on "is one set"
rather than on "does it cover the right topic", which
`_node_subscription_current` has been drift-aware about all along.

An operator who changes zwave-js-ui's MQTT prefix republishes discovery, so
the new prefix resolves and every publish goes there -- while the
subscription stays on the old one. The response is published where nothing
is listening, the call times out, the timeout drops `_api_base`, resolution
re-derives the same new prefix, and the guard passes again because a topic
is set. The loop sustains itself.

A push lock eventually falls out of it when a connection transition tears
the subscription down. An api-only lock -- a MANUAL gateway whose custom
state topic carries no node address -- never does: that teardown is gated on
`supports_push`, which such a lock derives as False. It stays stranded until
the integration is reloaded, and the reload re-resolves into the same wall.

So the guard now compares against the topic the prefix implies and releases
before resubscribing, and `_async_resolve_api_base` ensures the subscription
rather than merely checking it -- that resolve is the only place a lock with
no push lifecycle passes through often enough to notice the prefix moved.
Refusing the attempt that resubscribes is the same debounce-settling rule a
first subscription already obeys: Home Assistant defers the wire SUBSCRIBE
while a publish goes out immediately, and zwave-js-ui sends api responses
unretained.

A prefix that will not resolve right now still leaves a live subscription
alone. Transiently missing discovery data is not a prefix that moved, and
tearing down over it would drop traffic for a lock that never changed
address.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lock

Push is derived per lock from whether its discovery state topic yields a
node address, so a single config entry can hold a structured-topic lock and
a MANUAL-gateway lock at once. Every consequence of that was reachable but
untested: the two locks take different subscription paths, share one api
response wildcard on the same prefix, run their coordinators at different
cadences, and are released by one unload.

The correlation case is the one that needed care. Both locks subscribe to
`<prefix>/_CLIENTS/+/api/+`, so every response reaches both handlers and the
echoed nonce is the only thing telling them apart -- but a stand-in gateway
that answers each request before the next goes out never puts two nonces in
play, and a test written that way passes with the nonce ignored entirely.
So the stand-in holds both requests until both calls are waiting, then
answers them in the opposite order, and each node reports its own id as its
capacity so a crossed wire is a wrong number rather than a silent pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Zigbee2MQTT and zwave-js-ui speak different protocols to different radios,
but they reach them the same way: through a bridge Home Assistant's MQTT
integration already talks to, at topics the bridge published in its own
discovery payload. Everything that hardening has added lately has therefore
had to be added twice, in parallel -- most recently the all-transport-failed
read, which landed as two implementations of one invariant a week apart.
Two copies of a rule is one copy plus a place for it to drift.

`providers/_mqtt.py` holds the three decisions that are about the transport
rather than the protocol:

* `_async_ensure_operational`, the preamble every public operation runs
  first. `require_device` carries the one real difference: Zigbee2MQTT hands
  a write to a bridge that will queue it for a sleeping device, while a
  zwave-js-ui write is an api round trip the node itself has to answer.
* `_async_read_slots`, the sequential per-slot read and the
  all-transport-failed raise. Providers supply a `_async_read_slot` that
  returns None for silence and a credential for anything the lock described,
  plus the phrase naming what silence means on their bridge -- "was refused"
  for a gateway declining a command, "failed to reach the lock" for a GET
  that never got an answer.
* `async_is_device_available`, identical in both.

Deliberately not hoisted: subscription lifecycles (a per-node value tree
versus a per-device topic, and zwave-js-ui's api transport with its own
lifetime), the payload projections, the api client, and the poll cadences --
whose intervals coincide today for unrelated reasons.

Two things resisted. Zigbee2MQTT names the wrong-bridge misconfiguration
before falling back to "Lock not connected", which is now a
`_raise_not_connected` hook rather than three inline calls; it is undecorated
because `@callback` costs mypy the `NoReturn` inference. And the two
providers phrase their all-failed message differently, so the base owns the
frame and the caller owns the noun.

Behaviour is unchanged: both provider test directories pass with three patch
targets moved (the preamble's MQTT-enabled check is bound in `_mqtt` now, not
in each provider, which still binds its own for the subscription paths) and
one message match updated. The one thing that was not pinned is now: nothing
tested that a Zigbee2MQTT write skips the device-availability check a read
makes, so `require_device` survived being ignored.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
raman325 and others added 15 commits August 24, 2026 00:02
… the call

Resolution refused any call whose api response subscription had just been
created, on the assumption that the coordinator's backoff would retry. Only
a provider that lives long enough to be retried gets that: the config flow's
allocation reads, the option flow's capacity check, and the unmanaged sweep
each build a provider, make exactly one call, and drop it. Every attempt to
add a zwave-js-ui lock through the user interface therefore failed with
occupancy_unknown, and the sweep skipped zwave-js-ui locks permanently.

Every subscribe now waits SUBSCRIBE_SETTLE_DELAY before returning, so a
subscription is settled by the time the call that created it publishes into
it. Putting the wait at the subscribe rather than at the caller also closes
the setup-time race: async_setup subscribed and the coordinator's first poll
published milliseconds later, inside Home Assistant's SUBSCRIBE_COOLDOWN, so
the reply was lost and the lock stalled for a whole timeout budget.

The fail-loud path is kept for the one case that is not a timing problem:
no subscription could be established at all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three allocation sites built a provider, read it, and dropped it. An MQTT
provider subscribes to its lock's topics on the way to answering, and a
zwave-js-ui one also opens its gateway's api channel, so every config flow
attempt left another live subscription behind -- each one reporting the same
keypad press again, from an instance nothing owns.

Only the unmanaged sweep had a teardown, hand-rolled in a finally. All four
now go through one borrowed_lock_instance context manager, so a new query
site cannot forget.

Disposal is unsubscribe_push_updates, not async_unload. Unload means "this
lock is leaving the entry" and providers act on that: the virtual lock
writes its store back, which from a borrowed instance that never read one
would erase every code in it. Releasing the transport is the whole of what
a borrowed instance acquired -- and unsubscribe_push_updates is ungated at
the base, so it reaches the zwave-js-ui api transport even on an api-only
lock that reports no push support.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The retained-status scan bound a lone candidate outright, on the reasoning
that getInfo only exists to break a tie. Filtering dead gateways out at the
status changed what "lone candidate" means: a user whose own controller is
down, sharing a broker with a neighbouring network, is left with that
network's gateway as the only one answering on the prefix. Binding it wrote
this lock's Personal Identification Numbers into whatever the other network
calls node 20.

Every candidate is now asked which network it runs, and a mismatch or a
silence refuses by name. The availability fast path still skips the check
and now says why in the code: that topic came out of the discovery payload
that created this very lock entity, so the gateway named it about itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
_release_api_subscription cancels every pending call's future, and the wait
only caught TimeoutError, so the CancelledError escaped the provider. Being
a BaseException it slipped past the coordinator's and the sync layer's error
handling entirely and surfaced as an unhandled task cancellation, for what
is simply a lost transport.

The two cancellations that reach this point are told apart by whether this
task itself is being cancelled. Ours -- the release -- becomes
LockDisconnected. An outer one, Home Assistant shutting down or the entry
unloading, is never converted: swallowing it would stall the teardown that
asked for it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every existing claimed-mqtt-lock flow test stopped at choose_path, one step
short of the only place a config flow talks to the lock. Submitting a user
count is what builds a throwaway provider and reads for free numbers, and
nothing covered it -- which is why a provider change that made those reads
refuse could make a zwave-js-ui lock unaddable through the user interface
with the whole suite green.

Both bridged providers now run that step: reverting the settle-wait makes
the zwave-js-ui one fail with occupancy_unknown at the code_slot step.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nything

The shared MQTT read raised LockDisconnected whenever every slot it asked
about came back silent, which for a one-user entry is one lost reply. On a
lossy mesh that is routine -- issue #1397 had a node dropping roughly half
its responses -- so the same lock, losing at the same rate, tripped the
connectivity breaker for a household with one user and polled on untroubled
for a household with two. How many people live there is not evidence about
the transport.

Rule chosen: raise only when two or more slots were read and all of them
were silent. The alternative considered was to keep raising at n=1 for
failures that prove the transport is dead, but neither bridge reports one
cleanly here -- zwave-js-ui's silence is the gateway explicitly refusing the
command, which proves the transport is alive, and Zigbee2MQTT folds a
refused publish and a lost reply into the same None before the shared loop
sees either. Dead transports are still caught by the operational preamble
every read runs first, whose entity availability follows the bridge's own
status topic, and by writes, which fail on their own path at any slot count.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A lock whose provider cannot be resolved is popped from the entry during
setup: no entities appear for it, nothing is written to it, and the only
trace was one line in the log. Selection-time validation refuses that
choice today, so the entries this happens to are the ones configured before
the check existed -- exactly the users who never chose it knowingly and
never see the log.

A lock_dropped repair now names the entity and quotes what refused it, and
is dismissed when the lock sets up or leaves the entry. Removing it from
the entry is what the repair advises, so that path had to work: it read the
missing instance out of runtime data first, and the KeyError -- swallowed
by the fire-and-forget update listener -- abandoned the rest of the update.
The read was dead code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ady loaded

Provider setup is skipped when the lock's integration reports itself not
connected, and the only thing that re-ran it was that integration reaching
LOADED. An MQTT bridge is loaded long before Lock Code Manager on a cold
boot, and what is late is its discovery data, not its entry -- so the
transition never came. The lock stayed un-set-up for the rest of the run:
never validated, its provider's async_setup never called, and sync, which
gates writes on that, never wrote another code to it.

The 30-second connection check now runs the same integration-loaded path
when it finds a reachable lock whose setup was deferred. A dedicated flag
arms it rather than provider_setup_succeeded, which is also False for a
lock whose setup ran and failed: that lock has a diagnosis and its own
revalidation path, and re-probing its capabilities every thirty seconds
would put a read on the wire for a lock already known to be degraded. The
flag is cleared by setup running, so this fires once either way.

Two triggers now spawn the reconnect, so the spawn (and its superseding of
an in-flight task) is factored into one place and stays in the single slot
async_unload cancels.

Deferred, and commented at the coordinator: supports_push is still snapshot
when the coordinator is built, so a lock that gains push from late
discovery keeps polling until the next reload. Push itself works -- the
retry re-runs the provider's subscribe -- so this is a redundant poll, not
a lost update, and re-deriving it live means reaching into an interval
whose breaker backoff, cold-start probe, and restore-on-recovery arms each
own it at different times.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…estion

``_gateway_prefix`` asked its two sources in turn, and each walked Home
Assistant's MQTT debug data -- a walk that rebuilds the debug state of
every MQTT entity on the device. It runs on every connectivity check and
several times per operation, so a gateway whose availability list names
no client status paid for it twice.

The two derivations become pure functions over one payload, read once.
``_resolve_state_topic``, ``_prefix_and_node_topic`` and
``_gateway_from_availability`` keep their signatures and their single
walk apiece.

Also records why ``_async_ensure_operational`` repeats two of the checks
``_execute_rate_limited`` just made: the unmanaged-code sweep and slot
allocation reach the provider's operations without that wrapper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two changes to the fallback that finds a gateway by scanning a prefix's
retained client statuses.

The getInfo probes now run concurrently. They are answered from each
gateway's own cached driver state and put nothing on anybody's mesh, so
asking in turn only meant paying the local budget once per gateway that
had stopped answering before reaching the live one. Arbitration is
unchanged: a home id still has to match exactly one candidate.

The result is now shared across provider instances, keyed by
``(prefix, home hex)`` in ``hass.data`` and serialized behind a mutex.
The scan answers a question about a gateway, not about a lock, and every
lock behind one gateway asks it together -- their coordinators refresh on
the same tick. A disconnect that drops the instance binding drops the
shared answer with it, so nobody replays an address that stopped
answering; a failed scan caches nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e cadences

Zigbee2MQTT and zwave-js-ui had the same forty-five lines of
``setup_push_subscription`` twice, differing only in which topic they
resolve and which subscribe they run. Both now hand those two things to
``BaseMqttLock._schedule_push_subscription``, which holds the policy:
keep a working subscription when the topic goes transiently
unresolvable, refuse when there is nothing to fall back on, and run the
provider's own idempotent subscribe in a task because the caller is
synchronous and cannot be raised at.

The explicit "MQTT is disabled" gate goes with it rather than being
hoisted. Both providers' subscribe already refuses on that, so it
arrives as an ordinary deferral instead of being asked twice.

The poll cadences and the one-line hard refresh move to the base too.
The module docstring had claimed their agreement was coincidence; it is
not. A read on either bridge costs a round trip per slot, which is what
sets the interval, and neither has a cache for a hard refresh to go
behind, which is why it is the ordinary read.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e way twice

``userIdStatus`` was compared against two hand-written ints that
restate ``CodeSlotStatus`` from zwave-js-server -- the same enum the
zwave_js provider already imports for the same field. The boolean guard
stays: ``CodeSlotStatus.ENABLED`` is an int, so ``True == 1`` is still
the trap it was.

Three sites also spelled the withheld-code check inline as
``code == "*" * len(code)``, which ``_util.is_masked_code`` exists to
say. That helper answers False for an empty string where the inline form
answered True, so both sites that could see one keep an explicit
emptiness test in front of it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The provider register listed neither ``zwave_js_ui.py`` nor ``zha.py``,
and said nothing about ``BaseMqttLock`` -- which is now the class a new
MQTT-bridged provider is supposed to start from, so the step that says
"subclass BaseLock" says which one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
``_unclaimed_mqtt_locks`` had one caller that did nothing but test its
result for emptiness, and ``entity_state_is_available`` had one caller
that was already the docstring's subject -- the MQTT base's device
availability. Both read better where they are used, and the availability
one can now say which bridges it is deferring to.

``resolve_discovery_payload``'s scan for its own entity becomes a
``next``, which is what the loop's match-then-return was spelling.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions github-actions Bot added the documentation Documentation changes label Aug 24, 2026
@raman325
raman325 merged commit b63ab79 into main Aug 24, 2026
22 checks passed
@raman325
raman325 deleted the zwave-js-ui-mqtt-provider branch August 24, 2026 14:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Documentation changes enhancement New feature or request lcm-minor Minor version bump python Pull requests that update Python code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Question: Does Lock Code Manager support Z-Wave JS over MQTT, or only the native Z-Wave JS integration (WS)?

2 participants