From 71ec3c31fa71310bbc48ebc3dcf995c9bdace68d Mon Sep 17 00:00:00 2001 From: Markus Tuominen <3738613+Markus98@users.noreply.github.com> Date: Tue, 26 May 2026 13:22:37 +0300 Subject: [PATCH] Add valve platform to Ouman EH-800 (#172149) --- .../components/ouman_eh_800/__init__.py | 1 + .../components/ouman_eh_800/coordinator.py | 22 +++- .../components/ouman_eh_800/strings.json | 3 + .../components/ouman_eh_800/valve.py | 83 ++++++++++++ tests/components/ouman_eh_800/conftest.py | 15 +++ .../ouman_eh_800/snapshots/test_valve.ambr | 109 ++++++++++++++++ tests/components/ouman_eh_800/test_valve.py | 123 ++++++++++++++++++ 7 files changed, 355 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/ouman_eh_800/valve.py create mode 100644 tests/components/ouman_eh_800/snapshots/test_valve.ambr create mode 100644 tests/components/ouman_eh_800/test_valve.py diff --git a/homeassistant/components/ouman_eh_800/__init__.py b/homeassistant/components/ouman_eh_800/__init__.py index 586d9afb5276..d90b6f4b0a6b 100644 --- a/homeassistant/components/ouman_eh_800/__init__.py +++ b/homeassistant/components/ouman_eh_800/__init__.py @@ -7,6 +7,7 @@ from .coordinator import OumanEh800ConfigEntry, OumanEh800Coordinator _PLATFORMS: list[Platform] = [ Platform.SENSOR, + Platform.VALVE, ] diff --git a/homeassistant/components/ouman_eh_800/coordinator.py b/homeassistant/components/ouman_eh_800/coordinator.py index 0b895a206876..fac5d1701e5e 100644 --- a/homeassistant/components/ouman_eh_800/coordinator.py +++ b/homeassistant/components/ouman_eh_800/coordinator.py @@ -4,6 +4,7 @@ from datetime import timedelta import logging from ouman_eh_800_api import ( + ControllableEndpoint, L1BaseEndpoints, L2BaseEndpoints, OumanClientAuthenticationError, @@ -17,7 +18,11 @@ from ouman_eh_800_api import ( 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.exceptions import ( + ConfigEntryError, + ConfigEntryNotReady, + HomeAssistantError, +) from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -97,6 +102,21 @@ class OumanEh800Coordinator(DataUpdateCoordinator[dict[OumanEndpoint, OumanValue except OumanClientCommunicationError as err: raise UpdateFailed("Error communicating with API") from err + async def async_set_endpoint_value( + self, endpoint: ControllableEndpoint, value: OumanValues | int + ) -> None: + """Set a value on the device and refresh.""" + try: + result = await self.client.set_endpoint_value(endpoint, value) + except OumanClientAuthenticationError as err: + raise HomeAssistantError("Authentication failed") from err + except OumanClientCommunicationError as err: + raise HomeAssistantError("Error communicating with API") from err + + self.async_set_updated_data({**self.data, endpoint: result}) + # Separate refresh on all endpoints to catch cascading changes. + await self.async_request_refresh() + def sync_circuit_device_names(self) -> None: """Set the device-reported circuit names for the L1/L2 sub-device names. diff --git a/homeassistant/components/ouman_eh_800/strings.json b/homeassistant/components/ouman_eh_800/strings.json index cc006ebfd43c..9c7acdb4aab2 100644 --- a/homeassistant/components/ouman_eh_800/strings.json +++ b/homeassistant/components/ouman_eh_800/strings.json @@ -49,6 +49,9 @@ "name": "Supply water temperature setpoint" }, "valve_position": { "name": "Valve position" } + }, + "valve": { + "valve_position_setpoint": { "name": "Valve position setpoint" } } } } diff --git a/homeassistant/components/ouman_eh_800/valve.py b/homeassistant/components/ouman_eh_800/valve.py new file mode 100644 index 000000000000..c5209d18b3bc --- /dev/null +++ b/homeassistant/components/ouman_eh_800/valve.py @@ -0,0 +1,83 @@ +"""Valve platform for the Ouman EH-800 integration.""" + +from dataclasses import dataclass + +from ouman_eh_800_api import IntControlOumanEndpoint, L1BaseEndpoints, L2BaseEndpoints + +from homeassistant.components.valve import ( + ValveDeviceClass, + ValveEntity, + ValveEntityDescription, + ValveEntityFeature, +) +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 = 1 + + +@dataclass(frozen=True, kw_only=True) +class OumanEh800ValveEntityDescription( + OumanEh800EntityDescription, ValveEntityDescription +): + """Valve description with main/L1/L2 device assignment.""" + + +VALVE_DESCRIPTIONS: dict[IntControlOumanEndpoint, OumanEh800ValveEntityDescription] = { + L1BaseEndpoints.VALVE_POSITION_SETPOINT: OumanEh800ValveEntityDescription( + device=OumanDevice.L1, + key="valve_position_setpoint", + translation_key="valve_position_setpoint", + device_class=ValveDeviceClass.WATER, + ), + L2BaseEndpoints.VALVE_POSITION_SETPOINT: OumanEh800ValveEntityDescription( + device=OumanDevice.L2, + key="valve_position_setpoint", + translation_key="valve_position_setpoint", + device_class=ValveDeviceClass.WATER, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: OumanEh800ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Ouman EH-800 valve entities based on a config entry.""" + coordinator = entry.runtime_data + async_add_entities( + OumanEh800ValveEntity(coordinator, endpoint, description) + for endpoint in coordinator.data + if isinstance(endpoint, IntControlOumanEndpoint) + and (description := VALVE_DESCRIPTIONS.get(endpoint)) is not None + ) + + +class OumanEh800ValveEntity(OumanEh800Entity, ValveEntity): + """Ouman EH-800 valve entity.""" + + entity_description: OumanEh800ValveEntityDescription + _endpoint: IntControlOumanEndpoint + + _attr_reports_position = True + _attr_supported_features = ( + ValveEntityFeature.SET_POSITION + | ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE + ) + + @property + def current_valve_position(self) -> int: + """Return the current valve position 0-100.""" + value = self.coordinator.data[self._endpoint] + assert isinstance(value, float) + return int(value) + + async def async_set_valve_position(self, position: int) -> None: + """Move the valve to the given position.""" + await self.coordinator.async_set_endpoint_value(self._endpoint, position) diff --git a/tests/components/ouman_eh_800/conftest.py b/tests/components/ouman_eh_800/conftest.py index e20e033fb551..9613a179dcdf 100644 --- a/tests/components/ouman_eh_800/conftest.py +++ b/tests/components/ouman_eh_800/conftest.py @@ -236,6 +236,21 @@ def mock_ouman_client(registry_set: OumanRegistrySet) -> Generator[AsyncMock]: client = mock_client.return_value client.get_active_registries.return_value = registry_set client.get_values.return_value = values + + # Simulate the device: a successful write changes what subsequent + # reads return, so the coordinator's post-write refresh keeps the + # new value instead of reverting. The API library parses numeric + # responses as floats via ``NumberOumanEndpoint.parse_value``, so + # we mirror that here so int writes round-trip as floats. Tests can + # override by replacing ``set_endpoint_value.side_effect``. + def _set_endpoint_value( + endpoint: OumanEndpoint, value: OumanValues + ) -> OumanValues: + stored: OumanValues = float(value) if isinstance(value, int) else value + values[endpoint] = stored + return stored + + client.set_endpoint_value.side_effect = _set_endpoint_value yield client diff --git a/tests/components/ouman_eh_800/snapshots/test_valve.ambr b/tests/components/ouman_eh_800/snapshots/test_valve.ambr new file mode 100644 index 000000000000..f7862b5ab2b0 --- /dev/null +++ b/tests/components/ouman_eh_800/snapshots/test_valve.ambr @@ -0,0 +1,109 @@ +# serializer version: 1 +# name: test_entities[valve-room_sensors][valve.heating_circuit_1_patterilammitys_valve_position_setpoint-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'valve', + 'entity_category': None, + 'entity_id': 'valve.heating_circuit_1_patterilammitys_valve_position_setpoint', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Valve position setpoint', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Valve position setpoint', + 'platform': 'ouman_eh_800', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'valve_position_setpoint', + 'unique_id': '01JABCDEFGHIJKLMNOPQRSTUVW_l1_valve_position_setpoint', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[valve-room_sensors][valve.heating_circuit_1_patterilammitys_valve_position_setpoint-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 0, + 'device_class': 'water', + 'friendly_name': 'Heating circuit 1 Patterilämmitys Valve position setpoint', + 'is_closed': True, + 'supported_features': , + }), + 'context': , + 'entity_id': 'valve.heating_circuit_1_patterilammitys_valve_position_setpoint', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- +# name: test_entities[valve-room_sensors][valve.heating_circuit_2_lattialammitys_valve_position_setpoint-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'valve', + 'entity_category': None, + 'entity_id': 'valve.heating_circuit_2_lattialammitys_valve_position_setpoint', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Valve position setpoint', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Valve position setpoint', + 'platform': 'ouman_eh_800', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'valve_position_setpoint', + 'unique_id': '01JABCDEFGHIJKLMNOPQRSTUVW_l2_valve_position_setpoint', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[valve-room_sensors][valve.heating_circuit_2_lattialammitys_valve_position_setpoint-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 0, + 'device_class': 'water', + 'friendly_name': 'Heating circuit 2 Lattialämmitys Valve position setpoint', + 'is_closed': True, + 'supported_features': , + }), + 'context': , + 'entity_id': 'valve.heating_circuit_2_lattialammitys_valve_position_setpoint', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- diff --git a/tests/components/ouman_eh_800/test_valve.py b/tests/components/ouman_eh_800/test_valve.py new file mode 100644 index 000000000000..61cb7c9579ae --- /dev/null +++ b/tests/components/ouman_eh_800/test_valve.py @@ -0,0 +1,123 @@ +"""Tests for the Ouman EH-800 valve platform.""" + +from unittest.mock import AsyncMock + +from ouman_eh_800_api import ( + L1BaseEndpoints, + OumanClientAuthenticationError, + OumanClientCommunicationError, +) +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.valve import ( + ATTR_POSITION, + DOMAIN as VALVE_DOMAIN, + SERVICE_CLOSE_VALVE, + SERVICE_OPEN_VALVE, + SERVICE_SET_VALVE_POSITION, +) +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 tests.common import MockConfigEntry, snapshot_platform + + +# Only L1 and L2 base endpoints produce valve entities. A single scenario +# that exposes both is enough to cover the snapshot; the relay-only and +# single-circuit scenarios would either be empty or duplicate this output. +@pytest.mark.parametrize("scenario", ["room_sensors"], indirect=True) +@pytest.mark.parametrize("init_integration", [Platform.VALVE], 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 valve entities for each registry-set scenario.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize("init_integration", [Platform.VALVE], indirect=True) +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize( + ("service", "service_data", "expected_position"), + [ + pytest.param( + SERVICE_SET_VALVE_POSITION, + {ATTR_POSITION: 42}, + 42, + id="set_position", + ), + pytest.param(SERVICE_OPEN_VALVE, {}, 100, id="open"), + pytest.param(SERVICE_CLOSE_VALVE, {}, 0, id="close"), + ], +) +async def test_async_set_valve_position( + hass: HomeAssistant, + mock_ouman_client: AsyncMock, + service: str, + service_data: dict[str, int], + expected_position: int, +) -> None: + """Test that valve services write to the device and update state.""" + entity_id = "valve.heating_circuit_1_patterilammitys_valve_position_setpoint" + + await hass.services.async_call( + VALVE_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id, **service_data}, + blocking=True, + ) + + mock_ouman_client.set_endpoint_value.assert_called_once_with( + L1BaseEndpoints.VALVE_POSITION_SETPOINT, expected_position + ) + assert ( + hass.states.get(entity_id).attributes["current_position"] == expected_position + ) + + +@pytest.mark.parametrize("init_integration", [Platform.VALVE], 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_valve_position_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( + VALVE_DOMAIN, + SERVICE_SET_VALVE_POSITION, + { + ATTR_ENTITY_ID: "valve.heating_circuit_1_patterilammitys_valve_position_setpoint", + ATTR_POSITION: 50, + }, + blocking=True, + ) + + mock_ouman_client.set_endpoint_value.assert_called_once_with( + L1BaseEndpoints.VALVE_POSITION_SETPOINT, 50 + )