-
-
Notifications
You must be signed in to change notification settings - Fork 37.4k
Add Ouman EH-800 heating controller integration #169733
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
base: dev
Are you sure you want to change the base?
Changes from 14 commits
93c31c5
dba8069
d5b0b13
d22e708
0965fee
5aa533e
4176cd2
c10b8c2
e1deaee
0673906
337d17d
1a9859f
c1d0929
293f607
77f22ca
140bba0
a72985f
aaaf8f8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| """The Ouman EH-800 integration.""" | ||
|
|
||
| from __future__ import annotations | ||
|
Check failure on line 3 in homeassistant/components/ouman_eh_800/__init__.py
|
||
|
|
||
| from homeassistant.const import Platform | ||
| from homeassistant.core import HomeAssistant | ||
|
|
||
| from .coordinator import OumanEh800ConfigEntry, OumanEh800Coordinator | ||
|
|
||
| _PLATFORMS: list[Platform] = [ | ||
| Platform.SENSOR, | ||
| ] | ||
|
|
||
|
|
||
| async def async_setup_entry(hass: HomeAssistant, entry: OumanEh800ConfigEntry) -> bool: | ||
| """Set up Ouman EH-800 from a config entry.""" | ||
| coordinator = OumanEh800Coordinator(hass, entry) | ||
|
|
||
| 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: OumanEh800ConfigEntry) -> bool: | ||
| """Unload a config entry.""" | ||
| return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| """Config flow for the Ouman EH-800 integration.""" | ||
|
|
||
| from __future__ import annotations | ||
|
Check failure on line 3 in homeassistant/components/ouman_eh_800/config_flow.py
|
||
|
|
||
| import logging | ||
| from typing import Any | ||
|
|
||
| from ouman_eh_800_api import ( | ||
| OumanClientAuthenticationError, | ||
| OumanClientCommunicationError, | ||
| OumanEh800Client, | ||
| ) | ||
| import voluptuous as vol | ||
| from yarl import URL | ||
|
|
||
| from homeassistant.config_entries import ConfigFlow, ConfigFlowResult | ||
| from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME | ||
| from homeassistant.helpers.aiohttp_client import async_get_clientsession | ||
|
|
||
| from .const import DOMAIN | ||
|
|
||
| _LOGGER = logging.getLogger(__name__) | ||
|
|
||
| STEP_USER_DATA_SCHEMA = vol.Schema( | ||
| { | ||
| vol.Required(CONF_URL): str, | ||
| vol.Required(CONF_USERNAME): str, | ||
| vol.Required(CONF_PASSWORD): str, | ||
| } | ||
| ) | ||
|
|
||
|
|
||
| def _normalize_url(url: str) -> str: | ||
| """Reduce URL to scheme://host[:port], discarding any path, query, or fragment.""" | ||
| return str(URL(url.strip()).origin()) | ||
|
|
||
|
|
||
| class OumanEh800ConfigFlow(ConfigFlow, domain=DOMAIN): | ||
| """Handle a config flow for Ouman EH-800.""" | ||
|
|
||
| VERSION = 1 | ||
|
|
||
| async def async_step_user( | ||
| self, user_input: dict[str, Any] | None = None | ||
| ) -> ConfigFlowResult: | ||
| """Handle the initial step.""" | ||
| errors: dict[str, str] = {} | ||
| if user_input is not None: | ||
| try: | ||
| user_input[CONF_URL] = _normalize_url(user_input[CONF_URL]) | ||
| except ValueError: | ||
| errors[CONF_URL] = "invalid_url" | ||
| else: | ||
| self._async_abort_entries_match({CONF_URL: user_input[CONF_URL]}) | ||
| client = OumanEh800Client( | ||
| session=async_get_clientsession(self.hass), | ||
| username=user_input[CONF_USERNAME], | ||
| password=user_input[CONF_PASSWORD], | ||
| address=user_input[CONF_URL], | ||
| ) | ||
| try: | ||
| await client.login() | ||
| except OumanClientCommunicationError: | ||
| errors["base"] = "cannot_connect" | ||
| except OumanClientAuthenticationError: | ||
| errors["base"] = "invalid_auth" | ||
| except Exception: | ||
| _LOGGER.exception("Unexpected exception") | ||
| errors["base"] = "unknown" | ||
| else: | ||
| return self.async_create_entry( | ||
| title="Ouman EH-800", data=user_input | ||
| ) | ||
|
Markus98 marked this conversation as resolved.
|
||
|
|
||
| return self.async_show_form( | ||
| step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors | ||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| """Constants for the Ouman EH-800 integration.""" | ||
|
|
||
| from enum import StrEnum | ||
|
|
||
| DOMAIN = "ouman_eh_800" | ||
|
Markus98 marked this conversation as resolved.
|
||
|
|
||
| DEFAULT_SCAN_INTERVAL_SECONDS = 60 | ||
|
|
||
|
|
||
| class OumanDevice(StrEnum): | ||
| """Logical device that an entity belongs to.""" | ||
|
|
||
| MAIN = "main" | ||
| L1 = "l1" | ||
| L2 = "l2" | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| """Data update coordinator for the Ouman EH-800 integration.""" | ||
|
|
||
| from datetime import timedelta | ||
| import logging | ||
|
|
||
| from ouman_eh_800_api import ( | ||
| OumanClientAuthenticationError, | ||
| OumanClientCommunicationError, | ||
| OumanEh800Client, | ||
| OumanEndpoint, | ||
| OumanRegistrySet, | ||
| OumanValues, | ||
| ) | ||
|
|
||
| from homeassistant.config_entries import ConfigEntry | ||
| from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME | ||
| from homeassistant.core import HomeAssistant | ||
| from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady | ||
| from homeassistant.helpers.aiohttp_client import async_get_clientsession | ||
| from homeassistant.helpers.device_registry import DeviceInfo | ||
| from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed | ||
|
|
||
| from .const import DEFAULT_SCAN_INTERVAL_SECONDS, DOMAIN, OumanDevice | ||
|
|
||
| _LOGGER = logging.getLogger(__name__) | ||
|
|
||
| type OumanEh800ConfigEntry = ConfigEntry[OumanEh800Coordinator] | ||
|
|
||
|
|
||
| class OumanEh800Coordinator(DataUpdateCoordinator[dict[OumanEndpoint, OumanValues]]): | ||
| """Ouman EH-800 data update coordinator.""" | ||
|
|
||
| _registry_set: OumanRegistrySet | ||
|
|
||
| def __init__( | ||
| self, | ||
| hass: HomeAssistant, | ||
| config_entry: OumanEh800ConfigEntry, | ||
| ) -> None: | ||
| """Initialize the coordinator.""" | ||
| super().__init__( | ||
| hass, | ||
| _LOGGER, | ||
| name="Ouman EH-800", | ||
| config_entry=config_entry, | ||
| update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL_SECONDS), | ||
| always_update=False, | ||
| ) | ||
| self.client: OumanEh800Client = OumanEh800Client( | ||
| session=async_get_clientsession(hass), | ||
| username=config_entry.data[CONF_USERNAME], | ||
| password=config_entry.data[CONF_PASSWORD], | ||
| address=config_entry.data[CONF_URL], | ||
| ) | ||
|
|
||
| entry_id = config_entry.entry_id | ||
| main_device_identifier = (DOMAIN, entry_id) | ||
| self.device_info: dict[OumanDevice, DeviceInfo] = { | ||
| OumanDevice.MAIN: DeviceInfo( | ||
| identifiers={main_device_identifier}, | ||
| name="Ouman EH-800", | ||
|
Markus98 marked this conversation as resolved.
|
||
| manufacturer="Ouman", | ||
| model="EH-800", | ||
| configuration_url=config_entry.data[CONF_URL], | ||
| ), | ||
| OumanDevice.L1: DeviceInfo( | ||
| identifiers={(DOMAIN, f"{entry_id}_{OumanDevice.L1}")}, | ||
| translation_key=OumanDevice.L1, | ||
| via_device=main_device_identifier, | ||
| ), | ||
| OumanDevice.L2: DeviceInfo( | ||
| identifiers={(DOMAIN, f"{entry_id}_{OumanDevice.L2}")}, | ||
| translation_key=OumanDevice.L2, | ||
| via_device=main_device_identifier, | ||
| ), | ||
| } | ||
|
|
||
| async def _async_setup(self) -> None: | ||
| try: | ||
| # Even though not required to fetch values, perform login once | ||
| # at the start to verify that the credentials are valid. | ||
| await self.client.login() | ||
| self._registry_set = await self.client.get_active_registries() | ||
| except OumanClientAuthenticationError as err: | ||
| raise ConfigEntryError("Invalid credentials") from err | ||
| except OumanClientCommunicationError as err: | ||
| raise ConfigEntryNotReady("Error communicating with API") from err | ||
|
|
||
|
Markus98 marked this conversation as resolved.
|
||
| async def _async_update_data(self) -> dict[OumanEndpoint, OumanValues]: | ||
| """Fetch registry values from the device.""" | ||
| try: | ||
| return await self.client.get_values(self._registry_set) | ||
| except OumanClientCommunicationError as err: | ||
| raise UpdateFailed("Error communicating with API") from err | ||
|
Markus98 marked this conversation as resolved.
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| """Base entity for Ouman EH-800.""" | ||
|
|
||
| from dataclasses import dataclass | ||
|
|
||
| from ouman_eh_800_api import OumanEndpoint | ||
|
|
||
| from homeassistant.helpers.entity import EntityDescription | ||
| from homeassistant.helpers.update_coordinator import CoordinatorEntity | ||
|
|
||
| from .const import OumanDevice | ||
| from .coordinator import OumanEh800Coordinator | ||
|
|
||
|
|
||
| @dataclass(frozen=True, kw_only=True) | ||
| class OumanEh800EntityDescription(EntityDescription): | ||
| """Common Ouman EH-800 entity description fields.""" | ||
|
|
||
| device: OumanDevice | ||
|
|
||
|
|
||
| class OumanEh800Entity(CoordinatorEntity[OumanEh800Coordinator]): | ||
| """Base entity for Ouman EH-800.""" | ||
|
|
||
| _attr_has_entity_name = True | ||
| entity_description: OumanEh800EntityDescription | ||
|
|
||
| def __init__( | ||
| self, | ||
| coordinator: OumanEh800Coordinator, | ||
| endpoint: OumanEndpoint, | ||
| description: OumanEh800EntityDescription, | ||
| ) -> None: | ||
| """Initialize the entity.""" | ||
| super().__init__(coordinator) | ||
| self._endpoint = endpoint | ||
| self.entity_description = description | ||
|
|
||
| assert coordinator.config_entry is not None | ||
|
Markus98 marked this conversation as resolved.
|
||
| self._attr_unique_id = ( | ||
| f"{coordinator.config_entry.entry_id}" | ||
| f"_{description.device}_{description.key}" | ||
| ) | ||
| self._attr_device_info = coordinator.device_info[description.device] | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| { | ||
| "entity": { | ||
| "sensor": { | ||
| "valve_position": { | ||
| "default": "mdi:pipe-valve" | ||
| } | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| { | ||
| "domain": "ouman_eh_800", | ||
| "name": "Ouman EH-800", | ||
| "codeowners": ["@Markus98"], | ||
| "config_flow": true, | ||
| "documentation": "https://www.home-assistant.io/integrations/ouman_eh_800", | ||
| "integration_type": "device", | ||
| "iot_class": "local_polling", | ||
| "quality_scale": "bronze", | ||
| "requirements": ["ouman-eh-800-api==0.5.0"] | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,76 @@ | ||
| rules: | ||
| # Bronze | ||
| action-setup: | ||
| status: exempt | ||
| comment: Integration does not provide actions. | ||
| appropriate-polling: done | ||
| brands: done | ||
| common-modules: done | ||
| config-flow-test-coverage: done | ||
| config-flow: done | ||
| dependency-transparency: done | ||
| docs-actions: | ||
| status: exempt | ||
| comment: Integration does not provide actions. | ||
| docs-high-level-description: done | ||
| docs-installation-instructions: done | ||
| docs-removal-instructions: done | ||
| entity-event-setup: | ||
| status: exempt | ||
| comment: Integration does not use events. | ||
| 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: Integration does not provide actions. | ||
| config-entry-unloading: done | ||
| docs-configuration-parameters: done | ||
| docs-installation-parameters: done | ||
| entity-unavailable: done | ||
| integration-owner: done | ||
| log-when-unavailable: done | ||
| parallel-updates: done | ||
| reauthentication-flow: todo | ||
| test-coverage: todo | ||
|
|
||
| # Gold | ||
| devices: done | ||
| diagnostics: todo | ||
| discovery-update-info: | ||
| status: exempt | ||
| comment: Integration is local polling only, no discovery. | ||
| discovery: | ||
| status: exempt | ||
| comment: Integration is local polling only, no discovery. | ||
|
Markus98 marked this conversation as resolved.
|
||
| docs-data-update: done | ||
| docs-examples: todo | ||
| docs-known-limitations: done | ||
| docs-supported-devices: done | ||
| docs-supported-functions: done | ||
| docs-troubleshooting: todo | ||
| docs-use-cases: todo | ||
| dynamic-devices: | ||
| status: exempt | ||
| comment: Integration supports a single device per config entry. | ||
| entity-category: done | ||
| entity-device-class: done | ||
| entity-disabled-by-default: done | ||
| entity-translations: done | ||
| exception-translations: todo | ||
| icon-translations: done | ||
| reconfiguration-flow: todo | ||
| repair-issues: todo | ||
| stale-devices: | ||
| status: exempt | ||
| comment: Integration supports a single device per config entry. | ||
|
|
||
| # Platinum | ||
| async-dependency: done | ||
| inject-websession: done | ||
| strict-typing: done | ||
Uh oh!
There was an error while loading. Please reload this page.