From 6886dd6fb595f39c6ff2355f2a806bfc62de8e2f Mon Sep 17 00:00:00 2001 From: Jean-Marc Collin Date: Mon, 30 Oct 2023 14:51:24 +0000 Subject: [PATCH] Implementation of regulation --- .../versatile_thermostat/sensor.py | 55 +++++ .../thermostat_climate.py | 6 +- tests/commons.py | 30 ++- tests/const.py | 15 +- tests/test_auto_regulation.py | 210 ++++++++++++++++++ tests/test_bugs.py | 7 +- tests/test_security.py | 20 +- 7 files changed, 326 insertions(+), 17 deletions(-) create mode 100644 tests/test_auto_regulation.py diff --git a/custom_components/versatile_thermostat/sensor.py b/custom_components/versatile_thermostat/sensor.py index 13cc18b..0f0d1a1 100644 --- a/custom_components/versatile_thermostat/sensor.py +++ b/custom_components/versatile_thermostat/sensor.py @@ -11,6 +11,7 @@ from homeassistant.components.sensor import ( SensorEntity, SensorDeviceClass, SensorStateClass, + UnitOfTemperature ) from homeassistant.config_entries import ConfigEntry @@ -24,6 +25,7 @@ from .const import ( PROPORTIONAL_FUNCTION_TPI, CONF_THERMOSTAT_SWITCH, CONF_THERMOSTAT_VALVE, + CONF_THERMOSTAT_CLIMATE, CONF_THERMOSTAT_TYPE, ) @@ -63,6 +65,9 @@ async def async_setup_entry( if entry.data.get(CONF_THERMOSTAT_TYPE) == CONF_THERMOSTAT_VALVE: entities.append(ValveOpenPercentSensor(hass, unique_id, name, entry.data)) + if entry.data.get(CONF_THERMOSTAT_TYPE) == CONF_THERMOSTAT_CLIMATE: + entities.append(RegulatedTemperatureSensor(hass, unique_id, name, entry.data)) + async_add_entities(entities, True) @@ -470,3 +475,53 @@ class TemperatureSlopeSensor(VersatileThermostatBaseEntity, SensorEntity): def suggested_display_precision(self) -> int | None: """Return the suggested number of decimal digits for display.""" return 2 + +class RegulatedTemperatureSensor(VersatileThermostatBaseEntity, SensorEntity): + """Representation of a Energy sensor which exposes the energy""" + + def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None: + """Initialize the regulated temperature sensor""" + super().__init__(hass, unique_id, entry_infos.get(CONF_NAME)) + self._attr_name = "Regulated temperature" + self._attr_unique_id = f"{self._device_name}_regulated_temperature" + + @callback + async def async_my_climate_changed(self, event: Event = None): + """Called when my climate have change""" + _LOGGER.debug("%s - climate state change", self._attr_unique_id) + + if math.isnan(self.my_climate.regulated_target_temp) or math.isinf( + self.my_climate.regulated_target_temp + ): + raise ValueError(f"Sensor has illegal state {self.my_climate.regulated_target_temp}") + + old_state = self._attr_native_value + self._attr_native_value = round( + self.my_climate.regulated_target_temp, self.suggested_display_precision + ) + if old_state != self._attr_native_value: + self.async_write_ha_state() + return + + @property + def icon(self) -> str | None: + return "mdi:thermometer-auto" + + @property + def device_class(self) -> SensorDeviceClass | None: + return SensorDeviceClass.TEMPERATURE + + @property + def state_class(self) -> SensorStateClass | None: + return SensorStateClass.MEASUREMENT + + @property + def native_unit_of_measurement(self) -> str | None: + if not self.my_climate: + return UnitOfTemperature.CELSIUS + return self.my_climate.temperature_unit + + @property + def suggested_display_precision(self) -> int | None: + """Return the suggested number of decimal digits for display.""" + return 1 diff --git a/custom_components/versatile_thermostat/thermostat_climate.py b/custom_components/versatile_thermostat/thermostat_climate.py index 7923b57..5237f3a 100644 --- a/custom_components/versatile_thermostat/thermostat_climate.py +++ b/custom_components/versatile_thermostat/thermostat_climate.py @@ -40,7 +40,7 @@ class ThermostatOverClimate(BaseThermostat): _entity_component_unrecorded_attributes = BaseThermostat._entity_component_unrecorded_attributes.union(frozenset( { "is_over_climate", "start_hvac_action_date", "underlying_climate_0", "underlying_climate_1", - "underlying_climate_2", "underlying_climate_3" + "underlying_climate_2", "underlying_climate_3", "regulation_accumulated_error" })) def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None: @@ -197,6 +197,7 @@ class ThermostatOverClimate(BaseThermostat): if self.is_regulated: self._attr_extra_state_attributes["regulated_target_temp"] = self._regulated_target_temp + self._attr_extra_state_attributes["regulation_accumulated_error"] = self._regulation_algo.accumulated_error self.async_write_ha_state() _LOGGER.debug( @@ -403,7 +404,8 @@ class ThermostatOverClimate(BaseThermostat): new_state.attributes, ) if ( - self.is_over_climate + # we do not change target temperature on regulated VTherm + not self.is_regulated and new_state.attributes and (new_target_temp := new_state.attributes.get("temperature")) and new_target_temp != self.target_temperature diff --git a/tests/commons.py b/tests/commons.py index adc2047..9aedd3a 100644 --- a/tests/commons.py +++ b/tests/commons.py @@ -34,6 +34,8 @@ from .const import ( # pylint: disable=unused-import MOCK_TH_OVER_SWITCH_AC_TYPE_CONFIG, MOCK_TH_OVER_4SWITCH_TYPE_CONFIG, MOCK_TH_OVER_CLIMATE_TYPE_CONFIG, + MOCK_TH_OVER_CLIMATE_TYPE_AC_CONFIG, + MOCK_TH_OVER_CLIMATE_TYPE_NOT_REGULATED_CONFIG, MOCK_TH_OVER_SWITCH_TPI_CONFIG, MOCK_PRESETS_CONFIG, MOCK_PRESETS_AC_CONFIG, @@ -83,6 +85,20 @@ PARTIAL_CLIMATE_CONFIG = ( | MOCK_ADVANCED_CONFIG ) +PARTIAL_CLIMATE_NOT_REGULATED_CONFIG = ( + MOCK_TH_OVER_CLIMATE_USER_CONFIG + | MOCK_TH_OVER_CLIMATE_TYPE_NOT_REGULATED_CONFIG + | MOCK_PRESETS_CONFIG + | MOCK_ADVANCED_CONFIG +) + +PARTIAL_CLIMATE_AC_CONFIG = ( + MOCK_TH_OVER_CLIMATE_USER_CONFIG + | MOCK_TH_OVER_CLIMATE_TYPE_AC_CONFIG + | MOCK_PRESETS_CONFIG + | MOCK_ADVANCED_CONFIG +) + FULL_4SWITCH_CONFIG = ( MOCK_TH_OVER_4SWITCH_USER_CONFIG | MOCK_TH_OVER_4SWITCH_TYPE_CONFIG @@ -101,7 +117,7 @@ _LOGGER = logging.getLogger(__name__) class MockClimate(ClimateEntity): """A Mock Climate class used for Underlying climate mode""" - def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos, hvac_mode:HVACMode = HVACMode.OFF) -> None: # pylint: disable=unused-argument + def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos, hvac_mode:HVACMode = HVACMode.OFF, hvac_action:HVACAction = HVACAction.OFF) -> None: # pylint: disable=unused-argument """Initialize the thermostat.""" super().__init__() @@ -118,17 +134,25 @@ class MockClimate(ClimateEntity): self._attr_temperature_unit = UnitOfTemperature.CELSIUS self._attr_target_temperature = 20 self._attr_current_temperature = 15 + self._attr_hvac_action = hvac_action def set_temperature(self, **kwargs): """ Set the target temperature""" temperature = kwargs.get(ATTR_TEMPERATURE) self._attr_target_temperature = temperature - self.async_write_ha_state() async def async_set_hvac_mode(self, hvac_mode): """ The hvac mode""" self._attr_hvac_mode = hvac_mode - self.async_write_ha_state() + + @property + def hvac_action(self): + """ The hvac action of the mock climate""" + return self._attr_hvac_action + + def set_hvac_action(self, hvac_action: HVACAction): + """ Set the HVACaction """ + self._attr_hvac_action = hvac_action class MockUnavailableClimate(ClimateEntity): """A Mock Climate class used for Underlying climate mode""" diff --git a/tests/const.py b/tests/const.py index b1762a7..f12db1e 100644 --- a/tests/const.py +++ b/tests/const.py @@ -51,7 +51,8 @@ from custom_components.versatile_thermostat.const import ( PRESET_AWAY_SUFFIX, CONF_CLIMATE, CONF_AUTO_REGULATION_MODE, - CONF_AUTO_REGULATION_MEDIUM + CONF_AUTO_REGULATION_MEDIUM, + CONF_AUTO_REGULATION_NONE, ) MOCK_TH_OVER_SWITCH_USER_CONFIG = { CONF_NAME: "TheOverSwitchMockName", @@ -127,6 +128,18 @@ MOCK_TH_OVER_CLIMATE_TYPE_CONFIG = { CONF_AUTO_REGULATION_MODE: CONF_AUTO_REGULATION_MEDIUM } +MOCK_TH_OVER_CLIMATE_TYPE_NOT_REGULATED_CONFIG = { + CONF_CLIMATE: "climate.mock_climate", + CONF_AC_MODE: False, + CONF_AUTO_REGULATION_MODE: CONF_AUTO_REGULATION_NONE +} + +MOCK_TH_OVER_CLIMATE_TYPE_AC_CONFIG = { + CONF_CLIMATE: "climate.mock_climate", + CONF_AC_MODE: True, + CONF_AUTO_REGULATION_MODE: CONF_AUTO_REGULATION_MEDIUM +} + MOCK_PRESETS_CONFIG = { PRESET_ECO + "_temp": 16, PRESET_COMFORT + "_temp": 17, diff --git a/tests/test_auto_regulation.py b/tests/test_auto_regulation.py new file mode 100644 index 0000000..5851134 --- /dev/null +++ b/tests/test_auto_regulation.py @@ -0,0 +1,210 @@ +# pylint: disable=wildcard-import, unused-wildcard-import, protected-access, unused-argument, line-too-long + +""" Test the normal start of a Thermostat """ +from unittest.mock import patch #, call +from datetime import datetime, timedelta + +from homeassistant.core import HomeAssistant +from homeassistant.components.climate import HVACAction, HVACMode +from homeassistant.config_entries import ConfigEntryState + +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.components.climate import ClimateEntity, DOMAIN as CLIMATE_DOMAIN + +from pytest_homeassistant_custom_component.common import MockConfigEntry + +# from custom_components.versatile_thermostat.base_thermostat import BaseThermostat +from custom_components.versatile_thermostat.thermostat_climate import ThermostatOverClimate + +from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import + +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize("expected_lingering_timers", [True]) +async def test_over_climate_regulation(hass: HomeAssistant, skip_hass_states_is_state): + """Test the regulation of an over climate thermostat""" + + entry = MockConfigEntry( + domain=DOMAIN, + title="TheOverClimateMockName", + unique_id="uniqueId", + # This is include a medium regulation + data=PARTIAL_CLIMATE_CONFIG, + ) + + tz = get_tz(hass) # pylint: disable=invalid-name + now: datetime = datetime.now(tz=tz) + + fake_underlying_climate = MockClimate(hass, "mockUniqueId", "MockClimateName", {}) + + # Creates the regulated VTherm over climate + 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, + ): + 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:ThermostatOverClimate = find_my_entity("climate.theoverclimatemockname") + + assert entity + assert isinstance(entity, ThermostatOverClimate) + + assert entity.name == "TheOverClimateMockName" + assert entity.is_over_climate is True + assert entity.is_regulated is True + assert entity.hvac_action is HVACAction.OFF + assert entity.hvac_mode is HVACMode.OFF + assert entity.target_temperature == entity.min_temp + assert entity.preset_modes == [ + PRESET_NONE, + PRESET_ECO, + PRESET_COMFORT, + PRESET_BOOST, + ] + assert entity.preset_mode is PRESET_NONE + + # Activate the heating by changing HVACMode and temperature + with patch( + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" + ): + # Select a hvacmode, presence and preset + await entity.async_set_hvac_mode(HVACMode.HEAT) + assert entity.hvac_mode is HVACMode.HEAT + assert entity.hvac_action == HVACAction.OFF + + # change temperature so that the heating will start + event_timestamp = now - timedelta(minutes=10) + await send_temperature_change_event(entity, 15, event_timestamp) + await send_ext_temperature_change_event(entity, 10, event_timestamp) + + + # set manual target temp + await entity.async_set_temperature(temperature=18) + + fake_underlying_climate.set_hvac_action(HVACAction.HEATING) # simulate under heating + assert entity.hvac_action == HVACAction.HEATING + assert entity.preset_mode == PRESET_NONE # Manual mode + + # the regulated temperature should be greater + assert entity.regulated_target_temp > entity.target_temperature + assert entity.regulated_target_temp == 18+2.9 # In medium we could go up to +3 degre + assert entity.hvac_action == HVACAction.HEATING + + # change temperature so that the regulated temperature should slow down + event_timestamp = now - timedelta(minutes=9) + await send_temperature_change_event(entity, 19, event_timestamp) + await send_ext_temperature_change_event(entity, 18, event_timestamp) + + # the regulated temperature should be under + assert entity.regulated_target_temp < entity.target_temperature + assert entity.regulated_target_temp == 18-0.1 + +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize("expected_lingering_timers", [True]) +async def test_over_climate_regulation_ac_mode(hass: HomeAssistant, skip_hass_states_is_state): + """Test the regulation of an over climate thermostat""" + + entry = MockConfigEntry( + domain=DOMAIN, + title="TheOverClimateMockName", + unique_id="uniqueId", + # This is include a medium regulation + data=PARTIAL_CLIMATE_AC_CONFIG, + ) + + tz = get_tz(hass) # pylint: disable=invalid-name + now: datetime = datetime.now(tz=tz) + + fake_underlying_climate = MockClimate(hass, "mockUniqueId", "MockClimateName", {}) + + # Creates the regulated VTherm over climate + 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, + ): + 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:ThermostatOverClimate = find_my_entity("climate.theoverclimatemockname") + + assert entity + assert isinstance(entity, ThermostatOverClimate) + + assert entity.name == "TheOverClimateMockName" + assert entity.is_over_climate is True + assert entity.is_regulated is True + assert entity.hvac_action is HVACAction.OFF + assert entity.hvac_mode is HVACMode.OFF + assert entity.target_temperature == entity.max_temp + assert entity.preset_modes == [ + PRESET_NONE, + PRESET_ECO, + PRESET_COMFORT, + PRESET_BOOST, + ] + assert entity.preset_mode is PRESET_NONE + + # Activate the heating by changing HVACMode and temperature + with patch( + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" + ): + # Select a hvacmode, presence and preset + await entity.async_set_hvac_mode(HVACMode.HEAT) + assert entity.hvac_mode is HVACMode.HEAT + assert entity.hvac_action == HVACAction.OFF + + # change temperature so that the heating will start + event_timestamp = now - timedelta(minutes=10) + await send_temperature_change_event(entity, 30, event_timestamp) + await send_ext_temperature_change_event(entity, 35, event_timestamp) + + + # set manual target temp + await entity.async_set_temperature(temperature=25) + + fake_underlying_climate.set_hvac_action(HVACAction.COOLING) # simulate under heating + assert entity.hvac_action == HVACAction.COOLING + assert entity.preset_mode == PRESET_NONE # Manual mode + + # the regulated temperature should be lower + assert entity.regulated_target_temp < entity.target_temperature + assert entity.regulated_target_temp == 25-3 # In medium we could go up to -3 degre + assert entity.hvac_action == HVACAction.COOLING + + # change temperature so that the regulated temperature should slow down + event_timestamp = now - timedelta(minutes=9) + await send_temperature_change_event(entity, 26, event_timestamp) + await send_ext_temperature_change_event(entity, 35, event_timestamp) + + # the regulated temperature should be under + assert entity.regulated_target_temp < entity.target_temperature + assert entity.regulated_target_temp == 25-2.7 + + # change temperature so that the regulated temperature should slow down + event_timestamp = now - timedelta(minutes=9) + await send_temperature_change_event(entity, 20, event_timestamp) + await send_ext_temperature_change_event(entity, 30, event_timestamp) + + # the regulated temperature should be greater + assert entity.regulated_target_temp > entity.target_temperature + assert entity.regulated_target_temp == 25+1.8 diff --git a/tests/test_bugs.py b/tests/test_bugs.py index 630f391..4291227 100644 --- a/tests/test_bugs.py +++ b/tests/test_bugs.py @@ -463,11 +463,11 @@ async def test_bug_101( domain=DOMAIN, title="TheOverClimateMockName", unique_id="uniqueId", - data=PARTIAL_CLIMATE_CONFIG, # 5 minutes security delay + data=PARTIAL_CLIMATE_NOT_REGULATED_CONFIG, # 5 minutes security delay ) # Underlying is in HEAT mode but should be shutdown at startup - fake_underlying_climate = MockClimate(hass, "mockUniqueId", "MockClimateName", {}, HVACMode.HEAT) + fake_underlying_climate = MockClimate(hass, "mockUniqueId", "MockClimateName", {}, HVACMode.HEAT, HVACAction.HEATING) with patch( "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" @@ -495,7 +495,7 @@ async def test_bug_101( assert entity.name == "TheOverClimateMockName" assert entity.is_over_climate is True assert entity.hvac_mode is HVACMode.OFF - # because the underlying is heating. In real life the underlying should be shut-off + # because in MockClimate HVACAction is HEATING if hvac_mode is not set assert entity.hvac_action is HVACAction.HEATING # Underlying should have been shutdown assert mock_underlying_set_hvac_mode.call_count == 1 @@ -539,6 +539,7 @@ async def test_bug_101( # 2. Change the target temp of underlying thermostat at 11 sec later -> the event will be taken # Wait 11 sec event_timestamp = now + timedelta(seconds=11) + assert entity.is_regulated is False await send_climate_change_event_with_temperature(entity, HVACMode.HEAT, HVACMode.HEAT, HVACAction.OFF, HVACAction.OFF, event_timestamp, 12.75) assert entity.target_temperature == 12.75 assert entity.preset_mode is PRESET_NONE diff --git a/tests/test_security.py b/tests/test_security.py index a13bc12..3388365 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -1,11 +1,15 @@ +# pylint: disable=wildcard-import, unused-wildcard-import, protected-access, unused-argument, line-too-long + """ Test the Security featrure """ from unittest.mock import patch, call - -from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import - from datetime import timedelta, datetime import logging +from custom_components.versatile_thermostat.thermostat_climate import ThermostatOverClimate +from custom_components.versatile_thermostat.thermostat_switch import ThermostatOverSwitch +from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import + + logging.getLogger().setLevel(logging.DEBUG) @@ -55,7 +59,7 @@ async def test_security_feature(hass: HomeAssistant, skip_hass_states_is_state): # 1. creates a thermostat and check that security is off now: datetime = datetime.now(tz=tz) - entity: VersatileThermostat = await create_thermostat( + entity: ThermostatOverSwitch = await create_thermostat( hass, entry, "climate.theoverswitchmockname" ) assert entity @@ -211,7 +215,7 @@ async def test_security_over_climate( data=PARTIAL_CLIMATE_CONFIG, # 5 minutes security delay ) - fake_underlying_climate = MockClimate(hass, "mockUniqueId", "MockClimateName", {}, HVACMode.HEAT) + fake_underlying_climate = MockClimate(hass, "mockUniqueId", "MockClimateName", {}, HVACMode.HEAT, HVACAction.HEATING) with patch( "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" @@ -230,7 +234,7 @@ async def test_security_over_climate( if entity.entity_id == entity_id: return entity - entity = find_my_entity("climate.theoverclimatemockname") + entity: ThermostatOverClimate = find_my_entity("climate.theoverclimatemockname") assert entity @@ -295,11 +299,11 @@ async def test_security_over_climate( "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event, patch( "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" - ) as mock_heater_on: + ): event_timestamp = now - timedelta(minutes=6) await send_temperature_change_event(entity, 15, event_timestamp) # Should stay False because a climate is never in security mode assert entity.security_state is False assert entity.preset_mode == 'none' - assert entity._saved_preset_mode == 'none' \ No newline at end of file + assert entity._saved_preset_mode == 'none'