zigpy 1.2.2__tar.gz → 1.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. {zigpy-1.2.2/zigpy.egg-info → zigpy-1.4.0}/PKG-INFO +2 -2
  2. {zigpy-1.2.2 → zigpy-1.4.0}/pyproject.toml +1 -1
  3. {zigpy-1.2.2 → zigpy-1.4.0}/tests/test_application.py +201 -0
  4. {zigpy-1.2.2 → zigpy-1.4.0}/tests/test_device.py +224 -0
  5. {zigpy-1.2.2 → zigpy-1.4.0}/tests/test_serial.py +2 -36
  6. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/application.py +84 -3
  7. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/device.py +80 -5
  8. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/event/event_base.py +1 -4
  9. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/serial.py +30 -53
  10. {zigpy-1.2.2 → zigpy-1.4.0/zigpy.egg-info}/PKG-INFO +2 -2
  11. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy.egg-info/requires.txt +1 -1
  12. {zigpy-1.2.2 → zigpy-1.4.0}/COPYING +0 -0
  13. {zigpy-1.2.2 → zigpy-1.4.0}/LICENSE +0 -0
  14. {zigpy-1.2.2 → zigpy-1.4.0}/README.md +0 -0
  15. {zigpy-1.2.2 → zigpy-1.4.0}/setup.cfg +0 -0
  16. {zigpy-1.2.2 → zigpy-1.4.0}/setup.py +0 -0
  17. {zigpy-1.2.2 → zigpy-1.4.0}/tests/test_app_state.py +0 -0
  18. {zigpy-1.2.2 → zigpy-1.4.0}/tests/test_appdb.py +0 -0
  19. {zigpy-1.2.2 → zigpy-1.4.0}/tests/test_appdb_migration.py +0 -0
  20. {zigpy-1.2.2 → zigpy-1.4.0}/tests/test_backups.py +0 -0
  21. {zigpy-1.2.2 → zigpy-1.4.0}/tests/test_config.py +0 -0
  22. {zigpy-1.2.2 → zigpy-1.4.0}/tests/test_datastructures.py +0 -0
  23. {zigpy-1.2.2 → zigpy-1.4.0}/tests/test_datastructures_cpython.py +0 -0
  24. {zigpy-1.2.2 → zigpy-1.4.0}/tests/test_endpoint.py +0 -0
  25. {zigpy-1.2.2 → zigpy-1.4.0}/tests/test_event.py +0 -0
  26. {zigpy-1.2.2 → zigpy-1.4.0}/tests/test_group.py +0 -0
  27. {zigpy-1.2.2 → zigpy-1.4.0}/tests/test_listeners.py +0 -0
  28. {zigpy-1.2.2 → zigpy-1.4.0}/tests/test_packet_callbacks.py +0 -0
  29. {zigpy-1.2.2 → zigpy-1.4.0}/tests/test_quirks.py +0 -0
  30. {zigpy-1.2.2 → zigpy-1.4.0}/tests/test_quirks_registry.py +0 -0
  31. {zigpy-1.2.2 → zigpy-1.4.0}/tests/test_quirks_v2.py +0 -0
  32. {zigpy-1.2.2 → zigpy-1.4.0}/tests/test_struct.py +0 -0
  33. {zigpy-1.2.2 → zigpy-1.4.0}/tests/test_topology.py +0 -0
  34. {zigpy-1.2.2 → zigpy-1.4.0}/tests/test_types.py +0 -0
  35. {zigpy-1.2.2 → zigpy-1.4.0}/tests/test_zcl.py +0 -0
  36. {zigpy-1.2.2 → zigpy-1.4.0}/tests/test_zcl_clusters.py +0 -0
  37. {zigpy-1.2.2 → zigpy-1.4.0}/tests/test_zcl_foundation.py +0 -0
  38. {zigpy-1.2.2 → zigpy-1.4.0}/tests/test_zcl_helpers.py +0 -0
  39. {zigpy-1.2.2 → zigpy-1.4.0}/tests/test_zdo.py +0 -0
  40. {zigpy-1.2.2 → zigpy-1.4.0}/tests/test_zdo_types.py +0 -0
  41. {zigpy-1.2.2 → zigpy-1.4.0}/tests/test_zigbee_util.py +0 -0
  42. {zigpy-1.2.2 → zigpy-1.4.0}/tools/__init__.py +0 -0
  43. {zigpy-1.2.2 → zigpy-1.4.0}/tools/regenerate_mypy_ignores.py +0 -0
  44. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/__init__.py +0 -0
  45. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/appdb.py +0 -0
  46. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/appdb_schemas/__init__.py +0 -0
  47. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/appdb_schemas/schema_v0.sql +0 -0
  48. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/appdb_schemas/schema_v1.sql +0 -0
  49. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/appdb_schemas/schema_v10.sql +0 -0
  50. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/appdb_schemas/schema_v11.sql +0 -0
  51. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/appdb_schemas/schema_v12.sql +0 -0
  52. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/appdb_schemas/schema_v13.sql +0 -0
  53. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/appdb_schemas/schema_v14.sql +0 -0
  54. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/appdb_schemas/schema_v15.sql +0 -0
  55. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/appdb_schemas/schema_v2.sql +0 -0
  56. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/appdb_schemas/schema_v3.sql +0 -0
  57. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/appdb_schemas/schema_v4.sql +0 -0
  58. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/appdb_schemas/schema_v5.sql +0 -0
  59. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/appdb_schemas/schema_v6.sql +0 -0
  60. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/appdb_schemas/schema_v7.sql +0 -0
  61. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/appdb_schemas/schema_v8.sql +0 -0
  62. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/appdb_schemas/schema_v9.sql +0 -0
  63. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/backports/__init__.py +0 -0
  64. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/backups.py +0 -0
  65. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/config/__init__.py +0 -0
  66. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/config/defaults.py +0 -0
  67. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/config/validators.py +0 -0
  68. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/const.py +0 -0
  69. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/datastructures.py +0 -0
  70. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/endpoint.py +0 -0
  71. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/event/__init__.py +0 -0
  72. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/exceptions.py +0 -0
  73. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/group.py +0 -0
  74. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/listeners.py +0 -0
  75. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/ota/__init__.py +0 -0
  76. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/ota/image.py +0 -0
  77. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/ota/json_schemas.py +0 -0
  78. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/ota/manager.py +0 -0
  79. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/ota/providers.py +0 -0
  80. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/ota/validators.py +0 -0
  81. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/profiles/__init__.py +0 -0
  82. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/profiles/zgp.py +0 -0
  83. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/profiles/zha.py +0 -0
  84. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/profiles/zll.py +0 -0
  85. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/py.typed +0 -0
  86. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/quirks/__init__.py +0 -0
  87. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/quirks/registry.py +0 -0
  88. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/quirks/v2/__init__.py +0 -0
  89. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/quirks/v2/homeassistant/__init__.py +0 -0
  90. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/quirks/v2/homeassistant/binary_sensor.py +0 -0
  91. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/quirks/v2/homeassistant/number.py +0 -0
  92. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/quirks/v2/homeassistant/sensor.py +0 -0
  93. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/state.py +0 -0
  94. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/topology.py +0 -0
  95. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/types/__init__.py +0 -0
  96. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/types/basic.py +0 -0
  97. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/types/named.py +0 -0
  98. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/types/struct.py +0 -0
  99. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/typing.py +0 -0
  100. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/util.py +0 -0
  101. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/zcl/__init__.py +0 -0
  102. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/zcl/clusters/__init__.py +0 -0
  103. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/zcl/clusters/closures.py +0 -0
  104. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/zcl/clusters/general.py +0 -0
  105. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/zcl/clusters/general_const.py +0 -0
  106. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/zcl/clusters/greenpower.py +0 -0
  107. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/zcl/clusters/homeautomation.py +0 -0
  108. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/zcl/clusters/hvac.py +0 -0
  109. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/zcl/clusters/lighting.py +0 -0
  110. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/zcl/clusters/lightlink.py +0 -0
  111. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/zcl/clusters/manufacturer_specific.py +0 -0
  112. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/zcl/clusters/measurement.py +0 -0
  113. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/zcl/clusters/protocol.py +0 -0
  114. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/zcl/clusters/security.py +0 -0
  115. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/zcl/clusters/smartenergy.py +0 -0
  116. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/zcl/clusters/wwah.py +0 -0
  117. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/zcl/foundation.py +0 -0
  118. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/zcl/helpers.py +0 -0
  119. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/zdo/__init__.py +0 -0
  120. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/zdo/types.py +0 -0
  121. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/zgp/__init__.py +0 -0
  122. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy/zgp/types.py +0 -0
  123. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy.egg-info/SOURCES.txt +0 -0
  124. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy.egg-info/dependency_links.txt +0 -0
  125. {zigpy-1.2.2 → zigpy-1.4.0}/zigpy.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zigpy
3
- Version: 1.2.2
3
+ Version: 1.4.0
4
4
  Summary: Library implementing a Zigbee stack
5
5
  Author-email: Russell Cloran <rcloran@gmail.com>
6
6
  License: GPL-3.0
@@ -16,7 +16,7 @@ Requires-Dist: crccheck
16
16
  Requires-Dist: cryptography
17
17
  Requires-Dist: voluptuous
18
18
  Requires-Dist: jsonschema
19
- Requires-Dist: pyserial-asyncio-fast
19
+ Requires-Dist: serialx>=1.4.0
20
20
  Requires-Dist: typing_extensions
21
21
  Requires-Dist: frozendict
22
22
  Dynamic: license-file
@@ -21,7 +21,7 @@ dependencies = [
21
21
  "cryptography",
22
22
  "voluptuous",
23
23
  "jsonschema",
24
- 'pyserial-asyncio-fast',
24
+ "serialx>=1.4.0",
25
25
  "typing_extensions",
26
26
  "frozendict",
27
27
  ]
@@ -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()
@@ -54,7 +54,7 @@ async def test_serial_normal(
54
54
  kwargs["rtscts"] = rtscts
55
55
 
56
56
  with patch(
57
- "zigpy.serial.pyserial_asyncio.create_serial_connection",
57
+ "zigpy.serial.serialx_create_serial_connection",
58
58
  AsyncMock(
59
59
  return_value=(AsyncMock(), AsyncMock())
60
60
  ),
@@ -71,32 +71,7 @@ async def test_serial_normal(
71
71
  assert mock_calls[0].kwargs[kwarg] == expected_kwargs[kwarg]
72
72
 
73
73
 
74
- async def test_serial_socket() -> None:
75
- loop = asyncio.get_running_loop()
76
- protocol_factory = Mock()
77
-
78
- with patch.object(
79
- loop,
80
- "create_connection",
81
- AsyncMock(
82
- return_value=(AsyncMock(), AsyncMock())
83
- ),
84
- ):
85
- await zigpy.serial.create_serial_connection(
86
- loop, protocol_factory, "socket://1.2.3.4:5678"
87
- )
88
- await zigpy.serial.create_serial_connection(
89
- loop, protocol_factory, "socket://1.2.3.4"
90
- )
91
-
92
- assert len(loop.create_connection.mock_calls) == 2
93
- assert loop.create_connection.mock_calls[0].kwargs["host"] == "1.2.3.4"
94
- assert loop.create_connection.mock_calls[0].kwargs["port"] == 5678
95
- assert loop.create_connection.mock_calls[1].kwargs["host"] == "1.2.3.4"
96
- assert loop.create_connection.mock_calls[1].kwargs["port"] == 6638
97
-
98
-
99
- async def test_pyserial_error_remapping(tmp_path: pathlib.Path) -> None:
74
+ async def test_serial_error_remapping(tmp_path: pathlib.Path) -> None:
100
75
  loop = asyncio.get_running_loop()
101
76
  protocol_factory = Mock()
102
77
 
@@ -119,15 +94,6 @@ async def test_pyserial_error_remapping(tmp_path: pathlib.Path) -> None:
119
94
  loop, protocol_factory, url=denied_port
120
95
  )
121
96
 
122
- # IsADirectoryError
123
- a_folder = tmp_path / "a_folder"
124
- a_folder.mkdir()
125
-
126
- with pytest.raises(IsADirectoryError):
127
- await zigpy.serial.create_serial_connection(
128
- loop, protocol_factory, url=a_folder
129
- )
130
-
131
97
  # Locked
132
98
  locked_port = tmp_path / "locked"
133
99
  with locked_port.open("w") as f:
@@ -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 device_initialized(self, device: zigpy.device.Device) -> None:
600
- """Used by a device to signal that it is initialized"""
601
- LOGGER.debug("Device is initialized %s", device)
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: