diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f06da5b..5403040 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,7 @@ on: env: CACHE_VERSION: 1 - DEFAULT_PYTHON: 3.7 + DEFAULT_PYTHON: 3.8 PRE_COMMIT_HOME: ~/.cache/pre-commit jobs: @@ -18,7 +18,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.7, 3.8, 3.9] + python-version: [3.8, 3.9, "3.10"] steps: - name: Check out code from GitHub uses: actions/checkout@v2 @@ -35,7 +35,7 @@ jobs: key: >- ${{ env.CACHE_VERSION}}-${{ runner.os }}-base-venv-${{ steps.python.outputs.python-version }}-${{ - hashFiles('requirements_test.txt') }} + hashFiles('requirements_test.txt', 'setup.py') }} restore-keys: | ${{ env.CACHE_VERSION}}-${{ runner.os }}-base-venv-${{ steps.python.outputs.python-version }}- - name: Create Python virtual environment @@ -67,7 +67,7 @@ jobs: key: >- ${{ env.CACHE_VERSION}}-${{ runner.os }}-base-venv-${{ steps.python.outputs.python-version }}-${{ - hashFiles('requirements_test.txt') }} + hashFiles('requirements_test.txt', 'setup.py') }} - name: Fail job if Python cache restore failed if: steps.cache-venv.outputs.cache-hit != 'true' run: | @@ -108,7 +108,7 @@ jobs: key: >- ${{ env.CACHE_VERSION}}-${{ runner.os }}-base-venv-${{ steps.python.outputs.python-version }}-${{ - hashFiles('requirements_test.txt') }} + hashFiles('requirements_test.txt', 'setup.py') }} - name: Fail job if Python cache restore failed if: steps.cache-venv.outputs.cache-hit != 'true' run: | @@ -151,7 +151,7 @@ jobs: key: >- ${{ env.CACHE_VERSION}}-${{ runner.os }}-base-venv-${{ steps.python.outputs.python-version }}-${{ - hashFiles('requirements_test.txt') }} + hashFiles('requirements_test.txt', 'setup.py') }} - name: Fail job if Python cache restore failed if: steps.cache-venv.outputs.cache-hit != 'true' run: | @@ -197,7 +197,7 @@ jobs: key: >- ${{ env.CACHE_VERSION}}-${{ runner.os }}-base-venv-${{ steps.python.outputs.python-version }}-${{ - hashFiles('requirements_test.txt') }} + hashFiles('requirements_test.txt', 'setup.py') }} - name: Fail job if Python cache restore failed if: steps.cache-venv.outputs.cache-hit != 'true' run: | @@ -240,7 +240,7 @@ jobs: key: >- ${{ env.CACHE_VERSION}}-${{ runner.os }}-base-venv-${{ steps.python.outputs.python-version }}-${{ - hashFiles('requirements_test.txt') }} + hashFiles('requirements_test.txt', 'setup.py') }} - name: Fail job if Python cache restore failed if: steps.cache-venv.outputs.cache-hit != 'true' run: | @@ -271,7 +271,7 @@ jobs: needs: prepare-base strategy: matrix: - python-version: [3.7, 3.8, 3.9] + python-version: [3.8, 3.9, "3.10"] name: >- Run tests Python ${{ matrix.python-version }} steps: @@ -290,7 +290,7 @@ jobs: key: >- ${{ env.CACHE_VERSION}}-${{ runner.os }}-base-venv-${{ steps.python.outputs.python-version }}-${{ - hashFiles('requirements_test.txt') }} + hashFiles('requirements_test.txt', 'setup.py') }} - name: Fail job if Python cache restore failed if: steps.cache-venv.outputs.cache-hit != 'true' run: | @@ -353,7 +353,7 @@ jobs: key: >- ${{ env.CACHE_VERSION}}-${{ runner.os }}-base-venv-${{ steps.python.outputs.python-version }}-${{ - hashFiles('requirements_test.txt') }} + hashFiles('requirements_test.txt', 'setup.py') }} - name: Fail job if Python cache restore failed if: steps.cache-venv.outputs.cache-hit != 'true' run: | diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml index f4715f2..ede535a 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 7cdb769..f4a3ff4 100644 --- a/setup.py +++ b/setup.py @@ -21,6 +21,6 @@ author_email="schmidt.d@aon.at", license="GPL-3.0", packages=find_packages(exclude=["tests"]), - install_requires=["pyserial-asyncio", "zigpy>=0.47.0"], - tests_require=["pytest", "pytest-asyncio>=0.17", "asynctest"], + install_requires=["zigpy>=0.51.0"], + tests_require=["pytest", "asynctest"], ) diff --git a/tests/test_api.py b/tests/test_api.py index fe41071..2263d71 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -149,7 +149,7 @@ def _fake_args(arg_type): return list(arg_type)[0] # Pick the first enum value elif issubclass(arg_type, t.DeconzAddressEndpoint): addr = t.DeconzAddressEndpoint() - addr.address_mode = t.ADDRESS_MODE.NWK + addr.address_mode = t.AddressMode.NWK addr.address = t.uint8_t(0) addr.endpoint = t.uint8_t(0) return addr @@ -242,11 +242,34 @@ async def test_aps_data_confirm(api, monkeypatch): success = True - def mock_cmd(*args, **kwargs): - res = asyncio.Future() - if success: - res.set_result([7, 0x22, 0x11, sentinel.dst_addr, 1, 0x00, 0, 0, 0, 0]) - return asyncio.wait_for(res, timeout=deconz_api.COMMAND_TIMEOUT) + async def mock_cmd(*args, **kwargs): + if not success: + raise asyncio.TimeoutError() + + dst = t.DeconzAddressEndpoint() + dst.address_mode = t.AddressMode.NWK + dst.address = 0x26FF + dst.endpoint = 1 + + rsp = [ + 12, + ( + deconz_api.DeviceState.APSDE_DATA_REQUEST_SLOTS_AVAILABLE + | deconz_api.DeviceState.APSDE_DATA_INDICATION + | deconz_api.DeviceState.APSDE_DATA_CONFIRM + | 2 + ), + 98, + dst, + 1, + deconz_api.TXStatus.SUCCESS, + 0, + 0, + 0, + 0, + ] + api._handle_aps_data_confirm(rsp) + return rsp api._command = mock_cmd api._data_confirm = True @@ -586,3 +609,53 @@ async def test_connection_lost(api): api.connection_lost(err) app.connection_lost.assert_called_once_with(err) + + +async def test_aps_data_indication(api): + dst = t.DeconzAddress() + dst.address_mode = t.AddressMode.NWK + dst.address = 0x0000 + + src = t.DeconzAddress() + src.address_mode = t.AddressMode.NWK + src.address = 0xC643 + + data = b"\x18\x1f\x01\x04\x00\x00B\x12Third Reality, Inc\x05\x00\x00B\t3RSP019BZ" + + packet = [ + 63, + (deconz_api.DeviceState.APSDE_DATA_REQUEST_SLOTS_AVAILABLE | 2), + dst, + 1, + src, + 1, + 260, + 0, + data, + 0, + 175, + 255, + 186, + 25, + 78, + 3, + -47, + ] + + api._handle_aps_data_indication(packet) + + api._app.handle_rx.assert_called_once_with( + src=src, + src_ep=1, + dst=dst, + dst_ep=1, + profile_id=260, + cluster_id=0x0000, + data=data, + lqi=255, + rssi=-47, + ) + + # No error is thrown when the app is disconnected + api._app = None + api._handle_aps_data_indication(packet) diff --git a/tests/test_application.py b/tests/test_application.py index 1d7b4a4..d4ebfeb 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -12,7 +12,6 @@ from zigpy_deconz import types as t import zigpy_deconz.api as deconz_api -from zigpy_deconz.config import CONF_DECONZ_CONFIG, CONF_MAX_CONCURRENT_REQUESTS import zigpy_deconz.exception import zigpy_deconz.zigbee.application as application @@ -25,7 +24,6 @@ zigpy.config.CONF_NWK_UPDATE_ID: 22, zigpy.config.CONF_NWK_KEY: [0xAA] * 16, }, - CONF_DECONZ_CONFIG: {CONF_MAX_CONCURRENT_REQUESTS: 20}, } @@ -91,7 +89,7 @@ def nwk(): @pytest.fixture def addr_ieee(ieee): addr = t.DeconzAddress() - addr.address_mode = t.ADDRESS_MODE.IEEE + addr.address_mode = t.AddressMode.IEEE addr.address = ieee return addr @@ -99,7 +97,7 @@ def addr_ieee(ieee): @pytest.fixture def addr_nwk(nwk): addr = t.DeconzAddress() - addr.address_mode = t.ADDRESS_MODE.NWK + addr.address_mode = t.AddressMode.NWK addr.address = nwk return addr @@ -107,121 +105,12 @@ def addr_nwk(nwk): @pytest.fixture def addr_nwk_and_ieee(nwk, ieee): addr = t.DeconzAddress() - addr.address_mode = t.ADDRESS_MODE.NWK_AND_IEEE + addr.address_mode = t.AddressMode.NWK_AND_IEEE addr.address = nwk addr.ieee = ieee return addr -def _test_rx(app, addr_ieee, addr_nwk, device, data): - app.get_device = MagicMock(return_value=device) - app.devices = (EUI64(addr_ieee.address),) - - app.handle_rx( - addr_nwk, - sentinel.src_ep, - sentinel.dst_ep, - sentinel.profile_id, - sentinel.cluster_id, - data, - sentinel.lqi, - sentinel.rssi, - ) - - -def test_rx(app, addr_ieee, addr_nwk): - device = MagicMock() - app.handle_message = MagicMock() - _test_rx(app, addr_ieee, addr_nwk, device, sentinel.args) - assert app.handle_message.call_count == 1 - assert app.handle_message.call_args == ( - ( - device, - sentinel.profile_id, - sentinel.cluster_id, - sentinel.src_ep, - sentinel.dst_ep, - sentinel.args, - ), - ) - - -def test_rx_ieee(app, addr_ieee, addr_nwk): - device = MagicMock() - app.handle_message = MagicMock() - _test_rx(app, addr_ieee, addr_ieee, device, sentinel.args) - assert app.handle_message.call_count == 1 - assert app.handle_message.call_args == ( - ( - device, - sentinel.profile_id, - sentinel.cluster_id, - sentinel.src_ep, - sentinel.dst_ep, - sentinel.args, - ), - ) - - -def test_rx_nwk_ieee(app, addr_ieee, addr_nwk_and_ieee): - device = MagicMock() - app.handle_message = MagicMock() - _test_rx(app, addr_ieee, addr_nwk_and_ieee, device, sentinel.args) - assert app.handle_message.call_count == 1 - assert app.handle_message.call_args == ( - ( - device, - sentinel.profile_id, - sentinel.cluster_id, - sentinel.src_ep, - sentinel.dst_ep, - sentinel.args, - ), - ) - - -def test_rx_wrong_addr_mode(app, addr_ieee, addr_nwk, caplog): - device = MagicMock() - app.handle_message = MagicMock() - app.get_device = MagicMock(return_value=device) - - app.devices = (EUI64(addr_ieee.address),) - - with pytest.raises(Exception): # TODO: don't use broad exceptions - addr_nwk.address_mode = 0x22 - app.handle_rx( - addr_nwk, - sentinel.src_ep, - sentinel.dst_ep, - sentinel.profile_id, - sentinel.cluster_id, - b"", - sentinel.lqi, - sentinel.rssi, - ) - - assert app.handle_message.call_count == 0 - - -def test_rx_unknown_device(app, addr_ieee, addr_nwk, caplog): - app.handle_message = MagicMock() - - caplog.set_level(logging.DEBUG) - app.handle_rx( - addr_nwk, - sentinel.src_ep, - sentinel.dst_ep, - sentinel.profile_id, - sentinel.cluster_id, - b"", - sentinel.lqi, - sentinel.rssi, - ) - - assert "Received frame from unknown device" in caplog.text - assert app.handle_message.call_count == 0 - - @pytest.mark.parametrize( "proto_ver, nwk_state, error", [ @@ -273,128 +162,6 @@ async def test_permit(app, nwk): assert app._api.write_parameter.call_args_list[0][0][1] == time_s -async def _test_request(app, *, send_success=True, aps_data_error=False, **kwargs): - seq = 123 - - async def req_mock( - req_id, - dst_addr_ep, - profile, - cluster, - src_ep, - data, - *, - relays=None, - tx_options=t.DeconzTransmitOptions.USE_NWK_KEY_SECURITY, - radius=0 - ): - if aps_data_error: - raise zigpy_deconz.exception.CommandError(1, "Command Error") - if send_success: - app._pending[req_id].result.set_result(0) - else: - app._pending[req_id].result.set_result(1) - - app._api.aps_data_request = MagicMock(side_effect=req_mock) - app._api.protocol_version = 0 - device = zigpy.device.Device(app, sentinel.ieee, 0x1122) - app.get_device = MagicMock(return_value=device) - - return await app.request(device, 0x0260, 1, 2, 3, seq, b"\x01\x02\x03", **kwargs) - - -async def test_request_send_success(app): - req_id = sentinel.req_id - app.get_sequence = MagicMock(return_value=req_id) - r = await _test_request(app, send_success=True) - assert r[0] == 0 - - r = await _test_request(app, send_success=True, use_ieee=True) - assert r[0] == 0 - - -async def test_request_send_fail(app): - req_id = sentinel.req_id - app.get_sequence = MagicMock(return_value=req_id) - r = await _test_request(app, send_success=False) - assert r[0] != 0 - - -async def test_request_send_aps_data_error(app): - req_id = sentinel.req_id - app.get_sequence = MagicMock(return_value=req_id) - r = await _test_request(app, send_success=False, aps_data_error=True) - assert r[0] != 0 - - -async def _test_broadcast(app, send_success=True, aps_data_error=False, **kwargs): - seq = sentinel.req_id - - async def req_mock(req_id, dst_addr_ep, profile, cluster, src_ep, data): - if aps_data_error: - raise zigpy_deconz.exception.CommandError(1, "Command Error") - if send_success: - app._pending[req_id].result.set_result(0) - else: - app._pending[req_id].result.set_result(1) - - app._api.aps_data_request = MagicMock(side_effect=req_mock) - app.get_device = MagicMock(spec_set=zigpy.device.Device) - - r = await app.broadcast( - sentinel.profile, - sentinel.cluster, - 2, - sentinel.dst_ep, - sentinel.grp_id, - sentinel.radius, - seq, - b"\x01\x02\x03", - **kwargs - ) - assert app._api.aps_data_request.call_count == 1 - assert app._api.aps_data_request.call_args[0][0] is seq - assert app._api.aps_data_request.call_args[0][2] is sentinel.profile - assert app._api.aps_data_request.call_args[0][3] is sentinel.cluster - assert app._api.aps_data_request.call_args[0][5] == b"\x01\x02\x03" - return r - - -async def test_broadcast_send_success(app): - req_id = sentinel.req_id - app.get_sequence = MagicMock(return_value=req_id) - r = await _test_broadcast(app, True) - assert r[0] == 0 - - -async def test_broadcast_send_fail(app): - req_id = sentinel.req_id - app.get_sequence = MagicMock(return_value=req_id) - r = await _test_broadcast(app, False) - assert r[0] != 0 - - -async def test_broadcast_send_aps_data_error(app): - req_id = sentinel.req_id - app.get_sequence = MagicMock(return_value=req_id) - r = await _test_broadcast(app, False, aps_data_error=True) - assert r[0] != 0 - - -def _handle_reply(app, tsn): - app.handle_message = MagicMock() - return app._handle_reply( - sentinel.device, - sentinel.profile, - sentinel.cluster, - sentinel.src_ep, - sentinel.dst_ep, - tsn, - sentinel.command_id, - sentinel.args, - ) - - async def test_connect(app): def new_api(*args): api = MagicMock() @@ -443,40 +210,6 @@ async def test_permit_with_key_not_implemented(app): await app.permit_with_key(node=MagicMock(), code=b"abcdef") -def test_rx_device_annce(app, addr_ieee, addr_nwk): - dst_ep = 0 - cluster_id = zdo_t.ZDOCmd.Device_annce - device = MagicMock() - device.status = zigpy.device.Status.NEW - app.get_device = MagicMock(return_value=device) - - app.handle_join = MagicMock() - app._handle_reply = MagicMock() - app.handle_message = MagicMock() - - data = t.uint8_t(0xAA).serialize() - data += addr_nwk.address.serialize() - data += addr_ieee.address.serialize() - data += t.uint8_t(0x8E).serialize() - - app.handle_rx( - addr_nwk, - sentinel.src_ep, - dst_ep, - sentinel.profile_id, - cluster_id, - data, - sentinel.lqi, - sentinel.rssi, - ) - - assert app.handle_message.call_count == 1 - assert app.handle_join.call_count == 1 - assert app.handle_join.call_args[0][0] == addr_nwk.address - assert app.handle_join.call_args[0][1] == addr_ieee.address - assert app.handle_join.call_args[0][2] == 0 - - async def test_deconz_dev_add_to_group(app, nwk, device_path): group = MagicMock() app._groups = MagicMock() @@ -582,41 +315,6 @@ def test_tx_confirm_unexpcted(app, caplog): assert "Unexpected transmit confirm for request id" in caplog.text -async def _test_mrequest(app, send_success=True, aps_data_error=False, **kwargs): - seq = 123 - req_id = sentinel.req_id - app.get_sequence = MagicMock(return_value=req_id) - - async def req_mock(req_id, dst_addr_ep, profile, cluster, src_ep, data): - if aps_data_error: - raise zigpy_deconz.exception.CommandError(1, "Command Error") - if send_success: - app._pending[req_id].result.set_result(0) - else: - app._pending[req_id].result.set_result(1) - - app._api.aps_data_request = MagicMock(side_effect=req_mock) - device = zigpy.device.Device(app, sentinel.ieee, 0x1122) - app.get_device = MagicMock(return_value=device) - - return await app.mrequest(0x55AA, 0x0260, 1, 2, seq, b"\x01\x02\x03", **kwargs) - - -async def test_mrequest_send_success(app): - r = await _test_mrequest(app, True) - assert r[0] == 0 - - -async def test_mrequest_send_fail(app): - r = await _test_mrequest(app, False) - assert r[0] != 0 - - -async def test_mrequest_send_aps_data_error(app): - r = await _test_mrequest(app, False, aps_data_error=True) - assert r[0] != 0 - - async def test_reset_watchdog(app): """Test watchdog.""" with patch.object(app._api, "write_parameter") as mock_api: @@ -714,48 +412,6 @@ async def test_delayed_scan(): assert coord.neighbors.scan.await_count == 1 -async def test_request_concurrency(app): - """Test the request concurrency limit.""" - max_concurrency = 0 - num_concurrent = 0 - - async def req_mock( - req_id, - dst_addr_ep, - profile, - cluster, - src_ep, - data, - *, - relays=None, - tx_options=t.DeconzTransmitOptions.USE_NWK_KEY_SECURITY, - radius=0 - ): - nonlocal num_concurrent, max_concurrency - - num_concurrent += 1 - max_concurrency = max(num_concurrent, max_concurrency) - - try: - await asyncio.sleep(0.01) - app._pending[req_id].result.set_result(0) - finally: - num_concurrent -= 1 - - app._api.aps_data_request = MagicMock(side_effect=req_mock) - app._api.protocol_version = 0 - device = zigpy.device.Device(app, sentinel.ieee, 0x1122) - app.get_device = MagicMock(return_value=device) - - requests = [ - app.request(device, 0x0260, 1, 2, 3, seq, b"\x01\x02\x03") for seq in range(100) - ] - - await asyncio.gather(*requests) - - assert max_concurrency == 20 - - @patch("zigpy_deconz.zigbee.application.CHANGE_NETWORK_WAIT", 0.001) @pytest.mark.parametrize("support_watchdog", [False, True]) async def test_change_network_state(app, support_watchdog): @@ -905,3 +561,10 @@ async def test_disconnect_during_reconnect(app): await app.disconnect() assert app._reconnect_task is None + + +async def test_reset_network_info(app): + app.form_network = AsyncMock() + await app.reset_network_info() + + app.form_network.assert_called_once() diff --git a/tests/test_send_receive.py b/tests/test_send_receive.py new file mode 100644 index 0000000..67d90c4 --- /dev/null +++ b/tests/test_send_receive.py @@ -0,0 +1,240 @@ +"""Test sending and receiving Zigbee packets using the zigpy packet API.""" + +import asyncio +import contextlib +from unittest.mock import Mock, patch + +import pytest +import zigpy.exceptions +import zigpy.types as zigpy_t + +from zigpy_deconz.api import TXStatus +import zigpy_deconz.exception +import zigpy_deconz.types as t + +from tests.test_application import api, app, device_path # noqa: F401 + + +@contextlib.contextmanager +def patch_data_request(app, *, fail_enqueue=False, fail_deliver=False): # noqa: F811 + with patch.object(app._api, "aps_data_request") as mock_request: + + async def mock_send(req_id, *args, **kwargs): + await asyncio.sleep(0) + + if fail_enqueue: + raise zigpy_deconz.exception.CommandError("Error") + + if fail_deliver: + app.handle_tx_confirm( + req_id, TXStatus(int(zigpy_t.APSStatus.APS_NO_ACK)) + ) + else: + app.handle_tx_confirm(req_id, TXStatus.SUCCESS) + + mock_request.side_effect = mock_send + + yield mock_request + + +@pytest.fixture +def tx_packet(): + return zigpy_t.ZigbeePacket( + src=zigpy_t.AddrModeAddress(addr_mode=zigpy_t.AddrMode.NWK, address=0x0000), + src_ep=0x12, + dst=zigpy_t.AddrModeAddress(addr_mode=zigpy_t.AddrMode.NWK, address=0x1234), + dst_ep=0x34, + tsn=0x56, + profile_id=0x7890, + cluster_id=0xABCD, + data=zigpy_t.SerializableBytes(b"some data"), + tx_options=zigpy_t.TransmitOptions.ACK, + radius=0, + ) + + +async def test_send_packet_nwk(app, tx_packet): # noqa: F811 + with patch_data_request(app) as mock_req: + await app.send_packet(tx_packet) + + assert len(mock_req.mock_calls) == 1 + req = mock_req.mock_calls[0].kwargs + + assert req["dst_addr_ep"].address_mode == t.AddressMode.NWK + assert req["dst_addr_ep"].address == tx_packet.dst.address + assert req["dst_addr_ep"].endpoint == tx_packet.dst_ep + assert req["profile"] == tx_packet.profile_id + assert req["cluster"] == tx_packet.cluster_id + assert req["aps_payload"] == tx_packet.data.serialize() + assert req["tx_options"] == ( + t.DeconzTransmitOptions.USE_NWK_KEY_SECURITY + | t.DeconzTransmitOptions.USE_APS_ACKS + ) + assert req["relays"] is None + assert req["radius"] == 0 + + +async def test_send_packet_nwk_no_ack(app, tx_packet): # noqa: F811 + tx_packet.tx_options &= ~zigpy_t.TransmitOptions.ACK + + with patch_data_request(app) as mock_req: + await app.send_packet(tx_packet) + + assert len(mock_req.mock_calls) == 1 + req = mock_req.mock_calls[0].kwargs + + assert req["dst_addr_ep"].address_mode == t.AddressMode.NWK + assert req["dst_addr_ep"].address == tx_packet.dst.address + assert req["dst_addr_ep"].endpoint == tx_packet.dst_ep + assert req["profile"] == tx_packet.profile_id + assert req["cluster"] == tx_packet.cluster_id + assert req["aps_payload"] == tx_packet.data.serialize() + assert req["tx_options"] == t.DeconzTransmitOptions.USE_NWK_KEY_SECURITY + assert req["relays"] is None + assert req["radius"] == 0 + + +async def test_send_packet_source_route(app, tx_packet): # noqa: F811 + tx_packet.source_route = [0xAABB, 0xCCDD] + + with patch_data_request(app) as mock_req: + await app.send_packet(tx_packet) + + assert len(mock_req.mock_calls) == 1 + req = mock_req.mock_calls[0].kwargs + + assert req["dst_addr_ep"].address_mode == t.AddressMode.NWK + assert req["dst_addr_ep"].address == tx_packet.dst.address + assert req["dst_addr_ep"].endpoint == tx_packet.dst_ep + assert req["profile"] == tx_packet.profile_id + assert req["cluster"] == tx_packet.cluster_id + assert req["aps_payload"] == tx_packet.data.serialize() + assert req["tx_options"] == ( + t.DeconzTransmitOptions.USE_NWK_KEY_SECURITY + | t.DeconzTransmitOptions.USE_APS_ACKS + ) + assert req["relays"] == [0xAABB, 0xCCDD] + assert req["radius"] == 0 + + +async def test_send_packet_ieee(app, tx_packet): # noqa: F811 + tx_packet.dst = zigpy_t.AddrModeAddress( + addr_mode=zigpy_t.AddrMode.IEEE, + address=zigpy_t.EUI64.convert("aa:bb:cc:dd:11:22:33:44"), + ) + + with patch_data_request(app) as mock_req: + await app.send_packet(tx_packet) + + assert len(mock_req.mock_calls) == 1 + req = mock_req.mock_calls[0].kwargs + + assert req["dst_addr_ep"].address_mode == t.AddressMode.IEEE + assert req["dst_addr_ep"].address == tx_packet.dst.address + assert req["dst_addr_ep"].endpoint == tx_packet.dst_ep + assert req["profile"] == tx_packet.profile_id + assert req["cluster"] == tx_packet.cluster_id + assert req["aps_payload"] == tx_packet.data.serialize() + assert req["tx_options"] == ( + t.DeconzTransmitOptions.USE_NWK_KEY_SECURITY + | t.DeconzTransmitOptions.USE_APS_ACKS + ) + assert req["relays"] is None + assert req["radius"] == 0 + + +async def test_send_packet_group(app, tx_packet): # noqa: F811 + tx_packet.dst = zigpy_t.AddrModeAddress( + addr_mode=zigpy_t.AddrMode.Group, address=0x1234 + ) + tx_packet.radius = 12 + + with patch_data_request(app) as mock_req: + await app.send_packet(tx_packet) + + assert len(mock_req.mock_calls) == 1 + req = mock_req.mock_calls[0].kwargs + + assert req["dst_addr_ep"].address_mode == t.AddressMode.GROUP + assert req["dst_addr_ep"].address == tx_packet.dst.address + assert req["dst_addr_ep"].endpoint == tx_packet.dst_ep + assert req["profile"] == tx_packet.profile_id + assert req["cluster"] == tx_packet.cluster_id + assert req["aps_payload"] == tx_packet.data.serialize() + assert req["tx_options"] == t.DeconzTransmitOptions.USE_NWK_KEY_SECURITY + assert req["relays"] is None + assert req["radius"] == 12 + + +async def test_send_packet_broadcast(app, tx_packet): # noqa: F811 + tx_packet.dst = zigpy_t.AddrModeAddress( + addr_mode=zigpy_t.AddrMode.Broadcast, + address=zigpy_t.BroadcastAddress.ALL_ROUTERS_AND_COORDINATOR, + ) + tx_packet.radius = 12 + + with patch_data_request(app) as mock_req: + await app.send_packet(tx_packet) + + assert len(mock_req.mock_calls) == 1 + req = mock_req.mock_calls[0].kwargs + + assert req["dst_addr_ep"].address_mode == t.AddressMode.NWK + assert req["dst_addr_ep"].address == tx_packet.dst.address + assert req["dst_addr_ep"].endpoint == tx_packet.dst_ep + assert req["profile"] == tx_packet.profile_id + assert req["cluster"] == tx_packet.cluster_id + assert req["aps_payload"] == tx_packet.data.serialize() + assert req["tx_options"] == t.DeconzTransmitOptions.USE_NWK_KEY_SECURITY + assert req["relays"] is None + assert req["radius"] == 12 + + +async def test_send_packet_enqueue_failure(app, tx_packet): # noqa: F811 + with patch_data_request(app, fail_enqueue=True): # noqa: F811 + with pytest.raises(zigpy.exceptions.DeliveryError) as e: + await app.send_packet(tx_packet) + + assert "Failed to enqueue" in str(e) + + +async def test_send_packet_deliver_failure(app, tx_packet): # noqa: F811 + with patch_data_request(app, fail_deliver=True): # noqa: F811 + with pytest.raises(zigpy.exceptions.DeliveryError) as e: + await app.send_packet(tx_packet) + + assert "Failed to deliver" in str(e) + + +@pytest.fixture +def rx_packet(): + return zigpy_t.ZigbeePacket( + src=zigpy_t.AddrModeAddress(addr_mode=zigpy_t.AddrMode.NWK, address=0x1234), + src_ep=0x12, + dst=zigpy_t.AddrModeAddress(addr_mode=zigpy_t.AddrMode.NWK, address=0x0000), + dst_ep=0x34, + tsn=0x56, + profile_id=0x7890, + cluster_id=0xABCD, + data=zigpy_t.SerializableBytes(b"some data"), + tx_options=zigpy_t.TransmitOptions.NONE, + radius=0, + ) + + +async def test_receive_packet_nwk(app, rx_packet): # noqa: F811 + app.packet_received = Mock(spec_set=app.packet_received) + + app.handle_rx( + src=t.DeconzAddress.from_zigpy_type(rx_packet.src), + src_ep=rx_packet.src_ep, + dst=t.DeconzAddress.from_zigpy_type(rx_packet.dst), + dst_ep=rx_packet.dst_ep, + profile_id=rx_packet.profile_id, + cluster_id=rx_packet.cluster_id, + data=rx_packet.data.serialize(), + lqi=rx_packet.lqi, + rssi=rx_packet.rssi, + ) + + app.packet_received.assert_called_once_with(rx_packet.replace(tsn=None)) diff --git a/tests/test_types.py b/tests/test_types.py index ee6a236..06f3ac9 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -3,6 +3,7 @@ from unittest import mock import pytest +import zigpy.types as zigpy_t import zigpy_deconz.types as t @@ -13,12 +14,20 @@ def test_deconz_address_group(): addr, rest = t.DeconzAddress.deserialize(data + extra) assert rest == extra - assert addr.address_mode == t.ADDRESS_MODE.GROUP + assert addr.address_mode == t.AddressMode.GROUP assert addr.address_mode == 1 assert addr.address == 0xAA55 assert addr.serialize() == data + zigpy_addr = zigpy_t.AddrModeAddress( + addr_mode=zigpy_t.AddrMode.Group, address=0xAA55 + ) + assert addr.as_zigpy_type() == zigpy_addr + + converted_addr = t.DeconzAddress.from_zigpy_type(zigpy_addr) + assert converted_addr == addr + def test_deconz_address_nwk(): data = b"\x02\x55\xaa" @@ -26,12 +35,40 @@ def test_deconz_address_nwk(): addr, rest = t.DeconzAddress.deserialize(data + extra) assert rest == extra - assert addr.address_mode == t.ADDRESS_MODE.NWK + assert addr.address_mode == t.AddressMode.NWK assert addr.address_mode == 2 assert addr.address == 0xAA55 assert addr.serialize() == data + zigpy_addr = zigpy_t.AddrModeAddress(addr_mode=zigpy_t.AddrMode.NWK, address=0xAA55) + assert addr.as_zigpy_type() == zigpy_addr + + converted_addr = t.DeconzAddress.from_zigpy_type(zigpy_addr) + assert converted_addr == addr + + +def test_deconz_address_nwk_broadcast(): + data = b"\x02\xfc\xff" + extra = b"the rest of the owl" + + addr, rest = t.DeconzAddress.deserialize(data + extra) + assert rest == extra + assert addr.address_mode == t.AddressMode.NWK + assert addr.address_mode == 2 + assert addr.address == 0xFFFC + + assert addr.serialize() == data + + zigpy_addr = zigpy_t.AddrModeAddress( + addr_mode=zigpy_t.AddrMode.Broadcast, + address=zigpy_t.BroadcastAddress.ALL_ROUTERS_AND_COORDINATOR, + ) + assert addr.as_zigpy_type() == zigpy_addr + + converted_addr = t.DeconzAddress.from_zigpy_type(zigpy_addr) + assert converted_addr == addr + def test_deconz_address_ieee(): data = b"\x03\x55\xaa\xbb\xcc\xdd\xee\xef\xbe" @@ -39,7 +76,7 @@ def test_deconz_address_ieee(): addr, rest = t.DeconzAddress.deserialize(data + extra) assert rest == extra - assert addr.address_mode == t.ADDRESS_MODE.IEEE + assert addr.address_mode == t.AddressMode.IEEE assert addr.address_mode == 3 assert addr.address[0] == 0x55 assert addr.address[1] == 0xAA @@ -52,6 +89,15 @@ def test_deconz_address_ieee(): assert addr.serialize() == data + zigpy_addr = zigpy_t.AddrModeAddress( + addr_mode=zigpy_t.AddrMode.IEEE, + address=zigpy_t.EUI64.convert("BE:EF:EE:DD:CC:BB:AA:55"), + ) + assert addr.as_zigpy_type() == zigpy_addr + + converted_addr = t.DeconzAddress.from_zigpy_type(zigpy_addr) + assert converted_addr == addr + def test_deconz_address_nwk_and_ieee(): data = b"\x04\x55\xaa\x88\x99\xbb\xcc\xdd\xee\xef\xbe" @@ -59,7 +105,7 @@ def test_deconz_address_nwk_and_ieee(): addr, rest = t.DeconzAddress.deserialize(data + extra) assert rest == extra - assert addr.address_mode == t.ADDRESS_MODE.NWK_AND_IEEE + assert addr.address_mode == t.AddressMode.NWK_AND_IEEE assert addr.address_mode == 4 assert addr.ieee[0] == 0x88 assert addr.ieee[1] == 0x99 @@ -73,6 +119,12 @@ def test_deconz_address_nwk_and_ieee(): assert addr.serialize() == data + zigpy_addr = zigpy_t.AddrModeAddress( + addr_mode=zigpy_t.AddrMode.IEEE, + address=zigpy_t.EUI64.convert("BE:EF:EE:DD:CC:BB:99:88"), + ) + assert addr.as_zigpy_type() == zigpy_addr + def test_pan_id(): t.PanId() @@ -124,6 +176,8 @@ class TestStruct(t.Struct): ts2 = TestStruct(ts) assert ts2.a == ts.a assert ts2.b == ts.b + assert ts == ts2 + assert ts != 123 r = repr(ts) assert "TestStruct" in r @@ -218,7 +272,7 @@ def test_addr_ep_nwk(): r, rest = t.DeconzAddressEndpoint.deserialize(data + extra) assert rest == extra - assert r.address_mode == t.ADDRESS_MODE.NWK + assert r.address_mode == t.AddressMode.NWK assert r.address == 0x55AA assert r.endpoint == 0xCC @@ -229,7 +283,7 @@ def test_addr_ep_ieee(): r, rest = t.DeconzAddressEndpoint.deserialize(data + extra) assert rest == extra - assert r.address_mode == t.ADDRESS_MODE.IEEE + assert r.address_mode == t.AddressMode.IEEE assert repr(r.address) == "31:32:33:34:35:36:37:38" assert r.endpoint == 0xCC @@ -240,7 +294,7 @@ def test_deconz_addr_ep(): r, rest = t.DeconzAddressEndpoint.deserialize(data + extra) assert rest == extra - assert r.address_mode == t.ADDRESS_MODE.GROUP + assert r.address_mode == t.AddressMode.GROUP assert r.address == 0x55AA assert r.serialize() == data a = t.DeconzAddressEndpoint() @@ -251,7 +305,7 @@ def test_deconz_addr_ep(): data = b"\x02\xaa\x55\xcc" r, rest = t.DeconzAddressEndpoint.deserialize(data + extra) assert rest == extra - assert r.address_mode == t.ADDRESS_MODE.NWK + assert r.address_mode == t.AddressMode.NWK assert r.address == 0x55AA assert r.endpoint == 0xCC assert r.serialize() == data @@ -266,7 +320,7 @@ def test_deconz_addr_ep(): data = b"\x03\x31\x32\x33\x34\x35\x36\x37\x38\xcc" r, rest = t.DeconzAddressEndpoint.deserialize(data + extra) assert rest == extra - assert r.address_mode == t.ADDRESS_MODE.IEEE + assert r.address_mode == t.AddressMode.IEEE assert r.address == [0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38] assert r.endpoint == 0xCC assert r.serialize() == data diff --git a/tests/test_uart.py b/tests/test_uart.py index 30b1e6e..f69fa88 100644 --- a/tests/test_uart.py +++ b/tests/test_uart.py @@ -4,8 +4,8 @@ from unittest import mock import pytest -import serial_asyncio from zigpy.config import CONF_DEVICE_PATH +import zigpy.serial from zigpy_deconz import uart @@ -25,7 +25,7 @@ async def mock_conn(loop, protocol_factory, **kwargs): loop.call_soon(protocol.connection_made, None) return None, protocol - monkeypatch.setattr(serial_asyncio, "create_serial_connection", mock_conn) + monkeypatch.setattr(zigpy.serial, "create_serial_connection", mock_conn) await uart.connect({CONF_DEVICE_PATH: "/dev/null"}, api) diff --git a/zigpy_deconz/__init__.py b/zigpy_deconz/__init__.py index 6b7bf01..982a43c 100644 --- a/zigpy_deconz/__init__.py +++ b/zigpy_deconz/__init__.py @@ -2,7 +2,7 @@ # coding: utf-8 MAJOR_VERSION = 0 -MINOR_VERSION = 18 -PATCH_VERSION = "1" +MINOR_VERSION = 19 +PATCH_VERSION = "0" __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" diff --git a/zigpy_deconz/api.py b/zigpy_deconz/api.py index 48230ae..4143854 100644 --- a/zigpy_deconz/api.py +++ b/zigpy_deconz/api.py @@ -9,7 +9,6 @@ import logging from typing import Any, Callable, Optional -import serial from zigpy.config import CONF_DEVICE_PATH import zigpy.exceptions from zigpy.types import APSStatus, Bool, Channels @@ -24,6 +23,7 @@ COMMAND_TIMEOUT = 1.8 PROBE_TIMEOUT = 2 MIN_PROTO_VERSION = 0x010B +REQUEST_RETRY_DELAYS = (0.5, 1.0, 1.5, None) class Status(t.uint8_t, enum.Enum): @@ -109,7 +109,10 @@ def _missing_(cls, value): TX_COMMANDS = { Command.add_neighbour: (t.uint16_t, t.uint8_t, t.NWK, t.EUI64, t.uint8_t), Command.aps_data_confirm: (t.uint16_t,), - Command.aps_data_indication: (t.uint16_t, t.uint8_t), + Command.aps_data_indication: ( + t.uint16_t, + t.DataIndicationFlags, + ), Command.aps_data_request: ( t.uint16_t, t.uint8_t, @@ -238,7 +241,9 @@ class Deconz: def __init__(self, app: Callable, device_config: dict[str, Any]): """Init instance.""" self._app = app - self._aps_data_ind_flags: int = 0x01 + self._aps_data_ind_flags: t.DataIndicationFlags = ( + t.DataIndicationFlags.Always_Use_NWK_Source_Addr + ) self._awaiting = {} self._command_lock = asyncio.Lock() self._config = device_config @@ -364,7 +369,8 @@ def data_received(self, data): "Duplicate or delayed response for 0x:%02x sequence", seq ) - getattr(self, "_handle_%s" % (command.name,))(data) + LOGGER.debug("Received command %s%r", command.name, data) + getattr(self, f"_handle_{command.name}")(data) add_neighbour = functools.partialmethod(_command, Command.add_neighbour, 12) device_state = functools.partialmethod(_command, Command.device_state, 0, 0, 0) @@ -386,7 +392,7 @@ async def probe(cls, device_config: dict[str, Any]) -> bool: try: await asyncio.wait_for(api._probe(), timeout=PROBE_TIMEOUT) return True - except (asyncio.TimeoutError, serial.SerialException, APIException) as exc: + except Exception as exc: LOGGER.debug( "Unsuccessful radio probe of '%s' port", device_config[CONF_DEVICE_PATH], @@ -456,7 +462,7 @@ async def version(self): self.protocol_version >= MIN_PROTO_VERSION and (self.firmware_version & 0x0000FF00) == 0x00000500 ): - self._aps_data_ind_flags = 0x04 + self._aps_data_ind_flags = t.DataIndicationFlags.Include_Both_NWK_And_IEEE return self.firmware_version def _handle_version(self, data): @@ -492,17 +498,21 @@ def _handle_aps_data_indication(self, data): LOGGER.debug("APS data indication response: %s", data) self._data_indication = False self._handle_device_state_value(data[1]) - if self._app: - self._app.handle_rx( - data[4], # src_addr - data[5], # src_ep - data[3], # dst_ep - data[6], # profile_id - data[7], # cluster_id - data[8], # APS payload - data[11], # lqi - data[16], - ) # rssi + + if not self._app: + return + + self._app.handle_rx( + src=data[4], + src_ep=data[5], + dst=data[2], + dst_ep=data[3], + profile_id=data[6], + cluster_id=data[7], + data=data[8], + lqi=data[11], + rssi=data[16], + ) async def aps_data_request( self, @@ -519,7 +529,6 @@ async def aps_data_request( ): dst = dst_addr_ep.serialize() length = len(dst) + len(aps_payload) + 11 - delays = (0.5, 1.0, 1.5, None) flags = t.DeconzSendDataFlags.NONE extras = [] @@ -528,16 +537,12 @@ async def aps_data_request( if relays: # There is a max of 9 relays assert len(relays) <= 9 - - # APS ACKs should be used to mitigate errors. - tx_options |= t.DeconzTransmitOptions.USE_APS_ACKS - flags |= t.DeconzSendDataFlags.RELAYS extras.append(t.NWKList(relays)) length += sum(len(e.serialize()) for e in extras) - for delay in delays: + for delay in REQUEST_RETRY_DELAYS: try: return await self._command( Command.aps_data_request, @@ -569,7 +574,7 @@ async def _aps_data_confirm(self): try: r = await self._command(Command.aps_data_confirm, 0) LOGGER.debug( - ("Request id: 0x%02x 'aps_data_confirm' for %s, " "status: 0x%02x"), + "Request id: 0x%02x 'aps_data_confirm' for %s, status: 0x%02x", r[2], r[3], r[5], diff --git a/zigpy_deconz/config.py b/zigpy_deconz/config.py index ed2ad40..7e1b8f4 100644 --- a/zigpy_deconz/config.py +++ b/zigpy_deconz/config.py @@ -4,6 +4,7 @@ from zigpy.config import ( # noqa: F401 pylint: disable=unused-import CONF_DEVICE, CONF_DEVICE_PATH, + CONF_MAX_CONCURRENT_REQUESTS, CONF_NWK, CONF_NWK_CHANNEL, CONF_NWK_CHANNELS, @@ -20,7 +21,6 @@ CONF_DECONZ_CONFIG = "deconz_config" -CONF_MAX_CONCURRENT_REQUESTS = "max_concurrent_requests" CONF_MAX_CONCURRENT_REQUESTS_DEFAULT = 8 CONF_WATCHDOG_TTL = "watchdog_ttl" @@ -31,13 +31,8 @@ vol.Optional(CONF_WATCHDOG_TTL, default=CONF_WATCHDOG_TTL_DEFAULT): vol.All( int, vol.Range(min=180) ), - vol.Optional(CONF_DECONZ_CONFIG, default={}): vol.Schema( - { - vol.Optional( - CONF_MAX_CONCURRENT_REQUESTS, - default=CONF_MAX_CONCURRENT_REQUESTS_DEFAULT, - ): vol.All(int, vol.Range(min=1)) - } - ), + vol.Optional( + CONF_MAX_CONCURRENT_REQUESTS, default=CONF_MAX_CONCURRENT_REQUESTS_DEFAULT + ): CONFIG_SCHEMA.schema[CONF_MAX_CONCURRENT_REQUESTS], } ) diff --git a/zigpy_deconz/types.py b/zigpy_deconz/types.py index 0308d93..e0237d6 100644 --- a/zigpy_deconz/types.py +++ b/zigpy_deconz/types.py @@ -2,6 +2,8 @@ import enum +import zigpy.types as zigpy_t + def deserialize(data, schema): result = [] @@ -117,7 +119,7 @@ class uint64_t(uint_t): _size = 8 -class ADDRESS_MODE(uint8_t, enum.Enum): +class AddressMode(uint8_t, enum.Enum): # Address modes used in deconz protocol GROUP = 0x01 @@ -195,6 +197,13 @@ def deserialize(cls, data): setattr(r, field_name, v) return r, data + def __eq__(self, other): + """Check equality between structs.""" + if not isinstance(other, type(self)): + return NotImplemented + + return all(getattr(self, n) == getattr(other, n) for n, _ in self._fields) + def __repr__(self): """Instance representation.""" r = "<%s " % (self.__class__.__name__,) @@ -300,72 +309,149 @@ class NWKList(LVList): _itemtype = NWK +ZIGPY_ADDR_MODE_MAPPING = { + zigpy_t.AddrMode.NWK: AddressMode.NWK, + zigpy_t.AddrMode.IEEE: AddressMode.IEEE, + zigpy_t.AddrMode.Group: AddressMode.GROUP, + zigpy_t.AddrMode.Broadcast: AddressMode.NWK, +} + + +ZIGPY_ADDR_TYPE_MAPPING = { + zigpy_t.AddrMode.NWK: NWK, + zigpy_t.AddrMode.IEEE: EUI64, + zigpy_t.AddrMode.Group: GroupId, + zigpy_t.AddrMode.Broadcast: NWK, +} + + +ZIGPY_ADDR_MODE_REVERSE_MAPPING = { + AddressMode.NWK: zigpy_t.AddrMode.NWK, + AddressMode.IEEE: zigpy_t.AddrMode.IEEE, + AddressMode.GROUP: zigpy_t.AddrMode.Group, + AddressMode.NWK_AND_IEEE: zigpy_t.AddrMode.IEEE, +} + + +ZIGPY_ADDR_TYPE_REVERSE_MAPPING = { + AddressMode.NWK: zigpy_t.NWK, + AddressMode.IEEE: zigpy_t.EUI64, + AddressMode.GROUP: zigpy_t.Group, + AddressMode.NWK_AND_IEEE: zigpy_t.NWK, +} + + class DeconzAddress(Struct): _fields = [ # The address format (AddressMode) - ("address_mode", ADDRESS_MODE), + ("address_mode", AddressMode), ("address", EUI64), ] @classmethod def deserialize(cls, data): r = cls() - mode, data = ADDRESS_MODE.deserialize(data) + mode, data = AddressMode.deserialize(data) r.address_mode = mode - if mode in [ADDRESS_MODE.GROUP, ADDRESS_MODE.NWK, ADDRESS_MODE.NWK_AND_IEEE]: + if mode in [AddressMode.GROUP, AddressMode.NWK, AddressMode.NWK_AND_IEEE]: r.address, data = NWK.deserialize(data) - elif mode == ADDRESS_MODE.IEEE: + elif mode == AddressMode.IEEE: r.address, data = EUI64.deserialize(data) - if mode == ADDRESS_MODE.NWK_AND_IEEE: + if mode == AddressMode.NWK_AND_IEEE: r.ieee, data = EUI64.deserialize(data) return r, data def serialize(self): r = super().serialize() - if self.address_mode == ADDRESS_MODE.NWK_AND_IEEE: + if self.address_mode == AddressMode.NWK_AND_IEEE: r += self.ieee.serialize() return r + def as_zigpy_type(self): + addr_mode = ZIGPY_ADDR_MODE_REVERSE_MAPPING[self.address_mode] + address = ZIGPY_ADDR_TYPE_REVERSE_MAPPING[self.address_mode](self.address) + + if self.address_mode == AddressMode.NWK and self.address > 0xFFF7: + addr_mode = zigpy_t.AddrMode.Broadcast + address = zigpy_t.BroadcastAddress(self.address) + elif self.address_mode == AddressMode.NWK_AND_IEEE: + address = zigpy_t.EUI64(self.ieee) + + return zigpy_t.AddrModeAddress( + addr_mode=addr_mode, + address=address, + ) + + @classmethod + def from_zigpy_type(cls, addr): + instance = cls() + instance.address_mode = ZIGPY_ADDR_MODE_MAPPING[addr.addr_mode] + instance.address = ZIGPY_ADDR_TYPE_MAPPING[addr.addr_mode](addr.address) + + return instance + class DeconzAddressEndpoint(Struct): _fields = [ # The address format (AddressMode) - ("address_mode", ADDRESS_MODE), + ("address_mode", AddressMode), ("address", EUI64), ("endpoint", uint8_t), ] @classmethod def deserialize(cls, data): - r = cls() - mode, data = ADDRESS_MODE.deserialize(data) - r.address_mode = mode - a = e = None - if mode == ADDRESS_MODE.GROUP: - a, data = GroupId.deserialize(data) - elif mode == ADDRESS_MODE.NWK: - a, data = NWK.deserialize(data) - elif mode == ADDRESS_MODE.IEEE: - a, data = EUI64.deserialize(data) - setattr(r, cls._fields[1][0], a) - if mode in [ADDRESS_MODE.NWK, ADDRESS_MODE.IEEE]: - e, data = uint8_t.deserialize(data) - setattr(r, cls._fields[2][0], e) + r, data = DeconzAddress.deserialize.__func__(cls, data) + + if r.address_mode in ( + AddressMode.NWK, + AddressMode.IEEE, + AddressMode.NWK_AND_IEEE, + ): + r.endpoint, data = uint8_t.deserialize(data) + else: + r.endpoint = None + return r, data def serialize(self): r = uint8_t(self.address_mode).serialize() - if self.address_mode == ADDRESS_MODE.NWK: + + if self.address_mode in (AddressMode.NWK, AddressMode.NWK_AND_IEEE): r += NWK(self.address).serialize() - elif self.address_mode == ADDRESS_MODE.GROUP: + elif self.address_mode == AddressMode.GROUP: r += GroupId(self.address).serialize() - elif self.address_mode == ADDRESS_MODE.IEEE: + + if self.address_mode in (AddressMode.IEEE, AddressMode.NWK_AND_IEEE): r += EUI64(self.address).serialize() - if self.address_mode in (ADDRESS_MODE.NWK, ADDRESS_MODE.IEEE): + + if self.address_mode in ( + AddressMode.NWK, + AddressMode.IEEE, + AddressMode.NWK_AND_IEEE, + ): r += uint8_t(self.endpoint).serialize() + return r + @classmethod + def from_zigpy_type(cls, addr, endpoint): + temp_addr = DeconzAddress.from_zigpy_type(addr) + + instance = cls() + instance.address_mode = temp_addr.address_mode + instance.address = temp_addr.address + instance.endpoint = endpoint + + return instance + class Key(FixedList): _itemtype = uint8_t _length = 16 + + +class DataIndicationFlags(bitmap8): + Always_Use_NWK_Source_Addr = 0b00000001 + Last_Hop_In_Reserved_Bytes = 0b00000010 + Include_Both_NWK_And_IEEE = 0b00000100 diff --git a/zigpy_deconz/uart.py b/zigpy_deconz/uart.py index a4f0d36..5209d80 100644 --- a/zigpy_deconz/uart.py +++ b/zigpy_deconz/uart.py @@ -5,9 +5,8 @@ import logging from typing import Callable, Dict -import serial -import serial_asyncio from zigpy.config import CONF_DEVICE_PATH +import zigpy.serial LOGGER = logging.getLogger(__name__) @@ -132,18 +131,16 @@ async def connect(config: Dict[str, str], api: Callable) -> Gateway: LOGGER.debug("Connecting to %s", config[CONF_DEVICE_PATH]) - _, protocol = await serial_asyncio.create_serial_connection( + _, protocol = await zigpy.serial.create_serial_connection( loop=loop, protocol_factory=lambda: protocol, url=config[CONF_DEVICE_PATH], baudrate=DECONZ_BAUDRATE, - parity=serial.PARITY_NONE, - stopbits=serial.STOPBITS_ONE, xonxoff=False, ) await connected_future - LOGGER.debug("Connected to to %s", config[CONF_DEVICE_PATH]) + LOGGER.debug("Connected to %s", config[CONF_DEVICE_PATH]) return protocol diff --git a/zigpy_deconz/zigbee/application.py b/zigpy_deconz/zigbee/application.py index ae569d5..55680ce 100644 --- a/zigpy_deconz/zigbee/application.py +++ b/zigpy_deconz/zigbee/application.py @@ -3,11 +3,8 @@ from __future__ import annotations import asyncio -import binascii -import contextlib import logging import re -import time from typing import Any import zigpy.application @@ -30,14 +27,9 @@ NetworkState, SecurityMode, Status, + TXStatus, ) -from zigpy_deconz.config import ( - CONF_DECONZ_CONFIG, - CONF_MAX_CONCURRENT_REQUESTS, - CONF_WATCHDOG_TTL, - CONFIG_SCHEMA, - SCHEMA_DEVICE, -) +from zigpy_deconz.config import CONF_WATCHDOG_TTL, CONFIG_SCHEMA, SCHEMA_DEVICE import zigpy_deconz.exception LOGGER = logging.getLogger(__name__) @@ -62,10 +54,6 @@ def __init__(self, config: dict[str, Any]): self._api = None self._pending = zigpy.util.Requests() - self._concurrent_requests_semaphore = asyncio.Semaphore( - self._config[CONF_DECONZ_CONFIG][CONF_MAX_CONCURRENT_REQUESTS] - ) - self._currently_waiting_requests = 0 self.version = 0 self._reset_watchdog_task = None @@ -160,6 +148,10 @@ async def change_loop(): if target_state == NetworkState.CONNECTED: self._reset_watchdog_task = asyncio.create_task(self._reset_watchdog()) + async def reset_network_info(self): + # TODO: There does not appear to be a way to factory reset a Conbee + await self.form_network() + async def write_network_info(self, *, network_info, node_info): try: await self._api.write_parameter( @@ -168,7 +160,8 @@ async def write_network_info(self, *, network_info, node_info): except zigpy_deconz.exception.CommandError as ex: assert ex.status == Status.UNSUPPORTED LOGGER.warning( - "Writing network frame counter is not supported with this firmware" + "Writing the network frame counter is not supported with this firmware," + " please update your Conbee" ) if node_info.logical_type == zdo_t.LogicalType.Coordinator: @@ -385,224 +378,69 @@ async def add_endpoint(self, descriptor: zdo_t.SimpleDescriptor) -> None: NetworkParameter.configure_endpoint, target_index, descriptor ) - @contextlib.asynccontextmanager - async def _limit_concurrency(self): - """Async context manager to prevent devices from being overwhelmed by requests. - - Mainly a thin wrapper around `asyncio.Semaphore` that logs when it has to wait. - """ + async def send_packet(self, packet): + LOGGER.debug("Sending packet: %r", packet) - start_time = time.time() - was_locked = self._concurrent_requests_semaphore.locked() - - if was_locked: - self._currently_waiting_requests += 1 - LOGGER.debug( - "Max concurrency (%s) reached, delaying requests (%s enqueued)", - self._config[CONF_DECONZ_CONFIG][CONF_MAX_CONCURRENT_REQUESTS], - self._currently_waiting_requests, - ) - - try: - async with self._concurrent_requests_semaphore: - if was_locked: - LOGGER.debug( - "Previously delayed request is now running, " - "delayed by %0.2f seconds", - time.time() - start_time, - ) + tx_options = t.DeconzTransmitOptions.USE_NWK_KEY_SECURITY - yield - finally: - if was_locked: - self._currently_waiting_requests -= 1 - - 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 - """ - req_id = self.get_sequence() - LOGGER.debug( - "Sending Zigbee multicast with tsn %s under %s request id, data: %s", - sequence, - req_id, - binascii.hexlify(data), - ) - dst_addr_ep = t.DeconzAddressEndpoint() - dst_addr_ep.address_mode = t.ADDRESS_MODE.GROUP - dst_addr_ep.address = group_id + if ( + zigpy.types.TransmitOptions.ACK in packet.tx_options + and packet.dst.addr_mode + in (zigpy.types.AddrMode.NWK, zigpy.types.AddrMode.IEEE) + ): + tx_options |= t.DeconzTransmitOptions.USE_APS_ACKS async with self._limit_concurrency(): - with self._pending.new(req_id) as req: - try: - await self._api.aps_data_request( - req_id, dst_addr_ep, profile, cluster, min(1, src_ep), data - ) - except zigpy_deconz.exception.CommandError as ex: - return ex.status, f"Couldn't enqueue send data request: {ex!r}" - - r = await asyncio.wait_for(req.result, SEND_CONFIRM_TIMEOUT) - if r: - LOGGER.debug("Error while sending %s req id frame: %s", req_id, r) - return r, f"message send failure: {r}" - - return Status.SUCCESS, "message send success" - - @zigpy.util.retryable_request - async def request( - self, - device, - profile, - cluster, - src_ep, - dst_ep, - sequence, - data, - expect_reply=True, - use_ieee=False, - ): - req_id = self.get_sequence() - LOGGER.debug( - "Sending Zigbee request with tsn %s under %s request id, data: %s", - sequence, - req_id, - binascii.hexlify(data), - ) - dst_addr_ep = t.DeconzAddressEndpoint() - dst_addr_ep.endpoint = t.uint8_t(dst_ep) - if use_ieee: - dst_addr_ep.address_mode = t.uint8_t(t.ADDRESS_MODE.IEEE) - dst_addr_ep.address = device.ieee - else: - dst_addr_ep.address_mode = t.uint8_t(t.ADDRESS_MODE.NWK) - dst_addr_ep.address = device.nwk - - tx_options = t.DeconzTransmitOptions.USE_NWK_KEY_SECURITY + req_id = self.get_sequence() - async with self._limit_concurrency(): with self._pending.new(req_id) as req: try: await self._api.aps_data_request( - req_id, - dst_addr_ep, - profile, - cluster, - min(1, src_ep), - data, + req_id=req_id, + dst_addr_ep=t.DeconzAddressEndpoint.from_zigpy_type( + packet.dst, packet.dst_ep or 0 + ), + profile=packet.profile_id, + cluster=packet.cluster_id, + src_ep=min(1, packet.src_ep), + aps_payload=packet.data.serialize(), tx_options=tx_options, + relays=packet.source_route, + radius=packet.radius or 0, ) except zigpy_deconz.exception.CommandError as ex: - return ex.status, f"Couldn't enqueue send data request: {ex!r}" - - r = await asyncio.wait_for(req.result, SEND_CONFIRM_TIMEOUT) - - if r: - LOGGER.debug("Error while sending %s req id frame: %s", req_id, r) - return r, "message send failure" - - return r, "message send success" - - async def broadcast( - self, - profile, - cluster, - src_ep, - dst_ep, - grpid, - radius, - sequence, - data, - broadcast_address=zigpy.types.BroadcastAddress.RX_ON_WHEN_IDLE, - ): - req_id = self.get_sequence() - LOGGER.debug( - "Sending Zigbee broadcast with tsn %s under %s request id, data: %s", - sequence, - req_id, - binascii.hexlify(data), - ) - dst_addr_ep = t.DeconzAddressEndpoint() - dst_addr_ep.address_mode = t.uint8_t(t.ADDRESS_MODE.NWK.value) - dst_addr_ep.address = t.uint16_t(broadcast_address) - dst_addr_ep.endpoint = dst_ep - - async with self._limit_concurrency(): - with self._pending.new(req_id) as req: - try: - await self._api.aps_data_request( - req_id, dst_addr_ep, profile, cluster, min(1, src_ep), data - ) - except zigpy_deconz.exception.CommandError as ex: - return ( - ex.status, - f"Couldn't enqueue send data request for broadcast: {ex!r}", + raise zigpy.exceptions.DeliveryError( + f"Failed to enqueue packet: {ex!r}", ex.status ) - r = await asyncio.wait_for(req.result, SEND_CONFIRM_TIMEOUT) + status = await asyncio.wait_for(req.result, SEND_CONFIRM_TIMEOUT) - if r: - LOGGER.debug( - "Error while sending %s req id broadcast: %s", req_id, r + if status != TXStatus.SUCCESS: + raise zigpy.exceptions.DeliveryError( + f"Failed to deliver packet: {status!r}", status ) - return r, f"broadcast send failure: {r}" - return r, "broadcast send success" - - async def permit_ncp(self, time_s=60): - assert 0 <= time_s <= 254 - await self._api.write_parameter(NetworkParameter.permit_join, time_s) def handle_rx( - self, src_addr, src_ep, dst_ep, profile_id, cluster_id, data, lqi, rssi + self, src, src_ep, dst, dst_ep, profile_id, cluster_id, data, lqi, rssi ): - # intercept ZDO device announce frames - if dst_ep == 0 and cluster_id == 0x13: - nwk, rest = t.uint16_t.deserialize(data[1:]) - ieee, _ = zigpy.types.EUI64.deserialize(rest) - LOGGER.info("New device joined: 0x%04x, %s", nwk, ieee) - self.handle_join(nwk, ieee, 0) - - try: - if src_addr.address_mode == t.ADDRESS_MODE.NWK_AND_IEEE: - device = self.get_device(ieee=src_addr.ieee) - elif src_addr.address_mode == t.ADDRESS_MODE.NWK.value: - device = self.get_device(nwk=src_addr.address) - elif src_addr.address_mode == t.ADDRESS_MODE.IEEE.value: - device = self.get_device(ieee=src_addr.address) - else: - raise Exception( - "Unsupported address mode in handle_rx: %s" - % (src_addr.address_mode) - ) - except KeyError: - LOGGER.debug("Received frame from unknown device: 0x%04x", src_addr.address) - return + self.packet_received( + zigpy.types.ZigbeePacket( + src=src.as_zigpy_type(), + src_ep=src_ep, + dst=dst.as_zigpy_type(), + dst_ep=dst_ep, + tsn=None, + profile_id=profile_id, + cluster_id=cluster_id, + data=zigpy.types.SerializableBytes(data), + lqi=lqi, + rssi=rssi, + ) + ) - device.radio_details(lqi, rssi) - self.handle_message(device, profile_id, cluster_id, src_ep, dst_ep, data) + async def permit_ncp(self, time_s=60): + assert 0 <= time_s <= 254 + await self._api.write_parameter(NetworkParameter.permit_join, time_s) def handle_tx_confirm(self, req_id, status): try: