diff --git a/.strict-typing b/.strict-typing index ade5d6afb7b..66dae130fb5 100644 --- a/.strict-typing +++ b/.strict-typing @@ -170,6 +170,7 @@ homeassistant.components.easyenergy.* homeassistant.components.ecovacs.* homeassistant.components.ecowitt.* homeassistant.components.efergy.* +homeassistant.components.eheimdigital.* homeassistant.components.electrasmart.* homeassistant.components.electric_kiwi.* homeassistant.components.elevenlabs.* diff --git a/CODEOWNERS b/CODEOWNERS index afd150ffb0c..06eb70c7576 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -387,6 +387,8 @@ build.json @home-assistant/supervisor /homeassistant/components/efergy/ @tkdrob /tests/components/efergy/ @tkdrob /homeassistant/components/egardia/ @jeroenterheerdt +/homeassistant/components/eheimdigital/ @autinerd +/tests/components/eheimdigital/ @autinerd /homeassistant/components/electrasmart/ @jafar-atili /tests/components/electrasmart/ @jafar-atili /homeassistant/components/electric_kiwi/ @mikey0000 diff --git a/homeassistant/components/eheimdigital/__init__.py b/homeassistant/components/eheimdigital/__init__.py new file mode 100644 index 00000000000..cf08f45bed5 --- /dev/null +++ b/homeassistant/components/eheimdigital/__init__.py @@ -0,0 +1,51 @@ +"""The EHEIM Digital integration.""" + +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntry + +from .const import DOMAIN +from .coordinator import EheimDigitalUpdateCoordinator + +PLATFORMS = [Platform.LIGHT] + +type EheimDigitalConfigEntry = ConfigEntry[EheimDigitalUpdateCoordinator] + + +async def async_setup_entry( + hass: HomeAssistant, entry: EheimDigitalConfigEntry +) -> bool: + """Set up EHEIM Digital from a config entry.""" + + coordinator = EheimDigitalUpdateCoordinator(hass) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: EheimDigitalConfigEntry +) -> bool: + """Unload a config entry.""" + await entry.runtime_data.hub.close() + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_remove_config_entry_device( + hass: HomeAssistant, + config_entry: EheimDigitalConfigEntry, + device_entry: DeviceEntry, +) -> bool: + """Remove a config entry from a device.""" + return not any( + identifier + for identifier in device_entry.identifiers + if identifier[0] == DOMAIN + and identifier[1] in config_entry.runtime_data.hub.devices + ) diff --git a/homeassistant/components/eheimdigital/config_flow.py b/homeassistant/components/eheimdigital/config_flow.py new file mode 100644 index 00000000000..6994c6f65b5 --- /dev/null +++ b/homeassistant/components/eheimdigital/config_flow.py @@ -0,0 +1,127 @@ +"""Config flow for EHEIM Digital.""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING, Any + +from aiohttp import ClientError +from eheimdigital.device import EheimDigitalDevice +from eheimdigital.hub import EheimDigitalHub +import voluptuous as vol + +from homeassistant.components.zeroconf import ZeroconfServiceInfo +from homeassistant.config_entries import SOURCE_USER, ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST +from homeassistant.helpers import selector +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN, LOGGER + +CONFIG_SCHEMA = vol.Schema( + {vol.Required(CONF_HOST, default="eheimdigital.local"): selector.TextSelector()} +) + + +class EheimDigitalConfigFlow(ConfigFlow, domain=DOMAIN): + """The EHEIM Digital config flow.""" + + def __init__(self) -> None: + """Initialize the config flow.""" + super().__init__() + self.data: dict[str, Any] = {} + self.main_device_added_event = asyncio.Event() + + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle zeroconf discovery.""" + self.data[CONF_HOST] = host = discovery_info.host + + self._async_abort_entries_match(self.data) + + hub = EheimDigitalHub( + host=host, + session=async_get_clientsession(self.hass), + loop=self.hass.loop, + main_device_added_event=self.main_device_added_event, + ) + try: + await hub.connect() + + async with asyncio.timeout(2): + # This event gets triggered when the first message is received from + # the device, it contains the data necessary to create the main device. + # This removes the race condition where the main device is accessed + # before the response from the device is parsed. + await self.main_device_added_event.wait() + if TYPE_CHECKING: + # At this point the main device is always set + assert isinstance(hub.main, EheimDigitalDevice) + await hub.close() + except (ClientError, TimeoutError): + return self.async_abort(reason="cannot_connect") + except Exception: # noqa: BLE001 + return self.async_abort(reason="unknown") + await self.async_set_unique_id(hub.main.mac_address) + self._abort_if_unique_id_configured(updates={CONF_HOST: host}) + return await self.async_step_discovery_confirm() + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm discovery.""" + if user_input is not None: + return self.async_create_entry( + title=self.data[CONF_HOST], + data={CONF_HOST: self.data[CONF_HOST]}, + ) + + self._set_confirm_only() + return self.async_show_form(step_id="discovery_confirm") + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the user step.""" + if user_input is None: + return self.async_show_form(step_id=SOURCE_USER, data_schema=CONFIG_SCHEMA) + + self._async_abort_entries_match(user_input) + errors: dict[str, str] = {} + hub = EheimDigitalHub( + host=user_input[CONF_HOST], + session=async_get_clientsession(self.hass), + loop=self.hass.loop, + main_device_added_event=self.main_device_added_event, + ) + + try: + await hub.connect() + + async with asyncio.timeout(2): + # This event gets triggered when the first message is received from + # the device, it contains the data necessary to create the main device. + # This removes the race condition where the main device is accessed + # before the response from the device is parsed. + await self.main_device_added_event.wait() + if TYPE_CHECKING: + # At this point the main device is always set + assert isinstance(hub.main, EheimDigitalDevice) + await self.async_set_unique_id( + hub.main.mac_address, raise_on_progress=False + ) + await hub.close() + except (ClientError, TimeoutError): + errors["base"] = "cannot_connect" + except Exception: # noqa: BLE001 + errors["base"] = "unknown" + LOGGER.exception("Unknown exception occurred") + else: + self._abort_if_unique_id_configured() + return self.async_create_entry(data=user_input, title=user_input[CONF_HOST]) + return self.async_show_form( + step_id=SOURCE_USER, + data_schema=CONFIG_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/eheimdigital/const.py b/homeassistant/components/eheimdigital/const.py new file mode 100644 index 00000000000..5ed9303be40 --- /dev/null +++ b/homeassistant/components/eheimdigital/const.py @@ -0,0 +1,17 @@ +"""Constants for the EHEIM Digital integration.""" + +from logging import Logger, getLogger + +from eheimdigital.types import LightMode + +from homeassistant.components.light import EFFECT_OFF + +LOGGER: Logger = getLogger(__package__) +DOMAIN = "eheimdigital" + +EFFECT_DAYCL_MODE = "daycl_mode" + +EFFECT_TO_LIGHT_MODE = { + EFFECT_DAYCL_MODE: LightMode.DAYCL_MODE, + EFFECT_OFF: LightMode.MAN_MODE, +} diff --git a/homeassistant/components/eheimdigital/coordinator.py b/homeassistant/components/eheimdigital/coordinator.py new file mode 100644 index 00000000000..f122a1227c5 --- /dev/null +++ b/homeassistant/components/eheimdigital/coordinator.py @@ -0,0 +1,78 @@ +"""Data update coordinator for the EHEIM Digital integration.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from typing import Any + +from aiohttp import ClientError +from eheimdigital.device import EheimDigitalDevice +from eheimdigital.hub import EheimDigitalHub +from eheimdigital.types import EheimDeviceType + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.entity_component import DEFAULT_SCAN_INTERVAL +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER + +type AsyncSetupDeviceEntitiesCallback = Callable[[str], Coroutine[Any, Any, None]] + + +class EheimDigitalUpdateCoordinator( + DataUpdateCoordinator[dict[str, EheimDigitalDevice]] +): + """The EHEIM Digital data update coordinator.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the EHEIM Digital data update coordinator.""" + super().__init__( + hass, LOGGER, name=DOMAIN, update_interval=DEFAULT_SCAN_INTERVAL + ) + self.hub = EheimDigitalHub( + host=self.config_entry.data[CONF_HOST], + session=async_get_clientsession(hass), + loop=hass.loop, + receive_callback=self._async_receive_callback, + device_found_callback=self._async_device_found, + ) + self.known_devices: set[str] = set() + self.platform_callbacks: set[AsyncSetupDeviceEntitiesCallback] = set() + + def add_platform_callback( + self, + async_setup_device_entities: AsyncSetupDeviceEntitiesCallback, + ) -> None: + """Add the setup callbacks from a specific platform.""" + self.platform_callbacks.add(async_setup_device_entities) + + async def _async_device_found( + self, device_address: str, device_type: EheimDeviceType + ) -> None: + """Set up a new device found. + + This function is called from the library whenever a new device is added. + """ + + if device_address not in self.known_devices: + for platform_callback in self.platform_callbacks: + await platform_callback(device_address) + + async def _async_receive_callback(self) -> None: + self.async_set_updated_data(self.hub.devices) + + async def _async_setup(self) -> None: + await self.hub.connect() + await self.hub.update() + + async def _async_update_data(self) -> dict[str, EheimDigitalDevice]: + try: + await self.hub.update() + except ClientError as ex: + raise UpdateFailed from ex + return self.data diff --git a/homeassistant/components/eheimdigital/entity.py b/homeassistant/components/eheimdigital/entity.py new file mode 100644 index 00000000000..c0f91a4b798 --- /dev/null +++ b/homeassistant/components/eheimdigital/entity.py @@ -0,0 +1,53 @@ +"""Base entity for EHEIM Digital.""" + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +from eheimdigital.device import EheimDigitalDevice + +from homeassistant.const import CONF_HOST +from homeassistant.core import callback +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import EheimDigitalUpdateCoordinator + + +class EheimDigitalEntity[_DeviceT: EheimDigitalDevice]( + CoordinatorEntity[EheimDigitalUpdateCoordinator], ABC +): + """Represent a EHEIM Digital entity.""" + + _attr_has_entity_name = True + + def __init__( + self, coordinator: EheimDigitalUpdateCoordinator, device: _DeviceT + ) -> None: + """Initialize a EHEIM Digital entity.""" + super().__init__(coordinator) + if TYPE_CHECKING: + # At this point at least one device is found and so there is always a main device set + assert isinstance(coordinator.hub.main, EheimDigitalDevice) + self._attr_device_info = DeviceInfo( + configuration_url=f"http://{coordinator.config_entry.data[CONF_HOST]}", + name=device.name, + connections={(CONNECTION_NETWORK_MAC, device.mac_address)}, + manufacturer="EHEIM", + model=device.device_type.model_name, + identifiers={(DOMAIN, device.mac_address)}, + suggested_area=device.aquarium_name, + sw_version=device.sw_version, + via_device=(DOMAIN, coordinator.hub.main.mac_address), + ) + self._device = device + self._device_address = device.mac_address + + @abstractmethod + def _async_update_attrs(self) -> None: ... + + @callback + def _handle_coordinator_update(self) -> None: + """Update attributes when the coordinator updates.""" + self._async_update_attrs() + super()._handle_coordinator_update() diff --git a/homeassistant/components/eheimdigital/light.py b/homeassistant/components/eheimdigital/light.py new file mode 100644 index 00000000000..a119e0bda8d --- /dev/null +++ b/homeassistant/components/eheimdigital/light.py @@ -0,0 +1,127 @@ +"""EHEIM Digital lights.""" + +from typing import Any + +from eheimdigital.classic_led_ctrl import EheimDigitalClassicLEDControl +from eheimdigital.types import EheimDigitalClientError, LightMode + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_EFFECT, + EFFECT_OFF, + ColorMode, + LightEntity, + LightEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util.color import brightness_to_value, value_to_brightness + +from . import EheimDigitalConfigEntry +from .const import EFFECT_DAYCL_MODE, EFFECT_TO_LIGHT_MODE +from .coordinator import EheimDigitalUpdateCoordinator +from .entity import EheimDigitalEntity + +BRIGHTNESS_SCALE = (1, 100) + +# 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 lights 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] + entities: list[EheimDigitalClassicLEDControlLight] = [] + + if isinstance(device, EheimDigitalClassicLEDControl): + for channel in range(2): + if len(device.tankconfig[channel]) > 0: + entities.append( + EheimDigitalClassicLEDControlLight(coordinator, device, channel) + ) + coordinator.known_devices.add(device.mac_address) + async_add_entities(entities) + + 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 EheimDigitalClassicLEDControlLight( + EheimDigitalEntity[EheimDigitalClassicLEDControl], LightEntity +): + """Represent a EHEIM Digital classicLEDcontrol light.""" + + _attr_supported_color_modes = {ColorMode.BRIGHTNESS} + _attr_color_mode = ColorMode.BRIGHTNESS + _attr_effect_list = [EFFECT_DAYCL_MODE] + _attr_supported_features = LightEntityFeature.EFFECT + _attr_translation_key = "channel" + + def __init__( + self, + coordinator: EheimDigitalUpdateCoordinator, + device: EheimDigitalClassicLEDControl, + channel: int, + ) -> None: + """Initialize an EHEIM Digital classicLEDcontrol light entity.""" + super().__init__(coordinator, device) + self._channel = channel + self._attr_translation_placeholders = {"channel_id": str(channel)} + self._attr_unique_id = f"{self._device_address}_{channel}" + self._async_update_attrs() + + @property + def available(self) -> bool: + """Return whether the entity is available.""" + return super().available and self._device.light_level[self._channel] is not None + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the light.""" + if ATTR_EFFECT in kwargs: + await self._device.set_light_mode(EFFECT_TO_LIGHT_MODE[kwargs[ATTR_EFFECT]]) + return + if ATTR_BRIGHTNESS in kwargs: + if self._device.light_mode == LightMode.DAYCL_MODE: + await self._device.set_light_mode(LightMode.MAN_MODE) + try: + await self._device.turn_on( + int(brightness_to_value(BRIGHTNESS_SCALE, kwargs[ATTR_BRIGHTNESS])), + self._channel, + ) + except EheimDigitalClientError as err: + raise HomeAssistantError from err + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the light.""" + if self._device.light_mode == LightMode.DAYCL_MODE: + await self._device.set_light_mode(LightMode.MAN_MODE) + try: + await self._device.turn_off(self._channel) + except EheimDigitalClientError as err: + raise HomeAssistantError from err + + def _async_update_attrs(self) -> None: + light_level = self._device.light_level[self._channel] + + self._attr_is_on = light_level > 0 if light_level is not None else None + self._attr_brightness = ( + value_to_brightness(BRIGHTNESS_SCALE, light_level) + if light_level is not None + else None + ) + self._attr_effect = ( + EFFECT_DAYCL_MODE + if self._device.light_mode == LightMode.DAYCL_MODE + else EFFECT_OFF + ) diff --git a/homeassistant/components/eheimdigital/manifest.json b/homeassistant/components/eheimdigital/manifest.json new file mode 100644 index 00000000000..159aecd6b6c --- /dev/null +++ b/homeassistant/components/eheimdigital/manifest.json @@ -0,0 +1,15 @@ +{ + "domain": "eheimdigital", + "name": "EHEIM Digital", + "codeowners": ["@autinerd"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/eheimdigital", + "integration_type": "hub", + "iot_class": "local_polling", + "loggers": ["eheimdigital"], + "quality_scale": "bronze", + "requirements": ["eheimdigital==1.0.3"], + "zeroconf": [ + { "type": "_http._tcp.local.", "name": "eheimdigital._http._tcp.local." } + ] +} diff --git a/homeassistant/components/eheimdigital/quality_scale.yaml b/homeassistant/components/eheimdigital/quality_scale.yaml new file mode 100644 index 00000000000..a56551a14f6 --- /dev/null +++ b/homeassistant/components/eheimdigital/quality_scale.yaml @@ -0,0 +1,70 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: No service actions implemented. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: No service actions implemented. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: No service actions implemented. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: This integration doesn't have an options flow. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: This integration requires no authentication. + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: done + discovery: done + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: done + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: done + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/eheimdigital/strings.json b/homeassistant/components/eheimdigital/strings.json new file mode 100644 index 00000000000..0e6fa6a0814 --- /dev/null +++ b/homeassistant/components/eheimdigital/strings.json @@ -0,0 +1,39 @@ +{ + "config": { + "step": { + "discovery_confirm": { + "description": "[%key:common::config_flow::description::confirm_setup%]" + }, + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The host or IP address of your main device. Only needed to change if 'eheimdigital' doesn't work." + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + }, + "entity": { + "light": { + "channel": { + "name": "Channel {channel_id}", + "state_attributes": { + "effect": { + "state": { + "daycl_mode": "Daycycle mode" + } + } + } + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 930bda4e81b..3b33d31a2a2 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -155,6 +155,7 @@ FLOWS = { "ecowitt", "edl21", "efergy", + "eheimdigital", "electrasmart", "electric_kiwi", "elevenlabs", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index ecbe3f0dcbf..1530e308e7d 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1524,6 +1524,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "eheimdigital": { + "name": "EHEIM Digital", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "electrasmart": { "name": "Electra Smart", "integration_type": "hub", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index b04e6ad6f52..e5b50841d11 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -524,6 +524,10 @@ ZEROCONF = { "domain": "bosch_shc", "name": "bosch shc*", }, + { + "domain": "eheimdigital", + "name": "eheimdigital._http._tcp.local.", + }, { "domain": "lektrico", "name": "lektrico*", diff --git a/mypy.ini b/mypy.ini index 2d8e0ea3f61..6daf54a8eb7 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1455,6 +1455,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.eheimdigital.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.electrasmart.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 3fab70ecab3..7eab703836c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -809,6 +809,9 @@ ebusdpy==0.0.17 # homeassistant.components.ecoal_boiler ecoaliface==0.4.0 +# homeassistant.components.eheimdigital +eheimdigital==1.0.3 + # homeassistant.components.electric_kiwi electrickiwi-api==0.8.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 06fd689a0ff..2a785e363f7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -687,6 +687,9 @@ eagle100==0.1.1 # homeassistant.components.easyenergy easyenergy==2.1.2 +# homeassistant.components.eheimdigital +eheimdigital==1.0.3 + # homeassistant.components.electric_kiwi electrickiwi-api==0.8.5 diff --git a/tests/components/eheimdigital/__init__.py b/tests/components/eheimdigital/__init__.py new file mode 100644 index 00000000000..1f608f868de --- /dev/null +++ b/tests/components/eheimdigital/__init__.py @@ -0,0 +1 @@ +"""Tests for the EHEIM Digital integration.""" diff --git a/tests/components/eheimdigital/conftest.py b/tests/components/eheimdigital/conftest.py new file mode 100644 index 00000000000..cdad628de6b --- /dev/null +++ b/tests/components/eheimdigital/conftest.py @@ -0,0 +1,58 @@ +"""Configurations for the EHEIM Digital tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +from eheimdigital.classic_led_ctrl import EheimDigitalClassicLEDControl +from eheimdigital.hub import EheimDigitalHub +from eheimdigital.types import EheimDeviceType, LightMode +import pytest + +from homeassistant.components.eheimdigital.const import DOMAIN +from homeassistant.const import CONF_HOST + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "eheimdigital"}, unique_id="00:00:00:00:00:01" + ) + + +@pytest.fixture +def classic_led_ctrl_mock(): + """Mock a classicLEDcontrol device.""" + classic_led_ctrl_mock = MagicMock(spec=EheimDigitalClassicLEDControl) + classic_led_ctrl_mock.tankconfig = [["CLASSIC_DAYLIGHT"], []] + classic_led_ctrl_mock.mac_address = "00:00:00:00:00:01" + classic_led_ctrl_mock.device_type = ( + EheimDeviceType.VERSION_EHEIM_CLASSIC_LED_CTRL_PLUS_E + ) + classic_led_ctrl_mock.name = "Mock classicLEDcontrol+e" + classic_led_ctrl_mock.aquarium_name = "Mock Aquarium" + classic_led_ctrl_mock.light_mode = LightMode.DAYCL_MODE + classic_led_ctrl_mock.light_level = (10, 39) + return classic_led_ctrl_mock + + +@pytest.fixture +def eheimdigital_hub_mock(classic_led_ctrl_mock: MagicMock) -> Generator[AsyncMock]: + """Mock eheimdigital hub.""" + with ( + patch( + "homeassistant.components.eheimdigital.coordinator.EheimDigitalHub", + spec=EheimDigitalHub, + ) as eheimdigital_hub_mock, + patch( + "homeassistant.components.eheimdigital.config_flow.EheimDigitalHub", + new=eheimdigital_hub_mock, + ), + ): + eheimdigital_hub_mock.return_value.devices = { + "00:00:00:00:00:01": classic_led_ctrl_mock + } + eheimdigital_hub_mock.return_value.main = classic_led_ctrl_mock + yield eheimdigital_hub_mock diff --git a/tests/components/eheimdigital/snapshots/test_light.ambr b/tests/components/eheimdigital/snapshots/test_light.ambr new file mode 100644 index 00000000000..8df4745997e --- /dev/null +++ b/tests/components/eheimdigital/snapshots/test_light.ambr @@ -0,0 +1,316 @@ +# serializer version: 1 +# name: test_dynamic_new_devices[light.mock_classicledcontrol_e_channel_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'effect_list': list([ + 'daycl_mode', + ]), + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.mock_classicledcontrol_e_channel_0', + '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': 'Channel 0', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'channel', + 'unique_id': '00:00:00:00:00:01_0', + 'unit_of_measurement': None, + }) +# --- +# name: test_dynamic_new_devices[light.mock_classicledcontrol_e_channel_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 26, + 'color_mode': , + 'effect': 'daycl_mode', + 'effect_list': list([ + 'daycl_mode', + ]), + 'friendly_name': 'Mock classicLEDcontrol+e Channel 0', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.mock_classicledcontrol_e_channel_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_setup_classic_led_ctrl[tankconfig0][light.mock_classicledcontrol_e_channel_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'effect_list': list([ + 'daycl_mode', + ]), + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.mock_classicledcontrol_e_channel_0', + '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': 'Channel 0', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'channel', + 'unique_id': '00:00:00:00:00:01_0', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_classic_led_ctrl[tankconfig0][light.mock_classicledcontrol_e_channel_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 26, + 'color_mode': , + 'effect': 'daycl_mode', + 'effect_list': list([ + 'daycl_mode', + ]), + 'friendly_name': 'Mock classicLEDcontrol+e Channel 0', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.mock_classicledcontrol_e_channel_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_setup_classic_led_ctrl[tankconfig1][light.mock_classicledcontrol_e_channel_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'effect_list': list([ + 'daycl_mode', + ]), + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.mock_classicledcontrol_e_channel_1', + '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': 'Channel 1', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'channel', + 'unique_id': '00:00:00:00:00:01_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_classic_led_ctrl[tankconfig1][light.mock_classicledcontrol_e_channel_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 99, + 'color_mode': , + 'effect': 'daycl_mode', + 'effect_list': list([ + 'daycl_mode', + ]), + 'friendly_name': 'Mock classicLEDcontrol+e Channel 1', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.mock_classicledcontrol_e_channel_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_setup_classic_led_ctrl[tankconfig2][light.mock_classicledcontrol_e_channel_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'effect_list': list([ + 'daycl_mode', + ]), + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.mock_classicledcontrol_e_channel_0', + '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': 'Channel 0', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'channel', + 'unique_id': '00:00:00:00:00:01_0', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_classic_led_ctrl[tankconfig2][light.mock_classicledcontrol_e_channel_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 26, + 'color_mode': , + 'effect': 'daycl_mode', + 'effect_list': list([ + 'daycl_mode', + ]), + 'friendly_name': 'Mock classicLEDcontrol+e Channel 0', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.mock_classicledcontrol_e_channel_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_setup_classic_led_ctrl[tankconfig2][light.mock_classicledcontrol_e_channel_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'effect_list': list([ + 'daycl_mode', + ]), + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.mock_classicledcontrol_e_channel_1', + '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': 'Channel 1', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'channel', + 'unique_id': '00:00:00:00:00:01_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_classic_led_ctrl[tankconfig2][light.mock_classicledcontrol_e_channel_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 99, + 'color_mode': , + 'effect': 'daycl_mode', + 'effect_list': list([ + 'daycl_mode', + ]), + 'friendly_name': 'Mock classicLEDcontrol+e Channel 1', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.mock_classicledcontrol_e_channel_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/eheimdigital/test_config_flow.py b/tests/components/eheimdigital/test_config_flow.py new file mode 100644 index 00000000000..e75cf31eb98 --- /dev/null +++ b/tests/components/eheimdigital/test_config_flow.py @@ -0,0 +1,212 @@ +"""Tests the config flow of EHEIM Digital.""" + +from ipaddress import ip_address +from unittest.mock import AsyncMock, MagicMock, patch + +from aiohttp import ClientConnectionError +import pytest + +from homeassistant.components.eheimdigital.const import DOMAIN +from homeassistant.components.zeroconf import ZeroconfServiceInfo +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +ZEROCONF_DISCOVERY = ZeroconfServiceInfo( + ip_address=ip_address("192.0.2.1"), + ip_addresses=[ip_address("192.0.2.1")], + hostname="eheimdigital.local.", + name="eheimdigital._http._tcp.local.", + port=80, + type="_http._tcp.local.", + properties={}, +) + +USER_INPUT = {CONF_HOST: "eheimdigital"} + + +@patch("homeassistant.components.eheimdigital.config_flow.asyncio.Event", new=AsyncMock) +async def test_full_flow(hass: HomeAssistant, eheimdigital_hub_mock: AsyncMock) -> None: + """Test full flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == USER_INPUT[CONF_HOST] + assert result["data"] == USER_INPUT + assert ( + result["result"].unique_id + == eheimdigital_hub_mock.return_value.main.mac_address + ) + + +@patch("homeassistant.components.eheimdigital.config_flow.asyncio.Event", new=AsyncMock) +@pytest.mark.parametrize( + ("side_effect", "error_value"), + [(ClientConnectionError(), "cannot_connect"), (Exception(), "unknown")], +) +async def test_flow_errors( + hass: HomeAssistant, + eheimdigital_hub_mock: AsyncMock, + side_effect: BaseException, + error_value: str, +) -> None: + """Test flow errors.""" + eheimdigital_hub_mock.return_value.connect.side_effect = side_effect + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error_value} + + eheimdigital_hub_mock.return_value.connect.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == USER_INPUT[CONF_HOST] + assert result["data"] == USER_INPUT + assert ( + result["result"].unique_id + == eheimdigital_hub_mock.return_value.main.mac_address + ) + + +@patch("homeassistant.components.eheimdigital.config_flow.asyncio.Event", new=AsyncMock) +async def test_zeroconf_flow( + hass: HomeAssistant, eheimdigital_hub_mock: AsyncMock +) -> None: + """Test zeroconf flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == ZEROCONF_DISCOVERY.host + assert result["data"] == { + CONF_HOST: ZEROCONF_DISCOVERY.host, + } + assert ( + result["result"].unique_id + == eheimdigital_hub_mock.return_value.main.mac_address + ) + + +@pytest.mark.parametrize( + ("side_effect", "error_value"), + [(ClientConnectionError(), "cannot_connect"), (Exception(), "unknown")], +) +@patch("homeassistant.components.eheimdigital.config_flow.asyncio.Event", new=AsyncMock) +async def test_zeroconf_flow_errors( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + side_effect: BaseException, + error_value: str, +) -> None: + """Test zeroconf flow errors.""" + eheimdigital_hub_mock.return_value.connect.side_effect = side_effect + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == error_value + + +@patch("homeassistant.components.eheimdigital.config_flow.asyncio.Event", new=AsyncMock) +async def test_abort(hass: HomeAssistant, eheimdigital_hub_mock: AsyncMock) -> None: + """Test flow abort on matching data or unique_id.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == USER_INPUT[CONF_HOST] + assert result["data"] == USER_INPUT + assert ( + result["result"].unique_id + == eheimdigital_hub_mock.return_value.main.mac_address + ) + + result2 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + await hass.async_block_till_done() + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "user" + + result2 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + USER_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "already_configured" + + result3 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + await hass.async_block_till_done() + assert result3["type"] is FlowResultType.FORM + assert result3["step_id"] == "user" + + result2 = await hass.config_entries.flow.async_configure( + result3["flow_id"], + {CONF_HOST: "eheimdigital2"}, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "already_configured" diff --git a/tests/components/eheimdigital/test_init.py b/tests/components/eheimdigital/test_init.py new file mode 100644 index 00000000000..211a8b3b6fd --- /dev/null +++ b/tests/components/eheimdigital/test_init.py @@ -0,0 +1,55 @@ +"""Tests for the init module.""" + +from unittest.mock import MagicMock + +from eheimdigital.types import EheimDeviceType + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry +from tests.typing import WebSocketGenerator + + +async def test_remove_device( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test removing a device.""" + assert await async_setup_component(hass, "config", {}) + 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:01", EheimDeviceType.VERSION_EHEIM_CLASSIC_LED_CTRL_PLUS_E + ) + await hass.async_block_till_done() + + mac_address: str = eheimdigital_hub_mock.return_value.main.mac_address + + device_entry = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, mac_address)}, + ) + assert device_entry is not None + + hass_client = await hass_ws_client(hass) + + # Do not allow to delete a connected device + response = await hass_client.remove_device( + device_entry.id, mock_config_entry.entry_id + ) + assert not response["success"] + + eheimdigital_hub_mock.return_value.devices = {} + + # Allow to delete a not connected device + response = await hass_client.remove_device( + device_entry.id, mock_config_entry.entry_id + ) + assert response["success"] diff --git a/tests/components/eheimdigital/test_light.py b/tests/components/eheimdigital/test_light.py new file mode 100644 index 00000000000..da224979c43 --- /dev/null +++ b/tests/components/eheimdigital/test_light.py @@ -0,0 +1,249 @@ +"""Tests for the light module.""" + +from datetime import timedelta +from unittest.mock import MagicMock, patch + +from aiohttp import ClientError +from eheimdigital.types import EheimDeviceType, LightMode +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.eheimdigital.const import EFFECT_DAYCL_MODE +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_EFFECT, + DOMAIN as LIGHT_DOMAIN, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_UNAVAILABLE, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.util.color import value_to_brightness + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.mark.parametrize( + "tankconfig", + [ + [["CLASSIC_DAYLIGHT"], []], + [[], ["CLASSIC_DAYLIGHT"]], + [["CLASSIC_DAYLIGHT"], ["CLASSIC_DAYLIGHT"]], + ], +) +async def test_setup_classic_led_ctrl( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + tankconfig: list[list[str]], + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + classic_led_ctrl_mock: MagicMock, +) -> None: + """Test light platform setup with different channels.""" + mock_config_entry.add_to_hass(hass) + + classic_led_ctrl_mock.tankconfig = tankconfig + + with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.LIGHT]): + 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:01", EheimDeviceType.VERSION_EHEIM_CLASSIC_LED_CTRL_PLUS_E + ) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_dynamic_new_devices( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + classic_led_ctrl_mock: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """Test light platform setup with at first no devices and dynamically adding a device.""" + mock_config_entry.add_to_hass(hass) + + eheimdigital_hub_mock.return_value.devices = {} + + with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.LIGHT]): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert ( + len( + entity_registry.entities.get_entries_for_config_entry_id( + mock_config_entry.entry_id + ) + ) + == 0 + ) + + eheimdigital_hub_mock.return_value.devices = { + "00:00:00:00:00:01": classic_led_ctrl_mock + } + + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + "00:00:00:00:00:01", EheimDeviceType.VERSION_EHEIM_CLASSIC_LED_CTRL_PLUS_E + ) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("eheimdigital_hub_mock") +async def test_turn_off( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + classic_led_ctrl_mock: MagicMock, +) -> None: + """Test turning off the light.""" + mock_config_entry.add_to_hass(hass) + + with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.LIGHT]): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await mock_config_entry.runtime_data._async_device_found( + "00:00:00:00:00:01", EheimDeviceType.VERSION_EHEIM_CLASSIC_LED_CTRL_PLUS_E + ) + await hass.async_block_till_done() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.mock_classicledcontrol_e_channel_0"}, + blocking=True, + ) + + classic_led_ctrl_mock.set_light_mode.assert_awaited_once_with(LightMode.MAN_MODE) + classic_led_ctrl_mock.turn_off.assert_awaited_once_with(0) + + +@pytest.mark.parametrize( + ("dim_input", "expected_dim_value"), + [ + (3, 1), + (255, 100), + (128, 50), + ], +) +async def test_turn_on_brightness( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + classic_led_ctrl_mock: MagicMock, + dim_input: int, + expected_dim_value: int, +) -> None: + """Test turning on the light with different brightness values.""" + mock_config_entry.add_to_hass(hass) + + with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.LIGHT]): + 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:01", EheimDeviceType.VERSION_EHEIM_CLASSIC_LED_CTRL_PLUS_E + ) + await hass.async_block_till_done() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "light.mock_classicledcontrol_e_channel_0", + ATTR_BRIGHTNESS: dim_input, + }, + blocking=True, + ) + + classic_led_ctrl_mock.set_light_mode.assert_awaited_once_with(LightMode.MAN_MODE) + classic_led_ctrl_mock.turn_on.assert_awaited_once_with(expected_dim_value, 0) + + +async def test_turn_on_effect( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + classic_led_ctrl_mock: MagicMock, +) -> None: + """Test turning on the light with an effect value.""" + mock_config_entry.add_to_hass(hass) + + classic_led_ctrl_mock.light_mode = LightMode.MAN_MODE + + with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.LIGHT]): + 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:01", EheimDeviceType.VERSION_EHEIM_CLASSIC_LED_CTRL_PLUS_E + ) + await hass.async_block_till_done() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "light.mock_classicledcontrol_e_channel_0", + ATTR_EFFECT: EFFECT_DAYCL_MODE, + }, + blocking=True, + ) + + classic_led_ctrl_mock.set_light_mode.assert_awaited_once_with(LightMode.DAYCL_MODE) + + +async def test_state_update( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + classic_led_ctrl_mock: MagicMock, +) -> None: + """Test the light state update.""" + mock_config_entry.add_to_hass(hass) + + with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.LIGHT]): + 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:01", EheimDeviceType.VERSION_EHEIM_CLASSIC_LED_CTRL_PLUS_E + ) + await hass.async_block_till_done() + + classic_led_ctrl_mock.light_level = (20, 30) + + await eheimdigital_hub_mock.call_args.kwargs["receive_callback"]() + + assert (state := hass.states.get("light.mock_classicledcontrol_e_channel_0")) + assert state.attributes["brightness"] == value_to_brightness((1, 100), 20) + + +async def test_update_failed( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test an failed update.""" + mock_config_entry.add_to_hass(hass) + + with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.LIGHT]): + 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:01", EheimDeviceType.VERSION_EHEIM_CLASSIC_LED_CTRL_PLUS_E + ) + await hass.async_block_till_done() + + eheimdigital_hub_mock.return_value.update.side_effect = ClientError + + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + hass.states.get("light.mock_classicledcontrol_e_channel_0").state + == STATE_UNAVAILABLE + )