diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e2365a1..c7c4399 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,16 +1,19 @@ repos: - repo: https://github.com/psf/black - rev: 19.3b0 + rev: 20.8b1 hooks: - id: black args: - --safe - --quiet - repo: https://gitlab.com/pycqa/flake8 - rev: 3.7.8 + rev: 3.8.3 hooks: - id: flake8 - - repo: https://github.com/pre-commit/mirrors-isort - rev: v4.3.21 + additional_dependencies: + - flake8-docstrings==1.5.0 + - pydocstyle==5.1.1 + - repo: https://github.com/PyCQA/isort + rev: 5.5.2 hooks: - id: isort diff --git a/.travis.yml b/.travis.yml index 0e871cb..7020c1c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,12 +2,10 @@ language: python matrix: fast_finish: true include: - - python: "3.6" + - python: "3.7" env: TOXENV=lint - - python: "3.6" + - python: "3.7" env: TOXENV=black - - python: "3.6" - env: TOXENV=py36 - python: "3.7" env: TOXENV=py37 - python: "3.8" diff --git a/setup.cfg b/setup.cfg index af878e0..712ca4a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -8,24 +8,15 @@ max-line-length = 88 ignore = W503, E203, + D101, + D102, + D103, D202 [isort] -# https://github.com/timothycrosley/isort -# https://github.com/timothycrosley/isort/wiki/isort-Settings -# splits long import on multiple lines indented by 4 spaces -multi_line_output = 3 -include_trailing_comma=True -force_grid_wrap=0 -use_parentheses=True -line_length=88 -indent = " " -# by default isort don't check module indexes -not_skip = __init__.py +profile = black # will group `import x` and `from x import` of the same module. force_sort_within_sections = true -sections = FUTURE,STDLIB,INBETWEENS,THIRDPARTY,FIRSTPARTY,LOCALFOLDER -default_section = THIRDPARTY known_first_party = zigpy_deconz,tests forced_separate = tests combine_as_imports = true diff --git a/setup.py b/setup.py index 8aa1347..8ea0514 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,4 @@ -"""Setup module for zigpy-deconz""" +"""Setup module for zigpy-deconz.""" import os @@ -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.20.a1"], + install_requires=["pyserial-asyncio", "zigpy>=0.24.0"], tests_require=["pytest", "pytest-asyncio", "asynctest"], ) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..adfa6fa --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests modules.""" diff --git a/tests/async_mock.py b/tests/async_mock.py new file mode 100644 index 0000000..8257ddd --- /dev/null +++ b/tests/async_mock.py @@ -0,0 +1,9 @@ +"""Mock utilities that are async aware.""" +import sys + +if sys.version_info[:2] < (3, 8): + from asynctest.mock import * # noqa + + AsyncMock = CoroutineMock # noqa: F405 +else: + from unittest.mock import * # noqa diff --git a/tests/test_api.py b/tests/test_api.py index 838f27a..6c2a68a 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,7 +1,8 @@ +"""Test api module.""" + import asyncio import logging -from asynctest import CoroutineMock, mock import pytest import serial import zigpy.config @@ -10,27 +11,29 @@ import zigpy_deconz.exception import zigpy_deconz.zigbee.application +from .async_mock import AsyncMock, MagicMock, patch, sentinel + +pytestmark = pytest.mark.asyncio DEVICE_CONFIG = {zigpy.config.CONF_DEVICE_PATH: "/dev/null"} @pytest.fixture def api(event_loop): - controller = mock.MagicMock( + controller = MagicMock( spec_set=zigpy_deconz.zigbee.application.ControllerApplication ) api = deconz_api.Deconz(controller, {zigpy.config.CONF_DEVICE_PATH: "/dev/null"}) - api._uart = mock.MagicMock() + api._uart = MagicMock() return api -@pytest.mark.asyncio async def test_connect(): - controller = mock.MagicMock( + controller = MagicMock( spec_set=zigpy_deconz.zigbee.application.ControllerApplication ) api = deconz_api.Deconz(controller, {zigpy.config.CONF_DEVICE_PATH: "/dev/null"}) - with mock.patch.object(uart, "connect", new=CoroutineMock()) as conn_mck: + with patch.object(uart, "connect", new=AsyncMock()) as conn_mck: await api.connect() assert conn_mck.call_count == 1 assert conn_mck.await_count == 1 @@ -57,100 +60,102 @@ def test_commands(): assert isinstance(schema, tuple) is True -@pytest.mark.asyncio async def test_command(api, monkeypatch): def mock_api_frame(name, *args): - return mock.sentinel.api_frame_data, api._seq + return sentinel.api_frame_data, api._seq - api._api_frame = mock.MagicMock(side_effect=mock_api_frame) - api._uart.send = mock.MagicMock() + api._api_frame = MagicMock(side_effect=mock_api_frame) + api._uart.send = MagicMock() async def mock_fut(): - return mock.sentinel.cmd_result + return sentinel.cmd_result monkeypatch.setattr(asyncio, "Future", mock_fut) for cmd, cmd_opts in deconz_api.TX_COMMANDS.items(): - ret = await api._command(cmd, mock.sentinel.cmd_data) - assert ret is mock.sentinel.cmd_result + ret = await api._command(cmd, sentinel.cmd_data) + assert ret is sentinel.cmd_result assert api._api_frame.call_count == 1 assert api._api_frame.call_args[0][0] == cmd - assert api._api_frame.call_args[0][1] == mock.sentinel.cmd_data + assert api._api_frame.call_args[0][1] == sentinel.cmd_data assert api._uart.send.call_count == 1 - assert api._uart.send.call_args[0][0] == mock.sentinel.api_frame_data + assert api._uart.send.call_args[0][0] == sentinel.api_frame_data api._api_frame.reset_mock() api._uart.send.reset_mock() -@pytest.mark.asyncio async def test_command_queue(api, monkeypatch): def mock_api_frame(name, *args): - return mock.sentinel.api_frame_data, api._seq + return sentinel.api_frame_data, api._seq - api._api_frame = mock.MagicMock(side_effect=mock_api_frame) - api._uart.send = mock.MagicMock() + api._api_frame = MagicMock(side_effect=mock_api_frame) + api._uart.send = MagicMock() monkeypatch.setattr(deconz_api, "COMMAND_TIMEOUT", 0.1) for cmd, cmd_opts in deconz_api.TX_COMMANDS.items(): async with api._command_lock: with pytest.raises(asyncio.TimeoutError): - await asyncio.wait_for(api._command(cmd, mock.sentinel.cmd_data), 0.1) + await asyncio.wait_for(api._command(cmd, sentinel.cmd_data), 0.1) assert api._api_frame.call_count == 0 assert api._uart.send.call_count == 0 api._api_frame.reset_mock() api._uart.send.reset_mock() -@pytest.mark.asyncio async def test_command_timeout(api, monkeypatch): def mock_api_frame(name, *args): - return mock.sentinel.api_frame_data, api._seq + return sentinel.api_frame_data, api._seq - api._api_frame = mock.MagicMock(side_effect=mock_api_frame) - api._uart.send = mock.MagicMock() + api._api_frame = MagicMock(side_effect=mock_api_frame) + api._uart.send = MagicMock() monkeypatch.setattr(deconz_api, "COMMAND_TIMEOUT", 0.1) for cmd, cmd_opts in deconz_api.TX_COMMANDS.items(): with pytest.raises(asyncio.TimeoutError): - await api._command(cmd, mock.sentinel.cmd_data) + await api._command(cmd, sentinel.cmd_data) assert api._api_frame.call_count == 1 assert api._api_frame.call_args[0][0] == cmd - assert api._api_frame.call_args[0][1] == mock.sentinel.cmd_data + assert api._api_frame.call_args[0][1] == sentinel.cmd_data assert api._uart.send.call_count == 1 - assert api._uart.send.call_args[0][0] == mock.sentinel.api_frame_data + assert api._uart.send.call_args[0][0] == sentinel.api_frame_data api._api_frame.reset_mock() api._uart.send.reset_mock() -@pytest.mark.asyncio async def test_command_not_connected(api): api._uart = None def mock_api_frame(name, *args): - return mock.sentinel.api_frame_data, api._seq + return sentinel.api_frame_data, api._seq - api._api_frame = mock.MagicMock(side_effect=mock_api_frame) + api._api_frame = MagicMock(side_effect=mock_api_frame) for cmd, cmd_opts in deconz_api.TX_COMMANDS.items(): with pytest.raises(deconz_api.CommandError): - await api._command(cmd, mock.sentinel.cmd_data) + await api._command(cmd, sentinel.cmd_data) assert api._api_frame.call_count == 0 api._api_frame.reset_mock() +def _fake_args(arg_type): + if isinstance(arg_type(), t.DeconzAddressEndpoint): + addr = t.DeconzAddressEndpoint() + addr.address_mode = t.ADDRESS_MODE.NWK + addr.address = t.uint8_t(0) + addr.endpoint = t.uint8_t(0) + return addr + if isinstance(arg_type(), t.EUI64): + return t.EUI64([0x01] * 8) + + return arg_type() + + def test_api_frame(api): - addr = t.DeconzAddressEndpoint() - addr.address_mode = t.ADDRESS_MODE.NWK - addr.address = t.uint8_t(0) - addr.endpoint = t.uint8_t(0) for cmd, schema in deconz_api.TX_COMMANDS.items(): if schema: - args = [ - addr if isinstance(a(), t.DeconzAddressEndpoint) else a() - for a in schema - ] + args = [_fake_args(a) for a in schema] api._api_frame(cmd, *args) else: api._api_frame(cmd) @@ -160,20 +165,20 @@ def test_data_received(api, monkeypatch): monkeypatch.setattr( t, "deserialize", - mock.MagicMock(return_value=(mock.sentinel.deserialize_data, b"")), + MagicMock(return_value=(sentinel.deserialize_data, b"")), ) - my_handler = mock.MagicMock() + my_handler = MagicMock() for cmd, cmd_opts in deconz_api.RX_COMMANDS.items(): payload = b"\x01\x02\x03\x04" data = cmd.serialize() + b"\x00\x00\x00\x00" + payload setattr(api, "_handle_{}".format(cmd.name), my_handler) - api._awaiting[0] = mock.MagicMock() + api._awaiting[0] = MagicMock() api.data_received(data) assert t.deserialize.call_count == 1 assert t.deserialize.call_args[0][0] == payload assert my_handler.call_count == 1 - assert my_handler.call_args[0][0] == mock.sentinel.deserialize_data + assert my_handler.call_args[0][0] == sentinel.deserialize_data t.deserialize.reset_mock() my_handler.reset_mock() @@ -182,9 +187,9 @@ def test_data_received_unk_status(api, monkeypatch): monkeypatch.setattr( t, "deserialize", - mock.MagicMock(return_value=(mock.sentinel.deserialize_data, b"")), + MagicMock(return_value=(sentinel.deserialize_data, b"")), ) - my_handler = mock.MagicMock() + my_handler = MagicMock() for cmd, cmd_opts in deconz_api.RX_COMMANDS.items(): _, unsolicited = cmd_opts @@ -192,7 +197,7 @@ def test_data_received_unk_status(api, monkeypatch): status = t.uint8_t(0xFE).serialize() data = cmd.serialize() + b"\x00" + status + b"\x00\x00" + payload setattr(api, "_handle_{}".format(cmd.name), my_handler) - api._awaiting[0] = mock.MagicMock() + api._awaiting[0] = MagicMock() api.data_received(data) assert t.deserialize.call_count == 1 assert t.deserialize.call_args[0][0] == payload @@ -208,14 +213,14 @@ def test_data_received_unk_cmd(api, monkeypatch): monkeypatch.setattr( t, "deserialize", - mock.MagicMock(return_value=(mock.sentinel.deserialize_data, b"")), + MagicMock(return_value=(sentinel.deserialize_data, b"")), ) for cmd_id in range(253, 255): payload = b"\x01\x02\x03\x04" status = t.uint8_t(0x00).serialize() data = cmd_id.to_bytes(1, "big") + b"\x00" + status + b"\x00\x00" + payload - api._awaiting[0] = (mock.MagicMock(),) + api._awaiting[0] = (MagicMock(),) api.data_received(data) assert t.deserialize.call_count == 0 t.deserialize.reset_mock() @@ -225,7 +230,6 @@ def test_simplified_beacon(api): api._handle_simplified_beacon((0x0007, 0x1234, 0x5678, 0x19, 0x00, 0x01)) -@pytest.mark.asyncio async def test_aps_data_confirm(api, monkeypatch): monkeypatch.setattr(deconz_api, "COMMAND_TIMEOUT", 0.1) @@ -234,7 +238,7 @@ async def test_aps_data_confirm(api, monkeypatch): def mock_cmd(*args, **kwargs): res = asyncio.Future() if success: - res.set_result([7, 0x22, 0x11, mock.sentinel.dst_addr, 1, 0x00, 0, 0, 0, 0]) + 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) api._command = mock_cmd @@ -250,7 +254,6 @@ def mock_cmd(*args, **kwargs): assert api._data_confirm is False -@pytest.mark.asyncio async def test_aps_data_ind(api, monkeypatch): monkeypatch.setattr(deconz_api, "COMMAND_TIMEOUT", 0.1) @@ -258,7 +261,7 @@ async def test_aps_data_ind(api, monkeypatch): def mock_cmd(*args, **kwargs): res = asyncio.Future() - s = mock.sentinel + s = sentinel if success: res.set_result( [ @@ -288,7 +291,6 @@ def mock_cmd(*args, **kwargs): assert api._data_indication is False -@pytest.mark.asyncio async def test_aps_data_request(api): params = [ 0x00, # req id @@ -299,14 +301,13 @@ async def test_aps_data_request(api): b"aps payload", ] - mock_cmd = mock.MagicMock(side_effect=asyncio.coroutine(mock.MagicMock())) + mock_cmd = AsyncMock() api._command = mock_cmd await api.aps_data_request(*params) assert mock_cmd.call_count == 1 -@pytest.mark.asyncio async def test_aps_data_request_timeout(api, monkeypatch): params = [ 0x00, # req id @@ -318,7 +319,7 @@ async def test_aps_data_request_timeout(api, monkeypatch): ] monkeypatch.setattr(deconz_api, "COMMAND_TIMEOUT", 0.1) - mock_cmd = mock.MagicMock( + mock_cmd = MagicMock( return_value=asyncio.wait_for( asyncio.Future(), timeout=deconz_api.COMMAND_TIMEOUT ) @@ -330,7 +331,6 @@ async def test_aps_data_request_timeout(api, monkeypatch): assert mock_cmd.call_count == 1 -@pytest.mark.asyncio async def test_aps_data_request_busy(api, monkeypatch): params = [ 0x00, # req id @@ -344,11 +344,11 @@ async def test_aps_data_request_busy(api, monkeypatch): res = asyncio.Future() exc = zigpy_deconz.exception.CommandError(deconz_api.Status.BUSY, "busy") res.set_exception(exc) - mock_cmd = mock.MagicMock(return_value=res) + mock_cmd = MagicMock(return_value=res) api._command = mock_cmd monkeypatch.setattr(deconz_api, "COMMAND_TIMEOUT", 0.1) - sleep = mock.MagicMock(side_effect=asyncio.coroutine(mock.MagicMock())) + sleep = AsyncMock() monkeypatch.setattr(asyncio, "sleep", sleep) with pytest.raises(zigpy_deconz.exception.CommandError): @@ -357,16 +357,12 @@ async def test_aps_data_request_busy(api, monkeypatch): def test_handle_read_parameter(api): - api._handle_read_parameter(mock.sentinel.data) + api._handle_read_parameter(sentinel.data) -@pytest.mark.asyncio async def test_read_parameter(api): - api._command = mock.MagicMock() - api._command.side_effect = asyncio.coroutine( - mock.MagicMock( - return_value=(mock.sentinel.len, mock.sentinel.param_id, b"\xaa\x55") - ) + api._command = AsyncMock( + return_value=(sentinel.len, sentinel.param_id, b"\xaa\x55") ) r = await api.read_parameter(deconz_api.NetworkParameter.nwk_panid) @@ -389,17 +385,15 @@ async def test_read_parameter(api): def test_handle_write_parameter(api): param_id = 0x05 - api._handle_write_parameter([mock.sentinel.len, param_id]) + api._handle_write_parameter([sentinel.len, param_id]) unk_param = 0xFF assert unk_param not in list(deconz_api.NetworkParameter) - api._handle_write_parameter([mock.sentinel.len, unk_param]) + api._handle_write_parameter([sentinel.len, unk_param]) -@pytest.mark.asyncio async def test_write_parameter(api): - api._command = mock.MagicMock() - api._command.side_effect = asyncio.coroutine(mock.MagicMock()) + api._command = AsyncMock() await api.write_parameter(deconz_api.NetworkParameter.nwk_panid, 0x55AA) assert api._command.call_count == 1 @@ -426,23 +420,16 @@ async def test_write_parameter(api): (0x010B, 0x123407DD, 0x01), ], ) -@pytest.mark.asyncio async def test_version(protocol_ver, firmware_version, flags, api): - api.read_parameter = mock.MagicMock() - api.read_parameter.side_effect = asyncio.coroutine( - mock.MagicMock(return_value=[protocol_ver]) - ) - api._command = mock.MagicMock() - api._command.side_effect = asyncio.coroutine( - mock.MagicMock(return_value=[firmware_version]) - ) + api.read_parameter = AsyncMock(return_value=[protocol_ver]) + api._command = AsyncMock(return_value=[firmware_version]) r = await api.version() assert r == firmware_version assert api._aps_data_ind_flags == flags def test_handle_version(api): - api._handle_version([mock.sentinel.version]) + api._handle_version([sentinel.version]) @pytest.mark.parametrize( @@ -461,58 +448,50 @@ def test_device_state_network_state(data, network_state): assert state.serialize() == new_data -@pytest.mark.asyncio -async def test_reconnect_multiple_disconnects(monkeypatch, caplog): +@patch("zigpy_deconz.uart.connect") +async def test_reconnect_multiple_disconnects(connect_mock, caplog): api = deconz_api.Deconz(None, DEVICE_CONFIG) - connect_mock = CoroutineMock() - connect_mock.return_value = asyncio.Future() - connect_mock.return_value.set_result(True) - monkeypatch.setattr(uart, "connect", connect_mock) + gw = MagicMock(spec_set=uart.Gateway) + connect_mock.return_value = gw await api.connect() caplog.set_level(logging.DEBUG) - connected = asyncio.Future() - connected.set_result(mock.sentinel.uart_reconnect) connect_mock.reset_mock() - connect_mock.side_effect = [asyncio.Future(), connected] + connect_mock.return_value = asyncio.Future() api.connection_lost("connection lost") - await asyncio.sleep(0.3) + await asyncio.sleep(0) + connect_mock.return_value = sentinel.uart_reconnect api.connection_lost("connection lost 2") - await asyncio.sleep(0.3) + await asyncio.sleep(0) + assert api._uart is sentinel.uart_reconnect + assert connect_mock.call_count == 1 assert "Cancelling reconnection attempt" in caplog.messages - assert api._uart is mock.sentinel.uart_reconnect - assert connect_mock.call_count == 2 -@pytest.mark.asyncio -async def test_reconnect_multiple_attempts(monkeypatch, caplog): +@patch("zigpy_deconz.uart.connect") +async def test_reconnect_multiple_attempts(connect_mock, caplog): api = deconz_api.Deconz(None, DEVICE_CONFIG) - connect_mock = CoroutineMock() - connect_mock.return_value = asyncio.Future() - connect_mock.return_value.set_result(True) - monkeypatch.setattr(uart, "connect", connect_mock) + gw = MagicMock(spec_set=uart.Gateway) + connect_mock.return_value = gw await api.connect() caplog.set_level(logging.DEBUG) - connected = asyncio.Future() - connected.set_result(mock.sentinel.uart_reconnect) connect_mock.reset_mock() - connect_mock.side_effect = [asyncio.TimeoutError, OSError, connected] + connect_mock.side_effect = [asyncio.TimeoutError, OSError, gw] - with mock.patch("asyncio.sleep"): + with patch("asyncio.sleep"): api.connection_lost("connection lost") await api._conn_lost_task - assert api._uart is mock.sentinel.uart_reconnect + assert api._uart is gw assert connect_mock.call_count == 3 -@pytest.mark.asyncio -@mock.patch.object(deconz_api.Deconz, "device_state", new_callable=CoroutineMock) -@mock.patch.object(uart, "connect") +@patch.object(deconz_api.Deconz, "device_state", new_callable=AsyncMock) +@patch("zigpy_deconz.uart.connect", return_value=MagicMock(spec_set=uart.Gateway)) async def test_probe_success(mock_connect, mock_device_state): """Test device probing.""" @@ -536,9 +515,8 @@ async def test_probe_success(mock_connect, mock_device_state): assert mock_connect.return_value.close.call_count == 1 -@pytest.mark.asyncio -@mock.patch.object(deconz_api.Deconz, "device_state", new_callable=CoroutineMock) -@mock.patch.object(uart, "connect") +@patch.object(deconz_api.Deconz, "device_state", new_callable=AsyncMock) +@patch("zigpy_deconz.uart.connect", return_value=MagicMock(spec_set=uart.Gateway)) @pytest.mark.parametrize( "exception", (asyncio.TimeoutError, serial.SerialException, zigpy_deconz.exception.CommandError), @@ -583,3 +561,8 @@ def test_tx_status(value, name): assert status == value assert status.value == value assert status.name == name + + +def test_handle_add_neighbour(api): + """Test handle_add_neighbour.""" + api._handle_add_neighbour((12, 1, 0x1234, sentinel.ieee, 0x80)) diff --git a/tests/test_application.py b/tests/test_application.py index a97778a..e54bf09 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -1,10 +1,12 @@ +"""Test application module.""" + import asyncio import logging -from asynctest import CoroutineMock, mock import pytest import zigpy.config import zigpy.device +import zigpy.neighbor from zigpy.types import EUI64 import zigpy.zdo.types as zdo_t @@ -13,6 +15,18 @@ import zigpy_deconz.exception import zigpy_deconz.zigbee.application as application +from .async_mock import AsyncMock, MagicMock, patch, sentinel + +pytestmark = pytest.mark.asyncio +ZIGPY_NWK_CONFIG = { + zigpy.config.CONF_NWK: { + zigpy.config.CONF_NWK_PAN_ID: 0x4567, + zigpy.config.CONF_NWK_EXTENDED_PAN_ID: "11:22:33:44:55:66:77:88", + zigpy.config.CONF_NWK_UPDATE_ID: 22, + zigpy.config.CONF_NWK_KEY: [0xAA] * 16, + } +} + @pytest.fixture def device_path(): @@ -20,17 +34,31 @@ def device_path(): @pytest.fixture -def app(device_path, database_file=None): +def api(): + """Return API fixture.""" + api = MagicMock(spec_set=zigpy_deconz.api.Deconz) + api.device_state = AsyncMock( + return_value=(deconz_api.DeviceState(deconz_api.NetworkState.CONNECTED), 0, 0) + ) + api.write_parameter = AsyncMock() + api.change_network_state = AsyncMock() + return api + + +@pytest.fixture +def app(device_path, api, database_file=None): config = application.ControllerApplication.SCHEMA( { + **ZIGPY_NWK_CONFIG, zigpy.config.CONF_DEVICE: {zigpy.config.CONF_DEVICE_PATH: device_path}, zigpy.config.CONF_DATABASE: database_file, } ) app = application.ControllerApplication(config) - app._api = deconz_api.Deconz(app, config[zigpy.config.CONF_DEVICE]) - return app + p2 = patch.object(app, "_delayed_neighbour_scan") + with patch.object(app, "_api", api), p2: + yield app @pytest.fixture @@ -69,76 +97,76 @@ def addr_nwk_and_ieee(nwk, ieee): def _test_rx(app, addr_ieee, addr_nwk, device, data): - app.get_device = mock.MagicMock(return_value=device) + app.get_device = MagicMock(return_value=device) app.devices = (EUI64(addr_ieee.address),) app.handle_rx( addr_nwk, - mock.sentinel.src_ep, - mock.sentinel.dst_ep, - mock.sentinel.profile_id, - mock.sentinel.cluster_id, + sentinel.src_ep, + sentinel.dst_ep, + sentinel.profile_id, + sentinel.cluster_id, data, - mock.sentinel.lqi, - mock.sentinel.rssi, + sentinel.lqi, + sentinel.rssi, ) def test_rx(app, addr_ieee, addr_nwk): - device = mock.MagicMock() - app.handle_message = mock.MagicMock() - _test_rx(app, addr_ieee, addr_nwk, device, mock.sentinel.args) + 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, - mock.sentinel.profile_id, - mock.sentinel.cluster_id, - mock.sentinel.src_ep, - mock.sentinel.dst_ep, - mock.sentinel.args, + sentinel.profile_id, + sentinel.cluster_id, + sentinel.src_ep, + sentinel.dst_ep, + sentinel.args, ), ) def test_rx_ieee(app, addr_ieee, addr_nwk): - device = mock.MagicMock() - app.handle_message = mock.MagicMock() - _test_rx(app, addr_ieee, addr_ieee, device, mock.sentinel.args) + 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, - mock.sentinel.profile_id, - mock.sentinel.cluster_id, - mock.sentinel.src_ep, - mock.sentinel.dst_ep, - mock.sentinel.args, + 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 = mock.MagicMock() - app.handle_message = mock.MagicMock() - _test_rx(app, addr_ieee, addr_nwk_and_ieee, device, mock.sentinel.args) + 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, - mock.sentinel.profile_id, - mock.sentinel.cluster_id, - mock.sentinel.src_ep, - mock.sentinel.dst_ep, - mock.sentinel.args, + 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 = mock.MagicMock() - app.handle_message = mock.MagicMock() - app.get_device = mock.MagicMock(return_value=device) + device = MagicMock() + app.handle_message = MagicMock() + app.get_device = MagicMock(return_value=device) app.devices = (EUI64(addr_ieee.address),) @@ -146,94 +174,117 @@ def test_rx_wrong_addr_mode(app, addr_ieee, addr_nwk, caplog): addr_nwk.address_mode = 0x22 app.handle_rx( addr_nwk, - mock.sentinel.src_ep, - mock.sentinel.dst_ep, - mock.sentinel.profile_id, - mock.sentinel.cluster_id, + sentinel.src_ep, + sentinel.dst_ep, + sentinel.profile_id, + sentinel.cluster_id, b"", - mock.sentinel.lqi, - mock.sentinel.rssi, + 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 = mock.MagicMock() + app.handle_message = MagicMock() caplog.set_level(logging.DEBUG) app.handle_rx( addr_nwk, - mock.sentinel.src_ep, - mock.sentinel.dst_ep, - mock.sentinel.profile_id, - mock.sentinel.cluster_id, + sentinel.src_ep, + sentinel.dst_ep, + sentinel.profile_id, + sentinel.cluster_id, b"", - mock.sentinel.lqi, - mock.sentinel.rssi, + sentinel.lqi, + sentinel.rssi, ) assert "Received frame from unknown device" in caplog.text assert app.handle_message.call_count == 0 -@pytest.mark.asyncio -async def test_form_network(app): - app._api.change_network_state = CoroutineMock() - app._api.device_state = CoroutineMock( - return_value=deconz_api.NetworkState.CONNECTED - ) +@patch.object(application, "CHANGE_NETWORK_WAIT", 0.001) +async def test_form_network(app, api): + """Test network forming.""" - app._api._device_state = deconz_api.DeviceState(deconz_api.NetworkState.CONNECTED) await app.form_network() - assert app._api.change_network_state.call_count == 0 - assert app._api.change_network_state.await_count == 0 - assert app._api.device_state.await_count == 0 + assert api.change_network_state.await_count == 2 + assert ( + api.change_network_state.call_args_list[0][0][0] + == deconz_api.NetworkState.OFFLINE + ) + assert ( + api.change_network_state.call_args_list[1][0][0] + == deconz_api.NetworkState.CONNECTED + ) + assert api.write_parameter.await_count >= 3 + assert ( + api.write_parameter.await_args_list[0][0][0] + == deconz_api.NetworkParameter.aps_designed_coordinator + ) + assert api.write_parameter.await_args_list[0][0][1] == 1 - app._api._device_state = deconz_api.DeviceState(deconz_api.NetworkState.OFFLINE) - application.CHANGE_NETWORK_WAIT = 0.001 + api.device_state.return_value = ( + deconz_api.DeviceState(deconz_api.NetworkState.JOINING), + 0, + 0, + ) with pytest.raises(Exception): await app.form_network() - assert app._api.change_network_state.call_count == 1 - assert app._api.change_network_state.await_count == 1 - assert app._api.device_state.await_count == 10 - assert app._api.device_state.call_count == 10 @pytest.mark.parametrize( - "protocol_ver, watchdog_cc", [(0x0107, False), (0x0108, True), (0x010B, True)] + "protocol_ver, watchdog_cc, nwk_state, designed_coord, form_count", + [ + (0x0107, False, deconz_api.NetworkState.CONNECTED, 1, 0), + (0x0108, True, deconz_api.NetworkState.CONNECTED, 1, 0), + (0x010B, True, deconz_api.NetworkState.CONNECTED, 1, 0), + (0x010B, True, deconz_api.NetworkState.CONNECTED, 0, 1), + (0x010B, True, deconz_api.NetworkState.OFFLINE, 1, 1), + (0x010B, True, deconz_api.NetworkState.OFFLINE, 0, 1), + ], ) -@pytest.mark.asyncio -async def test_startup(protocol_ver, watchdog_cc, app, monkeypatch, version=0): +async def test_startup( + protocol_ver, watchdog_cc, app, nwk_state, designed_coord, form_count, version=0 +): async def _version(): app._api._proto_ver = protocol_ver return [version] - app._reset_watchdog = CoroutineMock() - app.form_network = CoroutineMock() + async def _read_param(param, *args): + if param == deconz_api.NetworkParameter.mac_address: + return (t.EUI64([0x01] * 8),) + return (designed_coord,) + + app._reset_watchdog = AsyncMock() + app.form_network = AsyncMock() + app._delayed_neighbour_scan = AsyncMock() - app._api._command = CoroutineMock() + app._api._command = AsyncMock() api = deconz_api.Deconz(app, app._config[zigpy.config.CONF_DEVICE]) - api.connect = CoroutineMock() - api._command = CoroutineMock() - api.read_parameter = CoroutineMock(return_value=[[0]]) - api.version = mock.MagicMock(side_effect=_version) - api.write_parameter = CoroutineMock() - - monkeypatch.setattr(application.DeconzDevice, "new", CoroutineMock()) - with mock.patch.object(application, "Deconz", return_value=api): + api.connect = AsyncMock() + api._command = AsyncMock() + api.device_state = AsyncMock(return_value=(deconz_api.DeviceState(nwk_state), 0, 0)) + api.read_parameter = AsyncMock(side_effect=_read_param) + api.version = MagicMock(side_effect=_version) + api.write_parameter = AsyncMock() + + p2 = patch( + "zigpy_deconz.zigbee.application.DeconzDevice.new", + new=AsyncMock(return_value=zigpy.device.Device(app, sentinel.ieee, 0x0000)), + ) + with patch.object(application, "Deconz", return_value=api), p2: await app.startup(auto_form=False) assert app.form_network.call_count == 0 assert app._reset_watchdog.call_count == watchdog_cc await app.startup(auto_form=True) - assert app.form_network.call_count == 1 + assert app.form_network.call_count == form_count -@pytest.mark.asyncio async def test_permit(app, nwk): - app._api.write_parameter = mock.MagicMock( - side_effect=asyncio.coroutine(mock.MagicMock()) - ) + app._api.write_parameter = AsyncMock() time_s = 30 await app.permit_ncp(time_s) assert app._api.write_parameter.call_count == 1 @@ -251,17 +302,16 @@ async def req_mock(req_id, dst_addr_ep, profile, cluster, src_ep, data): else: app._pending[req_id].result.set_result(1) - app._api.aps_data_request = mock.MagicMock(side_effect=req_mock) - device = zigpy.device.Device(app, mock.sentinel.ieee, 0x1122) - app.get_device = mock.MagicMock(return_value=device) + 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.request(device, 0x0260, 1, 2, 3, seq, b"\x01\x02\x03", **kwargs) -@pytest.mark.asyncio async def test_request_send_success(app): - req_id = mock.sentinel.req_id - app.get_sequence = mock.MagicMock(return_value=req_id) + req_id = sentinel.req_id + app.get_sequence = MagicMock(return_value=req_id) r = await _test_request(app, True) assert r[0] == 0 @@ -269,24 +319,22 @@ async def test_request_send_success(app): assert r[0] == 0 -@pytest.mark.asyncio async def test_request_send_fail(app): - req_id = mock.sentinel.req_id - app.get_sequence = mock.MagicMock(return_value=req_id) + req_id = sentinel.req_id + app.get_sequence = MagicMock(return_value=req_id) r = await _test_request(app, False) assert r[0] != 0 -@pytest.mark.asyncio async def test_request_send_aps_data_error(app): - req_id = mock.sentinel.req_id - app.get_sequence = mock.MagicMock(return_value=req_id) + req_id = sentinel.req_id + app.get_sequence = MagicMock(return_value=req_id) r = await _test_request(app, False, aps_data_error=True) assert r[0] != 0 async def _test_broadcast(app, send_success=True, aps_data_error=False, **kwargs): - seq = mock.sentinel.req_id + seq = sentinel.req_id async def req_mock(req_id, dst_addr_ep, profile, cluster, src_ep, data): if aps_data_error: @@ -296,69 +344,65 @@ async def req_mock(req_id, dst_addr_ep, profile, cluster, src_ep, data): else: app._pending[req_id].result.set_result(1) - app._api.aps_data_request = mock.MagicMock(side_effect=req_mock) - app.get_device = mock.MagicMock(spec_set=zigpy.device.Device) + app._api.aps_data_request = MagicMock(side_effect=req_mock) + app.get_device = MagicMock(spec_set=zigpy.device.Device) r = await app.broadcast( - mock.sentinel.profile, - mock.sentinel.cluster, + sentinel.profile, + sentinel.cluster, 2, - mock.sentinel.dst_ep, - mock.sentinel.grp_id, - mock.sentinel.radius, + 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 mock.sentinel.profile - assert app._api.aps_data_request.call_args[0][3] is mock.sentinel.cluster + 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 -@pytest.mark.asyncio async def test_broadcast_send_success(app): - req_id = mock.sentinel.req_id - app.get_sequence = mock.MagicMock(return_value=req_id) + req_id = sentinel.req_id + app.get_sequence = MagicMock(return_value=req_id) r = await _test_broadcast(app, True) assert r[0] == 0 -@pytest.mark.asyncio async def test_broadcast_send_fail(app): - req_id = mock.sentinel.req_id - app.get_sequence = mock.MagicMock(return_value=req_id) + req_id = sentinel.req_id + app.get_sequence = MagicMock(return_value=req_id) r = await _test_broadcast(app, False) assert r[0] != 0 -@pytest.mark.asyncio async def test_broadcast_send_aps_data_error(app): - req_id = mock.sentinel.req_id - app.get_sequence = mock.MagicMock(return_value=req_id) + 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 = mock.MagicMock() + app.handle_message = MagicMock() return app._handle_reply( - mock.sentinel.device, - mock.sentinel.profile, - mock.sentinel.cluster, - mock.sentinel.src_ep, - mock.sentinel.dst_ep, + sentinel.device, + sentinel.profile, + sentinel.cluster, + sentinel.src_ep, + sentinel.dst_ep, tsn, - mock.sentinel.command_id, - mock.sentinel.args, + sentinel.command_id, + sentinel.args, ) -@pytest.mark.asyncio async def test_shutdown(app): - app._api.close = mock.MagicMock() + app._api.close = MagicMock() await app.shutdown() assert app._api.close.call_count == 1 @@ -366,13 +410,13 @@ async def test_shutdown(app): def test_rx_device_annce(app, addr_ieee, addr_nwk): dst_ep = 0 cluster_id = zdo_t.ZDOCmd.Device_annce - device = mock.MagicMock() + device = MagicMock() device.status = zigpy.device.Status.NEW - app.get_device = mock.MagicMock(return_value=device) + app.get_device = MagicMock(return_value=device) - app.handle_join = mock.MagicMock() - app._handle_reply = mock.MagicMock() - app.handle_message = mock.MagicMock() + app.handle_join = MagicMock() + app._handle_reply = MagicMock() + app.handle_message = MagicMock() data = t.uint8_t(0xAA).serialize() data += addr_nwk.address.serialize() @@ -381,13 +425,13 @@ def test_rx_device_annce(app, addr_ieee, addr_nwk): app.handle_rx( addr_nwk, - mock.sentinel.src_ep, + sentinel.src_ep, dst_ep, - mock.sentinel.profile_id, + sentinel.profile_id, cluster_id, data, - mock.sentinel.lqi, - mock.sentinel.rssi, + sentinel.lqi, + sentinel.rssi, ) assert app.handle_message.call_count == 1 @@ -397,44 +441,42 @@ def test_rx_device_annce(app, addr_ieee, addr_nwk): assert app.handle_join.call_args[0][2] == 0 -@pytest.mark.asyncio async def test_deconz_dev_add_to_group(app, nwk, device_path): - group = mock.MagicMock() - app._groups = mock.MagicMock() + group = MagicMock() + app._groups = MagicMock() app._groups.add_group.return_value = group - deconz = application.DeconzDevice(0, device_path, app, mock.sentinel.ieee, nwk) + deconz = application.DeconzDevice(0, device_path, app, sentinel.ieee, nwk) deconz.endpoints = { - 0: mock.sentinel.zdo, - 1: mock.sentinel.ep1, - 2: mock.sentinel.ep2, + 0: sentinel.zdo, + 1: sentinel.ep1, + 2: sentinel.ep2, } - await deconz.add_to_group(mock.sentinel.grp_id, mock.sentinel.grp_name) + await deconz.add_to_group(sentinel.grp_id, sentinel.grp_name) assert group.add_member.call_count == 2 assert app.groups.add_group.call_count == 1 - assert app.groups.add_group.call_args[0][0] is mock.sentinel.grp_id - assert app.groups.add_group.call_args[0][1] is mock.sentinel.grp_name + assert app.groups.add_group.call_args[0][0] is sentinel.grp_id + assert app.groups.add_group.call_args[0][1] is sentinel.grp_name -@pytest.mark.asyncio async def test_deconz_dev_remove_from_group(app, nwk, device_path): - group = mock.MagicMock() - app.groups[mock.sentinel.grp_id] = group - deconz = application.DeconzDevice(0, device_path, app, mock.sentinel.ieee, nwk) + group = MagicMock() + app.groups[sentinel.grp_id] = group + deconz = application.DeconzDevice(0, device_path, app, sentinel.ieee, nwk) deconz.endpoints = { - 0: mock.sentinel.zdo, - 1: mock.sentinel.ep1, - 2: mock.sentinel.ep2, + 0: sentinel.zdo, + 1: sentinel.ep1, + 2: sentinel.ep2, } - await deconz.remove_from_group(mock.sentinel.grp_id) + await deconz.remove_from_group(sentinel.grp_id) assert group.remove_member.call_count == 2 def test_deconz_props(nwk, device_path): - deconz = application.DeconzDevice(0, device_path, app, mock.sentinel.ieee, nwk) + deconz = application.DeconzDevice(0, device_path, app, sentinel.ieee, nwk) assert deconz.manufacturer is not None assert deconz.model is not None @@ -452,53 +494,48 @@ def test_deconz_props(nwk, device_path): ) def test_deconz_name(nwk, name, firmware_version, device_path): deconz = application.DeconzDevice( - firmware_version, device_path, app, mock.sentinel.ieee, nwk + firmware_version, device_path, app, sentinel.ieee, nwk ) assert deconz.model == name -@pytest.mark.asyncio async def test_deconz_new(app, nwk, device_path, monkeypatch): - mock_init = mock.MagicMock(side_effect=asyncio.coroutine(mock.MagicMock())) + mock_init = AsyncMock() monkeypatch.setattr(zigpy.device.Device, "_initialize", mock_init) - deconz = await application.DeconzDevice.new( - app, mock.sentinel.ieee, nwk, 0, device_path - ) + deconz = await application.DeconzDevice.new(app, sentinel.ieee, nwk, 0, device_path) assert isinstance(deconz, application.DeconzDevice) assert mock_init.call_count == 1 mock_init.reset_mock() - mock_dev = mock.MagicMock() + mock_dev = MagicMock() mock_dev.endpoints = { - 0: mock.MagicMock(), - 1: mock.MagicMock(), - 22: mock.MagicMock(), + 0: MagicMock(), + 1: MagicMock(), + 22: MagicMock(), } - app.devices[mock.sentinel.ieee] = mock_dev - deconz = await application.DeconzDevice.new( - app, mock.sentinel.ieee, nwk, 0, device_path - ) + app.devices[sentinel.ieee] = mock_dev + deconz = await application.DeconzDevice.new(app, sentinel.ieee, nwk, 0, device_path) assert isinstance(deconz, application.DeconzDevice) assert mock_init.call_count == 0 def test_tx_confirm_success(app): tsn = 123 - req = app._pending[tsn] = mock.MagicMock() - app.handle_tx_confirm(tsn, mock.sentinel.status) + req = app._pending[tsn] = MagicMock() + app.handle_tx_confirm(tsn, sentinel.status) assert req.result.set_result.call_count == 1 - assert req.result.set_result.call_args[0][0] is mock.sentinel.status + assert req.result.set_result.call_args[0][0] is sentinel.status def test_tx_confirm_dup(app, caplog): caplog.set_level(logging.DEBUG) tsn = 123 - req = app._pending[tsn] = mock.MagicMock() + req = app._pending[tsn] = MagicMock() req.result.set_result.side_effect = asyncio.InvalidStateError - app.handle_tx_confirm(tsn, mock.sentinel.status) + app.handle_tx_confirm(tsn, sentinel.status) assert req.result.set_result.call_count == 1 - assert req.result.set_result.call_args[0][0] is mock.sentinel.status + assert req.result.set_result.call_args[0][0] is sentinel.status assert any(r.levelname == "DEBUG" for r in caplog.records) assert "probably duplicate response" in caplog.text @@ -511,8 +548,8 @@ def test_tx_confirm_unexpcted(app, caplog): async def _test_mrequest(app, send_success=True, aps_data_error=False, **kwargs): seq = 123 - req_id = mock.sentinel.req_id - app.get_sequence = mock.MagicMock(return_value=req_id) + 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: @@ -522,43 +559,121 @@ async def req_mock(req_id, dst_addr_ep, profile, cluster, src_ep, data): else: app._pending[req_id].result.set_result(1) - app._api.aps_data_request = mock.MagicMock(side_effect=req_mock) - device = zigpy.device.Device(app, mock.sentinel.ieee, 0x1122) - app.get_device = mock.MagicMock(return_value=device) + 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) -@pytest.mark.asyncio async def test_mrequest_send_success(app): r = await _test_mrequest(app, True) assert r[0] == 0 -@pytest.mark.asyncio async def test_mrequest_send_fail(app): r = await _test_mrequest(app, False) assert r[0] != 0 -@pytest.mark.asyncio async def test_mrequest_send_aps_data_error(app): r = await _test_mrequest(app, False, aps_data_error=True) assert r[0] != 0 -@pytest.mark.asyncio async def test_reset_watchdog(app): """Test watchdog.""" - with mock.patch.object(app._api, "write_parameter") as mock_api: + with patch.object(app._api, "write_parameter") as mock_api: dog = asyncio.ensure_future(app._reset_watchdog()) await asyncio.sleep(0.3) dog.cancel() assert mock_api.call_count == 1 - with mock.patch.object(app._api, "write_parameter") as mock_api: + with patch.object(app._api, "write_parameter") as mock_api: mock_api.side_effect = zigpy_deconz.exception.CommandError dog = asyncio.ensure_future(app._reset_watchdog()) await asyncio.sleep(0.3) dog.cancel() assert mock_api.call_count == 1 + + +async def test_force_remove(app): + """Test forcibly removing a device.""" + await app.force_remove(sentinel.device) + + +async def test_restore_neighbours(app): + """Test neighbour restoration.""" + + # FFD, Rx on when idle + desc_1 = zdo_t.NodeDescriptor(1, 64, 142, 0xBEEF, 82, 82, 0, 82, 0) + device_1 = MagicMock() + device_1.node_desc = desc_1 + device_1.ieee = sentinel.ieee_1 + device_1.nwk = 0x1111 + nei_1 = zigpy.neighbor.Neighbor(sentinel.nei_1, device_1) + + # RFD, Rx on when idle + desc_2 = zdo_t.NodeDescriptor(1, 64, 142, 0xBEEF, 82, 82, 0, 82, 0) + device_2 = MagicMock() + device_2.node_desc = desc_2 + device_2.ieee = sentinel.ieee_2 + device_2.nwk = 0x2222 + nei_2 = zigpy.neighbor.Neighbor(sentinel.nei_2, device_2) + + # invalid node descriptor + desc_3 = zdo_t.NodeDescriptor() + device_3 = MagicMock() + device_3.node_desc = desc_3 + device_3.ieee = sentinel.ieee_3 + device_3.nwk = 0x3333 + nei_3 = zigpy.neighbor.Neighbor(sentinel.nei_3, device_3) + + # no device + nei_4 = zigpy.neighbor.Neighbor(sentinel.nei_4, None) + + # RFD, Rx off when idle + desc_5 = zdo_t.NodeDescriptor(2, 64, 128, 0xBEEF, 82, 82, 0, 82, 0) + device_5 = MagicMock() + device_5.node_desc = desc_5 + device_5.ieee = sentinel.ieee_5 + device_5.nwk = 0x5555 + nei_5 = zigpy.neighbor.Neighbor(sentinel.nei_5, device_5) + + coord = MagicMock() + coord.ieee = sentinel.coord_ieee + coord.nwk = 0x0000 + neighbours = zigpy.neighbor.Neighbors(coord) + neighbours.neighbors.append(nei_1) + neighbours.neighbors.append(nei_2) + neighbours.neighbors.append(nei_3) + neighbours.neighbors.append(nei_4) + neighbours.neighbors.append(nei_5) + coord.neighbors = neighbours + + p2 = patch.object(app, "_api", spec_set=zigpy_deconz.api.Deconz) + with patch.object(app, "get_device", return_value=coord), p2 as api_mock: + api_mock.add_neighbour = AsyncMock() + await app.restore_neighbours() + + assert api_mock.add_neighbour.call_count == 1 + assert api_mock.add_neighbour.await_count == 1 + + +@patch("zigpy_deconz.zigbee.application.DELAY_NEIGHBOUR_SCAN_S", 0) +async def test_delayed_scan(): + """Delayed scan.""" + + coord = MagicMock() + coord.neighbors.scan = AsyncMock() + config = application.ControllerApplication.SCHEMA( + { + zigpy.config.CONF_DEVICE: {zigpy.config.CONF_DEVICE_PATH: "usb0"}, + zigpy.config.CONF_DATABASE: "tmp", + } + ) + + app = application.ControllerApplication(config) + with patch.object(app, "get_device", return_value=coord): + await app._delayed_neighbour_scan() + assert coord.neighbors.scan.await_count == 1 diff --git a/tox.ini b/tox.ini index 150de6e..1a6af8d 100644 --- a/tox.ini +++ b/tox.ini @@ -4,7 +4,7 @@ # and then run "tox" from this directory. [tox] -envlist = py36, py37, py38, lint, black +envlist = py37, py38, lint, black skip_missing_interpreters = True [testenv] @@ -21,16 +21,17 @@ deps = [testenv:lint] basepython = python3 deps = - flake8 - isort + flake8==3.8.3 + isort==5.5.2 commands = flake8 - isort --check -rc {toxinidir}/zigpy_deconz {toxinidir}/tests {toxinidir}/setup.py + isort --check {toxinidir}/zigpy_deconz {toxinidir}/tests {toxinidir}/setup.py [testenv:black] -deps=black +deps = + black==20.8b1 setenv = LC_ALL=C.UTF-8 LANG=C.UTF-8 commands= - black --check --fast {toxinidir}/zigpy_deconz {toxinidir}/tests {toxinidir}/setup.py + black --check --fast --diff {toxinidir}/zigpy_deconz {toxinidir}/tests {toxinidir}/setup.py diff --git a/zigpy_deconz/__init__.py b/zigpy_deconz/__init__.py index 520096d..edf073c 100644 --- a/zigpy_deconz/__init__.py +++ b/zigpy_deconz/__init__.py @@ -1,6 +1,6 @@ # coding: utf-8 MAJOR_VERSION = 0 -MINOR_VERSION = 9 -PATCH_VERSION = "2" +MINOR_VERSION = 10 +PATCH_VERSION = "0" __short_version__ = "{}.{}".format(MAJOR_VERSION, MINOR_VERSION) __version__ = "{}.{}".format(__short_version__, PATCH_VERSION) diff --git a/zigpy_deconz/api.py b/zigpy_deconz/api.py index 57ed23f..04075d0 100644 --- a/zigpy_deconz/api.py +++ b/zigpy_deconz/api.py @@ -1,12 +1,15 @@ +"""deCONZ serial protocol API.""" + import asyncio import binascii import enum +import functools import logging from typing import Any, Callable, Dict, Optional, Tuple import serial from zigpy.config import CONF_DEVICE_PATH -from zigpy.types import APSStatus +from zigpy.types import APSStatus, Channels from zigpy_deconz.exception import APIException, CommandError import zigpy_deconz.types as t @@ -14,7 +17,7 @@ LOGGER = logging.getLogger(__name__) -COMMAND_TIMEOUT = 1 +COMMAND_TIMEOUT = 1.8 PROBE_TIMEOUT = 2 MIN_PROTO_VERSION = 0x010B @@ -71,6 +74,7 @@ class Command(t.uint8_t, enum.Enum): aps_data_indication = 0x17 zigbee_green_power = 0x19 mac_poll = 0x1C + add_neighbour = 0x1D simplified_beacon = 0x1F @@ -87,6 +91,7 @@ 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_request: ( @@ -104,11 +109,12 @@ def _missing_(cls, value): Command.change_network_state: (t.uint8_t,), Command.device_state: (t.uint8_t, t.uint8_t, t.uint8_t), Command.read_parameter: (t.uint16_t, t.uint8_t, t.Bytes), - Command.version: (), + Command.version: (t.uint32_t,), Command.write_parameter: (t.uint16_t, t.uint8_t, t.Bytes), } RX_COMMANDS = { + Command.add_neighbour: ((t.uint16_t, t.uint8_t, t.NWK, t.EUI64, t.uint8_t), True), Command.aps_data_confirm: ( ( t.uint16_t, @@ -186,7 +192,7 @@ class NetworkParameter(t.uint8_t, enum.Enum): NetworkParameter.nwk_address: (t.NWK,), NetworkParameter.nwk_extended_panid: (t.ExtendedPanId,), NetworkParameter.aps_designed_coordinator: (t.uint8_t,), - NetworkParameter.channel_mask: (t.uint32_t,), + NetworkParameter.channel_mask: (Channels,), NetworkParameter.aps_extended_panid: (t.ExtendedPanId,), NetworkParameter.trust_center_address: (t.EUI64,), NetworkParameter.security_mode: (t.uint8_t,), @@ -200,7 +206,10 @@ class NetworkParameter(t.uint8_t, enum.Enum): class Deconz: + """deCONZ API class.""" + def __init__(self, app: Callable, device_config: Dict[str, Any]): + """Init instance.""" self._app = app self._aps_data_ind_flags: int = 0x01 self._awaiting = {} @@ -212,15 +221,21 @@ def __init__(self, app: Callable, device_config: Dict[str, Any]): self._device_state = DeviceState(NetworkState.OFFLINE) self._seq = 1 self._proto_ver: Optional[int] = None + self._firmware_version: Optional[int] = None self._uart: Optional[zigpy_deconz.uart.Gateway] = None + @property + def firmware_version(self) -> Optional[int]: + """Return ConBee firmware version.""" + return self._firmware_version + @property def network_state(self) -> NetworkState: """Return current network state.""" return self._device_state.network_state @property - def protocol_version(self): + def protocol_version(self) -> Optional[int]: """Protocol Version.""" return self._proto_ver @@ -335,16 +350,16 @@ def data_received(self, data): fut.set_result(data) getattr(self, "_handle_%s" % (command.name,))(data) - def device_state(self): - return self._command(Command.device_state, 0, 0, 0) + add_neighbour = functools.partialmethod(_command, Command.add_neighbour, 12) + device_state = functools.partialmethod(_command, Command.device_state, 0, 0, 0) + change_network_state = functools.partialmethod( + _command, Command.change_network_state + ) def _handle_device_state(self, data): LOGGER.debug("Device state response: %s", data) self._handle_device_state_value(data[0]) - def change_network_state(self, state): - return self._command(Command.change_network_state, state) - def _handle_change_network_state(self, data): LOGGER.debug("Change network state response: %s", NetworkState(data[0]).name) @@ -367,7 +382,7 @@ async def probe(cls, device_config: Dict[str, Any]) -> bool: return False async def _probe(self) -> None: - """Open port and try sending a command""" + """Open port and try sending a command.""" await self.connect() await self.device_state() self.close() @@ -418,13 +433,13 @@ def _handle_write_parameter(self, data): async def version(self): (self._proto_ver,) = await self[NetworkParameter.protocol_version] - version = await self._command(Command.version) + (self._firmware_version,) = await self._command(Command.version, 0) if ( self.protocol_version >= MIN_PROTO_VERSION - and (version[0] & 0x0000FF00) == 0x00000500 + and (self.firmware_version & 0x0000FF00) == 0x00000500 ): self._aps_data_ind_flags = 0x04 - return version[0] + return self.firmware_version def _handle_version(self, data): LOGGER.debug("Version response: %x", data[0]) @@ -515,6 +530,10 @@ async def _aps_data_confirm(self): except asyncio.TimeoutError: self._data_confirm = False + def _handle_add_neighbour(self, data) -> None: + """Handle add_neighbour response.""" + LOGGER.debug("add neighbour response: %s", data) + def _handle_aps_data_confirm(self, data): LOGGER.debug( "APS data confirm response for request with id %s: %02x", data[2], data[5] @@ -561,7 +580,9 @@ def _handle_device_state_value(self, state: DeviceState) -> None: asyncio.ensure_future(self._aps_data_confirm()) def __getitem__(self, key): + """Access parameters via getitem.""" return self.read_parameter(key) def __setitem__(self, key, value): + """Set parameters via setitem.""" return asyncio.ensure_future(self.write_parameter(key, value)) diff --git a/zigpy_deconz/zigbee/application.py b/zigpy_deconz/zigbee/application.py index 860b974..5a40a43 100644 --- a/zigpy_deconz/zigbee/application.py +++ b/zigpy_deconz/zigbee/application.py @@ -1,3 +1,5 @@ +"""ControllerApplication for deCONZ protocol based adapters.""" + import asyncio import binascii import logging @@ -9,6 +11,7 @@ import zigpy.device import zigpy.endpoint import zigpy.exceptions +import zigpy.neighbor import zigpy.types import zigpy.util @@ -20,8 +23,10 @@ LOGGER = logging.getLogger(__name__) CHANGE_NETWORK_WAIT = 1 +DELAY_NEIGHBOUR_SCAN_S = 1500 SEND_CONFIRM_TIMEOUT = 60 PROTO_VER_WATCHDOG = 0x0108 +PROTO_VER_NEIGBOURS = 0x0107 WATCHDOG_TTL = 600 @@ -32,6 +37,8 @@ class ControllerApplication(zigpy.application.ControllerApplication): probe = Deconz.probe def __init__(self, config: Dict[str, Any]): + """Initialize instance.""" + super().__init__(config=zigpy.config.ZIGPY_SCHEMA(config)) self._api = None self._pending = zigpy.util.Requests() @@ -53,31 +60,37 @@ async def shutdown(self): self._api.close() async def startup(self, auto_form=False): - """Perform a complete application startup""" + """Perform a complete application startup.""" self._api = Deconz(self, self._config[zigpy.config.CONF_DEVICE]) await self._api.connect() self.version = await self._api.version() await self._api.device_state() (ieee,) = await self._api[NetworkParameter.mac_address] self._ieee = zigpy.types.EUI64(ieee) - await self._api[NetworkParameter.nwk_panid] - await self._api[NetworkParameter.nwk_address] - await self._api[NetworkParameter.nwk_extended_panid] + + if self._api.protocol_version >= PROTO_VER_WATCHDOG: + asyncio.ensure_future(self._reset_watchdog()) + + (designed_coord,) = await self._api[NetworkParameter.aps_designed_coordinator] + device_state, _, _ = await self._api.device_state() + should_form = ( + device_state.network_state != NetworkState.CONNECTED or designed_coord != 1 + ) + if auto_form and should_form: + await self.form_network() + + (self._pan_id,) = await self._api[NetworkParameter.nwk_panid] + (self._nwk,) = await self._api[NetworkParameter.nwk_address] + (self._ext_pan_id,) = await self._api[NetworkParameter.nwk_extended_panid] await self._api[NetworkParameter.channel_mask] await self._api[NetworkParameter.aps_extended_panid] await self._api[NetworkParameter.trust_center_address] await self._api[NetworkParameter.security_mode] - await self._api[NetworkParameter.current_channel] + (self._channel,) = await self._api[NetworkParameter.current_channel] await self._api[NetworkParameter.protocol_version] - await self._api[NetworkParameter.nwk_update_id] - self._api[NetworkParameter.aps_designed_coordinator] = 1 - - if self._api.protocol_version >= PROTO_VER_WATCHDOG: - asyncio.ensure_future(self._reset_watchdog()) + (self._nwk_update_id,) = await self._api[NetworkParameter.nwk_update_id] - if auto_form: - await self.form_network() - self.devices[self.ieee] = await DeconzDevice.new( + coordinator = await DeconzDevice.new( self, self.ieee, self.nwk, @@ -85,19 +98,54 @@ async def startup(self, auto_form=False): self._config[zigpy.config.CONF_DEVICE][zigpy.config.CONF_DEVICE_PATH], ) + coordinator.neighbors.add_context_listener(self._dblistener) + self.devices[self.ieee] = coordinator + if self._api.protocol_version >= PROTO_VER_NEIGBOURS: + await self.restore_neighbours() + asyncio.create_task(self._delayed_neighbour_scan()) + async def force_remove(self, dev): """Forcibly remove device from NCP.""" pass - async def form_network(self, channel=15, pan_id=None, extended_pan_id=None): + async def form_network(self): LOGGER.info("Forming network") - if self._api.network_state == NetworkState.CONNECTED.value: - return + await self._api.change_network_state(NetworkState.OFFLINE) + await self._api.write_parameter(NetworkParameter.aps_designed_coordinator, 1) + + nwk_config = self.config[zigpy.config.CONF_NWK] + + # set channel + channel = nwk_config.get(zigpy.config.CONF_NWK_CHANNEL) + if channel is not None: + channel_mask = zigpy.types.Channels.from_channel_list([channel]) + else: + channel_mask = nwk_config[zigpy.config.CONF_NWK_CHANNELS] + await self._api.write_parameter(NetworkParameter.channel_mask, channel_mask) + + pan_id = nwk_config[zigpy.config.CONF_NWK_PAN_ID] + if pan_id is not None: + await self._api.write_parameter(NetworkParameter.nwk_panid, pan_id) + + ext_pan_id = nwk_config[zigpy.config.CONF_NWK_EXTENDED_PAN_ID] + if ext_pan_id is not None: + await self._api.write_parameter( + NetworkParameter.aps_extended_panid, ext_pan_id + ) + + nwk_update_id = nwk_config[zigpy.config.CONF_NWK_UPDATE_ID] + await self._api.write_parameter(NetworkParameter.nwk_update_id, nwk_update_id) + + nwk_key = nwk_config[zigpy.config.CONF_NWK_KEY] + if nwk_key is not None: + await self._api.write_parameter(NetworkParameter.network_key, 0, nwk_key) + + # bring network up + await self._api.change_network_state(NetworkState.CONNECTED) - await self._api.change_network_state(NetworkState.CONNECTED.value) for _ in range(10): - await self._api.device_state() - if self._api.network_state == NetworkState.CONNECTED.value: + (state, _, _) = await self._api.device_state() + if state.network_state == NetworkState.CONNECTED: return await asyncio.sleep(CHANGE_NETWORK_WAIT) raise Exception("Could not form network.") @@ -290,11 +338,48 @@ def handle_tx_confirm(self, req_id, status): "Invalid state on future - probably duplicate response: %s", exc ) + async def restore_neighbours(self) -> None: + """Restore children.""" + coord = self.get_device(ieee=self.ieee) + devices = (nei.device for nei in coord.neighbors) + for device in devices: + if device is None: + continue + LOGGER.debug( + "device: 0x%04x - %s %s, FFD=%s, Rx_on_when_idle=%s", + device.nwk, + device.manufacturer, + device.model, + device.node_desc.is_full_function_device, + device.node_desc.is_receiver_on_when_idle, + ) + descr = device.node_desc + if not descr.is_valid: + continue + if descr.is_full_function_device or descr.is_receiver_on_when_idle: + continue + LOGGER.debug( + "Restoring %s/0x%04x device as direct child", + device.ieee, + device.nwk, + ) + await self._api.add_neighbour( + 0x01, device.nwk, device.ieee, descr.mac_capability_flags + ) + + async def _delayed_neighbour_scan(self) -> None: + """Scan coordinator's neighbours.""" + await asyncio.sleep(DELAY_NEIGHBOUR_SCAN_S) + coord = self.get_device(ieee=self.ieee) + await coord.neighbors.scan() + class DeconzDevice(zigpy.device.Device): """Zigpy Device representing Coordinator.""" def __init__(self, version: int, device_path: str, *args): + """Initialize instance.""" + super().__init__(*args) is_gpio_device = re.match(r"/dev/tty(S|AMA)\d+", device_path) self._model = "RaspBee" if is_gpio_device else "ConBee" @@ -333,6 +418,9 @@ async def new(cls, application, ieee, nwk, version: int, device_path: str): from_dev = application.get_device(ieee=ieee) dev.status = from_dev.status dev.node_desc = from_dev.node_desc + dev.neighbors = zigpy.neighbor.Neighbors(dev) + for nei in from_dev.neighbors.neighbors: + dev.neighbors.add_neighbor(nei.neighbor) for ep_id, from_ep in from_dev.endpoints.items(): if not ep_id: continue # Skip ZDO