Repository navigation
Child aging is forced off at every start: why, and should zigpy-znp stop writing NWK_CHILD_AGE_ENABLE? #285
Description
zigpy-znp writes NWK_CHILD_AGE_ENABLE=False at every start; Koenkk's coordinator builds run with child aging on
since 20230507. In my test network (one run per value), with aging off the host saw no LeaveInd for a sleepy
direct child whose entry was removed with UTIL.AssocRemove, as the recovery does, and the coordinator did not apply
the child's End Device Timeout Request. I would like to know why the item is written, and whether zigpy-znp should
stop writing it on such builds or make it an option.
Impact: IKEA remotes (STYRBAR, SHORTCUT) joined directly to the coordinator under ZHA became unreachable after
zigpy-znp's association recovery ran during their interview. Two defects in the recovery are addressed by #286 and
#284; I think that whether the network can heal from what remains may depend on child aging.
| Component | Version |
|---|---|
| Home Assistant, ZHA | 2026.10.2, 2.3.1 |
| zigpy, zigpy-znp | 2.3.0, 1.1.1 (zigbee/application.py identical to dev at 34098a5) |
| Coordinator | SLZB-MR1U (CC2652P7), Koenkk's Z-Stack 3.x.0 build 20250321 |
| Test network | a second SLZB-MR1U with the same build and its own network, driven over MT with zigpy-znp 1.1.1, no zigpy interview; test device an IKEA SHORTCUT |
What happens
- A sleepy remote joins directly to the coordinator and zigpy interviews it.
- A frame to it ends with
MAC_TRANSACTION_EXPIRED. In two production joins this came 0.09 s and 0.18 s after the
request while the device was answering, so not the hold timeout; the trigger is not visible to the host. - Since Zigpy application-level retries #260 the recovery runs on the first expiry: lookup,
UTIL.AssocRemove, route discovery, one retry
(application.pyL939-L977).
Its comment gives the reason: "Child aging is disabled so if a child switches parents ... We have to manually drop
the child". - If the retry succeeds, the entry stays removed. If it fails, the
finallyblock restores it withUTIL.AssocAdd
(L1003-L1014),
which on Koenkk's builds wrote a phantom entry instead: the firmware reads IEEE first (FixUTIL.AssocAddparameter order to match the firmware #284). Both predicted pairs
were confirmed:0x725E/68:0a:e2:ff:fe:32:db:95becamee2:ff:fe:32:db:95:72:5e/0x680Ain the test network
(association table read back),0x434B/84:2e:14:ff:fe:70:1b:9bbecame14:ff:fe:70:1b:9b:43:4b/0x842Ein the
home network (nwk_addressesof the zigpy network backup of 2026-10-20 20:05). That the real device was then
missing from the table was measured in the test network only. - When the lookup did not find the device at all, the recovery still removed "nothing" and later added
NWK=0xFFFE, PARENT: Koenkk's firmware zeroes the "not found" answer, which reads asPARENT, and onlyNOTUSED
is checked (Validate association lookups before dropping or restoring a child #286). - A frame to a direct child without an entry is not held: in the test network it ended with
NWK_NO_ROUTEafter
1.6 s. With child aging off I saw no leave-with-rejoin that would bring the device back (below).
Home network, 2026-10-20: lookup "not found", followed by Remove and Add (abridged; full excerpt in #286).
15:08:06.309 AF.DataConfirm.Callback(Status=MAC_TRANSACTION_EXPIRED, Endpoint=1, TSN=2)
15:08:06.320 UTIL.AssocGetWithAddress.Rsp(Device=Device(shortAddr=0xFFFE, addrIdx=0, nodeRelation=PARENT, ...))
15:08:06.321 UTIL.AssocRemove.Req(IEEE=84:2e:14:ff:fe:70:1b:9b) # nothing to remove
15:08:08.131 UTIL.AssocAdd.Req(NWK=0xFFFE, IEEE=84:2e:14:ff:fe:70:1b:9b, NodeRelation=PARENT)
The child-aging question
- zigpy-znp writes the item as False at every start that is not read-only
(L142-L144,
L619-L627).
It came in with18448dc
(2020-04-25, "Explicitly configure some NVIDs instead of relying on defaults"), together with the concentrator and
source-routing items; no reason for child aging is given. That commit predates the firmware'sAssocRemove
(20200805),AssocAdd(20201026) and Koenkk's switch to child aging. - TI's default is on (
zglobals.cL228);
on my test coordinator the factory value of build 20250321 read True. - Koenkk enabled child aging in 20230507
and skips the NotMyChild leave for the Xiaomi prefix00:15:8din the firmware
(hook,
installation). - zigbee-herdsman defines the item (
common.tsL70)
but writes it nowhere insrc/adapter/z-stackatc1dcb11. - In DeliveryError : Request failed after 5 attempts: <Status.MAC_NO_ACK: 233> #189 (comment) @puddly found: "I can
confirm no response toEnd Device Timeout Requestwhen an IKEA dimmer is joined directly to a CC2652. The device
never properly configures." zigpy-znp already wrote the item as False at that time.
Test network, one run per value, host view only:
| Child aging | Entry after the device's End Device Timeout Request | Device after its entry was removed |
|---|---|---|
| off | Not applied: endDevCfg 0xFF, timeout 15360 s (default), the counter equals the timeout in every read, so the entry does not age. A device with an entry and no host traffic did not leave within 14 min. |
No LeaveInd. It came back by itself once after 4 min 58 s, another time not within 11 min 50 s. |
| on | The requested 480 s is applied and the counter runs. | ZDO.LeaveInd with rejoin=1, then an announce under the same address, 8 min 31 s after the removal. |
Not verified: whether the coordinator sends an End Device Timeout Response in either mode, and whether the
NotMyChild leave is inactive with aging off. TI ships the NWK layer as a library (source/ti/zstack/lib/), so the
open sources cannot show either; it needs a radio capture.
Why it matters. With aging on, a Z-Stack parent applies the timeout a sleepy child requests, drops a child that
stays silent, and answers a device it holds no entry for with a leave that has rejoin set
(TI, child management).
zigpy-znp's recovery removes a direct child at the first expiry, which hits exactly the sleepy children. With aging off
such a device has no entry, the host saw no LeaveInd, and the phantoms written by the old restore did not age in
my runs. With aging on, a LeaveInd with rejoin=1 preceded its return (one run); the host cannot see who sent it.
How that combines with zigpy-znp's recovery is not measured.
Questions:
- What was the reason for writing
NWK_CHILD_AGE_ENABLE=Falsein18448dc? - Should zigpy-znp stop writing the item on builds that enable child aging, or make it a configuration option? Koenkk's
builds before 20230507 ran without child aging and lack the Xiaomi exception, so writing True there could expose
older Xiaomi devices to NotMyChild leaves. Not writing the item leaves older builds alone, but a coordinator that
already ran under zigpy-znp keeps False in NV. I have no older Xiaomi device for a counter-test.
Queue limit
TI holds at most three messages per child; a fourth request failed at once with
MAC_TRANSACTION_EXPIRED (test network, one run), which is not by itself a defect.
TI holds at most three messages per associated device
(nwk_globals.c L119,
NWK_INDIRECT_MSG_MAX_PER 3); Koenkk raises the buffer total from 12 to 72, not this limit
(firmware.patch L631-L636).
What happens to a fourth message is not in the open sources. Test network, child aging off, entry 0x725E present
and unchanged before and after, four Basic reads as raw AF.DataRequestExt (not through send_packet):
| Time | Event | Host requests pending after the event |
|---|---|---|
| before | child entry 0x725E present |
none |
| 01:22:21.437 | send TSN 2 | 2 |
| 01:22:21.482 | send TSN 3 | 2, 3 |
| 01:22:21.501 | send TSN 4 | 2, 3, 4 |
| 01:22:21.520 | send TSN 5 | 2, 3, 4, 5 |
| 01:22:21.642 | TSN 5: MAC_TRANSACTION_EXPIRED after 0.122 s |
2, 3, 4 |
| 01:22:28.553 | TSN 2, 3, 4: MAC_TRANSACTION_EXPIRED after about 7.1 s |
none |
The last column lists host requests awaiting AF.DataConfirm; it is not a dump of the firmware queue, whose
occupancy was not observed. Times are the collector's completion stamps; the raw confirms arrive a few milliseconds
earlier. One run. The fourth request fails at once with the status of a hold timeout, the signature of the fast
first expiries in production. But in production only one host frame was outstanding at the first failure, so host
frames did not fill the queue there; frames the coordinator's stack queues by itself are invisible to the host. The
removal and the following NWK_NO_ROUTE were measured in a separate run; reading both runs as one sequence is a
composition of experiments, not one observation. A bounded queue rejecting a fourth request is not by itself a defect.
Comparison: zigbee-herdsman
At c1dcb11, herdsman removes a child only from the second attempt on and skips 0xFFFE lookups
(zStackAdapter.ts L603-L620);
the 0xFFFE check in both recovery paths came with Koenkk/zigbee-herdsman#685 (slugzero, merged 2023-03-27). It sends
assocAdd IEEE first, gates assocRemove (20200805) and assocAdd (20201026) by build separately
(L237-L243)
and does not write the child-aging item. The same device on the same coordinator under Zigbee2MQTT 2.14.2
(2026-10-21, one run; zigbee-herdsman 10.10.0 in that run, whose association recovery is identical to c1dcb11):
direct join, interview complete in 47 s (two node descriptor requests timed out and were retried), binds and reporting
configured, a press arrived as action: on, no association-table command from the host at any point; after Z2M's
commissioning the coordinator ran with child aging on (NV read back True) and applied the device's 480 s End Device
Timeout Request, counter running.
Separate topics
- DataConfirm timeout (8 s) for routers can be shorter than Z-Stack's APS retry window #283 (the host's DataConfirm timeout for routers can be shorter than Z-Stack's APS retry window) is a different
event from a receivedMAC_TRANSACTION_EXPIREDand is not folded in here. - In production two recoveries for the same device ran in parallel (two lookups, removals and adds within 3 s); one
table change per device at a time would be a separate patch.
Prepared with the help of an AI assistant (Claude); I have checked the findings against the cited sources and logs
and take responsibility for them.
Related: #287
Metadata
Metadata
Assignees
Labels
Type
Fields
Priority
Size
Area
Activity
puddly commentedon Oct 21, 2026
Child aging was disabled specifically to deal with bad Xiaomi devices.
If this is now handled by the firmware, we should disable it only when the firmware version is < 20230507 to maintain backwards compatibility.
TheJulianJES commentedon Oct 21, 2026
@zigpy-review-bot Investigate (check different Z-Stack firmware patches), possibly open PR(s) to address.
zigpy-review-bot commentedon Oct 21, 2026
Opened #287 as a first step. Findings:
NWK_CHILD_AGE_ENABLE=Falseis written by zigpy-znp at every start (since 18448dc, 2020). Koenkk's Z-Stack 3.x.0 changelog says aging is enabled from 20230507, "but not for older Xiaomi devices", and firmware.patch implements that as a skipped NotMyChild leave for the 00:15:8d prefix. The 20221226 build had aging off in firmware for the Xiaomi reason. So on 20230507+ builds, zigpy-znp's write overrides the firmware's choice.False.Truefor build id 20230507 and newer,Falseotherwise. It writes the item in both cases, since coordinators that already ran under zigpy-znp holdFalsein NVRAM. Full suite passes, with a new parametrized test over the cutoff.CodeRevisionto be Koenkk's build date (like the existing 20210708 check), so a non-Koenkk build reporting a later number without the Xiaomi exemption would now age children out.AssocRemoverecovery insend_packet, and the association-table fixes in FixUTIL.AssocAddparameter order to match the firmware #284 and Validate association lookups before dropping or restoring a child #286. How the recovery combines with aging on is still unmeasured; that needs a radio capture.Re-mention me if you want any of those followed up.
fabcoded commentedon Oct 22, 2026
Thanks, #287 covers this. For reference, my measurements on Koenkk's 20250321 (CC2652P7), including the old Aqara sensors and what I think is going on with them: https://gist.github.com/fabcoded/ed34bd75c28f9727952aa4d400aad04e
Short version: with aging on, the Aqaras lose their table entry even while reporting (reports don't refresh it), but they keep reporting and no leave showed up. What the missing entry likely costs is the downlink. Closing.