From 23074e6f46344c562054fa02f292e327b3611745 Mon Sep 17 00:00:00 2001 From: Jean-Marc Collin Date: Sun, 26 Feb 2023 11:22:20 +0100 Subject: [PATCH] Add energy for thermostat over climate --- .devcontainer/configuration.yaml | 20 +++ .../versatile_thermostat/climate.py | 122 ++++++++++++++---- .../versatile_thermostat/tests/commons.py | 93 ++++++++++++- .../versatile_thermostat/tests/test_power.py | 107 ++++++++++++++- 4 files changed, 316 insertions(+), 26 deletions(-) diff --git a/.devcontainer/configuration.yaml b/.devcontainer/configuration.yaml index 3a22415..03da98c 100644 --- a/.devcontainer/configuration.yaml +++ b/.devcontainer/configuration.yaml @@ -129,6 +129,26 @@ template: {% if energy == 'unavailable' or energy is none%}unavailable{% else %} {{ ((energy | float) / 1.0) | round(2, default=0) }} {% endif %} + - name: "Total énergie climate 2" + unique_id: total_energie_climate2 + unit_of_measurement: "kWh" + device_class: energy + state_class: total_increasing + state: > + {% set energy = state_attr('climate.thermostat_climate_2', 'total_energy') %} + {% if energy == 'unavailable' or energy is none%}unavailable{% else %} + {{ ((energy | float) / 1.0) | round(2, default=0) }} + {% endif %} + - name: "Total énergie chambre" + unique_id: total_energie_chambre + unit_of_measurement: "kWh" + device_class: energy + state_class: total_increasing + state: > + {% set energy = state_attr('climate.thermostat_chambre', 'total_energy') %} + {% if energy == 'unavailable' or energy is none%}unavailable{% else %} + {{ ((energy | float) / 1.0) | round(2, default=0) }} + {% endif %} switch: - platform: template diff --git a/custom_components/versatile_thermostat/climate.py b/custom_components/versatile_thermostat/climate.py index 745e4c2..3d9c859 100644 --- a/custom_components/versatile_thermostat/climate.py +++ b/custom_components/versatile_thermostat/climate.py @@ -261,6 +261,8 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): self._attr_translation_key = "versatile_thermostat" self._total_energy = None + self._underlying_climate_start_hvac_action_date = None + self._underlying_climate_delta_t = 0 self._current_tz = dt_util.get_time_zone(self._hass.config.time_zone) @@ -1000,13 +1002,11 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): @property def mean_cycle_power(self) -> float | None: """Returns tne mean power consumption during the cycle""" - if self._is_over_climate: - return None - elif self._device_power: - return float(self._device_power * self._prop_algorithm.on_percent) - else: + if not self._device_power or self._is_over_climate: return None + return float(self._device_power * self._prop_algorithm.on_percent) + @property def total_energy(self) -> float | None: """Returns the total energy calculated for this thermostast""" @@ -1265,6 +1265,22 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, data, context=self._context ) + def get_state_date_or_now(self, state: State): + """Extract the last_changed state from State or return now if not available""" + return ( + state.last_changed.astimezone(self._current_tz) + if state.last_changed is not None + else datetime.now(tz=self._current_tz) + ) + + def get_last_updated_date_or_now(self, state: State): + """Extract the last_changed state from State or return now if not available""" + return ( + state.last_updated.astimezone(self._current_tz) + if state.last_updated is not None + else datetime.now(tz=self._current_tz) + ) + @callback async def entry_update_listener( self, _, config_entry: ConfigEntry # hass: HomeAssistant, @@ -1451,14 +1467,29 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): async def _async_climate_changed(self, event): """Handle unerdlying climate state changes.""" new_state = event.data.get("new_state") + _LOGGER.warning("%s - _async_climate_changed new_state is %s", self, new_state) + old_state = event.data.get("old_state") + old_hvac_action = ( + old_state.attributes.get("hvac_action") + if old_state and old_state.attributes + else None + ) + new_hvac_action = ( + new_state.attributes.get("hvac_action") + if new_state and new_state.attributes + else None + ) + _LOGGER.info( - "%s - Underlying climate changed. Event.new_state is %s, hvac_mode=%s", + "%s - Underlying climate changed. Event.new_state is %s, hvac_mode=%s, hvac_action=%s, old_hvac_action=%s", self, new_state, self._hvac_mode, + new_hvac_action, + old_hvac_action, ) - # old_state = event.data.get("old_state") - if new_state is None or new_state.state not in [ + + if new_state.state in [ HVACMode.OFF, HVACMode.HEAT, HVACMode.COOL, @@ -1467,8 +1498,45 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): HVACMode.AUTO, HVACMode.FAN_ONLY, ]: - return - self._hvac_mode = new_state.state + self._hvac_mode = new_state.state + + # Interpretation of hvac + HVAC_ACTION_ON = [ + HVACAction.COOLING, + HVACAction.DRYING, + HVACAction.FAN, + HVACAction.HEATING, + ] + if old_hvac_action not in HVAC_ACTION_ON and new_hvac_action in HVAC_ACTION_ON: + self._underlying_climate_start_hvac_action_date = ( + self.get_last_updated_date_or_now(new_state) + ) + _LOGGER.info( + "%s - underlying just switch ON. Set power and energy start date %s", + self, + self._underlying_climate_start_hvac_action_date.isoformat(), + ) + + if old_hvac_action in HVAC_ACTION_ON and new_hvac_action not in HVAC_ACTION_ON: + stop_power_date = self.get_last_updated_date_or_now(new_state) + if self._underlying_climate_start_hvac_action_date: + delta = ( + stop_power_date - self._underlying_climate_start_hvac_action_date + ) + self._underlying_climate_delta_t = delta.total_seconds() / 3600.0 + + # increment energy at the end of the cycle + self.incremente_energy() + + self._underlying_climate_start_hvac_action_date = None + + _LOGGER.info( + "%s - underlying just switch OFF at %s. delta_h=%.3f h", + self, + stop_power_date.isoformat(), + self._underlying_climate_delta_t, + ) + self.update_custom_attributes() await self._async_control_heating(True) @@ -1481,11 +1549,7 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): raise ValueError(f"Sensor has illegal state {state.state}") self._cur_temp = cur_temp - self._last_temperature_mesure = ( - state.last_changed.astimezone(self._current_tz) - if state.last_changed is not None - else datetime.now(tz=self._current_tz) - ) + self._last_temperature_mesure = self.get_state_date_or_now(state) _LOGGER.debug( "%s - After setting _last_temperature_mesure %s , state.last_changed.replace=%s", @@ -1509,11 +1573,7 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): if math.isnan(cur_ext_temp) or math.isinf(cur_ext_temp): raise ValueError(f"Sensor has illegal state {state.state}") self._cur_ext_temp = cur_ext_temp - self._last_ext_temperature_mesure = ( - state.last_changed.astimezone(self._current_tz) - if state.last_changed is not None - else datetime.now(tz=self._current_tz) - ) + self._last_ext_temperature_mesure = self.get_state_date_or_now(state) _LOGGER.debug( "%s - After setting _last_ext_temperature_mesure %s , state.last_changed.replace=%s", @@ -2120,8 +2180,23 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): def incremente_energy(self): """increment the energy counter if device is active""" - if self.hvac_mode != HVACMode.OFF: - self._total_energy += self.mean_cycle_power * float(self._cycle_min) / 60.0 + if self.hvac_mode == HVACMode.OFF: + return + + added_energy = 0 + if self._is_over_climate and self._underlying_climate_delta_t is not None: + added_energy = self._device_power * self._underlying_climate_delta_t + + if not self._is_over_climate and self.mean_cycle_power is not None: + added_energy = self.mean_cycle_power * float(self._cycle_min) / 60.0 + + self._total_energy += added_energy + _LOGGER.debug( + "%s - added energy is %.3f . Total energy is now: %.3f", + self, + added_energy, + self._total_energy, + ) def update_custom_attributes(self): """Update the custom extra attributes for the entity""" @@ -2176,6 +2251,9 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): self._attr_extra_state_attributes[ "underlying_climate" ] = self._climate_entity_id + self._attr_extra_state_attributes[ + "start_hvac_action_date" + ] = self._underlying_climate_start_hvac_action_date else: self._attr_extra_state_attributes[ "underlying_switch" diff --git a/custom_components/versatile_thermostat/tests/commons.py b/custom_components/versatile_thermostat/tests/commons.py index 39646c3..4c8a246 100644 --- a/custom_components/versatile_thermostat/tests/commons.py +++ b/custom_components/versatile_thermostat/tests/commons.py @@ -1,5 +1,6 @@ """ Some common resources """ -from unittest.mock import patch +from typing import Mapping +from unittest.mock import patch, MagicMock from homeassistant.core import HomeAssistant, Event, EVENT_STATE_CHANGED, State from homeassistant.const import UnitOfTemperature, STATE_ON, STATE_OFF @@ -18,6 +19,7 @@ from homeassistant.components.climate import ( ATTR_PRESET_MODE, HVACMode, HVACAction, + ClimateEntityFeature, ) from .const import ( @@ -78,6 +80,64 @@ class MockClimate(ClimateEntity): self._attr_temperature_unit = UnitOfTemperature.CELSIUS +class MagicMockClimate(MagicMock): + @property + def temperature_unit(self): + return UnitOfTemperature.CELSIUS + + @property + def hvac_mode(self): + return HVACMode.HEAT + + @property + def hvac_action(self): + return HVACAction.IDLE + + @property + def target_temperature(self): + return 15 + + @property + def current_temperature(self): + return 14 + + @property + def target_temperature_step(self) -> float | None: + return 0.5 + + @property + def target_temperature_high(self) -> float | None: + return 35 + + @property + def target_temperature_low(self) -> float | None: + return 7 + + @property + def hvac_modes(self) -> list[str] | None: + return [HVACMode.HEAT, HVACMode.OFF, HVACMode.COOL] + + @property + def fan_modes(self) -> list[str] | None: + return None + + @property + def swing_modes(self) -> list[str] | None: + return None + + @property + def fan_mode(self) -> str | None: + return None + + @property + def swing_mode(self) -> str | None: + return None + + @property + def supported_features(self): + return ClimateEntityFeature.TARGET_TEMPERATURE + + async def create_thermostat( hass: HomeAssistant, entry: MockConfigEntry, entity_id: str ) -> VersatileThermostat: @@ -178,3 +238,34 @@ def get_tz(hass): """Get the current timezone""" return dt_util.get_time_zone(hass.config.time_zone) + + +async def send_climate_change_event( + entity: VersatileThermostat, + new_hvac_mode: HVACMode, + old_hvac_mode: HVACMode, + new_hvac_action: HVACAction, + old_hvac_action: HVACAction, + date, +): + """Sending a new climate event simulating a change on the underlying climate state""" + climate_event = Event( + EVENT_STATE_CHANGED, + { + "new_state": State( + entity_id=entity.entity_id, + state=new_hvac_mode, + attributes={"hvac_action": new_hvac_action}, + last_changed=date, + last_updated=date, + ), + "old_state": State( + entity_id=entity.entity_id, + state=old_hvac_mode, + attributes={"hvac_action": old_hvac_action}, + last_changed=date, + last_updated=date, + ), + }, + ) + ret = await entity._async_climate_changed(climate_event) diff --git a/custom_components/versatile_thermostat/tests/test_power.py b/custom_components/versatile_thermostat/tests/test_power.py index ac1c4f0..f8a2f15 100644 --- a/custom_components/versatile_thermostat/tests/test_power.py +++ b/custom_components/versatile_thermostat/tests/test_power.py @@ -1,7 +1,9 @@ """ Test the Power management """ -from unittest.mock import patch, call +from unittest.mock import patch, call, MagicMock from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import -from datetime import datetime +from datetime import datetime, timedelta + +from homeassistant.const import UnitOfTemperature import logging @@ -224,7 +226,9 @@ async def test_power_management_hvac_on(hass: HomeAssistant, skip_hass_states_is assert mock_heater_off.call_count == 0 -async def test_power_management_energy(hass: HomeAssistant, skip_hass_states_is_state): +async def test_power_management_energy_over_switch( + hass: HomeAssistant, skip_hass_states_is_state +): """Test the Power management energy mesurement""" entry = MockConfigEntry( @@ -344,3 +348,100 @@ async def test_power_management_energy(hass: HomeAssistant, skip_hass_states_is_ # Still no change entity.incremente_energy() assert round(entity.total_energy, 2) == round((2.0 + 0.6) * 100 * 5 / 60.0, 2) + + +async def test_power_management_energy_over_climate( + hass: HomeAssistant, skip_hass_states_is_state +): + """Test the Power management for a over_climate thermostat""" + + the_mock_underlying = MagicMockClimate() + with patch( + "custom_components.versatile_thermostat.climate.VersatileThermostat.find_underlying_climate", + return_value=the_mock_underlying, + ): + entry = MockConfigEntry( + domain=DOMAIN, + title="TheOverClimateMockName", + unique_id="uniqueId", + data={ + CONF_NAME: "TheOverClimateMockName", + CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_CLIMATE, + CONF_TEMP_SENSOR: "sensor.mock_temp_sensor", + CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor", + CONF_CYCLE_MIN: 5, + CONF_TEMP_MIN: 15, + CONF_TEMP_MAX: 30, + "eco_temp": 17, + "comfort_temp": 18, + "boost_temp": 19, + CONF_USE_WINDOW_FEATURE: False, + CONF_USE_MOTION_FEATURE: False, + CONF_USE_POWER_FEATURE: True, + CONF_USE_PRESENCE_FEATURE: False, + CONF_CLIMATE: "climate.mock_climate", + CONF_MINIMAL_ACTIVATION_DELAY: 30, + CONF_SECURITY_DELAY_MIN: 5, + CONF_SECURITY_MIN_ON_PERCENT: 0.3, + CONF_POWER_SENSOR: "sensor.mock_power_sensor", + CONF_MAX_POWER_SENSOR: "sensor.mock_power_max_sensor", + CONF_DEVICE_POWER: 100, + CONF_PRESET_POWER: 12, + }, + ) + + entity: VersatileThermostat = await create_thermostat( + hass, entry, "climate.theoverclimatemockname" + ) + assert entity + assert entity._is_over_climate + + now = datetime.now(tz=get_tz(hass)) + await send_temperature_change_event(entity, 15, now) + await entity.async_set_hvac_mode(HVACMode.HEAT) + await entity.async_set_preset_mode(PRESET_BOOST) + + assert entity.hvac_mode is HVACMode.HEAT + assert entity.hvac_action is HVACAction.IDLE + assert entity.preset_mode is PRESET_BOOST + assert entity.target_temperature == 19 + assert entity.current_temperature == 15 + + # Not initialised yet + assert entity.mean_cycle_power is None + assert entity._underlying_climate_start_hvac_action_date is None + + # Send a climate_change event with HVACAction=HEATING + event_timestamp = now - timedelta(minutes=3) + await send_climate_change_event( + entity, + new_hvac_mode=HVACMode.HEAT, + old_hvac_mode=HVACMode.HEAT, + new_hvac_action=HVACAction.HEATING, + old_hvac_action=HVACAction.OFF, + date=event_timestamp, + ) + # We have the start event and not the end event + assert (entity._underlying_climate_start_hvac_action_date - now).total_seconds() < 1 + + entity.incremente_energy() + assert entity.total_energy == 0 + + # Send a climate_change event with HVACAction=IDLE (end of heating) + await send_climate_change_event( + entity, + new_hvac_mode=HVACMode.HEAT, + old_hvac_mode=HVACMode.HEAT, + new_hvac_action=HVACAction.IDLE, + old_hvac_action=HVACAction.HEATING, + date=now, + ) + # We have the end event -> we should have some power and on_percent + assert entity._underlying_climate_start_hvac_action_date is None + + # 3 minutes at 100 W + assert entity.total_energy == 100 * 3.0 / 60 + + # Test the re-increment + entity.incremente_energy() + assert entity.total_energy == 2 * 100 * 3.0 / 60