diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml index 6d0e183..46d771b 100644 --- a/.github/workflows/publish-to-pypi.yml +++ b/.github/workflows/publish-to-pypi.yml @@ -10,10 +10,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@master - - name: Set up Python 3.7 + - name: Set up Python 3.8 uses: actions/setup-python@v1 with: - version: 3.7 + version: 3.8 - name: Install wheel run: >- pip install wheel diff --git a/setup.py b/setup.py index 1372fbc..313e926 100644 --- a/setup.py +++ b/setup.py @@ -16,7 +16,7 @@ author="Russell Cloran", author_email="rcloran@gmail.com", license="GPL-3.0", - packages=find_packages(exclude=["*.tests"]), - install_requires=["pyserial-asyncio", "zigpy>=0.47.0"], + packages=find_packages(exclude=["tests", "tests.*"]), + install_requires=["zigpy>=0.51.0"], tests_require=["pytest", "asynctest", "pytest-asyncio"], ) diff --git a/tests/test_application.py b/tests/test_application.py index 0baa393..b98ea67 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -217,12 +217,18 @@ async def test_broadcast(app): assert app._api._command.call_args[0][9] == data app._api._command.return_value = xbee_t.TXStatus.ADDRESS_NOT_FOUND - r = await app.broadcast(profile, cluster, src_ep, dst_ep, grpid, radius, tsn, data) - assert r[0] != xbee_t.TXStatus.SUCCESS + + with pytest.raises(zigpy.exceptions.DeliveryError): + r = await app.broadcast( + profile, cluster, src_ep, dst_ep, grpid, radius, tsn, data + ) app._api._command.side_effect = asyncio.TimeoutError - r = await app.broadcast(profile, cluster, src_ep, dst_ep, grpid, radius, tsn, data) - assert r[0] != xbee_t.TXStatus.SUCCESS + + with pytest.raises(zigpy.exceptions.DeliveryError): + r = await app.broadcast( + profile, cluster, src_ep, dst_ep, grpid, radius, tsn, data + ) async def test_get_association_state(app): @@ -242,6 +248,14 @@ async def test_form_network(app): async def mock_at_command(cmd, *args): if cmd == "MY": return 0x0000 + if cmd == "OI": + return 0x1234 + elif cmd == "ID": + return 0x1234567812345678 + elif cmd == "SL": + return 0x11223344 + elif cmd == "SH": + return 0x55667788 elif cmd == "WR": app._api.coordinator_started_event.set() elif cmd == "CE" and legacy_module: @@ -393,25 +407,13 @@ async def test_permit(app): async def _test_request( - app, - expect_reply=True, - send_success=True, - send_timeout=False, - is_end_device=True, - node_desc=True, - **kwargs + app, expect_reply=True, send_success=True, send_timeout=False, **kwargs ): seq = 123 nwk = 0x2345 ieee = t.EUI64(b"\x01\x02\x03\x04\x05\x06\x07\x08") dev = app.add_device(ieee, nwk) - if node_desc: - dev.node_desc = mock.MagicMock() - dev.node_desc.is_end_device = is_end_device - else: - dev.node_desc = None - def _mock_command( cmdname, ieee, nwk, src_ep, dst_ep, cluster, profile, radius, options, data ): @@ -437,42 +439,34 @@ def _mock_command( ) -async def test_request_with_reply(app): - r = await _test_request(app, expect_reply=True, send_success=True) +async def test_request_with_ieee(app): + r = await _test_request(app, use_ieee=True, send_success=True) assert r[0] == 0 -async def test_request_without_node_desc(app): - r = await _test_request(app, expect_reply=True, send_success=True, node_desc=False) +async def test_request_with_reply(app): + r = await _test_request(app, expect_reply=True, send_success=True) assert r[0] == 0 async def test_request_send_timeout(app): - r = await _test_request(app, send_timeout=True) - assert r[0] != 0 + with pytest.raises(zigpy.exceptions.DeliveryError): + await _test_request(app, send_timeout=True) async def test_request_send_fail(app): - r = await _test_request(app, send_success=False) - assert r[0] != 0 + with pytest.raises(zigpy.exceptions.DeliveryError): + await _test_request(app, send_success=False) async def test_request_extended_timeout(app): - is_end_device = False - r = await _test_request(app, True, True, is_end_device=is_end_device) + r = await _test_request(app, True, True, extended_timeout=False) assert r[0] == xbee_t.TXStatus.SUCCESS assert app._api._command.call_count == 1 assert app._api._command.call_args[0][8] & 0x40 == 0x00 app._api._command.reset_mock() - r = await _test_request(app, True, True, node_desc=False) - assert r[0] == xbee_t.TXStatus.SUCCESS - assert app._api._command.call_count == 1 - assert app._api._command.call_args[0][8] & 0x40 == 0x40 - app._api._command.reset_mock() - - is_end_device = True - r = await _test_request(app, True, True, is_end_device=is_end_device) + r = await _test_request(app, True, True, extended_timeout=True) assert r[0] == xbee_t.TXStatus.SUCCESS assert app._api._command.call_count == 1 assert app._api._command.call_args[0][8] & 0x40 == 0x40 @@ -570,10 +564,26 @@ async def test_mrequest_with_reply(app): async def test_mrequest_send_timeout(app): - r = await _test_mrequest(app, send_timeout=True) - assert r[0] != 0 + with pytest.raises(zigpy.exceptions.DeliveryError): + await _test_mrequest(app, send_timeout=True) async def test_mrequest_send_fail(app): - r = await _test_mrequest(app, send_success=False) - assert r[0] != 0 + with pytest.raises(zigpy.exceptions.DeliveryError): + await _test_mrequest(app, send_success=False) + + +async def test_reset_network_info(app): + async def mock_at_command(cmd, *args): + if cmd == "NR": + return 0x00 + + return None + + app._api._at_command = mock.MagicMock( + spec=XBee._at_command, side_effect=mock_at_command + ) + + await app.reset_network_info() + + app._api._at_command.assert_called_once_with("NR", 0) diff --git a/zigpy_xbee/__init__.py b/zigpy_xbee/__init__.py index c54134d..917e6cb 100644 --- a/zigpy_xbee/__init__.py +++ b/zigpy_xbee/__init__.py @@ -1,5 +1,5 @@ MAJOR_VERSION = 0 -MINOR_VERSION = 15 +MINOR_VERSION = 16 PATCH_VERSION = "0" __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" diff --git a/zigpy_xbee/types.py b/zigpy_xbee/types.py index 210aacb..8250239 100644 --- a/zigpy_xbee/types.py +++ b/zigpy_xbee/types.py @@ -239,3 +239,11 @@ class DiscoveryStatus(uint8_t, UndefinedEnum): ADDRESS_AND_ROUTE = 0x03 EXTENDED_TIMEOUT = 0x40 _UNDEFINED = 0x00 + + +class TXOptions(zigpy.types.bitmap8): + NONE = 0x00 + + Disable_Retries_and_Route_Repair = 0x01 + Enable_APS_Encryption = 0x20 + Use_Extended_TX_Timeout = 0x40 diff --git a/zigpy_xbee/uart.py b/zigpy_xbee/uart.py index dcfe359..3f5d854 100644 --- a/zigpy_xbee/uart.py +++ b/zigpy_xbee/uart.py @@ -2,8 +2,7 @@ import logging from typing import Any, Dict -import serial -import serial_asyncio +import zigpy.serial from zigpy_xbee.config import CONF_DEVICE_BAUDRATE, CONF_DEVICE_PATH @@ -169,13 +168,11 @@ async def connect(device_config: Dict[str, Any], api, loop=None) -> Gateway: connected_future = asyncio.Future() protocol = Gateway(api, connected_future) - transport, protocol = await serial_asyncio.create_serial_connection( + transport, protocol = await zigpy.serial.create_serial_connection( loop, lambda: protocol, url=device_config[CONF_DEVICE_PATH], baudrate=device_config[CONF_DEVICE_BAUDRATE], - parity=serial.PARITY_NONE, - stopbits=serial.STOPBITS_ONE, xonxoff=False, ) diff --git a/zigpy_xbee/zigbee/application.py b/zigpy_xbee/zigbee/application.py index 534c118..639ba47 100644 --- a/zigpy_xbee/zigbee/application.py +++ b/zigpy_xbee/zigbee/application.py @@ -1,7 +1,6 @@ from __future__ import annotations import asyncio -import binascii import logging import time from typing import Any @@ -20,7 +19,7 @@ import zigpy_xbee import zigpy_xbee.api from zigpy_xbee.config import CONF_DEVICE, CONFIG_SCHEMA, SCHEMA_DEVICE -from zigpy_xbee.types import EUI64, UNKNOWN_IEEE, UNKNOWN_NWK, TXStatus +from zigpy_xbee.types import EUI64, UNKNOWN_IEEE, UNKNOWN_NWK, TXOptions, TXStatus # how long coordinator would hold message for an end device in 10ms units CONF_CYCLIC_SLEEP_PERIOD = 0x0300 @@ -131,6 +130,9 @@ async def load_network_info(self, *, load_devices=False): ) network_info.channel = await self._api._at_command("CH") + async def reset_network_info(self) -> None: + await self._api._at_command("NR", 0) + async def write_network_info(self, *, network_info, node_info): scan_bitmask = 1 << (network_info.channel - 11) @@ -177,101 +179,55 @@ async def _get_association_state(self): state = await self._api._at_command("AI") return state - async def mrequest( - self, - group_id, - profile, - cluster, - src_ep, - sequence, - data, - *, - hops=0, - non_member_radius=3, - ): - """Submit and send data out as a multicast transmission. - :param group_id: destination multicast address - :param profile: Zigbee Profile ID to use for outgoing message - :param cluster: cluster id where the message is being sent - :param src_ep: source endpoint id - :param sequence: transaction sequence number of the message - :param data: Zigbee message payload - :param hops: the message will be delivered to all nodes within this number of - hops of the sender. A value of zero is converted to MAX_HOPS - :param non_member_radius: the number of hops that the message will be forwarded - by devices that are not members of the group. A value - of 7 or greater is treated as infinite - :returns: return a tuple of a status and an error_message. Original requestor - has more context to provide a more meaningful error message - """ - LOGGER.debug("Zigbee request tsn #%s: %s", sequence, binascii.hexlify(data)) + async def send_packet(self, packet: zigpy.types.ZigbeePacket) -> None: + LOGGER.debug("Sending packet %r", packet) - send_req = self._api.tx_explicit( - UNKNOWN_IEEE, group_id, src_ep, src_ep, cluster, profile, hops, 0x08, data - ) + tx_opts = TXOptions.NONE - try: - v = await asyncio.wait_for(send_req, timeout=TIMEOUT_TX_STATUS) - except asyncio.TimeoutError: - return TXStatus.NETWORK_ACK_FAILURE, "Timeout waiting for ACK" + if packet.extended_timeout: + tx_opts |= TXOptions.Use_Extended_TX_Timeout + + if packet.dst.addr_mode == zigpy.types.AddrMode.Group: + tx_opts |= 0x08 # where did this come from? + + long_addr = UNKNOWN_IEEE + short_addr = UNKNOWN_NWK + + if packet.dst.addr_mode == zigpy.types.AddrMode.IEEE: + long_addr = packet.dst.address + elif packet.dst.addr_mode == zigpy.types.AddrMode.Broadcast: + long_addr = EUI64( + [ + zigpy.types.uint8_t(b) + for b in packet.dst.address.to_bytes(8, "little") + ] + ) + else: + short_addr = packet.dst.address - if v != TXStatus.SUCCESS: - return v, f"Error sending tsn #{sequence}: {v.name}" - return v, f"Successfully sent tsn #{sequence}: {v.name}" - - async def request( - self, - device, - profile, - cluster, - src_ep, - dst_ep, - sequence, - data, - expect_reply=True, - use_ieee=False, - ): - """Submit and send data out as an unicast transmission. - - :param device: destination device - :param profile: Zigbee Profile ID to use for outgoing message - :param cluster: cluster id where the message is being sent - :param src_ep: source endpoint id - :param dst_ep: destination endpoint id - :param sequence: transaction sequence number of the message - :param data: Zigbee message payload - :param expect_reply: True if this is essentially a request - :param use_ieee: use EUI64 for destination addressing - :returns: return a tuple of a status and an error_message. Original requestor - has more context to provide a more meaningful error message - """ - LOGGER.debug("Zigbee request tsn #%s: %s", sequence, binascii.hexlify(data)) - - tx_opts = 0x00 - if expect_reply and ( - device.node_desc is None or device.node_desc.is_end_device - ): - tx_opts |= 0x40 send_req = self._api.tx_explicit( - device.ieee, - UNKNOWN_NWK if use_ieee else device.nwk, - src_ep, - dst_ep, - cluster, - profile, - 0, + long_addr, + short_addr, + packet.src_ep, + packet.dst_ep, + packet.cluster_id, + packet.profile_id, + packet.radius, tx_opts, - data, + packet.data.serialize(), ) try: v = await asyncio.wait_for(send_req, timeout=TIMEOUT_TX_STATUS) except asyncio.TimeoutError: - return TXStatus.NETWORK_ACK_FAILURE, "Timeout waiting for ACK" + raise zigpy.exceptions.DeliveryError( + "Timeout waiting for ACK", status=TXStatus.NETWORK_ACK_FAILURE + ) if v != TXStatus.SUCCESS: - return v, f"Error sending tsn #{sequence}: {v.name}" - return v, f"Succesfuly sent tsn #{sequence}: {v.name}" + raise zigpy.exceptions.DeliveryError( + f"Failed to deliver packet: {v!r}", status=v + ) @zigpy.util.retryable_request def remote_at_command( @@ -344,57 +300,6 @@ def handle_rx( self.handle_message(device, profile_id, cluster_id, src_ep, dst_ep, data) - async def broadcast( - self, - profile, - cluster, - src_ep, - dst_ep, - grpid, - radius, - sequence, - data, - broadcast_address=zigpy.types.BroadcastAddress.RX_ON_WHEN_IDLE, - ): - """Submit and send data out as an broadcast transmission. - - :param profile: Zigbee Profile ID to use for outgoing message - :param cluster: cluster id where the message is being sent - :param src_ep: source endpoint id - :param dst_ep: destination endpoint id - :param grpid: group id to address the broadcast to - :param radius: max radius of the broadcast - :param sequence: transaction sequence number of the message - :param data: zigbee message payload - :param broadcast_address: broadcast address. - :returns: return a tuple of a status and an error_message. Original requestor - has more context to provide a more meaningful error message - """ - - LOGGER.debug("Broadcast request seq %s", sequence) - broadcast_as_bytes = [ - zigpy.types.uint8_t(b) for b in broadcast_address.to_bytes(8, "little") - ] - request = self._api.tx_explicit( - EUI64(broadcast_as_bytes), - broadcast_address, - src_ep, - dst_ep, - cluster, - profile, - radius, - 0x00, - data, - ) - try: - v = await asyncio.wait_for(request, timeout=TIMEOUT_TX_STATUS) - except asyncio.TimeoutError: - return TXStatus.NETWORK_ACK_FAILURE, "Timeout waiting for ACK" - - if v != TXStatus.SUCCESS: - return v, f"Error sending broadcast tsn #{sequence}: {v.name}" - return v, f"Succesfuly sent broadcast tsn #{sequence}: {v.name}" - class XBeeCoordinator(zigpy.quirks.CustomDevice): class XBeeGroup(zigpy.quirks.CustomCluster, Groups):