Add Ouman EH-800 heating controller integration (#169733)

This commit is contained in:
Markus Tuominen
2026-05-13 12:59:49 +03:00
committed by GitHub
parent 070ef8f0b0
commit b179d71658
23 changed files with 3404 additions and 0 deletions
+1
View File
@@ -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
View File
@@ -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" }
}
}
}
+1
View File
@@ -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",
Generated
+10
View File
@@ -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
+3
View File
@@ -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
+3
View File
@@ -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."""
+260
View File
@@ -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)