From 8edb253ace430189d9aeb37fd46480561a723899 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Tue, 23 May 2023 19:22:50 +0200 Subject: [PATCH] Add Airzone Cloud integration (#93238) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * airzone-cloud: add new integration Signed-off-by: Álvaro Fernández Rojas * Add missing aioairzone-cloud to test requirements Signed-off-by: Álvaro Fernández Rojas * Update aioairzone-cloud to v0.0.4 Allows to handle TooManyRequests exception on coordinator. Signed-off-by: Álvaro Fernández Rojas * aioairzone_cloud: reduce API requests Signed-off-by: Álvaro Fernández Rojas * airzone_cloud: remove system_zone_id As opposed to the Local API of Airzone devices, the Cloud API provides unique IDs for both systems and zones, so we can remove the system_zone_id copied from the Local API integration. Signed-off-by: Álvaro Fernández Rojas * airzone_cloud: minor improvements Signed-off-by: Álvaro Fernández Rojas * tests: airzone_cloud: simplify mock_get_webserver Signed-off-by: Álvaro Fernández Rojas * Update aioairzone to v0.0.5 - Add token refresh and relogin support. - Improve fetching installation devices. Signed-off-by: Álvaro Fernández Rojas * airzone_cloud: add to strict typing Signed-off-by: Álvaro Fernández Rojas * Update aioairzone to v0.0.7 Signed-off-by: Álvaro Fernández Rojas * trigger CI * airzone_cloud: remove unneeded api_get_user call Signed-off-by: Álvaro Fernández Rojas * Add Airzone brand Signed-off-by: Álvaro Fernández Rojas * Update aioairzone to v0.1.1 Signed-off-by: Álvaro Fernández Rojas * airzone_cloud: use unique_id instead of entry_id Signed-off-by: Álvaro Fernández Rojas * airzone_cloud: remove special handling of TooManyRequests Signed-off-by: Álvaro Fernández Rojas * airzone_cloud: bump coordinator timeout to 30s Signed-off-by: Álvaro Fernández Rojas * airzone_cloud: make AirzoneEntity an ABC Signed-off-by: Álvaro Fernández Rojas * airzone_cloud: fix strings typo Signed-off-by: Álvaro Fernández Rojas * tests: airzone_cloud: simplify webserver mock Signed-off-by: Álvaro Fernández Rojas * Update aioairzone-cloud to v0.1.2 Signed-off-by: Álvaro Fernández Rojas --------- Signed-off-by: Álvaro Fernández Rojas --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/brands/airzone.json | 5 + .../components/airzone_cloud/__init__.py | 48 +++++ .../components/airzone_cloud/config_flow.py | 116 ++++++++++++ .../components/airzone_cloud/const.py | 8 + .../components/airzone_cloud/coordinator.py | 43 +++++ .../components/airzone_cloud/entity.py | 54 ++++++ .../components/airzone_cloud/manifest.json | 10 + .../components/airzone_cloud/sensor.py | 97 ++++++++++ .../components/airzone_cloud/strings.json | 19 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 17 +- mypy.ini | 10 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/airzone_cloud/__init__.py | 1 + .../airzone_cloud/test_config_flow.py | 143 ++++++++++++++ .../airzone_cloud/test_coordinator.py | 70 +++++++ tests/components/airzone_cloud/test_init.py | 43 +++++ tests/components/airzone_cloud/test_sensor.py | 26 +++ tests/components/airzone_cloud/util.py | 175 ++++++++++++++++++ 22 files changed, 892 insertions(+), 3 deletions(-) create mode 100644 homeassistant/brands/airzone.json create mode 100644 homeassistant/components/airzone_cloud/__init__.py create mode 100644 homeassistant/components/airzone_cloud/config_flow.py create mode 100644 homeassistant/components/airzone_cloud/const.py create mode 100644 homeassistant/components/airzone_cloud/coordinator.py create mode 100644 homeassistant/components/airzone_cloud/entity.py create mode 100644 homeassistant/components/airzone_cloud/manifest.json create mode 100644 homeassistant/components/airzone_cloud/sensor.py create mode 100644 homeassistant/components/airzone_cloud/strings.json create mode 100644 tests/components/airzone_cloud/__init__.py create mode 100644 tests/components/airzone_cloud/test_config_flow.py create mode 100644 tests/components/airzone_cloud/test_coordinator.py create mode 100644 tests/components/airzone_cloud/test_init.py create mode 100644 tests/components/airzone_cloud/test_sensor.py create mode 100644 tests/components/airzone_cloud/util.py diff --git a/.strict-typing b/.strict-typing index fc40996a37b..9ed4bf53497 100644 --- a/.strict-typing +++ b/.strict-typing @@ -49,6 +49,7 @@ homeassistant.components.air_quality.* homeassistant.components.airly.* homeassistant.components.airvisual.* homeassistant.components.airzone.* +homeassistant.components.airzone_cloud.* homeassistant.components.aladdin_connect.* homeassistant.components.alarm_control_panel.* homeassistant.components.alert.* diff --git a/CODEOWNERS b/CODEOWNERS index b3ae7e94215..8a4e095e14b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -59,6 +59,8 @@ build.json @home-assistant/supervisor /tests/components/airvisual_pro/ @bachya /homeassistant/components/airzone/ @Noltari /tests/components/airzone/ @Noltari +/homeassistant/components/airzone_cloud/ @Noltari +/tests/components/airzone_cloud/ @Noltari /homeassistant/components/aladdin_connect/ @mkmer /tests/components/aladdin_connect/ @mkmer /homeassistant/components/alarm_control_panel/ @home-assistant/core diff --git a/homeassistant/brands/airzone.json b/homeassistant/brands/airzone.json new file mode 100644 index 00000000000..b41d1cb2e1c --- /dev/null +++ b/homeassistant/brands/airzone.json @@ -0,0 +1,5 @@ +{ + "domain": "airzone", + "name": "Airzone", + "integrations": ["airzone", "airzone_cloud"] +} diff --git a/homeassistant/components/airzone_cloud/__init__.py b/homeassistant/components/airzone_cloud/__init__.py new file mode 100644 index 00000000000..cdc0f30a533 --- /dev/null +++ b/homeassistant/components/airzone_cloud/__init__.py @@ -0,0 +1,48 @@ +"""The Airzone Cloud integration.""" +from __future__ import annotations + +from aioairzone_cloud.cloudapi import AirzoneCloudApi +from aioairzone_cloud.common import ConnectionOptions + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client + +from .const import DOMAIN +from .coordinator import AirzoneUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Airzone Cloud from a config entry.""" + options = ConnectionOptions( + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + ) + + airzone = AirzoneCloudApi(aiohttp_client.async_get_clientsession(hass), options) + await airzone.login() + inst_list = await airzone.list_installations() + for inst in inst_list: + if inst.get_id() == entry.data[CONF_ID]: + airzone.select_installation(inst) + await airzone.update_installation(inst) + + coordinator = AirzoneUpdateCoordinator(hass, airzone) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/airzone_cloud/config_flow.py b/homeassistant/components/airzone_cloud/config_flow.py new file mode 100644 index 00000000000..32274d4e8ef --- /dev/null +++ b/homeassistant/components/airzone_cloud/config_flow.py @@ -0,0 +1,116 @@ +"""Config flow for Airzone Cloud.""" +from __future__ import annotations + +from typing import Any + +from aioairzone_cloud.cloudapi import AirzoneCloudApi +from aioairzone_cloud.common import ConnectionOptions +from aioairzone_cloud.const import AZD_ID, AZD_NAME, AZD_WEBSERVERS +from aioairzone_cloud.exceptions import AirzoneCloudError, LoginError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .const import DOMAIN + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle config flow for an Airzone Cloud device.""" + + airzone: AirzoneCloudApi + + async def async_step_inst_pick( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the installation selection.""" + errors = {} + options: dict[str, str] = {} + + inst_desc = None + inst_id = None + if user_input is not None: + inst_id = user_input[CONF_ID] + + try: + inst_list = await self.airzone.list_installations() + except AirzoneCloudError: + errors["base"] = "cannot_connect" + else: + for inst in inst_list: + _data = inst.data() + _id = _data[AZD_ID] + options[_id] = f"{_data[AZD_NAME]} {_data[AZD_WEBSERVERS][0]} ({_id})" + if _id is not None and _id == inst_id: + inst_desc = options[_id] + + if user_input is not None and inst_desc is not None: + await self.async_set_unique_id(inst_id) + self._abort_if_unique_id_configured() + + user_input[CONF_USERNAME] = self.airzone.options.username + user_input[CONF_PASSWORD] = self.airzone.options.password + + return self.async_create_entry(title=inst_desc, data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_ID): SelectSelector( + SelectSelectorConfig( + options=[ + SelectOptionDict(value=k, label=v) + for k, v in options.items() + ], + mode=SelectSelectorMode.DROPDOWN, + ) + ), + } + ), + errors=errors, + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors = {} + + if user_input is not None: + if CONF_ID in user_input: + return await self.async_step_inst_pick(user_input) + + self.airzone = AirzoneCloudApi( + aiohttp_client.async_get_clientsession(self.hass), + ConnectionOptions( + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + ), + ) + + try: + await self.airzone.login() + except (AirzoneCloudError, LoginError): + errors["base"] = "cannot_connect" + else: + return await self.async_step_inst_pick() + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/airzone_cloud/const.py b/homeassistant/components/airzone_cloud/const.py new file mode 100644 index 00000000000..625d897188d --- /dev/null +++ b/homeassistant/components/airzone_cloud/const.py @@ -0,0 +1,8 @@ +"""Constants for the Airzone Cloud integration.""" + +from typing import Final + +DOMAIN: Final[str] = "airzone_cloud" +MANUFACTURER: Final[str] = "Airzone" + +AIOAIRZONE_CLOUD_TIMEOUT_SEC: Final[int] = 30 diff --git a/homeassistant/components/airzone_cloud/coordinator.py b/homeassistant/components/airzone_cloud/coordinator.py new file mode 100644 index 00000000000..edd99355092 --- /dev/null +++ b/homeassistant/components/airzone_cloud/coordinator.py @@ -0,0 +1,43 @@ +"""The Airzone Cloud integration coordinator.""" +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Any + +from aioairzone_cloud.cloudapi import AirzoneCloudApi +from aioairzone_cloud.exceptions import AirzoneCloudError +import async_timeout + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import AIOAIRZONE_CLOUD_TIMEOUT_SEC, DOMAIN + +SCAN_INTERVAL = timedelta(seconds=60) + +_LOGGER = logging.getLogger(__name__) + + +class AirzoneUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Class to manage fetching data from the Airzone Cloud device.""" + + def __init__(self, hass: HomeAssistant, airzone: AirzoneCloudApi) -> None: + """Initialize.""" + self.airzone = airzone + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + + async def _async_update_data(self) -> dict[str, Any]: + """Update data via library.""" + async with async_timeout.timeout(AIOAIRZONE_CLOUD_TIMEOUT_SEC): + try: + await self.airzone.update() + except AirzoneCloudError as error: + raise UpdateFailed(error) from error + return self.airzone.data() diff --git a/homeassistant/components/airzone_cloud/entity.py b/homeassistant/components/airzone_cloud/entity.py new file mode 100644 index 00000000000..bf02bf1ccf8 --- /dev/null +++ b/homeassistant/components/airzone_cloud/entity.py @@ -0,0 +1,54 @@ +"""Entity classes for the Airzone Cloud integration.""" +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any + +from aioairzone_cloud.const import AZD_NAME, AZD_SYSTEM_ID, AZD_ZONES + +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MANUFACTURER +from .coordinator import AirzoneUpdateCoordinator + + +class AirzoneEntity(CoordinatorEntity[AirzoneUpdateCoordinator], ABC): + """Define an Airzone Cloud entity.""" + + @abstractmethod + def get_airzone_value(self, key: str) -> Any: + """Return Airzone Cloud entity value by key.""" + + +class AirzoneZoneEntity(AirzoneEntity): + """Define an Airzone Cloud Zone entity.""" + + def __init__( + self, + coordinator: AirzoneUpdateCoordinator, + entry: ConfigEntry, + zone_id: str, + zone_data: dict[str, Any], + ) -> None: + """Initialize.""" + super().__init__(coordinator) + + self.system_id = zone_data[AZD_SYSTEM_ID] + self.zone_id = zone_id + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{entry.unique_id}_{zone_id}")}, + manufacturer=MANUFACTURER, + name=zone_data[AZD_NAME], + via_device=(DOMAIN, f"{entry.unique_id}_{self.system_id}"), + ) + + def get_airzone_value(self, key: str) -> Any: + """Return zone value by key.""" + value = None + if zone := self.coordinator.data[AZD_ZONES].get(self.zone_id): + if key in zone: + value = zone[key] + return value diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json new file mode 100644 index 00000000000..cc0e05f5968 --- /dev/null +++ b/homeassistant/components/airzone_cloud/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "airzone_cloud", + "name": "Airzone Cloud", + "codeowners": ["@Noltari"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/airzone_cloud", + "iot_class": "cloud_polling", + "loggers": ["aioairzone_cloud"], + "requirements": ["aioairzone-cloud==0.1.2"] +} diff --git a/homeassistant/components/airzone_cloud/sensor.py b/homeassistant/components/airzone_cloud/sensor.py new file mode 100644 index 00000000000..9df7518fa62 --- /dev/null +++ b/homeassistant/components/airzone_cloud/sensor.py @@ -0,0 +1,97 @@ +"""Support for the Airzone Cloud sensors.""" +from __future__ import annotations + +from typing import Any, Final + +from aioairzone_cloud.const import AZD_HUMIDITY, AZD_NAME, AZD_TEMP, AZD_ZONES + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE, UnitOfTemperature +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import AirzoneUpdateCoordinator +from .entity import AirzoneEntity, AirzoneZoneEntity + +ZONE_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = ( + SensorEntityDescription( + device_class=SensorDeviceClass.TEMPERATURE, + key=AZD_TEMP, + name="Temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + device_class=SensorDeviceClass.HUMIDITY, + key=AZD_HUMIDITY, + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Add Airzone Cloud sensors from a config_entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + + sensors = [] + for zone_id, zone_data in coordinator.data[AZD_ZONES].items(): + for description in ZONE_SENSOR_TYPES: + if description.key in zone_data: + sensors.append( + AirzoneZoneSensor( + coordinator, + description, + entry, + zone_id, + zone_data, + ) + ) + + async_add_entities(sensors) + + +class AirzoneSensor(AirzoneEntity, SensorEntity): + """Define an Airzone Cloud sensor.""" + + @callback + def _handle_coordinator_update(self) -> None: + """Update attributes when the coordinator updates.""" + self._async_update_attrs() + super()._handle_coordinator_update() + + @callback + def _async_update_attrs(self) -> None: + """Update sensor attributes.""" + self._attr_native_value = self.get_airzone_value(self.entity_description.key) + + +class AirzoneZoneSensor(AirzoneZoneEntity, AirzoneSensor): + """Define an Airzone Cloud Zone sensor.""" + + def __init__( + self, + coordinator: AirzoneUpdateCoordinator, + description: SensorEntityDescription, + entry: ConfigEntry, + zone_id: str, + zone_data: dict[str, Any], + ) -> None: + """Initialize.""" + super().__init__(coordinator, entry, zone_id, zone_data) + + self._attr_name = f"{zone_data[AZD_NAME]} {description.name}" + self._attr_unique_id = f"{entry.unique_id}_{zone_id}_{description.key}" + self.entity_description = description + + self._async_update_attrs() diff --git a/homeassistant/components/airzone_cloud/strings.json b/homeassistant/components/airzone_cloud/strings.json new file mode 100644 index 00000000000..12f155b4486 --- /dev/null +++ b/homeassistant/components/airzone_cloud/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "step": { + "user": { + "data": { + "id": "Installation", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 1b76aa2b56d..399e54c3b55 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -32,6 +32,7 @@ FLOWS = { "airvisual", "airvisual_pro", "airzone", + "airzone_cloud", "aladdin_connect", "alarmdecoder", "amberelectric", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index c9cd79e60dc..c9ce171e3d4 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -137,9 +137,20 @@ }, "airzone": { "name": "Airzone", - "integration_type": "hub", - "config_flow": true, - "iot_class": "local_polling" + "integrations": { + "airzone": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling", + "name": "Airzone" + }, + "airzone_cloud": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling", + "name": "Airzone Cloud" + } + } }, "aladdin_connect": { "name": "Aladdin Connect", diff --git a/mypy.ini b/mypy.ini index fd5e87bd499..b2c6b7a9c83 100644 --- a/mypy.ini +++ b/mypy.ini @@ -251,6 +251,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.airzone_cloud.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.aladdin_connect.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index ef7c225d8c4..cf5b9fd441f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -115,6 +115,9 @@ aio_georss_gdacs==0.8 # homeassistant.components.airq aioairq==0.2.4 +# homeassistant.components.airzone_cloud +aioairzone-cloud==0.1.2 + # homeassistant.components.airzone aioairzone==0.5.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 689c731a057..7a43f7cee3a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -105,6 +105,9 @@ aio_georss_gdacs==0.8 # homeassistant.components.airq aioairq==0.2.4 +# homeassistant.components.airzone_cloud +aioairzone-cloud==0.1.2 + # homeassistant.components.airzone aioairzone==0.5.6 diff --git a/tests/components/airzone_cloud/__init__.py b/tests/components/airzone_cloud/__init__.py new file mode 100644 index 00000000000..a00eeb760eb --- /dev/null +++ b/tests/components/airzone_cloud/__init__.py @@ -0,0 +1 @@ +"""Tests for the Airzone Cloud integration.""" diff --git a/tests/components/airzone_cloud/test_config_flow.py b/tests/components/airzone_cloud/test_config_flow.py new file mode 100644 index 00000000000..477511b5f20 --- /dev/null +++ b/tests/components/airzone_cloud/test_config_flow.py @@ -0,0 +1,143 @@ +"""Define tests for the Airzone Cloud config flow.""" + +from unittest.mock import patch + +from aioairzone_cloud.exceptions import AirzoneCloudError, LoginError + +from homeassistant import data_entry_flow +from homeassistant.components.airzone_cloud.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER, ConfigEntryState +from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from .util import ( + CONFIG, + GET_INSTALLATION_MOCK, + GET_INSTALLATIONS_MOCK, + GET_WEBSERVER_MOCK, + WS_ID, + mock_get_device_status, +) + + +async def test_form(hass: HomeAssistant) -> None: + """Test that the form is served with valid input.""" + + with patch( + "homeassistant.components.airzone_cloud.async_setup_entry", + return_value=True, + ) as mock_setup_entry, patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_device_status", + side_effect=mock_get_device_status, + ), patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_installation", + return_value=GET_INSTALLATION_MOCK, + ), patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_installations", + return_value=GET_INSTALLATIONS_MOCK, + ), patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_webserver", + return_value=GET_WEBSERVER_MOCK, + ), patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.login", + return_value=None, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: CONFIG[CONF_USERNAME], + CONF_PASSWORD: CONFIG[CONF_PASSWORD], + }, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ID: CONFIG[CONF_ID], + }, + ) + + await hass.async_block_till_done() + + conf_entries = hass.config_entries.async_entries(DOMAIN) + entry = conf_entries[0] + assert entry.state is ConfigEntryState.LOADED + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["title"] == f"House {WS_ID} ({CONFIG[CONF_ID]})" + assert result["data"][CONF_ID] == CONFIG[CONF_ID] + assert result["data"][CONF_USERNAME] == CONFIG[CONF_USERNAME] + assert result["data"][CONF_PASSWORD] == CONFIG[CONF_PASSWORD] + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_installations_list_error(hass: HomeAssistant) -> None: + """Test connection error.""" + + with patch( + "homeassistant.components.airzone_cloud.async_setup_entry", + return_value=True, + ), patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_device_status", + side_effect=mock_get_device_status, + ), patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_installations", + side_effect=AirzoneCloudError, + ), patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_webserver", + return_value=GET_WEBSERVER_MOCK, + ), patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.login", + return_value=None, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: CONFIG[CONF_USERNAME], + CONF_PASSWORD: CONFIG[CONF_PASSWORD], + }, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_login_error(hass: HomeAssistant) -> None: + """Test login error.""" + + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.login", + side_effect=LoginError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_USERNAME: CONFIG[CONF_USERNAME], + CONF_PASSWORD: CONFIG[CONF_PASSWORD], + }, + ) + + assert result["errors"] == {"base": "cannot_connect"} diff --git a/tests/components/airzone_cloud/test_coordinator.py b/tests/components/airzone_cloud/test_coordinator.py new file mode 100644 index 00000000000..3bea3869881 --- /dev/null +++ b/tests/components/airzone_cloud/test_coordinator.py @@ -0,0 +1,70 @@ +"""Define tests for the Airzone Cloud coordinator.""" + +from unittest.mock import patch + +from aioairzone_cloud.exceptions import AirzoneCloudError + +from homeassistant.components.airzone_cloud.const import DOMAIN +from homeassistant.components.airzone_cloud.coordinator import SCAN_INTERVAL +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.util.dt import utcnow + +from .util import ( + CONFIG, + GET_INSTALLATION_MOCK, + GET_INSTALLATIONS_MOCK, + GET_WEBSERVER_MOCK, + mock_get_device_status, +) + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_coordinator_client_connector_error(hass: HomeAssistant) -> None: + """Test ClientConnectorError on coordinator update.""" + + config_entry = MockConfigEntry( + data=CONFIG, + domain=DOMAIN, + unique_id="airzone_cloud_unique_id", + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_device_status", + side_effect=mock_get_device_status, + ) as mock_device_status, patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_installation", + return_value=GET_INSTALLATION_MOCK, + ) as mock_installation, patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_installations", + return_value=GET_INSTALLATIONS_MOCK, + ) as mock_installations, patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_webserver", + return_value=GET_WEBSERVER_MOCK, + ) as mock_webserver, patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.login", + return_value=None, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + mock_device_status.assert_called() + mock_installation.assert_awaited_once() + mock_installations.assert_called_once() + mock_webserver.assert_called_once() + + mock_device_status.reset_mock() + mock_installation.reset_mock() + mock_installations.reset_mock() + mock_webserver.reset_mock() + + mock_device_status.side_effect = AirzoneCloudError + async_fire_time_changed(hass, utcnow() + SCAN_INTERVAL) + await hass.async_block_till_done() + + mock_device_status.assert_called() + + state = hass.states.get("sensor.salon_temperature") + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/airzone_cloud/test_init.py b/tests/components/airzone_cloud/test_init.py new file mode 100644 index 00000000000..3a6497fdeba --- /dev/null +++ b/tests/components/airzone_cloud/test_init.py @@ -0,0 +1,43 @@ +"""Define tests for the Airzone Cloud init.""" + +from unittest.mock import patch + +from homeassistant.components.airzone_cloud.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from .util import CONFIG + +from tests.common import MockConfigEntry + + +async def test_unload_entry(hass: HomeAssistant) -> None: + """Test unload.""" + + config_entry = MockConfigEntry( + data=CONFIG, + domain=DOMAIN, + unique_id="airzone_cloud_unique_id", + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.login", + return_value=None, + ), patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.list_installations", + return_value=[], + ), patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.update_installation", + return_value=None, + ), patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.update", + return_value=None, + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/airzone_cloud/test_sensor.py b/tests/components/airzone_cloud/test_sensor.py new file mode 100644 index 00000000000..f8cbfbf2fde --- /dev/null +++ b/tests/components/airzone_cloud/test_sensor.py @@ -0,0 +1,26 @@ +"""The sensor tests for the Airzone Cloud platform.""" + +from homeassistant.core import HomeAssistant + +from .util import async_init_integration + + +async def test_airzone_create_sensors( + hass: HomeAssistant, entity_registry_enabled_by_default: None +) -> None: + """Test creation of sensors.""" + + await async_init_integration(hass) + + # Zones + state = hass.states.get("sensor.dormitorio_temperature") + assert state.state == "25.0" + + state = hass.states.get("sensor.dormitorio_humidity") + assert state.state == "24" + + state = hass.states.get("sensor.salon_temperature") + assert state.state == "20.0" + + state = hass.states.get("sensor.salon_humidity") + assert state.state == "30" diff --git a/tests/components/airzone_cloud/util.py b/tests/components/airzone_cloud/util.py new file mode 100644 index 00000000000..c88221fc5cf --- /dev/null +++ b/tests/components/airzone_cloud/util.py @@ -0,0 +1,175 @@ +"""Tests for the Airzone integration.""" + +from typing import Any +from unittest.mock import patch + +from aioairzone_cloud.const import ( + API_AZ_SYSTEM, + API_AZ_ZONE, + API_CELSIUS, + API_CONFIG, + API_CONNECTION_DATE, + API_DEVICE_ID, + API_DEVICES, + API_DISCONNECTION_DATE, + API_FAH, + API_GROUPS, + API_HUMIDITY, + API_INSTALLATION_ID, + API_INSTALLATIONS, + API_IS_CONNECTED, + API_LOCAL_TEMP, + API_META, + API_NAME, + API_STAT_AP_MAC, + API_STAT_CHANNEL, + API_STAT_QUALITY, + API_STAT_SSID, + API_STATUS, + API_SYSTEM_NUMBER, + API_TYPE, + API_WS_FW, + API_WS_ID, + API_WS_IDS, + API_WS_TYPE, + API_ZONE_NUMBER, +) +from aioairzone_cloud.device import Device + +from homeassistant.components.airzone_cloud import DOMAIN +from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +WS_ID = "11:22:33:44:55:66" + +CONFIG = { + CONF_ID: "inst1", + CONF_USERNAME: "user", + CONF_PASSWORD: "pass", +} + +GET_INSTALLATION_MOCK = { + API_GROUPS: [ + { + API_NAME: "Group", + API_DEVICES: [ + { + API_DEVICE_ID: "system1", + API_TYPE: API_AZ_SYSTEM, + API_META: { + API_SYSTEM_NUMBER: 1, + }, + API_WS_ID: WS_ID, + }, + { + API_DEVICE_ID: "zone1", + API_NAME: "Salon", + API_TYPE: API_AZ_ZONE, + API_META: { + API_SYSTEM_NUMBER: 1, + API_ZONE_NUMBER: 1, + }, + API_WS_ID: WS_ID, + }, + { + API_DEVICE_ID: "zone2", + API_NAME: "Dormitorio", + API_TYPE: API_AZ_ZONE, + API_META: { + API_SYSTEM_NUMBER: 1, + API_ZONE_NUMBER: 2, + }, + API_WS_ID: WS_ID, + }, + ], + }, + ], +} + +GET_INSTALLATIONS_MOCK = { + API_INSTALLATIONS: [ + { + API_INSTALLATION_ID: CONFIG[CONF_ID], + API_NAME: "House", + API_WS_IDS: [ + WS_ID, + ], + }, + ], +} + +GET_WEBSERVER_MOCK = { + API_WS_TYPE: "ws_az", + API_CONFIG: { + API_WS_FW: "3.44", + API_STAT_SSID: "Wifi", + API_STAT_CHANNEL: 36, + API_STAT_AP_MAC: "00:00:00:00:00:00", + }, + API_STATUS: { + API_IS_CONNECTED: True, + API_STAT_QUALITY: 4, + API_CONNECTION_DATE: "2023-05-07T12:55:51.000Z", + API_DISCONNECTION_DATE: "2023-01-01T22:26:55.376Z", + }, +} + + +def mock_get_device_status(device: Device) -> dict[str, Any]: + """Mock API device status.""" + + if device.get_id() == "system1": + return { + API_IS_CONNECTED: True, + } + if device.get_id() == "zone2": + return { + API_HUMIDITY: 24, + API_IS_CONNECTED: True, + API_LOCAL_TEMP: { + API_FAH: 77, + API_CELSIUS: 25, + }, + } + return { + API_HUMIDITY: 30, + API_IS_CONNECTED: True, + API_LOCAL_TEMP: { + API_FAH: 68, + API_CELSIUS: 20, + }, + } + + +async def async_init_integration( + hass: HomeAssistant, +) -> None: + """Set up the Airzone integration in Home Assistant.""" + + config_entry = MockConfigEntry( + data=CONFIG, + domain=DOMAIN, + unique_id="airzone_cloud_unique_id", + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_device_status", + side_effect=mock_get_device_status, + ), patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_installation", + return_value=GET_INSTALLATION_MOCK, + ), patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_installations", + return_value=GET_INSTALLATIONS_MOCK, + ), patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_webserver", + return_value=GET_WEBSERVER_MOCK, + ), patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.login", + return_value=None, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done()