Skip to content

Commit

Permalink
[#28] Add a window open detection based on internal temperature change
Browse files Browse the repository at this point in the history
  • Loading branch information
Jean-Marc Collin committed Mar 11, 2023
1 parent 6e40a15 commit 0ffa6d7
Show file tree
Hide file tree
Showing 12 changed files with 600 additions and 114 deletions.
38 changes: 16 additions & 22 deletions custom_components/versatile_thermostat/binary_sensor.py
Expand Up @@ -64,10 +64,8 @@ def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
@callback
async def async_my_climate_changed(self, event: Event = None):
"""Called when my climate have change"""
_LOGGER.debug(
"%s - climate state change",
event.origin.name if event and event.origin else None,
)
_LOGGER.debug("%s - climate state change", self._attr_unique_id)

old_state = self._attr_is_on
self._attr_is_on = self.my_climate.security_state is True
if old_state != self._attr_is_on:
Expand Down Expand Up @@ -99,10 +97,8 @@ def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
@callback
async def async_my_climate_changed(self, event: Event = None):
"""Called when my climate have change"""
_LOGGER.debug(
"%s - climate state change",
event.origin.name if event and event.origin else None,
)
_LOGGER.debug("%s - climate state change", self._attr_unique_id)

old_state = self._attr_is_on
self._attr_is_on = self.my_climate.overpowering_state is True
if old_state != self._attr_is_on:
Expand Down Expand Up @@ -134,12 +130,13 @@ def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
@callback
async def async_my_climate_changed(self, event: Event = None):
"""Called when my climate have change"""
_LOGGER.debug(
"%s - climate state change",
event.origin.name if event and event.origin else None,
)
_LOGGER.debug("%s - climate state change", self._attr_unique_id)

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:
self.async_write_ha_state()
return
Expand All @@ -151,7 +148,10 @@ def device_class(self) -> BinarySensorDeviceClass | None:
@property
def icon(self) -> str | None:
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:
return "mdi:window-closed-variant"

Expand All @@ -169,10 +169,7 @@ def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
@callback
async def async_my_climate_changed(self, event: Event = None):
"""Called when my climate have change"""
_LOGGER.debug(
"%s - climate state change",
event.origin.name if event and event.origin else None,
)
_LOGGER.debug("%s - climate state change", self._attr_unique_id)
old_state = self._attr_is_on
self._attr_is_on = self.my_climate.motion_state == STATE_ON
if old_state != self._attr_is_on:
Expand Down Expand Up @@ -205,10 +202,7 @@ def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
async def async_my_climate_changed(self, event: Event = None):
"""Called when my climate have change"""

_LOGGER.debug(
"%s - climate state change",
event.origin.name if event and event.origin else None,
)
_LOGGER.debug("%s - climate state change", self._attr_unique_id)
old_state = self._attr_is_on
self._attr_is_on = self.my_climate.presence_state == STATE_ON
if old_state != self._attr_is_on:
Expand Down
172 changes: 150 additions & 22 deletions custom_components/versatile_thermostat/climate.py
Expand Up @@ -103,6 +103,9 @@
CONF_MAX_POWER_SENSOR,
CONF_WINDOW_SENSOR,
CONF_WINDOW_DELAY,
CONF_WINDOW_AUTO_CLOSE_THRESHOLD,
CONF_WINDOW_AUTO_OPEN_THRESHOLD,
CONF_WINDOW_AUTO_MAX_DURATION,
CONF_MOTION_SENSOR,
CONF_MOTION_DELAY,
CONF_MOTION_PRESET,
Expand Down Expand Up @@ -144,6 +147,7 @@
)

from .prop_algorithm import PropAlgorithm
from .open_window_algorithm import WindowOpenDetectionAlgorithm

_LOGGER = logging.getLogger(__name__)

Expand Down Expand Up @@ -216,6 +220,7 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
_motion_state: bool
_presence_state: bool
_security_state: bool
_window_auto_state: bool

def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
"""Initialize the thermostat."""
Expand Down Expand Up @@ -270,6 +275,13 @@ def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
self._underlying_climate_start_hvac_action_date = None
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.post_init(entry_infos)
Expand Down Expand Up @@ -340,6 +352,27 @@ def post_init(self, entry_infos):
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_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_delay_sec = entry_infos.get(CONF_MOTION_DELAY)
self._motion_preset = entry_infos.get(CONF_MOTION_PRESET)
Expand Down Expand Up @@ -1044,6 +1077,11 @@ def window_state(self) -> bool | None:
"""Get the 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
def security_state(self) -> bool | None:
"""Get the security_state"""
Expand Down Expand Up @@ -1074,6 +1112,41 @@ def last_ext_temperature_mesure(self) -> datetime | None:
"""Get the last external temperature datetime"""
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:
"""Turn auxiliary heater on."""
if self._is_over_climate and self._underlying_climate:
Expand Down Expand Up @@ -1102,28 +1175,6 @@ async def async_turn_aux_heat_off(self) -> None:

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):
"""Set new target hvac mode."""
_LOGGER.info("%s - Set hvac mode: %s", self, hvac_mode)
Expand Down Expand Up @@ -1621,6 +1672,9 @@ async def _async_update_temp(self, state: State):
if self._security_state:
await self.check_security()

# check window_auto
await self._async_manage_window_auto()

except ValueError as ex:
_LOGGER.error("Unable to update temperature from sensor: %s", ex)

Expand Down Expand Up @@ -1815,6 +1869,80 @@ async def _async_underlying_entity_turn_off(self):
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):
"""Save the current preset mode to be restored later
We never save a hidden preset mode
Expand Down
2 changes: 1 addition & 1 deletion custom_components/versatile_thermostat/commons.py
Expand Up @@ -58,7 +58,7 @@ def find_my_versatile_thermostat(self) -> VersatileThermostat:
try:
component: EntityComponent[ClimateEntity] = self.hass.data[CLIMATE_DOMAIN]
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:
_LOGGER.debug("Found %s!", entity)
return entity
Expand Down

0 comments on commit 0ffa6d7

Please sign in to comment.