Add energy for thermostat over climate
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user