From 62b5932a27b18fabf578fe0c36bec1053bbe9747 Mon Sep 17 00:00:00 2001 From: Fabian <6667968+fabcoded@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:37:22 +0200 Subject: [PATCH 1/2] Fix `UTIL.AssocAdd` parameter order to match the firmware zigpy-znp serialized `UTIL.AssocAdd` as NWK, IEEE, NodeRelation. Koenkk's `MT_UtilAssocAdd`, the firmware side of this command since build 20201026, reads the IEEE address first: `AssocAddNew(BUILD_UINT16(pBuf[8], pBuf[9]), pBuf, pBuf[10])`. zigbee-herdsman sends the same order (ieeeadr, nwkaddr, noderelation). With the old order the firmware took the NWK address plus the low six IEEE bytes as the IEEE address and the top two IEEE bytes as the NWK address, so the restore step of the `send_packet` recovery created a phantom child instead of re-adding the device it had just removed. Send IEEE, NWK, NodeRelation. Callers use keyword arguments and are unaffected. --- tests/test_commands.py | 65 ++++++++++++++++++++++++++++++++++++++ zigpy_znp/commands/util.py | 5 +-- 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/tests/test_commands.py b/tests/test_commands.py index d3376713..30b0e174 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -559,3 +559,68 @@ def test_neighbors_missing_payload(): Src=0x821F, Status=t.ZDOStatus.NOT_SUPPORTED, ) + + +def parse_assoc_add_like_firmware(data: bytes) -> tuple[t.EUI64, t.NWK, int]: + """Read a `UTIL.AssocAdd` payload the way Koenkk's `MT_UtilAssocAdd` does.""" + ieee, rest = t.EUI64.deserialize(data[0:8]) + assert not rest + nwk, rest = t.NWK.deserialize(data[8:10]) + assert not rest + + return ieee, nwk, data[10] + + +def test_util_assoc_add_field_order(): + ieee = t.EUI64.convert("68:0a:e2:ff:fe:32:db:95") + nwk = t.NWK(0x725E) + + req = c.UTIL.AssocAdd.Req( + NWK=nwk, IEEE=ieee, NodeRelation=c.util.NodeRelation.CHILD_RFD + ) + + assert req.to_frame().data == ieee.serialize() + nwk.serialize() + b"\x01" + assert parse_assoc_add_like_firmware(req.to_frame().data) == (ieee, nwk, 1) + + +@pytest.mark.parametrize( + ("ieee", "nwk", "phantom_ieee", "phantom_nwk"), + [ + # Test network, 2026-10-21: `UTIL.AssocFindDevice` afterwards found this entry + pytest.param( + "68:0a:e2:ff:fe:32:db:95", + 0x725E, + "e2:ff:fe:32:db:95:72:5e", + 0x680A, + id="test_network", + ), + # Home network, 2026-10-20: this IEEE then showed up in the zigpy + # network backup under `nwk_addresses` + pytest.param( + "84:2e:14:ff:fe:70:1b:9b", + 0x434B, + "14:ff:fe:70:1b:9b:43:4b", + 0x842E, + id="home_network", + ), + ], +) +def test_util_assoc_add_old_field_order(ieee, nwk, phantom_ieee, phantom_nwk): + ieee = t.EUI64.convert(ieee) + nwk = t.NWK(nwk) + + # The NWK address used to be sent first. The firmware read it as the first two + # bytes of the IEEE address and took the IEEE address's top two bytes as the + # NWK address, so the entry it added belonged to no real device. + old_payload = nwk.serialize() + ieee.serialize() + b"\x01" + + assert parse_assoc_add_like_firmware(old_payload) == ( + t.EUI64.convert(phantom_ieee), + t.NWK(phantom_nwk), + 1, + ) + + req = c.UTIL.AssocAdd.Req( + NWK=nwk, IEEE=ieee, NodeRelation=c.util.NodeRelation.CHILD_RFD + ) + assert parse_assoc_add_like_firmware(req.to_frame().data) == (ieee, nwk, 1) diff --git a/zigpy_znp/commands/util.py b/zigpy_znp/commands/util.py index 8f5f0a05..40e67305 100644 --- a/zigpy_znp/commands/util.py +++ b/zigpy_znp/commands/util.py @@ -477,13 +477,14 @@ class UTIL(t.CommandsBase, subsystem=t.Subsystem.UTIL): rsp_schema=t.STATUS_SCHEMA, ) - # Z2M firmware: proxy call to AssocAddNew + # Z2M firmware: proxy call to AssocAddNew. The IEEE address comes first: the + # firmware calls `AssocAddNew(BUILD_UINT16(pBuf[8], pBuf[9]), pBuf, pBuf[10])` AssocAdd = t.CommandDef( t.CommandType.SREQ, 0x64, req_schema=( - t.Param("NWK", t.NWK, "Short address of the device"), t.Param("IEEE", t.EUI64, "Extended address of the device to add"), + t.Param("NWK", t.NWK, "Short address of the device"), t.Param( "NodeRelation", NodeRelation, From 18e135f7a1ff77e8091d9d8c5809e39cf1101057 Mon Sep 17 00:00:00 2001 From: Fabian <6667968+fabcoded@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:41:18 +0200 Subject: [PATCH 2/2] Drop the added tests and the comment change (review) --- tests/test_commands.py | 65 -------------------------------------- zigpy_znp/commands/util.py | 3 +- 2 files changed, 1 insertion(+), 67 deletions(-) diff --git a/tests/test_commands.py b/tests/test_commands.py index 30b0e174..d3376713 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -559,68 +559,3 @@ def test_neighbors_missing_payload(): Src=0x821F, Status=t.ZDOStatus.NOT_SUPPORTED, ) - - -def parse_assoc_add_like_firmware(data: bytes) -> tuple[t.EUI64, t.NWK, int]: - """Read a `UTIL.AssocAdd` payload the way Koenkk's `MT_UtilAssocAdd` does.""" - ieee, rest = t.EUI64.deserialize(data[0:8]) - assert not rest - nwk, rest = t.NWK.deserialize(data[8:10]) - assert not rest - - return ieee, nwk, data[10] - - -def test_util_assoc_add_field_order(): - ieee = t.EUI64.convert("68:0a:e2:ff:fe:32:db:95") - nwk = t.NWK(0x725E) - - req = c.UTIL.AssocAdd.Req( - NWK=nwk, IEEE=ieee, NodeRelation=c.util.NodeRelation.CHILD_RFD - ) - - assert req.to_frame().data == ieee.serialize() + nwk.serialize() + b"\x01" - assert parse_assoc_add_like_firmware(req.to_frame().data) == (ieee, nwk, 1) - - -@pytest.mark.parametrize( - ("ieee", "nwk", "phantom_ieee", "phantom_nwk"), - [ - # Test network, 2026-10-21: `UTIL.AssocFindDevice` afterwards found this entry - pytest.param( - "68:0a:e2:ff:fe:32:db:95", - 0x725E, - "e2:ff:fe:32:db:95:72:5e", - 0x680A, - id="test_network", - ), - # Home network, 2026-10-20: this IEEE then showed up in the zigpy - # network backup under `nwk_addresses` - pytest.param( - "84:2e:14:ff:fe:70:1b:9b", - 0x434B, - "14:ff:fe:70:1b:9b:43:4b", - 0x842E, - id="home_network", - ), - ], -) -def test_util_assoc_add_old_field_order(ieee, nwk, phantom_ieee, phantom_nwk): - ieee = t.EUI64.convert(ieee) - nwk = t.NWK(nwk) - - # The NWK address used to be sent first. The firmware read it as the first two - # bytes of the IEEE address and took the IEEE address's top two bytes as the - # NWK address, so the entry it added belonged to no real device. - old_payload = nwk.serialize() + ieee.serialize() + b"\x01" - - assert parse_assoc_add_like_firmware(old_payload) == ( - t.EUI64.convert(phantom_ieee), - t.NWK(phantom_nwk), - 1, - ) - - req = c.UTIL.AssocAdd.Req( - NWK=nwk, IEEE=ieee, NodeRelation=c.util.NodeRelation.CHILD_RFD - ) - assert parse_assoc_add_like_firmware(req.to_frame().data) == (ieee, nwk, 1) diff --git a/zigpy_znp/commands/util.py b/zigpy_znp/commands/util.py index 40e67305..9fa5ac82 100644 --- a/zigpy_znp/commands/util.py +++ b/zigpy_znp/commands/util.py @@ -477,8 +477,7 @@ class UTIL(t.CommandsBase, subsystem=t.Subsystem.UTIL): rsp_schema=t.STATUS_SCHEMA, ) - # Z2M firmware: proxy call to AssocAddNew. The IEEE address comes first: the - # firmware calls `AssocAddNew(BUILD_UINT16(pBuf[8], pBuf[9]), pBuf, pBuf[10])` + # Z2M firmware: proxy call to AssocAddNew AssocAdd = t.CommandDef( t.CommandType.SREQ, 0x64,