Add climate platform to eheimdigital (#135878)

This commit is contained in:
Sid 2025-01-28 17:38:28 +01:00 committed by GitHub
parent 661bacda10
commit 3680e39c43
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 483 additions and 5 deletions

View File

@ -10,7 +10,7 @@ from homeassistant.helpers.device_registry import DeviceEntry
from .const import DOMAIN from .const import DOMAIN
from .coordinator import EheimDigitalUpdateCoordinator from .coordinator import EheimDigitalUpdateCoordinator
PLATFORMS = [Platform.LIGHT] PLATFORMS = [Platform.CLIMATE, Platform.LIGHT]
type EheimDigitalConfigEntry = ConfigEntry[EheimDigitalUpdateCoordinator] type EheimDigitalConfigEntry = ConfigEntry[EheimDigitalUpdateCoordinator]

View File

@ -0,0 +1,139 @@
"""EHEIM Digital climate."""
from typing import Any
from eheimdigital.heater import EheimDigitalHeater
from eheimdigital.types import EheimDigitalClientError, HeaterMode, HeaterUnit
from homeassistant.components.climate import (
PRESET_NONE,
ClimateEntity,
ClimateEntityFeature,
HVACAction,
HVACMode,
)
from homeassistant.const import (
ATTR_TEMPERATURE,
PRECISION_HALVES,
PRECISION_TENTHS,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import EheimDigitalConfigEntry
from .const import HEATER_BIO_MODE, HEATER_PRESET_TO_HEATER_MODE, HEATER_SMART_MODE
from .coordinator import EheimDigitalUpdateCoordinator
from .entity import EheimDigitalEntity
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: EheimDigitalConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the callbacks for the coordinator so climate entities can be added as devices are found."""
coordinator = entry.runtime_data
async def async_setup_device_entities(device_address: str) -> None:
"""Set up the light entities for a device."""
device = coordinator.hub.devices[device_address]
if isinstance(device, EheimDigitalHeater):
async_add_entities([EheimDigitalHeaterClimate(coordinator, device)])
coordinator.add_platform_callback(async_setup_device_entities)
for device_address in entry.runtime_data.hub.devices:
await async_setup_device_entities(device_address)
class EheimDigitalHeaterClimate(EheimDigitalEntity[EheimDigitalHeater], ClimateEntity):
"""Represent an EHEIM Digital heater."""
_attr_hvac_modes = [HVACMode.OFF, HVACMode.AUTO]
_attr_hvac_mode = HVACMode.OFF
_attr_precision = PRECISION_TENTHS
_attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.TURN_ON
| ClimateEntityFeature.TURN_OFF
| ClimateEntityFeature.PRESET_MODE
)
_attr_target_temperature_step = PRECISION_HALVES
_attr_preset_modes = [PRESET_NONE, HEATER_BIO_MODE, HEATER_SMART_MODE]
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_preset_mode = PRESET_NONE
_attr_translation_key = "heater"
def __init__(
self, coordinator: EheimDigitalUpdateCoordinator, device: EheimDigitalHeater
) -> None:
"""Initialize an EHEIM Digital thermocontrol climate entity."""
super().__init__(coordinator, device)
self._attr_unique_id = self._device_address
self._async_update_attrs()
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set the preset mode."""
try:
if preset_mode in HEATER_PRESET_TO_HEATER_MODE:
await self._device.set_operation_mode(
HEATER_PRESET_TO_HEATER_MODE[preset_mode]
)
except EheimDigitalClientError as err:
raise HomeAssistantError from err
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set a new temperature."""
try:
if ATTR_TEMPERATURE in kwargs:
await self._device.set_target_temperature(kwargs[ATTR_TEMPERATURE])
except EheimDigitalClientError as err:
raise HomeAssistantError from err
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set the heating mode."""
try:
match hvac_mode:
case HVACMode.OFF:
await self._device.set_active(active=False)
case HVACMode.AUTO:
await self._device.set_active(active=True)
except EheimDigitalClientError as err:
raise HomeAssistantError from err
def _async_update_attrs(self) -> None:
if self._device.temperature_unit == HeaterUnit.CELSIUS:
self._attr_min_temp = 18
self._attr_max_temp = 32
self._attr_temperature_unit = UnitOfTemperature.CELSIUS
elif self._device.temperature_unit == HeaterUnit.FAHRENHEIT:
self._attr_min_temp = 64
self._attr_max_temp = 90
self._attr_temperature_unit = UnitOfTemperature.FAHRENHEIT
self._attr_current_temperature = self._device.current_temperature
self._attr_target_temperature = self._device.target_temperature
if self._device.is_heating:
self._attr_hvac_action = HVACAction.HEATING
self._attr_hvac_mode = HVACMode.AUTO
elif self._device.is_active:
self._attr_hvac_action = HVACAction.IDLE
self._attr_hvac_mode = HVACMode.AUTO
else:
self._attr_hvac_action = HVACAction.OFF
self._attr_hvac_mode = HVACMode.OFF
match self._device.operation_mode:
case HeaterMode.MANUAL:
self._attr_preset_mode = PRESET_NONE
case HeaterMode.BIO:
self._attr_preset_mode = HEATER_BIO_MODE
case HeaterMode.SMART:
self._attr_preset_mode = HEATER_SMART_MODE

View File

@ -2,8 +2,9 @@
from logging import Logger, getLogger from logging import Logger, getLogger
from eheimdigital.types import LightMode from eheimdigital.types import HeaterMode, LightMode
from homeassistant.components.climate import PRESET_NONE
from homeassistant.components.light import EFFECT_OFF from homeassistant.components.light import EFFECT_OFF
LOGGER: Logger = getLogger(__package__) LOGGER: Logger = getLogger(__package__)
@ -15,3 +16,12 @@ EFFECT_TO_LIGHT_MODE = {
EFFECT_DAYCL_MODE: LightMode.DAYCL_MODE, EFFECT_DAYCL_MODE: LightMode.DAYCL_MODE,
EFFECT_OFF: LightMode.MAN_MODE, EFFECT_OFF: LightMode.MAN_MODE,
} }
HEATER_BIO_MODE = "bio_mode"
HEATER_SMART_MODE = "smart_mode"
HEATER_PRESET_TO_HEATER_MODE = {
HEATER_BIO_MODE: HeaterMode.BIO,
HEATER_SMART_MODE: HeaterMode.SMART,
PRESET_NONE: HeaterMode.MANUAL,
}

View File

@ -23,6 +23,18 @@
} }
}, },
"entity": { "entity": {
"climate": {
"heater": {
"state_attributes": {
"preset_mode": {
"state": {
"bio_mode": "Bio mode",
"smart_mode": "Smart mode"
}
}
}
}
},
"light": { "light": {
"channel": { "channel": {
"name": "Channel {channel_id}", "name": "Channel {channel_id}",

View File

@ -4,8 +4,9 @@ from collections.abc import Generator
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
from eheimdigital.classic_led_ctrl import EheimDigitalClassicLEDControl from eheimdigital.classic_led_ctrl import EheimDigitalClassicLEDControl
from eheimdigital.heater import EheimDigitalHeater
from eheimdigital.hub import EheimDigitalHub from eheimdigital.hub import EheimDigitalHub
from eheimdigital.types import EheimDeviceType, LightMode from eheimdigital.types import EheimDeviceType, HeaterMode, HeaterUnit, LightMode
import pytest import pytest
from homeassistant.components.eheimdigital.const import DOMAIN from homeassistant.components.eheimdigital.const import DOMAIN
@ -39,7 +40,26 @@ def classic_led_ctrl_mock():
@pytest.fixture @pytest.fixture
def eheimdigital_hub_mock(classic_led_ctrl_mock: MagicMock) -> Generator[AsyncMock]: def heater_mock():
"""Mock a Heater device."""
heater_mock = MagicMock(spec=EheimDigitalHeater)
heater_mock.mac_address = "00:00:00:00:00:02"
heater_mock.device_type = EheimDeviceType.VERSION_EHEIM_EXT_HEATER
heater_mock.name = "Mock Heater"
heater_mock.aquarium_name = "Mock Aquarium"
heater_mock.temperature_unit = HeaterUnit.CELSIUS
heater_mock.current_temperature = 24.2
heater_mock.target_temperature = 25.5
heater_mock.is_heating = True
heater_mock.is_active = True
heater_mock.operation_mode = HeaterMode.MANUAL
return heater_mock
@pytest.fixture
def eheimdigital_hub_mock(
classic_led_ctrl_mock: MagicMock, heater_mock: MagicMock
) -> Generator[AsyncMock]:
"""Mock eheimdigital hub.""" """Mock eheimdigital hub."""
with ( with (
patch( patch(
@ -52,7 +72,8 @@ def eheimdigital_hub_mock(classic_led_ctrl_mock: MagicMock) -> Generator[AsyncMo
), ),
): ):
eheimdigital_hub_mock.return_value.devices = { eheimdigital_hub_mock.return_value.devices = {
"00:00:00:00:00:01": classic_led_ctrl_mock "00:00:00:00:00:01": classic_led_ctrl_mock,
"00:00:00:00:00:02": heater_mock,
} }
eheimdigital_hub_mock.return_value.main = classic_led_ctrl_mock eheimdigital_hub_mock.return_value.main = classic_led_ctrl_mock
yield eheimdigital_hub_mock yield eheimdigital_hub_mock

View File

@ -0,0 +1,77 @@
# serializer version: 1
# name: test_setup_heater[climate.mock_heater_none-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.AUTO: 'auto'>,
]),
'max_temp': 32,
'min_temp': 18,
'preset_modes': list([
'none',
'bio_mode',
'smart_mode',
]),
'target_temp_step': 0.5,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'climate',
'entity_category': None,
'entity_id': 'climate.mock_heater_none',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'eheimdigital',
'previous_unique_id': None,
'supported_features': <ClimateEntityFeature: 401>,
'translation_key': 'heater',
'unique_id': '00:00:00:00:00:02',
'unit_of_measurement': None,
})
# ---
# name: test_setup_heater[climate.mock_heater_none-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 24.2,
'friendly_name': 'Mock Heater None',
'hvac_action': <HVACAction.HEATING: 'heating'>,
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.AUTO: 'auto'>,
]),
'max_temp': 32,
'min_temp': 18,
'preset_mode': 'none',
'preset_modes': list([
'none',
'bio_mode',
'smart_mode',
]),
'supported_features': <ClimateEntityFeature: 401>,
'target_temp_step': 0.5,
'temperature': 25.5,
}),
'context': <ANY>,
'entity_id': 'climate.mock_heater_none',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'auto',
})
# ---

View File

@ -0,0 +1,219 @@
"""Tests for the climate module."""
from unittest.mock import MagicMock, patch
from eheimdigital.types import (
EheimDeviceType,
EheimDigitalClientError,
HeaterMode,
HeaterUnit,
)
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.climate import (
ATTR_HVAC_MODE,
ATTR_PRESET_MODE,
DOMAIN as CLIMATE_DOMAIN,
PRESET_NONE,
SERVICE_SET_HVAC_MODE,
SERVICE_SET_PRESET_MODE,
SERVICE_SET_TEMPERATURE,
HVACAction,
HVACMode,
)
from homeassistant.components.eheimdigital.const import (
HEATER_BIO_MODE,
HEATER_SMART_MODE,
)
from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry, snapshot_platform
@pytest.mark.usefixtures("heater_mock")
async def test_setup_heater(
hass: HomeAssistant,
eheimdigital_hub_mock: MagicMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test climate platform setup for heater."""
mock_config_entry.add_to_hass(hass)
with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.CLIMATE]):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"](
"00:00:00:00:00:02", EheimDeviceType.VERSION_EHEIM_EXT_HEATER
)
await hass.async_block_till_done()
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.parametrize(
("preset_mode", "heater_mode"),
[
(PRESET_NONE, HeaterMode.MANUAL),
(HEATER_BIO_MODE, HeaterMode.BIO),
(HEATER_SMART_MODE, HeaterMode.SMART),
],
)
async def test_set_preset_mode(
hass: HomeAssistant,
eheimdigital_hub_mock: MagicMock,
heater_mock: MagicMock,
mock_config_entry: MockConfigEntry,
preset_mode: str,
heater_mode: HeaterMode,
) -> None:
"""Test setting a preset mode."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"](
"00:00:00:00:00:02", EheimDeviceType.VERSION_EHEIM_EXT_HEATER
)
await hass.async_block_till_done()
heater_mock.set_operation_mode.side_effect = EheimDigitalClientError
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_PRESET_MODE,
{ATTR_ENTITY_ID: "climate.mock_heater_none", ATTR_PRESET_MODE: preset_mode},
blocking=True,
)
heater_mock.set_operation_mode.side_effect = None
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_PRESET_MODE,
{ATTR_ENTITY_ID: "climate.mock_heater_none", ATTR_PRESET_MODE: preset_mode},
blocking=True,
)
heater_mock.set_operation_mode.assert_awaited_with(heater_mode)
async def test_set_temperature(
hass: HomeAssistant,
eheimdigital_hub_mock: MagicMock,
heater_mock: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test setting a preset mode."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"](
"00:00:00:00:00:02", EheimDeviceType.VERSION_EHEIM_EXT_HEATER
)
await hass.async_block_till_done()
heater_mock.set_target_temperature.side_effect = EheimDigitalClientError
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_TEMPERATURE,
{ATTR_ENTITY_ID: "climate.mock_heater_none", ATTR_TEMPERATURE: 26.0},
blocking=True,
)
heater_mock.set_target_temperature.side_effect = None
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_TEMPERATURE,
{ATTR_ENTITY_ID: "climate.mock_heater_none", ATTR_TEMPERATURE: 26.0},
blocking=True,
)
heater_mock.set_target_temperature.assert_awaited_with(26.0)
@pytest.mark.parametrize(
("hvac_mode", "active"), [(HVACMode.AUTO, True), (HVACMode.OFF, False)]
)
async def test_set_hvac_mode(
hass: HomeAssistant,
eheimdigital_hub_mock: MagicMock,
heater_mock: MagicMock,
mock_config_entry: MockConfigEntry,
hvac_mode: HVACMode,
active: bool,
) -> None:
"""Test setting a preset mode."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"](
"00:00:00:00:00:02", EheimDeviceType.VERSION_EHEIM_EXT_HEATER
)
await hass.async_block_till_done()
heater_mock.set_active.side_effect = EheimDigitalClientError
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_HVAC_MODE,
{ATTR_ENTITY_ID: "climate.mock_heater_none", ATTR_HVAC_MODE: hvac_mode},
blocking=True,
)
heater_mock.set_active.side_effect = None
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_HVAC_MODE,
{ATTR_ENTITY_ID: "climate.mock_heater_none", ATTR_HVAC_MODE: hvac_mode},
blocking=True,
)
heater_mock.set_active.assert_awaited_with(active=active)
async def test_state_update(
hass: HomeAssistant,
eheimdigital_hub_mock: MagicMock,
mock_config_entry: MockConfigEntry,
heater_mock: MagicMock,
) -> None:
"""Test the climate state update."""
heater_mock.temperature_unit = HeaterUnit.FAHRENHEIT
heater_mock.is_heating = False
heater_mock.operation_mode = HeaterMode.BIO
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"](
"00:00:00:00:00:02", EheimDeviceType.VERSION_EHEIM_EXT_HEATER
)
await hass.async_block_till_done()
assert (state := hass.states.get("climate.mock_heater_none"))
assert state.attributes["hvac_action"] == HVACAction.IDLE
assert state.attributes["preset_mode"] == HEATER_BIO_MODE
heater_mock.is_active = False
heater_mock.operation_mode = HeaterMode.SMART
await eheimdigital_hub_mock.call_args.kwargs["receive_callback"]()
assert (state := hass.states.get("climate.mock_heater_none"))
assert state.state == HVACMode.OFF
assert state.attributes["preset_mode"] == HEATER_SMART_MODE