From 381db535864b9c5d26a92c80ae6a123a11fa6584 Mon Sep 17 00:00:00 2001 From: Christian Lackas Date: Sat, 29 Aug 2026 17:34:02 +0200 Subject: [PATCH] Add ventilation quickmode switches to ViCare (#179430) --- homeassistant/components/vicare/const.py | 1 + homeassistant/components/vicare/strings.json | 17 + homeassistant/components/vicare/switch.py | 123 +++++++ .../vicare/snapshots/test_switch.ambr | 301 ++++++++++++++++++ tests/components/vicare/test_switch.py | 208 ++++++++++++ 5 files changed, 650 insertions(+) create mode 100644 homeassistant/components/vicare/switch.py create mode 100644 tests/components/vicare/snapshots/test_switch.ambr create mode 100644 tests/components/vicare/test_switch.py diff --git a/homeassistant/components/vicare/const.py b/homeassistant/components/vicare/const.py index bf147950b928..8d747c0374b3 100644 --- a/homeassistant/components/vicare/const.py +++ b/homeassistant/components/vicare/const.py @@ -12,6 +12,7 @@ PLATFORMS = [ Platform.NUMBER, Platform.SELECT, Platform.SENSOR, + Platform.SWITCH, Platform.WATER_HEATER, ] diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index 036b3fdc5dc5..a528e2ca33b6 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -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": { diff --git a/homeassistant/components/vicare/switch.py b/homeassistant/components/vicare/switch.py new file mode 100644 index 000000000000..bb972728af05 --- /dev/null +++ b/homeassistant/components/vicare/switch.py @@ -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) diff --git a/tests/components/vicare/snapshots/test_switch.ambr b/tests/components/vicare/snapshots/test_switch.ambr new file mode 100644 index 000000000000..fbc7cba8ff4f --- /dev/null +++ b/tests/components/vicare/snapshots/test_switch.ambr @@ -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': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.model0_boost', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + '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({ + : 'model0 Boost', + }), + 'context': , + 'entity_id': 'switch.model0_boost', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[switch.model0_silent-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.model0_silent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + '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({ + : 'model0 Silent', + }), + 'context': , + 'entity_id': 'switch.model0_silent', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[switch.model1_boost-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.model1_boost', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + '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({ + : 'model1 Boost', + }), + 'context': , + 'entity_id': 'switch.model1_boost', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[switch.model1_silent-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.model1_silent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + '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({ + : 'model1 Silent', + }), + 'context': , + 'entity_id': 'switch.model1_silent', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[switch.model2_eco-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.model2_eco', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + '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({ + : 'model2 Eco', + }), + 'context': , + 'entity_id': 'switch.model2_eco', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[switch.model2_intensive-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.model2_intensive', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + '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({ + : 'model2 Intensive', + }), + 'context': , + 'entity_id': 'switch.model2_intensive', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/vicare/test_switch.py b/tests/components/vicare/test_switch.py new file mode 100644 index 000000000000..efdda6901e05 --- /dev/null +++ b/tests/components/vicare/test_switch.py @@ -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")