Support zigpy 2.0.0 - #179
Conversation
zigpy 2.0.0 moved the quirks API out of zigpy into zha-device-handlers, so importing CustomDevice/CustomCluster from zigpy.quirks now fails with ModuleNotFoundError (zhaquirks is not, and must not be, a dependency). - Reimplement XBeeGroup/XBeeGroupResponse as plain Groups subclasses kept out of the global cluster registry via _skip_registry. - Reimplement XBeeCoordinator as a zigpy.device.Device that builds its coordinator endpoint and clusters directly instead of via a quirks CustomDevice 'replacement' dict. - Rewrite ControllerApplication.add_endpoint to register endpoints and clusters directly rather than mutating the quirks replacement dict. - Replace the deprecated Device.update_last_seen() call with a direct last_seen assignment.
zigpy 2.0.0 switched its serial layer from pyserial-asyncio-fast to serialx, whose Serial class does not expose a BAUDRATES attribute. The baudrate setter (used when entering AT command mode) relied on self._transport.serial.BAUDRATES and would raise AttributeError. - Define the standard baudrate list on Gateway and validate against it. - Drop the stale 'import serial_asyncio_fast' from the uart tests and patch zigpy.serial.create_serial_connection (what uart.connect actually calls) instead.
- Require zigpy>=2.0.0 (where the quirks API was moved out) instead of the long-outdated 0.70.0 floor. - Require Python >=3.11 to match zigpy 2.0.0, and update the tox env list (py311-py313) and pyupgrade target (--py311-plus) accordingly. - Drop the unused, unmaintained asynctest test dependency; the tests use unittest.mock.
The --py311-plus pyupgrade target rewrites the deprecated asyncio.TimeoutError alias to the builtin TimeoutError and replaces typing.Dict/Optional with builtin generics and PEP 604 unions; autoflake drops the now-unused imports.
CI runs via the shared zigpy/workflows workflow, not tox; the file is no longer referenced.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## dev #179 +/- ##
=========================================
Coverage 100.00% 100.00%
=========================================
Files 7 7
Lines 718 733 +15
=========================================
+ Hits 718 733 +15 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
| {"status": foundation.Status}, | ||
| direction=foundation.Direction.Client_to_Server, | ||
| ), | ||
| } |
There was a problem hiding this comment.
I'm not actually seeing where this is used. #46 introduced the change.
There was a problem hiding this comment.
@zigpy-review-bot Also post inline reply here for what/if this is used, or not.
There was a problem hiding this comment.
Answering the "I'm not actually seeing where this is used" — checked against current zigpy (2.1.0) by probing it, not just by reading. Short version: the request half is still on a live path, the response half is unreachable, so the feature as a whole cannot complete today.
XBeeGroup (0x0006) is live on the transmit side. It inherits ep_attribute = "groups", so Endpoint.add_to_group() resolves self.groups to it (zigpy/endpoint.py:139) and Device.add_to_group() walks every non-ZDO endpoint. ZHA's one coordinator-side caller is LightLinkGroupJoin.async_configure_cluster (zha/application/platforms/virtual.py:223-225), which adds the coordinator to a LightLink device's groups, or to 0x0000 as a fallback. Driving that against a freshly built XBeeCoordinator, the frame really does go out the way the Digi Group Table API expects — profile 0xC105, cluster 0x0006, endpoint 0xE6.
XBeeGroupResponse (0x8006), and its remove_all_response, is dead code on current zigpy. Two independent reasons, both in zigpy and both long after #46:
- Since zigpy 0.82.0 (zigpy#1628, 2025-07-30)
Device.packet_received()only parses ZCL for the ZHA and ZLL profiles; everything else goes tocustom_profile_packet_received(), which logs and drops it. Endpoint0xE6is profile0xC105, so every reply on it is discarded before any cluster is consulted — whether the module answers on0x8006or on0x0006. - Since zigpy 0.81.0 (zigpy#1622, 2025-07-29) replies are matched on
(endpoint, cluster, direction, tsn), with the^ 0x8000flip applied only to ZDO endpoints — so a0x8006reply cannot match a0x0006request regardless. On 0.81.0 the frame still reachedXBeeGroupResponseand parsed correctly, but the pending request stayed unresolved. It last worked end to end on ≤ 0.80.x, where pending requests were keyed by TSN alone — which is what XBee Group subscription. #46 was written against.
Net effect today: coordinator.add_to_group(...) still transmits, and the module presumably does register the group, but the caller waits out the APS reply timeout and then raises.
None of this is caused by this PR, and nothing here blocks it — the two clusters carry over unchanged and cost nothing to keep. It does mean neither XBeeGroupResponse nor the remove_all_response definition is exercised by anything at the moment, on hardware or in tests. If XBee group support is worth reviving, zigpy 2.1.0's new _subscribe_to_multicast_group() / _unsubscribe_from_multicast_group() hooks (zigpy#1864) are the natural seam, and it would additionally need zigpy to stop dropping Digi-profile replies.
What I ran
Coordinator built from this branch, zigpy 2.1.0, no zhaquirks installed.
Transmit side — ep.add_to_group(0x1234, "grp") with the radio stubbed out:
TX: profile 0xc105 cluster 0x6 src_ep 0xe6 tsn 1 data 010100341203677270
pending request keys: [ResponseKey(endpoint_id=230, cluster_id=6, direction=Direction.Server_to_Client, tsn=1)]
(010100341203677270 = cluster-specific Groups.add(group_id=0x1234, group_name='grp').)
Receive side — feeding a synthetic add_response back in, three ways:
reply profile 0xc105 / cluster 0x8006 -> "Received packet with custom profile 0xc105, ignoring"; add_to_group unresolved
reply profile 0xc105 / cluster 0x0006 -> same, unresolved (so the response cluster ID is not what breaks it)
reply profile 0x0104 / cluster 0x0006 -> resolved, Status.SUCCESS (what zigpy expects today)
The same probe on zigpy 0.81.0 (the release between the two changes above): the frame is deserialized by XBeeGroupResponse — XBeeGroupResponse:add_response(status=Status.SUCCESS, group_id=0x1234) reaches its handle_message — while the pending 0x0006 request still goes unresolved.
zigpy-review-bot
left a comment
There was a problem hiding this comment.
Reviewed at 1a14981. No blockers — the migration is faithful, and I verified the load-bearing parts empirically rather than by reading.
What I ran
Fresh worktree + venv, uv pip install -e ".[testing]" -r requirements_test.txt, without zha-quirks installed (the whole point of the PR):
pytest: 105 passed against zigpy 2.1.0, and 105 passed against 2.0.0 — the declared floor inpyproject.tomlactually holds.pre-commit run --all-files: pyupgrade / autoflake / black / flake8 / isort / codespell / mypy / ruff all pass.- Premise confirmed: on the base branch,
zigpy.quirks.CustomDeviceraisesModuleNotFoundError: No module named 'zhaquirks'— sozigpy_xbee.zigbee.applicationcurrently fails at class-definition time on a standalone zigpy 2.x install. This PR is a genuine fix, not just a tidy-up. - Constructed an
XBeeCoordinatordirectly and diffed the resulting object against what the oldCustomDevicepath produced:status,manufacturer="Digi",model="XBee",node_desc,skip_configuration=False,original_signature=None, endpoint0xE6(status=ZDO_INIT,profile_id=0xC105,device_type=0x0050),in_clusters={0x0006: XBeeGroup, 0x8006: XBeeGroupResponse},ep.groups/ep.xbee_groups_responseboth resolving — identical, and noDeprecationWarningwith-W error::DeprecationWarning. - Checked the attributes
zhaquirks'BaseCustomDevice.__init__copies that the new__init__does not (lqi,rssi,last_seen,relays,original_signature): all of them are still at their defaults on the freshly-builtreplacesdevice at the call site, so nothing is actually lost. apply_custom_configurationis gone from the coordinator now that it isn't aCustomDevice, but ZHA's only caller ishasattr-guarded (zha/zigbee/device.py:1001), so a plainDeviceis fine there.
The add_endpoint rewrite fixes more than it looks like
Worth calling out explicitly, because it's not in the PR description: the old add_endpoint was already silently broken on zigpy 2.x, and this PR repairs it.
Current zhaquirks gained a replace_device is None early-return in BaseCustomDevice.add_endpoint, so self._device.add_endpoint(descriptor.endpoint) (called without replace_device) fell through to plain Device.add_endpoint and the freshly-mutated replacement["endpoints"] entry was ignored. I reproduced the old construct against current zigpy + zha-quirks and got a bare endpoint back for zigpy's endpoint 1:
OLD PATH -> ep1.profile_id: None device_type: None status: 0
OLD PATH -> ep1.in_clusters: {}
OLD PATH -> ep1.out_clusters: {}
versus this PR:
NEW PATH -> ep1.profile_id: 0x104 device_type: 0x400 status: 1
NEW PATH -> ep1.in: {0x0: Basic, 0x6: OnOff, 0xa: Time, 0x19: Ota, 0x25: KeepAlive, 0x501: IasAce}
NEW PATH -> ep1.out: {0x1: PowerConfiguration, 0x20: PollControl, 0x500: IasZone, 0x502: IasWd}
So on current zigpy the coordinator's endpoints 1 and 2 had no Basic / Ota / IasAce / … clusters at all. Might be worth a line in the changelog or PR body — it's a user-visible fix.
Side note that falls out of the same probe: 0x6 on endpoint 1 correctly resolves to OnOff, not XBeeGroup — i.e. _skip_registry = True is doing its job. That matters more than usual here, since XBeeGroup overrides cluster_id to 0x0006, which is OnOff's real ID; without it the global registry entry for OnOff would be clobbered process-wide. I checked zigpy.zcl.Cluster._registry after importing zigpy_xbee and it's clean (_registry[0x0006] is OnOff, 0x8006 absent).
Re: where XBeeGroupResponse is used (puddly's open thread)
Answering rather than re-raising, since the thread is still open and the code is carried over unchanged.
Both clusters are used, but only implicitly — which is why grepping for them finds nothing:
XBeeGroup(0x0006on endpoint0xE6) inheritsep_attribute = "groups", andzigpy.endpoint.Endpoint.add_to_group/remove_from_groupdispatch through exactly that attribute (res = await self.groups.add(grp_id, name)).Device.add_to_groupiterates every non-ZDO endpoint, so subscribing the XBee coordinator to a group sends the ZCLGroupscommand to cluster0x0006on endpoint0xE6— the Digi XBee Group Table API that #46 was implementing.XBeeGroupResponse(0x8006) is purely a receive-side handler: XBee replies come back on0x8000 | cluster, and zigpy dispatches an incoming frame toep.in_clusters[0x8006], which is this class. It is never called by name, only matched against. Itsremove_all_responseaddition exists because plain ZCLGroupshas no response forremove_all— the Digi profile adds one, so without it the reply would fail to parse.
So removing either would break XBee group subscription, but only at runtime on real hardware — nothing in the test suite exercises it.
Suggestions (all optional)
- Test the migrated structure, not just its line coverage. Coverage is 100%, but no test asserts the shape of what's built —
XBeeCoordinatorisn't referenced intests/at all. That's precisely the gap that let the oldadd_endpointdegenerate to a bare endpoint without CI noticing. Two small assertions would close it for good: one thatapp._device.endpoints[0xE6]hasprofile_id == 0xC105,device_type == 0x0050and both group clusters, and one that afterregister_endpoints()endpoint 1 has its profile/device_type and the expected in/out clusters. - The
devbuilt atzigpy_xbee/zigbee/application.py:99-104is now vestigial —dev.add_endpoint(XBEE_ENDPOINT_ID)has no effect (the newXBeeCoordinatorbuilds its own endpoint and never readsreplaces' endpoints), andreplacesis consumed only for.status, which is set to a constant two lines earlier. See the inline note. - Minor:
datetime.now(timezone.utc)can bedatetime.now(UTC)on 3.11+, which is what zigpy's own (now-deprecated)update_last_seenuses.
Given the PR says it wasn't tested on hardware, the two paths I'd smoke-test on a real XBee are init_api_mode (the baudrate-sweep path, the only caller of the changed BAUDRATES) and group subscription (XBeeGroup / XBeeGroupResponse) — everything else is covered by the suite.
Second opinion from GitHub Copilot (GPT-5.6 Sol, read-only) returned no findings.
| "output_clusters": descriptor.output_clusters, | ||
| } | ||
| self._device.add_endpoint(descriptor.endpoint) | ||
| ep = self._device.add_endpoint(descriptor.endpoint) |
There was a problem hiding this comment.
This rewrite is correct, and it also repairs behaviour that was already broken on zigpy 2.x — see the review body for the before/after probe.
Short version: current zhaquirks' BaseCustomDevice.add_endpoint early-returns to the plain Device.add_endpoint when replace_device is None, which is how the old code called it. The replacement["endpoints"][...] entry set on the line above was therefore ignored, and endpoints 1 and 2 came out with profile_id=None, device_type=None and zero clusters. Building the endpoint explicitly like this is both the right post-quirks API and a fix.
|
@zigpy-review-bot Make the changes, do review rounds before pushing. Also check if anything else should be changed (e.g., require even newer zigpy version? Everything still fine with latest zigpy/ZHA?). |
Matches the spelling of zigpy's own (deprecated) `update_last_seen()` and drops the `timezone` import; `datetime.UTC` is available on Python 3.11+.
`XBeeCoordinator` builds its own `0xE6` endpoint and only read `replaces` for `.status`, which the call site assigned two lines earlier, so the plain `Device` it was handed — and the `add_endpoint()` call on it — had no effect.
Coverage was already 100%, but nothing asserted the shape of what `start_network()` builds, which is what let the old quirks-based `add_endpoint()` silently degenerate to bare endpoints.
|
@zigpy-review-bot You can push to this PR/repo now. Check my above comment and pending/parked changes. Push when ready, or do review rounds. |
|
@zigpy-review-bot Oops, didn't realize this was opened from my fork. Accept the invite and push / see instructions above. |
|
Pushed to Made the three changes agreed in the threads above, and ran review rounds before pushing — round 2 came back clean from both reviewers. What changed (
Left alone on purpose: the hardcoded On "anything else / require a newer zigpy?" —
Still not tested on hardware — the two paths I'd smoke-test remain Review roundsTwo rounds, each an adversarial Claude reviewer (Fable 5) reviewing the delta in a worktree plus GitHub Copilot (GPT-5.6 Sol), both read-only and independent. Round 1 — Copilot: no findings. Fable: verdict "ready", with two test-quality notes, both folded in: a comment claiming Round 2 (the amended tests) — Copilot: no findings. Fable: "ready", with a mutation matrix confirming the relaxed assertions still kill every regression the exact ones did. |
zigpy-review-bot
left a comment
There was a problem hiding this comment.
Fresh re-review at 7187a17. Rebuilt the branch in a clean worktree with no zhaquirks installed and verified the migration by running it rather than by reading: 107 tests pass on zigpy 2.1.0 / Python 3.13 and on zigpy 2.0.0 / Python 3.11. An independent second opinion (Copilot, GPT-5.6 Sol) came back with no findings.
The three changes agreed in the earlier threads all landed as discussed, and the migration itself looks right to me. One non-blocking ordering suggestion inline; everything else below is verification, not objection.
What I checked empirically
- The global cluster registry stays clean, which is the main risk of a
_skip_registrycluster overridingcluster_id:Cluster._registry[0x0006]is stillOnOff,0x8006is absent entirely, and0x0004is stillGroups. add_endpoint()really does produce fully-populated endpoints now — ep 1 → profile0x0104, in-clusters{0x0000, 0x0006, 0x000A, 0x0019, 0x0025, 0x0501}; ep 2 → profile0xC05E, in-clusters{0x0000}; ep0xE6→ profile0xC105, in-clusters{0x0006, 0x8006}.XBeeGroupResponse.remove_all_responsesurvives the move out of the quirks API: zigpy 2.x rebuildsClientCommandDefsfrom the legacy dict-formclient_commands, socommands_by_namestill resolvesremove_all_responseandclient_commandsstill has ids 0–4. (Whether anything can reach it is the separate matter in puddly's thread, unchanged by this PR.)- The serial side works with the new backend, not just the validation list: serialx's
SerialTransport.serialproperty exists andBaseSerial.baudratestill has a setter that calls_configure_port(), soinit_api_mode()'s baud sweep (api.py:547) can still retune an open port. serialx's docstring labels that setter "(deprecated)" but it emits no warning, so nothing here trips-W error::DeprecationWarning. Worth remembering if serialx ever removes it, since that sweep is the only caller that needs runtime retuning. - On "should this require an even newer zigpy?" — no.
zigpy>=2.0.0is the correct floor: the whole suite is green against exactly 2.0.0, and nothing on the branch uses an API introduced after it.requires-python >= 3.11matches zigpy's own floor and is required bydatetime.UTC. - All nine
BAUDRATE_TO_BDkeys (1200 … 230400) are present in the newGateway.BAUDRATEStuple, so the sweep's candidate set is unchanged.
Still unverified: hardware. The two paths worth a smoke test remain init_api_mode()'s baudrate sweep and coordinator group subscription.
| self, self.state.node_info.ieee, self.state.node_info.nwk | ||
| ) | ||
| xbee_dev.status = zigpy.device.Status.ENDPOINTS_INIT | ||
| self.listener_event("raw_device_initialized", xbee_dev) |
There was a problem hiding this comment.
Not a regression from this PR, but this PR is what makes it matter: the event fires three lines above register_endpoints(), so every listener sees a coordinator that still only has endpoint 0xE6.
Two concrete consequences:
zigpy.appdb.PersistingListener.raw_device_initialized()takesdevice.clone()synchronously inside the event, and_raw_device_initialized_internal()is the only code path that ever writesendpoints/clustersrows (PersistingListener.device_initialized()is apass). So endpoints 1 and 2 — which this PR gives real profiles, device types and clusters for the first time — never reach the database.- ZHA's handler (
zha/application/gateway.py:416) putsdevice.get_signature()into the event it emits, so that signature is missing endpoints 1 and 2 as well.
Probed against a real sqlite DB driving the exact start_network() sequence. In memory the device ends up with endpoints [0, 1, 2, 230], but the DB gets only:
endpoints_v15: [(ieee, 230, 49413, 80, 1)]
clusters_v15: [(ieee, 230, 0, 6), (ieee, 230, 0, 32774)]
Moving the event below register_endpoints() fixes both:
xbee_dev.status = zigpy.device.Status.ENDPOINTS_INIT
self.devices[xbee_dev.ieee] = xbee_dev
await self.register_endpoints()
self.listener_event("raw_device_initialized", xbee_dev)The self.devices[...] insertion has to stay above register_endpoints(), since add_endpoint() resolves self._device out of self.devices. With that reorder the same probe records all three endpoints and their 13 clusters, and the suite still passes 107/107.
Low impact in practice: start_network() rebuilds the coordinator device on every startup, so the incomplete DB rows self-heal and nothing user-visible reads them today. Entirely reasonable to leave for a follow-up rather than grow this PR.
There was a problem hiding this comment.
@zigpy-review-bot Create issue about this or directly create a PR, possibly stacked on top of this for now, later rebased after this one is merged.
There was a problem hiding this comment.
Opened #180 with the reorder, stacked on this PR's head (7187a175) — so its diff currently includes this PR's commits, and only 1711d8f belongs to it. I'll rebase it onto dev once this one merges; re-mention me when that happens.
It carries a regression test that asserts the listener sees all endpoints at event time (it fails on the current ordering), and I re-ran the sqlite probe at this head: endpoints_v15 goes from [(230, 49413)] / 2 clusters to [(1, 260), (2, 49246), (230, 49413)] / 13 clusters. Full suite 108 passed, pre-commit clean.
Nothing here needs a change in this PR.

This PR updates zigpy-xbee to be more compatible with zigpy 2.0.0, without relying on the presence of zha-quirks.
As zha-quirks now depends on ZHA, which pulls in zigpy-xbee, I noticed some test failures of the deprecated
CustomClusterusage in all quirks tests. These are addressed by this.Do note this PR was not tested.
AI summary
Why
zigpy.quirksis now only a deprecation shim that lazily re-importsCustomDevice/CustomClusterfromzhaquirks.legacy. This gives zigpy-xbee an implicit, undeclared dependency onzhaquirks: under Home Assistant it kept working (ZHA installszha-quirks, so the shim resolves — onlyDeprecationWarnings were emitted), but a standalone install fails at import withModuleNotFoundError: No module named 'zhaquirks'. zigpy-xbee should not depend on zha-device-handlers at all.pyserial-asyncio-fasttoserialx, whoseSerialclass has noBAUDRATESattribute. This breaks the baudrate setter on the path that switches baud to auto-enter API mode (init_api_mode); a coordinator already in API mode does not hit it.Changes
XBeeGroup/XBeeGroupResponseas plainGroupssubclasses kept out of the global cluster registry (_skip_registry), andXBeeCoordinatoras azigpy.device.Devicethat builds its endpoint and clusters directly instead of via a quirksCustomDevice.add_endpointregisters endpoints directly rather than mutating a quirksreplacementdict.Device.update_last_seen()call.Gatewayso validation no longer depends on the serial backend.zigpy>=2.0.0(was0.70.0),requires-python>=3.11(was3.8); bump the pyupgrade target to--py311-plus; drop the unused, unmaintainedasynctesttest dependency.tox.ini, which is no longer used (CI runs via the sharedzigpy/workflowsworkflow).asyncio.TimeoutErroralias to the builtinTimeoutError, and replacetyping.Dict/Optionalwith builtin generics and PEP 604 unions (unused imports dropped).Testing
pytest: 105 passed, 100% coverage, withDeprecationWarningtreated as errors.pre-commit(black, flake8, isort, ruff, mypy, pyupgrade, autoflake, codespell) all pass.