From 285f7ec6963e9149b164120797a67facc90b4826 Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Tue, 15 Apr 2025 16:45:56 +0200 Subject: [PATCH] Add number platform to eheimdigital (#142835) * Add number platform to eheimdigital * Pylint * Review * Update homeassistant/components/eheimdigital/number.py * Update homeassistant/components/eheimdigital/number.py * Review --------- Co-authored-by: Josef Zweck Co-authored-by: Joost Lekkerkerker --- .../components/eheimdigital/__init__.py | 2 +- .../components/eheimdigital/icons.json | 17 ++ .../components/eheimdigital/number.py | 177 +++++++++++ .../components/eheimdigital/strings.json | 17 ++ tests/components/eheimdigital/conftest.py | 5 + .../eheimdigital/snapshots/test_number.ambr | 286 ++++++++++++++++++ tests/components/eheimdigital/test_number.py | 189 ++++++++++++ 7 files changed, 692 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/eheimdigital/number.py create mode 100644 tests/components/eheimdigital/snapshots/test_number.ambr create mode 100644 tests/components/eheimdigital/test_number.py diff --git a/homeassistant/components/eheimdigital/__init__.py b/homeassistant/components/eheimdigital/__init__.py index e4fb7989931..77e722f3e0c 100644 --- a/homeassistant/components/eheimdigital/__init__.py +++ b/homeassistant/components/eheimdigital/__init__.py @@ -9,7 +9,7 @@ from homeassistant.helpers.device_registry import DeviceEntry from .const import DOMAIN from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator -PLATFORMS = [Platform.CLIMATE, Platform.LIGHT, Platform.SENSOR] +PLATFORMS = [Platform.CLIMATE, Platform.LIGHT, Platform.NUMBER, Platform.SENSOR] async def async_setup_entry( diff --git a/homeassistant/components/eheimdigital/icons.json b/homeassistant/components/eheimdigital/icons.json index 32f3f1eee9c..428e383dd83 100644 --- a/homeassistant/components/eheimdigital/icons.json +++ b/homeassistant/components/eheimdigital/icons.json @@ -1,5 +1,22 @@ { "entity": { + "number": { + "manual_speed": { + "default": "mdi:pump" + }, + "day_speed": { + "default": "mdi:weather-sunny" + }, + "night_speed": { + "default": "mdi:moon-waning-crescent" + }, + "temperature_offset": { + "default": "mdi:thermometer" + }, + "night_temperature_offset": { + "default": "mdi:thermometer" + } + }, "sensor": { "current_speed": { "default": "mdi:pump" diff --git a/homeassistant/components/eheimdigital/number.py b/homeassistant/components/eheimdigital/number.py new file mode 100644 index 00000000000..f4504be624c --- /dev/null +++ b/homeassistant/components/eheimdigital/number.py @@ -0,0 +1,177 @@ +"""EHEIM Digital numbers.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Generic, TypeVar, override + +from eheimdigital.classic_vario import EheimDigitalClassicVario +from eheimdigital.device import EheimDigitalDevice +from eheimdigital.heater import EheimDigitalHeater +from eheimdigital.types import HeaterUnit + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, +) +from homeassistant.const import ( + PERCENTAGE, + PRECISION_HALVES, + PRECISION_TENTHS, + PRECISION_WHOLE, + EntityCategory, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator +from .entity import EheimDigitalEntity + +PARALLEL_UPDATES = 0 + +_DeviceT_co = TypeVar("_DeviceT_co", bound=EheimDigitalDevice, covariant=True) + + +@dataclass(frozen=True, kw_only=True) +class EheimDigitalNumberDescription(NumberEntityDescription, Generic[_DeviceT_co]): + """Class describing EHEIM Digital sensor entities.""" + + value_fn: Callable[[_DeviceT_co], float | None] + set_value_fn: Callable[[_DeviceT_co, float], Awaitable[None]] + uom_fn: Callable[[_DeviceT_co], str] | None = None + + +CLASSICVARIO_DESCRIPTIONS: tuple[ + EheimDigitalNumberDescription[EheimDigitalClassicVario], ... +] = ( + EheimDigitalNumberDescription[EheimDigitalClassicVario]( + key="manual_speed", + translation_key="manual_speed", + entity_category=EntityCategory.CONFIG, + native_step=PRECISION_WHOLE, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda device: device.manual_speed, + set_value_fn=lambda device, value: device.set_manual_speed(int(value)), + ), + EheimDigitalNumberDescription[EheimDigitalClassicVario]( + key="day_speed", + translation_key="day_speed", + entity_category=EntityCategory.CONFIG, + native_step=PRECISION_WHOLE, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda device: device.day_speed, + set_value_fn=lambda device, value: device.set_day_speed(int(value)), + ), + EheimDigitalNumberDescription[EheimDigitalClassicVario]( + key="night_speed", + translation_key="night_speed", + entity_category=EntityCategory.CONFIG, + native_step=PRECISION_WHOLE, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda device: device.night_speed, + set_value_fn=lambda device, value: device.set_night_speed(int(value)), + ), +) + +HEATER_DESCRIPTIONS: tuple[EheimDigitalNumberDescription[EheimDigitalHeater], ...] = ( + EheimDigitalNumberDescription[EheimDigitalHeater]( + key="temperature_offset", + translation_key="temperature_offset", + entity_category=EntityCategory.CONFIG, + native_min_value=-3, + native_max_value=3, + native_step=PRECISION_TENTHS, + device_class=NumberDeviceClass.TEMPERATURE, + uom_fn=lambda device: ( + UnitOfTemperature.CELSIUS + if device.temperature_unit is HeaterUnit.CELSIUS + else UnitOfTemperature.FAHRENHEIT + ), + value_fn=lambda device: device.temperature_offset, + set_value_fn=lambda device, value: device.set_temperature_offset(value), + ), + EheimDigitalNumberDescription[EheimDigitalHeater]( + key="night_temperature_offset", + translation_key="night_temperature_offset", + entity_category=EntityCategory.CONFIG, + native_min_value=-5, + native_max_value=5, + native_step=PRECISION_HALVES, + device_class=NumberDeviceClass.TEMPERATURE, + uom_fn=lambda device: ( + UnitOfTemperature.CELSIUS + if device.temperature_unit is HeaterUnit.CELSIUS + else UnitOfTemperature.FAHRENHEIT + ), + value_fn=lambda device: device.night_temperature_offset, + set_value_fn=lambda device, value: device.set_night_temperature_offset(value), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: EheimDigitalConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the callbacks for the coordinator so numbers can be added as devices are found.""" + coordinator = entry.runtime_data + + def async_setup_device_entities( + device_address: dict[str, EheimDigitalDevice], + ) -> None: + """Set up the number entities for one or multiple devices.""" + entities: list[EheimDigitalNumber[EheimDigitalDevice]] = [] + for device in device_address.values(): + if isinstance(device, EheimDigitalClassicVario): + entities.extend( + EheimDigitalNumber[EheimDigitalClassicVario]( + coordinator, device, description + ) + for description in CLASSICVARIO_DESCRIPTIONS + ) + if isinstance(device, EheimDigitalHeater): + entities.extend( + EheimDigitalNumber[EheimDigitalHeater]( + coordinator, device, description + ) + for description in HEATER_DESCRIPTIONS + ) + + async_add_entities(entities) + + coordinator.add_platform_callback(async_setup_device_entities) + async_setup_device_entities(coordinator.hub.devices) + + +class EheimDigitalNumber( + EheimDigitalEntity[_DeviceT_co], NumberEntity, Generic[_DeviceT_co] +): + """Represent a EHEIM Digital number entity.""" + + entity_description: EheimDigitalNumberDescription[_DeviceT_co] + + def __init__( + self, + coordinator: EheimDigitalUpdateCoordinator, + device: _DeviceT_co, + description: EheimDigitalNumberDescription[_DeviceT_co], + ) -> None: + """Initialize an EHEIM Digital number entity.""" + super().__init__(coordinator, device) + self.entity_description = description + self._attr_unique_id = f"{self._device_address}_{description.key}" + + @override + async def async_set_native_value(self, value: float) -> None: + return await self.entity_description.set_value_fn(self._device, value) + + @override + def _async_update_attrs(self) -> None: + self._attr_native_value = self.entity_description.value_fn(self._device) + self._attr_native_unit_of_measurement = ( + self.entity_description.uom_fn(self._device) + if self.entity_description.uom_fn + else self.entity_description.native_unit_of_measurement + ) diff --git a/homeassistant/components/eheimdigital/strings.json b/homeassistant/components/eheimdigital/strings.json index 81fa521bbaf..d7a14b023f7 100644 --- a/homeassistant/components/eheimdigital/strings.json +++ b/homeassistant/components/eheimdigital/strings.json @@ -47,6 +47,23 @@ } } }, + "number": { + "manual_speed": { + "name": "Manual speed" + }, + "day_speed": { + "name": "Day speed" + }, + "night_speed": { + "name": "Night speed" + }, + "temperature_offset": { + "name": "Temperature offset" + }, + "night_temperature_offset": { + "name": "Night temperature offset" + } + }, "sensor": { "current_speed": { "name": "Current speed" diff --git a/tests/components/eheimdigital/conftest.py b/tests/components/eheimdigital/conftest.py index 2c4af207642..01ef9e44b5d 100644 --- a/tests/components/eheimdigital/conftest.py +++ b/tests/components/eheimdigital/conftest.py @@ -61,6 +61,8 @@ def heater_mock(): heater_mock.temperature_unit = HeaterUnit.CELSIUS heater_mock.current_temperature = 24.2 heater_mock.target_temperature = 25.5 + heater_mock.temperature_offset = 0.1 + heater_mock.night_temperature_offset = -0.2 heater_mock.is_heating = True heater_mock.is_active = True heater_mock.operation_mode = HeaterMode.MANUAL @@ -77,6 +79,9 @@ def classic_vario_mock(): classic_vario_mock.aquarium_name = "Mock Aquarium" classic_vario_mock.sw_version = "1.0.0_1.0.0" classic_vario_mock.current_speed = 75 + classic_vario_mock.manual_speed = 75 + classic_vario_mock.day_speed = 80 + classic_vario_mock.night_speed = 20 classic_vario_mock.is_active = True classic_vario_mock.filter_mode = FilterMode.MANUAL classic_vario_mock.error_code = FilterErrorCode.NO_ERROR diff --git a/tests/components/eheimdigital/snapshots/test_number.ambr b/tests/components/eheimdigital/snapshots/test_number.ambr new file mode 100644 index 00000000000..d647b16bf49 --- /dev/null +++ b/tests/components/eheimdigital/snapshots/test_number.ambr @@ -0,0 +1,286 @@ +# serializer version: 1 +# name: test_setup[number.mock_classicvario_day_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.mock_classicvario_day_speed', + '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': 'Day speed', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'day_speed', + 'unique_id': '00:00:00:00:00:03_day_speed', + 'unit_of_measurement': '%', + }) +# --- +# name: test_setup[number.mock_classicvario_day_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock classicVARIO Day speed', + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.mock_classicvario_day_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[number.mock_classicvario_manual_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.mock_classicvario_manual_speed', + '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': 'Manual speed', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'manual_speed', + 'unique_id': '00:00:00:00:00:03_manual_speed', + 'unit_of_measurement': '%', + }) +# --- +# name: test_setup[number.mock_classicvario_manual_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock classicVARIO Manual speed', + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.mock_classicvario_manual_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[number.mock_classicvario_night_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.mock_classicvario_night_speed', + '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': 'Night speed', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'night_speed', + 'unique_id': '00:00:00:00:00:03_night_speed', + 'unit_of_measurement': '%', + }) +# --- +# name: test_setup[number.mock_classicvario_night_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock classicVARIO Night speed', + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.mock_classicvario_night_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[number.mock_heater_night_temperature_offset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 5, + 'min': -5, + 'mode': , + 'step': 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.mock_heater_night_temperature_offset', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Night temperature offset', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'night_temperature_offset', + 'unique_id': '00:00:00:00:00:02_night_temperature_offset', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[number.mock_heater_night_temperature_offset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Mock Heater Night temperature offset', + 'max': 5, + 'min': -5, + 'mode': , + 'step': 0.5, + }), + 'context': , + 'entity_id': 'number.mock_heater_night_temperature_offset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[number.mock_heater_temperature_offset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 3, + 'min': -3, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.mock_heater_temperature_offset', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature offset', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_offset', + 'unique_id': '00:00:00:00:00:02_temperature_offset', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[number.mock_heater_temperature_offset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Mock Heater Temperature offset', + 'max': 3, + 'min': -3, + 'mode': , + 'step': 0.1, + }), + 'context': , + 'entity_id': 'number.mock_heater_temperature_offset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/eheimdigital/test_number.py b/tests/components/eheimdigital/test_number.py new file mode 100644 index 00000000000..d84c14f95a5 --- /dev/null +++ b/tests/components/eheimdigital/test_number.py @@ -0,0 +1,189 @@ +"""Tests for the number module.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import init_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("classic_vario_mock", "heater_mock") +async def test_setup( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test number platform setup.""" + mock_config_entry.add_to_hass(hass) + + with ( + patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.NUMBER]), + patch( + "homeassistant.components.eheimdigital.coordinator.asyncio.Event", + new=AsyncMock, + ), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + for device in eheimdigital_hub_mock.return_value.devices: + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + device, eheimdigital_hub_mock.return_value.devices[device].device_type + ) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("classic_vario_mock", "heater_mock") +@pytest.mark.parametrize( + ("device_name", "entity_list"), + [ + ( + "heater_mock", + [ + ( + "number.mock_heater_temperature_offset", + 0.4, + "set_temperature_offset", + (0.4,), + ), + ( + "number.mock_heater_night_temperature_offset", + 0.4, + "set_night_temperature_offset", + (0.4,), + ), + ], + ), + ( + "classic_vario_mock", + [ + ( + "number.mock_classicvario_manual_speed", + 72.1, + "set_manual_speed", + (int(72.1),), + ), + ( + "number.mock_classicvario_day_speed", + 72.1, + "set_day_speed", + (int(72.1),), + ), + ( + "number.mock_classicvario_night_speed", + 72.1, + "set_night_speed", + (int(72.1),), + ), + ], + ), + ], +) +async def test_set_value( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + device_name: str, + entity_list: list[tuple[str, float, str, tuple[float]]], + request: pytest.FixtureRequest, +) -> None: + """Test setting a value.""" + device: MagicMock = request.getfixturevalue(device_name) + await init_integration(hass, mock_config_entry) + + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + device.mac_address, device.device_type + ) + + await hass.async_block_till_done() + + for item in entity_list: + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: item[0], ATTR_VALUE: item[1]}, + blocking=True, + ) + calls = [call for call in device.mock_calls if call[0] == item[2]] + assert len(calls) == 1 and calls[0][1] == item[3] + + +@pytest.mark.usefixtures("classic_vario_mock", "heater_mock") +@pytest.mark.parametrize( + ("device_name", "entity_list"), + [ + ( + "heater_mock", + [ + ( + "number.mock_heater_temperature_offset", + "temperature_offset", + -1.1, + ), + ( + "number.mock_heater_night_temperature_offset", + "night_temperature_offset", + 2.3, + ), + ], + ), + ( + "classic_vario_mock", + [ + ( + "number.mock_classicvario_manual_speed", + "manual_speed", + 34, + ), + ( + "number.mock_classicvario_day_speed", + "day_speed", + 79, + ), + ( + "number.mock_classicvario_night_speed", + "night_speed", + 12, + ), + ], + ), + ], +) +async def test_state_update( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + device_name: str, + entity_list: list[tuple[str, str, float]], + request: pytest.FixtureRequest, +) -> None: + """Test state updates.""" + device: MagicMock = request.getfixturevalue(device_name) + await init_integration(hass, mock_config_entry) + + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + device.mac_address, device.device_type + ) + + await hass.async_block_till_done() + + for item in entity_list: + setattr(device, item[1], item[2]) + await eheimdigital_hub_mock.call_args.kwargs["receive_callback"]() + assert (state := hass.states.get(item[0])) + assert state.state == str(item[2])