Skip to content

Commit

Permalink
Implement new zigpy radio API (#123)
Browse files Browse the repository at this point in the history
* WIP

* Re-enable ZDO passthrough

* Fix unit tests for Python 3.8+

* Fix network formation logic

* Clean up types

* Remove `asyncio.coroutine` decorator and clean up tests

* Increase patch test coverage to 100%

* Update pre-commit config to fix issue with `black`

* Use zigpy `_device` property

* Fix invalid calls to `str.format`

* Pass the correct data type when writing keys

* Create an `add_endpoint` stub

* Use new ZCL cluster command syntax

* Fix isort config warnings

* Include radio library version in network info

* Use the correct data types when setting the PAN IDs

* Do not send `CB(2)` when permitting joins
#123 (comment)

* Bump minimum required zigpy version to 0.47.0
  • Loading branch information
puddly committed Jun 21, 2022
1 parent dfa2196 commit 4ccb5ae
Show file tree
Hide file tree
Showing 11 changed files with 242 additions and 238 deletions.
10 changes: 5 additions & 5 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
repos:
- repo: https://github.com/psf/black
rev: 19.10b0
rev: 22.3.0
hooks:
- id: black
args:
- --safe
- --quiet
- repo: https://gitlab.com/pycqa/flake8
rev: 3.7.9
rev: 4.0.1
hooks:
- id: flake8
- repo: https://github.com/pre-commit/mirrors-isort
rev: v4.3.21
- repo: https://github.com/PyCQA/isort
rev: 5.10.1
hooks:
- id: isort
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v2.4.0
rev: v4.1.0
hooks:
- id: no-commit-to-branch
args:
Expand Down
7 changes: 4 additions & 3 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,13 @@ force_grid_wrap=0
use_parentheses=True
line_length=88
indent = " "
# by default isort don't check module indexes
not_skip = __init__.py
# will group `import x` and `from x import` of the same module.
force_sort_within_sections = true
sections = FUTURE,STDLIB,INBETWEENS,THIRDPARTY,FIRSTPARTY,LOCALFOLDER
sections = FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER
default_section = THIRDPARTY
known_first_party = zigpy_xbee,tests
forced_separate = tests
combine_as_imports = true

[tool:pytest]
asyncio_mode = auto
12 changes: 4 additions & 8 deletions setup.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,22 @@
"""Setup module for zigpy-xbee"""

import os
import pathlib

from setuptools import find_packages, setup

import zigpy_xbee

this_directory = os.path.join(os.path.abspath(os.path.dirname(__file__)))
with open(os.path.join(this_directory, "README.md"), encoding="utf-8") as f:
long_description = f.read()

setup(
name="zigpy-xbee",
version=zigpy_xbee.__version__,
description="A library which communicates with XBee radios for zigpy",
long_description=long_description,
long_description=(pathlib.Path(__file__).parent / "README.md").read_text(),
long_description_content_type="text/markdown",
url="http://github.com/zigpy/zigpy-xbee",
author="Russell Cloran",
author_email="rcloran@gmail.com",
license="GPL-3.0",
packages=find_packages(exclude=["*.tests"]),
install_requires=["pyserial-asyncio", "zigpy>= 0.23.0"],
tests_require=["pytest"],
install_requires=["pyserial-asyncio", "zigpy>=0.47.0"],
tests_require=["pytest", "asynctest", "pytest-asyncio"],
)
Empty file added tests/__init__.py
Empty file.
9 changes: 9 additions & 0 deletions tests/async_mock.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"""Mock utilities that are async aware."""
import sys

if sys.version_info[:2] < (3, 8):
from asynctest.mock import * # noqa

AsyncMock = CoroutineMock # noqa: F405
else:
from unittest.mock import * # noqa
67 changes: 17 additions & 50 deletions tests/test_api.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import asyncio
import logging

from asynctest import CoroutineMock, mock
import pytest
import serial
import zigpy.exceptions
Expand All @@ -10,6 +9,8 @@
import zigpy_xbee.config
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"}
)
Expand All @@ -22,10 +23,9 @@ def api():
return api


@pytest.mark.asyncio
async def test_connect(monkeypatch):
api = xbee_api.XBee(DEVICE_CONFIG)
monkeypatch.setattr(uart, "connect", CoroutineMock())
monkeypatch.setattr(uart, "connect", mock.AsyncMock())
await api.connect()


Expand All @@ -52,7 +52,6 @@ def test_commands():
assert reply is None or isinstance(reply, int)


@pytest.mark.asyncio
async def test_command(api):
def mock_api_frame(name, *args):
c = xbee_api.COMMAND_REQUESTS[name]
Expand Down Expand Up @@ -90,7 +89,6 @@ def mock_api_frame(name, *args):
api._uart.send.reset_mock()


@pytest.mark.asyncio
async def test_command_not_connected(api):
api._uart = None

Expand Down Expand Up @@ -135,20 +133,17 @@ def mock_command(name, *args):
api._command.reset_mock()


@pytest.mark.asyncio
async def test_at_command(api, monkeypatch):
await _test_at_or_queued_at_command(api, api._at_command, monkeypatch)


@pytest.mark.asyncio
async def test_at_command_no_response(api, monkeypatch):
with pytest.raises(asyncio.TimeoutError):
await _test_at_or_queued_at_command(
api, api._at_command, monkeypatch, do_reply=False
)


@pytest.mark.asyncio
async def test_queued_at_command(api, monkeypatch):
await _test_at_or_queued_at_command(api, api._queued_at, monkeypatch)

Expand Down Expand Up @@ -191,12 +186,10 @@ def mock_command(name, *args):
api._command.reset_mock()


@pytest.mark.asyncio
async def test_remote_at_cmd(api, monkeypatch):
await _test_remote_at_command(api, monkeypatch)


@pytest.mark.asyncio
async def test_remote_at_cmd_no_rsp(api, monkeypatch):
monkeypatch.setattr(xbee_api, "REMOTE_AT_COMMAND_TIMEOUT", 0.1)
with pytest.raises(asyncio.TimeoutError):
Expand Down Expand Up @@ -417,7 +410,6 @@ def test_handle_tx_status_duplicate(api):
assert send_fut.set_exception.call_count == 0


@pytest.mark.asyncio
async def test_command_mode_at_cmd(api):
command = "+++"

Expand All @@ -430,7 +422,6 @@ def cmd_mode_send(cmd):
assert result


@pytest.mark.asyncio
async def test_command_mode_at_cmd_timeout(api):
command = "+++"

Expand Down Expand Up @@ -462,21 +453,15 @@ def test_handle_command_mode_rsp(api):
assert api._cmd_mode_future.result() == data


@pytest.mark.asyncio
async def test_enter_at_command_mode(api):
api.command_mode_at_cmd = mock.MagicMock(
side_effect=asyncio.coroutine(lambda x: mock.sentinel.at_response)
)
api.command_mode_at_cmd = mock.AsyncMock(return_value=mock.sentinel.at_response)

res = await api.enter_at_command_mode()
assert res == mock.sentinel.at_response


@pytest.mark.asyncio
async def test_api_mode_at_commands(api):
api.command_mode_at_cmd = mock.MagicMock(
side_effect=asyncio.coroutine(lambda x: mock.sentinel.api_mode)
)
api.command_mode_at_cmd = mock.AsyncMock(return_value=mock.sentinel.api_mode)

res = await api.api_mode_at_commands(57600)
assert res is True
Expand All @@ -491,20 +476,15 @@ async def mock_at_cmd(cmd):
assert res is None


@pytest.mark.asyncio
async def test_init_api_mode(api, monkeypatch):
monkeypatch.setattr(api._uart, "baudrate", 57600)
api.enter_at_command_mode = mock.MagicMock(
side_effect=asyncio.coroutine(mock.MagicMock(return_value=True))
)
api.enter_at_command_mode = mock.AsyncMock(return_value=True)

res = await api.init_api_mode()
assert res is None
assert api.enter_at_command_mode.call_count == 1

api.enter_at_command_mode = mock.MagicMock(
side_effect=asyncio.coroutine(mock.MagicMock(return_value=False))
)
api.enter_at_command_mode = mock.AsyncMock(return_value=False)

res = await api.init_api_mode()
assert res is False
Expand All @@ -517,9 +497,7 @@ async def enter_at_mode():

api._uart.baudrate = 57600
api.enter_at_command_mode = mock.MagicMock(side_effect=enter_at_mode)
api.api_mode_at_commands = mock.MagicMock(
side_effect=asyncio.coroutine(mock.MagicMock(return_value=True))
)
api.api_mode_at_commands = mock.AsyncMock(return_value=True)

res = await api.init_api_mode()
assert res is True
Expand All @@ -542,21 +520,16 @@ def test_handle_many_to_one_rri(api):
api._handle_many_to_one_rri(ieee, nwk, 0)


@pytest.mark.asyncio
async def test_reconnect_multiple_disconnects(monkeypatch, caplog):
api = xbee_api.XBee(DEVICE_CONFIG)
connect_mock = CoroutineMock()
connect_mock.return_value = asyncio.Future()
connect_mock.return_value.set_result(True)
connect_mock = mock.AsyncMock(return_value=True)
monkeypatch.setattr(uart, "connect", connect_mock)

await api.connect()

caplog.set_level(logging.DEBUG)
connected = asyncio.Future()
connected.set_result(mock.sentinel.uart_reconnect)
connect_mock.reset_mock()
connect_mock.side_effect = [asyncio.Future(), connected]
connect_mock.side_effect = [OSError, mock.sentinel.uart_reconnect]
api.connection_lost("connection lost")
await asyncio.sleep(0.3)
api.connection_lost("connection lost 2")
Expand All @@ -567,21 +540,20 @@ async def test_reconnect_multiple_disconnects(monkeypatch, caplog):
assert connect_mock.call_count == 2


@pytest.mark.asyncio
async def test_reconnect_multiple_attempts(monkeypatch, caplog):
api = xbee_api.XBee(DEVICE_CONFIG)
connect_mock = CoroutineMock()
connect_mock.return_value = asyncio.Future()
connect_mock.return_value.set_result(True)
connect_mock = mock.AsyncMock(return_value=True)
monkeypatch.setattr(uart, "connect", connect_mock)

await api.connect()

caplog.set_level(logging.DEBUG)
connected = asyncio.Future()
connected.set_result(mock.sentinel.uart_reconnect)
connect_mock.reset_mock()
connect_mock.side_effect = [asyncio.TimeoutError, OSError, connected]
connect_mock.side_effect = [
asyncio.TimeoutError,
OSError,
mock.sentinel.uart_reconnect,
]

with mock.patch("asyncio.sleep"):
api.connection_lost("connection lost")
Expand All @@ -591,8 +563,7 @@ async def test_reconnect_multiple_attempts(monkeypatch, caplog):
assert connect_mock.call_count == 3


@pytest.mark.asyncio
@mock.patch.object(xbee_api.XBee, "_at_command", new_callable=CoroutineMock)
@mock.patch.object(xbee_api.XBee, "_at_command", new_callable=mock.AsyncMock)
@mock.patch.object(uart, "connect")
async def test_probe_success(mock_connect, mock_at_cmd):
"""Test device probing."""
Expand All @@ -606,7 +577,6 @@ async def test_probe_success(mock_connect, mock_at_cmd):
assert mock_connect.return_value.close.call_count == 1


@pytest.mark.asyncio
@mock.patch.object(xbee_api.XBee, "init_api_mode", return_value=True)
@mock.patch.object(xbee_api.XBee, "_at_command", side_effect=asyncio.TimeoutError)
@mock.patch.object(uart, "connect")
Expand All @@ -623,7 +593,6 @@ async def test_probe_success_api_mode(mock_connect, mock_at_cmd, mock_api_mode):
assert mock_connect.return_value.close.call_count == 1


@pytest.mark.asyncio
@mock.patch.object(xbee_api.XBee, "init_api_mode")
@mock.patch.object(xbee_api.XBee, "_at_command", side_effect=asyncio.TimeoutError)
@mock.patch.object(uart, "connect")
Expand All @@ -648,7 +617,6 @@ async def test_probe_fail(mock_connect, mock_at_cmd, mock_api_mode, exception):
assert mock_connect.return_value.close.call_count == 1


@pytest.mark.asyncio
@mock.patch.object(xbee_api.XBee, "init_api_mode", return_value=False)
@mock.patch.object(xbee_api.XBee, "_at_command", side_effect=asyncio.TimeoutError)
@mock.patch.object(uart, "connect")
Expand All @@ -668,7 +636,6 @@ async def test_probe_fail_api_mode(mock_connect, mock_at_cmd, mock_api_mode):
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."""
Expand Down
Loading

0 comments on commit 4ccb5ae

Please sign in to comment.