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.
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [zigpy.radio]
2
+ ziggurat = zigpy_ziggurat.zigbee.application:ControllerApplication
@@ -0,0 +1 @@
1
+ zigpy_ziggurat