From c0d587585de6d10363ed797a3d5bfce2f4b77f41 Mon Sep 17 00:00:00 2001 From: Philipp Date: Sat, 4 Nov 2023 19:58:47 +0100 Subject: [PATCH] Ventilation Fan entity --- custom_components/mypyllant/climate.py | 152 ------------------- custom_components/mypyllant/fan.py | 196 +++++++++++++++++++++++++ tests/test_climate.py | 33 +---- tests/test_fan.py | 36 +++++ 4 files changed, 234 insertions(+), 183 deletions(-) create mode 100644 custom_components/mypyllant/fan.py create mode 100644 tests/test_fan.py diff --git a/custom_components/mypyllant/climate.py b/custom_components/mypyllant/climate.py index 6230637..013d943 100644 --- a/custom_components/mypyllant/climate.py +++ b/custom_components/mypyllant/climate.py @@ -12,10 +12,6 @@ HVACMode, ) from homeassistant.components.climate.const import ( - FAN_AUTO, - FAN_LOW, - FAN_OFF, - FAN_ON, PRESET_AWAY, PRESET_BOOST, PRESET_NONE, @@ -36,9 +32,6 @@ ) from myPyllant.models import ( System, - Ventilation, - VentilationFanStageType, - VentilationOperationMode, Zone, ZoneCurrentSpecialFunction, ZoneHeatingOperatingMode, @@ -56,7 +49,6 @@ SERVICE_SET_HOLIDAY, SERVICE_SET_MANUAL_MODE_SETPOINT, SERVICE_SET_QUICK_VETO, - SERVICE_SET_VENTILATION_FAN_STAGE, SERVICE_SET_ZONE_TIME_PROGRAM, ) @@ -80,23 +72,6 @@ PRESET_SLEEP: ZoneCurrentSpecialFunction.SYSTEM_OFF, } -VENTILATION_HVAC_MODE_MAP = { - HVACMode.FAN_ONLY: VentilationOperationMode.NORMAL, - HVACMode.AUTO: VentilationOperationMode.TIME_CONTROLLED, -} - -VENTILATION_FAN_MODE_MAP = { - FAN_OFF: VentilationOperationMode.OFF, - FAN_ON: VentilationOperationMode.NORMAL, - FAN_LOW: VentilationOperationMode.REDUCED, - FAN_AUTO: VentilationOperationMode.TIME_CONTROLLED, -} - -_FAN_STAGE_TYPE_OPTIONS = [ - selector.SelectOptionDict(value=v.value, label=v.value.title()) - for v in VentilationFanStageType -] - async def async_setup_entry( hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -116,7 +91,6 @@ async def async_setup_entry( return zone_entities: list[ClimateEntity] = [] - ventilation_entities: list[ClimateEntity] = [] for index, system in enumerate(coordinator.data): for zone_index, _ in enumerate(system.zones): @@ -129,17 +103,8 @@ async def async_setup_entry( time_program_overwrite, ) ) - for ventilation_index, _ in enumerate(system.ventilation): - ventilation_entities.append( - VentilationClimate( - index, - ventilation_index, - coordinator, - ) - ) async_add_entities(zone_entities) - async_add_entities(ventilation_entities) if len(zone_entities) > 0: platform = entity_platform.async_get_current_platform() @@ -216,27 +181,6 @@ async def async_setup_entry( "set_zone_time_program", ) - if len(ventilation_entities) > 0: - platform = entity_platform.async_get_current_platform() - _LOGGER.debug("Setting up ventilation climate entity services for %s", platform) - # noinspection PyTypeChecker - # Wrapping the schema in vol.Schema() breaks entity_id passing - platform.async_register_entity_service( - SERVICE_SET_VENTILATION_FAN_STAGE, - { - vol.Required("maximum_fan_stage"): vol.All( - vol.Coerce(int), vol.Clamp(min=1, max=6) - ), - vol.Required("fan_stage_type"): selector.SelectSelector( - selector.SelectSelectorConfig( - options=_FAN_STAGE_TYPE_OPTIONS, - mode=selector.SelectSelectorMode.LIST, - ), - ), - }, - "set_ventilation_fan_stage", - ) - class ZoneClimate(CoordinatorEntity, ClimateEntity): """Climate for a zone.""" @@ -476,99 +420,3 @@ async def async_set_preset_mode(self, preset_mode): await self.async_set_hvac_mode(HVACMode.OFF) await self.coordinator.async_request_refresh_delayed() - - -class VentilationClimate(CoordinatorEntity, ClimateEntity): - coordinator: SystemCoordinator - _attr_fan_modes = [str(k) for k in VENTILATION_FAN_MODE_MAP.keys()] - _attr_hvac_modes = [str(k) for k in VENTILATION_HVAC_MODE_MAP.keys()] - _attr_temperature_unit = UnitOfTemperature.CELSIUS - - def __init__( - self, - system_index: int, - ventilation_index: int, - coordinator: SystemCoordinator, - ) -> None: - super().__init__(coordinator) - self.system_index = system_index - self.ventilation_index = ventilation_index - self.entity_id = f"{DOMAIN}.ventilation_{ventilation_index}" - - @property - def system(self) -> System: - return self.coordinator.data[self.system_index] - - @property - def ventilation(self) -> Ventilation: - return self.system.ventilation[self.ventilation_index] - - @property - def device_info(self) -> DeviceInfo: - return DeviceInfo( - identifiers={(DOMAIN, f"ventilation{self.ventilation.index}")}, - name=self.name, - manufacturer=self.system.brand_name, - ) - - @property - def unique_id(self) -> str: - return f"{DOMAIN}_climate_ventilation_{self.ventilation_index}" - - @property - def name(self) -> str: - return [d for d in self.system.devices if d.type == "ventilation"][ - 0 - ].name_display - - @property - def extra_state_attributes(self) -> Mapping[str, Any] | None: - attr = { - "time_program_ventilation": self.ventilation.time_program_ventilation, - } - return attr - - @property - def supported_features(self) -> ClimateEntityFeature: - """Return the list of supported features.""" - return ClimateEntityFeature.FAN_MODE - - @property - def hvac_mode(self) -> HVACMode: - return [ - k - for k, v in VENTILATION_HVAC_MODE_MAP.items() - if v == self.ventilation.operation_mode_ventilation - ][0] - - async def async_set_hvac_mode(self, hvac_mode): - await self.coordinator.api.set_ventilation_operation_mode( - self.ventilation, - VENTILATION_HVAC_MODE_MAP[hvac_mode], - ) - await self.coordinator.async_request_refresh_delayed() - - @property - def fan_mode(self) -> HVACMode: - return [ - k - for k, v in VENTILATION_FAN_MODE_MAP.items() - if v == self.ventilation.operation_mode_ventilation - ][0] - - async def async_set_fan_mode(self, fan_mode: str) -> None: - await self.coordinator.api.set_ventilation_operation_mode( - self.ventilation, - VENTILATION_FAN_MODE_MAP[fan_mode], - ) - await self.coordinator.async_request_refresh_delayed() - - async def set_ventilation_fan_stage( - self, maximum_fan_stage: int | str, **kwargs: Any - ) -> None: - await self.coordinator.api.set_ventilation_fan_stage( - self.ventilation, - int(maximum_fan_stage), - VentilationFanStageType(kwargs.get("fan_stage_type")), - ) - await self.coordinator.async_request_refresh_delayed() diff --git a/custom_components/mypyllant/fan.py b/custom_components/mypyllant/fan.py new file mode 100644 index 0000000..74c934f --- /dev/null +++ b/custom_components/mypyllant/fan.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +import logging +from collections.abc import Mapping +from typing import Any + +from homeassistant.components.fan import FanEntity, FanEntityFeature +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers import selector +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util.percentage import ( + ordered_list_item_to_percentage, + percentage_to_ordered_list_item, +) +from myPyllant.models import ( + System, + Ventilation, + VentilationFanStageType, + VentilationOperationMode, +) + +from . import SystemCoordinator +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +_FAN_STAGE_TYPE_OPTIONS = [ + selector.SelectOptionDict(value=v.value, label=v.value.title()) + for v in VentilationFanStageType +] + +FAN_SPEED_OPTIONS = [ + VentilationOperationMode.REDUCED, + VentilationOperationMode.NORMAL, +] + + +async def async_setup_entry( + hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the climate platform.""" + coordinator: SystemCoordinator = hass.data[DOMAIN][config.entry_id][ + "system_coordinator" + ] + if not coordinator.data: + _LOGGER.warning("No system data, skipping climate") + return + + ventilation_entities: list[FanEntity] = [] + + for index, system in enumerate(coordinator.data): + for ventilation_index, _ in enumerate(system.ventilation): + ventilation_entities.append( + VentilationFan( + index, + ventilation_index, + coordinator, + ) + ) + + async_add_entities(ventilation_entities) + + +class VentilationFan(CoordinatorEntity, FanEntity): + coordinator: SystemCoordinator + _attr_preset_modes = [str(k) for k in VentilationOperationMode] + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_speed_count = len(FAN_SPEED_OPTIONS) + + def __init__( + self, + system_index: int, + ventilation_index: int, + coordinator: SystemCoordinator, + ) -> None: + super().__init__(coordinator) + self.system_index = system_index + self.ventilation_index = ventilation_index + self.entity_id = f"{DOMAIN}.ventilation_{ventilation_index}" + + @property + def system(self) -> System: + return self.coordinator.data[self.system_index] + + @property + def ventilation(self) -> Ventilation: + return self.system.ventilation[self.ventilation_index] + + @property + def device_info(self) -> DeviceInfo: + return DeviceInfo( + identifiers={(DOMAIN, f"ventilation{self.ventilation.index}")}, + name=self.name, + manufacturer=self.system.brand_name, + ) + + @property + def unique_id(self) -> str: + return f"{DOMAIN}_climate_ventilation_{self.ventilation_index}" + + @property + def name(self) -> str: + return [d for d in self.system.devices if d.type == "ventilation"][ + 0 + ].name_display + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + attr = { + "time_program_ventilation": self.ventilation.time_program_ventilation, + } + return attr + + @property + def supported_features(self) -> FanEntityFeature: + """Return the list of supported features.""" + return FanEntityFeature.PRESET_MODE | FanEntityFeature.SET_SPEED + + async def async_turn_on( + self, + speed: str | None = None, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + if not preset_mode: + preset_mode = str(VentilationOperationMode.TIME_CONTROLLED) + await self.coordinator.api.set_ventilation_operation_mode( + self.ventilation, + VentilationOperationMode(preset_mode), + ) + await self.coordinator.async_request_refresh_delayed() + + async def async_turn_off(self, **kwargs: Any) -> None: + await self.coordinator.api.set_ventilation_operation_mode( + self.ventilation, + VentilationOperationMode.OFF, + ) + await self.coordinator.async_request_refresh_delayed() + + @property + def is_on(self) -> bool | None: + return ( + self.ventilation.operation_mode_ventilation != VentilationOperationMode.OFF + ) + + @property + def preset_mode(self) -> str | None: + return ( + str(self.ventilation.operation_mode_ventilation) + if self.ventilation.operation_mode_ventilation + else None + ) + + async def async_set_preset_mode(self, preset_mode): + await self.coordinator.api.set_ventilation_operation_mode( + self.ventilation, + VentilationOperationMode(preset_mode), + ) + await self.coordinator.async_request_refresh_delayed() + + @property + def percentage(self) -> int | None: + # See https://developers.home-assistant.io/docs/core/entity/fan#set-speed-percentage + if ( + self.ventilation.operation_mode_ventilation + == VentilationOperationMode.TIME_CONTROLLED + ): + return 100 + elif ( + self.ventilation.operation_mode_ventilation == VentilationOperationMode.OFF + ): + return 0 + else: + return ordered_list_item_to_percentage( + FAN_SPEED_OPTIONS, + self.ventilation.operation_mode_ventilation, + ) + + async def async_set_percentage(self, percentage: int) -> None: + if percentage == 0: + await self.coordinator.api.set_ventilation_operation_mode( + self.ventilation, + VentilationOperationMode.OFF, + ) + else: + mode = percentage_to_ordered_list_item(FAN_SPEED_OPTIONS, percentage) + await self.coordinator.api.set_ventilation_operation_mode( + self.ventilation, + mode, + ) + await self.coordinator.async_request_refresh_delayed() diff --git a/tests/test_climate.py b/tests/test_climate.py index 07e97c6..35656cf 100644 --- a/tests/test_climate.py +++ b/tests/test_climate.py @@ -1,13 +1,13 @@ import pytest as pytest from homeassistant.components.climate import HVACMode -from homeassistant.components.climate.const import FAN_OFF, PRESET_AWAY +from homeassistant.components.climate.const import PRESET_AWAY from homeassistant.const import ATTR_TEMPERATURE from myPyllant.api import MyPyllantAPI from myPyllant.const import DEFAULT_QUICK_VETO_DURATION from myPyllant.tests.test_api import list_test_data from custom_components.mypyllant import SystemCoordinator -from custom_components.mypyllant.climate import VentilationClimate, ZoneClimate +from custom_components.mypyllant.climate import ZoneClimate from custom_components.mypyllant.const import DEFAULT_TIME_PROGRAM_OVERWRITE @@ -53,32 +53,3 @@ async def test_zone_climate( assert climate.hvac_mode in climate.hvac_modes assert climate.preset_mode in climate.preset_modes await mocked_api.aiohttp_session.close() - - -@pytest.mark.parametrize("test_data", list_test_data()) -async def test_ventilation_climate( - mypyllant_aioresponses, - mocked_api: MyPyllantAPI, - system_coordinator_mock: SystemCoordinator, - test_data, -): - with mypyllant_aioresponses(test_data) as _: - system_coordinator_mock.data = ( - await system_coordinator_mock._async_update_data() - ) - if not system_coordinator_mock.data[0].ventilation: - await mocked_api.aiohttp_session.close() - pytest.skip("No ventilation entity in system") - - ventilation = VentilationClimate( - 0, - 0, - system_coordinator_mock, - ) - assert isinstance(ventilation.device_info, dict) - assert isinstance(ventilation.extra_state_attributes, dict) - assert isinstance(ventilation.fan_mode, str) - - await ventilation.async_set_fan_mode(FAN_OFF) - system_coordinator_mock._debounced_refresh.async_cancel() - await mocked_api.aiohttp_session.close() diff --git a/tests/test_fan.py b/tests/test_fan.py new file mode 100644 index 0000000..d90a499 --- /dev/null +++ b/tests/test_fan.py @@ -0,0 +1,36 @@ +import pytest as pytest +from myPyllant.api import MyPyllantAPI +from myPyllant.models import VentilationOperationMode +from myPyllant.tests.test_api import list_test_data + +from custom_components.mypyllant import SystemCoordinator +from custom_components.mypyllant.fan import VentilationFan + + +@pytest.mark.parametrize("test_data", list_test_data()) +async def test_ventilation_fan( + mypyllant_aioresponses, + mocked_api: MyPyllantAPI, + system_coordinator_mock: SystemCoordinator, + test_data, +): + with mypyllant_aioresponses(test_data) as _: + system_coordinator_mock.data = ( + await system_coordinator_mock._async_update_data() + ) + if not system_coordinator_mock.data[0].ventilation: + await mocked_api.aiohttp_session.close() + pytest.skip("No ventilation entity in system") + + ventilation = VentilationFan( + 0, + 0, + system_coordinator_mock, + ) + assert isinstance(ventilation.device_info, dict) + assert isinstance(ventilation.extra_state_attributes, dict) + assert isinstance(ventilation.preset_mode, str) + + await ventilation.async_set_preset_mode(str(VentilationOperationMode.REDUCED)) + system_coordinator_mock._debounced_refresh.async_cancel() + await mocked_api.aiohttp_session.close()