zigpy-ziggurat 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- zigpy_ziggurat-1.0.0/PKG-INFO +16 -0
- zigpy_ziggurat-1.0.0/README.md +3 -0
- zigpy_ziggurat-1.0.0/pyproject.toml +109 -0
- zigpy_ziggurat-1.0.0/setup.cfg +4 -0
- zigpy_ziggurat-1.0.0/tests/test_api.py +218 -0
- zigpy_ziggurat-1.0.0/tests/test_application.py +939 -0
- zigpy_ziggurat-1.0.0/zigpy_ziggurat/__init__.py +0 -0
- zigpy_ziggurat-1.0.0/zigpy_ziggurat/zigbee/__init__.py +0 -0
- zigpy_ziggurat-1.0.0/zigpy_ziggurat/zigbee/application.py +943 -0
- zigpy_ziggurat-1.0.0/zigpy_ziggurat/zigbee/commands.py +402 -0
- zigpy_ziggurat-1.0.0/zigpy_ziggurat.egg-info/PKG-INFO +16 -0
- zigpy_ziggurat-1.0.0/zigpy_ziggurat.egg-info/SOURCES.txt +14 -0
- zigpy_ziggurat-1.0.0/zigpy_ziggurat.egg-info/dependency_links.txt +1 -0
- zigpy_ziggurat-1.0.0/zigpy_ziggurat.egg-info/entry_points.txt +2 -0
- zigpy_ziggurat-1.0.0/zigpy_ziggurat.egg-info/requires.txt +3 -0
- zigpy_ziggurat-1.0.0/zigpy_ziggurat.egg-info/top_level.txt +2 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: zigpy-ziggurat
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Zigpy radio library for the Ziggurat server
|
|
5
|
+
Author-email: puddly <puddly3@gmail.com>
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: repository, https://github.com/zigpy/zigpy-ziggurat
|
|
8
|
+
Requires-Python: >=3.13
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: zigpy
|
|
11
|
+
Requires-Dist: aiohttp
|
|
12
|
+
Requires-Dist: mashumaro
|
|
13
|
+
|
|
14
|
+
# zigpy-ziggurat
|
|
15
|
+
|
|
16
|
+
A Zigpy radio library for [Ziggurat](https://github.com/zigpy/ziggurat/), a Zigbee stack implemented in Rust.
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0.0", "wheel", "setuptools-git-versioning<3"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "zigpy-ziggurat"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Zigpy radio library for the Ziggurat server"
|
|
9
|
+
urls = {repository = "https://github.com/zigpy/zigpy-ziggurat"}
|
|
10
|
+
authors = [
|
|
11
|
+
{name = "puddly", email = "puddly3@gmail.com"}
|
|
12
|
+
]
|
|
13
|
+
readme = "README.md"
|
|
14
|
+
license = {text = "Apache-2.0"}
|
|
15
|
+
requires-python = ">=3.13"
|
|
16
|
+
dependencies = [
|
|
17
|
+
"zigpy",
|
|
18
|
+
"aiohttp",
|
|
19
|
+
"mashumaro",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.entry-points."zigpy.radio"]
|
|
23
|
+
ziggurat = "zigpy_ziggurat.zigbee.application:ControllerApplication"
|
|
24
|
+
|
|
25
|
+
[tool.setuptools.packages.find]
|
|
26
|
+
exclude = ["tests", "tests.*"]
|
|
27
|
+
|
|
28
|
+
[dependency-groups]
|
|
29
|
+
testing = [
|
|
30
|
+
"mypy>=2.1.0",
|
|
31
|
+
"pre-commit>=4.6.0",
|
|
32
|
+
"uv",
|
|
33
|
+
"tomli",
|
|
34
|
+
"coverage[toml]",
|
|
35
|
+
"pytest",
|
|
36
|
+
"pytest-asyncio",
|
|
37
|
+
"pytest-cov",
|
|
38
|
+
"pytest-timeout",
|
|
39
|
+
]
|
|
40
|
+
ci = [
|
|
41
|
+
{include-group = "testing"},
|
|
42
|
+
"pytest-github-actions-annotate-failures",
|
|
43
|
+
"pytest-xdist",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
[tool.setuptools-git-versioning]
|
|
47
|
+
enabled = true
|
|
48
|
+
|
|
49
|
+
[tool.pytest.ini_options]
|
|
50
|
+
asyncio_mode = "auto"
|
|
51
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
52
|
+
|
|
53
|
+
[tool.ruff]
|
|
54
|
+
required-version = ">=0.5.0"
|
|
55
|
+
target-version = "py39"
|
|
56
|
+
line-length = 88
|
|
57
|
+
|
|
58
|
+
exclude = [
|
|
59
|
+
".venv",
|
|
60
|
+
".git",
|
|
61
|
+
".tox",
|
|
62
|
+
"docs",
|
|
63
|
+
"venv",
|
|
64
|
+
"bin",
|
|
65
|
+
"lib",
|
|
66
|
+
"deps",
|
|
67
|
+
"build",
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
[tool.ruff.lint]
|
|
71
|
+
select = ["E", "F", "UP", "B", "SIM", "I"]
|
|
72
|
+
|
|
73
|
+
[tool.ruff.lint.per-file-ignores]
|
|
74
|
+
# Fixtures are imported explicitly and referenced only as parameter names
|
|
75
|
+
"tests/*.py" = ["F401", "F811"]
|
|
76
|
+
|
|
77
|
+
[tool.ruff.lint.isort]
|
|
78
|
+
force-sort-within-sections = true
|
|
79
|
+
known-first-party = [
|
|
80
|
+
"zigpy_ziggurat",
|
|
81
|
+
]
|
|
82
|
+
combine-as-imports = true
|
|
83
|
+
split-on-trailing-comma = false
|
|
84
|
+
|
|
85
|
+
[tool.codespell]
|
|
86
|
+
ignore-words-list = ["hass"]
|
|
87
|
+
skip = ["./.*", "tests/*", "pyproject.toml"]
|
|
88
|
+
quiet-level = 2
|
|
89
|
+
|
|
90
|
+
[tool.mypy]
|
|
91
|
+
strict = true
|
|
92
|
+
show_error_codes = true
|
|
93
|
+
show_error_context = true
|
|
94
|
+
error_summary = true
|
|
95
|
+
# zigpy's integer and struct constructors are unannotated, making nearly every
|
|
96
|
+
# zigpy type construction an "untyped call"
|
|
97
|
+
disallow_untyped_calls = false
|
|
98
|
+
|
|
99
|
+
[tool.coverage.run]
|
|
100
|
+
source = ["zigpy_ziggurat"]
|
|
101
|
+
|
|
102
|
+
[tool.coverage.report]
|
|
103
|
+
exclude_lines = [
|
|
104
|
+
"pragma: no cover",
|
|
105
|
+
"if TYPE_CHECKING:",
|
|
106
|
+
"if typing.TYPE_CHECKING:",
|
|
107
|
+
"raise NotImplementedError",
|
|
108
|
+
"raise NotImplementedError()",
|
|
109
|
+
]
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Tests for the `ZigguratApi` request/response layer, against the synthetic server."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import AsyncIterator
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
from zigpy.exceptions import DeliveryError
|
|
8
|
+
import zigpy.types as t
|
|
9
|
+
|
|
10
|
+
from tests.common import RpcError, SyntheticZiggurat, server
|
|
11
|
+
from zigpy_ziggurat.zigbee import commands
|
|
12
|
+
from zigpy_ziggurat.zigbee.application import ZigguratApi
|
|
13
|
+
|
|
14
|
+
SEND_APS = commands.SendAps(
|
|
15
|
+
delivery_mode="unicast",
|
|
16
|
+
destination_eui64=None,
|
|
17
|
+
destination=t.NWK(0x1234),
|
|
18
|
+
profile_id=0x0104,
|
|
19
|
+
cluster_id=0x0006,
|
|
20
|
+
src_ep=1,
|
|
21
|
+
dst_ep=1,
|
|
22
|
+
aps_ack=True,
|
|
23
|
+
aps_seq=55,
|
|
24
|
+
radius=30,
|
|
25
|
+
aps_encryption=False,
|
|
26
|
+
priority=0,
|
|
27
|
+
data=b"\x01\x02",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RecordingApi(ZigguratApi):
|
|
32
|
+
"""A `ZigguratApi` whose callbacks record into plain lists."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, url: str) -> None:
|
|
35
|
+
self.notifications: list[commands.Notification] = []
|
|
36
|
+
self.disconnects: list[BaseException | None] = []
|
|
37
|
+
super().__init__(url, self.notifications.append, self.disconnects.append)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@pytest.fixture
|
|
41
|
+
async def api(server: SyntheticZiggurat) -> AsyncIterator[RecordingApi]:
|
|
42
|
+
instance = RecordingApi(server.url)
|
|
43
|
+
await instance.connect()
|
|
44
|
+
|
|
45
|
+
yield instance
|
|
46
|
+
|
|
47
|
+
await instance.disconnect()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def test_request(api: RecordingApi) -> None:
|
|
51
|
+
status = await api.request(commands.Ping())
|
|
52
|
+
assert status == commands.Status(status="pong")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def test_error_response(api: RecordingApi, server: SyntheticZiggurat) -> None:
|
|
56
|
+
async def fail(command: commands.Ping, request_id: int) -> commands.Status:
|
|
57
|
+
raise RpcError("serial_port_error", "it burned down")
|
|
58
|
+
|
|
59
|
+
server.handlers["ping"] = fail
|
|
60
|
+
|
|
61
|
+
with pytest.raises(DeliveryError, match="serial_port_error: it burned down"):
|
|
62
|
+
await api.request(commands.Ping())
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def test_request_transmitted(
|
|
66
|
+
api: RecordingApi, server: SyntheticZiggurat
|
|
67
|
+
) -> None:
|
|
68
|
+
await api.request_transmitted(SEND_APS)
|
|
69
|
+
assert server.sent(commands.SendAps)[-1].aps_seq == 55
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
async def test_request_transmitted_failure_before_transmission(
|
|
73
|
+
api: RecordingApi, server: SyntheticZiggurat
|
|
74
|
+
) -> None:
|
|
75
|
+
async def fail(command: commands.SendAps, request_id: int) -> commands.Status:
|
|
76
|
+
raise RpcError("transmit_failed", "channel busy")
|
|
77
|
+
|
|
78
|
+
server.handlers["send_aps"] = fail
|
|
79
|
+
|
|
80
|
+
with pytest.raises(DeliveryError, match="transmit_failed"):
|
|
81
|
+
await api.request_transmitted(SEND_APS)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def test_late_delivery_failure_is_logged(
|
|
85
|
+
api: RecordingApi, server: SyntheticZiggurat, caplog: pytest.LogCaptureFixture
|
|
86
|
+
) -> None:
|
|
87
|
+
async def ack_timeout(
|
|
88
|
+
command: commands.SendAps, request_id: int
|
|
89
|
+
) -> commands.Status:
|
|
90
|
+
await server.send_event(request_id, "transmitted")
|
|
91
|
+
raise RpcError("aps_ack_timeout", "no ack")
|
|
92
|
+
|
|
93
|
+
server.handlers["send_aps"] = ack_timeout
|
|
94
|
+
|
|
95
|
+
# Resolves at the `transmitted` stage; the terminal failure arrives later and is
|
|
96
|
+
# logged instead of raised
|
|
97
|
+
await api.request_transmitted(SEND_APS)
|
|
98
|
+
|
|
99
|
+
async with asyncio.timeout(1):
|
|
100
|
+
while "Delivery failed after transmission" not in caplog.text:
|
|
101
|
+
await asyncio.sleep(0.01)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
async def test_unsolicited_messages_are_ignored(
|
|
105
|
+
api: RecordingApi, server: SyntheticZiggurat, caplog: pytest.LogCaptureFixture
|
|
106
|
+
) -> None:
|
|
107
|
+
await server.send_raw("not json")
|
|
108
|
+
await server.send_raw('{"type": "response", "id": 9999, "result": {}}')
|
|
109
|
+
await server.send_raw('{"type": "event", "id": 9999, "event": "transmitted"}')
|
|
110
|
+
|
|
111
|
+
# A `transmitted` event for a request that did not ask for one
|
|
112
|
+
async def eager(command: commands.Ping, request_id: int) -> commands.Status:
|
|
113
|
+
await server.send_event(request_id, "transmitted")
|
|
114
|
+
return commands.Status(status="pong")
|
|
115
|
+
|
|
116
|
+
server.handlers["ping"] = eager
|
|
117
|
+
|
|
118
|
+
# The connection survives all of it
|
|
119
|
+
status = await api.request(commands.Ping())
|
|
120
|
+
assert status == commands.Status(status="pong")
|
|
121
|
+
assert "Failed to handle message" in caplog.text
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
async def test_notifications(api: RecordingApi, server: SyntheticZiggurat) -> None:
|
|
125
|
+
sent: list[commands.Notification] = [
|
|
126
|
+
commands.ReceivedApsCommand(
|
|
127
|
+
source=t.NWK(0xAB12),
|
|
128
|
+
destination=t.NWK(0x0000),
|
|
129
|
+
group=None,
|
|
130
|
+
profile_id=t.uint16_t(0x0104),
|
|
131
|
+
cluster_id=t.uint16_t(0x0006),
|
|
132
|
+
src_ep=t.uint8_t(1),
|
|
133
|
+
dst_ep=t.uint8_t(1),
|
|
134
|
+
lqi=t.uint8_t(255),
|
|
135
|
+
rssi=t.int8s(-40),
|
|
136
|
+
data=b"\x01\x02",
|
|
137
|
+
),
|
|
138
|
+
commands.FrameCounterUpdate(frame_counter=t.uint32_t(1000)),
|
|
139
|
+
commands.LinkKeyUpdate(
|
|
140
|
+
ieee=t.EUI64.convert("aa:aa:aa:aa:aa:aa:aa:aa"),
|
|
141
|
+
key=t.KeyData.convert("00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff"),
|
|
142
|
+
),
|
|
143
|
+
commands.DeviceJoined(
|
|
144
|
+
nwk=t.NWK(0xAB12),
|
|
145
|
+
ieee=t.EUI64.convert("aa:aa:aa:aa:aa:aa:aa:aa"),
|
|
146
|
+
parent=t.NWK(0x0000),
|
|
147
|
+
),
|
|
148
|
+
commands.DeviceLeft(
|
|
149
|
+
nwk=t.NWK(0xAB12),
|
|
150
|
+
ieee=None,
|
|
151
|
+
reason=commands.DeviceLeaveReason.ROUTER_REPORTED,
|
|
152
|
+
router=t.NWK(0x0000),
|
|
153
|
+
router_ieee=t.EUI64.convert("aa:aa:aa:aa:aa:aa:aa:aa"),
|
|
154
|
+
),
|
|
155
|
+
commands.ApsDecryptionFailure(
|
|
156
|
+
source=t.NWK(0x1234),
|
|
157
|
+
source_ieee=t.EUI64.convert("aa:aa:aa:aa:aa:aa:aa:aa"),
|
|
158
|
+
frame_counter=t.uint32_t(42),
|
|
159
|
+
key_id="tc_link_key",
|
|
160
|
+
),
|
|
161
|
+
]
|
|
162
|
+
|
|
163
|
+
for notification in sent:
|
|
164
|
+
await server.send_notification(notification)
|
|
165
|
+
|
|
166
|
+
async with asyncio.timeout(1):
|
|
167
|
+
while len(api.notifications) < len(sent):
|
|
168
|
+
await asyncio.sleep(0.01)
|
|
169
|
+
|
|
170
|
+
assert api.notifications == sent
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
async def test_connection_lost_fails_pending_requests(
|
|
174
|
+
api: RecordingApi, server: SyntheticZiggurat
|
|
175
|
+
) -> None:
|
|
176
|
+
async def withhold(command: commands.Ping, request_id: int) -> None:
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
server.handlers["ping"] = withhold
|
|
180
|
+
|
|
181
|
+
request = asyncio.ensure_future(api.request(commands.Ping()))
|
|
182
|
+
await server.wait_for(commands.Ping)
|
|
183
|
+
await server.ws.close()
|
|
184
|
+
|
|
185
|
+
with pytest.raises(ConnectionError):
|
|
186
|
+
await request
|
|
187
|
+
|
|
188
|
+
assert api.disconnects == [None]
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
async def test_protocol_error_disconnects(
|
|
192
|
+
api: RecordingApi, server: SyntheticZiggurat
|
|
193
|
+
) -> None:
|
|
194
|
+
# A malformed frame (reserved opcode) surfaces as a websocket protocol error
|
|
195
|
+
server.transport.write(b"\x8f\x00")
|
|
196
|
+
|
|
197
|
+
async with asyncio.timeout(1):
|
|
198
|
+
while not api.disconnects:
|
|
199
|
+
await asyncio.sleep(0.01)
|
|
200
|
+
|
|
201
|
+
assert len(api.disconnects) == 1
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
async def test_timed_out_request_failed_late(
|
|
205
|
+
api: RecordingApi, server: SyntheticZiggurat
|
|
206
|
+
) -> None:
|
|
207
|
+
async def withhold(command: commands.Ping, request_id: int) -> None:
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
server.handlers["ping"] = withhold
|
|
211
|
+
|
|
212
|
+
# The caller gave up before any response arrived (zigpy wraps requests in
|
|
213
|
+
# timeouts); disconnecting must tolerate the abandoned, cancelled future
|
|
214
|
+
with pytest.raises(TimeoutError):
|
|
215
|
+
await asyncio.wait_for(api.request(commands.Ping()), 0.05)
|
|
216
|
+
|
|
217
|
+
await api.disconnect()
|
|
218
|
+
await asyncio.sleep(0)
|