From f76470562936dc4ce650346e79f60a49a61214c6 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Wed, 31 Jul 2024 16:23:27 +0200 Subject: [PATCH] Add support for ventilation device to ViCare (#114175) * add ventilation program & mode * add ventilation device * Update climate.py * Update climate.py * Update climate.py * Update climate.py * Update climate.py * Update const.py * Create fan.py * Update fan.py * Update types.py * add test case * add translation key * use translation key * update snapshot * fix ruff findings * fix ruff findings * add log messages to setter * adjust test case * reset climate entity * do not display speed if not in permanent mode * update snapshot * update test cases * add comment * mark fan as always on * prevent turning off device * allow to set permanent mode * make speed_count static * add debug outputs * add preset state translations * allow permanent mode * update snapshot * add test case * load programs only on init * comment on ventilation modes * adjust test cases * add exception message * ignore test coverage on fan.py * Update test_fan.py * simplify * Apply suggestions from code review * remove tests * remove extra state attributes * fix leftover * add missing labels * adjust label * change state keys * use _attr_preset_modes * fix ruff findings * fix attribute access * fix from_ha_mode * fix ruff findings * fix mypy findings * simplify * format * fix typo * fix ruff finding * Apply suggestions from code review * change fan mode handling * add test cases * remove turn_off * Apply suggestions from code review Co-authored-by: Erik Montnemery * Apply suggestions from code review * Update fan.py --------- Co-authored-by: Erik Montnemery --- homeassistant/components/vicare/const.py | 1 + homeassistant/components/vicare/fan.py | 124 +++++++++++++++++++ homeassistant/components/vicare/strings.json | 15 +++ homeassistant/components/vicare/types.py | 49 ++++++++ tests/components/vicare/test_types.py | 87 +++++++++++++ 5 files changed, 276 insertions(+) create mode 100644 homeassistant/components/vicare/fan.py create mode 100644 tests/components/vicare/test_types.py diff --git a/homeassistant/components/vicare/const.py b/homeassistant/components/vicare/const.py index 286dcbbdfa8..8f8ae3c94e3 100644 --- a/homeassistant/components/vicare/const.py +++ b/homeassistant/components/vicare/const.py @@ -10,6 +10,7 @@ PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CLIMATE, + Platform.FAN, Platform.NUMBER, Platform.SENSOR, Platform.WATER_HEATER, diff --git a/homeassistant/components/vicare/fan.py b/homeassistant/components/vicare/fan.py new file mode 100644 index 00000000000..088e54c7354 --- /dev/null +++ b/homeassistant/components/vicare/fan.py @@ -0,0 +1,124 @@ +"""Viessmann ViCare ventilation device.""" + +from __future__ import annotations + +from contextlib import suppress +import logging + +from PyViCare.PyViCareDevice import Device as PyViCareDevice +from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig +from PyViCare.PyViCareUtils import ( + PyViCareInvalidDataError, + PyViCareNotSupportedFeatureError, + PyViCareRateLimitError, +) +from PyViCare.PyViCareVentilationDevice import ( + VentilationDevice as PyViCareVentilationDevice, +) +from requests.exceptions import ConnectionError as RequestConnectionError + +from homeassistant.components.fan import FanEntity, FanEntityFeature +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util.percentage import ( + ordered_list_item_to_percentage, + percentage_to_ordered_list_item, +) + +from .const import DEVICE_LIST, DOMAIN +from .entity import ViCareEntity +from .types import VentilationMode, VentilationProgram + +_LOGGER = logging.getLogger(__name__) + +ORDERED_NAMED_FAN_SPEEDS = [ + VentilationProgram.LEVEL_ONE, + VentilationProgram.LEVEL_TWO, + VentilationProgram.LEVEL_THREE, + VentilationProgram.LEVEL_FOUR, +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the ViCare fan platform.""" + + device_list = hass.data[DOMAIN][config_entry.entry_id][DEVICE_LIST] + + async_add_entities( + [ + ViCareFan(device.config, device.api) + for device in device_list + if isinstance(device.api, PyViCareVentilationDevice) + ] + ) + + +class ViCareFan(ViCareEntity, FanEntity): + """Representation of the ViCare ventilation device.""" + + _attr_preset_modes = list[str]( + [ + VentilationMode.PERMANENT, + VentilationMode.VENTILATION, + VentilationMode.SENSOR_DRIVEN, + VentilationMode.SENSOR_OVERRIDE, + ] + ) + _attr_speed_count = len(ORDERED_NAMED_FAN_SPEEDS) + _attr_supported_features = FanEntityFeature.SET_SPEED | FanEntityFeature.PRESET_MODE + _attr_translation_key = "ventilation" + _enable_turn_on_off_backwards_compatibility = False + + def __init__( + self, + device_config: PyViCareDeviceConfig, + device: PyViCareDevice, + ) -> None: + """Initialize the fan entity.""" + super().__init__(device_config, device, self._attr_translation_key) + + def update(self) -> None: + """Update state of fan.""" + try: + with suppress(PyViCareNotSupportedFeatureError): + self._attr_preset_mode = VentilationMode.from_vicare_mode( + self._api.getActiveMode() + ) + with suppress(PyViCareNotSupportedFeatureError): + self._attr_percentage = ordered_list_item_to_percentage( + ORDERED_NAMED_FAN_SPEEDS, self._api.getActiveProgram() + ) + except RequestConnectionError: + _LOGGER.error("Unable to retrieve data from ViCare server") + except ValueError: + _LOGGER.error("Unable to decode data from ViCare server") + except PyViCareRateLimitError as limit_exception: + _LOGGER.error("Vicare API rate limit exceeded: %s", limit_exception) + except PyViCareInvalidDataError as invalid_data_exception: + _LOGGER.error("Invalid data from Vicare server: %s", invalid_data_exception) + + @property + def is_on(self) -> bool | None: + """Return true if the entity is on.""" + # Viessmann ventilation unit cannot be turned off + return True + + def set_percentage(self, percentage: int) -> None: + """Set the speed of the fan, as a percentage.""" + if self._attr_preset_mode != str(VentilationMode.PERMANENT): + self.set_preset_mode(VentilationMode.PERMANENT) + + level = percentage_to_ordered_list_item(ORDERED_NAMED_FAN_SPEEDS, percentage) + _LOGGER.debug("changing ventilation level to %s", level) + self._api.setPermanentLevel(level) + + def set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + target_mode = VentilationMode.to_vicare_mode(preset_mode) + _LOGGER.debug("changing ventilation mode to %s", target_mode) + self._api.setActiveMode(target_mode) diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index de92d0ec271..7c0088d065f 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -65,6 +65,21 @@ "name": "Heating" } }, + "fan": { + "ventilation": { + "name": "Ventilation", + "state_attributes": { + "preset_mode": { + "state": { + "permanent": "permanent", + "ventilation": "schedule", + "sensor_driven": "sensor", + "sensor_override": "schedule with sensor-override" + } + } + } + } + }, "number": { "heating_curve_shift": { "name": "Heating curve shift" diff --git a/homeassistant/components/vicare/types.py b/homeassistant/components/vicare/types.py index 7e1ec7f8bee..596605fccdd 100644 --- a/homeassistant/components/vicare/types.py +++ b/homeassistant/components/vicare/types.py @@ -64,6 +64,55 @@ VICARE_TO_HA_PRESET_HEATING = { } +class VentilationMode(enum.StrEnum): + """ViCare ventilation modes.""" + + PERMANENT = "permanent" # on, speed controlled by program (levelOne-levelFour) + VENTILATION = "ventilation" # activated by schedule + SENSOR_DRIVEN = "sensor_driven" # activated by schedule, override by sensor + SENSOR_OVERRIDE = "sensor_override" # activated by sensor + + @staticmethod + def to_vicare_mode(mode: str | None) -> str | None: + """Return the mapped ViCare ventilation mode for the Home Assistant mode.""" + if mode: + try: + ventilation_mode = VentilationMode(mode) + except ValueError: + # ignore unsupported / unmapped modes + return None + return HA_TO_VICARE_MODE_VENTILATION.get(ventilation_mode) if mode else None + return None + + @staticmethod + def from_vicare_mode(vicare_mode: str | None) -> str | None: + """Return the mapped Home Assistant mode for the ViCare ventilation mode.""" + for mode in VentilationMode: + if HA_TO_VICARE_MODE_VENTILATION.get(VentilationMode(mode)) == vicare_mode: + return mode + return None + + +HA_TO_VICARE_MODE_VENTILATION = { + VentilationMode.PERMANENT: "permanent", + VentilationMode.VENTILATION: "ventilation", + VentilationMode.SENSOR_DRIVEN: "sensorDriven", + VentilationMode.SENSOR_OVERRIDE: "sensorOverride", +} + + +class VentilationProgram(enum.StrEnum): + """ViCare preset ventilation programs. + + As listed in https://github.com/somm15/PyViCare/blob/6c5b023ca6c8bb2d38141dd1746dc1705ec84ce8/PyViCare/PyViCareVentilationDevice.py#L37 + """ + + LEVEL_ONE = "levelOne" + LEVEL_TWO = "levelTwo" + LEVEL_THREE = "levelThree" + LEVEL_FOUR = "levelFour" + + @dataclass(frozen=True) class ViCareDevice: """Dataclass holding the device api and config.""" diff --git a/tests/components/vicare/test_types.py b/tests/components/vicare/test_types.py new file mode 100644 index 00000000000..575e549f0d9 --- /dev/null +++ b/tests/components/vicare/test_types.py @@ -0,0 +1,87 @@ +"""Test ViCare diagnostics.""" + +import pytest + +from homeassistant.components.climate import PRESET_COMFORT, PRESET_SLEEP +from homeassistant.components.vicare.types import HeatingProgram, VentilationMode + + +@pytest.mark.parametrize( + ("vicare_program", "expected_result"), + [ + ("", None), + (None, None), + ("anything", None), + (HeatingProgram.COMFORT, PRESET_COMFORT), + (HeatingProgram.COMFORT_HEATING, PRESET_COMFORT), + ], +) +async def test_heating_program_to_ha_preset( + vicare_program: str | None, + expected_result: str | None, +) -> None: + """Testing ViCare HeatingProgram to HA Preset.""" + + assert HeatingProgram.to_ha_preset(vicare_program) == expected_result + + +@pytest.mark.parametrize( + ("ha_preset", "expected_result"), + [ + ("", None), + (None, None), + ("anything", None), + (PRESET_SLEEP, HeatingProgram.REDUCED), + ], +) +async def test_ha_preset_to_heating_program( + ha_preset: str | None, + expected_result: str | None, +) -> None: + """Testing HA Preset tp ViCare HeatingProgram.""" + + supported_programs = [ + HeatingProgram.COMFORT, + HeatingProgram.ECO, + HeatingProgram.NORMAL, + HeatingProgram.REDUCED, + ] + assert ( + HeatingProgram.from_ha_preset(ha_preset, supported_programs) == expected_result + ) + + +@pytest.mark.parametrize( + ("vicare_mode", "expected_result"), + [ + ("", None), + (None, None), + ("anything", None), + ("sensorOverride", VentilationMode.SENSOR_OVERRIDE), + ], +) +async def test_ventilation_mode_to_ha_mode( + vicare_mode: str | None, + expected_result: str | None, +) -> None: + """Testing ViCare mode to VentilationMode.""" + + assert VentilationMode.from_vicare_mode(vicare_mode) == expected_result + + +@pytest.mark.parametrize( + ("ha_mode", "expected_result"), + [ + ("", None), + (None, None), + ("anything", None), + (VentilationMode.SENSOR_OVERRIDE, "sensorOverride"), + ], +) +async def test_ha_mode_to_ventilation_mode( + ha_mode: str | None, + expected_result: str | None, +) -> None: + """Testing VentilationMode to ViCare mode.""" + + assert VentilationMode.to_vicare_mode(ha_mode) == expected_result