zigpy 1.4.1__tar.gz → 1.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. {zigpy-1.4.1/zigpy.egg-info → zigpy-1.5.0}/PKG-INFO +1 -1
  2. {zigpy-1.4.1 → zigpy-1.5.0}/tests/test_application.py +2 -52
  3. {zigpy-1.4.1 → zigpy-1.5.0}/tests/test_device.py +257 -9
  4. {zigpy-1.4.1 → zigpy-1.5.0}/tests/test_endpoint.py +6 -0
  5. {zigpy-1.4.1 → zigpy-1.5.0}/tests/test_topology.py +2 -2
  6. {zigpy-1.4.1 → zigpy-1.5.0}/tests/test_zcl.py +198 -0
  7. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/application.py +20 -37
  8. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/config/__init__.py +2 -13
  9. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/config/defaults.py +0 -7
  10. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/device.py +76 -41
  11. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/endpoint.py +8 -0
  12. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/ota/__init__.py +0 -10
  13. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/ota/json_schemas.py +0 -61
  14. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/ota/providers.py +0 -63
  15. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/topology.py +1 -4
  16. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/zcl/__init__.py +208 -151
  17. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/zdo/__init__.py +8 -0
  18. {zigpy-1.4.1 → zigpy-1.5.0/zigpy.egg-info}/PKG-INFO +1 -1
  19. {zigpy-1.4.1 → zigpy-1.5.0}/COPYING +0 -0
  20. {zigpy-1.4.1 → zigpy-1.5.0}/LICENSE +0 -0
  21. {zigpy-1.4.1 → zigpy-1.5.0}/README.md +0 -0
  22. {zigpy-1.4.1 → zigpy-1.5.0}/pyproject.toml +0 -0
  23. {zigpy-1.4.1 → zigpy-1.5.0}/setup.cfg +0 -0
  24. {zigpy-1.4.1 → zigpy-1.5.0}/setup.py +0 -0
  25. {zigpy-1.4.1 → zigpy-1.5.0}/tests/test_app_state.py +0 -0
  26. {zigpy-1.4.1 → zigpy-1.5.0}/tests/test_appdb.py +0 -0
  27. {zigpy-1.4.1 → zigpy-1.5.0}/tests/test_appdb_migration.py +0 -0
  28. {zigpy-1.4.1 → zigpy-1.5.0}/tests/test_backups.py +0 -0
  29. {zigpy-1.4.1 → zigpy-1.5.0}/tests/test_config.py +0 -0
  30. {zigpy-1.4.1 → zigpy-1.5.0}/tests/test_datastructures.py +0 -0
  31. {zigpy-1.4.1 → zigpy-1.5.0}/tests/test_datastructures_cpython.py +0 -0
  32. {zigpy-1.4.1 → zigpy-1.5.0}/tests/test_event.py +0 -0
  33. {zigpy-1.4.1 → zigpy-1.5.0}/tests/test_group.py +0 -0
  34. {zigpy-1.4.1 → zigpy-1.5.0}/tests/test_listeners.py +0 -0
  35. {zigpy-1.4.1 → zigpy-1.5.0}/tests/test_packet_callbacks.py +0 -0
  36. {zigpy-1.4.1 → zigpy-1.5.0}/tests/test_quirks.py +0 -0
  37. {zigpy-1.4.1 → zigpy-1.5.0}/tests/test_quirks_registry.py +0 -0
  38. {zigpy-1.4.1 → zigpy-1.5.0}/tests/test_quirks_v2.py +0 -0
  39. {zigpy-1.4.1 → zigpy-1.5.0}/tests/test_serial.py +0 -0
  40. {zigpy-1.4.1 → zigpy-1.5.0}/tests/test_struct.py +0 -0
  41. {zigpy-1.4.1 → zigpy-1.5.0}/tests/test_types.py +0 -0
  42. {zigpy-1.4.1 → zigpy-1.5.0}/tests/test_zcl_clusters.py +0 -0
  43. {zigpy-1.4.1 → zigpy-1.5.0}/tests/test_zcl_foundation.py +0 -0
  44. {zigpy-1.4.1 → zigpy-1.5.0}/tests/test_zcl_helpers.py +0 -0
  45. {zigpy-1.4.1 → zigpy-1.5.0}/tests/test_zdo.py +0 -0
  46. {zigpy-1.4.1 → zigpy-1.5.0}/tests/test_zdo_types.py +0 -0
  47. {zigpy-1.4.1 → zigpy-1.5.0}/tests/test_zigbee_util.py +0 -0
  48. {zigpy-1.4.1 → zigpy-1.5.0}/tools/__init__.py +0 -0
  49. {zigpy-1.4.1 → zigpy-1.5.0}/tools/regenerate_mypy_ignores.py +0 -0
  50. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/__init__.py +0 -0
  51. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/appdb.py +0 -0
  52. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/appdb_schemas/__init__.py +0 -0
  53. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/appdb_schemas/schema_v0.sql +0 -0
  54. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/appdb_schemas/schema_v1.sql +0 -0
  55. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/appdb_schemas/schema_v10.sql +0 -0
  56. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/appdb_schemas/schema_v11.sql +0 -0
  57. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/appdb_schemas/schema_v12.sql +0 -0
  58. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/appdb_schemas/schema_v13.sql +0 -0
  59. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/appdb_schemas/schema_v14.sql +0 -0
  60. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/appdb_schemas/schema_v15.sql +0 -0
  61. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/appdb_schemas/schema_v2.sql +0 -0
  62. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/appdb_schemas/schema_v3.sql +0 -0
  63. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/appdb_schemas/schema_v4.sql +0 -0
  64. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/appdb_schemas/schema_v5.sql +0 -0
  65. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/appdb_schemas/schema_v6.sql +0 -0
  66. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/appdb_schemas/schema_v7.sql +0 -0
  67. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/appdb_schemas/schema_v8.sql +0 -0
  68. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/appdb_schemas/schema_v9.sql +0 -0
  69. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/backports/__init__.py +0 -0
  70. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/backups.py +0 -0
  71. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/config/validators.py +0 -0
  72. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/const.py +0 -0
  73. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/datastructures.py +0 -0
  74. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/event/__init__.py +0 -0
  75. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/event/event_base.py +0 -0
  76. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/exceptions.py +0 -0
  77. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/group.py +0 -0
  78. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/listeners.py +0 -0
  79. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/ota/image.py +0 -0
  80. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/ota/manager.py +0 -0
  81. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/ota/validators.py +0 -0
  82. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/profiles/__init__.py +0 -0
  83. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/profiles/zgp.py +0 -0
  84. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/profiles/zha.py +0 -0
  85. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/profiles/zll.py +0 -0
  86. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/py.typed +0 -0
  87. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/quirks/__init__.py +0 -0
  88. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/quirks/registry.py +0 -0
  89. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/quirks/v2/__init__.py +0 -0
  90. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/quirks/v2/homeassistant/__init__.py +0 -0
  91. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/quirks/v2/homeassistant/binary_sensor.py +0 -0
  92. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/quirks/v2/homeassistant/number.py +0 -0
  93. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/quirks/v2/homeassistant/sensor.py +0 -0
  94. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/serial.py +0 -0
  95. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/state.py +0 -0
  96. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/types/__init__.py +0 -0
  97. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/types/basic.py +0 -0
  98. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/types/named.py +0 -0
  99. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/types/struct.py +0 -0
  100. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/typing.py +0 -0
  101. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/util.py +0 -0
  102. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/zcl/clusters/__init__.py +0 -0
  103. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/zcl/clusters/closures.py +0 -0
  104. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/zcl/clusters/general.py +0 -0
  105. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/zcl/clusters/general_const.py +0 -0
  106. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/zcl/clusters/greenpower.py +0 -0
  107. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/zcl/clusters/homeautomation.py +0 -0
  108. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/zcl/clusters/hvac.py +0 -0
  109. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/zcl/clusters/lighting.py +0 -0
  110. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/zcl/clusters/lightlink.py +0 -0
  111. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/zcl/clusters/manufacturer_specific.py +0 -0
  112. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/zcl/clusters/measurement.py +0 -0
  113. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/zcl/clusters/protocol.py +0 -0
  114. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/zcl/clusters/security.py +0 -0
  115. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/zcl/clusters/smartenergy.py +0 -0
  116. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/zcl/clusters/wwah.py +0 -0
  117. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/zcl/foundation.py +0 -0
  118. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/zcl/helpers.py +0 -0
  119. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/zdo/types.py +0 -0
  120. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/zgp/__init__.py +0 -0
  121. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy/zgp/types.py +0 -0
  122. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy.egg-info/SOURCES.txt +0 -0
  123. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy.egg-info/dependency_links.txt +0 -0
  124. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy.egg-info/requires.txt +0 -0
  125. {zigpy-1.4.1 → zigpy-1.5.0}/zigpy.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zigpy
3
- Version: 1.4.1
3
+ Version: 1.5.0
4
4
  Summary: Library implementing a Zigbee stack
5
5
  Author-email: Russell Cloran <rcloran@gmail.com>
6
6
  License: GPL-3.0
@@ -947,13 +947,7 @@ async def test_request(app, device, packet):
947
947
  app.send_packet.reset_mock()
948
948
 
949
949
 
950
- async def test_request_retrying_success(app, device, packet) -> None:
951
- app.send_packet.side_effect = [
952
- DeliveryError("Failure"),
953
- DeliveryError("Failure"),
954
- None,
955
- ]
956
-
950
+ async def test_force_route_discovery(app, device, packet) -> None:
957
951
  await app.request(
958
952
  device=device,
959
953
  profile=0x1234,
@@ -965,54 +959,10 @@ async def test_request_retrying_success(app, device, packet) -> None:
965
959
  expect_reply=True,
966
960
  use_ieee=False,
967
961
  extended_timeout=False,
962
+ force_route_discovery=True,
968
963
  )
969
964
 
970
965
  assert app.send_packet.mock_calls == [
971
- call(packet.replace(priority=t.PacketPriority.NORMAL)),
972
- call(
973
- packet.replace(
974
- tx_options=packet.tx_options | t.TransmitOptions.FORCE_ROUTE_DISCOVERY,
975
- priority=t.PacketPriority.NORMAL,
976
- )
977
- ),
978
- call(
979
- packet.replace(
980
- tx_options=packet.tx_options | t.TransmitOptions.FORCE_ROUTE_DISCOVERY,
981
- priority=t.PacketPriority.NORMAL,
982
- )
983
- ),
984
- ]
985
-
986
-
987
- async def test_request_retrying_failure(app, device, packet) -> None:
988
- app.send_packet.side_effect = [
989
- DeliveryError("Failure"),
990
- DeliveryError("Failure"),
991
- DeliveryError("Failure"),
992
- ]
993
-
994
- with pytest.raises(DeliveryError):
995
- await app.request(
996
- device=device,
997
- profile=0x1234,
998
- cluster=0x0006,
999
- src_ep=0x9A,
1000
- dst_ep=0xBC,
1001
- sequence=0xDE,
1002
- data=b"test data",
1003
- expect_reply=True,
1004
- use_ieee=False,
1005
- extended_timeout=False,
1006
- )
1007
-
1008
- assert app.send_packet.mock_calls == [
1009
- call(packet.replace(priority=t.PacketPriority.NORMAL)),
1010
- call(
1011
- packet.replace(
1012
- tx_options=packet.tx_options | t.TransmitOptions.FORCE_ROUTE_DISCOVERY,
1013
- priority=t.PacketPriority.NORMAL,
1014
- )
1015
- ),
1016
966
  call(
1017
967
  packet.replace(
1018
968
  tx_options=packet.tx_options | t.TransmitOptions.FORCE_ROUTE_DISCOVERY,
@@ -523,10 +523,6 @@ async def test_update_device_firmware_already_in_progress(dev, caplog):
523
523
 
524
524
  @patch("zigpy.ota.manager.MAX_TIME_WITHOUT_PROGRESS", 0.1)
525
525
  @patch("zigpy.device.AFTER_OTA_ATTR_READ_DELAY", 0.01)
526
- @patch(
527
- "zigpy.device.OTA_RETRY_DECORATOR",
528
- zigpy.util.retryable_request(tries=1, delay=0.01),
529
- )
530
526
  async def test_update_device_firmware(monkeypatch, dev, caplog):
531
527
  """Test that device firmware updates execute the expected calls."""
532
528
  ep = dev.add_endpoint(1)
@@ -947,10 +943,6 @@ async def test_update_device_firmware(monkeypatch, dev, caplog):
947
943
 
948
944
  @patch("zigpy.ota.manager.MAX_TIME_WITHOUT_PROGRESS", 0.1)
949
945
  @patch("zigpy.device.AFTER_OTA_ATTR_READ_DELAY", 0.01)
950
- @patch(
951
- "zigpy.device.OTA_RETRY_DECORATOR",
952
- zigpy.util.retryable_request(tries=1, delay=0.01),
953
- )
954
946
  async def test_update_legrand_device_firmware(monkeypatch, dev, caplog):
955
947
  """Legrand device (manufacturer_code == 4129) firmware update expects the "image_block" command "maximum_data_size" to be complied with."""
956
948
  ep = dev.add_endpoint(1)
@@ -1359,7 +1351,7 @@ async def test_request_exception_propagation(dev):
1359
1351
  data=t.SerializableBytes(
1360
1352
  foundation.ZCLHeader(
1361
1353
  frame_control=foundation.FrameControl(
1362
- frame_type=foundation.FrameType.CLUSTER_COMMAND,
1354
+ frame_type=foundation.FrameType.GLOBAL_COMMAND,
1363
1355
  is_manufacturer_specific=False,
1364
1356
  direction=foundation.Direction.Server_to_Client,
1365
1357
  disable_default_response=True,
@@ -2227,3 +2219,259 @@ async def test_update_firmware_triggers_reinterview(monkeypatch, dev):
2227
2219
 
2228
2220
  assert result == foundation.Status.SUCCESS
2229
2221
  dev.reinterview.assert_awaited_once()
2222
+
2223
+
2224
+ async def test_request_retry_success(app) -> None:
2225
+ """Test retry logic succeeding after a few attempts."""
2226
+ tsn = 0x12
2227
+
2228
+ dev = app.add_device(nwk=0x1234, ieee=t.EUI64.convert("aa:bb:cc:dd:ee:ff:00:11"))
2229
+ dev.node_desc = make_node_desc()
2230
+
2231
+ ep = dev.add_endpoint(1)
2232
+ ep.status = endpoint.Status.ZDO_INIT
2233
+ ep.add_input_cluster(Basic.cluster_id)
2234
+
2235
+ attempt = 0
2236
+
2237
+ def send_packet(*args, **kwargs) -> None:
2238
+ nonlocal attempt
2239
+ attempt += 1
2240
+
2241
+ if attempt < 3:
2242
+ raise zigpy.exceptions.DeliveryError("Failure")
2243
+
2244
+ asyncio.get_running_loop().call_soon(
2245
+ dev.packet_received,
2246
+ t.ZigbeePacket(
2247
+ profile_id=260,
2248
+ cluster_id=Basic.cluster_id,
2249
+ src_ep=1,
2250
+ dst_ep=1,
2251
+ data=t.SerializableBytes(
2252
+ foundation.ZCLHeader(
2253
+ frame_control=foundation.FrameControl(
2254
+ frame_type=foundation.FrameType.GLOBAL_COMMAND,
2255
+ is_manufacturer_specific=False,
2256
+ direction=foundation.Direction.Server_to_Client,
2257
+ disable_default_response=True,
2258
+ reserved=0,
2259
+ ),
2260
+ tsn=tsn,
2261
+ command_id=foundation.GeneralCommand.Default_Response,
2262
+ manufacturer=None,
2263
+ ).serialize()
2264
+ + (
2265
+ foundation.GENERAL_COMMANDS[
2266
+ foundation.GeneralCommand.Default_Response
2267
+ ]
2268
+ .schema(
2269
+ command_id=Basic.ServerCommandDefs.reset_fact_default.id,
2270
+ status=foundation.Status.SUCCESS,
2271
+ )
2272
+ .serialize()
2273
+ )
2274
+ ),
2275
+ src=t.AddrModeAddress(
2276
+ addr_mode=t.AddrMode.NWK,
2277
+ address=dev.nwk,
2278
+ ),
2279
+ dst=t.AddrModeAddress(
2280
+ addr_mode=t.AddrMode.NWK,
2281
+ address=0x0000,
2282
+ ),
2283
+ ),
2284
+ )
2285
+
2286
+ app.send_packet.side_effect = send_packet
2287
+ dev.get_sequence = MagicMock(return_value=tsn)
2288
+
2289
+ rsp = await dev.endpoints[1].basic.reset_fact_default()
2290
+ assert rsp == foundation.DefaultResponse(
2291
+ command_id=Basic.ServerCommandDefs.reset_fact_default.id,
2292
+ status=foundation.Status.SUCCESS,
2293
+ )
2294
+
2295
+ assert len(app.send_packet.mock_calls) == 3
2296
+
2297
+
2298
+ async def test_request_retry_failure(app) -> None:
2299
+ """Test retry logic when all attempts fail."""
2300
+ dev = app.add_device(nwk=0x1234, ieee=t.EUI64.convert("aa:bb:cc:dd:ee:ff:00:11"))
2301
+ dev.node_desc = make_node_desc()
2302
+
2303
+ ep = dev.add_endpoint(1)
2304
+ ep.status = endpoint.Status.ZDO_INIT
2305
+ ep.add_input_cluster(Basic.cluster_id)
2306
+
2307
+ app.send_packet.side_effect = [
2308
+ zigpy.exceptions.DeliveryError("Failure"),
2309
+ zigpy.exceptions.DeliveryError("Failure"),
2310
+ zigpy.exceptions.DeliveryError("Failure"),
2311
+ zigpy.exceptions.DeliveryError("Failure"),
2312
+ ]
2313
+
2314
+ with pytest.raises(zigpy.exceptions.DeliveryError):
2315
+ await dev.request(
2316
+ profile=0x1234,
2317
+ cluster=0x0006,
2318
+ src_ep=1,
2319
+ dst_ep=1,
2320
+ sequence=0xDE,
2321
+ data=b"test data",
2322
+ expect_reply=True,
2323
+ use_ieee=False,
2324
+ retries=3,
2325
+ )
2326
+
2327
+ packet = t.ZigbeePacket(
2328
+ priority=None,
2329
+ src=t.AddrModeAddress(addr_mode=t.AddrMode.NWK, address=0x0000),
2330
+ src_ep=1,
2331
+ dst=t.AddrModeAddress(addr_mode=t.AddrMode.NWK, address=dev.nwk),
2332
+ dst_ep=1,
2333
+ source_route=None,
2334
+ extended_timeout=False,
2335
+ tsn=222,
2336
+ profile_id=4660,
2337
+ cluster_id=6,
2338
+ data=t.SerializableBytes(b"test data"),
2339
+ tx_options=t.TransmitOptions.NONE,
2340
+ radius=0,
2341
+ non_member_radius=0,
2342
+ )
2343
+
2344
+ assert app.send_packet.mock_calls == [
2345
+ call(packet),
2346
+ call(
2347
+ packet.replace(
2348
+ tx_options=packet.tx_options | t.TransmitOptions.FORCE_ROUTE_DISCOVERY
2349
+ )
2350
+ ),
2351
+ call(
2352
+ packet.replace(
2353
+ tx_options=packet.tx_options | t.TransmitOptions.FORCE_ROUTE_DISCOVERY
2354
+ )
2355
+ ),
2356
+ call(
2357
+ packet.replace(
2358
+ tx_options=packet.tx_options | t.TransmitOptions.FORCE_ROUTE_DISCOVERY
2359
+ )
2360
+ ),
2361
+ ]
2362
+
2363
+
2364
+ async def test_request_retry_reply_timeout(app) -> None:
2365
+ """Test retry logic when a request is enqueued but no reply arrives, then a later attempt succeeds."""
2366
+ tsn = 0x12
2367
+
2368
+ dev = app.add_device(nwk=0x1234, ieee=t.EUI64.convert("aa:bb:cc:dd:ee:ff:00:11"))
2369
+ dev.node_desc = make_node_desc()
2370
+
2371
+ ep = dev.add_endpoint(1)
2372
+ ep.status = endpoint.Status.ZDO_INIT
2373
+ ep.add_input_cluster(Basic.cluster_id)
2374
+
2375
+ attempt = 0
2376
+
2377
+ def send_packet(*args, **kwargs) -> None:
2378
+ nonlocal attempt
2379
+ attempt += 1
2380
+
2381
+ # The first two attempts are enqueued successfully but no reply ever arrives
2382
+ if attempt < 3:
2383
+ return
2384
+
2385
+ # The third attempt receives a reply
2386
+ asyncio.get_running_loop().call_soon(
2387
+ dev.packet_received,
2388
+ t.ZigbeePacket(
2389
+ profile_id=260,
2390
+ cluster_id=Basic.cluster_id,
2391
+ src_ep=1,
2392
+ dst_ep=1,
2393
+ data=t.SerializableBytes(
2394
+ foundation.ZCLHeader(
2395
+ frame_control=foundation.FrameControl(
2396
+ frame_type=foundation.FrameType.GLOBAL_COMMAND,
2397
+ is_manufacturer_specific=False,
2398
+ direction=foundation.Direction.Server_to_Client,
2399
+ disable_default_response=True,
2400
+ reserved=0,
2401
+ ),
2402
+ tsn=tsn,
2403
+ command_id=foundation.GeneralCommand.Default_Response,
2404
+ manufacturer=None,
2405
+ ).serialize()
2406
+ + (
2407
+ foundation.GENERAL_COMMANDS[
2408
+ foundation.GeneralCommand.Default_Response
2409
+ ]
2410
+ .schema(
2411
+ command_id=Basic.ServerCommandDefs.reset_fact_default.id,
2412
+ status=foundation.Status.SUCCESS,
2413
+ )
2414
+ .serialize()
2415
+ )
2416
+ ),
2417
+ src=t.AddrModeAddress(
2418
+ addr_mode=t.AddrMode.NWK,
2419
+ address=dev.nwk,
2420
+ ),
2421
+ dst=t.AddrModeAddress(
2422
+ addr_mode=t.AddrMode.NWK,
2423
+ address=0x0000,
2424
+ ),
2425
+ ),
2426
+ )
2427
+
2428
+ app.send_packet.side_effect = send_packet
2429
+ dev.get_sequence = MagicMock(return_value=tsn)
2430
+
2431
+ rsp = await dev.endpoints[1].basic.reset_fact_default(timeout=0.01, retry_delay=0)
2432
+ assert rsp == foundation.DefaultResponse(
2433
+ command_id=Basic.ServerCommandDefs.reset_fact_default.id,
2434
+ status=foundation.Status.SUCCESS,
2435
+ )
2436
+
2437
+ assert len(app.send_packet.mock_calls) == 3
2438
+
2439
+
2440
+ async def test_request_retry_delay_releases_concurrency(app) -> None:
2441
+ """A request awaiting its post-retry delay must not hold a concurrency slot."""
2442
+ dev = app.add_device(nwk=0x1234, ieee=t.EUI64.convert("aa:bb:cc:dd:ee:ff:00:11"))
2443
+
2444
+ # Every send fails, so each request enters its (long) post-retry delay
2445
+ app.send_packet.side_effect = zigpy.exceptions.DeliveryError("Failure")
2446
+
2447
+ async def make_request(sequence: int) -> None:
2448
+ await dev.request(
2449
+ profile=0x1234,
2450
+ cluster=0x0006,
2451
+ src_ep=1,
2452
+ dst_ep=1,
2453
+ sequence=sequence,
2454
+ data=b"",
2455
+ expect_reply=False,
2456
+ retries=1,
2457
+ retry_delay=10,
2458
+ )
2459
+
2460
+ async with asyncio.TaskGroup() as tg:
2461
+ tasks = [
2462
+ tg.create_task(make_request(seq))
2463
+ for seq in range(2 * device.MAX_DEVICE_CONCURRENCY)
2464
+ ]
2465
+
2466
+ # Each request is now parked in a 10s post-retry delay. Even so, every one
2467
+ # should still get its first attempt sent: the concurrency slot is released
2468
+ # *before* the delay, not held during it.
2469
+ async with asyncio.timeout(1):
2470
+ while len(app.send_packet.mock_calls) < len(tasks):
2471
+ await asyncio.sleep(0)
2472
+
2473
+ assert len(app.send_packet.mock_calls) == len(tasks)
2474
+
2475
+ # Tear down the still-pending requests so the task group can exit
2476
+ for task in tasks:
2477
+ task.cancel()
@@ -175,6 +175,8 @@ async def test_reply_change_profile_id(ep):
175
175
  use_ieee=False,
176
176
  ask_for_ack=None,
177
177
  priority=None,
178
+ retries=None,
179
+ retry_delay=None,
178
180
  )
179
181
  ]
180
182
 
@@ -193,6 +195,8 @@ async def test_reply_change_profile_id(ep):
193
195
  use_ieee=False,
194
196
  ask_for_ack=None,
195
197
  priority=None,
198
+ retries=None,
199
+ retry_delay=None,
196
200
  )
197
201
  ]
198
202
 
@@ -212,6 +216,8 @@ async def test_reply_change_profile_id(ep):
212
216
  use_ieee=False,
213
217
  ask_for_ack=None,
214
218
  priority=None,
219
+ retries=None,
220
+ retry_delay=None,
215
221
  )
216
222
  ]
217
223
 
@@ -47,7 +47,7 @@ def patch_device_tables(
47
47
  neighbors: list | BaseException | zdo_t.Status,
48
48
  routes: list | BaseException | zdo_t.Status,
49
49
  ):
50
- def mgmt_lqi_req(StartIndex: t.uint8_t):
50
+ def mgmt_lqi_req(StartIndex: t.uint8_t, **kwargs):
51
51
  status = zdo_t.Status.SUCCESS
52
52
  entries = 0
53
53
  start_index = 0
@@ -73,7 +73,7 @@ def patch_device_tables(
73
73
  }.values()
74
74
  )
75
75
 
76
- def mgmt_rtg_req(StartIndex: t.uint8_t):
76
+ def mgmt_rtg_req(StartIndex: t.uint8_t, **kwargs):
77
77
  status = zdo_t.Status.SUCCESS
78
78
  entries = 0
79
79
  start_index = 0
@@ -21,10 +21,12 @@ import zigpy.endpoint
21
21
  import zigpy.profiles.zha
22
22
  import zigpy.types as t
23
23
  from zigpy.zcl import (
24
+ MAX_ATTRIBUTE_RECORDS_BYTES,
24
25
  AttributeReadEvent,
25
26
  AttributeReportedEvent,
26
27
  AttributeUpdatedEvent,
27
28
  AttributeWrittenEvent,
29
+ _chunk_records_by_size,
28
30
  foundation,
29
31
  )
30
32
  from zigpy.zcl.clusters.general import Basic, OnOff, Ota
@@ -1612,6 +1614,8 @@ async def test_received_onoff_toggle_generates_default_response():
1612
1614
  use_ieee=False,
1613
1615
  ask_for_ack=None,
1614
1616
  priority=t.PacketPriority.LOW,
1617
+ retries=None,
1618
+ retry_delay=None,
1615
1619
  )
1616
1620
  ]
1617
1621
 
@@ -2401,6 +2405,200 @@ async def test_configure_reporting_multiple_manufacturer_groups(app_mock) -> Non
2401
2405
  assert manuf_call.args[0][0].reportable_change == 5
2402
2406
 
2403
2407
 
2408
+ async def test_configure_reporting_multiple_chunked_by_size(app_mock) -> None:
2409
+ """Configure_reporting_multiple splits requests so no single one exceeds
2410
+ MAX_ATTRIBUTE_RECORDS_BYTES of serialized records.
2411
+ """
2412
+
2413
+ class TestCluster(Basic):
2414
+ _skip_registry = True
2415
+
2416
+ class AttributeDefs(Basic.AttributeDefs):
2417
+ # 6 uint8 attrs, each serializing to 9 bytes as a SendReports config
2418
+ # (1 dir + 2 attrid + 1 type + 2 min + 2 max + 1 reportable_change).
2419
+ # 6 * 9 = 54 bytes > 50, so the request must split into 2 chunks.
2420
+ attr_a = foundation.ZCLAttributeDef(id=0xFF00, type=t.uint8_t)
2421
+ attr_b = foundation.ZCLAttributeDef(id=0xFF01, type=t.uint8_t)
2422
+ attr_c = foundation.ZCLAttributeDef(id=0xFF02, type=t.uint8_t)
2423
+ attr_d = foundation.ZCLAttributeDef(id=0xFF03, type=t.uint8_t)
2424
+ attr_e = foundation.ZCLAttributeDef(id=0xFF04, type=t.uint8_t)
2425
+ attr_f = foundation.ZCLAttributeDef(id=0xFF05, type=t.uint8_t)
2426
+
2427
+ dev = add_initialized_device(app_mock, nwk=0x1234, ieee=make_ieee(1))
2428
+ cluster = TestCluster(dev.endpoints[1])
2429
+ dev.endpoints[1].add_input_cluster(TestCluster.cluster_id, cluster)
2430
+
2431
+ cfg_success = zcl.foundation.ConfigureReportingResponse(
2432
+ [zcl.foundation.ConfigureReportingResponseRecord(zcl.foundation.Status.SUCCESS)]
2433
+ )
2434
+
2435
+ cfg = ReportingConfig(min_interval=1, max_interval=2, reportable_change=3)
2436
+ attrs = [
2437
+ TestCluster.AttributeDefs.attr_a,
2438
+ TestCluster.AttributeDefs.attr_b,
2439
+ TestCluster.AttributeDefs.attr_c,
2440
+ TestCluster.AttributeDefs.attr_d,
2441
+ TestCluster.AttributeDefs.attr_e,
2442
+ TestCluster.AttributeDefs.attr_f,
2443
+ ]
2444
+
2445
+ with patch.object(
2446
+ cluster,
2447
+ "_configure_reporting",
2448
+ new_callable=AsyncMock,
2449
+ side_effect=[[cfg_success], [cfg_success]],
2450
+ ) as mock_configure:
2451
+ results = await cluster.configure_reporting_multiple(dict.fromkeys(attrs, cfg))
2452
+
2453
+ assert mock_configure.await_count == 2
2454
+
2455
+ sent_attrids = []
2456
+ for call_obj in mock_configure.call_args_list:
2457
+ chunk_configs = call_obj.args[0]
2458
+ chunk_size = sum(len(c.serialize()) for c in chunk_configs)
2459
+ assert chunk_size <= MAX_ATTRIBUTE_RECORDS_BYTES
2460
+ sent_attrids.extend(c.attrid for c in chunk_configs)
2461
+
2462
+ assert sent_attrids == [a.id for a in attrs]
2463
+
2464
+ assert len(results) == 6
2465
+ assert all(s == zcl.foundation.Status.SUCCESS for s in results.values())
2466
+
2467
+
2468
+ async def test_read_attributes_chunked_by_count(app_mock) -> None:
2469
+ """Read_attributes splits requests into chunks of at most five."""
2470
+
2471
+ class TestCluster(Basic):
2472
+ _skip_registry = True
2473
+
2474
+ class AttributeDefs(Basic.AttributeDefs):
2475
+ attr_0 = foundation.ZCLAttributeDef(id=0xFF00, type=t.uint8_t)
2476
+ attr_1 = foundation.ZCLAttributeDef(id=0xFF01, type=t.uint8_t)
2477
+ attr_2 = foundation.ZCLAttributeDef(id=0xFF02, type=t.uint8_t)
2478
+ attr_3 = foundation.ZCLAttributeDef(id=0xFF03, type=t.uint8_t)
2479
+ attr_4 = foundation.ZCLAttributeDef(id=0xFF04, type=t.uint8_t)
2480
+ attr_5 = foundation.ZCLAttributeDef(id=0xFF05, type=t.uint8_t)
2481
+ attr_6 = foundation.ZCLAttributeDef(id=0xFF06, type=t.uint8_t)
2482
+ attr_7 = foundation.ZCLAttributeDef(id=0xFF07, type=t.uint8_t)
2483
+ attr_8 = foundation.ZCLAttributeDef(id=0xFF08, type=t.uint8_t)
2484
+ attr_9 = foundation.ZCLAttributeDef(id=0xFF09, type=t.uint8_t)
2485
+ attr_10 = foundation.ZCLAttributeDef(id=0xFF0A, type=t.uint8_t)
2486
+
2487
+ dev = add_initialized_device(app_mock, nwk=0x1234, ieee=make_ieee(1))
2488
+ cluster = TestCluster(dev.endpoints[1])
2489
+ dev.endpoints[1].add_input_cluster(TestCluster.cluster_id, cluster)
2490
+
2491
+ attrs = [getattr(TestCluster.AttributeDefs, f"attr_{i}") for i in range(11)]
2492
+
2493
+ # Exclude 2 and 7 so the mock returns UNSUPPORTED
2494
+ supported = {attr: i for i, attr in enumerate(attrs) if i not in (2, 7)}
2495
+ with mock_attribute_reads(cluster, supported) as (mock_read, _):
2496
+ success, failure = await cluster.read_attributes(attrs)
2497
+
2498
+ # 11 attributes, at most MAX_READ_ATTRIBUTES_PER_REQ (5) per request -> 5 + 5 + 1
2499
+ chunks = [call_obj.args[0] for call_obj in mock_read.call_args_list]
2500
+ assert [len(chunk) for chunk in chunks] == [5, 5, 1]
2501
+
2502
+ # Every attribute was requested exactly once, in order, across the chunks
2503
+ requested_ids = []
2504
+ for chunk in chunks:
2505
+ requested_ids.extend(chunk)
2506
+ assert requested_ids == [attr.id for attr in attrs]
2507
+
2508
+ # Supported attributes succeed; the two omitted ones fail
2509
+ assert success == supported
2510
+ assert failure == {
2511
+ attrs[2]: foundation.Status.UNSUPPORTED_ATTRIBUTE,
2512
+ attrs[7]: foundation.Status.UNSUPPORTED_ATTRIBUTE,
2513
+ }
2514
+
2515
+
2516
+ async def test_write_attributes_chunked_by_size(app_mock) -> None:
2517
+ """Write_attributes splits requests if a single one would exceed the limit."""
2518
+
2519
+ class TestCluster(Basic):
2520
+ _skip_registry = True
2521
+
2522
+ class AttributeDefs(Basic.AttributeDefs):
2523
+ attr_a = foundation.ZCLAttributeDef(id=0xFF00, type=t.uint64_t)
2524
+ attr_b = foundation.ZCLAttributeDef(id=0xFF01, type=t.uint64_t)
2525
+ attr_c = foundation.ZCLAttributeDef(id=0xFF02, type=t.uint64_t)
2526
+ attr_d = foundation.ZCLAttributeDef(id=0xFF03, type=t.uint64_t)
2527
+ attr_e = foundation.ZCLAttributeDef(id=0xFF04, type=t.uint64_t)
2528
+ attr_f = foundation.ZCLAttributeDef(id=0xFF05, type=t.uint64_t)
2529
+
2530
+ dev = add_initialized_device(app_mock, nwk=0x1234, ieee=make_ieee(1))
2531
+ cluster = TestCluster(dev.endpoints[1])
2532
+ dev.endpoints[1].add_input_cluster(TestCluster.cluster_id, cluster)
2533
+
2534
+ attrs = [
2535
+ TestCluster.AttributeDefs.attr_a,
2536
+ TestCluster.AttributeDefs.attr_b,
2537
+ TestCluster.AttributeDefs.attr_c,
2538
+ TestCluster.AttributeDefs.attr_d,
2539
+ TestCluster.AttributeDefs.attr_e,
2540
+ TestCluster.AttributeDefs.attr_f,
2541
+ ]
2542
+
2543
+ with mock_attribute_writes(
2544
+ cluster, dict.fromkeys(attrs, foundation.Status.SUCCESS)
2545
+ ) as (mock_write, _):
2546
+ [results] = await cluster.write_attributes(dict.fromkeys(attrs, 1))
2547
+
2548
+ # 6 records of 11 bytes each split into 2 chunks: 44 bytes + 22 bytes
2549
+ assert mock_write.call_count == 2
2550
+
2551
+ sent_attrids = []
2552
+ for call_obj in mock_write.call_args_list:
2553
+ chunk_attrs = call_obj.args[0]
2554
+ chunk_size = sum(len(a.serialize()) for a in chunk_attrs)
2555
+ assert chunk_size <= MAX_ATTRIBUTE_RECORDS_BYTES
2556
+ sent_attrids.extend(a.attrid for a in chunk_attrs)
2557
+
2558
+ assert sent_attrids == [attr.id for attr in attrs]
2559
+
2560
+ assert len(results) == 6
2561
+ assert all(r.status == foundation.Status.SUCCESS for r in results)
2562
+
2563
+
2564
+ @pytest.mark.parametrize(
2565
+ ("sizes", "max_bytes", "expected"),
2566
+ [
2567
+ # No records produces no chunks
2568
+ ([], 10, []),
2569
+ # Records that all fit stay in a single chunk
2570
+ ([3, 3, 3], 10, [[3, 3, 3]]),
2571
+ # Filling exactly to the limit does not start a new chunk
2572
+ ([5, 5], 10, [[5, 5]]),
2573
+ # A single record exactly at the limit is allowed
2574
+ ([10], 10, [[10]]),
2575
+ # Exceeding the limit rolls over into a new chunk
2576
+ ([5, 5, 1], 10, [[5, 5], [1]]),
2577
+ ],
2578
+ )
2579
+ def test_chunk_records_by_size(sizes, max_bytes, expected) -> None:
2580
+ """The chunker packs records into chunks that never exceed max_bytes."""
2581
+ chunks = _chunk_records_by_size(sizes, lambda size: size, max_bytes=max_bytes)
2582
+ assert chunks == expected
2583
+
2584
+
2585
+ @pytest.mark.parametrize(
2586
+ ("sizes", "max_bytes"),
2587
+ [
2588
+ # A single record larger than the limit, on its own
2589
+ ([15], 10),
2590
+ # An oversized record surrounded by ones that would otherwise fit
2591
+ ([3, 15, 4], 10),
2592
+ ],
2593
+ )
2594
+ def test_chunk_records_by_size_oversized_record(sizes, max_bytes) -> None:
2595
+ """A record that on its own exceeds max_bytes can never be sent, so the chunker
2596
+ fails loudly instead of emitting an oversized chunk the transport would reject.
2597
+ """
2598
+ with pytest.raises(ValueError, match="too large to fit in a single request"):
2599
+ _chunk_records_by_size(sizes, lambda size: size, max_bytes=max_bytes)
2600
+
2601
+
2404
2602
  def test_manufacturer_id_override_manuf_specific_cluster(app_mock) -> None:
2405
2603
  """Test class-level `manufacturer_id_override` for custom clusters."""
2406
2604