Skip to content

Commit

Permalink
Merge pull request #205 from puddly/rc
Browse files Browse the repository at this point in the history
0.18.1 Release
  • Loading branch information
puddly authored Sep 9, 2022
2 parents f236e8d + 4adde3b commit 9b595f5
Show file tree
Hide file tree
Showing 8 changed files with 171 additions and 158 deletions.
15 changes: 10 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@

[zigpy-deconz](https://github.com/zigpy/zigpy-deconz) is a Python 3 implementation for the [Zigpy](https://github.com/zigpy/) project to implement [deCONZ](https://www.dresden-elektronik.de/funktechnik/products/software/pc/deconz/) based [Zigbee](https://www.zigbee.org) radio devices.

The goal of this project to add native support for the Dresden-Elektronik deCONZ based ZigBee modules in Home Assistant via [Zigpy](https://github.com/zigpy/).
The goal of this project to add native support for the Dresden-Elektronik/Phoscon deCONZ based ZigBee modules in Home Assistant via [zigpy](https://github.com/zigpy/).

This library uses the deCONZ serial protocol for communicating with [ConBee](https://www.dresden-elektronik.de/conbee/), [ConBee II (ConBee 2)](https://shop.dresden-elektronik.de/conbee-2.html), and [RaspBee](https://www.dresden-elektronik.de/raspbee/) adapters from [Dresden-Elektronik](https://github.com/dresden-elektronik/).
This library uses the deCONZ serial protocol for communicating with [ConBee](https://phoscon.de/en/conbee), [ConBee II (ConBee 2)](https://phoscon.de/en/conbee2), [RaspBee](https://phoscon.de/en/raspbee), and [RaspBee II (RaspBee 2)](https://phoscon.de/en/raspbee2) adapters from [Dresden-Elektronik](https://github.com/dresden-elektronik/)/[Phoscon](https://phoscon.de).

# Releases via PyPI

Tagged versions are also released via PyPI

- https://pypi.org/project/zigpy-deconz/
Expand All @@ -18,10 +19,13 @@ Tagged versions are also released via PyPI

# External documentation and reference

Note! Latest official documentation for the deCONZ serial protocol can currently be obtained by contacting Dresden-Elektronik employees via GitHub here
- https://github.com/dresden-elektronik/deconz-rest-plugin/issues/158
Note! Latest official documentation for the deCONZ serial protocol can currently be obtained by following link in Dresden-Elektronik GitHub repository here:

- https://github.com/dresden-elektronik/deconz-serial-protocol
- https://github.com/dresden-elektronik/deconz-serial-protocol/issues/2

For reference, here is a list of unrelated projects that also use the same deCONZ serial protocol for other implementations:

For reference, here is a list of unrelated projects that also use the same deCONZ serial protocol for other implementations
- https://github.com/Equidamoid/pyconz/commits/master
- https://github.com/mozilla-iot/deconz-api
- https://github.com/adetante/deconz-sp
Expand All @@ -30,6 +34,7 @@ For reference, here is a list of unrelated projects that also use the same deCON
# How to contribute

If you are looking to make a contribution to this project we suggest that you follow the steps in these guides:

- https://github.com/firstcontributions/first-contributions/blob/master/README.md
- https://github.com/firstcontributions/first-contributions/blob/master/github-desktop-tutorial.md

Expand Down
63 changes: 9 additions & 54 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import asyncio
import binascii
import enum
import logging

import pytest
import zigpy.config
Expand Down Expand Up @@ -458,47 +457,6 @@ def test_device_state_network_state(data, network_state):
assert state.serialize() == new_data


@patch("zigpy_deconz.uart.connect")
async def test_reconnect_multiple_disconnects(connect_mock, caplog):
api = deconz_api.Deconz(None, DEVICE_CONFIG)
gw = MagicMock(spec_set=uart.Gateway)
connect_mock.return_value = gw

await api.connect()

caplog.set_level(logging.DEBUG)
connect_mock.reset_mock()
connect_mock.return_value = asyncio.Future()
api.connection_lost("connection lost")
await asyncio.sleep(0)
connect_mock.return_value = sentinel.uart_reconnect
api.connection_lost("connection lost 2")
await asyncio.sleep(0)

assert api._uart is sentinel.uart_reconnect
assert connect_mock.call_count == 1


@patch("zigpy_deconz.uart.connect")
async def test_reconnect_multiple_attempts(connect_mock, caplog):
api = deconz_api.Deconz(None, DEVICE_CONFIG)
gw = MagicMock(spec_set=uart.Gateway)
connect_mock.return_value = gw

await api.connect()

caplog.set_level(logging.DEBUG)
connect_mock.reset_mock()
connect_mock.side_effect = [asyncio.TimeoutError, OSError, gw]

with patch("asyncio.sleep"):
api.connection_lost("connection lost")
await api._conn_lost_task

assert api._uart is gw
assert connect_mock.call_count == 3


@patch.object(deconz_api.Deconz, "device_state", new_callable=AsyncMock)
@patch("zigpy_deconz.uart.connect", return_value=MagicMock(spec_set=uart.Gateway))
async def test_probe_success(mock_connect, mock_device_state):
Expand Down Expand Up @@ -601,18 +559,6 @@ async def test_aps_data_req_deserialize_error(api, uart_gw, status, caplog):
assert api._data_indication is False


async def test_set_item(api):
"""Test item setter."""

with patch.object(api, "write_parameter", new=AsyncMock()) as write_mock:
api["test"] = sentinel.test_param
for i in range(10):
await asyncio.sleep(0)
assert write_mock.await_count == 1
assert write_mock.call_args[0][0] == "test"
assert write_mock.call_args[0][1] is sentinel.test_param


@pytest.mark.parametrize("relays", (None, [], [0x1234, 0x5678]))
async def test_aps_data_request_relays(relays, api):
mock_cmd = api._command = AsyncMock()
Expand All @@ -631,3 +577,12 @@ async def test_aps_data_request_relays(relays, api):
if relays:
assert isinstance(mock_cmd.mock_calls[0][1][-1], t.NWKList)
assert mock_cmd.mock_calls[0][1][-1] == t.NWKList(relays)


async def test_connection_lost(api):
app = api._app = MagicMock()

err = RuntimeError()
api.connection_lost(err)

app.connection_lost.assert_called_once_with(err)
67 changes: 49 additions & 18 deletions tests/test_application.py
Original file line number Diff line number Diff line change
Expand Up @@ -414,12 +414,16 @@ def new_api(*args):


async def test_disconnect(app):
app._reset_watchdog_task = MagicMock()
app._api.close = MagicMock()
reset_watchdog_task = app._reset_watchdog_task = MagicMock()
api_close = app._api.close = MagicMock()

await app.disconnect()
assert app._api.close.call_count == 1
assert app._reset_watchdog_task.cancel.call_count == 1

assert app._api is None
assert app._reset_watchdog_task is None

assert api_close.call_count == 1
assert reset_watchdog_task.cancel.call_count == 1


async def test_disconnect_no_api(app):
Expand Down Expand Up @@ -798,21 +802,23 @@ async def test_change_network_state(app, support_watchdog):
@pytest.mark.parametrize(
"descriptor, slots, target_slot",
[
(ENDPOINT.replace(endpoint=1), {0: ENDPOINT.replace(endpoint=2), 1: None}, 1),
(ENDPOINT.replace(endpoint=1), {0: ENDPOINT.replace(endpoint=2)}, 0),
# Prefer the endpoint with the same ID
(
ENDPOINT.replace(endpoint=1),
{0: ENDPOINT.replace(endpoint=1, profile=1234), 1: None},
0,
{
0: ENDPOINT.replace(endpoint=2, profile=1234),
1: ENDPOINT.replace(endpoint=1, profile=1234),
},
1,
),
],
)
async def test_add_endpoint(app, descriptor, slots, target_slot):
async def read_param(param_id, index):
assert param_id == deconz_api.NetworkParameter.configure_endpoint
assert index in (0x00, 0x01)

if slots[index] is None:
if index not in slots:
raise zigpy_deconz.exception.CommandError(
deconz_api.Status.UNSUPPORTED, "Unsupported"
)
Expand All @@ -822,14 +828,6 @@ async def read_param(param_id, index):
app._api.read_parameter = AsyncMock(side_effect=read_param)
app._api.write_parameter = AsyncMock()

if target_slot is None:
with pytest.raises(ValueError):
await app.add_endpoint(descriptor)

app._api.write_parameter.assert_not_called()

return

await app.add_endpoint(descriptor)
app._api.write_parameter.assert_called_once_with(
deconz_api.NetworkParameter.configure_endpoint, target_slot, descriptor
Expand Down Expand Up @@ -859,7 +857,11 @@ async def read_param(param_id, index):
async def test_add_endpoint_no_unnecessary_writes(app):
async def read_param(param_id, index):
assert param_id == deconz_api.NetworkParameter.configure_endpoint
assert index in (0x00, 0x01)

if index > 0x01:
raise zigpy_deconz.exception.CommandError(
deconz_api.Status.UNSUPPORTED, "Unsupported"
)

return index, ENDPOINT.replace(endpoint=1)

Expand All @@ -874,3 +876,32 @@ async def read_param(param_id, index):
app._api.write_parameter.assert_called_once_with(
deconz_api.NetworkParameter.configure_endpoint, 1, ENDPOINT.replace(endpoint=2)
)


@patch("zigpy_deconz.zigbee.application.asyncio.sleep", new_callable=AsyncMock)
@patch(
"zigpy_deconz.zigbee.application.ControllerApplication.initialize",
side_effect=[RuntimeError(), None],
)
@patch(
"zigpy_deconz.zigbee.application.ControllerApplication.connect",
side_effect=[RuntimeError(), None, None],
)
async def test_reconnect(mock_connect, mock_initialize, mock_sleep, app):
assert app._reconnect_task is None
app.connection_lost(RuntimeError())

assert app._reconnect_task is not None
await app._reconnect_task

assert mock_connect.call_count == 3
assert mock_initialize.call_count == 2


async def test_disconnect_during_reconnect(app):
assert app._reconnect_task is None
app.connection_lost(RuntimeError())
await asyncio.sleep(0)
await app.disconnect()

assert app._reconnect_task is None
18 changes: 16 additions & 2 deletions tests/test_uart.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Tests for the uart module."""

import logging
from unittest import mock

import pytest
Expand All @@ -16,7 +17,6 @@ def gw():
return gw


@pytest.mark.asyncio
async def test_connect(monkeypatch):
api = mock.MagicMock()

Expand Down Expand Up @@ -84,6 +84,20 @@ def test_data_received_wrong_checksum(gw):
assert gw._api.data_received.call_count == 0


def test_data_received_error(gw, caplog):
data = b"\x07\x01\x00\x08\x00\xaa\x00\x02\x44\xFF\xC0"

gw._api.data_received.side_effect = [RuntimeError("error")]

with caplog.at_level(logging.ERROR):
gw.data_received(data)

assert "RuntimeError" in caplog.text and "handling the frame" in caplog.text

assert gw._api.data_received.call_count == 1
assert gw._api.data_received.call_args[0][0] == data[:-3]


def test_unescape(gw):
data = b"\x00\xDB\xDC\x00\xDB\xDD\x00\x00\x00"
data_unescaped = b"\x00\xC0\x00\xDB\x00\x00\x00"
Expand Down Expand Up @@ -122,4 +136,4 @@ def test_connection_lost_exc(gw):
def test_connection_closed(gw):
gw.connection_lost(None)

assert gw._api.connection_lost.call_count == 0
assert gw._api.connection_lost.call_count == 1
2 changes: 1 addition & 1 deletion zigpy_deconz/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@
# coding: utf-8
MAJOR_VERSION = 0
MINOR_VERSION = 18
PATCH_VERSION = "0"
PATCH_VERSION = "1"
__short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__ = f"{__short_version__}.{PATCH_VERSION}"
52 changes: 10 additions & 42 deletions zigpy_deconz/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,6 @@ def __init__(self, app: Callable, device_config: dict[str, Any]):
self._awaiting = {}
self._command_lock = asyncio.Lock()
self._config = device_config
self._conn_lost_task: Optional[asyncio.Task] = None
self._data_indication: bool = False
self._data_confirm: bool = False
self._device_state = DeviceState(NetworkState.OFFLINE)
Expand Down Expand Up @@ -272,48 +271,19 @@ async def connect(self) -> None:

def connection_lost(self, exc: Exception) -> None:
"""Lost serial connection."""
LOGGER.warning(
"Serial '%s' connection lost unexpectedly: %s",
LOGGER.debug(
"Serial %r connection lost unexpectedly: %r",
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")

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):
if self._uart:
self._app = None

if self._uart is not None:
self._uart.close()
self._uart = None

Expand Down Expand Up @@ -478,7 +448,9 @@ def _handle_write_parameter(self, data):
LOGGER.debug("Write parameter %s: SUCCESS", param.name)

async def version(self):
(self._proto_ver,) = await self[NetworkParameter.protocol_version]
(self._proto_ver,) = await self.read_parameter(
NetworkParameter.protocol_version
)
(self._firmware_version,) = await self._command(Command.version, 0)
if (
self.protocol_version >= MIN_PROTO_VERSION
Expand Down Expand Up @@ -660,7 +632,3 @@ def _handle_device_state_value(self, state: DeviceState) -> None:
def __getitem__(self, key):
"""Access parameters via getitem."""
return self.read_parameter(key)

def __setitem__(self, key, value):
"""Set parameters via setitem."""
return asyncio.create_task(self.write_parameter(key, value))
Loading

0 comments on commit 9b595f5

Please sign in to comment.