Skip to content

Fix UTIL.AssocAdd parameter order to match the firmware - #284

Merged
puddly merged 2 commits into
zigpy:devfrom
fabcoded:fix/assoc-add-field-order
Oct 21, 2026
Merged

puddly merged 2 commits into
zigpy:devfrom
fabcoded:fix/assoc-add-field-order

Conversation

@fabcoded

@fabcoded fabcoded commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

This PR corrects the field order of UTIL.AssocAdd to match Koenkk's Z-Stack firmware: IEEE, NWK, relation.
The existing schema sends NWK before IEEE, so the firmware interprets parts of the two addresses as a different
address pair. zigbee-herdsman already uses this order in its
assocAdd command definition.

I found this while investigating IKEA remotes that became unreachable after association recovery under ZHA.
The recovery removes a child entry and, if the send still fails, tries to restore it. In the test network, that
restore returned SUCCESS but created an entry under a different IEEE and short address, leaving the real device
absent. I could predict the unexpected address pair from the request bytes and read it back from the table. A
second predicted pair also appears in my home network's zigpy backup; the detailed traces are below.

With the corrected byte order, the test coordinator finds the intended device under its IEEE and short address
again. This is a small serialization change: it preserves the recovery policy. The byte-level check is kept in this description as a device-free script (Evidence 3)
rather than as a unit test, following review feedback.
Successful delivery after restoration remains to be measured, and existing incorrect entries are not removed by
this change. The related lookup and restore checks are covered separately in #PR1.

Proposed change

-    # 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"),

The schema now matches the firmware; the only caller passes keyword arguments, so no call site changes.

Verification

Check dev at 34098a5 This PR
pytest tests/test_commands.py 18 passed 18 passed
pytest tests (full suite) 390 passed 390 passed
Coverage 99 % 99 %
pre-commit run --all-files (ruff, ruff-format, codespell, mypy) pass pass

No new unit tests: a definition error is fixed by the definition correction. The device-free script in Evidence 3
pins the byte layout: against dev it prints the two phantom pairs, against this branch the intended pairs. Run
locally with Python 3.12 and zigpy 2.3.0.

How the mismatch occurs

zigpy-znp, zigpy_znp/commands/util.py L480-L494 at 34098a5, unchanged since ae0f446 (2020-11-11):

        req_schema=(
            t.Param("NWK", t.NWK, "Short address of the device"),                # writes bytes 0-1
            t.Param("IEEE", t.EUI64, "Extended address of the device to add"),  # writes bytes 2-9
            t.Param(                                                            # writes byte 10
                "NodeRelation",
                NodeRelation,
                "Relation of the device to the coordinator",
            ),
        ),

Koenkk's MT_UtilAssocAdd, coordinator/Z-Stack_3.x.0/firmware.patch L269-L285 at 5e4db67 (build 20250321),
identical since the command was added in 83a282f (build 20201026); Z_EXTADDR_LEN is 8:

  AssocAddNew(
    BUILD_UINT16(pBuf[Z_EXTADDR_LEN], pBuf[Z_EXTADDR_LEN + 1]),  // shortAddr <- reads bytes 8-9
    pBuf,                                                        // extAddr   <- reads bytes 0-7
    pBuf[Z_EXTADDR_LEN + 2]                                      // relation  <- reads byte 10
  );
  // ...
  MT_BuildAndSendZToolResponse(..., 1, &retValue);               // retValue = 0: always SUCCESS

zigbee-herdsman, src/adapter/z-stack/znp/definition.ts L2444-L2455 at c1dcb11:

            // Custom command
            name: "assocAdd",
            ID: 100,                                                         // 0x64
            type: CommandType.SREQ,
            request: [
                {name: "ieeeadr", parameterType: ParameterType.IEEEADDR},    // writes bytes 0-7
                {name: "nwkaddr", parameterType: ParameterType.UINT16},      // writes bytes 8-9
                {name: "noderelation", parameterType: ParameterType.UINT8},  // writes byte 10
            ],

A real request (test network, 2026-10-21 01:46:49: NWK=0x725E, IEEE=68:0a:e2:ff:fe:32:db:95, CHILD_RFD):

                  bytes on the wire                    decoded
host sends        5e 72 | 95 db 32 fe ff e2 0a 68 | 01   NWK 0x725E, IEEE 68:0a:e2:ff:fe:32:db:95, CHILD_RFD
firmware reads    5e 72 95 db 32 fe ff e2 | 0a 68 | 01   IEEE e2:ff:fe:32:db:95:72:5e, NWK 0x680A, CHILD_RFD

The same device from request to table, in Evidence 1. These are separate steps of one test-network session series, not
one atomic transaction: the tool soft-resets the coordinator at the start and end of every run, and a removal of the
phantom at 01:57:41 was undone by such a reset one second later.

Step (2026-10-21) Request bytes or table state
Before, 01:46:49.062 Lookup of 68:0a:e2:ff:fe:32:db:95 returns 0xFFFE: no entry after UTIL.AssocRemove
zigpy-znp request, 01:46:49.111 5e 72 95 db 32 fe ff e2 0a 68 01
Firmware reads IEEE from bytes 0-7: e2:ff:fe:32:db:95:72:5e; NWK from bytes 8-9: 0x680A
Result SUCCESS; the real IEEE is still absent (01:46:49.137); entry 0x680A under the misread IEEE (01:51:19, 01:52:41)
This PR's byte order, 01:58:38.982 95 db 32 fe ff e2 0a 68 5e 72 01
Result Lookup of the real IEEE returns 0x725E, CHILD_RFD (01:58:39.012); the phantom stays at index 0

One possible explanation is the distinction between the C function and its MT command. The schema's comment
refers to AssocAddNew, and the schema follows that function's parameter order (shortAddr, extAddr, nodeRelation).
The MT handler expects a different wire order: IEEE first. That correspondence may explain how the mismatch arose,
although I have not confirmed its historical cause.

Several aspects make the mismatch easy to miss. This caller is reached when association recovery needs to restore
an entry after an unsuccessful send, and the handler returns SUCCESS without reporting whether the intended
entry was created. The existing recovery tests compare request objects using the same command schema on both
sides, so they do not independently check the firmware's byte layout. Evidence 3 adds that check as a script.

Evidence

1. Test network: one zigpy-znp AssocAdd leaves the device out of the table and creates child 0x680A under the misread IEEE, still present after two coordinator resets; the same add with pre-swapped fields creates the real child 0x725E.

Test coordinator with Koenkk's build 20250321, zigpy-znp 1.1.1, raw MT commands; log abridged, raw answers shortened.

01:46:49.036 UTIL.AssocRemove.Req(IEEE=68:0a:e2:ff:fe:32:db:95)                 # start without the device in the table
01:46:49.062 UTIL.AssocGetWithAddress(IEEE=68:0a:e2:ff:fe:32:db:95) -> fe ff 00 00 00 ...   # removed
01:46:49.111 UTIL.AssocAdd.Req(NWK=0x725E, IEEE=68:0a:e2:ff:fe:32:db:95, NodeRelation=CHILD_RFD)
01:46:49.119 UTIL.AssocAdd.Rsp(Status=SUCCESS)
01:46:49.137 UTIL.AssocGetWithAddress(IEEE=68:0a:e2:ff:fe:32:db:95) -> fe ff 00 00 00 ...   # still not in the table
01:47:50.352 SYS.ResetReq(Type=Soft)                                              # end of run, 61 s after the add
01:51:06.939 SYS.ResetReq(Type=Soft)                                              # start of the next run
01:51:11.607 SYS.ResetInd.Callback(Reason=PowerUp)                                 # up again after the second reset
01:51:19.926 UTIL.AssocFindDevice(Index=0) -> 0a 68 01 00 01 04 00 00 06 01 aa ...
             table[0]: shortAddr=0x680A addrIdx=1 nodeRelation=CHILD_RFD          # a child that does not exist
01:52:41.351 UTIL.AssocGetWithAddress(IEEE=e2:ff:fe:32:db:95:72:5e) -> 0a 68 01 00 01 04 ...
             shortAddr=0x680A                                                     # stored under the misread IEEE

# Same add with the fields pre-swapped, which puts the bytes in this PR's order: 95 db 32 fe ff e2 0a 68 | 5e 72 | 01
01:58:38.982 UTIL.AssocAdd.Req(NWK=0xDB95, IEEE=72:5e:68:0a:e2:ff:fe:32, NodeRelation=CHILD_RFD)
01:58:38.995 UTIL.AssocAdd.Rsp(Status=SUCCESS)
01:58:39.012 UTIL.AssocGetWithAddress(IEEE=68:0a:e2:ff:fe:32:db:95) -> 5e 72 00 00 01 04 ...
             shortAddr=0x725E nodeRelation=CHILD_RFD                              # the real device
01:58:39.050 table[0]: shortAddr=0x680A addrIdx=1 CHILD_RFD                       # the phantom from 01:46:49
01:58:39.081 table[1]: shortAddr=0x725E addrIdx=0 CHILD_RFD                       # the real device
2. Home network: the phantom pair (IEEE and NWK) predicted from a logged recovery request appears in the address table of the zigpy network backup.
2026-10-20 17:34:55.152 UTIL.AssocAdd.Req(NWK=0x434B, IEEE=84:2e:14:ff:fe:70:1b:9b, NodeRelation=CHILD_RFD)
                        # restore step of the send_packet recovery, zigpy-znp 1.1.1, Koenkk's build 20250321
bytes sent              4b 43 | 9b 1b 70 fe ff 14 2e 84 | 01
firmware reads          4b 43 9b 1b 70 fe ff 14 | 2e 84 | 01   -> IEEE 14:ff:fe:70:1b:9b:43:4b, NWK 0x842E
zigpy network backup of 2026-10-20 20:05, nwk_addresses:
                        14:ff:fe:70:1b:9b:43:4b -> 0x842E   # the predicted phantom pair
                        84:2e:14:ff:fe:70:1b:9b -> 0xA13D   # the real device
3. Device-free check: serializes the request with zigpy-znp and parses it like MT_UtilAssocAdd; on dev it reproduces both phantom entries, with this PR its assertion fails because the firmware reads the intended pair.
"""Check, without a device, how Koenkk's MT_UtilAssocAdd reads zigpy-znp's UTIL.AssocAdd request.

Run: pip install zigpy-znp==1.1.1 && python3 assoc_add_order_check.py
"""

import unittest

import zigpy_znp.commands as c
import zigpy_znp.types as t


def firmware_reads(data: bytes) -> tuple[str, int, int]:
    """Parse like MT_UtilAssocAdd: IEEE = bytes 0-7, NWK = BUILD_UINT16(pBuf[8], pBuf[9]), relation = byte 10."""
    ieee = ":".join(f"{b:02x}" for b in reversed(data[0:8]))
    return ieee, data[8] | (data[9] << 8), data[10]


# Sent NWK and IEEE, then IEEE and NWK of the entry the firmware creates from them
VECTORS = [
    (0x725E, "68:0a:e2:ff:fe:32:db:95", "e2:ff:fe:32:db:95:72:5e", 0x680A),  # test network, 2026-10-21
    (0x434B, "84:2e:14:ff:fe:70:1b:9b", "14:ff:fe:70:1b:9b:43:4b", 0x842E),  # home network, 2026-10-20
]


class AssocAddOrder(unittest.TestCase):
    def test_firmware_creates_phantom_entry(self):
        for nwk, ieee, fw_ieee, fw_nwk in VECTORS:
            req = c.UTIL.AssocAdd.Req(NWK=nwk, IEEE=t.EUI64.convert(ieee), NodeRelation=c.util.NodeRelation.CHILD_RFD)
            data = req.to_frame().data
            ieee_read, nwk_read, relation = firmware_reads(data)
            print(f"{data.hex(' ')} -> IEEE {ieee_read}, NWK 0x{nwk_read:04X}, relation {relation}")
            self.assertEqual((ieee_read, nwk_read, relation), (fw_ieee, fw_nwk, 1))


if __name__ == "__main__":
    unittest.main()

Output on dev (34098a5): 5e 72 95 db 32 fe ff e2 0a 68 01 -> IEEE e2:ff:fe:32:db:95:72:5e, NWK 0x680A, relation 1,
4b 43 9b 1b 70 fe ff 14 2e 84 01 -> IEEE 14:ff:fe:70:1b:9b:43:4b, NWK 0x842E, relation 1, OK. With this PR it prints
95 db 32 fe ff e2 0a 68 5e 72 01 -> IEEE 68:0a:e2:ff:fe:32:db:95, NWK 0x725E, relation 1 and fails.

4. Test network: in this PR's byte order, an add for an IEEE the firmware already holds updates that entry in place, short address included; no duplicate.

Same coordinator and build, 2026-10-21, both adds with the fields pre-swapped as in Evidence 1; log abridged.

11:47:31.071 UTIL.AssocGetWithAddress(IEEE=68:0a:e2:ff:fe:32:db:95) -> shortAddr=0x725E CHILD_RFD   # 1 used entry
11:47:31.084 UTIL.AssocAdd.Req(NWK=0xDB95, IEEE=72:5e:68:0a:e2:ff:fe:32, ...)   # firmware reads ...db:95, 0x725E
11:47:31.096 UTIL.AssocAdd.Rsp(Status=SUCCESS)
11:47:31.137 table[0]: shortAddr=0x725E addrIdx=0 CHILD_RFD                     # still 1 used entry, same index
11:47:31.904 UTIL.AssocAdd.Req(NWK=0xDB95, IEEE=12:34:68:0a:e2:ff:fe:32, ...)   # firmware reads ...db:95, 0x1234
11:47:31.913 UTIL.AssocAdd.Rsp(Status=SUCCESS)
11:47:31.952 table[0]: shortAddr=0x1234 addrIdx=0 CHILD_RFD                     # same entry, new short address
11:47:32.945 UTIL.AssocGetWithAddress(NWK=0x725E) -> fe ff 00 00 ...            # the old address is gone

Compatibility and risk

Topic Assessment
Scope A fix to zigpy-znp's Z-Stack integration, not to the Zigbee protocol or other radio libraries. The schema comment ("Z2M firmware: proxy call to AssocAddNew") shows zigpy-znp implements this command for Koenkk's extension.
Affected firmware Builds that implement UTIL.AssocAdd (0x64): Koenkk's Z-Stack 3.x.0 coordinator builds since 20201026 (checked at 83a282f and 5e4db67). Builds derived from his patch were not checked individually.
Builds 20200805 and 20200925 UTIL.AssocRemove exists, UTIL.AssocAdd does not (Koenkk's changelog). The recovery removes the child; if the retry fails, the restore raises CommandNotRecognized in the finally block and the child stays removed. Pre-existing and unchanged by this PR. zigbee-herdsman checks the two build dates separately.
Builds before 20200805 Not reached: UTIL.AssocRemove fails with CommandNotRecognized and the original error is raised, as before.
Change for users After a failed retry the restore writes the device's own IEEE, NWK and relation instead of a phantom. Measured once: the firmware then finds the device under its IEEE with its NWK, and the entry holds a frame for 8.4 s like a joined child's. Not measured: successful delivery after the corrected add. The frame expired without a successful fetch being observed; the host log does not directly show MAC polls.
Stale restore The restore uses the NWK from the lookup before the removal. TI documents AssocAddNew as "Create a new or update a previous association" (assoc_list.h L166); in the corrected order an add for an IEEE the firmware already holds rewrote that entry, short address included (Evidence 4, measured on build 20250321, one run each). Such a restore can overwrite a newer address. #PR1 checks for a changed host address and an existing firmware entry before restoration; its description also explains the remaining races after those checks.
Unchanged When the recovery runs, the retry budget, NV settings, other radio backends.
Not addressed Phantoms written before this fix stay in the table and, once in NV, survive resets; this PR does not remove them. A reset within 1 s undid table changes (3 of 3); after 61 s or more they were kept (5 of 5), which suggests delayed NV writes. A phantom's NWK is the top two bytes of a known IEEE, its IEEE the remaining six followed by the requested NWK; UTIL.AssocFindDevice over all indices lists them.
Risk A firmware that reads NWK first would now get swapped fields. None found: Koenkk's patch has read IEEE first since it introduced the command, and zigbee-herdsman sends IEEE first. Exposure is limited to Z-Stack 3.30+: the only caller runs only when self._znp.version >= 3.30 (application.py L947).

Interaction with #PR1

#PR1 adds checks for missing child entries (0xFFFE) and for newer information available before restoration.
Its description sets out the remaining concurrency limits. The two patches apply independently, but interact at runtime: with this PR alone, a recovery started from an
invalid lookup now sends the real IEEE with NWK 0xFFFE and relation PARENT to the firmware, instead of the
previously misparsed bytes. What the firmware does with that entry was not measured. #PR1's tests mock the request
objects; this PR adds the independent byte-layout checks. Reviewing the two changes together would help assess both
when restoration is appropriate and how its command is encoded.

Prior art and references

Prepared with the help of an AI assistant (Claude); I have read the change and the tests and take responsibility
for them.

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.
Comment thread tests/test_commands.py Outdated
Src=0x821F,
Status=t.ZDOStatus.NOT_SUPPORTED,
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I don't think this needs unit testing, to be honest. This is a definition error and is fixed by the definition correction.

@fabcoded fabcoded Oct 21, 2026 •

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.

Thanks, done: the added tests are removed in 18e135f. The device-free byte check stays in the description as evidence.

Comment thread zigpy_znp/commands/util.py Outdated
)

# Z2M firmware: proxy call to AssocAddNew
# Z2M firmware: proxy call to AssocAddNew. The IEEE address comes first: the

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

There's no need to update the comment to describe how the firmware works.

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.

Thanks, done: the comment is back to its original wording in 18e135f.

@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.

The new field order is correct. I checked the schema change against Koenkk's firmware and the new tests against the PR branch.

Verified

  • Koenkk's MT_UtilAssocAdd in coordinator/Z-Stack_3.x.0/firmware.patch (current master) calls AssocAddNew(BUILD_UINT16(pBuf[Z_EXTADDR_LEN], pBuf[Z_EXTADDR_LEN + 1]), pBuf, pBuf[Z_EXTADDR_LEN + 2]), so the wire layout is IEEE (bytes 0-7), NWK (bytes 8-9), relation (byte 10). The new schema matches it.
  • zigbee-herdsman's assocAdd definition uses the same order: ieeeadr, nwkaddr, noderelation.
  • The only caller in the repo (zigpy_znp/zigbee/application.py) and the request-comparing tests in tests/application/test_requests.py all use keyword arguments, so nothing else needs to change.
  • On the PR head (62b5932) the full suite passes: 393 passed.
  • The new tests check the raw bytes against a parser written like the firmware, which the existing tests could not do because they build both sides from the same schema. That is the right way to pin this.

Nit (non-blocking)

The PR description still contains the literal placeholder #PR1 in several places (the intro, the compatibility table and the "Interaction with #PR1" section). It autolinks to nothing, so it would be good to replace it with the real PR number, or drop those references if that PR is not open yet.

@codecov

codecov Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.54%. Comparing base (34098a5) to head (18e135f).
⚠️ Report is 1 commits behind head on dev.

Additional details and impacted files
@@            Coverage Diff             @@
##              dev     #284      +/-   ##
==========================================
- Coverage   98.60%   98.54%   -0.06%     
==========================================
  Files          43       43              
  Lines        3581     3582       +1     
==========================================
- Hits         3531     3530       -1     
- Misses         50       52       +2     

☔ 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 added the bugfix This PR fixes a bug label Oct 21, 2026
@fabcoded

Copy link
Copy Markdown
Contributor Author

Reduced to the two-line schema change (follow-up commit 18e135f): the added tests and the comment change are gone, as requested. The device-free byte check stays in the description as evidence. The #PR1 placeholder now points to #286.

@puddly
puddly merged commit 926626c into zigpy:dev Oct 21, 2026
11 of 12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bugfix This PR fixes a bug

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants