From 857db679ae8fdbc60e856c48c40215b971d41e81 Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Wed, 30 Apr 2025 15:34:28 +0200 Subject: [PATCH] Add time platform to eheimdigital (#143168) --- .../components/eheimdigital/__init__.py | 8 +- .../components/eheimdigital/icons.json | 8 + .../components/eheimdigital/strings.json | 8 + homeassistant/components/eheimdigital/time.py | 132 ++++++++++++ tests/components/eheimdigital/conftest.py | 7 + .../eheimdigital/snapshots/test_time.ambr | 189 ++++++++++++++++++ tests/components/eheimdigital/test_time.py | 179 +++++++++++++++++ 7 files changed, 530 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/eheimdigital/time.py create mode 100644 tests/components/eheimdigital/snapshots/test_time.ambr create mode 100644 tests/components/eheimdigital/test_time.py diff --git a/homeassistant/components/eheimdigital/__init__.py b/homeassistant/components/eheimdigital/__init__.py index 77e722f3e0c..fee2db089b2 100644 --- a/homeassistant/components/eheimdigital/__init__.py +++ b/homeassistant/components/eheimdigital/__init__.py @@ -9,7 +9,13 @@ from homeassistant.helpers.device_registry import DeviceEntry from .const import DOMAIN from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator -PLATFORMS = [Platform.CLIMATE, Platform.LIGHT, Platform.NUMBER, Platform.SENSOR] +PLATFORMS = [ + Platform.CLIMATE, + Platform.LIGHT, + Platform.NUMBER, + Platform.SENSOR, + Platform.TIME, +] async def async_setup_entry( diff --git a/homeassistant/components/eheimdigital/icons.json b/homeassistant/components/eheimdigital/icons.json index 428e383dd83..a09e15e008c 100644 --- a/homeassistant/components/eheimdigital/icons.json +++ b/homeassistant/components/eheimdigital/icons.json @@ -30,6 +30,14 @@ "no_error": "mdi:check-circle" } } + }, + "time": { + "day_start_time": { + "default": "mdi:weather-sunny" + }, + "night_start_time": { + "default": "mdi:moon-waning-crescent" + } } } } diff --git a/homeassistant/components/eheimdigital/strings.json b/homeassistant/components/eheimdigital/strings.json index d7a14b023f7..97a3fbe4e0d 100644 --- a/homeassistant/components/eheimdigital/strings.json +++ b/homeassistant/components/eheimdigital/strings.json @@ -79,6 +79,14 @@ "air_in_filter": "Air in filter" } } + }, + "time": { + "day_start_time": { + "name": "Day start time" + }, + "night_start_time": { + "name": "Night start time" + } } } } diff --git a/homeassistant/components/eheimdigital/time.py b/homeassistant/components/eheimdigital/time.py new file mode 100644 index 00000000000..ae64fad0c92 --- /dev/null +++ b/homeassistant/components/eheimdigital/time.py @@ -0,0 +1,132 @@ +"""EHEIM Digital time entities.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from datetime import time +from typing import Generic, TypeVar, final, override + +from eheimdigital.classic_vario import EheimDigitalClassicVario +from eheimdigital.device import EheimDigitalDevice +from eheimdigital.heater import EheimDigitalHeater + +from homeassistant.components.time import TimeEntity, TimeEntityDescription +from homeassistant.const import EntityCategory +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 EheimDigitalTimeDescription(TimeEntityDescription, Generic[_DeviceT_co]): + """Class describing EHEIM Digital time entities.""" + + value_fn: Callable[[_DeviceT_co], time | None] + set_value_fn: Callable[[_DeviceT_co, time], Awaitable[None]] + + +CLASSICVARIO_DESCRIPTIONS: tuple[ + EheimDigitalTimeDescription[EheimDigitalClassicVario], ... +] = ( + EheimDigitalTimeDescription[EheimDigitalClassicVario]( + key="day_start_time", + translation_key="day_start_time", + entity_category=EntityCategory.CONFIG, + value_fn=lambda device: device.day_start_time, + set_value_fn=lambda device, value: device.set_day_start_time(value), + ), + EheimDigitalTimeDescription[EheimDigitalClassicVario]( + key="night_start_time", + translation_key="night_start_time", + entity_category=EntityCategory.CONFIG, + value_fn=lambda device: device.night_start_time, + set_value_fn=lambda device, value: device.set_night_start_time(value), + ), +) + +HEATER_DESCRIPTIONS: tuple[EheimDigitalTimeDescription[EheimDigitalHeater], ...] = ( + EheimDigitalTimeDescription[EheimDigitalHeater]( + key="day_start_time", + translation_key="day_start_time", + entity_category=EntityCategory.CONFIG, + value_fn=lambda device: device.day_start_time, + set_value_fn=lambda device, value: device.set_day_start_time(value), + ), + EheimDigitalTimeDescription[EheimDigitalHeater]( + key="night_start_time", + translation_key="night_start_time", + entity_category=EntityCategory.CONFIG, + value_fn=lambda device: device.night_start_time, + set_value_fn=lambda device, value: device.set_night_start_time(value), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: EheimDigitalConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the callbacks for the coordinator so times 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 time entities for one or multiple devices.""" + entities: list[EheimDigitalTime[EheimDigitalDevice]] = [] + for device in device_address.values(): + if isinstance(device, EheimDigitalClassicVario): + entities.extend( + EheimDigitalTime[EheimDigitalClassicVario]( + coordinator, device, description + ) + for description in CLASSICVARIO_DESCRIPTIONS + ) + if isinstance(device, EheimDigitalHeater): + entities.extend( + EheimDigitalTime[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) + + +@final +class EheimDigitalTime( + EheimDigitalEntity[_DeviceT_co], TimeEntity, Generic[_DeviceT_co] +): + """Represent an EHEIM Digital time entity.""" + + entity_description: EheimDigitalTimeDescription[_DeviceT_co] + + def __init__( + self, + coordinator: EheimDigitalUpdateCoordinator, + device: _DeviceT_co, + description: EheimDigitalTimeDescription[_DeviceT_co], + ) -> None: + """Initialize an EHEIM Digital time entity.""" + super().__init__(coordinator, device) + self.entity_description = description + self._attr_unique_id = f"{device.mac_address}_{description.key}" + + @override + async def async_set_value(self, value: time) -> None: + """Change the time.""" + return await self.entity_description.set_value_fn(self._device, value) + + @override + def _async_update_attrs(self) -> None: + """Update the entity attributes.""" + self._attr_native_value = self.entity_description.value_fn(self._device) diff --git a/tests/components/eheimdigital/conftest.py b/tests/components/eheimdigital/conftest.py index 01ef9e44b5d..654028c7c11 100644 --- a/tests/components/eheimdigital/conftest.py +++ b/tests/components/eheimdigital/conftest.py @@ -1,6 +1,7 @@ """Configurations for the EHEIM Digital tests.""" from collections.abc import Generator +from datetime import time, timedelta, timezone from unittest.mock import AsyncMock, MagicMock, patch from eheimdigital.classic_led_ctrl import EheimDigitalClassicLEDControl @@ -66,6 +67,8 @@ def heater_mock(): heater_mock.is_heating = True heater_mock.is_active = True heater_mock.operation_mode = HeaterMode.MANUAL + heater_mock.day_start_time = time(8, 0, tzinfo=timezone(timedelta(hours=1))) + heater_mock.night_start_time = time(20, 0, tzinfo=timezone(timedelta(hours=1))) return heater_mock @@ -81,6 +84,10 @@ def classic_vario_mock(): classic_vario_mock.current_speed = 75 classic_vario_mock.manual_speed = 75 classic_vario_mock.day_speed = 80 + classic_vario_mock.day_start_time = time(8, 0, tzinfo=timezone(timedelta(hours=1))) + classic_vario_mock.night_start_time = time( + 20, 0, tzinfo=timezone(timedelta(hours=1)) + ) classic_vario_mock.night_speed = 20 classic_vario_mock.is_active = True classic_vario_mock.filter_mode = FilterMode.MANUAL diff --git a/tests/components/eheimdigital/snapshots/test_time.ambr b/tests/components/eheimdigital/snapshots/test_time.ambr new file mode 100644 index 00000000000..bdd4bdaddb7 --- /dev/null +++ b/tests/components/eheimdigital/snapshots/test_time.ambr @@ -0,0 +1,189 @@ +# serializer version: 1 +# name: test_setup[time.mock_classicvario_day_start_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'time', + 'entity_category': , + 'entity_id': 'time.mock_classicvario_day_start_time', + '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 start time', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'day_start_time', + 'unique_id': '00:00:00:00:00:03_day_start_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[time.mock_classicvario_day_start_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock classicVARIO Day start time', + }), + 'context': , + 'entity_id': 'time.mock_classicvario_day_start_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[time.mock_classicvario_night_start_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'time', + 'entity_category': , + 'entity_id': 'time.mock_classicvario_night_start_time', + '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 start time', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'night_start_time', + 'unique_id': '00:00:00:00:00:03_night_start_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[time.mock_classicvario_night_start_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock classicVARIO Night start time', + }), + 'context': , + 'entity_id': 'time.mock_classicvario_night_start_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[time.mock_heater_day_start_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'time', + 'entity_category': , + 'entity_id': 'time.mock_heater_day_start_time', + '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 start time', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'day_start_time', + 'unique_id': '00:00:00:00:00:02_day_start_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[time.mock_heater_day_start_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Heater Day start time', + }), + 'context': , + 'entity_id': 'time.mock_heater_day_start_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[time.mock_heater_night_start_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'time', + 'entity_category': , + 'entity_id': 'time.mock_heater_night_start_time', + '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 start time', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'night_start_time', + 'unique_id': '00:00:00:00:00:02_night_start_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[time.mock_heater_night_start_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Heater Night start time', + }), + 'context': , + 'entity_id': 'time.mock_heater_night_start_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/eheimdigital/test_time.py b/tests/components/eheimdigital/test_time.py new file mode 100644 index 00000000000..acb96ae4023 --- /dev/null +++ b/tests/components/eheimdigital/test_time.py @@ -0,0 +1,179 @@ +"""Tests for the time module.""" + +from datetime import time, timedelta, timezone +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.time import ( + ATTR_TIME, + DOMAIN as TIME_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.TIME]), + 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", + [ + ( + "time.mock_heater_day_start_time", + time(9, 0, tzinfo=timezone(timedelta(hours=1))), + "set_day_start_time", + (time(9, 0, tzinfo=timezone(timedelta(hours=1))),), + ), + ( + "time.mock_heater_night_start_time", + time(19, 0, tzinfo=timezone(timedelta(hours=1))), + "set_night_start_time", + (time(19, 0, tzinfo=timezone(timedelta(hours=1))),), + ), + ], + ), + ( + "classic_vario_mock", + [ + ( + "time.mock_classicvario_day_start_time", + time(9, 0, tzinfo=timezone(timedelta(hours=1))), + "set_day_start_time", + (time(9, 0, tzinfo=timezone(timedelta(hours=1))),), + ), + ( + "time.mock_classicvario_night_start_time", + time(19, 0, tzinfo=timezone(timedelta(hours=1))), + "set_night_start_time", + (time(19, 0, tzinfo=timezone(timedelta(hours=1))),), + ), + ], + ), + ], +) +async def test_set_value( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + device_name: str, + entity_list: list[tuple[str, time, str, tuple[time]]], + 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( + TIME_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: item[0], ATTR_TIME: 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", + [ + ( + "time.mock_heater_day_start_time", + "day_start_time", + time(9, 0, tzinfo=timezone(timedelta(hours=3))), + ), + ( + "time.mock_heater_night_start_time", + "night_start_time", + time(19, 0, tzinfo=timezone(timedelta(hours=3))), + ), + ], + ), + ( + "classic_vario_mock", + [ + ( + "time.mock_classicvario_day_start_time", + "day_start_time", + time(9, 0, tzinfo=timezone(timedelta(hours=1))), + ), + ( + "time.mock_classicvario_night_start_time", + "night_start_time", + time(22, 0, tzinfo=timezone(timedelta(hours=1))), + ), + ], + ), + ], +) +async def test_state_update( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + device_name: str, + entity_list: list[tuple[str, str, time]], + 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 == item[2].isoformat()