From a01f5770d989236148bc097284ffd9bc972c4e82 Mon Sep 17 00:00:00 2001 From: Jean-Marc Collin Date: Tue, 19 Dec 2023 19:39:33 +0000 Subject: [PATCH] =?UTF-8?q?FIX=20issue=20#272=20and=20#24=C3=A7=20-=20min?= =?UTF-8?q?=20and=20max=20values=20depending=20of=20the=20underlying?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .devcontainer/configuration.yaml | 4 +- .devcontainer/devcontainer.json | 7 +- .vscode/settings.json | 3 +- .../thermostat_climate.py | 24 +-- .../versatile_thermostat/thermostat_switch.py | 24 +-- .../versatile_thermostat/thermostat_valve.py | 104 +++++++------ .../versatile_thermostat/underlyings.py | 131 +++++++++++++---- tests/commons.py | 8 + tests/test_bugs.py | 137 ++++++++++++++++++ tests/test_valve.py | 48 +++++- 10 files changed, 381 insertions(+), 109 deletions(-) diff --git a/.devcontainer/configuration.yaml b/.devcontainer/configuration.yaml index a543fa5..455b418 100644 --- a/.devcontainer/configuration.yaml +++ b/.devcontainer/configuration.yaml @@ -59,8 +59,8 @@ input_number: unit_of_measurement: kW fake_valve1: name: The valve 1 - min: 0 - max: 100 + min: 10 + max: 90 icon: mdi:pipe-valve unit_of_measurement: percentage diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index cd93742..8e13a01 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -30,13 +30,8 @@ "waderyan.gitblame", "keesschollaart.vscode-home-assistant", "vscode.markdown-math", - "yzhang.markdown-all-in-one", - "ms-python.vscode-pylance" + "yzhang.markdown-all-in-one" ], - // "mounts": [ - // "source=${localWorkspaceFolder}/.devcontainer/configuration.yaml,target=${localWorkspaceFolder}/config/www/community/,type=bind,consistency=cached", - // "source=${localWorkspaceFolder}/custom_components,target=/home/vscode/core/config/custom_components,type=bind,consistency=cached" - // ], "settings": { "files.eol": "\n", "editor.tabSize": 4, diff --git a/.vscode/settings.json b/.vscode/settings.json index 4d5aa44..ef6794b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -14,7 +14,8 @@ "python.testing.pytestEnabled": true, "python.analysis.extraPaths": [ // "/home/vscode/core", - "/workspaces/versatile_thermostat/custom_components/versatile_thermostat" + "/workspaces/versatile_thermostat/custom_components/versatile_thermostat", + "/home/vscode/.local/lib/python3.11/site-packages/homeassistant" ], "python.formatting.provider": "none" } \ No newline at end of file diff --git a/custom_components/versatile_thermostat/thermostat_climate.py b/custom_components/versatile_thermostat/thermostat_climate.py index a4169b8..784e107 100644 --- a/custom_components/versatile_thermostat/thermostat_climate.py +++ b/custom_components/versatile_thermostat/thermostat_climate.py @@ -234,45 +234,45 @@ class ThermostatOverClimate(BaseThermostat): await self.async_set_fan_mode(self._auto_deactivated_fan_mode) @overrides - def post_init(self, entry_infos): + def post_init(self, config_entry): """Initialize the Thermostat""" - super().post_init(entry_infos) + super().post_init(config_entry) for climate in [ CONF_CLIMATE, CONF_CLIMATE_2, CONF_CLIMATE_3, CONF_CLIMATE_4, ]: - if entry_infos.get(climate): + if config_entry.get(climate): self._underlyings.append( UnderlyingClimate( hass=self._hass, thermostat=self, - climate_entity_id=entry_infos.get(climate), + climate_entity_id=config_entry.get(climate), ) ) self.choose_auto_regulation_mode( - entry_infos.get(CONF_AUTO_REGULATION_MODE) - if entry_infos.get(CONF_AUTO_REGULATION_MODE) is not None + config_entry.get(CONF_AUTO_REGULATION_MODE) + if config_entry.get(CONF_AUTO_REGULATION_MODE) is not None else CONF_AUTO_REGULATION_NONE ) self._auto_regulation_dtemp = ( - entry_infos.get(CONF_AUTO_REGULATION_DTEMP) - if entry_infos.get(CONF_AUTO_REGULATION_DTEMP) is not None + config_entry.get(CONF_AUTO_REGULATION_DTEMP) + if config_entry.get(CONF_AUTO_REGULATION_DTEMP) is not None else 0.5 ) self._auto_regulation_period_min = ( - entry_infos.get(CONF_AUTO_REGULATION_PERIOD_MIN) - if entry_infos.get(CONF_AUTO_REGULATION_PERIOD_MIN) is not None + config_entry.get(CONF_AUTO_REGULATION_PERIOD_MIN) + if config_entry.get(CONF_AUTO_REGULATION_PERIOD_MIN) is not None else 5 ) self._auto_fan_mode = ( - entry_infos.get(CONF_AUTO_FAN_MODE) - if entry_infos.get(CONF_AUTO_FAN_MODE) is not None + config_entry.get(CONF_AUTO_FAN_MODE) + if config_entry.get(CONF_AUTO_FAN_MODE) is not None else CONF_AUTO_FAN_NONE ) diff --git a/custom_components/versatile_thermostat/thermostat_switch.py b/custom_components/versatile_thermostat/thermostat_switch.py index ff8d5a0..ff50a41 100644 --- a/custom_components/versatile_thermostat/thermostat_switch.py +++ b/custom_components/versatile_thermostat/thermostat_switch.py @@ -48,9 +48,9 @@ class ThermostatOverSwitch(BaseThermostat): ) # useless for now - # def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None: + # def __init__(self, hass: HomeAssistant, unique_id, name, config_entry) -> None: # """Initialize the thermostat over switch.""" - # super().__init__(hass, unique_id, name, entry_infos) + # super().__init__(hass, unique_id, name, config_entry) _is_inversed: bool = None @property @@ -72,10 +72,10 @@ class ThermostatOverSwitch(BaseThermostat): return None @overrides - def post_init(self, entry_infos): + def post_init(self, config_entry): """Initialize the Thermostat""" - super().post_init(entry_infos) + super().post_init(config_entry) self._prop_algorithm = PropAlgorithm( self._proportional_function, @@ -85,13 +85,13 @@ class ThermostatOverSwitch(BaseThermostat): self._minimal_activation_delay, ) - lst_switches = [entry_infos.get(CONF_HEATER)] - if entry_infos.get(CONF_HEATER_2): - lst_switches.append(entry_infos.get(CONF_HEATER_2)) - if entry_infos.get(CONF_HEATER_3): - lst_switches.append(entry_infos.get(CONF_HEATER_3)) - if entry_infos.get(CONF_HEATER_4): - lst_switches.append(entry_infos.get(CONF_HEATER_4)) + lst_switches = [config_entry.get(CONF_HEATER)] + if config_entry.get(CONF_HEATER_2): + lst_switches.append(config_entry.get(CONF_HEATER_2)) + if config_entry.get(CONF_HEATER_3): + lst_switches.append(config_entry.get(CONF_HEATER_3)) + if config_entry.get(CONF_HEATER_4): + lst_switches.append(config_entry.get(CONF_HEATER_4)) delta_cycle = self._cycle_min * 60 / len(lst_switches) for idx, switch in enumerate(lst_switches): @@ -104,7 +104,7 @@ class ThermostatOverSwitch(BaseThermostat): ) ) - self._is_inversed = entry_infos.get(CONF_INVERSE_SWITCH) is True + self._is_inversed = config_entry.get(CONF_INVERSE_SWITCH) is True self._should_relaunch_control_heating = False @overrides diff --git a/custom_components/versatile_thermostat/thermostat_valve.py b/custom_components/versatile_thermostat/thermostat_valve.py index 17d1933..f246a55 100644 --- a/custom_components/versatile_thermostat/thermostat_valve.py +++ b/custom_components/versatile_thermostat/thermostat_valve.py @@ -3,7 +3,10 @@ import logging from datetime import timedelta -from homeassistant.helpers.event import async_track_state_change_event, async_track_time_interval +from homeassistant.helpers.event import ( + async_track_state_change_event, + async_track_time_interval, +) from homeassistant.core import callback from homeassistant.components.climate import HVACMode @@ -16,39 +19,53 @@ from .underlyings import UnderlyingValve _LOGGER = logging.getLogger(__name__) + class ThermostatOverValve(BaseThermostat): """Representation of a class for a Versatile Thermostat over a Valve""" - _entity_component_unrecorded_attributes = BaseThermostat._entity_component_unrecorded_attributes.union(frozenset( - { - "is_over_valve", "underlying_valve_0", "underlying_valve_1", - "underlying_valve_2", "underlying_valve_3", "on_time_sec", "off_time_sec", - "cycle_min", "function", "tpi_coef_int", "tpi_coef_ext" - })) + _entity_component_unrecorded_attributes = ( + BaseThermostat._entity_component_unrecorded_attributes.union( + frozenset( + { + "is_over_valve", + "underlying_valve_0", + "underlying_valve_1", + "underlying_valve_2", + "underlying_valve_3", + "on_time_sec", + "off_time_sec", + "cycle_min", + "function", + "tpi_coef_int", + "tpi_coef_ext", + } + ) + ) + ) # Useless for now - # def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None: + # def __init__(self, hass: HomeAssistant, unique_id, name, config_entry) -> None: # """Initialize the thermostat over switch.""" - # super().__init__(hass, unique_id, name, entry_infos) + # super().__init__(hass, unique_id, name, config_entry) @property def is_over_valve(self) -> bool: - """ True if the Thermostat is over_valve""" + """True if the Thermostat is over_valve""" return True @property def valve_open_percent(self) -> int: - """ Gives the percentage of valve needed""" + """Gives the percentage of valve needed""" if self._hvac_mode == HVACMode.OFF: return 0 else: return round(max(0, min(self.proportional_algorithm.on_percent, 1)) * 100) @overrides - def post_init(self, entry_infos): - """ Initialize the Thermostat""" + def post_init(self, config_entry): + """Initialize the Thermostat""" - super().post_init(entry_infos) + super().post_init(config_entry) self._prop_algorithm = PropAlgorithm( self._proportional_function, self._tpi_coef_int, @@ -57,21 +74,17 @@ class ThermostatOverValve(BaseThermostat): self._minimal_activation_delay, ) - lst_valves = [entry_infos.get(CONF_VALVE)] - if entry_infos.get(CONF_VALVE_2): - lst_valves.append(entry_infos.get(CONF_VALVE_2)) - if entry_infos.get(CONF_VALVE_3): - lst_valves.append(entry_infos.get(CONF_VALVE_3)) - if entry_infos.get(CONF_VALVE_4): - lst_valves.append(entry_infos.get(CONF_VALVE_4)) + lst_valves = [config_entry.get(CONF_VALVE)] + if config_entry.get(CONF_VALVE_2): + lst_valves.append(config_entry.get(CONF_VALVE_2)) + if config_entry.get(CONF_VALVE_3): + lst_valves.append(config_entry.get(CONF_VALVE_3)) + if config_entry.get(CONF_VALVE_4): + lst_valves.append(config_entry.get(CONF_VALVE_4)) for _, valve in enumerate(lst_valves): self._underlyings.append( - UnderlyingValve( - hass=self._hass, - thermostat=self, - valve_entity_id=valve - ) + UnderlyingValve(hass=self._hass, thermostat=self, valve_entity_id=valve) ) self._should_relaunch_control_heating = False @@ -89,7 +102,7 @@ class ThermostatOverValve(BaseThermostat): async_track_state_change_event( self.hass, [valve.entity_id], self._async_valve_changed ) - ) + ) # Start the control_heating # starts a cycle @@ -107,29 +120,34 @@ class ThermostatOverValve(BaseThermostat): This method just log the change. It changes nothing to avoid loops. """ new_state = event.data.get("new_state") - _LOGGER.debug("%s - _async_valve_changed new_state is %s", self, new_state.state) + _LOGGER.debug( + "%s - _async_valve_changed new_state is %s", self, new_state.state + ) @overrides def update_custom_attributes(self): - """ Custom attributes """ + """Custom attributes""" super().update_custom_attributes() - self._attr_extra_state_attributes["valve_open_percent"] = self.valve_open_percent + self._attr_extra_state_attributes[ + "valve_open_percent" + ] = self.valve_open_percent self._attr_extra_state_attributes["is_over_valve"] = self.is_over_valve - self._attr_extra_state_attributes["underlying_valve_0"] = ( - self._underlyings[0].entity_id) + self._attr_extra_state_attributes["underlying_valve_0"] = self._underlyings[ + 0 + ].entity_id self._attr_extra_state_attributes["underlying_valve_1"] = ( - self._underlyings[1].entity_id if len(self._underlyings) > 1 else None - ) + self._underlyings[1].entity_id if len(self._underlyings) > 1 else None + ) self._attr_extra_state_attributes["underlying_valve_2"] = ( - self._underlyings[2].entity_id if len(self._underlyings) > 2 else None - ) + self._underlyings[2].entity_id if len(self._underlyings) > 2 else None + ) self._attr_extra_state_attributes["underlying_valve_3"] = ( - self._underlyings[3].entity_id if len(self._underlyings) > 3 else None - ) + self._underlyings[3].entity_id if len(self._underlyings) > 3 else None + ) self._attr_extra_state_attributes[ - "on_percent" - ] = self._prop_algorithm.on_percent + "on_percent" + ] = self._prop_algorithm.on_percent self._attr_extra_state_attributes[ "on_time_sec" ] = self._prop_algorithm.on_time_sec @@ -162,9 +180,7 @@ class ThermostatOverValve(BaseThermostat): ) for under in self._underlyings: - under.set_valve_open_percent( - self._prop_algorithm.on_percent - ) + under.set_valve_open_percent() self.update_custom_attributes() self.async_write_ha_state() @@ -185,4 +201,4 @@ class ThermostatOverValve(BaseThermostat): self, added_energy, self._total_energy, - ) \ No newline at end of file + ) diff --git a/custom_components/versatile_thermostat/underlyings.py b/custom_components/versatile_thermostat/underlyings.py index 9a082a5..75cf83e 100644 --- a/custom_components/versatile_thermostat/underlyings.py +++ b/custom_components/versatile_thermostat/underlyings.py @@ -6,6 +6,7 @@ from typing import Any from enum import StrEnum from homeassistant.const import ATTR_ENTITY_ID, STATE_ON, UnitOfTemperature +from homeassistant.core import State from homeassistant.exceptions import ServiceNotFound @@ -111,18 +112,18 @@ class UnderlyingEntity: # This should be the correct way to handle turn_off and turn_on but this breaks the unit test # will an not understandable error: TypeError: object MagicMock can't be used in 'await' expression async def turn_off(self): - """ Turn off the underlying equipement. - Need to be overriden""" + """Turn off the underlying equipement. + Need to be overriden""" return NotImplementedError async def turn_on(self): - """ Turn off the underlying equipement. - Need to be overriden""" + """Turn off the underlying equipement. + Need to be overriden""" return NotImplementedError @property def is_inversed(self): - """ Tells if the switch command should be inversed""" + """Tells if the switch command should be inversed""" return False def remove_entity(self): @@ -164,7 +165,11 @@ class UnderlyingEntity: """Starting cycle for switch""" def _cancel_cycle(self): - """ Stops an eventual cycle """ + """Stops an eventual cycle""" + + def cap_sent_value(self, value) -> float: + """capping of the value send to the underlying eqt""" + return value class UnderlyingSwitch(UnderlyingEntity): @@ -205,7 +210,7 @@ class UnderlyingSwitch(UnderlyingEntity): @overrides @property def is_inversed(self): - """ Tells if the switch command should be inversed""" + """Tells if the switch command should be inversed""" return self._thermostat.is_inversed # @overrides this breaks some unit tests TypeError: object MagicMock can't be used in 'await' expression @@ -227,14 +232,16 @@ class UnderlyingSwitch(UnderlyingEntity): def is_device_active(self): """If the toggleable device is currently active.""" real_state = self._hass.states.is_state(self._entity_id, STATE_ON) - return (self.is_inversed and not real_state) or (not self.is_inversed and real_state) + return (self.is_inversed and not real_state) or ( + not self.is_inversed and real_state + ) # @overrides this breaks some unit tests TypeError: object MagicMock can't be used in 'await' expression async def turn_off(self): """Turn heater toggleable device off.""" _LOGGER.debug("%s - Stopping underlying entity %s", self, self._entity_id) command = SERVICE_TURN_OFF if not self.is_inversed else SERVICE_TURN_ON - domain = self._entity_id.split('.')[0] + domain = self._entity_id.split(".")[0] # This may fails if called after shutdown try: data = {ATTR_ENTITY_ID: self._entity_id} @@ -250,7 +257,7 @@ class UnderlyingSwitch(UnderlyingEntity): """Turn heater toggleable device on.""" _LOGGER.debug("%s - Starting underlying entity %s", self, self._entity_id) command = SERVICE_TURN_ON if not self.is_inversed else SERVICE_TURN_OFF - domain = self._entity_id.split('.')[0] + domain = self._entity_id.split(".")[0] try: data = {ATTR_ENTITY_ID: self._entity_id} await self._hass.services.async_call( @@ -261,7 +268,6 @@ class UnderlyingSwitch(UnderlyingEntity): except ServiceNotFound as err: _LOGGER.error(err) - @overrides async def start_cycle( self, @@ -490,10 +496,14 @@ class UnderlyingClimate(UnderlyingEntity): def is_device_active(self): """If the toggleable device is currently active.""" if self.is_initialized: - return self._underlying_climate.hvac_mode != HVACMode.OFF and self._underlying_climate.hvac_action not in [ - HVACAction.IDLE, - HVACAction.OFF, - ] + return ( + self._underlying_climate.hvac_mode != HVACMode.OFF + and self._underlying_climate.hvac_action + not in [ + HVACAction.IDLE, + HVACAction.OFF, + ] + ) else: return None @@ -550,7 +560,7 @@ class UnderlyingClimate(UnderlyingEntity): return data = { ATTR_ENTITY_ID: self._entity_id, - "temperature": temperature, + "temperature": self.cap_sent_value(temperature), "target_temp_high": max_temp, "target_temp_low": min_temp, } @@ -664,6 +674,40 @@ class UnderlyingClimate(UnderlyingEntity): return None return self._underlying_climate.turn_aux_heat_off() + @overrides + def cap_sent_value(self, value) -> float: + """Try to adapt the target temp value to the min_temp / max_temp found + in the underlying entity (if any)""" + + if not self.is_initialized: + return value + + # Gets the min_temp and max_temp + if ( + self._underlying_climate.min_temp is not None + and self._underlying_climate is not None + ): + min_val = self._underlying_climate.min_temp + max_val = self._underlying_climate.max_temp + + new_value = round(max(min_val, min(value, max_val))) + else: + _LOGGER.debug("%s - no min and max attributes on underlying", self) + new_value = value + + if new_value != value: + _LOGGER.info( + "%s - Target temp have been updated due min, max of the underlying entity. new_value=%.0f value=%.0f min=%.0f max=%.0f", + self, + new_value, + value, + min_val, + max_val, + ) + + return new_value + + class UnderlyingValve(UnderlyingEntity): """Represent a underlying switch""" @@ -672,10 +716,7 @@ class UnderlyingValve(UnderlyingEntity): _percent_open: int def __init__( - self, - hass: HomeAssistant, - thermostat: Any, - valve_entity_id: str + self, hass: HomeAssistant, thermostat: Any, valve_entity_id: str ) -> None: """Initialize the underlying switch""" @@ -689,13 +730,14 @@ class UnderlyingValve(UnderlyingEntity): self._should_relaunch_control_heating = False self._hvac_mode = None self._percent_open = self._thermostat.valve_open_percent + self._valve_entity_id = valve_entity_id async def send_percent_open(self): - """ Send the percent open to the underlying valve """ + """Send the percent open to the underlying valve""" # This may fails if called after shutdown try: - data = { ATTR_ENTITY_ID: self._entity_id, "value": self._percent_open } - domain = self._entity_id.split('.')[0] + data = {ATTR_ENTITY_ID: self._entity_id, "value": self._percent_open} + domain = self._entity_id.split(".")[0] await self._hass.services.async_call( domain, SERVICE_SET_VALUE, @@ -734,7 +776,7 @@ class UnderlyingValve(UnderlyingEntity): # To test if real device is open but this is causing some side effect # because the activation can be deferred - # or float(self._hass.states.get(self._entity_id).state) > 0 - except Exception: # pylint: disable=broad-exception-caught + except Exception: # pylint: disable=broad-exception-caught return False @overrides @@ -750,9 +792,40 @@ class UnderlyingValve(UnderlyingEntity): if force: await self.send_percent_open() - def set_valve_open_percent(self, percent): - """ Update the valve open percent """ - caped_val = self._thermostat.valve_open_percent + @overrides + def cap_sent_value(self, value) -> float: + """Try to adapt the open_percent value to the min / max found + in the underlying entity (if any)""" + + # Gets the last number state + valve_state: State = self._hass.states.get(self._valve_entity_id) + if valve_state is None: + return value + + if "min" in valve_state.attributes and "max" in valve_state.attributes: + min_val = valve_state.attributes["min"] + max_val = valve_state.attributes["max"] + + new_value = round(max(min_val, min(value, max_val))) + else: + _LOGGER.debug("%s - no min and max attributes on underlying", self) + new_value = value + + if new_value != value: + _LOGGER.info( + "%s - Valve open percent have been updated due min, max of the underlying entity. new_value=%.0f value=%.0f min=%.0f max=%.0f", + self, + new_value, + value, + min_val, + max_val, + ) + + return new_value + + def set_valve_open_percent(self): + """Update the valve open percent""" + caped_val = self.cap_sent_value(self._thermostat.valve_open_percent) if self._percent_open == caped_val: # No changes return @@ -760,7 +833,9 @@ class UnderlyingValve(UnderlyingEntity): self._percent_open = caped_val # Send the new command to valve via a service call - _LOGGER.info("%s - Setting valve ouverture percent to %s", self, self._percent_open) + _LOGGER.info( + "%s - Setting valve ouverture percent to %s", self, self._percent_open + ) # Send the change to the valve, in background self._hass.create_task(self.send_percent_open()) diff --git a/tests/commons.py b/tests/commons.py index fad70d0..c7ef56f 100644 --- a/tests/commons.py +++ b/tests/commons.py @@ -336,6 +336,14 @@ class MagicMockClimate(MagicMock): def supported_features(self): # pylint: disable=missing-function-docstring return ClimateEntityFeature.TARGET_TEMPERATURE + @property + def min_temp(self): # pylint: disable=missing-function-docstring + return 15 + + @property + def max_temp(self): # pylint: disable=missing-function-docstring + return 19 + async def create_thermostat( hass: HomeAssistant, entry: MockConfigEntry, entity_id: str diff --git a/tests/test_bugs.py b/tests/test_bugs.py index 00b6fac..d7cdda9 100644 --- a/tests/test_bugs.py +++ b/tests/test_bugs.py @@ -6,6 +6,10 @@ from datetime import datetime, timedelta import logging +from homeassistant.components.climate import ( + SERVICE_SET_TEMPERATURE, +) + from .commons import * logging.getLogger().setLevel(logging.DEBUG) @@ -568,3 +572,136 @@ async def test_bug_101( ) assert entity.target_temperature == 12.75 assert entity.preset_mode is PRESET_NONE + + +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize("expected_lingering_timers", [True]) +async def test_bug_272( + hass: HomeAssistant, + skip_hass_states_is_state, + skip_turn_on_off_heater, + skip_send_event, +): + """Test that it not possible to set the target temperature under the min_temp setting""" + + tz = get_tz(hass) # pylint: disable=invalid-name + now: datetime = datetime.now(tz=tz) + + entry = MockConfigEntry( + domain=DOMAIN, + title="TheOverClimateMockName", + unique_id="uniqueId", + data=PARTIAL_CLIMATE_CONFIG, # 5 minutes security delay + ) + + # Min_temp is 15 and max_temp is 19 + fake_underlying_climate = MagicMockClimate() + + with patch( + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" + ), patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate", + return_value=fake_underlying_climate, + ), patch( + "homeassistant.core.ServiceRegistry.async_call" + ) as mock_service_call: + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state is ConfigEntryState.LOADED + + def find_my_entity(entity_id) -> ClimateEntity: + """Find my new entity""" + component: EntityComponent[ClimateEntity] = hass.data[CLIMATE_DOMAIN] + for entity in component.entities: + if entity.entity_id == entity_id: + return entity + + entity = find_my_entity("climate.theoverclimatemockname") + + assert entity + + assert entity.name == "TheOverClimateMockName" + assert entity.is_over_climate is True + assert entity.hvac_mode is HVACMode.OFF + assert entity.target_temperature == entity.min_temp + assert entity.is_regulated is True + + assert mock_service_call.call_count == 0 + + # Set the hvac_mode to HEAT + entity.async_set_hvac_mode(HVACMode.HEAT) + + # In the accepted interval + await entity.async_set_temperature(temperature=17) + assert mock_service_call.call_count == 1 + mock_service_call.assert_has_calls( + [ + call.async_call( + "climate", + SERVICE_SET_TEMPERATURE, + { + "entity_id": "climate.mock_climate", + "temperature": 17, + "target_temp_high": 30, + "target_temp_low": 15, + }, + ), + ] + ) + + tz = get_tz(hass) # pylint: disable=invalid-name + now: datetime = datetime.now(tz=tz) + + with patch( + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" + ), patch("homeassistant.core.ServiceRegistry.async_call") as mock_service_call: + # Set room temperature to something very cold + event_timestamp = now + timedelta(minutes=1) + + await send_temperature_change_event(entity, 13, event_timestamp) + await send_ext_temperature_change_event(entity, 9, event_timestamp) + + # In the accepted interval + await entity.async_set_temperature(temperature=10) + assert mock_service_call.call_count == 1 + mock_service_call.assert_has_calls( + [ + call.async_call( + "climate", + SERVICE_SET_TEMPERATURE, + { + "entity_id": "climate.mock_climate", + "temperature": 15, # the minimum acceptable + "target_temp_high": 30, + "target_temp_low": 15, + }, + ), + ] + ) + + with patch( + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" + ), patch("homeassistant.core.ServiceRegistry.async_call") as mock_service_call: + # Set room temperature to something very cold + event_timestamp = now + timedelta(minutes=1) + + await send_temperature_change_event(entity, 13, event_timestamp) + await send_ext_temperature_change_event(entity, 9, event_timestamp) + + # In the accepted interval + await entity.async_set_temperature(temperature=20) + assert mock_service_call.call_count == 1 + mock_service_call.assert_has_calls( + [ + call.async_call( + "climate", + SERVICE_SET_TEMPERATURE, + { + "entity_id": "climate.mock_climate", + "temperature": 19, # the maximum acceptable + "target_temp_high": 30, + "target_temp_low": 15, + }, + ), + ] + ) diff --git a/tests/test_valve.py b/tests/test_valve.py index 63a3045..2807e0e 100644 --- a/tests/test_valve.py +++ b/tests/test_valve.py @@ -4,7 +4,7 @@ from unittest.mock import patch, call from datetime import datetime, timedelta -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, State from homeassistant.components.climate import HVACAction, HVACMode from homeassistant.config_entries import ConfigEntryState @@ -214,20 +214,60 @@ async def test_over_valve_full_start( assert entity.hvac_action == HVACAction.HEATING # Change internal temperature + expected_state = State( + entity_id="number.mock_valve", state="0", attributes={"min": 10, "max": 50} + ) + with patch( "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event, patch( "homeassistant.core.ServiceRegistry.async_call" ) as mock_service_call, patch( - "homeassistant.core.StateMachine.get", return_value=0 + "homeassistant.core.StateMachine.get", return_value=expected_state ): event_timestamp = now - timedelta(minutes=3) await send_temperature_change_event(entity, 20, datetime.now()) assert entity.valve_open_percent == 0 - assert entity.is_device_active is False - assert entity.hvac_action == HVACAction.IDLE + assert entity.is_device_active is True # Should be 0 but in fact 10 is send + assert ( + entity.hvac_action == HVACAction.HEATING + ) # Should be IDLE but heating due to 10 + + assert mock_service_call.call_count == 1 + # The VTherm valve is 0, but the underlying have received 10 which is the min + mock_service_call.assert_has_calls( + [ + call.async_call( + "number", + "set_value", + {"entity_id": "number.mock_valve", "value": 10}, + ) + ] + ) await send_temperature_change_event(entity, 17, datetime.now()) + assert mock_service_call.call_count == 2 + # The VTherm valve is 0, but the underlying have received 10 which is the min + mock_service_call.assert_has_calls( + [ + call.async_call( + "number", + "set_value", + { + "entity_id": "number.mock_valve", + "value": 10, + }, # the min allowed value + ), + call.async_call( + "number", + "set_value", + { + "entity_id": "number.mock_valve", + "value": 50, + }, # the max allowed value + ), + ] + ) # switch to Eco await entity.async_set_preset_mode(PRESET_ECO) assert entity.preset_mode is PRESET_ECO