First implem + tests (not finished)
This commit is contained in:
@@ -16,10 +16,10 @@ _LOGGER = logging.getLogger(__name__)
|
|||||||
class BaseFeatureManager:
|
class BaseFeatureManager:
|
||||||
"""A base class for all feature"""
|
"""A base class for all feature"""
|
||||||
|
|
||||||
def __init__(self, vtherm: Any, hass: HomeAssistant):
|
def __init__(self, vtherm: Any, hass: HomeAssistant, name: str = None):
|
||||||
"""Init of a featureManager"""
|
"""Init of a featureManager"""
|
||||||
self._vtherm = vtherm
|
self._vtherm = vtherm
|
||||||
self._name = vtherm.name
|
self._name = vtherm.name if vtherm else name
|
||||||
self._active_listener: list[CALLBACK_TYPE] = []
|
self._active_listener: list[CALLBACK_TYPE] = []
|
||||||
self._hass = hass
|
self._hass = hass
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,306 @@
|
|||||||
|
""" Implements a central Power Feature Manager for Versatile Thermostat """
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from homeassistant.const import (
|
||||||
|
STATE_UNAVAILABLE,
|
||||||
|
STATE_UNKNOWN,
|
||||||
|
)
|
||||||
|
from homeassistant.core import HomeAssistant, Event, callback
|
||||||
|
from homeassistant.helpers.event import (
|
||||||
|
async_track_state_change_event,
|
||||||
|
EventStateChangedData,
|
||||||
|
)
|
||||||
|
from homeassistant.helpers.entity_component import EntityComponent
|
||||||
|
from homeassistant.components.climate import (
|
||||||
|
ClimateEntity,
|
||||||
|
DOMAIN as CLIMATE_DOMAIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
from .const import * # pylint: disable=wildcard-import, unused-wildcard-import
|
||||||
|
from .commons import ConfigData
|
||||||
|
from .base_manager import BaseFeatureManager
|
||||||
|
|
||||||
|
# circular dependency
|
||||||
|
# from .base_thermostat import BaseThermostat
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class CentralFeaturePowerManager(BaseFeatureManager):
|
||||||
|
"""A central Power feature manager"""
|
||||||
|
|
||||||
|
def __init__(self, hass: HomeAssistant, vtherm_api: Any):
|
||||||
|
"""Init of a featureManager"""
|
||||||
|
super().__init__(None, hass, "centralPowerManager")
|
||||||
|
self._hass: HomeAssistant = hass
|
||||||
|
self._vtherm_api = vtherm_api # no type due to circular reference
|
||||||
|
self._is_configured: bool = False
|
||||||
|
self._power_sensor_entity_id: str = None
|
||||||
|
self._max_power_sensor_entity_id: str = None
|
||||||
|
self._current_power: float = None
|
||||||
|
self._current_max_power: float = None
|
||||||
|
self._power_temp: float = None
|
||||||
|
|
||||||
|
def post_init(self, entry_infos: ConfigData):
|
||||||
|
"""Gets the configuration parameters"""
|
||||||
|
central_config = self._vtherm_api.find_central_configuration()
|
||||||
|
if not central_config:
|
||||||
|
_LOGGER.info(
|
||||||
|
"%s - No central configuration is found. Power management will be deactivated.",
|
||||||
|
self,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
self._power_sensor_entity_id = entry_infos.get(CONF_POWER_SENSOR)
|
||||||
|
self._max_power_sensor_entity_id = entry_infos.get(CONF_MAX_POWER_SENSOR)
|
||||||
|
self._power_temp = entry_infos.get(CONF_PRESET_POWER)
|
||||||
|
|
||||||
|
self._is_configured = False
|
||||||
|
self._current_power = None
|
||||||
|
self._current_max_power = None
|
||||||
|
if (
|
||||||
|
entry_infos.get(CONF_USE_POWER_FEATURE, False)
|
||||||
|
and self._max_power_sensor_entity_id
|
||||||
|
and self._power_sensor_entity_id
|
||||||
|
and self._power_temp
|
||||||
|
):
|
||||||
|
self._is_configured = True
|
||||||
|
else:
|
||||||
|
_LOGGER.info(
|
||||||
|
"%s - Power management is not fully configured and will be deactivated",
|
||||||
|
self,
|
||||||
|
)
|
||||||
|
|
||||||
|
def start_listening(self):
|
||||||
|
"""Start listening the power sensor"""
|
||||||
|
if not self._is_configured:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.stop_listening()
|
||||||
|
|
||||||
|
self.add_listener(
|
||||||
|
async_track_state_change_event(
|
||||||
|
self.hass,
|
||||||
|
[self._power_sensor_entity_id],
|
||||||
|
self._async_power_sensor_changed,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.add_listener(
|
||||||
|
async_track_state_change_event(
|
||||||
|
self.hass,
|
||||||
|
[self._max_power_sensor_entity_id],
|
||||||
|
self._async_max_power_sensor_changed,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@callback
|
||||||
|
async def _async_power_sensor_changed(self, event: Event[EventStateChangedData]):
|
||||||
|
"""Handle power changes."""
|
||||||
|
_LOGGER.debug("Thermostat %s - Receive new Power event", self)
|
||||||
|
_LOGGER.debug(event)
|
||||||
|
new_state = event.data.get("new_state")
|
||||||
|
old_state = event.data.get("old_state")
|
||||||
|
if (
|
||||||
|
new_state is None
|
||||||
|
or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN)
|
||||||
|
or (old_state is not None and new_state.state == old_state.state)
|
||||||
|
):
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
current_power = float(new_state.state)
|
||||||
|
if math.isnan(current_power) or math.isinf(current_power):
|
||||||
|
raise ValueError(f"Sensor has illegal state {new_state.state}")
|
||||||
|
self._current_power = current_power
|
||||||
|
|
||||||
|
if self._vtherm.preset_mode == PRESET_POWER:
|
||||||
|
await self._vtherm.async_control_heating()
|
||||||
|
|
||||||
|
except ValueError as ex:
|
||||||
|
_LOGGER.error("Unable to update current_power from sensor: %s", ex)
|
||||||
|
|
||||||
|
@callback
|
||||||
|
async def _async_max_power_sensor_changed(
|
||||||
|
self, event: Event[EventStateChangedData]
|
||||||
|
):
|
||||||
|
"""Handle power max changes."""
|
||||||
|
_LOGGER.debug("Thermostat %s - Receive new Power Max event", self.name)
|
||||||
|
_LOGGER.debug(event)
|
||||||
|
new_state = event.data.get("new_state")
|
||||||
|
old_state = event.data.get("old_state")
|
||||||
|
if (
|
||||||
|
new_state is None
|
||||||
|
or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN)
|
||||||
|
or (old_state is not None and new_state.state == old_state.state)
|
||||||
|
):
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
current_power_max = float(new_state.state)
|
||||||
|
if math.isnan(current_power_max) or math.isinf(current_power_max):
|
||||||
|
raise ValueError(f"Sensor has illegal state {new_state.state}")
|
||||||
|
self._current_max_power = current_power_max
|
||||||
|
if self._vtherm.preset_mode == PRESET_POWER:
|
||||||
|
await self._vtherm.async_control_heating()
|
||||||
|
|
||||||
|
except ValueError as ex:
|
||||||
|
_LOGGER.error("Unable to update current_power from sensor: %s", ex)
|
||||||
|
|
||||||
|
@overrides
|
||||||
|
async def refresh_state(self) -> bool:
|
||||||
|
"""Tries to get the last state from sensor
|
||||||
|
Returns True if a change has been made"""
|
||||||
|
ret = False
|
||||||
|
if self._is_configured:
|
||||||
|
# try to acquire current power and power max
|
||||||
|
current_power_state = self.hass.states.get(self._power_sensor_entity_id)
|
||||||
|
if current_power_state and current_power_state.state not in (
|
||||||
|
STATE_UNAVAILABLE,
|
||||||
|
STATE_UNKNOWN,
|
||||||
|
):
|
||||||
|
self._current_power = float(current_power_state.state)
|
||||||
|
_LOGGER.debug(
|
||||||
|
"%s - Current power have been retrieved: %.3f",
|
||||||
|
self,
|
||||||
|
self._current_power,
|
||||||
|
)
|
||||||
|
ret = True
|
||||||
|
|
||||||
|
# Try to acquire power max
|
||||||
|
current_power_max_state = self.hass.states.get(
|
||||||
|
self._max_power_sensor_entity_id
|
||||||
|
)
|
||||||
|
if current_power_max_state and current_power_max_state.state not in (
|
||||||
|
STATE_UNAVAILABLE,
|
||||||
|
STATE_UNKNOWN,
|
||||||
|
):
|
||||||
|
self._current_max_power = float(current_power_max_state.state)
|
||||||
|
_LOGGER.debug(
|
||||||
|
"%s - Current power max have been retrieved: %.3f",
|
||||||
|
self,
|
||||||
|
self._current_max_power,
|
||||||
|
)
|
||||||
|
ret = True
|
||||||
|
|
||||||
|
return ret
|
||||||
|
|
||||||
|
async def calculate_shedding(self):
|
||||||
|
"""Do the shedding calculation and set/unset VTherm into overpowering state"""
|
||||||
|
if (
|
||||||
|
not self.is_configured
|
||||||
|
or not self.current_max_power
|
||||||
|
or not self.current_power
|
||||||
|
):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Find all VTherms
|
||||||
|
vtherms_sorted = self.find_all_vtherm_with_power_management_sorted_by_dtemp()
|
||||||
|
available_power = self.current_max_power - self.current_power
|
||||||
|
|
||||||
|
total_affected_power = 0
|
||||||
|
|
||||||
|
for vtherm in vtherms_sorted:
|
||||||
|
device_power = vtherm.power_manager.device_power
|
||||||
|
if vtherm.is_device_active:
|
||||||
|
power_consumption_max = 0
|
||||||
|
else:
|
||||||
|
if vtherm.is_over_climate:
|
||||||
|
power_consumption_max = device_power
|
||||||
|
else:
|
||||||
|
power_consumption_max = max(
|
||||||
|
device_power / self._vtherm.nb_underlying_entities,
|
||||||
|
device_power * self._vtherm.proportional_algorithm.on_percent,
|
||||||
|
)
|
||||||
|
|
||||||
|
_LOGGER.debug(
|
||||||
|
"%s - vtherm %s power_consumption_max is %s (device_power=%s, overclimate=%s)",
|
||||||
|
self,
|
||||||
|
vtherm.name,
|
||||||
|
power_consumption_max,
|
||||||
|
vtherm.power_management.device_power,
|
||||||
|
vtherm.is_over_climate,
|
||||||
|
)
|
||||||
|
if total_affected_power + power_consumption_max >= available_power:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"%s - vtherm %s should be in overpowering state", self, vtherm.name
|
||||||
|
)
|
||||||
|
await vtherm.power_manager.set_overpowering(True)
|
||||||
|
elif vtherm.power_manager.is_overpowering_detected:
|
||||||
|
total_affected_power += power_consumption_max
|
||||||
|
_LOGGER.debug(
|
||||||
|
"%s - vtherm %s should not be in overpowering state",
|
||||||
|
self,
|
||||||
|
vtherm.name,
|
||||||
|
)
|
||||||
|
await vtherm.power_manager.set_overpowering(False)
|
||||||
|
|
||||||
|
_LOGGER.debug(
|
||||||
|
"%s - after vtherm %s total_affected_power=%s, available_power=%s",
|
||||||
|
self,
|
||||||
|
vtherm.name,
|
||||||
|
total_affected_power,
|
||||||
|
available_power,
|
||||||
|
)
|
||||||
|
|
||||||
|
def find_all_vtherm_with_power_management_sorted_by_dtemp(
|
||||||
|
self,
|
||||||
|
) -> list:
|
||||||
|
"""Returns all the VTherms with power management activated"""
|
||||||
|
vtherms = []
|
||||||
|
component: EntityComponent[ClimateEntity] = self._hass.data.get(
|
||||||
|
CLIMATE_DOMAIN, None
|
||||||
|
)
|
||||||
|
if component:
|
||||||
|
for entity in component.entities:
|
||||||
|
# A little hack to test if the climate is a VTherm. Cannot use isinstance
|
||||||
|
# due to circular dependency of BaseThermostat
|
||||||
|
if (
|
||||||
|
entity.device_info
|
||||||
|
and entity.device_info.get("model", None) == DOMAIN
|
||||||
|
):
|
||||||
|
# The climate is a VTherm, we add it if it is active and power is configured
|
||||||
|
vtherm = entity
|
||||||
|
if vtherm.power_manager.is_configured and vtherm.is_on:
|
||||||
|
vtherms.append(vtherm)
|
||||||
|
|
||||||
|
# sort the result with the min temp difference first. A and B should be BaseThermostat class
|
||||||
|
def cmp_temps(a, b) -> int:
|
||||||
|
diff_a = float("inf")
|
||||||
|
diff_b = float("inf")
|
||||||
|
if a.current_temperature is not None and a.target_temperature is not None:
|
||||||
|
diff_a = a.target_temperature - a.current_temperature
|
||||||
|
if b.current_temperature is not None and b.target_temperature is not None:
|
||||||
|
diff_b = b.target_temperature - b.current_temperature
|
||||||
|
|
||||||
|
if diff_a == diff_b:
|
||||||
|
return 0
|
||||||
|
return 1 if diff_a > diff_b else -1
|
||||||
|
|
||||||
|
return vtherms.sort(key=cmp_temps)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_configured(self) -> bool:
|
||||||
|
"""True if the FeatureManager is fully configured"""
|
||||||
|
return self._is_configured
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_power(self) -> float | None:
|
||||||
|
"""Return the current power from sensor"""
|
||||||
|
return self._current_power
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_max_power(self) -> float | None:
|
||||||
|
"""Return the current power from sensor"""
|
||||||
|
return self._current_max_power
|
||||||
|
|
||||||
|
@property
|
||||||
|
def power_temperature(self) -> float | None:
|
||||||
|
"""Return the power temperature"""
|
||||||
|
return self._power_temp
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return "CentralPowerManager"
|
||||||
@@ -28,6 +28,7 @@ from .thermostat_switch import ThermostatOverSwitch
|
|||||||
from .thermostat_climate import ThermostatOverClimate
|
from .thermostat_climate import ThermostatOverClimate
|
||||||
from .thermostat_valve import ThermostatOverValve
|
from .thermostat_valve import ThermostatOverValve
|
||||||
from .thermostat_climate_valve import ThermostatOverClimateValve
|
from .thermostat_climate_valve import ThermostatOverClimateValve
|
||||||
|
from .vtherm_api import VersatileThermostatAPI
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -51,6 +52,9 @@ async def async_setup_entry(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if vt_type == CONF_THERMOSTAT_CENTRAL_CONFIG:
|
if vt_type == CONF_THERMOSTAT_CENTRAL_CONFIG:
|
||||||
|
# Initialize the central power manager
|
||||||
|
vtherm_api = VersatileThermostatAPI.get_vtherm_api(hass)
|
||||||
|
vtherm_api.central_power_manager.post_init(entry.data)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Instantiate the right base class
|
# Instantiate the right base class
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ from .const import (
|
|||||||
CONF_MAX_ON_PERCENT,
|
CONF_MAX_ON_PERCENT,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from .central_feature_power_manager import CentralFeaturePowerManager
|
||||||
|
|
||||||
VTHERM_API_NAME = "vtherm_api"
|
VTHERM_API_NAME = "vtherm_api"
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
@@ -62,6 +64,9 @@ class VersatileThermostatAPI(dict):
|
|||||||
# A dict that will store all Number entities which holds the temperature
|
# A dict that will store all Number entities which holds the temperature
|
||||||
self._number_temperatures = dict()
|
self._number_temperatures = dict()
|
||||||
self._max_on_percent = None
|
self._max_on_percent = None
|
||||||
|
self._central_power_manager = CentralFeaturePowerManager(
|
||||||
|
VersatileThermostatAPI._hass, self
|
||||||
|
)
|
||||||
|
|
||||||
def find_central_configuration(self):
|
def find_central_configuration(self):
|
||||||
"""Search for a central configuration"""
|
"""Search for a central configuration"""
|
||||||
@@ -176,6 +181,10 @@ class VersatileThermostatAPI(dict):
|
|||||||
if entry_id is None or entry_id == entity.unique_id:
|
if entry_id is None or entry_id == entity.unique_id:
|
||||||
await entity.async_startup(self.find_central_configuration())
|
await entity.async_startup(self.find_central_configuration())
|
||||||
|
|
||||||
|
# start listening for the central power manager if not only one vtherm reload
|
||||||
|
if not entry_id:
|
||||||
|
self.central_power_manager.start_listening()
|
||||||
|
|
||||||
async def init_vtherm_preset_with_central(self):
|
async def init_vtherm_preset_with_central(self):
|
||||||
"""Init all VTherm presets when the VTherm uses central temperature"""
|
"""Init all VTherm presets when the VTherm uses central temperature"""
|
||||||
# Initialization of all preset for all VTherm
|
# Initialization of all preset for all VTherm
|
||||||
@@ -289,3 +298,8 @@ class VersatileThermostatAPI(dict):
|
|||||||
def hass(self):
|
def hass(self):
|
||||||
"""Get the HomeAssistant object"""
|
"""Get the HomeAssistant object"""
|
||||||
return VersatileThermostatAPI._hass
|
return VersatileThermostatAPI._hass
|
||||||
|
|
||||||
|
@property
|
||||||
|
def central_power_manager(self) -> any:
|
||||||
|
"""Returns the central power manager"""
|
||||||
|
return self._central_power_manager
|
||||||
|
|||||||
293
tests/test_central_power_manager.py
Normal file
293
tests/test_central_power_manager.py
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
# pylint: disable=protected-access, unused-argument, line-too-long
|
||||||
|
""" Test the Central Power management """
|
||||||
|
from unittest.mock import patch, call, AsyncMock, MagicMock, PropertyMock
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from custom_components.versatile_thermostat.thermostat_switch import (
|
||||||
|
ThermostatOverSwitch,
|
||||||
|
)
|
||||||
|
from custom_components.versatile_thermostat.feature_power_manager import (
|
||||||
|
FeaturePowerManager,
|
||||||
|
)
|
||||||
|
from custom_components.versatile_thermostat.central_feature_power_manager import (
|
||||||
|
CentralFeaturePowerManager,
|
||||||
|
)
|
||||||
|
from custom_components.versatile_thermostat.prop_algorithm import PropAlgorithm
|
||||||
|
from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import
|
||||||
|
|
||||||
|
logging.getLogger().setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"use_power_feature, power_entity_id, max_power_entity_id, power_temp, is_configured",
|
||||||
|
[
|
||||||
|
(True, "sensor.power_id", "sensor.max_power_id", 13, True),
|
||||||
|
(True, None, "sensor.max_power_id", 13, False),
|
||||||
|
(True, "sensor.power_id", None, 13, False),
|
||||||
|
(True, "sensor.power_id", "sensor.max_power_id", None, False),
|
||||||
|
(False, "sensor.power_id", "sensor.max_power_id", 13, False),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_central_power_manager_init(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
use_power_feature,
|
||||||
|
power_entity_id,
|
||||||
|
max_power_entity_id,
|
||||||
|
power_temp,
|
||||||
|
is_configured,
|
||||||
|
):
|
||||||
|
"""Test creation and post_init of the Central Power Manager"""
|
||||||
|
vtherm_api: VersatileThermostatAPI = MagicMock(spec=VersatileThermostatAPI)
|
||||||
|
central_power_manager = CentralFeaturePowerManager(hass, vtherm_api)
|
||||||
|
|
||||||
|
assert central_power_manager.is_configured is False
|
||||||
|
assert central_power_manager.current_max_power is None
|
||||||
|
assert central_power_manager.current_power is None
|
||||||
|
assert central_power_manager.power_temperature is None
|
||||||
|
assert central_power_manager.name == "centralPowerManager"
|
||||||
|
|
||||||
|
# 2. post_init
|
||||||
|
central_power_manager.post_init(
|
||||||
|
{
|
||||||
|
CONF_POWER_SENSOR: power_entity_id,
|
||||||
|
CONF_MAX_POWER_SENSOR: max_power_entity_id,
|
||||||
|
CONF_USE_POWER_FEATURE: use_power_feature,
|
||||||
|
CONF_PRESET_POWER: power_temp,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert central_power_manager.is_configured == is_configured
|
||||||
|
assert central_power_manager.current_max_power is None
|
||||||
|
assert central_power_manager.current_power is None
|
||||||
|
assert central_power_manager.power_temperature == power_temp
|
||||||
|
|
||||||
|
# 3. start listening
|
||||||
|
central_power_manager.start_listening()
|
||||||
|
assert len(central_power_manager._active_listener) == (2 if is_configured else 0)
|
||||||
|
|
||||||
|
# 4. stop listening
|
||||||
|
central_power_manager.stop_listening()
|
||||||
|
assert len(central_power_manager._active_listener) == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"is_over_climate, is_device_active, power, max_power, current_overpowering_state, overpowering_state, nb_call, changed, check_overpowering_ret",
|
||||||
|
[
|
||||||
|
# don't switch to overpower (power is enough)
|
||||||
|
(False, False, 1000, 3000, STATE_OFF, STATE_OFF, 0, True, False),
|
||||||
|
# switch to overpower (power is not enough)
|
||||||
|
(False, False, 2000, 3000, STATE_OFF, STATE_ON, 1, True, True),
|
||||||
|
# don't switch to overpower (power is not enough but device is already on)
|
||||||
|
(False, True, 2000, 3000, STATE_OFF, STATE_OFF, 0, True, False),
|
||||||
|
# Same with a over_climate
|
||||||
|
# don't switch to overpower (power is enough)
|
||||||
|
(True, False, 1000, 3000, STATE_OFF, STATE_OFF, 0, True, False),
|
||||||
|
# switch to overpower (power is not enough)
|
||||||
|
(True, False, 2000, 3000, STATE_OFF, STATE_ON, 1, True, True),
|
||||||
|
# don't switch to overpower (power is not enough but device is already on)
|
||||||
|
(True, True, 2000, 3000, STATE_OFF, STATE_OFF, 0, True, False),
|
||||||
|
# Leave overpowering state
|
||||||
|
# switch to not overpower (power is enough)
|
||||||
|
(False, False, 1000, 3000, STATE_ON, STATE_OFF, 1, True, False),
|
||||||
|
# don't switch to overpower (power is still not enough)
|
||||||
|
(False, False, 2000, 3000, STATE_ON, STATE_ON, 0, True, True),
|
||||||
|
# keep overpower (power is not enough but device is already on)
|
||||||
|
(False, True, 3000, 3000, STATE_ON, STATE_ON, 0, True, True),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_central_power_manager(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
is_over_climate,
|
||||||
|
is_device_active,
|
||||||
|
power,
|
||||||
|
max_power,
|
||||||
|
current_overpowering_state,
|
||||||
|
overpowering_state,
|
||||||
|
nb_call,
|
||||||
|
changed,
|
||||||
|
check_overpowering_ret,
|
||||||
|
):
|
||||||
|
"""Test the FeaturePresenceManager class direclty"""
|
||||||
|
|
||||||
|
fake_vtherm = MagicMock(spec=BaseThermostat)
|
||||||
|
type(fake_vtherm).name = PropertyMock(return_value="the name")
|
||||||
|
|
||||||
|
# 1. creation
|
||||||
|
power_manager = FeaturePowerManager(fake_vtherm, hass)
|
||||||
|
|
||||||
|
assert power_manager is not None
|
||||||
|
assert power_manager.is_configured is False
|
||||||
|
assert power_manager.overpowering_state == STATE_UNAVAILABLE
|
||||||
|
assert power_manager.name == "the name"
|
||||||
|
|
||||||
|
assert len(power_manager._active_listener) == 0
|
||||||
|
|
||||||
|
custom_attributes = {}
|
||||||
|
power_manager.add_custom_attributes(custom_attributes)
|
||||||
|
assert custom_attributes["power_sensor_entity_id"] is None
|
||||||
|
assert custom_attributes["max_power_sensor_entity_id"] is None
|
||||||
|
assert custom_attributes["overpowering_state"] == STATE_UNAVAILABLE
|
||||||
|
assert custom_attributes["is_power_configured"] is False
|
||||||
|
assert custom_attributes["device_power"] is 0
|
||||||
|
assert custom_attributes["power_temp"] is None
|
||||||
|
assert custom_attributes["current_power"] is None
|
||||||
|
assert custom_attributes["current_max_power"] is None
|
||||||
|
|
||||||
|
# 2. post_init
|
||||||
|
power_manager.post_init(
|
||||||
|
{
|
||||||
|
CONF_POWER_SENSOR: "sensor.the_power_sensor",
|
||||||
|
CONF_MAX_POWER_SENSOR: "sensor.the_max_power_sensor",
|
||||||
|
CONF_USE_POWER_FEATURE: True,
|
||||||
|
CONF_PRESET_POWER: 10,
|
||||||
|
CONF_DEVICE_POWER: 1234,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert power_manager.is_configured is True
|
||||||
|
assert power_manager.overpowering_state == STATE_UNKNOWN
|
||||||
|
|
||||||
|
custom_attributes = {}
|
||||||
|
power_manager.add_custom_attributes(custom_attributes)
|
||||||
|
assert custom_attributes["power_sensor_entity_id"] == "sensor.the_power_sensor"
|
||||||
|
assert (
|
||||||
|
custom_attributes["max_power_sensor_entity_id"] == "sensor.the_max_power_sensor"
|
||||||
|
)
|
||||||
|
assert custom_attributes["overpowering_state"] == STATE_UNKNOWN
|
||||||
|
assert custom_attributes["is_power_configured"] is True
|
||||||
|
assert custom_attributes["device_power"] == 1234
|
||||||
|
assert custom_attributes["power_temp"] == 10
|
||||||
|
assert custom_attributes["current_power"] is None
|
||||||
|
assert custom_attributes["current_max_power"] is None
|
||||||
|
|
||||||
|
# 3. start listening
|
||||||
|
power_manager.start_listening()
|
||||||
|
assert power_manager.is_configured is True
|
||||||
|
assert power_manager.overpowering_state == STATE_UNKNOWN
|
||||||
|
|
||||||
|
assert len(power_manager._active_listener) == 2
|
||||||
|
|
||||||
|
# 4. test refresh and check_overpowering with the parametrized
|
||||||
|
side_effects = SideEffects(
|
||||||
|
{
|
||||||
|
"sensor.the_power_sensor": State("sensor.the_power_sensor", power),
|
||||||
|
"sensor.the_max_power_sensor": State(
|
||||||
|
"sensor.the_max_power_sensor", max_power
|
||||||
|
),
|
||||||
|
},
|
||||||
|
State("unknown.entity_id", "unknown"),
|
||||||
|
)
|
||||||
|
# fmt:off
|
||||||
|
with patch("homeassistant.core.StateMachine.get", side_effect=side_effects.get_side_effects()) as mock_get_state:
|
||||||
|
# fmt:on
|
||||||
|
# Finish the mock configuration
|
||||||
|
tpi_algo = PropAlgorithm(PROPORTIONAL_FUNCTION_TPI, 0.6, 0.01, 5, 0, "climate.vtherm")
|
||||||
|
tpi_algo._on_percent = 1 # pylint: disable="protected-access"
|
||||||
|
type(fake_vtherm).hvac_mode = PropertyMock(return_value=HVACMode.HEAT)
|
||||||
|
type(fake_vtherm).is_device_active = PropertyMock(return_value=is_device_active)
|
||||||
|
type(fake_vtherm).is_over_climate = PropertyMock(return_value=is_over_climate)
|
||||||
|
type(fake_vtherm).proportional_algorithm = PropertyMock(return_value=tpi_algo)
|
||||||
|
type(fake_vtherm).nb_underlying_entities = PropertyMock(return_value=1)
|
||||||
|
type(fake_vtherm).preset_mode = PropertyMock(return_value=PRESET_COMFORT if current_overpowering_state == STATE_OFF else PRESET_POWER)
|
||||||
|
type(fake_vtherm)._saved_preset_mode = PropertyMock(return_value=PRESET_ECO)
|
||||||
|
|
||||||
|
fake_vtherm.save_hvac_mode = MagicMock()
|
||||||
|
fake_vtherm.restore_hvac_mode = AsyncMock()
|
||||||
|
fake_vtherm.save_preset_mode = MagicMock()
|
||||||
|
fake_vtherm.restore_preset_mode = AsyncMock()
|
||||||
|
fake_vtherm.async_underlying_entity_turn_off = AsyncMock()
|
||||||
|
fake_vtherm.async_set_preset_mode_internal = AsyncMock()
|
||||||
|
fake_vtherm.send_event = MagicMock()
|
||||||
|
fake_vtherm.update_custom_attributes = MagicMock()
|
||||||
|
|
||||||
|
|
||||||
|
ret = await power_manager.refresh_state()
|
||||||
|
assert ret == changed
|
||||||
|
assert power_manager.is_configured is True
|
||||||
|
assert power_manager.overpowering_state == STATE_UNKNOWN
|
||||||
|
assert power_manager.current_power == power
|
||||||
|
assert power_manager.current_max_power == max_power
|
||||||
|
|
||||||
|
# check overpowering
|
||||||
|
power_manager._overpowering_state = current_overpowering_state
|
||||||
|
ret2 = await power_manager.check_overpowering()
|
||||||
|
assert ret2 == check_overpowering_ret
|
||||||
|
assert power_manager.overpowering_state == overpowering_state
|
||||||
|
assert mock_get_state.call_count == 2
|
||||||
|
|
||||||
|
if power_manager.overpowering_state == STATE_OFF:
|
||||||
|
assert fake_vtherm.save_hvac_mode.call_count == 0
|
||||||
|
assert fake_vtherm.save_preset_mode.call_count == 0
|
||||||
|
assert fake_vtherm.async_underlying_entity_turn_off.call_count == 0
|
||||||
|
assert fake_vtherm.async_set_preset_mode_internal.call_count == 0
|
||||||
|
assert fake_vtherm.send_event.call_count == nb_call
|
||||||
|
|
||||||
|
if current_overpowering_state == STATE_ON:
|
||||||
|
assert fake_vtherm.update_custom_attributes.call_count == 1
|
||||||
|
assert fake_vtherm.restore_preset_mode.call_count == 1
|
||||||
|
if is_over_climate:
|
||||||
|
assert fake_vtherm.restore_hvac_mode.call_count == 1
|
||||||
|
else:
|
||||||
|
assert fake_vtherm.restore_hvac_mode.call_count == 0
|
||||||
|
else:
|
||||||
|
assert fake_vtherm.update_custom_attributes.call_count == 0
|
||||||
|
|
||||||
|
if nb_call == 1:
|
||||||
|
fake_vtherm.send_event.assert_has_calls(
|
||||||
|
[
|
||||||
|
call.fake_vtherm.send_event(
|
||||||
|
EventType.POWER_EVENT,
|
||||||
|
{'type': 'end', 'current_power': power, 'device_power': 1234, 'current_max_power': max_power}),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
elif power_manager.overpowering_state == STATE_ON:
|
||||||
|
if is_over_climate:
|
||||||
|
assert fake_vtherm.save_hvac_mode.call_count == 1
|
||||||
|
else:
|
||||||
|
assert fake_vtherm.save_hvac_mode.call_count == 0
|
||||||
|
|
||||||
|
if current_overpowering_state == STATE_OFF:
|
||||||
|
assert fake_vtherm.save_preset_mode.call_count == 1
|
||||||
|
assert fake_vtherm.async_underlying_entity_turn_off.call_count == 1
|
||||||
|
assert fake_vtherm.async_set_preset_mode_internal.call_count == 1
|
||||||
|
assert fake_vtherm.send_event.call_count == 1
|
||||||
|
assert fake_vtherm.update_custom_attributes.call_count == 1
|
||||||
|
else:
|
||||||
|
assert fake_vtherm.save_preset_mode.call_count == 0
|
||||||
|
assert fake_vtherm.async_underlying_entity_turn_off.call_count == 0
|
||||||
|
assert fake_vtherm.async_set_preset_mode_internal.call_count == 0
|
||||||
|
assert fake_vtherm.send_event.call_count == 0
|
||||||
|
assert fake_vtherm.update_custom_attributes.call_count == 0
|
||||||
|
assert fake_vtherm.restore_hvac_mode.call_count == 0
|
||||||
|
assert fake_vtherm.restore_preset_mode.call_count == 0
|
||||||
|
|
||||||
|
if nb_call == 1:
|
||||||
|
fake_vtherm.send_event.assert_has_calls(
|
||||||
|
[
|
||||||
|
call.fake_vtherm.send_event(
|
||||||
|
EventType.POWER_EVENT,
|
||||||
|
{'type': 'start', 'current_power': power, 'device_power': 1234, 'current_max_power': max_power, 'current_power_consumption': 1234.0}),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
fake_vtherm.reset_mock()
|
||||||
|
|
||||||
|
# 5. Check custom_attributes
|
||||||
|
custom_attributes = {}
|
||||||
|
power_manager.add_custom_attributes(custom_attributes)
|
||||||
|
assert custom_attributes["power_sensor_entity_id"] == "sensor.the_power_sensor"
|
||||||
|
assert (
|
||||||
|
custom_attributes["max_power_sensor_entity_id"] == "sensor.the_max_power_sensor"
|
||||||
|
)
|
||||||
|
assert custom_attributes["overpowering_state"] == overpowering_state
|
||||||
|
assert custom_attributes["is_power_configured"] is True
|
||||||
|
assert custom_attributes["device_power"] == 1234
|
||||||
|
assert custom_attributes["power_temp"] == 10
|
||||||
|
assert custom_attributes["current_power"] == power
|
||||||
|
assert custom_attributes["current_max_power"] == max_power
|
||||||
|
|
||||||
|
power_manager.stop_listening()
|
||||||
|
await hass.async_block_till_done()
|
||||||
Reference in New Issue
Block a user