zigpy-ziggurat 1.2.0__tar.gz → 1.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. {zigpy_ziggurat-1.2.0 → zigpy_ziggurat-1.2.2}/PKG-INFO +2 -2
  2. {zigpy_ziggurat-1.2.0 → zigpy_ziggurat-1.2.2}/pyproject.toml +1 -1
  3. {zigpy_ziggurat-1.2.0 → zigpy_ziggurat-1.2.2}/tests/test_api.py +25 -0
  4. {zigpy_ziggurat-1.2.0 → zigpy_ziggurat-1.2.2}/tests/test_application.py +73 -2
  5. {zigpy_ziggurat-1.2.0 → zigpy_ziggurat-1.2.2}/tests/test_transport.py +4 -4
  6. {zigpy_ziggurat-1.2.0 → zigpy_ziggurat-1.2.2}/zigpy_ziggurat/zigbee/api.py +8 -1
  7. {zigpy_ziggurat-1.2.0 → zigpy_ziggurat-1.2.2}/zigpy_ziggurat/zigbee/application.py +47 -11
  8. {zigpy_ziggurat-1.2.0 → zigpy_ziggurat-1.2.2}/zigpy_ziggurat/zigbee/transport.py +10 -25
  9. {zigpy_ziggurat-1.2.0 → zigpy_ziggurat-1.2.2}/zigpy_ziggurat.egg-info/PKG-INFO +2 -2
  10. {zigpy_ziggurat-1.2.0 → zigpy_ziggurat-1.2.2}/zigpy_ziggurat.egg-info/requires.txt +1 -1
  11. {zigpy_ziggurat-1.2.0 → zigpy_ziggurat-1.2.2}/README.md +0 -0
  12. {zigpy_ziggurat-1.2.0 → zigpy_ziggurat-1.2.2}/setup.cfg +0 -0
  13. {zigpy_ziggurat-1.2.0 → zigpy_ziggurat-1.2.2}/tests/test_protocol.py +0 -0
  14. {zigpy_ziggurat-1.2.0 → zigpy_ziggurat-1.2.2}/zigpy_ziggurat/__init__.py +0 -0
  15. {zigpy_ziggurat-1.2.0 → zigpy_ziggurat-1.2.2}/zigpy_ziggurat/config.py +0 -0
  16. {zigpy_ziggurat-1.2.0 → zigpy_ziggurat-1.2.2}/zigpy_ziggurat/zigbee/__init__.py +0 -0
  17. {zigpy_ziggurat-1.2.0 → zigpy_ziggurat-1.2.2}/zigpy_ziggurat/zigbee/protocol.py +0 -0
  18. {zigpy_ziggurat-1.2.0 → zigpy_ziggurat-1.2.2}/zigpy_ziggurat/zigbee/wire.py +0 -0
  19. {zigpy_ziggurat-1.2.0 → zigpy_ziggurat-1.2.2}/zigpy_ziggurat.egg-info/SOURCES.txt +0 -0
  20. {zigpy_ziggurat-1.2.0 → zigpy_ziggurat-1.2.2}/zigpy_ziggurat.egg-info/dependency_links.txt +0 -0
  21. {zigpy_ziggurat-1.2.0 → zigpy_ziggurat-1.2.2}/zigpy_ziggurat.egg-info/entry_points.txt +0 -0
  22. {zigpy_ziggurat-1.2.0 → zigpy_ziggurat-1.2.2}/zigpy_ziggurat.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zigpy-ziggurat
3
- Version: 1.2.0
3
+ Version: 1.2.2
4
4
  Summary: Zigpy radio library for the Ziggurat server
5
5
  Author-email: puddly <puddly3@gmail.com>
6
6
  License: Apache-2.0
@@ -9,7 +9,7 @@ Requires-Python: >=3.12
9
9
  Description-Content-Type: text/markdown
10
10
  Requires-Dist: zigpy>=2.4.0
11
11
  Requires-Dist: aiohttp
12
- Requires-Dist: aiospinel>=1.2.0
12
+ Requires-Dist: aiospinel>=1.3.0
13
13
 
14
14
  # zigpy-ziggurat
15
15
 
@@ -16,7 +16,7 @@ requires-python = ">=3.12"
16
16
  dependencies = [
17
17
  "zigpy>=2.4.0",
18
18
  "aiohttp",
19
- "aiospinel>=1.2.0",
19
+ "aiospinel>=1.3.0",
20
20
  ]
21
21
 
22
22
  [project.entry-points."zigpy.radio"]
@@ -488,12 +488,37 @@ async def test_connection_lost_fails_pending_requests(
488
488
  assert api.disconnects == [None]
489
489
 
490
490
 
491
+ async def test_startup_hello_ignored(
492
+ api: RecordingApi, transport: SyntheticBinaryTransport
493
+ ) -> None:
494
+ async def start_then_reply(request: p.Request, request_id: int) -> None:
495
+ transport.notify(
496
+ p.NotificationCommand.HELLO,
497
+ 0,
498
+ p.Hello(
499
+ protocol_version=t.uint8_t(p.PROTOCOL_VERSION),
500
+ configured=t.Bool(False),
501
+ ),
502
+ )
503
+ transport.ok(request.command, request_id)
504
+
505
+ # Embedded firmware starts its stack on the first host frame and announces it
506
+ # with a `hello` before the reply: that opens the session, it is not a reboot
507
+ transport.handlers[p.RequestCommand.RESET] = start_then_reply
508
+ assert await api.request(p.Reset(hard=t.Bool(False))) is None
509
+
510
+ assert api.disconnects == []
511
+ assert api.notifications == []
512
+
513
+
491
514
  async def test_hello_reported_as_disconnect(
492
515
  api: RecordingApi, transport: SyntheticBinaryTransport
493
516
  ) -> None:
494
517
  async def withhold(request: p.Request, request_id: int) -> None:
495
518
  return None
496
519
 
520
+ assert await api.request(p.Shutdown()) is None
521
+
497
522
  transport.handlers[p.RequestCommand.SHUTDOWN] = withhold
498
523
  request = asyncio.ensure_future(api.request(p.Shutdown()))
499
524
  await asyncio.sleep(0)
@@ -471,6 +471,59 @@ async def test_write_network_info_restores_children(
471
471
  assert [entry.nwk for entry in entries] == list(nwk_addresses.values())
472
472
  # The backup carries no capability, so the device type is unknown
473
473
  assert {entry.device_type for entry in entries} == {p.ChildDeviceType.UNKNOWN}
474
+ assert {entry.rx_on_when_idle for entry in entries} == {0}
475
+
476
+
477
+ async def test_write_network_info_restores_children_capability(
478
+ connected_app: ControllerApplication, server: SyntheticZiggurat
479
+ ) -> None:
480
+ """A child's capability comes from its node descriptor, so an end device that
481
+ keeps its receiver on is not restored as sleepy."""
482
+ app = connected_app
483
+ await app.load_network_info()
484
+
485
+ def child(
486
+ n: int, mac_capability_flags: int | None, logical_type: zdo_t.LogicalType | None
487
+ ) -> t.EUI64:
488
+ ieee = t.EUI64([n] * 8)
489
+ device = app.add_device(ieee, t.NWK(0x1000 + n))
490
+ if mac_capability_flags is not None:
491
+ device.node_desc = zdo_t.NodeDescriptor(
492
+ logical_type=logical_type,
493
+ mac_capability_flags=zdo_t.NodeDescriptor.MACCapabilityFlags(
494
+ mac_capability_flags
495
+ ),
496
+ )
497
+ return ieee
498
+
499
+ children = [
500
+ # A Sengled bulb: an end device with its receiver on
501
+ child(1, 0x8C, zdo_t.LogicalType.EndDevice),
502
+ # A battery sensor
503
+ child(2, 0x80, zdo_t.LogicalType.EndDevice),
504
+ child(3, 0x8E, zdo_t.LogicalType.Router),
505
+ # Not interviewed
506
+ child(4, None, None),
507
+ # A node descriptor with no logical type
508
+ child(5, 0x80, None),
509
+ ]
510
+
511
+ await app.write_network_info(
512
+ network_info=app.state.network_info.replace(
513
+ children=children,
514
+ nwk_addresses={ieee: app.get_device(ieee=ieee).nwk for ieee in children},
515
+ ),
516
+ node_info=app.state.node_info,
517
+ )
518
+
519
+ (load,) = server.sent(p.LoadChildren)
520
+ assert [(entry.device_type, entry.rx_on_when_idle) for entry in load.entries] == [
521
+ (p.ChildDeviceType.END_DEVICE, 1),
522
+ (p.ChildDeviceType.END_DEVICE, 0),
523
+ (p.ChildDeviceType.ROUTER, 1),
524
+ (p.ChildDeviceType.UNKNOWN, 0),
525
+ (p.ChildDeviceType.UNKNOWN, 0),
526
+ ]
474
527
 
475
528
 
476
529
  async def test_permit_ncp(
@@ -745,6 +798,19 @@ async def test_send_packet_encrypted_without_eui64(
745
798
  assert server.sent(p.SendUnicast) == []
746
799
 
747
800
 
801
+ async def test_send_packet_disconnected(
802
+ app: ControllerApplication, server: SyntheticZiggurat
803
+ ) -> None:
804
+ await app.disconnect()
805
+
806
+ with pytest.raises(DeliveryError, match="Not connected"):
807
+ await app.send_packet(
808
+ aps_packet(t.AddrModeAddress(addr_mode=t.AddrMode.NWK, address=DEVICE_NWK))
809
+ )
810
+
811
+ assert server.sent(p.SendUnicast) == []
812
+
813
+
748
814
  async def test_send_packet_broadcast(
749
815
  app: ControllerApplication, server: SyntheticZiggurat
750
816
  ) -> None:
@@ -1127,12 +1193,13 @@ async def test_on_notification_received_aps_zdo_reply_encryption(
1127
1193
 
1128
1194
 
1129
1195
  @pytest.mark.parametrize(
1130
- ("destination", "group", "expected_dst"),
1196
+ ("destination", "group", "expected_dst", "expected_dst_ep"),
1131
1197
  [
1132
1198
  (
1133
1199
  t.NWK(0x0000),
1134
1200
  2,
1135
1201
  t.AddrModeAddress(addr_mode=t.AddrMode.Group, address=t.Group(2)),
1202
+ None,
1136
1203
  ),
1137
1204
  (
1138
1205
  t.NWK(0xFFFD),
@@ -1140,11 +1207,13 @@ async def test_on_notification_received_aps_zdo_reply_encryption(
1140
1207
  t.AddrModeAddress(
1141
1208
  addr_mode=t.AddrMode.Broadcast, address=t.BroadcastAddress(0xFFFD)
1142
1209
  ),
1210
+ 1,
1143
1211
  ),
1144
1212
  (
1145
1213
  t.NWK(0x0000),
1146
1214
  None,
1147
1215
  t.AddrModeAddress(addr_mode=t.AddrMode.NWK, address=t.NWK(0x0000)),
1216
+ 1,
1148
1217
  ),
1149
1218
  ],
1150
1219
  )
@@ -1153,9 +1222,10 @@ async def test_on_notification_received_aps_address_modes(
1153
1222
  destination: t.NWK,
1154
1223
  group: int | None,
1155
1224
  expected_dst: t.AddrModeAddress,
1225
+ expected_dst_ep: int | None,
1156
1226
  ) -> None:
1157
1227
  """A group id, a broadcast address and a plain network address each decode into
1158
- their own zigpy address mode."""
1228
+ their own zigpy address mode. A group frame has no destination endpoint."""
1159
1229
  app = RecordingApplication(make_app_config(server.url))
1160
1230
  await app.connect()
1161
1231
  await app.start_network()
@@ -1184,6 +1254,7 @@ async def test_on_notification_received_aps_address_modes(
1184
1254
  assert len(app.packets) == 1
1185
1255
  packet = app.packets[0]
1186
1256
  assert packet.dst == expected_dst
1257
+ assert packet.dst_ep == expected_dst_ep
1187
1258
  assert packet.src == t.AddrModeAddress(addr_mode=t.AddrMode.NWK, address=DEVICE_NWK)
1188
1259
  assert packet.lqi == 200
1189
1260
  assert packet.rssi == -70
@@ -3,7 +3,7 @@ the transports it returns."""
3
3
 
4
4
  import asyncio
5
5
 
6
- import aiospinel
6
+ from aiospinel import PropertyStatusError, Status
7
7
  import pytest
8
8
 
9
9
  from tests.common import (
@@ -97,7 +97,7 @@ async def test_spinel_stream_frame_handler_error(
97
97
 
98
98
 
99
99
  async def test_spinel_connect_rejects_foreign_firmware() -> None:
100
- rcp = SyntheticSpinelRcp(get_prop_id=aiospinel.PackedUInt21(0x0001))
100
+ rcp = SyntheticSpinelRcp(get_status=Status.PROP_NOT_FOUND)
101
101
  await rcp.start()
102
102
  try:
103
103
  with pytest.raises(ConnectionError, match="does not embed"):
@@ -107,11 +107,11 @@ async def test_spinel_connect_rejects_foreign_firmware() -> None:
107
107
 
108
108
 
109
109
  async def test_spinel_tunnel_write_rejected() -> None:
110
- rcp = SyntheticSpinelRcp(set_prop_id=aiospinel.PackedUInt21(0x0001))
110
+ rcp = SyntheticSpinelRcp(set_status=Status.INVALID_ARGUMENT)
111
111
  await rcp.start()
112
112
  transport = await connect_transport(rcp.url, lambda frame: None, lambda exc: None)
113
113
  try:
114
- with pytest.raises(ConnectionError, match="Tunnel write rejected"):
114
+ with pytest.raises(PropertyStatusError):
115
115
  await transport.send_frame(b"\x01")
116
116
  finally:
117
117
  await transport.disconnect()
@@ -56,6 +56,7 @@ class ZigguratApi:
56
56
  ] = {}
57
57
  self._awaiting_aps_ack: set[int] = set()
58
58
  self._transport: Transport | None = None
59
+ self._received_frame = False
59
60
 
60
61
  async def connect(self) -> None:
61
62
  self._transport = await connect_transport(
@@ -201,6 +202,8 @@ class ZigguratApi:
201
202
  _LOGGER.debug("Received notification (id=%d): %r", request_id, notification)
202
203
  self._handle_notification(request_id, notification)
203
204
 
205
+ self._received_frame = True
206
+
204
207
  def _handle_response(self, request_id: int, body: bytes) -> None:
205
208
  pending = self._pending.get(request_id)
206
209
  if pending is None or pending.response.done():
@@ -261,7 +264,11 @@ class ZigguratApi:
261
264
  if confirm is not None and not confirm.done():
262
265
  confirm.set_result(notification)
263
266
  elif isinstance(notification, p.Hello):
264
- # The firmware only sends `hello` when it reboots
267
+ # Embedded firmware starts its stack on the first frame the host sends, and
268
+ # the stack announces itself before it answers. So a `hello` that opens the
269
+ # session is that start. Any later `hello` means the firmware rebooted.
270
+ if not self._received_frame:
271
+ return
265
272
  self._connection_lost(ConnectionError("Ziggurat firmware reset"))
266
273
  elif isinstance(notification, p.LastReset):
267
274
  logging.getLogger("ziggurat.fw").warning(
@@ -541,16 +541,9 @@ class ControllerApplication(zigpy.application.ControllerApplication):
541
541
 
542
542
  async def _restore_children(self, network_info: zigpy.state.NetworkInfo) -> None:
543
543
  assert self._api is not None
544
- # The backup carries no capability, so device type is Unknown (restored as a
545
- # sleepy end device); children without a known NWK address can't be loaded.
544
+ # Children without a known NWK address can't be loaded
546
545
  entries = [
547
- p.ChildEntry(
548
- ieee=ieee,
549
- nwk=network_info.nwk_addresses[ieee],
550
- rx_on_when_idle=t.uint1_t(1),
551
- device_type=p.ChildDeviceType.UNKNOWN,
552
- reserved=t.uint5_t(0),
553
- )
546
+ self._child_entry(ieee, network_info.nwk_addresses[ieee])
554
547
  for ieee in network_info.children
555
548
  if ieee in network_info.nwk_addresses
556
549
  ]
@@ -563,6 +556,41 @@ class ControllerApplication(zigpy.application.ControllerApplication):
563
556
  )
564
557
  )
565
558
 
559
+ def _child_entry(self, ieee: t.EUI64, nwk: t.NWK) -> p.ChildEntry:
560
+ # The backup carries no capability, so it comes from the child's node
561
+ # descriptor. Some end devices keep their receiver on and never poll (Sengled
562
+ # bulbs), so they must not be restored as sleepy. Without a node descriptor
563
+ # the child is restored as a sleepy end device, as most end devices are.
564
+ try:
565
+ device = self.get_device(ieee=ieee)
566
+ except KeyError:
567
+ node_desc = None
568
+ else:
569
+ # A child joined the network, so it is never a GPD
570
+ assert isinstance(device, zigpy.device.ZigbeeDevice)
571
+ node_desc = device.node_desc
572
+
573
+ if node_desc is None:
574
+ device_type = p.ChildDeviceType.UNKNOWN
575
+ rx_on_when_idle = False
576
+ else:
577
+ if node_desc.is_router:
578
+ device_type = p.ChildDeviceType.ROUTER
579
+ elif node_desc.is_end_device:
580
+ device_type = p.ChildDeviceType.END_DEVICE
581
+ else:
582
+ device_type = p.ChildDeviceType.UNKNOWN
583
+
584
+ rx_on_when_idle = bool(node_desc.is_receiver_on_when_idle)
585
+
586
+ return p.ChildEntry(
587
+ ieee=ieee,
588
+ nwk=nwk,
589
+ rx_on_when_idle=t.uint1_t(rx_on_when_idle),
590
+ device_type=device_type,
591
+ reserved=t.uint5_t(0),
592
+ )
593
+
566
594
  async def reset_network_info(self) -> None:
567
595
  assert self._api is not None
568
596
  await self._api.request(p.Shutdown())
@@ -778,11 +806,16 @@ class ControllerApplication(zigpy.application.ControllerApplication):
778
806
 
779
807
  def _handle_received_aps_command(self, command: p.ReceivedAps) -> None:
780
808
  group = command.group_id
809
+ dst_ep: t.uint8_t | None = command.dst_ep
810
+
781
811
  if group is not None:
782
812
  dst = t.AddrModeAddress(
783
813
  addr_mode=t.AddrMode.Group,
784
814
  address=t.Group(group),
785
815
  )
816
+ # A group frame carries no destination endpoint, so `dst_ep` is filler.
817
+ # Passing it on would make zigpy treat a filler of 0 as ZDO.
818
+ dst_ep = None
786
819
  elif command.destination >= 0xFFF8:
787
820
  dst = t.AddrModeAddress(
788
821
  addr_mode=t.AddrMode.Broadcast,
@@ -801,7 +834,7 @@ class ControllerApplication(zigpy.application.ControllerApplication):
801
834
  ),
802
835
  dst=dst,
803
836
  src_ep=command.src_ep,
804
- dst_ep=command.dst_ep,
837
+ dst_ep=dst_ep,
805
838
  profile_id=command.profile_id,
806
839
  cluster_id=command.cluster_id,
807
840
  lqi=command.lqi,
@@ -865,7 +898,6 @@ class ControllerApplication(zigpy.application.ControllerApplication):
865
898
  # Resolves once the send is confirmed: passive-ack quorum for a broadcast or
866
899
  # groupcast, next-hop acceptance for a no-ack unicast, or the end-to-end APS
867
900
  # ack. A rejected or failed send raises `DeliveryError`.
868
- assert self._api is not None
869
901
  priority = packet.priority if packet.priority is not None else 0
870
902
  radius = packet.radius or 30
871
903
  asdu = packet.data.serialize()
@@ -942,4 +974,8 @@ class ControllerApplication(zigpy.application.ControllerApplication):
942
974
  asdu=asdu,
943
975
  )
944
976
 
977
+ # The connection can close while the send waits for a concurrency slot
978
+ if self._api is None:
979
+ raise DeliveryError("Not connected to Ziggurat")
980
+
945
981
  await self._api.request_confirmed(send)
@@ -8,14 +8,14 @@ import logging
8
8
  from typing import Any, Protocol, cast
9
9
 
10
10
  import aiohttp
11
- import aiospinel
11
+ from aiospinel import PropertyStatusError, SpinelProtocol
12
12
  import zigpy.serial
13
13
 
14
14
  _LOGGER = logging.getLogger(__name__)
15
15
 
16
16
  WEBSOCKET_HEARTBEAT = 15
17
17
 
18
- PROP_VENDOR_ZIGGURAT = aiospinel.PackedUInt21(0x3D5A)
18
+ PROP_VENDOR_ZIGGURAT = 0x3D5A
19
19
 
20
20
  # The callback the API installs to receive a device -> host binary frame.
21
21
  OnFrame = Callable[[bytes], None]
@@ -56,7 +56,7 @@ async def connect_transport(
56
56
  # -- serial (Spinel tunnel) ------------------------------------------------------
57
57
 
58
58
 
59
- class _SpinelProtocol(aiospinel.SpinelProtocol):
59
+ class _SpinelProtocol(SpinelProtocol):
60
60
  """Tunnels binary frames over the vendor Spinel stream property."""
61
61
 
62
62
  def __init__(self, on_frame: OnFrame, on_lost: OnLost) -> None:
@@ -80,32 +80,18 @@ class _SpinelProtocol(aiospinel.SpinelProtocol):
80
80
  _LOGGER.exception("Failed to handle frame: %r", data)
81
81
 
82
82
  async def start_ziggurat(self) -> None:
83
- rsp = await self.send_command(
84
- aiospinel.CommandID.PROP_VALUE_GET,
85
- PROP_VENDOR_ZIGGURAT.serialize(),
86
- )
87
- prop_id, _ = aiospinel.PackedUInt21.deserialize(rsp.data)
88
- if prop_id != PROP_VENDOR_ZIGGURAT:
89
- raise ConnectionError(
90
- f"Firmware does not embed the Ziggurat stack: {rsp!r}"
91
- )
83
+ try:
84
+ await self.get_property(PROP_VENDOR_ZIGGURAT)
85
+ except PropertyStatusError as exc:
86
+ raise ConnectionError("Firmware does not embed the Ziggurat stack") from exc
92
87
  _LOGGER.debug("Embedded Ziggurat firmware detected")
93
88
 
94
89
  async def tunnel_send(self, frame: bytes) -> None:
95
90
  # No retries: a timed-out tunnel write must not resend the request (the first
96
91
  # copy may already have been processed).
97
- rsp = await self.send_command(
98
- aiospinel.CommandID.PROP_VALUE_SET,
99
- (
100
- PROP_VENDOR_ZIGGURAT.serialize()
101
- + len(frame).to_bytes(2, "little")
102
- + frame
103
- ),
104
- retries=0,
92
+ await self.set_property(
93
+ PROP_VENDOR_ZIGGURAT, len(frame).to_bytes(2, "little") + frame, retries=0
105
94
  )
106
- prop_id, _ = aiospinel.PackedUInt21.deserialize(rsp.data)
107
- if prop_id != PROP_VENDOR_ZIGGURAT:
108
- raise ConnectionError(f"Tunnel write rejected: {rsp!r}")
109
95
 
110
96
 
111
97
  class SpinelTransport:
@@ -141,8 +127,7 @@ class SpinelTransport:
141
127
 
142
128
  async def disconnect(self) -> None:
143
129
  if self._protocol is not None:
144
- self._protocol.close()
145
- await self._protocol.wait_until_closed()
130
+ await self._protocol.disconnect()
146
131
  self._protocol = None
147
132
 
148
133
  async def send_frame(self, frame: bytes) -> None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zigpy-ziggurat
3
- Version: 1.2.0
3
+ Version: 1.2.2
4
4
  Summary: Zigpy radio library for the Ziggurat server
5
5
  Author-email: puddly <puddly3@gmail.com>
6
6
  License: Apache-2.0
@@ -9,7 +9,7 @@ Requires-Python: >=3.12
9
9
  Description-Content-Type: text/markdown
10
10
  Requires-Dist: zigpy>=2.4.0
11
11
  Requires-Dist: aiohttp
12
- Requires-Dist: aiospinel>=1.2.0
12
+ Requires-Dist: aiospinel>=1.3.0
13
13
 
14
14
  # zigpy-ziggurat
15
15
 
@@ -1,3 +1,3 @@
1
1
  zigpy>=2.4.0
2
2
  aiohttp
3
- aiospinel>=1.2.0
3
+ aiospinel>=1.3.0
File without changes
File without changes