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.
@@ -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,3 @@
1
+ # zigpy-ziggurat
2
+
3
+ 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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)