Skip to content

fix: harden the MQTT providers against review findings from #1466 - #1471

Merged
raman325 merged 12 commits into
mainfrom
zwave-js-ui-review-fixes
Aug 24, 2026
Merged

fix: harden the MQTT providers against review findings from #1466#1471
raman325 merged 12 commits into
mainfrom
zwave-js-ui-review-fixes

Conversation

@raman325

Copy link
Copy Markdown
Owner

Proposed change

Follow-up to #1466 (merged/released): a second full-PR review found defects that per-commit review could not see — several were interactions between individually-correct fixes. This PR addresses all of them, slimmed to the minimal mechanism per finding (417 production insertions):

Correctness

  • Push classification now respects status semantics: the stale-AVAILABLE guard the zwave_js provider applies to the identical hardware signal (documented infinite reprogram loop) now also guards the zwave-js-ui push path, and a userCode publication no longer confirms a slot whose last-seen userIdStatus was non-ENABLED (retained-message order no longer makes state nondeterministic).
  • The all-silent-read disconnect only fires once reads have ever succeeded on that lock — a Zigbee2MQTT converter that supports setting PINs but never answers a get (worked pre-5.1) polls all-unreadable again instead of tripping the breaker and suspending writes. A redundant consecutive-poll counter was deleted after analysis showed it reset the breaker it was meant to feed.
  • Borrowed/throwaway instance releases no longer wipe the shared per-gateway scan cache; a node-level command timeout only invalidates the shared binding after a gateway-level probe (home-id checked) also fails — a slow FLiRS node no longer forces every lock behind a healthy gateway to rescan into the jammed queue.
  • The lock_dropped repair fires only for a genuinely unclaimed lock (new UnclaimedLockError); CancelledError propagates out of setup instead of minting a persistent misdirecting repair at shutdown; other setup errors keep the log-only path.
  • Options flow and reauth validate only locks a submission adds — a grandfathered pre-upgrade unclaimed lock no longer blocks unrelated PIN edits or reauth completion.
  • A connectivity blip during the deferred-setup retry re-arms the retry instead of stranding writes until reload; ZHA gains the masked-code guard every other provider got; the ensure-api-subscription path is serialized (double-subscribe leak); MQTT-provider dispatch precedence is enforced across the whole identifier set (was hash-order-dependent for a device carrying both identifier shapes).
  • Multi-level gateway prefixes (upstream permits / in the prefix — verified in zwave-js-ui source) now fail loud with a diagnosable message in the scan fallback, pointing at the discovery availability entry that handles them correctly.

Efficiency

  • The 1s subscribe-settle sleep became a recorded deadline awaited only before the first publish (entry setup and config-flow allocation no longer stack sleeps); foreign api payloads are skipped by a bytes-level nonce prefilter before JSON parsing; a lock that gains push support from late-arriving discovery stops its now-redundant polling at the deferred-setup transition.

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: feat: Z-Wave JS UI (MQTT) lock provider #1466 (follow-up review round)
  • Full suite: 2153 passed / 3 skipped; total coverage 100%; every fix landed fail-first with targeted mutations (15 re-verified against the final slimmed code, each killed by a uniquely identifiable test).

🤖 Generated with Claude Code

raman325 and others added 12 commits August 24, 2026 10:45
Two independent ways the node subscription could contradict what a poll of
the same slot reports.

The AVAILABLE branch took the lock at its word. Some locks re-announce
AVAILABLE after a code lands, which marks the slot cleared, makes sync
rewrite it, and starts a loop that never settles. The zwave_js provider
already guards the identical hardware signal against LCM's desired
credential; the same guard now applies here.

The userCode branch confirmed a code from any publication, while the poll
projection requires ENABLED. A Disabled slot keeps its digits, so the same
slot read in sync over push and unreadable over poll -- decided by which
retained topic the broker replayed first, which is not a property of the
lock. The last-seen userIdStatus per slot is now tracked and a code is only
confirmed when that status is ENABLED, or when no status has been seen at
all: a gateway that publishes codes and never statuses would otherwise leave
every slot permanently unconfirmed, and an over-confirmed code is corrected
by the next poll while a muted one has no such correction. The tracking dies
with the node subscription it was observed on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The predicate raised on any two-or-more slot read where nothing came back,
which bricks two kinds of lock that were working before it existed.

A bridge that cannot answer a read at all -- a Zigbee2MQTT converter
exposing the PIN as write-only, a node with User Code Set but not Get --
previously polled all-unreadable forever while every write landed. Under the
old rule every poll raised, the connectivity breaker tripped, and the writes
were suspended along with the reads that were never going to work. The raise
is now gated on this instance having read something successfully at least
once, so a lock that can never prove it is readable is left exactly as it
was.

And a #1397-class lossy mesh drops the replies of one poll together, not
independently, so a two-slot poll on a node timing out half its responses
comes back entirely empty a good fraction of the time. One silent poll is
now absorbed and only a second consecutive one raises, which costs one poll
interval of detection latency and buys not suspending a lock that is merely
slow.

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

The shared scan result answers a question about a gateway, and every lock
behind that gateway binds from it. Two paths threw it away on evidence that
was never about the gateway at all.

Releasing a transport did. Every borrowed provider releases on the way out --
a config flow read, the unmanaged sweep -- and so does a connection blip, so
a sweep of N locks paid N full three-second discovery windows instead of one.
Instance teardown now clears only the instance's own base.

A timed-out call did. Every api this provider sends a lock is a sendCommand
that waits on the mesh, so a FLiRS lock missing its wake window times one out
routinely; that dropped the shared binding and sent every lock on the prefix
rescanning into the same command queue that was already too slow to answer.
The gateway is now asked directly first -- getInfo, on the gateway-local
budget, out of its own cached driver state and off the mesh -- and a gateway
still answering for this lock's network keeps its binding. An answer from
another network, no answer, or no response subscription to ask over all fall
back to invalidating, so a gateway that really did move is still rediscovered.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The repair fired for any BaseException the per-lock setup gather returned,
while its text diagnoses an unsupported bridge and asks the user to remove
the entity. Two wrong audiences got it.

CancelledError, which asyncio.gather(return_exceptions=True) hands back as a
result rather than propagating: Home Assistant shutting down mid-setup
dropped the lock and left a persistent repair, blaming a bridge that was
never at fault and outliving the restart that ended the condition.
Cancellation is now re-raised out of the results loop.

And any bug in a provider LCM does speak, which told a user with a working
lock to throw it out. Unclaimed locks now raise their own UnclaimedLockError
from the resolver, and only that type creates the repair -- a type rather
than a message match, so the diagnosis cannot drift onto a different failure
that happens to read similarly. Everything else keeps the log-only pop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The unclaimed-mqtt check ran over the whole pre-filled lock list on every
options and reauth submission, so an entry configured before that check
existed -- carrying an mqtt lock whose bridge no provider speaks -- could
never be saved again. An unrelated PIN edit was refused, and reauth, the
recovery path that entry is most likely to be in, could not complete. There
was no way out but hand-editing storage.

Both flows now pass the entry's current lock list and only newly added locks
are checked. Grandfathering is per lock, so any other unclaimed lock added
to the same entry is still refused, and the initial user step -- which has no
pre-existing entry -- keeps validating everything. A pre-existing unclaimed
lock is not silently accepted either: it reaches the runtime, is dropped at
setup, and raises its own repair naming the entity.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
_async_run_provider_setup clears _setup_deferred as its first statement, and
that flag is the only thing arming another attempt for a lock whose provider
integration was loaded all along -- no LOADED transition is ever coming for
it. So a transient LockDisconnected during the retried setup consumed the one
retry: the lock stayed un-set-up and sync, gated on that, refused to write
anything to it until the integration was reloaded by hand.

The reconnect path now re-arms the flag when the retry fails on
connectivity, which is the exact condition the retry exists for. A validation
failure still does not re-arm: that lock is known-degraded with a diagnosis
and its own revalidation path, and re-probing it from every connection check
would put a capability read on the wire every thirty seconds to learn what it
already knows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The read path confirmed whatever an Enabled slot reported, so a lock
configured to withhold its user codes -- which answers with an asterisk per
digit -- had those asterisks confirmed as the PIN. They never equal the
configured one, so sync saw a permanent mismatch and reprogrammed the slot on
every tick, forever. Same Zigbee hardware as Zigbee2MQTT, whose read path
already guards this; ZHA was the one site the earlier sweep missed.

Deliberately deferred: the deeper fix is to apply the mask check once at the
provider boundary rather than at each read site, so a new read path cannot
miss it again. That refactor spans every provider and is out of scope here.

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

Verified upstream: zwave-js-ui does permit '/' inside mqtt.prefix. The
settings UI's validPrefix rule lists it among the allowed characters, its
sanitizeTopic keeps it unless a caller asks for it to be stripped, the
settings POST handler validates the field not at all, and every topic is
built by plain concatenation -- so a prefix of home/zwave produces real
multi-level topics.

That makes the state topic structurally ambiguous: in
home/zwave/nodeID_20/98/0/currentMode nothing distinguishes the prefix's
second segment from a NAMED gateway's location segment. The scan fallback
takes the first segment, searches under home/_CLIENTS, finds an empty
broker, and reports it exactly as it reports a gateway that is down -- a
diagnosis the operator cannot tell apart or guess.

It cannot be made right, so it is made loud. The refusal now says the prefix
was taken from the state topic's first segment, that a multi-level prefix is
only recoverable from the gateway's discovery availability entry, and what to
enable; a debug line records the topic the guess came from. The reasoning and
the upstream citation sit at _split_state_topic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The idempotence guard reads _api_response_topic and the subscribe that sets
it awaits, so two callers arriving together both passed the guard and both
subscribed. Whichever finished second overwrote the other's unsub, and
nothing else held a reference to the loser -- so the broker kept delivering
into a handler nothing could ever release, for the rest of the run.

Reachable in ordinary operation: the deferred-setup retry task and an
in-flight poll both re-ensure. A dedicated lock, not the base's _aio_lock,
which is held for the whole of every rate-limited provider operation -- and
those resolve the api base, which ensures this subscription, so sharing it
would deadlock on the first read.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Four independent costs, none of which bought the payer anything.

The settling window after subscribing to the api response topic was slept
through at subscribe time, so setup and every borrowed instance -- a config
flow allocation read, the unmanaged sweep -- paid a full second for a
guarantee only a publish needs. It is now a recorded deadline that the first
publish waits out, crediting whatever time resolution already spent.

The api response handler parsed every message on the wildcard into a dict
before discovering it belonged to another client. The gateway's own user
interface polls getNodes on that topic, whose response can run to megabytes.
The request id only ever appears in the echo of one of our own requests, so a
substring scan of the undecoded payload settles it first; anything that gets
through is still parsed and correlated exactly as before.

A bridged provider derives push support from discovery data that arrives on
the broker's schedule, so a lock whose setup was deferred was built before
that answer existed and kept the poll cadence chosen for a lock with none --
polling a pushing lock forever, one api round trip per slot every five
minutes. The deferred-setup retry now re-reads the capability once, on
success, and the coordinator carries it directly rather than inferring it
from a nullable interval.

And the mqtt resolver tested both dispatch rules against each identifier in
turn, over a SET whose iteration order is arbitrary. A device carrying both
shapes resolved to whichever the hash yielded first, so the same device could
dispatch differently across restarts. Two passes over the whole set is what
makes the documented Zigbee2MQTT-first precedence hold over the device.

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

The block still described a flat test_zigbee2mqtt.py and per-provider files
named test_<provider>.py, neither of which has existed for a while, and had
never gained zha/, zigbee2mqtt/, or zwave_js_ui/. It also listed none of the
cross-provider modules that sit beside them.

Documented as the convention it actually is -- conftest, test_provider,
test_e2e per provider, with anything further split by subject -- so the
add-a-provider checklist has something to point at.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
No fix's semantics change; every mutation that was killed before is still
killed, each by a uniquely identified test.

R2 loses its consecutive-silent-poll counter. It existed to absorb a
#1397-class burst where a two-slot poll loses both replies at once, but the
coordinator's connectivity breaker already takes BACKOFF_FAILURE_THRESHOLD
(3) consecutive failures before it suspends anything, and nothing reacts to a
single raise before that. The counter was a second absorber in front of one
that already worked, and it made an absorbed poll look like a successful one,
resetting the breaker it was supposed to be feeding. The _reads_have_succeeded
latch -- the part that actually protects a bridge that can never answer a
read -- stays.

R1b's three helpers and the status-interpreting module function collapse into
the one method they were split out of. Tracking bool "was the last status
Enabled" instead of the status itself makes the unknown case a dict default
rather than a branch, and a value naming no status simply records nothing.

R3b's reappraise helper inlines into the one caller. The reviewer's simpler
alternative -- let the scan path invalidate the shared entry when a re-scan
finds the gateway gone -- cannot work: a re-scan only runs when the shared
entry is already None, so nothing would ever null it and a renamed gateway
would be unreachable for the process lifetime. The probe stays, at a third
the size. The home-id comparison stays too: a gateway answering at the same
client topic for a different network is exactly the case that would write
PINs into a neighbouring network.

R10a's settle helper inlines at its one call site. R9's lock and R10c's
transition keep their shape -- there is no smaller thing that protects -- but
lose the prose.

Tests: dropped the retained-order permutation grid (all four rows duplicate an
explicit test), the enabled-then-code test (with "unknown admits" as the
default it asserts what the no-status test already does; the ENABLED branch's
unique killer is the re-enable test), the disabled-code e2e (the gate never
consults the coordinator, so a real one adds nothing), the text half of the
payload-type grid, and both counter tests.

AGENTS.md trimmed to the correction the review asked for.

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

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 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.14%. Comparing base (b63ab79) to head (38039bf).
✅ All tests successful. No failed tests found.

Additional details and impacted files

Impacted file tree graph

@@           Coverage Diff           @@
##             main    #1471   +/-   ##
=======================================
  Coverage   99.13%   99.14%           
=======================================
  Files          64       64           
  Lines        8222     8268   +46     
  Branches      520      520           
=======================================
+ Hits         8151     8197   +46     
  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/coordinator.py 100.00% <100.00%> (ø)
..._components/lock_code_manager/domain/exceptions.py 100.00% <100.00%> (ø)
...ustom_components/lock_code_manager/domain/locks.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%> (ø)
...stom_components/lock_code_manager/providers/zha.py 100.00% <100.00%> (ø)
...ponents/lock_code_manager/providers/zwave_js_ui.py 100.00% <100.00%> (ø)
🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@raman325
raman325 merged commit 9b0e3c2 into main Aug 24, 2026
18 checks passed
@raman325
raman325 deleted the zwave-js-ui-review-fixes branch August 24, 2026 17:47
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