zigpy 1.3.0__tar.gz → 1.4.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.
- {zigpy-1.3.0/zigpy.egg-info → zigpy-1.4.1}/PKG-INFO +1 -1
- {zigpy-1.3.0 → zigpy-1.4.1}/tests/test_application.py +201 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/tests/test_device.py +224 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/application.py +84 -3
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/device.py +81 -6
- {zigpy-1.3.0 → zigpy-1.4.1/zigpy.egg-info}/PKG-INFO +1 -1
- {zigpy-1.3.0 → zigpy-1.4.1}/COPYING +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/LICENSE +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/README.md +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/pyproject.toml +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/setup.cfg +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/setup.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/tests/test_app_state.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/tests/test_appdb.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/tests/test_appdb_migration.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/tests/test_backups.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/tests/test_config.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/tests/test_datastructures.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/tests/test_datastructures_cpython.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/tests/test_endpoint.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/tests/test_event.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/tests/test_group.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/tests/test_listeners.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/tests/test_packet_callbacks.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/tests/test_quirks.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/tests/test_quirks_registry.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/tests/test_quirks_v2.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/tests/test_serial.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/tests/test_struct.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/tests/test_topology.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/tests/test_types.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/tests/test_zcl.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/tests/test_zcl_clusters.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/tests/test_zcl_foundation.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/tests/test_zcl_helpers.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/tests/test_zdo.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/tests/test_zdo_types.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/tests/test_zigbee_util.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/tools/__init__.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/tools/regenerate_mypy_ignores.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/__init__.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/appdb.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/appdb_schemas/__init__.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/appdb_schemas/schema_v0.sql +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/appdb_schemas/schema_v1.sql +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/appdb_schemas/schema_v10.sql +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/appdb_schemas/schema_v11.sql +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/appdb_schemas/schema_v12.sql +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/appdb_schemas/schema_v13.sql +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/appdb_schemas/schema_v14.sql +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/appdb_schemas/schema_v15.sql +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/appdb_schemas/schema_v2.sql +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/appdb_schemas/schema_v3.sql +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/appdb_schemas/schema_v4.sql +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/appdb_schemas/schema_v5.sql +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/appdb_schemas/schema_v6.sql +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/appdb_schemas/schema_v7.sql +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/appdb_schemas/schema_v8.sql +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/appdb_schemas/schema_v9.sql +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/backports/__init__.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/backups.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/config/__init__.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/config/defaults.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/config/validators.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/const.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/datastructures.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/endpoint.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/event/__init__.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/event/event_base.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/exceptions.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/group.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/listeners.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/ota/__init__.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/ota/image.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/ota/json_schemas.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/ota/manager.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/ota/providers.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/ota/validators.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/profiles/__init__.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/profiles/zgp.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/profiles/zha.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/profiles/zll.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/py.typed +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/quirks/__init__.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/quirks/registry.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/quirks/v2/__init__.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/quirks/v2/homeassistant/__init__.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/quirks/v2/homeassistant/binary_sensor.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/quirks/v2/homeassistant/number.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/quirks/v2/homeassistant/sensor.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/serial.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/state.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/topology.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/types/__init__.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/types/basic.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/types/named.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/types/struct.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/typing.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/util.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/zcl/__init__.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/zcl/clusters/__init__.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/zcl/clusters/closures.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/zcl/clusters/general.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/zcl/clusters/general_const.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/zcl/clusters/greenpower.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/zcl/clusters/homeautomation.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/zcl/clusters/hvac.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/zcl/clusters/lighting.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/zcl/clusters/lightlink.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/zcl/clusters/manufacturer_specific.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/zcl/clusters/measurement.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/zcl/clusters/protocol.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/zcl/clusters/security.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/zcl/clusters/smartenergy.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/zcl/clusters/wwah.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/zcl/foundation.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/zcl/helpers.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/zdo/__init__.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/zdo/types.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/zgp/__init__.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy/zgp/types.py +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy.egg-info/SOURCES.txt +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy.egg-info/dependency_links.txt +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy.egg-info/requires.txt +0 -0
- {zigpy-1.3.0 → zigpy-1.4.1}/zigpy.egg-info/top_level.txt +0 -0
|
@@ -12,6 +12,8 @@ import pytest
|
|
|
12
12
|
import zigpy.application
|
|
13
13
|
import zigpy.config as conf
|
|
14
14
|
from zigpy.datastructures import RequestLimiter
|
|
15
|
+
import zigpy.device
|
|
16
|
+
import zigpy.endpoint
|
|
15
17
|
from zigpy.exceptions import (
|
|
16
18
|
DeliveryError,
|
|
17
19
|
NetworkNotFormed,
|
|
@@ -1862,3 +1864,202 @@ async def test_callback_wrapping_async(
|
|
|
1862
1864
|
),
|
|
1863
1865
|
),
|
|
1864
1866
|
]
|
|
1867
|
+
|
|
1868
|
+
|
|
1869
|
+
async def test_device_reinterviewed_with_db(app):
|
|
1870
|
+
"""Test _device_reinterviewed removes old device from DB before saving new one."""
|
|
1871
|
+
|
|
1872
|
+
ieee = make_ieee()
|
|
1873
|
+
nwk = t.NWK(0x1234)
|
|
1874
|
+
|
|
1875
|
+
old_dev = app.add_device(ieee=ieee, nwk=nwk)
|
|
1876
|
+
old_dev.node_desc = make_node_desc()
|
|
1877
|
+
|
|
1878
|
+
# Set up a mock DB listener
|
|
1879
|
+
db_listener = MagicMock()
|
|
1880
|
+
db_listener._remove_device = AsyncMock()
|
|
1881
|
+
app._dblistener = db_listener
|
|
1882
|
+
old_dev.add_context_listener(db_listener)
|
|
1883
|
+
|
|
1884
|
+
shadow = zigpy.device.Device(app, ieee, nwk)
|
|
1885
|
+
shadow.node_desc = make_node_desc()
|
|
1886
|
+
shadow.status = zigpy.device.Status.ENDPOINTS_INIT
|
|
1887
|
+
ep = shadow.add_endpoint(1)
|
|
1888
|
+
ep.profile_id = 260
|
|
1889
|
+
ep.device_type = 0x0100
|
|
1890
|
+
ep.status = zigpy.endpoint.Status.ZDO_INIT
|
|
1891
|
+
|
|
1892
|
+
await app._device_reinterviewed(old_dev, shadow)
|
|
1893
|
+
|
|
1894
|
+
# DB removal should have been called for the old device
|
|
1895
|
+
db_listener._remove_device.assert_awaited_once_with(old_dev)
|
|
1896
|
+
|
|
1897
|
+
|
|
1898
|
+
async def test_reinterview_device_public_api(app):
|
|
1899
|
+
"""Test reinterview_device delegates to dev.reinterview()."""
|
|
1900
|
+
ieee = make_ieee()
|
|
1901
|
+
nwk = t.NWK(0x1234)
|
|
1902
|
+
dev = app.add_device(ieee=ieee, nwk=nwk)
|
|
1903
|
+
dev.reinterview = AsyncMock()
|
|
1904
|
+
|
|
1905
|
+
await app.reinterview_device(ieee)
|
|
1906
|
+
|
|
1907
|
+
dev.reinterview.assert_awaited_once()
|
|
1908
|
+
|
|
1909
|
+
|
|
1910
|
+
async def test_reinterview_device_not_found(app):
|
|
1911
|
+
"""Test reinterview_device raises KeyError for unknown device."""
|
|
1912
|
+
with pytest.raises(KeyError):
|
|
1913
|
+
await app.reinterview_device(make_ieee(99))
|
|
1914
|
+
|
|
1915
|
+
|
|
1916
|
+
async def test_device_reinterviewed_preserves_groups(app):
|
|
1917
|
+
"""Test _device_reinterviewed migrates group memberships to the new device."""
|
|
1918
|
+
ieee = make_ieee()
|
|
1919
|
+
nwk = t.NWK(0x1234)
|
|
1920
|
+
|
|
1921
|
+
old_dev = app.add_device(ieee=ieee, nwk=nwk)
|
|
1922
|
+
old_dev.node_desc = make_node_desc()
|
|
1923
|
+
old_ep = old_dev.add_endpoint(1)
|
|
1924
|
+
old_ep.profile_id = 260
|
|
1925
|
+
old_ep.device_type = 0x0100
|
|
1926
|
+
old_ep.status = zigpy.endpoint.Status.ZDO_INIT
|
|
1927
|
+
|
|
1928
|
+
# Add old endpoint to groups
|
|
1929
|
+
group_10 = app.groups.add_group(10, "Group 10")
|
|
1930
|
+
group_20 = app.groups.add_group(20, "Group 20")
|
|
1931
|
+
group_10.add_member(old_ep, suppress_event=True)
|
|
1932
|
+
group_20.add_member(old_ep, suppress_event=True)
|
|
1933
|
+
assert old_ep.unique_id in group_10
|
|
1934
|
+
assert old_ep.unique_id in group_20
|
|
1935
|
+
|
|
1936
|
+
# Create shadow with matching endpoint
|
|
1937
|
+
shadow = zigpy.device.Device(app, ieee, nwk)
|
|
1938
|
+
shadow.node_desc = make_node_desc()
|
|
1939
|
+
shadow.status = zigpy.device.Status.ENDPOINTS_INIT
|
|
1940
|
+
new_ep = shadow.add_endpoint(1)
|
|
1941
|
+
new_ep.profile_id = 260
|
|
1942
|
+
new_ep.device_type = 0x0100
|
|
1943
|
+
new_ep.status = zigpy.endpoint.Status.ZDO_INIT
|
|
1944
|
+
|
|
1945
|
+
await app._device_reinterviewed(old_dev, shadow)
|
|
1946
|
+
|
|
1947
|
+
new_dev = app.devices[ieee]
|
|
1948
|
+
new_ep = new_dev.endpoints[1]
|
|
1949
|
+
|
|
1950
|
+
# Group members should point to new endpoint objects, not old ones
|
|
1951
|
+
assert group_10[new_ep.unique_id] is new_ep
|
|
1952
|
+
assert group_10[new_ep.unique_id] is not old_ep
|
|
1953
|
+
assert group_20[new_ep.unique_id] is new_ep
|
|
1954
|
+
assert group_20[new_ep.unique_id] is not old_ep
|
|
1955
|
+
|
|
1956
|
+
# New endpoint should know about its group memberships
|
|
1957
|
+
assert 10 in new_ep.member_of
|
|
1958
|
+
assert 20 in new_ep.member_of
|
|
1959
|
+
|
|
1960
|
+
# Old endpoint should no longer be a member
|
|
1961
|
+
assert not old_ep.member_of
|
|
1962
|
+
|
|
1963
|
+
|
|
1964
|
+
async def test_device_reinterviewed_skips_group_removed_during_swap(app):
|
|
1965
|
+
"""If a group is removed while `_remove_device` is awaiting, restoration skips it
|
|
1966
|
+
instead of raising and aborting the reinterview.
|
|
1967
|
+
"""
|
|
1968
|
+
ieee = make_ieee()
|
|
1969
|
+
nwk = t.NWK(0x1234)
|
|
1970
|
+
|
|
1971
|
+
old_dev = app.add_device(ieee=ieee, nwk=nwk)
|
|
1972
|
+
old_dev.node_desc = make_node_desc()
|
|
1973
|
+
old_ep = old_dev.add_endpoint(1)
|
|
1974
|
+
old_ep.profile_id = 260
|
|
1975
|
+
old_ep.device_type = 0x0100
|
|
1976
|
+
old_ep.status = zigpy.endpoint.Status.ZDO_INIT
|
|
1977
|
+
|
|
1978
|
+
surviving = app.groups.add_group(10, "Group 10")
|
|
1979
|
+
doomed = app.groups.add_group(20, "Group 20")
|
|
1980
|
+
surviving.add_member(old_ep, suppress_event=True)
|
|
1981
|
+
doomed.add_member(old_ep, suppress_event=True)
|
|
1982
|
+
|
|
1983
|
+
# Simulate the race: between `_remove_device` returning and group restoration
|
|
1984
|
+
# running, group 20 is removed. We trigger this by patching the dblistener's
|
|
1985
|
+
# `_remove_device` to drop the group as a side effect.
|
|
1986
|
+
db_listener = MagicMock()
|
|
1987
|
+
|
|
1988
|
+
async def fake_remove(_dev):
|
|
1989
|
+
app.groups.pop(20, None)
|
|
1990
|
+
|
|
1991
|
+
db_listener._remove_device = AsyncMock(side_effect=fake_remove)
|
|
1992
|
+
app._dblistener = db_listener
|
|
1993
|
+
old_dev.add_context_listener(db_listener)
|
|
1994
|
+
|
|
1995
|
+
shadow = zigpy.device.Device(app, ieee, nwk)
|
|
1996
|
+
shadow.node_desc = make_node_desc()
|
|
1997
|
+
shadow.status = zigpy.device.Status.ENDPOINTS_INIT
|
|
1998
|
+
new_ep = shadow.add_endpoint(1)
|
|
1999
|
+
new_ep.profile_id = 260
|
|
2000
|
+
new_ep.device_type = 0x0100
|
|
2001
|
+
new_ep.status = zigpy.endpoint.Status.ZDO_INIT
|
|
2002
|
+
|
|
2003
|
+
await app._device_reinterviewed(old_dev, shadow)
|
|
2004
|
+
|
|
2005
|
+
new_dev = app.devices[ieee]
|
|
2006
|
+
new_ep = new_dev.endpoints[1]
|
|
2007
|
+
|
|
2008
|
+
# Surviving group has the new endpoint; doomed group is gone, no KeyError raised.
|
|
2009
|
+
assert new_ep.unique_id in surviving
|
|
2010
|
+
assert 20 not in app.groups
|
|
2011
|
+
app.listener_event.assert_any_call("device_reinterviewed", new_dev)
|
|
2012
|
+
|
|
2013
|
+
|
|
2014
|
+
async def test_device_reinterviewed_finalization_failure_propagates(app):
|
|
2015
|
+
"""Test that _device_reinterviewed lets exceptions propagate to reinterview()."""
|
|
2016
|
+
ieee = make_ieee()
|
|
2017
|
+
nwk = t.NWK(0x1234)
|
|
2018
|
+
|
|
2019
|
+
old_dev = app.add_device(ieee=ieee, nwk=nwk)
|
|
2020
|
+
old_dev.node_desc = make_node_desc()
|
|
2021
|
+
old_dev.model = "OldModel"
|
|
2022
|
+
|
|
2023
|
+
shadow = zigpy.device.Device(app, ieee, nwk)
|
|
2024
|
+
shadow.node_desc = make_node_desc()
|
|
2025
|
+
shadow.status = zigpy.device.Status.ENDPOINTS_INIT
|
|
2026
|
+
|
|
2027
|
+
# Make _finalize_device fail
|
|
2028
|
+
app._finalize_device = Mock(side_effect=RuntimeError("finalization boom"))
|
|
2029
|
+
|
|
2030
|
+
# Exception should propagate — reinterview() is responsible for restoration
|
|
2031
|
+
with pytest.raises(RuntimeError, match="finalization boom"):
|
|
2032
|
+
await app._device_reinterviewed(old_dev, shadow)
|
|
2033
|
+
|
|
2034
|
+
|
|
2035
|
+
async def test_device_reinterviewed_persists_relays(app):
|
|
2036
|
+
"""Test that relays are re-persisted after reinterview."""
|
|
2037
|
+
ieee = make_ieee()
|
|
2038
|
+
nwk = t.NWK(0x1234)
|
|
2039
|
+
|
|
2040
|
+
old_dev = app.add_device(ieee=ieee, nwk=nwk)
|
|
2041
|
+
old_dev.node_desc = make_node_desc()
|
|
2042
|
+
old_dev._relays = t.Relays([t.NWK(0xAAAA)])
|
|
2043
|
+
|
|
2044
|
+
db_listener = MagicMock()
|
|
2045
|
+
db_listener._remove_device = AsyncMock()
|
|
2046
|
+
app._dblistener = db_listener
|
|
2047
|
+
old_dev.add_context_listener(db_listener)
|
|
2048
|
+
|
|
2049
|
+
shadow = zigpy.device.Device(app, ieee, nwk)
|
|
2050
|
+
shadow.node_desc = make_node_desc()
|
|
2051
|
+
shadow.status = zigpy.device.Status.ENDPOINTS_INIT
|
|
2052
|
+
ep = shadow.add_endpoint(1)
|
|
2053
|
+
ep.profile_id = 260
|
|
2054
|
+
ep.device_type = 0x0100
|
|
2055
|
+
ep.status = zigpy.endpoint.Status.ZDO_INIT
|
|
2056
|
+
|
|
2057
|
+
await app._device_reinterviewed(old_dev, shadow)
|
|
2058
|
+
|
|
2059
|
+
new_dev = app.devices[ieee]
|
|
2060
|
+
|
|
2061
|
+
# Relays should have been copied and re-persisted
|
|
2062
|
+
assert new_dev._relays == t.Relays([t.NWK(0xAAAA)])
|
|
2063
|
+
db_listener.device_relays_updated.assert_called_once_with(
|
|
2064
|
+
new_dev, t.Relays([t.NWK(0xAAAA)])
|
|
2065
|
+
)
|
|
@@ -547,6 +547,7 @@ async def test_update_device_firmware(monkeypatch, dev, caplog):
|
|
|
547
547
|
monkeypatch.setattr(endpoint.Endpoint, "initialize", mockepinit)
|
|
548
548
|
monkeypatch.setattr(endpoint.Endpoint, "get_model_info", mock_ep_get_model_info)
|
|
549
549
|
dev.zdo.Active_EP_req = mockrequest
|
|
550
|
+
dev.reinterview = AsyncMock() # prevent post-OTA reinterview side effects
|
|
550
551
|
|
|
551
552
|
with mock_attribute_reads(cluster, {"current_file_version": 0x00000001}):
|
|
552
553
|
await dev.initialize()
|
|
@@ -970,6 +971,7 @@ async def test_update_legrand_device_firmware(monkeypatch, dev, caplog):
|
|
|
970
971
|
monkeypatch.setattr(endpoint.Endpoint, "initialize", mockepinit)
|
|
971
972
|
monkeypatch.setattr(endpoint.Endpoint, "get_model_info", mock_ep_get_model_info)
|
|
972
973
|
dev.zdo.Active_EP_req = mockrequest
|
|
974
|
+
dev.reinterview = AsyncMock() # prevent post-OTA reinterview side effects
|
|
973
975
|
|
|
974
976
|
with mock_attribute_reads(cluster, {"current_file_version": 0x00000001}):
|
|
975
977
|
await dev.initialize()
|
|
@@ -2003,3 +2005,225 @@ async def test_attribute_report_not_matched_with_request(dev):
|
|
|
2003
2005
|
result = await request_task
|
|
2004
2006
|
|
|
2005
2007
|
assert result == default_rsp_cmd
|
|
2008
|
+
|
|
2009
|
+
|
|
2010
|
+
async def test_reinterview_end_to_end(
|
|
2011
|
+
monkeypatch,
|
|
2012
|
+
app: zigpy.application.ControllerApplication,
|
|
2013
|
+
):
|
|
2014
|
+
"""End-to-end reinterview: real app, full flow from reinterview() through swap."""
|
|
2015
|
+
ieee = t.EUI64.convert("aa:bb:cc:dd:ee:ff:00:11")
|
|
2016
|
+
node_desc = zdo_t.NodeDescriptor(1, 1, 1, 4, 5, 6, 7, 8)
|
|
2017
|
+
|
|
2018
|
+
async def mock_get_node_descriptor(self):
|
|
2019
|
+
self.node_desc = node_desc
|
|
2020
|
+
return node_desc
|
|
2021
|
+
|
|
2022
|
+
async def mockrequest(*args, **kwargs):
|
|
2023
|
+
return [0, None, [0, 1]]
|
|
2024
|
+
|
|
2025
|
+
async def mockepinit(self, *args, **kwargs):
|
|
2026
|
+
self.status = endpoint.Status.ZDO_INIT
|
|
2027
|
+
self.add_input_cluster(Basic.cluster_id)
|
|
2028
|
+
|
|
2029
|
+
async def mock_ep_get_model_info(self):
|
|
2030
|
+
return "Model", "Manufacturer"
|
|
2031
|
+
|
|
2032
|
+
monkeypatch.setattr(device.Device, "get_node_descriptor", mock_get_node_descriptor)
|
|
2033
|
+
monkeypatch.setattr(endpoint.Endpoint, "initialize", mockepinit)
|
|
2034
|
+
monkeypatch.setattr(endpoint.Endpoint, "get_model_info", mock_ep_get_model_info)
|
|
2035
|
+
|
|
2036
|
+
# Initial join and initialization
|
|
2037
|
+
dev = app.add_device(ieee=ieee, nwk=t.NWK(0x1234))
|
|
2038
|
+
dev.zdo.Active_EP_req = mockrequest
|
|
2039
|
+
await dev.initialize()
|
|
2040
|
+
assert dev.is_initialized
|
|
2041
|
+
old_dev = app.devices[ieee]
|
|
2042
|
+
|
|
2043
|
+
# Set non-discovery state on the old device
|
|
2044
|
+
old_dev._last_seen = datetime(2026, 1, 1, tzinfo=UTC)
|
|
2045
|
+
old_dev.lqi = 200
|
|
2046
|
+
old_dev.rssi = -40
|
|
2047
|
+
|
|
2048
|
+
# Patch Device.__init__ so the shadow also gets Active_EP_req mocked
|
|
2049
|
+
original_init = device.Device.__init__
|
|
2050
|
+
|
|
2051
|
+
def patched_init(self, *args, **kwargs):
|
|
2052
|
+
original_init(self, *args, **kwargs)
|
|
2053
|
+
self.zdo.Active_EP_req = mockrequest
|
|
2054
|
+
|
|
2055
|
+
monkeypatch.setattr(device.Device, "__init__", patched_init)
|
|
2056
|
+
|
|
2057
|
+
# Change what model info returns for the reinterview
|
|
2058
|
+
async def mock_ep_get_model_info_v2(self):
|
|
2059
|
+
return "ModelV2", "ManufacturerV2"
|
|
2060
|
+
|
|
2061
|
+
monkeypatch.setattr(endpoint.Endpoint, "get_model_info", mock_ep_get_model_info_v2)
|
|
2062
|
+
|
|
2063
|
+
# Reset listener_event mock so we only see events from the reinterview
|
|
2064
|
+
app.listener_event.reset_mock()
|
|
2065
|
+
|
|
2066
|
+
# Run the full reinterview flow
|
|
2067
|
+
await old_dev.reinterview()
|
|
2068
|
+
|
|
2069
|
+
# The device in app.devices should be a new object
|
|
2070
|
+
new_dev = app.devices[ieee]
|
|
2071
|
+
assert new_dev is not old_dev
|
|
2072
|
+
assert new_dev.model == "ModelV2"
|
|
2073
|
+
assert new_dev.manufacturer == "ManufacturerV2"
|
|
2074
|
+
assert new_dev.is_initialized
|
|
2075
|
+
assert 1 in new_dev.endpoints
|
|
2076
|
+
assert not new_dev.reinterviewing
|
|
2077
|
+
assert not old_dev.reinterviewing
|
|
2078
|
+
|
|
2079
|
+
# Non-discovery state should have been preserved
|
|
2080
|
+
assert new_dev._last_seen == datetime(2026, 1, 1, tzinfo=UTC)
|
|
2081
|
+
assert new_dev.lqi == 200
|
|
2082
|
+
assert new_dev.rssi == -40
|
|
2083
|
+
|
|
2084
|
+
# device_reinterviewed event should have been fired, but NOT device_initialized
|
|
2085
|
+
app.listener_event.assert_any_call("device_reinterviewed", new_dev)
|
|
2086
|
+
device_initialized_calls = [
|
|
2087
|
+
c for c in app.listener_event.call_args_list if c[0][0] == "device_initialized"
|
|
2088
|
+
]
|
|
2089
|
+
assert len(device_initialized_calls) == 0
|
|
2090
|
+
|
|
2091
|
+
|
|
2092
|
+
@pytest.mark.parametrize(
|
|
2093
|
+
"exception",
|
|
2094
|
+
[TimeoutError, RuntimeError("unexpected")],
|
|
2095
|
+
ids=["timeout", "unexpected"],
|
|
2096
|
+
)
|
|
2097
|
+
async def test_reinterview_failure_preserves_device(monkeypatch, dev, exception):
|
|
2098
|
+
"""Test that failed re-interview preserves the old device regardless of exception type."""
|
|
2099
|
+
|
|
2100
|
+
async def mockrequest_success(*args, **kwargs):
|
|
2101
|
+
return [0, None, [0, 1]]
|
|
2102
|
+
|
|
2103
|
+
async def mockepinit(self, *args, **kwargs):
|
|
2104
|
+
self.status = endpoint.Status.ZDO_INIT
|
|
2105
|
+
self.add_input_cluster(Basic.cluster_id)
|
|
2106
|
+
|
|
2107
|
+
async def mock_ep_get_model_info(self):
|
|
2108
|
+
return "OldModel", "OldManufacturer"
|
|
2109
|
+
|
|
2110
|
+
monkeypatch.setattr(endpoint.Endpoint, "initialize", mockepinit)
|
|
2111
|
+
monkeypatch.setattr(endpoint.Endpoint, "get_model_info", mock_ep_get_model_info)
|
|
2112
|
+
|
|
2113
|
+
# First initialize normally
|
|
2114
|
+
dev.zdo.Active_EP_req = mockrequest_success
|
|
2115
|
+
await dev.initialize()
|
|
2116
|
+
assert dev.model == "OldModel"
|
|
2117
|
+
|
|
2118
|
+
# Use a real dict for devices so we can verify restoration
|
|
2119
|
+
dev._application.devices = {dev.ieee: dev}
|
|
2120
|
+
|
|
2121
|
+
# Make the shadow's discovery fail
|
|
2122
|
+
monkeypatch.setattr(
|
|
2123
|
+
device.Device, "get_node_descriptor", AsyncMock(side_effect=exception)
|
|
2124
|
+
)
|
|
2125
|
+
|
|
2126
|
+
dev._application._device_reinterviewed = AsyncMock()
|
|
2127
|
+
|
|
2128
|
+
await dev.reinterview()
|
|
2129
|
+
|
|
2130
|
+
# _device_reinterviewed should NOT have been called
|
|
2131
|
+
dev._application._device_reinterviewed.assert_not_called()
|
|
2132
|
+
|
|
2133
|
+
# Old device should be restored in app.devices
|
|
2134
|
+
assert dev._application.devices[dev.ieee] is dev
|
|
2135
|
+
|
|
2136
|
+
# Old device is completely untouched
|
|
2137
|
+
assert dev.model == "OldModel"
|
|
2138
|
+
assert dev.manufacturer == "OldManufacturer"
|
|
2139
|
+
assert 1 in dev.endpoints
|
|
2140
|
+
assert dev.is_initialized
|
|
2141
|
+
assert not dev.reinterviewing
|
|
2142
|
+
|
|
2143
|
+
# Failure event was fired
|
|
2144
|
+
dev._application.listener_event.assert_called_with(
|
|
2145
|
+
"device_reinterview_failure", dev
|
|
2146
|
+
)
|
|
2147
|
+
|
|
2148
|
+
|
|
2149
|
+
async def test_reinterview_blocks_auto_init(dev):
|
|
2150
|
+
"""Test that schedule_initialize is a no-op while reinterviewing."""
|
|
2151
|
+
dev._reinterview_in_progress = True
|
|
2152
|
+
|
|
2153
|
+
result = dev.schedule_initialize()
|
|
2154
|
+
|
|
2155
|
+
assert result is None
|
|
2156
|
+
assert not dev.initializing
|
|
2157
|
+
|
|
2158
|
+
|
|
2159
|
+
async def test_reinterview_already_in_progress(dev):
|
|
2160
|
+
"""Test that concurrent reinterview calls are prevented."""
|
|
2161
|
+
dev._reinterview_in_progress = True
|
|
2162
|
+
dev._application._device_reinterviewed = AsyncMock()
|
|
2163
|
+
|
|
2164
|
+
await dev.reinterview()
|
|
2165
|
+
|
|
2166
|
+
dev._application._device_reinterviewed.assert_not_called()
|
|
2167
|
+
|
|
2168
|
+
|
|
2169
|
+
async def test_reinterview_during_initialization(dev):
|
|
2170
|
+
"""Test that reinterview is skipped if initialization is in progress."""
|
|
2171
|
+
dev._initialize_task = asyncio.Future() # simulate in-progress init
|
|
2172
|
+
dev._application._device_reinterviewed = AsyncMock()
|
|
2173
|
+
|
|
2174
|
+
await dev.reinterview()
|
|
2175
|
+
|
|
2176
|
+
dev._application._device_reinterviewed.assert_not_called()
|
|
2177
|
+
dev._initialize_task.cancel()
|
|
2178
|
+
|
|
2179
|
+
|
|
2180
|
+
async def test_reinterview_during_ota(dev):
|
|
2181
|
+
"""Test that reinterview is skipped while an OTA is in progress."""
|
|
2182
|
+
dev.ota_in_progress = True
|
|
2183
|
+
dev._application._device_reinterviewed = AsyncMock()
|
|
2184
|
+
|
|
2185
|
+
await dev.reinterview()
|
|
2186
|
+
|
|
2187
|
+
dev._application._device_reinterviewed.assert_not_called()
|
|
2188
|
+
|
|
2189
|
+
|
|
2190
|
+
async def test_update_firmware_triggers_reinterview(monkeypatch, dev):
|
|
2191
|
+
"""Test that successful OTA triggers reinterview."""
|
|
2192
|
+
ep = dev.add_endpoint(1)
|
|
2193
|
+
cluster = zigpy.zcl.Cluster.from_id(ep, Ota.cluster_id, is_server=False)
|
|
2194
|
+
ep.add_output_cluster(Ota.cluster_id, cluster)
|
|
2195
|
+
|
|
2196
|
+
async def mockrequest(nwk, tries=None, delay=None):
|
|
2197
|
+
return [0, None, [0, 1]]
|
|
2198
|
+
|
|
2199
|
+
async def mockepinit(self, *args, **kwargs):
|
|
2200
|
+
self.status = endpoint.Status.ZDO_INIT
|
|
2201
|
+
self.add_input_cluster(Basic.cluster_id)
|
|
2202
|
+
|
|
2203
|
+
async def mock_ep_get_model_info(self):
|
|
2204
|
+
return "Model", "Manufacturer"
|
|
2205
|
+
|
|
2206
|
+
monkeypatch.setattr(endpoint.Endpoint, "initialize", mockepinit)
|
|
2207
|
+
monkeypatch.setattr(endpoint.Endpoint, "get_model_info", mock_ep_get_model_info)
|
|
2208
|
+
dev.zdo.Active_EP_req = mockrequest
|
|
2209
|
+
|
|
2210
|
+
with mock_attribute_reads(cluster, {"current_file_version": 0x00000001}):
|
|
2211
|
+
await dev.initialize()
|
|
2212
|
+
|
|
2213
|
+
# Mock the OTA process to return success
|
|
2214
|
+
|
|
2215
|
+
monkeypatch.setattr(
|
|
2216
|
+
"zigpy.device.update_firmware",
|
|
2217
|
+
AsyncMock(return_value=foundation.Status.SUCCESS),
|
|
2218
|
+
)
|
|
2219
|
+
|
|
2220
|
+
dev.reinterview = AsyncMock()
|
|
2221
|
+
|
|
2222
|
+
with mock_attribute_reads(cluster, {"current_file_version": 0x00000002}):
|
|
2223
|
+
result = await dev.update_firmware(
|
|
2224
|
+
MagicMock(),
|
|
2225
|
+
progress_callback=MagicMock(),
|
|
2226
|
+
)
|
|
2227
|
+
|
|
2228
|
+
assert result == foundation.Status.SUCCESS
|
|
2229
|
+
dev.reinterview.assert_awaited_once()
|
|
@@ -596,9 +596,13 @@ class ControllerApplication(zigpy.util.ListenableMixin, abc.ABC):
|
|
|
596
596
|
self.devices[ieee] = dev
|
|
597
597
|
return dev
|
|
598
598
|
|
|
599
|
-
def
|
|
600
|
-
"""
|
|
601
|
-
|
|
599
|
+
def _finalize_device(self, device: zigpy.device.Device) -> zigpy.device.Device:
|
|
600
|
+
"""Apply quirks, persist to DB, and register the device.
|
|
601
|
+
|
|
602
|
+
Returns the (possibly quirked) device stored in ``self.devices``.
|
|
603
|
+
Does **not** fire any listener events beyond ``raw_device_initialized``
|
|
604
|
+
(which triggers the DB save).
|
|
605
|
+
"""
|
|
602
606
|
device.original_signature = device.get_signature()
|
|
603
607
|
|
|
604
608
|
self.listener_event("raw_device_initialized", device)
|
|
@@ -606,8 +610,85 @@ class ControllerApplication(zigpy.util.ListenableMixin, abc.ABC):
|
|
|
606
610
|
self.devices[device.ieee] = device
|
|
607
611
|
if self._dblistener is not None:
|
|
608
612
|
device.add_context_listener(self._dblistener)
|
|
613
|
+
|
|
614
|
+
return device
|
|
615
|
+
|
|
616
|
+
def device_initialized(self, device: zigpy.device.Device) -> None:
|
|
617
|
+
"""Used by a device to signal that it is initialized"""
|
|
618
|
+
LOGGER.debug("Device is initialized %s", device)
|
|
619
|
+
device = self._finalize_device(device)
|
|
609
620
|
self.listener_event("device_initialized", device)
|
|
610
621
|
|
|
622
|
+
async def _device_reinterviewed(
|
|
623
|
+
self,
|
|
624
|
+
old_device: zigpy.device.Device,
|
|
625
|
+
shadow: zigpy.device.Device,
|
|
626
|
+
) -> None:
|
|
627
|
+
"""Swap an old device with a successfully re-interviewed shadow.
|
|
628
|
+
|
|
629
|
+
Preserves non-discovery state (last_seen, relays, lqi, rssi) and group
|
|
630
|
+
memberships. Exceptions propagate to ``reinterview()`` which logs them
|
|
631
|
+
and fires the ``device_reinterview_failure`` event.
|
|
632
|
+
"""
|
|
633
|
+
# Copy non-discovery state from old device to shadow
|
|
634
|
+
shadow._last_seen = old_device._last_seen
|
|
635
|
+
shadow._relays = old_device._relays
|
|
636
|
+
shadow.lqi = old_device.lqi
|
|
637
|
+
shadow.rssi = old_device.rssi
|
|
638
|
+
|
|
639
|
+
# Collect group memberships and remove old endpoints from groups
|
|
640
|
+
old_group_memberships: dict[int, set[int]] = {}
|
|
641
|
+
for ep in old_device.non_zdo_endpoints:
|
|
642
|
+
if ep.member_of:
|
|
643
|
+
old_group_memberships[ep.endpoint_id] = set(ep.member_of)
|
|
644
|
+
for group in list(ep.member_of.values()):
|
|
645
|
+
group.remove_member(ep, suppress_event=True)
|
|
646
|
+
|
|
647
|
+
# Remove old device data from DB (cascade deletes endpoints, clusters,
|
|
648
|
+
# attribute cache, group members, and relays). We call _remove_device
|
|
649
|
+
# directly instead of the public device_removed() because we need the
|
|
650
|
+
# delete to complete before _finalize_device enqueues the save.
|
|
651
|
+
if self._dblistener is not None:
|
|
652
|
+
old_device.remove_listener(self._dblistener)
|
|
653
|
+
await self._dblistener._remove_device(old_device)
|
|
654
|
+
|
|
655
|
+
# Clean up old device's callbacks and tasks
|
|
656
|
+
old_device.on_remove()
|
|
657
|
+
|
|
658
|
+
# Apply quirks, persist to DB, and register the device — but do NOT
|
|
659
|
+
# fire the device_initialized listener event. Callers (and ZHA)
|
|
660
|
+
# should listen for device_reinterviewed instead.
|
|
661
|
+
new_device = self._finalize_device(shadow)
|
|
662
|
+
|
|
663
|
+
# Clear the reinterview flag so the new device isn't permanently stuck
|
|
664
|
+
# (relevant when no quirk is applied and new_device is the shadow itself)
|
|
665
|
+
new_device._reinterview_in_progress = False
|
|
666
|
+
|
|
667
|
+
# Restore group memberships on the new device's matching endpoints.
|
|
668
|
+
# A group may have been removed while we were awaiting `_remove_device`,
|
|
669
|
+
# so guard against missing entries rather than aborting the reinterview.
|
|
670
|
+
for ep_id, group_ids in old_group_memberships.items():
|
|
671
|
+
if ep_id in new_device.endpoints:
|
|
672
|
+
new_ep = new_device.endpoints[ep_id]
|
|
673
|
+
for group_id in group_ids:
|
|
674
|
+
group = self.groups.get(group_id)
|
|
675
|
+
if group is not None:
|
|
676
|
+
group.add_member(new_ep)
|
|
677
|
+
|
|
678
|
+
# Persist relays for the new device (cascade deleted the old row)
|
|
679
|
+
if self._dblistener is not None and new_device._relays is not None:
|
|
680
|
+
self._dblistener.device_relays_updated(new_device, new_device._relays)
|
|
681
|
+
|
|
682
|
+
self.listener_event("device_reinterviewed", new_device)
|
|
683
|
+
|
|
684
|
+
async def reinterview_device(self, ieee: t.EUI64) -> None:
|
|
685
|
+
"""Re-interview a device. Safe for sleepy end-devices.
|
|
686
|
+
|
|
687
|
+
If the device does not respond, existing state is preserved.
|
|
688
|
+
"""
|
|
689
|
+
dev = self.get_device(ieee=ieee)
|
|
690
|
+
await dev.reinterview()
|
|
691
|
+
|
|
611
692
|
async def remove(
|
|
612
693
|
self, ieee: t.EUI64, remove_children: bool = True, rejoin: bool = False
|
|
613
694
|
) -> None:
|
|
@@ -56,7 +56,7 @@ DEFAULT_FAST_POLL_TIMEOUT = 30
|
|
|
56
56
|
|
|
57
57
|
AFTER_OTA_ATTR_READ_DELAY = 10
|
|
58
58
|
OTA_RETRY_DECORATOR = zigpy.util.retryable_request(
|
|
59
|
-
tries=
|
|
59
|
+
tries=10, delay=AFTER_OTA_ATTR_READ_DELAY
|
|
60
60
|
)
|
|
61
61
|
|
|
62
62
|
|
|
@@ -102,6 +102,7 @@ class Device(zigpy.util.LocalLogMixin, zigpy.util.ListenableMixin):
|
|
|
102
102
|
self._last_seen: datetime | None = None
|
|
103
103
|
|
|
104
104
|
self._initialize_task: asyncio.Task | None = None
|
|
105
|
+
self._reinterview_in_progress: bool = False
|
|
105
106
|
self._group_scan_task: asyncio.Task | None = None
|
|
106
107
|
self._fast_polling_reset_task: asyncio.Task | None = None
|
|
107
108
|
|
|
@@ -271,6 +272,11 @@ class Device(zigpy.util.LocalLogMixin, zigpy.util.ListenableMixin):
|
|
|
271
272
|
"""Return True if device is being initialized."""
|
|
272
273
|
return self._initialize_task is not None and not self._initialize_task.done()
|
|
273
274
|
|
|
275
|
+
@property
|
|
276
|
+
def reinterviewing(self) -> bool:
|
|
277
|
+
"""Return True if device is being re-interviewed."""
|
|
278
|
+
return self._reinterview_in_progress
|
|
279
|
+
|
|
274
280
|
def cancel_initialization(self) -> None:
|
|
275
281
|
"""Cancel initialization call."""
|
|
276
282
|
if self.initializing:
|
|
@@ -284,6 +290,10 @@ class Device(zigpy.util.LocalLogMixin, zigpy.util.ListenableMixin):
|
|
|
284
290
|
self._application.device_initialized(self)
|
|
285
291
|
return None
|
|
286
292
|
|
|
293
|
+
if self.reinterviewing:
|
|
294
|
+
self.debug("Skipping initialization, re-interview in progress")
|
|
295
|
+
return None
|
|
296
|
+
|
|
287
297
|
self.debug("Scheduling initialization")
|
|
288
298
|
|
|
289
299
|
self.cancel_initialization()
|
|
@@ -291,6 +301,61 @@ class Device(zigpy.util.LocalLogMixin, zigpy.util.ListenableMixin):
|
|
|
291
301
|
|
|
292
302
|
return self._initialize_task
|
|
293
303
|
|
|
304
|
+
async def reinterview(self) -> None:
|
|
305
|
+
"""Re-interview this device using a shadow device for safety.
|
|
306
|
+
|
|
307
|
+
Creates a fresh Device, discovers its endpoints/clusters/model info,
|
|
308
|
+
and on success swaps the old device out. On failure, the old device
|
|
309
|
+
and its DB state are preserved.
|
|
310
|
+
"""
|
|
311
|
+
if self.reinterviewing:
|
|
312
|
+
self.debug("Re-interview already in progress, skipping")
|
|
313
|
+
return
|
|
314
|
+
|
|
315
|
+
if self.initializing:
|
|
316
|
+
self.debug("Initialization in progress, skipping re-interview")
|
|
317
|
+
return
|
|
318
|
+
|
|
319
|
+
if self.ota_in_progress:
|
|
320
|
+
self.debug("OTA in progress, skipping re-interview")
|
|
321
|
+
return
|
|
322
|
+
|
|
323
|
+
self._reinterview_in_progress = True
|
|
324
|
+
|
|
325
|
+
try:
|
|
326
|
+
shadow = Device(self._application, self._ieee, self.nwk)
|
|
327
|
+
shadow._reinterview_in_progress = True # prevent auto-initialization
|
|
328
|
+
|
|
329
|
+
# Temporarily register the shadow in app.devices so it receives
|
|
330
|
+
# ZDO responses routed by packet_received().
|
|
331
|
+
self._application.devices[self._ieee] = shadow
|
|
332
|
+
|
|
333
|
+
try:
|
|
334
|
+
async with self._application.request_priority(
|
|
335
|
+
t.PacketPriority.CRITICAL
|
|
336
|
+
):
|
|
337
|
+
await zigpy.util.retryable_request(tries=2, delay=0.5)(
|
|
338
|
+
shadow._discover
|
|
339
|
+
)()
|
|
340
|
+
except Exception:
|
|
341
|
+
# Discovery failed — restore old device, clean up shadow
|
|
342
|
+
self._application.devices[self._ieee] = self
|
|
343
|
+
shadow.on_remove()
|
|
344
|
+
raise
|
|
345
|
+
|
|
346
|
+
# Discovery succeeded — swap the old device for the new one.
|
|
347
|
+
# If this somehow fails, the state may be partially swapped; attempting
|
|
348
|
+
# to undo would likely make things worse.
|
|
349
|
+
await self._application._device_reinterviewed(self, shadow)
|
|
350
|
+
except Exception: # noqa: BLE001
|
|
351
|
+
self.warning(
|
|
352
|
+
"Re-interview failed, keeping existing device",
|
|
353
|
+
exc_info=True,
|
|
354
|
+
)
|
|
355
|
+
self._application.listener_event("device_reinterview_failure", self)
|
|
356
|
+
finally:
|
|
357
|
+
self._reinterview_in_progress = False
|
|
358
|
+
|
|
294
359
|
async def get_node_descriptor(self) -> zdo_t.NodeDescriptor:
|
|
295
360
|
self.info("Requesting 'Node Descriptor'")
|
|
296
361
|
|
|
@@ -348,6 +413,7 @@ class Device(zigpy.util.LocalLogMixin, zigpy.util.ListenableMixin):
|
|
|
348
413
|
# to be sent
|
|
349
414
|
if (
|
|
350
415
|
self.initializing
|
|
416
|
+
or self.reinterviewing
|
|
351
417
|
or self._concurrent_requests_semaphore.active_requests > 0
|
|
352
418
|
or self._fast_polling
|
|
353
419
|
):
|
|
@@ -421,11 +487,10 @@ class Device(zigpy.util.LocalLogMixin, zigpy.util.ListenableMixin):
|
|
|
421
487
|
LOGGER.debug("Stopping fast polling on next device check-in")
|
|
422
488
|
self._fast_polling = False
|
|
423
489
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
attributes from any Basic cluster exposing those attributes.
|
|
490
|
+
async def _discover(self) -> None:
|
|
491
|
+
"""Discover all basic information about a device: its node descriptor, all
|
|
492
|
+
endpoints and clusters, and the model and manufacturer attributes from any
|
|
493
|
+
Basic cluster exposing those attributes. Does not signal completion.
|
|
429
494
|
"""
|
|
430
495
|
|
|
431
496
|
# Some devices are improperly initialized and are missing a node descriptor
|
|
@@ -524,6 +589,11 @@ class Device(zigpy.util.LocalLogMixin, zigpy.util.ListenableMixin):
|
|
|
524
589
|
|
|
525
590
|
self.info("Discovered basic device information for %s", self)
|
|
526
591
|
|
|
592
|
+
@zigpy.util.retryable_request(tries=5, delay=0.5)
|
|
593
|
+
async def _initialize(self) -> None:
|
|
594
|
+
"""Discover device information and signal to the application."""
|
|
595
|
+
await self._discover()
|
|
596
|
+
|
|
527
597
|
# Signal to the application that the device is ready
|
|
528
598
|
self._application.device_initialized(self)
|
|
529
599
|
|
|
@@ -962,6 +1032,11 @@ class Device(zigpy.util.LocalLogMixin, zigpy.util.ListenableMixin):
|
|
|
962
1032
|
except Exception: # noqa: BLE001
|
|
963
1033
|
self.debug("Post-OTA image_notify failed", exc_info=True)
|
|
964
1034
|
|
|
1035
|
+
# Re-interview the device after successful OTA to pick up
|
|
1036
|
+
# any changes in clusters/endpoints/model and re-apply quirks.
|
|
1037
|
+
# reinterview() handles its own errors internally.
|
|
1038
|
+
await self.reinterview()
|
|
1039
|
+
|
|
965
1040
|
return result
|
|
966
1041
|
|
|
967
1042
|
def get_last_ota_query_cmd(self) -> QueryNextImageCommand | None:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|