Compare commits
1 Commits
3.1.0.alph
...
3.1.0.beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63cf77abc9 |
@@ -64,10 +64,8 @@ class SecurityBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity):
|
|||||||
@callback
|
@callback
|
||||||
async def async_my_climate_changed(self, event: Event = None):
|
async def async_my_climate_changed(self, event: Event = None):
|
||||||
"""Called when my climate have change"""
|
"""Called when my climate have change"""
|
||||||
_LOGGER.debug(
|
_LOGGER.debug("%s - climate state change", self._attr_unique_id)
|
||||||
"%s - climate state change",
|
|
||||||
event.origin.name if event and event.origin else None,
|
|
||||||
)
|
|
||||||
old_state = self._attr_is_on
|
old_state = self._attr_is_on
|
||||||
self._attr_is_on = self.my_climate.security_state is True
|
self._attr_is_on = self.my_climate.security_state is True
|
||||||
if old_state != self._attr_is_on:
|
if old_state != self._attr_is_on:
|
||||||
@@ -99,10 +97,8 @@ class OverpoweringBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity
|
|||||||
@callback
|
@callback
|
||||||
async def async_my_climate_changed(self, event: Event = None):
|
async def async_my_climate_changed(self, event: Event = None):
|
||||||
"""Called when my climate have change"""
|
"""Called when my climate have change"""
|
||||||
_LOGGER.debug(
|
_LOGGER.debug("%s - climate state change", self._attr_unique_id)
|
||||||
"%s - climate state change",
|
|
||||||
event.origin.name if event and event.origin else None,
|
|
||||||
)
|
|
||||||
old_state = self._attr_is_on
|
old_state = self._attr_is_on
|
||||||
self._attr_is_on = self.my_climate.overpowering_state is True
|
self._attr_is_on = self.my_climate.overpowering_state is True
|
||||||
if old_state != self._attr_is_on:
|
if old_state != self._attr_is_on:
|
||||||
@@ -134,12 +130,13 @@ class WindowBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity):
|
|||||||
@callback
|
@callback
|
||||||
async def async_my_climate_changed(self, event: Event = None):
|
async def async_my_climate_changed(self, event: Event = None):
|
||||||
"""Called when my climate have change"""
|
"""Called when my climate have change"""
|
||||||
_LOGGER.debug(
|
_LOGGER.debug("%s - climate state change", self._attr_unique_id)
|
||||||
"%s - climate state change",
|
|
||||||
event.origin.name if event and event.origin else None,
|
|
||||||
)
|
|
||||||
old_state = self._attr_is_on
|
old_state = self._attr_is_on
|
||||||
self._attr_is_on = self.my_climate.window_state == STATE_ON
|
self._attr_is_on = (
|
||||||
|
self.my_climate.window_state == STATE_ON
|
||||||
|
or self.my_climate.window_auto_state == STATE_ON
|
||||||
|
)
|
||||||
if old_state != self._attr_is_on:
|
if old_state != self._attr_is_on:
|
||||||
self.async_write_ha_state()
|
self.async_write_ha_state()
|
||||||
return
|
return
|
||||||
@@ -151,7 +148,10 @@ class WindowBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity):
|
|||||||
@property
|
@property
|
||||||
def icon(self) -> str | None:
|
def icon(self) -> str | None:
|
||||||
if self._attr_is_on:
|
if self._attr_is_on:
|
||||||
return "mdi:window-open-variant"
|
if self.my_climate.window_state == STATE_ON:
|
||||||
|
return "mdi:window-open-variant"
|
||||||
|
else:
|
||||||
|
return "mdi:window-open"
|
||||||
else:
|
else:
|
||||||
return "mdi:window-closed-variant"
|
return "mdi:window-closed-variant"
|
||||||
|
|
||||||
@@ -169,10 +169,7 @@ class MotionBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity):
|
|||||||
@callback
|
@callback
|
||||||
async def async_my_climate_changed(self, event: Event = None):
|
async def async_my_climate_changed(self, event: Event = None):
|
||||||
"""Called when my climate have change"""
|
"""Called when my climate have change"""
|
||||||
_LOGGER.debug(
|
_LOGGER.debug("%s - climate state change", self._attr_unique_id)
|
||||||
"%s - climate state change",
|
|
||||||
event.origin.name if event and event.origin else None,
|
|
||||||
)
|
|
||||||
old_state = self._attr_is_on
|
old_state = self._attr_is_on
|
||||||
self._attr_is_on = self.my_climate.motion_state == STATE_ON
|
self._attr_is_on = self.my_climate.motion_state == STATE_ON
|
||||||
if old_state != self._attr_is_on:
|
if old_state != self._attr_is_on:
|
||||||
@@ -205,10 +202,7 @@ class PresenceBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity):
|
|||||||
async def async_my_climate_changed(self, event: Event = None):
|
async def async_my_climate_changed(self, event: Event = None):
|
||||||
"""Called when my climate have change"""
|
"""Called when my climate have change"""
|
||||||
|
|
||||||
_LOGGER.debug(
|
_LOGGER.debug("%s - climate state change", self._attr_unique_id)
|
||||||
"%s - climate state change",
|
|
||||||
event.origin.name if event and event.origin else None,
|
|
||||||
)
|
|
||||||
old_state = self._attr_is_on
|
old_state = self._attr_is_on
|
||||||
self._attr_is_on = self.my_climate.presence_state == STATE_ON
|
self._attr_is_on = self.my_climate.presence_state == STATE_ON
|
||||||
if old_state != self._attr_is_on:
|
if old_state != self._attr_is_on:
|
||||||
|
|||||||
@@ -103,6 +103,9 @@ from .const import (
|
|||||||
CONF_MAX_POWER_SENSOR,
|
CONF_MAX_POWER_SENSOR,
|
||||||
CONF_WINDOW_SENSOR,
|
CONF_WINDOW_SENSOR,
|
||||||
CONF_WINDOW_DELAY,
|
CONF_WINDOW_DELAY,
|
||||||
|
CONF_WINDOW_AUTO_CLOSE_THRESHOLD,
|
||||||
|
CONF_WINDOW_AUTO_OPEN_THRESHOLD,
|
||||||
|
CONF_WINDOW_AUTO_MAX_DURATION,
|
||||||
CONF_MOTION_SENSOR,
|
CONF_MOTION_SENSOR,
|
||||||
CONF_MOTION_DELAY,
|
CONF_MOTION_DELAY,
|
||||||
CONF_MOTION_PRESET,
|
CONF_MOTION_PRESET,
|
||||||
@@ -144,6 +147,7 @@ from .const import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from .prop_algorithm import PropAlgorithm
|
from .prop_algorithm import PropAlgorithm
|
||||||
|
from .open_window_algorithm import WindowOpenDetectionAlgorithm
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -216,6 +220,7 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
|||||||
_motion_state: bool
|
_motion_state: bool
|
||||||
_presence_state: bool
|
_presence_state: bool
|
||||||
_security_state: bool
|
_security_state: bool
|
||||||
|
_window_auto_state: bool
|
||||||
|
|
||||||
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."""
|
||||||
@@ -270,6 +275,13 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
|||||||
self._underlying_climate_start_hvac_action_date = None
|
self._underlying_climate_start_hvac_action_date = None
|
||||||
self._underlying_climate_delta_t = 0
|
self._underlying_climate_delta_t = 0
|
||||||
|
|
||||||
|
self._window_auto_open_threshold = 0
|
||||||
|
self._window_auto_close_threshold = 0
|
||||||
|
self._window_auto_max_duration = 0
|
||||||
|
self._window_auto_state = False
|
||||||
|
self._window_auto_on = False
|
||||||
|
self._window_auto_algo = None
|
||||||
|
|
||||||
self._current_tz = dt_util.get_time_zone(self._hass.config.time_zone)
|
self._current_tz = dt_util.get_time_zone(self._hass.config.time_zone)
|
||||||
|
|
||||||
self.post_init(entry_infos)
|
self.post_init(entry_infos)
|
||||||
@@ -340,6 +352,27 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
|||||||
self._max_power_sensor_entity_id = entry_infos.get(CONF_MAX_POWER_SENSOR)
|
self._max_power_sensor_entity_id = entry_infos.get(CONF_MAX_POWER_SENSOR)
|
||||||
self._window_sensor_entity_id = entry_infos.get(CONF_WINDOW_SENSOR)
|
self._window_sensor_entity_id = entry_infos.get(CONF_WINDOW_SENSOR)
|
||||||
self._window_delay_sec = entry_infos.get(CONF_WINDOW_DELAY)
|
self._window_delay_sec = entry_infos.get(CONF_WINDOW_DELAY)
|
||||||
|
|
||||||
|
self._window_auto_open_threshold = entry_infos.get(
|
||||||
|
CONF_WINDOW_AUTO_OPEN_THRESHOLD
|
||||||
|
)
|
||||||
|
self._window_auto_close_threshold = entry_infos.get(
|
||||||
|
CONF_WINDOW_AUTO_CLOSE_THRESHOLD
|
||||||
|
)
|
||||||
|
self._window_auto_max_duration = entry_infos.get(CONF_WINDOW_AUTO_MAX_DURATION)
|
||||||
|
self._window_auto_on = (
|
||||||
|
self._window_auto_open_threshold is not None
|
||||||
|
and self._window_auto_open_threshold > 0.0
|
||||||
|
and self._window_auto_close_threshold is not None
|
||||||
|
and self._window_auto_max_duration is not None
|
||||||
|
and self._window_auto_max_duration > 0
|
||||||
|
)
|
||||||
|
self._window_auto_state = False
|
||||||
|
self._window_auto_algo = WindowOpenDetectionAlgorithm(
|
||||||
|
alert_threshold=self._window_auto_open_threshold,
|
||||||
|
end_alert_threshold=self._window_auto_close_threshold,
|
||||||
|
)
|
||||||
|
|
||||||
self._motion_sensor_entity_id = entry_infos.get(CONF_MOTION_SENSOR)
|
self._motion_sensor_entity_id = entry_infos.get(CONF_MOTION_SENSOR)
|
||||||
self._motion_delay_sec = entry_infos.get(CONF_MOTION_DELAY)
|
self._motion_delay_sec = entry_infos.get(CONF_MOTION_DELAY)
|
||||||
self._motion_preset = entry_infos.get(CONF_MOTION_PRESET)
|
self._motion_preset = entry_infos.get(CONF_MOTION_PRESET)
|
||||||
@@ -1044,6 +1077,11 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
|||||||
"""Get the window_state"""
|
"""Get the window_state"""
|
||||||
return self._window_state
|
return self._window_state
|
||||||
|
|
||||||
|
@property
|
||||||
|
def window_auto_state(self) -> bool | None:
|
||||||
|
"""Get the window_auto_state"""
|
||||||
|
return STATE_ON if self._window_auto_state else STATE_OFF
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def security_state(self) -> bool | None:
|
def security_state(self) -> bool | None:
|
||||||
"""Get the security_state"""
|
"""Get the security_state"""
|
||||||
@@ -1074,6 +1112,41 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
|||||||
"""Get the last external temperature datetime"""
|
"""Get the last external temperature datetime"""
|
||||||
return self._last_ext_temperature_mesure
|
return self._last_ext_temperature_mesure
|
||||||
|
|
||||||
|
@property
|
||||||
|
def preset_mode(self) -> str | None:
|
||||||
|
"""Return the current preset mode, e.g., home, away, temp.
|
||||||
|
|
||||||
|
Requires ClimateEntityFeature.PRESET_MODE.
|
||||||
|
"""
|
||||||
|
return self._attr_preset_mode
|
||||||
|
|
||||||
|
@property
|
||||||
|
def preset_modes(self) -> list[str] | None:
|
||||||
|
"""Return a list of available preset modes.
|
||||||
|
|
||||||
|
Requires ClimateEntityFeature.PRESET_MODE.
|
||||||
|
"""
|
||||||
|
return self._attr_preset_modes
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_over_climate(self) -> bool | None:
|
||||||
|
"""return True is the thermostat is over a climate
|
||||||
|
or False is over switch"""
|
||||||
|
return self._is_over_climate
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_temperature_slope(self) -> float | None:
|
||||||
|
"""Return the last temperature slope curve if any"""
|
||||||
|
if not self._window_auto_algo:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
return self._window_auto_algo.last_slope
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_window_auto_enabled(self) -> bool:
|
||||||
|
"""True if the Window auto feature is enabled"""
|
||||||
|
return self._window_auto_on
|
||||||
|
|
||||||
def turn_aux_heat_on(self) -> None:
|
def turn_aux_heat_on(self) -> None:
|
||||||
"""Turn auxiliary heater on."""
|
"""Turn auxiliary heater on."""
|
||||||
if self._is_over_climate and self._underlying_climate:
|
if self._is_over_climate and self._underlying_climate:
|
||||||
@@ -1102,28 +1175,6 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
|||||||
|
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
@property
|
|
||||||
def preset_mode(self) -> str | None:
|
|
||||||
"""Return the current preset mode, e.g., home, away, temp.
|
|
||||||
|
|
||||||
Requires ClimateEntityFeature.PRESET_MODE.
|
|
||||||
"""
|
|
||||||
return self._attr_preset_mode
|
|
||||||
|
|
||||||
@property
|
|
||||||
def preset_modes(self) -> list[str] | None:
|
|
||||||
"""Return a list of available preset modes.
|
|
||||||
|
|
||||||
Requires ClimateEntityFeature.PRESET_MODE.
|
|
||||||
"""
|
|
||||||
return self._attr_preset_modes
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_over_climate(self) -> bool | None:
|
|
||||||
"""return True is the thermostat is over a climate
|
|
||||||
or False is over switch"""
|
|
||||||
return self._is_over_climate
|
|
||||||
|
|
||||||
async def async_set_hvac_mode(self, hvac_mode):
|
async def async_set_hvac_mode(self, hvac_mode):
|
||||||
"""Set new target hvac mode."""
|
"""Set new target hvac mode."""
|
||||||
_LOGGER.info("%s - Set hvac mode: %s", self, hvac_mode)
|
_LOGGER.info("%s - Set hvac mode: %s", self, hvac_mode)
|
||||||
@@ -1621,6 +1672,9 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
|||||||
if self._security_state:
|
if self._security_state:
|
||||||
await self.check_security()
|
await self.check_security()
|
||||||
|
|
||||||
|
# check window_auto
|
||||||
|
await self._async_manage_window_auto()
|
||||||
|
|
||||||
except ValueError as ex:
|
except ValueError as ex:
|
||||||
_LOGGER.error("Unable to update temperature from sensor: %s", ex)
|
_LOGGER.error("Unable to update temperature from sensor: %s", ex)
|
||||||
|
|
||||||
@@ -1815,6 +1869,80 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
|
|||||||
HA_DOMAIN, SERVICE_TURN_OFF, data, context=self._context
|
HA_DOMAIN, SERVICE_TURN_OFF, data, context=self._context
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def _async_manage_window_auto(self):
|
||||||
|
"""The management of the window auto feature"""
|
||||||
|
|
||||||
|
async def dearm_window_auto(_):
|
||||||
|
"""Callback that will be called after end of WINDOW_AUTO_MAX_DURATION"""
|
||||||
|
_LOGGER.info("Unset window auto because MAX_DURATION is exceeded")
|
||||||
|
await deactivate_window_auto(auto=True)
|
||||||
|
|
||||||
|
async def deactivate_window_auto(auto=False):
|
||||||
|
"""Deactivation of the Window auto state"""
|
||||||
|
_LOGGER.warning(
|
||||||
|
"%s - End auto detection of open window slope=%.3f", self, slope
|
||||||
|
)
|
||||||
|
# Send an event
|
||||||
|
cause = "max duration expiration" if auto else "end of slope alert"
|
||||||
|
self.send_event(
|
||||||
|
EventType.WINDOW_AUTO_EVENT,
|
||||||
|
{"type": "end", "cause": cause, "curve_slope": slope},
|
||||||
|
)
|
||||||
|
# Set attributes
|
||||||
|
self._window_auto_state = False
|
||||||
|
await self.restore_hvac_mode()
|
||||||
|
|
||||||
|
if self._window_call_cancel:
|
||||||
|
self._window_call_cancel()
|
||||||
|
self._window_call_cancel = None
|
||||||
|
|
||||||
|
if not self._window_auto_algo:
|
||||||
|
return
|
||||||
|
|
||||||
|
slope = self._window_auto_algo.add_temp_measurement(
|
||||||
|
temperature=self._cur_temp, datetime_measure=self._last_temperature_mesure
|
||||||
|
)
|
||||||
|
_LOGGER.debug(
|
||||||
|
"%s - Window auto is on, check the alert. last slope is %.3f",
|
||||||
|
self,
|
||||||
|
slope if slope is not None else 0.0,
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
self._window_auto_algo.is_window_open_detected()
|
||||||
|
and self._window_auto_state is False
|
||||||
|
):
|
||||||
|
_LOGGER.warning(
|
||||||
|
"%s - Start auto detection of open window slope=%.3f", self, slope
|
||||||
|
)
|
||||||
|
# Send an event
|
||||||
|
self.send_event(
|
||||||
|
EventType.WINDOW_AUTO_EVENT,
|
||||||
|
{"type": "start", "cause": "slope alert", "curve_slope": slope},
|
||||||
|
)
|
||||||
|
# Set attributes
|
||||||
|
self._window_auto_state = True
|
||||||
|
self.save_hvac_mode()
|
||||||
|
await self.async_set_hvac_mode(HVACMode.OFF)
|
||||||
|
|
||||||
|
# Arm the end trigger
|
||||||
|
if self._window_call_cancel:
|
||||||
|
self._window_call_cancel()
|
||||||
|
self._window_call_cancel = None
|
||||||
|
self._window_call_cancel = async_call_later(
|
||||||
|
self.hass,
|
||||||
|
timedelta(minutes=self._window_auto_max_duration),
|
||||||
|
dearm_window_auto,
|
||||||
|
)
|
||||||
|
|
||||||
|
elif (
|
||||||
|
self._window_auto_algo.is_window_close_detected()
|
||||||
|
and self._window_auto_state is True
|
||||||
|
):
|
||||||
|
await deactivate_window_auto(False)
|
||||||
|
|
||||||
|
# For testing purpose we need to return the inner function
|
||||||
|
return dearm_window_auto
|
||||||
|
|
||||||
def save_preset_mode(self):
|
def save_preset_mode(self):
|
||||||
"""Save the current preset mode to be restored later
|
"""Save the current preset mode to be restored later
|
||||||
We never save a hidden preset mode
|
We never save a hidden preset mode
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ class VersatileThermostatBaseEntity(Entity):
|
|||||||
try:
|
try:
|
||||||
component: EntityComponent[ClimateEntity] = self.hass.data[CLIMATE_DOMAIN]
|
component: EntityComponent[ClimateEntity] = self.hass.data[CLIMATE_DOMAIN]
|
||||||
for entity in component.entities:
|
for entity in component.entities:
|
||||||
_LOGGER.debug("Device_info is %s", entity.device_info)
|
# _LOGGER.debug("Device_info is %s", entity.device_info)
|
||||||
if entity.device_info == self.device_info:
|
if entity.device_info == self.device_info:
|
||||||
_LOGGER.debug("Found %s!", entity)
|
_LOGGER.debug("Found %s!", entity)
|
||||||
return entity
|
return entity
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ from .const import (
|
|||||||
CONF_MAX_POWER_SENSOR,
|
CONF_MAX_POWER_SENSOR,
|
||||||
CONF_WINDOW_SENSOR,
|
CONF_WINDOW_SENSOR,
|
||||||
CONF_WINDOW_DELAY,
|
CONF_WINDOW_DELAY,
|
||||||
|
CONF_WINDOW_AUTO_MAX_DURATION,
|
||||||
|
CONF_WINDOW_AUTO_CLOSE_THRESHOLD,
|
||||||
|
CONF_WINDOW_AUTO_OPEN_THRESHOLD,
|
||||||
CONF_MOTION_SENSOR,
|
CONF_MOTION_SENSOR,
|
||||||
CONF_MOTION_DELAY,
|
CONF_MOTION_DELAY,
|
||||||
CONF_MOTION_PRESET,
|
CONF_MOTION_PRESET,
|
||||||
@@ -79,6 +82,7 @@ from .const import (
|
|||||||
CONF_USE_POWER_FEATURE,
|
CONF_USE_POWER_FEATURE,
|
||||||
CONF_THERMOSTAT_TYPES,
|
CONF_THERMOSTAT_TYPES,
|
||||||
UnknownEntity,
|
UnknownEntity,
|
||||||
|
WindowOpenDetectionMethod,
|
||||||
)
|
)
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
@@ -167,7 +171,9 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
|
|||||||
is_empty: bool = not bool(infos)
|
is_empty: bool = not bool(infos)
|
||||||
# Fix features selection depending to infos
|
# Fix features selection depending to infos
|
||||||
self._infos[CONF_USE_WINDOW_FEATURE] = (
|
self._infos[CONF_USE_WINDOW_FEATURE] = (
|
||||||
is_empty or self._infos.get(CONF_WINDOW_SENSOR) is not None
|
is_empty
|
||||||
|
or self._infos.get(CONF_WINDOW_SENSOR) is not None
|
||||||
|
or self._infos.get(CONF_WINDOW_AUTO_OPEN_THRESHOLD) is not None
|
||||||
)
|
)
|
||||||
self._infos[CONF_USE_MOTION_FEATURE] = (
|
self._infos[CONF_USE_MOTION_FEATURE] = (
|
||||||
is_empty or self._infos.get(CONF_MOTION_SENSOR) is not None
|
is_empty or self._infos.get(CONF_MOTION_SENSOR) is not None
|
||||||
@@ -195,12 +201,11 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
|
|||||||
domain=[SENSOR_DOMAIN, INPUT_NUMBER_DOMAIN]
|
domain=[SENSOR_DOMAIN, INPUT_NUMBER_DOMAIN]
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
# vol.In(temp_sensors),
|
|
||||||
vol.Required(CONF_EXTERNAL_TEMP_SENSOR): selector.EntitySelector(
|
vol.Required(CONF_EXTERNAL_TEMP_SENSOR): selector.EntitySelector(
|
||||||
selector.EntitySelectorConfig(
|
selector.EntitySelectorConfig(
|
||||||
domain=[SENSOR_DOMAIN, INPUT_NUMBER_DOMAIN]
|
domain=[SENSOR_DOMAIN, INPUT_NUMBER_DOMAIN]
|
||||||
),
|
),
|
||||||
), # vol.In(temp_sensors),
|
),
|
||||||
vol.Required(CONF_CYCLE_MIN, default=5): cv.positive_int,
|
vol.Required(CONF_CYCLE_MIN, default=5): cv.positive_int,
|
||||||
vol.Required(CONF_TEMP_MIN, default=7): vol.Coerce(float),
|
vol.Required(CONF_TEMP_MIN, default=7): vol.Coerce(float),
|
||||||
vol.Required(CONF_TEMP_MAX, default=35): vol.Coerce(float),
|
vol.Required(CONF_TEMP_MAX, default=35): vol.Coerce(float),
|
||||||
@@ -218,7 +223,7 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
|
|||||||
selector.EntitySelectorConfig(
|
selector.EntitySelectorConfig(
|
||||||
domain=[SWITCH_DOMAIN, INPUT_BOOLEAN_DOMAIN]
|
domain=[SWITCH_DOMAIN, INPUT_BOOLEAN_DOMAIN]
|
||||||
),
|
),
|
||||||
), # vol.In(switches),
|
),
|
||||||
vol.Required(
|
vol.Required(
|
||||||
CONF_PROP_FUNCTION, default=PROPORTIONAL_FUNCTION_TPI
|
CONF_PROP_FUNCTION, default=PROPORTIONAL_FUNCTION_TPI
|
||||||
): vol.In(
|
): vol.In(
|
||||||
@@ -233,7 +238,7 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
|
|||||||
{
|
{
|
||||||
vol.Required(CONF_CLIMATE): selector.EntitySelector(
|
vol.Required(CONF_CLIMATE): selector.EntitySelector(
|
||||||
selector.EntitySelectorConfig(domain=CLIMATE_DOMAIN),
|
selector.EntitySelectorConfig(domain=CLIMATE_DOMAIN),
|
||||||
), # vol.In(climates),
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -257,8 +262,11 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
|
|||||||
selector.EntitySelectorConfig(
|
selector.EntitySelectorConfig(
|
||||||
domain=[BINARY_SENSOR_DOMAIN, INPUT_BOOLEAN_DOMAIN]
|
domain=[BINARY_SENSOR_DOMAIN, INPUT_BOOLEAN_DOMAIN]
|
||||||
),
|
),
|
||||||
), # vol.In(window_sensors),
|
),
|
||||||
vol.Optional(CONF_WINDOW_DELAY, default=30): cv.positive_int,
|
vol.Optional(CONF_WINDOW_DELAY, default=30): cv.positive_int,
|
||||||
|
vol.Optional(CONF_WINDOW_AUTO_OPEN_THRESHOLD): vol.Coerce(float),
|
||||||
|
vol.Optional(CONF_WINDOW_AUTO_CLOSE_THRESHOLD): vol.Coerce(float),
|
||||||
|
vol.Optional(CONF_WINDOW_AUTO_MAX_DURATION): cv.positive_int,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -268,7 +276,7 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
|
|||||||
selector.EntitySelectorConfig(
|
selector.EntitySelectorConfig(
|
||||||
domain=[BINARY_SENSOR_DOMAIN, INPUT_BOOLEAN_DOMAIN]
|
domain=[BINARY_SENSOR_DOMAIN, INPUT_BOOLEAN_DOMAIN]
|
||||||
),
|
),
|
||||||
), # vol.In(window_sensors),
|
),
|
||||||
vol.Optional(CONF_MOTION_DELAY, default=30): cv.positive_int,
|
vol.Optional(CONF_MOTION_DELAY, default=30): cv.positive_int,
|
||||||
vol.Optional(CONF_MOTION_PRESET, default="comfort"): vol.In(
|
vol.Optional(CONF_MOTION_PRESET, default="comfort"): vol.In(
|
||||||
CONF_PRESETS_SELECTIONABLE
|
CONF_PRESETS_SELECTIONABLE
|
||||||
@@ -285,12 +293,12 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
|
|||||||
selector.EntitySelectorConfig(
|
selector.EntitySelectorConfig(
|
||||||
domain=[SENSOR_DOMAIN, INPUT_NUMBER_DOMAIN]
|
domain=[SENSOR_DOMAIN, INPUT_NUMBER_DOMAIN]
|
||||||
),
|
),
|
||||||
), # vol.In(power_sensors),
|
),
|
||||||
vol.Optional(CONF_MAX_POWER_SENSOR): selector.EntitySelector(
|
vol.Optional(CONF_MAX_POWER_SENSOR): selector.EntitySelector(
|
||||||
selector.EntitySelectorConfig(
|
selector.EntitySelectorConfig(
|
||||||
domain=[SENSOR_DOMAIN, INPUT_NUMBER_DOMAIN]
|
domain=[SENSOR_DOMAIN, INPUT_NUMBER_DOMAIN]
|
||||||
),
|
),
|
||||||
), # vol.In(power_sensors),
|
),
|
||||||
vol.Optional(CONF_PRESET_POWER, default="13"): vol.Coerce(float),
|
vol.Optional(CONF_PRESET_POWER, default="13"): vol.Coerce(float),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -305,7 +313,7 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
|
|||||||
INPUT_BOOLEAN_DOMAIN,
|
INPUT_BOOLEAN_DOMAIN,
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
), # vol.In(presence_sensors),
|
),
|
||||||
}
|
}
|
||||||
).extend(
|
).extend(
|
||||||
{
|
{
|
||||||
@@ -357,6 +365,19 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
|
|||||||
)
|
)
|
||||||
raise UnknownEntity(conf)
|
raise UnknownEntity(conf)
|
||||||
|
|
||||||
|
# Check that only one window feature is used
|
||||||
|
ws = data.get(CONF_WINDOW_SENSOR) # pylint: disable=invalid-name
|
||||||
|
waot = data.get(CONF_WINDOW_AUTO_OPEN_THRESHOLD)
|
||||||
|
wact = data.get(CONF_WINDOW_AUTO_CLOSE_THRESHOLD)
|
||||||
|
wamd = data.get(CONF_WINDOW_AUTO_MAX_DURATION)
|
||||||
|
if ws is not None and (
|
||||||
|
waot is not None or wact is not None or wamd is not None
|
||||||
|
):
|
||||||
|
_LOGGER.error(
|
||||||
|
"Only one window detection method should be used. Use window_sensor or auto window open detection but not both"
|
||||||
|
)
|
||||||
|
raise WindowOpenDetectionMethod(CONF_WINDOW_SENSOR)
|
||||||
|
|
||||||
def merge_user_input(self, data_schema: vol.Schema, user_input: dict):
|
def merge_user_input(self, data_schema: vol.Schema, user_input: dict):
|
||||||
"""For each schema entry not in user_input, set or remove values in infos"""
|
"""For each schema entry not in user_input, set or remove values in infos"""
|
||||||
self._infos.update(user_input)
|
self._infos.update(user_input)
|
||||||
@@ -387,6 +408,8 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
|
|||||||
await self.validate_input(user_input)
|
await self.validate_input(user_input)
|
||||||
except UnknownEntity as err:
|
except UnknownEntity as err:
|
||||||
errors[str(err)] = "unknown_entity"
|
errors[str(err)] = "unknown_entity"
|
||||||
|
except WindowOpenDetectionMethod as err:
|
||||||
|
errors[str(err)] = "window_open_detection_method"
|
||||||
except Exception: # pylint: disable=broad-except
|
except Exception: # pylint: disable=broad-except
|
||||||
_LOGGER.exception("Unexpected exception")
|
_LOGGER.exception("Unexpected exception")
|
||||||
errors["base"] = "unknown"
|
errors["base"] = "unknown"
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ CONF_USE_WINDOW_FEATURE = "use_window_feature"
|
|||||||
CONF_USE_MOTION_FEATURE = "use_motion_feature"
|
CONF_USE_MOTION_FEATURE = "use_motion_feature"
|
||||||
CONF_USE_PRESENCE_FEATURE = "use_presence_feature"
|
CONF_USE_PRESENCE_FEATURE = "use_presence_feature"
|
||||||
CONF_USE_POWER_FEATURE = "use_power_feature"
|
CONF_USE_POWER_FEATURE = "use_power_feature"
|
||||||
|
CONF_WINDOW_AUTO_OPEN_THRESHOLD = "window_auto_open_threshold"
|
||||||
|
CONF_WINDOW_AUTO_CLOSE_THRESHOLD = "window_auto_close_threshold"
|
||||||
|
CONF_WINDOW_AUTO_MAX_DURATION = "window_auto_max_duration"
|
||||||
|
|
||||||
CONF_PRESETS = {
|
CONF_PRESETS = {
|
||||||
p: f"{p}_temp"
|
p: f"{p}_temp"
|
||||||
@@ -97,6 +100,9 @@ ALL_CONF = (
|
|||||||
CONF_MAX_POWER_SENSOR,
|
CONF_MAX_POWER_SENSOR,
|
||||||
CONF_WINDOW_SENSOR,
|
CONF_WINDOW_SENSOR,
|
||||||
CONF_WINDOW_DELAY,
|
CONF_WINDOW_DELAY,
|
||||||
|
CONF_WINDOW_AUTO_OPEN_THRESHOLD,
|
||||||
|
CONF_WINDOW_AUTO_CLOSE_THRESHOLD,
|
||||||
|
CONF_WINDOW_AUTO_MAX_DURATION,
|
||||||
CONF_MOTION_SENSOR,
|
CONF_MOTION_SENSOR,
|
||||||
CONF_MOTION_DELAY,
|
CONF_MOTION_DELAY,
|
||||||
CONF_MOTION_PRESET,
|
CONF_MOTION_PRESET,
|
||||||
@@ -153,7 +159,12 @@ class EventType(Enum):
|
|||||||
TEMPERATURE_EVENT: str = "versatile_thermostat_temperature_event"
|
TEMPERATURE_EVENT: str = "versatile_thermostat_temperature_event"
|
||||||
HVAC_MODE_EVENT: str = "versatile_thermostat_hvac_mode_event"
|
HVAC_MODE_EVENT: str = "versatile_thermostat_hvac_mode_event"
|
||||||
PRESET_EVENT: str = "versatile_thermostat_preset_event"
|
PRESET_EVENT: str = "versatile_thermostat_preset_event"
|
||||||
|
WINDOW_AUTO_EVENT: str = "versatile_thermostat_window_auto_event"
|
||||||
|
|
||||||
|
|
||||||
class UnknownEntity(HomeAssistantError):
|
class UnknownEntity(HomeAssistantError):
|
||||||
"""Error to indicate there is an unknown entity_id given."""
|
"""Error to indicate there is an unknown entity_id given."""
|
||||||
|
|
||||||
|
|
||||||
|
class WindowOpenDetectionMethod(HomeAssistantError):
|
||||||
|
"""Error to indicate there is an error in the window open detection method given."""
|
||||||
|
|||||||
117
custom_components/versatile_thermostat/open_window_algorithm.py
Normal file
117
custom_components/versatile_thermostat/open_window_algorithm.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
""" This file implements the Open Window by temperature algorithm
|
||||||
|
This algo works the following way:
|
||||||
|
- each time a new temperature is measured
|
||||||
|
- calculate the slope of the temperature curve. For this we calculate the slope(t) = 1/2 slope(t-1) + 1/2 * dTemp / dt
|
||||||
|
- if the slope is lower than a threshold the window opens alert is notified
|
||||||
|
- if the slope regain positive the end of the window open alert is notified
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# To filter bad values
|
||||||
|
MIN_DELTA_T_SEC = 10 # two temp mesure should be > 10 sec
|
||||||
|
MAX_SLOPE_VALUE = 2 # slope cannot be > 2 or < -2 -> else this is an aberrant point
|
||||||
|
|
||||||
|
|
||||||
|
class WindowOpenDetectionAlgorithm:
|
||||||
|
"""The class that implements the algorithm listed above"""
|
||||||
|
|
||||||
|
_alert_threshold: float
|
||||||
|
_end_alert_threshold: float
|
||||||
|
_last_slope: float
|
||||||
|
_last_datetime: datetime
|
||||||
|
_last_temperature: float
|
||||||
|
|
||||||
|
def __init__(self, alert_threshold, end_alert_threshold) -> None:
|
||||||
|
"""Initalize a new algorithm with the both threshold"""
|
||||||
|
self._alert_threshold = alert_threshold
|
||||||
|
self._end_alert_threshold = end_alert_threshold
|
||||||
|
self._last_slope = None
|
||||||
|
self._last_datetime = None
|
||||||
|
|
||||||
|
def add_temp_measurement(
|
||||||
|
self, temperature: float, datetime_measure: datetime
|
||||||
|
) -> float:
|
||||||
|
"""Add a new temperature measurement
|
||||||
|
returns the last slope
|
||||||
|
"""
|
||||||
|
if self._last_datetime is None or self._last_temperature is None:
|
||||||
|
_LOGGER.debug("First initialisation")
|
||||||
|
self._last_datetime = datetime_measure
|
||||||
|
self._last_temperature = temperature
|
||||||
|
return None
|
||||||
|
|
||||||
|
_LOGGER.debug(
|
||||||
|
"We are already initialized slope=%s last_temp=%0.2f",
|
||||||
|
self._last_slope,
|
||||||
|
self._last_temperature,
|
||||||
|
)
|
||||||
|
lspe = self._last_slope
|
||||||
|
|
||||||
|
delta_t_sec = float((datetime_measure - self._last_datetime).total_seconds())
|
||||||
|
delta_t = delta_t_sec / 60.0
|
||||||
|
if delta_t_sec <= MIN_DELTA_T_SEC:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Delta t is %d < %d which should be not possible. We don't consider this value",
|
||||||
|
delta_t_sec,
|
||||||
|
MIN_DELTA_T_SEC,
|
||||||
|
)
|
||||||
|
return lspe
|
||||||
|
|
||||||
|
delta_temp = float(temperature - self._last_temperature)
|
||||||
|
new_slope = delta_temp / delta_t
|
||||||
|
if new_slope > MAX_SLOPE_VALUE or new_slope < -MAX_SLOPE_VALUE:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"New_slope is abs(%.2f) > %.2f which should be not possible. We don't consider this value",
|
||||||
|
new_slope,
|
||||||
|
MAX_SLOPE_VALUE,
|
||||||
|
)
|
||||||
|
return lspe
|
||||||
|
|
||||||
|
if self._last_slope is None:
|
||||||
|
self._last_slope = new_slope
|
||||||
|
else:
|
||||||
|
self._last_slope = (0.5 * self._last_slope) + (0.5 * new_slope)
|
||||||
|
|
||||||
|
self._last_datetime = datetime_measure
|
||||||
|
self._last_temperature = temperature
|
||||||
|
|
||||||
|
_LOGGER.debug(
|
||||||
|
"delta_t=%.3f delta_temp=%.3f new_slope=%.3f last_slope=%s slope=%.3f",
|
||||||
|
delta_t,
|
||||||
|
delta_temp,
|
||||||
|
new_slope,
|
||||||
|
lspe,
|
||||||
|
self._last_slope,
|
||||||
|
)
|
||||||
|
return self._last_slope
|
||||||
|
|
||||||
|
def is_window_open_detected(self) -> bool:
|
||||||
|
"""True if the last calculated slope is under (because negative value) the _alert_threshold"""
|
||||||
|
if self._alert_threshold is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return (
|
||||||
|
self._last_slope < -self._alert_threshold
|
||||||
|
if self._last_slope is not None
|
||||||
|
else False
|
||||||
|
)
|
||||||
|
|
||||||
|
def is_window_close_detected(self) -> bool:
|
||||||
|
"""True if the last calculated slope is above (cause negative) the _end_alert_threshold"""
|
||||||
|
if self._end_alert_threshold is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return (
|
||||||
|
self._last_slope >= self._end_alert_threshold
|
||||||
|
if self._last_slope is not None
|
||||||
|
else False
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_slope(self) -> float:
|
||||||
|
"""Return the last calculated slope"""
|
||||||
|
return self._last_slope
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
# -r requirements_dev.txt
|
# -r requirements_dev.txt
|
||||||
# aiodiscover
|
# aiodiscover
|
||||||
|
ulid_transform
|
||||||
pytest-homeassistant-custom-component
|
pytest-homeassistant-custom-component
|
||||||
@@ -46,6 +46,7 @@ async def async_setup_entry(
|
|||||||
entities = [
|
entities = [
|
||||||
LastTemperatureSensor(hass, unique_id, name, entry.data),
|
LastTemperatureSensor(hass, unique_id, name, entry.data),
|
||||||
LastExtTemperatureSensor(hass, unique_id, name, entry.data),
|
LastExtTemperatureSensor(hass, unique_id, name, entry.data),
|
||||||
|
TemperatureSlopeSensor(hass, unique_id, name, entry.data),
|
||||||
]
|
]
|
||||||
if entry.data.get(CONF_DEVICE_POWER):
|
if entry.data.get(CONF_DEVICE_POWER):
|
||||||
entities.append(EnergySensor(hass, unique_id, name, entry.data))
|
entities.append(EnergySensor(hass, unique_id, name, entry.data))
|
||||||
@@ -72,10 +73,7 @@ class EnergySensor(VersatileThermostatBaseEntity, SensorEntity):
|
|||||||
@callback
|
@callback
|
||||||
async def async_my_climate_changed(self, event: Event = None):
|
async def async_my_climate_changed(self, event: Event = None):
|
||||||
"""Called when my climate have change"""
|
"""Called when my climate have change"""
|
||||||
_LOGGER.debug(
|
_LOGGER.debug("%s - climate state change", self._attr_unique_id)
|
||||||
"%s - climate state change",
|
|
||||||
event.origin.name if event and event.origin else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
if math.isnan(self.my_climate.total_energy) or math.isinf(
|
if math.isnan(self.my_climate.total_energy) or math.isinf(
|
||||||
self.my_climate.total_energy
|
self.my_climate.total_energy
|
||||||
@@ -130,10 +128,7 @@ class MeanPowerSensor(VersatileThermostatBaseEntity, SensorEntity):
|
|||||||
@callback
|
@callback
|
||||||
async def async_my_climate_changed(self, event: Event = None):
|
async def async_my_climate_changed(self, event: Event = None):
|
||||||
"""Called when my climate have change"""
|
"""Called when my climate have change"""
|
||||||
_LOGGER.debug(
|
_LOGGER.debug("%s - climate state change", self._attr_unique_id)
|
||||||
"%s - climate state change",
|
|
||||||
event.origin.name if event and event.origin else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
if math.isnan(float(self.my_climate.mean_cycle_power)) or math.isinf(
|
if math.isnan(float(self.my_climate.mean_cycle_power)) or math.isinf(
|
||||||
self.my_climate.mean_cycle_power
|
self.my_climate.mean_cycle_power
|
||||||
@@ -190,10 +185,7 @@ class OnPercentSensor(VersatileThermostatBaseEntity, SensorEntity):
|
|||||||
@callback
|
@callback
|
||||||
async def async_my_climate_changed(self, event: Event = None):
|
async def async_my_climate_changed(self, event: Event = None):
|
||||||
"""Called when my climate have change"""
|
"""Called when my climate have change"""
|
||||||
_LOGGER.debug(
|
_LOGGER.debug("%s - climate state change", self._attr_unique_id)
|
||||||
"%s - climate state change",
|
|
||||||
event.origin.name if event and event.origin else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
on_percent = (
|
on_percent = (
|
||||||
float(self.my_climate.proportional_algorithm.on_percent)
|
float(self.my_climate.proportional_algorithm.on_percent)
|
||||||
@@ -245,10 +237,7 @@ class OnTimeSensor(VersatileThermostatBaseEntity, SensorEntity):
|
|||||||
@callback
|
@callback
|
||||||
async def async_my_climate_changed(self, event: Event = None):
|
async def async_my_climate_changed(self, event: Event = None):
|
||||||
"""Called when my climate have change"""
|
"""Called when my climate have change"""
|
||||||
_LOGGER.debug(
|
_LOGGER.debug("%s - climate state change", self._attr_unique_id)
|
||||||
"%s - climate state change",
|
|
||||||
event.origin.name if event and event.origin else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
on_time = (
|
on_time = (
|
||||||
float(self.my_climate.proportional_algorithm.on_time_sec)
|
float(self.my_climate.proportional_algorithm.on_time_sec)
|
||||||
@@ -293,10 +282,7 @@ class OffTimeSensor(VersatileThermostatBaseEntity, SensorEntity):
|
|||||||
@callback
|
@callback
|
||||||
async def async_my_climate_changed(self, event: Event = None):
|
async def async_my_climate_changed(self, event: Event = None):
|
||||||
"""Called when my climate have change"""
|
"""Called when my climate have change"""
|
||||||
_LOGGER.debug(
|
_LOGGER.debug("%s - climate state change", self._attr_unique_id)
|
||||||
"%s - climate state change",
|
|
||||||
event.origin.name if event and event.origin else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
off_time = (
|
off_time = (
|
||||||
float(self.my_climate.proportional_algorithm.off_time_sec)
|
float(self.my_climate.proportional_algorithm.off_time_sec)
|
||||||
@@ -341,10 +327,7 @@ class LastTemperatureSensor(VersatileThermostatBaseEntity, SensorEntity):
|
|||||||
@callback
|
@callback
|
||||||
async def async_my_climate_changed(self, event: Event = None):
|
async def async_my_climate_changed(self, event: Event = None):
|
||||||
"""Called when my climate have change"""
|
"""Called when my climate have change"""
|
||||||
_LOGGER.debug(
|
_LOGGER.debug("%s - climate state change", self._attr_unique_id)
|
||||||
"%s - climate state change",
|
|
||||||
event.origin.name if event and event.origin else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
old_state = self._attr_native_value
|
old_state = self._attr_native_value
|
||||||
self._attr_native_value = self.my_climate.last_temperature_mesure
|
self._attr_native_value = self.my_climate.last_temperature_mesure
|
||||||
@@ -373,10 +356,7 @@ class LastExtTemperatureSensor(VersatileThermostatBaseEntity, SensorEntity):
|
|||||||
@callback
|
@callback
|
||||||
async def async_my_climate_changed(self, event: Event = None):
|
async def async_my_climate_changed(self, event: Event = None):
|
||||||
"""Called when my climate have change"""
|
"""Called when my climate have change"""
|
||||||
_LOGGER.debug(
|
_LOGGER.debug("%s - climate state change", self._attr_unique_id)
|
||||||
"%s - climate state change",
|
|
||||||
event.origin.name if event and event.origin else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
old_state = self._attr_native_value
|
old_state = self._attr_native_value
|
||||||
self._attr_native_value = self.my_climate.last_ext_temperature_mesure
|
self._attr_native_value = self.my_climate.last_ext_temperature_mesure
|
||||||
@@ -391,3 +371,56 @@ class LastExtTemperatureSensor(VersatileThermostatBaseEntity, SensorEntity):
|
|||||||
@property
|
@property
|
||||||
def device_class(self) -> SensorDeviceClass | None:
|
def device_class(self) -> SensorDeviceClass | None:
|
||||||
return SensorDeviceClass.TIMESTAMP
|
return SensorDeviceClass.TIMESTAMP
|
||||||
|
|
||||||
|
|
||||||
|
class TemperatureSlopeSensor(VersatileThermostatBaseEntity, SensorEntity):
|
||||||
|
"""Representation of a sensor which exposes the temperature slope curve"""
|
||||||
|
|
||||||
|
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
|
||||||
|
"""Initialize the slope sensor"""
|
||||||
|
super().__init__(hass, unique_id, entry_infos.get(CONF_NAME))
|
||||||
|
self._attr_name = "Temperature slope"
|
||||||
|
self._attr_unique_id = f"{self._device_name}_temperature_slope"
|
||||||
|
|
||||||
|
@callback
|
||||||
|
async def async_my_climate_changed(self, event: Event = None):
|
||||||
|
"""Called when my climate have change"""
|
||||||
|
_LOGGER.debug("%s - climate state change", self._attr_unique_id)
|
||||||
|
|
||||||
|
last_slope = self.my_climate.last_temperature_slope
|
||||||
|
if last_slope is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if math.isnan(last_slope) or math.isinf(last_slope):
|
||||||
|
raise ValueError(f"Sensor has illegal state {last_slope}")
|
||||||
|
|
||||||
|
old_state = self._attr_native_value
|
||||||
|
self._attr_native_value = round(last_slope, self.suggested_display_precision)
|
||||||
|
if old_state != self._attr_native_value:
|
||||||
|
self.async_write_ha_state()
|
||||||
|
return
|
||||||
|
|
||||||
|
@property
|
||||||
|
def icon(self) -> str | None:
|
||||||
|
if self._attr_native_value is None or self._attr_native_value == 0:
|
||||||
|
return "mdi:thermometer"
|
||||||
|
elif self._attr_native_value > 0:
|
||||||
|
return "mdi:thermometer-chevron-up"
|
||||||
|
else:
|
||||||
|
return "mdi:thermometer-chevron-down"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state_class(self) -> SensorStateClass | None:
|
||||||
|
return SensorStateClass.MEASUREMENT
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_unit_of_measurement(self) -> str | None:
|
||||||
|
if not self.my_climate:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return self.my_climate.temperature_unit + "/min"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def suggested_display_precision(self) -> int | None:
|
||||||
|
"""Return the suggested number of decimal digits for display."""
|
||||||
|
return 2
|
||||||
|
|||||||
@@ -49,10 +49,19 @@
|
|||||||
},
|
},
|
||||||
"window": {
|
"window": {
|
||||||
"title": "Window management",
|
"title": "Window management",
|
||||||
"description": "Open window management.\nLeave corresponding entity_id empty if not used.",
|
"description": "Open window management.\nLeave corresponding entity_id empty if not used\nYou can also configure automatic window open detection based on temperature decrease",
|
||||||
"data": {
|
"data": {
|
||||||
"window_sensor_entity_id": "Window sensor entity id",
|
"window_sensor_entity_id": "Window sensor entity id",
|
||||||
"window_delay": "Window delay (seconds)"
|
"window_delay": "Window sensor delay (seconds)",
|
||||||
|
"window_auto_open_threshold": "Temperature decrease threshold for automatic window open detection (in °/min)",
|
||||||
|
"window_auto_close_threshold": "Temperature increase threshold for end of automatic detection (in °/min)",
|
||||||
|
"window_auto_max_duration": "Maximum duration of automatic window open detection (in min)"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"window_sensor_entity_id": "Leave empty if no window sensor should be use",
|
||||||
|
"window_auto_open_threshold": "Recommended value: between 0.05 and 0.1. Leave empty if automatic window open detection is not use",
|
||||||
|
"window_auto_close_threshold": "Recommended value: 0. Leave empty if automatic window open detection is not use",
|
||||||
|
"window_auto_max_duration": "Recommended value: 60 (one hour). Leave empty if automatic window open detection is not use"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"motion": {
|
"motion": {
|
||||||
@@ -97,7 +106,8 @@
|
|||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"unknown": "Unexpected error",
|
"unknown": "Unexpected error",
|
||||||
"unknown_entity": "Unknown entity id"
|
"unknown_entity": "Unknown entity id",
|
||||||
|
"window_open_detection_method": "Only one window open detection method should be used. Use sensor or automatic detection through temperature threshold but not both"
|
||||||
},
|
},
|
||||||
"abort": {
|
"abort": {
|
||||||
"already_configured": "Device is already configured"
|
"already_configured": "Device is already configured"
|
||||||
@@ -151,11 +161,20 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"window": {
|
"window": {
|
||||||
"title": "Window management",
|
"title": "[%key:component::versatile_thermostat::config::step::window::title%]",
|
||||||
"description": "Open window management.\nLeave corresponding entity_id empty if not used.",
|
"description": "[%key:component::versatile_thermostat::config::step::window::description%]",
|
||||||
"data": {
|
"data": {
|
||||||
"window_sensor_entity_id": "Window sensor entity id",
|
"window_sensor_entity_id": "[%key:component::versatile_thermostat::config::step::window::data::window_sensor_entity_id%]",
|
||||||
"window_delay": "Window delay (seconds)"
|
"window_delay": "[%key:component::versatile_thermostat::config::step::window::data::window_delay%]",
|
||||||
|
"window_auto_open_threshold": "[%key:component::versatile_thermostat::config::step::window::data::window_auto_open_threshold%]",
|
||||||
|
"window_auto_close_threshold": "[%key:component::versatile_thermostat::config::step::window::data::window_auto_close_threshold%]",
|
||||||
|
"window_auto_max_duration": "[%key:component::versatile_thermostat::config::step::window::data::window_auto_max_duration%]"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"window_sensor_entity_id": "[%key:component::versatile_thermostat::config::step::window::data_description::window_sensor_entity_id%]",
|
||||||
|
"window_auto_open_threshold": "[%key:component::versatile_thermostat::config::step::window::data_description::window_auto_open_threshold%]",
|
||||||
|
"window_auto_close_threshold": "[%key:component::versatile_thermostat::config::step::window::data_description::window_auto_close_threshold%]",
|
||||||
|
"window_auto_max_duration": "[%key:component::versatile_thermostat::config::step::window::data_description::window_auto_max_duration%]"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"motion": {
|
"motion": {
|
||||||
@@ -199,11 +218,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"unknown": "Unexpected error",
|
"unknown": "[%key:component::versatile_thermostat::config::error::unknown%]",
|
||||||
"unknown_entity": "Unknown entity id"
|
"unknown_entity": "[%key:component::versatile_thermostat::config::error::unknown_entity%]",
|
||||||
|
"window_open_detection_method": "[%key:component::versatile_thermostat::config::error::window_open_detection_method%]"
|
||||||
},
|
},
|
||||||
"abort": {
|
"abort": {
|
||||||
"already_configured": "Device is already configured"
|
"already_configured": "[%key:component::versatile_thermostat::config::abort::already_configured%]"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"selector": {
|
"selector": {
|
||||||
|
|||||||
@@ -78,6 +78,15 @@ def skip_hass_states_get_fixture():
|
|||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="skip_control_heating")
|
||||||
|
def skip_control_heating_fixture():
|
||||||
|
"""Skip the control_heating of VersatileThermostat"""
|
||||||
|
with patch(
|
||||||
|
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_control_heating"
|
||||||
|
):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name="skip_hass_states_is_state")
|
@pytest.fixture(name="skip_hass_states_is_state")
|
||||||
def skip_hass_states_is_state_fixture():
|
def skip_hass_states_is_state_fixture():
|
||||||
"""Skip the is_state in HomeAssistant"""
|
"""Skip the is_state in HomeAssistant"""
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from homeassistant.components.climate.const import (
|
""" The commons const for all tests """
|
||||||
|
from homeassistant.components.climate.const import ( # pylint: disable=unused-import
|
||||||
PRESET_BOOST,
|
PRESET_BOOST,
|
||||||
PRESET_COMFORT,
|
PRESET_COMFORT,
|
||||||
PRESET_ECO,
|
PRESET_ECO,
|
||||||
@@ -30,6 +31,9 @@ from custom_components.versatile_thermostat.const import (
|
|||||||
CONF_USE_PRESENCE_FEATURE,
|
CONF_USE_PRESENCE_FEATURE,
|
||||||
CONF_WINDOW_SENSOR,
|
CONF_WINDOW_SENSOR,
|
||||||
CONF_WINDOW_DELAY,
|
CONF_WINDOW_DELAY,
|
||||||
|
CONF_WINDOW_AUTO_OPEN_THRESHOLD,
|
||||||
|
CONF_WINDOW_AUTO_CLOSE_THRESHOLD,
|
||||||
|
CONF_WINDOW_AUTO_MAX_DURATION,
|
||||||
CONF_MOTION_SENSOR,
|
CONF_MOTION_SENSOR,
|
||||||
CONF_MOTION_DELAY,
|
CONF_MOTION_DELAY,
|
||||||
CONF_MOTION_PRESET,
|
CONF_MOTION_PRESET,
|
||||||
@@ -95,6 +99,12 @@ MOCK_WINDOW_CONFIG = {
|
|||||||
CONF_WINDOW_DELAY: 10,
|
CONF_WINDOW_DELAY: 10,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MOCK_WINDOW_AUTO_CONFIG = {
|
||||||
|
CONF_WINDOW_AUTO_OPEN_THRESHOLD: 1.0,
|
||||||
|
CONF_WINDOW_AUTO_CLOSE_THRESHOLD: 0.0,
|
||||||
|
CONF_WINDOW_AUTO_MAX_DURATION: 5.0,
|
||||||
|
}
|
||||||
|
|
||||||
MOCK_MOTION_CONFIG = {
|
MOCK_MOTION_CONFIG = {
|
||||||
CONF_MOTION_SENSOR: "input_boolean.motion_sensor",
|
CONF_MOTION_SENSOR: "input_boolean.motion_sensor",
|
||||||
CONF_MOTION_DELAY: 10,
|
CONF_MOTION_DELAY: 10,
|
||||||
|
|||||||
@@ -6,20 +6,7 @@ from homeassistant.config_entries import SOURCE_USER, ConfigEntry
|
|||||||
|
|
||||||
from custom_components.versatile_thermostat.const import DOMAIN
|
from custom_components.versatile_thermostat.const import DOMAIN
|
||||||
|
|
||||||
from .const import (
|
from .const import * # pylint: disable=wildcard-import, unused-wildcard-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:
|
async def test_show_form(hass: HomeAssistant) -> None:
|
||||||
@@ -217,3 +204,168 @@ async def test_user_config_flow_over_climate(hass: HomeAssistant, skip_hass_stat
|
|||||||
assert result["result"].version == 1
|
assert result["result"].version == 1
|
||||||
assert result["result"].title == "TheOverClimateMockName"
|
assert result["result"].title == "TheOverClimateMockName"
|
||||||
assert isinstance(result["result"], ConfigEntry)
|
assert isinstance(result["result"], ConfigEntry)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_user_config_flow_window_auto_ok(
|
||||||
|
hass: HomeAssistant, skip_hass_states_get, skip_control_heating
|
||||||
|
):
|
||||||
|
"""Test the config flow with only window auto feature"""
|
||||||
|
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={
|
||||||
|
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_DEVICE_POWER: 1,
|
||||||
|
CONF_USE_WINDOW_FEATURE: True,
|
||||||
|
CONF_USE_MOTION_FEATURE: False,
|
||||||
|
CONF_USE_POWER_FEATURE: False,
|
||||||
|
CONF_USE_PRESENCE_FEATURE: False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
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_AUTO_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
|
||||||
|
| {
|
||||||
|
CONF_USE_MOTION_FEATURE: False,
|
||||||
|
CONF_USE_POWER_FEATURE: False,
|
||||||
|
CONF_USE_PRESENCE_FEATURE: False,
|
||||||
|
CONF_WINDOW_DELAY: 30, # the default value is added
|
||||||
|
CONF_USE_POWER_FEATURE: False,
|
||||||
|
CONF_USE_PRESENCE_FEATURE: False,
|
||||||
|
}
|
||||||
|
| MOCK_TH_OVER_SWITCH_TYPE_CONFIG
|
||||||
|
| MOCK_TH_OVER_SWITCH_TPI_CONFIG
|
||||||
|
| MOCK_PRESETS_CONFIG
|
||||||
|
| MOCK_WINDOW_AUTO_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_window_auto_ko(
|
||||||
|
hass: HomeAssistant, skip_hass_states_get
|
||||||
|
):
|
||||||
|
"""Test the config flow with window auto and window features -> not allowed"""
|
||||||
|
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={
|
||||||
|
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_DEVICE_POWER: 1,
|
||||||
|
CONF_USE_WINDOW_FEATURE: True,
|
||||||
|
CONF_USE_MOTION_FEATURE: False,
|
||||||
|
CONF_USE_POWER_FEATURE: False,
|
||||||
|
CONF_USE_PRESENCE_FEATURE: False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
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_AUTO_CONFIG | MOCK_WINDOW_CONFIG,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
# We should stay on window with an error
|
||||||
|
assert result["step_id"] == "window"
|
||||||
|
assert result["errors"] == {
|
||||||
|
"window_sensor_entity_id": "window_open_detection_method"
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
""" Test the OpenWindow algorithm """
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import
|
||||||
|
from ..open_window_algorithm import WindowOpenDetectionAlgorithm
|
||||||
|
|
||||||
|
|
||||||
|
async def test_open_window_algo(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
skip_hass_states_is_state,
|
||||||
|
):
|
||||||
|
"""Tests the Algo"""
|
||||||
|
|
||||||
|
the_algo = WindowOpenDetectionAlgorithm(1.0, 0.0)
|
||||||
|
assert the_algo.last_slope is None
|
||||||
|
|
||||||
|
tz = get_tz(hass) # pylint: disable=invalid-name
|
||||||
|
now = datetime.now(tz)
|
||||||
|
|
||||||
|
event_timestamp = now - timedelta(minutes=5)
|
||||||
|
last_slope = the_algo.add_temp_measurement(
|
||||||
|
temperature=10, datetime_measure=event_timestamp
|
||||||
|
)
|
||||||
|
|
||||||
|
# We need at least 2 measurement
|
||||||
|
assert last_slope is None
|
||||||
|
assert the_algo.last_slope is None
|
||||||
|
assert the_algo.is_window_close_detected() is False
|
||||||
|
assert the_algo.is_window_open_detected() is False
|
||||||
|
|
||||||
|
event_timestamp = now - timedelta(minutes=4)
|
||||||
|
last_slope = the_algo.add_temp_measurement(
|
||||||
|
temperature=10, datetime_measure=event_timestamp
|
||||||
|
)
|
||||||
|
|
||||||
|
# No slope because same temperature
|
||||||
|
assert last_slope == 0
|
||||||
|
assert the_algo.last_slope == 0
|
||||||
|
assert the_algo.is_window_close_detected() is True
|
||||||
|
assert the_algo.is_window_open_detected() is False
|
||||||
|
|
||||||
|
event_timestamp = now - timedelta(minutes=3)
|
||||||
|
last_slope = the_algo.add_temp_measurement(
|
||||||
|
temperature=9, datetime_measure=event_timestamp
|
||||||
|
)
|
||||||
|
|
||||||
|
# A slope is calculated
|
||||||
|
assert last_slope == -0.5
|
||||||
|
assert the_algo.last_slope == -0.5
|
||||||
|
assert the_algo.is_window_close_detected() is False
|
||||||
|
assert the_algo.is_window_open_detected() is False
|
||||||
|
|
||||||
|
# A new temperature with 2 degre less in one minute (value will be rejected)
|
||||||
|
event_timestamp = now - timedelta(minutes=2)
|
||||||
|
last_slope = the_algo.add_temp_measurement(
|
||||||
|
temperature=7, datetime_measure=event_timestamp
|
||||||
|
)
|
||||||
|
|
||||||
|
# A slope is calculated
|
||||||
|
assert last_slope == -0.5 / 2.0 - 2.0 / 2.0
|
||||||
|
assert the_algo.last_slope == -1.25
|
||||||
|
assert the_algo.is_window_close_detected() is False
|
||||||
|
assert the_algo.is_window_open_detected() is True
|
||||||
|
|
||||||
|
# A new temperature with 1 degre less
|
||||||
|
event_timestamp = now - timedelta(minutes=1)
|
||||||
|
last_slope = the_algo.add_temp_measurement(
|
||||||
|
temperature=6, datetime_measure=event_timestamp
|
||||||
|
)
|
||||||
|
|
||||||
|
# A slope is calculated
|
||||||
|
assert last_slope == -1.25 / 2 - 1.0 / 2.0
|
||||||
|
assert the_algo.last_slope == -1.125
|
||||||
|
assert the_algo.is_window_close_detected() is False
|
||||||
|
assert the_algo.is_window_open_detected() is True
|
||||||
|
|
||||||
|
# A new temperature with 0 degre less
|
||||||
|
event_timestamp = now - timedelta(minutes=0)
|
||||||
|
last_slope = the_algo.add_temp_measurement(
|
||||||
|
temperature=6, datetime_measure=event_timestamp
|
||||||
|
)
|
||||||
|
|
||||||
|
# A slope is calculated
|
||||||
|
assert last_slope == -1.125 / 2
|
||||||
|
assert the_algo.last_slope == -1.125 / 2
|
||||||
|
assert the_algo.is_window_close_detected() is False
|
||||||
|
assert the_algo.is_window_open_detected() is False
|
||||||
|
|
||||||
|
# A new temperature with 1 degre more
|
||||||
|
event_timestamp = now + timedelta(minutes=1)
|
||||||
|
last_slope = the_algo.add_temp_measurement(
|
||||||
|
temperature=7, datetime_measure=event_timestamp
|
||||||
|
)
|
||||||
|
|
||||||
|
# A slope is calculated
|
||||||
|
assert last_slope == -1.125 / 4 + 0.5
|
||||||
|
assert the_algo.last_slope == 0.21875
|
||||||
|
assert the_algo.is_window_close_detected() is True
|
||||||
|
assert the_algo.is_window_open_detected() is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_open_window_algo_wrong(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
skip_hass_states_is_state,
|
||||||
|
):
|
||||||
|
"""Tests the Algo with wrong date"""
|
||||||
|
the_algo = WindowOpenDetectionAlgorithm(1.0, 0.0)
|
||||||
|
assert the_algo.last_slope is None
|
||||||
|
|
||||||
|
tz = get_tz(hass) # pylint: disable=invalid-name
|
||||||
|
now = datetime.now(tz)
|
||||||
|
|
||||||
|
event_timestamp = now - timedelta(minutes=5)
|
||||||
|
last_slope = the_algo.add_temp_measurement(
|
||||||
|
temperature=10, datetime_measure=event_timestamp
|
||||||
|
)
|
||||||
|
|
||||||
|
# We need at least 2 measurement
|
||||||
|
assert last_slope is None
|
||||||
|
assert the_algo.last_slope is None
|
||||||
|
assert the_algo.is_window_close_detected() is False
|
||||||
|
assert the_algo.is_window_open_detected() is False
|
||||||
|
|
||||||
|
# The next datetime_measurement cannot be in the past
|
||||||
|
event_timestamp = now - timedelta(minutes=6)
|
||||||
|
last_slope = the_algo.add_temp_measurement(
|
||||||
|
temperature=18, datetime_measure=event_timestamp
|
||||||
|
)
|
||||||
|
|
||||||
|
# No slope because same temperature
|
||||||
|
assert last_slope is None
|
||||||
|
assert the_algo.last_slope is None
|
||||||
|
assert the_algo.is_window_close_detected() is False
|
||||||
|
assert the_algo.is_window_open_detected() is False
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
""" Test the Window management """
|
""" Test the Window management """
|
||||||
from unittest.mock import patch, call
|
import asyncio
|
||||||
|
from unittest.mock import patch, call, PropertyMock
|
||||||
from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import
|
from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
@@ -11,7 +12,7 @@ logging.getLogger().setLevel(logging.DEBUG)
|
|||||||
async def test_window_management_time_not_enough(
|
async def test_window_management_time_not_enough(
|
||||||
hass: HomeAssistant, skip_hass_states_is_state
|
hass: HomeAssistant, skip_hass_states_is_state
|
||||||
):
|
):
|
||||||
"""Test the Power management"""
|
"""Test the Window management when time is not enough"""
|
||||||
|
|
||||||
entry = MockConfigEntry(
|
entry = MockConfigEntry(
|
||||||
domain=DOMAIN,
|
domain=DOMAIN,
|
||||||
@@ -97,7 +98,7 @@ async def test_window_management_time_not_enough(
|
|||||||
async def test_window_management_time_enough(
|
async def test_window_management_time_enough(
|
||||||
hass: HomeAssistant, skip_hass_states_is_state
|
hass: HomeAssistant, skip_hass_states_is_state
|
||||||
):
|
):
|
||||||
"""Test the Power management"""
|
"""Test the Window management when time is enough"""
|
||||||
|
|
||||||
entry = MockConfigEntry(
|
entry = MockConfigEntry(
|
||||||
domain=DOMAIN,
|
domain=DOMAIN,
|
||||||
@@ -197,3 +198,306 @@ async def test_window_management_time_enough(
|
|||||||
any_order=False,
|
any_order=False,
|
||||||
)
|
)
|
||||||
assert entity.preset_mode is PRESET_BOOST
|
assert entity.preset_mode is PRESET_BOOST
|
||||||
|
|
||||||
|
|
||||||
|
async def test_window_auto_fast(hass: HomeAssistant, skip_hass_states_is_state):
|
||||||
|
"""Test the Power management"""
|
||||||
|
|
||||||
|
entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
title="TheOverSwitchMockName",
|
||||||
|
unique_id="uniqueId",
|
||||||
|
data={
|
||||||
|
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,
|
||||||
|
"eco_temp": 17,
|
||||||
|
"comfort_temp": 18,
|
||||||
|
"boost_temp": 21,
|
||||||
|
CONF_USE_WINDOW_FEATURE: True,
|
||||||
|
CONF_USE_MOTION_FEATURE: False,
|
||||||
|
CONF_USE_POWER_FEATURE: False,
|
||||||
|
CONF_USE_PRESENCE_FEATURE: False,
|
||||||
|
CONF_HEATER: "switch.mock_switch",
|
||||||
|
CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI,
|
||||||
|
CONF_TPI_COEF_INT: 0.3,
|
||||||
|
CONF_TPI_COEF_EXT: 0.01,
|
||||||
|
CONF_MINIMAL_ACTIVATION_DELAY: 30,
|
||||||
|
CONF_SECURITY_DELAY_MIN: 5,
|
||||||
|
CONF_SECURITY_MIN_ON_PERCENT: 0.3,
|
||||||
|
CONF_WINDOW_AUTO_OPEN_THRESHOLD: 0.1,
|
||||||
|
CONF_WINDOW_AUTO_CLOSE_THRESHOLD: 0.1,
|
||||||
|
CONF_WINDOW_AUTO_MAX_DURATION: 10, # Should be 0 for test
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
entity: VersatileThermostat = await create_thermostat(
|
||||||
|
hass, entry, "climate.theoverswitchmockname"
|
||||||
|
)
|
||||||
|
assert entity
|
||||||
|
|
||||||
|
tz = get_tz(hass) # pylint: disable=invalid-name
|
||||||
|
now = datetime.now(tz)
|
||||||
|
|
||||||
|
tpi_algo = entity._prop_algorithm
|
||||||
|
assert tpi_algo
|
||||||
|
|
||||||
|
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.preset_mode is PRESET_BOOST
|
||||||
|
assert entity.overpowering_state is None
|
||||||
|
assert entity.target_temperature == 21
|
||||||
|
|
||||||
|
assert entity.window_state is None
|
||||||
|
|
||||||
|
# Make the temperature down
|
||||||
|
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, patch(
|
||||||
|
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
|
||||||
|
) as mock_heater_off, patch(
|
||||||
|
"custom_components.versatile_thermostat.climate.VersatileThermostat._is_device_active",
|
||||||
|
return_value=True,
|
||||||
|
):
|
||||||
|
event_timestamp = now - timedelta(minutes=4)
|
||||||
|
await send_temperature_change_event(entity, 19, event_timestamp)
|
||||||
|
|
||||||
|
# The heater turns on
|
||||||
|
assert mock_send_event.call_count == 0
|
||||||
|
assert mock_heater_on.call_count == 1
|
||||||
|
assert entity.last_temperature_slope is None
|
||||||
|
assert entity._window_auto_algo.is_window_open_detected() is False
|
||||||
|
assert entity._window_auto_algo.is_window_close_detected() is False
|
||||||
|
assert entity.hvac_mode is HVACMode.HEAT
|
||||||
|
|
||||||
|
# send one degre down in one minute
|
||||||
|
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, patch(
|
||||||
|
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
|
||||||
|
) as mock_heater_off, patch(
|
||||||
|
"custom_components.versatile_thermostat.climate.VersatileThermostat._is_device_active",
|
||||||
|
return_value=True,
|
||||||
|
):
|
||||||
|
event_timestamp = now - timedelta(minutes=3)
|
||||||
|
await send_temperature_change_event(entity, 18, event_timestamp)
|
||||||
|
|
||||||
|
# The heater turns on
|
||||||
|
assert mock_send_event.call_count == 2
|
||||||
|
assert mock_heater_on.call_count == 0
|
||||||
|
assert mock_heater_off.call_count >= 1
|
||||||
|
assert entity.last_temperature_slope == -1
|
||||||
|
assert entity._window_auto_algo.is_window_open_detected() is True
|
||||||
|
assert entity._window_auto_algo.is_window_close_detected() is False
|
||||||
|
assert entity.window_auto_state == STATE_ON
|
||||||
|
assert entity.hvac_mode is HVACMode.OFF
|
||||||
|
|
||||||
|
mock_send_event.assert_has_calls(
|
||||||
|
[
|
||||||
|
call.send_event(EventType.HVAC_MODE_EVENT, {"hvac_mode": HVACMode.OFF}),
|
||||||
|
call.send_event(
|
||||||
|
EventType.WINDOW_AUTO_EVENT,
|
||||||
|
{"type": "start", "cause": "slope alert", "curve_slope": -1.0},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
any_order=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# send another 0.1 degre in one minute -> no change
|
||||||
|
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, patch(
|
||||||
|
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
|
||||||
|
) as mock_heater_off, patch(
|
||||||
|
"custom_components.versatile_thermostat.climate.VersatileThermostat._is_device_active",
|
||||||
|
new_callable=PropertyMock,
|
||||||
|
return_value=False,
|
||||||
|
):
|
||||||
|
event_timestamp = now - timedelta(minutes=2)
|
||||||
|
await send_temperature_change_event(entity, 17.9, event_timestamp)
|
||||||
|
|
||||||
|
# The heater turns on
|
||||||
|
assert mock_send_event.call_count == 0
|
||||||
|
assert mock_heater_on.call_count == 0
|
||||||
|
assert mock_heater_off.call_count == 0
|
||||||
|
assert round(entity.last_temperature_slope, 3) == -0.1 * 0.5 - 1 * 0.5
|
||||||
|
assert entity._window_auto_algo.is_window_open_detected() is True
|
||||||
|
assert entity._window_auto_algo.is_window_close_detected() is False
|
||||||
|
assert entity.window_auto_state == STATE_ON
|
||||||
|
assert entity.hvac_mode is HVACMode.OFF
|
||||||
|
|
||||||
|
# send another plus 1.1 degre in one minute -> restore state
|
||||||
|
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, patch(
|
||||||
|
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
|
||||||
|
) as mock_heater_off, patch(
|
||||||
|
"custom_components.versatile_thermostat.climate.VersatileThermostat._is_device_active",
|
||||||
|
new_callable=PropertyMock,
|
||||||
|
return_value=False,
|
||||||
|
):
|
||||||
|
event_timestamp = now - timedelta(minutes=1)
|
||||||
|
await send_temperature_change_event(entity, 19, event_timestamp)
|
||||||
|
|
||||||
|
# The heater turns on
|
||||||
|
assert mock_send_event.call_count == 2
|
||||||
|
mock_send_event.assert_has_calls(
|
||||||
|
[
|
||||||
|
call.send_event(
|
||||||
|
EventType.HVAC_MODE_EVENT, {"hvac_mode": HVACMode.HEAT}
|
||||||
|
),
|
||||||
|
call.send_event(
|
||||||
|
EventType.WINDOW_AUTO_EVENT,
|
||||||
|
{
|
||||||
|
"type": "end",
|
||||||
|
"cause": "end of slope alert",
|
||||||
|
"curve_slope": 0.27500000000000036,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
any_order=True,
|
||||||
|
)
|
||||||
|
assert mock_heater_on.call_count == 1
|
||||||
|
assert mock_heater_off.call_count == 0
|
||||||
|
assert round(entity.last_temperature_slope, 3) == 0.275
|
||||||
|
assert entity._window_auto_algo.is_window_open_detected() is False
|
||||||
|
assert entity._window_auto_algo.is_window_close_detected() is True
|
||||||
|
assert entity.window_auto_state == STATE_OFF
|
||||||
|
assert entity.hvac_mode is HVACMode.HEAT
|
||||||
|
|
||||||
|
|
||||||
|
async def test_window_auto_auto_stop(hass: HomeAssistant, skip_hass_states_is_state):
|
||||||
|
"""Test the Power management"""
|
||||||
|
|
||||||
|
entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
title="TheOverSwitchMockName",
|
||||||
|
unique_id="uniqueId",
|
||||||
|
data={
|
||||||
|
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,
|
||||||
|
"eco_temp": 17,
|
||||||
|
"comfort_temp": 18,
|
||||||
|
"boost_temp": 21,
|
||||||
|
CONF_USE_WINDOW_FEATURE: True,
|
||||||
|
CONF_USE_MOTION_FEATURE: False,
|
||||||
|
CONF_USE_POWER_FEATURE: False,
|
||||||
|
CONF_USE_PRESENCE_FEATURE: False,
|
||||||
|
CONF_HEATER: "switch.mock_switch",
|
||||||
|
CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI,
|
||||||
|
CONF_TPI_COEF_INT: 0.3,
|
||||||
|
CONF_TPI_COEF_EXT: 0.01,
|
||||||
|
CONF_MINIMAL_ACTIVATION_DELAY: 30,
|
||||||
|
CONF_SECURITY_DELAY_MIN: 5,
|
||||||
|
CONF_SECURITY_MIN_ON_PERCENT: 0.3,
|
||||||
|
CONF_WINDOW_AUTO_OPEN_THRESHOLD: 0.1,
|
||||||
|
CONF_WINDOW_AUTO_CLOSE_THRESHOLD: 0.1,
|
||||||
|
CONF_WINDOW_AUTO_MAX_DURATION: 0, # Should be 0 for test
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
entity: VersatileThermostat = await create_thermostat(
|
||||||
|
hass, entry, "climate.theoverswitchmockname"
|
||||||
|
)
|
||||||
|
assert entity
|
||||||
|
|
||||||
|
tz = get_tz(hass) # pylint: disable=invalid-name
|
||||||
|
now = datetime.now(tz)
|
||||||
|
|
||||||
|
tpi_algo = entity._prop_algorithm
|
||||||
|
assert tpi_algo
|
||||||
|
|
||||||
|
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.preset_mode is PRESET_BOOST
|
||||||
|
assert entity.overpowering_state is None
|
||||||
|
assert entity.target_temperature == 21
|
||||||
|
|
||||||
|
assert entity.window_state is None
|
||||||
|
|
||||||
|
# Make the temperature down
|
||||||
|
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, patch(
|
||||||
|
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
|
||||||
|
) as mock_heater_off, patch(
|
||||||
|
"custom_components.versatile_thermostat.climate.VersatileThermostat._is_device_active",
|
||||||
|
return_value=True,
|
||||||
|
):
|
||||||
|
event_timestamp = now - timedelta(minutes=4)
|
||||||
|
await send_temperature_change_event(entity, 19, event_timestamp)
|
||||||
|
|
||||||
|
# The heater turns on
|
||||||
|
assert mock_heater_on.call_count == 1
|
||||||
|
assert entity.last_temperature_slope is None
|
||||||
|
assert entity._window_auto_algo.is_window_open_detected() is False
|
||||||
|
assert entity._window_auto_algo.is_window_close_detected() is False
|
||||||
|
assert entity.hvac_mode is HVACMode.HEAT
|
||||||
|
|
||||||
|
# send one degre down in one minute
|
||||||
|
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, patch(
|
||||||
|
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
|
||||||
|
) as mock_heater_off, patch(
|
||||||
|
"custom_components.versatile_thermostat.climate.VersatileThermostat._is_device_active",
|
||||||
|
return_value=True,
|
||||||
|
):
|
||||||
|
event_timestamp = now - timedelta(minutes=3)
|
||||||
|
await send_temperature_change_event(entity, 18, event_timestamp)
|
||||||
|
|
||||||
|
# The heater turns on
|
||||||
|
assert mock_send_event.call_count == 2
|
||||||
|
assert mock_heater_on.call_count == 0
|
||||||
|
assert mock_heater_off.call_count >= 1
|
||||||
|
assert entity.last_temperature_slope == -1
|
||||||
|
assert entity._window_auto_algo.is_window_open_detected() is True
|
||||||
|
assert entity._window_auto_algo.is_window_close_detected() is False
|
||||||
|
assert entity.window_auto_state == STATE_ON
|
||||||
|
assert entity.hvac_mode is HVACMode.OFF
|
||||||
|
|
||||||
|
# Waits for automatic disable
|
||||||
|
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, patch(
|
||||||
|
"custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off"
|
||||||
|
) as mock_heater_off, patch(
|
||||||
|
"custom_components.versatile_thermostat.climate.VersatileThermostat._is_device_active",
|
||||||
|
return_value=False,
|
||||||
|
):
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
|
||||||
|
assert mock_heater_on.call_count == 1
|
||||||
|
assert mock_heater_off.call_count == 0
|
||||||
|
assert round(entity.last_temperature_slope, 3) == -1
|
||||||
|
# Because the algorithm is not aware of the expiration, for the algo we are still in alert
|
||||||
|
assert entity._window_auto_algo.is_window_open_detected() is True
|
||||||
|
assert entity._window_auto_algo.is_window_close_detected() is False
|
||||||
|
assert entity.window_auto_state == STATE_OFF
|
||||||
|
assert entity.hvac_mode is HVACMode.HEAT
|
||||||
|
assert entity.preset_mode is PRESET_BOOST
|
||||||
|
|||||||
@@ -49,10 +49,19 @@
|
|||||||
},
|
},
|
||||||
"window": {
|
"window": {
|
||||||
"title": "Window management",
|
"title": "Window management",
|
||||||
"description": "Open window management.\nLeave corresponding entity_id empty if not used.",
|
"description": "Open window management.\nLeave corresponding entity_id empty if not used\nYou can also configure automatic window open detection based on temperature decrease",
|
||||||
"data": {
|
"data": {
|
||||||
"window_sensor_entity_id": "Window sensor entity id",
|
"window_sensor_entity_id": "Window sensor entity id",
|
||||||
"window_delay": "Window delay (seconds)"
|
"window_delay": "Window sensor delay (seconds)",
|
||||||
|
"window_auto_open_threshold": "Temperature decrease threshold for automatic window open detection (in °/min)",
|
||||||
|
"window_auto_close_threshold": "Temperature increase threshold for end of automatic detection (in °/min)",
|
||||||
|
"window_auto_max_duration": "Maximum duration of automatic window open detection (in min)"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"window_sensor_entity_id": "Leave empty if no window sensor should be use",
|
||||||
|
"window_auto_open_threshold": "Recommended value: between 0.05 and 0.1. Leave empty if automatic window open detection is not use",
|
||||||
|
"window_auto_close_threshold": "Recommended value: 0. Leave empty if automatic window open detection is not use",
|
||||||
|
"window_auto_max_duration": "Recommended value: 60 (one hour). Leave empty if automatic window open detection is not use"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"motion": {
|
"motion": {
|
||||||
@@ -97,7 +106,8 @@
|
|||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"unknown": "Unexpected error",
|
"unknown": "Unexpected error",
|
||||||
"unknown_entity": "Unknown entity id"
|
"unknown_entity": "Unknown entity id",
|
||||||
|
"window_open_detection_method": "Only one window open detection method should be used. Use sensor or automatic detection through temperature threshold but not both"
|
||||||
},
|
},
|
||||||
"abort": {
|
"abort": {
|
||||||
"already_configured": "Device is already configured"
|
"already_configured": "Device is already configured"
|
||||||
@@ -151,11 +161,20 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"window": {
|
"window": {
|
||||||
"title": "Window management",
|
"title": "[%key:component::versatile_thermostat::config::step::window::title%]",
|
||||||
"description": "Open window management.\nLeave corresponding entity_id empty if not used.",
|
"description": "[%key:component::versatile_thermostat::config::step::window::description%]",
|
||||||
"data": {
|
"data": {
|
||||||
"window_sensor_entity_id": "Window sensor entity id",
|
"window_sensor_entity_id": "[%key:component::versatile_thermostat::config::step::window::data::window_sensor_entity_id%]",
|
||||||
"window_delay": "Window delay (seconds)"
|
"window_delay": "[%key:component::versatile_thermostat::config::step::window::data::window_delay%]",
|
||||||
|
"window_auto_open_threshold": "[%key:component::versatile_thermostat::config::step::window::data::window_auto_open_threshold%]",
|
||||||
|
"window_auto_close_threshold": "[%key:component::versatile_thermostat::config::step::window::data::window_auto_close_threshold%]",
|
||||||
|
"window_auto_max_duration": "[%key:component::versatile_thermostat::config::step::window::data::window_auto_max_duration%]"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"window_sensor_entity_id": "[%key:component::versatile_thermostat::config::step::window::data_description::window_sensor_entity_id%]",
|
||||||
|
"window_auto_open_threshold": "[%key:component::versatile_thermostat::config::step::window::data_description::window_auto_open_threshold%]",
|
||||||
|
"window_auto_close_threshold": "[%key:component::versatile_thermostat::config::step::window::data_description::window_auto_close_threshold%]",
|
||||||
|
"window_auto_max_duration": "[%key:component::versatile_thermostat::config::step::window::data_description::window_auto_max_duration%]"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"motion": {
|
"motion": {
|
||||||
@@ -192,18 +211,19 @@
|
|||||||
"description": "Configuration of advanced parameters. Leave the default values if you don't know what you are doing.\nThis parameters can lead to a very bad temperature or power regulation.",
|
"description": "Configuration of advanced parameters. Leave the default values if you don't know what you are doing.\nThis parameters can lead to a very bad temperature or power regulation.",
|
||||||
"data": {
|
"data": {
|
||||||
"minimal_activation_delay": "Delay in secondes under which the equipment will not be activated",
|
"minimal_activation_delay": "Delay in secondes under which the equipment will not be activated",
|
||||||
"security_delay_min": "Maximum allowed delay in minutes between two temperature mesures. Above this delay, the thermostat will turn to a sceurity off state",
|
"security_delay_min": "Maximum allowed delay in minutes between two temperature mesures. Above this delay, the thermostat will turn to a security off state",
|
||||||
"security_min_on_percent": "Minimal heating percent value for security preset activation. Below this amount of on_percent the thermostat won't go into security preset",
|
"security_min_on_percent": "Minimal heating percent value for security preset activation. Below this amount of on_percent the thermostat won't go into security preset",
|
||||||
"security_default_on_percent": "The default heating percent value in security preset. Set to 0 to switch off heater in security present"
|
"security_default_on_percent": "The default heating percent value in security preset. Set to 0 to switch off heater in security present"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"unknown": "Unexpected error",
|
"unknown": "[%key:component::versatile_thermostat::config::error::unknown%]",
|
||||||
"unknown_entity": "Unknown entity id"
|
"unknown_entity": "[%key:component::versatile_thermostat::config::error::unknown_entity%]",
|
||||||
|
"window_open_detection_method": "[%key:component::versatile_thermostat::config::error::window_open_detection_method%]"
|
||||||
},
|
},
|
||||||
"abort": {
|
"abort": {
|
||||||
"already_configured": "Device is already configured"
|
"already_configured": "[%key:component::versatile_thermostat::config::abort::already_configured%]"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"selector": {
|
"selector": {
|
||||||
|
|||||||
@@ -50,8 +50,17 @@
|
|||||||
"title": "Gestion d'une ouverture",
|
"title": "Gestion d'une ouverture",
|
||||||
"description": "Coupe le radiateur si l'ouverture est ouverte.\nLaissez l'entity id vide si non utilisé.",
|
"description": "Coupe le radiateur si l'ouverture est ouverte.\nLaissez l'entity id vide si non utilisé.",
|
||||||
"data": {
|
"data": {
|
||||||
"window_sensor_entity_id": "Ouverture sensor entity id",
|
"window_sensor_entity_id": "Détecteur d'ouverture (entity id)",
|
||||||
"window_delay": "Délai avant extinction (seconds)"
|
"window_delay": "Délai avant extinction (seconds)",
|
||||||
|
"window_auto_open_threshold": "seuil haut de chute de température pour la détection automatique (en °/min)",
|
||||||
|
"window_auto_close_threshold": "Seuil bas de chute de température pour la fin de détection automatique (en °/min)",
|
||||||
|
"window_auto_max_duration": "Durée maximum d'une extinction automatique (en min)"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"window_sensor_entity_id": "Laissez vide si vous n'avez de détecteur",
|
||||||
|
"window_auto_open_threshold": "Valeur recommandée: entre 0.05 et 0.1. Laissez vide si vous n'utilisez pas la détection automatique",
|
||||||
|
"window_auto_close_threshold": "Valeur recommandée: 0. Laissez vide si vous n'utilisez pas la détection automatique",
|
||||||
|
"window_auto_max_duration": "Valeur recommandée: 60 (1 heure). Laissez vide si vous n'utilisez pas la détection automatique"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"motion": {
|
"motion": {
|
||||||
@@ -96,7 +105,8 @@
|
|||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"unknown": "Erreur inattendue",
|
"unknown": "Erreur inattendue",
|
||||||
"unknown_entity": "entity id inconnu"
|
"unknown_entity": "entity id inconnu",
|
||||||
|
"window_open_detection_method": "Une seule méthode de détection des ouvertures ouvertes doit être utilisée. Utilisez le détecteur d'ouverture ou les seuils de température mais pas les deux."
|
||||||
},
|
},
|
||||||
"abort": {
|
"abort": {
|
||||||
"already_configured": "Le device est déjà configuré"
|
"already_configured": "Le device est déjà configuré"
|
||||||
@@ -151,11 +161,20 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"window": {
|
"window": {
|
||||||
"title": "Gestion d'une ouverture",
|
"title": "[%key:component::versatile_thermostat::config::step::window::title%]",
|
||||||
"description": "Coupe le radiateur si l'ouverture est ouverte.\nLaissez l'entity id vide si non utilisé.",
|
"description": "[%key:component::versatile_thermostat::config::step::window::description%]",
|
||||||
"data": {
|
"data": {
|
||||||
"window_sensor_entity_id": "Ouverture sensor entity id",
|
"window_sensor_entity_id": "[%key:component::versatile_thermostat::config::step::window::data::window_sensor_entity_id%]",
|
||||||
"window_delay": "Délai avant extinction (seconds)"
|
"window_delay": "[%key:component::versatile_thermostat::config::step::window::data::window_delay%]",
|
||||||
|
"window_auto_open_threshold": "[%key:component::versatile_thermostat::config::step::window::data::window_auto_open_threshold%]",
|
||||||
|
"window_auto_close_threshold": "[%key:component::versatile_thermostat::config::step::window::data::window_auto_close_threshold%]",
|
||||||
|
"window_auto_max_duration": "[%key:component::versatile_thermostat::config::step::window::data::window_auto_max_duration%]"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"window_sensor_entity_id": "[%key:component::versatile_thermostat::config::step::window::data_description::window_sensor_entity_id%]",
|
||||||
|
"window_auto_open_threshold": "[%key:component::versatile_thermostat::config::step::window::data_description::window_auto_open_threshold%]",
|
||||||
|
"window_auto_close_threshold": "[%key:component::versatile_thermostat::config::step::window::data_description::window_auto_close_threshold%]",
|
||||||
|
"window_auto_max_duration": "[%key:component::versatile_thermostat::config::step::window::data_description::window_auto_max_duration%]"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"motion": {
|
"motion": {
|
||||||
@@ -199,11 +218,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"unknown": "Erreur inattendue",
|
"unknown": "[%key:component::versatile_thermostat::config::error::unknown%]",
|
||||||
"unknown_entity": "entity id inconnu"
|
"unknown_entity": "[%key:component::versatile_thermostat::config::error::unknown_entity%]",
|
||||||
|
"window_open_detection_method": "[%key:component::versatile_thermostat::config::error::window_open_detection_method%]"
|
||||||
},
|
},
|
||||||
"abort": {
|
"abort": {
|
||||||
"already_configured": "Le device est déjà configuré"
|
"already_configured": "[%key:component::versatile_thermostat::config::abort::already_configured%]"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"selector": {
|
"selector": {
|
||||||
|
|||||||
Reference in New Issue
Block a user