diff --git a/custom_components/versatile_thermostat/base_thermostat.py b/custom_components/versatile_thermostat/base_thermostat.py index 70e02b7..29ab4e1 100644 --- a/custom_components/versatile_thermostat/base_thermostat.py +++ b/custom_components/versatile_thermostat/base_thermostat.py @@ -74,6 +74,7 @@ from .ema import ExponentialMovingAverage from .base_manager import BaseFeatureManager from .feature_presence_manager import FeaturePresenceManager from .feature_power_manager import FeaturePowerManager +from .feature_motion_manager import FeatureMotionManager _LOGGER = logging.getLogger(__name__) @@ -102,7 +103,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): "comfort_away_temp", "power_temp", "ac_mode", - "current_power_max", + "current_max_power", "saved_preset_mode", "saved_target_temp", "saved_hvac_mode", @@ -112,8 +113,6 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): "last_temperature_datetime", "last_ext_temperature_datetime", "minimal_activation_delay_sec", - "device_power", - "mean_cycle_power", "last_update_datetime", "timezone", "window_sensor_entity_id", @@ -123,12 +122,6 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): "window_auto_close_threshold", "window_auto_max_duration", "window_action", - "motion_sensor_entity_id", - "presence_sensor_entity_id", - "is_presence_configured", - "power_sensor_entity_id", - "max_power_sensor_entity_id", - "is_power_configured", "temperature_unit", "is_device_active", "device_actives", @@ -141,6 +134,9 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): } ) ) + .union(FeaturePresenceManager.unrecorded_attributes) + .union(FeaturePowerManager.unrecorded_attributes) + .union(FeatureMotionManager.unrecorded_attributes) ) def __init__( @@ -173,10 +169,10 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): self._humidity = None self._swing_mode = None self._window_state = None - self._motion_state = None + self._saved_hvac_mode = None self._window_call_cancel = None - self._motion_call_cancel = None + self._cur_temp = None self._ac_mode = None self._temp_sensor_entity_id = None @@ -249,9 +245,11 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): self, hass ) self._power_manager: FeaturePowerManager = FeaturePowerManager(self, hass) + self._motion_manager: FeatureMotionManager = FeatureMotionManager(self, hass) self.register_manager(self._presence_manager) self.register_manager(self._power_manager) + self.register_manager(self._motion_manager) self.post_init(entry_infos) @@ -343,9 +341,6 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): if self._window_call_cancel is not None: self._window_call_cancel() self._window_call_cancel = None - if self._motion_call_cancel is not None: - self._motion_call_cancel() - self._motion_call_cancel = None self._cycle_min = entry_infos.get(CONF_CYCLE_MIN) @@ -382,20 +377,6 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): 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_off_delay_sec = entry_infos.get(CONF_MOTION_OFF_DELAY) - if not self._motion_off_delay_sec: - self._motion_off_delay_sec = self._motion_delay_sec - - self._motion_preset = entry_infos.get(CONF_MOTION_PRESET) - self._no_motion_preset = entry_infos.get(CONF_NO_MOTION_PRESET) - self._motion_on = ( - self._motion_sensor_entity_id is not None - and self._motion_preset is not None - and self._no_motion_preset is not None - ) - self._tpi_coef_int = entry_infos.get(CONF_TPI_COEF_INT) self._tpi_coef_ext = entry_infos.get(CONF_TPI_COEF_EXT) @@ -461,7 +442,6 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): del self._prop_algorithm # Memory synthesis state - self._motion_state = None self._window_state = None self._total_energy = None @@ -542,14 +522,6 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): self._async_windows_changed, ) ) - if self._motion_sensor_entity_id: - self.async_on_remove( - async_track_state_change_event( - self.hass, - [self._motion_sensor_entity_id], - self._async_motion_changed, - ) - ) # start listening for all managers for manager in self._managers: @@ -647,23 +619,6 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): ) need_write_state = True - # try to acquire motion entity state - if self._motion_sensor_entity_id: - motion_state = self.hass.states.get(self._motion_sensor_entity_id) - if motion_state and motion_state.state not in ( - STATE_UNAVAILABLE, - STATE_UNKNOWN, - ): - self._motion_state = motion_state.state - _LOGGER.debug( - "%s - Motion state have been retrieved: %s", - self, - self._motion_state, - ) - # recalculate the right target_temp in activity mode - await self._async_update_motion_temp() - need_write_state = True - # refresh states for all managers for manager in self._managers: if await manager.refresh_state(): @@ -975,6 +930,11 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): """Get the presence manager""" return self._presence_manager + @property + def motion_manager(self) -> FeatureMotionManager | None: + """Get the motion manager""" + return self._motion_manager + @property def window_state(self) -> str | None: """Get the window_state""" @@ -1003,7 +963,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): @property def motion_state(self) -> str | None: """Get the motion_state""" - return self._motion_state + return self._motion_manager.motion_state @property def presence_state(self) -> str | None: @@ -1248,7 +1208,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): self._saved_preset_mode = preset_mode return - old_preset_mode = self._attr_preset_mode + # Remove this old_preset_mode = self._attr_preset_mode recalculate = True if preset_mode == PRESET_NONE: self._attr_preset_mode = PRESET_NONE @@ -1256,7 +1216,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): await self.change_target_temperature(self._saved_target_temp) elif preset_mode == PRESET_ACTIVITY: self._attr_preset_mode = PRESET_ACTIVITY - await self._async_update_motion_temp() + await self._motion_manager.update_motion(None, False) else: if self._attr_preset_mode == PRESET_NONE: self._saved_target_temp = self._target_temp @@ -1316,18 +1276,9 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): if preset_mode == PRESET_POWER: return self._power_manager.power_temperature if preset_mode == PRESET_ACTIVITY: + motion_preset = self._motion_manager.get_current_motion_preset() if self._ac_mode and self._hvac_mode == HVACMode.COOL: - motion_preset = ( - self._motion_preset + PRESET_AC_SUFFIX - if self._motion_state == STATE_ON - else self._no_motion_preset + PRESET_AC_SUFFIX - ) - else: - motion_preset = ( - self._motion_preset - if self._motion_state == STATE_ON - else self._no_motion_preset - ) + motion_preset = motion_preset + PRESET_AC_SUFFIX if motion_preset in self._presets: if self._presence_manager.is_absence_detected: @@ -1559,139 +1510,6 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): # For testing purpose we need to access the inner function return try_window_condition - @callback - async def _async_motion_changed(self, event): - """Handle motion changes.""" - new_state = event.data.get("new_state") - _LOGGER.info( - "%s - Motion changed. Event.new_state is %s, _attr_preset_mode=%s, activity=%s", - self, - new_state, - self._attr_preset_mode, - PRESET_ACTIVITY, - ) - - if new_state is None or new_state.state not in (STATE_OFF, STATE_ON): - return - - # Check delay condition - async def try_motion_condition(_): - try: - delay = ( - self._motion_delay_sec - if new_state.state == STATE_ON - else self._motion_off_delay_sec - ) - long_enough = condition.state( - self.hass, - self._motion_sensor_entity_id, - new_state.state, - timedelta(seconds=delay), - ) - except ConditionError: - long_enough = False - - if not long_enough: - _LOGGER.debug( - "Motion delay condition is not satisfied (the sensor have change its state during the delay). Check motion sensor state" - ) - # Get sensor current state - motion_state = self.hass.states.get(self._motion_sensor_entity_id) - _LOGGER.debug( - "%s - motion_state=%s, new_state.state=%s", - self, - motion_state.state, - new_state.state, - ) - if ( - motion_state.state == new_state.state - and new_state.state == STATE_ON - ): - _LOGGER.debug( - "%s - the motion sensor is finally 'on' after the delay", self - ) - long_enough = True - else: - long_enough = False - - if long_enough: - _LOGGER.debug("%s - Motion delay condition is satisfied", self) - self._motion_state = new_state.state - if self._attr_preset_mode == PRESET_ACTIVITY: - - new_preset = ( - self._motion_preset - if self._motion_state == STATE_ON - else self._no_motion_preset - ) - _LOGGER.info( - "%s - Motion condition have changes. New preset temp will be %s", - self, - new_preset, - ) - # We do not change the preset which is kept to ACTIVITY but only the target_temperature - # We take the presence into account - - await self.change_target_temperature( - self.find_preset_temp(new_preset) - ) - self.recalculate() - await self.async_control_heating(force=True) - else: - self._motion_state = ( - STATE_ON if new_state.state == STATE_OFF else STATE_OFF - ) - - self._motion_call_cancel = None - - im_on = self._motion_state == STATE_ON - delay_running = self._motion_call_cancel is not None - event_on = new_state.state == STATE_ON - - def arm(): - """Arm the timer""" - delay = ( - self._motion_delay_sec - if new_state.state == STATE_ON - else self._motion_off_delay_sec - ) - self._motion_call_cancel = async_call_later( - self.hass, timedelta(seconds=delay), try_motion_condition - ) - - def desarm(): - # restart the timer - self._motion_call_cancel() - self._motion_call_cancel = None - - # if I'm off - if not im_on: - if event_on and not delay_running: - _LOGGER.debug( - "%s - Arm delay cause i'm off and event is on and no delay is running", - self, - ) - arm() - return try_motion_condition - # Ignore the event - _LOGGER.debug("%s - Event ignored cause i'm already off", self) - return None - else: # I'm On - if not event_on and not delay_running: - _LOGGER.info("%s - Arm delay cause i'm on and event is off", self) - arm() - return try_motion_condition - if event_on and delay_running: - _LOGGER.debug( - "%s - Desarm off delay cause i'm on and event is on and a delay is running", - self, - ) - desarm() - return None - # Ignore the event - _LOGGER.debug("%s - Event ignored cause i'm already on", self) - return None - @callback async def _check_initial_state(self): """Prevent the device from keep running if HVAC_MODE_OFF.""" @@ -1771,41 +1589,6 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): except ValueError as ex: _LOGGER.error("Unable to update external temperature from sensor: %s", ex) - async def _async_update_motion_temp(self): - """Update the temperature considering the ACTIVITY preset and current motion state""" - _LOGGER.debug( - "%s - Calling _update_motion_temp preset_mode=%s, motion_state=%s", - self, - self._attr_preset_mode, - self._motion_state, - ) - if ( - self._motion_sensor_entity_id is None - or self._attr_preset_mode != PRESET_ACTIVITY - ): - return - - new_preset = ( - self._motion_preset - if self._motion_state == STATE_ON - else self._no_motion_preset - ) - _LOGGER.info( - "%s - Motion condition have changes. New preset temp will be %s", - self, - new_preset, - ) - # We do not change the preset which is kept to ACTIVITY but only the target_temperature - # We take the presence into account - - await self.change_target_temperature(self.find_preset_temp(new_preset)) - - _LOGGER.debug( - "%s - regarding motion, target_temp have been set to %.2f", - self, - self._target_temp, - ) - async def async_underlying_entity_turn_off(self): """Turn heater toggleable device off. Used by Window, overpowering, control_heating to turn all off""" @@ -2377,8 +2160,6 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): "saved_preset_mode": self._saved_preset_mode, "saved_target_temp": self._saved_target_temp, "saved_hvac_mode": self._saved_hvac_mode, - "motion_sensor_entity_id": self._motion_sensor_entity_id, - "motion_state": self._motion_state, "window_state": self.window_state, "window_auto_state": self.window_auto_state, "window_bypass_state": self._window_bypass_state, @@ -2400,7 +2181,6 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): ).isoformat(), "security_state": self._security_state, "minimal_activation_delay_sec": self._minimal_activation_delay, - ATTR_MEAN_POWER_CYCLE: self._power_manager.mean_cycle_power, ATTR_TOTAL_ENERGY: self.total_energy, "last_update_datetime": self.now.isoformat(), "timezone": str(self._current_tz), @@ -2633,7 +2413,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): else: _LOGGER.debug("No preset_modes") - if self._motion_on: + if self._motion_manager.is_configured: self._attr_preset_modes.append(PRESET_ACTIVITY) # Re-applicate the last preset if any to take change into account diff --git a/custom_components/versatile_thermostat/feature_motion_manager.py b/custom_components/versatile_thermostat/feature_motion_manager.py new file mode 100644 index 0000000..92255b9 --- /dev/null +++ b/custom_components/versatile_thermostat/feature_motion_manager.py @@ -0,0 +1,345 @@ +""" Implements the Motion Feature Manager """ + +# pylint: disable=line-too-long + +import logging +from typing import Any +from datetime import timedelta + +from homeassistant.const import ( + STATE_ON, + STATE_OFF, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) +from homeassistant.core import ( + HomeAssistant, + callback, + Event, +) +from homeassistant.helpers.event import ( + async_track_state_change_event, + EventStateChangedData, + async_call_later, +) + +from homeassistant.components.climate import ( + PRESET_ACTIVITY, +) + +from homeassistant.exceptions import ConditionError +from homeassistant.helpers import condition + +from .const import * # pylint: disable=wildcard-import, unused-wildcard-import +from .commons import ConfigData + +from .base_manager import BaseFeatureManager + +_LOGGER = logging.getLogger(__name__) + + +class FeatureMotionManager(BaseFeatureManager): + """The implementation of the Presence feature""" + + unrecorded_attributes = frozenset( + { + "motion_sensor_entity_id", + "is_motion_configured", + "motion_delay_sec", + "motion_off_delay_sec", + "motion_preset", + "no_motion_preset", + } + ) + + def __init__(self, vtherm: Any, hass: HomeAssistant): + """Init of a featureManager""" + super().__init__(vtherm, hass) + self._motion_state: str = STATE_UNAVAILABLE + self._motion_sensor_entity_id: str = None + self._motion_delay_sec: int | None = 0 + self._motion_off_delay_sec: int | None = 0 + self._motion_preset: str | None = None + self._no_motion_preset: str | None = None + self._is_configured: bool = False + self._motion_call_cancel: callable = None + + @overrides + def post_init(self, entry_infos: ConfigData): + """Reinit of the manager""" + if self._motion_call_cancel is not None: + self._motion_call_cancel() # pylint: disable="not-callable" + self._motion_call_cancel = None + + self._motion_sensor_entity_id = entry_infos.get(CONF_MOTION_SENSOR, None) + self._motion_delay_sec = entry_infos.get(CONF_MOTION_DELAY, 0) + self._motion_off_delay_sec = entry_infos.get(CONF_MOTION_OFF_DELAY, None) + if not self._motion_off_delay_sec: + self._motion_off_delay_sec = self._motion_delay_sec + + self._motion_preset = entry_infos.get(CONF_MOTION_PRESET) + self._no_motion_preset = entry_infos.get(CONF_NO_MOTION_PRESET) + if ( + self._motion_sensor_entity_id is not None + and self._motion_preset is not None + and self._no_motion_preset is not None + ): + self._is_configured = True + self._motion_state = STATE_UNKNOWN + + @overrides + def start_listening(self): + """Start listening the underlying entity""" + if self._is_configured: + self.stop_listening() + self.add_listener( + async_track_state_change_event( + self.hass, + [self._motion_sensor_entity_id], + self._motion_sensor_changed, + ) + ) + + @overrides + def stop_listening(self): + """Stop listening and remove the eventual timer still running""" + self.dearm_motion_timer() + super().stop_listening() + + def dearm_motion_timer(self): + """Dearm the eventual motion time running""" + if self._motion_call_cancel: + self._motion_call_cancel() + self._motion_call_cancel = None + + @overrides + async def refresh_state(self) -> bool: + """Tries to get the last state from sensor + Returns True if a change has been made""" + ret = False + if self._is_configured: + + motion_state = self.hass.states.get(self._motion_sensor_entity_id) + if motion_state and motion_state.state not in ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + _LOGGER.debug( + "%s - Motion state have been retrieved: %s", + self, + self._motion_state, + ) + # recalculate the right target_temp in activity mode + ret = await self.update_motion(motion_state.state, False) + + return ret + + @callback + async def _motion_sensor_changed(self, event: Event[EventStateChangedData]): + """Handle motion sensor changes.""" + new_state = event.data.get("new_state") + _LOGGER.info( + "%s - Motion changed. Event.new_state is %s, _attr_preset_mode=%s, activity=%s", + self, + new_state, + self._vtherm.preset_mode, + PRESET_ACTIVITY, + ) + + if new_state is None or new_state.state not in (STATE_OFF, STATE_ON): + return + + # Check delay condition + async def try_motion_condition(_): + self.dearm_motion_timer() + + try: + delay = ( + self._motion_delay_sec + if new_state.state == STATE_ON + else self._motion_off_delay_sec + ) + long_enough = condition.state( + self.hass, + self._motion_sensor_entity_id, + new_state.state, + timedelta(seconds=delay), + ) + except ConditionError: + long_enough = False + + if not long_enough: + _LOGGER.debug( + "Motion delay condition is not satisfied (the sensor have change its state during the delay). Check motion sensor state" + ) + # Get sensor current state + motion_state = self.hass.states.get(self._motion_sensor_entity_id) + _LOGGER.debug( + "%s - motion_state=%s, new_state.state=%s", + self, + motion_state.state, + new_state.state, + ) + if ( + motion_state.state == new_state.state + and new_state.state == STATE_ON + ): + _LOGGER.debug( + "%s - the motion sensor is finally 'on' after the delay", self + ) + long_enough = True + else: + long_enough = False + + if long_enough: + _LOGGER.debug("%s - Motion delay condition is satisfied", self) + await self.update_motion(new_state.state) + else: + await self.update_motion( + STATE_ON if new_state.state == STATE_OFF else STATE_OFF + ) + + im_on = self._motion_state == STATE_ON + delay_running = self._motion_call_cancel is not None + event_on = new_state.state == STATE_ON + + def arm(): + """Arm the timer""" + delay = ( + self._motion_delay_sec + if new_state.state == STATE_ON + else self._motion_off_delay_sec + ) + self._motion_call_cancel = async_call_later( + self.hass, timedelta(seconds=delay), try_motion_condition + ) + + # if I'm off + if not im_on: + if event_on and not delay_running: + _LOGGER.debug( + "%s - Arm delay cause i'm off and event is on and no delay is running", + self, + ) + arm() + return try_motion_condition + # Ignore the event + _LOGGER.debug("%s - Event ignored cause i'm already off", self) + return None + else: # I'm On + if not event_on and not delay_running: + _LOGGER.info("%s - Arm delay cause i'm on and event is off", self) + arm() + return try_motion_condition + if event_on and delay_running: + _LOGGER.debug( + "%s - Desarm off delay cause i'm on and event is on and a delay is running", + self, + ) + self.dearm_motion_timer() + return None + # Ignore the event + _LOGGER.debug("%s - Event ignored cause i'm already on", self) + return None + + async def update_motion( + self, new_state: str = None, recalculate: bool = True + ) -> bool: + """Update the value of the presence sensor and update the VTherm state accordingly + Return true if a change has been made""" + + _LOGGER.info("%s - Updating motion state. New state is %s", self, new_state) + old_motion_state = self._motion_state + if new_state is not None: + self._motion_state = STATE_ON if new_state == STATE_ON else STATE_OFF + + if self._vtherm.preset_mode == PRESET_ACTIVITY: + new_preset = self.get_current_motion_preset() + _LOGGER.info( + "%s - Motion condition have changes. New preset temp will be %s", + self, + new_preset, + ) + # We do not change the preset which is kept to ACTIVITY but only the target_temperature + # We take the presence into account + new_temp = self._vtherm.find_preset_temp(new_preset) + old_temp = self._vtherm.target_temperature + if new_temp != old_temp: + await self._vtherm.change_target_temperature(new_temp) + + if new_temp != old_temp and recalculate: + self._vtherm.recalculate() + await self._vtherm.async_control_heating(force=True) + + return old_motion_state != self._motion_state + + def get_current_motion_preset(self) -> str: + """Calculate and return the current motion preset""" + return ( + self._motion_preset + if self._motion_state == STATE_ON + else self._no_motion_preset + ) + + def add_custom_attributes(self, extra_state_attributes: dict[str, Any]): + """Add some custom attributes""" + extra_state_attributes.update( + { + "motion_sensor_entity_id": self._motion_sensor_entity_id, + "motion_state": self._motion_state, + "is_motion_configured": self._is_configured, + "motion_delay_sec": self._motion_delay_sec, + "motion_off_delay_sec": self._motion_off_delay_sec, + "motion_preset": self._motion_preset, + "no_motion_preset": self._no_motion_preset, + } + ) + + @overrides + @property + def is_configured(self) -> bool: + """Return True of the presence is configured""" + return self._is_configured + + @property + def motion_state(self) -> str | None: + """Return the current presence state STATE_ON or STATE_OFF + or STATE_UNAVAILABLE if not configured""" + if not self._is_configured: + return STATE_UNAVAILABLE + return self._motion_state + + @property + def is_motion_detected(self) -> bool: + """Return true if the presence is configured and presence sensor is OFF""" + return self._is_configured and self._motion_state in [ + STATE_ON, + ] + + @property + def motion_sensor_entity_id(self) -> bool: + """Return true if the presence is configured and presence sensor is OFF""" + return self._motion_sensor_entity_id + + @property + def motion_delay_sec(self) -> bool: + """Return the motion delay""" + return self._motion_delay_sec + + @property + def motion_off_delay_sec(self) -> bool: + """Return motion delay off""" + return self._motion_off_delay_sec + + @property + def motion_preset(self) -> bool: + """Return motion preset""" + return self._motion_preset + + @property + def no_motion_preset(self) -> bool: + """Return no motion preset""" + return self._no_motion_preset + + def __str__(self): + return f"MotionManager-{self.name}" diff --git a/custom_components/versatile_thermostat/feature_power_manager.py b/custom_components/versatile_thermostat/feature_power_manager.py index 243a438..eb173bc 100644 --- a/custom_components/versatile_thermostat/feature_power_manager.py +++ b/custom_components/versatile_thermostat/feature_power_manager.py @@ -35,6 +35,18 @@ _LOGGER = logging.getLogger(__name__) class FeaturePowerManager(BaseFeatureManager): """The implementation of the Power feature""" + unrecorded_attributes = frozenset( + { + "power_sensor_entity_id", + "max_power_sensor_entity_id", + "is_power_configured", + "device_power", + "power_temp", + "current_power", + "current_max_power", + } + ) + def __init__(self, vtherm: Any, hass: HomeAssistant): """Init of a featureManager""" super().__init__(vtherm, hass) @@ -55,7 +67,6 @@ class FeaturePowerManager(BaseFeatureManager): self._power_sensor_entity_id = entry_infos.get(CONF_POWER_SENSOR) self._max_power_sensor_entity_id = entry_infos.get(CONF_MAX_POWER_SENSOR) self._power_temp = entry_infos.get(CONF_PRESET_POWER) - self._overpowering_state = STATE_UNKNOWN self._device_power = entry_infos.get(CONF_DEVICE_POWER) or 0 self._is_configured = False @@ -68,6 +79,7 @@ class FeaturePowerManager(BaseFeatureManager): and self._device_power ): self._is_configured = True + self._overpowering_state = STATE_UNKNOWN else: _LOGGER.info("%s - Power management is not fully configured", self) @@ -197,7 +209,8 @@ class FeaturePowerManager(BaseFeatureManager): "device_power": self._device_power, "power_temp": self._power_temp, "current_power": self._current_power, - "current_power_max": self._current_max_power, + "current_max_power": self._current_max_power, + "mean_cycle_power": self.mean_cycle_power, } ) @@ -261,7 +274,7 @@ class FeaturePowerManager(BaseFeatureManager): "type": "start", "current_power": self._current_power, "device_power": self._device_power, - "current_power_max": self._current_max_power, + "current_max_power": self._current_max_power, "current_power_consumption": power_consumption_max, }, ) @@ -286,7 +299,7 @@ class FeaturePowerManager(BaseFeatureManager): "type": "end", "current_power": self._current_power, "device_power": self._device_power, - "current_power_max": self._current_max_power, + "current_max_power": self._current_max_power, }, ) diff --git a/custom_components/versatile_thermostat/feature_presence_manager.py b/custom_components/versatile_thermostat/feature_presence_manager.py index 89f43d3..5b3ab7d 100644 --- a/custom_components/versatile_thermostat/feature_presence_manager.py +++ b/custom_components/versatile_thermostat/feature_presence_manager.py @@ -41,6 +41,13 @@ _LOGGER = logging.getLogger(__name__) class FeaturePresenceManager(BaseFeatureManager): """The implementation of the Presence feature""" + unrecorded_attributes = frozenset( + { + "presence_sensor_entity_id", + "is_presence_configured", + } + ) + def __init__(self, vtherm: Any, hass: HomeAssistant): """Init of a featureManager""" super().__init__(vtherm, hass) @@ -52,11 +59,12 @@ class FeaturePresenceManager(BaseFeatureManager): def post_init(self, entry_infos: ConfigData): """Reinit of the manager""" self._presence_sensor_entity_id = entry_infos.get(CONF_PRESENCE_SENSOR) - self._is_configured = ( + if ( entry_infos.get(CONF_USE_PRESENCE_FEATURE, False) and self._presence_sensor_entity_id is not None - ) - self._presence_state = STATE_UNKNOWN + ): + self._is_configured = True + self._presence_state = STATE_UNKNOWN @overrides def start_listening(self): diff --git a/custom_components/versatile_thermostat/vtherm_api.py b/custom_components/versatile_thermostat/vtherm_api.py index 3ae8e8f..16f370d 100644 --- a/custom_components/versatile_thermostat/vtherm_api.py +++ b/custom_components/versatile_thermostat/vtherm_api.py @@ -125,15 +125,10 @@ class VersatileThermostatAPI(dict): ): """register the two number entities needed for boiler activation""" self._threshold_number_entity = threshold_number_entity - # If sensor and threshold number are initialized, reload the listener - # if self._nb_active_number_entity and self._central_boiler_entity: - # self._hass.async_add_job(self.reload_central_boiler_binary_listener) def register_nb_device_active_boiler(self, nb_active_number_entity): """register the two number entities needed for boiler activation""" self._nb_active_number_entity = nb_active_number_entity - # if self._threshold_number_entity and self._central_boiler_entity: - # self._hass.async_add_job(self.reload_central_boiler_binary_listener) def register_temperature_number( self, @@ -172,13 +167,6 @@ class VersatileThermostatAPI(dict): ) if component: for entity in component.entities: - # if hasattr(entity, "init_presets"): - # if ( - # only_use_central is False - # or entity.use_central_config_temperature - # ): - # await entity.init_presets(self.find_central_configuration()) - # A little hack to test if the climate is a VTherm. Cannot use isinstance # due to circular dependency of BaseThermostat if ( diff --git a/tests/commons.py b/tests/commons.py index b6ad9ad..5b8a5b0 100644 --- a/tests/commons.py +++ b/tests/commons.py @@ -599,12 +599,7 @@ async def create_thermostat( await hass.config_entries.async_setup(entry.entry_id) assert entry.state is ConfigEntryState.LOADED - # We should reload the VTherm links - # vtherm_api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api() - # central_config = vtherm_api.find_central_configuration() entity = search_entity(hass, entity_id, CLIMATE_DOMAIN) - # if entity and hasattr(entity, "init_presets"):: - # await entity.init_presets(central_config) return entity @@ -839,7 +834,7 @@ async def send_motion_change_event( ), }, ) - ret = await entity._async_motion_changed(motion_event) + ret = await entity.motion_manager._motion_sensor_changed(motion_event) if sleep: await asyncio.sleep(0.1) return ret @@ -1009,7 +1004,7 @@ async def set_climate_preset_temp( await temp_entity.async_set_native_value(temp) else: _LOGGER.warning( - "commons tests set_cliamte_preset_temp: cannot find number entity with entity_id '%s'", + "commons tests set_climate_preset_temp: cannot find number entity with entity_id '%s'", number_entity_id, ) @@ -1071,9 +1066,14 @@ async def set_all_climate_preset_temp( NUMBER_DOMAIN, ) assert temp_entity + if not temp_entity: + raise ConfigurationNotCompleteError( + f"'{number_entity_name}' don't exists as number entity" + ) # Because set_value is not implemented in Number class (really don't understand why...) assert temp_entity.state == value + await hass.async_block_till_done() # # Side effects management diff --git a/tests/test_binary_sensors.py b/tests/test_binary_sensors.py index ee14fcb..ad713ee 100644 --- a/tests/test_binary_sensors.py +++ b/tests/test_binary_sensors.py @@ -310,6 +310,8 @@ async def test_motion_binary_sensors( CONF_SECURITY_MIN_ON_PERCENT: 0.3, CONF_MOTION_SENSOR: "binary_sensor.mock_motion_sensor", CONF_MOTION_DELAY: 0, # important to not been obliged to wait + CONF_MOTION_PRESET: PRESET_BOOST, + CONF_NO_MOTION_PRESET: PRESET_ECO, }, ) @@ -329,7 +331,7 @@ async def test_motion_binary_sensors( await entity.async_set_preset_mode(PRESET_COMFORT) await entity.async_set_hvac_mode(HVACMode.HEAT) await send_temperature_change_event(entity, 15, now) - assert entity.motion_state is None + assert entity.motion_state is STATE_UNKNOWN await motion_binary_sensor.async_my_climate_changed() assert motion_binary_sensor.state is STATE_OFF diff --git a/tests/test_central_config.py b/tests/test_central_config.py index 2af7194..edcedee 100644 --- a/tests/test_central_config.py +++ b/tests/test_central_config.py @@ -286,11 +286,14 @@ async def test_full_over_switch_wo_central_config( assert entity._window_auto_open_threshold == 3 assert entity._window_auto_max_duration == 5 - assert entity._motion_sensor_entity_id == "binary_sensor.mock_motion_sensor" - assert entity._motion_delay_sec == 10 - assert entity._motion_off_delay_sec == 29 - assert entity._motion_preset == "comfort" - assert entity._no_motion_preset == "eco" + assert ( + entity.motion_manager.motion_sensor_entity_id + == "binary_sensor.mock_motion_sensor" + ) + assert entity.motion_manager.motion_delay_sec == 10 + assert entity.motion_manager.motion_off_delay_sec == 29 + assert entity.motion_manager.motion_preset == "comfort" + assert entity.motion_manager.no_motion_preset == "eco" assert entity.power_manager.power_sensor_entity_id == "sensor.mock_power_sensor" assert ( @@ -406,11 +409,14 @@ async def test_full_over_switch_with_central_config( assert entity._window_auto_open_threshold == 4 assert entity._window_auto_max_duration == 31 - assert entity._motion_sensor_entity_id == "binary_sensor.mock_motion_sensor" - assert entity._motion_delay_sec == 31 - assert entity._motion_off_delay_sec == 301 - assert entity._motion_preset == "boost" - assert entity._no_motion_preset == "frost" + assert ( + entity.motion_manager.motion_sensor_entity_id + == "binary_sensor.mock_motion_sensor" + ) + assert entity.motion_manager.motion_delay_sec == 31 + assert entity.motion_manager.motion_off_delay_sec == 301 + assert entity.motion_manager.motion_preset == "boost" + assert entity.motion_manager.no_motion_preset == "frost" assert entity.power_manager.power_sensor_entity_id == "sensor.mock_power_sensor" assert ( diff --git a/tests/test_movement.py b/tests/test_motion.py similarity index 70% rename from tests/test_movement.py rename to tests/test_motion.py index afb36fd..1b73a4b 100644 --- a/tests/test_movement.py +++ b/tests/test_motion.py @@ -3,20 +3,286 @@ """ Test the Window management """ from datetime import datetime, timedelta import logging -from unittest.mock import patch +from unittest.mock import patch, call, AsyncMock, MagicMock, PropertyMock from custom_components.versatile_thermostat.base_thermostat import BaseThermostat +from custom_components.versatile_thermostat.feature_motion_manager import ( + FeatureMotionManager, +) + from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import logging.getLogger().setLevel(logging.DEBUG) +@pytest.mark.parametrize( + "current_state, new_state, temp, nb_call, motion_state, is_motion_detected, preset_refresh, changed", + [ + (STATE_OFF, STATE_ON, 21, 1, STATE_ON, True, PRESET_BOOST, True), + # motion is ON. So is_motion_detected is true and preset is BOOST + (STATE_OFF, STATE_ON, 21, 1, STATE_ON, True, PRESET_BOOST, True), + # current_state is ON and motion is OFF. So is_motion_detected is false and preset is ECO + (STATE_ON, STATE_OFF, 17, 1, STATE_OFF, False, PRESET_ECO, True), + ], +) +async def test_motion_feature_manager_refresh( + hass: HomeAssistant, + current_state, + new_state, # new state of motion event + temp, + nb_call, + motion_state, + is_motion_detected, + preset_refresh, + changed, +): + """Test the FeatureMotionManager class direclty""" + + fake_vtherm = MagicMock(spec=BaseThermostat) + type(fake_vtherm).name = PropertyMock(return_value="the name") + type(fake_vtherm).preset_mode = PropertyMock(return_value=PRESET_ACTIVITY) + + # 1. creation + motion_manager = FeatureMotionManager(fake_vtherm, hass) + + assert motion_manager is not None + assert motion_manager.is_configured is False + assert motion_manager.is_motion_detected is False + assert motion_manager.motion_state == STATE_UNAVAILABLE + assert motion_manager.name == "the name" + + assert len(motion_manager._active_listener) == 0 + + custom_attributes = {} + motion_manager.add_custom_attributes(custom_attributes) + assert custom_attributes["motion_sensor_entity_id"] is None + assert custom_attributes["motion_state"] == STATE_UNAVAILABLE + assert custom_attributes["is_motion_configured"] is False + assert custom_attributes["motion_preset"] is None + assert custom_attributes["no_motion_preset"] is None + assert custom_attributes["motion_delay_sec"] == 0 + assert custom_attributes["motion_off_delay_sec"] == 0 + + # 2. post_init + motion_manager.post_init( + { + CONF_MOTION_SENSOR: "sensor.the_motion_sensor", + CONF_USE_MOTION_FEATURE: True, + CONF_MOTION_DELAY: 10, + CONF_MOTION_OFF_DELAY: 30, + CONF_MOTION_PRESET: PRESET_BOOST, + CONF_NO_MOTION_PRESET: PRESET_ECO, + } + ) + + assert motion_manager.is_configured is True + assert motion_manager.motion_state == STATE_UNKNOWN + assert motion_manager.is_motion_detected is False + + custom_attributes = {} + motion_manager.add_custom_attributes(custom_attributes) + assert custom_attributes["motion_sensor_entity_id"] == "sensor.the_motion_sensor" + assert custom_attributes["motion_state"] == STATE_UNKNOWN + assert custom_attributes["is_motion_configured"] is True + assert custom_attributes["motion_preset"] is PRESET_BOOST + assert custom_attributes["no_motion_preset"] is PRESET_ECO + assert custom_attributes["motion_delay_sec"] == 10 + assert custom_attributes["motion_off_delay_sec"] == 30 + + # 3. start listening + motion_manager.start_listening() + assert motion_manager.is_configured is True + assert motion_manager.motion_state == STATE_UNKNOWN + assert motion_manager.is_motion_detected is False + + assert len(motion_manager._active_listener) == 1 + + # 4. test refresh with the parametrized + # fmt:off + with patch("homeassistant.core.StateMachine.get", return_value=State("sensor.the_motion_sensor", new_state)) as mock_get_state: + # fmt:on + # Configurer les méthodes mockées + fake_vtherm.find_preset_temp.return_value = temp + fake_vtherm.change_target_temperature = AsyncMock() + fake_vtherm.async_control_heating = AsyncMock() + fake_vtherm.recalculate = MagicMock() + + # force old state for the test + motion_manager._motion_state = current_state + + ret = await motion_manager.refresh_state() + assert ret == changed + assert motion_manager.is_configured is True + # in the refresh there is no delay + assert motion_manager.motion_state == new_state + assert motion_manager.is_motion_detected is is_motion_detected + + assert mock_get_state.call_count == 1 + + assert fake_vtherm.find_preset_temp.call_count == nb_call + + if nb_call == 1: + fake_vtherm.find_preset_temp.assert_has_calls( + [ + call.find_preset_temp(preset_refresh), + ] + ) + + assert fake_vtherm.change_target_temperature.call_count == nb_call + fake_vtherm.change_target_temperature.assert_has_calls( + [ + call.find_preset_temp(temp), + ] + ) + + # We do not call control_heating at startup + assert fake_vtherm.recalculate.call_count == 0 + assert fake_vtherm.async_control_heating.call_count == 0 + + fake_vtherm.reset_mock() + + # 5. Check custom_attributes + custom_attributes = {} + motion_manager.add_custom_attributes(custom_attributes) + assert custom_attributes["motion_sensor_entity_id"] == "sensor.the_motion_sensor" + assert custom_attributes["motion_state"] == new_state + assert custom_attributes["is_motion_configured"] is True + assert custom_attributes["motion_preset"] is PRESET_BOOST + assert custom_attributes["no_motion_preset"] is PRESET_ECO + assert custom_attributes["motion_delay_sec"] == 10 + assert custom_attributes["motion_off_delay_sec"] == 30 + + motion_manager.stop_listening() + await hass.async_block_till_done() + + +@pytest.mark.parametrize( + "current_state, long_enough, new_state, temp, nb_call, motion_state, is_motion_detected, preset_event, changed", + [ + (STATE_OFF, True, STATE_ON, 21, 1, STATE_ON, True, PRESET_BOOST, True), + # motion is ON but for not enough time but sensor is on at the end. So is_motion_detected is true and preset is BOOST + (STATE_OFF, False, STATE_ON, 21, 1, STATE_ON, True, PRESET_BOOST, True), + # motion is OFF for enough time. So is_motion_detected is false and preset is ECO + (STATE_ON, True, STATE_OFF, 17, 1, STATE_OFF, False, PRESET_ECO, True), + # motion is OFF for not enough time. So is_motion_detected is false and preset is ECO + (STATE_ON, False, STATE_OFF, 21, 1, STATE_ON, True, PRESET_BOOST, True), + ], +) +async def test_motion_feature_manager_event( + hass: HomeAssistant, + current_state, + long_enough, + new_state, # new state of motion event + temp, + nb_call, + motion_state, + is_motion_detected, + preset_event, + changed, +): + """Test the FeatureMotionManager class direclty""" + + fake_vtherm = MagicMock(spec=BaseThermostat) + type(fake_vtherm).name = PropertyMock(return_value="the name") + type(fake_vtherm).preset_mode = PropertyMock(return_value=PRESET_ACTIVITY) + + # 1. iniitialization creation, post_init, start_listening + motion_manager = FeatureMotionManager(fake_vtherm, hass) + motion_manager.post_init( + { + CONF_MOTION_SENSOR: "sensor.the_motion_sensor", + CONF_USE_MOTION_FEATURE: True, + CONF_MOTION_DELAY: 10, + CONF_MOTION_OFF_DELAY: 30, + CONF_MOTION_PRESET: PRESET_BOOST, + CONF_NO_MOTION_PRESET: PRESET_ECO, + } + ) + motion_manager.start_listening() + + # 2. test _motion_sensor_changed with the parametrized + # fmt: off + with patch("homeassistant.helpers.condition.state", return_value=long_enough), \ + patch("homeassistant.core.StateMachine.get", return_value=State("sensor.the_motion_sensor", new_state)): + # fmt: on + fake_vtherm.find_preset_temp.return_value = temp + fake_vtherm.change_target_temperature = AsyncMock() + fake_vtherm.async_control_heating = AsyncMock() + fake_vtherm.recalculate = MagicMock() + + # force old state for the test + motion_manager._motion_state = current_state + + delay = await motion_manager._motion_sensor_changed( + event=Event( + event_type=EVENT_STATE_CHANGED, + data={ + "entity_id": "sensor.the_motion_sensor", + "new_state": State("sensor.the_motion_sensor", new_state), + "old_state": State("sensor.the_motion_sensor", STATE_UNAVAILABLE), + })) + assert delay is not None + + await delay(None) + assert motion_manager.is_configured is True + assert motion_manager.motion_state == motion_state + assert motion_manager.is_motion_detected is is_motion_detected + + assert fake_vtherm.find_preset_temp.call_count == nb_call + + if nb_call == 1: + fake_vtherm.find_preset_temp.assert_has_calls( + [ + call.find_preset_temp(preset_event), + ] + ) + + assert fake_vtherm.change_target_temperature.call_count == nb_call + fake_vtherm.change_target_temperature.assert_has_calls( + [ + call.find_preset_temp(temp), + ] + ) + + assert fake_vtherm.recalculate.call_count == 1 + assert fake_vtherm.async_control_heating.call_count == 1 + fake_vtherm.async_control_heating.assert_has_calls([ + call.async_control_heating(force=True) + ]) + + fake_vtherm.reset_mock() + + # 3. Check custom_attributes + custom_attributes = {} + motion_manager.add_custom_attributes(custom_attributes) + assert custom_attributes["motion_sensor_entity_id"] == "sensor.the_motion_sensor" + assert custom_attributes["motion_state"] == motion_state + assert custom_attributes["is_motion_configured"] is True + assert custom_attributes["motion_preset"] is PRESET_BOOST + assert custom_attributes["no_motion_preset"] is PRESET_ECO + assert custom_attributes["motion_delay_sec"] == 10 + assert custom_attributes["motion_off_delay_sec"] == 30 + + motion_manager.stop_listening() + await hass.async_block_till_done() + + @pytest.mark.parametrize("expected_lingering_tasks", [True]) @pytest.mark.parametrize("expected_lingering_timers", [True]) -async def test_movement_management_time_not_enough( +async def test_motion_management_time_not_enough( hass: HomeAssistant, skip_hass_states_is_state ): """Test the Presence management when time is not enough""" + temps = { + "frost": 10, + "eco": 17, + "comfort": 18, + "boost": 19, + "frost_away": 10, + "eco_away": 17, + "comfort_away": 18, + "boost_away": 19, + } entry = MockConfigEntry( domain=DOMAIN, @@ -30,17 +296,11 @@ async def test_movement_management_time_not_enough( CONF_CYCLE_MIN: 5, CONF_TEMP_MIN: 15, CONF_TEMP_MAX: 30, - "eco_temp": 17, - "comfort_temp": 18, - "boost_temp": 19, - "eco_away_temp": 17, - "comfort_away_temp": 18, - "boost_away_temp": 19, CONF_USE_WINDOW_FEATURE: False, CONF_USE_MOTION_FEATURE: True, CONF_USE_POWER_FEATURE: False, CONF_USE_PRESENCE_FEATURE: True, - CONF_HEATER: "switch.mock_switch", + CONF_UNDERLYING_LIST: ["switch.mock_switch"], CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI, CONF_TPI_COEF_INT: 0.3, CONF_TPI_COEF_EXT: 0.01, @@ -60,11 +320,12 @@ async def test_movement_management_time_not_enough( hass, entry, "climate.theoverswitchmockname" ) assert entity + await set_all_climate_preset_temp(hass, entity, temps, "theoverswitchmockname") tz = get_tz(hass) # pylint: disable=invalid-name now: datetime = datetime.now(tz=tz) - # start heating, in boost mode, when someone is present. We block the control_heating to avoid running a cycle + # 1. start heating, in boost mode, when someone is present. We block the control_heating to avoid running a cycle with patch( "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.async_control_heating" ): @@ -75,7 +336,7 @@ async def test_movement_management_time_not_enough( assert entity.preset_mode is PRESET_ACTIVITY # because no motion is detected yet assert entity.target_temperature == 18 - assert entity.motion_state is None + assert entity.motion_state is STATE_UNKNOWN assert entity.presence_state is STATE_UNKNOWN event_timestamp = now - timedelta(minutes=5) @@ -83,9 +344,9 @@ async def test_movement_management_time_not_enough( await send_ext_temperature_change_event(entity, 10, event_timestamp) await send_presence_change_event(entity, True, False, event_timestamp) - assert entity.presence_state == "on" + assert entity.presence_state == STATE_ON - # starts detecting motion with time not enough + # 2. starts detecting motion with time not enough with patch( "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event, patch( @@ -104,7 +365,9 @@ async def test_movement_management_time_not_enough( ), ): event_timestamp = now - timedelta(minutes=4) - try_condition = await send_motion_change_event(entity, True, False, event_timestamp) + try_condition = await send_motion_change_event( + entity, True, False, event_timestamp + ) # Will return False -> we will stay on movement False await try_condition(None) @@ -137,7 +400,9 @@ async def test_movement_management_time_not_enough( "homeassistant.helpers.condition.state", return_value=True ) as mock_condition: event_timestamp = now - timedelta(minutes=3) - try_condition = await send_motion_change_event(entity, True, False, event_timestamp) + try_condition = await send_motion_change_event( + entity, True, False, event_timestamp + ) # Will return True -> we will switch to movement On await try_condition(None) @@ -168,7 +433,9 @@ async def test_movement_management_time_not_enough( ), ): event_timestamp = now - timedelta(minutes=2) - try_condition = await send_motion_change_event(entity, False, True, event_timestamp) + try_condition = await send_motion_change_event( + entity, False, True, event_timestamp + ) # Will return False -> we will stay to movement On await try_condition(None) @@ -200,7 +467,9 @@ async def test_movement_management_time_not_enough( "homeassistant.helpers.condition.state", return_value=True ) as mock_condition: event_timestamp = now - timedelta(minutes=1) - try_condition = await send_motion_change_event(entity, False, True, event_timestamp) + try_condition = await send_motion_change_event( + entity, False, True, event_timestamp + ) # Will return True -> we will switch to movement Off await try_condition(None) @@ -221,7 +490,7 @@ async def test_movement_management_time_not_enough( @pytest.mark.parametrize("expected_lingering_tasks", [True]) @pytest.mark.parametrize("expected_lingering_timers", [True]) -async def test_movement_management_time_enough_and_presence( +async def test_motion_management_time_enough_and_presence( hass: HomeAssistant, skip_hass_states_is_state ): """Test the Motion management when time is not enough""" @@ -282,7 +551,7 @@ async def test_movement_management_time_enough_and_presence( assert entity.preset_mode is PRESET_ACTIVITY # because no motion is detected yet assert entity.target_temperature == 18 - assert entity.motion_state is None + assert entity.motion_state is STATE_UNKNOWN assert entity.presence_state is STATE_UNKNOWN event_timestamp = now - timedelta(minutes=4) @@ -312,7 +581,7 @@ async def test_movement_management_time_enough_and_presence( assert entity.preset_mode is PRESET_ACTIVITY # because motion is detected yet -> switch to Boost mode assert entity.target_temperature == 19 - assert entity.motion_state == "on" + assert entity.motion_state == STATE_ON assert entity.presence_state == STATE_ON assert mock_send_event.call_count == 0 # Change is confirmed. Heater should be started @@ -340,7 +609,7 @@ async def test_movement_management_time_enough_and_presence( assert entity.preset_mode is PRESET_ACTIVITY # because no motion is detected yet assert entity.target_temperature == 18 - assert entity.motion_state == "off" + assert entity.motion_state == STATE_OFF assert entity.presence_state == STATE_ON assert mock_send_event.call_count == 0 @@ -352,7 +621,7 @@ async def test_movement_management_time_enough_and_presence( @pytest.mark.parametrize("expected_lingering_tasks", [True]) @pytest.mark.parametrize("expected_lingering_timers", [True]) -async def test_movement_management_time_enoughand_not_presence( +async def test_motion_management_time_enough_and_not_presence( hass: HomeAssistant, skip_hass_states_is_state ): """Test the Presence management when time is not enough""" @@ -413,7 +682,7 @@ async def test_movement_management_time_enoughand_not_presence( assert entity.preset_mode is PRESET_ACTIVITY # because no motion is detected yet and presence is unknown assert entity.target_temperature == 18 - assert entity.motion_state is None + assert entity.motion_state is STATE_UNKNOWN assert entity.presence_state is STATE_UNKNOWN event_timestamp = now - timedelta(minutes=4) @@ -421,7 +690,7 @@ async def test_movement_management_time_enoughand_not_presence( await send_ext_temperature_change_event(entity, 10, event_timestamp) await send_presence_change_event(entity, False, True, event_timestamp) - assert entity.presence_state == "off" + assert entity.presence_state == STATE_OFF # starts detecting motion with patch( @@ -443,7 +712,7 @@ async def test_movement_management_time_enoughand_not_presence( assert entity.preset_mode is PRESET_ACTIVITY # because motion is detected yet -> switch to Boost away mode assert entity.target_temperature == 19.1 - assert entity.motion_state == "on" + assert entity.motion_state == STATE_ON assert entity.presence_state == STATE_OFF assert mock_send_event.call_count == 0 @@ -472,7 +741,7 @@ async def test_movement_management_time_enoughand_not_presence( assert entity.preset_mode is PRESET_ACTIVITY # because no motion is detected yet assert entity.target_temperature == 18.1 - assert entity.motion_state == "off" + assert entity.motion_state == STATE_OFF assert entity.presence_state == STATE_OFF assert mock_send_event.call_count == 0 # 18.1 starts heating with a low on_percent @@ -484,7 +753,7 @@ async def test_movement_management_time_enoughand_not_presence( @pytest.mark.parametrize("expected_lingering_tasks", [True]) @pytest.mark.parametrize("expected_lingering_timers", [True]) -async def test_movement_management_with_stop_during_condition( +async def test_motion_management_with_stop_during_condition( hass: HomeAssistant, skip_hass_states_is_state ): """Test the Motion management when the movement sensor switch to off and then to on during the test condition""" @@ -546,7 +815,7 @@ async def test_movement_management_with_stop_during_condition( assert entity.preset_mode is PRESET_ACTIVITY # because no motion is detected yet assert entity.target_temperature == 18 - assert entity.motion_state is None + assert entity.motion_state is STATE_UNKNOWN assert entity.presence_state is STATE_UNKNOWN event_timestamp = now - timedelta(minutes=6) @@ -554,7 +823,7 @@ async def test_movement_management_with_stop_during_condition( await send_ext_temperature_change_event(entity, 10, event_timestamp) await send_presence_change_event(entity, False, True, event_timestamp) - assert entity.presence_state == "off" + assert entity.presence_state == STATE_OFF # starts detecting motion with patch( @@ -580,7 +849,7 @@ async def test_movement_management_with_stop_during_condition( assert entity.preset_mode is PRESET_ACTIVITY # because motion is detected yet -> switch to Boost mode assert entity.target_temperature == 18 - assert entity.motion_state is None + assert entity.motion_state is STATE_UNKNOWN assert entity.presence_state == STATE_OFF # Send a stop detection event_timestamp = now - timedelta(minutes=4) @@ -592,7 +861,7 @@ async def test_movement_management_with_stop_during_condition( assert entity.hvac_mode is HVACMode.HEAT assert entity.preset_mode is PRESET_ACTIVITY assert entity.target_temperature == 18 - assert entity.motion_state is None + assert entity.motion_state is STATE_UNKNOWN assert entity.presence_state == STATE_OFF # Resend a start detection @@ -608,19 +877,19 @@ async def test_movement_management_with_stop_during_condition( assert entity.preset_mode is PRESET_ACTIVITY # still no motion detected assert entity.target_temperature == 18 - assert entity.motion_state is None + assert entity.motion_state is STATE_UNKNOWN assert entity.presence_state == STATE_OFF await try_condition1(None) # We should have switch this time assert entity.target_temperature == 19 # Boost - assert entity.motion_state == "on" # switch to movement on + assert entity.motion_state == STATE_ON # switch to movement on assert entity.presence_state == STATE_OFF # Non change @pytest.mark.parametrize("expected_lingering_tasks", [True]) @pytest.mark.parametrize("expected_lingering_timers", [True]) -async def test_movement_management_with_stop_during_condition_last_state_on( +async def test_motion_management_with_stop_during_condition_last_state_on( hass: HomeAssistant, skip_hass_states_is_state ): """Test the Motion management when the movement sensor switch to off and then to on during the test condition""" @@ -681,7 +950,7 @@ async def test_movement_management_with_stop_during_condition_last_state_on( assert entity.preset_mode is PRESET_ACTIVITY # because no motion is detected yet assert entity.target_temperature == 18 - assert entity.motion_state is None + assert entity.motion_state is STATE_UNKNOWN event_timestamp = now - timedelta(minutes=6) await send_temperature_change_event(entity, 18, event_timestamp) diff --git a/tests/test_multiple_switch.py b/tests/test_multiple_switch.py index 169de68..8e11724 100644 --- a/tests/test_multiple_switch.py +++ b/tests/test_multiple_switch.py @@ -814,7 +814,7 @@ async def test_multiple_switch_power_management( "type": "start", "current_power": 50, "device_power": 100, - "current_power_max": 74, + "current_max_power": 74, "current_power_consumption": 25.0, }, ), diff --git a/tests/test_overclimate_valve.py b/tests/test_overclimate_valve.py index 18bd5d0..57d28f1 100644 --- a/tests/test_overclimate_valve.py +++ b/tests/test_overclimate_valve.py @@ -113,7 +113,7 @@ async def test_over_climate_valve_mono(hass: HomeAssistant, skip_hass_states_get assert vtherm.preset_mode is PRESET_NONE assert vtherm._security_state is False assert vtherm._window_state is None - assert vtherm._motion_state is None + assert vtherm.motion_state is STATE_UNAVAILABLE assert vtherm.presence_state is STATE_UNAVAILABLE assert vtherm.is_device_active is False diff --git a/tests/test_power.py b/tests/test_power.py index 2e21256..366ea5b 100644 --- a/tests/test_power.py +++ b/tests/test_power.py @@ -77,7 +77,7 @@ async def test_power_feature_manager( assert custom_attributes["device_power"] is 0 assert custom_attributes["power_temp"] is None assert custom_attributes["current_power"] is None - assert custom_attributes["current_power_max"] is None + assert custom_attributes["current_max_power"] is None # 2. post_init power_manager.post_init( @@ -104,7 +104,7 @@ async def test_power_feature_manager( assert custom_attributes["device_power"] == 1234 assert custom_attributes["power_temp"] == 10 assert custom_attributes["current_power"] is None - assert custom_attributes["current_power_max"] is None + assert custom_attributes["current_max_power"] is None # 3. start listening power_manager.start_listening() @@ -183,7 +183,7 @@ async def test_power_feature_manager( [ call.fake_vtherm.send_event( EventType.POWER_EVENT, - {'type': 'end', 'current_power': power, 'device_power': 1234, 'current_power_max': max_power}), + {'type': 'end', 'current_power': power, 'device_power': 1234, 'current_max_power': max_power}), ] ) @@ -214,7 +214,7 @@ async def test_power_feature_manager( [ call.fake_vtherm.send_event( EventType.POWER_EVENT, - {'type': 'start', 'current_power': power, 'device_power': 1234, 'current_power_max': max_power, 'current_power_consumption': 1234.0}), + {'type': 'start', 'current_power': power, 'device_power': 1234, 'current_max_power': max_power, 'current_power_consumption': 1234.0}), ] ) @@ -232,7 +232,10 @@ async def test_power_feature_manager( assert custom_attributes["device_power"] == 1234 assert custom_attributes["power_temp"] == 10 assert custom_attributes["current_power"] == power - assert custom_attributes["current_power_max"] == max_power + assert custom_attributes["current_max_power"] == max_power + + power_manager.stop_listening() + await hass.async_block_till_done() @pytest.mark.parametrize("expected_lingering_tasks", [True]) @@ -410,7 +413,7 @@ async def test_power_management_hvac_on(hass: HomeAssistant, skip_hass_states_is "type": "start", "current_power": 50, "device_power": 100, - "current_power_max": 149, + "current_max_power": 149, "current_power_consumption": 100.0, }, ), @@ -445,7 +448,7 @@ async def test_power_management_hvac_on(hass: HomeAssistant, skip_hass_states_is "type": "end", "current_power": 48, "device_power": 100, - "current_power_max": 149, + "current_max_power": 149, }, ), ], diff --git a/tests/test_presence.py b/tests/test_presence.py index 3adb43e..c376c0d 100644 --- a/tests/test_presence.py +++ b/tests/test_presence.py @@ -173,3 +173,6 @@ async def test_presence_feature_manager( assert custom_attributes["presence_sensor_entity_id"] == "sensor.the_presence_sensor" assert custom_attributes["presence_state"] == presence_state assert custom_attributes["is_presence_configured"] is True + + presence_manager.stop_listening() + await hass.async_block_till_done() diff --git a/tests/test_start.py b/tests/test_start.py index bc2d5d4..d9c6175 100644 --- a/tests/test_start.py +++ b/tests/test_start.py @@ -55,7 +55,7 @@ async def test_over_switch_full_start(hass: HomeAssistant, skip_hass_states_is_s assert entity.preset_mode is PRESET_NONE assert entity._security_state is False assert entity._window_state is None - assert entity._motion_state is None + assert entity.motion_state is STATE_UNKNOWN assert entity.presence_state is STATE_UNKNOWN assert entity._prop_algorithm is not None assert entity.have_valve_regulation is False @@ -114,7 +114,7 @@ async def test_over_climate_full_start(hass: HomeAssistant, skip_hass_states_is_ assert entity.preset_mode is PRESET_NONE assert entity._security_state is False assert entity._window_state is None - assert entity._motion_state is None + assert entity.motion_state is STATE_UNAVAILABLE assert entity.presence_state is STATE_UNAVAILABLE assert entity.have_valve_regulation is False @@ -151,18 +151,6 @@ async def test_over_4switch_full_start(hass: HomeAssistant, skip_hass_states_is_ "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event: entity = await create_thermostat(hass, entry, "climate.theover4switchmockname") - # entry.add_to_hass(hass) - # await hass.config_entries.async_setup(entry.entry_id) - # assert entry.state is ConfigEntryState.LOADED - # - # def find_my_entity(entity_id) -> ClimateEntity: - # """Find my new entity""" - # component: EntityComponent[ClimateEntity] = hass.data[CLIMATE_DOMAIN] - # for entity in component.entities: - # if entity.entity_id == entity_id: - # return entity - # - # entity: BaseThermostat = find_my_entity("climate.theover4switchmockname") assert entity @@ -182,7 +170,7 @@ async def test_over_4switch_full_start(hass: HomeAssistant, skip_hass_states_is_ assert entity.preset_mode is PRESET_NONE assert entity._security_state is False assert entity._window_state is None - assert entity._motion_state is None + assert entity.motion_state is STATE_UNKNOWN assert entity.presence_state is STATE_UNKNOWN assert entity._prop_algorithm is not None diff --git a/tests/test_switch_ac.py b/tests/test_switch_ac.py index e87a266..a5a11ae 100644 --- a/tests/test_switch_ac.py +++ b/tests/test_switch_ac.py @@ -91,7 +91,7 @@ async def test_over_switch_ac_full_start( assert entity.preset_mode is PRESET_NONE assert entity._security_state is False # pylint: disable=protected-access assert entity._window_state is None # pylint: disable=protected-access - assert entity._motion_state is None # pylint: disable=protected-access + assert entity.motion_state is STATE_UNKNOWN # pylint: disable=protected-access assert entity.presence_state is STATE_UNKNOWN assert entity._prop_algorithm is not None # pylint: disable=protected-access diff --git a/tests/test_valve.py b/tests/test_valve.py index 1a2a3b3..5cfa843 100644 --- a/tests/test_valve.py +++ b/tests/test_valve.py @@ -100,7 +100,7 @@ async def test_over_valve_full_start( assert entity.preset_mode is PRESET_NONE assert entity._security_state is False # pylint: disable=protected-access assert entity._window_state is None # pylint: disable=protected-access - assert entity._motion_state is None # pylint: disable=protected-access + assert entity.motion_state is STATE_UNKNOWN # pylint: disable=protected-access assert entity.presence_state is STATE_UNKNOWN assert entity._prop_algorithm is not None # pylint: disable=protected-access assert entity.have_valve_regulation is False