diff --git a/pyproject.toml b/pyproject.toml index 1eea6cc..1544f76 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ readme = "README.md" license = {text = "GPL-3.0"} requires-python = ">=3.8" dependencies = [ - "zigpy>=0.56.0", + "zigpy>=0.60.0", ] [tool.setuptools.packages.find] diff --git a/tests/test_api.py b/tests/test_api.py index 8facdc9..259d11a 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,22 +1,24 @@ """Tests for API.""" import asyncio -import logging import pytest import serial +import zigpy.config import zigpy.exceptions import zigpy.types as t from zigpy_xbee import api as xbee_api, types as xbee_t, uart -import zigpy_xbee.config from zigpy_xbee.exceptions import ATCommandError, ATCommandException, InvalidCommand from zigpy_xbee.zigbee.application import ControllerApplication import tests.async_mock as mock -DEVICE_CONFIG = zigpy_xbee.config.SCHEMA_DEVICE( - {zigpy_xbee.config.CONF_DEVICE_PATH: "/dev/null"} +DEVICE_CONFIG = zigpy.config.SCHEMA_DEVICE( + { + zigpy.config.CONF_DEVICE_PATH: "/dev/null", + zigpy.config.CONF_DEVICE_BAUDRATE: 57600, + } ) @@ -38,13 +40,8 @@ async def test_connect(monkeypatch): def test_close(api): """Test connection close.""" uart = api._uart - conn_lost_task = mock.MagicMock() - api._conn_lost_task = conn_lost_task - api.close() - assert api._conn_lost_task is None - assert conn_lost_task.cancel.call_count == 1 assert api._uart is None assert uart.close.call_count == 1 @@ -602,51 +599,6 @@ def test_handle_many_to_one_rri(api): api._handle_many_to_one_rri(ieee, nwk, 0) -async def test_reconnect_multiple_disconnects(monkeypatch, caplog): - """Test reconnect with multiple disconnects.""" - api = xbee_api.XBee(DEVICE_CONFIG) - connect_mock = mock.AsyncMock(return_value=True) - monkeypatch.setattr(uart, "connect", connect_mock) - - await api.connect() - - caplog.set_level(logging.DEBUG) - connect_mock.reset_mock() - connect_mock.side_effect = [OSError, mock.sentinel.uart_reconnect] - api.connection_lost("connection lost") - await asyncio.sleep(0.3) - api.connection_lost("connection lost 2") - await asyncio.sleep(0.3) - - assert "Cancelling reconnection attempt" in caplog.messages - assert api._uart is mock.sentinel.uart_reconnect - assert connect_mock.call_count == 2 - - -async def test_reconnect_multiple_attempts(monkeypatch, caplog): - """Test reconnect with multiple attempts.""" - api = xbee_api.XBee(DEVICE_CONFIG) - connect_mock = mock.AsyncMock(return_value=True) - monkeypatch.setattr(uart, "connect", connect_mock) - - await api.connect() - - caplog.set_level(logging.DEBUG) - connect_mock.reset_mock() - connect_mock.side_effect = [ - asyncio.TimeoutError, - OSError, - mock.sentinel.uart_reconnect, - ] - - with mock.patch("asyncio.sleep"): - api.connection_lost("connection lost") - await api._conn_lost_task - - assert api._uart is mock.sentinel.uart_reconnect - assert connect_mock.call_count == 3 - - @mock.patch.object(xbee_api.XBee, "_at_command", new_callable=mock.AsyncMock) @mock.patch.object(uart, "connect", return_value=mock.MagicMock()) async def test_probe_success(mock_connect, mock_at_cmd): @@ -727,3 +679,17 @@ async def test_xbee_new(conn_mck): assert isinstance(api, xbee_api.XBee) assert conn_mck.call_count == 1 assert conn_mck.await_count == 1 + + +@mock.patch.object(xbee_api.XBee, "connect", return_value=mock.MagicMock()) +async def test_connection_lost(conn_mck): + """Test `connection_lost` propagataion.""" + api = await xbee_api.XBee.new(mock.sentinel.application, DEVICE_CONFIG) + await api.connect() + + app = api._app = mock.MagicMock() + + err = RuntimeError() + api.connection_lost(err) + + app.connection_lost.assert_called_once_with(err) diff --git a/tests/test_application.py b/tests/test_application.py index d70373d..8320a25 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -3,6 +3,7 @@ import asyncio import pytest +import zigpy.config as config import zigpy.exceptions import zigpy.state import zigpy.types as t @@ -10,7 +11,6 @@ import zigpy.zdo.types as zdo_t from zigpy_xbee.api import XBee -import zigpy_xbee.config as config from zigpy_xbee.exceptions import InvalidCommand import zigpy_xbee.types as xbee_t from zigpy_xbee.zigbee import application @@ -363,6 +363,7 @@ def _at_command_mock(cmd, *args): "SH": 0x08070605, "SL": 0x04030201, "ZS": zs, + "VR": 0x1234, }.get(cmd, None) def init_api_mode_mock(): @@ -441,20 +442,6 @@ async def test_permit(app): assert app._api._at_command.call_args_list[0][0][1] == time_s -async def test_permit_with_key(app): - """Test permit joins with join code.""" - app._api._command = mock.AsyncMock(return_value=xbee_t.TXStatus.SUCCESS) - app._api._at_command = mock.AsyncMock(return_value="OK") - node = t.EUI64(b"\x01\x02\x03\x04\x05\x06\x07\x08") - code = b"\xC9\xA7\xD2\x44\x1A\x71\x16\x95\xCD\x62\x17\x0D\x33\x28\xEA\x2B\x42\x3D" - time_s = 500 - await app.permit_with_key(node=node, code=code, time_s=time_s) - app._api._at_command.assert_called_once_with("KT", time_s) - app._api._command.assert_called_once_with( - "register_joining_device", node, 0xFFFE, 1, code - ) - - async def test_permit_with_link_key(app): """Test permit joins with link key.""" app._api._command = mock.AsyncMock(return_value=xbee_t.TXStatus.SUCCESS) @@ -879,3 +866,11 @@ async def test_routes_updated(app, device): assert router2.radio_details.call_count == 0 app._api._at_command.assert_awaited_once_with("DB") + + +async def test_watchdog(app): + """Test watchdog feed method.""" + app._api._at_command = mock.AsyncMock(return_value="OK") + await app._watchdog_feed() + + assert app._api._at_command.mock_calls == [mock.call("VR")] diff --git a/tests/test_uart.py b/tests/test_uart.py index 6d1bc2d..93362dc 100644 --- a/tests/test_uart.py +++ b/tests/test_uart.py @@ -5,12 +5,15 @@ import pytest import serial_asyncio +import zigpy.config from zigpy_xbee import uart -import zigpy_xbee.config -DEVICE_CONFIG = zigpy_xbee.config.SCHEMA_DEVICE( - {zigpy_xbee.config.CONF_DEVICE_PATH: "/dev/null"} +DEVICE_CONFIG = zigpy.config.SCHEMA_DEVICE( + { + zigpy.config.CONF_DEVICE_PATH: "/dev/null", + zigpy.config.CONF_DEVICE_BAUDRATE: 57600, + } ) diff --git a/zigpy_xbee/api.py b/zigpy_xbee/api.py index 1076e9c..b4a73da 100644 --- a/zigpy_xbee/api.py +++ b/zigpy_xbee/api.py @@ -7,11 +7,11 @@ from typing import Any, Dict, Optional import serial +from zigpy.config import CONF_DEVICE_PATH, SCHEMA_DEVICE from zigpy.exceptions import APIException, DeliveryError import zigpy.types as t import zigpy_xbee -from zigpy_xbee.config import CONF_DEVICE_BAUDRATE, CONF_DEVICE_PATH, SCHEMA_DEVICE from zigpy_xbee.exceptions import ( ATCommandError, ATCommandException, @@ -287,7 +287,6 @@ def __init__(self, device_config: Dict[str, Any]) -> None: self._awaiting = {} self._app = None self._cmd_mode_future: Optional[asyncio.Future] = None - self._conn_lost_task: Optional[asyncio.Task] = None self._reset: asyncio.Event = asyncio.Event() self._running: asyncio.Event = asyncio.Event() @@ -323,64 +322,13 @@ async def connect(self) -> None: assert self._uart is None self._uart = await uart.connect(self._config, self) - def reconnect(self): - """Reconnect using saved parameters.""" - LOGGER.debug( - "Reconnecting '%s' serial port using %s", - self._config[CONF_DEVICE_PATH], - self._config[CONF_DEVICE_BAUDRATE], - ) - return self.connect() - def connection_lost(self, exc: Exception) -> None: """Lost serial connection.""" - LOGGER.warning( - "Serial '%s' connection lost unexpectedly: %s", - self._config[CONF_DEVICE_PATH], - exc, - ) - 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.create_task(self._connection_lost()) - - async def _connection_lost(self) -> None: - """Reconnect serial port.""" - try: - await self._reconnect_till_done() - except asyncio.CancelledError: - LOGGER.debug("Cancelling reconnection attempt") - raise - - async def _reconnect_till_done(self) -> None: - attempt = 1 - while True: - try: - await asyncio.wait_for(self.reconnect(), timeout=10) - break - except (asyncio.TimeoutError, OSError) as exc: - wait = 2 ** min(attempt, 5) - attempt += 1 - LOGGER.debug( - "Couldn't re-open '%s' serial port, retrying in %ss: %s", - self._config[CONF_DEVICE_PATH], - wait, - str(exc), - ) - await asyncio.sleep(wait) - - LOGGER.debug( - "Reconnected '%s' serial port after %s attempts", - self._config[CONF_DEVICE_PATH], - attempt, - ) + if self._app is not None: + self._app.connection_lost(exc) def close(self): """Close the connection.""" - if self._conn_lost_task: - self._conn_lost_task.cancel() - self._conn_lost_task = None - if self._uart: self._uart.close() self._uart = None diff --git a/zigpy_xbee/config.py b/zigpy_xbee/config.py index 385b6a5..ec5b1e5 100644 --- a/zigpy_xbee/config.py +++ b/zigpy_xbee/config.py @@ -1,19 +1,12 @@ """XBee module config.""" import voluptuous as vol -from zigpy.config import ( # noqa: F401 pylint: disable=unused-import - CONF_DATABASE, - CONF_DEVICE, - CONF_DEVICE_PATH, - CONFIG_SCHEMA, - SCHEMA_DEVICE, - cv_boolean, -) - -CONF_DEVICE_BAUDRATE = "baudrate" +import zigpy.config -SCHEMA_DEVICE = SCHEMA_DEVICE.extend( - {vol.Optional(CONF_DEVICE_BAUDRATE, default=57600): int} +SCHEMA_DEVICE = zigpy.config.SCHEMA_DEVICE.extend( + {vol.Optional(zigpy.config.CONF_DEVICE_BAUDRATE, default=57600): int} ) -CONFIG_SCHEMA = CONFIG_SCHEMA.extend({vol.Required(CONF_DEVICE): SCHEMA_DEVICE}) +CONFIG_SCHEMA = zigpy.config.CONFIG_SCHEMA.extend( + {vol.Required(zigpy.config.CONF_DEVICE): zigpy.config.SCHEMA_DEVICE} +) diff --git a/zigpy_xbee/uart.py b/zigpy_xbee/uart.py index 28eed71..2dbacd1 100644 --- a/zigpy_xbee/uart.py +++ b/zigpy_xbee/uart.py @@ -4,10 +4,9 @@ import logging from typing import Any, Dict +import zigpy.config import zigpy.serial -from zigpy_xbee.config import CONF_DEVICE_BAUDRATE, CONF_DEVICE_PATH - LOGGER = logging.getLogger(__name__) @@ -178,8 +177,8 @@ async def connect(device_config: Dict[str, Any], api, loop=None) -> Gateway: transport, protocol = await zigpy.serial.create_serial_connection( loop, lambda: protocol, - url=device_config[CONF_DEVICE_PATH], - baudrate=device_config[CONF_DEVICE_BAUDRATE], + url=device_config[zigpy.config.CONF_DEVICE_PATH], + baudrate=device_config[zigpy.config.CONF_DEVICE_BAUDRATE], xonxoff=False, ) diff --git a/zigpy_xbee/zigbee/application.py b/zigpy_xbee/zigbee/application.py index dad1ae0..2158b95 100644 --- a/zigpy_xbee/zigbee/application.py +++ b/zigpy_xbee/zigbee/application.py @@ -10,6 +10,7 @@ import zigpy.application import zigpy.config +from zigpy.config import CONF_DEVICE import zigpy.device import zigpy.exceptions import zigpy.quirks @@ -22,7 +23,7 @@ import zigpy_xbee import zigpy_xbee.api -from zigpy_xbee.config import CONF_DEVICE, CONFIG_SCHEMA, SCHEMA_DEVICE +import zigpy_xbee.config from zigpy_xbee.exceptions import InvalidCommand from zigpy_xbee.types import EUI64, UNKNOWN_IEEE, UNKNOWN_NWK, TXOptions, TXStatus @@ -42,10 +43,7 @@ class ControllerApplication(zigpy.application.ControllerApplication): """Implementation of Zigpy ControllerApplication for XBee devices.""" - SCHEMA = CONFIG_SCHEMA - SCHEMA_DEVICE = SCHEMA_DEVICE - - probe = zigpy_xbee.api.XBee.probe + CONFIG_SCHEMA = zigpy_xbee.config.CONFIG_SCHEMA def __init__(self, config: dict[str, Any]): """Initialize instance.""" @@ -53,6 +51,9 @@ def __init__(self, config: dict[str, Any]): self._api: zigpy_xbee.api.XBee | None = None self.topology.add_listener(self) + async def _watchdog_feed(self): + await self._api._at_command("VR") + async def disconnect(self): """Shutdown application.""" if self._api: @@ -136,6 +137,13 @@ async def load_network_info(self, *, load_devices=False): LOGGER.warning("CE command failed, assuming node is coordinator") node_info.logical_type = zdo_t.LogicalType.Coordinator + # TODO: Feature detect the XBee's exact model + node_info.model = "XBee" + node_info.manufacturer = "Digi" + + version = await self._api._at_command("VR") + node_info.version = f"{int(version):#06x}" + # Load network info pan_id = await self._api._at_command("OI") extended_pan_id = await self._api._at_command("ID") @@ -350,10 +358,6 @@ async def permit_with_link_key( # 1 = Install Code With CRC (I? command of the joining device) await self._api.register_joining_device(node, reserved, key_type, link_key) - async def permit_with_key(self, node: EUI64, code: bytes, time_s=500): - """Permits a new device to join with the given IEEE and Install Code.""" - await self.permit_with_link_key(node, code, time_s, key_type=1) - def handle_modem_status(self, status): """Handle changed Modem Status of the device.""" LOGGER.info("Modem status update: %s (%s)", status.name, status.value)