Add eheimdigital integration (#126757)

Co-authored-by: Franck Nijhof <git@frenck.dev>
This commit is contained in:
Sid
2024-12-13 22:29:18 +01:00
committed by GitHub
parent f06fda8023
commit 0c8db8c8d6
23 changed files with 1498 additions and 0 deletions

View File

@ -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.*

View File

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

View File

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

View File

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

View File

@ -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,
}

View File

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

View File

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

View File

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

View File

@ -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." }
]
}

View File

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

View File

@ -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"
}
}
}
}
}
}
}

View File

@ -155,6 +155,7 @@ FLOWS = {
"ecowitt",
"edl21",
"efergy",
"eheimdigital",
"electrasmart",
"electric_kiwi",
"elevenlabs",

View File

@ -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",

View File

@ -524,6 +524,10 @@ ZEROCONF = {
"domain": "bosch_shc",
"name": "bosch shc*",
},
{
"domain": "eheimdigital",
"name": "eheimdigital._http._tcp.local.",
},
{
"domain": "lektrico",
"name": "lektrico*",

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@
"""Tests for the EHEIM Digital integration."""

View File

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

View File

@ -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([
<ColorMode.BRIGHTNESS: 'brightness'>,
]),
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'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': <LightEntityFeature: 4>,
'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': <ColorMode.BRIGHTNESS: 'brightness'>,
'effect': 'daycl_mode',
'effect_list': list([
'daycl_mode',
]),
'friendly_name': 'Mock classicLEDcontrol+e Channel 0',
'supported_color_modes': list([
<ColorMode.BRIGHTNESS: 'brightness'>,
]),
'supported_features': <LightEntityFeature: 4>,
}),
'context': <ANY>,
'entity_id': 'light.mock_classicledcontrol_e_channel_0',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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([
<ColorMode.BRIGHTNESS: 'brightness'>,
]),
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'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': <LightEntityFeature: 4>,
'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': <ColorMode.BRIGHTNESS: 'brightness'>,
'effect': 'daycl_mode',
'effect_list': list([
'daycl_mode',
]),
'friendly_name': 'Mock classicLEDcontrol+e Channel 0',
'supported_color_modes': list([
<ColorMode.BRIGHTNESS: 'brightness'>,
]),
'supported_features': <LightEntityFeature: 4>,
}),
'context': <ANY>,
'entity_id': 'light.mock_classicledcontrol_e_channel_0',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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([
<ColorMode.BRIGHTNESS: 'brightness'>,
]),
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'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': <LightEntityFeature: 4>,
'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': <ColorMode.BRIGHTNESS: 'brightness'>,
'effect': 'daycl_mode',
'effect_list': list([
'daycl_mode',
]),
'friendly_name': 'Mock classicLEDcontrol+e Channel 1',
'supported_color_modes': list([
<ColorMode.BRIGHTNESS: 'brightness'>,
]),
'supported_features': <LightEntityFeature: 4>,
}),
'context': <ANY>,
'entity_id': 'light.mock_classicledcontrol_e_channel_1',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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([
<ColorMode.BRIGHTNESS: 'brightness'>,
]),
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'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': <LightEntityFeature: 4>,
'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': <ColorMode.BRIGHTNESS: 'brightness'>,
'effect': 'daycl_mode',
'effect_list': list([
'daycl_mode',
]),
'friendly_name': 'Mock classicLEDcontrol+e Channel 0',
'supported_color_modes': list([
<ColorMode.BRIGHTNESS: 'brightness'>,
]),
'supported_features': <LightEntityFeature: 4>,
}),
'context': <ANY>,
'entity_id': 'light.mock_classicledcontrol_e_channel_0',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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([
<ColorMode.BRIGHTNESS: 'brightness'>,
]),
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'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': <LightEntityFeature: 4>,
'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': <ColorMode.BRIGHTNESS: 'brightness'>,
'effect': 'daycl_mode',
'effect_list': list([
'daycl_mode',
]),
'friendly_name': 'Mock classicLEDcontrol+e Channel 1',
'supported_color_modes': list([
<ColorMode.BRIGHTNESS: 'brightness'>,
]),
'supported_features': <LightEntityFeature: 4>,
}),
'context': <ANY>,
'entity_id': 'light.mock_classicledcontrol_e_channel_1',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---

View File

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

View File

@ -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"]

View File

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