Skip to content

Commit 9b595f5

Browse files
authored
Merge pull request #205 from puddly/rc
0.18.1 Release
2 parents f236e8d + 4adde3b commit 9b595f5

File tree

8 files changed

+171
-158
lines changed

8 files changed

+171
-158
lines changed

README.md

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,12 @@
55

66
[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.
77

8-
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/).
8+
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/).
99

10-
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/).
10+
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).
1111

1212
# Releases via PyPI
13+
1314
Tagged versions are also released via PyPI
1415

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

1920
# External documentation and reference
2021

21-
Note! Latest official documentation for the deCONZ serial protocol can currently be obtained by contacting Dresden-Elektronik employees via GitHub here
22-
- https://github.com/dresden-elektronik/deconz-rest-plugin/issues/158
22+
Note! Latest official documentation for the deCONZ serial protocol can currently be obtained by following link in Dresden-Elektronik GitHub repository here:
23+
24+
- https://github.com/dresden-elektronik/deconz-serial-protocol
25+
- https://github.com/dresden-elektronik/deconz-serial-protocol/issues/2
26+
27+
For reference, here is a list of unrelated projects that also use the same deCONZ serial protocol for other implementations:
2328

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

3236
If you are looking to make a contribution to this project we suggest that you follow the steps in these guides:
37+
3338
- https://github.com/firstcontributions/first-contributions/blob/master/README.md
3439
- https://github.com/firstcontributions/first-contributions/blob/master/github-desktop-tutorial.md
3540

tests/test_api.py

Lines changed: 9 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import asyncio
44
import binascii
55
import enum
6-
import logging
76

87
import pytest
98
import zigpy.config
@@ -458,47 +457,6 @@ def test_device_state_network_state(data, network_state):
458457
assert state.serialize() == new_data
459458

460459

461-
@patch("zigpy_deconz.uart.connect")
462-
async def test_reconnect_multiple_disconnects(connect_mock, caplog):
463-
api = deconz_api.Deconz(None, DEVICE_CONFIG)
464-
gw = MagicMock(spec_set=uart.Gateway)
465-
connect_mock.return_value = gw
466-
467-
await api.connect()
468-
469-
caplog.set_level(logging.DEBUG)
470-
connect_mock.reset_mock()
471-
connect_mock.return_value = asyncio.Future()
472-
api.connection_lost("connection lost")
473-
await asyncio.sleep(0)
474-
connect_mock.return_value = sentinel.uart_reconnect
475-
api.connection_lost("connection lost 2")
476-
await asyncio.sleep(0)
477-
478-
assert api._uart is sentinel.uart_reconnect
479-
assert connect_mock.call_count == 1
480-
481-
482-
@patch("zigpy_deconz.uart.connect")
483-
async def test_reconnect_multiple_attempts(connect_mock, caplog):
484-
api = deconz_api.Deconz(None, DEVICE_CONFIG)
485-
gw = MagicMock(spec_set=uart.Gateway)
486-
connect_mock.return_value = gw
487-
488-
await api.connect()
489-
490-
caplog.set_level(logging.DEBUG)
491-
connect_mock.reset_mock()
492-
connect_mock.side_effect = [asyncio.TimeoutError, OSError, gw]
493-
494-
with patch("asyncio.sleep"):
495-
api.connection_lost("connection lost")
496-
await api._conn_lost_task
497-
498-
assert api._uart is gw
499-
assert connect_mock.call_count == 3
500-
501-
502460
@patch.object(deconz_api.Deconz, "device_state", new_callable=AsyncMock)
503461
@patch("zigpy_deconz.uart.connect", return_value=MagicMock(spec_set=uart.Gateway))
504462
async def test_probe_success(mock_connect, mock_device_state):
@@ -601,18 +559,6 @@ async def test_aps_data_req_deserialize_error(api, uart_gw, status, caplog):
601559
assert api._data_indication is False
602560

603561

604-
async def test_set_item(api):
605-
"""Test item setter."""
606-
607-
with patch.object(api, "write_parameter", new=AsyncMock()) as write_mock:
608-
api["test"] = sentinel.test_param
609-
for i in range(10):
610-
await asyncio.sleep(0)
611-
assert write_mock.await_count == 1
612-
assert write_mock.call_args[0][0] == "test"
613-
assert write_mock.call_args[0][1] is sentinel.test_param
614-
615-
616562
@pytest.mark.parametrize("relays", (None, [], [0x1234, 0x5678]))
617563
async def test_aps_data_request_relays(relays, api):
618564
mock_cmd = api._command = AsyncMock()
@@ -631,3 +577,12 @@ async def test_aps_data_request_relays(relays, api):
631577
if relays:
632578
assert isinstance(mock_cmd.mock_calls[0][1][-1], t.NWKList)
633579
assert mock_cmd.mock_calls[0][1][-1] == t.NWKList(relays)
580+
581+
582+
async def test_connection_lost(api):
583+
app = api._app = MagicMock()
584+
585+
err = RuntimeError()
586+
api.connection_lost(err)
587+
588+
app.connection_lost.assert_called_once_with(err)

tests/test_application.py

Lines changed: 49 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -414,12 +414,16 @@ def new_api(*args):
414414

415415

416416
async def test_disconnect(app):
417-
app._reset_watchdog_task = MagicMock()
418-
app._api.close = MagicMock()
417+
reset_watchdog_task = app._reset_watchdog_task = MagicMock()
418+
api_close = app._api.close = MagicMock()
419419

420420
await app.disconnect()
421-
assert app._api.close.call_count == 1
422-
assert app._reset_watchdog_task.cancel.call_count == 1
421+
422+
assert app._api is None
423+
assert app._reset_watchdog_task is None
424+
425+
assert api_close.call_count == 1
426+
assert reset_watchdog_task.cancel.call_count == 1
423427

424428

425429
async def test_disconnect_no_api(app):
@@ -798,21 +802,23 @@ async def test_change_network_state(app, support_watchdog):
798802
@pytest.mark.parametrize(
799803
"descriptor, slots, target_slot",
800804
[
801-
(ENDPOINT.replace(endpoint=1), {0: ENDPOINT.replace(endpoint=2), 1: None}, 1),
805+
(ENDPOINT.replace(endpoint=1), {0: ENDPOINT.replace(endpoint=2)}, 0),
802806
# Prefer the endpoint with the same ID
803807
(
804808
ENDPOINT.replace(endpoint=1),
805-
{0: ENDPOINT.replace(endpoint=1, profile=1234), 1: None},
806-
0,
809+
{
810+
0: ENDPOINT.replace(endpoint=2, profile=1234),
811+
1: ENDPOINT.replace(endpoint=1, profile=1234),
812+
},
813+
1,
807814
),
808815
],
809816
)
810817
async def test_add_endpoint(app, descriptor, slots, target_slot):
811818
async def read_param(param_id, index):
812819
assert param_id == deconz_api.NetworkParameter.configure_endpoint
813-
assert index in (0x00, 0x01)
814820

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

825-
if target_slot is None:
826-
with pytest.raises(ValueError):
827-
await app.add_endpoint(descriptor)
828-
829-
app._api.write_parameter.assert_not_called()
830-
831-
return
832-
833831
await app.add_endpoint(descriptor)
834832
app._api.write_parameter.assert_called_once_with(
835833
deconz_api.NetworkParameter.configure_endpoint, target_slot, descriptor
@@ -859,7 +857,11 @@ async def read_param(param_id, index):
859857
async def test_add_endpoint_no_unnecessary_writes(app):
860858
async def read_param(param_id, index):
861859
assert param_id == deconz_api.NetworkParameter.configure_endpoint
862-
assert index in (0x00, 0x01)
860+
861+
if index > 0x01:
862+
raise zigpy_deconz.exception.CommandError(
863+
deconz_api.Status.UNSUPPORTED, "Unsupported"
864+
)
863865

864866
return index, ENDPOINT.replace(endpoint=1)
865867

@@ -874,3 +876,32 @@ async def read_param(param_id, index):
874876
app._api.write_parameter.assert_called_once_with(
875877
deconz_api.NetworkParameter.configure_endpoint, 1, ENDPOINT.replace(endpoint=2)
876878
)
879+
880+
881+
@patch("zigpy_deconz.zigbee.application.asyncio.sleep", new_callable=AsyncMock)
882+
@patch(
883+
"zigpy_deconz.zigbee.application.ControllerApplication.initialize",
884+
side_effect=[RuntimeError(), None],
885+
)
886+
@patch(
887+
"zigpy_deconz.zigbee.application.ControllerApplication.connect",
888+
side_effect=[RuntimeError(), None, None],
889+
)
890+
async def test_reconnect(mock_connect, mock_initialize, mock_sleep, app):
891+
assert app._reconnect_task is None
892+
app.connection_lost(RuntimeError())
893+
894+
assert app._reconnect_task is not None
895+
await app._reconnect_task
896+
897+
assert mock_connect.call_count == 3
898+
assert mock_initialize.call_count == 2
899+
900+
901+
async def test_disconnect_during_reconnect(app):
902+
assert app._reconnect_task is None
903+
app.connection_lost(RuntimeError())
904+
await asyncio.sleep(0)
905+
await app.disconnect()
906+
907+
assert app._reconnect_task is None

tests/test_uart.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Tests for the uart module."""
22

3+
import logging
34
from unittest import mock
45

56
import pytest
@@ -16,7 +17,6 @@ def gw():
1617
return gw
1718

1819

19-
@pytest.mark.asyncio
2020
async def test_connect(monkeypatch):
2121
api = mock.MagicMock()
2222

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

8686

87+
def test_data_received_error(gw, caplog):
88+
data = b"\x07\x01\x00\x08\x00\xaa\x00\x02\x44\xFF\xC0"
89+
90+
gw._api.data_received.side_effect = [RuntimeError("error")]
91+
92+
with caplog.at_level(logging.ERROR):
93+
gw.data_received(data)
94+
95+
assert "RuntimeError" in caplog.text and "handling the frame" in caplog.text
96+
97+
assert gw._api.data_received.call_count == 1
98+
assert gw._api.data_received.call_args[0][0] == data[:-3]
99+
100+
87101
def test_unescape(gw):
88102
data = b"\x00\xDB\xDC\x00\xDB\xDD\x00\x00\x00"
89103
data_unescaped = b"\x00\xC0\x00\xDB\x00\x00\x00"
@@ -122,4 +136,4 @@ def test_connection_lost_exc(gw):
122136
def test_connection_closed(gw):
123137
gw.connection_lost(None)
124138

125-
assert gw._api.connection_lost.call_count == 0
139+
assert gw._api.connection_lost.call_count == 1

zigpy_deconz/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,6 @@
33
# coding: utf-8
44
MAJOR_VERSION = 0
55
MINOR_VERSION = 18
6-
PATCH_VERSION = "0"
6+
PATCH_VERSION = "1"
77
__short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}"
88
__version__ = f"{__short_version__}.{PATCH_VERSION}"

zigpy_deconz/api.py

Lines changed: 10 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,6 @@ def __init__(self, app: Callable, device_config: dict[str, Any]):
242242
self._awaiting = {}
243243
self._command_lock = asyncio.Lock()
244244
self._config = device_config
245-
self._conn_lost_task: Optional[asyncio.Task] = None
246245
self._data_indication: bool = False
247246
self._data_confirm: bool = False
248247
self._device_state = DeviceState(NetworkState.OFFLINE)
@@ -272,48 +271,19 @@ async def connect(self) -> None:
272271

273272
def connection_lost(self, exc: Exception) -> None:
274273
"""Lost serial connection."""
275-
LOGGER.warning(
276-
"Serial '%s' connection lost unexpectedly: %s",
274+
LOGGER.debug(
275+
"Serial %r connection lost unexpectedly: %r",
277276
self._config[CONF_DEVICE_PATH],
278277
exc,
279278
)
280-
self._uart = None
281-
if self._conn_lost_task and not self._conn_lost_task.done():
282-
self._conn_lost_task.cancel()
283-
self._conn_lost_task = asyncio.create_task(self._connection_lost())
284-
285-
async def _connection_lost(self) -> None:
286-
"""Reconnect serial port."""
287-
try:
288-
await self._reconnect_till_done()
289-
except asyncio.CancelledError:
290-
LOGGER.debug("Cancelling reconnection attempt")
291-
292-
async def _reconnect_till_done(self) -> None:
293-
attempt = 1
294-
while True:
295-
try:
296-
await asyncio.wait_for(self.reconnect(), timeout=10)
297-
break
298-
except (asyncio.TimeoutError, OSError) as exc:
299-
wait = 2 ** min(attempt, 5)
300-
attempt += 1
301-
LOGGER.debug(
302-
"Couldn't re-open '%s' serial port, retrying in %ss: %s",
303-
self._config[CONF_DEVICE_PATH],
304-
wait,
305-
str(exc),
306-
)
307-
await asyncio.sleep(wait)
308279

309-
LOGGER.debug(
310-
"Reconnected '%s' serial port after %s attempts",
311-
self._config[CONF_DEVICE_PATH],
312-
attempt,
313-
)
280+
if self._app is not None:
281+
self._app.connection_lost(exc)
314282

315283
def close(self):
316-
if self._uart:
284+
self._app = None
285+
286+
if self._uart is not None:
317287
self._uart.close()
318288
self._uart = None
319289

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

480450
async def version(self):
481-
(self._proto_ver,) = await self[NetworkParameter.protocol_version]
451+
(self._proto_ver,) = await self.read_parameter(
452+
NetworkParameter.protocol_version
453+
)
482454
(self._firmware_version,) = await self._command(Command.version, 0)
483455
if (
484456
self.protocol_version >= MIN_PROTO_VERSION
@@ -660,7 +632,3 @@ def _handle_device_state_value(self, state: DeviceState) -> None:
660632
def __getitem__(self, key):
661633
"""Access parameters via getitem."""
662634
return self.read_parameter(key)
663-
664-
def __setitem__(self, key, value):
665-
"""Set parameters via setitem."""
666-
return asyncio.create_task(self.write_parameter(key, value))

0 commit comments

Comments
 (0)