zigpy-ziggurat 1.0.0__py3-none-any.whl
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/__init__.py +0 -0
- zigpy_ziggurat/zigbee/__init__.py +0 -0
- zigpy_ziggurat/zigbee/application.py +943 -0
- zigpy_ziggurat/zigbee/commands.py +402 -0
- zigpy_ziggurat-1.0.0.dist-info/METADATA +16 -0
- zigpy_ziggurat-1.0.0.dist-info/RECORD +9 -0
- zigpy_ziggurat-1.0.0.dist-info/WHEEL +5 -0
- zigpy_ziggurat-1.0.0.dist-info/entry_points.txt +2 -0
- zigpy_ziggurat-1.0.0.dist-info/top_level.txt +1 -0
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,943 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from collections.abc import AsyncGenerator, Callable
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import math
|
|
6
|
+
import statistics
|
|
7
|
+
from typing import Any, cast
|
|
8
|
+
|
|
9
|
+
import aiohttp
|
|
10
|
+
import zigpy.application
|
|
11
|
+
import zigpy.backups
|
|
12
|
+
import zigpy.config
|
|
13
|
+
import zigpy.device
|
|
14
|
+
import zigpy.endpoint
|
|
15
|
+
from zigpy.exceptions import DeliveryError, NetworkNotFormed
|
|
16
|
+
import zigpy.state
|
|
17
|
+
import zigpy.types as t
|
|
18
|
+
import zigpy.zdo.types as zdo_t
|
|
19
|
+
|
|
20
|
+
from zigpy_ziggurat.zigbee.commands import (
|
|
21
|
+
EVENT_T,
|
|
22
|
+
NOTIFICATIONS,
|
|
23
|
+
RESPONSE_T,
|
|
24
|
+
ApsDecryptionFailure,
|
|
25
|
+
Configure,
|
|
26
|
+
DeviceJoined,
|
|
27
|
+
DeviceLeft,
|
|
28
|
+
EnergyScan,
|
|
29
|
+
FrameCounterUpdate,
|
|
30
|
+
GetHwAddress,
|
|
31
|
+
GetNetworkInfo,
|
|
32
|
+
KeyTableEntry,
|
|
33
|
+
LinkKeyUpdate,
|
|
34
|
+
NetworkScan,
|
|
35
|
+
Notification,
|
|
36
|
+
PermitJoins,
|
|
37
|
+
Ping,
|
|
38
|
+
ReceivedApsCommand,
|
|
39
|
+
Request,
|
|
40
|
+
SendAps,
|
|
41
|
+
SetChannel,
|
|
42
|
+
SetNwkUpdateId,
|
|
43
|
+
SetProvisionalKey,
|
|
44
|
+
StreamingRequest,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
_LOGGER = logging.getLogger(__name__)
|
|
48
|
+
|
|
49
|
+
RSSI_MIN = -92
|
|
50
|
+
RSSI_MAX = -5
|
|
51
|
+
|
|
52
|
+
# How long a freshly-joined device gets to announce itself before zigpy is told about
|
|
53
|
+
# the join. Some devices do not tolerate being interviewed mid-join (see zigpy-znp).
|
|
54
|
+
DEVICE_JOIN_MAX_DELAY = 5
|
|
55
|
+
|
|
56
|
+
DEFAULT_MFG_ID = 0x134B # Open Home Foundation
|
|
57
|
+
MFG_ID_OVERRIDES = {
|
|
58
|
+
"04:CF:8C": 0x115F, # Xiaomi
|
|
59
|
+
"54:EF:44": 0x115F, # Lumi
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# 802.15.4 6.3.1: time spent scanning each channel is
|
|
63
|
+
# aBaseSuperframeDuration * (2^n + 1) symbols, at 16 us per symbol
|
|
64
|
+
SYMBOL_PERIOD_MS = 0.016
|
|
65
|
+
BASE_SUPERFRAME_DURATION_SYMBOLS = 960
|
|
66
|
+
|
|
67
|
+
WEBSOCKET_HEARTBEAT = 15
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def logistic(x: float, *, L: float = 1, x_0: float = 0, k: float = 1) -> float:
|
|
71
|
+
"""Logistic function."""
|
|
72
|
+
return L / (1 + math.exp(-k * (x - x_0)))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def map_rssi_to_energy(rssi: float) -> float:
|
|
76
|
+
"""Remaps RSSI (in dBm) to Energy (0-255), same curve as bellows."""
|
|
77
|
+
return logistic(
|
|
78
|
+
x=rssi,
|
|
79
|
+
L=255,
|
|
80
|
+
x_0=RSSI_MIN + 0.45 * (RSSI_MAX - RSSI_MIN),
|
|
81
|
+
k=0.13,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class PendingRequest:
|
|
86
|
+
"""The in-flight state of one request: an optional `transmitted` stage future and
|
|
87
|
+
the terminal `response` future."""
|
|
88
|
+
|
|
89
|
+
def __init__(
|
|
90
|
+
self, *, want_transmitted: bool, stream_event: str | None = None
|
|
91
|
+
) -> None:
|
|
92
|
+
loop = asyncio.get_running_loop()
|
|
93
|
+
self.response: asyncio.Future[dict[str, Any]] = loop.create_future()
|
|
94
|
+
self.transmitted: asyncio.Future[None] | None = (
|
|
95
|
+
loop.create_future() if want_transmitted else None
|
|
96
|
+
)
|
|
97
|
+
# For a streaming request: the event name carrying results, and a queue those
|
|
98
|
+
# results land in. `None` enqueued by the terminal response marks the end.
|
|
99
|
+
self.stream_event = stream_event
|
|
100
|
+
self.events: asyncio.Queue[dict[str, Any] | None] | None = (
|
|
101
|
+
asyncio.Queue() if stream_event is not None else None
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def fail(self, exc: BaseException) -> None:
|
|
105
|
+
if self.transmitted is not None and not self.transmitted.done():
|
|
106
|
+
self.transmitted.set_exception(exc)
|
|
107
|
+
|
|
108
|
+
if not self.response.done():
|
|
109
|
+
self.response.set_exception(exc)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _make_late_failure_logger(
|
|
113
|
+
pending: PendingRequest,
|
|
114
|
+
) -> Callable[[asyncio.Future[dict[str, Any]]], None]:
|
|
115
|
+
"""Consume the terminal result of a request that already resolved at the
|
|
116
|
+
`transmitted` stage, so delivery failures are visible but not raised. Failures
|
|
117
|
+
from before transmission were already raised to the caller and are not logged."""
|
|
118
|
+
|
|
119
|
+
def log_late_failure(fut: asyncio.Future[dict[str, Any]]) -> None:
|
|
120
|
+
if fut.cancelled():
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
exc = fut.exception()
|
|
124
|
+
if exc is None:
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
transmitted = (
|
|
128
|
+
pending.transmitted is not None
|
|
129
|
+
and pending.transmitted.done()
|
|
130
|
+
and pending.transmitted.exception() is None
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
if transmitted:
|
|
134
|
+
_LOGGER.warning("Delivery failed after transmission: %s", exc)
|
|
135
|
+
|
|
136
|
+
return log_late_failure
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class ZigguratApi:
|
|
140
|
+
"""The Ziggurat WebSocket API: concurrent requests correlated by id, with
|
|
141
|
+
lifecycle events (`accepted`, `transmitted`) preceding each terminal response."""
|
|
142
|
+
|
|
143
|
+
def __init__(
|
|
144
|
+
self,
|
|
145
|
+
url: str,
|
|
146
|
+
on_notification: Callable[[Notification], None],
|
|
147
|
+
on_disconnect: Callable[[BaseException | None], None],
|
|
148
|
+
) -> None:
|
|
149
|
+
self._url = url
|
|
150
|
+
self._on_notification = on_notification
|
|
151
|
+
self._on_disconnect = on_disconnect
|
|
152
|
+
|
|
153
|
+
self._session: aiohttp.ClientSession | None = None
|
|
154
|
+
self._websocket: aiohttp.ClientWebSocketResponse | None = None
|
|
155
|
+
self._receiver_task: asyncio.Task[None] | None = None
|
|
156
|
+
self._request_id = 1
|
|
157
|
+
self._pending: dict[int, PendingRequest] = {}
|
|
158
|
+
|
|
159
|
+
async def connect(self) -> None:
|
|
160
|
+
if self._url.startswith("ws+unix://"):
|
|
161
|
+
# The URL's path is the socket path; the HTTP-level host is a placeholder
|
|
162
|
+
connector = aiohttp.UnixConnector(path=self._url.removeprefix("ws+unix://"))
|
|
163
|
+
url = "ws://localhost/"
|
|
164
|
+
else:
|
|
165
|
+
connector = None
|
|
166
|
+
url = self._url
|
|
167
|
+
|
|
168
|
+
self._session = aiohttp.ClientSession(connector=connector)
|
|
169
|
+
self._websocket = await self._session.ws_connect(
|
|
170
|
+
url, heartbeat=WEBSOCKET_HEARTBEAT
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
hello = json.loads(await self._websocket.receive_str())
|
|
174
|
+
_LOGGER.debug("Connected to ziggurat: %r", hello)
|
|
175
|
+
|
|
176
|
+
self._receiver_task = asyncio.create_task(self._receive_loop())
|
|
177
|
+
|
|
178
|
+
async def disconnect(self) -> None:
|
|
179
|
+
if self._receiver_task is not None:
|
|
180
|
+
self._receiver_task.cancel()
|
|
181
|
+
self._receiver_task = None
|
|
182
|
+
|
|
183
|
+
if self._websocket is not None:
|
|
184
|
+
await self._websocket.close()
|
|
185
|
+
self._websocket = None
|
|
186
|
+
|
|
187
|
+
if self._session is not None:
|
|
188
|
+
await self._session.close()
|
|
189
|
+
self._session = None
|
|
190
|
+
|
|
191
|
+
async def _receive_loop(self) -> None:
|
|
192
|
+
websocket = self._websocket
|
|
193
|
+
assert websocket is not None
|
|
194
|
+
|
|
195
|
+
exc: BaseException | None = None
|
|
196
|
+
|
|
197
|
+
try:
|
|
198
|
+
async for msg in websocket:
|
|
199
|
+
if msg.type == aiohttp.WSMsgType.TEXT:
|
|
200
|
+
try:
|
|
201
|
+
self._handle_message(json.loads(msg.data))
|
|
202
|
+
except Exception:
|
|
203
|
+
_LOGGER.exception("Failed to handle message: %r", msg.data)
|
|
204
|
+
elif msg.type == aiohttp.WSMsgType.ERROR:
|
|
205
|
+
exc = websocket.exception()
|
|
206
|
+
break
|
|
207
|
+
except asyncio.CancelledError:
|
|
208
|
+
# A deliberate `disconnect()`, not a connection loss
|
|
209
|
+
self._fail_pending(ConnectionError("Connection closed"))
|
|
210
|
+
raise
|
|
211
|
+
except Exception as e: # pragma: no cover
|
|
212
|
+
# aiohttp surfaces connection failures as `ERROR` messages or by ending
|
|
213
|
+
# the iterator, never by raising; kept as a guard for other versions
|
|
214
|
+
exc = e
|
|
215
|
+
|
|
216
|
+
self._fail_pending(ConnectionError("Connection lost"))
|
|
217
|
+
self._on_disconnect(exc)
|
|
218
|
+
|
|
219
|
+
def _fail_pending(self, exc: BaseException) -> None:
|
|
220
|
+
for pending in self._pending.values():
|
|
221
|
+
pending.response.add_done_callback(_make_late_failure_logger(pending))
|
|
222
|
+
pending.fail(exc)
|
|
223
|
+
|
|
224
|
+
self._pending.clear()
|
|
225
|
+
|
|
226
|
+
def _handle_message(self, msg: dict[str, Any]) -> None:
|
|
227
|
+
_LOGGER.debug("Received: %r", msg)
|
|
228
|
+
msg_type = msg["type"]
|
|
229
|
+
|
|
230
|
+
if msg_type == "notification":
|
|
231
|
+
self._on_notification(NOTIFICATIONS[msg["event"]].from_dict(msg["data"]))
|
|
232
|
+
elif msg_type == "event":
|
|
233
|
+
pending = self._pending.get(msg["id"])
|
|
234
|
+
|
|
235
|
+
if pending is None:
|
|
236
|
+
pass
|
|
237
|
+
elif (
|
|
238
|
+
msg["event"] == "transmitted"
|
|
239
|
+
and pending.transmitted is not None
|
|
240
|
+
and not pending.transmitted.done()
|
|
241
|
+
):
|
|
242
|
+
pending.transmitted.set_result(None)
|
|
243
|
+
elif pending.events is not None and msg["event"] == pending.stream_event:
|
|
244
|
+
pending.events.put_nowait(msg["data"])
|
|
245
|
+
elif msg_type == "response":
|
|
246
|
+
pending = self._pending.pop(msg["id"], None)
|
|
247
|
+
|
|
248
|
+
if pending is None:
|
|
249
|
+
_LOGGER.debug("Response for unknown request: %r", msg)
|
|
250
|
+
return
|
|
251
|
+
|
|
252
|
+
if "error" in msg:
|
|
253
|
+
error = msg["error"]
|
|
254
|
+
pending.fail(DeliveryError(f"{error['code']}: {error['message']}"))
|
|
255
|
+
elif not pending.response.done():
|
|
256
|
+
pending.response.set_result(msg["result"])
|
|
257
|
+
|
|
258
|
+
async def request(self, command: Request[RESPONSE_T]) -> RESPONSE_T:
|
|
259
|
+
result = await self._send_request(command, want_transmitted=False)
|
|
260
|
+
assert result is not None
|
|
261
|
+
|
|
262
|
+
# `response_type` is a plain ClassVar: it cannot carry the type variable
|
|
263
|
+
return cast(RESPONSE_T, command.response_type.from_dict(result))
|
|
264
|
+
|
|
265
|
+
async def request_transmitted(self, command: Request[Any]) -> None:
|
|
266
|
+
"""Resolve once the frame is on the air instead of waiting for delivery."""
|
|
267
|
+
await self._send_request(command, want_transmitted=True)
|
|
268
|
+
|
|
269
|
+
async def _send_request(
|
|
270
|
+
self, command: Request[Any], *, want_transmitted: bool
|
|
271
|
+
) -> dict[str, Any] | None:
|
|
272
|
+
request_id = self._request_id
|
|
273
|
+
self._request_id = (self._request_id + 1) % 2**32 or 1
|
|
274
|
+
|
|
275
|
+
pending = PendingRequest(want_transmitted=want_transmitted)
|
|
276
|
+
self._pending[request_id] = pending
|
|
277
|
+
|
|
278
|
+
message = {
|
|
279
|
+
"id": request_id,
|
|
280
|
+
"method": command.method,
|
|
281
|
+
"params": command.to_dict(),
|
|
282
|
+
}
|
|
283
|
+
_LOGGER.debug("Sending: %r", message)
|
|
284
|
+
assert self._websocket is not None
|
|
285
|
+
await self._websocket.send_str(json.dumps(message))
|
|
286
|
+
|
|
287
|
+
if want_transmitted:
|
|
288
|
+
# The terminal response continues in the background; an end-to-end
|
|
289
|
+
# delivery failure after transmission is logged, not raised
|
|
290
|
+
pending.response.add_done_callback(_make_late_failure_logger(pending))
|
|
291
|
+
assert pending.transmitted is not None
|
|
292
|
+
await pending.transmitted
|
|
293
|
+
return None
|
|
294
|
+
|
|
295
|
+
return await pending.response
|
|
296
|
+
|
|
297
|
+
async def request_stream(
|
|
298
|
+
self, command: StreamingRequest[Any, EVENT_T]
|
|
299
|
+
) -> AsyncGenerator[EVENT_T, None]:
|
|
300
|
+
"""Issue a request that streams `event_type` results until its terminal
|
|
301
|
+
response, yielding each result. An error response (or a disconnect) raised once
|
|
302
|
+
the stream is exhausted."""
|
|
303
|
+
request_id = self._request_id
|
|
304
|
+
self._request_id = (self._request_id + 1) % 2**32 or 1
|
|
305
|
+
|
|
306
|
+
pending = PendingRequest(
|
|
307
|
+
want_transmitted=False, stream_event=command.event_name
|
|
308
|
+
)
|
|
309
|
+
self._pending[request_id] = pending
|
|
310
|
+
|
|
311
|
+
message = {
|
|
312
|
+
"id": request_id,
|
|
313
|
+
"method": command.method,
|
|
314
|
+
"params": command.to_dict(),
|
|
315
|
+
}
|
|
316
|
+
_LOGGER.debug("Sending: %r", message)
|
|
317
|
+
assert self._websocket is not None
|
|
318
|
+
await self._websocket.send_str(json.dumps(message))
|
|
319
|
+
|
|
320
|
+
assert pending.events is not None
|
|
321
|
+
events = pending.events
|
|
322
|
+
|
|
323
|
+
# The terminal response (success, error, or disconnect) ends the stream. Results
|
|
324
|
+
# are enqueued ahead of it in receive order, so the queue drains fully first.
|
|
325
|
+
pending.response.add_done_callback(lambda _: events.put_nowait(None))
|
|
326
|
+
|
|
327
|
+
try:
|
|
328
|
+
while (item := await events.get()) is not None:
|
|
329
|
+
yield cast(EVENT_T, command.event_type.from_dict(item))
|
|
330
|
+
|
|
331
|
+
# Surface an error response or disconnect; a success carries only a status
|
|
332
|
+
await pending.response
|
|
333
|
+
finally:
|
|
334
|
+
self._pending.pop(request_id, None)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
class ZigguratCoordinator(zigpy.device.Device):
|
|
338
|
+
"""Zigpy device representing the coordinator. Ziggurat has no loopback ZDO, so the
|
|
339
|
+
device is constructed statically instead of being interviewed over the air."""
|
|
340
|
+
|
|
341
|
+
@property
|
|
342
|
+
def manufacturer(self) -> str:
|
|
343
|
+
return "Ziggurat"
|
|
344
|
+
|
|
345
|
+
@manufacturer.setter
|
|
346
|
+
def manufacturer(self, value: str) -> None:
|
|
347
|
+
pass
|
|
348
|
+
|
|
349
|
+
@property
|
|
350
|
+
def model(self) -> str:
|
|
351
|
+
return "Coordinator"
|
|
352
|
+
|
|
353
|
+
@model.setter
|
|
354
|
+
def model(self, value: str) -> None:
|
|
355
|
+
pass
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
class ControllerApplication(zigpy.application.ControllerApplication):
|
|
359
|
+
DISPLAY_NAME = "Ziggurat"
|
|
360
|
+
DESCRIPTION = "Ziggurat: An open source, host-side Zigbee stack in Rust"
|
|
361
|
+
|
|
362
|
+
def __init__(self, config: dict[str, Any]) -> None:
|
|
363
|
+
super().__init__(config)
|
|
364
|
+
self._api: ZigguratApi | None = None
|
|
365
|
+
|
|
366
|
+
async def connect(self) -> None:
|
|
367
|
+
# The device path is the WebSocket URL of the ziggurat server
|
|
368
|
+
url = self._config[zigpy.config.CONF_DEVICE][zigpy.config.CONF_DEVICE_PATH]
|
|
369
|
+
|
|
370
|
+
# zigpy types `connection_lost` as Exception-only but handles None fine
|
|
371
|
+
api = ZigguratApi(
|
|
372
|
+
url,
|
|
373
|
+
self.on_notification,
|
|
374
|
+
self.connection_lost, # type: ignore[arg-type]
|
|
375
|
+
)
|
|
376
|
+
await api.connect()
|
|
377
|
+
self._api = api
|
|
378
|
+
|
|
379
|
+
async def disconnect(self) -> None:
|
|
380
|
+
if self._api is not None:
|
|
381
|
+
try:
|
|
382
|
+
await self._api.disconnect()
|
|
383
|
+
finally:
|
|
384
|
+
self._api = None
|
|
385
|
+
|
|
386
|
+
async def start_network(self) -> None:
|
|
387
|
+
await self.load_network_info()
|
|
388
|
+
await self.write_network_info(
|
|
389
|
+
network_info=self.state.network_info, node_info=self.state.node_info
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
self._register_coordinator_device()
|
|
393
|
+
await self.register_endpoints()
|
|
394
|
+
|
|
395
|
+
def _register_coordinator_device(self) -> None:
|
|
396
|
+
coordinator = ZigguratCoordinator(
|
|
397
|
+
self, self.state.node_info.ieee, self.state.node_info.nwk
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
# Remote devices read this via ZDO Node_Desc_req, which zigpy answers with the
|
|
401
|
+
# device's node descriptor. The server mask advertises a primary trust center
|
|
402
|
+
# with stack compliance revision 22: joiners check it to decide whether to
|
|
403
|
+
# perform the trust center link key exchange.
|
|
404
|
+
coordinator.node_desc = zdo_t.NodeDescriptor(
|
|
405
|
+
logical_type=zdo_t.LogicalType.Coordinator,
|
|
406
|
+
complex_descriptor_available=0,
|
|
407
|
+
user_descriptor_available=0,
|
|
408
|
+
reserved=0,
|
|
409
|
+
aps_flags=0,
|
|
410
|
+
frequency_band=zdo_t.NodeDescriptor.FrequencyBand.Freq2400MHz,
|
|
411
|
+
mac_capability_flags=(
|
|
412
|
+
zdo_t.NodeDescriptor.MACCapabilityFlags.FullFunctionDevice
|
|
413
|
+
| zdo_t.NodeDescriptor.MACCapabilityFlags.MainsPowered
|
|
414
|
+
| zdo_t.NodeDescriptor.MACCapabilityFlags.RxOnWhenIdle
|
|
415
|
+
| zdo_t.NodeDescriptor.MACCapabilityFlags.AllocateAddress
|
|
416
|
+
),
|
|
417
|
+
manufacturer_code=DEFAULT_MFG_ID,
|
|
418
|
+
maximum_buffer_size=82,
|
|
419
|
+
maximum_incoming_transfer_size=128,
|
|
420
|
+
server_mask=0x2C01, # Primary Trust Center, revision 22
|
|
421
|
+
maximum_outgoing_transfer_size=128,
|
|
422
|
+
descriptor_capability_field=zdo_t.NodeDescriptor.DescriptorCapability.NONE,
|
|
423
|
+
)
|
|
424
|
+
coordinator.status = zigpy.device.Status.ENDPOINTS_INIT
|
|
425
|
+
|
|
426
|
+
self.devices[self.state.node_info.ieee] = coordinator
|
|
427
|
+
|
|
428
|
+
async def load_network_info(self, *, load_devices: bool = False) -> None:
|
|
429
|
+
assert self._api is not None
|
|
430
|
+
|
|
431
|
+
try:
|
|
432
|
+
info = await self._api.request(GetNetworkInfo())
|
|
433
|
+
except DeliveryError as exc:
|
|
434
|
+
if not str(exc).startswith("not_configured"):
|
|
435
|
+
raise
|
|
436
|
+
|
|
437
|
+
# The server is stateless and has no network running (e.g. it just
|
|
438
|
+
# restarted): the most recent zigpy database backup is authoritative
|
|
439
|
+
self._get_network_settings()
|
|
440
|
+
return
|
|
441
|
+
|
|
442
|
+
stack_specific = {}
|
|
443
|
+
if info.tclk_seed is not None:
|
|
444
|
+
if info.tclk_flavor == "zstack":
|
|
445
|
+
stack_specific = {"zstack": {"tclk_seed": info.tclk_seed}}
|
|
446
|
+
else:
|
|
447
|
+
stack_specific = {"ezsp": {"hashed_tclk": info.tclk_seed}}
|
|
448
|
+
|
|
449
|
+
self.state.node_info = zigpy.state.NodeInfo(
|
|
450
|
+
nwk=info.nwk_address,
|
|
451
|
+
ieee=info.ieee_address,
|
|
452
|
+
logical_type=zdo_t.LogicalType.Coordinator,
|
|
453
|
+
manufacturer="Ziggurat",
|
|
454
|
+
model="Coordinator",
|
|
455
|
+
)
|
|
456
|
+
self.state.network_info = zigpy.state.NetworkInfo(
|
|
457
|
+
extended_pan_id=info.extended_pan_id,
|
|
458
|
+
pan_id=info.pan_id,
|
|
459
|
+
nwk_update_id=info.nwk_update_id,
|
|
460
|
+
nwk_manager_id=t.NWK(0x0000),
|
|
461
|
+
channel=info.channel,
|
|
462
|
+
tx_power=info.tx_power,
|
|
463
|
+
# zigpy mis-annotates the classmethod's `cls` as an instance
|
|
464
|
+
channel_mask=t.Channels.from_channel_list([info.channel]), # type: ignore[misc]
|
|
465
|
+
security_level=t.uint8_t(5),
|
|
466
|
+
network_key=zigpy.state.Key(
|
|
467
|
+
key=info.network_key,
|
|
468
|
+
seq=info.network_key_seq,
|
|
469
|
+
tx_counter=info.network_key_tx_counter,
|
|
470
|
+
),
|
|
471
|
+
tc_link_key=zigpy.state.Key(
|
|
472
|
+
key=info.tc_link_key,
|
|
473
|
+
partner_ieee=self.state.node_info.ieee,
|
|
474
|
+
),
|
|
475
|
+
key_table=[
|
|
476
|
+
zigpy.state.Key(key=entry.key, partner_ieee=entry.partner_ieee)
|
|
477
|
+
for entry in info.key_table
|
|
478
|
+
],
|
|
479
|
+
stack_specific=stack_specific,
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
def _get_network_settings(self) -> None:
|
|
483
|
+
try:
|
|
484
|
+
latest_backup = self.backups[-1]
|
|
485
|
+
except IndexError as exc:
|
|
486
|
+
raise NetworkNotFormed() from exc
|
|
487
|
+
|
|
488
|
+
# The backup's frame counter trails the radio's true counter by however many
|
|
489
|
+
# frames were sent after the last counter update notification: jump past it
|
|
490
|
+
network_key = latest_backup.network_info.network_key
|
|
491
|
+
self.state.network_info = latest_backup.network_info.replace(
|
|
492
|
+
network_key=network_key.replace(tx_counter=network_key.tx_counter + 500)
|
|
493
|
+
)
|
|
494
|
+
self.state.node_info = latest_backup.node_info
|
|
495
|
+
|
|
496
|
+
async def force_remove(self, dev: zigpy.device.Device) -> None:
|
|
497
|
+
_LOGGER.debug("Not implemented")
|
|
498
|
+
|
|
499
|
+
async def add_endpoint(self, descriptor: zdo_t.SimpleDescriptor) -> None:
|
|
500
|
+
# There is no firmware to register the endpoint with: it exists only on the
|
|
501
|
+
# static coordinator device, which ZDO requests are answered from
|
|
502
|
+
endpoint = self._device.add_endpoint(descriptor.endpoint)
|
|
503
|
+
endpoint.status = zigpy.endpoint.Status.ZDO_INIT
|
|
504
|
+
endpoint.profile_id = descriptor.profile
|
|
505
|
+
# zigpy stores the raw value too, converting to the profile's enum lazily
|
|
506
|
+
endpoint.device_type = descriptor.device_type # type: ignore[assignment]
|
|
507
|
+
|
|
508
|
+
for cluster_id in descriptor.input_clusters:
|
|
509
|
+
endpoint.add_input_cluster(cluster_id)
|
|
510
|
+
|
|
511
|
+
for cluster_id in descriptor.output_clusters:
|
|
512
|
+
endpoint.add_output_cluster(cluster_id)
|
|
513
|
+
|
|
514
|
+
async def _move_network_to_channel(
|
|
515
|
+
self, new_channel: int, new_nwk_update_id: int
|
|
516
|
+
) -> None:
|
|
517
|
+
# zigpy has already broadcast the migration to the network; this is the
|
|
518
|
+
# coordinator's own move. The update id goes first so no beacon on the new
|
|
519
|
+
# channel ever advertises the old network instance.
|
|
520
|
+
assert self._api is not None
|
|
521
|
+
await self._api.request(SetNwkUpdateId(nwk_update_id=new_nwk_update_id))
|
|
522
|
+
await self._api.request(SetChannel(channel=new_channel))
|
|
523
|
+
|
|
524
|
+
async def permit(self, time_s: int = 60, node: t.EUI64 | str | None = None) -> None:
|
|
525
|
+
if node is not None:
|
|
526
|
+
if not isinstance(node, t.EUI64):
|
|
527
|
+
node = t.EUI64.convert(node)
|
|
528
|
+
if node != self.state.node_info.ieee:
|
|
529
|
+
# The base sends a unicast Mgmt_Permit_Joining_req to the target
|
|
530
|
+
# router to steer joins through it. Open our trust center window too,
|
|
531
|
+
# without advertising the coordinator itself as a join target.
|
|
532
|
+
await super().permit(time_s, node=node)
|
|
533
|
+
assert self._api is not None
|
|
534
|
+
await self._api.request(
|
|
535
|
+
PermitJoins(duration=time_s, accept_direct_joins=False)
|
|
536
|
+
)
|
|
537
|
+
return
|
|
538
|
+
|
|
539
|
+
await super().permit(time_s, node=node)
|
|
540
|
+
|
|
541
|
+
async def permit_ncp(self, time_s: int = 60) -> None:
|
|
542
|
+
assert self._api is not None
|
|
543
|
+
await self._api.request(PermitJoins(duration=time_s, accept_direct_joins=True))
|
|
544
|
+
|
|
545
|
+
async def permit_with_link_key(
|
|
546
|
+
self, node: t.EUI64, link_key: t.KeyData, time_s: int = 60
|
|
547
|
+
) -> None:
|
|
548
|
+
assert self._api is not None
|
|
549
|
+
await self._api.request(SetProvisionalKey(ieee=node, key=link_key))
|
|
550
|
+
|
|
551
|
+
await super().permit(time_s)
|
|
552
|
+
|
|
553
|
+
async def energy_scan(
|
|
554
|
+
self, channels: t.Channels, duration_exp: int, count: int
|
|
555
|
+
) -> dict[int, float]:
|
|
556
|
+
duration_per_channel_ms = round(
|
|
557
|
+
SYMBOL_PERIOD_MS * BASE_SUPERFRAME_DURATION_SYMBOLS * (2**duration_exp + 1)
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
all_results: dict[int, list[float]] = {}
|
|
561
|
+
|
|
562
|
+
assert self._api is not None
|
|
563
|
+
for _ in range(count):
|
|
564
|
+
async for result in self._api.request_stream(
|
|
565
|
+
EnergyScan(
|
|
566
|
+
channels=list(channels),
|
|
567
|
+
duration_per_channel_ms=duration_per_channel_ms,
|
|
568
|
+
)
|
|
569
|
+
):
|
|
570
|
+
all_results.setdefault(result.channel, []).append(result.rssi)
|
|
571
|
+
|
|
572
|
+
return {
|
|
573
|
+
channel: map_rssi_to_energy(statistics.mean(all_results[channel]))
|
|
574
|
+
for channel in list(channels)
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
async def _network_scan(
|
|
578
|
+
self, channels: t.Channels, duration_exp: int
|
|
579
|
+
) -> AsyncGenerator[t.NetworkBeacon, None]:
|
|
580
|
+
duration_per_channel_ms = round(
|
|
581
|
+
SYMBOL_PERIOD_MS * BASE_SUPERFRAME_DURATION_SYMBOLS * (2**duration_exp + 1)
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
assert self._api is not None
|
|
585
|
+
async for beacon in self._api.request_stream(
|
|
586
|
+
NetworkScan(
|
|
587
|
+
channels=list(channels),
|
|
588
|
+
duration_per_channel_ms=duration_per_channel_ms,
|
|
589
|
+
)
|
|
590
|
+
):
|
|
591
|
+
yield t.NetworkBeacon(
|
|
592
|
+
pan_id=beacon.pan_id,
|
|
593
|
+
extended_pan_id=beacon.extended_pan_id,
|
|
594
|
+
channel=beacon.channel,
|
|
595
|
+
permit_joining=beacon.permit_joining,
|
|
596
|
+
stack_profile=beacon.stack_profile,
|
|
597
|
+
nwk_update_id=beacon.update_id,
|
|
598
|
+
lqi=beacon.lqi,
|
|
599
|
+
src=beacon.source,
|
|
600
|
+
rssi=beacon.rssi,
|
|
601
|
+
depth=beacon.device_depth,
|
|
602
|
+
router_capacity=beacon.router_capacity,
|
|
603
|
+
device_capacity=beacon.end_device_capacity,
|
|
604
|
+
protocol_version=beacon.protocol_version,
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
async def write_network_info(
|
|
608
|
+
self,
|
|
609
|
+
*,
|
|
610
|
+
network_info: zigpy.state.NetworkInfo,
|
|
611
|
+
node_info: zigpy.state.NodeInfo,
|
|
612
|
+
) -> None:
|
|
613
|
+
# A TCLK seed carried over from a microcontroller stack: ziggurat derives the
|
|
614
|
+
# unique link keys the previous stack issued to devices from it. Both stacks
|
|
615
|
+
# already store the seed as a plain hex string.
|
|
616
|
+
stack_specific = network_info.stack_specific
|
|
617
|
+
tclk_seed = None
|
|
618
|
+
tclk_flavor = None
|
|
619
|
+
|
|
620
|
+
if "zstack" in stack_specific and "tclk_seed" in stack_specific["zstack"]:
|
|
621
|
+
tclk_seed = stack_specific["zstack"]["tclk_seed"]
|
|
622
|
+
tclk_flavor = "zstack"
|
|
623
|
+
elif "ezsp" in stack_specific and "hashed_tclk" in stack_specific["ezsp"]:
|
|
624
|
+
tclk_seed = stack_specific["ezsp"]["hashed_tclk"]
|
|
625
|
+
tclk_flavor = "ezsp"
|
|
626
|
+
|
|
627
|
+
assert self._api is not None
|
|
628
|
+
|
|
629
|
+
# `UNKNOWN` is assigned after the class body, where mypy cannot see it
|
|
630
|
+
if node_info.ieee == t.EUI64.UNKNOWN: # type: ignore[attr-defined]
|
|
631
|
+
# zigpy leaves the IEEE address unspecified when forming a new network,
|
|
632
|
+
# deferring to the radio's hardware address
|
|
633
|
+
rsp = await self._api.request(GetHwAddress())
|
|
634
|
+
node_info = node_info.replace(ieee=rsp.ieee_address)
|
|
635
|
+
|
|
636
|
+
await self._api.request(
|
|
637
|
+
Configure(
|
|
638
|
+
channel=network_info.channel,
|
|
639
|
+
# None means "pick automatically": the server applies its safe default
|
|
640
|
+
tx_power=network_info.tx_power,
|
|
641
|
+
nwk_update_id=network_info.nwk_update_id,
|
|
642
|
+
pan_id=network_info.pan_id,
|
|
643
|
+
extended_pan_id=network_info.extended_pan_id,
|
|
644
|
+
nwk_address=node_info.nwk,
|
|
645
|
+
ieee_address=node_info.ieee,
|
|
646
|
+
network_key=network_info.network_key.key,
|
|
647
|
+
network_key_seq=network_info.network_key.seq,
|
|
648
|
+
network_key_tx_counter=network_info.network_key.tx_counter,
|
|
649
|
+
tc_link_key=network_info.tc_link_key.key,
|
|
650
|
+
source_routing=self.config[zigpy.config.CONF_SOURCE_ROUTING],
|
|
651
|
+
# Unique trust center link keys negotiated in earlier sessions
|
|
652
|
+
key_table=[
|
|
653
|
+
KeyTableEntry(partner_ieee=key.partner_ieee, key=key.key)
|
|
654
|
+
for key in network_info.key_table
|
|
655
|
+
],
|
|
656
|
+
tclk_seed=tclk_seed,
|
|
657
|
+
tclk_flavor=tclk_flavor,
|
|
658
|
+
)
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
# Ziggurat has no persistent storage of its own: zigpy's backup database is
|
|
662
|
+
# the network's NVRAM, so the settings just written are recorded there for
|
|
663
|
+
# `start_network` to find
|
|
664
|
+
self.state.network_info = network_info
|
|
665
|
+
self.state.node_info = node_info
|
|
666
|
+
self.backups.add_backup(
|
|
667
|
+
zigpy.backups.NetworkBackup(network_info=network_info, node_info=node_info)
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
async def reset_network_info(self) -> None:
|
|
671
|
+
pass
|
|
672
|
+
|
|
673
|
+
async def _watchdog_feed(self) -> None:
|
|
674
|
+
assert self._api is not None
|
|
675
|
+
await self._api.request(Ping())
|
|
676
|
+
|
|
677
|
+
def packet_received(self, packet: t.ZigbeePacket) -> None:
|
|
678
|
+
# ZDO requests addressed to the coordinator have to be answered here: there is
|
|
679
|
+
# no firmware ZDO underneath Ziggurat, and zigpy itself only handles a subset
|
|
680
|
+
# (NWK_addr_req, IEEE_addr_req, Match_Desc_req)
|
|
681
|
+
if (
|
|
682
|
+
packet.profile_id == 0x0000
|
|
683
|
+
and packet.src_ep == 0
|
|
684
|
+
and packet.dst_ep == 0
|
|
685
|
+
and packet.src is not None
|
|
686
|
+
and packet.src.addr_mode == t.AddrMode.NWK
|
|
687
|
+
):
|
|
688
|
+
self._maybe_handle_local_zdo_request(packet)
|
|
689
|
+
|
|
690
|
+
super().packet_received(packet)
|
|
691
|
+
|
|
692
|
+
def _maybe_handle_local_zdo_request(self, packet: t.ZigbeePacket) -> None:
|
|
693
|
+
assert packet.src is not None
|
|
694
|
+
|
|
695
|
+
try:
|
|
696
|
+
device = self.get_device(nwk=t.NWK(packet.src.address))
|
|
697
|
+
except KeyError:
|
|
698
|
+
return
|
|
699
|
+
|
|
700
|
+
try:
|
|
701
|
+
hdr, args = device.zdo.deserialize(
|
|
702
|
+
packet.cluster_id, packet.data.serialize()
|
|
703
|
+
)
|
|
704
|
+
except (ValueError, KeyError):
|
|
705
|
+
return
|
|
706
|
+
|
|
707
|
+
if hdr.command_id not in (
|
|
708
|
+
zdo_t.ZDOCmd.Node_Desc_req,
|
|
709
|
+
zdo_t.ZDOCmd.Active_EP_req,
|
|
710
|
+
zdo_t.ZDOCmd.Simple_Desc_req,
|
|
711
|
+
):
|
|
712
|
+
return
|
|
713
|
+
|
|
714
|
+
# The address of interest must be us
|
|
715
|
+
if args[0] != self.state.node_info.nwk:
|
|
716
|
+
return
|
|
717
|
+
|
|
718
|
+
coordinator = self._device
|
|
719
|
+
nwk = self.state.node_info.nwk
|
|
720
|
+
|
|
721
|
+
if hdr.command_id == zdo_t.ZDOCmd.Node_Desc_req:
|
|
722
|
+
# Joining devices read our node descriptor to learn the trust center's
|
|
723
|
+
# stack compliance revision before attempting the link key exchange
|
|
724
|
+
node_desc = coordinator.node_desc
|
|
725
|
+
assert node_desc is not None
|
|
726
|
+
|
|
727
|
+
# Aqara/Xiaomi/Lumi devices only finish joining if we report the Xiaomi
|
|
728
|
+
# manufacturer code; answer the requester with the code its OUI expects
|
|
729
|
+
mfg_id = MFG_ID_OVERRIDES.get(str(device.ieee)[:8].upper())
|
|
730
|
+
if mfg_id is not None:
|
|
731
|
+
node_desc = cast(
|
|
732
|
+
zdo_t.NodeDescriptor,
|
|
733
|
+
node_desc.replace(manufacturer_code=t.uint16_t(mfg_id)), # type: ignore[arg-type]
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
device.zdo.create_catching_task(
|
|
737
|
+
device.zdo.Node_Desc_rsp(
|
|
738
|
+
zdo_t.Status.SUCCESS,
|
|
739
|
+
nwk,
|
|
740
|
+
node_desc,
|
|
741
|
+
tsn=hdr.tsn,
|
|
742
|
+
)
|
|
743
|
+
)
|
|
744
|
+
elif hdr.command_id == zdo_t.ZDOCmd.Active_EP_req:
|
|
745
|
+
endpoints = [t.uint8_t(ep) for ep in coordinator.endpoints if ep != 0]
|
|
746
|
+
device.zdo.create_catching_task(
|
|
747
|
+
device.zdo.Active_EP_rsp(
|
|
748
|
+
zdo_t.Status.SUCCESS,
|
|
749
|
+
nwk,
|
|
750
|
+
endpoints,
|
|
751
|
+
tsn=hdr.tsn,
|
|
752
|
+
)
|
|
753
|
+
)
|
|
754
|
+
elif hdr.command_id == zdo_t.ZDOCmd.Simple_Desc_req:
|
|
755
|
+
endpoint = coordinator.endpoints.get(args[1])
|
|
756
|
+
|
|
757
|
+
if endpoint is None or args[1] == 0:
|
|
758
|
+
return
|
|
759
|
+
|
|
760
|
+
descriptor = zdo_t.SizePrefixedSimpleDescriptor(
|
|
761
|
+
endpoint=endpoint.endpoint_id,
|
|
762
|
+
profile=endpoint.profile_id,
|
|
763
|
+
device_type=endpoint.device_type,
|
|
764
|
+
device_version=1,
|
|
765
|
+
input_clusters=list(endpoint.in_clusters),
|
|
766
|
+
output_clusters=list(endpoint.out_clusters),
|
|
767
|
+
)
|
|
768
|
+
device.zdo.create_catching_task(
|
|
769
|
+
device.zdo.Simple_Desc_rsp(
|
|
770
|
+
zdo_t.Status.SUCCESS,
|
|
771
|
+
nwk,
|
|
772
|
+
descriptor,
|
|
773
|
+
tsn=hdr.tsn,
|
|
774
|
+
)
|
|
775
|
+
)
|
|
776
|
+
|
|
777
|
+
def _handle_device_joined(
|
|
778
|
+
self, nwk: t.NWK, ieee: t.EUI64, parent_nwk: t.NWK
|
|
779
|
+
) -> None:
|
|
780
|
+
try:
|
|
781
|
+
self.get_device(ieee=ieee)
|
|
782
|
+
except KeyError:
|
|
783
|
+
pass
|
|
784
|
+
else:
|
|
785
|
+
# A known device rejoined, possibly with a new network address
|
|
786
|
+
self.handle_join(nwk=nwk, ieee=ieee, parent_nwk=parent_nwk)
|
|
787
|
+
return
|
|
788
|
+
|
|
789
|
+
# Give a new device a chance to announce itself before the join starts the
|
|
790
|
+
# interview: the announcement creates the device through `packet_received`
|
|
791
|
+
# and a later `handle_join` would cancel and restart the interview
|
|
792
|
+
def join_if_still_unannounced() -> None:
|
|
793
|
+
try:
|
|
794
|
+
self.get_device(ieee=ieee)
|
|
795
|
+
except KeyError:
|
|
796
|
+
self.handle_join(nwk=nwk, ieee=ieee, parent_nwk=parent_nwk)
|
|
797
|
+
|
|
798
|
+
asyncio.get_running_loop().call_later(
|
|
799
|
+
DEVICE_JOIN_MAX_DELAY, join_if_still_unannounced
|
|
800
|
+
)
|
|
801
|
+
|
|
802
|
+
def on_notification(self, notification: Notification) -> None:
|
|
803
|
+
match notification:
|
|
804
|
+
case ReceivedApsCommand():
|
|
805
|
+
self._handle_received_aps_command(notification)
|
|
806
|
+
case FrameCounterUpdate():
|
|
807
|
+
self.state.network_info.network_key.tx_counter = (
|
|
808
|
+
notification.frame_counter
|
|
809
|
+
)
|
|
810
|
+
_LOGGER.debug(
|
|
811
|
+
"Frame counter updated to %d",
|
|
812
|
+
self.state.network_info.network_key.tx_counter,
|
|
813
|
+
)
|
|
814
|
+
self.backups.add_backup(
|
|
815
|
+
zigpy.backups.NetworkBackup(
|
|
816
|
+
network_info=self.state.network_info,
|
|
817
|
+
node_info=self.state.node_info,
|
|
818
|
+
)
|
|
819
|
+
)
|
|
820
|
+
case DeviceJoined():
|
|
821
|
+
self._handle_device_joined(
|
|
822
|
+
notification.nwk, notification.ieee, notification.parent
|
|
823
|
+
)
|
|
824
|
+
case DeviceLeft():
|
|
825
|
+
if notification.ieee is not None:
|
|
826
|
+
ieee = notification.ieee
|
|
827
|
+
else:
|
|
828
|
+
try:
|
|
829
|
+
ieee = self.get_device(nwk=notification.nwk).ieee
|
|
830
|
+
except KeyError:
|
|
831
|
+
return
|
|
832
|
+
|
|
833
|
+
_LOGGER.debug(
|
|
834
|
+
"Device %s (%s) left the network: %s",
|
|
835
|
+
notification.nwk,
|
|
836
|
+
ieee,
|
|
837
|
+
notification.reason.value,
|
|
838
|
+
)
|
|
839
|
+
self.handle_leave(nwk=notification.nwk, ieee=ieee)
|
|
840
|
+
case LinkKeyUpdate():
|
|
841
|
+
key = zigpy.state.Key(
|
|
842
|
+
key=notification.key,
|
|
843
|
+
partner_ieee=notification.ieee,
|
|
844
|
+
)
|
|
845
|
+
_LOGGER.debug("Link key updated for %s", key.partner_ieee)
|
|
846
|
+
|
|
847
|
+
self.state.network_info.key_table = [
|
|
848
|
+
k
|
|
849
|
+
for k in self.state.network_info.key_table
|
|
850
|
+
if k.partner_ieee != key.partner_ieee
|
|
851
|
+
] + [key]
|
|
852
|
+
self.backups.add_backup(
|
|
853
|
+
zigpy.backups.NetworkBackup(
|
|
854
|
+
network_info=self.state.network_info,
|
|
855
|
+
node_info=self.state.node_info,
|
|
856
|
+
)
|
|
857
|
+
)
|
|
858
|
+
case ApsDecryptionFailure():
|
|
859
|
+
_LOGGER.warning(
|
|
860
|
+
"Could not decrypt an APS command from %s (%s): its trust center "
|
|
861
|
+
"link key is wrong or missing.",
|
|
862
|
+
notification.source_ieee,
|
|
863
|
+
notification.source,
|
|
864
|
+
)
|
|
865
|
+
|
|
866
|
+
def _handle_received_aps_command(self, command: ReceivedApsCommand) -> None:
|
|
867
|
+
if command.group is not None:
|
|
868
|
+
dst = t.AddrModeAddress(
|
|
869
|
+
addr_mode=t.AddrMode.Group,
|
|
870
|
+
address=t.Group(command.group),
|
|
871
|
+
)
|
|
872
|
+
elif command.destination >= 0xFFF8:
|
|
873
|
+
dst = t.AddrModeAddress(
|
|
874
|
+
addr_mode=t.AddrMode.Broadcast,
|
|
875
|
+
address=t.BroadcastAddress(command.destination),
|
|
876
|
+
)
|
|
877
|
+
else:
|
|
878
|
+
dst = t.AddrModeAddress(
|
|
879
|
+
addr_mode=t.AddrMode.NWK,
|
|
880
|
+
address=command.destination,
|
|
881
|
+
)
|
|
882
|
+
|
|
883
|
+
packet = t.ZigbeePacket(
|
|
884
|
+
src=t.AddrModeAddress(
|
|
885
|
+
addr_mode=t.AddrMode.NWK,
|
|
886
|
+
address=command.source,
|
|
887
|
+
),
|
|
888
|
+
dst=dst,
|
|
889
|
+
src_ep=command.src_ep,
|
|
890
|
+
dst_ep=command.dst_ep,
|
|
891
|
+
profile_id=command.profile_id,
|
|
892
|
+
cluster_id=command.cluster_id,
|
|
893
|
+
lqi=command.lqi,
|
|
894
|
+
rssi=command.rssi,
|
|
895
|
+
data=t.SerializableBytes(command.data),
|
|
896
|
+
)
|
|
897
|
+
self.packet_received(packet)
|
|
898
|
+
|
|
899
|
+
async def send_packet(self, packet: t.ZigbeePacket) -> None:
|
|
900
|
+
aps_encryption = t.TransmitOptions.APS_Encryption in packet.tx_options
|
|
901
|
+
|
|
902
|
+
dst = packet.dst
|
|
903
|
+
assert dst is not None and dst.address is not None
|
|
904
|
+
|
|
905
|
+
destination: t.NWK | None = None
|
|
906
|
+
destination_eui64: t.EUI64 | None = None
|
|
907
|
+
|
|
908
|
+
if dst.addr_mode == t.AddrMode.IEEE:
|
|
909
|
+
# The server resolves the EUI64 to a network address
|
|
910
|
+
destination_eui64 = cast(t.EUI64, dst.address)
|
|
911
|
+
delivery_mode = "unicast"
|
|
912
|
+
else:
|
|
913
|
+
destination = t.NWK(dst.address)
|
|
914
|
+
delivery_mode = {
|
|
915
|
+
t.AddrMode.NWK: "unicast",
|
|
916
|
+
t.AddrMode.Group: "multicast",
|
|
917
|
+
t.AddrMode.Broadcast: "broadcast",
|
|
918
|
+
}[dst.addr_mode]
|
|
919
|
+
|
|
920
|
+
if aps_encryption:
|
|
921
|
+
# The server selects the link key by EUI64
|
|
922
|
+
destination_eui64 = self.get_device(nwk=destination).ieee
|
|
923
|
+
|
|
924
|
+
# Resolves once the frame is on the air (EZSP `messageSent` parity); the
|
|
925
|
+
# APS-ack delivery result arrives later and is logged by the API layer
|
|
926
|
+
assert self._api is not None
|
|
927
|
+
await self._api.request_transmitted(
|
|
928
|
+
SendAps(
|
|
929
|
+
delivery_mode=delivery_mode,
|
|
930
|
+
destination_eui64=destination_eui64,
|
|
931
|
+
destination=destination,
|
|
932
|
+
profile_id=packet.profile_id,
|
|
933
|
+
cluster_id=packet.cluster_id or 0x0000,
|
|
934
|
+
src_ep=packet.src_ep or 0,
|
|
935
|
+
dst_ep=packet.dst_ep or 0,
|
|
936
|
+
aps_ack=t.TransmitOptions.ACK in packet.tx_options,
|
|
937
|
+
aps_encryption=aps_encryption,
|
|
938
|
+
radius=packet.radius or 30,
|
|
939
|
+
aps_seq=packet.tsn,
|
|
940
|
+
priority=packet.priority if packet.priority is not None else 0,
|
|
941
|
+
data=packet.data.serialize(),
|
|
942
|
+
)
|
|
943
|
+
)
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
"""Typed models for the ziggurat JSON-RPC wire protocol, mirroring the server's
|
|
2
|
+
serde types. Requests and responses share one set of wire formats; notifications
|
|
3
|
+
encode network addresses little-endian."""
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
import enum
|
|
7
|
+
from typing import ClassVar, Generic, TypeVar
|
|
8
|
+
|
|
9
|
+
from mashumaro import DataClassDictMixin
|
|
10
|
+
from mashumaro.config import BaseConfig
|
|
11
|
+
from mashumaro.types import SerializationStrategy
|
|
12
|
+
import zigpy.types as t
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BigEndianHexNwk(SerializationStrategy):
|
|
16
|
+
"""`1a2b`-style hex, the network address format of requests and responses."""
|
|
17
|
+
|
|
18
|
+
def serialize(self, value: t.NWK) -> str:
|
|
19
|
+
return f"{int(value):04x}"
|
|
20
|
+
|
|
21
|
+
def deserialize(self, value: str) -> t.NWK:
|
|
22
|
+
return t.NWK(int(value, 16))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class BigEndianHexPanId(SerializationStrategy):
|
|
26
|
+
def serialize(self, value: t.PanId) -> str:
|
|
27
|
+
return f"{int(value):04x}"
|
|
28
|
+
|
|
29
|
+
def deserialize(self, value: str) -> t.PanId:
|
|
30
|
+
return t.PanId(int(value, 16))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class LittleEndianHexNwk(SerializationStrategy):
|
|
34
|
+
"""`2b1a`-style hex, the network address format of notifications."""
|
|
35
|
+
|
|
36
|
+
def serialize(self, value: t.NWK) -> str:
|
|
37
|
+
return value.serialize().hex()
|
|
38
|
+
|
|
39
|
+
def deserialize(self, value: str) -> t.NWK:
|
|
40
|
+
return t.NWK.deserialize(bytes.fromhex(value))[0]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ColonHexEui64(SerializationStrategy):
|
|
44
|
+
def serialize(self, value: t.EUI64) -> str:
|
|
45
|
+
return str(value)
|
|
46
|
+
|
|
47
|
+
def deserialize(self, value: str) -> t.EUI64:
|
|
48
|
+
return t.EUI64.convert(value)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ColonHexExtendedPanId(SerializationStrategy):
|
|
52
|
+
def serialize(self, value: t.ExtendedPanId) -> str:
|
|
53
|
+
return str(value)
|
|
54
|
+
|
|
55
|
+
def deserialize(self, value: str) -> t.ExtendedPanId:
|
|
56
|
+
return t.ExtendedPanId(t.EUI64.convert(value))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ColonHexKey(SerializationStrategy):
|
|
60
|
+
def serialize(self, value: t.KeyData) -> str:
|
|
61
|
+
return str(value)
|
|
62
|
+
|
|
63
|
+
def deserialize(self, value: str) -> t.KeyData:
|
|
64
|
+
return t.KeyData.convert(value)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class HexBytes(SerializationStrategy):
|
|
68
|
+
def serialize(self, value: bytes) -> str:
|
|
69
|
+
return value.hex()
|
|
70
|
+
|
|
71
|
+
def deserialize(self, value: str) -> bytes:
|
|
72
|
+
return bytes.fromhex(value)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class SizedInt(SerializationStrategy):
|
|
76
|
+
"""Plain JSON integers, validated into zigpy's sized integer types."""
|
|
77
|
+
|
|
78
|
+
def __init__(self, int_type: type[int]) -> None:
|
|
79
|
+
self._int_type = int_type
|
|
80
|
+
|
|
81
|
+
def serialize(self, value: int) -> int:
|
|
82
|
+
return int(value)
|
|
83
|
+
|
|
84
|
+
def deserialize(self, value: int) -> int:
|
|
85
|
+
return self._int_type(value)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class _WireConfig(BaseConfig):
|
|
89
|
+
serialization_strategy = {
|
|
90
|
+
t.NWK: BigEndianHexNwk(),
|
|
91
|
+
t.PanId: BigEndianHexPanId(),
|
|
92
|
+
t.EUI64: ColonHexEui64(),
|
|
93
|
+
t.ExtendedPanId: ColonHexExtendedPanId(),
|
|
94
|
+
t.KeyData: ColonHexKey(),
|
|
95
|
+
bytes: HexBytes(),
|
|
96
|
+
t.uint8_t: SizedInt(t.uint8_t),
|
|
97
|
+
t.uint16_t: SizedInt(t.uint16_t),
|
|
98
|
+
t.uint32_t: SizedInt(t.uint32_t),
|
|
99
|
+
t.int8s: SizedInt(t.int8s),
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class _NotificationConfig(_WireConfig):
|
|
104
|
+
serialization_strategy = {
|
|
105
|
+
**_WireConfig.serialization_strategy,
|
|
106
|
+
t.NWK: LittleEndianHexNwk(),
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass
|
|
111
|
+
class WireModel(DataClassDictMixin):
|
|
112
|
+
class Config(_WireConfig): ...
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass
|
|
116
|
+
class Response(WireModel): ...
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass
|
|
120
|
+
class Status(Response):
|
|
121
|
+
status: str
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
RESPONSE_T = TypeVar("RESPONSE_T", bound=Response)
|
|
125
|
+
EVENT_T = TypeVar("EVENT_T", bound=Response)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass
|
|
129
|
+
class Request(WireModel, Generic[RESPONSE_T]):
|
|
130
|
+
method: ClassVar[str]
|
|
131
|
+
response_type: ClassVar[type[Response]]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass
|
|
135
|
+
class StreamingRequest(Request[RESPONSE_T], Generic[RESPONSE_T, EVENT_T]):
|
|
136
|
+
"""A request answered by a stream of `event_name` events (each an `event_type`)
|
|
137
|
+
before the terminal `response_type`."""
|
|
138
|
+
|
|
139
|
+
event_type: ClassVar[type[Response]]
|
|
140
|
+
event_name: ClassVar[str]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass
|
|
144
|
+
class KeyTableEntry(WireModel):
|
|
145
|
+
partner_ieee: t.EUI64
|
|
146
|
+
key: t.KeyData
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass
|
|
150
|
+
class Ping(Request[Status]):
|
|
151
|
+
method = "ping"
|
|
152
|
+
response_type = Status
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclass
|
|
156
|
+
class Configure(Request[Status]):
|
|
157
|
+
method = "configure"
|
|
158
|
+
response_type = Status
|
|
159
|
+
|
|
160
|
+
channel: int
|
|
161
|
+
nwk_update_id: int
|
|
162
|
+
pan_id: t.PanId
|
|
163
|
+
extended_pan_id: t.ExtendedPanId
|
|
164
|
+
nwk_address: t.NWK
|
|
165
|
+
ieee_address: t.EUI64
|
|
166
|
+
network_key: t.KeyData
|
|
167
|
+
network_key_seq: int
|
|
168
|
+
network_key_tx_counter: int
|
|
169
|
+
tc_link_key: t.KeyData
|
|
170
|
+
source_routing: bool
|
|
171
|
+
# None means "pick automatically": the server applies its safe default
|
|
172
|
+
tx_power: int | None
|
|
173
|
+
# Unique trust center link keys negotiated in earlier sessions
|
|
174
|
+
key_table: list[KeyTableEntry]
|
|
175
|
+
# A TCLK seed carried over from a microcontroller stack, passed verbatim as the
|
|
176
|
+
# source stack's plain hex string. Requires `tclk_flavor`.
|
|
177
|
+
tclk_seed: str | None
|
|
178
|
+
tclk_flavor: str | None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@dataclass
|
|
182
|
+
class NetworkInfo(Response):
|
|
183
|
+
channel: t.uint8_t
|
|
184
|
+
nwk_update_id: t.uint8_t
|
|
185
|
+
pan_id: t.PanId
|
|
186
|
+
extended_pan_id: t.ExtendedPanId
|
|
187
|
+
nwk_address: t.NWK
|
|
188
|
+
ieee_address: t.EUI64
|
|
189
|
+
network_key: t.KeyData
|
|
190
|
+
network_key_seq: t.uint8_t
|
|
191
|
+
network_key_tx_counter: t.uint32_t
|
|
192
|
+
tc_link_key: t.KeyData
|
|
193
|
+
tx_power: int
|
|
194
|
+
tclk_seed: str | None
|
|
195
|
+
tclk_flavor: str | None
|
|
196
|
+
key_table: list[KeyTableEntry]
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@dataclass
|
|
200
|
+
class GetNetworkInfo(Request[NetworkInfo]):
|
|
201
|
+
method = "get_network_info"
|
|
202
|
+
response_type = NetworkInfo
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@dataclass
|
|
206
|
+
class HwAddress(Response):
|
|
207
|
+
ieee_address: t.EUI64
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@dataclass
|
|
211
|
+
class GetHwAddress(Request[HwAddress]):
|
|
212
|
+
method = "get_hw_address"
|
|
213
|
+
response_type = HwAddress
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@dataclass
|
|
217
|
+
class SendAps(Request[Status]):
|
|
218
|
+
method = "send_aps"
|
|
219
|
+
response_type = Status
|
|
220
|
+
|
|
221
|
+
delivery_mode: str
|
|
222
|
+
# Resolved by the server through its address map; takes precedence over
|
|
223
|
+
# `destination` and selects the link key when `aps_encryption` is set
|
|
224
|
+
destination_eui64: t.EUI64 | None
|
|
225
|
+
destination: t.NWK | None
|
|
226
|
+
profile_id: int
|
|
227
|
+
cluster_id: int
|
|
228
|
+
src_ep: int
|
|
229
|
+
dst_ep: int
|
|
230
|
+
aps_ack: bool
|
|
231
|
+
aps_seq: int
|
|
232
|
+
radius: int
|
|
233
|
+
aps_encryption: bool
|
|
234
|
+
priority: int
|
|
235
|
+
data: bytes
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@dataclass
|
|
239
|
+
class EnergyScanResult(Response):
|
|
240
|
+
channel: t.uint8_t
|
|
241
|
+
rssi: t.int8s
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@dataclass
|
|
245
|
+
class EnergyScan(StreamingRequest[Status, EnergyScanResult]):
|
|
246
|
+
method = "energy_scan"
|
|
247
|
+
response_type = Status
|
|
248
|
+
event_type = EnergyScanResult
|
|
249
|
+
event_name = "energy_result"
|
|
250
|
+
|
|
251
|
+
channels: list[int]
|
|
252
|
+
duration_per_channel_ms: int
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@dataclass
|
|
256
|
+
class NetworkBeaconEvent(Response):
|
|
257
|
+
channel: t.uint8_t
|
|
258
|
+
# Absent when the beacon's MAC source was not a short address
|
|
259
|
+
source: t.NWK | None
|
|
260
|
+
pan_id: t.PanId
|
|
261
|
+
extended_pan_id: t.ExtendedPanId
|
|
262
|
+
permit_joining: bool
|
|
263
|
+
stack_profile: t.uint8_t
|
|
264
|
+
protocol_version: t.uint8_t
|
|
265
|
+
router_capacity: bool
|
|
266
|
+
end_device_capacity: bool
|
|
267
|
+
device_depth: t.uint8_t
|
|
268
|
+
update_id: t.uint8_t
|
|
269
|
+
lqi: t.uint8_t
|
|
270
|
+
rssi: t.int8s
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@dataclass
|
|
274
|
+
class NetworkScan(StreamingRequest[Status, NetworkBeaconEvent]):
|
|
275
|
+
method = "network_scan"
|
|
276
|
+
response_type = Status
|
|
277
|
+
event_type = NetworkBeaconEvent
|
|
278
|
+
event_name = "network_found"
|
|
279
|
+
|
|
280
|
+
channels: list[int]
|
|
281
|
+
duration_per_channel_ms: int
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@dataclass
|
|
285
|
+
class PermitJoins(Request[Status]):
|
|
286
|
+
method = "permit_joins"
|
|
287
|
+
response_type = Status
|
|
288
|
+
|
|
289
|
+
duration: int
|
|
290
|
+
# Whether the coordinator also opens its own beacon for direct joins. False opens
|
|
291
|
+
# only the trust center's authorization window, steering joins through routers.
|
|
292
|
+
accept_direct_joins: bool = True
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@dataclass
|
|
296
|
+
class SetProvisionalKey(Request[Status]):
|
|
297
|
+
method = "set_provisional_key"
|
|
298
|
+
response_type = Status
|
|
299
|
+
|
|
300
|
+
ieee: t.EUI64
|
|
301
|
+
key: t.KeyData
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
@dataclass
|
|
305
|
+
class SetChannel(Request[Status]):
|
|
306
|
+
method = "set_channel"
|
|
307
|
+
response_type = Status
|
|
308
|
+
|
|
309
|
+
channel: int
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
@dataclass
|
|
313
|
+
class SetNwkUpdateId(Request[Status]):
|
|
314
|
+
method = "set_nwk_update_id"
|
|
315
|
+
response_type = Status
|
|
316
|
+
|
|
317
|
+
nwk_update_id: int
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
@dataclass
|
|
321
|
+
class Notification(DataClassDictMixin):
|
|
322
|
+
class Config(_NotificationConfig): ...
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
@dataclass
|
|
326
|
+
class ReceivedApsCommand(Notification):
|
|
327
|
+
source: t.NWK
|
|
328
|
+
destination: t.NWK
|
|
329
|
+
group: int | None
|
|
330
|
+
profile_id: t.uint16_t
|
|
331
|
+
cluster_id: t.uint16_t
|
|
332
|
+
src_ep: t.uint8_t
|
|
333
|
+
dst_ep: t.uint8_t
|
|
334
|
+
lqi: t.uint8_t
|
|
335
|
+
rssi: t.int8s
|
|
336
|
+
data: bytes
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
@dataclass
|
|
340
|
+
class FrameCounterUpdate(Notification):
|
|
341
|
+
frame_counter: t.uint32_t
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
@dataclass
|
|
345
|
+
class LinkKeyUpdate(Notification):
|
|
346
|
+
ieee: t.EUI64
|
|
347
|
+
key: t.KeyData
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@dataclass
|
|
351
|
+
class DeviceJoined(Notification):
|
|
352
|
+
nwk: t.NWK
|
|
353
|
+
ieee: t.EUI64
|
|
354
|
+
parent: t.NWK
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
class DeviceLeaveReason(enum.StrEnum):
|
|
358
|
+
"""How the server learned that a device left the network."""
|
|
359
|
+
|
|
360
|
+
# The device itself broadcast a NWK Leave announcement (`rejoin` is set)
|
|
361
|
+
ANNOUNCED = "announced"
|
|
362
|
+
# A parent router relayed an APS Update-Device "Device Left" (`router`/
|
|
363
|
+
# `router_ieee` are set)
|
|
364
|
+
ROUTER_REPORTED = "router_reported"
|
|
365
|
+
# A sleepy child aged out of the neighbor table without a keepalive
|
|
366
|
+
KEEPALIVE_TIMEOUT = "keepalive_timeout"
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
@dataclass
|
|
370
|
+
class DeviceLeft(Notification):
|
|
371
|
+
nwk: t.NWK
|
|
372
|
+
# Unknown when the leaving device never made it into the server's address map
|
|
373
|
+
ieee: t.EUI64 | None
|
|
374
|
+
# How the server learned of the departure
|
|
375
|
+
reason: DeviceLeaveReason
|
|
376
|
+
# Set only for ANNOUNCED: whether the device intends to rejoin
|
|
377
|
+
rejoin: bool | None = None
|
|
378
|
+
# Set only for ROUTER_REPORTED: the router that relayed the leave. The EUI64 is
|
|
379
|
+
# unknown when the server could not resolve it from its address map.
|
|
380
|
+
router: t.NWK | None = None
|
|
381
|
+
router_ieee: t.EUI64 | None = None
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
@dataclass
|
|
385
|
+
class ApsDecryptionFailure(Notification):
|
|
386
|
+
# An APS command frame from this device could not be decrypted with any key the
|
|
387
|
+
# server holds. Its link key is almost certainly wrong or missing, which also
|
|
388
|
+
# blocks joins routed through it (the trust center can't read its Update-Device).
|
|
389
|
+
source: t.NWK
|
|
390
|
+
source_ieee: t.EUI64
|
|
391
|
+
frame_counter: t.uint32_t
|
|
392
|
+
key_id: str
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
NOTIFICATIONS: dict[str, type[Notification]] = {
|
|
396
|
+
"received_aps_command": ReceivedApsCommand,
|
|
397
|
+
"frame_counter_update": FrameCounterUpdate,
|
|
398
|
+
"link_key_update": LinkKeyUpdate,
|
|
399
|
+
"device_joined": DeviceJoined,
|
|
400
|
+
"device_left": DeviceLeft,
|
|
401
|
+
"aps_decryption_failure": ApsDecryptionFailure,
|
|
402
|
+
}
|
|
@@ -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,9 @@
|
|
|
1
|
+
zigpy_ziggurat/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
zigpy_ziggurat/zigbee/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
zigpy_ziggurat/zigbee/application.py,sha256=7QV6Uv2Yjl94bEf3wjt1Clxfk8zfIjCVlMMDL87gRvo,35820
|
|
4
|
+
zigpy_ziggurat/zigbee/commands.py,sha256=qhmp2A0yKRtvTZtiphb9lWABlRVeegCd9hmA-HNPZEs,10210
|
|
5
|
+
zigpy_ziggurat-1.0.0.dist-info/METADATA,sha256=mPHZBV3cT5U9qqqwQ6jKxJFwEknf9NAbdGUP4RI2jl8,499
|
|
6
|
+
zigpy_ziggurat-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
7
|
+
zigpy_ziggurat-1.0.0.dist-info/entry_points.txt,sha256=-GlZEuFNkbx01Fqv-OzqP7JyYnlH92wQ7E9Yr93cME4,81
|
|
8
|
+
zigpy_ziggurat-1.0.0.dist-info/top_level.txt,sha256=EDtYCzEMgZYFjNZs_s_UilDMPBQnMRfDH8jsocx_3iI,15
|
|
9
|
+
zigpy_ziggurat-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
zigpy_ziggurat
|