-
-
Notifications
You must be signed in to change notification settings - Fork 30.9k
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
autinerd
wants to merge
14
commits into
home-assistant:dev
Choose a base branch
from
autinerd:eheimdigital
base: dev
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Add eheimdigital integration #126757
Changes from 12 commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
7f2fbe0
Add eheimdigital integration
autinerd 6c8dd69
Add config flow tests
autinerd 1d7d9fc
Full config flow; complete tests; base entity
autinerd 888f67f
Merge branch 'dev' into eheimdigital
autinerd 4809fda
fix tests
autinerd 5249cbe
Update lib; Use callbacks to add new devices and entites; Apply revie…
autinerd f79393a
More applying review comments
autinerd 943f174
Add strict typing
autinerd 5d9b456
Review comments addressed
autinerd 3a107ff
Review comments addressed
autinerd cf6800c
Reviews
autinerd 935ba22
Add quality scale, coordinator UpdateFailed, manual removal of devices
autinerd 0fdd596
Review
autinerd dfbfe1c
Review
autinerd File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Validating CODEOWNERS rules …
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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( | ||
identifier | ||
for identifier in device_entry.identifiers | ||
if identifier[0] == DOMAIN and identifier[1] in config_entry.runtime_data.data | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
|
||
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, | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
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 | ||
return self.data |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.