Skip to content

Child aging is forced off at every start: why, and should zigpy-znp stop writing NWK_CHILD_AGE_ENABLE? #285

Closed

Description

@fabcoded

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

  1. A sleepy remote joins directly to the coordinator and zigpy interviews it.
  2. 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.
  3. Since Zigpy application-level retries #260 the recovery runs on the first expiry: lookup, UTIL.AssocRemove, route discovery, one retry
    (application.py L939-L977).
    Its comment gives the reason: "Child aging is disabled so if a child switches parents ... We have to manually drop
    the child".
  4. If the retry succeeds, the entry stays removed. If it fails, the finally block restores it with UTIL.AssocAdd
    (L1003-L1014),
    which on Koenkk's builds wrote a phantom entry instead: the firmware reads IEEE first (Fix UTIL.AssocAdd parameter order to match the firmware #284). Both predicted pairs
    were confirmed: 0x725E/68:0a:e2:ff:fe:32:db:95 became e2:ff:fe:32:db:95:72:5e/0x680A in the test network
    (association table read back), 0x434B/84:2e:14:ff:fe:70:1b:9b became 14:ff:fe:70:1b:9b:43:4b/0x842E in the
    home network (nwk_addresses of 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.
  5. 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 as PARENT, and only NOTUSED
    is checked (Validate association lookups before dropping or restoring a child #286).
  6. A frame to a direct child without an entry is not held: in the test network it ended with NWK_NO_ROUTE after
    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 with 18448dc
    (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's AssocRemove
    (20200805), AssocAdd (20201026) and Koenkk's switch to child aging.
  • TI's default is on (zglobals.c L228);
    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 prefix 00:15:8d in the firmware
    (hook,
    installation).
  • zigbee-herdsman defines the item (common.ts L70)
    but writes it nowhere in src/adapter/z-stack at c1dcb11.
  • In DeliveryError : Request failed after 5 attempts: <Status.MAC_NO_ACK: 233> #189 (comment) @puddly found: "I can
    confirm no response to End Device Timeout Request when 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:

  1. What was the reason for writing NWK_CHILD_AGE_ENABLE=False in 18448dc?
  2. 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

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

Activity

  1. puddly commented on Oct 21, 2026

    @puddly
    Member

    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.

  2. TheJulianJES commented on Oct 21, 2026

    @TheJulianJES
    Contributor

    @zigpy-review-bot Investigate (check different Z-Stack firmware patches), possibly open PR(s) to address.

  3. zigpy-review-bot commented on Oct 21, 2026

    @zigpy-review-bot
    Collaborator

    Opened #287 as a first step. Findings:

    • NWK_CHILD_AGE_ENABLE=False is 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.
    • The Z-Stack 3.0.x (CC2531, 2019) and Home 1.2 builds are all far older than 20230507, so they keep False.
    • Enable child aging on Z-Stack builds from 20230507 #287 follows the suggestion above: write True for build id 20230507 and newer, False otherwise. It writes the item in both cases, since coordinators that already ran under zigpy-znp hold False in NVRAM. Full suite passes, with a new parametrized test over the cutoff.
    • Open: the cutoff trusts CodeRevision to 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.
    • Not touched: the first-expiry AssocRemove recovery in send_packet, and the association-table fixes in Fix UTIL.AssocAdd parameter 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.

  4. fabcoded commented on Oct 22, 2026

    @fabcoded
    ContributorAuthor

    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.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Fields

    Priority

    None yet

    Size

    None yet

    Area

    None yet

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions