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 <erik@montnemery.com>

* Apply suggestions from code review

* Update fan.py

---------

Co-authored-by: Erik Montnemery <erik@montnemery.com>
This commit is contained in:
Christopher Fenner 2024-07-31 16:23:27 +02:00 committed by GitHub
parent 3df78043c0
commit f764705629
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 276 additions and 0 deletions

View File

@ -10,6 +10,7 @@ PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.CLIMATE,
Platform.FAN,
Platform.NUMBER,
Platform.SENSOR,
Platform.WATER_HEATER,

View File

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

View File

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

View File

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

View File

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