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..a92a084 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -919,6 +919,74 @@ 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, + 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.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, + 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..068c218 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,27 @@ 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", "expected"), + [ + # 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, expected: bytes +) -> None: + send = p.SendGpdf.build( + target=target, + command_id=0xFE, + payload=b"\x00", + channel=15, + 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..9b8ccbd 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,31 @@ 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, + 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, + 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..6867361 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,47 @@ 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, + 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, + 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)), + 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 8efb829..d66a55c 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,30 @@ class SendGroupcastPayload(t.Struct): asdu: t.LongOctetString +class GpdfTargetKind(t.enum2): + SRC_ID = 0 + IEEE = 1 + MAINTENANCE = 2 + + +class SendGpdfPayload(t.Struct): + target: GpdfTargetKind + 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,) + ) + 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 + command_id: t.uint8_t + payload: t.LVBytes + + class CancelRequestPayload(t.Struct): request_id: t.uint16_t @@ -430,6 +455,8 @@ class SendStatus(t.enum8): FRAME_BUDGET_EXHAUSTED = 10 CANCELLED = 11 RADIO_ERROR = 12 + GPDF_EXPIRED = 14 + GPDF_REPLACED = 15 class SendConfirmPayload(t.Struct):