diff --git a/setup.py b/setup.py index ac3f2b5..9325759 100644 --- a/setup.py +++ b/setup.py @@ -21,6 +21,6 @@ author_email="rcloran@gmail.com", license="GPL-3.0", packages=find_packages(exclude=["*.tests"]), - install_requires=["pyserial-asyncio", "zigpy-homeassistant >= 0.17.0"], + install_requires=["pyserial-asyncio", "zigpy>= 0.20.a1"], tests_require=["pytest"], ) diff --git a/tests/test_api.py b/tests/test_api.py index 575a786..c7675c9 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -7,24 +7,26 @@ import zigpy.exceptions from zigpy_xbee import api as xbee_api, types as t, uart +import zigpy_xbee.config from zigpy_xbee.zigbee.application import ControllerApplication +DEVICE_CONFIG = zigpy_xbee.config.SCHEMA_DEVICE( + {zigpy_xbee.config.CONF_DEVICE_PATH: "/dev/null"} +) + @pytest.fixture def api(): - api = xbee_api.XBee() + api = xbee_api.XBee(DEVICE_CONFIG) api._uart = mock.MagicMock() return api @pytest.mark.asyncio async def test_connect(monkeypatch): - api = xbee_api.XBee() - dev = mock.MagicMock() - monkeypatch.setattr( - uart, "connect", mock.MagicMock(side_effect=asyncio.coroutine(mock.MagicMock())) - ) - await api.connect(dev, 115200) + api = xbee_api.XBee(DEVICE_CONFIG) + monkeypatch.setattr(uart, "connect", CoroutineMock()) + await api.connect() def test_close(api): @@ -542,14 +544,13 @@ def test_handle_many_to_one_rri(api): @pytest.mark.asyncio async def test_reconnect_multiple_disconnects(monkeypatch, caplog): - api = xbee_api.XBee() - dev = mock.sentinel.uart + api = xbee_api.XBee(DEVICE_CONFIG) connect_mock = CoroutineMock() connect_mock.return_value = asyncio.Future() connect_mock.return_value.set_result(True) monkeypatch.setattr(uart, "connect", connect_mock) - await api.connect(dev, 115200) + await api.connect() caplog.set_level(logging.DEBUG) connected = asyncio.Future() @@ -568,14 +569,13 @@ async def test_reconnect_multiple_disconnects(monkeypatch, caplog): @pytest.mark.asyncio async def test_reconnect_multiple_attempts(monkeypatch, caplog): - api = xbee_api.XBee() - dev = mock.sentinel.uart + api = xbee_api.XBee(DEVICE_CONFIG) connect_mock = CoroutineMock() connect_mock.return_value = asyncio.Future() connect_mock.return_value.set_result(True) monkeypatch.setattr(uart, "connect", connect_mock) - await api.connect(dev, 115200) + await api.connect() caplog.set_level(logging.DEBUG) connected = asyncio.Future() @@ -597,11 +597,11 @@ async def test_reconnect_multiple_attempts(monkeypatch, caplog): async def test_probe_success(mock_connect, mock_at_cmd): """Test device probing.""" - res = await xbee_api.XBee.probe(mock.sentinel.uart, mock.sentinel.baud) + res = await xbee_api.XBee.probe(DEVICE_CONFIG) assert res is True assert mock_connect.call_count == 1 assert mock_connect.await_count == 1 - assert mock_connect.call_args[0][0] is mock.sentinel.uart + assert mock_connect.call_args[0][0] == DEVICE_CONFIG assert mock_at_cmd.call_count == 1 assert mock_connect.return_value.close.call_count == 1 @@ -613,11 +613,11 @@ async def test_probe_success(mock_connect, mock_at_cmd): async def test_probe_success_api_mode(mock_connect, mock_at_cmd, mock_api_mode): """Test device probing.""" - res = await xbee_api.XBee.probe(mock.sentinel.uart, mock.sentinel.baud) + res = await xbee_api.XBee.probe(DEVICE_CONFIG) assert res is True assert mock_connect.call_count == 1 assert mock_connect.await_count == 1 - assert mock_connect.call_args[0][0] is mock.sentinel.uart + assert mock_connect.call_args[0][0] == DEVICE_CONFIG assert mock_at_cmd.call_count == 1 assert mock_api_mode.call_count == 1 assert mock_connect.return_value.close.call_count == 1 @@ -638,11 +638,11 @@ async def test_probe_fail(mock_connect, mock_at_cmd, mock_api_mode, exception): mock_api_mode.reset_mock() mock_at_cmd.reset_mock() mock_connect.reset_mock() - res = await xbee_api.XBee.probe(mock.sentinel.uart, mock.sentinel.baud) + res = await xbee_api.XBee.probe(DEVICE_CONFIG) assert res is False assert mock_connect.call_count == 1 assert mock_connect.await_count == 1 - assert mock_connect.call_args[0][0] is mock.sentinel.uart + assert mock_connect.call_args[0][0] == DEVICE_CONFIG assert mock_at_cmd.call_count == 1 assert mock_api_mode.call_count == 1 assert mock_connect.return_value.close.call_count == 1 @@ -658,11 +658,21 @@ async def test_probe_fail_api_mode(mock_connect, mock_at_cmd, mock_api_mode): mock_api_mode.reset_mock() mock_at_cmd.reset_mock() mock_connect.reset_mock() - res = await xbee_api.XBee.probe(mock.sentinel.uart, mock.sentinel.baud) + res = await xbee_api.XBee.probe(DEVICE_CONFIG) assert res is False assert mock_connect.call_count == 1 assert mock_connect.await_count == 1 - assert mock_connect.call_args[0][0] is mock.sentinel.uart + assert mock_connect.call_args[0][0] == DEVICE_CONFIG assert mock_at_cmd.call_count == 1 assert mock_api_mode.call_count == 1 assert mock_connect.return_value.close.call_count == 1 + + +@pytest.mark.asyncio +@mock.patch.object(xbee_api.XBee, "connect") +async def test_xbee_new(conn_mck): + """Test new class method.""" + api = await xbee_api.XBee.new(mock.sentinel.application, DEVICE_CONFIG) + assert isinstance(api, xbee_api.XBee) + assert conn_mck.call_count == 1 + assert conn_mck.await_count == 1 diff --git a/tests/test_application.py b/tests/test_application.py index 645a68d..aba5a20 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -1,21 +1,34 @@ import asyncio -from unittest import mock +from asynctest import CoroutineMock, mock import pytest from zigpy import types as t from zigpy.zdo.types import ZDOCmd from zigpy_xbee.api import ModemStatus, XBee +import zigpy_xbee.config as config import zigpy_xbee.types as xbee_t from zigpy_xbee.zigbee import application +APP_CONFIG = { + config.CONF_DEVICE: { + config.CONF_DEVICE_PATH: "/dev/null", + config.CONF_DEVICE_BAUDRATE: 115200, + }, + config.CONF_DATABASE: None, +} + @pytest.fixture -def app(monkeypatch, database_file=None): +def app(monkeypatch): monkeypatch.setattr(application, "TIMEOUT_TX_STATUS", 0.1) monkeypatch.setattr(application, "TIMEOUT_REPLY", 0.1) monkeypatch.setattr(application, "TIMEOUT_REPLY_EXTENDED", 0.1) - return application.ControllerApplication(XBee(), database_file=database_file) + app = application.ControllerApplication(APP_CONFIG) + api = XBee(APP_CONFIG[config.CONF_DEVICE]) + monkeypatch.setattr(api, "_command", CoroutineMock()) + app._api = api + return app def test_modem_status(app): @@ -188,11 +201,7 @@ async def test_broadcast(app): b"\x02\x01\x00", ) - app._api._command = mock.MagicMock( - side_effect=asyncio.coroutine( - mock.MagicMock(return_value=xbee_t.TXStatus.SUCCESS) - ) - ) + app._api._command.return_value = xbee_t.TXStatus.SUCCESS r = await app.broadcast(profile, cluster, src_ep, dst_ep, grpid, radius, tsn, data) assert r[0] == xbee_t.TXStatus.SUCCESS @@ -202,17 +211,11 @@ async def test_broadcast(app): assert app._api._command.call_args[0][4] == dst_ep assert app._api._command.call_args[0][9] == data - app._api._command = mock.MagicMock( - side_effect=asyncio.coroutine( - mock.MagicMock(return_value=xbee_t.TXStatus.ADDRESS_NOT_FOUND) - ) - ) + app._api._command.return_value = xbee_t.TXStatus.ADDRESS_NOT_FOUND r = await app.broadcast(profile, cluster, src_ep, dst_ep, grpid, radius, tsn, data) assert r[0] != xbee_t.TXStatus.SUCCESS - app._api._command = mock.MagicMock( - side_effect=asyncio.coroutine(mock.MagicMock(side_effect=asyncio.TimeoutError)) - ) + app._api._command.side_effect = asyncio.TimeoutError r = await app.broadcast(profile, cluster, src_ep, dst_ep, grpid, radius, tsn, data) assert r[0] != xbee_t.TXStatus.SUCCESS @@ -304,19 +307,17 @@ async def _at_command_mock(cmd, *args): "ZS": zs, }.get(cmd, None) - app._api._at_command = mock.MagicMock( - spec=XBee._at_command, side_effect=_at_command_mock - ) - async def init_api_mode_mock(): nonlocal api_mode api_mode = api_config_succeeds return api_config_succeeds - app._api.init_api_mode = mock.MagicMock(side_effect=init_api_mode_mock) - app.form_network = mock.MagicMock(side_effect=asyncio.coroutine(mock.MagicMock())) + app.form_network = CoroutineMock() - await app.startup(auto_form=auto_form) + with mock.patch.object(XBee, "new") as api: + api.return_value._at_command = CoroutineMock(side_effect=_at_command_mock) + api.return_value.init_api_mode = CoroutineMock(side_effect=init_api_mode_mock) + await app.startup(auto_form=auto_form) return app diff --git a/tests/test_uart.py b/tests/test_uart.py index 3c4d06a..b6c51ab 100644 --- a/tests/test_uart.py +++ b/tests/test_uart.py @@ -5,6 +5,11 @@ import serial_asyncio from zigpy_xbee import uart +import zigpy_xbee.config + +DEVICE_CONFIG = zigpy_xbee.config.SCHEMA_DEVICE( + {zigpy_xbee.config.CONF_DEVICE_PATH: "/dev/null"} +) @pytest.fixture @@ -29,7 +34,6 @@ def test_baudrate_fail(gw): @pytest.mark.asyncio async def test_connect(monkeypatch): api = mock.MagicMock() - portmock = mock.MagicMock() async def mock_conn(loop, protocol_factory, **kwargs): protocol = protocol_factory() @@ -38,7 +42,7 @@ async def mock_conn(loop, protocol_factory, **kwargs): monkeypatch.setattr(serial_asyncio, "create_serial_connection", mock_conn) - await uart.connect(portmock, 57600, api) + await uart.connect(DEVICE_CONFIG, api) def test_command_mode_rsp(gw): diff --git a/zigpy_xbee/api.py b/zigpy_xbee/api.py index 90acac0..d988264 100644 --- a/zigpy_xbee/api.py +++ b/zigpy_xbee/api.py @@ -3,11 +3,15 @@ import enum import functools import logging +from typing import Any, Dict, Optional import serial from zigpy.exceptions import APIException, DeliveryError from zigpy.types import LVList +from zigpy_xbee.config import CONF_DEVICE_BAUDRATE, CONF_DEVICE_PATH, SCHEMA_DEVICE +import zigpy_xbee.zigbee.application + from . import types as t, uart LOGGER = logging.getLogger(__name__) @@ -243,17 +247,17 @@ class ATCommandResult(enum.IntEnum): class XBee: - def __init__(self): - self._uart = None - self._uart_params = None - self._seq = 1 + def __init__(self, device_config: Dict[str, Any]) -> None: + self._config = device_config + self._uart: Optional[uart.Gateway] = None + self._seq: int = 1 self._commands_by_id = {v[0]: k for k, v in COMMAND_RESPONSES.items()} self._awaiting = {} self._app = None - self._cmd_mode_future = None - self._conn_lost_task = None - self._reset = asyncio.Event() - self._running = asyncio.Event() + 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() @property def reset_event(self): @@ -270,24 +274,37 @@ def is_running(self): """Return true if coordinator is running.""" return self.coordinator_started_event.is_set() - async def connect(self, device: str, baudrate: int = 115200) -> None: + @classmethod + async def new( + cls, + application: zigpy_xbee.zigbee.application.ControllerApplication, + config: Dict[str, Any], + ) -> "XBee": + """Create new instance from """ + xbee_api = cls(config) + await xbee_api.connect() + xbee_api.set_application(application) + return xbee_api + + async def connect(self) -> None: assert self._uart is None - self._uart = await uart.connect(device, baudrate, self) - self._uart_params = (device, baudrate) + self._uart = await uart.connect(self._config, self) def reconnect(self): """Reconnect using saved parameters.""" LOGGER.debug( "Reconnecting '%s' serial port using %s", - self._uart_params[0], - self._uart_params[1], + self._config[CONF_DEVICE_PATH], + self._config[CONF_DEVICE_BAUDRATE], ) - return self.connect(self._uart_params[0], self._uart_params[1]) + return self.connect() def connection_lost(self, exc: Exception) -> None: """Lost serial connection.""" LOGGER.warning( - "Serial '%s' connection lost unexpectedly: %s", self._uart_params[0], exc + "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(): @@ -312,7 +329,7 @@ async def _reconnect_till_done(self) -> None: attempt += 1 LOGGER.debug( "Couldn't re-open '%s' serial port, retrying in %ss: %s", - self._uart_params[0], + self._config[CONF_DEVICE_PATH], wait, str(exc), ) @@ -320,7 +337,7 @@ async def _reconnect_till_done(self) -> None: LOGGER.debug( "Reconnected '%s' serial port after %s attempts", - self._uart_params[0], + self._config[CONF_DEVICE_PATH], attempt, ) @@ -551,22 +568,26 @@ async def init_api_mode(self): return False @classmethod - async def probe(cls, device: str, baudrate: int) -> bool: + async def probe(cls, device_config: Dict[str, Any]) -> bool: """Probe port for the device presence.""" - api = cls() + api = cls(SCHEMA_DEVICE(device_config)) try: - await asyncio.wait_for(api._probe(device, baudrate), timeout=PROBE_TIMEOUT) + await asyncio.wait_for(api._probe(), timeout=PROBE_TIMEOUT) return True except (asyncio.TimeoutError, serial.SerialException, APIException) as exc: - LOGGER.debug("Unsuccessful radio probe of '%s' port", exc_info=exc) + LOGGER.debug( + "Unsuccessful radio probe of '%s' port", + device_config[CONF_DEVICE_PATH], + exc_info=exc, + ) finally: api.close() return False - async def _probe(self, device: str, baudrate: int) -> None: + async def _probe(self) -> None: """Open port and try sending a command""" - await self.connect(device, baudrate) + await self.connect() try: # Ensure we have escaped commands await self._at_command("AP", 2) diff --git a/zigpy_xbee/config.py b/zigpy_xbee/config.py new file mode 100644 index 0000000..eb7bf87 --- /dev/null +++ b/zigpy_xbee/config.py @@ -0,0 +1,17 @@ +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" + +SCHEMA_DEVICE = SCHEMA_DEVICE.extend( + {vol.Optional(CONF_DEVICE_BAUDRATE, default=57600): int} +) + +CONFIG_SCHEMA = CONFIG_SCHEMA.extend({vol.Required(CONF_DEVICE): SCHEMA_DEVICE}) diff --git a/zigpy_xbee/uart.py b/zigpy_xbee/uart.py index 1bfc34b..dcfe359 100644 --- a/zigpy_xbee/uart.py +++ b/zigpy_xbee/uart.py @@ -1,9 +1,12 @@ import asyncio import logging +from typing import Any, Dict import serial import serial_asyncio +from zigpy_xbee.config import CONF_DEVICE_BAUDRATE, CONF_DEVICE_PATH + LOGGER = logging.getLogger(__name__) @@ -159,7 +162,7 @@ def _checksum(self, data): return 0xFF - (sum(data) % 0x100) -async def connect(port, baudrate, api, loop=None): +async def connect(device_config: Dict[str, Any], api, loop=None) -> Gateway: if loop is None: loop = asyncio.get_event_loop() @@ -169,8 +172,8 @@ async def connect(port, baudrate, api, loop=None): transport, protocol = await serial_asyncio.create_serial_connection( loop, lambda: protocol, - url=port, - baudrate=baudrate, + url=device_config[CONF_DEVICE_PATH], + baudrate=device_config[CONF_DEVICE_BAUDRATE], parity=serial.PARITY_NONE, stopbits=serial.STOPBITS_ONE, xonxoff=False, diff --git a/zigpy_xbee/zigbee/application.py b/zigpy_xbee/zigbee/application.py index 8c4545f..93bfbb9 100644 --- a/zigpy_xbee/zigbee/application.py +++ b/zigpy_xbee/zigbee/application.py @@ -2,8 +2,10 @@ import binascii import logging import time +from typing import Any, Dict, Optional import zigpy.application +import zigpy.config import zigpy.device import zigpy.exceptions import zigpy.quirks @@ -12,6 +14,8 @@ from zigpy.zcl.clusters.general import Groups from zigpy.zdo.types import NodeDescriptor, ZDOCmd +import zigpy_xbee.api +from zigpy_xbee.config import CONF_DEVICE, CONFIG_SCHEMA from zigpy_xbee.types import EUI64, UNKNOWN_IEEE, UNKNOWN_NWK, TXStatus # how long coordinator would hold message for an end device in 10ms units @@ -28,19 +32,21 @@ class ControllerApplication(zigpy.application.ControllerApplication): - def __init__(self, api, database_file=None): - super().__init__(database_file=database_file) - self._api = api - api.set_application(self) + SCHEMA = CONFIG_SCHEMA + def __init__(self, config: Dict[str, Any]): + super().__init__(config=zigpy.config.ZIGPY_SCHEMA(config)) + self._api: Optional[zigpy_xbee.api.XBee] = None self._nwk = 0 async def shutdown(self): """Shutdown application.""" - self._api.close() + if self._api: + self._api.close() async def startup(self, auto_form=False): """Perform a complete application startup""" + self._api = await zigpy_xbee.api.XBee.new(self, self._config[CONF_DEVICE]) try: # Ensure we have escaped commands await self._api._at_command("AP", 2)