diff --git a/setup.cfg b/setup.cfg index 712ca4a..5550042 100644 --- a/setup.cfg +++ b/setup.cfg @@ -20,3 +20,6 @@ force_sort_within_sections = true known_first_party = zigpy_deconz,tests forced_separate = tests combine_as_imports = true + +[tool:pytest] +asyncio_mode = auto diff --git a/setup.py b/setup.py index f35e125..7cdb769 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.40.0"], - tests_require=["pytest", "pytest-asyncio", "asynctest"], + install_requires=["pyserial-asyncio", "zigpy>=0.47.0"], + tests_require=["pytest", "pytest-asyncio>=0.17", "asynctest"], ) diff --git a/tests/test_api.py b/tests/test_api.py index 91c7d66..8e1c31f 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -14,7 +14,6 @@ from .async_mock import AsyncMock, MagicMock, patch, sentinel -pytestmark = pytest.mark.asyncio DEVICE_CONFIG = {zigpy.config.CONF_DEVICE_PATH: "/dev/null"} diff --git a/tests/test_application.py b/tests/test_application.py index 8cee602..3129994 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -7,7 +7,7 @@ import zigpy.config import zigpy.device import zigpy.neighbor -from zigpy.types import EUI64, Channels +from zigpy.types import EUI64 import zigpy.zdo.types as zdo_t from zigpy_deconz import types as t @@ -18,7 +18,6 @@ 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, @@ -38,28 +37,44 @@ def device_path(): @pytest.fixture def api(): """Return API fixture.""" - api = MagicMock(spec_set=zigpy_deconz.api.Deconz) + api = MagicMock(spec_set=zigpy_deconz.api.Deconz(None, None)) api.device_state = AsyncMock( return_value=(deconz_api.DeviceState(deconz_api.NetworkState.CONNECTED), 0, 0) ) api.write_parameter = AsyncMock() - api.change_network_state = AsyncMock() + + # So the protocol version is effectively infinite + api._proto_ver.__ge__.return_value = True + api._proto_ver.__lt__.return_value = False + + api.protocol_version.__ge__.return_value = True + api.protocol_version.__lt__.return_value = False + return api @pytest.fixture -def app(device_path, api, database_file=None): +def app(device_path, api): 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) + + api.change_network_state = AsyncMock() + + device_state = MagicMock() + device_state.network_state.__eq__.return_value = True + api.device_state = AsyncMock(return_value=(device_state, 0, 0)) + + p1 = patch.object(app, "_api", api) p2 = patch.object(app, "_delayed_neighbour_scan") - with patch.object(app, "_api", api), p2: + p3 = patch.object(app, "_change_network_state", wraps=app._change_network_state) + + with p1, p2, p3: yield app @@ -207,106 +222,50 @@ def test_rx_unknown_device(app, addr_ieee, addr_nwk, caplog): assert app.handle_message.call_count == 0 -@patch.object(application, "CHANGE_NETWORK_WAIT", 0.001) -async def test_form_network(app, api): - """Test network forming.""" - - await app.form_network() - 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 - - api.device_state.return_value = ( - deconz_api.DeviceState(deconz_api.NetworkState.JOINING), - 0, - 0, - ) - with pytest.raises(Exception): - await app.form_network() - - @pytest.mark.parametrize( - "protocol_ver, watchdog_cc, nwk_state, designed_coord, form_count", + "proto_ver, nwk_state, error", [ - (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), + (0x0107, deconz_api.NetworkState.CONNECTED, None), + (0x0106, deconz_api.NetworkState.CONNECTED, None), + (0x0107, deconz_api.NetworkState.OFFLINE, None), + (0x0107, deconz_api.NetworkState.OFFLINE, asyncio.TimeoutError()), ], ) -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] - - params = { - deconz_api.NetworkParameter.aps_designed_coordinator: [designed_coord], - deconz_api.NetworkParameter.nwk_address: [designed_coord], - deconz_api.NetworkParameter.protocol_version: [protocol_ver], - deconz_api.NetworkParameter.mac_address: [EUI64([0x01] * 8)], - deconz_api.NetworkParameter.nwk_address: [0x0000], - deconz_api.NetworkParameter.nwk_panid: [0x1234], - deconz_api.NetworkParameter.nwk_extended_panid: [EUI64([0x02] * 8)], - deconz_api.NetworkParameter.channel_mask: [Channels.CHANNEL_25], - deconz_api.NetworkParameter.aps_extended_panid: [EUI64([0x02] * 8)], - deconz_api.NetworkParameter.network_key: [0, t.Key([0x03] * 16)], - deconz_api.NetworkParameter.trust_center_address: [EUI64([0x04] * 8)], - deconz_api.NetworkParameter.link_key: [ - EUI64([0x04] * 8), - t.Key(b"ZigBeeAlliance09"), - ], - deconz_api.NetworkParameter.security_mode: [3], - deconz_api.NetworkParameter.current_channel: [25], - deconz_api.NetworkParameter.nwk_update_id: [0], - } +async def test_start_network(app, proto_ver, nwk_state, error): + app.load_network_info = AsyncMock() + app.restore_neighbours = AsyncMock() + app.add_endpoint = AsyncMock() + app._change_network_state = AsyncMock(side_effect=error) + + app._api.device_state = AsyncMock( + return_value=(deconz_api.DeviceState(nwk_state), 0, 0) + ) + app._api._proto_ver = proto_ver + app._api.protocol_version = proto_ver - async def _read_param(param, *args): - try: - return params[param] - except KeyError: - raise zigpy_deconz.exception.CommandError( - deconz_api.Status.UNSUPPORTED, "Unsupported" - ) + if nwk_state != deconz_api.NetworkState.CONNECTED and error is not None: + with pytest.raises(zigpy.exceptions.FormationFailure): + await app.start_network() - app._reset_watchdog = AsyncMock() - app.form_network = AsyncMock() - app._delayed_neighbour_scan = AsyncMock() - - app._api._command = AsyncMock() - api = deconz_api.Deconz(app, app._config[zigpy.config.CONF_DEVICE]) - 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() + return - 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 == form_count + with patch.object(application.DeconzDevice, "initialize", AsyncMock()): + await app.start_network() + assert app.load_network_info.await_count == 1 + + if nwk_state != deconz_api.NetworkState.CONNECTED: + assert app._change_network_state.await_count == 1 + assert ( + app._change_network_state.await_args_list[0][0][0] + == deconz_api.NetworkState.CONNECTED + ) + else: + assert app._change_network_state.await_count == 0 + + if proto_ver >= application.PROTO_VER_NEIGBOURS: + assert app.restore_neighbours.await_count == 1 + else: + assert app.restore_neighbours.await_count == 0 async def test_permit(app, nwk): @@ -439,10 +398,48 @@ def _handle_reply(app, tsn): ) -async def test_shutdown(app): +async def test_connect(app): + def new_api(*args): + api = MagicMock() + api.connect = AsyncMock() + api.version = AsyncMock(return_value=sentinel.version) + + return api + + with patch.object(application, "Deconz", new=new_api): + app._api = None + await app.connect() + assert app._api is not None + + assert app._api.connect.await_count == 1 + assert app._api.version.await_count == 1 + assert app.version is sentinel.version + + +async def test_disconnect(app): + app._reset_watchdog_task = MagicMock() app._api.close = MagicMock() - await app.shutdown() + + await app.disconnect() assert app._api.close.call_count == 1 + assert app._reset_watchdog_task.cancel.call_count == 1 + + +async def test_disconnect_no_api(app): + app._api = None + await app.disconnect() + + +async def test_disconnect_close_error(app): + app._api.write_parameter = MagicMock( + side_effect=zigpy_deconz.exception.CommandError(1, "Error") + ) + await app.disconnect() + + +async def test_permit_with_key_not_implemented(app): + with pytest.raises(NotImplementedError): + await app.permit_with_key(node=MagicMock(), code=b"abcdef") def test_rx_device_annce(app, addr_ieee, addr_nwk): @@ -622,14 +619,14 @@ async def test_mrequest_send_aps_data_error(app): async def test_reset_watchdog(app): """Test watchdog.""" with patch.object(app._api, "write_parameter") as mock_api: - dog = asyncio.ensure_future(app._reset_watchdog()) + dog = asyncio.create_task(app._reset_watchdog()) await asyncio.sleep(0.3) dog.cancel() assert mock_api.call_count == 1 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()) + dog = asyncio.create_task(app._reset_watchdog()) await asyncio.sleep(0.3) dog.cancel() assert mock_api.call_count == 1 @@ -688,7 +685,7 @@ async def test_restore_neighbours(app): neighbours.neighbors.append(nei_5) coord.neighbors = neighbours - p2 = patch.object(app, "_api", spec_set=zigpy_deconz.api.Deconz) + p2 = patch.object(app, "_api", spec_set=zigpy_deconz.api.Deconz(None, None)) with patch.object(app, "get_device", return_value=coord), p2 as api_mock: api_mock.add_neighbour = AsyncMock() await app.restore_neighbours() @@ -756,3 +753,127 @@ async def req_mock( 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): + app._reset_watchdog_task = MagicMock() + + app._api.device_state = AsyncMock( + side_effect=[ + (deconz_api.DeviceState(deconz_api.NetworkState.OFFLINE), 0, 0), + (deconz_api.DeviceState(deconz_api.NetworkState.JOINING), 0, 0), + (deconz_api.DeviceState(deconz_api.NetworkState.CONNECTED), 0, 0), + ] + ) + + if support_watchdog: + app._api._proto_ver = application.PROTO_VER_WATCHDOG + app._api.protocol_version = application.PROTO_VER_WATCHDOG + else: + app._api._proto_ver = application.PROTO_VER_WATCHDOG - 1 + app._api.protocol_version = application.PROTO_VER_WATCHDOG - 1 + + old_watchdog_task = app._reset_watchdog_task + cancel_mock = app._reset_watchdog_task.cancel = MagicMock() + + await app._change_network_state(deconz_api.NetworkState.CONNECTED, timeout=0.01) + + if support_watchdog: + assert cancel_mock.call_count == 1 + assert app._reset_watchdog_task is not old_watchdog_task + else: + assert cancel_mock.call_count == 0 + assert app._reset_watchdog_task is old_watchdog_task + + +ENDPOINT = zdo_t.SimpleDescriptor( + endpoint=None, + profile=1, + device_type=2, + device_version=3, + input_clusters=[4], + output_clusters=[5], +) + + +@pytest.mark.parametrize( + "descriptor, slots, target_slot", + [ + (ENDPOINT.replace(endpoint=1), {0: ENDPOINT.replace(endpoint=2), 1: None}, 1), + # Prefer the endpoint with the same ID + ( + ENDPOINT.replace(endpoint=1), + {0: ENDPOINT.replace(endpoint=1, profile=1234), 1: None}, + 0, + ), + ], +) +async def test_add_endpoint(app, descriptor, slots, target_slot): + async def read_param(param_id, index): + assert param_id == deconz_api.NetworkParameter.configure_endpoint + assert index in (0x00, 0x01) + + if slots[index] is None: + raise zigpy_deconz.exception.CommandError( + deconz_api.Status.UNSUPPORTED, "Unsupported" + ) + else: + return index, slots[index] + + app._api.read_parameter = AsyncMock(side_effect=read_param) + app._api.write_parameter = AsyncMock() + + if target_slot is None: + with pytest.raises(ValueError): + await app.add_endpoint(descriptor) + + app._api.write_parameter.assert_not_called() + + return + + await app.add_endpoint(descriptor) + app._api.write_parameter.assert_called_once_with( + deconz_api.NetworkParameter.configure_endpoint, target_slot, descriptor + ) + + +async def test_add_endpoint_no_free_space(app): + async def read_param(param_id, index): + assert param_id == deconz_api.NetworkParameter.configure_endpoint + assert index in (0x00, 0x01) + + raise zigpy_deconz.exception.CommandError( + deconz_api.Status.UNSUPPORTED, "Unsupported" + ) + + app._api.read_parameter = AsyncMock(side_effect=read_param) + app._api.write_parameter = AsyncMock() + app._written_endpoints.add(0x00) + app._written_endpoints.add(0x01) + + with pytest.raises(ValueError): + await app.add_endpoint(ENDPOINT.replace(endpoint=1)) + + app._api.write_parameter.assert_not_called() + + +async def test_add_endpoint_no_unnecessary_writes(app): + async def read_param(param_id, index): + assert param_id == deconz_api.NetworkParameter.configure_endpoint + assert index in (0x00, 0x01) + + return index, ENDPOINT.replace(endpoint=1) + + app._api.read_parameter = AsyncMock(side_effect=read_param) + app._api.write_parameter = AsyncMock() + + await app.add_endpoint(ENDPOINT.replace(endpoint=1)) + app._api.write_parameter.assert_not_called() + + # Writing another endpoint will cause a write + await app.add_endpoint(ENDPOINT.replace(endpoint=2)) + app._api.write_parameter.assert_called_once_with( + deconz_api.NetworkParameter.configure_endpoint, 1, ENDPOINT.replace(endpoint=2) + ) diff --git a/tests/test_network_state.py b/tests/test_network_state.py new file mode 100644 index 0000000..c4c2d6a --- /dev/null +++ b/tests/test_network_state.py @@ -0,0 +1,297 @@ +"""Test `load_network_info` and `write_network_info` methods.""" + +import pytest +from zigpy.exceptions import NetworkNotFormed +import zigpy.state as app_state +import zigpy.types as t +import zigpy.zdo.types as zdo_t + +import zigpy_deconz +import zigpy_deconz.api +import zigpy_deconz.exception +import zigpy_deconz.zigbee.application as application + +from tests.async_mock import AsyncMock, patch +from tests.test_application import api, app, device_path # noqa: F401 + + +def merge_objects(obj: object, update: dict) -> None: + for key, value in update.items(): + if "." not in key: + setattr(obj, key, value) + else: + subkey, rest = key.split(".", 1) + merge_objects(getattr(obj, subkey), {rest: value}) + + +@pytest.fixture +def node_info(): + return app_state.NodeInfo( + nwk=t.NWK(0x0000), + ieee=t.EUI64.convert("93:2C:A9:34:D9:D0:5D:12"), + logical_type=zdo_t.LogicalType.Coordinator, + ) + + +@pytest.fixture +def network_info(node_info): + return app_state.NetworkInfo( + extended_pan_id=t.ExtendedPanId.convert("0D:49:91:99:AE:CD:3C:35"), + pan_id=t.PanId(0x9BB0), + nwk_update_id=0x12, + nwk_manager_id=t.NWK(0x0000), + channel=t.uint8_t(15), + channel_mask=t.Channels.from_channel_list([15, 20, 25]), + security_level=t.uint8_t(5), + network_key=app_state.Key( + key=t.KeyData.convert("9A:79:D6:9A:DA:EC:45:C6:F2:EF:EB:AF:DA:A3:07:B6"), + seq=108, + tx_counter=39009277, + ), + tc_link_key=app_state.Key( + key=t.KeyData(b"ZigBeeAlliance09"), + partner_ieee=node_info.ieee, + tx_counter=8712428, + ), + key_table=[], + children=[], + nwk_addresses={}, + stack_specific={}, + source=f"zigpy-deconz@{zigpy_deconz.__version__}", + metadata={ + "deconz": { + "version": 0, + } + }, + ) + + +@patch.object(application, "CHANGE_NETWORK_WAIT", 0.001) +@pytest.mark.parametrize( + "channel_mask, channel, security_level, fw_supports_fc, logical_type", + [ + ( + t.Channels.from_channel_list([15]), + 15, + 0, + True, + zdo_t.LogicalType.Coordinator, + ), + ( + t.Channels.from_channel_list([15]), + 15, + 0, + False, + zdo_t.LogicalType.Coordinator, + ), + ( + t.Channels.from_channel_list([15, 20]), + 15, + 5, + True, + zdo_t.LogicalType.Coordinator, + ), + ( + t.Channels.from_channel_list([15, 20, 25]), + None, + 5, + True, + zdo_t.LogicalType.Router, + ), + (None, 15, 5, True, zdo_t.LogicalType.Coordinator), + ], +) +async def test_write_network_info( + app, # noqa: F811 + network_info, + node_info, + channel_mask, + channel, + security_level, + fw_supports_fc, + logical_type, +): + """Test that network info is correctly written.""" + + params = {} + + async def write_parameter(param, *args): + if ( + not fw_supports_fc + and param == zigpy_deconz.api.NetworkParameter.nwk_frame_counter + ): + raise zigpy_deconz.exception.CommandError( + status=zigpy_deconz.api.Status.UNSUPPORTED + ) + + params[param.name] = args + + app._api.write_parameter = AsyncMock(side_effect=write_parameter) + + network_info = network_info.replace( + channel=channel, + channel_mask=channel_mask, + security_level=security_level, + ) + + node_info = node_info.replace(logical_type=logical_type) + + await app.write_network_info( + network_info=network_info, + node_info=node_info, + ) + + params = { + call[0][0].name: call[0][1:] + for call in app._api.write_parameter.await_args_list + } + + assert params["nwk_frame_counter"] == (network_info.network_key.tx_counter,) + + if node_info.logical_type == zdo_t.LogicalType.Coordinator: + assert params["aps_designed_coordinator"] == (1,) + else: + assert params["aps_designed_coordinator"] == (0,) + + assert params["nwk_address"] == (node_info.nwk,) + assert params["mac_address"] == (node_info.ieee,) + + if channel is not None: + assert params["channel_mask"] == ( + t.Channels.from_channel_list([network_info.channel]), + ) + elif channel_mask is not None: + assert params["channel_mask"] == (network_info.channel_mask,) + else: + assert False + + assert params["use_predefined_nwk_panid"] == (True,) + assert params["nwk_panid"] == (network_info.pan_id,) + assert params["aps_extended_panid"] == (network_info.extended_pan_id,) + assert params["nwk_update_id"] == (network_info.nwk_update_id,) + assert params["network_key"] == (0, network_info.network_key.key) + assert params["trust_center_address"] == (node_info.ieee,) + assert params["link_key"] == (node_info.ieee, network_info.tc_link_key.key) + + if security_level == 0: + assert params["security_mode"] == (zigpy_deconz.api.SecurityMode.NO_SECURITY,) + else: + assert params["security_mode"] == (zigpy_deconz.api.SecurityMode.ONLY_TCLK,) + + +@patch.object(application, "CHANGE_NETWORK_WAIT", 0.001) +@pytest.mark.parametrize( + "error, param_overrides, nwk_state_changes, node_state_changes", + [ + (None, {}, {}, {}), + ( + None, + {("aps_designed_coordinator",): [0x00]}, + {}, + {"logical_type": zdo_t.LogicalType.Router}, + ), + ( + None, + { + ("aps_extended_panid",): [t.EUI64.convert("00:00:00:00:00:00:00:00")], + ("nwk_extended_panid",): [t.EUI64.convert("0D:49:91:99:AE:CD:3C:35")], + }, + {}, + {}, + ), + (NetworkNotFormed, {("current_channel",): [0]}, {}, {}), + ( + None, + { + ("nwk_frame_counter",): zigpy_deconz.exception.CommandError( + zigpy_deconz.api.Status.UNSUPPORTED + ) + }, + {"network_key.tx_counter": 0}, + {}, + ), + ( + None, + {("security_mode",): [zigpy_deconz.api.SecurityMode.NO_SECURITY]}, + {"security_level": 0}, + {}, + ), + ( + None, + { + ("security_mode",): [ + zigpy_deconz.api.SecurityMode.PRECONFIGURED_NETWORK_KEY + ] + }, + {"security_level": 5}, + {}, + ), + ], +) +async def test_load_network_info( + app, # noqa: F811 + network_info, + node_info, + error, + param_overrides, + nwk_state_changes, + node_state_changes, +): + """Test that network info is correctly read.""" + + params = { + ("nwk_frame_counter",): [network_info.network_key.tx_counter], + ("aps_designed_coordinator",): [1], + ("nwk_address",): [node_info.nwk], + ("mac_address",): [node_info.ieee], + ("current_channel",): [network_info.channel], + ("channel_mask",): [t.Channels.from_channel_list([network_info.channel])], + ("use_predefined_nwk_panid",): [True], + ("nwk_panid",): [network_info.pan_id], + ("aps_extended_panid",): [network_info.extended_pan_id], + ("nwk_update_id",): [network_info.nwk_update_id], + ("network_key", 0): [0, network_info.network_key.key], + ("trust_center_address",): [node_info.ieee], + ("link_key", node_info.ieee): [node_info.ieee, network_info.tc_link_key.key], + ("security_mode",): [zigpy_deconz.api.SecurityMode.ONLY_TCLK], + } + + params.update(param_overrides) + + async def read_param(param, *args): + try: + value = params[(param.name,) + args] + except KeyError: + raise zigpy_deconz.exception.CommandError( + zigpy_deconz.api.Status.UNSUPPORTED, f"Unsupported: {param!r} {args!r}" + ) + + if isinstance(value, Exception): + raise value + + return value + + app._api.__getitem__ = app._api.read_parameter = AsyncMock(side_effect=read_param) + + if error is not None: + with pytest.raises(error): + await app.load_network_info() + + return + + assert app.state.network_info != network_info + assert app.state.node_info != node_info + + await app.load_network_info() + + # Almost all of the info matches + network_info = network_info.replace( + channel_mask=t.Channels.from_channel_list([network_info.channel]), + network_key=network_info.network_key.replace(seq=0), + tc_link_key=network_info.tc_link_key.replace(tx_counter=0), + ) + merge_objects(network_info, nwk_state_changes) + + assert app.state.network_info == network_info + + assert app.state.node_info == node_info.replace(**node_state_changes) diff --git a/zigpy_deconz/api.py b/zigpy_deconz/api.py index 7120ce2..13cfbea 100644 --- a/zigpy_deconz/api.py +++ b/zigpy_deconz/api.py @@ -1,16 +1,19 @@ """deCONZ serial protocol API.""" +from __future__ import annotations + import asyncio import binascii import enum import functools import logging -from typing import Any, Callable, Dict, Optional, Tuple +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 +from zigpy.zdo.types import SimpleDescriptor from zigpy_deconz.exception import APIException, CommandError import zigpy_deconz.types as t @@ -41,7 +44,7 @@ class DeviceState(enum.IntFlag): APSDE_DATA_REQUEST_SLOTS_AVAILABLE = 0x20 @classmethod - def deserialize(cls, data) -> Tuple["DeviceState", bytes]: + def deserialize(cls, data) -> tuple["DeviceState", bytes]: """Deserialize DevceState.""" state, data = t.uint8_t.deserialize(data) return cls(state), data @@ -63,6 +66,18 @@ class NetworkState(t.uint8_t, enum.Enum): LEAVING = 3 +class SecurityMode(t.uint8_t, enum.Enum): + NO_SECURITY = 0x00 + PRECONFIGURED_NETWORK_KEY = 0x01 + NETWORK_KEY_FROM_TC = 0x02 + ONLY_TCLK = 0x03 + + +class ZDPResponseHandling(t.bitmap16): + NONE = 0x0000 + NodeDescRsp = 0x0001 + + class Command(t.uint8_t, enum.Enum): aps_data_confirm = 0x04 device_state = 0x07 @@ -180,6 +195,7 @@ class NetworkParameter(t.uint8_t, enum.Enum): aps_extended_panid = 0x0B trust_center_address = 0x0E security_mode = 0x10 + configure_endpoint = 0x13 use_predefined_nwk_panid = 0x15 network_key = 0x18 link_key = 0x19 @@ -207,18 +223,19 @@ class NetworkParameter(t.uint8_t, enum.Enum): NetworkParameter.link_key: (t.EUI64, t.Key), NetworkParameter.current_channel: (t.uint8_t,), NetworkParameter.permit_join: (t.uint8_t,), + NetworkParameter.configure_endpoint: (t.uint8_t, SimpleDescriptor), NetworkParameter.protocol_version: (t.uint16_t,), NetworkParameter.nwk_update_id: (t.uint8_t,), NetworkParameter.watchdog_ttl: (t.uint32_t,), NetworkParameter.nwk_frame_counter: (t.uint32_t,), - NetworkParameter.app_zdp_response_handling: (t.uint16_t,), + NetworkParameter.app_zdp_response_handling: (ZDPResponseHandling,), } class Deconz: """deCONZ API class.""" - def __init__(self, app: Callable, device_config: Dict[str, Any]): + def __init__(self, app: Callable, device_config: dict[str, Any]): """Init instance.""" self._app = app self._aps_data_ind_flags: int = 0x01 @@ -263,7 +280,7 @@ def connection_lost(self, exc: Exception) -> None: self._uart = None if self._conn_lost_task and not self._conn_lost_task.done(): self._conn_lost_task.cancel() - self._conn_lost_task = asyncio.ensure_future(self._connection_lost()) + self._conn_lost_task = asyncio.create_task(self._connection_lost()) async def _connection_lost(self) -> None: """Reconnect serial port.""" @@ -393,7 +410,7 @@ def _handle_change_network_state(self, data): LOGGER.debug("Change network state response: %s", NetworkState(data[0]).name) @classmethod - async def probe(cls, device_config: Dict[str, Any]) -> bool: + async def probe(cls, device_config: dict[str, Any]) -> bool: """Probe port for the device presence.""" api = cls(None, device_config) try: @@ -635,10 +652,10 @@ def _handle_device_state_value(self, state: DeviceState) -> None: LOGGER.debug("Data request queue full.") if DeviceState.APSDE_DATA_INDICATION in state and not self._data_indication: self._data_indication = True - asyncio.ensure_future(self._aps_data_indication()) + asyncio.create_task(self._aps_data_indication()) if DeviceState.APSDE_DATA_CONFIRM in state and not self._data_confirm: self._data_confirm = True - asyncio.ensure_future(self._aps_data_confirm()) + asyncio.create_task(self._aps_data_confirm()) def __getitem__(self, key): """Access parameters via getitem.""" @@ -646,4 +663,4 @@ def __getitem__(self, key): def __setitem__(self, key, value): """Set parameters via setitem.""" - return asyncio.ensure_future(self.write_parameter(key, value)) + return asyncio.create_task(self.write_parameter(key, value)) diff --git a/zigpy_deconz/types.py b/zigpy_deconz/types.py index 56feee6..0308d93 100644 --- a/zigpy_deconz/types.py +++ b/zigpy_deconz/types.py @@ -149,6 +149,10 @@ class bitmap8(bitmap_factory(uint8_t)): pass +class bitmap16(bitmap_factory(uint16_t)): + pass + + class DeconzSendDataFlags(bitmap8): NONE = 0x00 NODE_ID = 0x01 diff --git a/zigpy_deconz/uart.py b/zigpy_deconz/uart.py index d26dfdb..61ca6ec 100644 --- a/zigpy_deconz/uart.py +++ b/zigpy_deconz/uart.py @@ -91,7 +91,7 @@ def data_received(self, data): try: self._api.data_received(frame) except Exception as exc: - LOGGER.error("Unexpected error handling the frame: %s", exc) + LOGGER.error("Unexpected error handling the frame", exc_info=exc) def _unescape(self, data): ret = [] diff --git a/zigpy_deconz/zigbee/application.py b/zigpy_deconz/zigbee/application.py index b68fba6..a8fbb5e 100644 --- a/zigpy_deconz/zigbee/application.py +++ b/zigpy_deconz/zigbee/application.py @@ -1,25 +1,36 @@ """ControllerApplication for deCONZ protocol based adapters.""" +from __future__ import annotations + import asyncio import binascii import contextlib import logging import re import time -from typing import Any, Dict +from typing import Any import zigpy.application import zigpy.config import zigpy.device import zigpy.endpoint import zigpy.exceptions +from zigpy.exceptions import FormationFailure, NetworkNotFormed import zigpy.neighbor import zigpy.state import zigpy.types import zigpy.util +import zigpy.zdo.types as zdo_t +import zigpy_deconz from zigpy_deconz import types as t -from zigpy_deconz.api import Deconz, NetworkParameter, NetworkState, Status +from zigpy_deconz.api import ( + Deconz, + NetworkParameter, + NetworkState, + SecurityMode, + Status, +) from zigpy_deconz.config import ( CONF_DECONZ_CONFIG, CONF_MAX_CONCURRENT_REQUESTS, @@ -38,17 +49,14 @@ PROTO_VER_WATCHDOG = 0x0108 PROTO_VER_NEIGBOURS = 0x0107 WATCHDOG_TTL = 600 - -MAX_REQUEST_RETRY_DELAY = 1.0 +MAX_NUM_ENDPOINTS = 2 # defined in firmware class ControllerApplication(zigpy.application.ControllerApplication): SCHEMA = CONFIG_SCHEMA SCHEMA_DEVICE = SCHEMA_DEVICE - probe = Deconz.probe - - def __init__(self, config: Dict[str, Any]): + def __init__(self, config: dict[str, Any]): """Initialize instance.""" super().__init__(config=zigpy.config.ZIGPY_SCHEMA(config)) @@ -62,6 +70,9 @@ def __init__(self, config: Dict[str, Any]): self._nwk = 0 self.version = 0 + self._reset_watchdog_task = None + + self._written_endpoints = set() async def _reset_watchdog(self): while True: @@ -73,147 +84,310 @@ async def _reset_watchdog(self): LOGGER.warning("No watchdog response") await asyncio.sleep(self._config[CONF_WATCHDOG_TTL] * 0.75) - async def shutdown(self): - """Shutdown application.""" - self._api.close() + async def connect(self): + api = Deconz(self, self._config[zigpy.config.CONF_DEVICE]) + await api.connect() + self.version = await api.version() + self._api = api + self._written_endpoints.clear() - async def startup(self, auto_form=False): - """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.state.node_information.ieee = zigpy.types.EUI64(ieee) + async def disconnect(self): + if self._reset_watchdog_task is not None: + self._reset_watchdog_task.cancel() - if self._api.protocol_version >= PROTO_VER_WATCHDOG: - asyncio.ensure_future(self._reset_watchdog()) + if self._api is None: + return + + try: + if self._api.protocol_version >= PROTO_VER_WATCHDOG: + await self._api.write_parameter(NetworkParameter.watchdog_ttl, 1) + except zigpy_deconz.exception.CommandError: + pass + finally: + self._api.close() + + async def permit_with_key(self, node: t.EUI64, code: bytes, time_s=60): + raise NotImplementedError() + + async def start_network(self): + await self.register_endpoints() + await self.load_network_info(load_devices=False) - (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 device_state.network_state != NetworkState.CONNECTED: + try: + await self._change_network_state(NetworkState.CONNECTED) + except asyncio.TimeoutError as e: + raise FormationFailure() from e + + coordinator = await DeconzDevice.new( + self, + self.state.node_info.ieee, + self.state.node_info.nwk, + self.version, + self._config[zigpy.config.CONF_DEVICE][zigpy.config.CONF_DEVICE_PATH], ) - if auto_form and should_form: - await self.form_network() - (self.state.node_information.nwk,) = await self._api[ - NetworkParameter.nwk_address - ] - (self.state.network_information.pan_id,) = await self._api[ - NetworkParameter.nwk_panid - ] - (self.state.network_information.extended_pan_id,) = await self._api[ - NetworkParameter.nwk_extended_panid - ] - (self.state.network_information.channel_mask,) = await self._api[ - NetworkParameter.channel_mask + coordinator.neighbors.add_context_listener(self._dblistener) + self.devices[self.state.node_info.ieee] = coordinator + if self._api.protocol_version >= PROTO_VER_NEIGBOURS: + await self.restore_neighbours() + asyncio.create_task(self._delayed_neighbour_scan()) + + async def _change_network_state( + self, target_state: NetworkState, *, timeout: int = 10 * CHANGE_NETWORK_WAIT + ): + async def change_loop(): + while True: + (state, _, _) = await self._api.device_state() + if state.network_state == target_state: + break + await asyncio.sleep(CHANGE_NETWORK_WAIT) + + await self._api.change_network_state(target_state) + await asyncio.wait_for(change_loop(), timeout=timeout) + + if self._api.protocol_version < PROTO_VER_WATCHDOG: + return + + if self._reset_watchdog_task is not None: + self._reset_watchdog_task.cancel() + + if target_state == NetworkState.CONNECTED: + self._reset_watchdog_task = asyncio.create_task(self._reset_watchdog()) + + async def write_network_info(self, *, network_info, node_info): + try: + await self._api.write_parameter( + NetworkParameter.nwk_frame_counter, network_info.network_key.tx_counter + ) + except zigpy_deconz.exception.CommandError as ex: + assert ex.status == Status.UNSUPPORTED + LOGGER.warning( + "Writing network frame counter is not supported with this firmware" + ) + + if node_info.logical_type == zdo_t.LogicalType.Coordinator: + await self._api.write_parameter( + NetworkParameter.aps_designed_coordinator, 1 + ) + else: + await self._api.write_parameter( + NetworkParameter.aps_designed_coordinator, 0 + ) + + await self._api.write_parameter(NetworkParameter.nwk_address, node_info.nwk) + + if node_info.ieee != zigpy.types.EUI64.UNKNOWN: + # TODO: is there a way to revert it back to the hardware default? Or is this + # information lost when the parameter is overwritten? + await self._api.write_parameter( + NetworkParameter.mac_address, node_info.ieee + ) + node_ieee = node_info.ieee + else: + (ieee,) = await self._api[NetworkParameter.mac_address] + node_ieee = zigpy.types.EUI64(ieee) + + # There is no way to specify both a mask and the logical channel + if network_info.channel is not None: + channel_mask = zigpy.types.Channels.from_channel_list( + [network_info.channel] + ) + + if network_info.channel_mask and channel_mask != network_info.channel_mask: + LOGGER.warning( + "Channel mask %s will be replaced with current logical channel %s", + network_info.channel_mask, + channel_mask, + ) + else: + channel_mask = network_info.channel_mask + + await self._api.write_parameter(NetworkParameter.channel_mask, channel_mask) + await self._api.write_parameter(NetworkParameter.use_predefined_nwk_panid, True) + await self._api.write_parameter(NetworkParameter.nwk_panid, network_info.pan_id) + await self._api.write_parameter( + NetworkParameter.aps_extended_panid, network_info.extended_pan_id + ) + await self._api.write_parameter( + NetworkParameter.nwk_update_id, network_info.nwk_update_id + ) + + await self._api.write_parameter( + NetworkParameter.network_key, 0, network_info.network_key.key + ) + + if network_info.network_key.seq != 0: + LOGGER.warning( + "Non-zero network key sequence number is not supported: %s", + network_info.network_key.seq, + ) + + tc_link_key_partner_ieee = network_info.tc_link_key.partner_ieee + + if tc_link_key_partner_ieee == zigpy.types.EUI64.UNKNOWN: + tc_link_key_partner_ieee = node_ieee + + await self._api.write_parameter( + NetworkParameter.trust_center_address, + tc_link_key_partner_ieee, + ) + await self._api.write_parameter( + NetworkParameter.link_key, + tc_link_key_partner_ieee, + network_info.tc_link_key.key, + ) + + if network_info.security_level == 0x00: + await self._api.write_parameter( + NetworkParameter.security_mode, SecurityMode.NO_SECURITY + ) + else: + await self._api.write_parameter( + NetworkParameter.security_mode, SecurityMode.ONLY_TCLK + ) + + # Note: Changed network configuration parameters become only affective after + # sending a Leave Network Request followed by a Create or Join Network Request + await self._change_network_state(NetworkState.OFFLINE) + await self._change_network_state(NetworkState.CONNECTED) + + async def load_network_info(self, *, load_devices=False): + network_info = self.state.network_info + node_info = self.state.node_info + + network_info.source = f"zigpy-deconz@{zigpy_deconz.__version__}" + network_info.metadata = { + "deconz": { + "version": self.version, + } + } + + (ieee,) = await self._api[NetworkParameter.mac_address] + node_info.ieee = zigpy.types.EUI64(ieee) + (designed_coord,) = await self._api[NetworkParameter.aps_designed_coordinator] + + if designed_coord == 0x01: + node_info.logical_type = zdo_t.LogicalType.Coordinator + else: + node_info.logical_type = zdo_t.LogicalType.Router + + (node_info.nwk,) = await self._api[NetworkParameter.nwk_address] + + (network_info.pan_id,) = await self._api[NetworkParameter.nwk_panid] + (network_info.extended_pan_id,) = await self._api[ + NetworkParameter.aps_extended_panid ] - await self._api[NetworkParameter.aps_extended_panid] - if self.state.network_information.network_key is None: - self.state.network_information.network_key = zigpy.state.Key() + if network_info.extended_pan_id == zigpy.types.EUI64.convert( + "00:00:00:00:00:00:00:00" + ): + (network_info.extended_pan_id,) = await self._api[ + NetworkParameter.nwk_extended_panid + ] + + (network_info.channel,) = await self._api[NetworkParameter.current_channel] + (network_info.channel_mask,) = await self._api[NetworkParameter.channel_mask] + (network_info.nwk_update_id,) = await self._api[NetworkParameter.nwk_update_id] + + if network_info.channel == 0: + raise NetworkNotFormed("Network channel is zero") + network_info.network_key = zigpy.state.Key() ( _, - self.state.network_information.network_key.key, + network_info.network_key.key, ) = await self._api.read_parameter(NetworkParameter.network_key, 0) - self.state.network_information.network_key.seq = 0 - self.state.network_information.network_key.rx_counter = None - self.state.network_information.network_key.partner_ieee = None try: - (self.state.network_information.network_key.tx_counter,) = await self._api[ + (network_info.network_key.tx_counter,) = await self._api[ NetworkParameter.nwk_frame_counter ] except zigpy_deconz.exception.CommandError as ex: assert ex.status == Status.UNSUPPORTED - self.state.network_information.network_key.tx_counter = None - if self.state.network_information.tc_link_key is None: - self.state.network_information.tc_link_key = zigpy.state.Key() - - (self.state.network_information.tc_link_key.partner_ieee,) = await self._api[ + network_info.tc_link_key = zigpy.state.Key() + (network_info.tc_link_key.partner_ieee,) = await self._api[ NetworkParameter.trust_center_address ] - ( - _, - self.state.network_information.tc_link_key.key, - ) = await self._api.read_parameter( + + (_, network_info.tc_link_key.key) = await self._api.read_parameter( NetworkParameter.link_key, - self.state.network_information.tc_link_key.partner_ieee, + network_info.tc_link_key.partner_ieee, ) - (self.state.network_information.security_level,) = await self._api[ - NetworkParameter.security_mode - ] - (self.state.network_information.channel,) = await self._api[ - NetworkParameter.current_channel - ] - await self._api[NetworkParameter.protocol_version] - (self.state.network_information.nwk_update_id,) = await self._api[ - NetworkParameter.nwk_update_id - ] - - coordinator = await DeconzDevice.new( - self, - self.ieee, - self.nwk, - self.version, - self._config[zigpy.config.CONF_DEVICE][zigpy.config.CONF_DEVICE_PATH], - ) + (security_mode,) = await self._api[NetworkParameter.security_mode] - 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()) + if security_mode == SecurityMode.NO_SECURITY: + network_info.security_level = 0x00 + elif security_mode == SecurityMode.ONLY_TCLK: + network_info.security_level = 0x05 + else: + LOGGER.warning("Unsupported security mode %r", security_mode) + network_info.security_level = 0x05 async def force_remove(self, dev): """Forcibly remove device from NCP.""" pass - async def form_network(self): - LOGGER.info("Forming network") - await self._api.change_network_state(NetworkState.OFFLINE) - await self._api.write_parameter(NetworkParameter.aps_designed_coordinator, 1) + async def add_endpoint(self, descriptor: zdo_t.SimpleDescriptor) -> None: + """Register a new endpoint on the device, replacing any with conflicting IDs. - nwk_config = self.config[zigpy.config.CONF_NWK] + Only three endpoints can be defined. + """ - # 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) + endpoints = {} - 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) + # Read the current endpoints + for index in range(MAX_NUM_ENDPOINTS): + try: + _, current_descriptor = await self._api.read_parameter( + NetworkParameter.configure_endpoint, index + ) + except zigpy_deconz.exception.CommandError as ex: + assert ex.status == Status.UNSUPPORTED + current_descriptor = None - 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 - ) + endpoints[index] = current_descriptor + + LOGGER.debug("Got endpoint slots: %r", endpoints) + + # Don't write endpoints unnecessarily + if descriptor in endpoints.values(): + LOGGER.debug("Endpoint already registered, skipping") + + # Pretend we wrote it + index = next(i for i, desc in endpoints.items() if desc == descriptor) + self._written_endpoints.add(index) + return + + # Keep track of the best endpoint descriptor to replace + target_index = None + + for index, current_descriptor in endpoints.items(): + # Ignore ones we've already written + if index in self._written_endpoints: + continue - nwk_update_id = nwk_config[zigpy.config.CONF_NWK_UPDATE_ID] - await self._api.write_parameter(NetworkParameter.nwk_update_id, nwk_update_id) + target_index = index - 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) + if ( + current_descriptor is not None + and current_descriptor.endpoint == descriptor.endpoint + ): + # Prefer to replace the endpoint with the same ID + break - # bring network up - await self._api.change_network_state(NetworkState.CONNECTED) + if target_index is None: + raise ValueError(f"No available endpoint slots exist: {endpoints!r}") - for _ in range(10): - (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.") + LOGGER.debug("Writing %s to slot %r", descriptor, target_index) + + await self._api.write_parameter( + NetworkParameter.configure_endpoint, target_index, descriptor + ) @contextlib.asynccontextmanager async def _limit_concurrency(self): @@ -451,7 +625,7 @@ def handle_tx_confirm(self, req_id, status): async def restore_neighbours(self) -> None: """Restore children.""" - coord = self.get_device(ieee=self.ieee) + coord = self.get_device(ieee=self.state.node_info.ieee) devices = (nei.device for nei in coord.neighbors) for device in devices: if device is None: @@ -483,7 +657,7 @@ async def restore_neighbours(self) -> None: 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) + coord = self.get_device(ieee=self.state.node_info.ieee) await coord.neighbors.scan()