Skip to content

Fix replying to default response with default response - #1882

Draft
TheJulianJES wants to merge 1 commit into
zigpy:devfrom
TheJulianJES:tjj/no-default-rsp-to-default-rsp
Draft

TheJulianJES wants to merge 1 commit into
zigpy:devfrom
TheJulianJES:tjj/no-default-rsp-to-default-rsp

Conversation

@TheJulianJES

@TheJulianJES TheJulianJES commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Proposed change

handle_cluster_general_request() emitted a Default Response for any general command whose Disable Default Response bit was clear. An incoming Default Response (0x0B) is a general command, so a device that leaves the bit clear — which real devices do — got answered with a Default Response carrying command_id=11.

ZCL R8 §2.5.12.2 forbids this: criterion 1 is "A device receives a unicast command that is not a Default Response command", and the section closes with "the Default Response command SHALL not be generated in response to reception of another Default Response command". The exclusion is narrow — the same paragraph still requires a Default Response for other response commands such as Write Attributes Response — so only Default Response itself is excluded here.

Guard the generation site rather than send_default_rsp(), which is public, documented """Send default response unconditionally.""", and called directly by quirks. Changing that method's contract would be a surprise to downstream callers.

Why it matters beyond spec compliance

Each spurious unicast takes the per-device concurrency slot (Device concurrency (1) reached, delaying request follows immediately), and the APS ack for it can take seconds on a sleepy end device. During an OTA this inserts an extra frame between every image block response.

To be clear about scope: this is a spec-compliance and overhead fix, not a fix for the OTA failure in the logs below. That had a separate root cause (fast polling never engaging because the device never sent a PollControl check-in — territory of #1817 and #1849/#1850/#1851/#1852). This removes wasted traffic; it does not on its own make that upgrade succeed.

Evidence

Observed on frient/Develco devices with PollControl/Ota on endpoint 35, in two HA debug logs:

  • 89 spurious frames in one failing OTA, all to a frient A/S SCAZB-141 (0x2BF3, endpoint 35, Ota). The device sends raw 00 20 0b 05 01 = DefaultResponse(command_id=5, status=FAILURE) with disable_default_response=0, and zigpy answers DefaultResponse(command_id=11, status=SUCCESS).
  • 407 occurrences in a second log on a different device (0xAE74) — and there it also lands on PollControl, not just Ota: an incoming PollControl:DefaultResponse(command_id=0, status=TIMEOUT) is answered with DefaultResponse(command_id=11, SUCCESS). So this is not OTA-specific.

Why only the general-command path is guarded

handle_cluster_request() deliberately gets no equivalent guard. FrameControl.is_cluster is purely frame_type == CLUSTER_COMMAND, and a frame only decodes as a Default Response under GLOBAL_COMMAND, so a Default Response never reaches the cluster path. Adding a command_id != 0x0B check there would instead suppress the legitimate Default Response owed to real cluster commands that happen to use ID 0x0b.

Relationship to #1870

#1870 (draft) addresses the same family of bug, but does not cover this case: its maybe_send_default_rsp() is called only from handle_cluster_request, the handle_cluster_general_request tail there is byte-identical to dev, and its suppression keys off registered command owners, which do not exist for a Default Response. Checked against head e90e4c5. The two changes do not conflict as they stand.

Forward note for whoever rebases #1870: if maybe_send_default_rsp() is later extended to cover the general-command path too, this guard should move into that method rather than leaving two divergent conditions in the two paths.

Verified

  • The narrow exclusion did not widen, and existing tests already pin that. test_handle_cluster_general_request_disable_default_rsp still asserts general_command.call_count == 1 for a Report_Attributes with the bit cleared, and test_handle_cluster_general_request_not_attr_report asserts exact mock_calls equality including Default_Response for a Write_Attributes. Both are untouched by this PR and both pass — a broader guard would have failed them.
  • New test fails before the fix (assert 1 == 0) and passes after. It uses the exact wire frame from the log (00 20 0b 05 01), mirroring the existing test_handle_cluster_general_request_disable_default_rsp shape.
  • zigpy — 1316 passed; ruff check / ruff format clean.
  • zha — 1374 passed, with this branch editable-installed over the pinned zigpy.
  • zha-device-handlers — 4820 passed, 2 xfailed, same setup.
  • Quirks that override handle_cluster_general_request — all six checked. develco/emi_han, sinope/light and bosch/rbsh_trv0_zb_eu delegate to super() for non-Report_Attributes commands and so inherit the fix; mli/tint, xiaomi and konke return early and never reach the tail. None relies on the previous behavior.

Out of scope

§2.5.12.2's other rule — no Default Response for a command received in error via broadcast or multicast — is not addressed here. Worth a follow-up.

`handle_cluster_general_request()` emitted a Default Response for any
general command whose Disable Default Response bit was clear. An incoming
Default Response (0x0B) is a general command, so a device that leaves the
bit clear — which real devices do — got answered with a Default Response
carrying `command_id=11`.

ZCL R8 2.5.12.2 forbids this: criterion 1 is "A device receives a unicast
command that is not a Default Response command", and the section closes
with "the Default Response command SHALL not be generated in response to
reception of another Default Response command". The exclusion is narrow —
the same paragraph still requires a Default Response for other response
commands such as Write Attributes Response — so only Default Response
itself is excluded here.

Beyond spec compliance, each spurious unicast takes the per-device
concurrency slot. On a sleepy end device mid-OTA this inserted an extra
frame between every image block response, and the APS ack for it could
take seconds.

Guard the generation site rather than `send_default_rsp()`, which is
public, documented as sending unconditionally, and called directly by
quirks.
@codecov

codecov Bot commented Aug 31, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.50%. Comparing base (083d14f) to head (5c8bf54).

Additional details and impacted files
@@           Coverage Diff           @@
##              dev    #1882   +/-   ##
=======================================
  Coverage   99.50%   99.50%           
=======================================
  Files          59       59           
  Lines       12413    12413           
=======================================
  Hits        12351    12351           
  Misses         62       62           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@zigpy-review-bot zigpy-review-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at 5c8bf54. Correct, correctly scoped, and well evidenced — the spec citation checks out verbatim, every load-bearing claim in the description reproduced, and all three suites are green. No blockers and nothing to address.

The spec text is unambiguous and the guard matches it exactly. ZCL R8 §2.5.12.2 closes with "The Default Response command SHALL be generated in response to reception of all commands, including response commands (such as the Write Attributes Response command), under the conditions specified above. However, the Default Response command SHALL not be generated in response to reception of another Default Response command." — so keying only on Default_Response is the whole exclusion, and widening it to other response commands would be a spec violation in the other direction. Guarding the generation site rather than send_default_rsp() is the right call for the reason given: that method's docstring promises unconditional behavior and quirks call it directly.

One mechanism detail worth recording — it confirms the description rather than contradicting it, and explains the frame counts.

Only unmatched Default Responses ever reach this code. Device.packet_received calls _maybe_match_response() first, and on a hit it returns before zcl_cluster.handle_message(); the ResponseKey is (endpoint, cluster, direction, TSN), so a Default Response that still has a live pending future for its TSN is consumed there and never reaches the tail. What falls through is the leftovers — a request already completed or timed out, one sent with expect_reply=False, or a duplicate. That is why the logs show 89 and 407 spurious frames rather than one per command sent, and it means the fix cannot regress request/response matching: it only touches frames that had already fallen out of that path.

Verified (9 checks)
  • Spec quote — pulled §2.5.12.2 from ZCL R8 directly. Criterion 1 ("A device receives a unicast command that is not a Default Response command") and the closing sentence are both quoted accurately, including the part that requires a Default Response for other response commands.
  • New test genuinely pins the fix — restored dev's zigpy/zcl/__init__.py under the PR's test file: fails assert 1 == 0. With the fix: passes.
  • The wire frame decodes as described00 20 0b 05 01Basic:DefaultResponse(command_id=5, status=Status.FAILURE), TSN 32, frame_type=GLOBAL_COMMAND, disable_default_response=0. Matches the log evidence exactly.
  • The exclusion did not widentest_handle_cluster_general_request_disable_default_rsp and test_handle_cluster_general_request_not_attr_report both still pass untouched, so Report_Attributes and Write_Attributes still get their Default Response.
  • zigpy — 1316 passed.
  • zha — 1374 passed, this branch editable-installed over the pinned zigpy.
  • zha-device-handlers — 4841 passed, 2 xfailed, same setup. (Count differs from the 4820 in the description only because quirks dev has gained tests since; green either way.)
  • All six quirks overriding handle_cluster_general_request — re-derived independently and the description's classification holds. develco/emi_han, sinope/light and bosch/rbsh_trv0_zb_eu delegate to super() and inherit the fix; konke (returns unless Report_Attributes), mli/tint (returns unless Write_Attributes) and xiaomi (a no-op body) never reached the tail to begin with. None depended on the old behavior.
  • #1870 does not conflict and does not cover this — checked at head e90e4c5, still the current head. maybe_send_default_rsp() has exactly one caller, inside handle_cluster_request, and its suppression keys off _command_owners_for(command), which is empty for a general command. Its handle_cluster_general_request tail is the unmodified if not hdr.frame_control.disable_default_response: — identical to dev.

Two notes, neither an ask:

The forward note about folding this into maybe_send_default_rsp() if #1870 later extends it to the general-command path is the right instinct — two divergent conditions in the two paths is exactly the shape that rots. Worth carrying into whichever PR merges second.

I also checked whether zigpy's own outgoing Default Response could invite a reply and start a ping-pong: it cannot. general_command() defaults disable_default_response to True, and a probe on a server cluster confirms the emitted frame carries DDR=1. So there was never a loop risk here, only the wasted unicast the description describes.

A second opinion from GitHub Copilot (GPT-5.6 Sol, read-only) returned no findings.

Comment thread zigpy/zcl/__init__.py
# ZCL R8 2.5.12.2: a Default Response is never sent in response to another
# Default Response
if (
hdr.command_id != foundation.GeneralCommand.Default_Response

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth knowing for anyone reading this guard later: only unmatched Default Responses ever get here.

Device.packet_received runs _maybe_match_response() before zcl_cluster.handle_message() and returns early on a hit, and the ResponseKey is (endpoint, cluster, direction, TSN) — so a Default Response that still has a live pending future for its TSN is consumed upstream and never reaches this line. What reaches it is the residue: a request already completed or timed out, one sent with expect_reply=False, or a duplicate.

That is a useful property for this guard — it means the change cannot affect request/response matching, only frames that had already fallen out of that path.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@zigpy-review-bot If it's a bug, create an issue: It seems like the OTA process doesn't react on "failure" default responses, so it eventually just dies with the stale timer. Should this be addressed (to fail earlier)?

@zigpy-review-bot zigpy-review-bot Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it's a real bug — verified against dev at 083d14f. But I'd argue against failing on the first failure Default Response; details below.

Why zigpy never reacts. OTAManager.__enter__ registers callback_for_response listeners for exactly four Ota server (cluster) commands — query_next_image, image_block, image_page, upgrade_end (zigpy/ota/manager.py:79-120). A Default Response is a general command, so it matches none of those schema filters. And there's no pending future for it either: image_block_response is an Ota client command, so it goes out via client_command()reply(), which defaults expect_reply=False and disable_default_response=True (zigpy/zcl/__init__.py:877, :1760). So it falls through _maybe_match_response into handle_cluster_general_request and is dropped — the same path this PR is fixing the reply side of. The transfer then ends only via _stall_callback_finish(Status.TIMEOUT) (zigpy/ota/manager.py:125-127), and the status the device actually reported is discarded. Same gap for query_next_image_response and upgrade_end_response.

Reproduced — fake device answers our Image Block Response with DefaultResponse(0x05, FAILURE), then goes silent
  -> we sent image_block_response tsn=3, disable_default_response=1
raw default response frame: 00 03 0b 05 01

RESULT: status=<Status.TIMEOUT: 148> after 0.32s (stall timer was patched to 0.3s), blocks sent=1

Note disable_default_response=1 on our side: zigpy asks the device not to respond, and it responds anyway — which §11.13.8.5 explicitly permits for error cases. The probe test is parked alongside the issue draft.

On failing earlier. ZCL R8 §11.13.8.5.1 makes a client-side Default Response a per-block rejection, not an abort: on a badly formatted Image Block Response "the client SHOULD ignore the command and SHALL send default response command with MALFORMED_COMMAND status to the server", and the client is then free to re-request the same offset. Nothing in §11.13.8.5 ends the transfer. Your own log agrees — 89 of these frames inside a single transfer that kept progressing in between (the 30 s stall timer didn't fire until the end), which is exactly reject-and-re-request. Aborting on the first one would kill transfers that would otherwise recover.

So what's actually worth doing is narrower: (1) stop discarding the status — log the rejection with the file offset, since today a failed OTA is a bare TIMEOUT with no hint the device was actively rejecting blocks; (2) fail on a stuck signature rather than a single rejection — N consecutive rejections with no forward progress, or a rejection never followed by another Image Block Request — which gives you the "fail earlier" behaviour with the device's own status; (3) treat MALFORMED_COMMAND separately, since §11.13.8.5.1's causes all accuse our own Image Block Response (data size vs. the client's maximum_data_size, offset past the image size, missing fields) rather than being something to retry. The frient device sends generic FAILURE, so that last rule wouldn't have caught it.

One implementation caveat: there's no clean seam for it today. callback_for_response listeners are device-scoped, and Device.packet_received hands listeners only (hdr, command) with no cluster context — so a matcher can't distinguish an Ota Default Response from a PollControl one, and command ids collide across clusters (your second log has PollControl:DefaultResponse(command_id=0, status=TIMEOUT) on the same device). Either the Ota cluster's general-command path feeds OTAManager directly, or the listener API gains cluster context.

Filed as #1883 with the trace, the probe test and the spec argument above.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants