Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 08d08e52de | |||
| 81b4f7e5f6 | |||
| 7a917c6ff7 | |||
| 20a9e2523e |
@@ -13,6 +13,8 @@ from homeassistant.core import (
|
|||||||
callback,
|
callback,
|
||||||
CoreState,
|
CoreState,
|
||||||
DOMAIN as HA_DOMAIN,
|
DOMAIN as HA_DOMAIN,
|
||||||
|
Event,
|
||||||
|
State,
|
||||||
)
|
)
|
||||||
|
|
||||||
from homeassistant.components.climate import ClimateEntity
|
from homeassistant.components.climate import ClimateEntity
|
||||||
@@ -197,6 +199,8 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
|||||||
# The list of VersatileThermostat entities
|
# The list of VersatileThermostat entities
|
||||||
# No more needed
|
# No more needed
|
||||||
# _registry: dict[str, object] = {}
|
# _registry: dict[str, object] = {}
|
||||||
|
_last_temperature_mesure: datetime
|
||||||
|
_last_ext_temperature_mesure: datetime
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
|
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
|
||||||
"""Initialize the thermostat."""
|
"""Initialize the thermostat."""
|
||||||
@@ -343,8 +347,8 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
|||||||
self._presets_away,
|
self._presets_away,
|
||||||
)
|
)
|
||||||
# Will be restored if possible
|
# Will be restored if possible
|
||||||
self._attr_preset_mode = None
|
self._attr_preset_mode = PRESET_NONE
|
||||||
self._saved_preset_mode = None
|
self._saved_preset_mode = PRESET_NONE
|
||||||
|
|
||||||
# Power management
|
# Power management
|
||||||
self._device_power = entry_infos.get(CONF_DEVICE_POWER)
|
self._device_power = entry_infos.get(CONF_DEVICE_POWER)
|
||||||
@@ -546,6 +550,14 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
|||||||
self._async_cancel_cycle()
|
self._async_cancel_cycle()
|
||||||
self._async_cancel_cycle = None
|
self._async_cancel_cycle = None
|
||||||
|
|
||||||
|
def find_underlying_climate(self, climate_entity_id) -> ClimateEntity:
|
||||||
|
"""Find the underlying climate entity"""
|
||||||
|
component: EntityComponent[ClimateEntity] = self.hass.data[CLIMATE_DOMAIN]
|
||||||
|
for entity in component.entities:
|
||||||
|
if climate_entity_id == entity.entity_id:
|
||||||
|
return entity
|
||||||
|
return None
|
||||||
|
|
||||||
async def async_startup(self):
|
async def async_startup(self):
|
||||||
"""Triggered on startup, used to get old state and set internal states accordingly"""
|
"""Triggered on startup, used to get old state and set internal states accordingly"""
|
||||||
_LOGGER.debug("%s - Calling async_startup", self)
|
_LOGGER.debug("%s - Calling async_startup", self)
|
||||||
@@ -557,19 +569,16 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
|||||||
|
|
||||||
# Get the underlying thermostat
|
# Get the underlying thermostat
|
||||||
if self._is_over_climate:
|
if self._is_over_climate:
|
||||||
component: EntityComponent[ClimateEntity] = self.hass.data[
|
self._underlying_climate = self.find_underlying_climate(
|
||||||
CLIMATE_DOMAIN
|
self._climate_entity_id
|
||||||
]
|
)
|
||||||
for entity in component.entities:
|
if self._underlying_climate:
|
||||||
if self._climate_entity_id == entity.entity_id:
|
_LOGGER.info(
|
||||||
_LOGGER.info(
|
"%s - The underlying climate entity: %s have been succesfully found",
|
||||||
"%s - The underlying climate entity: %s have been succesfully found",
|
self,
|
||||||
self,
|
self._underlying_climate,
|
||||||
entity,
|
)
|
||||||
)
|
else:
|
||||||
self._underlying_climate = entity
|
|
||||||
break
|
|
||||||
if self._underlying_climate is None:
|
|
||||||
_LOGGER.error(
|
_LOGGER.error(
|
||||||
"%s - Cannot find the underlying climate entity: %s. Thermostat will not be operational",
|
"%s - Cannot find the underlying climate entity: %s. Thermostat will not be operational",
|
||||||
self,
|
self,
|
||||||
@@ -924,7 +933,7 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
|||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
else:
|
else:
|
||||||
return self.hass.states.is_state(self._heater_entity_id, STATE_ON)
|
return self._hass.states.is_state(self._heater_entity_id, STATE_ON)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def current_temperature(self):
|
def current_temperature(self):
|
||||||
@@ -1071,6 +1080,16 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
|||||||
if preset_mode == self._attr_preset_mode and not force:
|
if preset_mode == self._attr_preset_mode and not force:
|
||||||
# I don't think we need to call async_write_ha_state if we didn't change the state
|
# I don't think we need to call async_write_ha_state if we didn't change the state
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# In security mode don't change preset but memorise the new expected preset when security will be off
|
||||||
|
if preset_mode != PRESET_SECURITY and self._security_state:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"%s - is in security mode. Just memorise the new expected ", self
|
||||||
|
)
|
||||||
|
if preset_mode not in HIDDEN_PRESETS:
|
||||||
|
self._saved_preset_mode = preset_mode
|
||||||
|
return
|
||||||
|
|
||||||
old_preset_mode = self._attr_preset_mode
|
old_preset_mode = self._attr_preset_mode
|
||||||
if preset_mode == PRESET_NONE:
|
if preset_mode == PRESET_NONE:
|
||||||
self._attr_preset_mode = PRESET_NONE
|
self._attr_preset_mode = PRESET_NONE
|
||||||
@@ -1208,9 +1227,9 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
|||||||
_LOGGER.info("%s - Change entry with the values: %s", self, config_entry.data)
|
_LOGGER.info("%s - Change entry with the values: %s", self, config_entry.data)
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
async def _async_temperature_changed(self, event):
|
async def _async_temperature_changed(self, event: Event):
|
||||||
"""Handle temperature changes."""
|
"""Handle temperature of the temperature sensor changes."""
|
||||||
new_state = event.data.get("new_state")
|
new_state: State = event.data.get("new_state")
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"%s - Temperature changed. Event.new_state is %s",
|
"%s - Temperature changed. Event.new_state is %s",
|
||||||
self,
|
self,
|
||||||
@@ -1223,9 +1242,9 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
|||||||
self.recalculate()
|
self.recalculate()
|
||||||
await self._async_control_heating(force=False)
|
await self._async_control_heating(force=False)
|
||||||
|
|
||||||
async def _async_ext_temperature_changed(self, event):
|
async def _async_ext_temperature_changed(self, event: Event):
|
||||||
"""Handle external temperature changes."""
|
"""Handle external temperature opf the sensor changes."""
|
||||||
new_state = event.data.get("new_state")
|
new_state: State = event.data.get("new_state")
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"%s - external Temperature changed. Event.new_state is %s",
|
"%s - external Temperature changed. Event.new_state is %s",
|
||||||
self,
|
self,
|
||||||
@@ -1404,14 +1423,16 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
|||||||
await self._async_control_heating(True)
|
await self._async_control_heating(True)
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
async def _async_update_temp(self, state):
|
async def _async_update_temp(self, state: State):
|
||||||
"""Update thermostat with latest state from sensor."""
|
"""Update thermostat with latest state from sensor."""
|
||||||
try:
|
try:
|
||||||
cur_temp = float(state.state)
|
cur_temp = float(state.state)
|
||||||
if math.isnan(cur_temp) or math.isinf(cur_temp):
|
if math.isnan(cur_temp) or math.isinf(cur_temp):
|
||||||
raise ValueError(f"Sensor has illegal state {state.state}")
|
raise ValueError(f"Sensor has illegal state {state.state}")
|
||||||
self._cur_temp = cur_temp
|
self._cur_temp = cur_temp
|
||||||
self._last_temperature_mesure = datetime.now()
|
self._last_temperature_mesure = (
|
||||||
|
state.last_changed if state.last_changed is not None else datetime.now()
|
||||||
|
)
|
||||||
# try to restart if we were in security mode
|
# try to restart if we were in security mode
|
||||||
if self._security_state:
|
if self._security_state:
|
||||||
await self.check_security()
|
await self.check_security()
|
||||||
@@ -1420,14 +1441,16 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
|||||||
_LOGGER.error("Unable to update temperature from sensor: %s", ex)
|
_LOGGER.error("Unable to update temperature from sensor: %s", ex)
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
async def _async_update_ext_temp(self, state):
|
async def _async_update_ext_temp(self, state: State):
|
||||||
"""Update thermostat with latest state from sensor."""
|
"""Update thermostat with latest state from sensor."""
|
||||||
try:
|
try:
|
||||||
cur_ext_temp = float(state.state)
|
cur_ext_temp = float(state.state)
|
||||||
if math.isnan(cur_ext_temp) or math.isinf(cur_ext_temp):
|
if math.isnan(cur_ext_temp) or math.isinf(cur_ext_temp):
|
||||||
raise ValueError(f"Sensor has illegal state {state.state}")
|
raise ValueError(f"Sensor has illegal state {state.state}")
|
||||||
self._cur_ext_temp = cur_ext_temp
|
self._cur_ext_temp = cur_ext_temp
|
||||||
self._last_ext_temperature_mesure = datetime.now()
|
self._last_ext_temperature_mesure = (
|
||||||
|
state.last_changed if state.last_changed is not None else datetime.now()
|
||||||
|
)
|
||||||
# try to restart if we were in security mode
|
# try to restart if we were in security mode
|
||||||
if self._security_state:
|
if self._security_state:
|
||||||
await self.check_security()
|
await self.check_security()
|
||||||
@@ -1708,9 +1731,11 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
|||||||
async def check_security(self) -> bool:
|
async def check_security(self) -> bool:
|
||||||
"""Check if last temperature date is too long"""
|
"""Check if last temperature date is too long"""
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
delta_temp = (now - self._last_temperature_mesure).total_seconds() / 60.0
|
delta_temp = (
|
||||||
|
now - self._last_temperature_mesure.replace(tzinfo=None)
|
||||||
|
).total_seconds() / 60.0
|
||||||
delta_ext_temp = (
|
delta_ext_temp = (
|
||||||
now - self._last_ext_temperature_mesure
|
now - self._last_ext_temperature_mesure.replace(tzinfo=None)
|
||||||
).total_seconds() / 60.0
|
).total_seconds() / 60.0
|
||||||
|
|
||||||
mode_cond = self._is_over_climate or self._hvac_mode != HVACMode.OFF
|
mode_cond = self._is_over_climate or self._hvac_mode != HVACMode.OFF
|
||||||
@@ -1730,6 +1755,17 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
|||||||
>= self._security_min_on_percent
|
>= self._security_min_on_percent
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_LOGGER.debug(
|
||||||
|
"%s - checking security delta_temp=%.1f delta_ext_temp=%.1f mod_cond=%s temp_cond=%s climate_cond=%s switch_cond=%s",
|
||||||
|
self,
|
||||||
|
delta_temp,
|
||||||
|
delta_ext_temp,
|
||||||
|
mode_cond,
|
||||||
|
temp_cond,
|
||||||
|
climate_cond,
|
||||||
|
switch_cond,
|
||||||
|
)
|
||||||
|
|
||||||
ret = False
|
ret = False
|
||||||
if mode_cond and temp_cond and climate_cond:
|
if mode_cond and temp_cond and climate_cond:
|
||||||
if not self._security_state:
|
if not self._security_state:
|
||||||
@@ -1743,17 +1779,6 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
|||||||
)
|
)
|
||||||
ret = True
|
ret = True
|
||||||
|
|
||||||
_LOGGER.debug(
|
|
||||||
"%s - checking security delta_temp=%.1f delta_ext_temp=%.1f mod_cond=%s temp_cond=%s climate_cond=%s switch_cond=%s",
|
|
||||||
self,
|
|
||||||
delta_temp,
|
|
||||||
delta_ext_temp,
|
|
||||||
mode_cond,
|
|
||||||
temp_cond,
|
|
||||||
climate_cond,
|
|
||||||
switch_cond,
|
|
||||||
)
|
|
||||||
|
|
||||||
if mode_cond and temp_cond and switch_cond:
|
if mode_cond and temp_cond and switch_cond:
|
||||||
if not self._security_state:
|
if not self._security_state:
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
|
|||||||
@@ -331,7 +331,7 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
async def validate_input(self, data: dict) -> dict[str]:
|
async def validate_input(self, data: dict) -> None:
|
||||||
"""Validate the user input allows us to connect.
|
"""Validate the user input allows us to connect.
|
||||||
|
|
||||||
Data has the keys from STEP_*_DATA_SCHEMA with values provided by the user.
|
Data has the keys from STEP_*_DATA_SCHEMA with values provided by the user.
|
||||||
|
|||||||
@@ -85,6 +85,12 @@ class PropAlgorithm:
|
|||||||
def _calculate_internal(self):
|
def _calculate_internal(self):
|
||||||
"""Finish the calculation to get the on_percent in seconds"""
|
"""Finish the calculation to get the on_percent in seconds"""
|
||||||
|
|
||||||
|
# calculated on_time duration in seconds
|
||||||
|
if self._calculated_on_percent > 1:
|
||||||
|
self._calculated_on_percent = 1
|
||||||
|
if self._calculated_on_percent < 0:
|
||||||
|
self._calculated_on_percent = 0
|
||||||
|
|
||||||
if self._security:
|
if self._security:
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"Security is On using the default_on_percent %f",
|
"Security is On using the default_on_percent %f",
|
||||||
@@ -98,11 +104,6 @@ class PropAlgorithm:
|
|||||||
)
|
)
|
||||||
self._on_percent = self._calculated_on_percent
|
self._on_percent = self._calculated_on_percent
|
||||||
|
|
||||||
# calculated on_time duration in seconds
|
|
||||||
if self._on_percent > 1:
|
|
||||||
self._on_percent = 1
|
|
||||||
if self._on_percent < 0:
|
|
||||||
self._on_percent = 0
|
|
||||||
self._on_time_sec = self._on_percent * self._cycle_min * 60
|
self._on_time_sec = self._on_percent * self._cycle_min * 60
|
||||||
|
|
||||||
# Do not heat for less than xx sec
|
# Do not heat for less than xx sec
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
homeassistant
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# -r requirements_dev.txt
|
||||||
|
# aiodiscover
|
||||||
|
pytest-homeassistant-custom-component
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
""" To make this repo a module """
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
""" Some common resources """
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from homeassistant.core import HomeAssistant, Event, EVENT_STATE_CHANGED, State
|
||||||
|
from homeassistant.const import UnitOfTemperature
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntryState
|
||||||
|
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||||
|
from homeassistant.helpers.entity_component import EntityComponent
|
||||||
|
|
||||||
|
from ..climate import VersatileThermostat
|
||||||
|
from ..const import DOMAIN, PRESET_SECURITY, PRESET_POWER, EventType
|
||||||
|
|
||||||
|
from homeassistant.components.climate import (
|
||||||
|
ClimateEntity,
|
||||||
|
DOMAIN as CLIMATE_DOMAIN,
|
||||||
|
ATTR_PRESET_MODE,
|
||||||
|
HVACMode,
|
||||||
|
HVACAction,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .const import (
|
||||||
|
MOCK_TH_OVER_SWITCH_USER_CONFIG,
|
||||||
|
MOCK_TH_OVER_CLIMATE_USER_CONFIG,
|
||||||
|
MOCK_TH_OVER_SWITCH_TYPE_CONFIG,
|
||||||
|
MOCK_TH_OVER_CLIMATE_TYPE_CONFIG,
|
||||||
|
MOCK_TH_OVER_SWITCH_TPI_CONFIG,
|
||||||
|
MOCK_PRESETS_CONFIG,
|
||||||
|
MOCK_WINDOW_CONFIG,
|
||||||
|
MOCK_MOTION_CONFIG,
|
||||||
|
MOCK_POWER_CONFIG,
|
||||||
|
MOCK_PRESENCE_CONFIG,
|
||||||
|
MOCK_ADVANCED_CONFIG,
|
||||||
|
# MOCK_DEFAULT_FEATURE_CONFIG,
|
||||||
|
PRESET_BOOST,
|
||||||
|
PRESET_COMFORT,
|
||||||
|
PRESET_NONE,
|
||||||
|
PRESET_ECO,
|
||||||
|
PRESET_ACTIVITY,
|
||||||
|
)
|
||||||
|
|
||||||
|
FULL_SWITCH_CONFIG = (
|
||||||
|
MOCK_TH_OVER_SWITCH_USER_CONFIG
|
||||||
|
| MOCK_TH_OVER_SWITCH_TYPE_CONFIG
|
||||||
|
| MOCK_TH_OVER_SWITCH_TPI_CONFIG
|
||||||
|
| MOCK_PRESETS_CONFIG
|
||||||
|
| MOCK_WINDOW_CONFIG
|
||||||
|
| MOCK_MOTION_CONFIG
|
||||||
|
| MOCK_POWER_CONFIG
|
||||||
|
| MOCK_PRESENCE_CONFIG
|
||||||
|
| MOCK_ADVANCED_CONFIG
|
||||||
|
)
|
||||||
|
|
||||||
|
PARTIAL_CLIMATE_CONFIG = (
|
||||||
|
MOCK_TH_OVER_CLIMATE_USER_CONFIG
|
||||||
|
| MOCK_TH_OVER_CLIMATE_TYPE_CONFIG
|
||||||
|
| MOCK_PRESETS_CONFIG
|
||||||
|
| MOCK_ADVANCED_CONFIG
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MockClimate(ClimateEntity):
|
||||||
|
"""A Mock Climate class used for Underlying climate mode"""
|
||||||
|
|
||||||
|
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
|
||||||
|
"""Initialize the thermostat."""
|
||||||
|
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
self._hass = hass
|
||||||
|
self._attr_extra_state_attributes = {}
|
||||||
|
self._unique_id = unique_id
|
||||||
|
self._name = name
|
||||||
|
self._attr_hvac_action = HVACAction.OFF
|
||||||
|
self._attr_hvac_mode = HVACMode.OFF
|
||||||
|
self._attr_hvac_modes = [HVACMode.OFF, HVACMode.COOL, HVACMode.HEAT]
|
||||||
|
self._attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||||
|
|
||||||
|
|
||||||
|
async def create_thermostat(
|
||||||
|
hass: HomeAssistant, entry: MockConfigEntry, entity_id: str
|
||||||
|
) -> VersatileThermostat:
|
||||||
|
"""Creates and return a TPI Thermostat"""
|
||||||
|
with patch(
|
||||||
|
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
|
||||||
|
):
|
||||||
|
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(entity_id)
|
||||||
|
|
||||||
|
return entity
|
||||||
|
|
||||||
|
|
||||||
|
async def send_temperature_change_event(entity: VersatileThermostat, new_temp, date):
|
||||||
|
"""Sending a new temperature event simulating a change on temperature sensor"""
|
||||||
|
temp_event = Event(
|
||||||
|
EVENT_STATE_CHANGED,
|
||||||
|
{
|
||||||
|
"new_state": State(
|
||||||
|
entity_id=entity.entity_id,
|
||||||
|
state=new_temp,
|
||||||
|
last_changed=date,
|
||||||
|
last_updated=date,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await entity._async_temperature_changed(temp_event)
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""Global fixtures for integration_blueprint integration."""
|
||||||
|
# Fixtures allow you to replace functions with a Mock object. You can perform
|
||||||
|
# many options via the Mock to reflect a particular behavior from the original
|
||||||
|
# function that you want to see without going through the function's actual logic.
|
||||||
|
# Fixtures can either be passed into tests as parameters, or if autouse=True, they
|
||||||
|
# will automatically be used across all tests.
|
||||||
|
#
|
||||||
|
# Fixtures that are defined in conftest.py are available across all tests. You can also
|
||||||
|
# define fixtures within a particular test file to scope them locally.
|
||||||
|
#
|
||||||
|
# pytest_homeassistant_custom_component provides some fixtures that are provided by
|
||||||
|
# Home Assistant core. You can find those fixture definitions here:
|
||||||
|
# https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/blob/master/pytest_homeassistant_custom_component/common.py
|
||||||
|
#
|
||||||
|
# See here for more info: https://docs.pytest.org/en/latest/fixture.html (note that
|
||||||
|
# pytest includes fixtures OOB which you can use as defined on this page)
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from homeassistant.core import HomeAssistant, StateMachine
|
||||||
|
|
||||||
|
from custom_components.versatile_thermostat.config_flow import (
|
||||||
|
VersatileThermostatBaseConfigFlow,
|
||||||
|
)
|
||||||
|
|
||||||
|
from custom_components.versatile_thermostat.climate import (
|
||||||
|
VersatileThermostat,
|
||||||
|
)
|
||||||
|
|
||||||
|
pytest_plugins = "pytest_homeassistant_custom_component"
|
||||||
|
|
||||||
|
|
||||||
|
# This fixture enables loading custom integrations in all tests.
|
||||||
|
# Remove to enable selective use of this fixture
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def auto_enable_custom_integrations(enable_custom_integrations):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
# This fixture is used to prevent HomeAssistant from attempting to create and dismiss persistent
|
||||||
|
# notifications. These calls would fail without this fixture since the persistent_notification
|
||||||
|
# integration is never loaded during a test.
|
||||||
|
@pytest.fixture(name="skip_notifications", autouse=True)
|
||||||
|
def skip_notifications_fixture():
|
||||||
|
"""Skip notification calls."""
|
||||||
|
with patch("homeassistant.components.persistent_notification.async_create"), patch(
|
||||||
|
"homeassistant.components.persistent_notification.async_dismiss"
|
||||||
|
):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
# This fixture is used to bypass the validate_input function in config_flow
|
||||||
|
# NOT USED Now (keeped for memory)
|
||||||
|
@pytest.fixture(name="skip_validate_input")
|
||||||
|
def skip_validate_input_fixture():
|
||||||
|
"""Skip the validate_input in config flow"""
|
||||||
|
with patch.object(VersatileThermostatBaseConfigFlow, "validate_input"):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="skip_hass_states_get")
|
||||||
|
def skip_hass_states_get_fixture():
|
||||||
|
"""Skip the get state in HomeAssistant"""
|
||||||
|
with patch.object(StateMachine, "get"):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="skip_hass_states_is_state")
|
||||||
|
def skip_hass_states_is_state_fixture():
|
||||||
|
"""Skip the is_state in HomeAssistant"""
|
||||||
|
with patch.object(StateMachine, "is_state", return_value=False):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="skip_send_event")
|
||||||
|
def skip_send_event_fixture():
|
||||||
|
"""Skip the send_event in VersatileThermostat"""
|
||||||
|
with patch.object(VersatileThermostat, "send_event"):
|
||||||
|
yield
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
from homeassistant.components.climate.const import (
|
||||||
|
PRESET_BOOST,
|
||||||
|
PRESET_COMFORT,
|
||||||
|
PRESET_ECO,
|
||||||
|
PRESET_NONE,
|
||||||
|
PRESET_ACTIVITY,
|
||||||
|
)
|
||||||
|
from custom_components.versatile_thermostat.const import (
|
||||||
|
CONF_NAME,
|
||||||
|
CONF_HEATER,
|
||||||
|
CONF_THERMOSTAT_CLIMATE,
|
||||||
|
CONF_THERMOSTAT_SWITCH,
|
||||||
|
CONF_THERMOSTAT_TYPE,
|
||||||
|
CONF_TEMP_SENSOR,
|
||||||
|
CONF_EXTERNAL_TEMP_SENSOR,
|
||||||
|
CONF_CYCLE_MIN,
|
||||||
|
CONF_TEMP_MAX,
|
||||||
|
CONF_TEMP_MIN,
|
||||||
|
CONF_PROP_FUNCTION,
|
||||||
|
PROPORTIONAL_FUNCTION_TPI,
|
||||||
|
CONF_TPI_COEF_INT,
|
||||||
|
CONF_TPI_COEF_EXT,
|
||||||
|
CONF_MINIMAL_ACTIVATION_DELAY,
|
||||||
|
CONF_SECURITY_DELAY_MIN,
|
||||||
|
CONF_SECURITY_MIN_ON_PERCENT,
|
||||||
|
CONF_SECURITY_DEFAULT_ON_PERCENT,
|
||||||
|
CONF_USE_WINDOW_FEATURE,
|
||||||
|
CONF_USE_MOTION_FEATURE,
|
||||||
|
CONF_USE_POWER_FEATURE,
|
||||||
|
CONF_USE_PRESENCE_FEATURE,
|
||||||
|
CONF_WINDOW_SENSOR,
|
||||||
|
CONF_WINDOW_DELAY,
|
||||||
|
CONF_MOTION_SENSOR,
|
||||||
|
CONF_MOTION_DELAY,
|
||||||
|
CONF_MOTION_PRESET,
|
||||||
|
CONF_NO_MOTION_PRESET,
|
||||||
|
CONF_POWER_SENSOR,
|
||||||
|
CONF_MAX_POWER_SENSOR,
|
||||||
|
CONF_DEVICE_POWER,
|
||||||
|
CONF_PRESET_POWER,
|
||||||
|
CONF_PRESENCE_SENSOR,
|
||||||
|
PRESET_AWAY_SUFFIX,
|
||||||
|
CONF_CLIMATE,
|
||||||
|
)
|
||||||
|
|
||||||
|
MOCK_TH_OVER_SWITCH_USER_CONFIG = {
|
||||||
|
CONF_NAME: "TheOverSwitchMockName",
|
||||||
|
CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_SWITCH,
|
||||||
|
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,
|
||||||
|
CONF_USE_WINDOW_FEATURE: True,
|
||||||
|
CONF_USE_MOTION_FEATURE: True,
|
||||||
|
CONF_USE_POWER_FEATURE: True,
|
||||||
|
CONF_USE_PRESENCE_FEATURE: True,
|
||||||
|
}
|
||||||
|
|
||||||
|
MOCK_TH_OVER_CLIMATE_USER_CONFIG = {
|
||||||
|
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,
|
||||||
|
# Keep default values which are False
|
||||||
|
}
|
||||||
|
|
||||||
|
MOCK_TH_OVER_SWITCH_TYPE_CONFIG = {
|
||||||
|
CONF_HEATER: "switch.mock_switch",
|
||||||
|
CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI,
|
||||||
|
}
|
||||||
|
|
||||||
|
MOCK_TH_OVER_SWITCH_TPI_CONFIG = {
|
||||||
|
CONF_TPI_COEF_INT: 0.3,
|
||||||
|
CONF_TPI_COEF_EXT: 0.1,
|
||||||
|
}
|
||||||
|
|
||||||
|
MOCK_TH_OVER_CLIMATE_TYPE_CONFIG = {
|
||||||
|
CONF_CLIMATE: "climate.mock_climate",
|
||||||
|
}
|
||||||
|
|
||||||
|
MOCK_PRESETS_CONFIG = {
|
||||||
|
PRESET_ECO + "_temp": 16,
|
||||||
|
PRESET_COMFORT + "_temp": 17,
|
||||||
|
PRESET_BOOST + "_temp": 18,
|
||||||
|
}
|
||||||
|
|
||||||
|
MOCK_WINDOW_CONFIG = {
|
||||||
|
CONF_WINDOW_SENSOR: "binary_sensor.window_sensor",
|
||||||
|
CONF_WINDOW_DELAY: 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
MOCK_MOTION_CONFIG = {
|
||||||
|
CONF_MOTION_SENSOR: "input_boolean.motion_sensor",
|
||||||
|
CONF_MOTION_DELAY: 10,
|
||||||
|
CONF_MOTION_PRESET: PRESET_COMFORT,
|
||||||
|
CONF_NO_MOTION_PRESET: PRESET_ECO,
|
||||||
|
}
|
||||||
|
|
||||||
|
MOCK_POWER_CONFIG = {
|
||||||
|
CONF_POWER_SENSOR: "sensor.power_sensor",
|
||||||
|
CONF_MAX_POWER_SENSOR: "sensor.power_max_sensor",
|
||||||
|
CONF_DEVICE_POWER: 1,
|
||||||
|
CONF_PRESET_POWER: 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
MOCK_PRESENCE_CONFIG = {
|
||||||
|
CONF_PRESENCE_SENSOR: "person.presence_sensor",
|
||||||
|
PRESET_ECO + PRESET_AWAY_SUFFIX + "_temp": 16,
|
||||||
|
PRESET_COMFORT + PRESET_AWAY_SUFFIX + "_temp": 17,
|
||||||
|
PRESET_BOOST + PRESET_AWAY_SUFFIX + "_temp": 18,
|
||||||
|
}
|
||||||
|
|
||||||
|
MOCK_ADVANCED_CONFIG = {
|
||||||
|
CONF_MINIMAL_ACTIVATION_DELAY: 10,
|
||||||
|
CONF_SECURITY_DELAY_MIN: 5,
|
||||||
|
CONF_SECURITY_MIN_ON_PERCENT: 0.4,
|
||||||
|
CONF_SECURITY_DEFAULT_ON_PERCENT: 0.3,
|
||||||
|
}
|
||||||
|
|
||||||
|
MOCK_DEFAULT_FEATURE_CONFIG = {
|
||||||
|
CONF_USE_WINDOW_FEATURE: False,
|
||||||
|
CONF_USE_MOTION_FEATURE: False,
|
||||||
|
CONF_USE_POWER_FEATURE: False,
|
||||||
|
CONF_USE_PRESENCE_FEATURE: False,
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
""" Test the Versatile Thermostat config flow """
|
||||||
|
|
||||||
|
from homeassistant import data_entry_flow
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.config_entries import SOURCE_USER, ConfigEntry
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pytest_homeassistant_custom_component.common import MockConfigEntry, load_fixture
|
||||||
|
|
||||||
|
from custom_components.versatile_thermostat.const import DOMAIN
|
||||||
|
from custom_components.versatile_thermostat import VersatileThermostatAPI
|
||||||
|
|
||||||
|
from .const import (
|
||||||
|
MOCK_TH_OVER_SWITCH_USER_CONFIG,
|
||||||
|
MOCK_TH_OVER_CLIMATE_USER_CONFIG,
|
||||||
|
MOCK_TH_OVER_SWITCH_TYPE_CONFIG,
|
||||||
|
MOCK_TH_OVER_CLIMATE_TYPE_CONFIG,
|
||||||
|
MOCK_TH_OVER_SWITCH_TPI_CONFIG,
|
||||||
|
MOCK_PRESETS_CONFIG,
|
||||||
|
MOCK_WINDOW_CONFIG,
|
||||||
|
MOCK_MOTION_CONFIG,
|
||||||
|
MOCK_POWER_CONFIG,
|
||||||
|
MOCK_PRESENCE_CONFIG,
|
||||||
|
MOCK_ADVANCED_CONFIG,
|
||||||
|
MOCK_DEFAULT_FEATURE_CONFIG,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_show_form(hass: HomeAssistant) -> None:
|
||||||
|
"""Test that the form is served with no input"""
|
||||||
|
# Init the API
|
||||||
|
# hass.data["custom_components"] = None
|
||||||
|
# loader.async_get_custom_components(hass)
|
||||||
|
# VersatileThermostatAPI(hass)
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": SOURCE_USER}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
assert result["step_id"] == SOURCE_USER
|
||||||
|
|
||||||
|
|
||||||
|
async def test_user_config_flow_over_switch(hass, skip_hass_states_get):
|
||||||
|
"""Test the config flow with all thermostat_over_switch features"""
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": SOURCE_USER}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
assert result["step_id"] == SOURCE_USER
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"], user_input=MOCK_TH_OVER_SWITCH_USER_CONFIG
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
assert result["step_id"] == "type"
|
||||||
|
assert result["errors"] == {}
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"], user_input=MOCK_TH_OVER_SWITCH_TYPE_CONFIG
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
assert result["step_id"] == "tpi"
|
||||||
|
assert result["errors"] == {}
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"], user_input=MOCK_TH_OVER_SWITCH_TPI_CONFIG
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
assert result["step_id"] == "presets"
|
||||||
|
assert result["errors"] == {}
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"], user_input=MOCK_PRESETS_CONFIG
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
assert result["step_id"] == "window"
|
||||||
|
assert result["errors"] == {}
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"], user_input=MOCK_WINDOW_CONFIG
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
assert result["step_id"] == "motion"
|
||||||
|
assert result["errors"] == {}
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"], user_input=MOCK_MOTION_CONFIG
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
assert result["step_id"] == "power"
|
||||||
|
assert result["errors"] == {}
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"], user_input=MOCK_POWER_CONFIG
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
assert result["step_id"] == "presence"
|
||||||
|
assert result["errors"] == {}
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"], user_input=MOCK_PRESENCE_CONFIG
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
assert result["step_id"] == "advanced"
|
||||||
|
assert result["errors"] == {}
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"], user_input=MOCK_ADVANCED_CONFIG
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
|
||||||
|
assert (
|
||||||
|
result["data"]
|
||||||
|
== MOCK_TH_OVER_SWITCH_USER_CONFIG
|
||||||
|
| MOCK_TH_OVER_SWITCH_TYPE_CONFIG
|
||||||
|
| MOCK_TH_OVER_SWITCH_TPI_CONFIG
|
||||||
|
| MOCK_PRESETS_CONFIG
|
||||||
|
| MOCK_WINDOW_CONFIG
|
||||||
|
| MOCK_MOTION_CONFIG
|
||||||
|
| MOCK_POWER_CONFIG
|
||||||
|
| MOCK_PRESENCE_CONFIG
|
||||||
|
| MOCK_ADVANCED_CONFIG
|
||||||
|
)
|
||||||
|
assert result["result"]
|
||||||
|
assert result["result"].domain == DOMAIN
|
||||||
|
assert result["result"].version == 1
|
||||||
|
assert result["result"].title == "TheOverSwitchMockName"
|
||||||
|
assert isinstance(result["result"], ConfigEntry)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_user_config_flow_over_climate(hass, skip_hass_states_get):
|
||||||
|
"""Test the config flow with all thermostat_over_climate features and no additional features"""
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": SOURCE_USER}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
assert result["step_id"] == SOURCE_USER
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"], user_input=MOCK_TH_OVER_CLIMATE_USER_CONFIG
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
assert result["step_id"] == "type"
|
||||||
|
assert result["errors"] == {}
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"], user_input=MOCK_TH_OVER_CLIMATE_TYPE_CONFIG
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
assert result["step_id"] == "presets"
|
||||||
|
assert result["errors"] == {}
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"], user_input=MOCK_PRESETS_CONFIG
|
||||||
|
)
|
||||||
|
|
||||||
|
# assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
# assert result["step_id"] == "window"
|
||||||
|
# assert result["errors"] == {}
|
||||||
|
|
||||||
|
# result = await hass.config_entries.flow.async_configure(
|
||||||
|
# result["flow_id"], user_input=MOCK_WINDOW_CONFIG
|
||||||
|
# )
|
||||||
|
|
||||||
|
# assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
# assert result["step_id"] == "motion"
|
||||||
|
# assert result["errors"] == {}
|
||||||
|
|
||||||
|
# result = await hass.config_entries.flow.async_configure(
|
||||||
|
# result["flow_id"], user_input=MOCK_MOTION_CONFIG
|
||||||
|
# )
|
||||||
|
|
||||||
|
# assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
# assert result["step_id"] == "power"
|
||||||
|
# assert result["errors"] == {}
|
||||||
|
|
||||||
|
# result = await hass.config_entries.flow.async_configure(
|
||||||
|
# result["flow_id"], user_input=MOCK_POWER_CONFIG
|
||||||
|
# )
|
||||||
|
|
||||||
|
# assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
# assert result["step_id"] == "presence"
|
||||||
|
# assert result["errors"] == {}
|
||||||
|
|
||||||
|
# result = await hass.config_entries.flow.async_configure(
|
||||||
|
# result["flow_id"], user_input=MOCK_PRESENCE_CONFIG
|
||||||
|
# )
|
||||||
|
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
assert result["step_id"] == "advanced"
|
||||||
|
assert result["errors"] == {}
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"], user_input=MOCK_ADVANCED_CONFIG
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
|
||||||
|
assert (
|
||||||
|
result["data"]
|
||||||
|
== MOCK_TH_OVER_CLIMATE_USER_CONFIG
|
||||||
|
| MOCK_TH_OVER_CLIMATE_TYPE_CONFIG
|
||||||
|
| MOCK_PRESETS_CONFIG
|
||||||
|
| MOCK_ADVANCED_CONFIG
|
||||||
|
| MOCK_DEFAULT_FEATURE_CONFIG
|
||||||
|
)
|
||||||
|
assert result["result"]
|
||||||
|
assert result["result"].domain == DOMAIN
|
||||||
|
assert result["result"].version == 1
|
||||||
|
assert result["result"].title == "TheOverClimateMockName"
|
||||||
|
assert isinstance(result["result"], ConfigEntry)
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
""" 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
|
||||||
|
|
||||||
|
logging.getLogger().setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_security_feature(hass: HomeAssistant, skip_hass_states_is_state):
|
||||||
|
"""Test the security feature and https://github.com/jmcollin78/versatile_thermostat/issues/49:
|
||||||
|
1. creates a thermostat and check that security is off
|
||||||
|
2. activate security feature when date is expired
|
||||||
|
3. change the preset to boost
|
||||||
|
4. check that security is still on
|
||||||
|
5. resolve the date issue
|
||||||
|
6. check that security is off and preset is changed to boost
|
||||||
|
"""
|
||||||
|
|
||||||
|
entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
title="TheOverSwitchMockName",
|
||||||
|
unique_id="uniqueId",
|
||||||
|
data={
|
||||||
|
"name": "TheOverSwitchMockName",
|
||||||
|
"thermostat_type": "thermostat_over_switch",
|
||||||
|
"temperature_sensor_entity_id": "sensor.mock_temp_sensor",
|
||||||
|
"external_temperature_sensor_entity_id": "sensor.mock_ext_temp_sensor",
|
||||||
|
"cycle_min": 5,
|
||||||
|
"temp_min": 15,
|
||||||
|
"temp_max": 30,
|
||||||
|
"eco_temp": 17,
|
||||||
|
"comfort_temp": 18,
|
||||||
|
"boost_temp": 19,
|
||||||
|
"use_window_feature": False,
|
||||||
|
"use_motion_feature": False,
|
||||||
|
"use_power_feature": False,
|
||||||
|
"use_presence_feature": False,
|
||||||
|
"heater_entity_id": "switch.mock_switch",
|
||||||
|
"proportional_function": "tpi",
|
||||||
|
"tpi_coef_int": 0.3,
|
||||||
|
"tpi_coef_ext": 0.01,
|
||||||
|
"minimal_activation_delay": 30,
|
||||||
|
"security_delay_min": 5, # 5 minutes
|
||||||
|
"security_min_on_percent": 0.2,
|
||||||
|
"security_default_on_percent": 0.1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# 1. creates a thermostat and check that security is off
|
||||||
|
now: datetime = datetime.now()
|
||||||
|
entity: VersatileThermostat = await create_thermostat(
|
||||||
|
hass, entry, "climate.theoverswitchmockname"
|
||||||
|
)
|
||||||
|
assert entity
|
||||||
|
|
||||||
|
assert entity._security_state is False
|
||||||
|
assert entity.preset_mode is not PRESET_SECURITY
|
||||||
|
assert entity.preset_modes == [
|
||||||
|
PRESET_NONE,
|
||||||
|
PRESET_ECO,
|
||||||
|
PRESET_COMFORT,
|
||||||
|
PRESET_BOOST,
|
||||||
|
]
|
||||||
|
assert entity._last_ext_temperature_mesure is not None
|
||||||
|
assert entity._last_temperature_mesure is not None
|
||||||
|
assert (entity._last_temperature_mesure - now).total_seconds() < 1
|
||||||
|
assert (entity._last_ext_temperature_mesure - now).total_seconds() < 1
|
||||||
|
|
||||||
|
# set a preset
|
||||||
|
assert entity.preset_mode is PRESET_NONE
|
||||||
|
await entity.async_set_preset_mode(PRESET_COMFORT)
|
||||||
|
assert entity.preset_mode is PRESET_COMFORT
|
||||||
|
|
||||||
|
# Turn On the thermostat
|
||||||
|
assert entity.hvac_mode == HVACMode.OFF
|
||||||
|
await entity.async_set_hvac_mode(HVACMode.HEAT)
|
||||||
|
assert entity.hvac_mode == HVACMode.HEAT
|
||||||
|
|
||||||
|
# 2. activate security feature when date is expired
|
||||||
|
with patch(
|
||||||
|
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
|
||||||
|
) as mock_send_event, patch(
|
||||||
|
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_heater_turn_on"
|
||||||
|
) as mock_heater_on:
|
||||||
|
event_timestamp = now - timedelta(minutes=6)
|
||||||
|
|
||||||
|
# set temperature to 15 so that on_percent will be > security_min_on_percent (0.2)
|
||||||
|
await send_temperature_change_event(entity, 15, event_timestamp)
|
||||||
|
assert entity._security_state is True
|
||||||
|
assert entity.preset_mode == PRESET_SECURITY
|
||||||
|
assert entity._saved_preset_mode == PRESET_COMFORT
|
||||||
|
assert entity._prop_algorithm.on_percent == 0.1
|
||||||
|
assert entity._prop_algorithm.calculated_on_percent == 0.9
|
||||||
|
|
||||||
|
assert mock_send_event.call_count == 3
|
||||||
|
mock_send_event.assert_has_calls(
|
||||||
|
[
|
||||||
|
call.send_event(EventType.PRESET_EVENT, {"preset": PRESET_SECURITY}),
|
||||||
|
call.send_event(
|
||||||
|
EventType.TEMPERATURE_EVENT,
|
||||||
|
{
|
||||||
|
"last_temperature_mesure": event_timestamp.isoformat(),
|
||||||
|
"last_ext_temperature_mesure": entity._last_ext_temperature_mesure.isoformat(),
|
||||||
|
"current_temp": 15,
|
||||||
|
"current_ext_temp": None,
|
||||||
|
"target_temp": 18,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
call.send_event(
|
||||||
|
EventType.SECURITY_EVENT,
|
||||||
|
{
|
||||||
|
"type": "start",
|
||||||
|
"last_temperature_mesure": event_timestamp.isoformat(),
|
||||||
|
"last_ext_temperature_mesure": entity._last_ext_temperature_mesure.isoformat(),
|
||||||
|
"current_temp": 15,
|
||||||
|
"current_ext_temp": None,
|
||||||
|
"target_temp": 18,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
any_order=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert mock_heater_on.call_count == 1
|
||||||
|
|
||||||
|
# 3. Change the preset to Boost (we should stay in SECURITY)
|
||||||
|
with patch(
|
||||||
|
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
|
||||||
|
) as mock_send_event, patch(
|
||||||
|
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_heater_turn_on"
|
||||||
|
) as mock_heater_on:
|
||||||
|
await entity.async_set_preset_mode(PRESET_BOOST)
|
||||||
|
|
||||||
|
# 4. check that security is still on
|
||||||
|
assert entity._security_state is True
|
||||||
|
assert entity._prop_algorithm.on_percent == 0.1
|
||||||
|
assert entity._prop_algorithm.calculated_on_percent == 0.9
|
||||||
|
assert entity._saved_preset_mode == PRESET_BOOST
|
||||||
|
assert entity.preset_mode is PRESET_SECURITY
|
||||||
|
|
||||||
|
# 5. resolve the datetime issue
|
||||||
|
with patch(
|
||||||
|
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
|
||||||
|
) as mock_send_event, patch(
|
||||||
|
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_heater_turn_on"
|
||||||
|
) as mock_heater_on:
|
||||||
|
event_timestamp = datetime.now()
|
||||||
|
|
||||||
|
# set temperature to 15 so that on_percent will be > security_min_on_percent (0.2)
|
||||||
|
await send_temperature_change_event(entity, 15.2, event_timestamp)
|
||||||
|
|
||||||
|
assert entity._security_state is False
|
||||||
|
assert entity.preset_mode == PRESET_BOOST
|
||||||
|
assert entity._saved_preset_mode == PRESET_BOOST
|
||||||
|
assert entity._prop_algorithm.on_percent == 1.0
|
||||||
|
assert entity._prop_algorithm.calculated_on_percent == 1.0
|
||||||
|
|
||||||
|
assert mock_send_event.call_count == 2
|
||||||
|
mock_send_event.assert_has_calls(
|
||||||
|
[
|
||||||
|
call.send_event(EventType.PRESET_EVENT, {"preset": PRESET_BOOST}),
|
||||||
|
call.send_event(
|
||||||
|
EventType.SECURITY_EVENT,
|
||||||
|
{
|
||||||
|
"type": "end",
|
||||||
|
"last_temperature_mesure": event_timestamp.isoformat(),
|
||||||
|
"last_ext_temperature_mesure": entity._last_ext_temperature_mesure.isoformat(),
|
||||||
|
"current_temp": 15.2,
|
||||||
|
"current_ext_temp": None,
|
||||||
|
"target_temp": 19,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
any_order=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert mock_heater_on.call_count == 0
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
""" Test the normal start of a Thermostat """
|
||||||
|
from unittest.mock import patch, call
|
||||||
|
|
||||||
|
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 ..climate import VersatileThermostat
|
||||||
|
|
||||||
|
from .commons import *
|
||||||
|
|
||||||
|
|
||||||
|
async def test_over_switch_full_start(hass: HomeAssistant, skip_hass_states_is_state):
|
||||||
|
"""Test the normal full start of a thermostat in thermostat_over_switch type"""
|
||||||
|
|
||||||
|
entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
title="TheOverSwitchMockName",
|
||||||
|
unique_id="uniqueId",
|
||||||
|
data=FULL_SWITCH_CONFIG,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
|
||||||
|
) as mock_send_event:
|
||||||
|
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: VersatileThermostat = find_my_entity("climate.theoverswitchmockname")
|
||||||
|
|
||||||
|
assert entity
|
||||||
|
|
||||||
|
assert entity.name == "TheOverSwitchMockName"
|
||||||
|
assert entity._is_over_climate is False
|
||||||
|
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,
|
||||||
|
PRESET_ACTIVITY,
|
||||||
|
]
|
||||||
|
assert entity.preset_mode is PRESET_NONE
|
||||||
|
assert entity._security_state is False
|
||||||
|
assert entity._window_state is None
|
||||||
|
assert entity._motion_state is None
|
||||||
|
assert entity._presence_state is None
|
||||||
|
assert entity._prop_algorithm is not None
|
||||||
|
|
||||||
|
# should have been called with EventType.PRESET_EVENT and EventType.HVAC_MODE_EVENT
|
||||||
|
assert mock_send_event.call_count == 2
|
||||||
|
|
||||||
|
mock_send_event.assert_has_calls(
|
||||||
|
[
|
||||||
|
call.send_event(EventType.PRESET_EVENT, {"preset": PRESET_NONE}),
|
||||||
|
call.send_event(
|
||||||
|
EventType.HVAC_MODE_EVENT,
|
||||||
|
{"hvac_mode": HVACMode.OFF},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_over_climate_full_start(hass: HomeAssistant, skip_hass_states_is_state):
|
||||||
|
"""Test the normal full start of a thermostat in thermostat_over_climate type"""
|
||||||
|
|
||||||
|
entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
title="TheOverClimateMockName",
|
||||||
|
unique_id="uniqueId",
|
||||||
|
data=PARTIAL_CLIMATE_CONFIG,
|
||||||
|
)
|
||||||
|
|
||||||
|
fake_underlying_climate = MockClimate(hass, "mockUniqueId", "MockClimateName", {})
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"custom_components.versatile_thermostat.climate.VersatileThermostat.send_event"
|
||||||
|
) as mock_send_event, patch(
|
||||||
|
"custom_components.versatile_thermostat.climate.VersatileThermostat.find_underlying_climate",
|
||||||
|
return_value=fake_underlying_climate,
|
||||||
|
) as mock_find_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 = find_my_entity("climate.theoverclimatemockname")
|
||||||
|
|
||||||
|
assert entity
|
||||||
|
|
||||||
|
assert entity.name == "TheOverClimateMockName"
|
||||||
|
assert entity._is_over_climate 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
|
||||||
|
assert entity._security_state is False
|
||||||
|
assert entity._window_state is None
|
||||||
|
assert entity._motion_state is None
|
||||||
|
assert entity._presence_state is None
|
||||||
|
|
||||||
|
# should have been called with EventType.PRESET_EVENT and EventType.HVAC_MODE_EVENT
|
||||||
|
assert mock_send_event.call_count == 2
|
||||||
|
mock_send_event.assert_has_calls(
|
||||||
|
[
|
||||||
|
call.send_event(EventType.PRESET_EVENT, {"preset": PRESET_NONE}),
|
||||||
|
call.send_event(
|
||||||
|
EventType.HVAC_MODE_EVENT,
|
||||||
|
{"hvac_mode": HVACMode.OFF},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert mock_find_climate.call_count == 1
|
||||||
|
assert mock_find_climate.mock_calls[0] == call("climate.mock_climate")
|
||||||
|
mock_find_climate.assert_has_calls(
|
||||||
|
[call.find_underlying_entity("climate.mock_climate")]
|
||||||
|
)
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
""" Test the TPI algorithm """
|
||||||
|
|
||||||
|
from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import
|
||||||
|
|
||||||
|
|
||||||
|
async def test_tpi_calculation(hass: HomeAssistant, skip_hass_states_is_state):
|
||||||
|
"""Test the TPI calculation"""
|
||||||
|
|
||||||
|
entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
title="TheOverSwitchMockName",
|
||||||
|
unique_id="uniqueId",
|
||||||
|
data={
|
||||||
|
"name": "TheOverSwitchMockName",
|
||||||
|
"thermostat_type": "thermostat_over_switch",
|
||||||
|
"temperature_sensor_entity_id": "sensor.mock_temp_sensor",
|
||||||
|
"external_temperature_sensor_entity_id": "sensor.mock_ext_temp_sensor",
|
||||||
|
"cycle_min": 5,
|
||||||
|
"temp_min": 15,
|
||||||
|
"temp_max": 30,
|
||||||
|
"use_window_feature": False,
|
||||||
|
"use_motion_feature": False,
|
||||||
|
"use_power_feature": False,
|
||||||
|
"use_presence_feature": False,
|
||||||
|
"heater_entity_id": "switch.mock_switch",
|
||||||
|
"proportional_function": "tpi",
|
||||||
|
"tpi_coef_int": 0.3,
|
||||||
|
"tpi_coef_ext": 0.01,
|
||||||
|
"minimal_activation_delay": 30,
|
||||||
|
"security_delay_min": 5,
|
||||||
|
"security_default_on_percent": 0.3,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
entity: VersatileThermostat = await create_thermostat(
|
||||||
|
hass, entry, "climate.theoverswitchmockname"
|
||||||
|
)
|
||||||
|
assert entity
|
||||||
|
|
||||||
|
tpi_algo = entity._prop_algorithm
|
||||||
|
assert tpi_algo
|
||||||
|
|
||||||
|
tpi_algo.calculate(15, 10, 7)
|
||||||
|
assert tpi_algo.on_percent == 1
|
||||||
|
assert tpi_algo.calculated_on_percent == 1
|
||||||
|
assert tpi_algo.on_time_sec == 300
|
||||||
|
assert tpi_algo.off_time_sec == 0
|
||||||
|
|
||||||
|
tpi_algo.calculate(15, 14, 5)
|
||||||
|
assert tpi_algo.on_percent == 0.4
|
||||||
|
assert tpi_algo.calculated_on_percent == 0.4
|
||||||
|
assert tpi_algo.on_time_sec == 120
|
||||||
|
assert tpi_algo.off_time_sec == 180
|
||||||
|
|
||||||
|
tpi_algo.set_security(0.1)
|
||||||
|
tpi_algo.calculate(15, 14, 5)
|
||||||
|
assert tpi_algo.on_percent == 0.1
|
||||||
|
assert tpi_algo.calculated_on_percent == 0.4
|
||||||
|
assert tpi_algo.on_time_sec == 30 # >= minimal_activation_delay (=30)
|
||||||
|
assert tpi_algo.off_time_sec == 270
|
||||||
|
|
||||||
|
tpi_algo.unset_security()
|
||||||
|
tpi_algo.calculate(15, 14, 5)
|
||||||
|
assert tpi_algo.on_percent == 0.4
|
||||||
|
assert tpi_algo.calculated_on_percent == 0.4
|
||||||
|
assert tpi_algo.on_time_sec == 120
|
||||||
|
assert tpi_algo.off_time_sec == 180
|
||||||
|
|
||||||
|
# Test minimal activation delay
|
||||||
|
tpi_algo.calculate(15, 14.7, 15)
|
||||||
|
assert tpi_algo.on_percent == 0.09
|
||||||
|
assert tpi_algo.calculated_on_percent == 0.09
|
||||||
|
assert tpi_algo.on_time_sec == 0
|
||||||
|
assert tpi_algo.off_time_sec == 300
|
||||||
|
|
||||||
|
tpi_algo.set_security(0.09)
|
||||||
|
tpi_algo.calculate(15, 14.7, 15)
|
||||||
|
assert tpi_algo.on_percent == 0.09
|
||||||
|
assert tpi_algo.calculated_on_percent == 0.09
|
||||||
|
assert tpi_algo.on_time_sec == 0
|
||||||
|
assert tpi_algo.off_time_sec == 300
|
||||||
Reference in New Issue
Block a user