diff --git a/homeassistant/components/airzone/__init__.py b/homeassistant/components/airzone/__init__.py index c1fd6deddf2..5d5350d7a29 100644 --- a/homeassistant/components/airzone/__init__.py +++ b/homeassistant/components/airzone/__init__.py @@ -17,7 +17,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, MANUFACTURER from .coordinator import AirzoneUpdateCoordinator -PLATFORMS: list[Platform] = [Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.SENSOR] class AirzoneEntity(CoordinatorEntity[AirzoneUpdateCoordinator]): diff --git a/homeassistant/components/airzone/climate.py b/homeassistant/components/airzone/climate.py new file mode 100644 index 00000000000..763603a758b --- /dev/null +++ b/homeassistant/components/airzone/climate.py @@ -0,0 +1,190 @@ +"""Support for the Airzone climate.""" +from __future__ import annotations + +import logging +from typing import Final + +from aioairzone.common import OperationMode +from aioairzone.const import ( + API_MODE, + API_ON, + API_SET_POINT, + API_SYSTEM_ID, + API_ZONE_ID, + AZD_DEMAND, + AZD_HUMIDITY, + AZD_MODE, + AZD_MODES, + AZD_NAME, + AZD_ON, + AZD_TEMP, + AZD_TEMP_MAX, + AZD_TEMP_MIN, + AZD_TEMP_SET, + AZD_TEMP_UNIT, + AZD_ZONES, +) +from aioairzone.exceptions import AirzoneError +from aiohttp.client_exceptions import ClientConnectorError + +from homeassistant.components.climate import ClimateEntity +from homeassistant.components.climate.const import ( + CURRENT_HVAC_COOL, + CURRENT_HVAC_DRY, + CURRENT_HVAC_FAN, + CURRENT_HVAC_HEAT, + CURRENT_HVAC_IDLE, + CURRENT_HVAC_OFF, + HVAC_MODE_COOL, + HVAC_MODE_DRY, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_OFF, + SUPPORT_TARGET_TEMPERATURE, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import AirzoneEntity +from .const import API_TEMPERATURE_STEP, DOMAIN, TEMP_UNIT_LIB_TO_HASS +from .coordinator import AirzoneUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +HVAC_ACTION_LIB_TO_HASS: Final[dict[OperationMode, str]] = { + OperationMode.STOP: CURRENT_HVAC_OFF, + OperationMode.COOLING: CURRENT_HVAC_COOL, + OperationMode.HEATING: CURRENT_HVAC_HEAT, + OperationMode.FAN: CURRENT_HVAC_FAN, + OperationMode.DRY: CURRENT_HVAC_DRY, +} +HVAC_MODE_LIB_TO_HASS: Final[dict[OperationMode, str]] = { + OperationMode.STOP: HVAC_MODE_OFF, + OperationMode.COOLING: HVAC_MODE_COOL, + OperationMode.HEATING: HVAC_MODE_HEAT, + OperationMode.FAN: HVAC_MODE_FAN_ONLY, + OperationMode.DRY: HVAC_MODE_DRY, + OperationMode.AUTO: HVAC_MODE_HEAT_COOL, +} +HVAC_MODE_HASS_TO_LIB: Final[dict[str, OperationMode]] = { + HVAC_MODE_OFF: OperationMode.STOP, + HVAC_MODE_COOL: OperationMode.COOLING, + HVAC_MODE_HEAT: OperationMode.HEATING, + HVAC_MODE_FAN_ONLY: OperationMode.FAN, + HVAC_MODE_DRY: OperationMode.DRY, + HVAC_MODE_HEAT_COOL: OperationMode.AUTO, +} + + +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] + async_add_entities( + AirzoneClimate( + coordinator, + entry, + system_zone_id, + zone_data, + ) + for system_zone_id, zone_data in coordinator.data[AZD_ZONES].items() + ) + + +class AirzoneClimate(AirzoneEntity, ClimateEntity): + """Define an Airzone sensor.""" + + def __init__( + self, + coordinator: AirzoneUpdateCoordinator, + entry: ConfigEntry, + system_zone_id: str, + zone_data: dict, + ) -> 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_supported_features = SUPPORT_TARGET_TEMPERATURE + self._attr_target_temperature_step = API_TEMPERATURE_STEP + self._attr_max_temp = self.get_zone_value(AZD_TEMP_MAX) + self._attr_min_temp = self.get_zone_value(AZD_TEMP_MIN) + self._attr_temperature_unit = TEMP_UNIT_LIB_TO_HASS[ + self.get_zone_value(AZD_TEMP_UNIT) + ] + self._attr_hvac_modes = [ + HVAC_MODE_LIB_TO_HASS[mode] + for mode in self.get_zone_value(AZD_MODES) + or [self.get_zone_value(AZD_MODE)] + ] + if HVAC_MODE_OFF not in self._attr_hvac_modes: + self._attr_hvac_modes.append(HVAC_MODE_OFF) + self._async_update_attrs() + + async def _async_update_hvac_params(self, params) -> None: + """Send HVAC parameters to API.""" + try: + await self.coordinator.airzone.put_hvac(params) + except (AirzoneError, ClientConnectorError) 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_set_hvac_mode(self, hvac_mode: str) -> None: + """Set hvac mode.""" + params = { + API_SYSTEM_ID: self.system_id, + API_ZONE_ID: self.zone_id, + } + if hvac_mode == HVAC_MODE_OFF: + params[API_ON] = 0 + else: + if self.get_zone_value(AZD_MODES): + mode = HVAC_MODE_HASS_TO_LIB[hvac_mode] + if mode != self.get_zone_value(AZD_MODE): + params[API_MODE] = mode + params[API_ON] = 1 + _LOGGER.debug("Set hvac_mode=%s params=%s", hvac_mode, params) + await self._async_update_hvac_params(params) + + async def async_set_temperature(self, **kwargs) -> None: + """Set new target temperature.""" + temp = kwargs.get(ATTR_TEMPERATURE) + params = { + API_SYSTEM_ID: self.system_id, + API_ZONE_ID: self.zone_id, + API_SET_POINT: temp, + } + _LOGGER.debug("Set temp=%s params=%s", temp, params) + await self._async_update_hvac_params(params) + + @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 climate attributes.""" + self._attr_current_temperature = self.get_zone_value(AZD_TEMP) + self._attr_current_humidity = self.get_zone_value(AZD_HUMIDITY) + if self.get_zone_value(AZD_ON): + mode = self.get_zone_value(AZD_MODE) + self._attr_hvac_mode = HVAC_MODE_LIB_TO_HASS[mode] + if self.get_zone_value(AZD_DEMAND): + self._attr_hvac_action = HVAC_ACTION_LIB_TO_HASS[mode] + else: + self._attr_hvac_action = CURRENT_HVAC_IDLE + else: + self._attr_hvac_action = CURRENT_HVAC_OFF + self._attr_hvac_mode = HVAC_MODE_OFF + self._attr_target_temperature = self.get_zone_value(AZD_TEMP_SET) diff --git a/homeassistant/components/airzone/const.py b/homeassistant/components/airzone/const.py index f1818334914..094e2319476 100644 --- a/homeassistant/components/airzone/const.py +++ b/homeassistant/components/airzone/const.py @@ -10,6 +10,7 @@ DOMAIN: Final = "airzone" MANUFACTURER: Final = "Airzone" AIOAIRZONE_DEVICE_TIMEOUT_SEC: Final = 10 +API_TEMPERATURE_STEP: Final = 0.5 DEFAULT_LOCAL_API_PORT: Final = 3000 TEMP_UNIT_LIB_TO_HASS: Final[dict[TemperatureUnit, str]] = { diff --git a/tests/components/airzone/test_climate.py b/tests/components/airzone/test_climate.py new file mode 100644 index 00000000000..bc88321720c --- /dev/null +++ b/tests/components/airzone/test_climate.py @@ -0,0 +1,238 @@ +"""The climate tests for the Airzone platform.""" + +from unittest.mock import patch + +from aioairzone.common import OperationMode +from aioairzone.const import ( + API_DATA, + API_MODE, + API_ON, + API_SET_POINT, + API_SYSTEM_ID, + API_ZONE_ID, +) +from aioairzone.exceptions import AirzoneError + +from homeassistant.components.airzone.const import API_TEMPERATURE_STEP +from homeassistant.components.climate.const import ( + ATTR_CURRENT_HUMIDITY, + ATTR_CURRENT_TEMPERATURE, + ATTR_HVAC_ACTION, + ATTR_HVAC_MODE, + ATTR_HVAC_MODES, + ATTR_MAX_TEMP, + ATTR_MIN_TEMP, + ATTR_TARGET_TEMP_STEP, + CURRENT_HVAC_HEAT, + CURRENT_HVAC_IDLE, + CURRENT_HVAC_OFF, + DOMAIN as CLIMATE_DOMAIN, + HVAC_MODE_COOL, + HVAC_MODE_DRY, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_TEMPERATURE, +) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE +from homeassistant.exceptions import HomeAssistantError + +from .util import async_init_integration + + +async def test_airzone_create_climates(hass): + """Test creation of climates.""" + + await async_init_integration(hass) + + state = hass.states.get("climate.despacho") + assert state.state == HVAC_MODE_OFF + assert state.attributes.get(ATTR_CURRENT_HUMIDITY) == 36 + assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 21.2 + assert state.attributes.get(ATTR_HVAC_ACTION) == CURRENT_HVAC_OFF + assert state.attributes.get(ATTR_HVAC_MODES) == [ + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + ] + assert state.attributes.get(ATTR_MAX_TEMP) == 30 + assert state.attributes.get(ATTR_MIN_TEMP) == 15 + assert state.attributes.get(ATTR_TARGET_TEMP_STEP) == API_TEMPERATURE_STEP + assert state.attributes.get(ATTR_TEMPERATURE) == 19.4 + + state = hass.states.get("climate.dorm_1") + assert state.state == HVAC_MODE_HEAT + assert state.attributes.get(ATTR_CURRENT_HUMIDITY) == 35 + assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 20.8 + assert state.attributes.get(ATTR_HVAC_ACTION) == CURRENT_HVAC_IDLE + assert state.attributes.get(ATTR_HVAC_MODES) == [ + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + ] + assert state.attributes.get(ATTR_MAX_TEMP) == 30 + assert state.attributes.get(ATTR_MIN_TEMP) == 15 + assert state.attributes.get(ATTR_TARGET_TEMP_STEP) == API_TEMPERATURE_STEP + assert state.attributes.get(ATTR_TEMPERATURE) == 19.3 + + state = hass.states.get("climate.dorm_2") + assert state.state == HVAC_MODE_OFF + assert state.attributes.get(ATTR_CURRENT_HUMIDITY) == 40 + assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 20.5 + assert state.attributes.get(ATTR_HVAC_ACTION) == CURRENT_HVAC_OFF + assert state.attributes.get(ATTR_HVAC_MODES) == [ + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + ] + assert state.attributes.get(ATTR_MAX_TEMP) == 30 + assert state.attributes.get(ATTR_MIN_TEMP) == 15 + assert state.attributes.get(ATTR_TARGET_TEMP_STEP) == API_TEMPERATURE_STEP + assert state.attributes.get(ATTR_TEMPERATURE) == 19.5 + + state = hass.states.get("climate.dorm_ppal") + assert state.state == HVAC_MODE_HEAT + assert state.attributes.get(ATTR_CURRENT_HUMIDITY) == 39 + assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 21.1 + assert state.attributes.get(ATTR_HVAC_ACTION) == CURRENT_HVAC_HEAT + assert state.attributes.get(ATTR_HVAC_MODES) == [ + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + ] + assert state.attributes.get(ATTR_MAX_TEMP) == 30 + assert state.attributes.get(ATTR_MIN_TEMP) == 15 + assert state.attributes.get(ATTR_TARGET_TEMP_STEP) == API_TEMPERATURE_STEP + assert state.attributes.get(ATTR_TEMPERATURE) == 19.2 + + state = hass.states.get("climate.salon") + assert state.state == HVAC_MODE_OFF + assert state.attributes.get(ATTR_CURRENT_HUMIDITY) == 34 + assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 19.6 + assert state.attributes.get(ATTR_HVAC_ACTION) == CURRENT_HVAC_OFF + assert state.attributes.get(ATTR_HVAC_MODES) == [ + HVAC_MODE_OFF, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_COOL, + HVAC_MODE_HEAT, + HVAC_MODE_DRY, + ] + assert state.attributes.get(ATTR_MAX_TEMP) == 30 + assert state.attributes.get(ATTR_MIN_TEMP) == 15 + assert state.attributes.get(ATTR_TARGET_TEMP_STEP) == API_TEMPERATURE_STEP + assert state.attributes.get(ATTR_TEMPERATURE) == 19.1 + + +async def test_airzone_climate_set_hvac_mode(hass): + """Test setting the target temperature.""" + + await async_init_integration(hass) + + HVAC_MOCK = { + API_DATA: [ + { + API_SYSTEM_ID: 1, + API_ZONE_ID: 1, + API_MODE: OperationMode.COOLING.value, + API_ON: 1, + } + ] + } + with patch( + "homeassistant.components.airzone.AirzoneLocalApi.http_request", + return_value=HVAC_MOCK, + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: "climate.salon", + ATTR_HVAC_MODE: HVAC_MODE_COOL, + }, + blocking=True, + ) + + state = hass.states.get("climate.salon") + assert state.state == HVAC_MODE_COOL + + HVAC_MOCK_2 = { + API_DATA: [ + { + API_SYSTEM_ID: 1, + API_ZONE_ID: 1, + API_ON: 0, + } + ] + } + with patch( + "homeassistant.components.airzone.AirzoneLocalApi.http_request", + return_value=HVAC_MOCK_2, + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: "climate.salon", + ATTR_HVAC_MODE: HVAC_MODE_OFF, + }, + blocking=True, + ) + + state = hass.states.get("climate.salon") + assert state.state == HVAC_MODE_OFF + + +async def test_airzone_climate_set_temp(hass): + """Test setting the target temperature.""" + + HVAC_MOCK = { + API_DATA: [ + { + API_SYSTEM_ID: 1, + API_ZONE_ID: 5, + API_SET_POINT: 20.5, + } + ] + } + + await async_init_integration(hass) + + with patch( + "homeassistant.components.airzone.AirzoneLocalApi.http_request", + return_value=HVAC_MOCK, + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: "climate.dorm_2", + ATTR_TEMPERATURE: 20.5, + }, + blocking=True, + ) + + state = hass.states.get("climate.dorm_2") + assert state.attributes.get(ATTR_TEMPERATURE) == 20.5 + + +async def test_airzone_climate_set_temp_error(hass): + """Test error when setting the target temperature.""" + + await async_init_integration(hass) + + with patch( + "homeassistant.components.airzone.AirzoneLocalApi.put_hvac", + side_effect=AirzoneError, + ): + try: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: "climate.dorm_2", + ATTR_TEMPERATURE: 20.5, + }, + blocking=True, + ) + except HomeAssistantError: + pass + + state = hass.states.get("climate.dorm_2") + assert state.attributes.get(ATTR_TEMPERATURE) == 19.5 diff --git a/tests/components/airzone/util.py b/tests/components/airzone/util.py index bc0332c959c..745daac9269 100644 --- a/tests/components/airzone/util.py +++ b/tests/components/airzone/util.py @@ -48,7 +48,7 @@ HVAC_MOCK = { API_ON: 0, API_MAX_TEMP: 30, API_MIN_TEMP: 15, - API_SET_POINT: 19.5, + API_SET_POINT: 19.1, API_ROOM_TEMP: 19.6, API_MODES: [1, 4, 2, 3, 5], API_MODE: 3, @@ -66,10 +66,10 @@ HVAC_MOCK = { API_SYSTEM_ID: 1, API_ZONE_ID: 2, API_NAME: "Dorm Ppal", - API_ON: 0, + API_ON: 1, API_MAX_TEMP: 30, API_MIN_TEMP: 15, - API_SET_POINT: 19.5, + API_SET_POINT: 19.2, API_ROOM_TEMP: 21.1, API_MODE: 3, API_COLD_STAGES: 1, @@ -79,17 +79,17 @@ HVAC_MOCK = { API_HUMIDITY: 39, API_UNITS: 0, API_ERRORS: [], - API_AIR_DEMAND: 0, + API_AIR_DEMAND: 1, API_FLOOR_DEMAND: 0, }, { API_SYSTEM_ID: 1, API_ZONE_ID: 3, API_NAME: "Dorm #1", - API_ON: 0, + API_ON: 1, API_MAX_TEMP: 30, API_MIN_TEMP: 15, - API_SET_POINT: 19.5, + API_SET_POINT: 19.3, API_ROOM_TEMP: 20.8, API_MODE: 3, API_COLD_STAGES: 1, @@ -109,7 +109,7 @@ HVAC_MOCK = { API_ON: 0, API_MAX_TEMP: 86, API_MIN_TEMP: 59, - API_SET_POINT: 67.1, + API_SET_POINT: 66.92, API_ROOM_TEMP: 70.16, API_MODE: 3, API_COLD_STAGES: 1,