From 1c6f6858a6f3dee5c9561e2ba22e9dd853b8b2a9 Mon Sep 17 00:00:00 2001 From: = <=> Date: Wed, 24 Jul 2024 10:22:59 +0000 Subject: [PATCH] fix: adds support opening closing valve entity Fixes #240 --- .../hvac_controller/generic_controller.py | 16 ++- .../hvac_device/generic_hvac_device.py | 123 ++++++++++++++++-- tests/__init__.py | 53 +++++++- tests/common.py | 1 + tests/test_heater_mode.py | 35 +++++ 5 files changed, 211 insertions(+), 17 deletions(-) diff --git a/custom_components/dual_smart_thermostat/hvac_controller/generic_controller.py b/custom_components/dual_smart_thermostat/hvac_controller/generic_controller.py index 689d105..647135b 100644 --- a/custom_components/dual_smart_thermostat/hvac_controller/generic_controller.py +++ b/custom_components/dual_smart_thermostat/hvac_controller/generic_controller.py @@ -3,7 +3,8 @@ from typing import Callable from homeassistant.components.climate import HVACMode -from homeassistant.const import STATE_ON +from homeassistant.components.valve import DOMAIN as VALVE_DOMAIN +from homeassistant.const import STATE_ON, STATE_OPEN from homeassistant.core import HomeAssistant from homeassistant.helpers import condition import homeassistant.util.dt as dt_util @@ -55,6 +56,12 @@ def __init__( self._hvac_action_reason = HVACActionReason.NONE + @property + def _is_valve(self) -> bool: + state = self.hass.states.get(self.entity_id) + domain = state.domain if state else None + return domain == VALVE_DOMAIN + @property def hvac_action_reason(self) -> HVACActionReason: return self._hvac_action_reason @@ -62,8 +69,13 @@ def hvac_action_reason(self) -> HVACActionReason: @property def is_active(self) -> bool: """If the toggleable hvac device is currently active.""" + on_state = STATE_OPEN if self._is_valve else STATE_ON + + _LOGGER.debug( + "Checking if device is active: %s, on_state: %s", self.entity_id, on_state + ) if self.entity_id is not None and self.hass.states.is_state( - self.entity_id, STATE_ON + self.entity_id, on_state ): return True return False diff --git a/custom_components/dual_smart_thermostat/hvac_device/generic_hvac_device.py b/custom_components/dual_smart_thermostat/hvac_device/generic_hvac_device.py index 7cc74c9..f6cc846 100644 --- a/custom_components/dual_smart_thermostat/hvac_device/generic_hvac_device.py +++ b/custom_components/dual_smart_thermostat/hvac_device/generic_hvac_device.py @@ -2,12 +2,17 @@ import logging from homeassistant.components.climate import HVACMode +from homeassistant.components.valve import DOMAIN as VALVE_DOMAIN, ValveEntityFeature from homeassistant.const import ( ATTR_ENTITY_ID, + SERVICE_CLOSE_VALVE, + SERVICE_OPEN_VALVE, SERVICE_TURN_OFF, SERVICE_TURN_ON, + STATE_CLOSED, STATE_OFF, STATE_ON, + STATE_OPEN, STATE_UNAVAILABLE, STATE_UNKNOWN, ) @@ -104,6 +109,32 @@ def set_context(self, context: Context): def get_device_ids(self) -> list[str]: return [self.entity_id] + @property + def _entity_state(self) -> str: + return self.hass.states.get(self.entity_id) + + @property + def _is_valve(self) -> bool: + domain = self._entity_state.domain if self._entity_state else None + return domain == VALVE_DOMAIN + + @property + def _entity_features(self) -> int: + return ( + self.hass.states.get(self.entity_id).attributes.get("supported_features") + if self._entity_state + else 0 + ) + + @property + def _supports_open_valve(self) -> bool: + _LOGGER.debug("entity_features: %s", self._entity_features) + return self._is_valve and self._entity_features & ValveEntityFeature.OPEN + + @property + def _supports_close_valve(self) -> bool: + return self._is_valve and self._entity_features & ValveEntityFeature.CLOSE + @property def target_env_attr(self) -> str: return self._target_env_attr @@ -111,11 +142,7 @@ def target_env_attr(self) -> str: @property def is_active(self) -> bool: """If the toggleable hvac device is currently active.""" - if self.entity_id is not None and self.hass.states.is_state( - self.entity_id, STATE_ON - ): - return True - return False + return self.hvac_controller.is_active def is_below_target_env_attr(self) -> bool: """is too cold?""" @@ -184,12 +211,12 @@ async def async_control_hvac(self, time=None, force=False): "%s - async_control_hvac - is device active: %s, %s, strategy: %s, is opening open: %s", self._device_type, self.entity_id, - self.is_active, + self.hvac_controller.is_active, self.strategy, any_opeing_open, ) - if self.is_active: + if self.hvac_controller.is_active: await self.hvac_controller.async_control_device_when_on( self.strategy, any_opeing_open, @@ -216,7 +243,7 @@ async def async_on_startup(self): async def _async_check_device_initial_state(self) -> None: """Prevent the device from keep running if HVACMode.OFF.""" - if self._hvac_mode == HVACMode.OFF and self.is_active: + if self._hvac_mode == HVACMode.OFF and self.hvac_controller.is_active: _LOGGER.warning( "The climate mode is OFF, but the switch device is ON. Turning off device %s", self.entity_id, @@ -224,27 +251,95 @@ async def _async_check_device_initial_state(self) -> None: await self.async_turn_off() async def async_turn_on(self): + _LOGGER.info( + "%s. Turning on or opening entity %s", + self.__class__.__name__, + self.entity_id, + ) + + if self.entity_id is None: + return + + if self._supports_open_valve: + await self._async_open_valve_entity() + else: + await self._async_turn_on_entity() + + async def async_turn_off(self): + _LOGGER.info( + "%s. Turning off or closing entity %s", + self.__class__.__name__, + self.entity_id, + ) + if self.entity_id is None: + return + + if self._supports_close_valve: + await self._async_close_valve_entity() + else: + await self._async_turn_off_entity() + + async def _async_turn_on_entity(self) -> None: + """Turn on the entity.""" _LOGGER.info( "%s. Turning on entity %s", self.__class__.__name__, self.entity_id ) + + _LOGGER.debug("entity_id: %s", self.entity_id) + _LOGGER.debug( + "is_state: %s", self.hass.states.is_state(self.entity_id, STATE_OFF) + ) + if self.entity_id is not None and self.hass.states.is_state( self.entity_id, STATE_OFF ): - - data = {ATTR_ENTITY_ID: self.entity_id} await self.hass.services.async_call( - HA_DOMAIN, SERVICE_TURN_ON, data, context=self._context + HA_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: self.entity_id}, + context=self._context, ) - async def async_turn_off(self): + async def _async_turn_off_entity(self) -> None: + """Turn off the entity.""" _LOGGER.info( "%s. Turning off entity %s", self.__class__.__name__, self.entity_id ) + if self.entity_id is not None and self.hass.states.is_state( self.entity_id, STATE_ON ): + await self.hass.services.async_call( + HA_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: self.entity_id}, + context=self._context, + ) + + async def _async_open_valve_entity(self) -> None: + """Open the entity.""" + _LOGGER.info("%s. Opening entity %s", self.__class__.__name__, self.entity_id) + + if self.entity_id is not None and self.hass.states.is_state( + self.entity_id, STATE_CLOSED + ): + await self.hass.services.async_call( + HA_DOMAIN, + SERVICE_OPEN_VALVE, + {ATTR_ENTITY_ID: self.entity_id}, + context=self._context, + ) - data = {ATTR_ENTITY_ID: self.entity_id} + async def _async_close_valve_entity(self) -> None: + """Close the entity.""" + _LOGGER.info("%s. Closing entity %s", self.__class__.__name__, self.entity_id) + + if self.entity_id is not None and self.hass.states.is_state( + self.entity_id, STATE_OPEN + ): await self.hass.services.async_call( - HA_DOMAIN, SERVICE_TURN_OFF, data, context=self._context + HA_DOMAIN, + SERVICE_CLOSE_VALVE, + {ATTR_ENTITY_ID: self.entity_id}, + context=self._context, ) diff --git a/tests/__init__.py b/tests/__init__.py index 6ec4c38..9b90652 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -16,7 +16,16 @@ STATE_ON, HVACMode, ) -from homeassistant.const import SERVICE_TURN_OFF, SERVICE_TURN_ON, UnitOfTemperature +from homeassistant.components.valve import ValveEntityFeature +from homeassistant.const import ( + SERVICE_CLOSE_VALVE, + SERVICE_OPEN_VALVE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_CLOSED, + STATE_OPEN, + UnitOfTemperature, +) import homeassistant.core as ha from homeassistant.core import HomeAssistant, callback from homeassistant.setup import async_setup_component @@ -61,6 +70,28 @@ async def setup_comp_heat(hass: HomeAssistant) -> None: await hass.async_block_till_done() +@pytest.fixture +async def setup_comp_heat_valve(hass: HomeAssistant) -> None: + """Initialize components.""" + hass.config.units = METRIC_SYSTEM + assert await async_setup_component( + hass, + CLIMATE, + { + "climate": { + "platform": DOMAIN, + "name": "test", + "cold_tolerance": 2, + "hot_tolerance": 4, + "heater": common.ENT_VALVE, + "target_sensor": common.ENT_SENSOR, + "initial_hvac_mode": HVACMode.HEAT, + } + }, + ) + await hass.async_block_till_done() + + @pytest.fixture async def setup_comp_heat_safety_delay(hass: HomeAssistant) -> None: """Initialize components.""" @@ -1050,6 +1081,26 @@ def log_call(call) -> None: return calls +def setup_valve(hass: HomeAssistant, is_open: bool) -> None: + """Set up the test switch.""" + hass.states.async_set( + common.ENT_VALVE, + STATE_OPEN if is_open else STATE_CLOSED, + {"supported_features": ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE}, + ) + calls = [] + + @callback + def log_call(call) -> None: + """Log service calls.""" + calls.append(call) + + hass.services.async_register(ha.DOMAIN, SERVICE_OPEN_VALVE, log_call) + hass.services.async_register(ha.DOMAIN, SERVICE_CLOSE_VALVE, log_call) + + return calls + + def setup_heat_pump_cooling_status(hass: HomeAssistant, is_on: bool) -> None: """Set up the test switch.""" hass.states.async_set( diff --git a/tests/common.py b/tests/common.py index 2e585f8..063c310 100644 --- a/tests/common.py +++ b/tests/common.py @@ -63,6 +63,7 @@ ENT_OPENING_SENSOR = "input_number.opneing1" ENT_HUMIDITY_SENSOR = "input_number.humidity" ENT_SWITCH = "switch.test" +ENT_VALVE = "valve.test" ENT_HEATER = "input_boolean.test" ENT_COOLER = "input_boolean.test_cooler" ENT_FAN = "switch.test_fan" diff --git a/tests/test_heater_mode.py b/tests/test_heater_mode.py index 2a5fd86..638a076 100644 --- a/tests/test_heater_mode.py +++ b/tests/test_heater_mode.py @@ -24,6 +24,8 @@ from homeassistant.components.climate.const import ATTR_PRESET_MODE, DOMAIN as CLIMATE from homeassistant.const import ( ATTR_TEMPERATURE, + SERVICE_CLOSE_VALVE, + SERVICE_OPEN_VALVE, SERVICE_RELOAD, SERVICE_TURN_OFF, SERVICE_TURN_ON, @@ -67,9 +69,11 @@ setup_comp_heat_floor_opening_sensor, setup_comp_heat_presets, setup_comp_heat_safety_delay, + setup_comp_heat_valve, setup_floor_sensor, setup_sensor, setup_switch, + setup_valve, ) COLD_TOLERANCE = 0.5 @@ -665,6 +669,7 @@ async def test_set_target_temp_heater_on( setup_sensor(hass, 25) await hass.async_block_till_done() await common.async_set_temperature(hass, 30) + assert len(calls) == 1 call = calls[0] assert call.domain == HASS_DOMAIN @@ -687,6 +692,36 @@ async def test_set_target_temp_heater_off( assert call.data["entity_id"] == common.ENT_SWITCH +async def test_set_target_temp_heater_valve_open( + hass: HomeAssistant, setup_comp_heat_valve # noqa: F811 +) -> None: + """Test if target temperature turn heater on.""" + calls = setup_valve(hass, False) + setup_sensor(hass, 25) + await hass.async_block_till_done() + await common.async_set_temperature(hass, 30) + assert len(calls) == 1 + call = calls[0] + assert call.domain == HASS_DOMAIN + assert call.service == SERVICE_OPEN_VALVE + assert call.data["entity_id"] == common.ENT_VALVE + + +async def test_set_target_temp_heater_valve_close( + hass: HomeAssistant, setup_comp_heat_valve # noqa: F811 +) -> None: + """Test if target temperature turn heater off.""" + calls = setup_valve(hass, True) + setup_sensor(hass, 30) + await hass.async_block_till_done() + await common.async_set_temperature(hass, 25) + assert len(calls) == 2 + call = calls[0] + assert call.domain == HASS_DOMAIN + assert call.service == SERVICE_CLOSE_VALVE + assert call.data["entity_id"] == common.ENT_VALVE + + async def test_temp_change_heater_on_within_tolerance( hass: HomeAssistant, setup_comp_heat # noqa: F811 ) -> None: