From bd09ac90308d6d70e530649a952bfac2293bbc3a Mon Sep 17 00:00:00 2001 From: Przemko92 <33545571+Przemko92@users.noreply.github.com> Date: Thu, 12 Feb 2026 18:39:02 +0100 Subject: [PATCH] Add water heater support for Compit (#162021) Co-authored-by: Joostlek --- homeassistant/components/compit/__init__.py | 1 + .../components/compit/water_heater.py | 315 ++++++++++++++++++ tests/components/compit/conftest.py | 44 ++- .../compit/snapshots/test_water_heater.ambr | 73 ++++ tests/components/compit/test_water_heater.py | 137 ++++++++ 5 files changed, 552 insertions(+), 18 deletions(-) create mode 100644 homeassistant/components/compit/water_heater.py create mode 100644 tests/components/compit/snapshots/test_water_heater.ambr create mode 100644 tests/components/compit/test_water_heater.py diff --git a/homeassistant/components/compit/__init__.py b/homeassistant/components/compit/__init__.py index cf8a22c729b..a02e54534a1 100644 --- a/homeassistant/components/compit/__init__.py +++ b/homeassistant/components/compit/__init__.py @@ -12,6 +12,7 @@ from .coordinator import CompitConfigEntry, CompitDataUpdateCoordinator PLATFORMS = [ Platform.CLIMATE, Platform.SELECT, + Platform.WATER_HEATER, ] diff --git a/homeassistant/components/compit/water_heater.py b/homeassistant/components/compit/water_heater.py new file mode 100644 index 00000000000..0d67ef8eb3a --- /dev/null +++ b/homeassistant/components/compit/water_heater.py @@ -0,0 +1,315 @@ +"""Water heater platform for Compit integration.""" + +from dataclasses import dataclass +from typing import Any + +from compit_inext_api.consts import CompitParameter +from propcache.api import cached_property + +from homeassistant.components.water_heater import ( + STATE_ECO, + STATE_OFF, + STATE_ON, + STATE_PERFORMANCE, + WaterHeaterEntity, + WaterHeaterEntityDescription, + WaterHeaterEntityFeature, +) +from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MANUFACTURER_NAME +from .coordinator import CompitConfigEntry, CompitDataUpdateCoordinator + +PARALLEL_UPDATES = 0 +STATE_SCHEDULE = "schedule" +COMPIT_STATE_TO_HA = { + STATE_OFF: STATE_OFF, + STATE_ON: STATE_PERFORMANCE, + STATE_SCHEDULE: STATE_ECO, +} +HA_STATE_TO_COMPIT = {value: key for key, value in COMPIT_STATE_TO_HA.items()} + + +@dataclass(frozen=True, kw_only=True) +class CompitWaterHeaterEntityDescription(WaterHeaterEntityDescription): + """Class to describe a Compit water heater device.""" + + min_temp: float + max_temp: float + supported_features: WaterHeaterEntityFeature + supports_current_temperature: bool = True + + +DEVICE_DEFINITIONS: dict[int, CompitWaterHeaterEntityDescription] = { + 34: CompitWaterHeaterEntityDescription( + key="r470", + min_temp=0.0, + max_temp=75.0, + supported_features=WaterHeaterEntityFeature.TARGET_TEMPERATURE + | WaterHeaterEntityFeature.ON_OFF + | WaterHeaterEntityFeature.OPERATION_MODE, + ), + 91: CompitWaterHeaterEntityDescription( + key="R770RS / R771RS", + min_temp=30.0, + max_temp=80.0, + supported_features=WaterHeaterEntityFeature.TARGET_TEMPERATURE + | WaterHeaterEntityFeature.ON_OFF + | WaterHeaterEntityFeature.OPERATION_MODE, + ), + 92: CompitWaterHeaterEntityDescription( + key="r490", + min_temp=30.0, + max_temp=80.0, + supported_features=WaterHeaterEntityFeature.TARGET_TEMPERATURE + | WaterHeaterEntityFeature.ON_OFF + | WaterHeaterEntityFeature.OPERATION_MODE, + ), + 215: CompitWaterHeaterEntityDescription( + key="R480", + min_temp=30.0, + max_temp=80.0, + supported_features=WaterHeaterEntityFeature.TARGET_TEMPERATURE + | WaterHeaterEntityFeature.ON_OFF + | WaterHeaterEntityFeature.OPERATION_MODE, + ), + 222: CompitWaterHeaterEntityDescription( + key="R377B", + min_temp=30.0, + max_temp=75.0, + supported_features=WaterHeaterEntityFeature.TARGET_TEMPERATURE + | WaterHeaterEntityFeature.ON_OFF + | WaterHeaterEntityFeature.OPERATION_MODE, + ), + 224: CompitWaterHeaterEntityDescription( + key="R 900", + min_temp=0.0, + max_temp=70.0, + supported_features=WaterHeaterEntityFeature.TARGET_TEMPERATURE + | WaterHeaterEntityFeature.ON_OFF + | WaterHeaterEntityFeature.OPERATION_MODE, + ), + 36: CompitWaterHeaterEntityDescription( + key="BioMax742", + min_temp=0.0, + max_temp=75.0, + supported_features=WaterHeaterEntityFeature.TARGET_TEMPERATURE + | WaterHeaterEntityFeature.ON_OFF + | WaterHeaterEntityFeature.OPERATION_MODE, + ), + 75: CompitWaterHeaterEntityDescription( + key="BioMax772", + min_temp=0.0, + max_temp=75.0, + supported_features=WaterHeaterEntityFeature.TARGET_TEMPERATURE + | WaterHeaterEntityFeature.ON_OFF + | WaterHeaterEntityFeature.OPERATION_MODE, + ), + 201: CompitWaterHeaterEntityDescription( + key="BioMax775", + min_temp=0.0, + max_temp=75.0, + supported_features=WaterHeaterEntityFeature.TARGET_TEMPERATURE + | WaterHeaterEntityFeature.ON_OFF + | WaterHeaterEntityFeature.OPERATION_MODE, + ), + 210: CompitWaterHeaterEntityDescription( + key="EL750", + min_temp=30.0, + max_temp=80.0, + supported_features=WaterHeaterEntityFeature.TARGET_TEMPERATURE + | WaterHeaterEntityFeature.ON_OFF + | WaterHeaterEntityFeature.OPERATION_MODE, + ), + 44: CompitWaterHeaterEntityDescription( + key="SolarComp 951", + min_temp=0.0, + max_temp=85.0, + supported_features=WaterHeaterEntityFeature.TARGET_TEMPERATURE, + supports_current_temperature=False, + ), + 45: CompitWaterHeaterEntityDescription( + key="SolarComp971", + min_temp=0.0, + max_temp=75.0, + supported_features=WaterHeaterEntityFeature.TARGET_TEMPERATURE, + supports_current_temperature=False, + ), + 99: CompitWaterHeaterEntityDescription( + key="SolarComp971C", + min_temp=0.0, + max_temp=75.0, + supported_features=WaterHeaterEntityFeature.TARGET_TEMPERATURE, + supports_current_temperature=False, + ), + 53: CompitWaterHeaterEntityDescription( + key="R350.CWU", + min_temp=0.0, + max_temp=80.0, + supported_features=WaterHeaterEntityFeature.TARGET_TEMPERATURE, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: CompitConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Compit water heater entities from a config entry.""" + + coordinator = entry.runtime_data + async_add_entities( + CompitWaterHeater(coordinator, device_id, entity_description) + for device_id, device in coordinator.connector.all_devices.items() + if (entity_description := DEVICE_DEFINITIONS.get(device.definition.code)) + ) + + +class CompitWaterHeater( + CoordinatorEntity[CompitDataUpdateCoordinator], WaterHeaterEntity +): + """Representation of a Compit Water Heater.""" + + _attr_target_temperature_step = PRECISION_WHOLE + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_has_entity_name = True + _attr_name = None + entity_description: CompitWaterHeaterEntityDescription + + def __init__( + self, + coordinator: CompitDataUpdateCoordinator, + device_id: int, + entity_description: CompitWaterHeaterEntityDescription, + ) -> None: + """Initialize the water heater.""" + super().__init__(coordinator) + self.device_id = device_id + self.entity_description = entity_description + self._attr_unique_id = f"{device_id}_{entity_description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, str(device_id))}, + name=entity_description.key, + manufacturer=MANUFACTURER_NAME, + model=entity_description.key, + ) + + @property + def available(self) -> bool: + """Return if entity is available.""" + return ( + super().available + and self.coordinator.connector.get_device(self.device_id) is not None + ) + + @cached_property + def min_temp(self) -> float: + """Return the minimum temperature.""" + return self.entity_description.min_temp + + @cached_property + def max_temp(self) -> float: + """Return the maximum temperature.""" + return self.entity_description.max_temp + + @cached_property + def supported_features(self) -> WaterHeaterEntityFeature: + """Return the supported features.""" + return self.entity_description.supported_features + + @cached_property + def operation_list(self) -> list[str] | None: + """Return the list of available operation modes.""" + if ( + self.entity_description.supported_features + & WaterHeaterEntityFeature.OPERATION_MODE + ): + return [STATE_OFF, STATE_PERFORMANCE, STATE_ECO] + return None + + @property + def target_temperature(self) -> float | None: + """Return the set target temperature.""" + value = self.coordinator.connector.get_current_value( + self.device_id, CompitParameter.DHW_TARGET_TEMPERATURE + ) + + if isinstance(value, float): + return value + + return None + + @property + def current_temperature(self) -> float | None: + """Return the current temperature.""" + if self.entity_description.supports_current_temperature is False: + return None + + value = self.coordinator.connector.get_current_value( + self.device_id, CompitParameter.DHW_CURRENT_TEMPERATURE + ) + + if isinstance(value, float): + return value + + return None + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + + if temperature is None: + return + + self._attr_target_temperature = temperature + + await self.coordinator.connector.set_device_parameter( + self.device_id, + CompitParameter.DHW_TARGET_TEMPERATURE, + float(temperature), + ) + self.async_write_ha_state() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the water heater on.""" + await self.coordinator.connector.select_device_option( + self.device_id, + CompitParameter.DHW_ON_OFF, + HA_STATE_TO_COMPIT[STATE_PERFORMANCE], + ) + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the water heater off.""" + await self.coordinator.connector.select_device_option( + self.device_id, + CompitParameter.DHW_ON_OFF, + HA_STATE_TO_COMPIT[STATE_OFF], + ) + self.async_write_ha_state() + + async def async_set_operation_mode(self, operation_mode: str) -> None: + """Set new operation mode.""" + await self.coordinator.connector.select_device_option( + self.device_id, + CompitParameter.DHW_ON_OFF, + HA_STATE_TO_COMPIT[operation_mode], + ) + self.async_write_ha_state() + + @property + def current_operation(self) -> str | None: + """Return the current operation mode.""" + on_off = self.coordinator.connector.get_current_option( + self.device_id, CompitParameter.DHW_ON_OFF + ) + + if on_off is None: + return None + + return COMPIT_STATE_TO_HA.get(on_off) diff --git a/tests/components/compit/conftest.py b/tests/components/compit/conftest.py index 60ad60003c7..c8d93903d7f 100644 --- a/tests/components/compit/conftest.py +++ b/tests/components/compit/conftest.py @@ -4,6 +4,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch from compit_inext_api import CompitParameter +from compit_inext_api.params_dictionary import PARAMS import pytest from homeassistant.components.compit.const import DOMAIN @@ -53,6 +54,9 @@ def mock_connector(): MagicMock( code="__trybpracy", value="de_icing" ), # parameter not relevant for this device, should be ignored + MagicMock(code="__temp_zada_prac_cwu", value=55.0), # DHW Target Temperature + MagicMock(code="__rr_temp_zmier_cwu", value=50.0), # DHW Current Temperature + MagicMock(code="__tryb_cwu", value="on"), # DHW On/Off ] mock_device_1.definition.code = 224 # R 900 @@ -72,31 +76,35 @@ def mock_connector(): def mock_get_device(device_id: int): return all_devices.get(device_id) - def get_current_option(device_id: int, parameter_code: CompitParameter): - return next( - ( - p - for p in all_devices[device_id].state.params - if p.code == parameter_code.value - ), - None, - ).value + def get_param(device_id: int, parameter_code: CompitParameter): + code = PARAMS[parameter_code][all_devices[device_id].definition.code] - def select_device_option( - device_id: int, parameter_code: CompitParameter, value: str + return next( + (p for p in all_devices[device_id].state.params if p.code == code), + None, + ) + + def get_current_value(device_id: int, parameter_code: CompitParameter): + param = get_param(device_id, parameter_code) + return param.value if param else None + + def set_device_parameter( + device_id: int, parameter_code: CompitParameter, value: float | str ): - next( - p - for p in all_devices[device_id].state.params - if p.code == parameter_code.value - ).value = value + param = get_param(device_id, parameter_code) + if not param: + return False + + param.value = value return True mock_instance = MagicMock() mock_instance.init = AsyncMock(return_value=True) mock_instance.all_devices = all_devices - mock_instance.get_current_option = MagicMock(side_effect=get_current_option) - mock_instance.select_device_option = AsyncMock(side_effect=select_device_option) + mock_instance.get_current_option = MagicMock(side_effect=get_current_value) + mock_instance.select_device_option = AsyncMock(side_effect=set_device_parameter) + mock_instance.get_current_value = MagicMock(side_effect=get_current_value) + mock_instance.set_device_parameter = AsyncMock(side_effect=set_device_parameter) mock_instance.update_state = AsyncMock() mock_instance.get_device = MagicMock(side_effect=mock_get_device) diff --git a/tests/components/compit/snapshots/test_water_heater.ambr b/tests/components/compit/snapshots/test_water_heater.ambr new file mode 100644 index 00000000000..72d2b01a7c7 --- /dev/null +++ b/tests/components/compit/snapshots/test_water_heater.ambr @@ -0,0 +1,73 @@ +# serializer version: 1 +# name: test_water_heater_entities_snapshot[water_heater.r_900-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_temp': 70.0, + 'min_temp': 0.0, + 'operation_list': list([ + 'off', + 'performance', + 'eco', + ]), + 'target_temp_step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'water_heater', + 'entity_category': None, + 'entity_id': 'water_heater.r_900', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'compit', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '1_R 900', + 'unit_of_measurement': None, + }) +# --- +# name: test_water_heater_entities_snapshot[water_heater.r_900-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 50.0, + 'friendly_name': 'R 900', + 'max_temp': 70.0, + 'min_temp': 0.0, + 'operation_list': list([ + 'off', + 'performance', + 'eco', + ]), + 'operation_mode': 'performance', + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'target_temp_step': 1, + 'temperature': 55.0, + }), + 'context': , + 'entity_id': 'water_heater.r_900', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'performance', + }) +# --- diff --git a/tests/components/compit/test_water_heater.py b/tests/components/compit/test_water_heater.py new file mode 100644 index 00000000000..38140600b0d --- /dev/null +++ b/tests/components/compit/test_water_heater.py @@ -0,0 +1,137 @@ +"""Tests for the Compit water heater platform.""" + +from typing import Any +from unittest.mock import MagicMock + +from compit_inext_api.consts import CompitParameter +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.water_heater import ATTR_TEMPERATURE +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration, snapshot_compit_entities + +from tests.common import MockConfigEntry + + +async def test_water_heater_entities_snapshot( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_connector: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Snapshot test for water heater entities creation, unique IDs, and device info.""" + await setup_integration(hass, mock_config_entry) + + snapshot_compit_entities(hass, entity_registry, snapshot, Platform.WATER_HEATER) + + +@pytest.mark.parametrize( + "mock_return_value", + [ + None, + "invalid", + ], +) +async def test_water_heater_unknown_temperature( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_connector: MagicMock, + mock_return_value: Any, +) -> None: + """Test that water heater shows unknown temperature when get_current_value returns invalid values.""" + mock_connector.get_current_value.side_effect = lambda device_id, parameter_code: ( + mock_return_value + ) + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("water_heater.r_900") + assert state is not None + assert state.attributes.get("temperature") is None + assert state.attributes.get("current_temperature") is None + + +async def test_water_heater_set_temperature( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_connector: MagicMock +) -> None: + """Test setting water heater temperature.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + "water_heater", + "set_temperature", + { + ATTR_ENTITY_ID: "water_heater.r_900", + ATTR_TEMPERATURE: 60.0, + }, + blocking=True, + ) + + mock_connector.set_device_parameter.assert_called_once() + assert ( + mock_connector.get_current_value(1, CompitParameter.DHW_TARGET_TEMPERATURE) + == 60.0 + ) + + +async def test_water_heater_turn_on( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_connector: MagicMock +) -> None: + """Test turning water heater on.""" + await mock_connector.select_device_option(1, CompitParameter.DHW_ON_OFF, "off") + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + "water_heater", + "turn_on", + {ATTR_ENTITY_ID: "water_heater.r_900"}, + blocking=True, + ) + + assert mock_connector.get_current_option(1, CompitParameter.DHW_ON_OFF) == "on" + + +async def test_water_heater_turn_off( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_connector: MagicMock +) -> None: + """Test turning water heater off.""" + await mock_connector.select_device_option(1, CompitParameter.DHW_ON_OFF, "on") + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + "water_heater", + "turn_off", + {ATTR_ENTITY_ID: "water_heater.r_900"}, + blocking=True, + ) + + assert mock_connector.get_current_option(1, CompitParameter.DHW_ON_OFF) == "off" + + +async def test_water_heater_current_operation( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_connector: MagicMock +) -> None: + """Test water heater current operation state.""" + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("water_heater.r_900") + assert state is not None + assert state.state == "performance" + + await hass.services.async_call( + "water_heater", + "set_operation_mode", + {ATTR_ENTITY_ID: "water_heater.r_900", "operation_mode": "eco"}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("water_heater.r_900") + assert state.state == "eco" + assert ( + mock_connector.get_current_option(1, CompitParameter.DHW_ON_OFF) == "schedule" + )