From c4655cbecd7f0b20c3244458aa6a7665ebbb6371 Mon Sep 17 00:00:00 2001 From: zigpy-review-bot <286747149+zigpy-review-bot@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:06:13 +0200 Subject: [PATCH 1/3] Enable child aging on Z-Stack builds from 20230507 --- tests/application/test_startup.py | 29 +++++++++++++++++++++++++++++ zigpy_znp/zigbee/application.py | 19 ++++++++++++++----- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/tests/application/test_startup.py b/tests/application/test_startup.py index 8394a86..af21f92 100644 --- a/tests/application/test_startup.py +++ b/tests/application/test_startup.py @@ -313,3 +313,32 @@ async def test_concurrency_auto_config(device, concurrency, make_application): await app.start_network() assert app._concurrent_requests_semaphore.max_concurrency == concurrency + + +@pytest.mark.parametrize( + ("code_revision", "expected"), + [ + (None, False), + (20190608, False), + (20221226, False), + (20230506, False), + (20230507, True), + (20250321, True), + ], +) +async def test_child_aging_by_build(code_revision, expected, make_application, mocker): + app, znp_server = make_application(FormedLaunchpadCC26X2R1) + mocker.patch.object( + type(app), + "_zstack_build_id", + mocker.PropertyMock(return_value=code_revision or 0), + ) + + await app.startup(auto_form=False) + + value = await app._znp.nvram.osal_read( + OsalNvIds.NWK_CHILD_AGE_ENABLE, item_type=t.Bool + ) + assert value == expected + + await app.shutdown() diff --git a/zigpy_znp/zigbee/application.py b/zigpy_znp/zigbee/application.py index be95a9a..2eafb2e 100644 --- a/zigpy_znp/zigbee/application.py +++ b/zigpy_znp/zigbee/application.py @@ -36,6 +36,8 @@ DATA_CONFIRM_TIMEOUT = 8 EXTENDED_DATA_CONFIRM_TIMEOUT = 30 DEVICE_JOIN_MAX_DELAY = 5 +# First Koenkk Z-Stack 3.x.0 build with child aging enabled (and Xiaomi exempted) +CHILD_AGING_MIN_BUILD_ID = 20230507 LOGGER = logging.getLogger(__name__) @@ -139,6 +141,9 @@ async def start_network(self, *, read_only=False): if self.state.node_info == zigpy.state.NodeInfo(): await self.load_network_info() + # Needed by the stack settings below + self._version_rsp = await self._znp.request(c.SYS.Version.Req()) + if not read_only: await self._znp.migrate_nvram() await self._write_stack_settings() @@ -150,8 +155,6 @@ async def start_network(self, *, read_only=False): await self._znp.start_network() - self._version_rsp = await self._znp.request(c.SYS.Version.Req()) - # The CC2531 running Z-Stack Home 1.2 overrides the LED setting if it is changed # before the coordinator has started. if self.znp_config[conf.CONF_LED_MODE] is not None: @@ -624,7 +627,13 @@ async def _write_stack_settings(self) -> bool: OsalNvIds.CONCENTRATOR_DISCOVERY: t.uint8_t(120), OsalNvIds.CONCENTRATOR_RC: t.Bool(True), OsalNvIds.SRC_RTG_EXPIRY_TIME: t.uint8_t(255), - OsalNvIds.NWK_CHILD_AGE_ENABLE: t.Bool(False), + # Koenkk's builds enable child aging from 20230507 on and skip the leave + # for Xiaomi devices, which do not implement it. Older builds have no such + # exception, so aging stays off there. The value is written either way + # because an earlier zigpy-znp may have left `False` in NVRAM. + OsalNvIds.NWK_CHILD_AGE_ENABLE: t.Bool( + self._zstack_build_id >= CHILD_AGING_MIN_BUILD_ID + ), # Default is 20 in Z-Stack 3.0.1, 30 in Z-Stack 3/4 OsalNvIds.BCAST_DELIVERY_TIME: t.uint8_t(30), # We want to receive all ZDO callbacks to proxy them back to zigpy @@ -936,8 +945,8 @@ async def send_packet(self, packet: zigpy.types.ZigbeePacket) -> None: except InvalidCommandResponse as e: last_error = e - # Child aging is disabled so if a child switches parents from - # the coordinator to another router, we will not be able to + # Without child aging (older builds) if a child switches parents + # from the coordinator to another router, we will not be able to # re-discover a route to it. We have to manually drop the child # to do this. if ( From 3c9bd3cf152ec01f31309a8b1c940d3578c175de Mon Sep 17 00:00:00 2001 From: zigpy-review-bot <286747149+zigpy-review-bot@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:17:09 +0200 Subject: [PATCH 2/3] Test child aging via the Version response, clarify recovery comment --- tests/application/test_startup.py | 20 +++++++++++++++----- zigpy_znp/zigbee/application.py | 8 ++++---- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/tests/application/test_startup.py b/tests/application/test_startup.py index af21f92..8211bc9 100644 --- a/tests/application/test_startup.py +++ b/tests/application/test_startup.py @@ -326,12 +326,22 @@ async def test_concurrency_auto_config(device, concurrency, make_application): (20250321, True), ], ) -async def test_child_aging_by_build(code_revision, expected, make_application, mocker): +async def test_child_aging_by_build(code_revision, expected, make_application): app, znp_server = make_application(FormedLaunchpadCC26X2R1) - mocker.patch.object( - type(app), - "_zstack_build_id", - mocker.PropertyMock(return_value=code_revision or 0), + + znp_server.reply_to( + c.SYS.Version.Req(), + responses=c.SYS.Version.Rsp( + TransportRev=2, + ProductId=1, + MajorRel=2, + MinorRel=7, + MaintRel=1, + CodeRevision=code_revision, + BootloaderBuildType=None, + BootloaderRevision=None, + ), + override=True, ) await app.startup(auto_form=False) diff --git a/zigpy_znp/zigbee/application.py b/zigpy_znp/zigbee/application.py index 2eafb2e..3f89e74 100644 --- a/zigpy_znp/zigbee/application.py +++ b/zigpy_znp/zigbee/application.py @@ -945,10 +945,10 @@ async def send_packet(self, packet: zigpy.types.ZigbeePacket) -> None: except InvalidCommandResponse as e: last_error = e - # Without child aging (older builds) if a child switches parents - # from the coordinator to another router, we will not be able to - # re-discover a route to it. We have to manually drop the child - # to do this. + # Child aging is off on older builds, so if a child switches + # parents from the coordinator to another router, we will not be + # able to re-discover a route to it. We have to manually drop the + # child to do this. if ( e.response.Status == t.Status.MAC_TRANSACTION_EXPIRED and device is not None From 6a6ba9da08fab8fe4fc4c21339599102a30fe950 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:27:01 -0400 Subject: [PATCH 3/3] Apply suggestion from @puddly --- zigpy_znp/zigbee/application.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/zigpy_znp/zigbee/application.py b/zigpy_znp/zigbee/application.py index 3f89e74..30f33e4 100644 --- a/zigpy_znp/zigbee/application.py +++ b/zigpy_znp/zigbee/application.py @@ -947,8 +947,8 @@ async def send_packet(self, packet: zigpy.types.ZigbeePacket) -> None: # Child aging is off on older builds, so if a child switches # parents from the coordinator to another router, we will not be - # able to re-discover a route to it. We have to manually drop the - # child to do this. + # able to re-discover a route to it. We have to manually drop + # the child to do this. if ( e.response.Status == t.Status.MAC_TRANSACTION_EXPIRED and device is not None