Add time platform to eheimdigital (#143168)

This commit is contained in:
Sid 2025-04-30 15:34:28 +02:00 committed by GitHub
parent 03ecd7f06c
commit 857db679ae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 530 additions and 1 deletions

View File

@ -9,7 +9,13 @@ from homeassistant.helpers.device_registry import DeviceEntry
from .const import DOMAIN from .const import DOMAIN
from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator 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( async def async_setup_entry(

View File

@ -30,6 +30,14 @@
"no_error": "mdi:check-circle" "no_error": "mdi:check-circle"
} }
} }
},
"time": {
"day_start_time": {
"default": "mdi:weather-sunny"
},
"night_start_time": {
"default": "mdi:moon-waning-crescent"
}
} }
} }
} }

View File

@ -79,6 +79,14 @@
"air_in_filter": "Air in filter" "air_in_filter": "Air in filter"
} }
} }
},
"time": {
"day_start_time": {
"name": "Day start time"
},
"night_start_time": {
"name": "Night start time"
}
} }
} }
} }

View File

@ -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)

View File

@ -1,6 +1,7 @@
"""Configurations for the EHEIM Digital tests.""" """Configurations for the EHEIM Digital tests."""
from collections.abc import Generator from collections.abc import Generator
from datetime import time, timedelta, timezone
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
@ -66,6 +67,8 @@ def heater_mock():
heater_mock.is_heating = True heater_mock.is_heating = True
heater_mock.is_active = True heater_mock.is_active = True
heater_mock.operation_mode = HeaterMode.MANUAL 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 return heater_mock
@ -81,6 +84,10 @@ def classic_vario_mock():
classic_vario_mock.current_speed = 75 classic_vario_mock.current_speed = 75
classic_vario_mock.manual_speed = 75 classic_vario_mock.manual_speed = 75
classic_vario_mock.day_speed = 80 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.night_speed = 20
classic_vario_mock.is_active = True classic_vario_mock.is_active = True
classic_vario_mock.filter_mode = FilterMode.MANUAL classic_vario_mock.filter_mode = FilterMode.MANUAL

View File

@ -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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'time',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'time.mock_classicvario_day_start_time',
'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': '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': <ANY>,
'entity_id': 'time.mock_classicvario_day_start_time',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_setup[time.mock_classicvario_night_start_time-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'time',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'time.mock_classicvario_night_start_time',
'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': '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': <ANY>,
'entity_id': 'time.mock_classicvario_night_start_time',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_setup[time.mock_heater_day_start_time-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'time',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'time.mock_heater_day_start_time',
'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': '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': <ANY>,
'entity_id': 'time.mock_heater_day_start_time',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_setup[time.mock_heater_night_start_time-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'time',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'time.mock_heater_night_start_time',
'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': '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': <ANY>,
'entity_id': 'time.mock_heater_night_start_time',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---

View File

@ -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()