diff --git a/homeassistant/components/eheimdigital/__init__.py b/homeassistant/components/eheimdigital/__init__.py index cf08f45bed5..a555a87cfbc 100644 --- a/homeassistant/components/eheimdigital/__init__.py +++ b/homeassistant/components/eheimdigital/__init__.py @@ -10,7 +10,7 @@ from homeassistant.helpers.device_registry import DeviceEntry from .const import DOMAIN from .coordinator import EheimDigitalUpdateCoordinator -PLATFORMS = [Platform.LIGHT] +PLATFORMS = [Platform.CLIMATE, Platform.LIGHT] type EheimDigitalConfigEntry = ConfigEntry[EheimDigitalUpdateCoordinator] diff --git a/homeassistant/components/eheimdigital/climate.py b/homeassistant/components/eheimdigital/climate.py new file mode 100644 index 00000000000..16771ba227d --- /dev/null +++ b/homeassistant/components/eheimdigital/climate.py @@ -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 diff --git a/homeassistant/components/eheimdigital/const.py b/homeassistant/components/eheimdigital/const.py index 5ed9303be40..61b391b6c63 100644 --- a/homeassistant/components/eheimdigital/const.py +++ b/homeassistant/components/eheimdigital/const.py @@ -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__) @@ -15,3 +16,12 @@ EFFECT_TO_LIGHT_MODE = { 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, +} diff --git a/homeassistant/components/eheimdigital/strings.json b/homeassistant/components/eheimdigital/strings.json index 0e6fa6a0814..ef6f6b10d0a 100644 --- a/homeassistant/components/eheimdigital/strings.json +++ b/homeassistant/components/eheimdigital/strings.json @@ -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}", diff --git a/tests/components/eheimdigital/conftest.py b/tests/components/eheimdigital/conftest.py index cdad628de6b..ef52eade9ae 100644 --- a/tests/components/eheimdigital/conftest.py +++ b/tests/components/eheimdigital/conftest.py @@ -4,8 +4,9 @@ from collections.abc import Generator 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 @@ -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( @@ -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 diff --git a/tests/components/eheimdigital/snapshots/test_climate.ambr b/tests/components/eheimdigital/snapshots/test_climate.ambr new file mode 100644 index 00000000000..02d60677b24 --- /dev/null +++ b/tests/components/eheimdigital/snapshots/test_climate.ambr @@ -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([ + , + , + ]), + 'max_temp': 32, + 'min_temp': 18, + 'preset_modes': list([ + 'none', + 'bio_mode', + 'smart_mode', + ]), + 'target_temp_step': 0.5, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.mock_heater_none', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': , + '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': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 32, + 'min_temp': 18, + 'preset_mode': 'none', + 'preset_modes': list([ + 'none', + 'bio_mode', + 'smart_mode', + ]), + 'supported_features': , + 'target_temp_step': 0.5, + 'temperature': 25.5, + }), + 'context': , + 'entity_id': 'climate.mock_heater_none', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- diff --git a/tests/components/eheimdigital/test_climate.py b/tests/components/eheimdigital/test_climate.py new file mode 100644 index 00000000000..4e770882263 --- /dev/null +++ b/tests/components/eheimdigital/test_climate.py @@ -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