Skip to content

Commit 3ead8ef

Browse files
authored
Handle port disconnects (#89)
* UART connection_lost() handler * Handle UART disconnects. * Update dependencies.
1 parent 621a9f2 commit 3ead8ef

File tree

6 files changed

+171
-7
lines changed

6 files changed

+171
-7
lines changed

setup.py

Lines changed: 1 addition & 1 deletion
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"],
16+
install_requires=["pyserial-asyncio", "zigpy-homeassistant >= 0.17.0"],
1717
tests_require=["pytest"],
1818
)

tests/test_api.py

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import asyncio
2-
from unittest import mock
2+
import logging
33

4+
from asynctest import CoroutineMock, mock
45
import pytest
6+
import zigpy.exceptions
57

68
from zigpy_xbee import api as xbee_api, types as t, uart
79
from zigpy_xbee.zigbee.application import ControllerApplication
@@ -25,9 +27,10 @@ async def test_connect(monkeypatch):
2527

2628

2729
def test_close(api):
28-
api._uart.close = mock.MagicMock()
30+
uart = api._uart
2931
api.close()
30-
assert api._uart.close.call_count == 1
32+
assert api._uart is None
33+
assert uart.close.call_count == 1
3134

3235

3336
def test_commands():
@@ -84,6 +87,22 @@ def mock_api_frame(name, *args):
8487
api._uart.send.reset_mock()
8588

8689

90+
@pytest.mark.asyncio
91+
async def test_command_not_connected(api):
92+
api._uart = None
93+
94+
def mock_api_frame(name, *args):
95+
return mock.sentinel.api_frame_data, api._seq
96+
97+
api._api_frame = mock.MagicMock(side_effect=mock_api_frame)
98+
99+
for cmd, cmd_opts in xbee_api.COMMAND_REQUESTS.items():
100+
with pytest.raises(zigpy.exceptions.APIException):
101+
await api._command(cmd, mock.sentinel.cmd_data)
102+
assert api._api_frame.call_count == 0
103+
api._api_frame.reset_mock()
104+
105+
87106
async def _test_at_or_queued_at_command(api, cmd, monkeypatch, do_reply=True):
88107
monkeypatch.setattr(
89108
t, "serialize", mock.MagicMock(return_value=mock.sentinel.serialize)
@@ -518,3 +537,54 @@ def test_handle_many_to_one_rri(api):
518537
ieee = t.EUI64([t.uint8_t(a) for a in range(0, 8)])
519538
nwk = 0x1234
520539
api._handle_many_to_one_rri(ieee, nwk, 0)
540+
541+
542+
@pytest.mark.asyncio
543+
async def test_reconnect_multiple_disconnects(monkeypatch, caplog):
544+
api = xbee_api.XBee()
545+
dev = mock.sentinel.uart
546+
connect_mock = CoroutineMock()
547+
connect_mock.return_value = asyncio.Future()
548+
connect_mock.return_value.set_result(True)
549+
monkeypatch.setattr(uart, "connect", connect_mock)
550+
551+
await api.connect(dev, 115200)
552+
553+
caplog.set_level(logging.DEBUG)
554+
connected = asyncio.Future()
555+
connected.set_result(mock.sentinel.uart_reconnect)
556+
connect_mock.reset_mock()
557+
connect_mock.side_effect = [asyncio.Future(), connected]
558+
api.connection_lost("connection lost")
559+
await asyncio.sleep(0.3)
560+
api.connection_lost("connection lost 2")
561+
await asyncio.sleep(0.3)
562+
563+
assert "Cancelling reconnection attempt" in caplog.messages
564+
assert api._uart is mock.sentinel.uart_reconnect
565+
assert connect_mock.call_count == 2
566+
567+
568+
@pytest.mark.asyncio
569+
async def test_reconnect_multiple_attempts(monkeypatch, caplog):
570+
api = xbee_api.XBee()
571+
dev = mock.sentinel.uart
572+
connect_mock = CoroutineMock()
573+
connect_mock.return_value = asyncio.Future()
574+
connect_mock.return_value.set_result(True)
575+
monkeypatch.setattr(uart, "connect", connect_mock)
576+
577+
await api.connect(dev, 115200)
578+
579+
caplog.set_level(logging.DEBUG)
580+
connected = asyncio.Future()
581+
connected.set_result(mock.sentinel.uart_reconnect)
582+
connect_mock.reset_mock()
583+
connect_mock.side_effect = [asyncio.TimeoutError, OSError, connected]
584+
585+
with mock.patch("asyncio.sleep"):
586+
api.connection_lost("connection lost")
587+
await api._conn_lost_task
588+
589+
assert api._uart is mock.sentinel.uart_reconnect
590+
assert connect_mock.call_count == 3

tests/test_uart.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import asyncio
12
from unittest import mock
23

34
import pytest
@@ -202,3 +203,24 @@ def test_unescape_underflow(gw):
202203
unescaped, rest = gw._get_unescaped(escaped, 3)
203204
assert unescaped is None
204205
assert rest is None
206+
207+
208+
def test_connection_lost_exc(gw):
209+
gw._connected_future = asyncio.Future()
210+
211+
gw.connection_lost(ValueError())
212+
213+
conn_lost = gw._api.connection_lost
214+
assert conn_lost.call_count == 1
215+
assert isinstance(conn_lost.call_args[0][0], Exception)
216+
assert gw._connected_future.done()
217+
assert gw._connected_future.exception()
218+
219+
220+
def test_connection_closed(gw):
221+
gw._connected_future = asyncio.Future()
222+
gw.connection_lost(None)
223+
224+
assert gw._api.connection_lost.call_count == 0
225+
assert gw._connected_future.done()
226+
assert gw._connected_future.result() is True

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_xbee/api.py

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import functools
55
import logging
66

7-
from zigpy.exceptions import DeliveryError
7+
from zigpy.exceptions import APIException, DeliveryError
88
from zigpy.types import LVList
99

1010
from . import types as t, uart
@@ -243,11 +243,13 @@ class ATCommandResult(enum.IntEnum):
243243
class XBee:
244244
def __init__(self):
245245
self._uart = None
246+
self._uart_params = None
246247
self._seq = 1
247248
self._commands_by_id = {v[0]: k for k, v in COMMAND_RESPONSES.items()}
248249
self._awaiting = {}
249250
self._app = None
250251
self._cmd_mode_future = None
252+
self._conn_lost_task = None
251253
self._reset = asyncio.Event()
252254
self._running = asyncio.Event()
253255

@@ -266,15 +268,69 @@ def is_running(self):
266268
"""Return true if coordinator is running."""
267269
return self.coordinator_started_event.is_set()
268270

269-
async def connect(self, device, baudrate=115200):
271+
async def connect(self, device: str, baudrate: int = 115200) -> None:
270272
assert self._uart is None
271273
self._uart = await uart.connect(device, baudrate, self)
274+
self._uart_params = (device, baudrate)
275+
276+
def reconnect(self):
277+
"""Reconnect using saved parameters."""
278+
LOGGER.debug(
279+
"Reconnecting '%s' serial port using %s",
280+
self._uart_params[0],
281+
self._uart_params[1],
282+
)
283+
return self.connect(self._uart_params[0], self._uart_params[1])
284+
285+
def connection_lost(self, exc: Exception) -> None:
286+
"""Lost serial connection."""
287+
LOGGER.warning(
288+
"Serial '%s' connection lost unexpectedly: %s", self._uart_params[0], exc
289+
)
290+
self._uart = None
291+
if self._conn_lost_task and not self._conn_lost_task.done():
292+
self._conn_lost_task.cancel()
293+
self._conn_lost_task = asyncio.ensure_future(self._connection_lost())
294+
295+
async def _connection_lost(self) -> None:
296+
"""Reconnect serial port."""
297+
try:
298+
await self._reconnect_till_done()
299+
except asyncio.CancelledError:
300+
LOGGER.debug("Cancelling reconnection attempt")
301+
302+
async def _reconnect_till_done(self) -> None:
303+
attempt = 1
304+
while True:
305+
try:
306+
await asyncio.wait_for(self.reconnect(), timeout=10)
307+
break
308+
except (asyncio.TimeoutError, OSError) as exc:
309+
wait = 2 ** min(attempt, 5)
310+
attempt += 1
311+
LOGGER.debug(
312+
"Couldn't re-open '%s' serial port, retrying in %ss: %s",
313+
self._uart_params[0],
314+
wait,
315+
str(exc),
316+
)
317+
await asyncio.sleep(wait)
318+
319+
LOGGER.debug(
320+
"Reconnected '%s' serial port after %s attempts",
321+
self._uart_params[0],
322+
attempt,
323+
)
272324

273325
def close(self):
274-
return self._uart.close()
326+
if self._uart:
327+
self._uart.close()
328+
self._uart = None
275329

276330
def _command(self, name, *args, mask_frame_id=False):
277331
LOGGER.debug("Command %s %s", name, args)
332+
if self._uart is None:
333+
raise APIException("API is not running")
278334
frame_id = 0 if mask_frame_id else self._seq
279335
data, needs_response = self._api_frame(name, frame_id, *args)
280336
self._uart.send(data)

zigpy_xbee/uart.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ class Gateway(asyncio.Protocol):
1414
XOFF = b"\x13"
1515

1616
RESERVED = START + ESCAPE + XON + XOFF
17+
THIS_ONE = True
1718

1819
def __init__(self, api, connected_future=None):
1920
self._buffer = b""
@@ -45,6 +46,20 @@ def baudrate(self, baudrate):
4546
"baudrate must be one of {}".format(self._transport.serial.BAUDRATES)
4647
)
4748

49+
def connection_lost(self, exc) -> None:
50+
"""Port was closed expectedly or unexpectedly."""
51+
if self._connected_future and not self._connected_future.done():
52+
if exc is None:
53+
self._connected_future.set_result(True)
54+
else:
55+
self._connected_future.set_exception(exc)
56+
if exc is None:
57+
LOGGER.debug("Closed serial connection")
58+
return
59+
60+
LOGGER.error("Lost serial connection: %s", exc)
61+
self._api.connection_lost(exc)
62+
4863
def connection_made(self, transport):
4964
"""Callback when the uart is connected"""
5065
LOGGER.debug("Connection made")

0 commit comments

Comments
 (0)