Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add eheimdigital integration #126757

Draft
wants to merge 14 commits into
base: dev
Choose a base branch
from
1 change: 1 addition & 0 deletions .strict-typing
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,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.*
Expand Down
2 changes: 2 additions & 0 deletions CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,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
Expand Down
50 changes: 50 additions & 0 deletions homeassistant/components/eheimdigital/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""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(

Check warning on line 46 in homeassistant/components/eheimdigital/__init__.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/eheimdigital/__init__.py#L46

Added line #L46 was not covered by tests
identifier
for identifier in device_entry.identifiers
if identifier[0] == DOMAIN and identifier[1] in config_entry.runtime_data.data
)
127 changes: 127 additions & 0 deletions homeassistant/components/eheimdigital/config_flow.py
Original file line number Diff line number Diff line change
@@ -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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the unique_id check is good enough

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It has the advantage of early return without needing to connect, especially in this case where only one device is advertising via zeroconf and so when it found one it only changes when the IP address changes or the user resets the devices and connect the devices to the network from scratch.


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"
autinerd marked this conversation as resolved.
Show resolved Hide resolved
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,
)
17 changes: 17 additions & 0 deletions homeassistant/components/eheimdigital/const.py
Original file line number Diff line number Diff line change
@@ -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,
}
81 changes: 81 additions & 0 deletions homeassistant/components/eheimdigital/coordinator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""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."""

platform_callbacks: set[AsyncSetupDeviceEntitiesCallback]
config_entry: ConfigEntry
hub: EheimDigitalHub
known_devices: set[str]
autinerd marked this conversation as resolved.
Show resolved Hide resolved

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()
self.platform_callbacks = 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)

Check warning on line 67 in homeassistant/components/eheimdigital/coordinator.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/eheimdigital/coordinator.py#L66-L67

Added lines #L66 - L67 were not covered by tests
autinerd marked this conversation as resolved.
Show resolved Hide resolved

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

Check warning on line 80 in homeassistant/components/eheimdigital/coordinator.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/eheimdigital/coordinator.py#L79-L80

Added lines #L79 - L80 were not covered by tests
return self.data
53 changes: 53 additions & 0 deletions homeassistant/components/eheimdigital/entity.py
Original file line number Diff line number Diff line change
@@ -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)
autinerd marked this conversation as resolved.
Show resolved Hide resolved
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()
Loading