diff --git a/homeassistant/components/airzone/__init__.py b/homeassistant/components/airzone/__init__.py index 65ce9193e07..1622bdb7bf3 100644 --- a/homeassistant/components/airzone/__init__.py +++ b/homeassistant/components/airzone/__init__.py @@ -19,7 +19,12 @@ from homeassistant.helpers import ( from .const import DOMAIN from .coordinator import AirzoneUpdateCoordinator -PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.SENSOR] +PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.CLIMATE, + Platform.SELECT, + Platform.SENSOR, +] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/airzone/climate.py b/homeassistant/components/airzone/climate.py index fa64efa355b..c344b1ff49c 100644 --- a/homeassistant/components/airzone/climate.py +++ b/homeassistant/components/airzone/climate.py @@ -1,7 +1,6 @@ """Support for the Airzone climate.""" from __future__ import annotations -import logging from typing import Any, Final from aioairzone.common import OperationMode @@ -9,8 +8,6 @@ from aioairzone.const import ( API_MODE, API_ON, API_SET_POINT, - API_SYSTEM_ID, - API_ZONE_ID, AZD_DEMAND, AZD_HUMIDITY, AZD_MASTER, @@ -25,7 +22,6 @@ from aioairzone.const import ( AZD_TEMP_UNIT, AZD_ZONES, ) -from aioairzone.exceptions import AirzoneError from homeassistant.components.climate import ( ClimateEntity, @@ -43,9 +39,6 @@ from .const import API_TEMPERATURE_STEP, DOMAIN, TEMP_UNIT_LIB_TO_HASS from .coordinator import AirzoneUpdateCoordinator from .entity import AirzoneZoneEntity -_LOGGER = logging.getLogger(__name__) - - HVAC_ACTION_LIB_TO_HASS: Final[dict[OperationMode, HVACAction]] = { OperationMode.STOP: HVACAction.OFF, OperationMode.COOLING: HVACAction.COOLING, @@ -114,23 +107,6 @@ class AirzoneClimate(AirzoneZoneEntity, ClimateEntity): ] self._async_update_attrs() - async def _async_update_hvac_params(self, params: dict[str, Any]) -> None: - """Send HVAC parameters to API.""" - _params = { - API_SYSTEM_ID: self.system_id, - API_ZONE_ID: self.zone_id, - **params, - } - _LOGGER.debug("update_hvac_params=%s", _params) - try: - await self.coordinator.airzone.set_hvac_parameters(_params) - except AirzoneError as error: - raise HomeAssistantError( - f"Failed to set zone {self.name}: {error}" - ) from error - else: - self.coordinator.async_set_updated_data(self.coordinator.airzone.data()) - async def async_turn_on(self) -> None: """Turn the entity on.""" params = { diff --git a/homeassistant/components/airzone/entity.py b/homeassistant/components/airzone/entity.py index f697a364bc8..2752e2932ad 100644 --- a/homeassistant/components/airzone/entity.py +++ b/homeassistant/components/airzone/entity.py @@ -1,9 +1,12 @@ """Entity classes for the Airzone integration.""" from __future__ import annotations +import logging from typing import Any from aioairzone.const import ( + API_SYSTEM_ID, + API_ZONE_ID, AZD_FIRMWARE, AZD_FULL_NAME, AZD_ID, @@ -17,8 +20,10 @@ from aioairzone.const import ( AZD_WEBSERVER, AZD_ZONES, ) +from aioairzone.exceptions import AirzoneError from homeassistant.config_entries import ConfigEntry +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -26,6 +31,8 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, MANUFACTURER from .coordinator import AirzoneUpdateCoordinator +_LOGGER = logging.getLogger(__name__) + class AirzoneEntity(CoordinatorEntity[AirzoneUpdateCoordinator]): """Define an Airzone entity.""" @@ -130,3 +137,20 @@ class AirzoneZoneEntity(AirzoneEntity): if key in zone: value = zone[key] return value + + async def _async_update_hvac_params(self, params: dict[str, Any]) -> None: + """Send HVAC parameters to API.""" + _params = { + API_SYSTEM_ID: self.system_id, + API_ZONE_ID: self.zone_id, + **params, + } + _LOGGER.debug("update_hvac_params=%s", _params) + try: + await self.coordinator.airzone.set_hvac_parameters(_params) + except AirzoneError as error: + raise HomeAssistantError( + f"Failed to set zone {self.name}: {error}" + ) from error + else: + self.coordinator.async_set_updated_data(self.coordinator.airzone.data()) diff --git a/homeassistant/components/airzone/select.py b/homeassistant/components/airzone/select.py new file mode 100644 index 00000000000..b67dab71c8d --- /dev/null +++ b/homeassistant/components/airzone/select.py @@ -0,0 +1,160 @@ +"""Support for the Airzone sensors.""" +from __future__ import annotations + +from dataclasses import dataclass, replace +from typing import Any, Final + +from aioairzone.common import GrilleAngle, SleepTimeout +from aioairzone.const import ( + API_COLD_ANGLE, + API_HEAT_ANGLE, + API_SLEEP, + AZD_COLD_ANGLE, + AZD_HEAT_ANGLE, + AZD_NAME, + AZD_SLEEP, + AZD_ZONES, +) + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import AirzoneUpdateCoordinator +from .entity import AirzoneEntity, AirzoneZoneEntity + + +@dataclass +class AirzoneSelectDescriptionMixin: + """Define an entity description mixin for select entities.""" + + api_param: str + options_dict: dict[str, int] + + +@dataclass +class AirzoneSelectDescription(SelectEntityDescription, AirzoneSelectDescriptionMixin): + """Class to describe an Airzone select entity.""" + + +GRILLE_ANGLE_DICT: Final[dict[str, int]] = { + "90º": GrilleAngle.DEG_90, + "50º": GrilleAngle.DEG_50, + "45º": GrilleAngle.DEG_45, + "40º": GrilleAngle.DEG_40, +} + +SLEEP_DICT: Final[dict[str, int]] = { + "Off": SleepTimeout.SLEEP_OFF, + "30m": SleepTimeout.SLEEP_30, + "60m": SleepTimeout.SLEEP_60, + "90m": SleepTimeout.SLEEP_90, +} + + +ZONE_SELECT_TYPES: Final[tuple[AirzoneSelectDescription, ...]] = ( + AirzoneSelectDescription( + api_param=API_COLD_ANGLE, + entity_category=EntityCategory.CONFIG, + key=AZD_COLD_ANGLE, + name="Cold Angle", + options_dict=GRILLE_ANGLE_DICT, + ), + AirzoneSelectDescription( + api_param=API_HEAT_ANGLE, + entity_category=EntityCategory.CONFIG, + key=AZD_HEAT_ANGLE, + name="Heat Angle", + options_dict=GRILLE_ANGLE_DICT, + ), + AirzoneSelectDescription( + api_param=API_SLEEP, + entity_category=EntityCategory.CONFIG, + key=AZD_SLEEP, + name="Sleep", + options_dict=SLEEP_DICT, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Add Airzone sensors from a config_entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + + entities: list[AirzoneBaseSelect] = [] + + for system_zone_id, zone_data in coordinator.data[AZD_ZONES].items(): + for description in ZONE_SELECT_TYPES: + if description.key in zone_data: + _desc = replace( + description, + options=list(description.options_dict.keys()), + ) + entities.append( + AirzoneZoneSelect( + coordinator, + _desc, + entry, + system_zone_id, + zone_data, + ) + ) + + async_add_entities(entities) + + +class AirzoneBaseSelect(AirzoneEntity, SelectEntity): + """Define an Airzone select.""" + + entity_description: AirzoneSelectDescription + values_dict: dict[int, str] + + @callback + def _handle_coordinator_update(self) -> None: + """Update attributes when the coordinator updates.""" + self._async_update_attrs() + super()._handle_coordinator_update() + + def _get_current_option(self) -> str | None: + value = self.get_airzone_value(self.entity_description.key) + return self.values_dict.get(value) + + @callback + def _async_update_attrs(self) -> None: + """Update select attributes.""" + self._attr_current_option = self._get_current_option() + + +class AirzoneZoneSelect(AirzoneZoneEntity, AirzoneBaseSelect): + """Define an Airzone Zone select.""" + + def __init__( + self, + coordinator: AirzoneUpdateCoordinator, + description: AirzoneSelectDescription, + entry: ConfigEntry, + system_zone_id: str, + zone_data: dict[str, Any], + ) -> 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"{self._attr_unique_id}_{system_zone_id}_{description.key}" + ) + self.entity_description = description + self.values_dict = {v: k for k, v in description.options_dict.items()} + + self._async_update_attrs() + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + param = self.entity_description.api_param + value = self.entity_description.options_dict[option] + await self._async_update_hvac_params({param: value}) diff --git a/tests/components/airzone/test_select.py b/tests/components/airzone/test_select.py new file mode 100644 index 00000000000..545a45508de --- /dev/null +++ b/tests/components/airzone/test_select.py @@ -0,0 +1,177 @@ +"""The select tests for the Airzone platform.""" + +from unittest.mock import patch + +from aioairzone.const import ( + API_COLD_ANGLE, + API_DATA, + API_HEAT_ANGLE, + API_SLEEP, + API_SYSTEM_ID, + API_ZONE_ID, +) +import pytest + +from homeassistant.components.select import DOMAIN as SELECT_DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION, SERVICE_SELECT_OPTION +from homeassistant.core import HomeAssistant + +from .util import async_init_integration + + +async def test_airzone_create_selects(hass: HomeAssistant) -> None: + """Test creation of selects.""" + + await async_init_integration(hass) + + state = hass.states.get("select.despacho_cold_angle") + assert state.state == "90º" + + state = hass.states.get("select.despacho_heat_angle") + assert state.state == "90º" + + state = hass.states.get("select.despacho_sleep") + assert state.state == "Off" + + state = hass.states.get("select.dorm_1_cold_angle") + assert state.state == "90º" + + state = hass.states.get("select.dorm_1_heat_angle") + assert state.state == "90º" + + state = hass.states.get("select.dorm_1_sleep") + assert state.state == "Off" + + state = hass.states.get("select.dorm_2_cold_angle") + assert state.state == "90º" + + state = hass.states.get("select.dorm_2_heat_angle") + assert state.state == "90º" + + state = hass.states.get("select.dorm_2_sleep") + assert state.state == "Off" + + state = hass.states.get("select.dorm_ppal_cold_angle") + assert state.state == "45º" + + state = hass.states.get("select.dorm_ppal_heat_angle") + assert state.state == "50º" + + state = hass.states.get("select.dorm_ppal_sleep") + assert state.state == "30m" + + state = hass.states.get("select.salon_cold_angle") + assert state.state == "90º" + + state = hass.states.get("select.salon_heat_angle") + assert state.state == "90º" + + state = hass.states.get("select.salon_sleep") + assert state.state == "Off" + + +async def test_airzone_select_sleep(hass: HomeAssistant) -> None: + """Test select sleep.""" + + await async_init_integration(hass) + + put_hvac_sleep = { + API_DATA: [ + { + API_SYSTEM_ID: 1, + API_ZONE_ID: 3, + API_SLEEP: 30, + } + ] + } + + with pytest.raises(ValueError): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.dorm_1_sleep", + ATTR_OPTION: "Invalid", + }, + blocking=True, + ) + + with patch( + "homeassistant.components.airzone.AirzoneLocalApi.put_hvac", + return_value=put_hvac_sleep, + ): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.dorm_1_sleep", + ATTR_OPTION: "30m", + }, + blocking=True, + ) + + state = hass.states.get("select.dorm_1_sleep") + assert state.state == "30m" + + +async def test_airzone_select_grille_angle(hass: HomeAssistant) -> None: + """Test select sleep.""" + + await async_init_integration(hass) + + # Cold Angle + + put_hvac_cold_angle = { + API_DATA: [ + { + API_SYSTEM_ID: 1, + API_ZONE_ID: 3, + API_COLD_ANGLE: 1, + } + ] + } + + with patch( + "homeassistant.components.airzone.AirzoneLocalApi.put_hvac", + return_value=put_hvac_cold_angle, + ): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.dorm_1_cold_angle", + ATTR_OPTION: "50º", + }, + blocking=True, + ) + + state = hass.states.get("select.dorm_1_cold_angle") + assert state.state == "50º" + + # Heat Angle + + put_hvac_heat_angle = { + API_DATA: [ + { + API_SYSTEM_ID: 1, + API_ZONE_ID: 3, + API_HEAT_ANGLE: 2, + } + ] + } + with patch( + "homeassistant.components.airzone.AirzoneLocalApi.put_hvac", + return_value=put_hvac_heat_angle, + ): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.dorm_1_heat_angle", + ATTR_OPTION: "45º", + }, + blocking=True, + ) + + state = hass.states.get("select.dorm_1_heat_angle") + assert state.state == "45º" diff --git a/tests/components/airzone/util.py b/tests/components/airzone/util.py index a29b035648b..6277c077c00 100644 --- a/tests/components/airzone/util.py +++ b/tests/components/airzone/util.py @@ -4,11 +4,13 @@ from unittest.mock import patch from aioairzone.const import ( API_AIR_DEMAND, + API_COLD_ANGLE, API_COLD_STAGE, API_COLD_STAGES, API_DATA, API_ERRORS, API_FLOOR_DEMAND, + API_HEAT_ANGLE, API_HEAT_STAGE, API_HEAT_STAGES, API_HUMIDITY, @@ -22,6 +24,7 @@ from aioairzone.const import ( API_POWER, API_ROOM_TEMP, API_SET_POINT, + API_SLEEP, API_SYSTEM_FIRMWARE, API_SYSTEM_ID, API_SYSTEM_TYPE, @@ -68,6 +71,7 @@ HVAC_MOCK = { API_MIN_TEMP: 15, API_SET_POINT: 19.1, API_ROOM_TEMP: 19.6, + API_SLEEP: 0, API_MODES: [1, 4, 2, 3, 5], API_MODE: 3, API_COLD_STAGES: 1, @@ -79,6 +83,8 @@ HVAC_MOCK = { API_ERRORS: [], API_AIR_DEMAND: 0, API_FLOOR_DEMAND: 0, + API_HEAT_ANGLE: 0, + API_COLD_ANGLE: 0, }, { API_SYSTEM_ID: 1, @@ -92,6 +98,7 @@ HVAC_MOCK = { API_MIN_TEMP: 15, API_SET_POINT: 19.2, API_ROOM_TEMP: 21.1, + API_SLEEP: 30, API_MODE: 3, API_COLD_STAGES: 1, API_COLD_STAGE: 1, @@ -102,6 +109,8 @@ HVAC_MOCK = { API_ERRORS: [], API_AIR_DEMAND: 1, API_FLOOR_DEMAND: 1, + API_HEAT_ANGLE: 1, + API_COLD_ANGLE: 2, }, { API_SYSTEM_ID: 1, @@ -115,6 +124,7 @@ HVAC_MOCK = { API_MIN_TEMP: 15, API_SET_POINT: 19.3, API_ROOM_TEMP: 20.8, + API_SLEEP: 0, API_MODE: 3, API_COLD_STAGES: 1, API_COLD_STAGE: 1, @@ -125,6 +135,8 @@ HVAC_MOCK = { API_ERRORS: [], API_AIR_DEMAND: 0, API_FLOOR_DEMAND: 0, + API_HEAT_ANGLE: 0, + API_COLD_ANGLE: 0, }, { API_SYSTEM_ID: 1, @@ -138,6 +150,7 @@ HVAC_MOCK = { API_MIN_TEMP: 59, API_SET_POINT: 66.92, API_ROOM_TEMP: 70.16, + API_SLEEP: 0, API_MODE: 3, API_COLD_STAGES: 1, API_COLD_STAGE: 1, @@ -152,6 +165,8 @@ HVAC_MOCK = { ], API_AIR_DEMAND: 0, API_FLOOR_DEMAND: 0, + API_HEAT_ANGLE: 0, + API_COLD_ANGLE: 0, }, { API_SYSTEM_ID: 1, @@ -165,6 +180,7 @@ HVAC_MOCK = { API_MIN_TEMP: 15, API_SET_POINT: 19.5, API_ROOM_TEMP: 20.5, + API_SLEEP: 0, API_MODE: 3, API_COLD_STAGES: 1, API_COLD_STAGE: 1, @@ -175,6 +191,8 @@ HVAC_MOCK = { API_ERRORS: [], API_AIR_DEMAND: 0, API_FLOOR_DEMAND: 0, + API_HEAT_ANGLE: 0, + API_COLD_ANGLE: 0, }, ] },