Skip to content

Commit faa6372

Browse files
authored
Feature/usb reconnect (#99)
* Add connection_lost handler to uart. * Reconnect serial port on disconnects. * Remove logging. * Reduce Watchdog TTL to 10min. * Update logging. * Catch watchdog exceptions. * Throw an Exception if command is sent while disconnected. * Use new zigpy exceptions. * Update test requirements.
1 parent 2b5e878 commit faa6372

File tree

9 files changed

+183
-13
lines changed

9 files changed

+183
-13
lines changed

setup.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,6 @@
1313
author_email="[email protected]",
1414
license="GPL-3.0",
1515
packages=find_packages(exclude=["*.tests"]),
16-
install_requires=["pyserial-asyncio", "zigpy-homeassistant>=0.10.0"],
17-
tests_require=["pytest"],
16+
install_requires=["pyserial-asyncio", "zigpy-homeassistant>=0.17.0"],
17+
tests_require=["pytest", "pytest-asyncio", "asynctest"],
1818
)

tests/test_api.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import asyncio
2+
import logging
23
from unittest import mock
34

5+
import asynctest
46
import pytest
57

68
from zigpy_deconz import api as deconz_api, types as t, uart
@@ -95,6 +97,22 @@ def mock_api_frame(name, *args):
9597
api._uart.send.reset_mock()
9698

9799

100+
@pytest.mark.asyncio
101+
async def test_command_not_connected(api):
102+
api._uart = None
103+
104+
def mock_api_frame(name, *args):
105+
return mock.sentinel.api_frame_data, api._seq
106+
107+
api._api_frame = mock.MagicMock(side_effect=mock_api_frame)
108+
109+
for cmd, cmd_opts in deconz_api.TX_COMMANDS.items():
110+
with pytest.raises(deconz_api.CommandError):
111+
await api._command(cmd, mock.sentinel.cmd_data)
112+
assert api._api_frame.call_count == 0
113+
api._api_frame.reset_mock()
114+
115+
98116
def test_api_frame(api):
99117
addr = t.DeconzAddressEndpoint()
100118
addr.address_mode = t.ADDRESS_MODE.NWK
@@ -414,3 +432,54 @@ def test_device_state_network_state(data, network_state):
414432
assert rest == extra
415433
assert state.network_state == deconz_api.NetworkState[network_state]
416434
assert state.serialize() == new_data
435+
436+
437+
@pytest.mark.asyncio
438+
async def test_reconnect_multiple_disconnects(monkeypatch, caplog):
439+
api = deconz_api.Deconz()
440+
dev = mock.sentinel.uart
441+
connect_mock = asynctest.CoroutineMock()
442+
connect_mock.return_value = asyncio.Future()
443+
connect_mock.return_value.set_result(True)
444+
monkeypatch.setattr(uart, "connect", connect_mock)
445+
446+
await api.connect(dev, 115200)
447+
448+
caplog.set_level(logging.DEBUG)
449+
connected = asyncio.Future()
450+
connected.set_result(mock.sentinel.uart_reconnect)
451+
connect_mock.reset_mock()
452+
connect_mock.side_effect = [asyncio.Future(), connected]
453+
api.connection_lost("connection lost")
454+
await asyncio.sleep(0.3)
455+
api.connection_lost("connection lost 2")
456+
await asyncio.sleep(0.3)
457+
458+
assert "Cancelling reconnection attempt" in caplog.messages
459+
assert api._uart is mock.sentinel.uart_reconnect
460+
assert connect_mock.call_count == 2
461+
462+
463+
@pytest.mark.asyncio
464+
async def test_reconnect_multiple_attempts(monkeypatch, caplog):
465+
api = deconz_api.Deconz()
466+
dev = mock.sentinel.uart
467+
connect_mock = asynctest.CoroutineMock()
468+
connect_mock.return_value = asyncio.Future()
469+
connect_mock.return_value.set_result(True)
470+
monkeypatch.setattr(uart, "connect", connect_mock)
471+
472+
await api.connect(dev, 115200)
473+
474+
caplog.set_level(logging.DEBUG)
475+
connected = asyncio.Future()
476+
connected.set_result(mock.sentinel.uart_reconnect)
477+
connect_mock.reset_mock()
478+
connect_mock.side_effect = [asyncio.TimeoutError, OSError, connected]
479+
480+
with asynctest.mock.patch("asyncio.sleep"):
481+
api.connection_lost("connection lost")
482+
await api._conn_lost_task
483+
484+
assert api._uart is mock.sentinel.uart_reconnect
485+
assert connect_mock.call_count == 3

tests/test_application.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import logging
33
from unittest import mock
44

5+
import asynctest
56
import pytest
67

78
import zigpy.device
@@ -510,3 +511,21 @@ async def test_mrequest_send_fail(app):
510511
async def test_mrequest_send_aps_data_error(app):
511512
r = await _test_mrequest(app, False, aps_data_error=True)
512513
assert r[0] != 0
514+
515+
516+
@pytest.mark.asyncio
517+
@mock.patch.object(application, "WATCHDOG_TTL", new=1)
518+
async def test_reset_watchdog(app):
519+
"""Test watchdog."""
520+
with asynctest.patch.object(app._api, "write_parameter") as mock_api:
521+
dog = asyncio.ensure_future(app._reset_watchdog())
522+
await asyncio.sleep(0.3)
523+
dog.cancel()
524+
assert mock_api.call_count == 1
525+
526+
with asynctest.patch.object(app._api, "write_parameter") as mock_api:
527+
mock_api.side_effect = zigpy_deconz.exception.CommandError
528+
dog = asyncio.ensure_future(app._reset_watchdog())
529+
await asyncio.sleep(0.3)
530+
dog.cancel()
531+
assert mock_api.call_count == 1

tests/test_uart.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,3 +107,17 @@ def test_checksum(gw):
107107
checksum = b"\x44\xFF"
108108
r = gw._checksum(data)
109109
assert r == checksum
110+
111+
112+
def test_connection_lost_exc(gw):
113+
gw.connection_lost(mock.sentinel.exception)
114+
115+
conn_lost = gw._api.connection_lost
116+
assert conn_lost.call_count == 1
117+
assert conn_lost.call_args[0][0] is mock.sentinel.exception
118+
119+
120+
def test_connection_closed(gw):
121+
gw.connection_lost(None)
122+
123+
assert gw._api.connection_lost.call_count == 0

tox.ini

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ setenv = PYTHONPATH = {toxinidir}
1212
install_command = pip install {opts} {packages}
1313
commands = py.test --cov --cov-report=
1414
deps =
15+
asynctest
1516
coveralls
1617
pytest
1718
pytest-cov

zigpy_deconz/api.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,10 +186,12 @@ class NetworkParameter(t.uint8_t, enum.Enum):
186186
class Deconz:
187187
def __init__(self):
188188
self._uart = None
189+
self._uart_path = None
189190
self._seq = 1
190191
self._awaiting = {}
191192
self._app = None
192193
self._cmd_mode_future = None
194+
self._conn_lost_task = None
193195
self._device_state = DeviceState(NetworkState.OFFLINE)
194196
self._data_indication = False
195197
self._data_confirm = False
@@ -209,15 +211,57 @@ def protocol_version(self):
209211
def set_application(self, app):
210212
self._app = app
211213

212-
async def connect(self, device, baudrate=DECONZ_BAUDRATE):
214+
async def connect(self, device: str, baudrate: int = DECONZ_BAUDRATE) -> None:
213215
assert self._uart is None
216+
self._uart_path = device
214217
self._uart = await uart.connect(device, DECONZ_BAUDRATE, self)
215218

219+
def connection_lost(self, exc: Exception) -> None:
220+
"""Lost serial connection."""
221+
LOGGER.warning(
222+
"Serial '%s' connection lost unexpectedly: %s", self._uart_path, exc
223+
)
224+
self._uart = None
225+
if self._conn_lost_task and not self._conn_lost_task.done():
226+
self._conn_lost_task.cancel()
227+
self._conn_lost_task = asyncio.ensure_future(self._connection_lost())
228+
229+
async def _connection_lost(self) -> None:
230+
"""Reconnect serial port."""
231+
try:
232+
await self._reconnect_till_done()
233+
except asyncio.CancelledError:
234+
LOGGER.debug("Cancelling reconnection attempt")
235+
236+
async def _reconnect_till_done(self) -> None:
237+
attempt = 1
238+
while True:
239+
try:
240+
await asyncio.wait_for(self.reconnect(), timeout=10)
241+
break
242+
except (asyncio.TimeoutError, OSError) as exc:
243+
wait = 2 ** min(attempt, 5)
244+
attempt += 1
245+
LOGGER.debug(
246+
"Couldn't re-open '%s' serial port, retrying in %ss: %s",
247+
self._uart_path,
248+
wait,
249+
str(exc),
250+
)
251+
await asyncio.sleep(wait)
252+
253+
LOGGER.debug(
254+
"Reconnected '%s' serial port after %s attempts", self._uart_path, attempt
255+
)
256+
216257
def close(self):
217258
return self._uart.close()
218259

219260
async def _command(self, cmd, *args):
220261
LOGGER.debug("Command %s %s", cmd, args)
262+
if self._uart is None:
263+
# connection was lost
264+
raise CommandError(Status.ERROR, "API is not running")
221265
data, seq = self._api_frame(cmd, *args)
222266
self._uart.send(data)
223267
fut = asyncio.Future()
@@ -298,6 +342,11 @@ async def read_parameter(self, id_, *args):
298342
LOGGER.debug("Read parameter %s response: %s", param.name, data)
299343
return data
300344

345+
def reconnect(self):
346+
"""Reconnect using saved parameters."""
347+
LOGGER.debug("Reconnecting '%s' serial port", self._uart_path)
348+
return self.connect(self._uart_path)
349+
301350
def _handle_read_parameter(self, data):
302351
pass
303352

zigpy_deconz/exception.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,7 @@
1-
from zigpy.exceptions import ZigbeeException
1+
from zigpy.exceptions import APIException
22

33

4-
class DeconzException(ZigbeeException):
5-
pass
6-
7-
8-
class CommandError(DeconzException):
4+
class CommandError(APIException):
95
def __init__(self, status, *args, **kwargs):
106
self._status = status
117
super().__init__(*args, **kwargs)

zigpy_deconz/uart.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,24 @@ class Gateway(asyncio.Protocol):
1515
ESC_ESC = b"\xDD"
1616

1717
def __init__(self, api, connected_future=None):
18+
self._api = api
1819
self._buffer = b""
1920
self._connected_future = connected_future
20-
self._api = api
21+
self._transport = None
22+
23+
def connection_lost(self, exc) -> None:
24+
"""Port was closed expecteddly or unexpectedly."""
25+
if self._connected_future and not self._connected_future.done():
26+
if exc is None:
27+
self._connected_future.set_result(True)
28+
else:
29+
self._connected_future.set_exception(exc)
30+
if exc is None:
31+
LOGGER.debug("Closed serial connection")
32+
return
33+
34+
LOGGER.error("Lost serial connection: %s", exc)
35+
self._api.connection_lost(exc)
2136

2237
def connection_made(self, transport):
2338
"""Callback when the uart is connected"""

zigpy_deconz/zigbee/application.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,17 @@
88
import zigpy.exceptions
99
import zigpy.types
1010
import zigpy.util
11-
import zigpy_deconz.exception
11+
1212
from zigpy_deconz import types as t
1313
from zigpy_deconz.api import NetworkParameter, NetworkState, Status
14+
import zigpy_deconz.exception
1415

1516
LOGGER = logging.getLogger(__name__)
1617

1718
CHANGE_NETWORK_WAIT = 1
1819
SEND_CONFIRM_TIMEOUT = 60
1920
PROTO_VER_WATCHDOG = 0x0108
21+
WATCHDOG_TTL = 600
2022

2123

2224
class ControllerApplication(zigpy.application.ControllerApplication):
@@ -33,8 +35,13 @@ def __init__(self, api, database_file=None):
3335

3436
async def _reset_watchdog(self):
3537
while True:
36-
await self._api.write_parameter(NetworkParameter.watchdog_ttl, 3600)
37-
await asyncio.sleep(1200)
38+
try:
39+
await self._api.write_parameter(
40+
NetworkParameter.watchdog_ttl, WATCHDOG_TTL
41+
)
42+
except (asyncio.TimeoutError, zigpy.exceptions.ZigbeeException):
43+
LOGGER.warning("No watchdog response")
44+
await asyncio.sleep(WATCHDOG_TTL * 0.75)
3845

3946
async def shutdown(self):
4047
"""Shutdown application."""

0 commit comments

Comments
 (0)