Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add climate platform to eheimdigital #135878

Merged
merged 3 commits into from
Jan 28, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion homeassistant/components/eheimdigital/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from .const import DOMAIN
from .coordinator import EheimDigitalUpdateCoordinator

PLATFORMS = [Platform.LIGHT]
PLATFORMS = [Platform.CLIMATE, Platform.LIGHT]

type EheimDigitalConfigEntry = ConfigEntry[EheimDigitalUpdateCoordinator]

Expand Down
139 changes: 139 additions & 0 deletions homeassistant/components/eheimdigital/climate.py
Original file line number Diff line number Diff line change
@@ -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)
autinerd marked this conversation as resolved.
Show resolved Hide resolved


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
12 changes: 11 additions & 1 deletion homeassistant/components/eheimdigital/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@

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

LOGGER: Logger = getLogger(__package__)
Expand All @@ -15,3 +16,12 @@
EFFECT_DAYCL_MODE: LightMode.DAYCL_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,
}
12 changes: 12 additions & 0 deletions homeassistant/components/eheimdigital/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,18 @@
}
},
"entity": {
"climate": {
"heater": {
"state_attributes": {
"preset_mode": {
"state": {
"bio_mode": "Bio mode",
"smart_mode": "Smart mode"
}
}
}
}
},
"light": {
"channel": {
"name": "Channel {channel_id}",
Expand Down
27 changes: 24 additions & 3 deletions tests/components/eheimdigital/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
from unittest.mock import AsyncMock, MagicMock, patch

from eheimdigital.classic_led_ctrl import EheimDigitalClassicLEDControl
from eheimdigital.heater import EheimDigitalHeater
from eheimdigital.hub import EheimDigitalHub
from eheimdigital.types import EheimDeviceType, LightMode
from eheimdigital.types import EheimDeviceType, HeaterMode, HeaterUnit, LightMode
import pytest

from homeassistant.components.eheimdigital.const import DOMAIN
Expand Down Expand Up @@ -39,7 +40,26 @@ def classic_led_ctrl_mock():


@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."""
with (
patch(
Expand All @@ -52,7 +72,8 @@ def eheimdigital_hub_mock(classic_led_ctrl_mock: MagicMock) -> Generator[AsyncMo
),
):
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
yield eheimdigital_hub_mock
77 changes: 77 additions & 0 deletions tests/components/eheimdigital/snapshots/test_climate.ambr
Original file line number Diff line number Diff line change
@@ -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',
})
# ---
Loading