diff --git a/custom_components/versatile_thermostat/base_entity.py b/custom_components/versatile_thermostat/base_entity.py new file mode 100644 index 0000000..34f8d3d --- /dev/null +++ b/custom_components/versatile_thermostat/base_entity.py @@ -0,0 +1,118 @@ +""" A base class for all VTherm entities""" + +import logging +from datetime import timedelta +from homeassistant.core import HomeAssistant, callback, Event +from homeassistant.components.climate import ClimateEntity, DOMAIN as CLIMATE_DOMAIN +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.device_registry import DeviceInfo, DeviceEntryType +from homeassistant.helpers.event import async_track_state_change_event, async_call_later + + +from .const import DOMAIN, DEVICE_MANUFACTURER + +from .base_thermostat import BaseThermostat + +_LOGGER = logging.getLogger(__name__) + + +class VersatileThermostatBaseEntity(Entity): + """A base class for all entities""" + + _my_climate: BaseThermostat + hass: HomeAssistant + _config_id: str + _device_name: str + + def __init__(self, hass: HomeAssistant, config_id, device_name) -> None: + """The CTOR""" + self.hass = hass + self._config_id = config_id + self._device_name = device_name + self._my_climate = None + self._cancel_call = None + self._attr_has_entity_name = True + + @property + def should_poll(self) -> bool: + """Do not poll for those entities""" + return False + + @property + def my_climate(self) -> BaseThermostat | None: + """Returns my climate if found""" + if not self._my_climate: + self._my_climate = self.find_my_versatile_thermostat() + if self._my_climate: + # Only the first time + self.my_climate_is_initialized() + return self._my_climate + + @property + def device_info(self) -> DeviceInfo: + """Return the device info.""" + return DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, self._config_id)}, + name=self._device_name, + manufacturer=DEVICE_MANUFACTURER, + model=DOMAIN, + ) + + def find_my_versatile_thermostat(self) -> BaseThermostat: + """Find the underlying climate entity""" + try: + component: EntityComponent[ClimateEntity] = self.hass.data[CLIMATE_DOMAIN] + for entity in component.entities: + # _LOGGER.debug("Device_info is %s", entity.device_info) + if entity.device_info == self.device_info: + _LOGGER.debug("Found %s!", entity) + return entity + except KeyError: + pass + + return None + + @callback + async def async_added_to_hass(self): + """Listen to my climate state change""" + + # Check delay condition + async def try_find_climate(_): + _LOGGER.debug( + "%s - Calling VersatileThermostatBaseEntity.async_added_to_hass", self + ) + mcl = self.my_climate + if mcl: + if self._cancel_call: + self._cancel_call() + self._cancel_call = None + self.async_on_remove( + async_track_state_change_event( + self.hass, + [mcl.entity_id], + self.async_my_climate_changed, + ) + ) + else: + _LOGGER.debug("%s - no entity to listen. Try later", self) + self._cancel_call = async_call_later( + self.hass, timedelta(seconds=1), try_find_climate + ) + + await try_find_climate(None) + + @callback + def my_climate_is_initialized(self): + """Called when the associated climate is initialized""" + return + + @callback + async def async_my_climate_changed( + self, event: Event + ): # pylint: disable=unused-argument + """Called when my climate have change + This method aims to be overriden to take the status change + """ + return diff --git a/custom_components/versatile_thermostat/base_manager.py b/custom_components/versatile_thermostat/base_manager.py new file mode 100644 index 0000000..a2c6975 --- /dev/null +++ b/custom_components/versatile_thermostat/base_manager.py @@ -0,0 +1,58 @@ +""" Implements a base Feature Manager for Versatile Thermostat """ + +import logging +from typing import Any + +from homeassistant.core import CALLBACK_TYPE, HomeAssistant + +from .const import * # pylint: disable=wildcard-import, unused-wildcard-import +from .commons import ConfigData + +from .config_schema import * # pylint: disable=wildcard-import, unused-wildcard-import + +_LOGGER = logging.getLogger(__name__) + + +class BaseFeatureManager: + """A base class for all feature""" + + def __init__(self, vtherm: Any, hass: HomeAssistant): + """Init of a featureManager""" + self._vtherm = vtherm + self._name = vtherm.name + self._active_listener: list[CALLBACK_TYPE] = [] + self._hass = hass + + def post_init(self, entry_infos: ConfigData): + """Initialize the attributes of the FeatureManager""" + raise NotImplementedError() + + def start_listening(self): + """Start listening the underlying entity""" + raise NotImplementedError() + + def stop_listening(self) -> bool: + """stop listening to the sensor""" + while self._active_listener: + self._active_listener.pop()() + + self._active_listener = [] + + def add_listener(self, func: CALLBACK_TYPE) -> None: + """Add a listener to the list of active listener""" + self._active_listener.append(func) + + @property + def is_configured(self) -> bool: + """True if the FeatureManager is fully configured""" + raise NotImplementedError() + + @property + def name(self) -> str: + """The name""" + return self._name + + @property + def hass(self) -> HomeAssistant: + """The HA instance""" + return self._hass diff --git a/custom_components/versatile_thermostat/base_thermostat.py b/custom_components/versatile_thermostat/base_thermostat.py index 25b3e08..1fd2368 100644 --- a/custom_components/versatile_thermostat/base_thermostat.py +++ b/custom_components/versatile_thermostat/base_thermostat.py @@ -4,11 +4,9 @@ """ Implements the VersatileThermostat climate component """ import math import logging +from typing import Any, Generic from datetime import timedelta, datetime -from types import MappingProxyType -from typing import Any, TypeVar, Generic - from homeassistant.core import ( HomeAssistant, callback, @@ -60,11 +58,10 @@ from homeassistant.const import ( STATE_UNKNOWN, STATE_OFF, STATE_ON, - STATE_HOME, - STATE_NOT_HOME, ) from .const import * # pylint: disable=wildcard-import, unused-wildcard-import +from .commons import ConfigData, T from .config_schema import * # pylint: disable=wildcard-import, unused-wildcard-import @@ -75,9 +72,9 @@ from .prop_algorithm import PropAlgorithm from .open_window_algorithm import WindowOpenDetectionAlgorithm from .ema import ExponentialMovingAverage +from .presence_manager import FeaturePresenceManager + _LOGGER = logging.getLogger(__name__) -ConfigData = MappingProxyType[str, Any] -T = TypeVar("T", bound=UnderlyingEntity) class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): """Representation of a base class for all Versatile Thermostat device.""" @@ -187,7 +184,6 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): self._last_ext_temperature_measure = None self._last_temperature_measure = None self._cur_ext_temp = None - self._presence_state = None self._overpowering_state = None self._should_relaunch_control_heating = None @@ -247,6 +243,10 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): self._hvac_off_reason: HVAC_OFF_REASONS | None = None + self._presence_manager: FeaturePresenceManager = FeaturePresenceManager( + self, hass + ) + self.post_init(entry_infos) def clean_central_config_doublon( @@ -311,6 +311,8 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): self._entry_infos = entry_infos + self._presence_manager.post_init(entry_infos) + self._use_central_config_temperature = entry_infos.get( CONF_USE_PRESETS_CENTRAL_CONFIG ) or ( @@ -386,14 +388,8 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): self._tpi_coef_int = entry_infos.get(CONF_TPI_COEF_INT) self._tpi_coef_ext = entry_infos.get(CONF_TPI_COEF_EXT) - self._presence_sensor_entity_id = entry_infos.get(CONF_PRESENCE_SENSOR) self._power_temp = entry_infos.get(CONF_PRESET_POWER) - self._presence_on = ( - entry_infos.get(CONF_USE_PRESENCE_FEATURE, False) - and self._presence_sensor_entity_id is not None - ) - if self._ac_mode: # Added by https://github.com/jmcollin78/versatile_thermostat/pull/144 # Some over_switch can do both heating and cooling @@ -473,7 +469,6 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): self._motion_state = None self._window_state = None self._overpowering_state = None - self._presence_state = None self._total_energy = None _LOGGER.debug("%s - post_init_ resetting energy to None", self) @@ -580,14 +575,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): ) ) - if self._presence_on: - self.async_on_remove( - async_track_state_change_event( - self.hass, - [self._presence_sensor_entity_id], - self._async_presence_changed, - ) - ) + self._presence_manager.start_listening() self.async_on_remove(self.remove_thermostat) @@ -606,6 +594,8 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): """Called when the thermostat will be removed""" _LOGGER.info("%s - Removing thermostat", self) + self._presence_manager.stop_listening() + for under in self._underlyings: under.remove_entity() @@ -725,20 +715,8 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): await self._async_update_motion_temp() need_write_state = True - if self._presence_on: - # try to acquire presence entity state - presence_state = self.hass.states.get(self._presence_sensor_entity_id) - if presence_state and presence_state.state not in ( - STATE_UNAVAILABLE, - STATE_UNKNOWN, - ): - await self._async_update_presence(presence_state.state) - _LOGGER.debug( - "%s - Presence have been retrieved: %s", - self, - presence_state.state, - ) - need_write_state = True + if await self._presence_manager.refresh_state(): + need_write_state = True if need_write_state: self.async_write_ha_state() @@ -776,16 +754,16 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): # If we have a previously saved temperature if old_state.attributes.get(ATTR_TEMPERATURE) is None: if self._ac_mode: - await self._async_internal_set_temperature(self.max_temp) + await self.change_target_temperature(self.max_temp) else: - await self._async_internal_set_temperature(self.min_temp) + await self.change_target_temperature(self.min_temp) _LOGGER.warning( "%s - Undefined target temperature, falling back to %s", self, self._target_temp, ) else: - await self._async_internal_set_temperature( + await self.change_target_temperature( float(old_state.attributes[ATTR_TEMPERATURE]) ) @@ -827,9 +805,9 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): # No previous state, try and restore defaults if self._target_temp is None: if self._ac_mode: - await self._async_internal_set_temperature(self.max_temp) + await self.change_target_temperature(self.max_temp) else: - await self._async_internal_set_temperature(self.min_temp) + await self.change_target_temperature(self.min_temp) _LOGGER.warning( "No previously saved temperature, setting to %s", self._target_temp ) @@ -1075,14 +1053,14 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): return self._security_state @property - def motion_state(self) -> bool | None: + def motion_state(self) -> str | None: """Get the motion_state""" return self._motion_state @property - def presence_state(self) -> bool | None: + def presence_state(self) -> str | None: """Get the presence_state""" - return self._presence_state + return self._presence_manager.presence_state @property def proportional_algorithm(self) -> PropAlgorithm | None: @@ -1330,7 +1308,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): if preset_mode == PRESET_NONE: self._attr_preset_mode = PRESET_NONE if self._saved_target_temp: - await self._async_internal_set_temperature(self._saved_target_temp) + 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() @@ -1340,9 +1318,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): self._attr_preset_mode = preset_mode # Switch the temperature if window is not 'on' if self.window_state != STATE_ON: - await self._async_internal_set_temperature( - self.find_preset_temp(preset_mode) - ) + await self.change_target_temperature(self.find_preset_temp(preset_mode)) else: # Window is on, so we just save the new expected temp # so that closing the window will restore it @@ -1409,7 +1385,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): ) if motion_preset in self._presets: - if self._presence_on and self.presence_state in [STATE_OFF, None]: + if self._presence_manager.is_absence_detected: return self._presets_away[motion_preset + PRESET_AWAY_SUFFIX] else: return self._presets[motion_preset] @@ -1423,13 +1399,12 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): _LOGGER.info("%s - find preset temp: %s", self, preset_mode) temp_val = self._presets.get(preset_mode, 0) - if not self._presence_on or self._presence_state in [ - None, - STATE_ON, - STATE_HOME, - ]: - return temp_val - else: + # if not self._presence_on or self._presence_state in [ + # None, + # STATE_ON, + # STATE_HOME, + # ]: + if self._presence_manager.is_absence_detected: # We should return the preset_away temp val but if # preset temp is 0, that means the user don't want to use # the preset so we return 0, even if there is a value is preset_away @@ -1438,6 +1413,8 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): if temp_val > 0 else temp_val ) + else: + return temp_val def get_preset_away_name(self, preset_mode: str) -> str: """Get the preset name in away mode (when presence is off)""" @@ -1467,14 +1444,14 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): self._attr_preset_mode = PRESET_NONE if self.window_state != STATE_ON: - await self._async_internal_set_temperature(temperature) + await self.change_target_temperature(temperature) self.recalculate() self.reset_last_change_time_from_vtherm() await self.async_control_heating(force=True) else: self._saved_target_temp = temperature - async def _async_internal_set_temperature(self, temperature: float): + async def change_target_temperature(self, temperature: float): """Set the target temperature and the target temperature of underlying climate if any""" if temperature: self._target_temp = temperature @@ -1710,7 +1687,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): # We do not change the preset which is kept to ACTIVITY but only the target_temperature # We take the presence into account - await self._async_internal_set_temperature( + await self.change_target_temperature( self.find_preset_temp(new_preset) ) self.recalculate() @@ -1900,59 +1877,6 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): except ValueError as ex: _LOGGER.error("Unable to update current_power from sensor: %s", ex) - @callback - async def _async_presence_changed(self, event: Event[EventStateChangedData]): - """Handle presence changes.""" - new_state = event.data.get("new_state") - _LOGGER.info( - "%s - Presence 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: - return - - await self._async_update_presence(new_state.state) - await self.async_control_heating(force=True) - - async def _async_update_presence(self, new_state: str): - _LOGGER.info("%s - Updating presence. New state is %s", self, new_state) - self._presence_state = ( - STATE_ON if new_state in (STATE_ON, STATE_HOME) else STATE_OFF - ) - if self._attr_preset_mode in HIDDEN_PRESETS or self._presence_on is False: - _LOGGER.info( - "%s - Ignoring presence change cause in Power or Security preset or presence not configured", - self, - ) - return - if new_state is None or new_state not in ( - STATE_OFF, - STATE_ON, - STATE_HOME, - STATE_NOT_HOME, - ): - return - if self._attr_preset_mode not in [ - PRESET_BOOST, - PRESET_COMFORT, - PRESET_ECO, - PRESET_ACTIVITY, - ]: - return - - new_temp = self.find_preset_temp(self.preset_mode) - if new_temp is not None: - _LOGGER.debug( - "%s - presence change in temperature mode new_temp will be: %.2f", - self, - new_temp, - ) - await self._async_internal_set_temperature(new_temp) - self.recalculate() - async def _async_update_motion_temp(self): """Update the temperature considering the ACTIVITY preset and current motion state""" _LOGGER.debug( @@ -1980,9 +1904,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): # We do not change the preset which is kept to ACTIVITY but only the target_temperature # We take the presence into account - await self._async_internal_set_temperature( - self.find_preset_temp(new_preset) - ) + await self.change_target_temperature(self.find_preset_temp(new_preset)) _LOGGER.debug( "%s - regarding motion, target_temp have been set to %.2f", @@ -2496,7 +2418,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): self._saved_target_temp, ) if self._window_action in [CONF_WINDOW_FROST_TEMP, CONF_WINDOW_ECO_TEMP]: - await self._async_internal_set_temperature(self._saved_target_temp) + await self.change_target_temperature(self._saved_target_temp) # default to TURN_OFF elif self._window_action in [CONF_WINDOW_TURN_OFF]: @@ -2544,16 +2466,14 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): self._window_action == CONF_WINDOW_FROST_TEMP and self._presets.get(PRESET_FROST_PROTECTION) is not None ): - await self._async_internal_set_temperature( + await self.change_target_temperature( self.find_preset_temp(PRESET_FROST_PROTECTION) ) elif ( self._window_action == CONF_WINDOW_ECO_TEMP and self._presets.get(PRESET_ECO) is not None ): - await self._async_internal_set_temperature( - self.find_preset_temp(PRESET_ECO) - ) + await self.change_target_temperature(self.find_preset_temp(PRESET_ECO)) else: # default is to turn_off self.set_hvac_off_reason(HVAC_OFF_REASON_WINDOW_DETECTION) await self.async_set_hvac_mode(HVACMode.OFF) @@ -2664,8 +2584,6 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): "power_sensor_entity_id": self._power_sensor_entity_id, "max_power_sensor_entity_id": self._max_power_sensor_entity_id, "overpowering_state": self.overpowering_state, - "presence_sensor_entity_id": self._presence_sensor_entity_id, - "presence_state": self._presence_state, "window_state": self.window_state, "window_auto_state": self.window_auto_state, "window_bypass_state": self._window_bypass_state, @@ -2711,20 +2629,11 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): ), } - _LOGGER.debug( - "%s - update_custom_attributes saved energy is %s", - self, - self.total_energy, - ) + self._presence_manager.add_custom_attributes(self._attr_extra_state_attributes) @overrides def async_write_ha_state(self): """overrides to have log""" - _LOGGER.debug( - "%s - async_write_ha_state written state energy is %s", - self, - self._total_energy, - ) return super().async_write_ha_state() @property @@ -2748,7 +2657,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): entity_id: climate.thermostat_1 """ _LOGGER.info("%s - Calling service_set_presence, presence: %s", self, presence) - await self._async_update_presence(presence) + await self._presence_manager.update_presence(presence) await self.async_control_heating(force=True) async def service_set_preset_temperature( @@ -2776,7 +2685,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): if preset in self._presets: if temperature is not None: self._presets[preset] = temperature - if self._presence_on and temperature_away is not None: + if self._presence_manager.is_configured and temperature_away is not None: self._presets_away[self.get_preset_away_name(preset)] = temperature_away else: _LOGGER.warning( @@ -2899,7 +2808,9 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): CONF_USE_PRESETS_CENTRAL_CONFIG, ) - if self._entry_infos.get(CONF_USE_PRESENCE_FEATURE) is True: + # refacto + # if self._entry_infos.get(CONF_USE_PRESENCE_FEATURE) is True: + if self._presence_manager.is_configured: presets_away = calculate_presets( ( CONF_PRESETS_AWAY_WITH_AC.items() diff --git a/custom_components/versatile_thermostat/binary_sensor.py b/custom_components/versatile_thermostat/binary_sensor.py index 36a1e06..2c6c04f 100644 --- a/custom_components/versatile_thermostat/binary_sensor.py +++ b/custom_components/versatile_thermostat/binary_sensor.py @@ -25,10 +25,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.entity_platform import AddEntitiesCallback from .vtherm_api import VersatileThermostatAPI -from .commons import ( - VersatileThermostatBaseEntity, - check_and_extract_service_configuration, -) +from .commons import check_and_extract_service_configuration +from .base_entity import VersatileThermostatBaseEntity from .const import ( DOMAIN, DEVICE_MANUFACTURER, diff --git a/custom_components/versatile_thermostat/commons.py b/custom_components/versatile_thermostat/commons.py index 3e768ed..6018d01 100644 --- a/custom_components/versatile_thermostat/commons.py +++ b/custom_components/versatile_thermostat/commons.py @@ -3,17 +3,14 @@ # pylint: disable=line-too-long import logging -from datetime import timedelta -from homeassistant.core import HomeAssistant, callback, Event -from homeassistant.components.climate import ClimateEntity, DOMAIN as CLIMATE_DOMAIN -from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.entity import Entity -from homeassistant.helpers.device_registry import DeviceInfo, DeviceEntryType -from homeassistant.helpers.event import async_track_state_change_event, async_call_later +from types import MappingProxyType +from typing import Any, TypeVar +from .const import ServiceConfigurationError +from .underlyings import UnderlyingEntity -from .base_thermostat import BaseThermostat -from .const import DOMAIN, DEVICE_MANUFACTURER, ServiceConfigurationError +ConfigData = MappingProxyType[str, Any] +T = TypeVar("T", bound=UnderlyingEntity) _LOGGER = logging.getLogger(__name__) @@ -135,104 +132,3 @@ def check_and_extract_service_configuration(service_config) -> dict: "check_and_extract_service_configuration(%s) gives '%s'", service_config, ret ) return ret - - -class VersatileThermostatBaseEntity(Entity): - """A base class for all entities""" - - _my_climate: BaseThermostat - hass: HomeAssistant - _config_id: str - _device_name: str - - def __init__(self, hass: HomeAssistant, config_id, device_name) -> None: - """The CTOR""" - self.hass = hass - self._config_id = config_id - self._device_name = device_name - self._my_climate = None - self._cancel_call = None - self._attr_has_entity_name = True - - @property - def should_poll(self) -> bool: - """Do not poll for those entities""" - return False - - @property - def my_climate(self) -> BaseThermostat | None: - """Returns my climate if found""" - if not self._my_climate: - self._my_climate = self.find_my_versatile_thermostat() - if self._my_climate: - # Only the first time - self.my_climate_is_initialized() - return self._my_climate - - @property - def device_info(self) -> DeviceInfo: - """Return the device info.""" - return DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, self._config_id)}, - name=self._device_name, - manufacturer=DEVICE_MANUFACTURER, - model=DOMAIN, - ) - - def find_my_versatile_thermostat(self) -> BaseThermostat: - """Find the underlying climate entity""" - try: - component: EntityComponent[ClimateEntity] = self.hass.data[CLIMATE_DOMAIN] - for entity in component.entities: - # _LOGGER.debug("Device_info is %s", entity.device_info) - if entity.device_info == self.device_info: - _LOGGER.debug("Found %s!", entity) - return entity - except KeyError: - pass - - return None - - @callback - async def async_added_to_hass(self): - """Listen to my climate state change""" - - # Check delay condition - async def try_find_climate(_): - _LOGGER.debug( - "%s - Calling VersatileThermostatBaseEntity.async_added_to_hass", self - ) - mcl = self.my_climate - if mcl: - if self._cancel_call: - self._cancel_call() - self._cancel_call = None - self.async_on_remove( - async_track_state_change_event( - self.hass, - [mcl.entity_id], - self.async_my_climate_changed, - ) - ) - else: - _LOGGER.debug("%s - no entity to listen. Try later", self) - self._cancel_call = async_call_later( - self.hass, timedelta(seconds=1), try_find_climate - ) - - await try_find_climate(None) - - @callback - def my_climate_is_initialized(self): - """Called when the associated climate is initialized""" - return - - @callback - async def async_my_climate_changed( - self, event: Event - ): # pylint: disable=unused-argument - """Called when my climate have change - This method aims to be overriden to take the status change - """ - return diff --git a/custom_components/versatile_thermostat/const.py b/custom_components/versatile_thermostat/const.py index 52cfecf..e0b3360 100644 --- a/custom_components/versatile_thermostat/const.py +++ b/custom_components/versatile_thermostat/const.py @@ -4,6 +4,7 @@ import logging import math from typing import Literal + from datetime import datetime from enum import Enum diff --git a/custom_components/versatile_thermostat/number.py b/custom_components/versatile_thermostat/number.py index af9b169..e155fae 100644 --- a/custom_components/versatile_thermostat/number.py +++ b/custom_components/versatile_thermostat/number.py @@ -28,7 +28,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import slugify from .vtherm_api import VersatileThermostatAPI -from .commons import VersatileThermostatBaseEntity +from .base_entity import VersatileThermostatBaseEntity from .const import ( DOMAIN, diff --git a/custom_components/versatile_thermostat/presence_manager.py b/custom_components/versatile_thermostat/presence_manager.py new file mode 100644 index 0000000..27d1bae --- /dev/null +++ b/custom_components/versatile_thermostat/presence_manager.py @@ -0,0 +1,189 @@ +""" Implements the Presence Feature Manager """ + +# pylint: disable=line-too-long + +import logging +from typing import Any + +from homeassistant.const import ( + STATE_ON, + STATE_OFF, + STATE_HOME, + STATE_NOT_HOME, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) +from homeassistant.core import ( + HomeAssistant, + callback, + Event, +) +from homeassistant.helpers.event import ( + async_track_state_change_event, + EventStateChangedData, +) + +from homeassistant.components.climate import ( + PRESET_ACTIVITY, + PRESET_BOOST, + PRESET_COMFORT, + PRESET_ECO, +) + +from .const import * # pylint: disable=wildcard-import, unused-wildcard-import +from .commons import ConfigData + +from .base_manager import BaseFeatureManager + +_LOGGER = logging.getLogger(__name__) + + +class FeaturePresenceManager(BaseFeatureManager): + """A base class for all feature""" + + def __init__(self, vtherm: Any, hass: HomeAssistant): + """Init of a featureManager""" + super().__init__(vtherm, hass) + self._presence_state: str = STATE_UNAVAILABLE + self._presence_sensor_entity_id: str = None + self._is_configured: bool = False + + @overrides + 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 = ( + entry_infos.get(CONF_USE_PRESENCE_FEATURE, False) + and self._presence_sensor_entity_id is not None + ) + self._presence_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._presence_sensor_entity_id], + self._presence_sensor_changed, + ) + ) + + @overrides + async def refresh_state(self) -> bool: + """Tries to get the last state from sensor + Returns True if a change has been made""" + ret = False + if self._is_configured: + # try to acquire presence entity state + presence_state = self.hass.states.get(self._presence_sensor_entity_id) + if presence_state and presence_state.state not in ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + ret = await self.update_presence(presence_state.state) + _LOGGER.debug( + "%s - Presence have been retrieved: %s", + self, + presence_state.state, + ) + return ret + + @callback + async def _presence_sensor_changed(self, event: Event[EventStateChangedData]): + """Handle presence changes.""" + new_state = event.data.get("new_state") + _LOGGER.info( + "%s - Presence 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: + return + + if await self.update_presence(new_state.state): + await self._vtherm.async_control_heating(force=True) + + async def update_presence(self, new_state: str) -> 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 presence. New state is %s", self, new_state) + old_presence_state = self._presence_state + self._presence_state = ( + STATE_ON if new_state in (STATE_ON, STATE_HOME) else STATE_OFF + ) + if self._vtherm.preset_mode in HIDDEN_PRESETS or self._is_configured is False: + _LOGGER.info( + "%s - Ignoring presence change cause in Power or Security preset or presence not configured", + self, + ) + return old_presence_state != self._presence_state + + if new_state is None or new_state not in ( + STATE_OFF, + STATE_ON, + STATE_HOME, + STATE_NOT_HOME, + ): + self._presence_state = STATE_UNKNOWN + return old_presence_state != self._presence_state + + if self._vtherm.preset_mode not in [ + PRESET_BOOST, + PRESET_COMFORT, + PRESET_ECO, + PRESET_ACTIVITY, + ]: + return old_presence_state != self._presence_state + + new_temp = self._vtherm.find_preset_temp(self._vtherm.preset_mode) + if new_temp is not None: + _LOGGER.debug( + "%s - presence change in temperature mode new_temp will be: %.2f", + self, + new_temp, + ) + await self._vtherm.change_target_temperature(new_temp) + self._vtherm.recalculate() + + return True + + return old_presence_state != self._presence_state + + def add_custom_attributes(self, extra_state_attributes: dict[str, Any]): + """Add some custom attributes""" + extra_state_attributes.update( + { + "presence_sensor_entity_id": self._presence_sensor_entity_id, + "presence_state": self._presence_state, + "presence_configured": self._is_configured, + } + ) + + @overrides + @property + def is_configured(self) -> bool: + """Return True of the presence is configured""" + return self._is_configured + + @property + def presence_state(self) -> str | None: + """Return the current presence state STATE_ON or STATE_OFF + or STATE_UNAVAILABLE if not configured""" + return self._presence_state + + @property + def is_absence_detected(self) -> bool: + """Return true if the presence is configured and presence sensor is OFF""" + return self._is_configured and self._presence_state in [ + STATE_NOT_HOME, + STATE_OFF, + ] + + def __str__(self): + return f"PresenceManager-{self.name}" diff --git a/custom_components/versatile_thermostat/sensor.py b/custom_components/versatile_thermostat/sensor.py index 23d624b..8d3b656 100644 --- a/custom_components/versatile_thermostat/sensor.py +++ b/custom_components/versatile_thermostat/sensor.py @@ -35,7 +35,7 @@ from homeassistant.components.climate import ( from .base_thermostat import BaseThermostat from .vtherm_api import VersatileThermostatAPI -from .commons import VersatileThermostatBaseEntity +from .base_entity import VersatileThermostatBaseEntity from .const import ( DOMAIN, DEVICE_MANUFACTURER, diff --git a/custom_components/versatile_thermostat/switch.py b/custom_components/versatile_thermostat/switch.py index 754eaef..6b49705 100644 --- a/custom_components/versatile_thermostat/switch.py +++ b/custom_components/versatile_thermostat/switch.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .commons import VersatileThermostatBaseEntity +from .base_entity import VersatileThermostatBaseEntity from .const import * # pylint: disable=unused-wildcard-import,wildcard-import diff --git a/custom_components/versatile_thermostat/thermostat_climate.py b/custom_components/versatile_thermostat/thermostat_climate.py index 92938e9..8f1e0cb 100644 --- a/custom_components/versatile_thermostat/thermostat_climate.py +++ b/custom_components/versatile_thermostat/thermostat_climate.py @@ -178,9 +178,9 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]): return self.calculate_hvac_action(self._underlyings) @overrides - async def _async_internal_set_temperature(self, temperature: float): + async def change_target_temperature(self, temperature: float): """Set the target temperature and the target temperature of underlying climate if any""" - await super()._async_internal_set_temperature(temperature) + await super().change_target_temperature(temperature) self._regulation_algo.set_target_temp(self.target_temperature) # Is necessary cause control_heating method will not force the update. @@ -593,7 +593,7 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]): _LOGGER.info( "%s - Force resent target temp cause we turn on some over climate" ) - await self._async_internal_set_temperature(self._target_temp) + await self.change_target_temperature(self._target_temp) @overrides def incremente_energy(self): diff --git a/tests/commons.py b/tests/commons.py index 7477036..03d26ca 100644 --- a/tests/commons.py +++ b/tests/commons.py @@ -1,4 +1,4 @@ -# pylint: disable=wildcard-import, unused-wildcard-import, protected-access, unused-argument, line-too-long, abstract-method, too-many-lines, redefined-builtin +# pylint: disable=wildcard-import, unused-wildcard-import, unused-import, protected-access, unused-argument, line-too-long, abstract-method, too-many-lines, redefined-builtin """ Some common resources """ import asyncio @@ -8,7 +8,16 @@ from unittest.mock import patch, MagicMock # pylint: disable=unused-import import pytest # pylint: disable=unused-import from homeassistant.core import HomeAssistant, Event, EVENT_STATE_CHANGED, State -from homeassistant.const import UnitOfTemperature, STATE_ON, STATE_OFF, ATTR_TEMPERATURE +from homeassistant.const import ( + UnitOfTemperature, + STATE_ON, + STATE_OFF, + ATTR_TEMPERATURE, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + STATE_HOME, + STATE_NOT_HOME, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.helpers.entity import Entity @@ -837,7 +846,7 @@ async def send_motion_change_event( async def send_presence_change_event( - entity: BaseThermostat, new_state: bool, old_state: bool, date, sleep=True + vtherm: BaseThermostat, new_state: bool, old_state: bool, date, sleep=True ): """Sending a new presence event simulating a change on the window state""" _LOGGER.info( @@ -845,26 +854,26 @@ async def send_presence_change_event( new_state, old_state, date, - entity, + vtherm, ) presence_event = Event( EVENT_STATE_CHANGED, { "new_state": State( - entity_id=entity.entity_id, + entity_id=vtherm.entity_id, state=STATE_ON if new_state else STATE_OFF, last_changed=date, last_updated=date, ), "old_state": State( - entity_id=entity.entity_id, + entity_id=vtherm.entity_id, state=STATE_ON if old_state else STATE_OFF, last_changed=date, last_updated=date, ), }, ) - ret = await entity._async_presence_changed(presence_event) + ret = await vtherm._presence_manager._presence_sensor_changed(presence_event) if sleep: await asyncio.sleep(0.1) return ret diff --git a/tests/test_presence.py b/tests/test_presence.py new file mode 100644 index 0000000..65a432d --- /dev/null +++ b/tests/test_presence.py @@ -0,0 +1,175 @@ +# pylint: disable=wildcard-import, unused-wildcard-import, protected-access, unused-argument, line-too-long + +""" Test the Security featrure """ +import logging +from unittest.mock import patch, call, AsyncMock, MagicMock, PropertyMock + +# from datetime import timedelta, datetime + +from custom_components.versatile_thermostat.base_thermostat import BaseThermostat +from custom_components.versatile_thermostat.presence_manager import ( + FeaturePresenceManager, +) + +from .commons import * + +logging.getLogger().setLevel(logging.DEBUG) + + +@pytest.mark.parametrize( + "temp, absence, state, nb_call, presence_state, changed", + [ + (19, False, STATE_ON, 1, STATE_ON, True), + (17, True, STATE_OFF, 1, STATE_OFF, True), + (19, False, STATE_HOME, 1, STATE_ON, True), + (17, True, STATE_NOT_HOME, 1, STATE_OFF, True), + (17, False, STATE_UNAVAILABLE, 0, STATE_UNKNOWN, False), + (17, False, STATE_UNKNOWN, 0, STATE_UNKNOWN, False), + (17, False, "wrong state", 0, STATE_UNKNOWN, False), + ], +) +async def test_presence_feature_manager( + hass: HomeAssistant, temp, absence, state, nb_call, presence_state, changed +): + """Test the FeaturePresenceManager 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_COMFORT) + + # 1. creation + presence_manager = FeaturePresenceManager(fake_vtherm, hass) + + assert presence_manager is not None + assert presence_manager.is_configured is False + assert presence_manager.is_absence_detected is False + assert presence_manager.presence_state == STATE_UNAVAILABLE + assert presence_manager.name == "the name" + + assert len(presence_manager._active_listener) == 0 + + custom_attributes = {} + presence_manager.add_custom_attributes(custom_attributes) + assert custom_attributes["presence_sensor_entity_id"] is None + assert custom_attributes["presence_state"] == STATE_UNAVAILABLE + assert custom_attributes["presence_configured"] is False + + # 2. post_init + presence_manager.post_init( + { + CONF_PRESENCE_SENSOR: "sensor.the_presence_sensor", + CONF_USE_PRESENCE_FEATURE: True, + } + ) + + assert presence_manager.is_configured is True + assert presence_manager.presence_state == STATE_UNKNOWN + assert presence_manager.is_absence_detected is False + + custom_attributes = {} + presence_manager.add_custom_attributes(custom_attributes) + assert ( + custom_attributes["presence_sensor_entity_id"] == "sensor.the_presence_sensor" + ) + assert custom_attributes["presence_state"] == STATE_UNKNOWN + assert custom_attributes["presence_configured"] is True + + # 3. start listening + presence_manager.start_listening() + assert presence_manager.is_configured is True + assert presence_manager.presence_state == STATE_UNKNOWN + assert presence_manager.is_absence_detected is False + + assert len(presence_manager._active_listener) == 1 + + # 4. test refresh with the parametrized + # fmt:off + with patch("homeassistant.core.StateMachine.get", return_value=State("sensor.the_presence_sensor", 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() + + ret = await presence_manager.refresh_state() + assert ret == changed + assert presence_manager.is_configured is True + assert presence_manager.presence_state == presence_state + assert presence_manager.is_absence_detected is absence + + 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_COMFORT), + ] + ) + + 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.async_control_heating.call_count == 0 + + fake_vtherm.reset_mock() + + # 5. Check custom_attributes + custom_attributes = {} + presence_manager.add_custom_attributes(custom_attributes) + assert custom_attributes["presence_sensor_entity_id"] == "sensor.the_presence_sensor" + assert custom_attributes["presence_state"] == presence_state + assert custom_attributes["presence_configured"] is True + + # 6. test _presence_sensor_changed with the parametrized + fake_vtherm.find_preset_temp.return_value = temp + fake_vtherm.change_target_temperature = AsyncMock() + fake_vtherm.async_control_heating = AsyncMock() + + await presence_manager._presence_sensor_changed( + event=Event( + event_type=EVENT_STATE_CHANGED, + data={ + "entity_id": "sensor.the_presence_sensor", + "new_state": State("sensor.the_presence_sensor", state), + "old_state": State("sensor.the_presence_sensor", STATE_UNAVAILABLE), + })) + assert ret == changed + assert presence_manager.is_configured is True + assert presence_manager.presence_state == presence_state + assert presence_manager.is_absence_detected is absence + + 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_COMFORT), + ] + ) + + 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.async_control_heating.call_count == 1 + fake_vtherm.async_control_heating.assert_has_calls([ + call.async_control_heating(force=True) + ]) + + fake_vtherm.reset_mock() + + # 7. Check custom_attributes + custom_attributes = {} + presence_manager.add_custom_attributes(custom_attributes) + assert custom_attributes["presence_sensor_entity_id"] == "sensor.the_presence_sensor" + assert custom_attributes["presence_state"] == presence_state + assert custom_attributes["presence_configured"] is True