From 272e4d27146a765859d10d1553feca2d4de72787 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 9 Nov 2026 05:30:26 +0000 Subject: [PATCH 1/3] Sync wire format with new GPDF statuses --- zigpy_ziggurat/zigbee/wire.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/zigpy_ziggurat/zigbee/wire.py b/zigpy_ziggurat/zigbee/wire.py index 8efb829..e1c0ad3 100644 --- a/zigpy_ziggurat/zigbee/wire.py +++ b/zigpy_ziggurat/zigbee/wire.py @@ -430,6 +430,9 @@ class SendStatus(t.enum8): FRAME_BUDGET_EXHAUSTED = 10 CANCELLED = 11 RADIO_ERROR = 12 + GPDF_EXPIRED = 14 + GPDF_REPLACED = 15 + GPDF_NO_KEY = 16 class SendConfirmPayload(t.Struct): From 852da4a5538b06bedb166484cb4e30527985540c Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 9 Nov 2026 22:14:11 +0000 Subject: [PATCH 2/3] Implement `_send_gpdf` --- tests/common.py | 8 ++++ tests/test_application.py | 72 ++++++++++++++++++++++++++++ tests/test_protocol.py | 38 +++++++++++++++ zigpy_ziggurat/zigbee/api.py | 2 +- zigpy_ziggurat/zigbee/application.py | 28 +++++++++++ zigpy_ziggurat/zigbee/protocol.py | 45 +++++++++++++++++ zigpy_ziggurat/zigbee/wire.py | 29 +++++++++++ 7 files changed, 221 insertions(+), 1 deletion(-) diff --git a/tests/common.py b/tests/common.py index 6e298a6..9b0ee27 100644 --- a/tests/common.py +++ b/tests/common.py @@ -118,6 +118,7 @@ def __init__(self) -> None: p.RequestCommand.SEND_UNICAST: self.on_send_unicast, p.RequestCommand.SEND_BROADCAST: self.on_send_broadcast, p.RequestCommand.SEND_GROUPCAST: self.on_send_groupcast, + p.RequestCommand.SEND_GPDF: self.on_send_gpdf, p.RequestCommand.CANCEL_REQUEST: self.on_cancel_request, p.RequestCommand.PERMIT_JOINS: self.on_empty_ok, p.RequestCommand.SET_CHANNEL: self.on_empty_ok, @@ -316,6 +317,13 @@ async def on_send_groupcast( ) return None + async def on_send_gpdf(self, request: p.SendGpdf, request_id: int) -> None: + # Sent once its GPD listened + await self.send_notification( + p.SendConfirm(status=p.SendStatus.SUCCESS), request_id + ) + return None + async def on_cancel_request( self, request: p.CancelRequest, request_id: int ) -> p.CancelResult: diff --git a/tests/test_application.py b/tests/test_application.py index 00fdb12..fe2bf5f 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -919,6 +919,78 @@ async def no_route(request: p.SendUnicast, request_id: int) -> None: ) +@pytest.mark.parametrize( + ("target", "kind", "src_id", "gpd_ieee", "endpoint"), + [ + ( + zgp.types.SrcID(0x12345678), + p.wire.GpdfTargetKind.SRC_ID, + 0x12345678, + None, + None, + ), + ( + (DEVICE_IEEE, 0x01), + p.wire.GpdfTargetKind.IEEE, + None, + DEVICE_IEEE, + 0x01, + ), + (None, p.wire.GpdfTargetKind.MAINTENANCE, None, None, None), + ], +) +async def test_send_gpdf( + app: ControllerApplication, + server: SyntheticZiggurat, + target: zgp.types.SrcID | tuple[t.EUI64, int] | None, + kind: p.wire.GpdfTargetKind, + src_id: int | None, + gpd_ieee: t.EUI64 | None, + endpoint: int | None, +) -> None: + await app.send_gpdf( + target=target, + command_id=zgp.types.GPDCommandID(0xFE), + payload=b"\x00", + channel=15, + key=t.KeyData(range(16)), + lifetime=timedelta(seconds=20), + ) + + send = server.sent(p.SendGpdf)[-1] + assert send.target == kind + assert send.src_id == src_id + assert send.gpd_ieee == gpd_ieee + assert send.endpoint == endpoint + assert send.channel == 15 + assert send.lifetime_ms == 20_000 + assert send.has_key + assert send.key == t.KeyData(range(16)) + assert send.command_id == 0xFE + assert bytes(send.payload) == b"\x00" + + +async def test_send_gpdf_not_sent( + app: ControllerApplication, server: SyntheticZiggurat +) -> None: + async def expired(request: p.SendGpdf, request_id: int) -> None: + await server.send_notification( + p.SendConfirm(status=p.SendStatus.GPDF_EXPIRED), request_id + ) + + server.handlers[p.RequestCommand.SEND_GPDF] = expired + + with pytest.raises(DeliveryError, match="GPDF_EXPIRED"): + await app.send_gpdf( + target=zgp.types.SrcID(0x12345678), + command_id=zgp.types.GPDCommandID(0xFE), + payload=b"\x00", + channel=15, + key=None, + lifetime=timedelta(seconds=20), + ) + + async def test_add_endpoint( app: ControllerApplication, server: SyntheticZiggurat ) -> None: diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 9a53d73..933e675 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -4,6 +4,7 @@ import pytest import zigpy.types as t +from zigpy.zgp.types import SrcID from zigpy_ziggurat.zigbee import protocol as p @@ -119,3 +120,40 @@ def test_load_request_round_trip(request_obj: p.Request) -> None: parsed, rest = type(request_obj).deserialize(request_obj.serialize()) assert rest == b"" assert parsed == request_obj + + +@pytest.mark.parametrize( + ("target", "key", "expected"), + [ + # Target kind in bits 0-1 and the key flag in bit 2 of the first byte + ( + SrcID(0x12345678), + t.KeyData(range(16)), + bytes.fromhex("04 78563412 0F 204E0000") + + bytes(range(16)) + + bytes.fromhex("FE 01 00"), + ), + ( + (_IEEE, 0x01), + None, + bytes.fromhex("01 7766554433221100 01 0F 204E0000 FE 01 00"), + ), + (None, None, bytes.fromhex("02 0F 204E0000 FE 01 00")), + ], +) +def test_send_gpdf_build( + target: SrcID | tuple[t.EUI64, int] | None, + key: t.KeyData | None, + expected: bytes, +) -> None: + send = p.SendGpdf.build( + target=target, + command_id=0xFE, + payload=b"\x00", + channel=15, + key=key, + lifetime=timedelta(seconds=20), + ) + + assert send.serialize() == expected + assert p.SendGpdf.deserialize(expected) == (send, b"") diff --git a/zigpy_ziggurat/zigbee/api.py b/zigpy_ziggurat/zigbee/api.py index d2c4102..02ec32c 100644 --- a/zigpy_ziggurat/zigbee/api.py +++ b/zigpy_ziggurat/zigbee/api.py @@ -124,7 +124,7 @@ async def request(self, request: p.Request) -> p.Response | None: self._pending.pop(request_id, None) async def request_confirmed( - self, send: p.SendUnicast | p.SendBroadcast | p.SendGroupcast + self, send: p.SendUnicast | p.SendBroadcast | p.SendGroupcast | p.SendGpdf ) -> None: """Send and await the terminal confirmation.""" diff --git a/zigpy_ziggurat/zigbee/application.py b/zigpy_ziggurat/zigbee/application.py index 7a5ce36..74f9474 100644 --- a/zigpy_ziggurat/zigbee/application.py +++ b/zigpy_ziggurat/zigbee/application.py @@ -18,6 +18,7 @@ import zigpy.state import zigpy.types as t import zigpy.zdo.types as zdo_t +from zigpy.zgp.types import GPDCommandID, SrcID from zigpy.zgp.util import parse_gpdf from zigpy_ziggurat.config import CONF_TUNABLES, CONF_ZIGGURAT_CONFIG, CONFIG_SCHEMA @@ -836,6 +837,33 @@ def _handle_received_gpdf(self, command: p.ReceivedGpdf) -> None: self.gp_packet_received(packet) + async def _send_gpdf( + self, + *, + target: SrcID | tuple[t.EUI64, int] | None, + command_id: GPDCommandID, + payload: bytes, + channel: int, + key: t.KeyData | None, + lifetime: timedelta, + ) -> None: + # Ziggurat holds the GPDF until its GPD sends a GPDF with RxAfterTx, then sends + # it in the GPD's receive window + assert self._api is not None + _, status = await self._api.request_confirmed( + p.SendGpdf.build( + target=target, + command_id=command_id, + payload=payload, + channel=channel, + key=key, + lifetime=lifetime, + ) + ) + + if status != p.SendStatus.SUCCESS: + raise DeliveryError(f"GPDF not sent: {status.name}") + async def send_packet(self, packet: t.ZigbeePacket) -> None: dst = packet.dst assert dst is not None and dst.address is not None diff --git a/zigpy_ziggurat/zigbee/protocol.py b/zigpy_ziggurat/zigbee/protocol.py index f5c6f87..f2aec15 100644 --- a/zigpy_ziggurat/zigbee/protocol.py +++ b/zigpy_ziggurat/zigbee/protocol.py @@ -7,6 +7,7 @@ from zigpy.exceptions import DeliveryError import zigpy.types as t +from zigpy.zgp.types import SrcID from zigpy_ziggurat.zigbee import wire @@ -296,6 +297,50 @@ def build( ) +class SendGpdf(Request, wire.SendGpdfPayload): + command = RequestCommand.SEND_GPDF + + @classmethod + def build( + cls, + *, + target: SrcID | tuple[t.EUI64, int] | None, + command_id: int, + payload: bytes, + channel: int, + key: t.KeyData | None, + lifetime: timedelta, + ) -> SendGpdf: + src_id: t.uint32_t | None = None + gpd_ieee: t.EUI64 | None = None + endpoint: t.uint8_t | None = None + + match target: + case None: + kind = wire.GpdfTargetKind.MAINTENANCE + case (ieee, ep): + kind = wire.GpdfTargetKind.IEEE + gpd_ieee = ieee + endpoint = t.uint8_t(ep) + case _: + kind = wire.GpdfTargetKind.SRC_ID + src_id = t.uint32_t(target) + + return cls( + target=kind, + has_key=t.uint1_t(key is not None), + reserved=t.uint5_t(0), + src_id=src_id, + gpd_ieee=gpd_ieee, + endpoint=endpoint, + channel=t.uint8_t(channel), + lifetime_ms=t.uint32_t(lifetime // timedelta(milliseconds=1)), + key=key, + command_id=t.uint8_t(command_id), + payload=t.LVBytes(payload), + ) + + class PermitJoins(Request, wire.PermitJoinsPayload): command = RequestCommand.PERMIT_JOINS diff --git a/zigpy_ziggurat/zigbee/wire.py b/zigpy_ziggurat/zigbee/wire.py index e1c0ad3..07952d5 100644 --- a/zigpy_ziggurat/zigbee/wire.py +++ b/zigpy_ziggurat/zigbee/wire.py @@ -42,6 +42,7 @@ class RequestCommand(t.enum8): SET_NWK_UPDATE_ID = 0x36 SET_PROVISIONAL_KEY = 0x37 SET_TUNABLE = 0x38 + SEND_GPDF = 0x39 ENERGY_SCAN = 0x40 NETWORK_SCAN = 0x41 PACKET_CAPTURE = 0x42 @@ -312,6 +313,34 @@ class SendGroupcastPayload(t.Struct): asdu: t.LongOctetString +class GpdfTargetKind(t.enum2): + SRC_ID = 0 + IEEE = 1 + MAINTENANCE = 2 + + +class SendGpdfPayload(t.Struct): + target: GpdfTargetKind + has_key: t.uint1_t + reserved: t.uint5_t + src_id: t.uint32_t = t.StructField( # type: ignore[assignment] + requires=lambda s: cast(SendGpdfPayload, s).target in (GpdfTargetKind.SRC_ID,) + ) + gpd_ieee: t.EUI64 = t.StructField( # type: ignore[assignment] + requires=lambda s: cast(SendGpdfPayload, s).target in (GpdfTargetKind.IEEE,) + ) + endpoint: t.uint8_t = t.StructField( # type: ignore[assignment] + requires=lambda s: cast(SendGpdfPayload, s).target in (GpdfTargetKind.IEEE,) + ) + channel: t.uint8_t + lifetime_ms: t.uint32_t + key: t.KeyData = t.StructField( # type: ignore[assignment] + requires=lambda s: bool(cast(SendGpdfPayload, s).has_key) + ) + command_id: t.uint8_t + payload: t.LVBytes + + class CancelRequestPayload(t.Struct): request_id: t.uint16_t From 305c8800ffeec1d69fddace232b0b674a34b4908 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 11 Nov 2026 02:24:37 +0000 Subject: [PATCH 3/3] Drop `key` --- tests/test_application.py | 4 ---- tests/test_protocol.py | 25 ++++++------------------- zigpy_ziggurat/zigbee/application.py | 2 -- zigpy_ziggurat/zigbee/protocol.py | 5 +---- zigpy_ziggurat/zigbee/wire.py | 7 +------ 5 files changed, 8 insertions(+), 35 deletions(-) diff --git a/tests/test_application.py b/tests/test_application.py index fe2bf5f..a92a084 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -953,7 +953,6 @@ async def test_send_gpdf( command_id=zgp.types.GPDCommandID(0xFE), payload=b"\x00", channel=15, - key=t.KeyData(range(16)), lifetime=timedelta(seconds=20), ) @@ -964,8 +963,6 @@ async def test_send_gpdf( assert send.endpoint == endpoint assert send.channel == 15 assert send.lifetime_ms == 20_000 - assert send.has_key - assert send.key == t.KeyData(range(16)) assert send.command_id == 0xFE assert bytes(send.payload) == b"\x00" @@ -986,7 +983,6 @@ async def expired(request: p.SendGpdf, request_id: int) -> None: command_id=zgp.types.GPDCommandID(0xFE), payload=b"\x00", channel=15, - key=None, lifetime=timedelta(seconds=20), ) diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 933e675..068c218 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -123,35 +123,22 @@ def test_load_request_round_trip(request_obj: p.Request) -> None: @pytest.mark.parametrize( - ("target", "key", "expected"), + ("target", "expected"), [ - # Target kind in bits 0-1 and the key flag in bit 2 of the first byte - ( - SrcID(0x12345678), - t.KeyData(range(16)), - bytes.fromhex("04 78563412 0F 204E0000") - + bytes(range(16)) - + bytes.fromhex("FE 01 00"), - ), - ( - (_IEEE, 0x01), - None, - bytes.fromhex("01 7766554433221100 01 0F 204E0000 FE 01 00"), - ), - (None, None, bytes.fromhex("02 0F 204E0000 FE 01 00")), + # Target kind in bits 0-1 of the first byte + (SrcID(0x12345678), bytes.fromhex("00 78563412 0F 204E0000 FE 01 00")), + ((_IEEE, 0x01), bytes.fromhex("01 7766554433221100 01 0F 204E0000 FE 01 00")), + (None, bytes.fromhex("02 0F 204E0000 FE 01 00")), ], ) def test_send_gpdf_build( - target: SrcID | tuple[t.EUI64, int] | None, - key: t.KeyData | None, - expected: bytes, + target: SrcID | tuple[t.EUI64, int] | None, expected: bytes ) -> None: send = p.SendGpdf.build( target=target, command_id=0xFE, payload=b"\x00", channel=15, - key=key, lifetime=timedelta(seconds=20), ) diff --git a/zigpy_ziggurat/zigbee/application.py b/zigpy_ziggurat/zigbee/application.py index 74f9474..9b8ccbd 100644 --- a/zigpy_ziggurat/zigbee/application.py +++ b/zigpy_ziggurat/zigbee/application.py @@ -844,7 +844,6 @@ async def _send_gpdf( command_id: GPDCommandID, payload: bytes, channel: int, - key: t.KeyData | None, lifetime: timedelta, ) -> None: # Ziggurat holds the GPDF until its GPD sends a GPDF with RxAfterTx, then sends @@ -856,7 +855,6 @@ async def _send_gpdf( command_id=command_id, payload=payload, channel=channel, - key=key, lifetime=lifetime, ) ) diff --git a/zigpy_ziggurat/zigbee/protocol.py b/zigpy_ziggurat/zigbee/protocol.py index f2aec15..6867361 100644 --- a/zigpy_ziggurat/zigbee/protocol.py +++ b/zigpy_ziggurat/zigbee/protocol.py @@ -308,7 +308,6 @@ def build( command_id: int, payload: bytes, channel: int, - key: t.KeyData | None, lifetime: timedelta, ) -> SendGpdf: src_id: t.uint32_t | None = None @@ -328,14 +327,12 @@ def build( return cls( target=kind, - has_key=t.uint1_t(key is not None), - reserved=t.uint5_t(0), + reserved=t.uint6_t(0), src_id=src_id, gpd_ieee=gpd_ieee, endpoint=endpoint, channel=t.uint8_t(channel), lifetime_ms=t.uint32_t(lifetime // timedelta(milliseconds=1)), - key=key, command_id=t.uint8_t(command_id), payload=t.LVBytes(payload), ) diff --git a/zigpy_ziggurat/zigbee/wire.py b/zigpy_ziggurat/zigbee/wire.py index 07952d5..d66a55c 100644 --- a/zigpy_ziggurat/zigbee/wire.py +++ b/zigpy_ziggurat/zigbee/wire.py @@ -321,8 +321,7 @@ class GpdfTargetKind(t.enum2): class SendGpdfPayload(t.Struct): target: GpdfTargetKind - has_key: t.uint1_t - reserved: t.uint5_t + reserved: t.uint6_t src_id: t.uint32_t = t.StructField( # type: ignore[assignment] requires=lambda s: cast(SendGpdfPayload, s).target in (GpdfTargetKind.SRC_ID,) ) @@ -334,9 +333,6 @@ class SendGpdfPayload(t.Struct): ) channel: t.uint8_t lifetime_ms: t.uint32_t - key: t.KeyData = t.StructField( # type: ignore[assignment] - requires=lambda s: bool(cast(SendGpdfPayload, s).has_key) - ) command_id: t.uint8_t payload: t.LVBytes @@ -461,7 +457,6 @@ class SendStatus(t.enum8): RADIO_ERROR = 12 GPDF_EXPIRED = 14 GPDF_REPLACED = 15 - GPDF_NO_KEY = 16 class SendConfirmPayload(t.Struct):