From de08b65ab413915a788b40ab02fd7677865f579f Mon Sep 17 00:00:00 2001 From: = <=> Date: Fri, 30 Aug 2024 10:09:55 +0000 Subject: [PATCH] fix: sets proper power levels when device state changes --- .../dual_smart_thermostat/climate.py | 14 +-- .../hvac_controller/generic_controller.py | 13 ++- .../hvac_device/generic_hvac_device.py | 27 +++-- .../managers/environment_manager.py | 29 ++++-- .../managers/hvac_power_manager.py | 20 +++- .../managers/opening_manager.py | 15 ++- tests/test_cooler_mode.py | 98 ++++++++++++++++++- 7 files changed, 188 insertions(+), 28 deletions(-) diff --git a/custom_components/dual_smart_thermostat/climate.py b/custom_components/dual_smart_thermostat/climate.py index 2ddab23..0e71c33 100644 --- a/custom_components/dual_smart_thermostat/climate.py +++ b/custom_components/dual_smart_thermostat/climate.py @@ -780,16 +780,8 @@ def extra_state_attributes(self) -> dict: _LOGGER.debug( "Setting HVAC Power Level: %s", self.power_manager.hvac_power_level ) - attributes[ATTR_HVAC_POWER_LEVEL] = ( - self.power_manager.hvac_power_level - if self.hvac_device.hvac_mode != HVACMode.OFF - else 0 - ) - attributes[ATTR_HVAC_POWER_PERCENT] = ( - self.power_manager.hvac_power_percent - if self.hvac_device.hvac_mode != HVACMode.OFF - else 0 - ) + attributes[ATTR_HVAC_POWER_LEVEL] = self.power_manager.hvac_power_level + attributes[ATTR_HVAC_POWER_PERCENT] = self.power_manager.hvac_power_percent _LOGGER.debug("Extra state attributes: %s", attributes) @@ -1172,6 +1164,8 @@ async def _async_control_climate_forced(self, time=None) -> None: _LOGGER.debug("_async_control_climate_forced, time %s", time) await self._async_control_climate(force=True, time=time) + self.async_write_ha_state() + @callback def _async_hvac_mode_changed(self, hvac_mode) -> None: """Handle HVAC mode changes.""" 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 b24fd02..d08bc71 100644 --- a/custom_components/dual_smart_thermostat/hvac_controller/generic_controller.py +++ b/custom_components/dual_smart_thermostat/hvac_controller/generic_controller.py @@ -72,7 +72,9 @@ def is_active(self) -> bool: 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 + "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, on_state @@ -132,14 +134,21 @@ async def async_control_device_when_on( ) -> None: """Check if we need to turn heating on or off when theheater is on.""" + _LOGGER.debug("%s _async_control_device_when_on", self.__class__.__name__) _LOGGER.debug("below_env_attr: %s", strategy.hvac_goal_reached) + _LOGGER.debug("any_opening_open: %s", any_opening_open) + _LOGGER.debug("hvac_goal_reached: %s", strategy.hvac_goal_reached) if strategy.hvac_goal_reached or any_opening_open: _LOGGER.debug("Turning off entity %s", self.entity_id) + await self.async_turn_off_callback() + if strategy.hvac_goal_reached: + _LOGGER.debug("setting hvac_action_reason goal reached") self._hvac_action_reason = strategy.goal_reached_reason() if any_opening_open: + _LOGGER.debug("setting hvac_action_reason opening") self._hvac_action_reason = HVACActionReason.OPENING elif time is not None and not any_opening_open: @@ -150,6 +159,8 @@ async def async_control_device_when_on( ) await self.async_turn_on_callback() self._hvac_action_reason = strategy.goal_not_reached_reason() + else: + _LOGGER.debug("No case matched when - keep device on") async def async_control_device_when_off( self, 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 6dbe143..c85731a 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,7 +2,7 @@ import logging from typing import Callable -from homeassistant.components.climate import HVACMode +from homeassistant.components.climate import HVACAction, HVACMode from homeassistant.components.valve import DOMAIN as VALVE_DOMAIN, ValveEntityFeature from homeassistant.const import ( ATTR_ENTITY_ID, @@ -211,7 +211,7 @@ async def async_control_hvac(self, time=None, force=False): ): return - any_opeing_open = self.openings.any_opening_open(self.hvac_mode) + any_opening_open = self.openings.any_opening_open(self.hvac_mode) _LOGGER.debug( "%s - async_control_hvac - is device active: %s, %s, strategy: %s, is opening open: %s", @@ -219,24 +219,31 @@ async def async_control_hvac(self, time=None, force=False): self.entity_id, self.hvac_controller.is_active, self.strategy, - any_opeing_open, + any_opening_open, ) if self.hvac_controller.is_active: await self.hvac_controller.async_control_device_when_on( self.strategy, - any_opeing_open, + any_opening_open, time, ) else: await self.hvac_controller.async_control_device_when_off( self.strategy, - any_opeing_open, + any_opening_open, time, ) + _LOGGER.debug( + "hvac action reason after control: %s", + self.hvac_controller.hvac_action_reason, + ) + self._hvac_action_reason = self.hvac_controller.hvac_action_reason - self.hvac_power.update_hvac_power(self.strategy, self.target_env_attr) + self.hvac_power.update_hvac_power( + self.strategy, self.target_env_attr, self.hvac_action + ) async def async_on_startup(self, async_write_ha_state_cb: Callable = None): @@ -287,6 +294,10 @@ async def async_turn_off(self): else: await self._async_turn_off_entity() + self.hvac_power.update_hvac_power( + self.strategy, self.target_env_attr, HVACAction.OFF + ) + async def _async_turn_on_entity(self) -> None: """Turn on the entity.""" _LOGGER.info( @@ -302,6 +313,7 @@ async def _async_turn_on_entity(self) -> None: SERVICE_TURN_ON, {ATTR_ENTITY_ID: self.entity_id}, context=self._context, + blocking=True, ) async def _async_turn_off_entity(self) -> None: @@ -318,6 +330,7 @@ async def _async_turn_off_entity(self) -> None: SERVICE_TURN_OFF, {ATTR_ENTITY_ID: self.entity_id}, context=self._context, + blocking=True, ) async def _async_open_valve_entity(self) -> None: @@ -332,6 +345,7 @@ async def _async_open_valve_entity(self) -> None: SERVICE_OPEN_VALVE, {ATTR_ENTITY_ID: self.entity_id}, context=self._context, + blocking=True, ) async def _async_close_valve_entity(self) -> None: @@ -346,4 +360,5 @@ async def _async_close_valve_entity(self) -> None: SERVICE_CLOSE_VALVE, {ATTR_ENTITY_ID: self.entity_id}, context=self._context, + blocking=True, ) diff --git a/custom_components/dual_smart_thermostat/managers/environment_manager.py b/custom_components/dual_smart_thermostat/managers/environment_manager.py index 4fcb619..0b33703 100644 --- a/custom_components/dual_smart_thermostat/managers/environment_manager.py +++ b/custom_components/dual_smart_thermostat/managers/environment_manager.py @@ -344,11 +344,12 @@ def is_warmer_outside(self) -> bool: def is_too_cold(self, target_attr="_target_temp") -> bool: """Checks if the current temperature is below target.""" - if self._cur_temp is None: - return False target_temp = getattr(self, target_attr) + if self._cur_temp is None or target_temp is None: + return False + _LOGGER.debug( - "Target temp attr: %s, Target temp: %s, current temp: %s, tolerance: %s", + "is_too_cold - target temp attr: %s, Target temp: %s, current temp: %s, tolerance: %s", target_attr, target_temp, self._cur_temp, @@ -358,23 +359,37 @@ def is_too_cold(self, target_attr="_target_temp") -> bool: def is_too_hot(self, target_attr="_target_temp") -> bool: """Checks if the current temperature is above target.""" - if self._cur_temp is None: - return False target_temp = getattr(self, target_attr) + if self._cur_temp is None or target_temp is None: + return False + + _LOGGER.debug( + "is_too_hot - target temp attr: %s, Target temp: %s, current temp: %s, tolerance: %s", + target_attr, + target_temp, + self._cur_temp, + self._hot_tolerance, + ) return self._cur_temp >= target_temp + self._hot_tolerance @property def is_too_moist(self) -> bool: """Checks if the current humidity is above target.""" - if self._cur_humidity is None: + if self._cur_humidity is None or self._target_humidity is None: return False return self._cur_humidity >= self._target_humidity + self._moist_tolerance @property def is_too_dry(self) -> bool: """Checks if the current humidity is below target.""" - if self._cur_humidity is None: + if self._cur_humidity is None or self._target_humidity is None: return False + _LOGGER.debug( + "is_too_dry - Target humidity: %s, current humidity: %s, tolerance: %s", + self._target_humidity, + self._cur_humidity, + self._dry_tolerance, + ) return self._cur_humidity <= self._target_humidity - self._dry_tolerance @property diff --git a/custom_components/dual_smart_thermostat/managers/hvac_power_manager.py b/custom_components/dual_smart_thermostat/managers/hvac_power_manager.py index ed659b2..ea63e52 100644 --- a/custom_components/dual_smart_thermostat/managers/hvac_power_manager.py +++ b/custom_components/dual_smart_thermostat/managers/hvac_power_manager.py @@ -1,5 +1,6 @@ import logging +from homeassistant.components.climate import HVACAction from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM @@ -104,7 +105,7 @@ def _get_hvac_power_tolerance(self, is_temperature: bool) -> int: ) def update_hvac_power( - self, strategy: HvacEnvStrategy, target_env_attr: str + self, strategy: HvacEnvStrategy, target_env_attr: str, hvac_action: HVACAction ) -> None: """updates the hvac power level based on the strategy and the target environment attribute""" @@ -113,7 +114,15 @@ def update_hvac_power( goal_reached = strategy.hvac_goal_reached goal_not_reached = strategy.hvac_goal_not_reached - if goal_reached: + _LOGGER.debug("goal reached: %s", goal_reached) + _LOGGER.debug("goal not reached: %s", goal_not_reached) + _LOGGER.debug("hvac_action: %s", hvac_action) + + if ( + goal_reached + or hvac_action == HVACAction.OFF + or hvac_action == HVACAction.IDLE + ): _LOGGER.debug("Updating hvac power because goal reached") self._hvac_power_level = 0 self._hvac_power_percent = 0 @@ -160,6 +169,13 @@ def _calculate_power_level(self, step_value: float, env_difference: float) -> in calculated_power_level = round(env_difference / step_value) + _LOGGER.debug( + "calculated power level, max_power_level, min_power_Level: %s, %s, %s", + calculated_power_level, + self._hvac_power_max, + self._hvac_power_min, + ) + return max( self._hvac_power_min, min(calculated_power_level, self._hvac_power_max) ) diff --git a/custom_components/dual_smart_thermostat/managers/opening_manager.py b/custom_components/dual_smart_thermostat/managers/opening_manager.py index 30eec07..7203e09 100644 --- a/custom_components/dual_smart_thermostat/managers/opening_manager.py +++ b/custom_components/dual_smart_thermostat/managers/opening_manager.py @@ -126,7 +126,7 @@ def _is_opening_open(self, opening: TIMED_OPENING_SCHEMA) -> bool: # type: igno _is_open = True _LOGGER.debug( "No timeout mode for opening %s, is open: %s.", - opening_entity, + opening, _is_open, ) return _is_open @@ -134,6 +134,19 @@ def _is_opening_open(self, opening: TIMED_OPENING_SCHEMA) -> bool: # type: igno def _is_opening_timed_out(self, opening: TIMED_OPENING_SCHEMA) -> bool: # type: ignore opening_entity = opening[ATTR_ENTITY_ID] _is_open = False + + _LOGGER.debug( + "Checking if opening %s is timed out, state: %s, timeout: %s, is_timed_out: %s", + opening, + self.hass.states.get(opening_entity), + opening[ATTR_TIMEOUT], + condition.state( + self.hass, + opening_entity, + STATE_OPEN, + opening[ATTR_TIMEOUT], + ), + ) if condition.state( self.hass, opening_entity, diff --git a/tests/test_cooler_mode.py b/tests/test_cooler_mode.py index 1b406f1..3a59296 100644 --- a/tests/test_cooler_mode.py +++ b/tests/test_cooler_mode.py @@ -39,6 +39,8 @@ from custom_components.dual_smart_thermostat.const import ( ATTR_HVAC_ACTION_REASON, + ATTR_HVAC_POWER_LEVEL, + ATTR_HVAC_POWER_PERCENT, ATTR_PREV_TARGET, DOMAIN, PRESET_ANTI_FREEZE, @@ -1230,7 +1232,7 @@ async def test_cooler_mode_opening_hvac_action_reason( # wait 10 seconds common.async_fire_time_changed( - hass, dt_util.utcnow() + datetime.timedelta(minutes=10) + hass, dt_util.utcnow() + datetime.timedelta(minutes=15) ) await hass.async_block_till_done() @@ -1248,6 +1250,100 @@ async def test_cooler_mode_opening_hvac_action_reason( ) +####################### +# HVAC POWER VALUES # +####################### + + +async def test_cooler_mode_hvac_power_value( + hass: HomeAssistant, setup_comp_1 # noqa: F811 +) -> None: + """Test thermostat cooler switch in cooling mode.""" + cooler_switch = "input_boolean.test" + opening_1 = "input_boolean.opening_1" + + assert await async_setup_component( + hass, + input_boolean.DOMAIN, + {"input_boolean": {"test": None, "opening_1": None}}, + ) + + assert await async_setup_component( + hass, + input_number.DOMAIN, + { + "input_number": { + "temp": {"name": "test", "initial": 10, "min": 0, "max": 40, "step": 1} + } + }, + ) + + assert await async_setup_component( + hass, + CLIMATE, + { + "climate": { + "platform": DOMAIN, + "name": "test", + "heater": cooler_switch, + "ac_mode": "true", + "target_sensor": common.ENT_SENSOR, + "initial_hvac_mode": HVACMode.COOL, + "hvac_power_levels": 5, + "openings": [opening_1], + } + }, + ) + await hass.async_block_till_done() + + assert hass.states.get(common.ENTITY).attributes.get(ATTR_HVAC_POWER_LEVEL) == 0 + assert hass.states.get(common.ENTITY).attributes.get(ATTR_HVAC_POWER_PERCENT) == 0 + + setup_sensor(hass, 23) + await hass.async_block_till_done() + + await common.async_set_temperature(hass, 18) + await hass.async_block_till_done() + + assert ( + hass.states.get(common.ENTITY).attributes.get("hvac_action") + == HVACAction.COOLING + ) + assert hass.states.get(common.ENTITY).attributes.get(ATTR_HVAC_POWER_LEVEL) == 5 + assert hass.states.get(common.ENTITY).attributes.get(ATTR_HVAC_POWER_PERCENT) == 100 + + setup_boolean(hass, opening_1, STATE_OPEN) + await hass.async_block_till_done() + + assert ( + hass.states.get(common.ENTITY).attributes.get("hvac_action") == HVACAction.IDLE + ) + assert hass.states.get(common.ENTITY).attributes.get(ATTR_HVAC_POWER_LEVEL) == 0 + assert hass.states.get(common.ENTITY).attributes.get(ATTR_HVAC_POWER_PERCENT) == 0 + + setup_boolean(hass, opening_1, STATE_CLOSED) + setup_sensor(hass, 17) + await hass.async_block_till_done() + + assert hass.states.get(cooler_switch).state == STATE_OFF + assert hass.states.get(common.ENTITY).attributes.get(ATTR_HVAC_POWER_LEVEL) == 0 + assert hass.states.get(common.ENTITY).attributes.get(ATTR_HVAC_POWER_PERCENT) == 0 + + setup_sensor(hass, 18.5) + await hass.async_block_till_done() + + assert hass.states.get(cooler_switch).state == STATE_ON + assert hass.states.get(common.ENTITY).attributes.get(ATTR_HVAC_POWER_LEVEL) == 2 + assert hass.states.get(common.ENTITY).attributes.get(ATTR_HVAC_POWER_PERCENT) == 50 + + await common.async_set_hvac_mode(hass, HVACMode.OFF) + await hass.async_block_till_done() + + assert hass.states.get(cooler_switch).state == STATE_OFF + assert hass.states.get(common.ENTITY).attributes.get(ATTR_HVAC_POWER_LEVEL) == 0 + assert hass.states.get(common.ENTITY).attributes.get(ATTR_HVAC_POWER_PERCENT) == 0 + + ############ # OPENINGS # ############