Add the number platform to the Ouman EH-800 integration (#172134)

This commit is contained in:
Markus Tuominen
2026-05-26 16:16:12 +03:00
committed by GitHub
parent c347afe28d
commit e37459c16b
5 changed files with 3640 additions and 0 deletions
@@ -6,6 +6,7 @@ from homeassistant.core import HomeAssistant
from .coordinator import OumanEh800ConfigEntry, OumanEh800Coordinator
_PLATFORMS: list[Platform] = [
Platform.NUMBER,
Platform.SENSOR,
Platform.VALVE,
]
@@ -0,0 +1,260 @@
"""Number platform for the Ouman EH-800 integration."""
from dataclasses import dataclass
from ouman_eh_800_api import (
FloatControlOumanEndpoint,
IntControlOumanEndpoint,
L1BaseEndpoints,
L1ConstantTempMode,
L1FivePointCurve,
L1NoRoomSensor,
L1RoomSensor,
L1ThreePointCurve,
L2BaseEndpoints,
L2FivePointCurve,
L2NoRoomSensor,
L2RoomSensor,
L2ThreePointCurve,
SystemEndpoints,
)
from homeassistant.components.number import (
NumberDeviceClass,
NumberEntity,
NumberEntityDescription,
NumberMode,
)
from homeassistant.const import EntityCategory, UnitOfTemperature, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import OumanDevice
from .coordinator import OumanEh800ConfigEntry, OumanEh800Coordinator
from .entity import OumanEh800Entity, OumanEh800EntityDescription
PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
class OumanEh800NumberEntityDescription(
OumanEh800EntityDescription, NumberEntityDescription
):
"""Number description with main/L1/L2 device assignment."""
def _temperature_number(
*,
device: OumanDevice,
key: str,
device_class: NumberDeviceClass = NumberDeviceClass.TEMPERATURE,
entity_category: EntityCategory | None = EntityCategory.CONFIG,
enabled_by_default: bool = True,
) -> OumanEh800NumberEntityDescription:
return OumanEh800NumberEntityDescription(
device=device,
key=key,
translation_key=key,
device_class=device_class,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
mode=NumberMode.BOX,
entity_category=entity_category,
entity_registry_enabled_default=enabled_by_default,
)
NUMBER_DESCRIPTIONS: dict[
IntControlOumanEndpoint | FloatControlOumanEndpoint,
OumanEh800NumberEntityDescription,
] = {
SystemEndpoints.TREND_SAMPLE_INTERVAL: OumanEh800NumberEntityDescription(
device=OumanDevice.MAIN,
key="trend_sampling_interval",
translation_key="trend_sampling_interval",
native_unit_of_measurement=UnitOfTime.SECONDS,
mode=NumberMode.BOX,
entity_category=EntityCategory.CONFIG,
entity_registry_enabled_default=False,
),
# L1 base water-out temperature limits.
L1BaseEndpoints.WATER_OUT_MIN_TEMP: _temperature_number(
device=OumanDevice.L1, key="water_out_minimum_temperature"
),
L1BaseEndpoints.WATER_OUT_MAX_TEMP: _temperature_number(
device=OumanDevice.L1, key="water_out_maximum_temperature"
),
# L1 heating curve. Three-point and five-point variants share keys
# where their meaning overlaps.
L1ThreePointCurve.CURVE_MINUS_20_TEMP: _temperature_number(
device=OumanDevice.L1, key="curve_minus_20_temperature"
),
L1ThreePointCurve.CURVE_0_TEMP: _temperature_number(
device=OumanDevice.L1, key="curve_0_temperature"
),
L1ThreePointCurve.CURVE_20_TEMP: _temperature_number(
device=OumanDevice.L1, key="curve_20_temperature"
),
L1FivePointCurve.CURVE_MINUS_20_TEMP: _temperature_number(
device=OumanDevice.L1, key="curve_minus_20_temperature"
),
L1FivePointCurve.CURVE_MINUS_10_TEMP: _temperature_number(
device=OumanDevice.L1, key="curve_minus_10_temperature"
),
L1FivePointCurve.CURVE_0_TEMP: _temperature_number(
device=OumanDevice.L1, key="curve_0_temperature"
),
L1FivePointCurve.CURVE_10_TEMP: _temperature_number(
device=OumanDevice.L1, key="curve_10_temperature"
),
L1FivePointCurve.CURVE_20_TEMP: _temperature_number(
device=OumanDevice.L1, key="curve_20_temperature"
),
# L1 no-room-sensor and room-sensor variants share keys for the offsets
# that conceptually mean the same thing on both axes.
L1NoRoomSensor.TEMPERATURE_DROP: _temperature_number(
device=OumanDevice.L1,
key="temperature_drop",
device_class=NumberDeviceClass.TEMPERATURE_DELTA,
),
L1NoRoomSensor.BIG_TEMPERATURE_DROP: _temperature_number(
device=OumanDevice.L1,
key="big_temperature_drop",
device_class=NumberDeviceClass.TEMPERATURE_DELTA,
),
L1NoRoomSensor.ROOM_TEMPERATURE_FINE_TUNING: _temperature_number(
device=OumanDevice.L1,
key="room_temperature_fine_tuning",
device_class=NumberDeviceClass.TEMPERATURE_DELTA,
),
L1RoomSensor.TEMPERATURE_DROP: _temperature_number(
device=OumanDevice.L1,
key="temperature_drop",
device_class=NumberDeviceClass.TEMPERATURE_DELTA,
),
L1RoomSensor.BIG_TEMPERATURE_DROP: _temperature_number(
device=OumanDevice.L1,
key="big_temperature_drop",
device_class=NumberDeviceClass.TEMPERATURE_DELTA,
),
L1RoomSensor.ROOM_TEMPERATURE_FINE_TUNING: _temperature_number(
device=OumanDevice.L1,
key="room_temperature_fine_tuning",
device_class=NumberDeviceClass.TEMPERATURE_DELTA,
),
L1ConstantTempMode.CONSTANT_TEMP_SETPOINT: _temperature_number(
device=OumanDevice.L1,
key="constant_temp_setpoint",
entity_category=None,
),
# L2 mirrors L1.
L2BaseEndpoints.WATER_OUT_MIN_TEMP: _temperature_number(
device=OumanDevice.L2, key="water_out_minimum_temperature"
),
L2BaseEndpoints.WATER_OUT_MAX_TEMP: _temperature_number(
device=OumanDevice.L2, key="water_out_maximum_temperature"
),
L2ThreePointCurve.CURVE_MINUS_20_TEMP: _temperature_number(
device=OumanDevice.L2, key="curve_minus_20_temperature"
),
L2ThreePointCurve.CURVE_0_TEMP: _temperature_number(
device=OumanDevice.L2, key="curve_0_temperature"
),
L2ThreePointCurve.CURVE_20_TEMP: _temperature_number(
device=OumanDevice.L2, key="curve_20_temperature"
),
L2FivePointCurve.CURVE_MINUS_20_TEMP: _temperature_number(
device=OumanDevice.L2, key="curve_minus_20_temperature"
),
L2FivePointCurve.CURVE_MINUS_10_TEMP: _temperature_number(
device=OumanDevice.L2, key="curve_minus_10_temperature"
),
L2FivePointCurve.CURVE_0_TEMP: _temperature_number(
device=OumanDevice.L2, key="curve_0_temperature"
),
L2FivePointCurve.CURVE_10_TEMP: _temperature_number(
device=OumanDevice.L2, key="curve_10_temperature"
),
L2FivePointCurve.CURVE_20_TEMP: _temperature_number(
device=OumanDevice.L2, key="curve_20_temperature"
),
L2NoRoomSensor.TEMPERATURE_DROP: _temperature_number(
device=OumanDevice.L2,
key="temperature_drop",
device_class=NumberDeviceClass.TEMPERATURE_DELTA,
),
L2NoRoomSensor.BIG_TEMPERATURE_DROP: _temperature_number(
device=OumanDevice.L2,
key="big_temperature_drop",
device_class=NumberDeviceClass.TEMPERATURE_DELTA,
),
L2NoRoomSensor.ROOM_TEMPERATURE_FINE_TUNING: _temperature_number(
device=OumanDevice.L2,
key="room_temperature_fine_tuning",
device_class=NumberDeviceClass.TEMPERATURE_DELTA,
),
L2RoomSensor.TEMPERATURE_DROP: _temperature_number(
device=OumanDevice.L2,
key="temperature_drop",
device_class=NumberDeviceClass.TEMPERATURE_DELTA,
),
L2RoomSensor.BIG_TEMPERATURE_DROP: _temperature_number(
device=OumanDevice.L2,
key="big_temperature_drop",
device_class=NumberDeviceClass.TEMPERATURE_DELTA,
),
L2RoomSensor.ROOM_TEMPERATURE_FINE_TUNING: _temperature_number(
device=OumanDevice.L2,
key="room_temperature_fine_tuning",
device_class=NumberDeviceClass.TEMPERATURE_DELTA,
),
}
async def async_setup_entry(
hass: HomeAssistant,
entry: OumanEh800ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Ouman EH-800 number entities based on a config entry."""
coordinator = entry.runtime_data
async_add_entities(
OumanEh800NumberEntity(coordinator, endpoint, description)
for endpoint in coordinator.data
if isinstance(endpoint, IntControlOumanEndpoint | FloatControlOumanEndpoint)
and (description := NUMBER_DESCRIPTIONS.get(endpoint)) is not None
)
class OumanEh800NumberEntity(OumanEh800Entity, NumberEntity):
"""Ouman EH-800 number entity."""
entity_description: OumanEh800NumberEntityDescription
_endpoint: IntControlOumanEndpoint | FloatControlOumanEndpoint
def __init__(
self,
coordinator: OumanEh800Coordinator,
endpoint: IntControlOumanEndpoint | FloatControlOumanEndpoint,
description: OumanEh800NumberEntityDescription,
) -> None:
"""Initialize the number entity."""
super().__init__(coordinator, endpoint, description)
self._attr_native_min_value = float(endpoint.min_val)
self._attr_native_max_value = float(endpoint.max_val)
self._attr_native_step = (
1 if isinstance(endpoint, IntControlOumanEndpoint) else 0.1
)
@property
def native_value(self) -> float:
"""Return the current value."""
value = self.coordinator.data[self._endpoint]
assert isinstance(value, float)
return value
async def async_set_native_value(self, value: float) -> None:
"""Set a new value on the device."""
final_value: int | float = (
int(value) if isinstance(self._endpoint, IntControlOumanEndpoint) else value
)
await self.coordinator.async_set_endpoint_value(self._endpoint, final_value)
@@ -31,6 +31,26 @@
}
},
"entity": {
"number": {
"big_temperature_drop": { "name": "Big temperature drop" },
"constant_temp_setpoint": { "name": "Constant temperature setpoint" },
"curve_0_temperature": { "name": "Curve 0°C temperature" },
"curve_10_temperature": { "name": "Curve 10°C temperature" },
"curve_20_temperature": { "name": "Curve 20°C temperature" },
"curve_minus_10_temperature": { "name": "Curve -10°C temperature" },
"curve_minus_20_temperature": { "name": "Curve -20°C temperature" },
"room_temperature_fine_tuning": {
"name": "Room temperature fine tuning"
},
"temperature_drop": { "name": "Temperature drop" },
"trend_sampling_interval": { "name": "Trend sampling interval" },
"water_out_maximum_temperature": {
"name": "Water out maximum temperature"
},
"water_out_minimum_temperature": {
"name": "Water out minimum temperature"
}
},
"sensor": {
"curve_supply_water_temperature": {
"name": "Curve supply water temperature"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,132 @@
"""Tests for the Ouman EH-800 number platform."""
from unittest.mock import AsyncMock
from ouman_eh_800_api import (
FloatControlOumanEndpoint,
IntControlOumanEndpoint,
L1BaseEndpoints,
L1RoomSensor,
L1ThreePointCurve,
OumanClientAuthenticationError,
OumanClientCommunicationError,
)
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.number import (
ATTR_VALUE,
DOMAIN as NUMBER_DOMAIN,
SERVICE_SET_VALUE,
)
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
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.NUMBER], 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 number entities for each registry-set scenario."""
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.parametrize("init_integration", [Platform.NUMBER], indirect=True)
@pytest.mark.usefixtures("init_integration")
@pytest.mark.parametrize(
("entity_id", "endpoint", "initial_value", "target_value", "set_value"),
[
pytest.param(
"number.heating_circuit_1_patterilammitys_curve_0degc_temperature",
L1ThreePointCurve.CURVE_0_TEMP,
41.0,
42.0,
42.0,
id="int_setpoint",
),
pytest.param(
"number.heating_circuit_1_patterilammitys_room_temperature_fine_tuning",
L1RoomSensor.ROOM_TEMPERATURE_FINE_TUNING,
0.0,
1.5,
1.5,
id="float_fine_tuning",
),
],
)
async def test_async_set_native_value(
hass: HomeAssistant,
mock_ouman_client: AsyncMock,
entity_id: str,
endpoint: IntControlOumanEndpoint | FloatControlOumanEndpoint,
initial_value: float,
target_value: float,
set_value: float,
) -> None:
"""Test that setting a number writes to the device and updates state."""
assert float(hass.states.get(entity_id).state) == initial_value
await hass.services.async_call(
NUMBER_DOMAIN,
SERVICE_SET_VALUE,
{ATTR_ENTITY_ID: entity_id, ATTR_VALUE: target_value},
blocking=True,
)
mock_ouman_client.set_endpoint_value.assert_called_once_with(endpoint, set_value)
assert float(hass.states.get(entity_id).state) == target_value
@pytest.mark.parametrize("init_integration", [Platform.NUMBER], indirect=True)
@pytest.mark.usefixtures("init_integration")
@pytest.mark.parametrize(
("client_error", "expected_message"),
[
pytest.param(
OumanClientAuthenticationError("Wrong username or password"),
"Authentication failed",
id="auth_failure",
),
pytest.param(
OumanClientCommunicationError("Network error: Connection refused"),
"Error communicating with API",
id="communication_failure",
),
],
)
async def test_async_set_native_value_errors(
hass: HomeAssistant,
mock_ouman_client: AsyncMock,
client_error: Exception,
expected_message: str,
) -> None:
"""Test that client errors are mapped to HomeAssistantError."""
mock_ouman_client.set_endpoint_value.side_effect = client_error
with pytest.raises(HomeAssistantError, match=expected_message):
await hass.services.async_call(
NUMBER_DOMAIN,
SERVICE_SET_VALUE,
{
ATTR_ENTITY_ID: "number.heating_circuit_1_patterilammitys_water_out_minimum_temperature",
ATTR_VALUE: 20,
},
blocking=True,
)
# First positional arg is the endpoint; we only assert the call happened.
mock_ouman_client.set_endpoint_value.assert_called_once()
args, _ = mock_ouman_client.set_endpoint_value.call_args
assert args[0] is L1BaseEndpoints.WATER_OUT_MIN_TEMP
assert args[1] == 20