From add7103d554a4f5564b73fc342238465ff86e689 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Tue, 26 Apr 2022 10:52:55 +0200 Subject: [PATCH] Use Airzone WebServer MAC address as unique ID (#70287) Co-authored-by: J. Nick Koston --- homeassistant/components/airzone/__init__.py | 58 +++++++++++++++++- .../components/airzone/binary_sensor.py | 6 +- homeassistant/components/airzone/climate.py | 3 +- .../components/airzone/config_flow.py | 12 +++- homeassistant/components/airzone/sensor.py | 5 +- tests/components/airzone/test_config_flow.py | 12 ++-- tests/components/airzone/test_coordinator.py | 10 +++- tests/components/airzone/test_init.py | 59 +++++++++++++++++-- tests/components/airzone/util.py | 19 +++++- 9 files changed, 161 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/airzone/__init__.py b/homeassistant/components/airzone/__init__.py index 1fd159be9bd..39f1fc978c3 100644 --- a/homeassistant/components/airzone/__init__.py +++ b/homeassistant/components/airzone/__init__.py @@ -1,14 +1,17 @@ """The Airzone integration.""" from __future__ import annotations +import logging from typing import Any from aioairzone.const import ( AZD_ID, + AZD_MAC, AZD_NAME, AZD_SYSTEM, AZD_THERMOSTAT_FW, AZD_THERMOSTAT_MODEL, + AZD_WEBSERVER, AZD_ZONES, DEFAULT_SYSTEM_ID, ) @@ -16,8 +19,12 @@ from aioairzone.localapi import AirzoneLocalApi, ConnectionOptions from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_ID, CONF_PORT, Platform -from homeassistant.core import HomeAssistant -from homeassistant.helpers import aiohttp_client +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import ( + aiohttp_client, + device_registry as dr, + entity_registry as er, +) from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -26,6 +33,8 @@ from .coordinator import AirzoneUpdateCoordinator PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.SENSOR] +_LOGGER = logging.getLogger(__name__) + class AirzoneEntity(CoordinatorEntity[AirzoneUpdateCoordinator]): """Define an Airzone entity.""" @@ -59,6 +68,9 @@ class AirzoneZoneEntity(AirzoneEntity): "name": f"Airzone [{system_zone_id}] {zone_data[AZD_NAME]}", "sw_version": self.get_airzone_value(AZD_THERMOSTAT_FW), } + self._attr_unique_id = ( + entry.entry_id if entry.unique_id is None else entry.unique_id + ) def get_airzone_value(self, key) -> Any: """Return zone value by key.""" @@ -70,6 +82,46 @@ class AirzoneZoneEntity(AirzoneEntity): return value +async def _async_migrate_unique_ids( + hass: HomeAssistant, + entry: ConfigEntry, + coordinator: AirzoneUpdateCoordinator, +) -> None: + """Migrate entities when the mac address gets discovered.""" + + @callback + def _async_migrator(entity_entry: er.RegistryEntry) -> dict[str, Any] | None: + updates = None + + unique_id = entry.unique_id + entry_id = entry.entry_id + entity_unique_id = entity_entry.unique_id + + if entity_unique_id.startswith(entry_id): + new_unique_id = f"{unique_id}{entity_unique_id[len(entry_id):]}" + _LOGGER.info( + "Migrating unique_id from [%s] to [%s]", + entity_unique_id, + new_unique_id, + ) + updates = {"new_unique_id": new_unique_id} + + return updates + + if ( + entry.unique_id is None + and AZD_WEBSERVER in coordinator.data + and AZD_MAC in coordinator.data[AZD_WEBSERVER] + and (mac := coordinator.data[AZD_WEBSERVER][AZD_MAC]) is not None + ): + updates: dict[str, Any] = { + "unique_id": dr.format_mac(mac), + } + hass.config_entries.async_update_entry(entry, **updates) + + await er.async_migrate_entries(hass, entry.entry_id, _async_migrator) + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Airzone from a config entry.""" options = ConnectionOptions( @@ -79,9 +131,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) airzone = AirzoneLocalApi(aiohttp_client.async_get_clientsession(hass), options) - coordinator = AirzoneUpdateCoordinator(hass, airzone) await coordinator.async_config_entry_first_refresh() + await _async_migrate_unique_ids(hass, entry, coordinator) hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator diff --git a/homeassistant/components/airzone/binary_sensor.py b/homeassistant/components/airzone/binary_sensor.py index fe29ceb3fb0..db96ccb0563 100644 --- a/homeassistant/components/airzone/binary_sensor.py +++ b/homeassistant/components/airzone/binary_sensor.py @@ -122,6 +122,10 @@ class AirzoneZoneBinarySensor(AirzoneZoneEntity, AirzoneBinarySensor): ) -> None: """Initialize.""" super().__init__(coordinator, entry, system_zone_id, zone_data) + self._attr_name = f"{zone_data[AZD_NAME]} {description.name}" - self._attr_unique_id = f"{entry.entry_id}_{system_zone_id}_{description.key}" + self._attr_unique_id = ( + f"{self._attr_unique_id}_{system_zone_id}_{description.key}" + ) + self.attributes = description.attributes self.entity_description = description diff --git a/homeassistant/components/airzone/climate.py b/homeassistant/components/airzone/climate.py index 2ae151a3b44..1ec7dfabcfa 100644 --- a/homeassistant/components/airzone/climate.py +++ b/homeassistant/components/airzone/climate.py @@ -99,8 +99,9 @@ class AirzoneClimate(AirzoneZoneEntity, ClimateEntity): ) -> None: """Initialize Airzone climate entity.""" super().__init__(coordinator, entry, system_zone_id, zone_data) + self._attr_name = f"{zone_data[AZD_NAME]}" - self._attr_unique_id = f"{entry.entry_id}_{system_zone_id}" + self._attr_unique_id = f"{self._attr_unique_id}_{system_zone_id}" self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE self._attr_target_temperature_step = API_TEMPERATURE_STEP self._attr_max_temp = self.get_airzone_value(AZD_TEMP_MAX) diff --git a/homeassistant/components/airzone/config_flow.py b/homeassistant/components/airzone/config_flow.py index e758bfa3129..89a2d7f1f9e 100644 --- a/homeassistant/components/airzone/config_flow.py +++ b/homeassistant/components/airzone/config_flow.py @@ -12,6 +12,7 @@ from homeassistant import config_entries from homeassistant.const import CONF_HOST, CONF_ID, CONF_PORT from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.device_registry import format_mac from .const import DOMAIN @@ -51,13 +52,22 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) try: - await airzone.validate() + mac = await airzone.validate() except InvalidSystem: data_schema = SYSTEM_ID_SCHEMA errors[CONF_ID] = "invalid_system_id" except AirzoneError: errors["base"] = "cannot_connect" else: + if mac: + await self.async_set_unique_id(format_mac(mac)) + self._abort_if_unique_id_configured( + updates={ + CONF_HOST: user_input[CONF_HOST], + CONF_PORT: user_input[CONF_PORT], + } + ) + title = f"Airzone {user_input[CONF_HOST]}:{user_input[CONF_PORT]}" return self.async_create_entry(title=title, data=user_input) diff --git a/homeassistant/components/airzone/sensor.py b/homeassistant/components/airzone/sensor.py index 9e6e886ccac..f41add5053a 100644 --- a/homeassistant/components/airzone/sensor.py +++ b/homeassistant/components/airzone/sensor.py @@ -83,8 +83,11 @@ class AirzoneZoneSensor(AirzoneZoneEntity, AirzoneSensor): ) -> None: """Initialize.""" super().__init__(coordinator, entry, system_zone_id, zone_data) + self._attr_name = f"{zone_data[AZD_NAME]} {description.name}" - self._attr_unique_id = f"{entry.entry_id}_{system_zone_id}_{description.key}" + self._attr_unique_id = ( + f"{self._attr_unique_id}_{system_zone_id}_{description.key}" + ) self.entity_description = description if description.key == AZD_TEMP: diff --git a/tests/components/airzone/test_config_flow.py b/tests/components/airzone/test_config_flow.py index 2d23f99aa47..251dcf01b60 100644 --- a/tests/components/airzone/test_config_flow.py +++ b/tests/components/airzone/test_config_flow.py @@ -16,7 +16,7 @@ from homeassistant.config_entries import SOURCE_USER, ConfigEntryState from homeassistant.const import CONF_HOST, CONF_ID, CONF_PORT from homeassistant.core import HomeAssistant -from .util import CONFIG, CONFIG_ID1, HVAC_MOCK +from .util import CONFIG, CONFIG_ID1, HVAC_MOCK, HVAC_WEBSERVER_MOCK from tests.common import MockConfigEntry @@ -35,7 +35,7 @@ async def test_form(hass: HomeAssistant) -> None: side_effect=SystemOutOfRange, ), patch( "homeassistant.components.airzone.AirzoneLocalApi.get_webserver", - side_effect=InvalidMethod, + return_value=HVAC_WEBSERVER_MOCK, ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -118,8 +118,12 @@ async def test_form_invalid_system_id(hass: HomeAssistant) -> None: async def test_form_duplicated_id(hass: HomeAssistant) -> None: """Test setting up duplicated entry.""" - entry = MockConfigEntry(domain=DOMAIN, data=CONFIG) - entry.add_to_hass(hass) + config_entry = MockConfigEntry( + data=CONFIG, + domain=DOMAIN, + unique_id="airzone_unique_id", + ) + config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=CONFIG diff --git a/tests/components/airzone/test_coordinator.py b/tests/components/airzone/test_coordinator.py index 8f333cd10c7..c2ee5c0cbca 100644 --- a/tests/components/airzone/test_coordinator.py +++ b/tests/components/airzone/test_coordinator.py @@ -18,8 +18,12 @@ from tests.common import MockConfigEntry, async_fire_time_changed async def test_coordinator_client_connector_error(hass: HomeAssistant) -> None: """Test ClientConnectorError on coordinator update.""" - entry = MockConfigEntry(domain=DOMAIN, data=CONFIG) - entry.add_to_hass(hass) + config_entry = MockConfigEntry( + data=CONFIG, + domain=DOMAIN, + unique_id="airzone_unique_id", + ) + config_entry.add_to_hass(hass) with patch( "homeassistant.components.airzone.AirzoneLocalApi.get_hvac", @@ -31,7 +35,7 @@ async def test_coordinator_client_connector_error(hass: HomeAssistant) -> None: "homeassistant.components.airzone.AirzoneLocalApi.get_webserver", side_effect=InvalidMethod, ): - await hass.config_entries.async_setup(entry.entry_id) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() mock_hvac.assert_called_once() mock_hvac.reset_mock() diff --git a/tests/components/airzone/test_init.py b/tests/components/airzone/test_init.py index 0fee80d581b..274ceb21d51 100644 --- a/tests/components/airzone/test_init.py +++ b/tests/components/airzone/test_init.py @@ -7,18 +7,19 @@ from aioairzone.exceptions import InvalidMethod, SystemOutOfRange from homeassistant.components.airzone.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er -from .util import CONFIG, HVAC_MOCK +from .util import CONFIG, HVAC_MOCK, HVAC_WEBSERVER_MOCK from tests.common import MockConfigEntry -async def test_unload_entry(hass: HomeAssistant) -> None: - """Test unload.""" +async def test_unique_id_migrate(hass: HomeAssistant) -> None: + """Test unique id migration.""" - config_entry = MockConfigEntry( - domain=DOMAIN, unique_id="airzone_unique_id", data=CONFIG - ) + entity_registry = er.async_get(hass) + + config_entry = MockConfigEntry(domain=DOMAIN, data=CONFIG) config_entry.add_to_hass(hass) with patch( @@ -30,6 +31,52 @@ async def test_unload_entry(hass: HomeAssistant) -> None: ), patch( "homeassistant.components.airzone.AirzoneLocalApi.get_webserver", side_effect=InvalidMethod, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert not config_entry.unique_id + assert ( + entity_registry.async_get("sensor.salon_temperature").unique_id + == f"{config_entry.entry_id}_1:1_temp" + ) + + with patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_hvac", + return_value=HVAC_MOCK, + ), patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_hvac_systems", + side_effect=SystemOutOfRange, + ), patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_webserver", + return_value=HVAC_WEBSERVER_MOCK, + ): + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.unique_id + assert ( + entity_registry.async_get("sensor.salon_temperature").unique_id + == f"{config_entry.unique_id}_1:1_temp" + ) + + +async def test_unload_entry(hass: HomeAssistant) -> None: + """Test unload.""" + + config_entry = MockConfigEntry( + data=CONFIG, + domain=DOMAIN, + unique_id="airzone_unique_id", + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.airzone.AirzoneLocalApi.validate", + return_value=None, + ), patch( + "homeassistant.components.airzone.AirzoneLocalApi.update", + return_value=None, ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/airzone/util.py b/tests/components/airzone/util.py index a4c06187665..52f15dd1476 100644 --- a/tests/components/airzone/util.py +++ b/tests/components/airzone/util.py @@ -12,6 +12,7 @@ from aioairzone.const import ( API_HEAT_STAGE, API_HEAT_STAGES, API_HUMIDITY, + API_MAC, API_MAX_TEMP, API_MIN_TEMP, API_MODE, @@ -26,6 +27,8 @@ from aioairzone.const import ( API_THERMOS_RADIO, API_THERMOS_TYPE, API_UNITS, + API_WIFI_CHANNEL, + API_WIFI_RSSI, API_ZONE_ID, ) from aioairzone.exceptions import InvalidMethod, SystemOutOfRange @@ -175,14 +178,24 @@ HVAC_MOCK = { ] } +HVAC_WEBSERVER_MOCK = { + API_MAC: "11:22:33:44:55:66", + API_WIFI_CHANNEL: 6, + API_WIFI_RSSI: -42, +} + async def async_init_integration( hass: HomeAssistant, ) -> None: """Set up the Airzone integration in Home Assistant.""" - entry = MockConfigEntry(domain=DOMAIN, data=CONFIG) - entry.add_to_hass(hass) + config_entry = MockConfigEntry( + data=CONFIG, + domain=DOMAIN, + unique_id="airzone_unique_id", + ) + config_entry.add_to_hass(hass) with patch( "homeassistant.components.airzone.AirzoneLocalApi.get_hvac", @@ -194,5 +207,5 @@ async def async_init_integration( "homeassistant.components.airzone.AirzoneLocalApi.get_webserver", side_effect=InvalidMethod, ): - await hass.config_entries.async_setup(entry.entry_id) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done()