Skip to content

Commit eb4d141

Browse files
authored
Zigpy serial protocol (#160)
* Migrate zigate to zigpy serial protocol * Fix unit tests * Let zigpy handle flow control * Bump minimum zigpy version * Remove unnecessary `close` * Clean API only on close * Fix annotations * Test `connection_lost`
1 parent 93c7358 commit eb4d141

File tree

7 files changed

+46
-72
lines changed

7 files changed

+46
-72
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ license = {text = "GPL-3.0"}
1515
requires-python = ">=3.8"
1616
dependencies = [
1717
"voluptuous",
18-
"zigpy>=0.66.0",
18+
"zigpy>=0.70.0",
1919
"pyusb>=1.1.0",
2020
"gpiozero",
2121
'async-timeout; python_version<"3.11"',

tests/test_api.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import asyncio
2-
from unittest.mock import MagicMock, patch, sentinel
2+
from unittest.mock import AsyncMock, MagicMock, patch, sentinel
33

44
import pytest
55
import serial_asyncio
@@ -37,10 +37,13 @@ async def mock_conn(loop, protocol_factory, **kwargs):
3737
await api.connect()
3838

3939

40-
def test_close(api):
40+
@pytest.mark.asyncio
41+
async def test_disconnect(api):
4142
uart = api._uart
42-
api.close()
43-
assert uart.close.call_count == 1
43+
uart.disconnect = AsyncMock()
44+
45+
await api.disconnect()
46+
assert uart.disconnect.call_count == 1
4447
assert api._uart is None
4548

4649

tests/test_application.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -102,28 +102,28 @@ async def mock_get_network_state():
102102

103103
@pytest.mark.asyncio
104104
async def test_disconnect_success(app):
105-
api = MagicMock()
105+
api = AsyncMock()
106106

107107
app._api = api
108108
await app.disconnect()
109109

110-
api.close.assert_called_once()
110+
api.disconnect.assert_called_once()
111111
assert app._api is None
112112

113113

114114
@pytest.mark.asyncio
115115
async def test_disconnect_failure(app, caplog):
116-
api = MagicMock()
117-
api.disconnect = MagicMock(side_effect=RuntimeError("Broken"))
116+
api = AsyncMock()
117+
api.reset = AsyncMock(side_effect=RuntimeError("Broken"))
118118

119119
app._api = api
120120

121121
with caplog.at_level(logging.WARNING):
122122
await app.disconnect()
123123

124-
assert "disconnect" in caplog.text
124+
assert "Failed to reset before disconnect" in caplog.text
125125

126-
api.close.assert_called_once()
126+
api.disconnect.assert_called_once()
127127
assert app._api is None
128128

129129

tests/test_uart.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from unittest.mock import AsyncMock, MagicMock
1+
from unittest.mock import AsyncMock, MagicMock, call
22

33
import gpiozero
44
import pytest
@@ -52,6 +52,12 @@ def test_close(gw):
5252
assert gw._transport.close.call_count == 1
5353

5454

55+
def test_connection_lost(gw):
56+
exc = RuntimeError()
57+
gw.connection_lost(exc)
58+
assert gw._api.connection_lost.mock_calls == [call(exc)]
59+
60+
5561
def test_data_received_chunk_frame(gw):
5662
data = b"\x01\x80\x10\x02\x10\x02\x15\xaa\x02\x10\x02\x1f?\xf0\xff\x03"
5763
gw.data_received(data[:-4])
@@ -108,13 +114,6 @@ def test_escape(gw):
108114
assert r == data_escaped
109115

110116

111-
def test_length(gw):
112-
data = b"\x80\x10\x00\x05\xaa\x00\x0f?\xf0\xff"
113-
length = 5
114-
r = gw._length(data)
115-
assert r == length
116-
117-
118117
def test_checksum(gw):
119118
data = b"\x00\x0f?\xf0"
120119
checksum = 0xAA

zigpy_zigate/api.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -246,9 +246,9 @@ def connection_lost(self, exc: Exception) -> None:
246246
if self._app is not None:
247247
self._app.connection_lost(exc)
248248

249-
def close(self):
250-
if self._uart:
251-
self._uart.close()
249+
async def disconnect(self):
250+
if self._uart is not None:
251+
await self._uart.disconnect()
252252
self._uart = None
253253

254254
def set_application(self, app):

zigpy_zigate/uart.py

Lines changed: 21 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
from __future__ import annotations
2+
13
import asyncio
24
import binascii
35
import logging
46
import struct
5-
from typing import Any, Dict
7+
from typing import Any
68

79
import zigpy.config
810
import zigpy.serial
@@ -12,39 +14,24 @@
1214
LOGGER = logging.getLogger(__name__)
1315

1416

15-
class Gateway(asyncio.Protocol):
17+
class Gateway(zigpy.serial.SerialProtocol):
1618
START = b"\x01"
1719
END = b"\x03"
1820

19-
def __init__(self, api, connected_future=None):
20-
self._buffer = b""
21-
self._connected_future = connected_future
21+
def __init__(self, api):
22+
super().__init__()
2223
self._api = api
2324

24-
def connection_lost(self, exc) -> None:
25-
"""Port was closed expecteddly or unexpectedly."""
26-
if self._connected_future and not self._connected_future.done():
27-
if exc is None:
28-
self._connected_future.set_result(True)
29-
else:
30-
self._connected_future.set_exception(exc)
31-
if exc is None:
32-
LOGGER.debug("Closed serial connection")
33-
return
34-
35-
LOGGER.error("Lost serial connection: %s", exc)
36-
self._api.connection_lost(exc)
25+
def connection_lost(self, exc: Exception | None) -> None:
26+
"""Port was closed expectedly or unexpectedly."""
27+
super().connection_lost(exc)
3728

38-
def connection_made(self, transport):
39-
"""Callback when the uart is connected"""
40-
LOGGER.debug("Connection made")
41-
self._transport = transport
42-
if self._connected_future:
43-
self._connected_future.set_result(True)
29+
if self._api is not None:
30+
self._api.connection_lost(exc)
4431

4532
def close(self):
46-
if self._transport:
47-
self._transport.close()
33+
super().close()
34+
self._api = None
4835

4936
def send(self, cmd, data=b""):
5037
"""Send data, taking care of escaping and framing"""
@@ -60,8 +47,7 @@ def send(self, cmd, data=b""):
6047

6148
def data_received(self, data):
6249
"""Callback when there is data received from the uart"""
63-
self._buffer += data
64-
# LOGGER.debug('data_received %s', self._buffer)
50+
super().data_received(data)
6551
endpos = self._buffer.find(self.END)
6652
while endpos != -1:
6753
startpos = self._buffer.rfind(self.START, 0, endpos)
@@ -71,7 +57,7 @@ def data_received(self, data):
7157
cmd, length, checksum, f_data, lqi = struct.unpack(
7258
"!HHB%dsB" % (len(frame) - 6), frame
7359
)
74-
if self._length(frame) != length:
60+
if len(frame) - 5 != length:
7561
LOGGER.warning(
7662
"Invalid length: %s, data: %s", length, len(frame) - 6
7763
)
@@ -126,42 +112,28 @@ def _checksum(self, *args):
126112
chcksum ^= x
127113
return chcksum
128114

129-
def _length(self, frame):
130-
length = len(frame) - 5
131-
return length
132-
133-
134-
async def connect(device_config: Dict[str, Any], api, loop=None):
135-
if loop is None:
136-
loop = asyncio.get_event_loop()
137-
138-
connected_future = asyncio.Future()
139-
protocol = Gateway(api, connected_future)
140115

116+
async def connect(device_config: dict[str, Any], api, loop=None):
117+
loop = asyncio.get_running_loop()
141118
port = device_config[zigpy.config.CONF_DEVICE_PATH]
142-
if port == "auto":
143-
port = await loop.run_in_executor(None, c.discover_port)
144119

145120
if await c.async_is_pizigate(port):
146121
LOGGER.debug("PiZiGate detected")
147122
await c.async_set_pizigate_running_mode()
148-
# in case of pizigate:/dev/ttyAMA0 syntax
149-
if port.startswith("pizigate:"):
150-
port = port.replace("pizigate:", "", 1)
123+
port = port.replace("pizigate:", "", 1)
151124
elif await c.async_is_zigate_din(port):
152125
LOGGER.debug("ZiGate USB DIN detected")
153126
await c.async_set_zigatedin_running_mode()
154-
elif c.is_zigate_wifi(port):
155-
LOGGER.debug("ZiGate WiFi detected")
156127

128+
protocol = Gateway(api)
157129
_, protocol = await zigpy.serial.create_serial_connection(
158130
loop,
159131
lambda: protocol,
160132
url=port,
161133
baudrate=device_config[zigpy.config.CONF_DEVICE_BAUDRATE],
162-
xonxoff=False,
134+
flow_control=device_config[zigpy.config.CONF_DEVICE_FLOW_CONTROL],
163135
)
164136

165-
await connected_future
137+
await protocol.wait_until_connected()
166138

167139
return protocol

zigpy_zigate/zigbee/application.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ async def disconnect(self):
6363
except Exception as e:
6464
LOGGER.warning("Failed to reset before disconnect: %s", e)
6565
finally:
66-
self._api.close()
66+
await self._api.disconnect()
6767
self._api = None
6868

6969
async def start_network(self):

0 commit comments

Comments
 (0)