From 74663dc106951b7ed468277f618242f33a76f06a Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Sun, 27 Sep 2026 23:15:34 +0000 Subject: [PATCH 1/6] Drop `aps_seq` --- zigpy_ziggurat/zigbee/wire.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/zigpy_ziggurat/zigbee/wire.py b/zigpy_ziggurat/zigbee/wire.py index 6b45221..2965d8b 100644 --- a/zigpy_ziggurat/zigbee/wire.py +++ b/zigpy_ziggurat/zigbee/wire.py @@ -267,7 +267,7 @@ class SendUnicastPayload(t.Struct): cluster_id: t.uint16_t src_ep: t.uint8_t dst_ep: t.uint8_t - aps_seq: t.uint8_t + _aps_seq: t.uint8_t radius: t.uint8_t priority: t.int8s route: RouteControl @@ -289,7 +289,7 @@ class SendBroadcastPayload(t.Struct): cluster_id: t.uint16_t src_ep: t.uint8_t dst_ep: t.uint8_t - aps_seq: t.uint8_t + _aps_seq: t.uint8_t radius: t.uint8_t priority: t.int8s asdu: t.LongOctetString @@ -301,7 +301,7 @@ class SendGroupcastPayload(t.Struct): profile_id: t.uint16_t cluster_id: t.uint16_t src_ep: t.uint8_t - aps_seq: t.uint8_t + _aps_seq: t.uint8_t radius: t.uint8_t priority: t.int8s asdu: t.LongOctetString From 2405aa43bfbc719340c4985662e405066d862430 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Sun, 27 Sep 2026 23:15:58 +0000 Subject: [PATCH 2/6] Expand `has_group` to add `reserved` and `aps_encrypted` --- zigpy_ziggurat/zigbee/wire.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/zigpy_ziggurat/zigbee/wire.py b/zigpy_ziggurat/zigbee/wire.py index 2965d8b..1858fbd 100644 --- a/zigpy_ziggurat/zigbee/wire.py +++ b/zigpy_ziggurat/zigbee/wire.py @@ -389,7 +389,9 @@ class LastResetPayload(t.Struct): class ReceivedApsPayload(t.Struct): source: t.NWK destination: t.NWK - has_group: t.Bool + has_group: t.uint1_t + aps_encrypted: t.uint1_t + reserved: t.uint6_t group: t.uint16_t profile_id: t.uint16_t cluster_id: t.uint16_t From 77c1a527283621561519db93ced0039c97f8e4a1 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Sun, 27 Sep 2026 23:17:29 +0000 Subject: [PATCH 3/6] Drop `aps_seq` --- tests/test_api.py | 3 +-- tests/test_application.py | 3 --- zigpy_ziggurat/zigbee/application.py | 3 --- zigpy_ziggurat/zigbee/protocol.py | 9 +++------ 4 files changed, 4 insertions(+), 14 deletions(-) diff --git a/tests/test_api.py b/tests/test_api.py index b15fbdd..bd4bace 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -28,7 +28,6 @@ def _send_aps(*, aps_ack: bool) -> p.SendUnicast: cluster_id=0x0006, src_ep=1, dst_ep=1, - aps_seq=55, radius=30, priority=0, asdu=b"\x01\x02", @@ -262,7 +261,7 @@ async def test_request_confirmed( ) -> None: """An APS-ack send resolves once the end-to-end APS ack arrives.""" await api.request_confirmed(_send_aps(aps_ack=True)) - assert transport.sent(p.SendUnicast)[-1].aps_seq == 55 + assert transport.sent(p.SendUnicast)[-1].aps_ack async def test_cancel_on_abandon( diff --git a/tests/test_application.py b/tests/test_application.py index 708c7f4..c1fefa4 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -668,7 +668,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 == 33 assert send.radius == 30 assert send.priority == 0 assert bytes(send.asdu) == b"\x01\x02\x03" @@ -750,7 +749,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 == 33 assert bytes(send.asdu) == b"\x01\x02\x03" @@ -766,7 +764,6 @@ async def test_send_packet_groupcast( send = server.sent(p.SendGroupcast)[-1] assert send.group_id == 0x0002 - assert send.aps_seq == 33 assert bytes(send.asdu) == b"\x01\x02\x03" diff --git a/zigpy_ziggurat/zigbee/application.py b/zigpy_ziggurat/zigbee/application.py index 8bb7989..88f29ca 100644 --- a/zigpy_ziggurat/zigbee/application.py +++ b/zigpy_ziggurat/zigbee/application.py @@ -841,7 +841,6 @@ 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, radius=radius, priority=priority, asdu=asdu, @@ -854,7 +853,6 @@ 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, radius=radius, priority=priority, asdu=asdu, @@ -899,7 +897,6 @@ 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, radius=radius, priority=priority, route_control=route_control, diff --git a/zigpy_ziggurat/zigbee/protocol.py b/zigpy_ziggurat/zigbee/protocol.py index 1b8c3c4..e9643eb 100644 --- a/zigpy_ziggurat/zigbee/protocol.py +++ b/zigpy_ziggurat/zigbee/protocol.py @@ -204,7 +204,6 @@ def build( cluster_id: int, src_ep: int, dst_ep: int, - aps_seq: int, radius: int, priority: int, asdu: bytes, @@ -225,7 +224,7 @@ def build( cluster_id=t.uint16_t(cluster_id), src_ep=t.uint8_t(src_ep), dst_ep=t.uint8_t(dst_ep), - aps_seq=t.uint8_t(aps_seq), + _aps_seq=t.uint8_t(0), radius=t.uint8_t(radius), priority=t.int8s(priority), route=route_control, @@ -251,7 +250,6 @@ def build( cluster_id: int, src_ep: int, dst_ep: int, - aps_seq: int, radius: int, priority: int, asdu: bytes, @@ -263,7 +261,7 @@ def build( cluster_id=t.uint16_t(cluster_id), src_ep=t.uint8_t(src_ep), dst_ep=t.uint8_t(dst_ep), - aps_seq=t.uint8_t(aps_seq), + _aps_seq=t.uint8_t(0), radius=t.uint8_t(radius), priority=t.int8s(priority), asdu=t.LongOctetString(asdu), @@ -281,7 +279,6 @@ def build( profile_id: int, cluster_id: int, src_ep: int, - aps_seq: int, radius: int, priority: int, asdu: bytes, @@ -292,7 +289,7 @@ def build( profile_id=t.uint16_t(profile_id), cluster_id=t.uint16_t(cluster_id), src_ep=t.uint8_t(src_ep), - aps_seq=t.uint8_t(aps_seq), + _aps_seq=t.uint8_t(0), radius=t.uint8_t(radius), priority=t.int8s(priority), asdu=t.LongOctetString(asdu), From 2dcc3ce6faa75919570aaac3efce9ed4ad747511 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Sun, 27 Sep 2026 23:18:31 +0000 Subject: [PATCH 4/6] Wire up `aps_encrypted` --- tests/test_api.py | 4 ++- tests/test_application.py | 50 ++++++++++++++++++++++++++-- zigpy_ziggurat/zigbee/application.py | 5 +++ 3 files changed, 56 insertions(+), 3 deletions(-) diff --git a/tests/test_api.py b/tests/test_api.py index bd4bace..62bbbee 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -406,7 +406,9 @@ async def test_notifications( p.ReceivedAps( source=t.NWK(0xAB12), destination=t.NWK(0x0000), - has_group=t.Bool(False), + has_group=t.uint1_t(0), + aps_encrypted=t.uint1_t(0), + reserved=t.uint6_t(0), group=t.uint16_t(0), profile_id=t.uint16_t(0x0104), cluster_id=t.uint16_t(0x0006), diff --git a/tests/test_application.py b/tests/test_application.py index c1fefa4..208b69b 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -1067,7 +1067,9 @@ async def test_on_notification_received_aps( p.ReceivedAps( source=DEVICE_NWK, destination=t.NWK(0x0000), - has_group=t.Bool(False), + has_group=t.uint1_t(0), + aps_encrypted=t.uint1_t(0), + reserved=t.uint6_t(0), group=t.uint16_t(0), profile_id=t.uint16_t(0x0000), cluster_id=t.uint16_t(zdo_t.ZDOCmd.Node_Desc_req), @@ -1122,7 +1124,9 @@ async def test_on_notification_received_aps_address_modes( p.ReceivedAps( source=DEVICE_NWK, destination=destination, - has_group=t.Bool(group is not None), + has_group=t.uint1_t(group is not None), + aps_encrypted=t.uint1_t(0), + reserved=t.uint6_t(0), group=t.uint16_t(group or 0), profile_id=t.uint16_t(0x0104), cluster_id=t.uint16_t(0x0006), @@ -1147,6 +1151,48 @@ async def test_on_notification_received_aps_address_modes( await app.shutdown(db=False) +@pytest.mark.parametrize( + ("aps_encrypted", "expected_tx_options"), + [ + (0, t.TransmitOptions.NONE), + (1, t.TransmitOptions.APS_Encryption), + ], +) +async def test_on_notification_received_aps_encrypted( + server: SyntheticZiggurat, + aps_encrypted: int, + expected_tx_options: t.TransmitOptions, +) -> None: + app = RecordingApplication(make_app_config(server.url)) + await app.connect() + await app.start_network() + add_initialized_device(app) + + await server.send_notification( + p.ReceivedAps( + source=DEVICE_NWK, + destination=t.NWK(0x0000), + has_group=t.uint1_t(0), + aps_encrypted=t.uint1_t(aps_encrypted), + reserved=t.uint6_t(0), + group=t.uint16_t(0), + profile_id=t.uint16_t(0x0104), + cluster_id=t.uint16_t(0x0006), + src_ep=t.uint8_t(1), + dst_ep=t.uint8_t(1), + lqi=t.uint8_t(200), + rssi=t.int8s(-70), + data=t.LongOctetString(b"\x00\x01\x00\x00\x00"), + ) + ) + await flush(app) + + assert len(app.packets) == 1 + assert app.packets[0].tx_options == expected_tx_options + + await app.shutdown(db=False) + + async def test_on_notification_frame_counter( app: ControllerApplication, server: SyntheticZiggurat ) -> None: diff --git a/zigpy_ziggurat/zigbee/application.py b/zigpy_ziggurat/zigbee/application.py index 88f29ca..4e5864b 100644 --- a/zigpy_ziggurat/zigbee/application.py +++ b/zigpy_ziggurat/zigbee/application.py @@ -794,6 +794,11 @@ def _handle_received_aps_command(self, command: p.ReceivedAps) -> None: cluster_id=command.cluster_id, lqi=command.lqi, rssi=command.rssi, + tx_options=( + t.TransmitOptions.APS_Encryption + if command.aps_encrypted + else t.TransmitOptions.NONE + ), data=t.SerializableBytes(command.data), ) self.packet_received(packet) From a8e0a32addf4c31d8cacd962c3b939c566f91f6a Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Sun, 27 Sep 2026 23:38:31 +0000 Subject: [PATCH 5/6] Reply to ZDO with the same APS security as the incoming request --- tests/test_application.py | 29 ++++++++++++++++++++++++++++ zigpy_ziggurat/zigbee/application.py | 6 ++++++ 2 files changed, 35 insertions(+) diff --git a/tests/test_application.py b/tests/test_application.py index 208b69b..343a03f 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -1085,6 +1085,35 @@ async def test_on_notification_received_aps( assert reply.asdu[0] == 0x77 +@pytest.mark.parametrize("aps_encrypted", [0, 1]) +async def test_on_notification_received_aps_zdo_reply_encryption( + app: ControllerApplication, server: SyntheticZiggurat, aps_encrypted: int +) -> None: + """A local ZDO reply uses the same APS security as the request.""" + add_initialized_device(app) + + await server.send_notification( + p.ReceivedAps( + source=DEVICE_NWK, + destination=t.NWK(0x0000), + has_group=t.uint1_t(0), + aps_encrypted=t.uint1_t(aps_encrypted), + reserved=t.uint6_t(0), + group=t.uint16_t(0), + profile_id=t.uint16_t(0x0000), + cluster_id=t.uint16_t(zdo_t.ZDOCmd.Node_Desc_req), + src_ep=t.uint8_t(0), + dst_ep=t.uint8_t(0), + lqi=t.uint8_t(255), + rssi=t.int8s(-40), + data=t.LongOctetString(b"\x77" + t.NWK(0x0000).serialize()), + ) + ) + reply = await server.wait_for(p.SendUnicast) + assert reply.cluster_id == zdo_t.ZDOCmd.Node_Desc_rsp + assert reply.aps_encryption == aps_encrypted + + @pytest.mark.parametrize( ("destination", "group", "expected_dst"), [ diff --git a/zigpy_ziggurat/zigbee/application.py b/zigpy_ziggurat/zigbee/application.py index 4e5864b..b792849 100644 --- a/zigpy_ziggurat/zigbee/application.py +++ b/zigpy_ziggurat/zigbee/application.py @@ -614,6 +614,9 @@ def _maybe_handle_local_zdo_request(self, packet: t.ZigbeePacket) -> None: coordinator = self._device nwk = self.state.node_info.nwk + # Answer with the same APS security the request arrived with + aps_encryption = t.TransmitOptions.APS_Encryption in packet.tx_options + if hdr.command_id == zdo_t.ZDOCmd.Node_Desc_req: # Joining devices read our node descriptor to learn the trust center's # stack compliance revision before attempting the link key exchange @@ -635,6 +638,7 @@ def _maybe_handle_local_zdo_request(self, packet: t.ZigbeePacket) -> None: nwk, node_desc, tsn=hdr.tsn, + aps_encryption=aps_encryption, ) ) elif hdr.command_id == zdo_t.ZDOCmd.Active_EP_req: @@ -645,6 +649,7 @@ def _maybe_handle_local_zdo_request(self, packet: t.ZigbeePacket) -> None: nwk, endpoints, tsn=hdr.tsn, + aps_encryption=aps_encryption, ) ) elif hdr.command_id == zdo_t.ZDOCmd.Simple_Desc_req: @@ -667,6 +672,7 @@ def _maybe_handle_local_zdo_request(self, packet: t.ZigbeePacket) -> None: nwk, descriptor, tsn=hdr.tsn, + aps_encryption=aps_encryption, ) ) From d16c5aca40e34d39db0405b4cb15ad35fd635af0 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Sun, 27 Sep 2026 23:39:28 +0000 Subject: [PATCH 6/6] Bump zigpy to 3.0.0 (unreleased) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index dada0a7..ddfc4c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ readme = "README.md" license = {text = "Apache-2.0"} requires-python = ">=3.12" dependencies = [ - "zigpy", + "zigpy>=3.0.0", "aiohttp", "aiospinel>=1.2.0", ]