zigpy 1.1.1__tar.gz → 1.2.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.
- {zigpy-1.1.1/zigpy.egg-info → zigpy-1.2.0}/PKG-INFO +1 -1
- {zigpy-1.1.1 → zigpy-1.2.0}/tests/test_appdb.py +235 -22
- {zigpy-1.1.1 → zigpy-1.2.0}/tests/test_appdb_migration.py +45 -16
- {zigpy-1.1.1 → zigpy-1.2.0}/tests/test_device.py +114 -10
- {zigpy-1.1.1 → zigpy-1.2.0}/tests/test_quirks_v2.py +50 -29
- {zigpy-1.1.1 → zigpy-1.2.0}/tests/test_zcl.py +44 -34
- {zigpy-1.1.1 → zigpy-1.2.0}/tests/test_zcl_clusters.py +93 -40
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/appdb.py +159 -8
- zigpy-1.2.0/zigpy/appdb_schemas/schema_v15.sql +218 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/device.py +41 -2
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/ota/__init__.py +176 -33
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/ota/manager.py +7 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/ota/providers.py +3 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/quirks/registry.py +1 -1
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/quirks/v2/__init__.py +29 -14
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/zcl/__init__.py +67 -43
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/zcl/clusters/__init__.py +2 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/zcl/clusters/general.py +40 -22
- zigpy-1.2.0/zigpy/zcl/clusters/wwah.py +509 -0
- {zigpy-1.1.1 → zigpy-1.2.0/zigpy.egg-info}/PKG-INFO +1 -1
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy.egg-info/SOURCES.txt +2 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/COPYING +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/LICENSE +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/README.md +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/pyproject.toml +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/setup.cfg +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/setup.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/tests/test_app_state.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/tests/test_application.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/tests/test_backups.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/tests/test_config.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/tests/test_datastructures.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/tests/test_datastructures_cpython.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/tests/test_endpoint.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/tests/test_event.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/tests/test_group.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/tests/test_listeners.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/tests/test_packet_callbacks.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/tests/test_quirks.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/tests/test_quirks_registry.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/tests/test_serial.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/tests/test_struct.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/tests/test_topology.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/tests/test_types.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/tests/test_zcl_foundation.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/tests/test_zcl_helpers.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/tests/test_zdo.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/tests/test_zdo_types.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/tests/test_zigbee_util.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/tools/__init__.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/tools/regenerate_mypy_ignores.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/__init__.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/appdb_schemas/__init__.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/appdb_schemas/schema_v0.sql +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/appdb_schemas/schema_v1.sql +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/appdb_schemas/schema_v10.sql +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/appdb_schemas/schema_v11.sql +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/appdb_schemas/schema_v12.sql +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/appdb_schemas/schema_v13.sql +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/appdb_schemas/schema_v14.sql +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/appdb_schemas/schema_v2.sql +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/appdb_schemas/schema_v3.sql +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/appdb_schemas/schema_v4.sql +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/appdb_schemas/schema_v5.sql +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/appdb_schemas/schema_v6.sql +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/appdb_schemas/schema_v7.sql +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/appdb_schemas/schema_v8.sql +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/appdb_schemas/schema_v9.sql +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/application.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/backports/__init__.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/backups.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/config/__init__.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/config/defaults.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/config/validators.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/const.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/datastructures.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/endpoint.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/event/__init__.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/event/event_base.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/exceptions.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/group.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/listeners.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/ota/image.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/ota/json_schemas.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/ota/validators.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/profiles/__init__.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/profiles/zgp.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/profiles/zha.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/profiles/zll.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/py.typed +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/quirks/__init__.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/quirks/v2/homeassistant/__init__.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/quirks/v2/homeassistant/binary_sensor.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/quirks/v2/homeassistant/number.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/quirks/v2/homeassistant/sensor.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/serial.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/state.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/topology.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/types/__init__.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/types/basic.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/types/named.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/types/struct.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/typing.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/util.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/zcl/clusters/closures.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/zcl/clusters/general_const.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/zcl/clusters/greenpower.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/zcl/clusters/homeautomation.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/zcl/clusters/hvac.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/zcl/clusters/lighting.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/zcl/clusters/lightlink.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/zcl/clusters/manufacturer_specific.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/zcl/clusters/measurement.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/zcl/clusters/protocol.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/zcl/clusters/security.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/zcl/clusters/smartenergy.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/zcl/foundation.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/zcl/helpers.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/zdo/__init__.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/zdo/types.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/zgp/__init__.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy/zgp/types.py +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy.egg-info/dependency_links.txt +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy.egg-info/requires.txt +0 -0
- {zigpy-1.1.1 → zigpy-1.2.0}/zigpy.egg-info/top_level.txt +0 -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
|
|
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
|
|
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
|
|
280
|
-
(
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|