diff --git a/homeassistant/components/eheimdigital/__init__.py b/homeassistant/components/eheimdigital/__init__.py index 881396ea4af..bc8bbded186 100644 --- a/homeassistant/components/eheimdigital/__init__.py +++ b/homeassistant/components/eheimdigital/__init__.py @@ -13,6 +13,7 @@ PLATFORMS = [ Platform.CLIMATE, Platform.LIGHT, Platform.NUMBER, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, Platform.TIME, diff --git a/homeassistant/components/eheimdigital/select.py b/homeassistant/components/eheimdigital/select.py new file mode 100644 index 00000000000..9311eb01ecc --- /dev/null +++ b/homeassistant/components/eheimdigital/select.py @@ -0,0 +1,102 @@ +"""EHEIM Digital select entities.""" + +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.types import FilterMode + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +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 EheimDigitalSelectDescription(SelectEntityDescription, Generic[_DeviceT_co]): + """Class describing EHEIM Digital select entities.""" + + value_fn: Callable[[_DeviceT_co], str | None] + set_value_fn: Callable[[_DeviceT_co, str], Awaitable[None]] + + +CLASSICVARIO_DESCRIPTIONS: tuple[ + EheimDigitalSelectDescription[EheimDigitalClassicVario], ... +] = ( + EheimDigitalSelectDescription[EheimDigitalClassicVario]( + key="filter_mode", + translation_key="filter_mode", + value_fn=( + lambda device: device.filter_mode.name.lower() + if device.filter_mode is not None + else None + ), + set_value_fn=( + lambda device, value: device.set_filter_mode(FilterMode[value.upper()]) + ), + options=[name.lower() for name in FilterMode.__members__], + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: EheimDigitalConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the callbacks for the coordinator so select entities 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[EheimDigitalSelect[EheimDigitalDevice]] = [] + for device in device_address.values(): + if isinstance(device, EheimDigitalClassicVario): + entities.extend( + EheimDigitalSelect[EheimDigitalClassicVario]( + coordinator, device, description + ) + for description in CLASSICVARIO_DESCRIPTIONS + ) + + async_add_entities(entities) + + coordinator.add_platform_callback(async_setup_device_entities) + async_setup_device_entities(coordinator.hub.devices) + + +class EheimDigitalSelect( + EheimDigitalEntity[_DeviceT_co], SelectEntity, Generic[_DeviceT_co] +): + """Represent an EHEIM Digital select entity.""" + + entity_description: EheimDigitalSelectDescription[_DeviceT_co] + + def __init__( + self, + coordinator: EheimDigitalUpdateCoordinator, + device: _DeviceT_co, + description: EheimDigitalSelectDescription[_DeviceT_co], + ) -> None: + """Initialize an EHEIM Digital select entity.""" + super().__init__(coordinator, device) + self.entity_description = description + self._attr_unique_id = f"{self._device_address}_{description.key}" + + @override + async def async_select_option(self, option: str) -> None: + return await self.entity_description.set_value_fn(self._device, option) + + @override + def _async_update_attrs(self) -> None: + self._attr_current_option = self.entity_description.value_fn(self._device) diff --git a/homeassistant/components/eheimdigital/strings.json b/homeassistant/components/eheimdigital/strings.json index f6f6b74a72e..89f802c9d6d 100644 --- a/homeassistant/components/eheimdigital/strings.json +++ b/homeassistant/components/eheimdigital/strings.json @@ -67,6 +67,16 @@ "name": "System LED brightness" } }, + "select": { + "filter_mode": { + "name": "Filter mode", + "state": { + "manual": "Manual", + "pulse": "Pulse", + "bio": "Bio" + } + } + }, "sensor": { "current_speed": { "name": "Current speed" diff --git a/tests/components/eheimdigital/snapshots/test_select.ambr b/tests/components/eheimdigital/snapshots/test_select.ambr new file mode 100644 index 00000000000..5416f5a2d78 --- /dev/null +++ b/tests/components/eheimdigital/snapshots/test_select.ambr @@ -0,0 +1,59 @@ +# serializer version: 1 +# name: test_setup[select.mock_classicvario_filter_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'manual', + 'pulse', + 'bio', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.mock_classicvario_filter_mode', + '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': 'Filter mode', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'filter_mode', + 'unique_id': '00:00:00:00:00:03_filter_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[select.mock_classicvario_filter_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock classicVARIO Filter mode', + 'options': list([ + 'manual', + 'pulse', + 'bio', + ]), + }), + 'context': , + 'entity_id': 'select.mock_classicvario_filter_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/eheimdigital/test_select.py b/tests/components/eheimdigital/test_select.py new file mode 100644 index 00000000000..89ec91b62a0 --- /dev/null +++ b/tests/components/eheimdigital/test_select.py @@ -0,0 +1,136 @@ +"""Tests for the select module.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +from eheimdigital.types import FilterMode +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.select import ( + ATTR_OPTION, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +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") +async def test_setup( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test select platform setup.""" + mock_config_entry.add_to_hass(hass) + + with ( + patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.SELECT]), + 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") +@pytest.mark.parametrize( + ("device_name", "entity_list"), + [ + ( + "classic_vario_mock", + [ + ( + "select.mock_classicvario_filter_mode", + "manual", + "set_filter_mode", + (FilterMode.MANUAL,), + ), + ], + ), + ], +) +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[FilterMode]]], + 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( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: item[0], ATTR_OPTION: 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"), + [ + ( + "classic_vario_mock", + [ + ( + "select.mock_classicvario_filter_mode", + "filter_mode", + FilterMode.BIO, + ), + ], + ), + ], +) +async def test_state_update( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + device_name: str, + entity_list: list[tuple[str, str, FilterMode]], + 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].name.lower()