mirror of
https://github.com/home-assistant/core.git
synced 2026-08-24 10:13:52 -05:00
Add Ouman EH-800 heating controller integration (#169733)
This commit is contained in:
@@ -423,6 +423,7 @@ homeassistant.components.opower.*
|
||||
homeassistant.components.oralb.*
|
||||
homeassistant.components.otbr.*
|
||||
homeassistant.components.otp.*
|
||||
homeassistant.components.ouman_eh_800.*
|
||||
homeassistant.components.overkiz.*
|
||||
homeassistant.components.overseerr.*
|
||||
homeassistant.components.p1_monitor.*
|
||||
|
||||
Generated
+2
@@ -1305,6 +1305,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/osoenergy/ @osohotwateriot
|
||||
/homeassistant/components/otbr/ @home-assistant/core
|
||||
/tests/components/otbr/ @home-assistant/core
|
||||
/homeassistant/components/ouman_eh_800/ @Markus98
|
||||
/tests/components/ouman_eh_800/ @Markus98
|
||||
/homeassistant/components/ourgroceries/ @OnFreund
|
||||
/tests/components/ourgroceries/ @OnFreund
|
||||
/homeassistant/components/overkiz/ @imicknl
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""The Ouman EH-800 integration."""
|
||||
|
||||
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()
|
||||
coordinator.sync_circuit_device_names()
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Config flow for the Ouman EH-800 integration."""
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
STEP_USER_DATA_SCHEMA, user_input
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Constants for the Ouman EH-800 integration."""
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
DOMAIN = "ouman_eh_800"
|
||||
|
||||
DEFAULT_SCAN_INTERVAL_SECONDS = 60
|
||||
|
||||
|
||||
class OumanDevice(StrEnum):
|
||||
"""Logical device that an entity belongs to."""
|
||||
|
||||
MAIN = "main"
|
||||
L1 = "l1"
|
||||
L2 = "l2"
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Data update coordinator for the Ouman EH-800 integration."""
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from ouman_eh_800_api import (
|
||||
L1BaseEndpoints,
|
||||
L2BaseEndpoints,
|
||||
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
|
||||
config_entry: OumanEh800ConfigEntry
|
||||
|
||||
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},
|
||||
manufacturer="Ouman",
|
||||
model="EH-800",
|
||||
configuration_url=config_entry.data[CONF_URL],
|
||||
),
|
||||
OumanDevice.L1: DeviceInfo(
|
||||
identifiers={(DOMAIN, f"{entry_id}_{OumanDevice.L1}")},
|
||||
translation_key="heating_circuit",
|
||||
translation_placeholders={"circuit_number": "1"},
|
||||
via_device=main_device_identifier,
|
||||
),
|
||||
OumanDevice.L2: DeviceInfo(
|
||||
identifiers={(DOMAIN, f"{entry_id}_{OumanDevice.L2}")},
|
||||
translation_key="heating_circuit",
|
||||
translation_placeholders={"circuit_number": "2"},
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
def sync_circuit_device_names(self) -> None:
|
||||
"""Set the device-reported circuit names for the L1/L2 sub-device names.
|
||||
|
||||
Should be called after the data update so that platforms register
|
||||
L1/L2 devices with the resolved names.
|
||||
"""
|
||||
for device, endpoint, circuit_number in (
|
||||
(OumanDevice.L1, L1BaseEndpoints.CIRCUIT_NAME, "1"),
|
||||
(OumanDevice.L2, L2BaseEndpoints.CIRCUIT_NAME, "2"),
|
||||
):
|
||||
if circuit_name := self.data.get(endpoint):
|
||||
assert isinstance(circuit_name, str)
|
||||
device_info = self.device_info[device]
|
||||
device_info["translation_key"] = "heating_circuit_with_name"
|
||||
device_info["translation_placeholders"] = {
|
||||
"circuit_number": circuit_number,
|
||||
"circuit_name": circuit_name,
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"""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
|
||||
|
||||
self._attr_unique_id = (
|
||||
f"{coordinator.config_entry.entry_id}"
|
||||
f"_{description.device}_{description.key}"
|
||||
)
|
||||
self._attr_device_info = coordinator.device_info[description.device]
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"valve_position": {
|
||||
"default": "mdi:pipe-valve"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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.
|
||||
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
|
||||
@@ -0,0 +1,186 @@
|
||||
"""Sensor platform for the Ouman EH-800 integration."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from ouman_eh_800_api import (
|
||||
L1BaseEndpoints,
|
||||
L1RoomSensor,
|
||||
L2BaseEndpoints,
|
||||
L2RoomSensor,
|
||||
OumanEndpoint,
|
||||
SystemEndpoints,
|
||||
)
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import OumanDevice
|
||||
from .coordinator import OumanEh800ConfigEntry
|
||||
from .entity import OumanEh800Entity, OumanEh800EntityDescription
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class OumanEh800SensorDescription(OumanEh800EntityDescription, SensorEntityDescription):
|
||||
"""Sensor description with main/L1/L2 device assignment."""
|
||||
|
||||
|
||||
def _temperature_sensor(
|
||||
*,
|
||||
device: OumanDevice,
|
||||
key: str,
|
||||
device_class: SensorDeviceClass = SensorDeviceClass.TEMPERATURE,
|
||||
entity_category: EntityCategory | None = None,
|
||||
enabled_by_default: bool = True,
|
||||
) -> OumanEh800SensorDescription:
|
||||
return OumanEh800SensorDescription(
|
||||
device=device,
|
||||
key=key,
|
||||
translation_key=key,
|
||||
device_class=device_class,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
suggested_display_precision=1,
|
||||
entity_category=entity_category,
|
||||
entity_registry_enabled_default=enabled_by_default,
|
||||
)
|
||||
|
||||
|
||||
def _percentage_sensor(
|
||||
*,
|
||||
device: OumanDevice,
|
||||
key: str,
|
||||
) -> OumanEh800SensorDescription:
|
||||
return OumanEh800SensorDescription(
|
||||
device=device,
|
||||
key=key,
|
||||
translation_key=key,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
suggested_display_precision=1,
|
||||
)
|
||||
|
||||
|
||||
SENSOR_DESCRIPTIONS: dict[OumanEndpoint, OumanEh800SensorDescription] = {
|
||||
SystemEndpoints.OUTSIDE_TEMPERATURE: _temperature_sensor(
|
||||
device=OumanDevice.MAIN, key="outside_temperature"
|
||||
),
|
||||
L1BaseEndpoints.SUPPLY_WATER_TEMPERATURE: _temperature_sensor(
|
||||
device=OumanDevice.L1, key="supply_water_temperature"
|
||||
),
|
||||
L1BaseEndpoints.VALVE_POSITION: _percentage_sensor(
|
||||
device=OumanDevice.L1, key="valve_position"
|
||||
),
|
||||
L1BaseEndpoints.SUPPLY_WATER_TEMPERATURE_SETPOINT: _temperature_sensor(
|
||||
device=OumanDevice.L1,
|
||||
key="supply_water_temperature_setpoint",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
L1BaseEndpoints.CURVE_SUPPLY_WATER_TEMPERATURE: _temperature_sensor(
|
||||
device=OumanDevice.L1,
|
||||
key="curve_supply_water_temperature",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
enabled_by_default=False,
|
||||
),
|
||||
L1BaseEndpoints.FINE_ADJUSTMENT_EFFECT: _temperature_sensor(
|
||||
device=OumanDevice.L1,
|
||||
key="fine_adjustment_effect",
|
||||
device_class=SensorDeviceClass.TEMPERATURE_DELTA,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
enabled_by_default=False,
|
||||
),
|
||||
L1RoomSensor.ROOM_TEMPERATURE: _temperature_sensor(
|
||||
device=OumanDevice.L1, key="room_temperature"
|
||||
),
|
||||
L1RoomSensor.ROOM_TEMPERATURE_SETPOINT: _temperature_sensor(
|
||||
device=OumanDevice.L1,
|
||||
key="room_temperature_setpoint",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
L1RoomSensor.DELAYED_ROOM_TEMPERATURE: _temperature_sensor(
|
||||
device=OumanDevice.L1,
|
||||
key="delayed_room_temperature",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
enabled_by_default=False,
|
||||
),
|
||||
L1RoomSensor.ROOM_SENSOR_POTENTIOMETER: _temperature_sensor(
|
||||
device=OumanDevice.L1,
|
||||
key="room_sensor_potentiometer",
|
||||
device_class=SensorDeviceClass.TEMPERATURE_DELTA,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
enabled_by_default=False,
|
||||
),
|
||||
L2BaseEndpoints.SUPPLY_WATER_TEMPERATURE: _temperature_sensor(
|
||||
device=OumanDevice.L2, key="supply_water_temperature"
|
||||
),
|
||||
L2BaseEndpoints.VALVE_POSITION: _percentage_sensor(
|
||||
device=OumanDevice.L2, key="valve_position"
|
||||
),
|
||||
L2BaseEndpoints.SUPPLY_WATER_TEMPERATURE_SETPOINT: _temperature_sensor(
|
||||
device=OumanDevice.L2,
|
||||
key="supply_water_temperature_setpoint",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
L2BaseEndpoints.CURVE_SUPPLY_WATER_TEMPERATURE: _temperature_sensor(
|
||||
device=OumanDevice.L2,
|
||||
key="curve_supply_water_temperature",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
enabled_by_default=False,
|
||||
),
|
||||
L2BaseEndpoints.DELAYED_OUTDOOR_TEMPERATURE_EFFECT: _temperature_sensor(
|
||||
device=OumanDevice.L2,
|
||||
key="delayed_outdoor_temperature_effect",
|
||||
device_class=SensorDeviceClass.TEMPERATURE_DELTA,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
enabled_by_default=False,
|
||||
),
|
||||
L2RoomSensor.ROOM_TEMPERATURE: _temperature_sensor(
|
||||
device=OumanDevice.L2, key="room_temperature"
|
||||
),
|
||||
L2RoomSensor.ROOM_TEMPERATURE_SETPOINT: _temperature_sensor(
|
||||
device=OumanDevice.L2,
|
||||
key="room_temperature_setpoint",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
L2RoomSensor.DELAYED_ROOM_TEMPERATURE: _temperature_sensor(
|
||||
device=OumanDevice.L2,
|
||||
key="delayed_room_temperature",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
enabled_by_default=False,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: OumanEh800ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Ouman EH-800 sensors based on a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
async_add_entities(
|
||||
OumanEh800SensorEntity(coordinator, endpoint, description)
|
||||
for endpoint in coordinator.data
|
||||
if (description := SENSOR_DESCRIPTIONS.get(endpoint)) is not None
|
||||
)
|
||||
|
||||
|
||||
class OumanEh800SensorEntity(OumanEh800Entity, SensorEntity):
|
||||
"""Ouman EH-800 sensor entity."""
|
||||
|
||||
entity_description: OumanEh800SensorDescription
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | str:
|
||||
"""Return the current sensor value."""
|
||||
value = self.coordinator.data[self._endpoint]
|
||||
assert isinstance(value, float | str)
|
||||
return value
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"invalid_url": "Invalid URL",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"url": "[%key:common::config_flow::data::url%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"data_description": {
|
||||
"password": "Password for the Ouman EH-800 web interface",
|
||||
"url": "The URL of the Ouman EH-800 web interface",
|
||||
"username": "Username for the Ouman EH-800 web interface"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"device": {
|
||||
"heating_circuit": { "name": "Heating circuit {circuit_number}" },
|
||||
"heating_circuit_with_name": {
|
||||
"name": "Heating circuit {circuit_number} {circuit_name}"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"curve_supply_water_temperature": {
|
||||
"name": "Curve supply water temperature"
|
||||
},
|
||||
"delayed_outdoor_temperature_effect": {
|
||||
"name": "Delayed outdoor temperature effect"
|
||||
},
|
||||
"delayed_room_temperature": { "name": "Delayed room temperature" },
|
||||
"fine_adjustment_effect": { "name": "Fine adjustment effect" },
|
||||
"outside_temperature": { "name": "Outside temperature" },
|
||||
"room_sensor_potentiometer": { "name": "Room sensor potentiometer" },
|
||||
"room_temperature": { "name": "Room temperature" },
|
||||
"room_temperature_setpoint": { "name": "Room temperature setpoint" },
|
||||
"supply_water_temperature": { "name": "Supply water temperature" },
|
||||
"supply_water_temperature_setpoint": {
|
||||
"name": "Supply water temperature setpoint"
|
||||
},
|
||||
"valve_position": { "name": "Valve position" }
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+1
@@ -542,6 +542,7 @@ FLOWS = {
|
||||
"orvibo",
|
||||
"osoenergy",
|
||||
"otbr",
|
||||
"ouman_eh_800",
|
||||
"ourgroceries",
|
||||
"overkiz",
|
||||
"overseerr",
|
||||
|
||||
@@ -5156,6 +5156,12 @@
|
||||
"config_flow": true,
|
||||
"iot_class": "local_polling"
|
||||
},
|
||||
"ouman_eh_800": {
|
||||
"name": "Ouman EH-800",
|
||||
"integration_type": "device",
|
||||
"config_flow": true,
|
||||
"iot_class": "local_polling"
|
||||
},
|
||||
"ourgroceries": {
|
||||
"name": "OurGroceries",
|
||||
"integration_type": "service",
|
||||
|
||||
@@ -3987,6 +3987,16 @@ disallow_untyped_defs = true
|
||||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.ouman_eh_800.*]
|
||||
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.overkiz.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
|
||||
Generated
+3
@@ -1777,6 +1777,9 @@ oru==0.1.11
|
||||
# homeassistant.components.orvibo
|
||||
orvibo==1.1.2
|
||||
|
||||
# homeassistant.components.ouman_eh_800
|
||||
ouman-eh-800-api==0.5.0
|
||||
|
||||
# homeassistant.components.ourgroceries
|
||||
ourgroceries==1.5.4
|
||||
|
||||
|
||||
Generated
+3
@@ -1557,6 +1557,9 @@ oralb-ble==1.1.0
|
||||
# homeassistant.components.orvibo
|
||||
orvibo==1.1.2
|
||||
|
||||
# homeassistant.components.ouman_eh_800
|
||||
ouman-eh-800-api==0.5.0
|
||||
|
||||
# homeassistant.components.ourgroceries
|
||||
ourgroceries==1.5.4
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for the Ouman EH-800 integration."""
|
||||
@@ -0,0 +1,260 @@
|
||||
"""Common fixtures for the Ouman EH-800 tests."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from contextlib import nullcontext
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from ouman_eh_800_api import (
|
||||
HomeAwayControl,
|
||||
L1BaseEndpoints,
|
||||
L1ConstantTempMode,
|
||||
L1FivePointCurve,
|
||||
L1NoRoomSensor,
|
||||
L1RoomSensor,
|
||||
L1ThreePointCurve,
|
||||
L2BaseEndpoints,
|
||||
L2FivePointCurve,
|
||||
L2NoRoomSensor,
|
||||
L2RoomSensor,
|
||||
L2ThreePointCurve,
|
||||
OperationMode,
|
||||
OumanEndpoint,
|
||||
OumanRegistry,
|
||||
OumanRegistrySet,
|
||||
OumanValues,
|
||||
PumpSummerStopControl,
|
||||
RelayControl,
|
||||
RelayL1ValvePosition,
|
||||
RelayPumpSummerStop,
|
||||
RelayTempDifference,
|
||||
RelayTemperature,
|
||||
RelayTimeProgram,
|
||||
SystemEndpoints,
|
||||
)
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.ouman_eh_800.const import DOMAIN
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
TEST_URL = "http://192.168.1.100"
|
||||
TEST_USERNAME = "test-user"
|
||||
TEST_PASSWORD = "test-pass"
|
||||
|
||||
# Realistic value for every endpoint the API can return. Each scenario picks
|
||||
# the subset for its registries, so a single endpoint is defined here once.
|
||||
_ENDPOINT_VALUES: dict[OumanEndpoint, OumanValues] = {
|
||||
# System
|
||||
SystemEndpoints.TREND_SAMPLE_INTERVAL: 600.0,
|
||||
SystemEndpoints.HOME_AWAY_MODE: HomeAwayControl.HOME,
|
||||
SystemEndpoints.OUTSIDE_TEMPERATURE: 0.4,
|
||||
SystemEndpoints.RELAY_CONFIGURATION_TYPE: "",
|
||||
SystemEndpoints.RELAY_STATUS_TEXT: "Rele ei käytössä",
|
||||
SystemEndpoints.L2_INSTALLED_STATUS: "1",
|
||||
# L1 base
|
||||
L1BaseEndpoints.OPERATION_MODE: OperationMode.AUTOMATIC,
|
||||
L1BaseEndpoints.VALVE_POSITION_SETPOINT: 0.0,
|
||||
L1BaseEndpoints.WATER_OUT_MIN_TEMP: 12.0,
|
||||
L1BaseEndpoints.WATER_OUT_MAX_TEMP: 75.0,
|
||||
L1BaseEndpoints.TEMPERATURE_LEVEL_STATUS_TEXT: "L1 Normaalilämpö",
|
||||
L1BaseEndpoints.CIRCUIT_NAME: "Patterilämmitys",
|
||||
L1BaseEndpoints.SUPPLY_WATER_TEMPERATURE: 39.1,
|
||||
L1BaseEndpoints.VALVE_POSITION: 11.0,
|
||||
L1BaseEndpoints.CURVE_SUPPLY_WATER_TEMPERATURE: 41.0,
|
||||
L1BaseEndpoints.FINE_ADJUSTMENT_EFFECT: 0.0,
|
||||
L1BaseEndpoints.SUPPLY_WATER_TEMPERATURE_SETPOINT: 43.7,
|
||||
L1BaseEndpoints.ROOM_SENSOR_INSTALLED: "off",
|
||||
# L1 three-point curve
|
||||
L1ThreePointCurve.CURVE_MINUS_20_TEMP: 58.0,
|
||||
L1ThreePointCurve.CURVE_0_TEMP: 41.0,
|
||||
L1ThreePointCurve.CURVE_20_TEMP: 18.0,
|
||||
# L1 five-point curve
|
||||
L1FivePointCurve.CURVE_MINUS_20_TEMP: 58.0,
|
||||
L1FivePointCurve.CURVE_MINUS_10_TEMP: 50.0,
|
||||
L1FivePointCurve.CURVE_0_TEMP: 41.0,
|
||||
L1FivePointCurve.CURVE_10_TEMP: 30.0,
|
||||
L1FivePointCurve.CURVE_20_TEMP: 18.0,
|
||||
# L1 no room sensor
|
||||
L1NoRoomSensor.TEMPERATURE_DROP: 6.0,
|
||||
L1NoRoomSensor.BIG_TEMPERATURE_DROP: 16.0,
|
||||
L1NoRoomSensor.ROOM_TEMPERATURE_FINE_TUNING: 0.0,
|
||||
# L1 room sensor
|
||||
L1RoomSensor.TEMPERATURE_DROP: 1.0,
|
||||
L1RoomSensor.BIG_TEMPERATURE_DROP: 3.0,
|
||||
L1RoomSensor.ROOM_TEMPERATURE_FINE_TUNING: 0.0,
|
||||
L1RoomSensor.ROOM_TEMPERATURE_SETPOINT_USER: 21.0,
|
||||
L1RoomSensor.ROOM_SENSOR_POTENTIOMETER: 0.0,
|
||||
L1RoomSensor.ROOM_TEMPERATURE: 21.5,
|
||||
L1RoomSensor.DELAYED_ROOM_TEMPERATURE: 21.4,
|
||||
L1RoomSensor.ROOM_TEMPERATURE_SETPOINT: 21.0,
|
||||
# L1 constant temp mode
|
||||
L1ConstantTempMode.CONSTANT_TEMP_SETPOINT: 50.0,
|
||||
# L2 base
|
||||
L2BaseEndpoints.OPERATION_MODE: OperationMode.AUTOMATIC,
|
||||
L2BaseEndpoints.VALVE_POSITION_SETPOINT: 0.0,
|
||||
L2BaseEndpoints.WATER_OUT_MIN_TEMP: 12.0,
|
||||
L2BaseEndpoints.WATER_OUT_MAX_TEMP: 75.0,
|
||||
L2BaseEndpoints.TEMPERATURE_LEVEL_STATUS_TEXT: "L2 Normaalilämpö",
|
||||
L2BaseEndpoints.CIRCUIT_NAME: "Lattialämmitys",
|
||||
L2BaseEndpoints.SUPPLY_WATER_TEMPERATURE: 30.0,
|
||||
L2BaseEndpoints.VALVE_POSITION: 5.0,
|
||||
L2BaseEndpoints.CURVE_SUPPLY_WATER_TEMPERATURE: 30.0,
|
||||
L2BaseEndpoints.DELAYED_OUTDOOR_TEMPERATURE_EFFECT: 0.0,
|
||||
L2BaseEndpoints.SUPPLY_WATER_TEMPERATURE_SETPOINT: 30.0,
|
||||
L2BaseEndpoints.ROOM_SENSOR_INSTALLED: "on",
|
||||
# L2 three-point curve
|
||||
L2ThreePointCurve.CURVE_MINUS_20_TEMP: 40.0,
|
||||
L2ThreePointCurve.CURVE_0_TEMP: 28.0,
|
||||
L2ThreePointCurve.CURVE_20_TEMP: 22.0,
|
||||
# L2 five-point curve
|
||||
L2FivePointCurve.CURVE_MINUS_20_TEMP: 40.0,
|
||||
L2FivePointCurve.CURVE_MINUS_10_TEMP: 35.0,
|
||||
L2FivePointCurve.CURVE_0_TEMP: 28.0,
|
||||
L2FivePointCurve.CURVE_10_TEMP: 25.0,
|
||||
L2FivePointCurve.CURVE_20_TEMP: 22.0,
|
||||
# L2 no room sensor
|
||||
L2NoRoomSensor.TEMPERATURE_DROP: 6.0,
|
||||
L2NoRoomSensor.BIG_TEMPERATURE_DROP: 16.0,
|
||||
L2NoRoomSensor.ROOM_TEMPERATURE_FINE_TUNING: 0.0,
|
||||
# L2 room sensor
|
||||
L2RoomSensor.TEMPERATURE_DROP: 1.0,
|
||||
L2RoomSensor.BIG_TEMPERATURE_DROP: 3.0,
|
||||
L2RoomSensor.ROOM_TEMPERATURE_FINE_TUNING: 0.0,
|
||||
L2RoomSensor.ROOM_TEMPERATURE_SETPOINT_USER: 21.0,
|
||||
L2RoomSensor.ROOM_TEMPERATURE: 22.0,
|
||||
L2RoomSensor.DELAYED_ROOM_TEMPERATURE: 21.9,
|
||||
L2RoomSensor.ROOM_TEMPERATURE_SETPOINT: 21.0,
|
||||
# Relay variants (mutually exclusive — at most one per scenario)
|
||||
RelayPumpSummerStop.CONTROL: PumpSummerStopControl.AUTO,
|
||||
RelayTemperature.CONTROL: RelayControl.AUTO,
|
||||
RelayTempDifference.CONTROL: RelayControl.AUTO,
|
||||
RelayL1ValvePosition.CONTROL: RelayControl.AUTO,
|
||||
RelayTimeProgram.CONTROL: RelayControl.AUTO,
|
||||
}
|
||||
|
||||
# Each scenario is a valid registry set the device may expose. Together they
|
||||
# cover every endpoint the API can return — both curve types, both room-sensor
|
||||
# variants on each channel, the additive ConstantTempMode, and all 5 relay
|
||||
# variants.
|
||||
SCENARIOS: dict[str, list[type[OumanRegistry]]] = {
|
||||
# Realistic combinations
|
||||
"room_sensors": [
|
||||
SystemEndpoints,
|
||||
L1BaseEndpoints,
|
||||
L1ThreePointCurve,
|
||||
L1RoomSensor,
|
||||
L1ConstantTempMode,
|
||||
L2BaseEndpoints,
|
||||
L2ThreePointCurve,
|
||||
L2RoomSensor,
|
||||
],
|
||||
"no_room_sensors": [
|
||||
SystemEndpoints,
|
||||
L1BaseEndpoints,
|
||||
L1FivePointCurve,
|
||||
L1NoRoomSensor,
|
||||
L2BaseEndpoints,
|
||||
L2FivePointCurve,
|
||||
L2NoRoomSensor,
|
||||
],
|
||||
"l1_constant_temp_relay_summer_stop": [
|
||||
SystemEndpoints,
|
||||
L1BaseEndpoints,
|
||||
L1ThreePointCurve,
|
||||
L1NoRoomSensor,
|
||||
L1ConstantTempMode,
|
||||
RelayPumpSummerStop,
|
||||
],
|
||||
# Minimal scenarios that wouldn't occur on a real device but test
|
||||
# each remaining Relay* registry so every endpoint that the API can
|
||||
# return is seen by the integration at least once. They're trimmed
|
||||
# to just SystemEndpoints + the relay because a fuller registry set
|
||||
# adds no additional coverage here and would only bloat the snapshots.
|
||||
"relay_valve_position": [SystemEndpoints, RelayL1ValvePosition],
|
||||
"relay_temperature": [SystemEndpoints, RelayTemperature],
|
||||
"relay_temp_difference": [SystemEndpoints, RelayTempDifference],
|
||||
"relay_time_program": [SystemEndpoints, RelayTimeProgram],
|
||||
}
|
||||
|
||||
_DEFAULT_SCENARIO = "room_sensors"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_setup_entry() -> Generator[AsyncMock]:
|
||||
"""Override async_setup_entry."""
|
||||
with patch(
|
||||
"homeassistant.components.ouman_eh_800.async_setup_entry", return_value=True
|
||||
) as mock_setup_entry:
|
||||
yield mock_setup_entry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config_entry() -> MockConfigEntry:
|
||||
"""Return the default mocked config entry."""
|
||||
return MockConfigEntry(
|
||||
entry_id="01JABCDEFGHIJKLMNOPQRSTUVW",
|
||||
domain=DOMAIN,
|
||||
title="Ouman EH-800",
|
||||
data={
|
||||
CONF_URL: TEST_URL,
|
||||
CONF_USERNAME: TEST_USERNAME,
|
||||
CONF_PASSWORD: TEST_PASSWORD,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(params=[_DEFAULT_SCENARIO])
|
||||
def scenario(request: pytest.FixtureRequest) -> str:
|
||||
"""Scenario id; defaults to ``room_sensors`` unless overridden via parametrize."""
|
||||
return request.param
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def registry_set(scenario: str) -> OumanRegistrySet:
|
||||
"""The registry set the mocked device exposes for the active scenario."""
|
||||
return OumanRegistrySet(registries=SCENARIOS[scenario])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_ouman_client(registry_set: OumanRegistrySet) -> Generator[AsyncMock]:
|
||||
"""Mock the Ouman EH-800 client for the active scenario."""
|
||||
values = {
|
||||
endpoint: _ENDPOINT_VALUES[endpoint] for endpoint in registry_set.endpoints
|
||||
}
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.ouman_eh_800.coordinator.OumanEh800Client",
|
||||
autospec=True,
|
||||
) as mock_client,
|
||||
patch(
|
||||
"homeassistant.components.ouman_eh_800.config_flow.OumanEh800Client",
|
||||
new=mock_client,
|
||||
),
|
||||
):
|
||||
client = mock_client.return_value
|
||||
client.get_active_registries.return_value = registry_set
|
||||
client.get_values.return_value = values
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def init_integration(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_ouman_client: AsyncMock,
|
||||
request: pytest.FixtureRequest,
|
||||
) -> MockConfigEntry:
|
||||
"""Set up the Ouman EH-800 integration for testing."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
context = nullcontext()
|
||||
if platform := getattr(request, "param", None):
|
||||
context = patch("homeassistant.components.ouman_eh_800._PLATFORMS", [platform])
|
||||
|
||||
with context:
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
return mock_config_entry
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,156 @@
|
||||
"""Test the Ouman EH-800 config flow."""
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from ouman_eh_800_api import (
|
||||
OumanClientAuthenticationError,
|
||||
OumanClientCommunicationError,
|
||||
)
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.ouman_eh_800.const import DOMAIN
|
||||
from homeassistant.config_entries import SOURCE_USER
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
|
||||
from .conftest import TEST_PASSWORD, TEST_URL, TEST_USERNAME
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
pytestmark = pytest.mark.usefixtures("mock_setup_entry")
|
||||
|
||||
USER_INPUT = {
|
||||
CONF_URL: TEST_URL,
|
||||
CONF_USERNAME: TEST_USERNAME,
|
||||
CONF_PASSWORD: TEST_PASSWORD,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_ouman_client")
|
||||
@pytest.mark.parametrize(
|
||||
("submitted_url", "expected_url"),
|
||||
[
|
||||
pytest.param(TEST_URL, TEST_URL, id="already_normalized"),
|
||||
pytest.param(f"{TEST_URL}/eh800.html", TEST_URL, id="html_path"),
|
||||
pytest.param(f"{TEST_URL}:80/eh800.html/", TEST_URL, id="port_80_and_path"),
|
||||
pytest.param(
|
||||
f"{TEST_URL}:8080/eh800.html",
|
||||
f"{TEST_URL}:8080",
|
||||
id="non_default_port",
|
||||
),
|
||||
pytest.param(
|
||||
"https://proxied.device.com/eh800.html/",
|
||||
"https://proxied.device.com",
|
||||
id="https_url",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_user_flow_success(
|
||||
hass: HomeAssistant,
|
||||
mock_setup_entry: AsyncMock,
|
||||
submitted_url: str,
|
||||
expected_url: str,
|
||||
) -> None:
|
||||
"""Test the user flow accepts and normalizes various URL forms."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
assert result["errors"] == {}
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_URL: submitted_url,
|
||||
CONF_USERNAME: TEST_USERNAME,
|
||||
CONF_PASSWORD: TEST_PASSWORD,
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "Ouman EH-800"
|
||||
assert result["data"] == {**USER_INPUT, CONF_URL: expected_url}
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("error", "expected_error"),
|
||||
[
|
||||
(OumanClientCommunicationError("Connection failed"), "cannot_connect"),
|
||||
(OumanClientAuthenticationError("Invalid credentials"), "invalid_auth"),
|
||||
(RuntimeError("Unexpected"), "unknown"),
|
||||
],
|
||||
)
|
||||
async def test_user_flow_errors_recover(
|
||||
hass: HomeAssistant,
|
||||
mock_ouman_client: AsyncMock,
|
||||
error: Exception,
|
||||
expected_error: str,
|
||||
) -> None:
|
||||
"""Test that errors are surfaced and the flow can recover."""
|
||||
mock_ouman_client.login.side_effect = error
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], USER_INPUT
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {"base": expected_error}
|
||||
|
||||
mock_ouman_client.login.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
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_ouman_client")
|
||||
async def test_user_flow_invalid_url_recovers(hass: HomeAssistant) -> None:
|
||||
"""Test that an unparsable URL surfaces an error and the flow can recover."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_URL: "not a url",
|
||||
CONF_USERNAME: TEST_USERNAME,
|
||||
CONF_PASSWORD: TEST_PASSWORD,
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {CONF_URL: "invalid_url"}
|
||||
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_ouman_client")
|
||||
async def test_user_flow_already_configured(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test aborting when device is already configured."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], USER_INPUT
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Test the Ouman EH-800 setup."""
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from ouman_eh_800_api import (
|
||||
OumanClientAuthenticationError,
|
||||
OumanClientCommunicationError,
|
||||
)
|
||||
import pytest
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_ouman_client")
|
||||
async def test_setup_unload_entry(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test config entry setup and unload."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
assert await hass.config_entries.async_unload(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("error", "expected_state"),
|
||||
[
|
||||
(
|
||||
OumanClientCommunicationError("Connection failed"),
|
||||
ConfigEntryState.SETUP_RETRY,
|
||||
),
|
||||
(
|
||||
OumanClientAuthenticationError("Invalid credentials"),
|
||||
ConfigEntryState.SETUP_ERROR,
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_setup_error(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_ouman_client: AsyncMock,
|
||||
error: Exception,
|
||||
expected_state: ConfigEntryState,
|
||||
) -> None:
|
||||
"""Test that setup raises the correct config-entry exception on client errors."""
|
||||
mock_ouman_client.login.side_effect = error
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
assert not await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert mock_config_entry.state is expected_state
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Tests for the Ouman EH-800 sensor platform."""
|
||||
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from .conftest import SCENARIOS
|
||||
|
||||
from tests.common import MockConfigEntry, snapshot_platform
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scenario", SCENARIOS.keys(), indirect=True)
|
||||
@pytest.mark.parametrize("init_integration", [Platform.SENSOR], indirect=True)
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
|
||||
async def test_entities(
|
||||
hass: HomeAssistant,
|
||||
snapshot: SnapshotAssertion,
|
||||
entity_registry: er.EntityRegistry,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test the sensor entities for each registry-set scenario."""
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
Reference in New Issue
Block a user