Skip to content

Sync up with zigpy 0.60.0 #170

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Nov 20, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
74 changes: 20 additions & 54 deletions tests/test_api.py
Original file line number Diff line number Diff line change
@@ -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,
}
)


Expand All @@ -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

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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)
25 changes: 10 additions & 15 deletions tests/test_application.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@
import asyncio

import pytest
import zigpy.config as config
import zigpy.exceptions
import zigpy.state
import zigpy.types as t
import zigpy.zdo
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
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")]
9 changes: 6 additions & 3 deletions tests/test_uart.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
)


Expand Down
58 changes: 3 additions & 55 deletions zigpy_xbee/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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
Expand Down
19 changes: 6 additions & 13 deletions zigpy_xbee/config.py
Original file line number Diff line number Diff line change
@@ -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}
)
7 changes: 3 additions & 4 deletions zigpy_xbee/uart.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)


Expand Down Expand Up @@ -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,
)

Expand Down
22 changes: 13 additions & 9 deletions zigpy_xbee/zigbee/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -42,17 +43,17 @@
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."""
super().__init__(config=zigpy.config.ZIGPY_SCHEMA(config))
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:
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand Down