From 6b857942071245a70beda5d08f2167441a3c9948 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Thu, 16 Nov 2023 15:05:19 -0500 Subject: [PATCH] Sync with zigpy 0.60.0 changes (#595) * Replace `permit_with_key` with `permit_with_link_key` * Use zigpy `probe` * Use zigpy device schema * Use zigpy watchdog * Fix existing unit tests * Load board info into node state object * Bump minimum zigpy version to 0.60.0 * Add a unit test for missing token value * Remove dead code --- bellows/cli/main.py | 9 +- bellows/cli/opts.py | 7 +- bellows/config/__init__.py | 14 -- bellows/ezsp/__init__.py | 61 ++----- bellows/uart.py | 13 +- bellows/zigbee/application.py | 184 ++++++++------------ pyproject.toml | 2 +- tests/test_application.py | 212 +++++------------------- tests/test_application_network_state.py | 3 + tests/test_ezsp.py | 85 +++------- tests/test_uart.py | 6 +- 11 files changed, 162 insertions(+), 434 deletions(-) diff --git a/bellows/cli/main.py b/bellows/cli/main.py index bf85fc3d..b5ab63c2 100644 --- a/bellows/cli/main.py +++ b/bellows/cli/main.py @@ -2,8 +2,7 @@ import click import click_log - -from bellows.config import CONF_DEVICE, CONF_DEVICE_BAUDRATE, CONF_FLOW_CONTROL +import zigpy.config from . import opts @@ -16,9 +15,9 @@ @click.pass_context def main(ctx, device, baudrate, flow_control): ctx.obj = { - CONF_DEVICE: device, - CONF_DEVICE_BAUDRATE: baudrate, - CONF_FLOW_CONTROL: flow_control, + zigpy.config.CONF_DEVICE_PATH: device, + zigpy.config.CONF_DEVICE_BAUDRATE: baudrate, + zigpy.config.CONF_DEVICE_FLOW_CONTROL: flow_control, } click_log.basic_config() diff --git a/bellows/cli/opts.py b/bellows/cli/opts.py index f476da52..4010fd33 100644 --- a/bellows/cli/opts.py +++ b/bellows/cli/opts.py @@ -1,8 +1,7 @@ import os import click - -from bellows.config import CONF_FLOW_CONTROL_DEFAULT +from zigpy.config import CONF_DEVICE_FLOW_CONTROL from . import util @@ -61,8 +60,8 @@ flow_control = click.option( "--flow-control", - default=CONF_FLOW_CONTROL_DEFAULT, - type=click.Choice((CONF_FLOW_CONTROL_DEFAULT, "hardware")), + default="software", + type=click.Choice(CONF_DEVICE_FLOW_CONTROL), envvar="EZSP_FLOW_CONTROL", help="use hardware flow control", ) diff --git a/bellows/config/__init__.py b/bellows/config/__init__.py index 8bcbdc74..2117da60 100644 --- a/bellows/config/__init__.py +++ b/bellows/config/__init__.py @@ -14,30 +14,16 @@ CONF_NWK_TC_LINK_KEY, CONF_NWK_UPDATE_ID, CONFIG_SCHEMA, - SCHEMA_DEVICE, cv_boolean, ) -CONF_DEVICE_BAUDRATE = "baudrate" CONF_USE_THREAD = "use_thread" CONF_EZSP_CONFIG = "ezsp_config" CONF_EZSP_POLICIES = "ezsp_policies" CONF_PARAM_MAX_WATCHDOG_FAILURES = "max_watchdog_failures" -CONF_FLOW_CONTROL = "flow_control" -CONF_FLOW_CONTROL_DEFAULT = "software" - -SCHEMA_DEVICE = SCHEMA_DEVICE.extend( - { - vol.Optional(CONF_DEVICE_BAUDRATE, default=57600): int, - vol.Optional(CONF_FLOW_CONTROL, default=CONF_FLOW_CONTROL_DEFAULT): vol.In( - ("hardware", "software") - ), - }, -) CONFIG_SCHEMA = CONFIG_SCHEMA.extend( { - vol.Required(CONF_DEVICE): SCHEMA_DEVICE, vol.Optional(CONF_PARAM_MAX_WATCHDOG_FAILURES, default=4): int, vol.Optional(CONF_EZSP_CONFIG, default={}): dict, vol.Optional(CONF_EZSP_POLICIES, default={}): vol.Schema( diff --git a/bellows/ezsp/__init__.py b/bellows/ezsp/__init__.py index da3fb8f2..9bac445c 100644 --- a/bellows/ezsp/__init__.py +++ b/bellows/ezsp/__init__.py @@ -97,46 +97,6 @@ def maybe_remove(_): with contextlib.suppress(ValueError): listeners.remove(future) - @classmethod - async def probe(cls, device_config: dict) -> bool | dict[str, int | str | bool]: - """Probe port for the device presence.""" - device_config = conf.SCHEMA_DEVICE(device_config) - probe_configs = [device_config] - - # Try probing with 115200 baud rate first, as it is the most common - if device_config[conf.CONF_DEVICE_BAUDRATE] != 115200: - probe_configs.insert( - 0, {**device_config, conf.CONF_DEVICE_BAUDRATE: 115200} - ) - - for config in probe_configs: - ezsp = cls(config) - - try: - async with asyncio_timeout( - UART_PROBE_TIMEOUT - if not ezsp.is_tcp_serial_port - else NETWORK_PROBE_TIMEOUT - ): - await ezsp._probe() - - return config - except Exception as exc: - LOGGER.debug( - "Unsuccessful radio probe of '%s' port", - device_config[conf.CONF_DEVICE_PATH], - exc_info=exc, - ) - finally: - ezsp.close() - - return False - - async def _probe(self) -> None: - """Open port and try sending a command""" - await self.connect(use_thread=False) - await self.startup_reset() - @property def is_tcp_serial_port(self) -> bool: parsed_path = urllib.parse.urlparse(self._config[conf.CONF_DEVICE_PATH]) @@ -344,10 +304,12 @@ def frame_received(self, data: bytes) -> None: self._protocol(data) - async def get_board_info(self) -> tuple[str, str, str]: + async def get_board_info( + self, + ) -> tuple[str, str, str | None] | tuple[None, None, str | None]: """Return board info.""" - tokens = [] + tokens = {} for token in (t.EzspMfgTokenId.MFG_STRING, t.EzspMfgTokenId.MFG_BOARD_NAME): (value,) = await self.getMfgToken(token) @@ -361,11 +323,15 @@ async def get_board_info(self) -> tuple[str, str, str]: except UnicodeDecodeError: result = "0x" + result.hex().upper() - tokens.append(result) + if not result: + result = None + + tokens[token] = result (status, ver_info_bytes) = await self.getValue( self.types.EzspValueId.VALUE_VERSION_INFO ) + version = None if status == t.EmberStatus.SUCCESS: build, ver_info_bytes = t.uint16_t.deserialize(ver_info_bytes) @@ -374,9 +340,12 @@ async def get_board_info(self) -> tuple[str, str, str]: patch, ver_info_bytes = t.uint8_t.deserialize(ver_info_bytes) special, ver_info_bytes = t.uint8_t.deserialize(ver_info_bytes) version = f"{major}.{minor}.{patch}.{special} build {build}" - else: - version = "unknown stack version" - return tokens[0], tokens[1], version + + return ( + tokens[t.EzspMfgTokenId.MFG_STRING], + tokens[t.EzspMfgTokenId.MFG_BOARD_NAME], + version, + ) async def _get_nv3_restored_eui64_key(self) -> t.NV3KeyId | None: """Get the NV3 key for the device's restored EUI64, if one exists.""" diff --git a/bellows/uart.py b/bellows/uart.py index 14b4999f..73bad18b 100644 --- a/bellows/uart.py +++ b/bellows/uart.py @@ -8,14 +8,9 @@ else: from asyncio import timeout as asyncio_timeout # pragma: no cover +import zigpy.config import zigpy.serial -from bellows.config import ( - CONF_DEVICE_BAUDRATE, - CONF_DEVICE_PATH, - CONF_FLOW_CONTROL, - CONF_FLOW_CONTROL_DEFAULT, -) from bellows.thread import EventLoopThread, ThreadsafeProxy import bellows.types as t @@ -376,7 +371,7 @@ async def _connect(config, application): connection_done_future = loop.create_future() protocol = Gateway(application, connection_future, connection_done_future) - if config[CONF_FLOW_CONTROL] == CONF_FLOW_CONTROL_DEFAULT: + if config[zigpy.config.CONF_DEVICE_FLOW_CONTROL] is None: xon_xoff, rtscts = True, False else: xon_xoff, rtscts = False, True @@ -384,8 +379,8 @@ async def _connect(config, application): transport, protocol = await zigpy.serial.create_serial_connection( loop, lambda: protocol, - url=config[CONF_DEVICE_PATH], - baudrate=config[CONF_DEVICE_BAUDRATE], + url=config[zigpy.config.CONF_DEVICE_PATH], + baudrate=config[zigpy.config.CONF_DEVICE_BAUDRATE], xonxoff=xon_xoff, rtscts=rtscts, ) diff --git a/bellows/zigbee/application.py b/bellows/zigbee/application.py index 5850c488..7c85e8ec 100644 --- a/bellows/zigbee/application.py +++ b/bellows/zigbee/application.py @@ -27,10 +27,8 @@ from bellows.config import ( CONF_EZSP_CONFIG, CONF_EZSP_POLICIES, - CONF_PARAM_MAX_WATCHDOG_FAILURES, CONF_USE_THREAD, CONFIG_SCHEMA, - SCHEMA_DEVICE, ) from bellows.exception import ControllerError, EzspError, StackAlreadyRunning import bellows.ezsp @@ -61,7 +59,7 @@ MFG_ID_RESET_DELAY = 180 RESET_ATTEMPT_BACKOFF_TIME = 5 NETWORK_UP_TIMEOUT_S = 10 -WATCHDOG_WAKE_PERIOD = 10 +MAX_WATCHDOG_FAILURES = 4 IEEE_PREFIX_MFG_ID = { "04:CF:8C": 0x115F, # Xiaomi "54:EF:44": 0x115F, # Lumi @@ -71,9 +69,13 @@ class ControllerApplication(zigpy.application.ControllerApplication): - probe = bellows.ezsp.EZSP.probe SCHEMA = CONFIG_SCHEMA - SCHEMA_DEVICE = SCHEMA_DEVICE + + _watchdog_period = 10 + _probe_configs = [ + {zigpy.config.CONF_DEVICE_BAUDRATE: 115200}, + {zigpy.config.CONF_DEVICE_BAUDRATE: 57600}, + ] def __init__(self, config: dict): super().__init__(config) @@ -82,8 +84,8 @@ def __init__(self, config: dict): self._multicast = None self._mfg_id_task: asyncio.Task | None = None self._pending = zigpy.util.Requests() - self._watchdog_task = None - self._reset_task = None + self._watchdog_failures = 0 + self._watchdog_feed_counter = 0 self._req_lock = asyncio.Lock() @@ -201,7 +203,6 @@ async def start_network(self): ezsp.add_callback(self.ezsp_callback_handler) self.controller_event.set() - self._watchdog_task = asyncio.create_task(self._watchdog()) try: db_device = self.get_device(ieee=self.state.node_info.ieee) @@ -242,10 +243,15 @@ async def load_network_info(self, *, load_devices=False) -> None: (nwk,) = await ezsp.getNodeId() (ieee,) = await ezsp.getEui64() + brd_manuf, brd_name, version = await self._get_board_info() + self.state.node_info = zigpy.state.NodeInfo( nwk=zigpy.types.NWK(nwk), ieee=zigpy.types.EUI64(ieee), logical_type=node_type.zdo_logical_type, + manufacturer=brd_manuf, + model=brd_name, + version=version, ) (status, security_level) = await ezsp.getConfigurationValue( @@ -281,7 +287,6 @@ async def load_network_info(self, *, load_devices=False) -> None: if self.state.node_info.logical_type == zdo_t.LogicalType.Coordinator: tc_link_key.partner_ieee = self.state.node_info.ieee - brd_manuf, brd_name, version = await self._get_board_info() can_burn_userdata_custom_eui64 = await ezsp.can_burn_userdata_custom_eui64() can_rewrite_custom_eui64 = await ezsp.can_rewrite_custom_eui64() @@ -302,9 +307,6 @@ async def load_network_info(self, *, load_devices=False) -> None: stack_specific=stack_specific, metadata={ "ezsp": { - "manufacturer": brd_manuf, - "board": brd_name, - "version": version, "stack_version": ezsp.ezsp_version, "can_burn_userdata_custom_eui64": can_burn_userdata_custom_eui64, "can_rewrite_custom_eui64": can_rewrite_custom_eui64, @@ -490,11 +492,6 @@ async def _reset(self): async def disconnect(self): # TODO: how do you shut down the stack? self.controller_event.clear() - if self._watchdog_task and not self._watchdog_task.done(): - LOGGER.debug("Cancelling watchdog") - self._watchdog_task.cancel() - if self._reset_task and not self._reset_task.done(): - self._reset_task.cancel() if self._ezsp is not None: self._ezsp.close() self._ezsp = None @@ -517,7 +514,7 @@ def ezsp_callback_handler(self, frame_name, args): elif frame_name == "incomingRouteErrorHandler": self.handle_route_error(*args) elif frame_name == "_reset_controller_application": - self._handle_reset_request(*args) + self.connection_lost(args[0]) elif frame_name == "idConflictHandler": self._handle_id_conflict(*args) @@ -665,45 +662,6 @@ async def _reset_mfg_id(self, mfg_id: int) -> None: await asyncio.sleep(MFG_ID_RESET_DELAY) await self._ezsp.setManufacturerCode(DEFAULT_MFG_ID) - def _handle_reset_request(self, error): - """Reinitialize application controller.""" - LOGGER.debug("Resetting ControllerApplication. Cause: %r", error) - self.controller_event.clear() - if self._reset_task: - LOGGER.debug("Preempting ControllerApplication reset") - self._reset_task.cancel() - - self._reset_task = asyncio.create_task(self._reset_controller_loop()) - - async def _reset_controller_loop(self): - """Keep trying to reset controller until we succeed.""" - self._watchdog_task.cancel() - while True: - try: - await self._reset_controller() - break - except Exception as exc: - LOGGER.warning( - "ControllerApplication reset unsuccessful: %r", - exc, - exc_info=exc, - ) - await asyncio.sleep(RESET_ATTEMPT_BACKOFF_TIME) - - self._reset_task = None - self.state.counters[COUNTERS_CTRL][COUNTER_RESET_SUCCESS].increment() - LOGGER.debug("ControllerApplication successfully reset") - - async def _reset_controller(self): - """Reset Controller.""" - if self._ezsp is not None: - self._ezsp.close() - self._ezsp = None - - await asyncio.sleep(0.5) - await self.connect() - await self.initialize() - async def _set_source_route( self, nwk: zigpy.types.NWK, relays: list[zigpy.types.NWK] ) -> bool: @@ -882,25 +840,23 @@ def permit_ncp(self, time_s=60): assert 0 <= time_s <= 254 return self._ezsp.permitJoining(time_s) - async def permit_with_key(self, node, code, time_s=60): - if type(node) is not t.EmberEUI64: - node = t.EmberEUI64([t.uint8_t(p) for p in node]) - - key = zigpy.util.convert_install_code(code) - if key is None: - raise Exception("Invalid install code") + async def permit_with_link_key( + self, node: t.EUI64, link_key: t.KeyData, time_s: int = 60 + ) -> None: + """Permits a new device to join with the given IEEE and link key.""" - link_key = t.EmberKeyData(key) v = await self._ezsp.addTransientLinkKey(node, link_key) if v[0] != t.EmberStatus.SUCCESS: raise Exception("Failed to set link key") if self._ezsp.ezsp_version >= 8: - mask_type = self._ezsp.types.EzspDecisionBitmask.ALLOW_JOINS - bitmask = mask_type.ALLOW_JOINS | mask_type.JOINS_USE_INSTALL_CODE_KEY await self._ezsp.setPolicy( - self._ezsp.types.EzspPolicyId.TRUST_CENTER_POLICY, bitmask + self._ezsp.types.EzspPolicyId.TRUST_CENTER_POLICY, + ( + self._ezsp.types.EzspDecisionBitmask.ALLOW_JOINS + | self._ezsp.types.EzspDecisionBitmask.JOINS_USE_INSTALL_CODE_KEY + ), ) return await super().permit(time_s) @@ -920,61 +876,51 @@ def _handle_id_conflict(self, nwk: t.EmberNodeId) -> None: ) self.handle_leave(nwk, device.ieee) - async def _watchdog(self): - """Watchdog handler.""" - LOGGER.debug("Starting EZSP watchdog") - failures = 0 - read_counter = 0 - await asyncio.sleep(WATCHDOG_WAKE_PERIOD) - while True: - try: - async with asyncio_timeout(WATCHDOG_WAKE_PERIOD * 2): - await self.controller_event.wait() - - if self._ezsp.ezsp_version == 4: - await self._ezsp.nop() - else: - counters = self.state.counters[COUNTERS_EZSP] - read_counter = ( - read_counter + 1 - ) % EZSP_COUNTERS_CLEAR_IN_WATCHDOG_PERIODS - if read_counter: - (res,) = await self._ezsp.readCounters() - else: - (res,) = await self._ezsp.readAndClearCounters() - - for cnt_type, value in zip(self._ezsp.types.EmberCounterType, res): - counters[cnt_type.name[8:]].update(value) - - if not read_counter: - counters.reset() + async def _watchdog_loop(self): + self._watchdog_failures = 0 + self._watchdog_feed_counter = 0 + await super()._watchdog_loop() - free_buffers = await self._get_free_buffers() - if free_buffers is not None: - cnt = counters[COUNTER_EZSP_BUFFERS] - cnt._raw_value = free_buffers - cnt._last_reset_value = 0 - - LOGGER.debug("%s", counters) + async def _watchdog_feed(self): + try: + if self._ezsp.ezsp_version == 4: + await self._ezsp.nop() + else: + counters = self.state.counters[COUNTERS_EZSP] + self._watchdog_feed_counter += 1 - failures = 0 - except (asyncio.TimeoutError, EzspError) as exc: - LOGGER.warning("Watchdog heartbeat timeout: %s", repr(exc)) - failures += 1 - if failures > self.config[CONF_PARAM_MAX_WATCHDOG_FAILURES]: - break - except asyncio.CancelledError: - raise - except Exception as exc: - LOGGER.error( - "Watchdog got an unexpected exception. Please report this issue: %s", - exc, + remainder = ( + self._watchdog_feed_counter + % EZSP_COUNTERS_CLEAR_IN_WATCHDOG_PERIODS ) - await asyncio.sleep(WATCHDOG_WAKE_PERIOD) - - self.state.counters[COUNTERS_CTRL][COUNTER_WATCHDOG].increment() - self._handle_reset_request(f"Watchdog timeout. Heartbeat timeouts: {failures}") + if remainder > 0: + (res,) = await self._ezsp.readCounters() + else: + (res,) = await self._ezsp.readAndClearCounters() + + for cnt_type, value in zip(self._ezsp.types.EmberCounterType, res): + counters[cnt_type.name[8:]].update(value) + + if remainder == 0: + counters.reset() + + free_buffers = await self._get_free_buffers() + if free_buffers is not None: + cnt = counters[COUNTER_EZSP_BUFFERS] + cnt._raw_value = free_buffers + cnt._last_reset_value = 0 + + LOGGER.debug("%s", counters) + except (asyncio.TimeoutError, EzspError) as exc: + # TODO: converted Silvercrest gateways break without this + LOGGER.warning("Watchdog heartbeat timeout: %s", repr(exc)) + self._watchdog_failures += 1 + if self._watchdog_failures > MAX_WATCHDOG_FAILURES: + raise + else: + self._watchdog_failures = 0 + self.state.counters[COUNTERS_CTRL][COUNTER_WATCHDOG].increment() async def _get_free_buffers(self) -> int | None: status, value = await self._ezsp.getValue( diff --git a/pyproject.toml b/pyproject.toml index dcaafd00..a2bf9d2d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ dependencies = [ "click-log>=0.2.1", "pure_pcapy3==1.0.1", "voluptuous", - "zigpy>=0.54.1", + "zigpy>=0.60.0", 'async-timeout; python_version<"3.11"', ] diff --git a/tests/test_application.py b/tests/test_application.py index f50066f3..46640247 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -1,6 +1,6 @@ import asyncio import logging -from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch, sentinel +from unittest.mock import AsyncMock, MagicMock, PropertyMock, call, patch, sentinel import pytest import zigpy.config @@ -26,8 +26,8 @@ APP_CONFIG = { config.CONF_DEVICE: { - config.CONF_DEVICE_PATH: "/dev/null", - config.CONF_DEVICE_BAUDRATE: 115200, + zigpy.config.CONF_DEVICE_PATH: "/dev/null", + zigpy.config.CONF_DEVICE_BAUDRATE: 115200, }, zigpy.config.CONF_DATABASE: None, } @@ -114,7 +114,6 @@ def aps(): @patch("zigpy.device.Device._initialize", new=AsyncMock()) -@patch("bellows.zigbee.application.ControllerApplication._watchdog", new=AsyncMock()) def _create_app_for_startup( app, nwk_type, @@ -620,34 +619,16 @@ async def test_permit_ncp(app): "version, tc_policy_count, ezsp_types", ((4, 0, t), (5, 0, ezsp_t5), (6, 0, ezsp_t6), (7, 0, ezsp_t7), (8, 1, ezsp_t8)), ) -async def test_permit_with_key(app, version, tc_policy_count, ezsp_types): - p1 = patch("zigpy.application.ControllerApplication.permit") - p2 = patch.object(app._ezsp, "types", ezsp_types) - - with patch.object(app._ezsp, "ezsp_version", version), p1 as permit_mock, p2: - await app.permit_with_key( - bytes([1, 2, 3, 4, 5, 6, 7, 8]), - bytes([0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x4A, 0xF7]), - 60, - ) - - assert app._ezsp.addTransientLinkKey.await_count == 1 - assert permit_mock.await_count == 1 - assert app._ezsp.setPolicy.await_count == tc_policy_count - - -@pytest.mark.parametrize( - "version, tc_policy_count, ezsp_types", - ((4, 0, t), (5, 0, ezsp_t5), (6, 0, ezsp_t6), (7, 0, ezsp_t7), (8, 1, ezsp_t8)), -) -async def test_permit_with_key_ieee(app, ieee, version, tc_policy_count, ezsp_types): +async def test_permit_with_link_key_ieee( + app, ieee, version, tc_policy_count, ezsp_types +): p1 = patch("zigpy.application.ControllerApplication.permit") p2 = patch.object(app._ezsp, "types", ezsp_types) with patch.object(app._ezsp, "ezsp_version", version), p1 as permit_mock, p2: - await app.permit_with_key( + await app.permit_with_link_key( ieee, - bytes([0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x4A, 0xF7]), + zigpy_t.KeyData.convert("11:22:33:44:55:66:77:88:11:22:33:44:55:66:77:88:"), 60, ) @@ -656,32 +637,25 @@ async def test_permit_with_key_ieee(app, ieee, version, tc_policy_count, ezsp_ty assert app._ezsp.setPolicy.await_count == tc_policy_count -async def test_permit_with_key_invalid_install_code(app, ieee): - with pytest.raises(Exception): - await app.permit_with_key( - ieee, bytes([0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88]), 60 - ) - - -async def test_permit_with_key_failed_add_key(app, ieee): +async def test_permit_with_link_key_failed_add_key(app, ieee): app._ezsp.addTransientLinkKey = AsyncMock(return_value=[1, 1]) with pytest.raises(Exception): - await app.permit_with_key( + await app.permit_with_link_key( ieee, - bytes([0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x4A, 0xF7]), + zigpy_t.KeyData.convert("11:22:33:44:55:66:77:88:11:22:33:44:55:66:77:88:"), 60, ) -async def test_permit_with_key_failed_set_policy(app, ieee): +async def test_permit_with_link_key_failed_set_policy(app, ieee): app._ezsp.addTransientLinkKey = AsyncMock(return_value=[0]) app._ezsp.setPolicy = AsyncMock(return_value=[1]) with pytest.raises(Exception): - await app.permit_with_key( + await app.permit_with_link_key( ieee, - bytes([0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x4A, 0xF7]), + zigpy_t.KeyData.convert("11:22:33:44:55:66:77:88:11:22:33:44:55:66:77:88:"), 60, ) @@ -1065,91 +1039,16 @@ def test_is_controller_running(app): def test_reset_frame(app): - app._handle_reset_request = MagicMock(spec_set=app._handle_reset_request) + app.connection_lost = MagicMock(spec_set=app.connection_lost) app.ezsp_callback_handler("_reset_controller_application", (sentinel.error,)) - assert app._handle_reset_request.call_count == 1 - assert app._handle_reset_request.call_args[0][0] is sentinel.error - - -async def test_handle_reset_req(app): - # no active reset task, no reset task preemption - app._ctrl_event.set() - assert app._reset_task is None - reset_ctrl_mock = AsyncMock() - app._reset_controller_loop = MagicMock(side_effect=reset_ctrl_mock) - - app._handle_reset_request(sentinel.error) - - assert asyncio.isfuture(app._reset_task) - assert app._ctrl_event.is_set() is False - await app._reset_task - assert app._reset_controller_loop.call_count == 1 - - -async def test_handle_reset_req_existing_preempt(app): - # active reset task, preempt reset task - app._ctrl_event.set() - assert app._reset_task is None - old_reset = asyncio.Future() - app._reset_task = old_reset - reset_ctrl_mock = AsyncMock() - app._reset_controller_loop = MagicMock(side_effect=reset_ctrl_mock) - - app._handle_reset_request(sentinel.error) - - assert asyncio.isfuture(app._reset_task) - await app._reset_task - assert app._ctrl_event.is_set() is False - assert app._reset_controller_loop.call_count == 1 - assert old_reset.done() is True - assert old_reset.cancelled() is True - - -async def test_reset_controller_loop(app, monkeypatch): - from bellows.zigbee import application - - monkeypatch.setattr(application, "RESET_ATTEMPT_BACKOFF_TIME", 0.1) - app._watchdog_task = asyncio.Future() - - reset_succ_on_try = reset_call_count = 2 - - async def reset_controller_mock(): - nonlocal reset_succ_on_try - if reset_succ_on_try: - reset_succ_on_try -= 1 - if reset_succ_on_try > 0: - raise asyncio.TimeoutError - return - - app._reset_controller = AsyncMock(side_effect=reset_controller_mock) - - await app._reset_controller_loop() - - assert app._watchdog_task.cancelled() is True - assert app._reset_controller.call_count == reset_call_count - assert app._reset_task is None - - -async def test_reset_controller_routine(app, monkeypatch): - from bellows.zigbee import application - - monkeypatch.setattr(application, "RESET_ATTEMPT_BACKOFF_TIME", 0.01) - - # Fails to connect, then connects but fails to start network, then finally works - app.connect = AsyncMock(side_effect=[RuntimeError("broken"), None, None]) - app.initialize = AsyncMock(side_effect=[asyncio.TimeoutError(), None]) - app._watchdog_task = MagicMock() - - await app._reset_controller_loop() - assert app.connect.call_count == 3 - assert app.initialize.call_count == 2 + assert app.connection_lost.mock_calls == [call(sentinel.error)] @pytest.mark.parametrize("ezsp_version", (4, 7)) async def test_watchdog(app, monkeypatch, ezsp_version): from bellows.zigbee import application - monkeypatch.setattr(application, "WATCHDOG_WAKE_PERIOD", 0.01) + monkeypatch.setattr(application.ControllerApplication, "_watchdog_period", 0.01) monkeypatch.setattr(application, "EZSP_COUNTERS_CLEAR_IN_WATCHDOG_PERIODS", 2) nop_success = 7 app._ezsp.ezsp_version = ezsp_version @@ -1164,26 +1063,36 @@ async def nop_mock(): return ([0] * 10,) raise asyncio.TimeoutError + app._ezsp.getValue = AsyncMock(return_value=[t.EmberStatus.SUCCESS, b"\xFE"]) app._ezsp.nop = AsyncMock(side_effect=nop_mock) app._ezsp.readCounters = AsyncMock(side_effect=nop_mock) app._ezsp.readAndClearCounters = AsyncMock(side_effect=nop_mock) - app._handle_reset_request = MagicMock() app._ctrl_event.set() + app.connection_lost = MagicMock() + + for i in range(nop_success): + await app._watchdog_feed() + + # Fail four times in a row to exhaust the watchdog buffer + await app._watchdog_feed() + await app._watchdog_feed() + await app._watchdog_feed() + await app._watchdog_feed() - await app._watchdog() + # The last time will throw a real error + with pytest.raises(asyncio.TimeoutError): + await app._watchdog_feed() if ezsp_version == 4: assert app._ezsp.nop.await_count > 4 else: assert app._ezsp.readCounters.await_count >= 4 - assert app._handle_reset_request.call_count == 1 - async def test_watchdog_counters(app, monkeypatch, caplog): from bellows.zigbee import application - monkeypatch.setattr(application, "WATCHDOG_WAKE_PERIOD", 0.01) + monkeypatch.setattr(application.ControllerApplication, "_watchdog_period", 0.01) nop_success = 3 async def counters_mock(): @@ -1196,20 +1105,21 @@ async def counters_mock(): return ([0, 1, 2, 3],) raise asyncio.TimeoutError + app._ezsp.getValue = AsyncMock(return_value=[t.EmberStatus.SUCCESS, b"\xFE"]) app._ezsp.readCounters = AsyncMock(side_effect=counters_mock) app._ezsp.nop = AsyncMock(side_effect=EzspError) app._handle_reset_request = MagicMock() app._ctrl_event.set() caplog.set_level(logging.DEBUG, "bellows.zigbee.application") - await app._watchdog() + await app._watchdog_feed() assert app._ezsp.readCounters.await_count != 0 assert app._ezsp.nop.await_count == 0 # don't do counters on older firmwares app._ezsp.ezsp_version = 4 app._ezsp.readCounters.reset_mock() - await app._watchdog() + await app._watchdog_feed() assert app._ezsp.readCounters.await_count == 0 assert app._ezsp.nop.await_count != 0 @@ -1217,7 +1127,7 @@ async def counters_mock(): async def test_ezsp_value_counter(app, monkeypatch): from bellows.zigbee import application - monkeypatch.setattr(application, "WATCHDOG_WAKE_PERIOD", 0.01) + monkeypatch.setattr(application.ControllerApplication, "_watchdog_period", 0.01) nop_success = 3 async def counters_mock(): @@ -1238,7 +1148,7 @@ async def counters_mock(): app._handle_reset_request = MagicMock() app._ctrl_event.set() - await app._watchdog() + await app._watchdog_feed() assert app._ezsp.readCounters.await_count != 0 assert app._ezsp.nop.await_count == 0 @@ -1280,39 +1190,18 @@ async def counters_mock(): # Ezsp Value success app._ezsp.getValue = AsyncMock(return_value=(t.EzspStatus.SUCCESS, b"\x20")) nop_success = 3 - await app._watchdog() + await app._watchdog_feed() assert ( app.state.counters[application.COUNTERS_EZSP][application.COUNTER_EZSP_BUFFERS] == 0x20 ) -async def test_watchdog_cancel(app, monkeypatch): - """Coverage for watchdog cancellation.""" - - from bellows.zigbee import application - - monkeypatch.setattr(application, "WATCHDOG_WAKE_PERIOD", 0.01) - - app._ezsp.readCounters = AsyncMock(side_effect=asyncio.CancelledError) - - with pytest.raises(asyncio.CancelledError): - await app._watchdog() - - async def test_shutdown(app): - reset_f = asyncio.Future() - watchdog_f = asyncio.Future() - app._reset_task = reset_f - app._watchdog_task = watchdog_f ezsp = app._ezsp await app.shutdown() assert app.controller_event.is_set() is False - assert reset_f.done() is True - assert reset_f.cancelled() is True - assert watchdog_f.done() is True - assert watchdog_f.cancelled() is True assert ezsp.close.call_count == 1 @@ -1485,31 +1374,6 @@ def test_handle_route_error(app): app.handle_relays.assert_called_once_with(nwk=sentinel.nwk, relays=None) -@patch.object(ezsp.EZSP, "version", new_callable=AsyncMock) -@patch("bellows.uart.connect", return_value=MagicMock(spec_set=uart.Gateway)) -async def test_probe_success(mock_connect, mock_version): - """Test device probing.""" - - res = await ezsp.EZSP.probe(APP_CONFIG[config.CONF_DEVICE]) - assert res - assert type(res) is dict - assert mock_connect.call_count == 1 - assert mock_connect.await_count == 1 - assert mock_version.call_count == 1 - assert mock_connect.return_value.close.call_count == 1 - - mock_connect.reset_mock() - mock_version.reset_mock() - mock_connect.reset_mock() - res = await ezsp.EZSP.probe(APP_CONFIG[config.CONF_DEVICE]) - assert res - assert type(res) is dict - assert mock_connect.call_count == 1 - assert mock_connect.await_count == 1 - assert mock_version.call_count == 1 - assert mock_connect.return_value.close.call_count == 1 - - def test_handle_id_conflict(app, ieee): """Test handling of an ID conflict report.""" nwk = t.EmberNodeId(0x1234) diff --git a/tests/test_application_network_state.py b/tests/test_application_network_state.py index 04171b0e..d2342bb4 100644 --- a/tests/test_application_network_state.py +++ b/tests/test_application_network_state.py @@ -18,6 +18,9 @@ def node_info(): nwk=zigpy_t.NWK(0x0000), ieee=zigpy_t.EUI64.convert("00:12:4b:00:1c:a1:b8:46"), logical_type=zdo_t.LogicalType.Coordinator, + model="Mock board", + manufacturer="Mock Manufacturer", + version="Mock version", ) diff --git a/tests/test_ezsp.py b/tests/test_ezsp.py index 30e04729..19f97691 100644 --- a/tests/test_ezsp.py +++ b/tests/test_ezsp.py @@ -4,6 +4,7 @@ import sys import pytest +import zigpy.config from bellows import config, ezsp, uart from bellows.exception import EzspError, InvalidCommandError @@ -18,8 +19,8 @@ from unittest.mock import ANY, AsyncMock, MagicMock, call, patch, sentinel DEVICE_CONFIG = { - config.CONF_DEVICE_PATH: "/dev/null", - config.CONF_DEVICE_BAUDRATE: 115200, + zigpy.config.CONF_DEVICE_PATH: "/dev/null", + zigpy.config.CONF_DEVICE_BAUDRATE: 115200, } @@ -277,60 +278,6 @@ async def test_no_close_without_callback(ezsp_f): assert ezsp_f.close.call_count == 0 -@patch("bellows.uart.connect", return_value=MagicMock(spec_set=uart.Gateway)) -async def test_probe_success(mock_connect): - """Test device probing.""" - - # Probe works with default baud - with patch( - "bellows.ezsp.protocol.ProtocolHandler.command", - AsyncMock(return_value=(4, 0, 0)), - ): - res = await ezsp.EZSP.probe( - {**DEVICE_CONFIG, config.CONF_DEVICE_BAUDRATE: 57600} - ) - - assert type(res) is dict - assert mock_connect.call_count == 1 - assert mock_connect.await_count == 1 - assert mock_connect.return_value.close.call_count == 1 - mock_connect.reset_mock() - - # Probe first fails with default baud but then works with alternate baud - with patch( - "bellows.ezsp.protocol.ProtocolHandler.command", - AsyncMock(side_effect=[asyncio.TimeoutError(), (4, 0, 0)]), - ): - res = await ezsp.EZSP.probe( - {**DEVICE_CONFIG, config.CONF_DEVICE_BAUDRATE: 57600} - ) - - assert type(res) is dict - assert mock_connect.call_count == 2 - assert mock_connect.await_count == 2 - assert mock_connect.return_value.close.call_count == 2 - - -@pytest.mark.parametrize("exception", (asyncio.TimeoutError, EzspError, RuntimeError)) -async def test_probe_fail(exception): - """Test device probing fails.""" - - p1 = patch.object(ezsp.EZSP, "version", new_callable=AsyncMock) - p2 = patch("bellows.uart.connect", return_value=MagicMock(spec_set=uart.Gateway)) - - with p1 as mock_version, p2 as mock_connect: - mock_version.side_effect = exception - res = await ezsp.EZSP.probe( - {**DEVICE_CONFIG, config.CONF_DEVICE_BAUDRATE: 57600} - ) - - assert res is False - assert mock_connect.call_count == 2 - assert mock_connect.await_count == 2 - assert mock_version.call_count == 2 - assert mock_connect.return_value.close.call_count == 2 - - @patch.object(ezsp.EZSP, "version", new_callable=AsyncMock) @patch.object(ezsp.EZSP, "reset", new_callable=AsyncMock) @patch("bellows.uart.connect", return_value=MagicMock(spec_set=uart.Gateway)) @@ -423,7 +370,7 @@ async def replacement(*args): assert mfg == "Manufacturer" assert brd == "0xFE" - assert ver == "unknown stack version" + assert ver is None with patch.object( ezsp_f, @@ -449,6 +396,26 @@ async def replacement(*args): assert brd == "SkyBlue v0.1" assert ver == "7.1.0.0 build 191" + with patch.object( + ezsp_f, + "_command", + new=cmd_mock( + { + ("getMfgToken", t.EzspMfgTokenId.MFG_BOARD_NAME): (b"\xff" * 16,), + ("getMfgToken", t.EzspMfgTokenId.MFG_STRING): (b"\xff" * 16,), + ("getValue", ezsp_f.types.EzspValueId.VALUE_VERSION_INFO): ( + 0x00, + b"\xbf\x00\x07\x01\x00\x00\xaa", + ), + } + ), + ): + mfg, brd, ver = await ezsp_f.get_board_info() + + assert mfg is None + assert brd is None + assert ver == "7.1.0.0 build 191" + async def test_pre_permit(ezsp_f): with patch("bellows.ezsp.v4.EZSPv4.pre_permit") as pre_mock: @@ -679,7 +646,7 @@ async def test_ezsp_init_zigbeed(conn_mock, reset_mock, version_mock): { "device": { **DEVICE_CONFIG, - config.CONF_DEVICE_PATH: "socket://localhost:1234", + zigpy.config.CONF_DEVICE_PATH: "socket://localhost:1234", } } ) @@ -704,7 +671,7 @@ async def test_ezsp_init_zigbeed_timeout(conn_mock, reset_mock, version_mock): { "device": { **DEVICE_CONFIG, - config.CONF_DEVICE_PATH: "socket://localhost:1234", + zigpy.config.CONF_DEVICE_PATH: "socket://localhost:1234", } } ) diff --git a/tests/test_uart.py b/tests/test_uart.py index 04bd6e96..73f43e46 100644 --- a/tests/test_uart.py +++ b/tests/test_uart.py @@ -3,14 +3,14 @@ import pytest import serial_asyncio +import zigpy.config as conf from bellows import uart -import bellows.config as conf from .async_mock import AsyncMock, MagicMock, patch, sentinel -@pytest.mark.parametrize("flow_control", [conf.CONF_FLOW_CONTROL_DEFAULT, "hardware"]) +@pytest.mark.parametrize("flow_control", ["software", "hardware"]) async def test_connect(flow_control, monkeypatch): appmock = MagicMock() transport = MagicMock() @@ -26,7 +26,7 @@ async def mockconnect(loop, protocol_factory, **kwargs): { conf.CONF_DEVICE_PATH: "/dev/serial", conf.CONF_DEVICE_BAUDRATE: 115200, - conf.CONF_FLOW_CONTROL: flow_control, + conf.CONF_DEVICE_FLOW_CONTROL: flow_control, } ), appmock,