From 434f95190086522fbca03f2a29ec3c4478472e96 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:16:56 -0400 Subject: [PATCH 1/2] Allocate APS sequence numbers directly --- tests/test_application.py | 9 +++------ tests/test_legacy.py | 6 +----- zigpy_ziggurat/zigbee/application.py | 16 +++++++++++++--- 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/tests/test_application.py b/tests/test_application.py index 708c7f4..c72f868 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -72,7 +72,6 @@ def zdo_packet(cluster_id: int, data: bytes, src: t.NWK = DEVICE_NWK) -> t.Zigbe src_ep=t.uint8_t(0), dst=t.AddrModeAddress(addr_mode=t.AddrMode.NWK, address=t.NWK(0x0000)), dst_ep=t.uint8_t(0), - tsn=t.uint8_t(data[0]), profile_id=t.uint16_t(0x0000), cluster_id=t.uint16_t(cluster_id), data=t.SerializableBytes(data), @@ -84,7 +83,6 @@ def zdo_packet(cluster_id: int, data: bytes, src: t.NWK = DEVICE_NWK) -> t.Zigbe def aps_packet( dst: t.AddrModeAddress, *, - tsn: int = 33, src_ep: int = 1, dst_ep: int = 1, tx_options: t.TransmitOptions = t.TransmitOptions.NONE, @@ -95,7 +93,6 @@ def aps_packet( src_ep=t.uint8_t(src_ep), dst=dst, dst_ep=t.uint8_t(dst_ep), - tsn=t.uint8_t(tsn), profile_id=t.uint16_t(0x0104), cluster_id=t.uint16_t(0x0006), data=t.SerializableBytes(data), @@ -668,7 +665,7 @@ async def test_send_packet_unicast( assert not send.aps_encryption assert not send.sleepy_destination assert send.profile_id == 0x0104 - assert send.aps_seq == 33 + assert send.aps_seq == 1 assert send.radius == 30 assert send.priority == 0 assert bytes(send.asdu) == b"\x01\x02\x03" @@ -750,7 +747,7 @@ async def test_send_packet_broadcast( send = server.sent(p.SendBroadcast)[-1] assert send.destination == t.NWK(0xFFFC) assert send.dst_ep == 255 - assert send.aps_seq == 33 + assert send.aps_seq == 1 assert bytes(send.asdu) == b"\x01\x02\x03" @@ -766,7 +763,7 @@ async def test_send_packet_groupcast( send = server.sent(p.SendGroupcast)[-1] assert send.group_id == 0x0002 - assert send.aps_seq == 33 + assert send.aps_seq == 1 assert bytes(send.asdu) == b"\x01\x02\x03" diff --git a/tests/test_legacy.py b/tests/test_legacy.py index 9212e81..323b827 100644 --- a/tests/test_legacy.py +++ b/tests/test_legacy.py @@ -577,7 +577,6 @@ async def test_legacy_send_packet( src_ep=t.uint8_t(1), dst=dst, dst_ep=t.uint8_t(1), - tsn=t.uint8_t(33), profile_id=t.uint16_t(0x0104), cluster_id=t.uint16_t(0x0006), data=t.SerializableBytes(b"\x01\x02\x03"), @@ -587,7 +586,7 @@ async def test_legacy_send_packet( request = legacy_server.sent(commands.SendAps)[-1] assert request.data == b"\x01\x02\x03" - assert request.aps_seq == 33 + assert request.aps_seq == 1 assert request.radius == 30 for field, value in expected.items(): assert getattr(request, field) == value @@ -609,7 +608,6 @@ async def fail(command: commands.SendAps, request_id: int) -> commands.Status: src_ep=t.uint8_t(1), dst=t.AddrModeAddress(addr_mode=t.AddrMode.NWK, address=DEVICE_NWK), dst_ep=t.uint8_t(1), - tsn=t.uint8_t(34), profile_id=t.uint16_t(0x0104), cluster_id=t.uint16_t(0x0006), data=t.SerializableBytes(b"\x04"), @@ -638,7 +636,6 @@ async def fail_confirm( src_ep=t.uint8_t(1), dst=t.AddrModeAddress(addr_mode=t.AddrMode.NWK, address=DEVICE_NWK), dst_ep=t.uint8_t(1), - tsn=t.uint8_t(35), profile_id=t.uint16_t(0x0104), cluster_id=t.uint16_t(0x0006), data=t.SerializableBytes(b"\x05"), @@ -663,7 +660,6 @@ async def fail_ack(command: commands.SendAps, request_id: int) -> commands.Statu src_ep=t.uint8_t(1), dst=t.AddrModeAddress(addr_mode=t.AddrMode.NWK, address=DEVICE_NWK), dst_ep=t.uint8_t(1), - tsn=t.uint8_t(36), profile_id=t.uint16_t(0x0104), cluster_id=t.uint16_t(0x0006), data=t.SerializableBytes(b"\x06"), diff --git a/zigpy_ziggurat/zigbee/application.py b/zigpy_ziggurat/zigbee/application.py index 8bb7989..77b8211 100644 --- a/zigpy_ziggurat/zigbee/application.py +++ b/zigpy_ziggurat/zigbee/application.py @@ -106,6 +106,7 @@ def __init__(self, config: dict[str, Any]) -> None: super().__init__(config) self._api: ZigguratApi | None = None self._start_time: datetime | None = None + self._aps_counter = 0 async def connect(self) -> None: # The device path is either the WebSocket URL of a ziggurat server or the @@ -798,6 +799,11 @@ def _handle_received_aps_command(self, command: p.ReceivedAps) -> None: ) self.packet_received(packet) + def _next_aps_counter(self) -> int: + """Allocate the APS counter for the next outgoing frame.""" + self._aps_counter = (self._aps_counter + 1) % 256 + return self._aps_counter + async def send_packet(self, packet: t.ZigbeePacket) -> None: dst = packet.dst assert dst is not None and dst.address is not None @@ -834,6 +840,10 @@ async def send_packet(self, packet: t.ZigbeePacket) -> None: send: p.SendUnicast | p.SendBroadcast | p.SendGroupcast async with self._limit_concurrency(priority=packet.priority): + # Allocated here rather than above the semaphore so counters advance in the + # order the server receives the frames + aps_seq = self._next_aps_counter() + if dst.addr_mode == t.AddrMode.Group: assert destination is not None send = p.SendGroupcast.build( @@ -841,7 +851,7 @@ async def send_packet(self, packet: t.ZigbeePacket) -> None: profile_id=packet.profile_id, cluster_id=packet.cluster_id or 0x0000, src_ep=packet.src_ep or 0, - aps_seq=packet.tsn, + aps_seq=aps_seq, radius=radius, priority=priority, asdu=asdu, @@ -854,7 +864,7 @@ async def send_packet(self, packet: t.ZigbeePacket) -> None: cluster_id=packet.cluster_id or 0x0000, src_ep=packet.src_ep or 0, dst_ep=packet.dst_ep or 0, - aps_seq=packet.tsn, + aps_seq=aps_seq, radius=radius, priority=priority, asdu=asdu, @@ -899,7 +909,7 @@ async def send_packet(self, packet: t.ZigbeePacket) -> None: cluster_id=packet.cluster_id or 0x0000, src_ep=packet.src_ep or 0, dst_ep=packet.dst_ep or 0, - aps_seq=packet.tsn, + aps_seq=aps_seq, radius=radius, priority=priority, route_control=route_control, From b2d72cd228b6881ed3841b8597dfb5622ec8dc74 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:19:55 -0400 Subject: [PATCH 2/2] Randomize the APS counter on startup --- tests/test_application.py | 3 --- tests/test_legacy.py | 1 - zigpy_ziggurat/zigbee/application.py | 9 ++++++--- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/tests/test_application.py b/tests/test_application.py index c72f868..38a2aa6 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -665,7 +665,6 @@ async def test_send_packet_unicast( assert not send.aps_encryption assert not send.sleepy_destination assert send.profile_id == 0x0104 - assert send.aps_seq == 1 assert send.radius == 30 assert send.priority == 0 assert bytes(send.asdu) == b"\x01\x02\x03" @@ -747,7 +746,6 @@ async def test_send_packet_broadcast( send = server.sent(p.SendBroadcast)[-1] assert send.destination == t.NWK(0xFFFC) assert send.dst_ep == 255 - assert send.aps_seq == 1 assert bytes(send.asdu) == b"\x01\x02\x03" @@ -763,7 +761,6 @@ async def test_send_packet_groupcast( send = server.sent(p.SendGroupcast)[-1] assert send.group_id == 0x0002 - assert send.aps_seq == 1 assert bytes(send.asdu) == b"\x01\x02\x03" diff --git a/tests/test_legacy.py b/tests/test_legacy.py index 323b827..6719e15 100644 --- a/tests/test_legacy.py +++ b/tests/test_legacy.py @@ -586,7 +586,6 @@ async def test_legacy_send_packet( request = legacy_server.sent(commands.SendAps)[-1] assert request.data == b"\x01\x02\x03" - assert request.aps_seq == 1 assert request.radius == 30 for field, value in expected.items(): assert getattr(request, field) == value diff --git a/zigpy_ziggurat/zigbee/application.py b/zigpy_ziggurat/zigbee/application.py index 77b8211..7a6feac 100644 --- a/zigpy_ziggurat/zigbee/application.py +++ b/zigpy_ziggurat/zigbee/application.py @@ -6,6 +6,7 @@ import logging import math import os +import random import statistics from typing import Any, cast @@ -106,7 +107,10 @@ def __init__(self, config: dict[str, Any]) -> None: super().__init__(config) self._api: ZigguratApi | None = None self._start_time: datetime | None = None - self._aps_counter = 0 + + # Randomized so a reconnect does not replay counters a device still remembers + # for duplicate rejection, which it would ack but not deliver + self._aps_counter = random.randint(0x00, 0xFF) async def connect(self) -> None: # The device path is either the WebSocket URL of a ziggurat server or the @@ -840,8 +844,7 @@ async def send_packet(self, packet: t.ZigbeePacket) -> None: send: p.SendUnicast | p.SendBroadcast | p.SendGroupcast async with self._limit_concurrency(priority=packet.priority): - # Allocated here rather than above the semaphore so counters advance in the - # order the server receives the frames + # Allocated inside the semaphore so counters advance in send order aps_seq = self._next_aps_counter() if dst.addr_mode == t.AddrMode.Group: