zigpy 1.1.1__tar.gz → 1.2.1__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.1.1/zigpy.egg-info → zigpy-1.2.1}/PKG-INFO +1 -1
  2. {zigpy-1.1.1 → zigpy-1.2.1}/tests/test_appdb.py +235 -22
  3. {zigpy-1.1.1 → zigpy-1.2.1}/tests/test_appdb_migration.py +45 -16
  4. {zigpy-1.1.1 → zigpy-1.2.1}/tests/test_device.py +114 -10
  5. {zigpy-1.1.1 → zigpy-1.2.1}/tests/test_quirks_v2.py +50 -29
  6. {zigpy-1.1.1 → zigpy-1.2.1}/tests/test_zcl.py +44 -34
  7. {zigpy-1.1.1 → zigpy-1.2.1}/tests/test_zcl_clusters.py +93 -40
  8. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/appdb.py +159 -8
  9. zigpy-1.2.1/zigpy/appdb_schemas/schema_v15.sql +218 -0
  10. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/device.py +41 -2
  11. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/ota/__init__.py +176 -33
  12. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/ota/manager.py +7 -0
  13. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/ota/providers.py +3 -0
  14. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/quirks/registry.py +1 -1
  15. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/quirks/v2/__init__.py +29 -14
  16. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/quirks/v2/homeassistant/__init__.py +85 -9
  17. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/quirks/v2/homeassistant/binary_sensor.py +2 -2
  18. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/quirks/v2/homeassistant/number.py +91 -32
  19. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/quirks/v2/homeassistant/sensor.py +86 -36
  20. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/zcl/__init__.py +67 -43
  21. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/zcl/clusters/__init__.py +2 -0
  22. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/zcl/clusters/general.py +41 -23
  23. zigpy-1.2.1/zigpy/zcl/clusters/wwah.py +509 -0
  24. {zigpy-1.1.1 → zigpy-1.2.1/zigpy.egg-info}/PKG-INFO +1 -1
  25. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy.egg-info/SOURCES.txt +2 -0
  26. {zigpy-1.1.1 → zigpy-1.2.1}/COPYING +0 -0
  27. {zigpy-1.1.1 → zigpy-1.2.1}/LICENSE +0 -0
  28. {zigpy-1.1.1 → zigpy-1.2.1}/README.md +0 -0
  29. {zigpy-1.1.1 → zigpy-1.2.1}/pyproject.toml +0 -0
  30. {zigpy-1.1.1 → zigpy-1.2.1}/setup.cfg +0 -0
  31. {zigpy-1.1.1 → zigpy-1.2.1}/setup.py +0 -0
  32. {zigpy-1.1.1 → zigpy-1.2.1}/tests/test_app_state.py +0 -0
  33. {zigpy-1.1.1 → zigpy-1.2.1}/tests/test_application.py +0 -0
  34. {zigpy-1.1.1 → zigpy-1.2.1}/tests/test_backups.py +0 -0
  35. {zigpy-1.1.1 → zigpy-1.2.1}/tests/test_config.py +0 -0
  36. {zigpy-1.1.1 → zigpy-1.2.1}/tests/test_datastructures.py +0 -0
  37. {zigpy-1.1.1 → zigpy-1.2.1}/tests/test_datastructures_cpython.py +0 -0
  38. {zigpy-1.1.1 → zigpy-1.2.1}/tests/test_endpoint.py +0 -0
  39. {zigpy-1.1.1 → zigpy-1.2.1}/tests/test_event.py +0 -0
  40. {zigpy-1.1.1 → zigpy-1.2.1}/tests/test_group.py +0 -0
  41. {zigpy-1.1.1 → zigpy-1.2.1}/tests/test_listeners.py +0 -0
  42. {zigpy-1.1.1 → zigpy-1.2.1}/tests/test_packet_callbacks.py +0 -0
  43. {zigpy-1.1.1 → zigpy-1.2.1}/tests/test_quirks.py +0 -0
  44. {zigpy-1.1.1 → zigpy-1.2.1}/tests/test_quirks_registry.py +0 -0
  45. {zigpy-1.1.1 → zigpy-1.2.1}/tests/test_serial.py +0 -0
  46. {zigpy-1.1.1 → zigpy-1.2.1}/tests/test_struct.py +0 -0
  47. {zigpy-1.1.1 → zigpy-1.2.1}/tests/test_topology.py +0 -0
  48. {zigpy-1.1.1 → zigpy-1.2.1}/tests/test_types.py +0 -0
  49. {zigpy-1.1.1 → zigpy-1.2.1}/tests/test_zcl_foundation.py +0 -0
  50. {zigpy-1.1.1 → zigpy-1.2.1}/tests/test_zcl_helpers.py +0 -0
  51. {zigpy-1.1.1 → zigpy-1.2.1}/tests/test_zdo.py +0 -0
  52. {zigpy-1.1.1 → zigpy-1.2.1}/tests/test_zdo_types.py +0 -0
  53. {zigpy-1.1.1 → zigpy-1.2.1}/tests/test_zigbee_util.py +0 -0
  54. {zigpy-1.1.1 → zigpy-1.2.1}/tools/__init__.py +0 -0
  55. {zigpy-1.1.1 → zigpy-1.2.1}/tools/regenerate_mypy_ignores.py +0 -0
  56. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/__init__.py +0 -0
  57. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/appdb_schemas/__init__.py +0 -0
  58. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/appdb_schemas/schema_v0.sql +0 -0
  59. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/appdb_schemas/schema_v1.sql +0 -0
  60. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/appdb_schemas/schema_v10.sql +0 -0
  61. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/appdb_schemas/schema_v11.sql +0 -0
  62. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/appdb_schemas/schema_v12.sql +0 -0
  63. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/appdb_schemas/schema_v13.sql +0 -0
  64. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/appdb_schemas/schema_v14.sql +0 -0
  65. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/appdb_schemas/schema_v2.sql +0 -0
  66. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/appdb_schemas/schema_v3.sql +0 -0
  67. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/appdb_schemas/schema_v4.sql +0 -0
  68. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/appdb_schemas/schema_v5.sql +0 -0
  69. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/appdb_schemas/schema_v6.sql +0 -0
  70. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/appdb_schemas/schema_v7.sql +0 -0
  71. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/appdb_schemas/schema_v8.sql +0 -0
  72. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/appdb_schemas/schema_v9.sql +0 -0
  73. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/application.py +0 -0
  74. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/backports/__init__.py +0 -0
  75. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/backups.py +0 -0
  76. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/config/__init__.py +0 -0
  77. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/config/defaults.py +0 -0
  78. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/config/validators.py +0 -0
  79. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/const.py +0 -0
  80. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/datastructures.py +0 -0
  81. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/endpoint.py +0 -0
  82. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/event/__init__.py +0 -0
  83. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/event/event_base.py +0 -0
  84. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/exceptions.py +0 -0
  85. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/group.py +0 -0
  86. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/listeners.py +0 -0
  87. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/ota/image.py +0 -0
  88. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/ota/json_schemas.py +0 -0
  89. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/ota/validators.py +0 -0
  90. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/profiles/__init__.py +0 -0
  91. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/profiles/zgp.py +0 -0
  92. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/profiles/zha.py +0 -0
  93. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/profiles/zll.py +0 -0
  94. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/py.typed +0 -0
  95. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/quirks/__init__.py +0 -0
  96. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/serial.py +0 -0
  97. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/state.py +0 -0
  98. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/topology.py +0 -0
  99. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/types/__init__.py +0 -0
  100. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/types/basic.py +0 -0
  101. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/types/named.py +0 -0
  102. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/types/struct.py +0 -0
  103. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/typing.py +0 -0
  104. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/util.py +0 -0
  105. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/zcl/clusters/closures.py +0 -0
  106. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/zcl/clusters/general_const.py +0 -0
  107. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/zcl/clusters/greenpower.py +0 -0
  108. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/zcl/clusters/homeautomation.py +0 -0
  109. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/zcl/clusters/hvac.py +0 -0
  110. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/zcl/clusters/lighting.py +0 -0
  111. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/zcl/clusters/lightlink.py +0 -0
  112. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/zcl/clusters/manufacturer_specific.py +0 -0
  113. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/zcl/clusters/measurement.py +0 -0
  114. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/zcl/clusters/protocol.py +0 -0
  115. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/zcl/clusters/security.py +0 -0
  116. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/zcl/clusters/smartenergy.py +0 -0
  117. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/zcl/foundation.py +0 -0
  118. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/zcl/helpers.py +0 -0
  119. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/zdo/__init__.py +0 -0
  120. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/zdo/types.py +0 -0
  121. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/zgp/__init__.py +0 -0
  122. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy/zgp/types.py +0 -0
  123. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy.egg-info/dependency_links.txt +0 -0
  124. {zigpy-1.1.1 → zigpy-1.2.1}/zigpy.egg-info/requires.txt +0 -0
  125. {zigpy-1.1.1 → zigpy-1.2.1}/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.1.1
3
+ Version: 1.2.1
4
4
  Summary: Library implementing a Zigbee stack
5
5
  Author-email: Russell Cloran <rcloran@gmail.com>
6
6
  License: GPL-3.0
@@ -1,8 +1,6 @@
1
1
  import asyncio
2
- import contextlib
3
2
  from datetime import UTC, datetime, timedelta
4
3
  import pathlib
5
- import threading
6
4
  import time
7
5
 
8
6
  import aiosqlite
@@ -42,30 +40,17 @@ from zigpy.quirks.registry import DeviceRegistry
42
40
  from zigpy.quirks.v2 import QuirkBuilder
43
41
  import zigpy.types as t
44
42
  import zigpy.zcl
45
- from zigpy.zcl import ClusterType, UnsupportedAttribute
43
+ from zigpy.zcl import (
44
+ ClusterType,
45
+ OtaQueryCacheClearedEvent,
46
+ OtaQueryCacheUpdatedEvent,
47
+ UnsupportedAttribute,
48
+ )
46
49
  from zigpy.zcl.clusters.general import Basic, Identify, OnOff, Ota
47
50
  from zigpy.zcl.foundation import Status as ZCLStatus, ZCLAttributeDef
48
51
  from zigpy.zdo import types as zdo_t
49
52
 
50
-
51
- @pytest.fixture(autouse=True)
52
- def auto_kill_aiosqlite():
53
- """Aiosqlite's background thread does not let pytest exit when a failure occurs"""
54
- yield
55
-
56
- for thread in threading.enumerate():
57
- if not isinstance(thread, aiosqlite.core.Connection):
58
- continue
59
-
60
- try:
61
- conn = thread._conn
62
- except ValueError:
63
- pass
64
- else:
65
- with contextlib.suppress(zigpy.appdb.sqlite3.ProgrammingError):
66
- conn.close()
67
-
68
- thread._running = False
53
+ pytestmark = pytest.mark.usefixtures("auto_kill_aiosqlite")
69
54
 
70
55
 
71
56
  async def make_app_with_db(database_file):
@@ -1607,3 +1592,231 @@ async def test_device_signature_ignores_quirks(tmp_path) -> None:
1607
1592
  assert dev2.original_signature == expected_signature
1608
1593
 
1609
1594
  await app2.shutdown()
1595
+
1596
+
1597
+ @patch("zigpy.device.Device.schedule_initialize", new=mock_dev_init(True))
1598
+ async def test_ota_query_cache_persistence(tmp_path):
1599
+ """Test that OTA query cache is persisted and restored from the database."""
1600
+ db = tmp_path / "test.db"
1601
+ app = await make_app_with_db(db)
1602
+ ieee = make_ieee()
1603
+ app.handle_join(99, ieee, 0)
1604
+
1605
+ dev = app.get_device(ieee)
1606
+ ep = dev.add_endpoint(1)
1607
+ ep.status = zigpy.endpoint.Status.ZDO_INIT
1608
+ ep.profile_id = 260
1609
+ ep.device_type = profiles.zha.DeviceType.PUMP
1610
+ ep.add_input_cluster(0) # Basic cluster, exercises non-OTA skip in load
1611
+ ota_cluster = ep.add_output_cluster(Ota.cluster_id)
1612
+ app.device_initialized(dev)
1613
+
1614
+ # With hardware_version
1615
+ cmd = Ota.QueryNextImageCommand(
1616
+ field_control=Ota.QueryNextImageCommand.FieldControl.HardwareVersion,
1617
+ manufacturer_code=0x1234,
1618
+ image_type=0x5678,
1619
+ current_file_version=0x000A0001,
1620
+ )
1621
+ cmd.hardware_version = 3
1622
+ ota_cluster.last_query_cmd = cmd
1623
+
1624
+ app.device_initialized(dev)
1625
+ await app.shutdown()
1626
+
1627
+ app2 = await make_app_with_db(db)
1628
+ dev2 = app2.get_device(ieee)
1629
+ ota2 = dev2.endpoints[1].out_clusters[Ota.cluster_id]
1630
+
1631
+ assert ota2.last_query_cmd is not None
1632
+ assert ota2.last_query_cmd.manufacturer_code == 0x1234
1633
+ assert ota2.last_query_cmd.image_type == 0x5678
1634
+ assert ota2.last_query_cmd.current_file_version == 0x000A0001
1635
+ assert ota2.last_query_cmd.hardware_version == 3
1636
+ assert dev2.get_last_ota_query_cmd() is ota2.last_query_cmd
1637
+
1638
+ # Update to a command without hardware_version
1639
+ ota2.last_query_cmd = Ota.QueryNextImageCommand(
1640
+ field_control=Ota.QueryNextImageCommand.FieldControl(0),
1641
+ manufacturer_code=0xAAAA,
1642
+ image_type=0xBBBB,
1643
+ current_file_version=0x00000042,
1644
+ )
1645
+ app2.device_initialized(dev2)
1646
+ await app2.shutdown()
1647
+
1648
+ app3 = await make_app_with_db(db)
1649
+ dev3 = app3.get_device(ieee)
1650
+ ota3 = dev3.endpoints[1].out_clusters[Ota.cluster_id]
1651
+
1652
+ assert ota3.last_query_cmd is not None
1653
+ assert ota3.last_query_cmd.manufacturer_code == 0xAAAA
1654
+ assert ota3.last_query_cmd.current_file_version == 0x00000042
1655
+ assert not hasattr(ota3.last_query_cmd, "hardware_version") or (
1656
+ ota3.last_query_cmd.hardware_version is None
1657
+ )
1658
+
1659
+ await app3.shutdown()
1660
+
1661
+
1662
+ @patch("zigpy.device.Device.schedule_initialize", new=mock_dev_init(True))
1663
+ async def test_ota_query_cache_event_save(tmp_path):
1664
+ """Test that the OtaQueryCacheUpdatedEvent triggers a DB save."""
1665
+ db = tmp_path / "test.db"
1666
+ app = await make_app_with_db(db)
1667
+ ieee = make_ieee()
1668
+ app.handle_join(99, ieee, 0)
1669
+
1670
+ dev = app.get_device(ieee)
1671
+ ep = dev.add_endpoint(1)
1672
+ ep.status = zigpy.endpoint.Status.ZDO_INIT
1673
+ ep.profile_id = 260
1674
+ ep.device_type = profiles.zha.DeviceType.PUMP
1675
+ ota_cluster = ep.add_output_cluster(Ota.cluster_id)
1676
+ app.device_initialized(dev)
1677
+
1678
+ # Simulate the event that _handle_query_next_image would emit
1679
+ event = OtaQueryCacheUpdatedEvent(
1680
+ device_ieee=str(ieee),
1681
+ endpoint_id=1,
1682
+ cluster_type=ota_cluster.cluster_type,
1683
+ cluster_id=ota_cluster.cluster_id,
1684
+ manufacturer_code=0x1111,
1685
+ image_type=0x2222,
1686
+ current_file_version=0x00000099,
1687
+ hardware_version=7,
1688
+ )
1689
+
1690
+ ota_cluster.emit(OtaQueryCacheUpdatedEvent.event_type, event)
1691
+ await app.shutdown()
1692
+
1693
+ app2 = await make_app_with_db(db)
1694
+ dev2 = app2.get_device(ieee)
1695
+ ota2 = dev2.endpoints[1].out_clusters[Ota.cluster_id]
1696
+
1697
+ assert ota2.last_query_cmd is not None
1698
+ assert ota2.last_query_cmd.manufacturer_code == 0x1111
1699
+ assert ota2.last_query_cmd.image_type == 0x2222
1700
+ assert ota2.last_query_cmd.current_file_version == 0x00000099
1701
+ assert ota2.last_query_cmd.hardware_version == 7
1702
+
1703
+ await app2.shutdown()
1704
+
1705
+
1706
+ @patch("zigpy.device.Device.schedule_initialize", new=mock_dev_init(True))
1707
+ async def test_ota_query_cache_cleared_after_update(tmp_path):
1708
+ """Test that OTA query cache is deleted from DB when cleared after an update."""
1709
+ db = tmp_path / "test.db"
1710
+ app = await make_app_with_db(db)
1711
+ ieee = make_ieee()
1712
+ app.handle_join(99, ieee, 0)
1713
+
1714
+ dev = app.get_device(ieee)
1715
+ ep = dev.add_endpoint(1)
1716
+ ep.status = zigpy.endpoint.Status.ZDO_INIT
1717
+ ep.profile_id = 260
1718
+ ep.device_type = profiles.zha.DeviceType.PUMP
1719
+ ota_cluster = ep.add_output_cluster(Ota.cluster_id)
1720
+ app.device_initialized(dev)
1721
+
1722
+ # Save a query cmd
1723
+ ota_cluster.last_query_cmd = Ota.QueryNextImageCommand(
1724
+ field_control=Ota.QueryNextImageCommand.FieldControl(0),
1725
+ manufacturer_code=0x1234,
1726
+ image_type=0x5678,
1727
+ current_file_version=0x00000001,
1728
+ )
1729
+ app.device_initialized(dev)
1730
+
1731
+ # Clear the cache (as update_firmware does after a successful OTA)
1732
+ ota_cluster.last_query_cmd = None
1733
+ ota_cluster.emit(
1734
+ OtaQueryCacheClearedEvent.event_type,
1735
+ OtaQueryCacheClearedEvent(
1736
+ device_ieee=str(ieee),
1737
+ endpoint_id=1,
1738
+ ),
1739
+ )
1740
+ await app.shutdown()
1741
+
1742
+ # Reload: the cleared cache should not be restored
1743
+ app2 = await make_app_with_db(db)
1744
+ dev2 = app2.get_device(ieee)
1745
+ ota2 = dev2.endpoints[1].out_clusters[Ota.cluster_id]
1746
+ assert ota2.last_query_cmd is None
1747
+ assert dev2.get_last_ota_query_cmd() is None
1748
+
1749
+ await app2.shutdown()
1750
+
1751
+
1752
+ @patch("zigpy.quirks.DEVICE_REGISTRY", new=DeviceRegistry())
1753
+ async def test_ota_query_cache_skips_quirk_removed_endpoint(tmp_path):
1754
+ """Test that OTA cache load skips entries for endpoints removed by quirks."""
1755
+ # Register a quirk that removes endpoint 2
1756
+ (
1757
+ QuirkBuilder(
1758
+ "ota manufacturer", "ota model", registry=zigpy.quirks.DEVICE_REGISTRY
1759
+ )
1760
+ .removes_endpoint(2)
1761
+ .add_to_registry()
1762
+ )
1763
+
1764
+ db = tmp_path / "test.db"
1765
+ app = await make_app_with_db(db)
1766
+
1767
+ dev = app.add_device(nwk=0x1234, ieee=t.EUI64.convert("aa:bb:cc:dd:11:22:33:44"))
1768
+ dev.node_desc = make_node_desc(logical_type=zdo_t.LogicalType.Router)
1769
+ dev.model = "ota model"
1770
+ dev.manufacturer = "ota manufacturer"
1771
+
1772
+ ep1 = dev.add_endpoint(1)
1773
+ ep1.status = zigpy.endpoint.Status.ZDO_INIT
1774
+ ep1.profile_id = 260
1775
+ ep1.device_type = profiles.zha.DeviceType.PUMP
1776
+ basic = ep1.add_input_cluster(Basic.cluster_id)
1777
+ basic.update_attribute(Basic.AttributeDefs.model, "ota model")
1778
+ basic.update_attribute(Basic.AttributeDefs.manufacturer, "ota manufacturer")
1779
+
1780
+ ep2 = dev.add_endpoint(2)
1781
+ ep2.status = zigpy.endpoint.Status.ZDO_INIT
1782
+ ep2.profile_id = 260
1783
+ ep2.device_type = profiles.zha.DeviceType.PUMP
1784
+ ota_cluster = ep2.add_output_cluster(Ota.cluster_id)
1785
+
1786
+ ota_cluster.last_query_cmd = Ota.QueryNextImageCommand(
1787
+ field_control=Ota.QueryNextImageCommand.FieldControl(0),
1788
+ manufacturer_code=0x1234,
1789
+ image_type=0x5678,
1790
+ current_file_version=0x00000001,
1791
+ )
1792
+
1793
+ app.device_initialized(dev)
1794
+ await app.shutdown()
1795
+
1796
+ # Reload: quirk removes endpoint 2, OTA cache load should skip it
1797
+ app2 = await make_app_with_db(db)
1798
+ dev2 = app2.get_device(t.EUI64.convert("aa:bb:cc:dd:11:22:33:44"))
1799
+ assert 2 not in dev2.endpoints
1800
+ assert dev2.get_last_ota_query_cmd() is None
1801
+
1802
+ await app2.shutdown()
1803
+
1804
+
1805
+ async def test_get_last_ota_query_cmd_returns_none(tmp_path):
1806
+ """Test that get_last_ota_query_cmd returns None when no query has been cached."""
1807
+ db = tmp_path / "test.db"
1808
+ app = await make_app_with_db(db)
1809
+ ieee = make_ieee()
1810
+ app.handle_join(99, ieee, 0)
1811
+
1812
+ dev = app.get_device(ieee)
1813
+ ep = dev.add_endpoint(1)
1814
+ ep.status = zigpy.endpoint.Status.ZDO_INIT
1815
+ ep.profile_id = 260
1816
+ ep.device_type = profiles.zha.DeviceType.PUMP
1817
+ ep.add_output_cluster(Ota.cluster_id)
1818
+ app.device_initialized(dev)
1819
+
1820
+ assert dev.get_last_ota_query_cmd() is None
1821
+
1822
+ await app.shutdown()
@@ -1,6 +1,7 @@
1
1
  from datetime import UTC, datetime
2
2
  import logging
3
3
  import pathlib
4
+ import sqlite3
4
5
  from sqlite3.dump import _iterdump as iterdump
5
6
 
6
7
  from aiosqlite.context import contextmanager
@@ -8,9 +9,8 @@ import pytest
8
9
 
9
10
  from tests.async_mock import AsyncMock, MagicMock, patch
10
11
  from tests.conftest import app, make_node_desc # noqa: F401
11
- from tests.test_appdb import auto_kill_aiosqlite, make_app_with_db # noqa: F401
12
+ from tests.test_appdb import make_app_with_db
12
13
  import zigpy.appdb
13
- from zigpy.appdb import sqlite3
14
14
  import zigpy.appdb_schemas
15
15
  import zigpy.endpoint
16
16
  from zigpy.profiles import zha as zha_profile
@@ -22,6 +22,8 @@ from zigpy.zcl.clusters.general import Basic
22
22
  from zigpy.zcl.foundation import BaseAttributeDefs, Status, ZCLAttributeDef
23
23
  from zigpy.zdo import types as zdo_t
24
24
 
25
+ pytestmark = pytest.mark.usefixtures("auto_kill_aiosqlite")
26
+
25
27
 
26
28
  @pytest.fixture
27
29
  def test_db(tmp_path):
@@ -276,8 +278,13 @@ async def test_migration_missing_node_descriptor(test_db, caplog):
276
278
  [
277
279
  ("INSERT INTO node_descriptors_v4 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)", 0),
278
280
  ("INSERT INTO neighbors_v4 VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", 5),
279
- ("SELECT * FROM output_clusters", 0),
280
- ("INSERT INTO neighbors_v5 VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", 5),
281
+ ("SELECT ieee, endpoint_id, cluster FROM output_clusters", 0),
282
+ (
283
+ "INSERT INTO neighbors_v5 (device_ieee, extended_pan_id, ieee, nwk,"
284
+ " device_type, rx_on_when_idle, relationship, reserved1, permit_joining,"
285
+ " reserved2, depth, lqi) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
286
+ 5,
287
+ ),
281
288
  ],
282
289
  )
283
290
  async def test_migration_failure(fail_on_sql, fail_on_count, test_db):
@@ -476,14 +483,36 @@ async def test_migration_missing_tables(app):
476
483
  # The untouched table will never be queried
477
484
  await appdb._migrate_tables({"table1_v1": "table1_v2", "table2_v1": None})
478
485
 
479
- mock_execute.assert_called_once_with("SELECT * FROM table1_v1")
480
-
481
- with pytest.raises(AssertionError):
482
- mock_execute.assert_called_once_with("SELECT * FROM table2_v1")
486
+ # table1_v1 is queried (pragma + select), table2_v1 is not
487
+ assert any("table1_v1" in call.args[0] for call in mock_execute.call_args_list)
488
+ assert not any("table2_v1" in call.args[0] for call in mock_execute.call_args_list)
483
489
 
484
490
  await appdb.shutdown()
485
491
 
486
492
 
493
+ async def test_migrate_tables_integrity_error_raises(tmp_path):
494
+ """Test that _migrate_tables re-raises IntegrityError with errors='raise'."""
495
+ db_path = tmp_path / "test.db"
496
+ app = await make_app_with_db(db_path)
497
+
498
+ # Create two simple tables, pre-populate the destination with a conflicting row
499
+ await app._dblistener.execute(
500
+ "CREATE TABLE src_v1 (id INTEGER PRIMARY KEY, val TEXT)"
501
+ )
502
+ await app._dblistener.execute(
503
+ "CREATE TABLE dst_v2 (id INTEGER PRIMARY KEY, val TEXT)"
504
+ )
505
+ await app._dblistener.execute("INSERT INTO src_v1 VALUES (1, 'hello')")
506
+ await app._dblistener.execute("INSERT INTO dst_v2 VALUES (1, 'conflict')")
507
+
508
+ app._dblistener._get_table_versions = AsyncMock(return_value={"src_v1": "1"})
509
+
510
+ with pytest.raises(sqlite3.IntegrityError):
511
+ await app._dblistener._migrate_tables({"src_v1": "dst_v2"})
512
+
513
+ await app.shutdown()
514
+
515
+
487
516
  async def test_last_seen_initial_migration(test_db):
488
517
  test_db_v5 = test_db("simple_v5.sql")
489
518
 
@@ -541,7 +570,7 @@ async def test_unknown_manufacturer_code_migration(test_db, caplog):
541
570
  # Count rows after migration
542
571
  with sqlite3.connect(test_db_prod) as conn:
543
572
  cur = conn.cursor()
544
- cur.execute("SELECT COUNT(*) FROM attributes_cache_v14")
573
+ cur.execute(f"SELECT COUNT(*) FROM attributes_cache{zigpy.appdb.DB_V}")
545
574
  after_total = cur.fetchone()[0]
546
575
 
547
576
  assert after_total == before_total
@@ -590,9 +619,9 @@ async def test_manufacturer_code_migration_uses_device_manufacturer_id(test_db):
590
619
  with sqlite3.connect(test_db_path) as conn:
591
620
  cur = conn.cursor()
592
621
  cur.execute(
593
- """
622
+ f"""
594
623
  SELECT manufacturer_code
595
- FROM attributes_cache_v14
624
+ FROM attributes_cache{zigpy.appdb.DB_V}
596
625
  WHERE cluster_id = 0xFC00 AND attr_id = 0x0002
597
626
  """,
598
627
  )
@@ -604,9 +633,9 @@ async def test_manufacturer_code_migration_uses_device_manufacturer_id(test_db):
604
633
  with sqlite3.connect(test_db_path) as conn:
605
634
  cur = conn.cursor()
606
635
  cur.execute(
607
- """
636
+ f"""
608
637
  SELECT manufacturer_code, status
609
- FROM attributes_cache_v14
638
+ FROM attributes_cache{zigpy.appdb.DB_V}
610
639
  WHERE ieee = ? AND cluster_id = 0xFC00 AND attr_id = 0x0004
611
640
  """,
612
641
  (third_reality_ieee,),
@@ -697,7 +726,7 @@ async def test_data_migration_ambiguous_attributes(tmp_path):
697
726
 
698
727
  with sqlite3.connect(db_path) as conn:
699
728
  conn.executemany(
700
- "INSERT INTO attributes_cache_v14"
729
+ f"INSERT INTO attributes_cache{zigpy.appdb.DB_V}"
701
730
  " (ieee, endpoint_id, cluster_type, cluster_id,"
702
731
  " attr_id, manufacturer_code, status, value, last_updated)"
703
732
  " VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)",
@@ -736,7 +765,7 @@ async def test_data_migration_ambiguous_attributes(tmp_path):
736
765
  with sqlite3.connect(db_path) as conn:
737
766
  # The disambiguated unmigrated row was deleted (a row with 0xABCD already existed)
738
767
  rows = conn.execute(
739
- "SELECT manufacturer_code FROM attributes_cache_v14"
768
+ f"SELECT manufacturer_code FROM attributes_cache{zigpy.appdb.DB_V}"
740
769
  " WHERE ieee = ? AND cluster_id = ? AND attr_id = ?",
741
770
  (str(dev.ieee), 0xFC01, 0x0010),
742
771
  ).fetchall()
@@ -744,7 +773,7 @@ async def test_data_migration_ambiguous_attributes(tmp_path):
744
773
 
745
774
  # The ambiguous unmigrated row is still present
746
775
  rows = conn.execute(
747
- "SELECT manufacturer_code FROM attributes_cache_v14"
776
+ f"SELECT manufacturer_code FROM attributes_cache{zigpy.appdb.DB_V}"
748
777
  " WHERE ieee = ? AND cluster_id = ? AND attr_id = ?",
749
778
  (str(dev.ieee), 0xFC02, 0x0020),
750
779
  ).fetchall()
@@ -18,7 +18,7 @@ from zigpy.profiles import zha
18
18
  import zigpy.state
19
19
  import zigpy.types as t
20
20
  import zigpy.util
21
- from zigpy.zcl import ClusterType, foundation
21
+ from zigpy.zcl import ClusterType, OtaQueryCacheClearedEvent, foundation
22
22
  from zigpy.zcl.clusters.general import Basic, OnOff, Ota, PollControl
23
23
  from zigpy.zdo import types as zdo_t
24
24
 
@@ -108,6 +108,57 @@ async def test_initialize_read_ota(
108
108
  assert success[Ota.AttributeDefs.current_file_version.id] == 0x12345678
109
109
 
110
110
 
111
+ async def test_initialize_sends_image_notify(
112
+ app: zigpy.application.ControllerApplication,
113
+ ) -> None:
114
+ """Test that image_notify is sent to OTA devices during initialization."""
115
+ dev = app.add_device(nwk=0x1234, ieee=t.EUI64.convert("aa:bb:cc:dd:ee:ff:00:11"))
116
+ dev.node_desc = make_node_desc()
117
+
118
+ ep = dev.add_endpoint(1)
119
+ ep.status = endpoint.Status.ZDO_INIT
120
+
121
+ basic = ep.add_input_cluster(Basic.cluster_id)
122
+ ota = ep.add_output_cluster(Ota.cluster_id)
123
+
124
+ with (
125
+ mock_attribute_reads(basic, {"model": "Model", "manufacturer": "Manufacturer"}),
126
+ mock_attribute_reads(ota, {"current_file_version": 0x12345678}),
127
+ ):
128
+ await dev.initialize()
129
+
130
+ # image_notify is the last packet sent during initialization
131
+ packet = app.send_packet.call_args_list[0][0][0]
132
+ hdr, cmd = ota.deserialize(packet.data.serialize())
133
+ assert isinstance(cmd, Ota.ImageNotifyCommand)
134
+ assert cmd.payload_type == Ota.ImageNotifyCommand.PayloadType.QueryJitter
135
+ assert cmd.query_jitter == 100
136
+
137
+
138
+ async def test_initialize_image_notify_failure_does_not_block(
139
+ app: zigpy.application.ControllerApplication,
140
+ ) -> None:
141
+ """Test that a failed image_notify does not prevent initialization."""
142
+ dev = app.add_device(nwk=0x1234, ieee=t.EUI64.convert("aa:bb:cc:dd:ee:ff:00:11"))
143
+ dev.node_desc = make_node_desc()
144
+
145
+ ep = dev.add_endpoint(1)
146
+ ep.status = endpoint.Status.ZDO_INIT
147
+
148
+ basic = ep.add_input_cluster(Basic.cluster_id)
149
+ ota = ep.add_output_cluster(Ota.cluster_id)
150
+ ota.image_notify = AsyncMock(side_effect=TimeoutError)
151
+
152
+ with (
153
+ mock_attribute_reads(basic, {"model": "Model", "manufacturer": "Manufacturer"}),
154
+ mock_attribute_reads(ota, {"current_file_version": 0x12345678}),
155
+ ):
156
+ await dev.initialize()
157
+
158
+ assert dev.is_initialized
159
+ ota.image_notify.assert_awaited_once()
160
+
161
+
111
162
  async def test_initialize_read_ota_unsupported(
112
163
  app: zigpy.application.ControllerApplication,
113
164
  ) -> None:
@@ -452,19 +503,21 @@ async def test_handle_unknown_cluster(dev, caplog) -> None:
452
503
 
453
504
  async def test_update_device_firmware_no_ota_cluster(dev):
454
505
  """Test that device firmware updates fails: no ota cluster."""
506
+ mock_image = MagicMock()
507
+
455
508
  with pytest.raises(ValueError, match="Cluster 0x0019 not found"):
456
- await dev.update_firmware(sentinel.firmware_image, sentinel.progress_callback)
509
+ await dev.update_firmware(mock_image, sentinel.progress_callback)
457
510
 
458
511
  dev.add_endpoint(1)
459
512
  dev.endpoints[1].output_clusters = MagicMock(side_effect=KeyError)
460
513
  with pytest.raises(ValueError, match="Cluster 0x0019 not found"):
461
- await dev.update_firmware(sentinel.firmware_image, sentinel.progress_callback)
514
+ await dev.update_firmware(mock_image, sentinel.progress_callback)
462
515
 
463
516
 
464
517
  async def test_update_device_firmware_already_in_progress(dev, caplog):
465
518
  """Test that device firmware updates no ops when update is in progress."""
466
519
  dev.ota_in_progress = True
467
- await dev.update_firmware(sentinel.firmware_image, sentinel.progress_callback)
520
+ await dev.update_firmware(MagicMock(), sentinel.progress_callback)
468
521
  assert "OTA already in progress" in caplog.text
469
522
 
470
523
 
@@ -529,7 +582,7 @@ async def test_update_device_firmware(monkeypatch, dev, caplog):
529
582
  )
530
583
  )
531
584
 
532
- dev.application.ota.get_ota_images = MagicMock(
585
+ dev.application.ota.get_ota_images = AsyncMock(
533
586
  return_value=OtaImagesResult(upgrades=(), downgrades=())
534
587
  )
535
588
  dev.update_firmware = MagicMock(wraps=dev.update_firmware)
@@ -582,6 +635,9 @@ async def test_update_device_firmware(monkeypatch, dev, caplog):
582
635
  elif isinstance(
583
636
  cmd, Ota.ClientCommandDefs.query_next_image_response.schema
584
637
  ):
638
+ # Post-OTA image_notify triggers a query with NO_IMAGE_AVAILABLE
639
+ if cmd.status == foundation.Status.NO_IMAGE_AVAILABLE:
640
+ return
585
641
  assert cmd.status == foundation.Status.SUCCESS
586
642
  assert (
587
643
  cmd.manufacturer_code
@@ -711,6 +767,10 @@ async def test_update_device_firmware(monkeypatch, dev, caplog):
711
767
 
712
768
  dev.application.send_packet = AsyncMock(side_effect=send_packet)
713
769
  progress_callback = MagicMock()
770
+
771
+ cleared_events = []
772
+ cluster.on_event(OtaQueryCacheClearedEvent.event_type, cleared_events.append)
773
+
714
774
  result = await dev.update_firmware(fw_image, progress_callback)
715
775
  assert (
716
776
  dev.endpoints[1]
@@ -719,11 +779,18 @@ async def test_update_device_firmware(monkeypatch, dev, caplog):
719
779
  == 0x12345678
720
780
  )
721
781
 
722
- assert dev.application.send_packet.await_count == 6
782
+ # Wait for background tasks (post-OTA query_next_image handling)
783
+ await asyncio.sleep(0)
784
+ # 6 OTA + 1 post-OTA image_notify + 1 post-OTA query_next_image_response
785
+ assert dev.application.send_packet.await_count == 8
723
786
  assert progress_callback.call_count == 2
724
787
  assert progress_callback.call_args_list[0] == call(40, 70, 57.142857142857146)
725
788
  assert progress_callback.call_args_list[1] == call(70, 70, 100.0)
726
789
  assert result == foundation.Status.SUCCESS
790
+ assert len(cleared_events) == 1
791
+ assert isinstance(cleared_events[0], OtaQueryCacheClearedEvent)
792
+ # Post-OTA image_notify repopulated the query cache
793
+ assert cluster.last_query_cmd is not None
727
794
 
728
795
  progress_callback.reset_mock()
729
796
  dev.application.send_packet.reset_mock()
@@ -731,12 +798,35 @@ async def test_update_device_firmware(monkeypatch, dev, caplog):
731
798
  fw_image, progress_callback=progress_callback, force=True
732
799
  )
733
800
 
734
- assert dev.application.send_packet.await_count == 6
801
+ await asyncio.sleep(0)
802
+ # 6 OTA + 1 post-OTA image_notify + 1 post-OTA query_next_image_response
803
+ assert dev.application.send_packet.await_count == 8
735
804
  assert progress_callback.call_count == 2
736
805
  assert progress_callback.call_args_list[0] == call(40, 70, 57.142857142857146)
737
806
  assert progress_callback.call_args_list[1] == call(70, 70, 100.0)
738
807
  assert result == foundation.Status.SUCCESS
739
808
 
809
+ # Post-OTA image_notify failure: OTA still succeeds
810
+ dev.application.send_packet.reset_mock()
811
+ progress_callback.reset_mock()
812
+ caplog.clear()
813
+ original_image_notify = cluster.image_notify
814
+ notify_calls = 0
815
+
816
+ async def image_notify_fail_post_ota(*args, **kwargs):
817
+ nonlocal notify_calls
818
+ notify_calls += 1
819
+ if notify_calls > 1:
820
+ raise zigpy.exceptions.DeliveryError("Device rebooting")
821
+ return await original_image_notify(*args, **kwargs)
822
+
823
+ cluster.image_notify = image_notify_fail_post_ota
824
+ result = await dev.update_firmware(fw_image, progress_callback=progress_callback)
825
+ assert result == foundation.Status.SUCCESS
826
+ assert "Post-OTA image_notify failed" in caplog.text
827
+ cluster.image_notify = original_image_notify
828
+ caplog.clear()
829
+
740
830
  # _image_query_req exception test
741
831
  dev.application.send_packet.reset_mock()
742
832
  progress_callback.reset_mock()
@@ -816,6 +906,9 @@ async def test_update_device_firmware(monkeypatch, dev, caplog):
816
906
  elif isinstance(
817
907
  cmd, Ota.ClientCommandDefs.query_next_image_response.schema
818
908
  ):
909
+ # Post-OTA image_notify triggers a query with NO_IMAGE_AVAILABLE
910
+ if cmd.status == foundation.Status.NO_IMAGE_AVAILABLE:
911
+ return
819
912
  assert cmd.status == foundation.Status.SUCCESS
820
913
  assert cmd.manufacturer_code == fw_image.firmware.header.manufacturer_id
821
914
  assert cmd.image_type == fw_image.firmware.header.image_type
@@ -912,7 +1005,7 @@ async def test_update_legrand_device_firmware(monkeypatch, dev, caplog):
912
1005
  )
913
1006
  )
914
1007
 
915
- dev.application.ota.get_ota_images = MagicMock(
1008
+ dev.application.ota.get_ota_images = AsyncMock(
916
1009
  return_value=OtaImagesResult(upgrades=(), downgrades=())
917
1010
  )
918
1011
  dev.update_firmware = MagicMock(wraps=dev.update_firmware)
@@ -964,6 +1057,9 @@ async def test_update_legrand_device_firmware(monkeypatch, dev, caplog):
964
1057
  elif isinstance(
965
1058
  cmd, Ota.ClientCommandDefs.query_next_image_response.schema
966
1059
  ):
1060
+ # Post-OTA image_notify triggers a query with NO_IMAGE_AVAILABLE
1061
+ if cmd.status == foundation.Status.NO_IMAGE_AVAILABLE:
1062
+ return
967
1063
  assert cmd.status == foundation.Status.SUCCESS
968
1064
  assert (
969
1065
  cmd.manufacturer_code
@@ -1101,7 +1197,10 @@ async def test_update_legrand_device_firmware(monkeypatch, dev, caplog):
1101
1197
  == 0x12345678
1102
1198
  )
1103
1199
 
1104
- assert dev.application.send_packet.await_count == 6
1200
+ # Wait for background tasks (post-OTA query_next_image handling)
1201
+ await asyncio.sleep(0)
1202
+ # 6 OTA + 1 post-OTA image_notify + 1 post-OTA query_next_image_response
1203
+ assert dev.application.send_packet.await_count == 8
1105
1204
  assert progress_callback.call_count == 2
1106
1205
  assert progress_callback.call_args_list[0] == call(64, 70, 91.42857142857143)
1107
1206
  assert progress_callback.call_args_list[1] == call(70, 70, 100.0)
@@ -1113,7 +1212,9 @@ async def test_update_legrand_device_firmware(monkeypatch, dev, caplog):
1113
1212
  fw_image, progress_callback=progress_callback, force=True
1114
1213
  )
1115
1214
 
1116
- assert dev.application.send_packet.await_count == 6
1215
+ await asyncio.sleep(0)
1216
+ # 6 OTA + 1 post-OTA image_notify + 1 post-OTA query_next_image_response
1217
+ assert dev.application.send_packet.await_count == 8
1117
1218
  assert progress_callback.call_count == 2
1118
1219
  assert progress_callback.call_args_list[0] == call(64, 70, 91.42857142857143)
1119
1220
  assert progress_callback.call_args_list[1] == call(70, 70, 100.0)
@@ -1198,6 +1299,9 @@ async def test_update_legrand_device_firmware(monkeypatch, dev, caplog):
1198
1299
  elif isinstance(
1199
1300
  cmd, Ota.ClientCommandDefs.query_next_image_response.schema
1200
1301
  ):
1302
+ # Post-OTA image_notify triggers a query with NO_IMAGE_AVAILABLE
1303
+ if cmd.status == foundation.Status.NO_IMAGE_AVAILABLE:
1304
+ return
1201
1305
  assert cmd.status == foundation.Status.SUCCESS
1202
1306
  assert cmd.manufacturer_code == fw_image.firmware.header.manufacturer_id
1203
1307
  assert cmd.image_type == fw_image.firmware.header.image_type