diff --git a/pyproject.toml b/pyproject.toml index efdbd36..bb1e0bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "zigpy", "aiohttp", "mashumaro", + "aiospinel>=1.2.0", ] [project.entry-points."zigpy.radio"] diff --git a/tests/common.py b/tests/common.py index 4420b17..e1dead4 100644 --- a/tests/common.py +++ b/tests/common.py @@ -1,58 +1,53 @@ -"""A synthetic ziggurat server: a real aiohttp websocket server speaking the wire -protocol, with per-method handlers that tests can override. The harness decodes -incoming params and encodes responses through the same wire models as the client, -so every serialization strategy is exercised in both directions.""" +"""A synthetic ziggurat server: a real aiohttp websocket server speaking the binary +wire protocol, with per-command handlers that tests can override. The harness decodes +request frames and encodes replies through the same structs as the client, so every +serialization strategy is exercised in both directions.""" import asyncio -from collections.abc import AsyncIterator, Awaitable, Callable -import json +import base64 +from collections.abc import AsyncIterator, Awaitable, Callable, Sequence +import hashlib from typing import Any, TypeVar from aiohttp import web from aiohttp.test_utils import TestServer +import aiospinel import pytest import zigpy.config import zigpy.types as t -from zigpy_ziggurat.zigbee import commands +from zigpy_ziggurat.zigbee import protocol as p from zigpy_ziggurat.zigbee.application import ControllerApplication - - -def _request_types() -> dict[str, type[commands.Request[Any]]]: - """Every concrete request, walking past intermediate bases like - `StreamingRequest` that declare `method` without assigning it.""" - result: dict[str, type[commands.Request[Any]]] = {} - stack = list(commands.Request.__subclasses__()) - while stack: - cls = stack.pop() - stack.extend(cls.__subclasses__()) - if "method" in cls.__dict__: - result[cls.method] = cls - return result - - -REQUEST_TYPES: dict[str, type[commands.Request[Any]]] = _request_types() -NOTIFICATION_EVENTS: dict[type[commands.Notification], str] = { - cls: name for name, cls in commands.NOTIFICATIONS.items() -} +from zigpy_ziggurat.zigbee.transport import PROP_VENDOR_ZIGGURAT COORDINATOR_IEEE = t.EUI64.convert("00:11:22:33:44:55:66:77") NETWORK_KEY = t.KeyData.convert("11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff:00") +TC_LINK_KEY = t.KeyData(b"ZigBeeAlliance09") + +DEVICE_IEEE = t.EUI64.convert("aa:aa:aa:aa:aa:aa:aa:aa") +DEVICE_NWK = t.NWK(0xAB12) +LINK_KEY = t.KeyData.convert("00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff") REQUEST_T = TypeVar("REQUEST_T") +Handler = Callable[[Any, int], Awaitable[p.Response | None]] -class RpcError(Exception): - """Raised by a handler to produce an error response.""" +# Notification struct -> command id, the inverse of the client's decode table. +NOTIFICATION_COMMANDS: dict[type[p.Notification], p.NotificationCommand] = { + cls: command for command, cls in p.NOTIFICATIONS.items() +} - def __init__(self, code: str, message: str) -> None: - super().__init__(f"{code}: {message}") - self.code = code - self.message = message +class StatusError(Exception): + """Raised by a handler to reply with an error status instead of an OK.""" -def make_network_info() -> commands.NetworkInfo: - return commands.NetworkInfo( + def __init__(self, status: p.Status) -> None: + super().__init__(status.name) + self.status = status + + +def make_network_state() -> p.NetworkState: + return p.NetworkState( channel=t.uint8_t(15), nwk_update_id=t.uint8_t(0), pan_id=t.PanId(0x1A2B), @@ -62,15 +57,21 @@ def make_network_info() -> commands.NetworkInfo: network_key=NETWORK_KEY, network_key_seq=t.uint8_t(0), network_key_tx_counter=t.uint32_t(1000), - tc_link_key=t.KeyData(b"ZigBeeAlliance09"), - tx_power=8, - tclk_seed=None, - tclk_flavor=None, - key_table=[], + tc_link_key=TC_LINK_KEY, + has_tclk_seed=t.Bool(False), + tclk_seed=t.KeyData(bytes(16)), + tclk_flavor=p.TclkFlavorId.EZSP, + tx_power=t.int8s(8), + aps_frame_counter=t.uint32_t(2000), ) class SyntheticZiggurat: + """A websocket server speaking the binary protocol, driven by per-command + handlers. A handler returns the OK response payload (`None` for an empty OK), + emits any streamed events itself, and raises `StatusError` to reply with an + error status.""" + def __init__(self) -> None: self.web_app = web.Application() self.web_app.router.add_get("/", self._handle_connection) @@ -78,22 +79,46 @@ def __init__(self) -> None: self.connections = 0 self._ws: web.WebSocketResponse | None = None self._transport: asyncio.Transport | None = None - self.requests: list[Any] = [] - self._configured: commands.Configure | None = None - self.network_info = make_network_info() + self.requests: list[p.Request] = [] + self.network_state = make_network_state() + self.started = True self.hw_address = t.EUI64.convert("11:22:33:44:55:66:77:88") - self.handlers: dict[str, Callable[[Any, int], Awaitable[Any]]] = { - "ping": self.on_ping, - "configure": self.on_configure, - "get_network_info": self.on_get_network_info, - "get_hw_address": self.on_get_hw_address, - "send_aps": self.on_send_aps, - "energy_scan": self.on_energy_scan, - "network_scan": self.on_network_scan, - "permit_joins": self.on_status, - "set_provisional_key": self.on_status, - "set_channel": self.on_status, - "set_nwk_update_id": self.on_status, + self.key_table: list[p.KeyEntry] = [] + self.children: list[p.ChildEntry] = [] + self.address_cache: list[p.AddressEntry] = [] + self.route_table: list[p.RouteEntry] = [] + self.beacons: list[p.Beacon] = [] + self.captured_packets: list[p.CapturedPacket] = [] + self.handlers: dict[p.RequestCommand, Handler] = { + p.RequestCommand.RESET: self.on_empty_ok, + p.RequestCommand.SHUTDOWN: self.on_empty_ok, + p.RequestCommand.GET_FIRMWARE_INFO: self.on_get_firmware_info, + p.RequestCommand.GET_HW_ADDRESS: self.on_get_hw_address, + p.RequestCommand.GET_NETWORK_INFO: self.on_get_network_info, + p.RequestCommand.SCAN_KEY_TABLE: self.on_scan_key_table, + p.RequestCommand.SCAN_CHILDREN: self.on_scan_children, + p.RequestCommand.SCAN_ADDRESS_CACHE: self.on_scan_address_cache, + p.RequestCommand.SCAN_ROUTE_TABLE: self.on_scan_route_table, + p.RequestCommand.CONFIGURE: self.on_empty_ok, + p.RequestCommand.LOAD_KEY_TABLE: self.on_empty_ok, + p.RequestCommand.LOAD_CHILDREN: self.on_empty_ok, + p.RequestCommand.LOAD_ADDRESS_CACHE: self.on_empty_ok, + p.RequestCommand.LOAD_ROUTE_TABLE: self.on_empty_ok, + p.RequestCommand.LOAD_SOURCE_ROUTES: self.on_empty_ok, + p.RequestCommand.START_NETWORK: self.on_empty_ok, + 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.CANCEL_REQUEST: self.on_cancel_request, + p.RequestCommand.PERMIT_JOINS: self.on_empty_ok, + p.RequestCommand.SET_CHANNEL: self.on_empty_ok, + p.RequestCommand.SET_NWK_UPDATE_ID: self.on_empty_ok, + p.RequestCommand.SET_PROVISIONAL_KEY: self.on_empty_ok, + p.RequestCommand.SET_TUNABLE: self.on_empty_ok, + p.RequestCommand.ENERGY_SCAN: self.on_energy_scan, + p.RequestCommand.NETWORK_SCAN: self.on_network_scan, + p.RequestCommand.PACKET_CAPTURE: self.on_packet_capture, + p.RequestCommand.PACKET_CAPTURE_CHANNEL: self.on_empty_ok, } @property @@ -107,69 +132,74 @@ def transport(self) -> asyncio.Transport: return self._transport @property - def configured(self) -> commands.Configure: - assert self._configured is not None - return self._configured + def configured(self) -> p.NetworkState: + return self.sent(p.Configure)[-1].state - async def _handle_connection(self, request: web.Request) -> web.WebSocketResponse: + async def _handle_connection( + self, http_request: web.Request + ) -> web.WebSocketResponse: self.connections += 1 - self._transport = request.transport + self._transport = http_request.transport ws = web.WebSocketResponse() - await ws.prepare(request) + await ws.prepare(http_request) self._ws = ws - await ws.send_json({"type": "hello", "version": 1, "state": "running"}) + await self.send_notification( + p.Hello( + protocol_version=t.uint8_t(p.PROTOCOL_VERSION), configured=t.Bool(True) + ) + ) async for msg in ws: - data = json.loads(msg.data) - command = REQUEST_TYPES[data["method"]].from_dict(data["params"]) - self.requests.append(command) - await ws.send_json({"type": "event", "id": data["id"], "event": "accepted"}) + header, body = p.Header.deserialize(msg.data) + command = p.RequestCommand(header.command) + request_id = int(header.request_id) + request = p.REQUESTS[command].deserialize(body)[0] + self.requests.append(request) try: - response = await self.handlers[data["method"]](command, data["id"]) - except RpcError as exc: - await ws.send_json( - { - "type": "response", - "id": data["id"], - "error": {"code": exc.code, "message": exc.message}, - } + response = await self.handlers[command](request, request_id) + except StatusError as exc: + await self._emit( + p.FrameType.RESPONSE, command, request_id, bytes([exc.status]) ) else: - # `None` deliberately withholds the response - if response is not None: - await ws.send_json( - { - "type": "response", - "id": data["id"], - "result": response.to_dict(), - } - ) + await self._emit( + p.FrameType.RESPONSE, + command, + request_id, + bytes([p.Status.OK]) + + (response.serialize() if response is not None else b""), + ) return ws - async def send_event(self, request_id: int, event: str) -> None: - await self.ws.send_json({"type": "event", "id": request_id, "event": event}) + async def _emit( + self, + frame_type: p.FrameType, + command: p.RequestCommand | p.NotificationCommand, + request_id: int, + body: bytes = b"", + ) -> None: + await self.ws.send_bytes(p.encode_reply(frame_type, command, request_id, body)) - async def send_event_data( - self, request_id: int, event: str, data: dict[str, Any] + async def send_event( + self, command: p.RequestCommand, request_id: int, payload: p.Response ) -> None: - await self.ws.send_json( - {"type": "event", "id": request_id, "event": event, "data": data} - ) + await self._emit(p.FrameType.EVENT, command, request_id, payload.serialize()) - async def send_notification(self, notification: commands.Notification) -> None: - await self.ws.send_json( - { - "type": "notification", - "event": NOTIFICATION_EVENTS[type(notification)], - "data": notification.to_dict(), - } + async def send_notification( + self, notification: p.Notification, request_id: int = 0 + ) -> None: + await self._emit( + p.FrameType.NOTIFICATION, + NOTIFICATION_COMMANDS[type(notification)], + request_id, + notification.serialize(), ) - async def send_raw(self, text: str) -> None: - await self.ws.send_str(text) + async def send_raw(self, frame: bytes) -> None: + await self.ws.send_bytes(frame) def sent(self, request_type: type[REQUEST_T]) -> list[REQUEST_T]: return [r for r in self.requests if isinstance(r, request_type)] @@ -183,55 +213,298 @@ async def wait_for( return self.sent(request_type)[count - 1] - async def on_ping(self, command: commands.Ping, request_id: int) -> commands.Status: - return commands.Status(status="pong") + # -- default handlers ---------------------------------------------------------- + + async def on_empty_ok(self, request: p.Request, request_id: int) -> None: + return None - async def on_status(self, command: Any, request_id: int) -> commands.Status: - return commands.Status(status="success") + async def on_get_firmware_info( + self, request: p.GetFirmwareInfo, request_id: int + ) -> p.FirmwareInfo: + return p.FirmwareInfo( + protocol_version=t.uint8_t(p.PROTOCOL_VERSION), + version=t.LongCharacterString("ziggurat/synthetic"), + ) - async def on_configure( - self, command: commands.Configure, request_id: int - ) -> commands.Status: - self._configured = command - return commands.Status(status="success") + async def on_get_hw_address( + self, request: p.GetHwAddress, request_id: int + ) -> p.HwAddress: + return p.HwAddress(ieee=self.hw_address) async def on_get_network_info( - self, command: commands.GetNetworkInfo, request_id: int - ) -> commands.NetworkInfo: - return self.network_info + self, request: p.GetNetworkInfo, request_id: int + ) -> p.NetworkInfo: + return p.NetworkInfo( + state=self.network_state, + key_count=t.uint16_t(len(self.key_table)), + started=t.Bool(self.started), + ) - async def on_get_hw_address( - self, command: commands.GetHwAddress, request_id: int - ) -> commands.HwAddress: - return commands.HwAddress(ieee_address=self.hw_address) - - async def on_send_aps( - self, command: commands.SendAps, request_id: int - ) -> commands.Status: - await self.send_event(request_id, "transmitted") - return commands.Status(status="delivered" if command.aps_ack else "sent") - - async def on_energy_scan( - self, command: commands.EnergyScan, request_id: int - ) -> commands.Status: - for channel in command.channels: - await self.send_event_data( + async def _scan( + self, + command: p.RequestCommand, + request_id: int, + entries: Sequence[p.Response], + ) -> p.ScanCount: + for entry in entries: + await self.send_event(command, request_id, entry) + return p.ScanCount(count=t.uint16_t(len(entries))) + + async def on_scan_key_table( + self, request: p.ScanKeyTable, request_id: int + ) -> p.ScanCount: + return await self._scan( + p.RequestCommand.SCAN_KEY_TABLE, request_id, self.key_table + ) + + async def on_scan_children( + self, request: p.ScanChildren, request_id: int + ) -> p.ScanCount: + return await self._scan( + p.RequestCommand.SCAN_CHILDREN, request_id, self.children + ) + + async def on_scan_address_cache( + self, request: p.ScanAddressCache, request_id: int + ) -> p.ScanCount: + return await self._scan( + p.RequestCommand.SCAN_ADDRESS_CACHE, request_id, self.address_cache + ) + + async def on_scan_route_table( + self, request: p.ScanRouteTable, request_id: int + ) -> p.ScanCount: + return await self._scan( + p.RequestCommand.SCAN_ROUTE_TABLE, request_id, self.route_table + ) + + async def on_send_unicast(self, request: p.SendUnicast, request_id: int) -> None: + # The local handoff is terminal for a no-ack unicast; an ack-requested one + # is only confirmed by the end-to-end APS ack that follows. + await self.send_notification( + p.SendConfirm(status=p.SendStatus.SUCCESS), request_id + ) + if request.aps_ack: + await self.send_notification( + p.ApsAckConfirm(status=p.SendStatus.SUCCESS), request_id + ) + return None + + async def on_send_broadcast( + self, request: p.SendBroadcast, request_id: int + ) -> None: + # A broadcast is confirmed by its passive-ack quorum, not by the handoff. + await self.send_notification( + p.BroadcastConfirm(status=p.SendStatus.SUCCESS), request_id + ) + return None + + async def on_send_groupcast( + self, request: p.SendGroupcast, request_id: int + ) -> None: + await self.send_notification( + p.BroadcastConfirm(status=p.SendStatus.SUCCESS), request_id + ) + return None + + async def on_cancel_request( + self, request: p.CancelRequest, request_id: int + ) -> p.CancelResult: + return p.CancelResult(cancelled=t.Bool(True)) + + async def on_energy_scan(self, request: p.EnergyScan, request_id: int) -> None: + for channel in request.channels: + await self.send_event( + p.RequestCommand.ENERGY_SCAN, request_id, - "energy_result", - commands.EnergyScanResult( - channel=t.uint8_t(channel), rssi=t.int8s(-85) - ).to_dict(), + p.EnergyResult(channel=t.uint8_t(channel), rssi=t.int8s(-85)), ) - return commands.Status(status="complete") + return None + + async def on_network_scan(self, request: p.NetworkScan, request_id: int) -> None: + for beacon in self.beacons: + await self.send_event(p.RequestCommand.NETWORK_SCAN, request_id, beacon) + return None + + async def on_packet_capture( + self, request: p.PacketCapture, request_id: int + ) -> None: + for packet in self.captured_packets: + await self.send_event(p.RequestCommand.PACKET_CAPTURE, request_id, packet) + return None + + +class ClosingZiggurat: + """A server that closes without a hello, so the probe cannot pick a protocol.""" + + def __init__(self) -> None: + self.web_app = web.Application() + self.web_app.router.add_get("/", self._handle_connection) + self.url = "" + + async def _handle_connection(self, request: web.Request) -> web.WebSocketResponse: + ws = web.WebSocketResponse() + await ws.prepare(request) + await ws.close() + return ws + + +_WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + + +class ProtocolErrorWebSocket: + """A raw WebSocket server that sends a valid binary hello then a bad opcode.""" + + def __init__(self) -> None: + self.url = "" + self._server: asyncio.Server | None = None + + async def start(self) -> None: + self._server = await asyncio.start_server(self._serve, "127.0.0.1", 0) + port = self._server.sockets[0].getsockname()[1] + self.url = f"ws://127.0.0.1:{port}/" + + async def stop(self) -> None: + assert self._server is not None + self._server.close() + await self._server.wait_closed() + + async def _serve( + self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter + ) -> None: + request = b"" + while b"\r\n\r\n" not in request: + chunk = await reader.read(1024) + if not chunk: + return + request += chunk + + key = "" + for line in request.decode().split("\r\n"): + if line.lower().startswith("sec-websocket-key:"): + key = line.split(":", 1)[1].strip() + accept = base64.b64encode( + hashlib.sha1((key + _WS_GUID).encode()).digest() + ).decode() + + writer.write( + b"HTTP/1.1 101 Switching Protocols\r\n" + b"Upgrade: websocket\r\n" + b"Connection: Upgrade\r\n" + b"Sec-WebSocket-Accept: " + accept.encode() + b"\r\n\r\n" + ) + # A valid FIN+binary frame (the hello), then a frame using reserved opcode + # 0x3, a protocol error the client surfaces as a WSMsgType.ERROR message. + writer.write(b"\x82\x01\x00") + writer.write(b"\x83\x00") + await writer.drain() + writer.close() + + +class SyntheticSpinelRcp: + """A TCP server speaking Spinel, exposing the Ziggurat vendor property.""" + + def __init__( + self, + *, + get_prop_id: aiospinel.PackedUInt21 = PROP_VENDOR_ZIGGURAT, + set_prop_id: aiospinel.PackedUInt21 = PROP_VENDOR_ZIGGURAT, + ) -> None: + self.url = "" + self.tunnel_writes: list[bytes] = [] + self._get_prop_id = get_prop_id + self._set_prop_id = set_prop_id + self._server: asyncio.Server | None = None + self._writers: list[asyncio.StreamWriter] = [] + + async def start(self) -> None: + self._server = await asyncio.start_server(self._serve, "127.0.0.1", 0) + port = self._server.sockets[0].getsockname()[1] + self.url = f"socket://127.0.0.1:{port}" + + async def stop(self) -> None: + assert self._server is not None + for writer in self._writers: + writer.close() + self._server.close() + await self._server.wait_closed() + + async def _serve( + self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter + ) -> None: + self._writers.append(writer) + buffer = bytearray() + while True: + data = await reader.read(1024) + if not data: + break + buffer += data + while True: + chunk, flag, rest = buffer.partition( + bytes([aiospinel.HDLCSpecial.FLAG]) + ) + if not flag: + buffer = bytearray(chunk) + break + buffer = bytearray(rest) + if chunk: + self._handle(aiospinel.HDLCLiteFrame.from_bytes(chunk), writer) + + def _handle( + self, hdlc: aiospinel.HDLCLiteFrame, writer: asyncio.StreamWriter + ) -> None: + frame = aiospinel.SpinelFrame.from_bytes(hdlc.data) + tid = frame.header.transaction_id + if frame.command_id == aiospinel.CommandID.PROP_VALUE_GET: + self._respond(writer, tid, self._get_prop_id.serialize()) + elif frame.command_id == aiospinel.CommandID.PROP_VALUE_SET: + _, rest = aiospinel.PackedUInt21.deserialize(frame.data) + length = int.from_bytes(rest[:2], "little") + self.tunnel_writes.append(rest[2 : 2 + length]) + self._respond(writer, tid, self._set_prop_id.serialize()) + + def _respond( + self, writer: asyncio.StreamWriter, tid: int | None, data: bytes + ) -> None: + frame = aiospinel.SpinelFrame( + header=aiospinel.SpinelHeader( + flag=0b10, network_link_id=0, transaction_id=tid + ), + command_id=aiospinel.CommandID.PROP_VALUE_IS, + data=data, + ) + writer.write(aiospinel.HDLCLiteFrame(data=frame.serialize()).serialize()) - async def on_network_scan( - self, command: commands.NetworkScan, request_id: int - ) -> commands.Status: - return commands.Status(status="complete") + async def push_stream_frame(self, payload: bytes) -> None: + data = ( + PROP_VENDOR_ZIGGURAT.serialize() + + len(payload).to_bytes(2, "little") + + payload + ) + frame = aiospinel.SpinelFrame( + header=aiospinel.SpinelHeader( + flag=0b10, network_link_id=0, transaction_id=0 + ), + command_id=aiospinel.CommandID.PROP_VALUE_IS, + data=data, + ) + encoded = aiospinel.HDLCLiteFrame(data=frame.serialize()).serialize() + for writer in self._writers: + writer.write(encoded) + await writer.drain() + + +def make_app_config(url: str, **extra: Any) -> dict[str, Any]: + return { + zigpy.config.CONF_DEVICE: {zigpy.config.CONF_DEVICE_PATH: url}, + **extra, + } -def make_app_config(url: str) -> dict[str, Any]: - return {zigpy.config.CONF_DEVICE: {zigpy.config.CONF_DEVICE_PATH: url}} +async def flush(app: ControllerApplication) -> None: + """Round-trip a request: the websocket is ordered, so by the time the response + arrives every previously sent notification has been processed.""" + await app._watchdog_feed() @pytest.fixture @@ -246,6 +519,38 @@ async def server() -> AsyncIterator[SyntheticZiggurat]: await test_server.close() +@pytest.fixture +async def spinel_rcp() -> AsyncIterator[SyntheticSpinelRcp]: + rcp = SyntheticSpinelRcp() + await rcp.start() + + yield rcp + + await rcp.stop() + + +@pytest.fixture +async def closing_server() -> AsyncIterator[ClosingZiggurat]: + ziggurat = ClosingZiggurat() + test_server = TestServer(ziggurat.web_app) + await test_server.start_server() + ziggurat.url = f"ws://localhost:{test_server.port}/" + + yield ziggurat + + await test_server.close() + + +@pytest.fixture +async def protocol_error_server() -> AsyncIterator[ProtocolErrorWebSocket]: + ziggurat = ProtocolErrorWebSocket() + await ziggurat.start() + + yield ziggurat + + await ziggurat.stop() + + @pytest.fixture async def connected_app( server: SyntheticZiggurat, diff --git a/tests/test_api.py b/tests/test_api.py index 58be95d..b15fbdd 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,45 +1,201 @@ -"""Tests for the `ZigguratApi` request/response layer, against the synthetic server.""" +"""Tests for the `ZigguratApi` request/response layer against a fake transport.""" import asyncio -from collections.abc import AsyncIterator +from collections.abc import AsyncIterator, Awaitable, Callable +from datetime import timedelta +from typing import TypeVar, cast import pytest from zigpy.exceptions import DeliveryError import zigpy.types as t -from tests.common import RpcError, SyntheticZiggurat, server -from zigpy_ziggurat.zigbee import commands -from zigpy_ziggurat.zigbee.application import ZigguratApi - -SEND_APS = commands.SendAps( - delivery_mode="unicast", - destination_eui64=None, - destination=t.NWK(0x1234), - profile_id=0x0104, - cluster_id=0x0006, - src_ep=1, - dst_ep=1, - aps_ack=True, - aps_seq=55, - radius=30, - aps_encryption=False, - priority=0, - data=b"\x01\x02", -) +from zigpy_ziggurat.zigbee import api as api_module, protocol as p +from zigpy_ziggurat.zigbee.api import ZigguratApi + +_Bytes = t.LVList[t.uint8_t, t.uint16_t] +RequestT = TypeVar("RequestT", bound=p.Request) +Handler = Callable[[p.Request, int], Awaitable[None]] + + +def _send_aps(*, aps_ack: bool) -> p.SendUnicast: + return p.SendUnicast.build( + destination=t.NWK(0x1234), + destination_eui64=None, + aps_ack=aps_ack, + aps_encryption=False, + sleepy_destination=False, + profile_id=0x0104, + cluster_id=0x0006, + src_ep=1, + dst_ep=1, + aps_seq=55, + radius=30, + priority=0, + asdu=b"\x01\x02", + ) + + +class SyntheticBinaryTransport: + """A fake firmware: parses request frames, records them, and replies with the + binary frames its per-command handlers produce.""" + + def __init__(self) -> None: + self._on_frame: Callable[[bytes], None] = lambda frame: None + self._on_lost: Callable[[BaseException | None], None] = lambda exc: None + self.requests: list[p.Request] = [] + self.request_ids: list[int] = [] + self.hw_ieee = t.EUI64.convert("11:22:33:44:55:66:77:88") + self.handlers: dict[p.RequestCommand, Handler] = { + p.RequestCommand.RESET: self._empty_ok, + p.RequestCommand.SHUTDOWN: self._empty_ok, + p.RequestCommand.PERMIT_JOINS: self._empty_ok, + p.RequestCommand.SET_TUNABLE: self._empty_ok, + p.RequestCommand.GET_HW_ADDRESS: self._hw_address, + p.RequestCommand.SEND_UNICAST: self._send_aps, + p.RequestCommand.ENERGY_SCAN: self._energy_scan, + p.RequestCommand.CANCEL_REQUEST: self._cancel_request, + } + + async def factory( + self, + url: str, + on_frame: Callable[[bytes], None], + on_lost: Callable[[BaseException | None], None], + *, + baudrate: int = 115200, + flow_control: str | None = None, + ) -> "SyntheticBinaryTransport": + self._on_frame = on_frame + self._on_lost = on_lost + return self + + async def disconnect(self) -> None: + pass + + async def send_frame(self, frame: bytes) -> None: + header, body = p.Header.deserialize(frame) + assert header.frame_type == p.FrameType.REQUEST + command = p.RequestCommand(header.command) + request_id = int(header.request_id) + request = p.REQUESTS[command].deserialize(body)[0] + self.requests.append(request) + self.request_ids.append(request_id) + await self.handlers[command](request, request_id) + + def sent(self, request_type: type[RequestT]) -> list[RequestT]: + return [r for r in self.requests if isinstance(r, request_type)] + + # -- frame injection ----------------------------------------------------------- + + def ok( + self, + command: p.RequestCommand, + request_id: int, + payload: p.Response | None = None, + ) -> None: + body = bytes([p.Status.OK]) + (payload.serialize() if payload else b"") + self._on_frame(p.encode_reply(p.FrameType.RESPONSE, command, request_id, body)) + + def error( + self, command: p.RequestCommand, request_id: int, status: p.Status + ) -> None: + body = bytes([status]) + self._on_frame(p.encode_reply(p.FrameType.RESPONSE, command, request_id, body)) + + def rate_limited( + self, command: p.RequestCommand, request_id: int, retry_in_ms: int + ) -> None: + body = p.RateLimitedPayload( + status=p.Status.RATE_LIMITED, retry_in_ms=t.uint32_t(retry_in_ms) + ).serialize() + self._on_frame(p.encode_reply(p.FrameType.RESPONSE, command, request_id, body)) + + def event( + self, command: p.RequestCommand, request_id: int, payload: p.Response + ) -> None: + self._on_frame( + p.encode_reply(p.FrameType.EVENT, command, request_id, payload.serialize()) + ) + + def notify( + self, command: p.NotificationCommand, request_id: int, payload: p.Notification + ) -> None: + self._on_frame( + p.encode_reply( + p.FrameType.NOTIFICATION, command, request_id, payload.serialize() + ) + ) + + def send_confirm( + self, request_id: int, *, status: p.SendStatus = p.SendStatus.SUCCESS + ) -> None: + self.notify( + p.NotificationCommand.SEND_CONFIRM, + request_id, + p.SendConfirm(status=status), + ) + + def aps_ack_confirm( + self, request_id: int, *, status: p.SendStatus = p.SendStatus.SUCCESS + ) -> None: + self.notify( + p.NotificationCommand.APS_ACK_CONFIRM, + request_id, + p.ApsAckConfirm(status=status), + ) + + def lose(self, exc: BaseException | None = None) -> None: + self._on_lost(exc) + + def raw(self, frame: bytes) -> None: + self._on_frame(frame) + + # -- default handlers ---------------------------------------------------------- + + async def _empty_ok(self, request: p.Request, request_id: int) -> None: + self.ok(request.command, request_id) + + async def _hw_address(self, request: p.Request, request_id: int) -> None: + self.ok(request.command, request_id, p.HwAddress(ieee=self.hw_ieee)) + + async def _send_aps(self, request: p.Request, request_id: int) -> None: + self.ok(request.command, request_id) + self.send_confirm(request_id) + if request.aps_ack: # type: ignore[attr-defined] + self.aps_ack_confirm(request_id) + + async def _cancel_request(self, request: p.Request, request_id: int) -> None: + self.ok(request.command, request_id, p.CancelResult(cancelled=t.Bool(True))) + + async def _energy_scan(self, request: p.Request, request_id: int) -> None: + for channel in request.channels: # type: ignore[attr-defined] + self.event( + p.RequestCommand.ENERGY_SCAN, + request_id, + p.EnergyResult(channel=t.uint8_t(channel), rssi=t.int8s(-85)), + ) + self.ok(request.command, request_id) class RecordingApi(ZigguratApi): """A `ZigguratApi` whose callbacks record into plain lists.""" def __init__(self, url: str) -> None: - self.notifications: list[commands.Notification] = [] + self.notifications: list[p.Notification] = [] self.disconnects: list[BaseException | None] = [] super().__init__(url, self.notifications.append, self.disconnects.append) @pytest.fixture -async def api(server: SyntheticZiggurat) -> AsyncIterator[RecordingApi]: - instance = RecordingApi(server.url) +def transport(monkeypatch: pytest.MonkeyPatch) -> SyntheticBinaryTransport: + server = SyntheticBinaryTransport() + monkeypatch.setattr(api_module, "connect_transport", server.factory) + return server + + +@pytest.fixture +async def api(transport: SyntheticBinaryTransport) -> AsyncIterator[RecordingApi]: + instance = RecordingApi("binary://test") await instance.connect() yield instance @@ -47,140 +203,283 @@ async def api(server: SyntheticZiggurat) -> AsyncIterator[RecordingApi]: await instance.disconnect() -async def test_request(api: RecordingApi) -> None: - status = await api.request(commands.Ping()) - assert status == commands.Status(status="pong") +async def test_request(api: RecordingApi, transport: SyntheticBinaryTransport) -> None: + # An empty OK reply returns None + assert await api.request(p.Shutdown()) is None + hw = await api.request(p.GetHwAddress()) + assert isinstance(hw, p.HwAddress) + assert hw.ieee == transport.hw_ieee -async def test_error_response(api: RecordingApi, server: SyntheticZiggurat) -> None: - async def fail(command: commands.Ping, request_id: int) -> commands.Status: - raise RpcError("serial_port_error", "it burned down") - server.handlers["ping"] = fail +async def test_shutdown(api: RecordingApi, transport: SyntheticBinaryTransport) -> None: + assert await api.request(p.Shutdown()) is None + assert isinstance(transport.sent(p.Shutdown)[-1], p.Shutdown) + + +async def test_set_tunable( + api: RecordingApi, transport: SyntheticBinaryTransport +) -> None: + await api.set_tunable("aps_ack_timeout", timedelta(seconds=5)) - with pytest.raises(DeliveryError, match="serial_port_error: it burned down"): - await api.request(commands.Ping()) + sent = transport.sent(p.SetTunable)[-1] + assert sent.name == b"aps_ack_timeout" + assert sent.value == 5_000_000 -async def test_request_transmitted( - api: RecordingApi, server: SyntheticZiggurat +async def test_error_response( + api: RecordingApi, transport: SyntheticBinaryTransport ) -> None: - await api.request_transmitted(SEND_APS) - assert server.sent(commands.SendAps)[-1].aps_seq == 55 + async def fail(request: p.Request, request_id: int) -> None: + transport.error(request.command, request_id, p.Status.RADIO_ERROR) + transport.handlers[p.RequestCommand.SHUTDOWN] = fail -async def test_request_transmitted_failure_before_transmission( - api: RecordingApi, server: SyntheticZiggurat + with pytest.raises(DeliveryError, match="radio_error") as exc: + await api.request(p.Shutdown()) + + assert isinstance(exc.value, p.ProtocolError) + assert exc.value.status == p.Status.RADIO_ERROR + + +async def test_rate_limited_response( + api: RecordingApi, transport: SyntheticBinaryTransport ) -> None: - async def fail(command: commands.SendAps, request_id: int) -> commands.Status: - raise RpcError("transmit_failed", "channel busy") + async def rate_limit(request: p.Request, request_id: int) -> None: + transport.rate_limited(request.command, request_id, retry_in_ms=1800) + + transport.handlers[p.RequestCommand.SEND_UNICAST] = rate_limit + + with pytest.raises(p.RateLimitedError, match="rate_limited: retry in 1.8s") as exc: + await api.request_confirmed(_send_aps(aps_ack=False)) - server.handlers["send_aps"] = fail + assert exc.value.status == p.Status.RATE_LIMITED + assert exc.value.retry_in == timedelta(milliseconds=1800) - with pytest.raises(DeliveryError, match="transmit_failed"): - await api.request_transmitted(SEND_APS) + +async def test_request_confirmed( + api: RecordingApi, transport: SyntheticBinaryTransport +) -> 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 -async def test_late_delivery_failure_is_logged( - api: RecordingApi, server: SyntheticZiggurat, caplog: pytest.LogCaptureFixture +async def test_cancel_on_abandon( + api: RecordingApi, transport: SyntheticBinaryTransport ) -> None: - async def ack_timeout( - command: commands.SendAps, request_id: int - ) -> commands.Status: - await server.send_event(request_id, "transmitted") - raise RpcError("aps_ack_timeout", "no ack") + """Cancelling a send awaiting confirmation cancels it on the firmware.""" + send_ids: list[int] = [] + + async def accept_only(request: p.Request, request_id: int) -> None: + send_ids.append(request_id) + transport.ok(request.command, request_id) # accepted, never confirmed - server.handlers["send_aps"] = ack_timeout + transport.handlers[p.RequestCommand.SEND_UNICAST] = accept_only - # Resolves at the `transmitted` stage; the terminal failure arrives later and is - # logged instead of raised - await api.request_transmitted(SEND_APS) + task = asyncio.create_task(api.request_confirmed(_send_aps(aps_ack=False))) + while not transport.sent(p.SendUnicast): + await asyncio.sleep(0) - async with asyncio.timeout(1): - while "Delivery failed after transmission" not in caplog.text: - await asyncio.sleep(0.01) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + for _ in range(10): + await asyncio.sleep(0) -async def test_unsolicited_messages_are_ignored( - api: RecordingApi, server: SyntheticZiggurat, caplog: pytest.LogCaptureFixture + cancels = transport.sent(p.CancelRequest) + assert len(cancels) == 1 + assert cancels[0].request_id == send_ids[0] + + +async def test_cancel_on_timeout( + api: RecordingApi, + transport: SyntheticBinaryTransport, + monkeypatch: pytest.MonkeyPatch, ) -> None: - await server.send_raw("not json") - await server.send_raw('{"type": "response", "id": 9999, "result": {}}') - await server.send_raw('{"type": "event", "id": 9999, "event": "transmitted"}') + """A confirmation timeout cancels the still-in-flight send on the firmware.""" + monkeypatch.setattr(api_module, "CONFIRM_TIMEOUT", 0.01) + send_ids: list[int] = [] - # A `transmitted` event for a request that did not ask for one - async def eager(command: commands.Ping, request_id: int) -> commands.Status: - await server.send_event(request_id, "transmitted") - return commands.Status(status="pong") + async def accept_only(request: p.Request, request_id: int) -> None: + send_ids.append(request_id) + transport.ok(request.command, request_id) # accepted, never confirmed - server.handlers["ping"] = eager + transport.handlers[p.RequestCommand.SEND_UNICAST] = accept_only - # The connection survives all of it - status = await api.request(commands.Ping()) - assert status == commands.Status(status="pong") - assert "Failed to handle message" in caplog.text + with pytest.raises(TimeoutError): + await api.request_confirmed(_send_aps(aps_ack=False)) + + for _ in range(10): + await asyncio.sleep(0) + + cancels = transport.sent(p.CancelRequest) + assert len(cancels) == 1 + assert cancels[0].request_id == send_ids[0] + + +async def test_cancel_skipped_while_closing( + api: RecordingApi, transport: SyntheticBinaryTransport +) -> None: + """Once we are tearing the connection down there is nothing left to cancel on.""" + send_ids: list[int] = [] + + async def accept_only(request: p.Request, request_id: int) -> None: + send_ids.append(request_id) + transport.ok(request.command, request_id) # accepted, never confirmed + + transport.handlers[p.RequestCommand.SEND_UNICAST] = accept_only + + task = asyncio.create_task(api.request_confirmed(_send_aps(aps_ack=False))) + while not transport.sent(p.SendUnicast): + await asyncio.sleep(0) + + await api.disconnect() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + for _ in range(10): + await asyncio.sleep(0) + + assert send_ids + assert transport.sent(p.CancelRequest) == [] + + +async def test_confirmed_success_sends_no_cancel( + api: RecordingApi, transport: SyntheticBinaryTransport +) -> None: + """A send that confirms normally is never cancelled.""" + await api.request_confirmed(_send_aps(aps_ack=False)) + for _ in range(10): + await asyncio.sleep(0) + assert transport.sent(p.CancelRequest) == [] + + +async def test_request_confirmed_rejected( + api: RecordingApi, transport: SyntheticBinaryTransport +) -> None: + """The stack rejects the frame, so the send raises before any confirm.""" + + async def reject(request: p.Request, request_id: int) -> None: + transport.error(request.command, request_id, p.Status.PAYLOAD_TOO_LONG) + + transport.handlers[p.RequestCommand.SEND_UNICAST] = reject + + with pytest.raises(DeliveryError, match="payload_too_long"): + await api.request_confirmed(_send_aps(aps_ack=True)) + + +async def test_request_confirmed_failure( + api: RecordingApi, transport: SyntheticBinaryTransport +) -> None: + """The frame is handed off but the end-to-end APS ack never arrives.""" + + async def ack_timeout(request: p.Request, request_id: int) -> None: + transport.ok(request.command, request_id) + transport.send_confirm(request_id, status=p.SendStatus.SUCCESS) + transport.aps_ack_confirm(request_id, status=p.SendStatus.APS_ACK_TIMEOUT) + + transport.handlers[p.RequestCommand.SEND_UNICAST] = ack_timeout + with pytest.raises(DeliveryError, match="APS_ACK_TIMEOUT"): + await api.request_confirmed(_send_aps(aps_ack=True)) -async def test_notifications(api: RecordingApi, server: SyntheticZiggurat) -> None: - sent: list[commands.Notification] = [ - commands.ReceivedApsCommand( + +async def test_request_stream( + api: RecordingApi, transport: SyntheticBinaryTransport +) -> None: + results: list[p.EnergyResult] = [] + async for item in api.request_stream( + p.EnergyScan(channels=_Bytes([15, 20]), duration_per_channel_ms=t.uint16_t(100)) + ): + results.append(cast(p.EnergyResult, item)) + assert [(r.channel, r.rssi) for r in results] == [(15, -85), (20, -85)] + + +async def test_notifications( + api: RecordingApi, transport: SyntheticBinaryTransport +) -> None: + transport.notify( + p.NotificationCommand.RECEIVED_APS, + 0, + p.ReceivedAps( source=t.NWK(0xAB12), destination=t.NWK(0x0000), - group=None, + has_group=t.Bool(False), + 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(255), rssi=t.int8s(-40), - data=b"\x01\x02", + data=t.LongOctetString(b"\x01\x02"), ), - commands.FrameCounterUpdate(frame_counter=t.uint32_t(1000)), - commands.LinkKeyUpdate( - ieee=t.EUI64.convert("aa:aa:aa:aa:aa:aa:aa:aa"), - key=t.KeyData.convert("00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff"), - ), - commands.DeviceJoined( + ) + transport.notify( + p.NotificationCommand.FRAME_COUNTER, + 0, + p.FrameCounter(frame_counter=t.uint32_t(1000)), + ) + transport.notify( + p.NotificationCommand.DEVICE_JOINED, + 0, + p.DeviceJoined( nwk=t.NWK(0xAB12), ieee=t.EUI64.convert("aa:aa:aa:aa:aa:aa:aa:aa"), parent=t.NWK(0x0000), + rx_on_when_idle=t.uint1_t(1), + device_type=p.ChildDeviceType.END_DEVICE, + reserved=t.uint5_t(0), ), - commands.DeviceLeft( - nwk=t.NWK(0xAB12), - ieee=None, - reason=commands.DeviceLeaveReason.ROUTER_REPORTED, - router=t.NWK(0x0000), - router_ieee=t.EUI64.convert("aa:aa:aa:aa:aa:aa:aa:aa"), - ), - commands.ApsDecryptionFailure( - source=t.NWK(0x1234), - source_ieee=t.EUI64.convert("aa:aa:aa:aa:aa:aa:aa:aa"), - frame_counter=t.uint32_t(42), - key_id="tc_link_key", - ), + ) + + assert [type(n) for n in api.notifications] == [ + p.ReceivedAps, + p.FrameCounter, + p.DeviceJoined, ] + received = api.notifications[0] + assert isinstance(received, p.ReceivedAps) + assert received.data == b"\x01\x02" - for notification in sent: - await server.send_notification(notification) - async with asyncio.timeout(1): - while len(api.notifications) < len(sent): - await asyncio.sleep(0.01) +async def test_unsolicited_frames_are_ignored( + api: RecordingApi, transport: SyntheticBinaryTransport +) -> None: + # A response and an event for an unknown request id + transport.ok(p.RequestCommand.SHUTDOWN, 9999) + transport.event( + p.RequestCommand.ENERGY_SCAN, + 9999, + p.EnergyResult(channel=t.uint8_t(1), rssi=t.int8s(-10)), + ) + # A frame with an unknown command byte + transport.raw( + p.Header( + command=t.uint8_t(0xEE), + frame_type=p.FrameType.NOTIFICATION, + request_id=t.uint16_t(0), + ).serialize() + ) - assert api.notifications == sent + # The connection survives all of it + assert await api.request(p.Shutdown()) is None async def test_connection_lost_fails_pending_requests( - api: RecordingApi, server: SyntheticZiggurat + api: RecordingApi, transport: SyntheticBinaryTransport ) -> None: - async def withhold(command: commands.Ping, request_id: int) -> None: + async def withhold(request: p.Request, request_id: int) -> None: return None - server.handlers["ping"] = withhold + transport.handlers[p.RequestCommand.SHUTDOWN] = withhold - request = asyncio.ensure_future(api.request(commands.Ping())) - await server.wait_for(commands.Ping) - await server.ws.close() + request = asyncio.ensure_future(api.request(p.Shutdown())) + await asyncio.sleep(0) + transport.lose(None) with pytest.raises(ConnectionError): await request @@ -188,31 +487,112 @@ async def withhold(command: commands.Ping, request_id: int) -> None: assert api.disconnects == [None] -async def test_protocol_error_disconnects( - api: RecordingApi, server: SyntheticZiggurat +async def test_hello_reported_as_disconnect( + api: RecordingApi, transport: SyntheticBinaryTransport ) -> None: - # A malformed frame (reserved opcode) surfaces as a websocket protocol error - server.transport.write(b"\x8f\x00") + async def withhold(request: p.Request, request_id: int) -> None: + return None - async with asyncio.timeout(1): - while not api.disconnects: - await asyncio.sleep(0.01) + transport.handlers[p.RequestCommand.SHUTDOWN] = withhold + request = asyncio.ensure_future(api.request(p.Shutdown())) + await asyncio.sleep(0) + + # A firmware reboot (`hello`) wipes the stack, so it must surface as a disconnect + # that fails in-flight requests, not as an ordinary notification. + transport.notify( + p.NotificationCommand.HELLO, + 0, + p.Hello( + protocol_version=t.uint8_t(p.PROTOCOL_VERSION), configured=t.Bool(False) + ), + ) + + with pytest.raises(ConnectionError): + await request assert len(api.disconnects) == 1 + assert isinstance(api.disconnects[0], ConnectionError) + assert api.notifications == [] async def test_timed_out_request_failed_late( - api: RecordingApi, server: SyntheticZiggurat + api: RecordingApi, transport: SyntheticBinaryTransport ) -> None: - async def withhold(command: commands.Ping, request_id: int) -> None: + async def withhold(request: p.Request, request_id: int) -> None: return None - server.handlers["ping"] = withhold + transport.handlers[p.RequestCommand.SHUTDOWN] = withhold # The caller gave up before any response arrived (zigpy wraps requests in # timeouts); disconnecting must tolerate the abandoned, cancelled future with pytest.raises(TimeoutError): - await asyncio.wait_for(api.request(commands.Ping()), 0.05) + await asyncio.wait_for(api.request(p.Shutdown()), 0.05) await api.disconnect() await asyncio.sleep(0) + + +async def test_confirmed_send_delivery_failure( + api: RecordingApi, transport: SyntheticBinaryTransport +) -> None: + async def failed_confirm(request: p.Request, request_id: int) -> None: + transport.ok(request.command, request_id) + transport.send_confirm(request_id, status=p.SendStatus.ROUTE_DISCOVERY_TIMEOUT) + + transport.handlers[p.RequestCommand.SEND_UNICAST] = failed_confirm + + with pytest.raises(DeliveryError, match="ROUTE_DISCOVERY_TIMEOUT"): + await api.request_confirmed(_send_aps(aps_ack=False)) + + +async def test_connection_lost_fails_pending_confirm( + api: RecordingApi, transport: SyntheticBinaryTransport +) -> None: + async def accept_only(request: p.Request, request_id: int) -> None: + # Accept the send but never confirm, leaving a pending confirmation. + transport.ok(request.command, request_id) + + transport.handlers[p.RequestCommand.SEND_UNICAST] = accept_only + + request = asyncio.ensure_future(api.request_confirmed(_send_aps(aps_ack=False))) + while not transport.sent(p.SendUnicast): + await asyncio.sleep(0) + transport.lose(None) + + with pytest.raises(ConnectionError): + await request + + +async def test_unknown_notification_command_ignored( + api: RecordingApi, transport: SyntheticBinaryTransport +) -> None: + frame = p.Header( + command=t.uint8_t(0x06), + frame_type=p.FrameType.NOTIFICATION, + request_id=t.uint16_t(0), + ).serialize() + transport.raw(frame) + assert api.notifications == [] + + +async def test_send_confirm_without_pending_ignored( + api: RecordingApi, transport: SyntheticBinaryTransport +) -> None: + # A confirm for a request we aren't tracking is dropped, not misrouted. + transport.send_confirm(9999) + assert api.notifications == [] + + +async def test_last_reset_logged( + api: RecordingApi, + transport: SyntheticBinaryTransport, + caplog: pytest.LogCaptureFixture, +) -> None: + with caplog.at_level("WARNING", logger="ziggurat.fw"): + transport.notify( + p.NotificationCommand.LAST_RESET, + 0, + p.LastReset(message=t.LongCharacterString("brownout")), + ) + assert "brownout" in caplog.text + assert api.notifications == [] diff --git a/tests/test_application.py b/tests/test_application.py index e1a2a21..708c7f4 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -1,14 +1,15 @@ """One async test per public zigpy method of `ControllerApplication`, all running -against the synthetic websocket server.""" +against the synthetic binary websocket server.""" import asyncio +from datetime import timedelta import logging import os from typing import Any -from unittest.mock import AsyncMock from aiohttp import web import pytest +import zigpy.config import zigpy.device import zigpy.endpoint from zigpy.exceptions import DeliveryError, NetworkNotFormed @@ -18,30 +19,38 @@ from tests.common import ( COORDINATOR_IEEE, + DEVICE_IEEE, + DEVICE_NWK, + LINK_KEY, NETWORK_KEY, - RpcError, + StatusError, SyntheticZiggurat, app, connected_app, + flush, make_app_config, server, ) -from zigpy_ziggurat.zigbee import application as application_module, commands +from zigpy_ziggurat.config import CONF_TUNABLES, CONF_ZIGGURAT_CONFIG +from zigpy_ziggurat.zigbee import application as application_module, protocol as p from zigpy_ziggurat.zigbee.application import ( ControllerApplication, ZigguratCoordinator, map_rssi_to_energy, ) -DEVICE_IEEE = t.EUI64.convert("aa:aa:aa:aa:aa:aa:aa:aa") -DEVICE_NWK = t.NWK(0xAB12) -LINK_KEY = t.KeyData.convert("00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff") +class RecordingApplication(ControllerApplication): + """Records the packets zigpy is notified of, for tests that assert on the decoded + `ZigbeePacket` itself rather than on zigpy's reaction to it.""" -async def flush(app: ControllerApplication) -> None: - """Round-trip a request: the websocket is ordered, so by the time the response - arrives every previously sent notification has been processed.""" - await app._watchdog_feed() + def __init__(self, config: dict[str, Any]) -> None: + super().__init__(config) + self.packets: list[t.ZigbeePacket] = [] + + def packet_received(self, packet: t.ZigbeePacket) -> None: + self.packets.append(packet) + super().packet_received(packet) def add_initialized_device( @@ -72,14 +81,39 @@ 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, + data: bytes = b"\x01\x02\x03", +) -> t.ZigbeePacket: + return t.ZigbeePacket( + src=t.AddrModeAddress(addr_mode=t.AddrMode.NWK, address=t.NWK(0x0000)), + 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), + tx_options=tx_options, + ) + + async def test_connect( connected_app: ControllerApplication, server: SyntheticZiggurat ) -> None: assert server.connections == 1 + # Any transient radio state left by a previous client is cleared on connect + assert not server.sent(p.Reset)[-1].hard + # Requests round-trip over the socket await connected_app.permit_ncp(1) - assert server.sent(commands.PermitJoins)[-1].duration == 1 + assert server.sent(p.PermitJoins)[-1].duration == 1 async def test_connect_unix_socket() -> None: @@ -93,7 +127,7 @@ async def test_connect_unix_socket() -> None: app = ControllerApplication(make_app_config(f"ws+unix://{socket_path}")) await app.connect() await app.permit_ncp(2) - assert ziggurat.sent(commands.PermitJoins)[-1].duration == 2 + assert ziggurat.sent(p.PermitJoins)[-1].duration == 2 await app.shutdown(db=False) await runner.cleanup() @@ -129,6 +163,40 @@ async def test_start_network( assert server.configured.network_key == NETWORK_KEY assert connected_app.backups[-1].network_info.pan_id == t.PanId(0x1A2B) + # The network only starts once the state and tables have been loaded + commands = [type(r) for r in server.requests] + assert commands.index(p.Configure) < commands.index(p.StartNetwork) + + +async def test_start_network_applies_tunables( + server: SyntheticZiggurat, +) -> None: + """Tunables are a debug/experiment surface, applied once the network is up.""" + app = ControllerApplication( + make_app_config( + server.url, + **{ + CONF_ZIGGURAT_CONFIG: { + CONF_TUNABLES: {"aps_ack_timeout": 5, "max_broadcast_jitter": 100} + } + }, + ) + ) + await app.connect() + await app.start_network() + + assert [ + (bytes(s.name).decode(), int(s.value)) for s in server.sent(p.SetTunable) + ] == [ + ("aps_ack_timeout", 5), + ("max_broadcast_jitter", 100), + ] + # They are applied to a started network, not folded into the configuration + commands = [type(r) for r in server.requests] + assert commands.index(p.StartNetwork) < commands.index(p.SetTunable) + + await app.shutdown(db=False) + async def test_coordinator_device(app: ControllerApplication) -> None: coordinator = app.get_device(nwk=t.NWK(0x0000)) @@ -143,64 +211,149 @@ async def test_load_network_info( ) -> None: app = connected_app - async def not_configured( - command: commands.GetNetworkInfo, request_id: int - ) -> commands.NetworkInfo: - raise RpcError("not_configured", "no stack is running") + async def not_configured(request: p.Request, request_id: int) -> None: + raise StatusError(p.Status.NOT_CONFIGURED) # A stateless server with no network running and no local backup: no network - server.handlers["get_network_info"] = not_configured + server.handlers[p.RequestCommand.GET_NETWORK_INFO] = not_configured with pytest.raises(NetworkNotFormed): await app.load_network_info() # Unrelated errors propagate - async def serial_error( - command: commands.GetNetworkInfo, request_id: int - ) -> commands.NetworkInfo: - raise RpcError("serial_port_error", "it burned down") + async def radio_error(request: p.Request, request_id: int) -> None: + raise StatusError(p.Status.RADIO_ERROR) - server.handlers["get_network_info"] = serial_error - with pytest.raises(DeliveryError, match="serial_port_error"): + server.handlers[p.RequestCommand.GET_NETWORK_INFO] = radio_error + with pytest.raises(DeliveryError, match="radio_error"): await app.load_network_info() # The server has a running network - server.handlers["get_network_info"] = server.on_get_network_info + server.handlers[p.RequestCommand.GET_NETWORK_INFO] = server.on_get_network_info await app.load_network_info() assert app.state.node_info.ieee == COORDINATOR_IEEE assert app.state.network_info.channel == 15 # zigpy mis-annotates the classmethod's `cls` as an instance assert app.state.network_info.channel_mask == t.Channels.from_channel_list([15]) # type: ignore[misc] assert app.state.network_info.network_key.key == NETWORK_KEY + assert app.state.network_info.network_key.tx_counter == 1000 + # The APS outgoing frame counter lives on the TC link key by convention + assert app.state.network_info.tc_link_key.tx_counter == 2000 assert app.state.network_info.stack_specific == {} - # TCLK seeds map to the stack_specific layout of their source stack - server.network_info.tclk_seed = "ab" * 16 - server.network_info.tclk_flavor = "zstack" + # A restarted, stateless server: the latest backup is restored with both frame + # counters jumped past their stale values + await app.write_network_info( + network_info=app.state.network_info, node_info=app.state.node_info + ) + counter = app.state.network_info.network_key.tx_counter + aps_counter = app.state.network_info.tc_link_key.tx_counter + server.handlers[p.RequestCommand.GET_NETWORK_INFO] = not_configured await app.load_network_info() - assert app.state.network_info.stack_specific == {"zstack": {"tclk_seed": "ab" * 16}} + margin = application_module.FRAME_COUNTER_RESTORE_MARGIN + assert app.state.network_info.network_key.tx_counter == counter + margin + assert app.state.network_info.tc_link_key.tx_counter == aps_counter + margin - server.network_info.tclk_flavor = "ezsp" - await app.load_network_info() - assert app.state.network_info.stack_specific == {"ezsp": {"hashed_tclk": "ab" * 16}} - # Negotiated link keys come back as the key table - server.network_info.key_table = [ - commands.KeyTableEntry(partner_ieee=DEVICE_IEEE, key=LINK_KEY) +@pytest.mark.parametrize( + ("flavor", "expected"), + [ + (p.TclkFlavorId.Z_STACK, {"zstack": {"tclk_seed": "ab" * 16}}), + (p.TclkFlavorId.EZSP, {"ezsp": {"hashed_tclk": "ab" * 16}}), + ], +) +async def test_load_network_info_tclk_seed( + connected_app: ControllerApplication, + server: SyntheticZiggurat, + flavor: p.TclkFlavorId, + expected: dict[str, Any], +) -> None: + """A seed carried over from a microcontroller stack maps onto the stack_specific + layout of the stack it came from.""" + server.network_state.has_tclk_seed = t.Bool(True) + server.network_state.tclk_seed = t.KeyData(bytes.fromhex("ab" * 16)) + server.network_state.tclk_flavor = flavor + + await connected_app.load_network_info() + assert connected_app.state.network_info.stack_specific == expected + + +async def test_load_network_info_tables( + connected_app: ControllerApplication, server: SyntheticZiggurat +) -> None: + """The key table, children, address cache and route table each stream in as the + events of their own scan request.""" + child_ieee = t.EUI64.convert("bb:bb:bb:bb:bb:bb:bb:bb") + server.key_table = [ + p.KeyEntry( + key=LINK_KEY, + tx_counter=t.uint32_t(7), + rx_counter=t.uint32_t(9), + seq=t.uint8_t(1), + partner_ieee=DEVICE_IEEE, + ) ] - await app.load_network_info() - assert app.state.network_info.key_table == [ - zigpy.state.Key(key=LINK_KEY, partner_ieee=DEVICE_IEEE) + server.children = [ + p.ChildEntry( + ieee=DEVICE_IEEE, + nwk=DEVICE_NWK, + rx_on_when_idle=t.uint1_t(0), + device_type=p.ChildDeviceType.END_DEVICE, + reserved=t.uint5_t(0), + ), + p.ChildEntry( + ieee=child_ieee, + nwk=t.NWK(0x1234), + rx_on_when_idle=t.uint1_t(1), + device_type=p.ChildDeviceType.ROUTER, + reserved=t.uint5_t(0), + ), + ] + server.address_cache = [ + p.AddressEntry(ieee=DEVICE_IEEE, nwk=DEVICE_NWK), + p.AddressEntry(ieee=child_ieee, nwk=t.NWK(0x1234)), + ] + server.route_table = [ + p.RouteEntry( + destination=DEVICE_NWK, next_hop=t.NWK(0x1234), path_cost=t.uint8_t(3) + ) ] - # A restarted, stateless server: the latest backup is restored with the frame - # counter jumped past the stale value - await app.write_network_info( - network_info=app.state.network_info, node_info=app.state.node_info - ) - counter = app.state.network_info.network_key.tx_counter - server.handlers["get_network_info"] = not_configured - await app.load_network_info() - assert app.state.network_info.network_key.tx_counter == counter + 500 + await connected_app.load_network_info() + network_info = connected_app.state.network_info + + assert network_info.key_table == [ + zigpy.state.Key( + key=LINK_KEY, + partner_ieee=DEVICE_IEEE, + tx_counter=t.uint32_t(7), + rx_counter=t.uint32_t(9), + seq=t.uint8_t(1), + ) + ] + assert network_info.children == [DEVICE_IEEE, child_ieee] + assert network_info.nwk_addresses == { + DEVICE_IEEE: DEVICE_NWK, + child_ieee: t.NWK(0x1234), + } + assert network_info.stack_specific == { + "ziggurat": { + "routes": [ + { + "destination": DEVICE_NWK, + "next_hop": t.NWK(0x1234), + "path_cost": 3, + } + ] + } + } + + +async def test_load_network_info_no_routes( + connected_app: ControllerApplication, server: SyntheticZiggurat +) -> None: + # An empty route table adds no `ziggurat` section at all + await connected_app.load_network_info() + assert connected_app.state.network_info.stack_specific == {} async def test_write_network_info( @@ -219,10 +372,18 @@ async def test_write_network_info( ), node_info=node_info, ) - assert server.configured.tclk_seed == "cd" * 16 - assert server.configured.tclk_flavor == "zstack" - assert server.configured.key_table == [ - commands.KeyTableEntry(partner_ieee=DEVICE_IEEE, key=LINK_KEY) + zstack = server.configured + assert zstack.has_tclk_seed + assert bytes(zstack.tclk_seed).hex() == "cd" * 16 + assert zstack.tclk_flavor == p.TclkFlavorId.Z_STACK + assert list(server.sent(p.LoadKeyTable)[-1].entries) == [ + p.KeyEntry( + key=LINK_KEY, + tx_counter=t.uint32_t(0), + rx_counter=t.uint32_t(0), + seq=t.uint8_t(0), + partner_ieee=DEVICE_IEEE, + ) ] # An ezsp seed likewise @@ -232,8 +393,14 @@ async def test_write_network_info( ), node_info=node_info, ) - assert server.configured.tclk_seed == "ef" * 16 - assert server.configured.tclk_flavor == "ezsp" + ezsp = server.configured + assert bytes(ezsp.tclk_seed).hex() == "ef" * 16 + assert ezsp.tclk_flavor == p.TclkFlavorId.EZSP + + # With no seed at all one is generated, so unique link keys can still be issued + await app.write_network_info(network_info=network_info, node_info=node_info) + assert not server.configured.has_tclk_seed + assert bytes(server.configured.tclk_seed) != bytes(16) # When zigpy forms a fresh network it leaves the IEEE address unspecified, # deferring to the radio's hardware address @@ -241,7 +408,7 @@ async def test_write_network_info( network_info=network_info, node_info=node_info.replace(ieee=t.EUI64.UNKNOWN), # type: ignore[attr-defined] ) - assert server.sent(commands.GetHwAddress) + assert server.sent(p.GetHwAddress) assert server.configured.ieee_address == server.hw_address assert app.state.node_info.ieee == server.hw_address @@ -249,30 +416,80 @@ async def test_write_network_info( assert app.backups[-1].node_info.ieee == server.hw_address +async def test_write_network_info_default_tx_power( + connected_app: ControllerApplication, server: SyntheticZiggurat +) -> None: + await connected_app.load_network_info() + + # `None` means "pick automatically", which becomes a safe default + await connected_app.write_network_info( + network_info=connected_app.state.network_info.replace(tx_power=None), + node_info=connected_app.state.node_info, + ) + assert server.configured.tx_power == 8 + + +async def test_write_network_info_restores_children( + connected_app: ControllerApplication, server: SyntheticZiggurat +) -> None: + """Children are reloaded so the stack can route to sleepy end devices before + they check in again.""" + app = connected_app + await app.load_network_info() + + unknown_ieee = t.EUI64.convert("cc:cc:cc:cc:cc:cc:cc:cc") + children = [t.EUI64([n] * 8) for n in range(1, 16)] + nwk_addresses = {ieee: t.NWK(0x1000 + n) for n, ieee in enumerate(children)} + + await app.write_network_info( + network_info=app.state.network_info.replace( + # The unknown child has no address, so it cannot be loaded + children=[*children, unknown_ieee], + nwk_addresses=nwk_addresses, + ), + node_info=app.state.node_info, + ) + + # 15 children in batches of 12 + loads = server.sent(p.LoadChildren) + assert [len(load.entries) for load in loads] == [12, 3] + + entries = [entry for load in loads for entry in load.entries] + assert [entry.ieee for entry in entries] == children + assert [entry.nwk for entry in entries] == list(nwk_addresses.values()) + # The backup carries no capability, so the device type is unknown + assert {entry.device_type for entry in entries} == {p.ChildDeviceType.UNKNOWN} + + async def test_permit_ncp( app: ControllerApplication, server: SyntheticZiggurat ) -> None: await app.permit_ncp(42) - permit = server.sent(commands.PermitJoins)[-1] + permit = server.sent(p.PermitJoins)[-1] assert permit.duration == 42 # Permitting on the coordinator opens its own beacon for direct joins - assert permit.accept_direct_joins is True + assert permit.accept_direct_joins async def test_permit_steered_to_router( app: ControllerApplication, server: SyntheticZiggurat ) -> None: - device = app.add_device(DEVICE_IEEE, DEVICE_NWK) + device = add_initialized_device(app) + permits: list[int] = [] + # The unicast Mgmt_Permit_Joining_req awaits a ZDO reply no synthetic device sends - device.zdo.permit = AsyncMock() # type: ignore[method-assign] + async def permit(duration: int, *args: Any, **kwargs: Any) -> None: + permits.append(duration) + + device.zdo.permit = permit # type: ignore[method-assign,assignment] await app.permit(time_s=30, node=DEVICE_IEEE) - assert device.zdo.permit.mock_calls == [((30,), {})] + assert permits == [30] # The trust center window opens without advertising the coordinator as a parent - permit = server.sent(commands.PermitJoins)[-1] - assert permit.duration == 30 - assert permit.accept_direct_joins is False + permit_joins = server.sent(p.PermitJoins)[-1] + assert permit_joins.duration == 30 + assert not permit_joins.accept_direct_joins async def test_permit_with_link_key( @@ -280,30 +497,29 @@ async def test_permit_with_link_key( ) -> None: await app.permit_with_link_key(node=DEVICE_IEEE, link_key=LINK_KEY, time_s=12) - provisional = server.sent(commands.SetProvisionalKey)[-1] + provisional = server.sent(p.SetProvisionalKey)[-1] assert provisional.ieee == DEVICE_IEEE assert provisional.key == LINK_KEY # `super().permit()` broadcasts Mgmt_Permit_Joining_req and calls `permit_ncp` - broadcast = server.sent(commands.SendAps)[-1] - assert broadcast.delivery_mode == "broadcast" + broadcast = server.sent(p.SendBroadcast)[-1] assert broadcast.cluster_id == zdo_t.ZDOCmd.Mgmt_Permit_Joining_req - assert server.sent(commands.PermitJoins)[-1].duration == 12 + assert server.sent(p.PermitJoins)[-1].duration == 12 async def test_permit_node_conversion_and_all_routers( app: ControllerApplication, server: SyntheticZiggurat ) -> None: await app.permit(time_s=20, node="aa:bb:cc:dd:11:22:33:44") - steered = server.sent(commands.PermitJoins)[-1] + steered = server.sent(p.PermitJoins)[-1] assert steered.duration == 20 - assert steered.accept_direct_joins is False + assert not steered.accept_direct_joins # No node falls through to the base broadcast, which opens the coordinator too await app.permit(time_s=30) - opened = server.sent(commands.PermitJoins)[-1] + opened = server.sent(p.PermitJoins)[-1] assert opened.duration == 30 - assert opened.accept_direct_joins is True + assert opened.accept_direct_joins async def test_energy_scan( @@ -311,19 +527,16 @@ async def test_energy_scan( ) -> None: rssis = iter([-90, -80, -70]) - async def scan(command: commands.EnergyScan, request_id: int) -> commands.Status: + async def scan(request: p.EnergyScan, request_id: int) -> None: rssi = next(rssis) - for channel in command.channels: - await server.send_event_data( + for channel in request.channels: + await server.send_event( + p.RequestCommand.ENERGY_SCAN, request_id, - "energy_result", - commands.EnergyScanResult( - channel=t.uint8_t(channel), rssi=t.int8s(rssi) - ).to_dict(), + p.EnergyResult(channel=t.uint8_t(channel), rssi=t.int8s(rssi)), ) - return commands.Status(status="complete") - server.handlers["energy_scan"] = scan + server.handlers[p.RequestCommand.ENERGY_SCAN] = scan energies = await app.energy_scan( # zigpy mis-annotates the classmethod's `cls` as an instance @@ -332,9 +545,9 @@ async def scan(command: commands.EnergyScan, request_id: int) -> commands.Status count=3, ) - scans = server.sent(commands.EnergyScan) + scans = server.sent(p.EnergyScan) assert len(scans) == 3 - assert scans[0].channels == [11, 15] + assert list(scans[0].channels) == [11, 15] # 0.016 ms/symbol * 960 symbols * (2**2 + 1) assert scans[0].duration_per_channel_ms == 77 @@ -349,33 +562,35 @@ async def scan(command: commands.EnergyScan, request_id: int) -> commands.Status async def test_network_scan( app: ControllerApplication, server: SyntheticZiggurat ) -> None: - beacons = [ - commands.NetworkBeaconEvent( + server.beacons = [ + p.Beacon( channel=t.uint8_t(11), source=t.NWK(0x0000), pan_id=t.PanId(0x1A2B), extended_pan_id=t.ExtendedPanId(t.EUI64.convert("aa:bb:cc:dd:ee:ff:00:11")), - permit_joining=True, + permit_joining=t.uint1_t(1), + router_capacity=t.uint1_t(1), + end_device_capacity=t.uint1_t(1), + reserved=t.uint5_t(0), stack_profile=t.uint8_t(2), protocol_version=t.uint8_t(2), - router_capacity=True, - end_device_capacity=True, device_depth=t.uint8_t(0), update_id=t.uint8_t(0), lqi=t.uint8_t(200), rssi=t.int8s(-60), ), # A beacon whose MAC source was not a short address - commands.NetworkBeaconEvent( + p.Beacon( channel=t.uint8_t(15), - source=None, + source=t.NWK(0xFFFF), pan_id=t.PanId(0x4C5D), extended_pan_id=t.ExtendedPanId(t.EUI64.convert("01:02:03:04:05:06:07:08")), - permit_joining=False, + permit_joining=t.uint1_t(0), + router_capacity=t.uint1_t(0), + end_device_capacity=t.uint1_t(0), + reserved=t.uint5_t(0), stack_profile=t.uint8_t(2), protocol_version=t.uint8_t(2), - router_capacity=False, - end_device_capacity=False, device_depth=t.uint8_t(2), update_id=t.uint8_t(1), lqi=t.uint8_t(120), @@ -383,13 +598,6 @@ async def test_network_scan( ), ] - async def scan(command: commands.NetworkScan, request_id: int) -> commands.Status: - for beacon in beacons: - await server.send_event_data(request_id, "network_found", beacon.to_dict()) - return commands.Status(status="complete") - - server.handlers["network_scan"] = scan - found = [ beacon async for beacon in app.network_scan( @@ -399,9 +607,9 @@ async def scan(command: commands.NetworkScan, request_id: int) -> commands.Statu ) ] - scans = server.sent(commands.NetworkScan) + scans = server.sent(p.NetworkScan) assert len(scans) == 1 - assert scans[0].channels == [11, 15] + assert list(scans[0].channels) == [11, 15] # 0.016 ms/symbol * 960 symbols * (2**2 + 1) assert scans[0].duration_per_channel_ms == 77 @@ -439,136 +647,266 @@ async def scan(command: commands.NetworkScan, request_id: int) -> commands.Statu ] -@pytest.mark.parametrize( - ("dst", "tx_options", "src_ep", "dst_ep", "expected"), - [ - ( +async def test_send_packet_unicast( + app: ControllerApplication, server: SyntheticZiggurat +) -> None: + app.add_device(DEVICE_IEEE, DEVICE_NWK) + + await app.send_packet( + aps_packet( t.AddrModeAddress(addr_mode=t.AddrMode.NWK, address=DEVICE_NWK), - t.TransmitOptions.ACK, - 1, - 1, - { - "delivery_mode": "unicast", - "destination": DEVICE_NWK, - "destination_eui64": None, - "profile_id": 0x0104, - "aps_ack": True, - "aps_encryption": False, - }, - ), - ( + tx_options=t.TransmitOptions.ACK, + ) + ) + + send = server.sent(p.SendUnicast)[-1] + assert send.destination == DEVICE_NWK + assert send.has_eui64 + # The link key is selected by EUI64, resolved from the device registry + assert send.destination_eui64 == DEVICE_IEEE + assert send.aps_ack + 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" + + +async def test_send_packet_unicast_by_ieee( + app: ControllerApplication, server: SyntheticZiggurat +) -> None: + await app.send_packet( + aps_packet(t.AddrModeAddress(addr_mode=t.AddrMode.IEEE, address=DEVICE_IEEE)) + ) + + send = server.sent(p.SendUnicast)[-1] + # 0xFFFE stands in for "no short address": the server resolves the EUI64 + assert send.destination == t.NWK(0xFFFE) + assert send.has_eui64 + assert send.destination_eui64 == DEVICE_IEEE + + +async def test_send_packet_unicast_unknown_device( + app: ControllerApplication, server: SyntheticZiggurat +) -> None: + # An address with no device behind it carries no EUI64 at all + await app.send_packet( + aps_packet(t.AddrModeAddress(addr_mode=t.AddrMode.NWK, address=t.NWK(0x9999))) + ) + + send = server.sent(p.SendUnicast)[-1] + assert send.destination == t.NWK(0x9999) + assert not send.has_eui64 + + +async def test_send_packet_encrypted( + app: ControllerApplication, server: SyntheticZiggurat +) -> None: + app.add_device(DEVICE_IEEE, DEVICE_NWK) + + await app.send_packet( + aps_packet( t.AddrModeAddress(addr_mode=t.AddrMode.NWK, address=DEVICE_NWK), - t.TransmitOptions.NONE, - 0, - 0, - { - "delivery_mode": "unicast", - "profile_id": 0x0104, - "aps_ack": False, - }, - ), - ( - t.AddrModeAddress(addr_mode=t.AddrMode.IEEE, address=DEVICE_IEEE), - t.TransmitOptions.NONE, - 1, - 1, - { - "delivery_mode": "unicast", - "destination": None, - "destination_eui64": DEVICE_IEEE, - }, - ), - ( - t.AddrModeAddress(addr_mode=t.AddrMode.Group, address=t.Group(0x0002)), - t.TransmitOptions.NONE, - 1, - 255, - { - "delivery_mode": "multicast", - "destination": t.NWK(0x0002), - }, - ), - ( + tx_options=t.TransmitOptions.ACK | t.TransmitOptions.APS_Encryption, + ) + ) + + send = server.sent(p.SendUnicast)[-1] + assert send.aps_encryption + assert send.destination_eui64 == DEVICE_IEEE + + +async def test_send_packet_encrypted_without_eui64( + app: ControllerApplication, server: SyntheticZiggurat +) -> None: + """APS encryption selects the link key by EUI64, so a destination that resolves + to no device cannot be encrypted.""" + with pytest.raises(DeliveryError, match="without a destination EUI64"): + await app.send_packet( + aps_packet( + t.AddrModeAddress(addr_mode=t.AddrMode.NWK, address=t.NWK(0x9999)), + tx_options=t.TransmitOptions.APS_Encryption, + ) + ) + + assert server.sent(p.SendUnicast) == [] + + +async def test_send_packet_broadcast( + app: ControllerApplication, server: SyntheticZiggurat +) -> None: + await app.send_packet( + aps_packet( t.AddrModeAddress( addr_mode=t.AddrMode.Broadcast, address=t.BroadcastAddress.ALL_ROUTERS_AND_COORDINATOR, ), - t.TransmitOptions.NONE, - 1, - 255, - { - "delivery_mode": "broadcast", - "destination": t.NWK(0xFFFC), - }, + dst_ep=255, + ) + ) + + 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" + + +async def test_send_packet_groupcast( + app: ControllerApplication, server: SyntheticZiggurat +) -> None: + await app.send_packet( + aps_packet( + t.AddrModeAddress(addr_mode=t.AddrMode.Group, address=t.Group(0x0002)), + dst_ep=255, + ) + ) + + send = server.sent(p.SendGroupcast)[-1] + assert send.group_id == 0x0002 + assert send.aps_seq == 33 + assert bytes(send.asdu) == b"\x01\x02\x03" + + +async def test_send_packet_sleepy_destination( + app: ControllerApplication, server: SyntheticZiggurat +) -> None: + await app.send_packet( + t.ZigbeePacket( + src=t.AddrModeAddress(addr_mode=t.AddrMode.NWK, address=t.NWK(0x0000)), + 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(1), + profile_id=t.uint16_t(0x0104), + cluster_id=t.uint16_t(0x0006), + data=t.SerializableBytes(b"\x01"), + extended_timeout=True, + priority=t.PacketPriority.HIGH, + radius=t.uint8_t(5), + ) + ) + + send = server.sent(p.SendUnicast)[-1] + assert send.sleepy_destination + assert int(send.priority) == int(t.PacketPriority.HIGH) + assert send.radius == 5 + + +@pytest.mark.parametrize( + ("relays", "source_routing", "route", "next_hop", "expected_relays"), + [ + # Nothing is known about the route: the stack decides + (None, False, p.RouteControl.STACK_DECIDES, None, None), + # A direct child is one hop away + ([], False, p.RouteControl.HINT_NEXT_HOP, DEVICE_NWK, None), + # zigpy stores relays in received order, so the next hop is the last one + ([t.NWK(0x1111)], False, p.RouteControl.HINT_NEXT_HOP, t.NWK(0x1111), None), + ( + [t.NWK(0x1111), t.NWK(0x2222)], + False, + p.RouteControl.HINT_NEXT_HOP, + t.NWK(0x2222), + None, ), + # With source routing enabled the whole path is supplied instead ( - t.AddrModeAddress(addr_mode=t.AddrMode.NWK, address=DEVICE_NWK), - t.TransmitOptions.ACK | t.TransmitOptions.APS_Encryption, - 1, - 1, - { - "delivery_mode": "unicast", - "destination": DEVICE_NWK, - # The link key is selected by EUI64, resolved from the device registry - "destination_eui64": DEVICE_IEEE, - "aps_encryption": True, - }, + [t.NWK(0x1111), t.NWK(0x2222)], + True, + p.RouteControl.HINT_SOURCE_ROUTE, + None, + [t.NWK(0x2222), t.NWK(0x1111)], ), + # A device with no known relays gives source routing nothing to work with + (None, True, p.RouteControl.STACK_DECIDES, None, None), ], ) -async def test_send_packet( +async def test_send_packet_route_hints( + server: SyntheticZiggurat, + relays: list[t.NWK] | None, + source_routing: bool, + route: p.RouteControl, + next_hop: t.NWK | None, + expected_relays: list[t.NWK] | None, +) -> None: + """Within the startup window the app hints the route it already knows, to keep + the stack from rediscovering every path at once.""" + app = ControllerApplication( + make_app_config( + server.url, **{zigpy.config.CONF_SOURCE_ROUTING: source_routing} + ) + ) + await app.connect() + await app.start_network() + + device = app.add_device(DEVICE_IEEE, DEVICE_NWK) + device.relays = t.Relays(relays) if relays is not None else None + + await app.send_packet( + aps_packet(t.AddrModeAddress(addr_mode=t.AddrMode.NWK, address=DEVICE_NWK)) + ) + + send = server.sent(p.SendUnicast)[-1] + assert send.route == route + assert send.next_hop == next_hop + if expected_relays is None: + assert send.relays is None + else: + assert send.relays is not None + assert list(send.relays.relays) == expected_relays + + await app.shutdown(db=False) + + +async def test_send_packet_no_route_hints_after_startup( app: ControllerApplication, server: SyntheticZiggurat, - dst: t.AddrModeAddress, - tx_options: t.TransmitOptions, - src_ep: int, - dst_ep: int, - expected: dict[str, Any], + monkeypatch: pytest.MonkeyPatch, ) -> None: - app.add_device(DEVICE_IEEE, DEVICE_NWK) + """Past the startup window the stack's own routing table is authoritative.""" + monkeypatch.setattr(application_module, "ROUTE_HINT_DURATION", timedelta(0)) + + device = app.add_device(DEVICE_IEEE, DEVICE_NWK) + device.relays = t.Relays([t.NWK(0x1111)]) await app.send_packet( - t.ZigbeePacket( - src=t.AddrModeAddress(addr_mode=t.AddrMode.NWK, address=t.NWK(0x0000)), - src_ep=t.uint8_t(src_ep), - dst=dst, - dst_ep=t.uint8_t(dst_ep), - tsn=t.uint8_t(33), - profile_id=t.uint16_t(0x0104), - cluster_id=t.uint16_t(0x0006), - data=t.SerializableBytes(b"\x01\x02\x03"), - tx_options=tx_options, - ) + aps_packet(t.AddrModeAddress(addr_mode=t.AddrMode.NWK, address=DEVICE_NWK)) ) - request = server.sent(commands.SendAps)[-1] - assert request.data == b"\x01\x02\x03" - assert request.aps_seq == 33 - assert request.radius == 30 - for field, value in expected.items(): - assert getattr(request, field) == value + send = server.sent(p.SendUnicast)[-1] + assert send.route == p.RouteControl.STACK_DECIDES + assert send.next_hop is None async def test_send_packet_delivery_failure( app: ControllerApplication, server: SyntheticZiggurat ) -> None: - async def fail(command: commands.SendAps, request_id: int) -> commands.Status: - raise RpcError("transmit_failed", "radio unavailable") + async def fail(request: p.SendUnicast, request_id: int) -> None: + raise StatusError(p.Status.RADIO_ERROR) - server.handlers["send_aps"] = fail + server.handlers[p.RequestCommand.SEND_UNICAST] = fail - with pytest.raises(DeliveryError, match="transmit_failed"): + with pytest.raises(DeliveryError, match="radio_error"): await app.send_packet( - t.ZigbeePacket( - src=t.AddrModeAddress(addr_mode=t.AddrMode.NWK, address=t.NWK(0x0000)), - 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"), - ) + aps_packet(t.AddrModeAddress(addr_mode=t.AddrMode.NWK, address=DEVICE_NWK)) + ) + + +async def test_send_packet_confirm_failure( + app: ControllerApplication, server: SyntheticZiggurat +) -> None: + async def no_route(request: p.SendUnicast, request_id: int) -> None: + await server.send_notification( + p.SendConfirm(status=p.SendStatus.ROUTE_DISCOVERY_TIMEOUT), request_id + ) + + server.handlers[p.RequestCommand.SEND_UNICAST] = no_route + + with pytest.raises(DeliveryError, match="ROUTE_DISCOVERY_TIMEOUT"): + await app.send_packet( + aps_packet(t.AddrModeAddress(addr_mode=t.AddrMode.NWK, address=DEVICE_NWK)) ) @@ -603,15 +941,18 @@ async def test_force_remove(app: ControllerApplication) -> None: await app.force_remove(app.get_device(nwk=t.NWK(0x0000))) -async def test_reset_network_info(app: ControllerApplication) -> None: +async def test_reset_network_info( + app: ControllerApplication, server: SyntheticZiggurat +) -> None: await app.reset_network_info() + assert server.sent(p.Shutdown) async def test_watchdog_feed( app: ControllerApplication, server: SyntheticZiggurat ) -> None: await app._watchdog_feed() - assert isinstance(server.requests[-1], commands.Ping) + assert isinstance(server.requests[-1], p.GetFirmwareInfo) async def test_move_network_to_channel( @@ -621,10 +962,10 @@ async def test_move_network_to_channel( # The update id goes first so no beacon on the new channel ever advertises the # old network instance - methods = [type(r) for r in server.requests] - assert methods.index(commands.SetNwkUpdateId) < methods.index(commands.SetChannel) - assert server.sent(commands.SetNwkUpdateId)[-1].nwk_update_id == 1 - assert server.sent(commands.SetChannel)[-1].channel == 20 + commands = [type(r) for r in server.requests] + assert commands.index(p.SetNwkUpdateId) < commands.index(p.SetChannel) + assert server.sent(p.SetNwkUpdateId)[-1].nwk_update_id == 1 + assert server.sent(p.SetChannel)[-1].channel == 20 async def test_packet_received( @@ -635,32 +976,32 @@ async def test_packet_received( # Node_Desc_req: answered locally, advertising the trust center's revision app.packet_received(zdo_packet(zdo_t.ZDOCmd.Node_Desc_req, b"\x10" + our_nwk)) - reply = await server.wait_for(commands.SendAps) + reply = await server.wait_for(p.SendUnicast) assert reply.cluster_id == zdo_t.ZDOCmd.Node_Desc_rsp assert reply.destination == DEVICE_NWK - assert reply.data[0] == 0x10 - assert reply.data[1] == zdo_t.Status.SUCCESS + assert reply.asdu[0] == 0x10 + assert reply.asdu[1] == zdo_t.Status.SUCCESS # Active_EP_req app.packet_received(zdo_packet(zdo_t.ZDOCmd.Active_EP_req, b"\x11" + our_nwk)) - reply = await server.wait_for(commands.SendAps, count=2) + reply = await server.wait_for(p.SendUnicast, count=2) assert reply.cluster_id == zdo_t.ZDOCmd.Active_EP_rsp - assert reply.data[1] == zdo_t.Status.SUCCESS - endpoint_count = reply.data[4] - assert 1 in reply.data[5 : 5 + endpoint_count] + assert reply.asdu[1] == zdo_t.Status.SUCCESS + endpoint_count = reply.asdu[4] + assert 1 in reply.asdu[5 : 5 + endpoint_count] # Simple_Desc_req for a registered endpoint app.packet_received( zdo_packet(zdo_t.ZDOCmd.Simple_Desc_req, b"\x12" + our_nwk + b"\x01") ) - reply = await server.wait_for(commands.SendAps, count=3) + reply = await server.wait_for(p.SendUnicast, count=3) assert reply.cluster_id == zdo_t.ZDOCmd.Simple_Desc_rsp - assert reply.data[1] == zdo_t.Status.SUCCESS + assert reply.asdu[1] == zdo_t.Status.SUCCESS # Requests that are not answered locally. zigpy may originate its own requests # (e.g. IEEE_addr_req for an unknown sender), so only count ZDO responses. - def zdo_replies() -> list[commands.SendAps]: - return [r for r in server.sent(commands.SendAps) if r.cluster_id & 0x8000] + def zdo_replies() -> list[p.SendUnicast]: + return [r for r in server.sent(p.SendUnicast) if r.cluster_id & 0x8000] replies_before = len(zdo_replies()) for packet in [ @@ -685,91 +1026,183 @@ def zdo_replies() -> list[commands.SendAps]: assert len(zdo_replies()) == replies_before -async def test_on_notification_received_aps_command( +async def test_packet_received_aqara_node_desc_override( + app: ControllerApplication, server: SyntheticZiggurat +) -> None: + our_nwk = t.NWK(0x0000).serialize() + coordinator = app.get_device(nwk=t.NWK(0x0000)) + assert coordinator.node_desc is not None + assert coordinator.node_desc.manufacturer_code == application_module.DEFAULT_MFG_ID + + def reported_mfg_code(reply: p.SendUnicast) -> int: + node_desc, _ = zdo_t.NodeDescriptor.deserialize(bytes(reply.asdu[4:])) + return node_desc.manufacturer_code + + # A Lumi/Aqara device is answered with the Xiaomi manufacturer code so it pairs + aqara_nwk = t.NWK(0x1234) + add_initialized_device( + app, ieee=t.EUI64.convert("54:ef:44:00:00:00:00:01"), nwk=aqara_nwk + ) + app.packet_received( + zdo_packet(zdo_t.ZDOCmd.Node_Desc_req, b"\x20" + our_nwk, src=aqara_nwk) + ) + reply = await server.wait_for(p.SendUnicast) + assert reply.cluster_id == zdo_t.ZDOCmd.Node_Desc_rsp + assert reported_mfg_code(reply) == 0x115F + + # The coordinator's stored descriptor is untouched; other devices see the default + assert coordinator.node_desc.manufacturer_code == application_module.DEFAULT_MFG_ID + add_initialized_device(app) + app.packet_received( + zdo_packet(zdo_t.ZDOCmd.Node_Desc_req, b"\x21" + our_nwk, src=DEVICE_NWK) + ) + reply = await server.wait_for(p.SendUnicast, count=2) + assert reported_mfg_code(reply) == application_module.DEFAULT_MFG_ID + + +async def test_on_notification_received_aps( app: ControllerApplication, server: SyntheticZiggurat ) -> None: add_initialized_device(app) # A ZDO request arriving over the wire is answered end-to-end await server.send_notification( - commands.ReceivedApsCommand( + p.ReceivedAps( source=DEVICE_NWK, destination=t.NWK(0x0000), - group=None, + has_group=t.Bool(False), + 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=b"\x77" + t.NWK(0x0000).serialize(), + data=t.LongOctetString(b"\x77" + t.NWK(0x0000).serialize()), ) ) - reply = await server.wait_for(commands.SendAps) + reply = await server.wait_for(p.SendUnicast) assert reply.cluster_id == zdo_t.ZDOCmd.Node_Desc_rsp - assert reply.data[0] == 0x77 + assert reply.asdu[0] == 0x77 + + +@pytest.mark.parametrize( + ("destination", "group", "expected_dst"), + [ + ( + t.NWK(0x0000), + 2, + t.AddrModeAddress(addr_mode=t.AddrMode.Group, address=t.Group(2)), + ), + ( + t.NWK(0xFFFD), + None, + t.AddrModeAddress( + addr_mode=t.AddrMode.Broadcast, address=t.BroadcastAddress(0xFFFD) + ), + ), + ( + t.NWK(0x0000), + None, + t.AddrModeAddress(addr_mode=t.AddrMode.NWK, address=t.NWK(0x0000)), + ), + ], +) +async def test_on_notification_received_aps_address_modes( + server: SyntheticZiggurat, + destination: t.NWK, + group: int | None, + expected_dst: t.AddrModeAddress, +) -> None: + """A group id, a broadcast address and a plain network address each decode into + their own zigpy address mode.""" + app = RecordingApplication(make_app_config(server.url)) + await app.connect() + await app.start_network() + add_initialized_device(app) - # Group- and broadcast-addressed frames parse into their address modes - await server.send_notification( - commands.ReceivedApsCommand( - source=DEVICE_NWK, - destination=t.NWK(0x0000), - group=2, - profile_id=t.uint16_t(0x0104), - cluster_id=t.uint16_t(0x0006), - src_ep=t.uint8_t(1), - dst_ep=t.uint8_t(255), - lqi=t.uint8_t(255), - rssi=t.int8s(-40), - data=b"\x01", - ) - ) await server.send_notification( - commands.ReceivedApsCommand( + p.ReceivedAps( source=DEVICE_NWK, - destination=t.NWK(0xFFFD), - group=None, + destination=destination, + has_group=t.Bool(group is not None), + group=t.uint16_t(group or 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(255), - rssi=t.int8s(-40), - data=b"\x02", + lqi=t.uint8_t(200), + rssi=t.int8s(-70), + # A ZCL Read Attributes of the OnOff attribute + data=t.LongOctetString(b"\x00\x01\x00\x00\x00"), ) ) await flush(app) + assert len(app.packets) == 1 + packet = app.packets[0] + assert packet.dst == expected_dst + assert packet.src == t.AddrModeAddress(addr_mode=t.AddrMode.NWK, address=DEVICE_NWK) + assert packet.lqi == 200 + assert packet.rssi == -70 + assert packet.data.serialize() == b"\x00\x01\x00\x00\x00" -async def test_on_notification_frame_counter_update( + await app.shutdown(db=False) + + +async def test_on_notification_frame_counter( app: ControllerApplication, server: SyntheticZiggurat ) -> None: - await server.send_notification( - commands.FrameCounterUpdate(frame_counter=t.uint32_t(123456)) - ) + await server.send_notification(p.FrameCounter(frame_counter=t.uint32_t(123456))) await flush(app) assert app.state.network_info.network_key.tx_counter == 123456 assert app.backups[-1].network_info.network_key.tx_counter == 123456 -async def test_on_notification_link_key_update( +async def test_on_notification_aps_frame_counter( app: ControllerApplication, server: SyntheticZiggurat ) -> None: - new_key = t.KeyData.convert("ff:ee:dd:cc:bb:aa:99:88:77:66:55:44:33:22:11:00") + """The APS outgoing frame counter lives on the TC link key by convention.""" + assert app.state.network_info.tc_link_key.tx_counter != 654321 + await server.send_notification(p.ApsFrameCounter(frame_counter=t.uint32_t(654321))) + await flush(app) + + assert app.state.network_info.tc_link_key.tx_counter == 654321 + assert app.backups[-1].network_info.tc_link_key.tx_counter == 654321 + + +async def test_on_notification_route_record( + app: ControllerApplication, server: SyntheticZiggurat +) -> None: + """A route record reveals the path a device's frames took to reach us, which + zigpy stores as the relay list to send back along.""" + device = add_initialized_device(app) + assert device.relays is None + + relays = [t.NWK(0x1111), t.NWK(0x2222)] await server.send_notification( - commands.LinkKeyUpdate(ieee=DEVICE_IEEE, key=LINK_KEY) + p.RouteRecord(destination=DEVICE_NWK, relays=t.LVList[t.NWK, t.uint8_t](relays)) ) await flush(app) + + assert device.relays == relays + + +async def test_on_notification_link_key( + app: ControllerApplication, server: SyntheticZiggurat +) -> None: + new_key = t.KeyData.convert("ff:ee:dd:cc:bb:aa:99:88:77:66:55:44:33:22:11:00") + + await server.send_notification(p.LinkKey(ieee=DEVICE_IEEE, key=LINK_KEY)) + await flush(app) assert app.state.network_info.key_table == [ zigpy.state.Key(key=LINK_KEY, partner_ieee=DEVICE_IEEE) ] # A renegotiated key replaces the previous entry instead of duplicating it - await server.send_notification( - commands.LinkKeyUpdate(ieee=DEVICE_IEEE, key=new_key) - ) + await server.send_notification(p.LinkKey(ieee=DEVICE_IEEE, key=new_key)) await flush(app) assert app.state.network_info.key_table == [ zigpy.state.Key(key=new_key, partner_ieee=DEVICE_IEEE) @@ -784,10 +1217,18 @@ async def test_on_notification_device_joined( ) -> None: monkeypatch.setattr(application_module, "DEVICE_JOIN_MAX_DELAY", 0.05) + def joined(nwk: t.NWK, ieee: t.EUI64) -> p.DeviceJoined: + return p.DeviceJoined( + nwk=nwk, + ieee=ieee, + parent=t.NWK(0x0000), + rx_on_when_idle=t.uint1_t(1), + device_type=p.ChildDeviceType.ROUTER, + reserved=t.uint5_t(0), + ) + # A brand-new device only joins after the announcement grace period - await server.send_notification( - commands.DeviceJoined(nwk=DEVICE_NWK, ieee=DEVICE_IEEE, parent=t.NWK(0x0000)) - ) + await server.send_notification(joined(DEVICE_NWK, DEVICE_IEEE)) await flush(app) with pytest.raises(KeyError): app.get_device(ieee=DEVICE_IEEE) @@ -796,9 +1237,7 @@ async def test_on_notification_device_joined( # A device that announced itself within the grace period is not joined again ieee2 = t.EUI64.convert("bb:bb:bb:bb:bb:bb:bb:bb") - await server.send_notification( - commands.DeviceJoined(nwk=t.NWK(0x5678), ieee=ieee2, parent=t.NWK(0x0000)) - ) + await server.send_notification(joined(t.NWK(0x5678), ieee2)) await flush(app) device2 = app.add_device(ieee2, t.NWK(0x5678)) # the announcement's effect await asyncio.sleep(0.1) @@ -809,47 +1248,11 @@ async def test_on_notification_device_joined( device3 = app.add_device(ieee3, t.NWK(0x9999)) device3.node_desc = app.get_device(nwk=t.NWK(0x0000)).node_desc device3.status = zigpy.device.Status.ENDPOINTS_INIT - await server.send_notification( - commands.DeviceJoined(nwk=t.NWK(0x9AAA), ieee=ieee3, parent=t.NWK(0x0000)) - ) + await server.send_notification(joined(t.NWK(0x9AAA), ieee3)) await flush(app) assert app.get_device(ieee=ieee3).nwk == t.NWK(0x9AAA) -async def test_packet_received_aqara_node_desc_override( - app: ControllerApplication, server: SyntheticZiggurat -) -> None: - our_nwk = t.NWK(0x0000).serialize() - coordinator = app.get_device(nwk=t.NWK(0x0000)) - assert coordinator.node_desc is not None - assert coordinator.node_desc.manufacturer_code == application_module.DEFAULT_MFG_ID - - def reported_mfg_code(reply: commands.SendAps) -> int: - node_desc, _ = zdo_t.NodeDescriptor.deserialize(bytes(reply.data[4:])) - return node_desc.manufacturer_code - - # A Lumi/Aqara device is answered with the Xiaomi manufacturer code so it pairs - aqara_nwk = t.NWK(0x1234) - add_initialized_device( - app, ieee=t.EUI64.convert("54:ef:44:00:00:00:00:01"), nwk=aqara_nwk - ) - app.packet_received( - zdo_packet(zdo_t.ZDOCmd.Node_Desc_req, b"\x20" + our_nwk, src=aqara_nwk) - ) - reply = await server.wait_for(commands.SendAps) - assert reply.cluster_id == zdo_t.ZDOCmd.Node_Desc_rsp - assert reported_mfg_code(reply) == 0x115F - - # The coordinator's stored descriptor is untouched; other devices see the default - assert coordinator.node_desc.manufacturer_code == application_module.DEFAULT_MFG_ID - add_initialized_device(app) - app.packet_received( - zdo_packet(zdo_t.ZDOCmd.Node_Desc_req, b"\x21" + our_nwk, src=DEVICE_NWK) - ) - reply = await server.wait_for(commands.SendAps, count=2) - assert reported_mfg_code(reply) == application_module.DEFAULT_MFG_ID - - async def test_on_notification_device_left( app: ControllerApplication, server: SyntheticZiggurat ) -> None: @@ -862,38 +1265,38 @@ def device_left(self, device: zigpy.device.Device) -> None: app.add_listener(Listener()) device = app.add_device(DEVICE_IEEE, DEVICE_NWK) + def device_left( + nwk: t.NWK, ieee: t.EUI64 | None, reason: p.LeaveReason + ) -> p.DeviceLeft: + return p.DeviceLeft( + nwk=nwk, + has_ieee=t.uint1_t(ieee is not None), + rejoin=t.uint1_t(0), + has_router_ieee=t.uint1_t(0), + reserved=t.uint5_t(0), + ieee=ieee if ieee is not None else t.EUI64([0] * 8), + reason=reason, + router=t.NWK(0xFFFF), + router_ieee=t.EUI64([0] * 8), + ) + # The device announced its own departure await server.send_notification( - commands.DeviceLeft( - nwk=DEVICE_NWK, - ieee=DEVICE_IEEE, - reason=commands.DeviceLeaveReason.ANNOUNCED, - rejoin=False, - ) + device_left(DEVICE_NWK, DEVICE_IEEE, p.LeaveReason.ANNOUNCED) ) await flush(app) assert left == [device] # A parent router relayed the leave; the IEEE is resolved through the registry await server.send_notification( - commands.DeviceLeft( - nwk=DEVICE_NWK, - ieee=None, - reason=commands.DeviceLeaveReason.ROUTER_REPORTED, - router=t.NWK(0x1234), - router_ieee=t.EUI64.convert("bb:bb:bb:bb:bb:bb:bb:bb"), - ) + device_left(DEVICE_NWK, None, p.LeaveReason.ROUTER_REPORTED) ) await flush(app) assert left == [device, device] # An entirely unknown device is dropped await server.send_notification( - commands.DeviceLeft( - nwk=t.NWK(0xBEEF), - ieee=None, - reason=commands.DeviceLeaveReason.KEEPALIVE_TIMEOUT, - ) + device_left(t.NWK(0xBEEF), None, p.LeaveReason.KEEPALIVE_TIMEOUT) ) await flush(app) assert left == [device, device] @@ -904,20 +1307,19 @@ async def test_on_notification_aps_decryption_failure( server: SyntheticZiggurat, caplog: pytest.LogCaptureFixture, ) -> None: - source_ieee = t.EUI64.convert("aa:aa:aa:aa:aa:aa:aa:aa") with caplog.at_level(logging.WARNING): await server.send_notification( - commands.ApsDecryptionFailure( + p.ApsDecryptFailure( source=t.NWK(0x1234), - source_ieee=source_ieee, + source_ieee=DEVICE_IEEE, frame_counter=t.uint32_t(42), - key_id="tc_link_key", + key_id=p.KeyId.KEY_TRANSPORT, ) ) await flush(app) assert "Could not decrypt an APS command" in caplog.text - assert str(source_ieee) in caplog.text + assert str(DEVICE_IEEE) in caplog.text async def test_connection_lost( @@ -937,3 +1339,39 @@ def connection_lost(self, exc: BaseException | None) -> None: await asyncio.sleep(0.01) assert lost == [None] + + +async def test_packet_capture( + app: ControllerApplication, server: SyntheticZiggurat +) -> None: + server.captured_packets = [ + p.CapturedPacket( + channel=t.uint8_t(15), + rssi=t.int8s(-80), + lqi=t.uint8_t(200), + psdu=t.LongOctetString(b"\xaa\xbb\xcc"), + ) + ] + + packets = [packet async for packet in app.packet_capture(15)] + + assert len(packets) == 1 + assert packets[0].channel == 15 + assert packets[0].rssi == -80 + assert packets[0].lqi == 200 + assert packets[0].data == b"\xaa\xbb\xcc" + assert server.sent(p.PacketCapture)[0].channel == 15 + + +async def test_packet_capture_change_channel( + app: ControllerApplication, server: SyntheticZiggurat +) -> None: + await app.packet_capture_change_channel(20) + + assert server.sent(p.PacketCaptureChannel)[0].channel == 20 + + +def test_max_concurrent_requests() -> None: + assert application_module._max_concurrent_requests("ws://host/") == 128 + assert application_module._max_concurrent_requests("ws+unix:///run/z.sock") == 128 + assert application_module._max_concurrent_requests("/dev/ttyUSB0") == 32 diff --git a/tests/test_legacy.py b/tests/test_legacy.py new file mode 100644 index 0000000..9212e81 --- /dev/null +++ b/tests/test_legacy.py @@ -0,0 +1,1127 @@ +"""Tests for the legacy JSON-RPC server and the transport shim that transcodes the +binary protocol to it. Both the shim and this file are temporary: when the legacy +server is retired, delete them together. + +The synthetic JSON server lives here rather than in `tests/common.py` for the same +reason -- nothing else depends on it.""" + +import asyncio +from collections.abc import AsyncIterator, Awaitable, Callable +import dataclasses +import json +import logging +from typing import Any, TypeVar + +from aiohttp import web +from aiohttp.test_utils import TestServer +import pytest +import zigpy.device +import zigpy.endpoint +from zigpy.exceptions import DeliveryError, NetworkNotFormed +import zigpy.state +import zigpy.types as t +import zigpy.zdo.types as zdo_t + +from tests.common import ( + COORDINATOR_IEEE, + DEVICE_IEEE, + DEVICE_NWK, + LINK_KEY, + NETWORK_KEY, + flush, + make_app_config, +) +from zigpy_ziggurat.zigbee import ( + application as application_module, + legacy as commands, + protocol as p, +) +from zigpy_ziggurat.zigbee.application import ControllerApplication +from zigpy_ziggurat.zigbee.transport import LegacyWebSocketTransport, connect_transport + +REQUEST_T = TypeVar("REQUEST_T") + + +def _request_types() -> dict[str, type[commands.Request[Any]]]: + """Every concrete request, walking past intermediate bases like + `StreamingRequest` that declare `method` without assigning it.""" + result: dict[str, type[commands.Request[Any]]] = {} + stack = list(commands.Request.__subclasses__()) + while stack: + cls = stack.pop() + stack.extend(cls.__subclasses__()) + if "method" in cls.__dict__: + result[cls.method] = cls + return result + + +REQUEST_TYPES: dict[str, type[commands.Request[Any]]] = _request_types() +NOTIFICATION_EVENTS: dict[type[commands.Notification], str] = { + cls: name for name, cls in commands.NOTIFICATIONS.items() +} + + +class RpcError(Exception): + """Raised by a handler to produce an error response.""" + + def __init__(self, code: str, message: str) -> None: + super().__init__(f"{code}: {message}") + self.code = code + self.message = message + + +def make_network_info() -> commands.NetworkInfo: + return commands.NetworkInfo( + channel=t.uint8_t(15), + nwk_update_id=t.uint8_t(0), + pan_id=t.PanId(0x1A2B), + extended_pan_id=t.ExtendedPanId(t.EUI64.convert("aa:bb:cc:dd:ee:ff:00:11")), + nwk_address=t.NWK(0x0000), + ieee_address=COORDINATOR_IEEE, + network_key=NETWORK_KEY, + network_key_seq=t.uint8_t(0), + network_key_tx_counter=t.uint32_t(1000), + tc_link_key=t.KeyData(b"ZigBeeAlliance09"), + tx_power=8, + tclk_seed=None, + tclk_flavor=None, + key_table=[], + ) + + +class SyntheticLegacyZiggurat: + """A real aiohttp websocket server speaking the legacy JSON protocol, with + per-method handlers that tests can override.""" + + def __init__(self) -> None: + self.web_app = web.Application() + self.web_app.router.add_get("/", self._handle_connection) + self.url = "" + self.connections = 0 + self._ws: web.WebSocketResponse | None = None + self.requests: list[Any] = [] + self._configured: commands.Configure | None = None + self.network_info = make_network_info() + self.hw_address = t.EUI64.convert("11:22:33:44:55:66:77:88") + self.handlers: dict[str, Callable[[Any, int], Awaitable[Any]]] = { + "ping": self.on_ping, + "configure": self.on_configure, + "get_network_info": self.on_get_network_info, + "get_hw_address": self.on_get_hw_address, + "send_aps": self.on_send_aps, + "energy_scan": self.on_energy_scan, + "network_scan": self.on_network_scan, + "permit_joins": self.on_status, + "set_provisional_key": self.on_status, + "set_channel": self.on_status, + "set_nwk_update_id": self.on_status, + "packet_capture": self.on_status, + "packet_capture_change_channel": self.on_status, + } + + @property + def ws(self) -> web.WebSocketResponse: + assert self._ws is not None + return self._ws + + @property + def configured(self) -> commands.Configure: + assert self._configured is not None + return self._configured + + async def _handle_connection(self, request: web.Request) -> web.WebSocketResponse: + self.connections += 1 + ws = web.WebSocketResponse() + await ws.prepare(request) + self._ws = ws + + await ws.send_json({"type": "hello", "version": 1, "state": "running"}) + + async for msg in ws: + data = json.loads(msg.data) + command = REQUEST_TYPES[data["method"]].from_dict(data["params"]) + self.requests.append(command) + await ws.send_json({"type": "event", "id": data["id"], "event": "accepted"}) + + try: + response = await self.handlers[data["method"]](command, data["id"]) + except RpcError as exc: + await ws.send_json( + { + "type": "response", + "id": data["id"], + "error": {"code": exc.code, "message": exc.message}, + } + ) + else: + # `None` deliberately withholds the response + if response is not None: + await ws.send_json( + { + "type": "response", + "id": data["id"], + "result": response.to_dict(), + } + ) + + return ws + + async def send_event(self, request_id: int, event: str) -> None: + await self.ws.send_json({"type": "event", "id": request_id, "event": event}) + + async def send_event_data( + self, request_id: int, event: str, data: dict[str, Any] + ) -> None: + await self.ws.send_json( + {"type": "event", "id": request_id, "event": event, "data": data} + ) + + async def send_confirm(self, request_id: int, *, reason: str | None = None) -> None: + if reason is not None: + data: dict[str, Any] = { + "id": request_id, + "status": "failed", + "reason": reason, + } + else: + data = {"id": request_id, "status": "confirmed", "next_hop": None} + + await self.ws.send_json( + {"type": "notification", "event": "send_confirm", "data": data} + ) + + async def aps_ack_confirm( + self, request_id: int, *, reason: str | None = None + ) -> None: + if reason is not None: + data: dict[str, Any] = { + "id": request_id, + "status": "failed", + "reason": reason, + } + else: + data = {"id": request_id, "status": "confirmed"} + + await self.ws.send_json( + {"type": "notification", "event": "aps_ack_confirm", "data": data} + ) + + async def send_notification(self, notification: commands.Notification) -> None: + await self.ws.send_json( + { + "type": "notification", + "event": NOTIFICATION_EVENTS[type(notification)], + "data": notification.to_dict(), + } + ) + + async def send_raw(self, text: str) -> None: + await self.ws.send_str(text) + + def sent(self, request_type: type[REQUEST_T]) -> list[REQUEST_T]: + return [r for r in self.requests if isinstance(r, request_type)] + + async def wait_for( + self, request_type: type[REQUEST_T], count: int = 1 + ) -> REQUEST_T: + async with asyncio.timeout(2): + while len(self.sent(request_type)) < count: + await asyncio.sleep(0.01) + + return self.sent(request_type)[count - 1] + + async def on_ping(self, command: commands.Ping, request_id: int) -> commands.Status: + return commands.Status(status="pong") + + async def on_status(self, command: Any, request_id: int) -> commands.Status: + return commands.Status(status="success") + + async def on_configure( + self, command: commands.Configure, request_id: int + ) -> commands.Status: + self._configured = command + return commands.Status(status="success") + + async def on_get_network_info( + self, command: commands.GetNetworkInfo, request_id: int + ) -> commands.NetworkInfo: + return self.network_info + + async def on_get_hw_address( + self, command: commands.GetHwAddress, request_id: int + ) -> commands.HwAddress: + return commands.HwAddress(ieee_address=self.hw_address) + + async def on_send_aps( + self, command: commands.SendAps, request_id: int + ) -> commands.Status: + await self.send_confirm(request_id) + if command.aps_ack: + await self.aps_ack_confirm(request_id) + return commands.Status(status="accepted") + + async def on_energy_scan( + self, command: commands.EnergyScan, request_id: int + ) -> commands.Status: + for channel in command.channels: + await self.send_event_data( + request_id, + "energy_result", + commands.EnergyScanResult( + channel=t.uint8_t(channel), rssi=t.int8s(-85) + ).to_dict(), + ) + return commands.Status(status="complete") + + async def on_network_scan( + self, command: commands.NetworkScan, request_id: int + ) -> commands.Status: + return commands.Status(status="complete") + + +@pytest.fixture +async def legacy_server() -> AsyncIterator[SyntheticLegacyZiggurat]: + ziggurat = SyntheticLegacyZiggurat() + test_server = TestServer(ziggurat.web_app) + await test_server.start_server() + ziggurat.url = f"ws://localhost:{test_server.port}/" + + yield ziggurat + + await test_server.close() + + +@pytest.fixture +async def legacy_connected_app( + legacy_server: SyntheticLegacyZiggurat, +) -> AsyncIterator[ControllerApplication]: + app = ControllerApplication(make_app_config(legacy_server.url)) + await app.connect() + + yield app + + await app.shutdown(db=False) + + +@pytest.fixture +async def legacy_app( + legacy_connected_app: ControllerApplication, +) -> ControllerApplication: + await legacy_connected_app.start_network() + return legacy_connected_app + + +async def _legacy( + server: SyntheticLegacyZiggurat, +) -> tuple[LegacyWebSocketTransport, list[bytes]]: + frames: list[bytes] = [] + transport = await connect_transport(server.url, frames.append, lambda exc: None) + assert isinstance(transport, LegacyWebSocketTransport) + return transport, frames + + +async def _wait_for(frames: list[bytes], count: int = 1) -> None: + async with asyncio.timeout(2): + while len(frames) < count: + await asyncio.sleep(0.01) + + +def add_initialized_device(app: ControllerApplication) -> zigpy.device.Device: + device = app.add_device(DEVICE_IEEE, DEVICE_NWK) + device.node_desc = app.get_device(nwk=t.NWK(0x0000)).node_desc + device.status = zigpy.device.Status.ENDPOINTS_INIT + device.add_endpoint(1).status = zigpy.endpoint.Status.ZDO_INIT + return device + + +# -- protocol probing ------------------------------------------------------------ + + +async def test_probe_selects_legacy(legacy_server: SyntheticLegacyZiggurat) -> None: + transport = await connect_transport( + legacy_server.url, lambda frame: None, lambda exc: None + ) + try: + assert isinstance(transport, LegacyWebSocketTransport) + finally: + await transport.disconnect() + + +# -- application against the legacy server --------------------------------------- + + +async def test_legacy_connect( + legacy_connected_app: ControllerApplication, + legacy_server: SyntheticLegacyZiggurat, +) -> None: + assert legacy_server.connections == 1 + + await legacy_connected_app.permit_ncp(1) + permit = legacy_server.sent(commands.PermitJoins)[-1] + assert permit.duration == 1 + assert permit.accept_direct_joins is True + + +async def test_legacy_start_network( + legacy_connected_app: ControllerApplication, + legacy_server: SyntheticLegacyZiggurat, +) -> None: + """The binary `Configure` + `LoadKeyTable`* + `StartNetwork` sequence coalesces + into the single JSON `configure` call the legacy server takes.""" + await legacy_connected_app.start_network() + + assert legacy_server.configured.channel == 15 + assert legacy_server.configured.pan_id == t.PanId(0x1A2B) + assert legacy_server.configured.network_key == NETWORK_KEY + assert legacy_server.configured.aps_frame_counter == 0 + # One JSON call, however many binary frames it was split across + assert len(legacy_server.sent(commands.Configure)) == 1 + assert legacy_connected_app.backups[-1].network_info.pan_id == t.PanId(0x1A2B) + + +async def test_legacy_load_network_info( + legacy_connected_app: ControllerApplication, + legacy_server: SyntheticLegacyZiggurat, +) -> None: + app = legacy_connected_app + + async def not_configured( + command: commands.GetNetworkInfo, request_id: int + ) -> commands.NetworkInfo: + raise RpcError("not_configured", "no stack is running") + + # A stateless server with no network running and no local backup: no network + legacy_server.handlers["get_network_info"] = not_configured + with pytest.raises(NetworkNotFormed): + await app.load_network_info() + + # Unrelated errors propagate. The unknown JSON code has no binary status, so + # it degrades to a generic invalid-request; the detail goes to the log. + async def serial_error( + command: commands.GetNetworkInfo, request_id: int + ) -> commands.NetworkInfo: + raise RpcError("serial_port_error", "it burned down") + + legacy_server.handlers["get_network_info"] = serial_error + with pytest.raises(DeliveryError, match="invalid_request"): + await app.load_network_info() + + legacy_server.handlers["get_network_info"] = legacy_server.on_get_network_info + await app.load_network_info() + assert app.state.node_info.ieee == COORDINATOR_IEEE + assert app.state.network_info.channel == 15 + assert app.state.network_info.network_key.key == NETWORK_KEY + assert app.state.network_info.stack_specific == {} + # The JSON server surfaces no children, address cache or route table + assert app.state.network_info.children == [] + assert app.state.network_info.nwk_addresses == {} + + # TCLK seeds map to the stack_specific layout of their source stack + legacy_server.network_info.tclk_seed = "ab" * 16 + legacy_server.network_info.tclk_flavor = "zstack" + await app.load_network_info() + assert app.state.network_info.stack_specific == {"zstack": {"tclk_seed": "ab" * 16}} + + legacy_server.network_info.tclk_flavor = "ezsp" + await app.load_network_info() + assert app.state.network_info.stack_specific == {"ezsp": {"hashed_tclk": "ab" * 16}} + + # The key table the JSON `get_network_info` returns inline is replayed as the + # events of the binary `ScanKeyTable` that follows + legacy_server.network_info.key_table = [ + commands.KeyTableEntry(partner_ieee=DEVICE_IEEE, key=LINK_KEY) + ] + await app.load_network_info() + assert app.state.network_info.key_table == [ + zigpy.state.Key(key=LINK_KEY, partner_ieee=DEVICE_IEEE) + ] + + +async def test_legacy_write_network_info( + legacy_connected_app: ControllerApplication, + legacy_server: SyntheticLegacyZiggurat, +) -> None: + app = legacy_connected_app + await app.load_network_info() + network_info = app.state.network_info + node_info = app.state.node_info + + # A zstack TCLK seed rides along verbatim + await app.write_network_info( + network_info=network_info.replace( + stack_specific={"zstack": {"tclk_seed": "cd" * 16}}, + key_table=[zigpy.state.Key(key=LINK_KEY, partner_ieee=DEVICE_IEEE)], + ), + node_info=node_info, + ) + assert legacy_server.configured.tclk_seed == "cd" * 16 + assert legacy_server.configured.tclk_flavor == "zstack" + assert legacy_server.configured.key_table == [ + commands.KeyTableEntry(partner_ieee=DEVICE_IEEE, key=LINK_KEY) + ] + + # An ezsp seed likewise + await app.write_network_info( + network_info=network_info.replace( + stack_specific={"ezsp": {"hashed_tclk": "ef" * 16}} + ), + node_info=node_info, + ) + assert legacy_server.configured.tclk_seed == "ef" * 16 + assert legacy_server.configured.tclk_flavor == "ezsp" + + # When zigpy forms a fresh network it leaves the IEEE address unspecified, + # deferring to the radio's hardware address + await app.write_network_info( + network_info=network_info, + node_info=node_info.replace(ieee=t.EUI64.UNKNOWN), # type: ignore[attr-defined] + ) + assert legacy_server.sent(commands.GetHwAddress) + assert legacy_server.configured.ieee_address == legacy_server.hw_address + assert app.state.node_info.ieee == legacy_server.hw_address + + +async def test_legacy_permits( + legacy_app: ControllerApplication, legacy_server: SyntheticLegacyZiggurat +) -> None: + await legacy_app.permit_with_link_key( + node=DEVICE_IEEE, link_key=LINK_KEY, time_s=12 + ) + + provisional = legacy_server.sent(commands.SetProvisionalKey)[-1] + assert provisional.ieee == DEVICE_IEEE + assert provisional.key == LINK_KEY + + # `super().permit()` broadcasts Mgmt_Permit_Joining_req and calls `permit_ncp` + broadcast = legacy_server.sent(commands.SendAps)[-1] + assert broadcast.delivery_mode == "broadcast" + assert broadcast.cluster_id == zdo_t.ZDOCmd.Mgmt_Permit_Joining_req + assert legacy_server.sent(commands.PermitJoins)[-1].duration == 12 + + +async def test_legacy_move_network_to_channel( + legacy_app: ControllerApplication, legacy_server: SyntheticLegacyZiggurat +) -> None: + await legacy_app._move_network_to_channel(new_channel=20, new_nwk_update_id=1) + + methods = [type(r) for r in legacy_server.requests] + assert methods.index(commands.SetNwkUpdateId) < methods.index(commands.SetChannel) + assert legacy_server.sent(commands.SetNwkUpdateId)[-1].nwk_update_id == 1 + assert legacy_server.sent(commands.SetChannel)[-1].channel == 20 + + +async def test_legacy_watchdog_feed( + legacy_app: ControllerApplication, legacy_server: SyntheticLegacyZiggurat +) -> None: + # The legacy server has no firmware-info call: it is probed with a JSON `ping` + await legacy_app._watchdog_feed() + assert isinstance(legacy_server.requests[-1], commands.Ping) + + +async def test_legacy_reset_network_info(legacy_app: ControllerApplication) -> None: + # The legacy server has no shutdown; the shim OKs it locally + await legacy_app.reset_network_info() + + +@pytest.mark.parametrize( + ("dst", "tx_options", "expected"), + [ + ( + t.AddrModeAddress(addr_mode=t.AddrMode.NWK, address=DEVICE_NWK), + t.TransmitOptions.ACK, + { + "delivery_mode": "unicast", + "destination": DEVICE_NWK, + "destination_eui64": DEVICE_IEEE, + "aps_ack": True, + "aps_encryption": False, + }, + ), + ( + t.AddrModeAddress(addr_mode=t.AddrMode.IEEE, address=DEVICE_IEEE), + t.TransmitOptions.NONE, + { + "delivery_mode": "unicast", + # 0xFFFE on the wire means "no short address" + "destination": None, + "destination_eui64": DEVICE_IEEE, + }, + ), + ( + t.AddrModeAddress(addr_mode=t.AddrMode.Group, address=t.Group(0x0002)), + t.TransmitOptions.NONE, + {"delivery_mode": "multicast", "destination": t.NWK(0x0002), "dst_ep": 0}, + ), + ( + t.AddrModeAddress( + addr_mode=t.AddrMode.Broadcast, + address=t.BroadcastAddress.ALL_ROUTERS_AND_COORDINATOR, + ), + t.TransmitOptions.NONE, + {"delivery_mode": "broadcast", "destination": t.NWK(0xFFFC)}, + ), + ], +) +async def test_legacy_send_packet( + legacy_app: ControllerApplication, + legacy_server: SyntheticLegacyZiggurat, + dst: t.AddrModeAddress, + tx_options: t.TransmitOptions, + expected: dict[str, Any], +) -> None: + legacy_app.add_device(DEVICE_IEEE, DEVICE_NWK) + + await legacy_app.send_packet( + t.ZigbeePacket( + src=t.AddrModeAddress(addr_mode=t.AddrMode.NWK, address=t.NWK(0x0000)), + 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"), + tx_options=tx_options, + ) + ) + + request = legacy_server.sent(commands.SendAps)[-1] + assert request.data == b"\x01\x02\x03" + assert request.aps_seq == 33 + assert request.radius == 30 + for field, value in expected.items(): + assert getattr(request, field) == value + + +async def test_legacy_send_packet_delivery_failure( + legacy_app: ControllerApplication, legacy_server: SyntheticLegacyZiggurat +) -> None: + async def fail(command: commands.SendAps, request_id: int) -> commands.Status: + raise RpcError("transmit_failed", "radio unavailable") + + legacy_server.handlers["send_aps"] = fail + + # The legacy `transmit_failed` code maps to the binary RADIO_ERROR status + with pytest.raises(DeliveryError, match="radio_error"): + await legacy_app.send_packet( + t.ZigbeePacket( + src=t.AddrModeAddress(addr_mode=t.AddrMode.NWK, address=t.NWK(0x0000)), + 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"), + ) + ) + + +async def test_legacy_send_confirm_failure( + legacy_app: ControllerApplication, legacy_server: SyntheticLegacyZiggurat +) -> None: + """The JSON protocol carries no failure kind, so a failed confirm becomes the + least-wrong binary stand-in.""" + + async def fail_confirm( + command: commands.SendAps, request_id: int + ) -> commands.Status: + await legacy_server.send_confirm(request_id, reason="no_route") + return commands.Status(status="accepted") + + legacy_server.handlers["send_aps"] = fail_confirm + + with pytest.raises(DeliveryError, match="TRANSMIT_FAILED"): + await legacy_app.send_packet( + t.ZigbeePacket( + src=t.AddrModeAddress(addr_mode=t.AddrMode.NWK, address=t.NWK(0x0000)), + 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"), + ) + ) + + +async def test_legacy_aps_ack_confirm_failure( + legacy_app: ControllerApplication, legacy_server: SyntheticLegacyZiggurat +) -> None: + async def fail_ack(command: commands.SendAps, request_id: int) -> commands.Status: + await legacy_server.send_confirm(request_id) + await legacy_server.aps_ack_confirm(request_id, reason="timeout") + return commands.Status(status="accepted") + + legacy_server.handlers["send_aps"] = fail_ack + + with pytest.raises(DeliveryError, match="APS_ACK_TIMEOUT"): + await legacy_app.send_packet( + t.ZigbeePacket( + src=t.AddrModeAddress(addr_mode=t.AddrMode.NWK, address=t.NWK(0x0000)), + 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"), + tx_options=t.TransmitOptions.ACK, + ) + ) + + +async def test_legacy_energy_scan( + legacy_app: ControllerApplication, legacy_server: SyntheticLegacyZiggurat +) -> None: + energies = await legacy_app.energy_scan( + # zigpy mis-annotates the classmethod's `cls` as an instance + channels=t.Channels.from_channel_list([11, 15]), # type: ignore[misc] + duration_exp=2, + count=1, + ) + + scan = legacy_server.sent(commands.EnergyScan)[-1] + assert scan.channels == [11, 15] + # 0.016 ms/symbol * 960 symbols * (2**2 + 1) + assert scan.duration_per_channel_ms == 77 + assert sorted(energies) == [11, 15] + + +async def test_legacy_network_scan( + legacy_app: ControllerApplication, legacy_server: SyntheticLegacyZiggurat +) -> None: + beacon = commands.NetworkBeaconEvent( + channel=t.uint8_t(11), + source=t.NWK(0x0000), + pan_id=t.PanId(0x1A2B), + extended_pan_id=t.ExtendedPanId(t.EUI64.convert("aa:bb:cc:dd:ee:ff:00:11")), + permit_joining=True, + stack_profile=t.uint8_t(2), + protocol_version=t.uint8_t(2), + router_capacity=True, + end_device_capacity=True, + device_depth=t.uint8_t(0), + update_id=t.uint8_t(0), + lqi=t.uint8_t(200), + rssi=t.int8s(-60), + ) + + async def scan(command: commands.NetworkScan, request_id: int) -> commands.Status: + await legacy_server.send_event_data( + request_id, "network_found", beacon.to_dict() + ) + # A beacon whose MAC source was not a short address + await legacy_server.send_event_data( + request_id, + "network_found", + dataclasses.replace(beacon, source=None).to_dict(), + ) + return commands.Status(status="complete") + + legacy_server.handlers["network_scan"] = scan + + found = [ + result + async for result in legacy_app.network_scan( + # zigpy mis-annotates the classmethod's `cls` as an instance + channels=t.Channels.from_channel_list([11]), # type: ignore[misc] + duration_exp=2, + ) + ] + + assert [f.src for f in found] == [t.NWK(0x0000), None] + assert found[0].pan_id == t.PanId(0x1A2B) + assert found[0].lqi == 200 + assert found[0].permit_joining is True + + +async def test_legacy_packet_capture( + legacy_app: ControllerApplication, legacy_server: SyntheticLegacyZiggurat +) -> None: + async def capture( + command: commands.PacketCapture, request_id: int + ) -> commands.Status: + await legacy_server.send_event_data( + request_id, + "captured_packet", + commands.CapturedPacketEvent( + channel=t.uint8_t(15), + rssi=t.int8s(-80), + lqi=t.uint8_t(200), + data="aabbcc", + ).to_dict(), + ) + return commands.Status(status="complete") + + legacy_server.handlers["packet_capture"] = capture + + packets = [packet async for packet in legacy_app.packet_capture(15)] + assert len(packets) == 1 + assert packets[0].data == b"\xaa\xbb\xcc" + assert legacy_server.sent(commands.PacketCapture)[0].channel == 15 + + await legacy_app.packet_capture_change_channel(20) + assert legacy_server.sent(commands.PacketCaptureChangeChannel)[0].channel == 20 + + +async def test_legacy_received_aps_notification( + legacy_app: ControllerApplication, legacy_server: SyntheticLegacyZiggurat +) -> None: + add_initialized_device(legacy_app) + + # A ZDO request arriving over the wire is answered end-to-end + await legacy_server.send_notification( + commands.ReceivedApsCommand( + source=DEVICE_NWK, + destination=t.NWK(0x0000), + group=None, + 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=b"\x77" + t.NWK(0x0000).serialize(), + ) + ) + reply = await legacy_server.wait_for(commands.SendAps) + assert reply.cluster_id == zdo_t.ZDOCmd.Node_Desc_rsp + assert reply.data[0] == 0x77 + + # A group-addressed frame carries its group id through the transcoder + await legacy_server.send_notification( + commands.ReceivedApsCommand( + source=DEVICE_NWK, + destination=t.NWK(0x0000), + group=2, + profile_id=t.uint16_t(0x0104), + cluster_id=t.uint16_t(0x0006), + src_ep=t.uint8_t(1), + dst_ep=t.uint8_t(255), + lqi=t.uint8_t(255), + rssi=t.int8s(-40), + data=b"\x01", + ) + ) + await flush(legacy_app) + + +async def test_legacy_frame_counter_notification( + legacy_app: ControllerApplication, legacy_server: SyntheticLegacyZiggurat +) -> None: + await legacy_server.send_notification( + commands.FrameCounterUpdate(frame_counter=t.uint32_t(123456)) + ) + await flush(legacy_app) + + assert legacy_app.state.network_info.network_key.tx_counter == 123456 + + +async def test_legacy_link_key_notification( + legacy_app: ControllerApplication, legacy_server: SyntheticLegacyZiggurat +) -> None: + await legacy_server.send_notification( + commands.LinkKeyUpdate(ieee=DEVICE_IEEE, key=LINK_KEY) + ) + await flush(legacy_app) + + assert legacy_app.state.network_info.key_table == [ + zigpy.state.Key(key=LINK_KEY, partner_ieee=DEVICE_IEEE) + ] + + +async def test_legacy_device_joined_notification( + legacy_app: ControllerApplication, + legacy_server: SyntheticLegacyZiggurat, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(application_module, "DEVICE_JOIN_MAX_DELAY", 0.05) + + await legacy_server.send_notification( + commands.DeviceJoined(nwk=DEVICE_NWK, ieee=DEVICE_IEEE, parent=t.NWK(0x0000)) + ) + await flush(legacy_app) + await asyncio.sleep(0.1) + + assert legacy_app.get_device(ieee=DEVICE_IEEE).nwk == DEVICE_NWK + + +async def test_legacy_device_left_notification( + legacy_app: ControllerApplication, legacy_server: SyntheticLegacyZiggurat +) -> None: + left: list[zigpy.device.Device] = [] + + class Listener: + def device_left(self, device: zigpy.device.Device) -> None: + left.append(device) + + legacy_app.add_listener(Listener()) + device = legacy_app.add_device(DEVICE_IEEE, DEVICE_NWK) + + # Each leave reason maps onto its binary counterpart + await legacy_server.send_notification( + commands.DeviceLeft( + nwk=DEVICE_NWK, + ieee=DEVICE_IEEE, + reason=commands.DeviceLeaveReason.ANNOUNCED, + rejoin=False, + ) + ) + await flush(legacy_app) + assert left == [device] + + # A parent router relayed the leave; the IEEE is resolved through the registry + await legacy_server.send_notification( + commands.DeviceLeft( + nwk=DEVICE_NWK, + ieee=None, + reason=commands.DeviceLeaveReason.ROUTER_REPORTED, + router=t.NWK(0x1234), + router_ieee=t.EUI64.convert("bb:bb:bb:bb:bb:bb:bb:bb"), + ) + ) + await flush(legacy_app) + assert left == [device, device] + + await legacy_server.send_notification( + commands.DeviceLeft( + nwk=t.NWK(0xBEEF), + ieee=None, + reason=commands.DeviceLeaveReason.KEEPALIVE_TIMEOUT, + ) + ) + await flush(legacy_app) + assert left == [device, device] + + +async def test_legacy_aps_decryption_failure_notification( + legacy_app: ControllerApplication, + legacy_server: SyntheticLegacyZiggurat, + caplog: pytest.LogCaptureFixture, +) -> None: + with caplog.at_level(logging.WARNING): + await legacy_server.send_notification( + commands.ApsDecryptionFailure( + source=t.NWK(0x1234), + source_ieee=DEVICE_IEEE, + frame_counter=t.uint32_t(42), + # An unknown key id degrades to the network key + key_id="tc_link_key", + ) + ) + await flush(legacy_app) + + assert "Could not decrypt an APS command" in caplog.text + + +# -- transcoding at the transport level ------------------------------------------- + + +async def test_legacy_acknowledges_restore_loads( + legacy_server: SyntheticLegacyZiggurat, +) -> None: + """The legacy server re-learns its topology tables, so the restore loads are + acknowledged locally and never reach it.""" + transport, frames = await _legacy(legacy_server) + try: + loads: list[p.Request] = [ + p.LoadChildren(entries=t.LVList[p.ChildEntry, t.uint16_t]([])), + p.LoadAddressCache(entries=t.LVList[p.AddressEntry, t.uint16_t]([])), + p.LoadRouteTable(entries=t.LVList[p.RouteEntry, t.uint16_t]([])), + p.LoadSourceRoutes(entries=t.LVList[p.SourceRouteEntry, t.uint16_t]([])), + ] + for request_id, load in enumerate(loads, start=1): + await transport.send_frame(p.encode_request(load, request_id)) + + await _wait_for(frames, count=len(loads)) + assert len(frames) == len(loads) + for frame, load in zip(frames, loads, strict=True): + header, body = p.Header.deserialize(frame) + assert header.frame_type == p.FrameType.RESPONSE + assert header.command == load.command + assert body == bytes([p.Status.OK]) + + assert legacy_server.requests == [] + finally: + await transport.disconnect() + + +async def test_legacy_drops_cancel_request( + legacy_server: SyntheticLegacyZiggurat, +) -> None: + """The legacy server has no request-cancel concept, so a best-effort cancel is + dropped rather than sent as an unknown method.""" + transport, frames = await _legacy(legacy_server) + try: + await transport.send_frame( + p.encode_request(p.CancelRequest(request_id=t.uint16_t(7)), 1) + ) + # Nothing is emitted locally and nothing reaches the server + await asyncio.sleep(0.05) + assert frames == [] + assert legacy_server.requests == [] + finally: + await transport.disconnect() + + +async def test_legacy_rejects_untranscodable_command( + legacy_server: SyntheticLegacyZiggurat, +) -> None: + """A binary request with no JSON equivalent fails loudly instead of being + silently dropped.""" + transport, _ = await _legacy(legacy_server) + try: + with pytest.raises(ValueError, match="Cannot transcode"): + await transport.send_frame( + p.encode_request(p.SetTunable.build("aps_ack_timeout", 5), 1) + ) + finally: + await transport.disconnect() + + +async def test_legacy_rejects_unknown_command( + legacy_server: SyntheticLegacyZiggurat, +) -> None: + transport, _ = await _legacy(legacy_server) + try: + # An unknown command byte fails loudly instead of silently vanishing. + frame = p.Header( + command=t.uint8_t(0xEE), + frame_type=p.FrameType.REQUEST, + request_id=t.uint16_t(1), + ).serialize() + with pytest.raises(KeyError): + await transport.send_frame(frame) + finally: + await transport.disconnect() + + +async def test_legacy_firmware_info_via_ping( + legacy_server: SyntheticLegacyZiggurat, +) -> None: + transport, frames = await _legacy(legacy_server) + try: + # The legacy server has no firmware-info call: the shim probes it with a + # JSON `ping` and fabricates the response payload. + await transport.send_frame(p.encode_request(p.GetFirmwareInfo(), 1)) + await legacy_server.wait_for(commands.Ping) + await _wait_for(frames) + header, body = p.Header.deserialize(frames[0]) + assert header.frame_type == p.FrameType.RESPONSE + assert header.command == p.RequestCommand.GET_FIRMWARE_INFO + assert body[0] == p.Status.OK + info = p.FirmwareInfo.deserialize(body[1:])[0] + assert info.protocol_version == p.PROTOCOL_VERSION + finally: + await transport.disconnect() + + +async def test_legacy_decodes_captured_packet( + legacy_server: SyntheticLegacyZiggurat, +) -> None: + transport, frames = await _legacy(legacy_server) + try: + # An unknown event is dropped; the captured packet is transcoded to an event. + await legacy_server.send_event_data(7, "not_a_real_event", {}) + await legacy_server.send_event_data( + 7, + "captured_packet", + {"channel": 15, "rssi": -80, "lqi": 200, "data": "aabbcc"}, + ) + await _wait_for(frames) + assert len(frames) == 1 + header, body = p.Header.deserialize(frames[0]) + assert header.frame_type == p.FrameType.EVENT + assert header.command == p.RequestCommand.PACKET_CAPTURE + packet = p.CapturedPacket.deserialize(body)[0] + assert bytes(packet.psdu) == b"\xaa\xbb\xcc" + finally: + await transport.disconnect() + + +async def test_legacy_forwards_firmware_log( + legacy_server: SyntheticLegacyZiggurat, caplog: pytest.LogCaptureFixture +) -> None: + transport, _ = await _legacy(legacy_server) + try: + with caplog.at_level(logging.WARNING, logger="ziggurat.fw.foo.bar"): + await legacy_server.send_raw( + json.dumps( + { + "type": "notification", + "event": "log", + "data": { + "level": "WARN", + "target": "foo::bar", + "message": "something happened", + }, + } + ) + ) + async with asyncio.timeout(2): + while "something happened" not in caplog.text: + await asyncio.sleep(0.01) + finally: + await transport.disconnect() + + +async def test_legacy_transmitted_becomes_send_confirm( + legacy_server: SyntheticLegacyZiggurat, +) -> None: + transport, frames = await _legacy(legacy_server) + try: + # The real server signals a send handoff with a bare `transmitted` event + # that carries no `data`; it must become a SEND_CONFIRM, not crash. + await legacy_server.send_event(9, "transmitted") + await _wait_for(frames) + header, body = p.Header.deserialize(frames[0]) + assert header.frame_type == p.FrameType.NOTIFICATION + assert header.command == p.NotificationCommand.SEND_CONFIRM + assert header.request_id == 9 + assert p.SendConfirm.deserialize(body)[0].status == p.SendStatus.SUCCESS + finally: + await transport.disconnect() + + +async def test_legacy_decodes_decrypt_failure_known_key( + legacy_server: SyntheticLegacyZiggurat, +) -> None: + transport, frames = await _legacy(legacy_server) + try: + await legacy_server.send_notification( + commands.ApsDecryptionFailure( + source=t.NWK(0x1234), + source_ieee=COORDINATOR_IEEE, + frame_counter=t.uint32_t(42), + key_id="network", + ) + ) + await _wait_for(frames) + header, body = p.Header.deserialize(frames[0]) + assert header.command == p.NotificationCommand.APS_DECRYPT_FAILURE + failure = p.ApsDecryptFailure.deserialize(body)[0] + assert failure.key_id == p.KeyId.NETWORK + finally: + await transport.disconnect() + + +async def test_legacy_ignores_binary_and_unknown_response( + legacy_server: SyntheticLegacyZiggurat, +) -> None: + transport, frames = await _legacy(legacy_server) + try: + # A binary frame and a response for an unknown id are both dropped; a + # following confirm still transcodes, proving the loop kept going. + await legacy_server.ws.send_bytes(b"\x00\x01\x02") + await legacy_server.send_raw( + json.dumps({"type": "response", "id": 9999, "result": {}}) + ) + await legacy_server.send_confirm(1) + await _wait_for(frames) + assert len(frames) == 1 + header, _ = p.Header.deserialize(frames[0]) + assert header.command == p.NotificationCommand.SEND_CONFIRM + finally: + await transport.disconnect() diff --git a/tests/test_protocol.py b/tests/test_protocol.py new file mode 100644 index 0000000..9a53d73 --- /dev/null +++ b/tests/test_protocol.py @@ -0,0 +1,121 @@ +"""Tests for the convenience accessors on the binary protocol structs.""" + +from datetime import timedelta + +import pytest +import zigpy.types as t + +from zigpy_ziggurat.zigbee import protocol as p + +_IEEE = t.EUI64.convert("00:11:22:33:44:55:66:77") + + +def _device_left( + reason: p.LeaveReason, + *, + rejoin: int = 0, + has_router_ieee: int = 0, + router: int = 0xFFFF, +) -> p.DeviceLeft: + return p.DeviceLeft( + nwk=t.NWK(0x1234), + has_ieee=t.uint1_t(1), + rejoin=t.uint1_t(rejoin), + has_router_ieee=t.uint1_t(has_router_ieee), + reserved=t.uint5_t(0), + ieee=_IEEE, + reason=reason, + router=t.NWK(router), + router_ieee=_IEEE, + ) + + +def test_device_left_announced() -> None: + left = _device_left(p.LeaveReason.ANNOUNCED, rejoin=1) + assert left.rejoin_or_none is True + assert left.router_or_none is None + assert left.router_ieee_or_none is None + + +def test_device_left_router_reported() -> None: + left = _device_left(p.LeaveReason.ROUTER_REPORTED, has_router_ieee=1, router=0x5678) + assert left.rejoin_or_none is None + assert left.router_or_none == 0x5678 + assert left.router_ieee_or_none == _IEEE + + +def test_device_left_router_reported_without_ieee() -> None: + left = _device_left(p.LeaveReason.ROUTER_REPORTED, has_router_ieee=0) + assert left.router_ieee_or_none is None + + +def test_set_tunable_build() -> None: + integer = p.SetTunable.build("unicast_retries", 5) + assert integer.name == b"unicast_retries" + assert integer.value == 5 + + duration = p.SetTunable.build("aps_ack_timeout", timedelta(milliseconds=1500)) + assert duration.value == 1_500_000 + + flag = p.SetTunable.build("allow_unsecured_rejoins", True) + assert flag.value == 1 + + +@pytest.mark.parametrize( + "notification", + [ + p.RouteRecord(destination=t.NWK(0x1234), relays=[t.NWK(0x0002), t.NWK(0x0003)]), + p.RouteRecord(destination=t.NWK(0x1234), relays=[]), + p.ApsFrameCounter(frame_counter=t.uint32_t(123456)), + p.DeviceJoined( + nwk=t.NWK(0xAB12), + ieee=_IEEE, + parent=t.NWK(0x0000), + rx_on_when_idle=t.uint1_t(1), + device_type=p.ChildDeviceType.ROUTER, + reserved=t.uint5_t(0), + ), + ], +) +def test_notification_round_trip(notification: p.Notification) -> None: + parsed, rest = type(notification).deserialize(notification.serialize()) + assert rest == b"" + assert parsed == notification + + +@pytest.mark.parametrize( + "request_obj", + [ + p.LoadRouteTable( + entries=t.LVList[p.RouteEntry, t.uint16_t]( + [ + p.RouteEntry( + destination=t.NWK(0x1234), + next_hop=t.NWK(0x5678), + path_cost=t.uint8_t(0xFF), + ) + ] + ) + ), + p.LoadSourceRoutes( + entries=t.LVList[p.SourceRouteEntry, t.uint16_t]( + [ + p.SourceRouteEntry( + destination=t.NWK(0x1234), + relays=t.LVList[t.NWK, t.uint8_t]( + [t.NWK(0x0002), t.NWK(0x0003)] + ), + ), + p.SourceRouteEntry( + destination=t.NWK(0xABCD), + relays=t.LVList[t.NWK, t.uint8_t]([]), + ), + ] + ) + ), + ], +) +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 diff --git a/tests/test_transport.py b/tests/test_transport.py new file mode 100644 index 0000000..35459b1 --- /dev/null +++ b/tests/test_transport.py @@ -0,0 +1,150 @@ +"""Tests for `connect_transport`, which probes a WebSocket for its protocol, and for +the transports it returns. The legacy JSON transcoding shim is covered separately in +`test_legacy.py`.""" + +import asyncio + +import aiospinel +import pytest + +from tests.common import ( + ClosingZiggurat, + ProtocolErrorWebSocket, + SyntheticSpinelRcp, + SyntheticZiggurat, + closing_server, + protocol_error_server, + server, + spinel_rcp, +) +from zigpy_ziggurat.zigbee import protocol as p +from zigpy_ziggurat.zigbee.transport import ( + SpinelTransport, + WebSocketTransport, + connect_transport, +) + + +async def _wait_for(frames: list[bytes], count: int = 1) -> None: + async with asyncio.timeout(2): + while len(frames) < count: + await asyncio.sleep(0.01) + + +async def test_probe_selects_binary(server: SyntheticZiggurat) -> None: + frames: list[bytes] = [] + transport = await connect_transport(server.url, frames.append, lambda exc: None) + try: + assert isinstance(transport, WebSocketTransport) + await transport.send_frame(p.encode_request(p.Shutdown(), 1)) + await _wait_for(frames) + finally: + await transport.disconnect() + + # The opening hello is consumed by the probe, so the only frame is the response. + assert len(frames) == 1 + header, body = p.Header.deserialize(frames[0]) + assert header.frame_type == p.FrameType.RESPONSE + assert header.command == p.RequestCommand.SHUTDOWN + assert body == bytes([p.Status.OK]) + assert isinstance(server.requests[0], p.Shutdown) + + +async def test_probe_rejects_unexpected_handshake( + closing_server: ClosingZiggurat, +) -> None: + with pytest.raises(ConnectionError): + await connect_transport( + closing_server.url, lambda frame: None, lambda exc: None + ) + + +async def test_spinel_transport_roundtrip(spinel_rcp: SyntheticSpinelRcp) -> None: + frames: list[bytes] = [] + lost: list[BaseException | None] = [] + transport = await connect_transport(spinel_rcp.url, frames.append, lost.append) + assert isinstance(transport, SpinelTransport) + + await transport.send_frame(b"\x01\x02\x03") + await _wait_for(spinel_rcp.tunnel_writes) + assert spinel_rcp.tunnel_writes == [b"\x01\x02\x03"] + + await spinel_rcp.push_stream_frame(b"\xaa\xbb") + await _wait_for(frames) + assert frames == [b"\xaa\xbb"] + + await transport.disconnect() + # A clean close surfaces as connection_lost with no error. + assert lost == [None] + + +async def test_spinel_stream_frame_handler_error( + spinel_rcp: SyntheticSpinelRcp, +) -> None: + attempts: list[bytes] = [] + + def boom(frame: bytes) -> None: + attempts.append(frame) + raise RuntimeError("handler blew up") + + transport = await connect_transport(spinel_rcp.url, boom, lambda exc: None) + try: + # The receive loop must survive a handler raising on a delivered frame. + await spinel_rcp.push_stream_frame(b"\x01") + await _wait_for(attempts) + assert attempts == [b"\x01"] + finally: + await transport.disconnect() + + +async def test_spinel_connect_rejects_foreign_firmware() -> None: + rcp = SyntheticSpinelRcp(get_prop_id=aiospinel.PackedUInt21(0x0001)) + await rcp.start() + try: + with pytest.raises(ConnectionError, match="does not embed"): + await connect_transport(rcp.url, lambda frame: None, lambda exc: None) + finally: + await rcp.stop() + + +async def test_spinel_tunnel_write_rejected() -> None: + rcp = SyntheticSpinelRcp(set_prop_id=aiospinel.PackedUInt21(0x0001)) + await rcp.start() + transport = await connect_transport(rcp.url, lambda frame: None, lambda exc: None) + try: + with pytest.raises(ConnectionError, match="Tunnel write rejected"): + await transport.send_frame(b"\x01") + finally: + await transport.disconnect() + await rcp.stop() + + +async def test_websocket_send_after_disconnect(server: SyntheticZiggurat) -> None: + transport = await connect_transport( + server.url, lambda frame: None, lambda exc: None + ) + await transport.disconnect() + with pytest.raises(ConnectionError, match="Not connected"): + await transport.send_frame(p.encode_request(p.Shutdown(), 1)) + + +async def test_websocket_receive_loop_error( + protocol_error_server: ProtocolErrorWebSocket, +) -> None: + lost: list[BaseException | None] = [] + lost_event = asyncio.Event() + + def on_lost(exc: BaseException | None) -> None: + lost.append(exc) + lost_event.set() + + transport = await connect_transport( + protocol_error_server.url, lambda frame: None, on_lost + ) + try: + async with asyncio.timeout(2): + await lost_event.wait() + # The malformed frame ends the receive loop, reporting the loss once. + assert len(lost) == 1 + finally: + await transport.disconnect() diff --git a/uv.lock b/uv.lock index 12b7ac4..49c01b3 100644 --- a/uv.lock +++ b/uv.lock @@ -128,6 +128,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, ] +[[package]] +name = "aiospinel" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/96/3ee10d7cc34f6b764b617714f9777a16a0b13ce4b63d832f06c93534432c/aiospinel-1.2.0.tar.gz", hash = "sha256:b71a771583a767cb57ff4f6cfa560f1ec22eef1496c790436daf80e808442075", size = 59890, upload-time = "2026-07-09T20:45:25.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/9e/239584868623787832574d4530ee3a8e7af4b61ef42370934e6e034aeae2/aiospinel-1.2.0-py3-none-any.whl", hash = "sha256:84824074101bff1ea7186fc541fdd6d39c1a8cda354252f0b80289944f5ef5ca", size = 14424, upload-time = "2026-07-09T20:45:24.155Z" }, +] + [[package]] name = "aiosqlite" version = "0.21.0" @@ -1498,6 +1510,7 @@ name = "zigpy-ziggurat" source = { editable = "." } dependencies = [ { name = "aiohttp" }, + { name = "aiospinel" }, { name = "mashumaro" }, { name = "zigpy" }, ] @@ -1531,6 +1544,7 @@ testing = [ [package.metadata] requires-dist = [ { name = "aiohttp" }, + { name = "aiospinel", specifier = ">=1.2.0" }, { name = "mashumaro" }, { name = "zigpy" }, ] diff --git a/zigpy_ziggurat/config.py b/zigpy_ziggurat/config.py new file mode 100644 index 0000000..2af1d13 --- /dev/null +++ b/zigpy_ziggurat/config.py @@ -0,0 +1,21 @@ +"""Configuration schema for Ziggurat.""" + +from __future__ import annotations + +import voluptuous as vol +from zigpy.config import CONFIG_SCHEMA as ZIGPY_CONFIG_SCHEMA + +CONF_ZIGGURAT_CONFIG = "ziggurat_config" +CONF_TUNABLES = "tunables" + +CONFIG_SCHEMA = ZIGPY_CONFIG_SCHEMA.extend( + { + vol.Optional(CONF_ZIGGURAT_CONFIG, default={}): vol.Schema( + { + vol.Optional(CONF_TUNABLES, default={}): vol.Schema( + {vol.Optional(str): int} + ) + } + ), + } +) diff --git a/zigpy_ziggurat/zigbee/api.py b/zigpy_ziggurat/zigbee/api.py new file mode 100644 index 0000000..5d80fc1 --- /dev/null +++ b/zigpy_ziggurat/zigbee/api.py @@ -0,0 +1,276 @@ +"""The transport-agnostic Ziggurat API, in terms of the binary `protocol` structs.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncGenerator, Callable +from datetime import timedelta +import logging + +from zigpy.exceptions import DeliveryError +import zigpy.types as t + +from zigpy_ziggurat.zigbee import protocol as p +from zigpy_ziggurat.zigbee.transport import Transport, connect_transport + +_LOGGER = logging.getLogger(__name__) + +# The end-to-end APS ack (or local handoff) must arrive within this window. +CONFIRM_TIMEOUT = 30 + + +class _Pending: + """One in-flight request: the request itself, its response, and a stream queue.""" + + def __init__(self, request: p.Request, *, streaming: bool) -> None: + loop = asyncio.get_running_loop() + self.request = request + self.response: asyncio.Future[p.Response | None] = loop.create_future() + self.events: asyncio.Queue[p.Response | None] | None = ( + asyncio.Queue() if streaming else None + ) + + +class ZigguratApi: + """The request surface `ControllerApplication` speaks, over any `Transport`.""" + + def __init__( + self, + url: str, + on_notification: Callable[[p.Notification], None], + on_disconnect: Callable[[BaseException | None], None], + *, + baudrate: int = 115200, + flow_control: str | None = None, + ) -> None: + self._url = url + self._baudrate = baudrate + self._flow_control = flow_control + self._on_notification = on_notification + self._on_disconnect = on_disconnect + self._closing = False + self._request_id = 1 + self._pending: dict[int, _Pending] = {} + self._pending_confirms: dict[ + int, asyncio.Future[p.SendConfirm | p.ApsAckConfirm | p.BroadcastConfirm] + ] = {} + self._awaiting_aps_ack: set[int] = set() + self._transport: Transport | None = None + + async def connect(self) -> None: + self._transport = await connect_transport( + self._url, + self._handle_frame, + self._on_transport_lost, + baudrate=self._baudrate, + flow_control=self._flow_control, + ) + + async def disconnect(self) -> None: + self._closing = True + if self._transport is not None: + await self._transport.disconnect() + + def _on_transport_lost(self, exc: BaseException | None) -> None: + self._connection_lost(exc) + + def _connection_lost(self, exc: BaseException | None) -> None: + for pending in self._pending.values(): + if not pending.response.done(): + pending.response.set_exception(ConnectionError("Connection lost")) + self._pending.clear() + for confirm in self._pending_confirms.values(): + if not confirm.done(): + confirm.set_exception(ConnectionError("Connection lost")) + self._pending_confirms.clear() + self._awaiting_aps_ack.clear() + # Report the loss once. `_closing` also suppresses it during our own teardown. + if not self._closing: + self._closing = True + self._on_disconnect(exc) + + def _next_id(self) -> int: + # Request ids are 14 bits on the wire; 0 is left to unsolicited notifications. + request_id = self._request_id + self._request_id = (self._request_id % 0x3FFF) + 1 + return request_id + + async def _cancel_send(self, request_id: int) -> None: + """Best-effort cancel of an in-flight send by its id.""" + if self._transport is None or self._closing: + return + frame = p.encode_request( + p.CancelRequest(request_id=t.uint16_t(request_id)), + self._next_id(), + ) + await asyncio.shield(self._transport.send_frame(frame)) + + # -- request surface ----------------------------------------------------------- + + async def request(self, request: p.Request) -> p.Response | None: + """Send a request; return its response, or None if the OK reply is empty.""" + request_id = self._next_id() + pending = _Pending(request, streaming=False) + self._pending[request_id] = pending + + _LOGGER.debug("Sending request (id=%d): %r", request_id, request) + + assert self._transport is not None + try: + await self._transport.send_frame(p.encode_request(request, request_id)) + return await pending.response + finally: + self._pending.pop(request_id, None) + + async def request_confirmed( + self, send: p.SendUnicast | p.SendBroadcast | p.SendGroupcast + ) -> None: + """Send and await the terminal confirmation.""" + + # The terminal confirmation is the end-to-end APS ack for an ack-requested + # unicast, the passive-ack quorum for a broadcast/groupcast, otherwise the local + # handoff. A rejected frame raises `DeliveryError` before any confirm; a failed + # confirmation raises it too. + request_id = self._next_id() + pending = _Pending(send, streaming=False) + self._pending[request_id] = pending + confirm: asyncio.Future[ + p.SendConfirm | p.ApsAckConfirm | p.BroadcastConfirm + ] = asyncio.get_running_loop().create_future() + self._pending_confirms[request_id] = confirm + if isinstance(send, p.SendUnicast) and send.aps_ack: + self._awaiting_aps_ack.add(request_id) + + _LOGGER.debug("Sending request with confirmation (id=%d): %r", request_id, send) + + assert self._transport is not None + try: + async with asyncio.timeout(CONFIRM_TIMEOUT): + await self._transport.send_frame(p.encode_request(send, request_id)) + await pending.response # accepted / rejected + result = await confirm + finally: + self._pending.pop(request_id, None) + self._pending_confirms.pop(request_id, None) + self._awaiting_aps_ack.discard(request_id) + + if not confirm.done() or confirm.cancelled(): + await self._cancel_send(request_id) + + if result.status != p.SendStatus.SUCCESS: + raise DeliveryError(f"Send failed: {result.status.name}") + + async def request_stream( + self, request: p.Request + ) -> AsyncGenerator[p.Response, None]: + """Yield each streamed `request.event` item until the terminal response.""" + # An error response or disconnect is raised once the stream is exhausted. + assert request.event is not None + request_id = self._next_id() + pending = _Pending(request, streaming=True) + self._pending[request_id] = pending + assert pending.events is not None + + _LOGGER.debug("Sending stream request (id=%d): %r", request_id, request) + + assert self._transport is not None + await self._transport.send_frame(p.encode_request(request, request_id)) + try: + while (item := await pending.events.get()) is not None: + yield item + await pending.response # surface an error + finally: + self._pending.pop(request_id, None) + + # -- inbound frame handling ---------------------------------------------------- + + def _handle_frame(self, frame: bytes) -> None: + header, body = p.Header.deserialize(frame) + request_id = header.request_id + + if header.frame_type == p.FrameType.RESPONSE: + self._handle_response(request_id, body) + elif header.frame_type == p.FrameType.EVENT: + self._handle_event(request_id, body) + elif header.frame_type == p.FrameType.NOTIFICATION: + command = p.NotificationCommand(header.command) + if command not in p.NOTIFICATIONS: + _LOGGER.debug("Unhandled notification %r", command) + return + notification = p.NOTIFICATIONS[command].deserialize(body)[0] + _LOGGER.debug("Received notification (id=%d): %r", request_id, notification) + self._handle_notification(request_id, notification) + + def _handle_response(self, request_id: int, body: bytes) -> None: + pending = self._pending.get(request_id) + if pending is None or pending.response.done(): + return + + status = p.Status(body[0]) + if status == p.Status.RATE_LIMITED: + rate_limited = p.RateLimitedPayload.deserialize(body)[0] + retry_in = timedelta(milliseconds=rate_limited.retry_in_ms) + _LOGGER.debug( + "Received rate-limited response (id=%d): retry in %s", + request_id, + retry_in, + ) + pending.response.set_exception(p.RateLimitedError(retry_in)) + elif status != p.Status.OK: + _LOGGER.debug("Received error response (id=%d): %r", request_id, status) + pending.response.set_exception(p.ProtocolError(status)) + else: + response = ( + pending.request.response.deserialize(body[1:])[0] + if pending.request.response is not None + else None + ) + _LOGGER.debug("Received response (id=%d): %r", request_id, response) + pending.response.set_result(response) + + if pending.events is not None: + pending.events.put_nowait(None) + + def _handle_event(self, request_id: int, body: bytes) -> None: + pending = self._pending.get(request_id) + if pending is None or pending.events is None: + return + assert pending.request.event is not None + event = pending.request.event.deserialize(body)[0] + _LOGGER.debug("Received event (id=%d): %r", request_id, event) + pending.events.put_nowait(event) + + def _handle_notification( + self, request_id: int, notification: p.Notification + ) -> None: + if isinstance(notification, p.SendConfirm): + confirm = self._pending_confirms.get(request_id) + if confirm is None or confirm.done(): + return + # A confirmed handoff is not terminal for an ack-requested send. + if ( + notification.status == p.SendStatus.SUCCESS + and request_id in self._awaiting_aps_ack + ): + return + self._awaiting_aps_ack.discard(request_id) + confirm.set_result(notification) + elif isinstance(notification, (p.ApsAckConfirm, p.BroadcastConfirm)): + self._awaiting_aps_ack.discard(request_id) + confirm = self._pending_confirms.get(request_id) + if confirm is not None and not confirm.done(): + confirm.set_result(notification) + elif isinstance(notification, p.Hello): + # The firmware only sends `hello` when it reboots + self._connection_lost(ConnectionError("Ziggurat firmware reset")) + elif isinstance(notification, p.LastReset): + logging.getLogger("ziggurat.fw").warning( + "The firmware's previous reset was abnormal: %s", + notification.message, + ) + else: + self._on_notification(notification) + + async def set_tunable(self, name: str, value: int | timedelta) -> None: + """Set a stack tunable by its Rust field name (a debug/experiment surface).""" + await self.request(p.SetTunable.build(name, value)) diff --git a/zigpy_ziggurat/zigbee/application.py b/zigpy_ziggurat/zigbee/application.py index e48fa22..83f495a 100644 --- a/zigpy_ziggurat/zigbee/application.py +++ b/zigpy_ziggurat/zigbee/application.py @@ -1,12 +1,14 @@ +from __future__ import annotations + import asyncio -from collections.abc import AsyncGenerator, Callable -import json +from collections.abc import AsyncGenerator +from datetime import datetime, timedelta, timezone import logging import math +import os import statistics from typing import Any, cast -import aiohttp import zigpy.application import zigpy.backups import zigpy.config @@ -17,32 +19,9 @@ import zigpy.types as t import zigpy.zdo.types as zdo_t -from zigpy_ziggurat.zigbee.commands import ( - EVENT_T, - NOTIFICATIONS, - RESPONSE_T, - ApsDecryptionFailure, - Configure, - DeviceJoined, - DeviceLeft, - EnergyScan, - FrameCounterUpdate, - GetHwAddress, - GetNetworkInfo, - KeyTableEntry, - LinkKeyUpdate, - NetworkScan, - Notification, - PermitJoins, - Ping, - ReceivedApsCommand, - Request, - SendAps, - SetChannel, - SetNwkUpdateId, - SetProvisionalKey, - StreamingRequest, -) +from zigpy_ziggurat.config import CONF_TUNABLES, CONF_ZIGGURAT_CONFIG, CONFIG_SCHEMA +from zigpy_ziggurat.zigbee import protocol as p +from zigpy_ziggurat.zigbee.api import ZigguratApi _LOGGER = logging.getLogger(__name__) @@ -59,12 +38,28 @@ "54:EF:44": 0x115F, # Lumi } + +def _max_concurrent_requests(url: str) -> int: + if url.startswith(("ws://", "wss://", "ws+unix://")): + return 128 + return 32 + + # 802.15.4 6.3.1: time spent scanning each channel is # aBaseSuperframeDuration * (2^n + 1) symbols, at 16 us per symbol SYMBOL_PERIOD_MS = 0.016 BASE_SUPERFRAME_DURATION_SYMBOLS = 960 -WEBSOCKET_HEARTBEAT = 15 +KEY_BATCH_SIZE = 12 + +# On a stateless restart we resume from the last persisted frame counters, which trail +# the radio's true counters by up to the persist stride (plus any commit lag). Jump both +# the NWK and APS outgoing counters past that gap so a restart can never roll back and +# make peers reject our secured frames. +FRAME_COUNTER_RESTORE_MARGIN = 1000 + +# How long route hints are supplied to the stack, to reduce startup churn. +ROUTE_HINT_DURATION = timedelta(minutes=10) def logistic(x: float, *, L: float = 1, x_0: float = 0, k: float = 1) -> float: @@ -82,261 +77,8 @@ def map_rssi_to_energy(rssi: float) -> float: ) -class PendingRequest: - """The in-flight state of one request: an optional `transmitted` stage future and - the terminal `response` future.""" - - def __init__( - self, *, want_transmitted: bool, stream_event: str | None = None - ) -> None: - loop = asyncio.get_running_loop() - self.response: asyncio.Future[dict[str, Any]] = loop.create_future() - self.transmitted: asyncio.Future[None] | None = ( - loop.create_future() if want_transmitted else None - ) - # For a streaming request: the event name carrying results, and a queue those - # results land in. `None` enqueued by the terminal response marks the end. - self.stream_event = stream_event - self.events: asyncio.Queue[dict[str, Any] | None] | None = ( - asyncio.Queue() if stream_event is not None else None - ) - - def fail(self, exc: BaseException) -> None: - if self.transmitted is not None and not self.transmitted.done(): - self.transmitted.set_exception(exc) - - if not self.response.done(): - self.response.set_exception(exc) - - -def _make_late_failure_logger( - pending: PendingRequest, -) -> Callable[[asyncio.Future[dict[str, Any]]], None]: - """Consume the terminal result of a request that already resolved at the - `transmitted` stage, so delivery failures are visible but not raised. Failures - from before transmission were already raised to the caller and are not logged.""" - - def log_late_failure(fut: asyncio.Future[dict[str, Any]]) -> None: - if fut.cancelled(): - return - - exc = fut.exception() - if exc is None: - return - - transmitted = ( - pending.transmitted is not None - and pending.transmitted.done() - and pending.transmitted.exception() is None - ) - - if transmitted: - _LOGGER.warning("Delivery failed after transmission: %s", exc) - - return log_late_failure - - -class ZigguratApi: - """The Ziggurat WebSocket API: concurrent requests correlated by id, with - lifecycle events (`accepted`, `transmitted`) preceding each terminal response.""" - - def __init__( - self, - url: str, - on_notification: Callable[[Notification], None], - on_disconnect: Callable[[BaseException | None], None], - ) -> None: - self._url = url - self._on_notification = on_notification - self._on_disconnect = on_disconnect - - self._session: aiohttp.ClientSession | None = None - self._websocket: aiohttp.ClientWebSocketResponse | None = None - self._receiver_task: asyncio.Task[None] | None = None - self._request_id = 1 - self._pending: dict[int, PendingRequest] = {} - - async def connect(self) -> None: - if self._url.startswith("ws+unix://"): - # The URL's path is the socket path; the HTTP-level host is a placeholder - connector = aiohttp.UnixConnector(path=self._url.removeprefix("ws+unix://")) - url = "ws://localhost/" - else: - connector = None - url = self._url - - self._session = aiohttp.ClientSession(connector=connector) - self._websocket = await self._session.ws_connect( - url, heartbeat=WEBSOCKET_HEARTBEAT - ) - - hello = json.loads(await self._websocket.receive_str()) - _LOGGER.debug("Connected to ziggurat: %r", hello) - - self._receiver_task = asyncio.create_task(self._receive_loop()) - - async def disconnect(self) -> None: - if self._receiver_task is not None: - self._receiver_task.cancel() - self._receiver_task = None - - if self._websocket is not None: - await self._websocket.close() - self._websocket = None - - if self._session is not None: - await self._session.close() - self._session = None - - async def _receive_loop(self) -> None: - websocket = self._websocket - assert websocket is not None - - exc: BaseException | None = None - - try: - async for msg in websocket: - if msg.type == aiohttp.WSMsgType.TEXT: - try: - self._handle_message(json.loads(msg.data)) - except Exception: - _LOGGER.exception("Failed to handle message: %r", msg.data) - elif msg.type == aiohttp.WSMsgType.ERROR: - exc = websocket.exception() - break - except asyncio.CancelledError: - # A deliberate `disconnect()`, not a connection loss - self._fail_pending(ConnectionError("Connection closed")) - raise - except Exception as e: # pragma: no cover - # aiohttp surfaces connection failures as `ERROR` messages or by ending - # the iterator, never by raising; kept as a guard for other versions - exc = e - - self._fail_pending(ConnectionError("Connection lost")) - self._on_disconnect(exc) - - def _fail_pending(self, exc: BaseException) -> None: - for pending in self._pending.values(): - pending.response.add_done_callback(_make_late_failure_logger(pending)) - pending.fail(exc) - - self._pending.clear() - - def _handle_message(self, msg: dict[str, Any]) -> None: - _LOGGER.debug("Received: %r", msg) - msg_type = msg["type"] - - if msg_type == "notification": - self._on_notification(NOTIFICATIONS[msg["event"]].from_dict(msg["data"])) - elif msg_type == "event": - pending = self._pending.get(msg["id"]) - - if pending is None: - pass - elif ( - msg["event"] == "transmitted" - and pending.transmitted is not None - and not pending.transmitted.done() - ): - pending.transmitted.set_result(None) - elif pending.events is not None and msg["event"] == pending.stream_event: - pending.events.put_nowait(msg["data"]) - elif msg_type == "response": - pending = self._pending.pop(msg["id"], None) - - if pending is None: - _LOGGER.debug("Response for unknown request: %r", msg) - return - - if "error" in msg: - error = msg["error"] - pending.fail(DeliveryError(f"{error['code']}: {error['message']}")) - elif not pending.response.done(): - pending.response.set_result(msg["result"]) - - async def request(self, command: Request[RESPONSE_T]) -> RESPONSE_T: - result = await self._send_request(command, want_transmitted=False) - assert result is not None - - # `response_type` is a plain ClassVar: it cannot carry the type variable - return cast(RESPONSE_T, command.response_type.from_dict(result)) - - async def request_transmitted(self, command: Request[Any]) -> None: - """Resolve once the frame is on the air instead of waiting for delivery.""" - await self._send_request(command, want_transmitted=True) - - async def _send_request( - self, command: Request[Any], *, want_transmitted: bool - ) -> dict[str, Any] | None: - request_id = self._request_id - self._request_id = (self._request_id + 1) % 2**32 or 1 - - pending = PendingRequest(want_transmitted=want_transmitted) - self._pending[request_id] = pending - - message = { - "id": request_id, - "method": command.method, - "params": command.to_dict(), - } - _LOGGER.debug("Sending: %r", message) - assert self._websocket is not None - await self._websocket.send_str(json.dumps(message)) - - if want_transmitted: - # The terminal response continues in the background; an end-to-end - # delivery failure after transmission is logged, not raised - pending.response.add_done_callback(_make_late_failure_logger(pending)) - assert pending.transmitted is not None - await pending.transmitted - return None - - return await pending.response - - async def request_stream( - self, command: StreamingRequest[Any, EVENT_T] - ) -> AsyncGenerator[EVENT_T, None]: - """Issue a request that streams `event_type` results until its terminal - response, yielding each result. An error response (or a disconnect) raised once - the stream is exhausted.""" - request_id = self._request_id - self._request_id = (self._request_id + 1) % 2**32 or 1 - - pending = PendingRequest( - want_transmitted=False, stream_event=command.event_name - ) - self._pending[request_id] = pending - - message = { - "id": request_id, - "method": command.method, - "params": command.to_dict(), - } - _LOGGER.debug("Sending: %r", message) - assert self._websocket is not None - await self._websocket.send_str(json.dumps(message)) - - assert pending.events is not None - events = pending.events - - # The terminal response (success, error, or disconnect) ends the stream. Results - # are enqueued ahead of it in receive order, so the queue drains fully first. - pending.response.add_done_callback(lambda _: events.put_nowait(None)) - - try: - while (item := await events.get()) is not None: - yield cast(EVENT_T, command.event_type.from_dict(item)) - - # Surface an error response or disconnect; a success carries only a status - await pending.response - finally: - self._pending.pop(request_id, None) - - class ZigguratCoordinator(zigpy.device.Device): - """Zigpy device representing the coordinator. Ziggurat has no loopback ZDO, so the - device is constructed statically instead of being interviewed over the air.""" + """The coordinator device, constructed statically (Ziggurat has no loopback ZDO).""" @property def manufacturer(self) -> str: @@ -358,24 +100,33 @@ def model(self, value: str) -> None: class ControllerApplication(zigpy.application.ControllerApplication): DISPLAY_NAME = "Ziggurat" DESCRIPTION = "Ziggurat: An open source, host-side Zigbee stack in Rust" + SCHEMA = CONFIG_SCHEMA def __init__(self, config: dict[str, Any]) -> None: super().__init__(config) self._api: ZigguratApi | None = None async def connect(self) -> None: - # The device path is the WebSocket URL of the ziggurat server - url = self._config[zigpy.config.CONF_DEVICE][zigpy.config.CONF_DEVICE_PATH] + # The device path is either the WebSocket URL of a ziggurat server or the + # serial port of a ziggurat firmware (e.g. an ESP32-C6 over USB-Serial-JTAG). + device = self._config[zigpy.config.CONF_DEVICE] + url = device[zigpy.config.CONF_DEVICE_PATH] # zigpy types `connection_lost` as Exception-only but handles None fine api = ZigguratApi( url, self.on_notification, self.connection_lost, # type: ignore[arg-type] + baudrate=device[zigpy.config.CONF_DEVICE_BAUDRATE], + flow_control=device[zigpy.config.CONF_DEVICE_FLOW_CONTROL], ) await api.connect() self._api = api + # Clear any transient radio state left by a previous client (e.g. a packet + # capture still streaming on the firmware) so this session starts from idle. + await api.request(p.Reset(hard=t.Bool(False))) + async def disconnect(self) -> None: if self._api is not None: try: @@ -389,9 +140,21 @@ async def start_network(self) -> None: network_info=self.state.network_info, node_info=self.state.node_info ) + assert self._api is not None + + for name, value in self._config[CONF_ZIGGURAT_CONFIG][CONF_TUNABLES].items(): + await self._api.set_tunable(name, value) + self._register_coordinator_device() await self.register_endpoints() + url = self._config[zigpy.config.CONF_DEVICE][zigpy.config.CONF_DEVICE_PATH] + self._concurrent_requests_semaphore.max_concurrency = _max_concurrent_requests( + url + ) + + self._start_time = datetime.now(timezone.utc) + def _register_coordinator_device(self) -> None: coordinator = ZigguratCoordinator( self, self.state.node_info.ieee, self.state.node_info.nwk @@ -429,9 +192,9 @@ async def load_network_info(self, *, load_devices: bool = False) -> None: assert self._api is not None try: - info = await self._api.request(GetNetworkInfo()) - except DeliveryError as exc: - if not str(exc).startswith("not_configured"): + info = cast(p.NetworkInfo, await self._api.request(p.GetNetworkInfo())) + except p.ProtocolError as exc: + if exc.status != p.Status.NOT_CONFIGURED: raise # The server is stateless and has no network running (e.g. it just @@ -439,43 +202,84 @@ async def load_network_info(self, *, load_devices: bool = False) -> None: self._get_network_settings() return - stack_specific = {} - if info.tclk_seed is not None: - if info.tclk_flavor == "zstack": - stack_specific = {"zstack": {"tclk_seed": info.tclk_seed}} + state = info.state + + key_table: list[zigpy.state.Key] = [] + async for key_entry in self._api.request_stream(p.ScanKeyTable()): + key_entry = cast(p.KeyEntry, key_entry) + key_table.append( + zigpy.state.Key( + key=key_entry.key, + partner_ieee=key_entry.partner_ieee, + tx_counter=key_entry.tx_counter, + rx_counter=key_entry.rx_counter, + seq=key_entry.seq, + ) + ) + + stack_specific: dict[str, Any] = {} + if state.has_tclk_seed: + seed_hex = bytes(state.tclk_seed).hex() + + if state.tclk_flavor == p.TclkFlavorId.Z_STACK: + stack_specific = {"zstack": {"tclk_seed": seed_hex}} else: - stack_specific = {"ezsp": {"hashed_tclk": info.tclk_seed}} + stack_specific = {"ezsp": {"hashed_tclk": seed_hex}} + + children: list[t.EUI64] = [] + async for child_entry in self._api.request_stream(p.ScanChildren()): + child_entry = cast(p.ChildEntry, child_entry) + children.append(child_entry.ieee) + + nwk_addresses: dict[t.EUI64, t.NWK] = {} + async for addr_entry in self._api.request_stream(p.ScanAddressCache()): + addr_entry = cast(p.AddressEntry, addr_entry) + nwk_addresses[addr_entry.ieee] = addr_entry.nwk + + routes = [] + async for route_entry in self._api.request_stream(p.ScanRouteTable()): + route_entry = cast(p.RouteEntry, route_entry) + routes.append( + { + "destination": route_entry.destination, + "next_hop": route_entry.next_hop, + "path_cost": route_entry.path_cost, + } + ) + if routes: + stack_specific["ziggurat"] = {"routes": routes} self.state.node_info = zigpy.state.NodeInfo( - nwk=info.nwk_address, - ieee=info.ieee_address, + nwk=state.nwk_address, + ieee=state.ieee_address, logical_type=zdo_t.LogicalType.Coordinator, manufacturer="Ziggurat", model="Coordinator", ) self.state.network_info = zigpy.state.NetworkInfo( - extended_pan_id=info.extended_pan_id, - pan_id=info.pan_id, - nwk_update_id=info.nwk_update_id, + extended_pan_id=state.extended_pan_id, + pan_id=state.pan_id, + nwk_update_id=state.nwk_update_id, nwk_manager_id=t.NWK(0x0000), - channel=info.channel, - tx_power=info.tx_power, + channel=state.channel, + tx_power=state.tx_power, # zigpy mis-annotates the classmethod's `cls` as an instance - channel_mask=t.Channels.from_channel_list([info.channel]), # type: ignore[misc] + channel_mask=t.Channels.from_channel_list([state.channel]), # type: ignore[misc] security_level=t.uint8_t(5), network_key=zigpy.state.Key( - key=info.network_key, - seq=info.network_key_seq, - tx_counter=info.network_key_tx_counter, + key=state.network_key, + seq=state.network_key_seq, + tx_counter=state.network_key_tx_counter, ), tc_link_key=zigpy.state.Key( - key=info.tc_link_key, + key=state.tc_link_key, partner_ieee=self.state.node_info.ieee, + # The APS outgoing frame counter lives on the TC link key by convention + tx_counter=state.aps_frame_counter, ), - key_table=[ - zigpy.state.Key(key=entry.key, partner_ieee=entry.partner_ieee) - for entry in info.key_table - ], + key_table=key_table, + children=children, + nwk_addresses=nwk_addresses, stack_specific=stack_specific, ) @@ -485,11 +289,18 @@ def _get_network_settings(self) -> None: except IndexError as exc: raise NetworkNotFormed() from exc - # The backup's frame counter trails the radio's true counter by however many - # frames were sent after the last counter update notification: jump past it + # The backup's counters trail the radio's true counters by however many frames + # were sent after the last counter notification: jump both past that gap so a + # restart never rolls back the NWK or APS outgoing frame counter. network_key = latest_backup.network_info.network_key + tc_link_key = latest_backup.network_info.tc_link_key self.state.network_info = latest_backup.network_info.replace( - network_key=network_key.replace(tx_counter=network_key.tx_counter + 500) + network_key=network_key.replace( + tx_counter=network_key.tx_counter + FRAME_COUNTER_RESTORE_MARGIN + ), + tc_link_key=tc_link_key.replace( + tx_counter=tc_link_key.tx_counter + FRAME_COUNTER_RESTORE_MARGIN + ), ) self.state.node_info = latest_backup.node_info @@ -518,8 +329,10 @@ async def _move_network_to_channel( # coordinator's own move. The update id goes first so no beacon on the new # channel ever advertises the old network instance. assert self._api is not None - await self._api.request(SetNwkUpdateId(nwk_update_id=new_nwk_update_id)) - await self._api.request(SetChannel(channel=new_channel)) + await self._api.request( + p.SetNwkUpdateId(nwk_update_id=t.uint8_t(new_nwk_update_id)) + ) + await self._api.request(p.SetChannel(channel=t.uint8_t(new_channel))) async def permit(self, time_s: int = 60, node: t.EUI64 | str | None = None) -> None: if node is not None: @@ -532,7 +345,9 @@ async def permit(self, time_s: int = 60, node: t.EUI64 | str | None = None) -> N await super().permit(time_s, node=node) assert self._api is not None await self._api.request( - PermitJoins(duration=time_s, accept_direct_joins=False) + p.PermitJoins( + duration=t.uint16_t(time_s), accept_direct_joins=t.Bool(False) + ) ) return @@ -540,13 +355,15 @@ async def permit(self, time_s: int = 60, node: t.EUI64 | str | None = None) -> N async def permit_ncp(self, time_s: int = 60) -> None: assert self._api is not None - await self._api.request(PermitJoins(duration=time_s, accept_direct_joins=True)) + await self._api.request( + p.PermitJoins(duration=t.uint16_t(time_s), accept_direct_joins=t.Bool(True)) + ) async def permit_with_link_key( self, node: t.EUI64, link_key: t.KeyData, time_s: int = 60 ) -> None: assert self._api is not None - await self._api.request(SetProvisionalKey(ieee=node, key=link_key)) + await self._api.request(p.SetProvisionalKey(ieee=node, key=link_key)) await super().permit(time_s) @@ -562,11 +379,12 @@ async def energy_scan( assert self._api is not None for _ in range(count): async for result in self._api.request_stream( - EnergyScan( + p.EnergyScan( channels=list(channels), duration_per_channel_ms=duration_per_channel_ms, ) ): + result = cast(p.EnergyResult, result) all_results.setdefault(result.channel, []).append(result.rssi) return { @@ -583,81 +401,131 @@ async def _network_scan( assert self._api is not None async for beacon in self._api.request_stream( - NetworkScan( + p.NetworkScan( channels=list(channels), duration_per_channel_ms=duration_per_channel_ms, ) ): + beacon = cast(p.Beacon, beacon) yield t.NetworkBeacon( pan_id=beacon.pan_id, extended_pan_id=beacon.extended_pan_id, channel=beacon.channel, - permit_joining=beacon.permit_joining, + permit_joining=bool(beacon.permit_joining), stack_profile=beacon.stack_profile, nwk_update_id=beacon.update_id, lqi=beacon.lqi, - src=beacon.source, + src=beacon.source_or_none, rssi=beacon.rssi, depth=beacon.device_depth, - router_capacity=beacon.router_capacity, - device_capacity=beacon.end_device_capacity, + router_capacity=bool(beacon.router_capacity), + device_capacity=bool(beacon.end_device_capacity), protocol_version=beacon.protocol_version, ) + async def _packet_capture( + self, channel: int + ) -> AsyncGenerator[t.CapturedPacket, None]: + assert self._api is not None + async for packet in self._api.request_stream( + p.PacketCapture(channel=t.uint8_t(channel)) + ): + packet = cast(p.CapturedPacket, packet) + yield t.CapturedPacket( + timestamp=datetime.now(timezone.utc), + rssi=packet.rssi, + lqi=packet.lqi, + channel=packet.channel, + data=packet.psdu, + ) + + async def _packet_capture_change_channel(self, channel: int) -> None: + assert self._api is not None + await self._api.request(p.PacketCaptureChannel(channel=t.uint8_t(channel))) + async def write_network_info( self, *, network_info: zigpy.state.NetworkInfo, node_info: zigpy.state.NodeInfo, ) -> None: + assert self._api is not None + # A TCLK seed carried over from a microcontroller stack: ziggurat derives the # unique link keys the previous stack issued to devices from it. Both stacks # already store the seed as a plain hex string. stack_specific = network_info.stack_specific tclk_seed = None - tclk_flavor = None + tclk_flavor = p.TclkFlavorId.EZSP if "zstack" in stack_specific and "tclk_seed" in stack_specific["zstack"]: tclk_seed = stack_specific["zstack"]["tclk_seed"] - tclk_flavor = "zstack" + tclk_flavor = p.TclkFlavorId.Z_STACK elif "ezsp" in stack_specific and "hashed_tclk" in stack_specific["ezsp"]: tclk_seed = stack_specific["ezsp"]["hashed_tclk"] - tclk_flavor = "ezsp" - - assert self._api is not None + tclk_flavor = p.TclkFlavorId.EZSP # `UNKNOWN` is assigned after the class body, where mypy cannot see it if node_info.ieee == t.EUI64.UNKNOWN: # type: ignore[attr-defined] # zigpy leaves the IEEE address unspecified when forming a new network, # deferring to the radio's hardware address - rsp = await self._api.request(GetHwAddress()) - node_info = node_info.replace(ieee=rsp.ieee_address) - + rsp = cast(p.HwAddress, await self._api.request(p.GetHwAddress())) + node_info = node_info.replace(ieee=rsp.ieee) + + state = p.NetworkState( + channel=t.uint8_t(network_info.channel), + nwk_update_id=t.uint8_t(network_info.nwk_update_id), + pan_id=network_info.pan_id, + extended_pan_id=network_info.extended_pan_id, + nwk_address=node_info.nwk, + ieee_address=node_info.ieee, + network_key=network_info.network_key.key, + network_key_seq=t.uint8_t(network_info.network_key.seq), + network_key_tx_counter=t.uint32_t(network_info.network_key.tx_counter), + tc_link_key=network_info.tc_link_key.key, + has_tclk_seed=t.Bool(tclk_seed is not None), + tclk_seed=t.KeyData( + bytes.fromhex(tclk_seed) if tclk_seed is not None else os.urandom(16) + ), + tclk_flavor=tclk_flavor, + # None means "pick automatically": apply a safe default + tx_power=t.int8s( + network_info.tx_power if network_info.tx_power is not None else 8 + ), + aps_frame_counter=t.uint32_t(network_info.tc_link_key.tx_counter), + ) await self._api.request( - Configure( - channel=network_info.channel, - # None means "pick automatically": the server applies its safe default - tx_power=network_info.tx_power, - nwk_update_id=network_info.nwk_update_id, - pan_id=network_info.pan_id, - extended_pan_id=network_info.extended_pan_id, - nwk_address=node_info.nwk, - ieee_address=node_info.ieee, - network_key=network_info.network_key.key, - network_key_seq=network_info.network_key.seq, - network_key_tx_counter=network_info.network_key.tx_counter, - tc_link_key=network_info.tc_link_key.key, - source_routing=self.config[zigpy.config.CONF_SOURCE_ROUTING], - # Unique trust center link keys negotiated in earlier sessions - key_table=[ - KeyTableEntry(partner_ieee=key.partner_ieee, key=key.key) - for key in network_info.key_table - ], - tclk_seed=tclk_seed, - tclk_flavor=tclk_flavor, + p.Configure( + role=p.NodeRole.COORDINATOR, + source_routing=t.Bool(self.config[zigpy.config.CONF_SOURCE_ROUTING]), + state=state, ) ) + # Unique trust center link keys negotiated in earlier sessions + entries = [ + p.KeyEntry( + key=key.key, + tx_counter=t.uint32_t(key.tx_counter), + rx_counter=t.uint32_t(key.rx_counter), + seq=t.uint8_t(key.seq), + partner_ieee=key.partner_ieee, + ) + for key in network_info.key_table + ] + for start in range(0, len(entries), KEY_BATCH_SIZE): + await self._api.request( + p.LoadKeyTable( + entries=t.LVList[p.KeyEntry, t.uint16_t]( + entries[start : start + KEY_BATCH_SIZE] + ) + ) + ) + + await self._restore_children(network_info) + + await self._api.request(p.StartNetwork()) + # Ziggurat has no persistent storage of its own: zigpy's backup database is # the network's NVRAM, so the settings just written are recorded there for # `start_network` to find @@ -667,12 +535,37 @@ async def write_network_info( zigpy.backups.NetworkBackup(network_info=network_info, node_info=node_info) ) + async def _restore_children(self, network_info: zigpy.state.NetworkInfo) -> None: + assert self._api is not None + # The backup carries no capability, so device type is Unknown (restored as a + # sleepy end device); children without a known NWK address can't be loaded. + entries = [ + p.ChildEntry( + ieee=ieee, + nwk=network_info.nwk_addresses[ieee], + rx_on_when_idle=t.uint1_t(1), + device_type=p.ChildDeviceType.UNKNOWN, + reserved=t.uint5_t(0), + ) + for ieee in network_info.children + if ieee in network_info.nwk_addresses + ] + for start in range(0, len(entries), KEY_BATCH_SIZE): + await self._api.request( + p.LoadChildren( + entries=t.LVList[p.ChildEntry, t.uint16_t]( + entries[start : start + KEY_BATCH_SIZE] + ) + ) + ) + async def reset_network_info(self) -> None: - pass + assert self._api is not None + await self._api.request(p.Shutdown()) async def _watchdog_feed(self) -> None: assert self._api is not None - await self._api.request(Ping()) + await self._api.request(p.GetFirmwareInfo()) def packet_received(self, packet: t.ZigbeePacket) -> None: # ZDO requests addressed to the coordinator have to be answered here: there is @@ -799,32 +692,37 @@ def join_if_still_unannounced() -> None: DEVICE_JOIN_MAX_DELAY, join_if_still_unannounced ) - def on_notification(self, notification: Notification) -> None: + def on_notification(self, notification: p.Notification) -> None: match notification: - case ReceivedApsCommand(): + case p.ReceivedAps(): self._handle_received_aps_command(notification) - case FrameCounterUpdate(): + case p.FrameCounter(): + _LOGGER.debug( + "NWK frame counter updated to %d", notification.frame_counter + ) self.state.network_info.network_key.tx_counter = ( notification.frame_counter ) + self.backups.add_backup(self.backups.from_network_state()) + case p.ApsFrameCounter(): _LOGGER.debug( - "Frame counter updated to %d", - self.state.network_info.network_key.tx_counter, + "APS frame counter updated to %d", notification.frame_counter ) - self.backups.add_backup( - zigpy.backups.NetworkBackup( - network_info=self.state.network_info, - node_info=self.state.node_info, - ) + self.state.network_info.tc_link_key.tx_counter = ( + notification.frame_counter + ) + self.backups.add_backup(self.backups.from_network_state()) + case p.RouteRecord(): + self.handle_relays( + nwk=notification.destination, relays=list(notification.relays) ) - case DeviceJoined(): + case p.DeviceJoined(): self._handle_device_joined( notification.nwk, notification.ieee, notification.parent ) - case DeviceLeft(): - if notification.ieee is not None: - ieee = notification.ieee - else: + case p.DeviceLeft(): + ieee = notification.ieee_or_none + if ieee is None: try: ieee = self.get_device(nwk=notification.nwk).ieee except KeyError: @@ -834,10 +732,10 @@ def on_notification(self, notification: Notification) -> None: "Device %s (%s) left the network: %s", notification.nwk, ieee, - notification.reason.value, + notification.reason.name.lower(), ) self.handle_leave(nwk=notification.nwk, ieee=ieee) - case LinkKeyUpdate(): + case p.LinkKey(): key = zigpy.state.Key( key=notification.key, partner_ieee=notification.ieee, @@ -855,7 +753,7 @@ def on_notification(self, notification: Notification) -> None: node_info=self.state.node_info, ) ) - case ApsDecryptionFailure(): + case p.ApsDecryptFailure(): _LOGGER.warning( "Could not decrypt an APS command from %s (%s): its trust center " "link key is wrong or missing.", @@ -863,11 +761,12 @@ def on_notification(self, notification: Notification) -> None: notification.source, ) - def _handle_received_aps_command(self, command: ReceivedApsCommand) -> None: - if command.group is not None: + def _handle_received_aps_command(self, command: p.ReceivedAps) -> None: + group = command.group_id + if group is not None: dst = t.AddrModeAddress( addr_mode=t.AddrMode.Group, - address=t.Group(command.group), + address=t.Group(group), ) elif command.destination >= 0xFFF8: dst = t.AddrModeAddress( @@ -897,47 +796,113 @@ def _handle_received_aps_command(self, command: ReceivedApsCommand) -> None: self.packet_received(packet) async def send_packet(self, packet: t.ZigbeePacket) -> None: - aps_encryption = t.TransmitOptions.APS_Encryption in packet.tx_options - dst = packet.dst assert dst is not None and dst.address is not None + try: + device = self.get_device_with_address(dst) + except (KeyError, ValueError): + device = None + destination: t.NWK | None = None - destination_eui64: t.EUI64 | None = None + destination_eui64 = device.ieee if device is not None else None if dst.addr_mode == t.AddrMode.IEEE: # The server resolves the EUI64 to a network address destination_eui64 = cast(t.EUI64, dst.address) - delivery_mode = "unicast" else: destination = t.NWK(dst.address) - delivery_mode = { - t.AddrMode.NWK: "unicast", - t.AddrMode.Group: "multicast", - t.AddrMode.Broadcast: "broadcast", - }[dst.addr_mode] - - if aps_encryption: - # The server selects the link key by EUI64 - destination_eui64 = self.get_device(nwk=destination).ieee - - # Resolves once the frame is on the air (EZSP `messageSent` parity); the - # APS-ack delivery result arrives later and is logged by the API layer - assert self._api is not None - await self._api.request_transmitted( - SendAps( - delivery_mode=delivery_mode, - destination_eui64=destination_eui64, - destination=destination, - profile_id=packet.profile_id, - cluster_id=packet.cluster_id or 0x0000, - src_ep=packet.src_ep or 0, - dst_ep=packet.dst_ep or 0, - aps_ack=t.TransmitOptions.ACK in packet.tx_options, - aps_encryption=aps_encryption, - radius=packet.radius or 30, - aps_seq=packet.tsn, - priority=packet.priority if packet.priority is not None else 0, - data=packet.data.serialize(), + + if ( + t.TransmitOptions.APS_Encryption in packet.tx_options + and destination_eui64 is None + ): + raise DeliveryError( + "Cannot send an encrypted packet without a destination EUI64" ) - ) + + # Resolves once the send is confirmed: passive-ack quorum for a broadcast or + # groupcast, next-hop acceptance for a no-ack unicast, or the end-to-end APS + # ack. A rejected or failed send raises `DeliveryError`. + assert self._api is not None + priority = packet.priority if packet.priority is not None else 0 + radius = packet.radius or 30 + asdu = packet.data.serialize() + + send: p.SendUnicast | p.SendBroadcast | p.SendGroupcast + async with self._limit_concurrency(priority=packet.priority): + if dst.addr_mode == t.AddrMode.Group: + assert destination is not None + send = p.SendGroupcast.build( + group_id=int(destination), + 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, + ) + elif dst.addr_mode == t.AddrMode.Broadcast: + assert destination is not None + send = p.SendBroadcast.build( + destination=destination, + profile_id=packet.profile_id, + 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, + ) + else: + route_control = p.RouteControl.STACK_DECIDES + next_hop = None + relays = None + + # Within the network startup period, provide route hints to the + # stack to reduce routing congestion + if ( + device is not None + and datetime.now(timezone.utc) - self._start_time + < ROUTE_HINT_DURATION + ): + maybe_relays = self.build_source_route_to(device) + + if maybe_relays is None: + maybe_next_hop = None + elif not maybe_relays: + maybe_next_hop = device.nwk + else: + maybe_next_hop = maybe_relays[0] + + if self.config[zigpy.config.CONF_SOURCE_ROUTING] and maybe_relays: + route_control = p.RouteControl.HINT_SOURCE_ROUTE + relays = maybe_relays + elif maybe_next_hop is not None: + route_control = p.RouteControl.HINT_NEXT_HOP + next_hop = maybe_next_hop + + send = p.SendUnicast.build( + destination=destination, + destination_eui64=destination_eui64, + aps_ack=t.TransmitOptions.ACK in packet.tx_options, + aps_encryption=( + t.TransmitOptions.APS_Encryption in packet.tx_options + ), + sleepy_destination=packet.extended_timeout, + profile_id=packet.profile_id, + 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, + next_hop=next_hop, + relays=relays, + asdu=asdu, + ) + + await self._api.request_confirmed(send) diff --git a/zigpy_ziggurat/zigbee/commands.py b/zigpy_ziggurat/zigbee/legacy.py similarity index 93% rename from zigpy_ziggurat/zigbee/commands.py rename to zigpy_ziggurat/zigbee/legacy.py index 7a39bd4..ba0be3c 100644 --- a/zigpy_ziggurat/zigbee/commands.py +++ b/zigpy_ziggurat/zigbee/legacy.py @@ -1,6 +1,4 @@ -"""Typed models for the ziggurat JSON-RPC wire protocol, mirroring the server's -serde types. Requests and responses share one set of wire formats; notifications -encode network addresses little-endian.""" +"""Legacy JSON-RPC wire protocol for the WebSocket transport.""" from dataclasses import dataclass import enum @@ -177,6 +175,9 @@ class Configure(Request[Status]): tclk_seed: str | None tclk_flavor: str | None + aps_frame_counter: int = 0 + started: bool = False + @dataclass class NetworkInfo(Response): @@ -195,6 +196,9 @@ class NetworkInfo(Response): tclk_flavor: str | None key_table: list[KeyTableEntry] + aps_frame_counter: int = 0 + started: bool = False + @dataclass class GetNetworkInfo(Request[NetworkInfo]): @@ -309,6 +313,33 @@ class SetChannel(Request[Status]): channel: int +@dataclass +class CapturedPacketEvent(Response): + channel: t.uint8_t + rssi: t.int8s + lqi: t.uint8_t + # Hex-encoded 802.15.4 MAC frame (FCS stripped) + data: str + + +@dataclass +class PacketCapture(StreamingRequest[Status, CapturedPacketEvent]): + method = "packet_capture" + response_type = Status + event_type = CapturedPacketEvent + event_name = "captured_packet" + + channel: int + + +@dataclass +class PacketCaptureChangeChannel(Request[Status]): + method = "packet_capture_change_channel" + response_type = Status + + channel: int + + @dataclass class SetNwkUpdateId(Request[Status]): method = "set_nwk_update_id" diff --git a/zigpy_ziggurat/zigbee/protocol.py b/zigpy_ziggurat/zigbee/protocol.py new file mode 100644 index 0000000..1b8c3c4 --- /dev/null +++ b/zigpy_ziggurat/zigbee/protocol.py @@ -0,0 +1,499 @@ +"""The binary Ziggurat control protocol.""" + +from __future__ import annotations + +from datetime import timedelta +from typing import ClassVar + +from zigpy.exceptions import DeliveryError +import zigpy.types as t + +from zigpy_ziggurat.zigbee import wire + +# Re-export the generated wire types for the rest of zigpy-ziggurat to use. +from zigpy_ziggurat.zigbee.wire import ( + PROTOCOL_VERSION as PROTOCOL_VERSION, + ChildDeviceType as ChildDeviceType, + DeliveryMode as DeliveryMode, + FrameType as FrameType, + Header as Header, + KeyId as KeyId, + LeaveReason as LeaveReason, + NetworkState as NetworkState, + NodeRole as NodeRole, + NotificationCommand as NotificationCommand, + RateLimitedPayload as RateLimitedPayload, + RequestCommand as RequestCommand, + RouteControl as RouteControl, + SendStatus as SendStatus, + Status as Status, + TclkFlavorId as TclkFlavorId, +) + + +class Response(t.Struct): + """A device -> host reply payload: a response or a streamed scan/event item.""" + + +class Notification(t.Struct): + """An unsolicited device -> host frame.""" + + +class Request(t.Struct): + """A host -> device request.""" + + command: ClassVar[RequestCommand] + # The OK-body response type; None when the OK reply is empty. + response: ClassVar[type[Response] | None] = None + # The streamed item type for a scan/stream request; None for plain request/response. + event: ClassVar[type[Response] | None] = None + + +# -- table entries (streamed by scans, loaded by the load requests) -------------- + + +class KeyEntry(Response, wire.KeyEntry): + pass + + +class ChildEntry(Response, wire.ChildEntry): + pass + + +class AddressEntry(Response, wire.AddressEntry): + pass + + +class RouteEntry(Response, wire.RouteEntry): + pass + + +class SourceRouteEntry(Response, wire.SourceRouteEntry): + pass + + +# -- responses / streamed events ------------------------------------------------- + + +class FirmwareInfo(Response, wire.FirmwareInfoPayload): + pass + + +class HwAddress(Response, wire.HwAddressPayload): + pass + + +class NetworkInfo(Response, wire.NetworkInfoPayload): + pass + + +class ScanCount(Response, wire.ScanCountPayload): + pass + + +class CancelResult(Response, wire.CancelResultPayload): + # Whether a still-cancellable (pre-delivery) send was found and removed. + pass + + +class EnergyResult(Response, wire.EnergyResultPayload): + pass + + +class Beacon(Response, wire.BeaconPayload): + @property + def source_or_none(self) -> t.NWK | None: + return self.source if self.source != t.NWK(0xFFFF) else None + + +class CapturedPacket(Response, wire.CapturedPacketPayload): + pass + + +# -- requests -------------------------------------------------------------------- + + +class Reset(Request, wire.ResetPayload): + command = RequestCommand.RESET + + +class Shutdown(Request): + command = RequestCommand.SHUTDOWN + + +class GetFirmwareInfo(Request): + command = RequestCommand.GET_FIRMWARE_INFO + response = FirmwareInfo + + +class GetHwAddress(Request): + command = RequestCommand.GET_HW_ADDRESS + response = HwAddress + + +class Configure(Request, wire.ConfigurePayload): + command = RequestCommand.CONFIGURE + + +class LoadKeyTable(Request, wire.LoadKeyTablePayload): + command = RequestCommand.LOAD_KEY_TABLE + + +class LoadChildren(Request, wire.LoadChildrenPayload): + command = RequestCommand.LOAD_CHILDREN + + +class LoadAddressCache(Request, wire.LoadAddressCachePayload): + command = RequestCommand.LOAD_ADDRESS_CACHE + + +class LoadRouteTable(Request, wire.LoadRouteTablePayload): + command = RequestCommand.LOAD_ROUTE_TABLE + + +class LoadSourceRoutes(Request, wire.LoadSourceRoutesPayload): + command = RequestCommand.LOAD_SOURCE_ROUTES + + +class StartNetwork(Request): + command = RequestCommand.START_NETWORK + + +class GetNetworkInfo(Request): + command = RequestCommand.GET_NETWORK_INFO + response = NetworkInfo + + +class ScanKeyTable(Request): + command = RequestCommand.SCAN_KEY_TABLE + response = ScanCount + event = KeyEntry + + +class ScanChildren(Request): + command = RequestCommand.SCAN_CHILDREN + response = ScanCount + event = ChildEntry + + +class ScanAddressCache(Request): + command = RequestCommand.SCAN_ADDRESS_CACHE + response = ScanCount + event = AddressEntry + + +class ScanRouteTable(Request): + command = RequestCommand.SCAN_ROUTE_TABLE + response = ScanCount + event = RouteEntry + + +class SendUnicast(Request, wire.SendUnicastPayload): + command = RequestCommand.SEND_UNICAST + + @classmethod + def build( + cls, + *, + destination: t.NWK | None, + destination_eui64: t.EUI64 | None, + aps_ack: bool, + aps_encryption: bool, + sleepy_destination: bool, + profile_id: int, + cluster_id: int, + src_ep: int, + dst_ep: int, + aps_seq: int, + radius: int, + priority: int, + asdu: bytes, + route_control: RouteControl = RouteControl.STACK_DECIDES, + next_hop: t.NWK | None = None, + relays: list[t.NWK] | None = None, + ) -> SendUnicast: + return cls( + has_eui64=t.uint1_t(destination_eui64 is not None), + aps_ack=t.uint1_t(aps_ack), + aps_encryption=t.uint1_t(aps_encryption), + sleepy_destination=t.uint1_t(sleepy_destination), + reserved=t.uint4_t(0), + # 0xFFFE stands in for "no short address"; the firmware resolves the EUI64. + destination=destination if destination is not None else t.NWK(0xFFFE), + destination_eui64=destination_eui64 or t.EUI64([0] * 8), + profile_id=t.uint16_t(profile_id), + 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), + radius=t.uint8_t(radius), + priority=t.int8s(priority), + route=route_control, + next_hop=next_hop, + relays=( + wire.SourceRouteRelays(relays=t.LVList[t.NWK, t.uint8_t](relays)) + if relays is not None + else None + ), + asdu=t.LongOctetString(asdu), + ) + + +class SendBroadcast(Request, wire.SendBroadcastPayload): + command = RequestCommand.SEND_BROADCAST + + @classmethod + def build( + cls, + *, + destination: t.NWK, + profile_id: int, + cluster_id: int, + src_ep: int, + dst_ep: int, + aps_seq: int, + radius: int, + priority: int, + asdu: bytes, + ) -> SendBroadcast: + return cls( + reserved=t.uint8_t(0), + destination=destination, + profile_id=t.uint16_t(profile_id), + 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), + radius=t.uint8_t(radius), + priority=t.int8s(priority), + asdu=t.LongOctetString(asdu), + ) + + +class SendGroupcast(Request, wire.SendGroupcastPayload): + command = RequestCommand.SEND_GROUPCAST + + @classmethod + def build( + cls, + *, + group_id: int, + profile_id: int, + cluster_id: int, + src_ep: int, + aps_seq: int, + radius: int, + priority: int, + asdu: bytes, + ) -> SendGroupcast: + return cls( + reserved=t.uint8_t(0), + group_id=t.uint16_t(group_id), + 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), + radius=t.uint8_t(radius), + priority=t.int8s(priority), + asdu=t.LongOctetString(asdu), + ) + + +class PermitJoins(Request, wire.PermitJoinsPayload): + command = RequestCommand.PERMIT_JOINS + + +class SetChannel(Request, wire.ChannelPayload): + command = RequestCommand.SET_CHANNEL + + +class SetNwkUpdateId(Request, wire.NwkUpdateIdPayload): + command = RequestCommand.SET_NWK_UPDATE_ID + + +class SetProvisionalKey(Request, wire.ProvisionalKeyPayload): + command = RequestCommand.SET_PROVISIONAL_KEY + + +class EnergyScan(Request, wire.ScanRequestPayload): + command = RequestCommand.ENERGY_SCAN + event = EnergyResult + + +class NetworkScan(Request, wire.ScanRequestPayload): + command = RequestCommand.NETWORK_SCAN + event = Beacon + + +class PacketCapture(Request, wire.ChannelPayload): + command = RequestCommand.PACKET_CAPTURE + event = CapturedPacket + + +class PacketCaptureChannel(Request, wire.ChannelPayload): + command = RequestCommand.PACKET_CAPTURE_CHANNEL + + +# The tunable name is a Rust field name of the stack's `Tunables` struct (see the +# `tunables!` block in ziggurat-zigbee). The value is type-punned into a u64: +# integers as-is, bools as 0/1, durations in microseconds, enums as their +# discriminant; the firmware rejects unknown names and out-of-range values. +class SetTunable(Request, wire.SetTunablePayload): + command = RequestCommand.SET_TUNABLE + + @classmethod + def build(cls, name: str, value: int | timedelta) -> SetTunable: + if isinstance(value, timedelta): + value = value // timedelta(microseconds=1) + return cls(name=t.LVBytes(name.encode("ascii")), value=t.uint64_t(value)) + + +class CancelRequest(Request, wire.CancelRequestPayload): + command = RequestCommand.CANCEL_REQUEST + response = CancelResult + + +# -- notifications --------------------------------------------------------------- + + +class Hello(Notification, wire.HelloPayload): + pass + + +class LastReset(Notification, wire.LastResetPayload): + pass + + +class ReceivedAps(Notification, wire.ReceivedApsPayload): + @property + def group_id(self) -> int | None: + return int(self.group) if self.has_group else None + + +class SendConfirm(Notification, wire.SendConfirmPayload): + pass + + +class ApsAckConfirm(Notification, wire.ApsAckConfirmPayload): + pass + + +class BroadcastConfirm(Notification, wire.BroadcastConfirmPayload): + pass + + +class DeviceJoined(Notification, wire.DeviceJoinedPayload): + pass + + +class DeviceLeft(Notification, wire.DeviceLeftPayload): + @property + def ieee_or_none(self) -> t.EUI64 | None: + return self.ieee if self.has_ieee else None + + @property + def rejoin_or_none(self) -> bool | None: + # `rejoin` is only meaningful for a self-announced leave. + return bool(self.rejoin) if self.reason == LeaveReason.ANNOUNCED else None + + @property + def router_or_none(self) -> t.NWK | None: + return self.router if self.reason == LeaveReason.ROUTER_REPORTED else None + + @property + def router_ieee_or_none(self) -> t.EUI64 | None: + if self.reason == LeaveReason.ROUTER_REPORTED and self.has_router_ieee: + return self.router_ieee + return None + + +class FrameCounter(Notification, wire.FrameCounterPayload): + pass + + +class LinkKey(Notification, wire.LinkKeyPayload): + pass + + +class ApsDecryptFailure(Notification, wire.ApsDecryptFailPayload): + pass + + +class RouteRecord(Notification, wire.RouteRecordPayload): + pass + + +class ApsFrameCounter(Notification, wire.ApsFrameCounterPayload): + pass + + +# Notification id -> struct, for decoding unsolicited frames. `SendConfirm`, +# `ApsAckConfirm` and `BroadcastConfirm` are handled specially (they resolve a pending +# send by request id). +NOTIFICATIONS: dict[NotificationCommand, type[Notification]] = { + NotificationCommand.HELLO: Hello, + NotificationCommand.LAST_RESET: LastReset, + NotificationCommand.RECEIVED_APS: ReceivedAps, + NotificationCommand.SEND_CONFIRM: SendConfirm, + NotificationCommand.APS_ACK_CONFIRM: ApsAckConfirm, + NotificationCommand.BROADCAST_CONFIRM: BroadcastConfirm, + NotificationCommand.DEVICE_JOINED: DeviceJoined, + NotificationCommand.DEVICE_LEFT: DeviceLeft, + NotificationCommand.FRAME_COUNTER: FrameCounter, + NotificationCommand.LINK_KEY: LinkKey, + NotificationCommand.APS_DECRYPT_FAILURE: ApsDecryptFailure, + NotificationCommand.ROUTE_RECORD: RouteRecord, + NotificationCommand.APS_FRAME_COUNTER: ApsFrameCounter, +} + + +def encode_request(request: Request, request_id: int) -> bytes: + """Serialize a request frame (3-byte header, then the payload).""" + header = Header( + command=t.uint8_t(request.command), + frame_type=FrameType.REQUEST, + request_id=t.uint16_t(request_id), + ) + return header.serialize() + request.serialize() + + +def encode_reply( + frame_type: FrameType, + command: RequestCommand | NotificationCommand, + request_id: int, + body: bytes = b"", +) -> bytes: + """Serialize a device -> host frame (3-byte header, then the body).""" + header = Header( + command=t.uint8_t(command), + frame_type=frame_type, + request_id=t.uint16_t(request_id), + ) + return header.serialize() + body + + +# Command id -> request type, for parsing an outbound frame back into a struct. +REQUESTS: dict[RequestCommand, type[Request]] = { + cls.command: cls for cls in Request.__subclasses__() +} + + +class ProtocolError(DeliveryError): + """A firmware error response (non-OK status).""" + + # `detail` is client-side context (e.g. the rate-limit retry delay); the wire + # carries only the status code. + def __init__(self, status: Status, detail: str = "") -> None: + code = status.name.lower() + super().__init__(f"{code}: {detail}" if detail else code) + self.status = status + + +class RateLimitedError(ProtocolError): + """A broadcast rejected by the firmware's rate limit, carrying when to retry.""" + + def __init__(self, retry_in: timedelta) -> None: + super().__init__( + Status.RATE_LIMITED, f"retry in {retry_in.total_seconds():.1f}s" + ) + self.retry_in = retry_in diff --git a/zigpy_ziggurat/zigbee/transport.py b/zigpy_ziggurat/zigbee/transport.py new file mode 100644 index 0000000..1830af4 --- /dev/null +++ b/zigpy_ziggurat/zigbee/transport.py @@ -0,0 +1,853 @@ +"""Frame transports for the binary protocol: serial (Spinel tunnel), binary +WebSocket, and a JSON-transcoding WebSocket for early users on the legacy server.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +import json +import logging +from typing import Any, Protocol, cast + +import aiohttp +import aiospinel +import zigpy.serial +import zigpy.types as t + +from zigpy_ziggurat.zigbee import legacy, protocol as p + +_LOGGER = logging.getLogger(__name__) + +WEBSOCKET_HEARTBEAT = 15 + +PROP_VENDOR_ZIGGURAT = aiospinel.PackedUInt21(0x3D5A) + +# The callback the API installs to receive a device -> host binary frame. +OnFrame = Callable[[bytes], None] +OnLost = Callable[[BaseException | None], None] + + +# The server must announce itself with a hello within this window. +HANDSHAKE_TIMEOUT = 5 + + +class Transport(Protocol): + """Moves binary protocol frames between the API and a device, once connected.""" + + async def disconnect(self) -> None: ... + + async def send_frame(self, frame: bytes) -> None: ... + + +async def connect_transport( + url: str, + on_frame: OnFrame, + on_lost: OnLost, + *, + baudrate: int = 115200, + flow_control: str | None = None, +) -> Transport: + """Open a connected transport for `url`, probing a WebSocket for its protocol.""" + if not url.startswith(("ws://", "wss://", "ws+unix://")): + spinel = SpinelTransport( + url, on_frame, on_lost, baudrate=baudrate, flow_control=flow_control + ) + await spinel.connect() + return spinel + + return await _probe_websocket(url, on_frame, on_lost) + + +# -- serial (Spinel tunnel) ------------------------------------------------------ + + +class _SpinelProtocol(aiospinel.SpinelProtocol): + """Tunnels binary frames over the vendor Spinel stream property.""" + + def __init__(self, on_frame: OnFrame, on_lost: OnLost) -> None: + super().__init__() + self._on_frame = on_frame + self._on_lost = on_lost + self.add_property_listener(PROP_VENDOR_ZIGGURAT, self._stream_frame_received) + + def connection_lost(self, exc: BaseException | None) -> None: + super().connection_lost(exc) + self._on_lost(exc) + + def _stream_frame_received(self, data: bytes) -> None: + # Responses to our own property SETs also land here, with no payload. + if len(data) < 2: + return + length = int.from_bytes(data[:2], "little") + try: + self._on_frame(data[2 : 2 + length]) + except Exception: + _LOGGER.exception("Failed to handle frame: %r", data) + + async def start_ziggurat(self) -> None: + rsp = await self.send_command( + aiospinel.CommandID.PROP_VALUE_GET, + PROP_VENDOR_ZIGGURAT.serialize(), + ) + prop_id, _ = aiospinel.PackedUInt21.deserialize(rsp.data) + if prop_id != PROP_VENDOR_ZIGGURAT: + raise ConnectionError( + f"Firmware does not embed the Ziggurat stack: {rsp!r}" + ) + _LOGGER.debug("Embedded Ziggurat firmware detected") + + async def tunnel_send(self, frame: bytes) -> None: + # No retries: a timed-out tunnel write must not resend the request (the first + # copy may already have been processed). + rsp = await self.send_command( + aiospinel.CommandID.PROP_VALUE_SET, + ( + PROP_VENDOR_ZIGGURAT.serialize() + + len(frame).to_bytes(2, "little") + + frame + ), + retries=0, + ) + prop_id, _ = aiospinel.PackedUInt21.deserialize(rsp.data) + if prop_id != PROP_VENDOR_ZIGGURAT: + raise ConnectionError(f"Tunnel write rejected: {rsp!r}") + + +class SpinelTransport: + """The binary protocol tunneled over a serial OpenThread RCP's Spinel stream.""" + + def __init__( + self, + url: str, + on_frame: OnFrame, + on_lost: OnLost, + *, + baudrate: int = 115200, + flow_control: str | None = None, + ) -> None: + self._url = url + self._on_frame = on_frame + self._on_lost = on_lost + self._baudrate = baudrate + self._flow_control = flow_control + self._protocol: _SpinelProtocol | None = None + + async def connect(self) -> None: + _, protocol = await zigpy.serial.create_serial_connection( + loop=asyncio.get_running_loop(), + protocol_factory=lambda: _SpinelProtocol(self._on_frame, self._on_lost), + url=self._url, + baudrate=self._baudrate, + flow_control=cast(Any, self._flow_control), + ) + self._protocol = cast(_SpinelProtocol, protocol) + await self._protocol.wait_until_connected() + await self._protocol.start_ziggurat() + + async def disconnect(self) -> None: + if self._protocol is not None: + self._protocol.close() + await self._protocol.wait_until_closed() + self._protocol = None + + async def send_frame(self, frame: bytes) -> None: + assert self._protocol is not None + await self._protocol.tunnel_send(frame) + + +# -- WebSocket ------------------------------------------------------------------- + + +async def _open_websocket( + url: str, +) -> tuple[aiohttp.ClientSession, aiohttp.ClientWebSocketResponse]: + if url.startswith("ws+unix://"): + # The URL's path is the socket path; the HTTP host is a placeholder. + connector: aiohttp.BaseConnector | None = aiohttp.UnixConnector( + path=url.removeprefix("ws+unix://") + ) + ws_url = "ws://localhost/" + else: + connector = None + ws_url = url + + session = aiohttp.ClientSession(connector=connector) + websocket = await session.ws_connect(ws_url, heartbeat=WEBSOCKET_HEARTBEAT) + return session, websocket + + +async def _probe_websocket(url: str, on_frame: OnFrame, on_lost: OnLost) -> Transport: + """Pick the transport from the server's opening hello: binary frame or JSON text.""" + session, websocket = await _open_websocket(url) + async with asyncio.timeout(HANDSHAKE_TIMEOUT): + hello = await websocket.receive() + + if hello.type == aiohttp.WSMsgType.BINARY: + transport: _WebSocketBase = WebSocketTransport(on_frame, on_lost) + _LOGGER.debug("Detected binary WebSocket protocol") + elif hello.type == aiohttp.WSMsgType.TEXT: + transport = LegacyWebSocketTransport(on_frame, on_lost) + _LOGGER.debug("Detected legacy JSON WebSocket protocol: %s", hello.data) + _LOGGER.warning( + "The legacy JSON WebSocket protocol will be removed soon. Please upgrade" + " the Ziggurat app to switch to the new binary protocol." + ) + else: + await session.close() + raise ConnectionError(f"Unexpected handshake from ziggurat: {hello!r}") + + transport._adopt(session, websocket) + return transport + + +class _WebSocketBase: + """Shared aiohttp WebSocket plumbing, driven from a socket passed to `_adopt`.""" + + def __init__(self, on_frame: OnFrame, on_lost: OnLost) -> None: + self._on_frame = on_frame + self._on_lost = on_lost + self._session: aiohttp.ClientSession | None = None + self._websocket: aiohttp.ClientWebSocketResponse | None = None + self._receiver_task: asyncio.Task[None] | None = None + + def _adopt( + self, + session: aiohttp.ClientSession, + websocket: aiohttp.ClientWebSocketResponse, + ) -> None: + self._session = session + self._websocket = websocket + self._receiver_task = asyncio.create_task(self._receive_loop()) + + async def disconnect(self) -> None: + if self._receiver_task is not None: + self._receiver_task.cancel() + self._receiver_task = None + if self._websocket is not None: + await self._websocket.close() + self._websocket = None + if self._session is not None: + await self._session.close() + self._session = None + + async def _receive_loop(self) -> None: + websocket = self._websocket + assert websocket is not None + exc: BaseException | None = None + try: + async for msg in websocket: + if msg.type in (aiohttp.WSMsgType.TEXT, aiohttp.WSMsgType.BINARY): + try: + self._handle_message(msg) + except Exception: + _LOGGER.exception("Failed to handle message: %r", msg.data) + elif msg.type == aiohttp.WSMsgType.ERROR: + exc = websocket.exception() + break + except asyncio.CancelledError: + # A deliberate disconnect; the API is already tearing down. + self._on_lost(None) + raise + self._on_lost(exc) + + def _handle_message(self, msg: aiohttp.WSMessage) -> None: + raise NotImplementedError + + async def send_frame(self, frame: bytes) -> None: + raise NotImplementedError + + async def _send(self, data: bytes | str) -> None: + if self._websocket is None: + raise ConnectionError("Not connected") + if isinstance(data, str): + await self._websocket.send_str(data) + else: + await self._websocket.send_bytes(data) + + +class WebSocketTransport(_WebSocketBase): + """The binary protocol carried as WebSocket binary frames.""" + + def _handle_message(self, msg: aiohttp.WSMessage) -> None: + if msg.type == aiohttp.WSMsgType.BINARY: + self._on_frame(msg.data) + + async def send_frame(self, frame: bytes) -> None: + await self._send(frame) + + +# JSON error code -> binary status +_STATUS_BY_CODE: dict[str, p.Status] = { + "parse": p.Status.MALFORMED_PAYLOAD, + "unknown_command": p.Status.UNKNOWN_COMMAND, + # The legacy server's lone state error is a load after the network started. + "invalid_state": p.Status.ALREADY_STARTED, + "not_configured": p.Status.NOT_CONFIGURED, + "radio_error": p.Status.RADIO_ERROR, + "network_start_failed": p.Status.NETWORK_START_FAILED, + "transmit_failed": p.Status.RADIO_ERROR, + "scan_failed": p.Status.SCAN_FAILED, + "invalid_request": p.Status.INVALID_REQUEST, +} + +_LEAVE_REASONS: dict[legacy.DeviceLeaveReason, p.LeaveReason] = { + legacy.DeviceLeaveReason.ANNOUNCED: p.LeaveReason.ANNOUNCED, + legacy.DeviceLeaveReason.ROUTER_REPORTED: p.LeaveReason.ROUTER_REPORTED, + legacy.DeviceLeaveReason.KEEPALIVE_TIMEOUT: p.LeaveReason.KEEPALIVE_TIMEOUT, +} + +_RUST_LOG_LEVELS = { + "ERROR": logging.ERROR, + "WARN": logging.WARNING, + "INFO": logging.INFO, + "DEBUG": logging.DEBUG, + "TRACE": 5, +} + + +class LegacyWebSocketTransport(_WebSocketBase): + """Transcodes the binary protocol to/from the legacy JSON-RPC server.""" + + def __init__(self, on_frame: OnFrame, on_lost: OnLost) -> None: + super().__init__(on_frame, on_lost) + # request id -> command, so a JSON response builds the right binary reply + self._pending_commands: dict[int, p.RequestCommand] = {} + # The binary protocol splits `configure` (Configure + LoadKeyTable* + + # StartNetwork) that the JSON server takes as one call; coalesce it. + self._pending_configure: p.Configure | None = None + self._pending_keys: list[p.KeyEntry] = [] + # The key table the JSON get_network_info returns inline, replayed as the + # events of the ScanKeyTable that follows on the binary side. + self._scan_keys: list[p.KeyEntry] = [] + + # -- outbound: binary frame -> JSON request ------------------------------------ + + async def send_frame(self, frame: bytes) -> None: + header, body = p.Header.deserialize(frame) + command = p.RequestCommand(header.command) + request_id = int(header.request_id) + request = p.REQUESTS[command].deserialize(body)[0] + + if command in (p.RequestCommand.SHUTDOWN, p.RequestCommand.RESET): + # The legacy server has neither shutdown nor reset; it replaces the stack + # on `configure`. OK them locally so callers don't depend on either. + self._emit_ok(command, request_id) + elif command == p.RequestCommand.CONFIGURE: + self._pending_configure = cast(p.Configure, request) + self._pending_keys = [] + self._emit_ok(command, request_id) + elif command == p.RequestCommand.LOAD_KEY_TABLE: + self._pending_keys.extend(cast(p.LoadKeyTable, request).entries) + self._emit_ok(command, request_id) + elif command in ( + p.RequestCommand.LOAD_CHILDREN, + p.RequestCommand.LOAD_ADDRESS_CACHE, + p.RequestCommand.LOAD_ROUTE_TABLE, + p.RequestCommand.LOAD_SOURCE_ROUTES, + ): + # The legacy server re-learns its topology tables, so acknowledge these + # restore loads locally and drop them. + self._emit_ok(command, request_id) + elif command == p.RequestCommand.START_NETWORK: + assert self._pending_configure is not None + params = self._configure_params(self._pending_configure, self._pending_keys) + self._pending_configure = None + self._pending_keys = [] + self._pending_commands[request_id] = command + await self._send_json(request_id, "configure", params) + elif command == p.RequestCommand.GET_NETWORK_INFO: + self._pending_commands[request_id] = command + await self._send_json(request_id, "get_network_info", {}) + elif command == p.RequestCommand.SCAN_KEY_TABLE: + for entry in self._scan_keys: + self._emit(p.FrameType.EVENT, command, request_id, entry.serialize()) + count = p.ScanCount(count=t.uint16_t(len(self._scan_keys))) + self._emit_ok(command, request_id, count) + self._scan_keys = [] + elif command in ( + p.RequestCommand.SCAN_CHILDREN, + p.RequestCommand.SCAN_ADDRESS_CACHE, + p.RequestCommand.SCAN_ROUTE_TABLE, + ): + # The JSON server surfaces only the key table (inline in get_network_info); + # it has no children/address/route scans, so these stream empty. The app + # re-learns that topology from join notifications during the transition. + self._emit_ok(command, request_id, p.ScanCount(count=t.uint16_t(0))) + elif command == p.RequestCommand.CANCEL_REQUEST: + # The legacy server has no request-cancel concept, so the best-effort + # cancel from `ZigguratApi._cancel_send` is dropped here. + pass + else: + method, params = self._encode_request(command, request) + self._pending_commands[request_id] = command + await self._send_json(request_id, method, params) + + async def _send_json( + self, request_id: int, method: str, params: dict[str, Any] + ) -> None: + await self._send( + json.dumps({"id": request_id, "method": method, "params": params}) + ) + + def _encode_request( + self, command: p.RequestCommand, request: p.Request + ) -> tuple[str, dict[str, Any]]: + if command == p.RequestCommand.GET_FIRMWARE_INFO: + # The legacy server has no firmware-info call; `ping` keeps the liveness + # probe end-to-end and the response is fabricated in `_handle_response`. + return "ping", {} + if command == p.RequestCommand.GET_HW_ADDRESS: + return "get_hw_address", {} + if command == p.RequestCommand.PERMIT_JOINS: + permit = cast(p.PermitJoins, request) + return ( + "permit_joins", + legacy.PermitJoins( + duration=int(permit.duration), + accept_direct_joins=bool(permit.accept_direct_joins), + ).to_dict(), + ) + if command == p.RequestCommand.SET_CHANNEL: + channel = int(cast(p.SetChannel, request).channel) + return "set_channel", legacy.SetChannel(channel=channel).to_dict() + if command == p.RequestCommand.SET_NWK_UPDATE_ID: + update_id = int(cast(p.SetNwkUpdateId, request).nwk_update_id) + return ( + "set_nwk_update_id", + legacy.SetNwkUpdateId(nwk_update_id=update_id).to_dict(), + ) + if command == p.RequestCommand.SET_PROVISIONAL_KEY: + key = cast(p.SetProvisionalKey, request) + return ( + "set_provisional_key", + legacy.SetProvisionalKey(ieee=key.ieee, key=key.key).to_dict(), + ) + if command == p.RequestCommand.ENERGY_SCAN: + scan = cast(p.EnergyScan, request) + return ( + "energy_scan", + legacy.EnergyScan( + channels=[int(c) for c in scan.channels], + duration_per_channel_ms=int(scan.duration_per_channel_ms), + ).to_dict(), + ) + if command == p.RequestCommand.NETWORK_SCAN: + net_scan = cast(p.NetworkScan, request) + return ( + "network_scan", + legacy.NetworkScan( + channels=[int(c) for c in net_scan.channels], + duration_per_channel_ms=int(net_scan.duration_per_channel_ms), + ).to_dict(), + ) + if command == p.RequestCommand.PACKET_CAPTURE: + channel = int(cast(p.PacketCapture, request).channel) + return "packet_capture", legacy.PacketCapture(channel=channel).to_dict() + if command == p.RequestCommand.PACKET_CAPTURE_CHANNEL: + channel = int(cast(p.PacketCaptureChannel, request).channel) + return ( + "packet_capture_change_channel", + legacy.PacketCaptureChangeChannel(channel=channel).to_dict(), + ) + if command == p.RequestCommand.SEND_UNICAST: + return "send_aps", self._send_unicast_params(cast(p.SendUnicast, request)) + if command == p.RequestCommand.SEND_BROADCAST: + return "send_aps", self._send_broadcast_params( + cast(p.SendBroadcast, request) + ) + if command == p.RequestCommand.SEND_GROUPCAST: + return "send_aps", self._send_groupcast_params( + cast(p.SendGroupcast, request) + ) + raise ValueError(f"Cannot transcode {command!r} to JSON") + + def _send_unicast_params(self, request: p.SendUnicast) -> dict[str, Any]: + destination = ( + None if request.destination == t.NWK(0xFFFE) else t.NWK(request.destination) + ) + return legacy.SendAps( + delivery_mode="unicast", + destination_eui64=request.destination_eui64 if request.has_eui64 else None, + destination=destination, + profile_id=int(request.profile_id), + cluster_id=int(request.cluster_id), + src_ep=int(request.src_ep), + dst_ep=int(request.dst_ep), + aps_ack=bool(request.aps_ack), + aps_seq=int(request.aps_seq), + radius=int(request.radius), + aps_encryption=bool(request.aps_encryption), + priority=int(request.priority), + data=bytes(request.asdu), + ).to_dict() + + def _send_broadcast_params(self, request: p.SendBroadcast) -> dict[str, Any]: + return legacy.SendAps( + delivery_mode="broadcast", + destination_eui64=None, + destination=t.NWK(request.destination), + profile_id=int(request.profile_id), + cluster_id=int(request.cluster_id), + src_ep=int(request.src_ep), + dst_ep=int(request.dst_ep), + aps_ack=False, + aps_seq=int(request.aps_seq), + radius=int(request.radius), + aps_encryption=False, + priority=int(request.priority), + data=bytes(request.asdu), + ).to_dict() + + def _send_groupcast_params(self, request: p.SendGroupcast) -> dict[str, Any]: + # The legacy server carried the group id in `destination` for a multicast. + return legacy.SendAps( + delivery_mode="multicast", + destination_eui64=None, + destination=t.NWK(request.group_id), + profile_id=int(request.profile_id), + cluster_id=int(request.cluster_id), + src_ep=int(request.src_ep), + dst_ep=0, + aps_ack=False, + aps_seq=int(request.aps_seq), + radius=int(request.radius), + aps_encryption=False, + priority=int(request.priority), + data=bytes(request.asdu), + ).to_dict() + + def _configure_params( + self, configure: p.Configure, keys: list[p.KeyEntry] + ) -> dict[str, Any]: + state = configure.state + seed = bytes(state.tclk_seed).hex() if state.has_tclk_seed else None + flavor = None + if state.has_tclk_seed: + flavor = "zstack" if state.tclk_flavor == p.TclkFlavorId.Z_STACK else "ezsp" + return legacy.Configure( + channel=int(state.channel), + nwk_update_id=int(state.nwk_update_id), + pan_id=state.pan_id, + extended_pan_id=state.extended_pan_id, + nwk_address=state.nwk_address, + ieee_address=state.ieee_address, + network_key=state.network_key, + network_key_seq=int(state.network_key_seq), + network_key_tx_counter=int(state.network_key_tx_counter), + tc_link_key=state.tc_link_key, + source_routing=bool(configure.source_routing), + tx_power=int(state.tx_power), + key_table=[ + legacy.KeyTableEntry(partner_ieee=k.partner_ieee, key=k.key) + for k in keys + ], + tclk_seed=seed, + tclk_flavor=flavor, + aps_frame_counter=int(state.aps_frame_counter), + ).to_dict() + + # -- inbound: JSON message -> binary frame ------------------------------------- + + def _handle_message(self, msg: aiohttp.WSMessage) -> None: + if msg.type != aiohttp.WSMsgType.TEXT: + return + message = json.loads(msg.data) + kind = message["type"] + if kind == "response": + self._handle_response(message) + elif kind == "event": + self._handle_event(message) + elif kind == "notification": + self._handle_notification(message) + + def _handle_response(self, message: dict[str, Any]) -> None: + request_id = message["id"] + if request_id not in self._pending_commands: + return + command = self._pending_commands.pop(request_id) + + if "error" in message: + error = message["error"] + code = error["code"] + # A JSON code with no binary status (a host-side failure the firmware + # can't produce) degrades to a generic invalid-request. + status = _STATUS_BY_CODE.get(code, p.Status.INVALID_REQUEST) + # The binary protocol carries only the status; the diagnostic text + # becomes a log line, like the binary server's own warnings. + _LOGGER.warning( + "Legacy server error for %r (id=%d): %s: %s", + command, + request_id, + code, + error["message"], + ) + self._emit(p.FrameType.RESPONSE, command, request_id, bytes([status])) + elif command == p.RequestCommand.GET_FIRMWARE_INFO: + # Transcoded to a JSON `ping`, which has no result: fabricate the payload. + self._emit_ok( + command, + request_id, + p.FirmwareInfo( + protocol_version=t.uint8_t(p.PROTOCOL_VERSION), + version=t.LongCharacterString("ziggurat/legacy"), + ), + ) + elif command == p.RequestCommand.GET_NETWORK_INFO: + self._emit_ok(command, request_id, self._network_info(message["result"])) + elif command == p.RequestCommand.GET_HW_ADDRESS: + hw = legacy.HwAddress.from_dict(message["result"]) + self._emit_ok(command, request_id, p.HwAddress(ieee=hw.ieee_address)) + else: + self._emit_ok(command, request_id) + + def _handle_event(self, message: dict[str, Any]) -> None: + request_id = message["id"] + event = message["event"] + if event == "transmitted": + # The legacy send handoff, delivered as a bare event; the binary protocol + # models it as a `send_confirm` notification keyed by request id. + self._emit_notification( + p.NotificationCommand.SEND_CONFIRM, + request_id, + p.SendConfirm(status=p.SendStatus.SUCCESS), + ) + return + if event == "energy_result": + result = legacy.EnergyScanResult.from_dict(message["data"]) + payload: p.Response = p.EnergyResult( + channel=t.uint8_t(result.channel), rssi=t.int8s(result.rssi) + ) + command = p.RequestCommand.ENERGY_SCAN + elif event == "network_found": + payload = self._beacon(message["data"]) + command = p.RequestCommand.NETWORK_SCAN + elif event == "captured_packet": + packet = legacy.CapturedPacketEvent.from_dict(message["data"]) + payload = p.CapturedPacket( + channel=t.uint8_t(packet.channel), + rssi=t.int8s(packet.rssi), + lqi=t.uint8_t(packet.lqi), + psdu=t.LongOctetString(bytes.fromhex(packet.data)), + ) + command = p.RequestCommand.PACKET_CAPTURE + else: + # `accepted` and any other bare event have no binary equivalent. + return + self._emit(p.FrameType.EVENT, command, request_id, payload.serialize()) + + def _handle_notification(self, message: dict[str, Any]) -> None: + event = message["event"] + data = message["data"] + if event == "log": + self._handle_log(data) + elif event == "send_confirm": + self._emit_notification( + p.NotificationCommand.SEND_CONFIRM, data["id"], self._send_confirm(data) + ) + elif event == "aps_ack_confirm": + self._emit_notification( + p.NotificationCommand.APS_ACK_CONFIRM, + data["id"], + self._aps_ack_confirm(data), + ) + elif event == "received_aps_command": + self._emit_notification( + p.NotificationCommand.RECEIVED_APS, 0, self._received_aps(data) + ) + elif event == "frame_counter_update": + counter = legacy.FrameCounterUpdate.from_dict(data) + self._emit_notification( + p.NotificationCommand.FRAME_COUNTER, + 0, + p.FrameCounter(frame_counter=t.uint32_t(counter.frame_counter)), + ) + elif event == "link_key_update": + link = legacy.LinkKeyUpdate.from_dict(data) + self._emit_notification( + p.NotificationCommand.LINK_KEY, + 0, + p.LinkKey(ieee=link.ieee, key=link.key), + ) + elif event == "device_joined": + joined = legacy.DeviceJoined.from_dict(data) + self._emit_notification( + p.NotificationCommand.DEVICE_JOINED, + 0, + p.DeviceJoined( + nwk=joined.nwk, + ieee=joined.ieee, + parent=joined.parent, + # The legacy JSON protocol carries no capability information + rx_on_when_idle=t.uint1_t(1), + device_type=p.ChildDeviceType.UNKNOWN, + reserved=t.uint5_t(0), + ), + ) + elif event == "device_left": + self._emit_notification( + p.NotificationCommand.DEVICE_LEFT, 0, self._device_left(data) + ) + elif event == "aps_decryption_failure": + self._emit_notification( + p.NotificationCommand.APS_DECRYPT_FAILURE, + 0, + self._aps_decrypt_failure(data), + ) + + def _handle_log(self, data: dict[str, Any]) -> None: + level = _RUST_LOG_LEVELS.get(data["level"], logging.INFO) + logger = logging.getLogger("ziggurat.fw." + data["target"].replace("::", ".")) + logger.log(level, "%s", data["message"]) + + # -- inbound payload builders -------------------------------------------------- + + def _network_info(self, result: dict[str, Any]) -> p.NetworkInfo: + info = legacy.NetworkInfo.from_dict(result) + self._scan_keys = [ + p.KeyEntry( + key=entry.key, + tx_counter=t.uint32_t(0), + rx_counter=t.uint32_t(0), + seq=t.uint8_t(0), + partner_ieee=entry.partner_ieee, + ) + for entry in info.key_table + ] + seed = info.tclk_seed + state = p.NetworkState( + channel=t.uint8_t(info.channel), + nwk_update_id=t.uint8_t(info.nwk_update_id), + pan_id=info.pan_id, + extended_pan_id=info.extended_pan_id, + nwk_address=info.nwk_address, + ieee_address=info.ieee_address, + network_key=info.network_key, + network_key_seq=t.uint8_t(info.network_key_seq), + network_key_tx_counter=t.uint32_t(info.network_key_tx_counter), + tc_link_key=info.tc_link_key, + has_tclk_seed=t.Bool(seed is not None), + tclk_seed=t.KeyData(bytes.fromhex(seed) if seed is not None else bytes(16)), + tclk_flavor=( + p.TclkFlavorId.Z_STACK + if info.tclk_flavor == "zstack" + else p.TclkFlavorId.EZSP + ), + tx_power=t.int8s(info.tx_power), + aps_frame_counter=t.uint32_t(info.aps_frame_counter), + ) + return p.NetworkInfo( + state=state, + key_count=t.uint16_t(len(info.key_table)), + started=t.Bool(info.started), + ) + + def _beacon(self, data: dict[str, Any]) -> p.Beacon: + beacon = legacy.NetworkBeaconEvent.from_dict(data) + return p.Beacon( + channel=t.uint8_t(beacon.channel), + source=beacon.source if beacon.source is not None else t.NWK(0xFFFF), + pan_id=beacon.pan_id, + extended_pan_id=beacon.extended_pan_id, + permit_joining=t.uint1_t(beacon.permit_joining), + router_capacity=t.uint1_t(beacon.router_capacity), + end_device_capacity=t.uint1_t(beacon.end_device_capacity), + reserved=t.uint5_t(0), + stack_profile=t.uint8_t(beacon.stack_profile), + protocol_version=t.uint8_t(beacon.protocol_version), + device_depth=t.uint8_t(beacon.device_depth), + update_id=t.uint8_t(beacon.update_id), + lqi=t.uint8_t(beacon.lqi), + rssi=t.int8s(beacon.rssi), + ) + + def _send_confirm(self, data: dict[str, Any]) -> p.SendConfirm: + return p.SendConfirm( + # The legacy JSON protocol carries no failure kind; a transmit failure is + # the least-wrong stand-in. + status=( + p.SendStatus.SUCCESS + if data["status"] == "confirmed" + else p.SendStatus.TRANSMIT_FAILED + ), + ) + + def _aps_ack_confirm(self, data: dict[str, Any]) -> p.ApsAckConfirm: + return p.ApsAckConfirm( + status=( + p.SendStatus.SUCCESS + if data["status"] == "confirmed" + else p.SendStatus.APS_ACK_TIMEOUT + ), + ) + + def _received_aps(self, data: dict[str, Any]) -> p.ReceivedAps: + received = legacy.ReceivedApsCommand.from_dict(data) + return p.ReceivedAps( + source=received.source, + destination=received.destination, + has_group=t.Bool(received.group is not None), + group=t.uint16_t(received.group or 0), + profile_id=t.uint16_t(received.profile_id), + cluster_id=t.uint16_t(received.cluster_id), + src_ep=t.uint8_t(received.src_ep), + dst_ep=t.uint8_t(received.dst_ep), + lqi=t.uint8_t(received.lqi), + rssi=t.int8s(received.rssi), + data=t.LongOctetString(received.data), + ) + + def _device_left(self, data: dict[str, Any]) -> p.DeviceLeft: + left = legacy.DeviceLeft.from_dict(data) + return p.DeviceLeft( + nwk=left.nwk, + has_ieee=t.uint1_t(left.ieee is not None), + rejoin=t.uint1_t(bool(left.rejoin)), + has_router_ieee=t.uint1_t(left.router_ieee is not None), + reserved=t.uint5_t(0), + ieee=left.ieee if left.ieee is not None else t.EUI64([0] * 8), + reason=_LEAVE_REASONS[left.reason], + router=left.router if left.router is not None else t.NWK(0xFFFF), + router_ieee=( + left.router_ieee if left.router_ieee is not None else t.EUI64([0] * 8) + ), + ) + + def _aps_decrypt_failure(self, data: dict[str, Any]) -> p.ApsDecryptFailure: + failure = legacy.ApsDecryptionFailure.from_dict(data) + key_id = p.KeyId.NETWORK + name = failure.key_id.upper() + if name in p.KeyId.__members__: + key_id = p.KeyId[name] + return p.ApsDecryptFailure( + source=failure.source, + source_ieee=failure.source_ieee, + frame_counter=t.uint32_t(failure.frame_counter), + key_id=key_id, + ) + + # -- frame emission ------------------------------------------------------------ + + def _emit( + self, + frame_type: p.FrameType, + command: p.RequestCommand | p.NotificationCommand, + request_id: int, + body: bytes = b"", + ) -> None: + self._on_frame(p.encode_reply(frame_type, command, request_id, body)) + + def _emit_ok( + self, + command: p.RequestCommand, + request_id: int, + payload: p.Response | None = None, + ) -> None: + body = bytes([p.Status.OK]) + ( + payload.serialize() if payload is not None else b"" + ) + self._emit(p.FrameType.RESPONSE, command, request_id, body) + + def _emit_notification( + self, command: p.NotificationCommand, request_id: int, payload: p.Notification + ) -> None: + self._emit(p.FrameType.NOTIFICATION, command, request_id, payload.serialize()) diff --git a/zigpy_ziggurat/zigbee/wire.py b/zigpy_ziggurat/zigbee/wire.py new file mode 100644 index 0000000..6b45221 --- /dev/null +++ b/zigpy_ziggurat/zigbee/wire.py @@ -0,0 +1,474 @@ +"""Autogenerated from Ziggurat's `wire.rs`. Do not edit.""" + +from __future__ import annotations + +from typing import Self, cast + +import zigpy.types as t + + +class DeliveryMode(t.enum2): + UNICAST = 0 + BROADCAST = 2 + MULTICAST = 3 + + +PROTOCOL_VERSION = 2 + + +class RequestCommand(t.enum8): + RESET = 0x00 + GET_FIRMWARE_INFO = 0x01 + GET_HW_ADDRESS = 0x02 + SHUTDOWN = 0x03 + CONFIGURE = 0x10 + LOAD_KEY_TABLE = 0x11 + LOAD_CHILDREN = 0x12 + LOAD_ADDRESS_CACHE = 0x13 + LOAD_ROUTE_TABLE = 0x14 + LOAD_SOURCE_ROUTES = 0x15 + START_NETWORK = 0x16 + GET_NETWORK_INFO = 0x20 + SCAN_KEY_TABLE = 0x21 + SCAN_CHILDREN = 0x22 + SCAN_ADDRESS_CACHE = 0x23 + SCAN_ROUTE_TABLE = 0x24 + SEND_UNICAST = 0x30 + SEND_BROADCAST = 0x31 + SEND_GROUPCAST = 0x32 + CANCEL_REQUEST = 0x33 + PERMIT_JOINS = 0x34 + SET_CHANNEL = 0x35 + SET_NWK_UPDATE_ID = 0x36 + SET_PROVISIONAL_KEY = 0x37 + SET_TUNABLE = 0x38 + ENERGY_SCAN = 0x40 + NETWORK_SCAN = 0x41 + PACKET_CAPTURE = 0x42 + PACKET_CAPTURE_CHANNEL = 0x43 + + +class NotificationCommand(t.enum8): + HELLO = 0x00 + LAST_RESET = 0x01 + RECEIVED_APS = 0x10 + SEND_CONFIRM = 0x11 + APS_ACK_CONFIRM = 0x12 + BROADCAST_CONFIRM = 0x13 + DEVICE_JOINED = 0x20 + DEVICE_LEFT = 0x21 + FRAME_COUNTER = 0x30 + APS_FRAME_COUNTER = 0x31 + LINK_KEY = 0x32 + APS_DECRYPT_FAILURE = 0x33 + ROUTE_RECORD = 0x34 + + +class FrameType(t.enum2): + REQUEST = 0 + RESPONSE = 1 + EVENT = 2 + NOTIFICATION = 3 + + +class Status(t.enum8): + OK = 0x00 + MALFORMED_PAYLOAD = 0x01 + UNKNOWN_COMMAND = 0x02 + INVALID_REQUEST = 0x03 + NOT_CONFIGURED = 0x10 + NOT_STARTED = 0x11 + ALREADY_STARTED = 0x12 + RATE_LIMITED = 0x20 + BUDGET_EXHAUSTED = 0x21 + PAYLOAD_TOO_LONG = 0x22 + SECURITY_UNAVAILABLE = 0x23 + NO_ROUTE = 0x24 + RADIO_ERROR = 0x30 + NETWORK_START_FAILED = 0x31 + SCAN_FAILED = 0x32 + RESPONSE_TOO_LARGE = 0x40 + + +class NodeRole(t.enum8): + COORDINATOR = 0 + ROUTER = 1 + + +class TclkFlavorId(t.enum8): + Z_STACK = 0 + EZSP = 1 + + +class ChildDeviceType(t.enum2): + UNKNOWN = 0 + ROUTER = 1 + END_DEVICE = 2 + + +class KeyId(t.enum8): + DATA = 0 + NETWORK = 1 + KEY_TRANSPORT = 2 + KEY_LOAD = 3 + + +class LeaveReason(t.enum8): + ANNOUNCED = 0 + ROUTER_REPORTED = 1 + KEEPALIVE_TIMEOUT = 2 + + +# The 3-byte header leading every frame in both directions: the command byte, then +# a little-endian u16 equal to `request_id << 2 | frame_type`. +class Header(t.Struct): + command: t.uint8_t + frame_type: FrameType + request_id: t.uint16_t + + def serialize(self) -> bytes: + word = t.uint16_t((self.request_id << 2) | self.frame_type) + return self.command.serialize() + word.serialize() + + @classmethod + def deserialize(cls, data: bytes) -> tuple[Self, bytes]: + command, data = t.uint8_t.deserialize(data) + word, data = t.uint16_t.deserialize(data) + return ( + cls( + command=command, + frame_type=FrameType(word & 0b11), + request_id=t.uint16_t(word >> 2), + ), + data, + ) + + +class ResetPayload(t.Struct): + hard: t.Bool + + +class FirmwareInfoPayload(t.Struct): + protocol_version: t.uint8_t + version: t.LongCharacterString + + +class HwAddressPayload(t.Struct): + ieee: t.EUI64 + + +class NetworkState(t.Struct): + channel: t.uint8_t + nwk_update_id: t.uint8_t + pan_id: t.PanId + extended_pan_id: t.ExtendedPanId + nwk_address: t.NWK + ieee_address: t.EUI64 + network_key: t.KeyData + network_key_seq: t.uint8_t + network_key_tx_counter: t.uint32_t + tc_link_key: t.KeyData + has_tclk_seed: t.Bool + tclk_seed: t.KeyData + tclk_flavor: TclkFlavorId + tx_power: t.int8s + aps_frame_counter: t.uint32_t + + +class ConfigurePayload(t.Struct): + role: NodeRole + source_routing: t.Bool + state: NetworkState + + +class KeyEntry(t.Struct): + key: t.KeyData + tx_counter: t.uint32_t + rx_counter: t.uint32_t + seq: t.uint8_t + partner_ieee: t.EUI64 + + +class LoadKeyTablePayload(t.Struct): + entries: t.LVList[KeyEntry, t.uint16_t] + + +class ChildEntry(t.Struct): + ieee: t.EUI64 + nwk: t.NWK + rx_on_when_idle: t.uint1_t + device_type: ChildDeviceType + reserved: t.uint5_t + + +class LoadChildrenPayload(t.Struct): + entries: t.LVList[ChildEntry, t.uint16_t] + + +class AddressEntry(t.Struct): + ieee: t.EUI64 + nwk: t.NWK + + +class LoadAddressCachePayload(t.Struct): + entries: t.LVList[AddressEntry, t.uint16_t] + + +class RouteEntry(t.Struct): + destination: t.NWK + next_hop: t.NWK + path_cost: t.uint8_t + + +class LoadRouteTablePayload(t.Struct): + entries: t.LVList[RouteEntry, t.uint16_t] + + +class SourceRouteEntry(t.Struct): + destination: t.NWK + relays: t.LVList[t.NWK, t.uint8_t] + + +class LoadSourceRoutesPayload(t.Struct): + entries: t.LVList[SourceRouteEntry, t.uint16_t] + + +class NetworkInfoPayload(t.Struct): + state: NetworkState + key_count: t.uint16_t + started: t.Bool + + +class ScanCountPayload(t.Struct): + count: t.uint16_t + + +class RouteControl(t.enum8): + STACK_DECIDES = 0 + HINT_NEXT_HOP = 1 + FORCE_NEXT_HOP = 2 + HINT_SOURCE_ROUTE = 3 + FORCE_SOURCE_ROUTE = 4 + + +class SourceRouteRelays(t.Struct): + relays: t.LVList[t.NWK, t.uint8_t] + + +class SendUnicastPayload(t.Struct): + has_eui64: t.uint1_t + aps_ack: t.uint1_t + aps_encryption: t.uint1_t + sleepy_destination: t.uint1_t + reserved: t.uint4_t + destination: t.NWK + destination_eui64: t.EUI64 + profile_id: t.uint16_t + cluster_id: t.uint16_t + src_ep: t.uint8_t + dst_ep: t.uint8_t + aps_seq: t.uint8_t + radius: t.uint8_t + priority: t.int8s + route: RouteControl + next_hop: t.NWK = t.StructField( # type: ignore[assignment] + requires=lambda s: cast(SendUnicastPayload, s).route + in (RouteControl.HINT_NEXT_HOP, RouteControl.FORCE_NEXT_HOP) + ) + relays: SourceRouteRelays = t.StructField( # type: ignore[assignment] + requires=lambda s: cast(SendUnicastPayload, s).route + in (RouteControl.HINT_SOURCE_ROUTE, RouteControl.FORCE_SOURCE_ROUTE) + ) + asdu: t.LongOctetString + + +class SendBroadcastPayload(t.Struct): + reserved: t.uint8_t + destination: t.NWK + profile_id: t.uint16_t + cluster_id: t.uint16_t + src_ep: t.uint8_t + dst_ep: t.uint8_t + aps_seq: t.uint8_t + radius: t.uint8_t + priority: t.int8s + asdu: t.LongOctetString + + +class SendGroupcastPayload(t.Struct): + reserved: t.uint8_t + group_id: t.uint16_t + profile_id: t.uint16_t + cluster_id: t.uint16_t + src_ep: t.uint8_t + aps_seq: t.uint8_t + radius: t.uint8_t + priority: t.int8s + asdu: t.LongOctetString + + +class CancelRequestPayload(t.Struct): + request_id: t.uint16_t + + +class CancelResultPayload(t.Struct): + cancelled: t.Bool + + +class PermitJoinsPayload(t.Struct): + duration: t.uint16_t + accept_direct_joins: t.Bool + + +class ChannelPayload(t.Struct): + channel: t.uint8_t + + +class NwkUpdateIdPayload(t.Struct): + nwk_update_id: t.uint8_t + + +class ProvisionalKeyPayload(t.Struct): + ieee: t.EUI64 + key: t.KeyData + + +class SetTunablePayload(t.Struct): + name: t.LVBytes + value: t.uint64_t + + +class ScanRequestPayload(t.Struct): + channels: t.LVList[t.uint8_t, t.uint16_t] + duration_per_channel_ms: t.uint16_t + + +class EnergyResultPayload(t.Struct): + channel: t.uint8_t + rssi: t.int8s + + +class BeaconPayload(t.Struct): + channel: t.uint8_t + source: t.NWK + pan_id: t.PanId + extended_pan_id: t.ExtendedPanId + permit_joining: t.uint1_t + router_capacity: t.uint1_t + end_device_capacity: t.uint1_t + reserved: t.uint5_t + stack_profile: t.uint8_t + protocol_version: t.uint8_t + device_depth: t.uint8_t + update_id: t.uint8_t + lqi: t.uint8_t + rssi: t.int8s + + +class CapturedPacketPayload(t.Struct): + channel: t.uint8_t + rssi: t.int8s + lqi: t.uint8_t + psdu: t.LongOctetString + + +class RateLimitedPayload(t.Struct): + status: Status + retry_in_ms: t.uint32_t + + +class HelloPayload(t.Struct): + protocol_version: t.uint8_t + configured: t.Bool + + +class LastResetPayload(t.Struct): + message: t.LongCharacterString + + +class ReceivedApsPayload(t.Struct): + source: t.NWK + destination: t.NWK + has_group: t.Bool + group: t.uint16_t + profile_id: t.uint16_t + cluster_id: t.uint16_t + src_ep: t.uint8_t + dst_ep: t.uint8_t + lqi: t.uint8_t + rssi: t.int8s + data: t.LongOctetString + + +class SendStatus(t.enum8): + SUCCESS = 0 + ROUTE_DISCOVERY_TIMEOUT = 1 + ROUTE_DISCOVERY_NO_ENTRY = 2 + ROUTE_INACTIVE_AFTER_DISCOVERY = 3 + NWK_NO_ACK = 4 + CCA_FAILURE = 5 + TRANSMIT_FAILED = 6 + APS_ACK_TIMEOUT = 7 + BROADCAST_QUORUM_NOT_REACHED = 8 + INDIRECT_EXPIRED = 9 + FRAME_BUDGET_EXHAUSTED = 10 + CANCELLED = 11 + RADIO_ERROR = 12 + + +class SendConfirmPayload(t.Struct): + status: SendStatus + + +class ApsAckConfirmPayload(t.Struct): + status: SendStatus + + +class BroadcastConfirmPayload(t.Struct): + status: SendStatus + + +class DeviceJoinedPayload(t.Struct): + nwk: t.NWK + ieee: t.EUI64 + parent: t.NWK + rx_on_when_idle: t.uint1_t + device_type: ChildDeviceType + reserved: t.uint5_t + + +class DeviceLeftPayload(t.Struct): + nwk: t.NWK + has_ieee: t.uint1_t + rejoin: t.uint1_t + has_router_ieee: t.uint1_t + reserved: t.uint5_t + ieee: t.EUI64 + reason: LeaveReason + router: t.NWK + router_ieee: t.EUI64 + + +class FrameCounterPayload(t.Struct): + frame_counter: t.uint32_t + + +class LinkKeyPayload(t.Struct): + ieee: t.EUI64 + key: t.KeyData + + +class RouteRecordPayload(t.Struct): + destination: t.NWK + relays: t.LVList[t.NWK, t.uint8_t] + + +class ApsFrameCounterPayload(t.Struct): + frame_counter: t.uint32_t + + +class ApsDecryptFailPayload(t.Struct): + source: t.NWK + source_ieee: t.EUI64 + frame_counter: t.uint32_t + key_id: KeyId