Skip to content

Commit dd22c78

Browse files
authored
Migrate ZHA config entries to derive unique_id from the Zigbee EPID (home-assistant#154489)
1 parent 1a732ac commit dd22c78

File tree

8 files changed

+78
-16
lines changed

8 files changed

+78
-16
lines changed

homeassistant/components/zha/__init__.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
HAZHAData,
4949
ZHAGatewayProxy,
5050
create_zha_config,
51+
get_config_entry_unique_id,
5152
get_zha_data,
5253
)
5354
from .radio_manager import ZhaRadioManager
@@ -198,6 +199,11 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
198199

199200
repairs.async_delete_blocking_issues(hass)
200201

202+
# Set unique_id if it was not migrated previously
203+
if not config_entry.unique_id or not config_entry.unique_id.startswith("epid="):
204+
unique_id = get_config_entry_unique_id(zha_gateway.state.network_info)
205+
hass.config_entries.async_update_entry(config_entry, unique_id=unique_id)
206+
201207
ha_zha_data.gateway_proxy = ZHAGatewayProxy(hass, config_entry, zha_gateway)
202208

203209
manufacturer = zha_gateway.state.node_info.manufacturer
@@ -313,5 +319,26 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
313319

314320
hass.config_entries.async_update_entry(config_entry, data=data, version=4)
315321

322+
if config_entry.version == 4:
323+
radio_mgr = ZhaRadioManager.from_config_entry(hass, config_entry)
324+
await radio_mgr.async_read_backups_from_database()
325+
326+
if radio_mgr.backups:
327+
# We migrate all ZHA config entries to use a `unique_id` specific to the
328+
# Zigbee network, not to the hardware
329+
backup = radio_mgr.backups[0]
330+
hass.config_entries.async_update_entry(
331+
config_entry,
332+
unique_id=get_config_entry_unique_id(backup.network_info),
333+
version=5,
334+
)
335+
else:
336+
# If no backups are available, the unique_id will be set when the network is
337+
# loaded during setup
338+
hass.config_entries.async_update_entry(
339+
config_entry,
340+
version=5,
341+
)
342+
316343
_LOGGER.info("Migration to version %s successful", config_entry.version)
317344
return True

homeassistant/components/zha/config_flow.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
from homeassistant.util import dt as dt_util
4848

4949
from .const import CONF_BAUDRATE, CONF_FLOW_CONTROL, CONF_RADIO_TYPE, DOMAIN
50-
from .helpers import get_zha_gateway
50+
from .helpers import get_config_entry_unique_id, get_zha_gateway
5151
from .radio_manager import (
5252
DEVICE_SCHEMA,
5353
HARDWARE_DISCOVERY_SCHEMA,
@@ -544,6 +544,8 @@ async def async_step_form_new_network(
544544
) -> ConfigFlowResult:
545545
"""Form a brand-new network."""
546546
await self._radio_mgr.async_form_network()
547+
# Load the newly formed network settings to get the network info
548+
await self._radio_mgr.async_load_network_settings()
547549
return await self._async_create_radio_entry()
548550

549551
def _parse_uploaded_backup(
@@ -668,7 +670,7 @@ async def async_step_maybe_confirm_ezsp_restore(
668670
class ZhaConfigFlowHandler(BaseZhaFlow, ConfigFlow, domain=DOMAIN):
669671
"""Handle a config flow."""
670672

671-
VERSION = 4
673+
VERSION = 5
672674

673675
async def _set_unique_id_and_update_ignored_flow(
674676
self, unique_id: str, device_path: str
@@ -927,6 +929,15 @@ async def _async_create_radio_entry(self) -> ConfigFlowResult:
927929
reason="reconfigure_successful",
928930
)
929931
if not zha_config_entries:
932+
# Load network settings from the radio to get the EPID
933+
await self._radio_mgr.async_load_network_settings()
934+
assert self._radio_mgr.current_settings is not None
935+
936+
unique_id = get_config_entry_unique_id(
937+
self._radio_mgr.current_settings.network_info
938+
)
939+
await self.async_set_unique_id(unique_id)
940+
930941
return self.async_create_entry(
931942
title=self._title,
932943
data=data,

homeassistant/components/zha/helpers.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@
9090
)
9191
import zigpy.exceptions
9292
from zigpy.profiles import PROFILES
93+
from zigpy.state import NetworkInfo
9394
import zigpy.types
9495
from zigpy.types import EUI64
9596
import zigpy.util
@@ -1390,3 +1391,8 @@ async def handler(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None:
13901391
def exclude_none_values(obj: Mapping[str, Any]) -> dict[str, Any]:
13911392
"""Return a new dictionary excluding keys with None values."""
13921393
return {k: v for k, v in obj.items() if v is not None}
1394+
1395+
1396+
def get_config_entry_unique_id(network_info: NetworkInfo) -> str:
1397+
"""Generate a unique id for a config entry based on the network info."""
1398+
return f"epid={network_info.extended_pan_id}".lower()

tests/components/zha/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ async def zigpy_app_controller():
195195
async def config_entry_fixture() -> MockConfigEntry:
196196
"""Fixture representing a config entry."""
197197
return MockConfigEntry(
198-
version=4,
198+
version=5,
199199
domain=zha_const.DOMAIN,
200200
data={
201201
zigpy.config.CONF_DEVICE: {

tests/components/zha/snapshots/test_diagnostics.ambr

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,8 @@
117117
'subentries': list([
118118
]),
119119
'title': 'Mock Title',
120-
'unique_id': None,
121-
'version': 4,
120+
'unique_id': '**REDACTED**',
121+
'version': 5,
122122
}),
123123
'devices': list([
124124
dict({

tests/components/zha/test_config_flow.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,9 @@ def mock_app() -> Generator[AsyncMock]:
9696
mock_app = AsyncMock()
9797
mock_app.backups = create_autospec(BackupManager, instance=True)
9898
mock_app.backups.backups = []
99+
mock_app.state.network_info.extended_pan_id = zigpy.types.EUI64.convert(
100+
"AABBCCDDEE000000"
101+
)
99102
mock_app.state.network_info.metadata = {
100103
"ezsp": {
101104
"can_burn_userdata_custom_eui64": True,
@@ -175,7 +178,7 @@ def com_port(device="/dev/ttyUSB1234") -> ListPortInfo:
175178
(
176179
# TubesZB, old ESPHome devices (ZNP)
177180
"tubeszb-cc2652-poe",
178-
"tubeszb-cc2652-poe",
181+
"epid=aa:bb:cc:dd:ee:00:00:00",
179182
RadioType.znp,
180183
ZeroconfServiceInfo(
181184
ip_address=ip_address("192.168.1.200"),
@@ -198,7 +201,7 @@ def com_port(device="/dev/ttyUSB1234") -> ListPortInfo:
198201
(
199202
# TubesZB, old ESPHome device (EFR32)
200203
"tubeszb-efr32-poe",
201-
"tubeszb-efr32-poe",
204+
"epid=aa:bb:cc:dd:ee:00:00:00",
202205
RadioType.ezsp,
203206
ZeroconfServiceInfo(
204207
ip_address=ip_address("192.168.1.200"),
@@ -221,7 +224,7 @@ def com_port(device="/dev/ttyUSB1234") -> ListPortInfo:
221224
(
222225
# TubesZB, newer devices
223226
"TubeZB",
224-
"tubeszb-cc2652-poe",
227+
"epid=aa:bb:cc:dd:ee:00:00:00",
225228
RadioType.znp,
226229
ZeroconfServiceInfo(
227230
ip_address=ip_address("192.168.1.200"),
@@ -242,7 +245,7 @@ def com_port(device="/dev/ttyUSB1234") -> ListPortInfo:
242245
(
243246
# Expected format for all new devices
244247
"Some Zigbee Gateway (12345)",
245-
"aabbccddeeff",
248+
"epid=aa:bb:cc:dd:ee:00:00:00",
246249
RadioType.znp,
247250
ZeroconfServiceInfo(
248251
ip_address=ip_address("192.168.1.200"),
@@ -1627,8 +1630,15 @@ async def test_formation_strategy_form_initial_network(
16271630
advanced_pick_radio: RadioPicker, mock_app: AsyncMock, hass: HomeAssistant
16281631
) -> None:
16291632
"""Test forming a new network, with no previous settings on the radio."""
1633+
# Initially, no network is formed
16301634
mock_app.load_network_info = AsyncMock(side_effect=NetworkNotFormed())
16311635

1636+
# After form_network is called, load_network_info should return the network settings
1637+
async def form_network_side_effect(*args, **kwargs):
1638+
mock_app.load_network_info = AsyncMock(return_value=mock_app.state.network_info)
1639+
1640+
mock_app.form_network.side_effect = form_network_side_effect
1641+
16321642
result = await advanced_pick_radio(RadioType.ezsp)
16331643
result2 = await hass.config_entries.flow.async_configure(
16341644
result["flow_id"],
@@ -1648,8 +1658,16 @@ async def test_onboarding_auto_formation_new_hardware(
16481658
mock_app: AsyncMock, hass: HomeAssistant
16491659
) -> None:
16501660
"""Test auto network formation with new hardware during onboarding."""
1661+
# Initially, no network is formed
16511662
mock_app.load_network_info = AsyncMock(side_effect=NetworkNotFormed())
16521663
mock_app.get_device = MagicMock(return_value=MagicMock(spec=zigpy.device.Device))
1664+
1665+
# After form_network is called, load_network_info should return the network settings
1666+
async def form_network_side_effect(*args, **kwargs):
1667+
mock_app.load_network_info = AsyncMock(return_value=mock_app.state.network_info)
1668+
1669+
mock_app.form_network.side_effect = form_network_side_effect
1670+
16531671
discovery_info = UsbServiceInfo(
16541672
device="/dev/ttyZIGBEE",
16551673
pid="AAAA",

tests/components/zha/test_homeassistant_hardware.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ async def test_get_firmware_info_normal(hass: HomeAssistant) -> None:
3535
},
3636
"radio_type": "ezsp",
3737
},
38-
version=4,
38+
version=5,
3939
)
4040
zha.add_to_hass(hass)
4141
zha.mock_state(hass, ConfigEntryState.LOADED)
@@ -87,7 +87,7 @@ async def test_get_firmware_info_errors(
8787
domain="zha",
8888
unique_id="some_unique_id",
8989
data=data,
90-
version=4,
90+
version=5,
9191
)
9292
zha.add_to_hass(hass)
9393

tests/components/zha/test_init.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ async def test_migration_from_v1_no_baudrate(
7373
assert CONF_DEVICE in config_entry_v1.data
7474
assert config_entry_v1.data[CONF_DEVICE][CONF_DEVICE_PATH] == DATA_PORT_PATH
7575
assert CONF_USB_PATH not in config_entry_v1.data
76-
assert config_entry_v1.version == 4
76+
assert config_entry_v1.version == 5
7777

7878

7979
@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True))
@@ -90,7 +90,7 @@ async def test_migration_from_v1_with_baudrate(
9090
assert CONF_USB_PATH not in config_entry_v1.data
9191
assert CONF_BAUDRATE in config_entry_v1.data[CONF_DEVICE]
9292
assert config_entry_v1.data[CONF_DEVICE][CONF_BAUDRATE] == 115200
93-
assert config_entry_v1.version == 4
93+
assert config_entry_v1.version == 5
9494

9595

9696
@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True))
@@ -105,7 +105,7 @@ async def test_migration_from_v1_wrong_baudrate(
105105
assert CONF_DEVICE in config_entry_v1.data
106106
assert config_entry_v1.data[CONF_DEVICE][CONF_DEVICE_PATH] == DATA_PORT_PATH
107107
assert CONF_USB_PATH not in config_entry_v1.data
108-
assert config_entry_v1.version == 4
108+
assert config_entry_v1.version == 5
109109

110110

111111
@pytest.mark.skipif(
@@ -167,7 +167,7 @@ async def test_setup_with_v3_cleaning_uri(
167167
CONF_FLOW_CONTROL: None,
168168
},
169169
},
170-
version=4,
170+
version=5,
171171
)
172172
config_entry_v4.add_to_hass(hass)
173173

@@ -177,7 +177,7 @@ async def test_setup_with_v3_cleaning_uri(
177177

178178
assert config_entry_v4.data[CONF_RADIO_TYPE] == DATA_RADIO_TYPE
179179
assert config_entry_v4.data[CONF_DEVICE][CONF_DEVICE_PATH] == cleaned_path
180-
assert config_entry_v4.version == 4
180+
assert config_entry_v4.version == 5
181181

182182

183183
@pytest.mark.parametrize(

0 commit comments

Comments
 (0)