From 14515b77bb79b245c177ea66a76dfa7f2020995e Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Wed, 17 Apr 2024 20:47:15 -0500 Subject: [PATCH] Add valve entity support for ESPHome (#115341) Co-authored-by: J. Nick Koston Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- .../components/esphome/entry_data.py | 2 + homeassistant/components/esphome/valve.py | 103 +++++++++ tests/components/esphome/test_valve.py | 196 ++++++++++++++++++ 3 files changed, 301 insertions(+) create mode 100644 homeassistant/components/esphome/valve.py create mode 100644 tests/components/esphome/test_valve.py diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 005963db872..52dc1f17ad6 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -36,6 +36,7 @@ from aioesphomeapi import ( TextSensorInfo, TimeInfo, UserService, + ValveInfo, build_unique_id, ) from aioesphomeapi.model import ButtonInfo @@ -78,6 +79,7 @@ INFO_TYPE_TO_PLATFORM: dict[type[EntityInfo], Platform] = { TextInfo: Platform.TEXT, TextSensorInfo: Platform.SENSOR, TimeInfo: Platform.TIME, + ValveInfo: Platform.VALVE, } diff --git a/homeassistant/components/esphome/valve.py b/homeassistant/components/esphome/valve.py new file mode 100644 index 00000000000..5798d38803f --- /dev/null +++ b/homeassistant/components/esphome/valve.py @@ -0,0 +1,103 @@ +"""Support for ESPHome valves.""" + +from __future__ import annotations + +from typing import Any + +from aioesphomeapi import EntityInfo, ValveInfo, ValveOperation, ValveState + +from homeassistant.components.valve import ( + ValveDeviceClass, + ValveEntity, + ValveEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util.enum import try_parse_enum + +from .entity import ( + EsphomeEntity, + convert_api_error_ha_error, + esphome_state_property, + platform_async_setup_entry, +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up ESPHome valves based on a config entry.""" + await platform_async_setup_entry( + hass, + entry, + async_add_entities, + info_type=ValveInfo, + entity_type=EsphomeValve, + state_type=ValveState, + ) + + +class EsphomeValve(EsphomeEntity[ValveInfo, ValveState], ValveEntity): + """A valve implementation for ESPHome.""" + + @callback + def _on_static_info_update(self, static_info: EntityInfo) -> None: + """Set attrs from static info.""" + super()._on_static_info_update(static_info) + static_info = self._static_info + flags = ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE + if static_info.supports_stop: + flags |= ValveEntityFeature.STOP + if static_info.supports_position: + flags |= ValveEntityFeature.SET_POSITION + self._attr_supported_features = flags + self._attr_device_class = try_parse_enum( + ValveDeviceClass, static_info.device_class + ) + self._attr_assumed_state = static_info.assumed_state + self._attr_reports_position = static_info.supports_position + + @property + @esphome_state_property + def is_closed(self) -> bool: + """Return if the valve is closed or not.""" + return self._state.position == 0.0 + + @property + @esphome_state_property + def is_opening(self) -> bool: + """Return if the valve is opening or not.""" + return self._state.current_operation is ValveOperation.IS_OPENING + + @property + @esphome_state_property + def is_closing(self) -> bool: + """Return if the valve is closing or not.""" + return self._state.current_operation is ValveOperation.IS_CLOSING + + @property + @esphome_state_property + def current_valve_position(self) -> int | None: + """Return current position of valve. 0 is closed, 100 is open.""" + return round(self._state.position * 100.0) + + @convert_api_error_ha_error + async def async_open_valve(self, **kwargs: Any) -> None: + """Open the valve.""" + self._client.valve_command(key=self._key, position=1.0) + + @convert_api_error_ha_error + async def async_close_valve(self, **kwargs: Any) -> None: + """Close valve.""" + self._client.valve_command(key=self._key, position=0.0) + + @convert_api_error_ha_error + async def async_stop_valve(self, **kwargs: Any) -> None: + """Stop the valve.""" + self._client.valve_command(key=self._key, stop=True) + + @convert_api_error_ha_error + async def async_set_valve_position(self, position: float) -> None: + """Move the valve to a specific position.""" + self._client.valve_command(key=self._key, position=position / 100) diff --git a/tests/components/esphome/test_valve.py b/tests/components/esphome/test_valve.py new file mode 100644 index 00000000000..5ba7bcbe187 --- /dev/null +++ b/tests/components/esphome/test_valve.py @@ -0,0 +1,196 @@ +"""Test ESPHome valves.""" + +from collections.abc import Awaitable, Callable +from unittest.mock import call + +from aioesphomeapi import ( + APIClient, + EntityInfo, + EntityState, + UserService, + ValveInfo, + ValveOperation, + ValveState, +) + +from homeassistant.components.valve import ( + ATTR_CURRENT_POSITION, + ATTR_POSITION, + DOMAIN as VALVE_DOMAIN, + SERVICE_CLOSE_VALVE, + SERVICE_OPEN_VALVE, + SERVICE_SET_VALVE_POSITION, + SERVICE_STOP_VALVE, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant + +from .conftest import MockESPHomeDevice + + +async def test_valve_entity( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test a generic valve entity.""" + entity_info = [ + ValveInfo( + object_id="myvalve", + key=1, + name="my valve", + unique_id="my_valve", + supports_position=True, + supports_stop=True, + ) + ] + states = [ + ValveState( + key=1, + position=0.5, + current_operation=ValveOperation.IS_OPENING, + ) + ] + user_service = [] + mock_device = await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("valve.test_myvalve") + assert state is not None + assert state.state == STATE_OPENING + assert state.attributes[ATTR_CURRENT_POSITION] == 50 + + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_CLOSE_VALVE, + {ATTR_ENTITY_ID: "valve.test_myvalve"}, + blocking=True, + ) + mock_client.valve_command.assert_has_calls([call(key=1, position=0.0)]) + mock_client.valve_command.reset_mock() + + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_OPEN_VALVE, + {ATTR_ENTITY_ID: "valve.test_myvalve"}, + blocking=True, + ) + mock_client.valve_command.assert_has_calls([call(key=1, position=1.0)]) + mock_client.valve_command.reset_mock() + + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_SET_VALVE_POSITION, + {ATTR_ENTITY_ID: "valve.test_myvalve", ATTR_POSITION: 50}, + blocking=True, + ) + mock_client.valve_command.assert_has_calls([call(key=1, position=0.5)]) + mock_client.valve_command.reset_mock() + + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_STOP_VALVE, + {ATTR_ENTITY_ID: "valve.test_myvalve"}, + blocking=True, + ) + mock_client.valve_command.assert_has_calls([call(key=1, stop=True)]) + mock_client.valve_command.reset_mock() + + mock_device.set_state( + ValveState(key=1, position=0.0, current_operation=ValveOperation.IDLE) + ) + await hass.async_block_till_done() + state = hass.states.get("valve.test_myvalve") + assert state is not None + assert state.state == STATE_CLOSED + + mock_device.set_state( + ValveState(key=1, position=0.5, current_operation=ValveOperation.IS_CLOSING) + ) + await hass.async_block_till_done() + state = hass.states.get("valve.test_myvalve") + assert state is not None + assert state.state == STATE_CLOSING + + mock_device.set_state( + ValveState(key=1, position=1.0, current_operation=ValveOperation.IDLE) + ) + await hass.async_block_till_done() + state = hass.states.get("valve.test_myvalve") + assert state is not None + assert state.state == STATE_OPEN + + +async def test_valve_entity_without_position( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test a generic valve entity without position or stop.""" + entity_info = [ + ValveInfo( + object_id="myvalve", + key=1, + name="my valve", + unique_id="my_valve", + supports_position=False, + supports_stop=False, + ) + ] + states = [ + ValveState( + key=1, + position=0.5, + current_operation=ValveOperation.IS_OPENING, + ) + ] + user_service = [] + mock_device = await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("valve.test_myvalve") + assert state is not None + assert state.state == STATE_OPENING + assert ATTR_CURRENT_POSITION not in state.attributes + + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_CLOSE_VALVE, + {ATTR_ENTITY_ID: "valve.test_myvalve"}, + blocking=True, + ) + mock_client.valve_command.assert_has_calls([call(key=1, position=0.0)]) + mock_client.valve_command.reset_mock() + + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_OPEN_VALVE, + {ATTR_ENTITY_ID: "valve.test_myvalve"}, + blocking=True, + ) + mock_client.valve_command.assert_has_calls([call(key=1, position=1.0)]) + mock_client.valve_command.reset_mock() + + mock_device.set_state( + ValveState(key=1, position=0.0, current_operation=ValveOperation.IDLE) + ) + await hass.async_block_till_done() + state = hass.states.get("valve.test_myvalve") + assert state is not None + assert state.state == STATE_CLOSED