Add ventilation quickmode switches to ViCare (#179430)

This commit is contained in:
Christian Lackas
2026-08-29 17:34:02 +02:00
committed by GitHub
parent 5f77b4b461
commit 381db53586
5 changed files with 650 additions and 0 deletions
+1
View File
@@ -12,6 +12,7 @@ PLATFORMS = [
Platform.NUMBER,
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
Platform.WATER_HEATER,
]
@@ -659,6 +659,20 @@
"name": "[%key:component::sensor::entity_component::signal_strength::name%]"
}
},
"switch": {
"quickmode_comfort": {
"name": "Intensive"
},
"quickmode_eco": {
"name": "Eco"
},
"quickmode_forced_level_four": {
"name": "Boost"
},
"quickmode_silent": {
"name": "Silent"
}
},
"water_heater": {
"domestic_hot_water": {
"name": "Domestic hot water"
@@ -674,6 +688,9 @@
},
"program_unknown": {
"message": "Cannot translate preset {preset} into a valid ViCare program"
},
"quickmode_not_activated": {
"message": "Unable to activate ViCare quickmode {quickmode}. Only one quickmode can be active at a time; another one may already be running."
}
},
"issues": {
+123
View File
@@ -0,0 +1,123 @@
"""Viessmann ViCare switch device."""
from contextlib import suppress
import enum
from typing import Any, override
from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig
from PyViCare.PyViCareUtils import (
PyViCareCommandError,
PyViCareNotSupportedFeatureError,
)
from PyViCare.PyViCareVentilationDevice import (
VentilationDevice as PyViCareVentilationDevice,
)
from homeassistant.components.switch import SwitchEntity
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .entity import ViCareEntity
from .types import ViCareConfigEntry, ViCareDevice
class VentilationQuickmode(enum.StrEnum):
"""ViCare ventilation quickmodes that can be switched on and off.
`standby` is used by the fan entity, `holiday` is scheduled instead of
activated.
"""
COMFORT = "comfort"
ECO = "eco"
FORCED_LEVEL_FOUR = "forcedLevelFour"
SILENT = "silent"
# Also used as unique id suffix, so the quickmodes cannot collide with other
# switches that may be added for the same device later on.
ENTITY_KEYS = {
VentilationQuickmode.COMFORT: "quickmode_comfort",
VentilationQuickmode.ECO: "quickmode_eco",
VentilationQuickmode.FORCED_LEVEL_FOUR: "quickmode_forced_level_four",
VentilationQuickmode.SILENT: "quickmode_silent",
}
def _build_entities(
device_list: list[ViCareDevice],
) -> list[ViCareQuickmodeSwitch]:
"""Create ViCare switch entities for a device."""
entities: list[ViCareQuickmodeSwitch] = []
for device in device_list:
if not device.api.isVentilationDevice():
continue
available: list[str] = []
with suppress(PyViCareNotSupportedFeatureError):
available = device.api.getVentilationQuickmodes()
entities.extend(
ViCareQuickmodeSwitch(quickmode, device.serial, device.config, device.api)
for quickmode in VentilationQuickmode
if quickmode in available
)
return entities
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ViCareConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Create the ViCare switch entities."""
async_add_entities(
await hass.async_add_executor_job(
_build_entities,
config_entry.runtime_data.devices,
),
# run update to have the current quickmode state on startup
True,
)
class ViCareQuickmodeSwitch(ViCareEntity, SwitchEntity):
"""Representation of a ViCare ventilation quickmode."""
_api: PyViCareVentilationDevice
def __init__(
self,
quickmode: VentilationQuickmode,
device_serial: str | None,
device_config: PyViCareDeviceConfig,
device: PyViCareVentilationDevice,
) -> None:
"""Initialize the switch."""
super().__init__(ENTITY_KEYS[quickmode], device_serial, device_config, device)
self._quickmode = quickmode
self._attr_translation_key = ENTITY_KEYS[quickmode]
def update(self) -> None:
"""Update state of the switch."""
with self.vicare_api_handler(), suppress(PyViCareNotSupportedFeatureError):
self._attr_is_on = self._api.getVentilationQuickmode(self._quickmode)
@override
def turn_on(self, **kwargs: Any) -> None:
"""Activate the quickmode."""
try:
self._api.activateVentilationQuickmode(self._quickmode)
except PyViCareCommandError as err:
# Any failed command lands here, but the one users hit is the
# device refusing a second quickmode instead of switching over.
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="quickmode_not_activated",
translation_placeholders={"quickmode": self._quickmode},
) from err
@override
def turn_off(self, **kwargs: Any) -> None:
"""Deactivate the quickmode."""
self._api.deactivateVentilationQuickmode(self._quickmode)
@@ -0,0 +1,301 @@
# serializer version: 1
# name: test_all_entities[switch.model0_boost-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': None,
'entity_id': 'switch.model0_boost',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Boost',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Boost',
'platform': 'vicare',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'quickmode_forced_level_four',
'unique_id': 'gateway0_deviceSerialViAir300F-quickmode_forced_level_four',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[switch.model0_boost-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'model0 Boost',
}),
'context': <ANY>,
'entity_id': 'switch.model0_boost',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_all_entities[switch.model0_silent-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': None,
'entity_id': 'switch.model0_silent',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Silent',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Silent',
'platform': 'vicare',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'quickmode_silent',
'unique_id': 'gateway0_deviceSerialViAir300F-quickmode_silent',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[switch.model0_silent-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'model0 Silent',
}),
'context': <ANY>,
'entity_id': 'switch.model0_silent',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_all_entities[switch.model1_boost-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': None,
'entity_id': 'switch.model1_boost',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Boost',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Boost',
'platform': 'vicare',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'quickmode_forced_level_four',
'unique_id': 'gateway1_deviceId1-quickmode_forced_level_four',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[switch.model1_boost-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'model1 Boost',
}),
'context': <ANY>,
'entity_id': 'switch.model1_boost',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_all_entities[switch.model1_silent-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': None,
'entity_id': 'switch.model1_silent',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Silent',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Silent',
'platform': 'vicare',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'quickmode_silent',
'unique_id': 'gateway1_deviceId1-quickmode_silent',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[switch.model1_silent-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'model1 Silent',
}),
'context': <ANY>,
'entity_id': 'switch.model1_silent',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_all_entities[switch.model2_eco-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': None,
'entity_id': 'switch.model2_eco',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Eco',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Eco',
'platform': 'vicare',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'quickmode_eco',
'unique_id': 'gateway2_################-quickmode_eco',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[switch.model2_eco-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'model2 Eco',
}),
'context': <ANY>,
'entity_id': 'switch.model2_eco',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_all_entities[switch.model2_intensive-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': None,
'entity_id': 'switch.model2_intensive',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Intensive',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Intensive',
'platform': 'vicare',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'quickmode_comfort',
'unique_id': 'gateway2_################-quickmode_comfort',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[switch.model2_intensive-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'model2 Intensive',
}),
'context': <ANY>,
'entity_id': 'switch.model2_intensive',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
+208
View File
@@ -0,0 +1,208 @@
"""Test ViCare switch."""
from unittest.mock import patch
import pytest
from PyViCare.PyViCareUtils import (
PyViCareCommandError,
PyViCareNotSupportedFeatureError,
)
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.const import (
ATTR_ENTITY_ID,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
STATE_OFF,
STATE_ON,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import entity_registry as er
from . import MODULE, setup_integration
from .conftest import Fixture, MockPyViCare
from tests.common import MockConfigEntry, snapshot_platform
VENTILATION_FIXTURES: list[Fixture] = [
Fixture({"type:ventilation"}, "vicare/ViAir300F.json"),
Fixture({"type:ventilation"}, "vicare/VitoPure.json"),
Fixture({"type:heatpump"}, "vicare/Vitocal222G_Vitovent300W.json"),
]
async def setup_switch_platform(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_vicare: MockPyViCare,
) -> None:
"""Set up the switch platform with the given mocked devices."""
with (
patch(
"homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session.async_ensure_token_valid",
),
patch(
f"{MODULE}._setup_vicare_api",
return_value=mock_vicare.as_vicare_data(),
),
patch(f"{MODULE}.PLATFORMS", [Platform.SWITCH]),
):
await setup_integration(hass, mock_config_entry)
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_all_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test all entities."""
await setup_switch_platform(
hass, mock_config_entry, MockPyViCare(VENTILATION_FIXTURES)
)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_switch_created_per_available_quickmode(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test that only quickmodes that can be switched get an entity."""
await setup_switch_platform(
hass, mock_config_entry, MockPyViCare(VENTILATION_FIXTURES)
)
assert set(hass.states.async_entity_ids(SWITCH_DOMAIN)) == {
"switch.model0_boost",
"switch.model0_silent",
"switch.model1_boost",
"switch.model1_silent",
"switch.model2_intensive",
"switch.model2_eco",
}
async def test_switch_state_follows_quickmode(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test that an active quickmode is reported as on."""
mock_vicare = MockPyViCare(VENTILATION_FIXTURES)
activate_quickmode(mock_vicare, 2, "comfort")
await setup_switch_platform(hass, mock_config_entry, mock_vicare)
assert hass.states.get("switch.model2_intensive").state == STATE_ON
assert hass.states.get("switch.model2_eco").state == STATE_OFF
async def test_turn_on_activates_quickmode(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test that turning the switch on activates the quickmode."""
mock_vicare = MockPyViCare(VENTILATION_FIXTURES)
await setup_switch_platform(hass, mock_config_entry, mock_vicare)
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: "switch.model2_intensive"},
blocking=True,
)
device = mock_vicare.devices[2]
device.service.setProperty.assert_called_once_with(
device.accessor, "ventilation.quickmodes.comfort", "activate", {}
)
async def test_turn_off_deactivates_quickmode(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test that turning the switch off deactivates the quickmode."""
mock_vicare = MockPyViCare(VENTILATION_FIXTURES)
await setup_switch_platform(hass, mock_config_entry, mock_vicare)
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: "switch.model0_boost"},
blocking=True,
)
device = mock_vicare.devices[0]
device.service.setProperty.assert_called_once_with(
device.accessor, "ventilation.quickmodes.forcedLevelFour", "deactivate", {}
)
async def test_no_switch_for_non_ventilation_device(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test that a device without ventilation does not get quickmode switches."""
await setup_switch_platform(
hass,
mock_config_entry,
MockPyViCare([Fixture({"type:boiler"}, "vicare/Vitodens300W.json")]),
)
assert not hass.states.async_entity_ids(SWITCH_DOMAIN)
async def test_turn_on_error_is_raised(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test that a failed activation is not swallowed."""
mock_vicare = MockPyViCare(VENTILATION_FIXTURES)
await setup_switch_platform(hass, mock_config_entry, mock_vicare)
mock_vicare.devices[
2
].service.setProperty.side_effect = PyViCareNotSupportedFeatureError("comfort")
with pytest.raises(PyViCareNotSupportedFeatureError):
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: "switch.model2_intensive"},
blocking=True,
)
async def test_turn_on_refused_while_another_quickmode_runs(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test that the device refusing a second quickmode reads as a clear error."""
mock_vicare = MockPyViCare(VENTILATION_FIXTURES)
await setup_switch_platform(hass, mock_config_entry, mock_vicare)
mock_vicare.devices[2].service.setProperty.side_effect = PyViCareCommandError(
{"statusCode": 400, "extendedPayload": "COMMAND_NOT_EXECUTABLE"}
)
with pytest.raises(ServiceValidationError) as err:
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: "switch.model2_intensive"},
blocking=True,
)
assert err.value.translation_key == "quickmode_not_activated"
def activate_quickmode(mock_vicare: MockPyViCare, device: int, quickmode: str) -> None:
"""Mark a quickmode as active in the fixture data of a mocked device."""
for feature in mock_vicare.devices[device].service._test_data["data"]:
if feature["feature"] == f"ventilation.quickmodes.{quickmode}":
feature["properties"]["active"]["value"] = True
return
pytest.fail(f"quickmode {quickmode} not found in fixture")