1
0
mirror of https://github.com/home-assistant/core.git synced 2026-05-08 17:49:37 +01:00

Add water heater support for Compit (#162021)

Co-authored-by: Joostlek <joostlek@outlook.com>
This commit is contained in:
Przemko92
2026-02-12 18:39:02 +01:00
committed by GitHub
parent 6d143c1ce2
commit bd09ac9030
5 changed files with 552 additions and 18 deletions
@@ -12,6 +12,7 @@ from .coordinator import CompitConfigEntry, CompitDataUpdateCoordinator
PLATFORMS = [
Platform.CLIMATE,
Platform.SELECT,
Platform.WATER_HEATER,
]
@@ -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)
+26 -18
View File
@@ -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)
@@ -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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'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': <WaterHeaterEntityFeature: 11>,
'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': <WaterHeaterEntityFeature: 11>,
'target_temp_high': None,
'target_temp_low': None,
'target_temp_step': 1,
'temperature': 55.0,
}),
'context': <ANY>,
'entity_id': 'water_heater.r_900',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'performance',
})
# ---
@@ -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"
)