diff --git a/bellows/exception.py b/bellows/exception.py index 21f2e6d8..2def7a3c 100644 --- a/bellows/exception.py +++ b/bellows/exception.py @@ -9,6 +9,10 @@ class InvalidCommandError(EzspError): pass +class PayloadTooLongError(InvalidCommandError): + pass + + class InvalidCommandPayload(InvalidCommandError): def __init__(self, msg: str, raw_bytes: bytes) -> None: super().__init__(msg) diff --git a/bellows/ezsp/__init__.py b/bellows/ezsp/__init__.py index 1ef25f56..3aaf0c13 100644 --- a/bellows/ezsp/__init__.py +++ b/bellows/ezsp/__init__.py @@ -17,10 +17,16 @@ from bellows.ash import NcpFailure import bellows.config as conf -from bellows.exception import EzspError, InvalidCommandError, InvalidCommandPayload +from bellows.exception import ( + EzspError, + InvalidCommandError, + InvalidCommandPayload, + PayloadTooLongError, +) from bellows.ezsp import xncp from bellows.ezsp.config import DEFAULT_CONFIG, RuntimeConfig, ValueConfig from bellows.ezsp.xncp import ( + MAX_XNCP_PAYLOAD_LENGTH, FirmwareFeatures, FlowControlType, GetRouteTableEntryRsp, @@ -785,6 +791,46 @@ async def xncp_set_manual_source_route( ) ) + def xncp_prepare_unicast( + self, + destination: t.NWK, + aps_frame: t.EmberApsFrame, + message_tag: t.uint8_t, + data: bytes, + *, + source_route: list[t.NWK] | None = None, + extended_timeout: tuple[t.EUI64, bool] | None = None, + ) -> xncp.SendUnicastReq: + """Prepare a combined unicast command.""" + flags = xncp.SendUnicastFlags.NONE + req = xncp.SendUnicastReq( + flags=flags, + destination=destination, + aps_frame=aps_frame, + message_tag=message_tag, + data=xncp.Bytes(data), + ) + + if extended_timeout is not None: + req.flags |= xncp.SendUnicastFlags.EXTENDED_TIMEOUT + req.ieee, req.extended_timeout = extended_timeout + + if source_route is not None: + req.flags |= xncp.SendUnicastFlags.SOURCE_ROUTE + req.source_route = source_route + + if len(req.serialize()) > MAX_XNCP_PAYLOAD_LENGTH: + raise PayloadTooLongError() + + return req + + async def xncp_send_unicast( + self, request: xncp.SendUnicastReq + ) -> tuple[t.sl_Status, t.uint8_t]: + """Send a combined unicast command.""" + rsp = await self.send_xncp_frame(request) + return rsp.status, rsp.sequence + async def xncp_get_mfg_token_override(self, token: t.EzspMfgTokenId) -> bytes: """Get manufacturing token override.""" rsp = await self.send_xncp_frame(xncp.GetMfgTokenOverrideReq(token=token)) diff --git a/bellows/ezsp/xncp.py b/bellows/ezsp/xncp.py index 2cfc50d4..aa1eb2b4 100644 --- a/bellows/ezsp/xncp.py +++ b/bellows/ezsp/xncp.py @@ -7,10 +7,19 @@ import zigpy.types as t -from bellows.types import EmberStatus, EzspMfgTokenId, RouteRecordStatus +from bellows.types import ( + EmberApsFrame, + EmberStatus, + EzspMfgTokenId, + RouteRecordStatus, + sl_Status, +) _LOGGER = logging.getLogger(__name__) +MAX_XNCP_FRAME_LENGTH = 119 +MAX_XNCP_PAYLOAD_LENGTH = MAX_XNCP_FRAME_LENGTH - 3 # u16 + u8 + COMMANDS: dict[XncpCommandId, type[XncpCommandPayload]] = {} REV_COMMANDS: dict[type[XncpCommandPayload], XncpCommandId] = {} @@ -43,6 +52,7 @@ class XncpCommandId(t.enum16): SET_ROUTE_TABLE_ENTRY_REQ = 0x0006 GET_ROUTE_TABLE_ENTRY_REQ = 0x0007 GET_TX_POWER_INFO_REQ = 0x0008 + SEND_UNICAST_REQ = 0x0009 GET_SUPPORTED_FEATURES_RSP = GET_SUPPORTED_FEATURES_REQ | 0x8000 SET_SOURCE_ROUTE_RSP = SET_SOURCE_ROUTE_REQ | 0x8000 @@ -53,6 +63,7 @@ class XncpCommandId(t.enum16): SET_ROUTE_TABLE_ENTRY_RSP = SET_ROUTE_TABLE_ENTRY_REQ | 0x8000 GET_ROUTE_TABLE_ENTRY_RSP = GET_ROUTE_TABLE_ENTRY_REQ | 0x8000 GET_TX_POWER_INFO_RSP = GET_TX_POWER_INFO_REQ | 0x8000 + SEND_UNICAST_RSP = SEND_UNICAST_REQ | 0x8000 UNKNOWN = 0xFFFF @@ -123,6 +134,10 @@ class FirmwareFeatures(t.bitmap32): # Recommended and maximum TX power can be queried by country code TX_POWER_INFO = 1 << 7 + # A unicast can be sent, its source route and extended timeout applied, in a + # single command + COMBINED_SEND = 1 << 8 + class XncpCommandPayload(t.Struct): pass @@ -233,6 +248,43 @@ class GetTxPowerInfoRsp(XncpCommandPayload): max_power_dbm: t.int8s +class SendUnicastFlags(t.bitmap8): + NONE = 0 + + # The extended timeout for the destination should be set before sending + EXTENDED_TIMEOUT = 1 << 0 + + # A manual source route should be installed before sending + SOURCE_ROUTE = 1 << 1 + + +@register_command(XncpCommandId.SEND_UNICAST_REQ) +class SendUnicastReq(XncpCommandPayload): + flags: SendUnicastFlags + destination: t.NWK + aps_frame: EmberApsFrame + message_tag: t.uint8_t + + ieee: t.EUI64 = t.StructField( + requires=lambda s: SendUnicastFlags.EXTENDED_TIMEOUT in s.flags + ) + extended_timeout: t.Bool = t.StructField( + requires=lambda s: SendUnicastFlags.EXTENDED_TIMEOUT in s.flags + ) + + source_route: t.LVList[t.NWK, t.uint8_t] = t.StructField( + requires=lambda s: SendUnicastFlags.SOURCE_ROUTE in s.flags + ) + + data: Bytes + + +@register_command(XncpCommandId.SEND_UNICAST_RSP) +class SendUnicastRsp(XncpCommandPayload): + status: sl_Status + sequence: t.uint8_t + + @register_command(XncpCommandId.UNKNOWN) class Unknown(XncpCommandPayload): pass diff --git a/bellows/zigbee/application.py b/bellows/zigbee/application.py index bee1ca54..d1bba5c0 100644 --- a/bellows/zigbee/application.py +++ b/bellows/zigbee/application.py @@ -36,6 +36,7 @@ ControllerError, EzspError, InvalidCommandError, + PayloadTooLongError, StackAlreadyRunning, ) import bellows.ezsp @@ -1028,45 +1029,85 @@ async def send_packet(self, packet: zigpy.types.ZigbeePacket) -> None: try: async with self._req_lock: - if packet.dst.addr_mode == zigpy.types.AddrMode.NWK: - if device is not None: - await self._ezsp.set_extended_timeout( - nwk=device.nwk, - ieee=device.ieee, - extended_timeout=extended_timeout, - ) + data = packet.data.serialize() - if packet.source_route is not None: - if ( + if packet.dst.addr_mode == zigpy.types.AddrMode.NWK: + # Manual source routes are installed through XNCP; native + # source routes cannot be folded into the combined command + use_manual_source_route = ( + packet.source_route is not None + and ( FirmwareFeatures.MANUAL_SOURCE_ROUTE in self._ezsp._xncp_features - and self.config[CONF_BELLOWS_CONFIG][ - CONF_MANUAL_SOURCE_ROUTING - ] - ): - await self._ezsp.xncp_set_manual_source_route( + ) + and self.config[CONF_BELLOWS_CONFIG][ + CONF_MANUAL_SOURCE_ROUTING + ] + ) + + # XNCP combined unicasts can fail in a few ways so it's simpler + # to prepare the request in advance + xncp_unicast = None + + if ( + FirmwareFeatures.COMBINED_SEND in self._ezsp._xncp_features + and (packet.source_route is None or use_manual_source_route) + ): + try: + xncp_unicast = self._ezsp.xncp_prepare_unicast( destination=packet.dst.address, - route=packet.source_route, + aps_frame=aps_frame, + message_tag=message_tag, + data=data, + source_route=( + packet.source_route + if use_manual_source_route + else None + ), + extended_timeout=( + (device.ieee, extended_timeout) + if device is not None + else None + ), ) - else: - await self._ezsp.set_source_route( - nwk=packet.dst.address, - relays=packet.source_route, + except PayloadTooLongError: + xncp_unicast = None + + if xncp_unicast is not None: + status, _ = await self._ezsp.xncp_send_unicast(xncp_unicast) + else: + if device is not None: + await self._ezsp.set_extended_timeout( + nwk=device.nwk, + ieee=device.ieee, + extended_timeout=extended_timeout, ) - status, _ = await self._ezsp.send_unicast( - nwk=packet.dst.address, - aps_frame=aps_frame, - message_tag=message_tag, - data=packet.data.serialize(), - ) + if packet.source_route is not None: + if use_manual_source_route: + await self._ezsp.xncp_set_manual_source_route( + destination=packet.dst.address, + route=packet.source_route, + ) + else: + await self._ezsp.set_source_route( + nwk=packet.dst.address, + relays=packet.source_route, + ) + + status, _ = await self._ezsp.send_unicast( + nwk=packet.dst.address, + aps_frame=aps_frame, + message_tag=message_tag, + data=data, + ) elif packet.dst.addr_mode == zigpy.types.AddrMode.Group: status, _ = await self._ezsp.send_multicast( aps_frame=aps_frame, radius=packet.radius, non_member_radius=packet.non_member_radius, message_tag=message_tag, - data=packet.data.serialize(), + data=data, ) elif packet.dst.addr_mode == zigpy.types.AddrMode.Broadcast: status, _ = await self._ezsp.send_broadcast( @@ -1075,7 +1116,7 @@ async def send_packet(self, packet: zigpy.types.ZigbeePacket) -> None: radius=packet.radius, message_tag=message_tag, aps_sequence=packet.tsn, - data=packet.data.serialize(), + data=data, ) if status != t.sl_Status.OK: diff --git a/tests/test_application.py b/tests/test_application.py index c5355d2e..3e643b06 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -18,11 +18,14 @@ import bellows.ezsp as ezsp from bellows.ezsp.v9.commands import GetTokenDataRsp from bellows.ezsp.xncp import ( + MAX_XNCP_PAYLOAD_LENGTH, FirmwareFeatures, FlowControlType, GetChipInfoRsp, GetRouteTableEntryRsp, GetTxPowerInfoRsp, + SendUnicastFlags, + SendUnicastReq, ) import bellows.types import bellows.types as t @@ -45,6 +48,28 @@ zigpy.config.CONF_STARTUP_ENERGY_SCAN: False, } +MSG_TAG = t.uint8_t(0x42) + +# Every `SendUnicastReq` field but `data` is fixed-size, so the largest unicast payload +# that still fits in a single combined send command is a simple subtraction +MAX_COMBINED_SEND_DATA_LENGTH = MAX_XNCP_PAYLOAD_LENGTH - len( + SendUnicastReq( + flags=SendUnicastFlags.NONE, + destination=t.EmberNodeId(0x0000), + aps_frame=t.EmberApsFrame( + profileId=0x0000, + clusterId=0x0000, + sourceEndpoint=0x00, + destinationEndpoint=0x00, + options=t.EmberApsOption(0), + groupId=0x0000, + sequence=0x00, + ), + message_tag=MSG_TAG, + data=b"", + ).serialize() +) + @pytest.fixture def ieee(init=0): @@ -798,7 +823,7 @@ def send_unicast(*args, **kwargs): type=t.EmberOutgoingMessageType.OUTGOING_DIRECT, indexOrDestination=0x1234, apsFrame=sentinel.aps, - messageTag=sentinel.msg_tag, + messageTag=MSG_TAG, status=sent_handler_status, message=b"", ).values() @@ -810,7 +835,7 @@ def send_unicast(*args, **kwargs): app._ezsp.send_unicast = AsyncMock( side_effect=send_unicast, spec=app._ezsp.send_unicast ) - app.get_sequence = MagicMock(return_value=sentinel.msg_tag) + app.get_sequence = MagicMock(return_value=MSG_TAG) await app.send_packet(packet) @@ -826,8 +851,8 @@ def send_unicast(*args, **kwargs): groupId=0x0000, sequence=packet.tsn, ), - message_tag=sentinel.msg_tag, - data=b"some data", + message_tag=MSG_TAG, + data=packet.data.serialize(), ) ] @@ -918,6 +943,168 @@ async def test_send_packet_unicast_manual_source_route(make_app, packet): ) +async def _test_send_packet_unicast_combined( + app, + packet, + *, + status=bellows.types.sl_Status.OK, + source_route=None, + extended_timeout=None, + options=( + t.EmberApsOption.APS_OPTION_ENABLE_ROUTE_DISCOVERY + | t.EmberApsOption.APS_OPTION_RETRY + ), +): + def xncp_send_unicast(*args, **kwargs): + asyncio.get_running_loop().call_later( + 0.01, + app.ezsp_callback_handler, + "messageSentHandler", + list( + dict( + type=t.EmberOutgoingMessageType.OUTGOING_DIRECT, + indexOrDestination=0x1234, + apsFrame=sentinel.aps, + messageTag=MSG_TAG, + status=bellows.types.sl_Status.OK, + message=b"", + ).values() + ), + ) + + return [status, 0x12] + + app._ezsp.xncp_send_unicast = AsyncMock( + side_effect=xncp_send_unicast, spec=app._ezsp.xncp_send_unicast + ) + app.get_sequence = MagicMock(return_value=MSG_TAG) + + await app.send_packet(packet) + + flags = SendUnicastFlags.NONE + + if extended_timeout is not None: + flags |= SendUnicastFlags.EXTENDED_TIMEOUT + + if source_route is not None: + flags |= SendUnicastFlags.SOURCE_ROUTE + + assert app._ezsp.xncp_send_unicast.mock_calls == [ + call( + SendUnicastReq( + flags=flags, + destination=t.EmberNodeId(0x1234), + aps_frame=t.EmberApsFrame( + profileId=packet.profile_id, + clusterId=packet.cluster_id, + sourceEndpoint=packet.src_ep, + destinationEndpoint=packet.dst_ep, + options=options, + groupId=0x0000, + sequence=packet.tsn, + ), + message_tag=MSG_TAG, + ieee=extended_timeout[0] if extended_timeout is not None else None, + extended_timeout=( + extended_timeout[1] if extended_timeout is not None else None + ), + source_route=source_route, + data=packet.data.serialize(), + ) + ) + ] + + assert len(app._pending_requests) == 0 + + +async def test_send_packet_unicast_combined(app, packet): + app._ezsp._xncp_features |= FirmwareFeatures.COMBINED_SEND + await _test_send_packet_unicast_combined(app, packet) + + +async def test_send_packet_unicast_combined_extended_timeout(app, ieee, packet): + app._ezsp._xncp_features |= FirmwareFeatures.COMBINED_SEND + app.add_device(nwk=packet.dst.address, ieee=ieee) + + # The APS ACK in the packet's tx_options disables the extended timeout + await _test_send_packet_unicast_combined( + app, packet, extended_timeout=(ieee, False) + ) + + +async def test_send_packet_unicast_combined_manual_source_route(make_app, packet): + app = make_app( + { + zigpy.config.CONF_SOURCE_ROUTING: True, + config.CONF_BELLOWS_CONFIG: {config.CONF_MANUAL_SOURCE_ROUTING: True}, + } + ) + app._ezsp._xncp_features |= ( + FirmwareFeatures.COMBINED_SEND | FirmwareFeatures.MANUAL_SOURCE_ROUTE + ) + + packet.source_route = [0x0001, 0x0002] + await _test_send_packet_unicast_combined( + app, + packet, + source_route=[0x0001, 0x0002], + options=( + t.EmberApsOption.APS_OPTION_RETRY + | t.EmberApsOption.APS_OPTION_ENABLE_ADDRESS_DISCOVERY + ), + ) + + +async def test_send_packet_unicast_combined_native_source_route_fallback( + make_app, packet +): + # A native (non-manual) source route cannot be folded into the combined command + app = make_app({zigpy.config.CONF_SOURCE_ROUTING: True}) + app._ezsp._xncp_features |= FirmwareFeatures.COMBINED_SEND + app._ezsp._protocol.set_source_route = AsyncMock( + return_value=t.sl_Status.OK, spec=app._ezsp._protocol.set_source_route + ) + app._ezsp.xncp_send_unicast = AsyncMock(spec=app._ezsp.xncp_send_unicast) + + packet.source_route = [0x0001, 0x0002] + await _test_send_packet_unicast( + app, + packet, + options=( + t.EmberApsOption.APS_OPTION_RETRY + | t.EmberApsOption.APS_OPTION_ENABLE_ADDRESS_DISCOVERY + ), + ) + + assert len(app._ezsp.xncp_send_unicast.mock_calls) == 0 + assert app._ezsp._protocol.set_source_route.mock_calls == [ + call(nwk=packet.dst.address, relays=[0x0001, 0x0002]) + ] + + +async def test_send_packet_unicast_combined_max_size(app, packet): + # The largest payload that fits in a custom frame still uses the combined command + app._ezsp._xncp_features |= FirmwareFeatures.COMBINED_SEND + + packet = packet.replace( + data=zigpy_t.SerializableBytes(b"a" * MAX_COMBINED_SEND_DATA_LENGTH) + ) + await _test_send_packet_unicast_combined(app, packet) + + +async def test_send_packet_unicast_combined_oversized_fallback(app, packet): + # One byte more does not fit and falls back to the multi-command path + app._ezsp._xncp_features |= FirmwareFeatures.COMBINED_SEND + app._ezsp.xncp_send_unicast = AsyncMock(spec=app._ezsp.xncp_send_unicast) + + packet = packet.replace( + data=zigpy_t.SerializableBytes(b"a" * (MAX_COMBINED_SEND_DATA_LENGTH + 1)) + ) + await _test_send_packet_unicast(app, packet) + + assert len(app._ezsp.xncp_send_unicast.mock_calls) == 0 + + async def test_send_packet_unicast_extended_timeout_with_acks(app, ieee, packet): app.add_device(nwk=packet.dst.address, ieee=ieee) @@ -1116,7 +1303,7 @@ async def test_send_packet_broadcast(app, packet): app._ezsp.send_broadcast = AsyncMock( return_value=(bellows.types.named.sl_Status.OK, 0x12) ) - app.get_sequence = MagicMock(return_value=sentinel.msg_tag) + app.get_sequence = MagicMock(return_value=MSG_TAG) asyncio.get_running_loop().call_soon( app.ezsp_callback_handler, @@ -1126,7 +1313,7 @@ async def test_send_packet_broadcast(app, packet): type=t.EmberOutgoingMessageType.OUTGOING_BROADCAST, indexOrDestination=0xFFFE, apsFrame=sentinel.aps, - messageTag=sentinel.msg_tag, + messageTag=MSG_TAG, status=t.EmberStatus.SUCCESS, message=b"", ).values() @@ -1147,7 +1334,7 @@ async def test_send_packet_broadcast(app, packet): sequence=packet.tsn, ), radius=packet.radius, - message_tag=sentinel.msg_tag, + message_tag=MSG_TAG, aps_sequence=packet.tsn, data=b"some data", ) @@ -1162,7 +1349,7 @@ async def test_send_packet_broadcast_ignored_delivery_failure(app, packet): ) packet.radius = 30 - app.get_sequence = MagicMock(return_value=sentinel.msg_tag) + app.get_sequence = MagicMock(return_value=MSG_TAG) asyncio.get_running_loop().call_soon( app.ezsp_callback_handler, @@ -1172,7 +1359,7 @@ async def test_send_packet_broadcast_ignored_delivery_failure(app, packet): type=t.EmberOutgoingMessageType.OUTGOING_BROADCAST, indexOrDestination=0xFFFE, apsFrame=sentinel.aps, - messageTag=sentinel.msg_tag, + messageTag=MSG_TAG, status=t.EmberStatus.DELIVERY_FAILED, message=b"", ).values() @@ -1195,7 +1382,7 @@ async def test_send_packet_broadcast_ignored_delivery_failure(app, packet): sequence=packet.tsn, ), radius=packet.radius, - message_tag=sentinel.msg_tag, + message_tag=MSG_TAG, aps_sequence=packet.tsn, data=b"some data", ) @@ -1215,7 +1402,7 @@ async def test_send_packet_multicast(app, packet): return_value=(bellows.types.sl_Status.OK, 0x12), spec=app._ezsp._protocol.send_multicast, ) - app.get_sequence = MagicMock(return_value=sentinel.msg_tag) + app.get_sequence = MagicMock(return_value=MSG_TAG) asyncio.get_running_loop().call_soon( app.ezsp_callback_handler, @@ -1225,7 +1412,7 @@ async def test_send_packet_multicast(app, packet): type=t.EmberOutgoingMessageType.OUTGOING_MULTICAST, indexOrDestination=0x1234, apsFrame=sentinel.aps, - messageTag=sentinel.msg_tag, + messageTag=MSG_TAG, status=t.EmberStatus.SUCCESS, message=b"", ).values() @@ -1246,7 +1433,7 @@ async def test_send_packet_multicast(app, packet): ), radius=packet.radius, non_member_radius=packet.non_member_radius, - message_tag=sentinel.msg_tag, + message_tag=MSG_TAG, data=b"some data", ) ] diff --git a/tests/test_ezsp.py b/tests/test_ezsp.py index d548309a..e2e26e1d 100644 --- a/tests/test_ezsp.py +++ b/tests/test_ezsp.py @@ -1014,6 +1014,78 @@ def test_frame_parsing_error_doesnt_disconnect(ezsp_f, caplog): assert "Failed to parse frame" in caplog.text +@pytest.mark.parametrize( + "source_route, extended_timeout, expected_flags, expected_extra", + [ + (None, None, xncp.SendUnicastFlags.NONE, {}), + ( + [t.NWK(0x0001), t.NWK(0x0002)], + None, + xncp.SendUnicastFlags.SOURCE_ROUTE, + {"source_route": [t.NWK(0x0001), t.NWK(0x0002)]}, + ), + ( + None, + (t.EUI64.convert("00:11:22:33:44:55:66:77"), True), + xncp.SendUnicastFlags.EXTENDED_TIMEOUT, + { + "ieee": t.EUI64.convert("00:11:22:33:44:55:66:77"), + "extended_timeout": t.Bool.true, + }, + ), + ], +) +async def test_xncp_send_unicast( + ezsp_f, source_route, extended_timeout, expected_flags, expected_extra +): + """Test sending a unicast via the combined XNCP command.""" + ezsp_f._xncp_features = xncp.FirmwareFeatures.COMBINED_SEND + + aps_frame = t.EmberApsFrame( + profileId=0x0104, + clusterId=0x0006, + sourceEndpoint=1, + destinationEndpoint=1, + options=t.EmberApsOption.APS_OPTION_RETRY, + groupId=0x0000, + sequence=0x42, + ) + + with patch.object( + ezsp_f, + "send_xncp_frame", + new=AsyncMock( + return_value=xncp.SendUnicastRsp(status=t.sl_Status.OK, sequence=0x12) + ), + ) as mock_send: + status, sequence = await ezsp_f.xncp_send_unicast( + ezsp_f.xncp_prepare_unicast( + destination=t.NWK(0x1234), + aps_frame=aps_frame, + message_tag=0x07, + data=b"some data", + source_route=source_route, + extended_timeout=extended_timeout, + ) + ) + + assert status == t.sl_Status.OK + assert sequence == 0x12 + + assert mock_send.mock_calls == [ + call( + xncp.SendUnicastReq( + flags=expected_flags, + destination=t.NWK(0x1234), + aps_frame=aps_frame, + message_tag=0x07, + data=xncp.Bytes(b"some data"), + **expected_extra, + ) + ) + ] + + async def test_xncp_get_chip_info(ezsp_f): """Test getting chip info via XNCP.""" ezsp_f._xncp_features = xncp.FirmwareFeatures.CHIP_INFO