Add Airzone Select platform support (#76415)

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Álvaro Fernández Rojas 2023-01-05 22:03:36 +01:00 committed by GitHub
parent 829c8e611e
commit 146b43f8c5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 385 additions and 25 deletions

View file

@ -19,7 +19,12 @@ from homeassistant.helpers import (
from .const import DOMAIN from .const import DOMAIN
from .coordinator import AirzoneUpdateCoordinator 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__) _LOGGER = logging.getLogger(__name__)

View file

@ -1,7 +1,6 @@
"""Support for the Airzone climate.""" """Support for the Airzone climate."""
from __future__ import annotations from __future__ import annotations
import logging
from typing import Any, Final from typing import Any, Final
from aioairzone.common import OperationMode from aioairzone.common import OperationMode
@ -9,8 +8,6 @@ from aioairzone.const import (
API_MODE, API_MODE,
API_ON, API_ON,
API_SET_POINT, API_SET_POINT,
API_SYSTEM_ID,
API_ZONE_ID,
AZD_DEMAND, AZD_DEMAND,
AZD_HUMIDITY, AZD_HUMIDITY,
AZD_MASTER, AZD_MASTER,
@ -25,7 +22,6 @@ from aioairzone.const import (
AZD_TEMP_UNIT, AZD_TEMP_UNIT,
AZD_ZONES, AZD_ZONES,
) )
from aioairzone.exceptions import AirzoneError
from homeassistant.components.climate import ( from homeassistant.components.climate import (
ClimateEntity, ClimateEntity,
@ -43,9 +39,6 @@ from .const import API_TEMPERATURE_STEP, DOMAIN, TEMP_UNIT_LIB_TO_HASS
from .coordinator import AirzoneUpdateCoordinator from .coordinator import AirzoneUpdateCoordinator
from .entity import AirzoneZoneEntity from .entity import AirzoneZoneEntity
_LOGGER = logging.getLogger(__name__)
HVAC_ACTION_LIB_TO_HASS: Final[dict[OperationMode, HVACAction]] = { HVAC_ACTION_LIB_TO_HASS: Final[dict[OperationMode, HVACAction]] = {
OperationMode.STOP: HVACAction.OFF, OperationMode.STOP: HVACAction.OFF,
OperationMode.COOLING: HVACAction.COOLING, OperationMode.COOLING: HVACAction.COOLING,
@ -114,23 +107,6 @@ class AirzoneClimate(AirzoneZoneEntity, ClimateEntity):
] ]
self._async_update_attrs() 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: async def async_turn_on(self) -> None:
"""Turn the entity on.""" """Turn the entity on."""
params = { params = {

View file

@ -1,9 +1,12 @@
"""Entity classes for the Airzone integration.""" """Entity classes for the Airzone integration."""
from __future__ import annotations from __future__ import annotations
import logging
from typing import Any from typing import Any
from aioairzone.const import ( from aioairzone.const import (
API_SYSTEM_ID,
API_ZONE_ID,
AZD_FIRMWARE, AZD_FIRMWARE,
AZD_FULL_NAME, AZD_FULL_NAME,
AZD_ID, AZD_ID,
@ -17,8 +20,10 @@ from aioairzone.const import (
AZD_WEBSERVER, AZD_WEBSERVER,
AZD_ZONES, AZD_ZONES,
) )
from aioairzone.exceptions import AirzoneError
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
@ -26,6 +31,8 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER from .const import DOMAIN, MANUFACTURER
from .coordinator import AirzoneUpdateCoordinator from .coordinator import AirzoneUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
class AirzoneEntity(CoordinatorEntity[AirzoneUpdateCoordinator]): class AirzoneEntity(CoordinatorEntity[AirzoneUpdateCoordinator]):
"""Define an Airzone entity.""" """Define an Airzone entity."""
@ -130,3 +137,20 @@ class AirzoneZoneEntity(AirzoneEntity):
if key in zone: if key in zone:
value = zone[key] value = zone[key]
return value 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())

View file

@ -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})

View file

@ -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º"

View file

@ -4,11 +4,13 @@ from unittest.mock import patch
from aioairzone.const import ( from aioairzone.const import (
API_AIR_DEMAND, API_AIR_DEMAND,
API_COLD_ANGLE,
API_COLD_STAGE, API_COLD_STAGE,
API_COLD_STAGES, API_COLD_STAGES,
API_DATA, API_DATA,
API_ERRORS, API_ERRORS,
API_FLOOR_DEMAND, API_FLOOR_DEMAND,
API_HEAT_ANGLE,
API_HEAT_STAGE, API_HEAT_STAGE,
API_HEAT_STAGES, API_HEAT_STAGES,
API_HUMIDITY, API_HUMIDITY,
@ -22,6 +24,7 @@ from aioairzone.const import (
API_POWER, API_POWER,
API_ROOM_TEMP, API_ROOM_TEMP,
API_SET_POINT, API_SET_POINT,
API_SLEEP,
API_SYSTEM_FIRMWARE, API_SYSTEM_FIRMWARE,
API_SYSTEM_ID, API_SYSTEM_ID,
API_SYSTEM_TYPE, API_SYSTEM_TYPE,
@ -68,6 +71,7 @@ HVAC_MOCK = {
API_MIN_TEMP: 15, API_MIN_TEMP: 15,
API_SET_POINT: 19.1, API_SET_POINT: 19.1,
API_ROOM_TEMP: 19.6, API_ROOM_TEMP: 19.6,
API_SLEEP: 0,
API_MODES: [1, 4, 2, 3, 5], API_MODES: [1, 4, 2, 3, 5],
API_MODE: 3, API_MODE: 3,
API_COLD_STAGES: 1, API_COLD_STAGES: 1,
@ -79,6 +83,8 @@ HVAC_MOCK = {
API_ERRORS: [], API_ERRORS: [],
API_AIR_DEMAND: 0, API_AIR_DEMAND: 0,
API_FLOOR_DEMAND: 0, API_FLOOR_DEMAND: 0,
API_HEAT_ANGLE: 0,
API_COLD_ANGLE: 0,
}, },
{ {
API_SYSTEM_ID: 1, API_SYSTEM_ID: 1,
@ -92,6 +98,7 @@ HVAC_MOCK = {
API_MIN_TEMP: 15, API_MIN_TEMP: 15,
API_SET_POINT: 19.2, API_SET_POINT: 19.2,
API_ROOM_TEMP: 21.1, API_ROOM_TEMP: 21.1,
API_SLEEP: 30,
API_MODE: 3, API_MODE: 3,
API_COLD_STAGES: 1, API_COLD_STAGES: 1,
API_COLD_STAGE: 1, API_COLD_STAGE: 1,
@ -102,6 +109,8 @@ HVAC_MOCK = {
API_ERRORS: [], API_ERRORS: [],
API_AIR_DEMAND: 1, API_AIR_DEMAND: 1,
API_FLOOR_DEMAND: 1, API_FLOOR_DEMAND: 1,
API_HEAT_ANGLE: 1,
API_COLD_ANGLE: 2,
}, },
{ {
API_SYSTEM_ID: 1, API_SYSTEM_ID: 1,
@ -115,6 +124,7 @@ HVAC_MOCK = {
API_MIN_TEMP: 15, API_MIN_TEMP: 15,
API_SET_POINT: 19.3, API_SET_POINT: 19.3,
API_ROOM_TEMP: 20.8, API_ROOM_TEMP: 20.8,
API_SLEEP: 0,
API_MODE: 3, API_MODE: 3,
API_COLD_STAGES: 1, API_COLD_STAGES: 1,
API_COLD_STAGE: 1, API_COLD_STAGE: 1,
@ -125,6 +135,8 @@ HVAC_MOCK = {
API_ERRORS: [], API_ERRORS: [],
API_AIR_DEMAND: 0, API_AIR_DEMAND: 0,
API_FLOOR_DEMAND: 0, API_FLOOR_DEMAND: 0,
API_HEAT_ANGLE: 0,
API_COLD_ANGLE: 0,
}, },
{ {
API_SYSTEM_ID: 1, API_SYSTEM_ID: 1,
@ -138,6 +150,7 @@ HVAC_MOCK = {
API_MIN_TEMP: 59, API_MIN_TEMP: 59,
API_SET_POINT: 66.92, API_SET_POINT: 66.92,
API_ROOM_TEMP: 70.16, API_ROOM_TEMP: 70.16,
API_SLEEP: 0,
API_MODE: 3, API_MODE: 3,
API_COLD_STAGES: 1, API_COLD_STAGES: 1,
API_COLD_STAGE: 1, API_COLD_STAGE: 1,
@ -152,6 +165,8 @@ HVAC_MOCK = {
], ],
API_AIR_DEMAND: 0, API_AIR_DEMAND: 0,
API_FLOOR_DEMAND: 0, API_FLOOR_DEMAND: 0,
API_HEAT_ANGLE: 0,
API_COLD_ANGLE: 0,
}, },
{ {
API_SYSTEM_ID: 1, API_SYSTEM_ID: 1,
@ -165,6 +180,7 @@ HVAC_MOCK = {
API_MIN_TEMP: 15, API_MIN_TEMP: 15,
API_SET_POINT: 19.5, API_SET_POINT: 19.5,
API_ROOM_TEMP: 20.5, API_ROOM_TEMP: 20.5,
API_SLEEP: 0,
API_MODE: 3, API_MODE: 3,
API_COLD_STAGES: 1, API_COLD_STAGES: 1,
API_COLD_STAGE: 1, API_COLD_STAGE: 1,
@ -175,6 +191,8 @@ HVAC_MOCK = {
API_ERRORS: [], API_ERRORS: [],
API_AIR_DEMAND: 0, API_AIR_DEMAND: 0,
API_FLOOR_DEMAND: 0, API_FLOOR_DEMAND: 0,
API_HEAT_ANGLE: 0,
API_COLD_ANGLE: 0,
}, },
] ]
}, },