diff --git a/.devcontainer/configuration.yaml b/.devcontainer/configuration.yaml index 03da98c..d92ef20 100644 --- a/.devcontainer/configuration.yaml +++ b/.devcontainer/configuration.yaml @@ -169,3 +169,12 @@ switch: option: comfort-2 target: entity_id: select.seche_serviettes_sdb_rdc_cable_outlet_mode + +frontend: + themes: + versatile_thermostat_theme: + state-binary_sensor-safety-on-color: "#FF0B0B" + state-binary_sensor-power-on-color: "#FF0B0B" + state-binary_sensor-window-on-color: "rgb(156, 39, 176)" + state-binary_sensor-motion-on-color: "rgb(156, 39, 176)" + state-binary_sensor-presence-on-color: "lightgreen" diff --git a/custom_components/versatile_thermostat/__init__.py b/custom_components/versatile_thermostat/__init__.py index f56663a..e99a36c 100644 --- a/custom_components/versatile_thermostat/__init__.py +++ b/custom_components/versatile_thermostat/__init__.py @@ -99,7 +99,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry): if config_entry.version == 1: new = {**config_entry.data} - # TODO: modify Config Entry data + # TO DO: modify Config Entry data if there will be something to migrate config_entry.version = 2 hass.config_entries.async_update_entry(config_entry, data=new) diff --git a/custom_components/versatile_thermostat/binary_sensor.py b/custom_components/versatile_thermostat/binary_sensor.py index d393f67..3383992 100644 --- a/custom_components/versatile_thermostat/binary_sensor.py +++ b/custom_components/versatile_thermostat/binary_sensor.py @@ -5,7 +5,10 @@ from homeassistant.core import HomeAssistant, callback, Event from homeassistant.const import STATE_ON -from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorDeviceClass, +) from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -56,17 +59,25 @@ class SecurityBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity): super().__init__(hass, unique_id, entry_infos.get(CONF_NAME)) self._attr_name = "Security state" self._attr_unique_id = f"{self._device_name}_security_state" + self._attr_is_on = False @callback - async def async_my_climate_changed(self, event: Event): + async def async_my_climate_changed(self, event: Event = None): """Called when my climate have change""" - _LOGGER.debug("%s - climate state change", event.origin.name) + _LOGGER.debug( + "%s - climate state change", + event.origin.name if event and event.origin else None, + ) old_state = self._attr_is_on - self._attr_is_on = self.my_climate.security_state + self._attr_is_on = self.my_climate.security_state is True if old_state != self._attr_is_on: self.async_write_ha_state() return + @property + def device_class(self) -> BinarySensorDeviceClass | None: + return BinarySensorDeviceClass.SAFETY + @property def icon(self) -> str | None: if self._attr_is_on: @@ -83,17 +94,25 @@ class OverpoweringBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity super().__init__(hass, unique_id, entry_infos.get(CONF_NAME)) self._attr_name = "Overpowering state" self._attr_unique_id = f"{self._device_name}_overpowering_state" + self._attr_is_on = False @callback - async def async_my_climate_changed(self, event: Event): + async def async_my_climate_changed(self, event: Event = None): """Called when my climate have change""" - _LOGGER.debug("%s - climate state change", event.origin.name) + _LOGGER.debug( + "%s - climate state change", + event.origin.name if event and event.origin else None, + ) old_state = self._attr_is_on - self._attr_is_on = self.my_climate.overpowering_state + self._attr_is_on = self.my_climate.overpowering_state is True if old_state != self._attr_is_on: self.async_write_ha_state() return + @property + def device_class(self) -> BinarySensorDeviceClass | None: + return BinarySensorDeviceClass.POWER + @property def icon(self) -> str | None: if self._attr_is_on: @@ -110,17 +129,25 @@ class WindowBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity): super().__init__(hass, unique_id, entry_infos.get(CONF_NAME)) self._attr_name = "Window state" self._attr_unique_id = f"{self._device_name}_window_state" + self._attr_is_on = False @callback - async def async_my_climate_changed(self, event: Event): + async def async_my_climate_changed(self, event: Event = None): """Called when my climate have change""" - _LOGGER.debug("%s - climate state change", event.origin.name) + _LOGGER.debug( + "%s - climate state change", + event.origin.name if event and event.origin else None, + ) old_state = self._attr_is_on self._attr_is_on = self.my_climate.window_state == STATE_ON if old_state != self._attr_is_on: self.async_write_ha_state() return + @property + def device_class(self) -> BinarySensorDeviceClass | None: + return BinarySensorDeviceClass.WINDOW + @property def icon(self) -> str | None: if self._attr_is_on: @@ -137,17 +164,25 @@ class MotionBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity): super().__init__(hass, unique_id, entry_infos.get(CONF_NAME)) self._attr_name = "Motion state" self._attr_unique_id = f"{self._device_name}_motion_state" + self._attr_is_on = False @callback - async def async_my_climate_changed(self, event: Event): + async def async_my_climate_changed(self, event: Event = None): """Called when my climate have change""" - _LOGGER.debug("%s - climate state change", event.origin.name) + _LOGGER.debug( + "%s - climate state change", + event.origin.name if event and event.origin else None, + ) old_state = self._attr_is_on self._attr_is_on = self.my_climate.motion_state == STATE_ON if old_state != self._attr_is_on: self.async_write_ha_state() return + @property + def device_class(self) -> BinarySensorDeviceClass | None: + return BinarySensorDeviceClass.MOTION + @property def icon(self) -> str | None: if self._attr_is_on: @@ -164,18 +199,26 @@ class PresenceBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity): super().__init__(hass, unique_id, entry_infos.get(CONF_NAME)) self._attr_name = "Presence state" self._attr_unique_id = f"{self._device_name}_presence_state" + self._attr_is_on = False @callback - async def async_my_climate_changed(self, event: Event): + async def async_my_climate_changed(self, event: Event = None): """Called when my climate have change""" - _LOGGER.debug("%s - climate state change", event.origin.name) + _LOGGER.debug( + "%s - climate state change", + event.origin.name if event and event.origin else None, + ) old_state = self._attr_is_on self._attr_is_on = self.my_climate.presence_state == STATE_ON if old_state != self._attr_is_on: self.async_write_ha_state() return + @property + def device_class(self) -> BinarySensorDeviceClass | None: + return BinarySensorDeviceClass.PRESENCE + @property def icon(self) -> str | None: if self._attr_is_on: diff --git a/custom_components/versatile_thermostat/climate.py b/custom_components/versatile_thermostat/climate.py index 83a79ab..1723356 100644 --- a/custom_components/versatile_thermostat/climate.py +++ b/custom_components/versatile_thermostat/climate.py @@ -357,7 +357,7 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): self._presence_on = self._presence_sensor_entity_id is not None - # TODO if self.ac_mode: + # if self.ac_mode: -> MODE_COOL should be better to use thermostat_over_climate type # self.hvac_list = [HVAC_MODE_COOL, HVAC_MODE_OFF] # else: self._hvac_list = [HVACMode.HEAT, HVACMode.OFF] @@ -1118,6 +1118,12 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): """ return self._attr_preset_modes + @property + def is_over_climate(self) -> bool | None: + """return True is the thermostat is over a climate + or False is over switch""" + return self._is_over_climate + async def async_set_hvac_mode(self, hvac_mode): """Set new target hvac mode.""" _LOGGER.info("%s - Set hvac mode: %s", self, hvac_mode) @@ -1488,6 +1494,9 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): self.hass, timedelta(seconds=self._motion_delay_sec), try_motion_condition ) + # For testing purpose we need to access the inner function + return try_motion_condition + @callback async def _check_switch_initial_state(self): """Prevent the device from keep running if HVAC_MODE_OFF.""" @@ -1551,7 +1560,7 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): self._hvac_mode = new_state.state # Interpretation of hvac - HVAC_ACTION_ON = [ + HVAC_ACTION_ON = [ # pylint: disable=invalid-name HVACAction.COOLING, HVACAction.DRYING, HVACAction.FAN, @@ -2126,7 +2135,10 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): if self._hvac_mode == HVACMode.HEAT and on_time_sec > 0: async def _turn_on_off_later( - on: bool, time, heater_action, next_cycle_action + on: bool, # pylint: disable=invalid-name + time, + heater_action, + next_cycle_action, ): if self._async_cancel_cycle: self._async_cancel_cycle() diff --git a/custom_components/versatile_thermostat/config_flow.py b/custom_components/versatile_thermostat/config_flow.py index 30ff05c..a21da09 100644 --- a/custom_components/versatile_thermostat/config_flow.py +++ b/custom_components/versatile_thermostat/config_flow.py @@ -180,7 +180,7 @@ class VersatileThermostatBaseConfigFlow(FlowHandler): is_empty or self._infos.get(CONF_PRESENCE_SENSOR) is not None ) - self.STEP_USER_DATA_SCHEMA = vol.Schema( + self.STEP_USER_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name { vol.Required(CONF_NAME): cv.string, vol.Required( @@ -212,7 +212,7 @@ class VersatileThermostatBaseConfigFlow(FlowHandler): } ) - self.STEP_THERMOSTAT_SWITCH = vol.Schema( + self.STEP_THERMOSTAT_SWITCH = vol.Schema( # pylint: disable=invalid-name { vol.Required(CONF_HEATER): selector.EntitySelector( selector.EntitySelectorConfig( @@ -229,7 +229,7 @@ class VersatileThermostatBaseConfigFlow(FlowHandler): } ) - self.STEP_THERMOSTAT_CLIMATE = vol.Schema( + self.STEP_THERMOSTAT_CLIMATE = vol.Schema( # pylint: disable=invalid-name { vol.Required(CONF_CLIMATE): selector.EntitySelector( selector.EntitySelectorConfig(domain=CLIMATE_DOMAIN), @@ -237,21 +237,21 @@ class VersatileThermostatBaseConfigFlow(FlowHandler): } ) - self.STEP_TPI_DATA_SCHEMA = vol.Schema( + self.STEP_TPI_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name { vol.Required(CONF_TPI_COEF_INT, default=0.6): vol.Coerce(float), vol.Required(CONF_TPI_COEF_EXT, default=0.01): vol.Coerce(float), } ) - self.STEP_PRESETS_DATA_SCHEMA = vol.Schema( + self.STEP_PRESETS_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name { vol.Optional(v, default=0.0): vol.Coerce(float) for (k, v) in CONF_PRESETS.items() } ) - self.STEP_WINDOW_DATA_SCHEMA = vol.Schema( + self.STEP_WINDOW_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name { vol.Optional(CONF_WINDOW_SENSOR): selector.EntitySelector( selector.EntitySelectorConfig( @@ -262,7 +262,7 @@ class VersatileThermostatBaseConfigFlow(FlowHandler): } ) - self.STEP_MOTION_DATA_SCHEMA = vol.Schema( + self.STEP_MOTION_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name { vol.Optional(CONF_MOTION_SENSOR): selector.EntitySelector( selector.EntitySelectorConfig( @@ -279,7 +279,7 @@ class VersatileThermostatBaseConfigFlow(FlowHandler): } ) - self.STEP_POWER_DATA_SCHEMA = vol.Schema( + self.STEP_POWER_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name { vol.Optional(CONF_POWER_SENSOR): selector.EntitySelector( selector.EntitySelectorConfig( @@ -295,7 +295,7 @@ class VersatileThermostatBaseConfigFlow(FlowHandler): } ) - self.STEP_PRESENCE_DATA_SCHEMA = vol.Schema( + self.STEP_PRESENCE_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name { vol.Optional(CONF_PRESENCE_SENSOR): selector.EntitySelector( selector.EntitySelectorConfig( @@ -314,7 +314,7 @@ class VersatileThermostatBaseConfigFlow(FlowHandler): } ) - self.STEP_ADVANCED_DATA_SCHEMA = vol.Schema( + self.STEP_ADVANCED_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name { vol.Required( CONF_MINIMAL_ACTIVATION_DELAY, default=10 diff --git a/custom_components/versatile_thermostat/const.py b/custom_components/versatile_thermostat/const.py index 2d4f46f..3038752 100644 --- a/custom_components/versatile_thermostat/const.py +++ b/custom_components/versatile_thermostat/const.py @@ -13,13 +13,13 @@ from homeassistant.components.climate import ( from homeassistant.exceptions import HomeAssistantError -DEVICE_MANUFACTURER = "JMCOLLIN" -DEVICE_MODEL = "Versatile Thermostat" - from .prop_algorithm import ( PROPORTIONAL_FUNCTION_TPI, ) +DEVICE_MANUFACTURER = "JMCOLLIN" +DEVICE_MODEL = "Versatile Thermostat" + PRESET_POWER = "power" PRESET_SECURITY = "security" diff --git a/custom_components/versatile_thermostat/sensor.py b/custom_components/versatile_thermostat/sensor.py index de400af..670474e 100644 --- a/custom_components/versatile_thermostat/sensor.py +++ b/custom_components/versatile_thermostat/sensor.py @@ -70,9 +70,12 @@ class EnergySensor(VersatileThermostatBaseEntity, SensorEntity): self._attr_unique_id = f"{self._device_name}_energy" @callback - async def async_my_climate_changed(self, event: Event): + async def async_my_climate_changed(self, event: Event = None): """Called when my climate have change""" - _LOGGER.debug("%s - climate state change", event.origin.name) + _LOGGER.debug( + "%s - climate state change", + event.origin.name if event and event.origin else None, + ) if math.isnan(self.my_climate.total_energy) or math.isinf( self.my_climate.total_energy @@ -125,9 +128,12 @@ class MeanPowerSensor(VersatileThermostatBaseEntity, SensorEntity): self._attr_unique_id = f"{self._device_name}_mean_power_cycle" @callback - async def async_my_climate_changed(self, event: Event): + async def async_my_climate_changed(self, event: Event = None): """Called when my climate have change""" - _LOGGER.debug("%s - climate state change", event.origin.name) + _LOGGER.debug( + "%s - climate state change", + event.origin.name if event and event.origin else None, + ) if math.isnan(float(self.my_climate.mean_cycle_power)) or math.isinf( self.my_climate.mean_cycle_power @@ -182,9 +188,12 @@ class OnPercentSensor(VersatileThermostatBaseEntity, SensorEntity): self._attr_unique_id = f"{self._device_name}_power_percent" @callback - async def async_my_climate_changed(self, event: Event): + async def async_my_climate_changed(self, event: Event = None): """Called when my climate have change""" - _LOGGER.debug("%s - climate state change", event.origin.name) + _LOGGER.debug( + "%s - climate state change", + event.origin.name if event and event.origin else None, + ) on_percent = ( float(self.my_climate.proportional_algorithm.on_percent) @@ -234,9 +243,12 @@ class OnTimeSensor(VersatileThermostatBaseEntity, SensorEntity): self._attr_unique_id = f"{self._device_name}_on_time" @callback - async def async_my_climate_changed(self, event: Event): + async def async_my_climate_changed(self, event: Event = None): """Called when my climate have change""" - _LOGGER.debug("%s - climate state change", event.origin.name) + _LOGGER.debug( + "%s - climate state change", + event.origin.name if event and event.origin else None, + ) on_time = ( float(self.my_climate.proportional_algorithm.on_time_sec) @@ -279,9 +291,12 @@ class OffTimeSensor(VersatileThermostatBaseEntity, SensorEntity): self._attr_unique_id = f"{self._device_name}_off_time" @callback - async def async_my_climate_changed(self, event: Event): + async def async_my_climate_changed(self, event: Event = None): """Called when my climate have change""" - _LOGGER.debug("%s - climate state change", event.origin.name) + _LOGGER.debug( + "%s - climate state change", + event.origin.name if event and event.origin else None, + ) off_time = ( float(self.my_climate.proportional_algorithm.off_time_sec) @@ -324,9 +339,12 @@ class LastTemperatureSensor(VersatileThermostatBaseEntity, SensorEntity): self._attr_unique_id = f"{self._device_name}_last_temp_datetime" @callback - async def async_my_climate_changed(self, event: Event): + async def async_my_climate_changed(self, event: Event = None): """Called when my climate have change""" - _LOGGER.debug("%s - climate state change", event.origin.name) + _LOGGER.debug( + "%s - climate state change", + event.origin.name if event and event.origin else None, + ) old_state = self._attr_native_value self._attr_native_value = self.my_climate.last_temperature_mesure @@ -353,9 +371,12 @@ class LastExtTemperatureSensor(VersatileThermostatBaseEntity, SensorEntity): self._attr_unique_id = f"{self._device_name}_last_ext_temp_datetime" @callback - async def async_my_climate_changed(self, event: Event): + async def async_my_climate_changed(self, event: Event = None): """Called when my climate have change""" - _LOGGER.debug("%s - climate state change", event.origin.name) + _LOGGER.debug( + "%s - climate state change", + event.origin.name if event and event.origin else None, + ) old_state = self._attr_native_value self._attr_native_value = self.my_climate.last_ext_temperature_mesure diff --git a/custom_components/versatile_thermostat/tests/commons.py b/custom_components/versatile_thermostat/tests/commons.py index 4c8a246..c663d09 100644 --- a/custom_components/versatile_thermostat/tests/commons.py +++ b/custom_components/versatile_thermostat/tests/commons.py @@ -1,5 +1,4 @@ """ Some common resources """ -from typing import Mapping from unittest.mock import patch, MagicMock from homeassistant.core import HomeAssistant, Event, EVENT_STATE_CHANGED, State @@ -7,22 +6,21 @@ from homeassistant.const import UnitOfTemperature, STATE_ON, STATE_OFF from homeassistant.config_entries import ConfigEntryState from homeassistant.util import dt as dt_util -from homeassistant.helpers.entity_component import EntityComponent -from pytest_homeassistant_custom_component.common import MockConfigEntry - -from ..climate import VersatileThermostat -from ..const import * - +from homeassistant.helpers.entity import Entity from homeassistant.components.climate import ( ClimateEntity, DOMAIN as CLIMATE_DOMAIN, - ATTR_PRESET_MODE, HVACMode, HVACAction, ClimateEntityFeature, ) -from .const import ( +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from ..climate import VersatileThermostat +from ..const import * # pylint: disable=wildcard-import, unused-wildcard-import + +from .const import ( # pylint: disable=unused-import MOCK_TH_OVER_SWITCH_USER_CONFIG, MOCK_TH_OVER_CLIMATE_USER_CONFIG, MOCK_TH_OVER_SWITCH_TYPE_CONFIG, @@ -81,60 +79,74 @@ class MockClimate(ClimateEntity): class MagicMockClimate(MagicMock): + """A Magic Mock class for a underlying climate entity""" + @property - def temperature_unit(self): + def temperature_unit(self): # pylint: disable=missing-function-docstring return UnitOfTemperature.CELSIUS @property - def hvac_mode(self): + def hvac_mode(self): # pylint: disable=missing-function-docstring return HVACMode.HEAT @property - def hvac_action(self): + def hvac_action(self): # pylint: disable=missing-function-docstring return HVACAction.IDLE @property - def target_temperature(self): + def target_temperature(self): # pylint: disable=missing-function-docstring return 15 @property - def current_temperature(self): + def current_temperature(self): # pylint: disable=missing-function-docstring return 14 @property - def target_temperature_step(self) -> float | None: + def target_temperature_step( # pylint: disable=missing-function-docstring + self, + ) -> float | None: return 0.5 @property - def target_temperature_high(self) -> float | None: + def target_temperature_high( # pylint: disable=missing-function-docstring + self, + ) -> float | None: return 35 @property - def target_temperature_low(self) -> float | None: + def target_temperature_low( # pylint: disable=missing-function-docstring + self, + ) -> float | None: return 7 @property - def hvac_modes(self) -> list[str] | None: + def hvac_modes( # pylint: disable=missing-function-docstring + self, + ) -> list[str] | None: return [HVACMode.HEAT, HVACMode.OFF, HVACMode.COOL] @property - def fan_modes(self) -> list[str] | None: + def fan_modes( # pylint: disable=missing-function-docstring + self, + ) -> list[str] | None: return None @property - def swing_modes(self) -> list[str] | None: + def swing_modes( # pylint: disable=missing-function-docstring + self, + ) -> list[str] | None: return None @property - def fan_mode(self) -> str | None: + def fan_mode(self) -> str | None: # pylint: disable=missing-function-docstring return None @property - def swing_mode(self) -> str | None: + def swing_mode(self) -> str | None: # pylint: disable=missing-function-docstring return None @property - def supported_features(self): + def supported_features(self): # pylint: disable=missing-function-docstring return ClimateEntityFeature.TARGET_TEMPERATURE @@ -149,16 +161,23 @@ async def create_thermostat( 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 + # def find_my_entity(entity_id) -> ClimateEntity: + # """Find my new entity""" + # component: EntityComponent[ClimateEntity] = hass.data[CLIMATE_DOMAIN] + # for entity in component.entities: + # if entity.entity_id == entity_id: + # return entity - entity = find_my_entity(entity_id) + return search_entity(hass, entity_id, CLIMATE_DOMAIN) - return entity + +def search_entity(hass: HomeAssistant, entity_id, domain) -> Entity: + """Search and return the entity in the domain""" + component = hass.data[domain] + for entity in component.entities: + if entity.entity_id == entity_id: + return entity + return None async def send_temperature_change_event(entity: VersatileThermostat, new_temp, date): @@ -177,6 +196,24 @@ async def send_temperature_change_event(entity: VersatileThermostat, new_temp, d return await entity._async_temperature_changed(temp_event) +async def send_ext_temperature_change_event( + entity: VersatileThermostat, new_temp, date +): + """Sending a new external temperature event simulating a change on temperature sensor""" + temp_event = Event( + EVENT_STATE_CHANGED, + { + "new_state": State( + entity_id=entity.entity_id, + state=new_temp, + last_changed=date, + last_updated=date, + ) + }, + ) + return await entity._async_ext_temperature_changed(temp_event) + + async def send_power_change_event(entity: VersatileThermostat, new_power, date): """Sending a new power event simulating a change on power sensor""" power_event = Event( @@ -234,7 +271,57 @@ async def send_window_change_event( return ret -def get_tz(hass): +async def send_motion_change_event( + entity: VersatileThermostat, new_state: bool, old_state: bool, date +): + """Sending a new motion event simulating a change on the window state""" + motion_event = Event( + EVENT_STATE_CHANGED, + { + "new_state": State( + entity_id=entity.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, + state=STATE_ON if old_state else STATE_OFF, + last_changed=date, + last_updated=date, + ), + }, + ) + ret = await entity._async_motion_changed(motion_event) + return ret + + +async def send_presence_change_event( + entity: VersatileThermostat, new_state: bool, old_state: bool, date +): + """Sending a new presence event simulating a change on the window state""" + presence_event = Event( + EVENT_STATE_CHANGED, + { + "new_state": State( + entity_id=entity.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, + state=STATE_ON if old_state else STATE_OFF, + last_changed=date, + last_updated=date, + ), + }, + ) + ret = await entity._async_presence_changed(presence_event) + return ret + + +def get_tz(hass: HomeAssistant): """Get the current timezone""" return dt_util.get_time_zone(hass.config.time_zone) @@ -269,3 +356,4 @@ async def send_climate_change_event( }, ) ret = await entity._async_climate_changed(climate_event) + return ret diff --git a/custom_components/versatile_thermostat/tests/conftest.py b/custom_components/versatile_thermostat/tests/conftest.py index ea7aad9..fd8f184 100644 --- a/custom_components/versatile_thermostat/tests/conftest.py +++ b/custom_components/versatile_thermostat/tests/conftest.py @@ -18,7 +18,7 @@ from unittest.mock import patch import pytest -from homeassistant.core import HomeAssistant, StateMachine +from homeassistant.core import StateMachine from custom_components.versatile_thermostat.config_flow import ( VersatileThermostatBaseConfigFlow, @@ -28,13 +28,14 @@ from custom_components.versatile_thermostat.climate import ( VersatileThermostat, ) -pytest_plugins = "pytest_homeassistant_custom_component" +pytest_plugins = "pytest_homeassistant_custom_component" # pylint: disable=invalid-name # This fixture enables loading custom integrations in all tests. # Remove to enable selective use of this fixture @pytest.fixture(autouse=True) def auto_enable_custom_integrations(enable_custom_integrations): + """Enable all integration in tests""" yield @@ -50,6 +51,17 @@ def skip_notifications_fixture(): yield +@pytest.fixture(name="skip_turn_on_off_heater") +def skip_turn_on_off_heater(): + """Skip turning on and off the heater""" + with patch( + "custom_components.versatile_thermostat.climate.VersatileThermostat._async_heater_turn_on" + ), patch( + "custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off" + ): + yield + + # This fixture is used to bypass the validate_input function in config_flow # NOT USED Now (keeped for memory) @pytest.fixture(name="skip_validate_input") diff --git a/custom_components/versatile_thermostat/tests/test_binary_sensors.py b/custom_components/versatile_thermostat/tests/test_binary_sensors.py new file mode 100644 index 0000000..cb8e510 --- /dev/null +++ b/custom_components/versatile_thermostat/tests/test_binary_sensors.py @@ -0,0 +1,503 @@ +""" Test the normal start of a Thermostat """ +from unittest.mock import patch +from datetime import timedelta, datetime + +from homeassistant.core import HomeAssistant +from homeassistant.components.climate import HVACMode + +from homeassistant.components.binary_sensor import BinarySensorDeviceClass + +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from ..climate import VersatileThermostat +from ..binary_sensor import ( + SecurityBinarySensor, + OverpoweringBinarySensor, + WindowBinarySensor, + MotionBinarySensor, + PresenceBinarySensor, +) + +from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import + + +async def test_security_binary_sensors( + hass: HomeAssistant, + skip_hass_states_is_state, + skip_turn_on_off_heater, + skip_send_event, +): + """Test the security binary sensors in thermostat type""" + + entry = MockConfigEntry( + domain=DOMAIN, + title="TheOverSwitchMockName", + unique_id="uniqueId", + data={ + CONF_NAME: "TheOverSwitchMockName", + CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_SWITCH, + CONF_TEMP_SENSOR: "sensor.mock_temp_sensor", + CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor", + CONF_CYCLE_MIN: 5, + CONF_TEMP_MIN: 15, + CONF_TEMP_MAX: 30, + "eco_temp": 17, + "comfort_temp": 18, + "boost_temp": 19, + CONF_USE_WINDOW_FEATURE: False, + CONF_USE_MOTION_FEATURE: False, + CONF_USE_POWER_FEATURE: False, + CONF_USE_PRESENCE_FEATURE: False, + CONF_HEATER: "switch.mock_switch", + CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI, + CONF_TPI_COEF_INT: 0.3, + CONF_TPI_COEF_EXT: 0.01, + CONF_MINIMAL_ACTIVATION_DELAY: 30, + CONF_SECURITY_DELAY_MIN: 5, + CONF_SECURITY_MIN_ON_PERCENT: 0.3, + }, + ) + + entity: VersatileThermostat = await create_thermostat( + hass, entry, "climate.theoverswitchmockname" + ) + assert entity + + security_binary_sensor: SecurityBinarySensor = search_entity( + hass, "binary_sensor.theoverswitchmockname_security_state", "binary_sensor" + ) + assert security_binary_sensor + + now: datetime = datetime.now(tz=get_tz(hass)) + + # Security should be disabled + await entity.async_set_preset_mode(PRESET_COMFORT) + await entity.async_set_hvac_mode(HVACMode.HEAT) + + assert security_binary_sensor.state == STATE_OFF + assert security_binary_sensor.device_class == BinarySensorDeviceClass.SAFETY + + # Set temperature in the past + event_timestamp = now - timedelta(minutes=6) + + # set temperature to 15 so that on_percent will be > security_min_on_percent (0.2) + await send_temperature_change_event(entity, 15, event_timestamp) + + assert entity.security_state is True + # Simulate the event reception + await security_binary_sensor.async_my_climate_changed() + assert security_binary_sensor.state == STATE_ON + + # set temperature now + await send_temperature_change_event(entity, 15, now) + assert entity.security_state is False + # Simulate the event reception + await security_binary_sensor.async_my_climate_changed() + assert security_binary_sensor.state == STATE_OFF + + +async def test_overpowering_binary_sensors( + hass: HomeAssistant, + skip_hass_states_is_state, + skip_turn_on_off_heater, + skip_send_event, +): + """Test the overpowering binary sensors in thermostat type""" + + entry = MockConfigEntry( + domain=DOMAIN, + title="TheOverSwitchMockName", + unique_id="uniqueId", + data={ + CONF_NAME: "TheOverSwitchMockName", + CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_SWITCH, + CONF_TEMP_SENSOR: "sensor.mock_temp_sensor", + CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor", + CONF_CYCLE_MIN: 5, + CONF_TEMP_MIN: 15, + CONF_TEMP_MAX: 30, + "eco_temp": 17, + "comfort_temp": 18, + "boost_temp": 19, + CONF_USE_WINDOW_FEATURE: False, + CONF_USE_MOTION_FEATURE: False, + CONF_USE_POWER_FEATURE: True, + CONF_USE_PRESENCE_FEATURE: False, + CONF_HEATER: "switch.mock_switch", + CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI, + CONF_TPI_COEF_INT: 0.3, + CONF_TPI_COEF_EXT: 0.01, + CONF_MINIMAL_ACTIVATION_DELAY: 30, + CONF_SECURITY_DELAY_MIN: 5, + CONF_SECURITY_MIN_ON_PERCENT: 0.3, + CONF_POWER_SENSOR: "sensor.mock_power_sensor", + CONF_MAX_POWER_SENSOR: "sensor.mock_power_max_sensor", + CONF_DEVICE_POWER: 100, + CONF_PRESET_POWER: 12, + }, + ) + + entity: VersatileThermostat = await create_thermostat( + hass, entry, "climate.theoverswitchmockname" + ) + assert entity + + overpowering_binary_sensor: OverpoweringBinarySensor = search_entity( + hass, "binary_sensor.theoverswitchmockname_overpowering_state", "binary_sensor" + ) + assert overpowering_binary_sensor + + now: datetime = datetime.now(tz=get_tz(hass)) + + # Overpowering should be not set because poer have not been received + 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 await entity.check_overpowering() is False + assert entity.overpowering_state is None + + await overpowering_binary_sensor.async_my_climate_changed() + assert overpowering_binary_sensor.state is STATE_OFF + assert overpowering_binary_sensor.device_class == BinarySensorDeviceClass.POWER + + await send_power_change_event(entity, 100, now) + await send_max_power_change_event(entity, 150, now) + assert await entity.check_overpowering() is True + assert entity.overpowering_state is True + + # Simulate the event reception + await overpowering_binary_sensor.async_my_climate_changed() + assert overpowering_binary_sensor.state == STATE_ON + + # set max power to a low value + await send_max_power_change_event(entity, 201, now) + assert await entity.check_overpowering() is False + assert entity.overpowering_state is False + # Simulate the event reception + await overpowering_binary_sensor.async_my_climate_changed() + assert overpowering_binary_sensor.state == STATE_OFF + + +async def test_window_binary_sensors( + hass: HomeAssistant, + skip_hass_states_is_state, + skip_turn_on_off_heater, + skip_send_event, +): + """Test the window binary sensors in thermostat type""" + + entry = MockConfigEntry( + domain=DOMAIN, + title="TheOverSwitchMockName", + unique_id="uniqueId", + data={ + CONF_NAME: "TheOverSwitchMockName", + CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_SWITCH, + CONF_TEMP_SENSOR: "sensor.mock_temp_sensor", + CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor", + CONF_CYCLE_MIN: 5, + CONF_TEMP_MIN: 15, + CONF_TEMP_MAX: 30, + "eco_temp": 17, + "comfort_temp": 18, + "boost_temp": 19, + CONF_USE_WINDOW_FEATURE: True, + CONF_USE_MOTION_FEATURE: False, + CONF_USE_POWER_FEATURE: False, + CONF_USE_PRESENCE_FEATURE: False, + CONF_HEATER: "switch.mock_switch", + CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI, + CONF_TPI_COEF_INT: 0.3, + CONF_TPI_COEF_EXT: 0.01, + CONF_MINIMAL_ACTIVATION_DELAY: 30, + CONF_SECURITY_DELAY_MIN: 5, + CONF_SECURITY_MIN_ON_PERCENT: 0.3, + CONF_WINDOW_SENSOR: "binary_sensor.mock_window_sensor", + CONF_WINDOW_DELAY: 0, # important to not been obliged to wait + }, + ) + + entity: VersatileThermostat = await create_thermostat( + hass, entry, "climate.theoverswitchmockname" + ) + assert entity + + window_binary_sensor: WindowBinarySensor = search_entity( + hass, "binary_sensor.theoverswitchmockname_window_state", "binary_sensor" + ) + assert window_binary_sensor + + now: datetime = datetime.now(tz=get_tz(hass)) + + # Overpowering should be not set because poer have not been received + 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.window_state is None + + await window_binary_sensor.async_my_climate_changed() + assert window_binary_sensor.state is STATE_OFF + assert window_binary_sensor.device_class == BinarySensorDeviceClass.WINDOW + + # Open the window + with patch("homeassistant.helpers.condition.state", return_value=True): + try_window_condition = await send_window_change_event(entity, True, False, now) + # simulate the call to try_window_condition + await try_window_condition(None) + + assert entity.window_state is STATE_ON + + # Simulate the event reception + await window_binary_sensor.async_my_climate_changed() + assert window_binary_sensor.state == STATE_ON + + # close the window + with patch("homeassistant.helpers.condition.state", return_value=True): + try_window_condition = await send_window_change_event(entity, False, True, now) + # simulate the call to try_window_condition + await try_window_condition(None) + + assert entity.window_state is STATE_OFF + + # Simulate the event reception + await window_binary_sensor.async_my_climate_changed() + assert window_binary_sensor.state == STATE_OFF + + +async def test_motion_binary_sensors( + hass: HomeAssistant, + skip_hass_states_is_state, + skip_turn_on_off_heater, + skip_send_event, +): + """Test the motion binary sensors in thermostat type""" + + entry = MockConfigEntry( + domain=DOMAIN, + title="TheOverSwitchMockName", + unique_id="uniqueId", + data={ + CONF_NAME: "TheOverSwitchMockName", + CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_SWITCH, + CONF_TEMP_SENSOR: "sensor.mock_temp_sensor", + CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor", + CONF_CYCLE_MIN: 5, + CONF_TEMP_MIN: 15, + CONF_TEMP_MAX: 30, + "eco_temp": 17, + "comfort_temp": 18, + "boost_temp": 19, + CONF_USE_WINDOW_FEATURE: False, + CONF_USE_MOTION_FEATURE: True, + CONF_USE_POWER_FEATURE: False, + CONF_USE_PRESENCE_FEATURE: False, + CONF_HEATER: "switch.mock_switch", + CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI, + CONF_TPI_COEF_INT: 0.3, + CONF_TPI_COEF_EXT: 0.01, + CONF_MINIMAL_ACTIVATION_DELAY: 30, + CONF_SECURITY_DELAY_MIN: 5, + CONF_SECURITY_MIN_ON_PERCENT: 0.3, + CONF_MOTION_SENSOR: "binary_sensor.mock_motion_sensor", + CONF_MOTION_DELAY: 0, # important to not been obliged to wait + }, + ) + + entity: VersatileThermostat = await create_thermostat( + hass, entry, "climate.theoverswitchmockname" + ) + assert entity + + motion_binary_sensor: MotionBinarySensor = search_entity( + hass, "binary_sensor.theoverswitchmockname_motion_state", "binary_sensor" + ) + assert motion_binary_sensor + + now: datetime = datetime.now(tz=get_tz(hass)) + + # Overpowering should be not set because poer have not been received + 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 + + await motion_binary_sensor.async_my_climate_changed() + assert motion_binary_sensor.state is STATE_OFF + assert motion_binary_sensor.device_class == BinarySensorDeviceClass.MOTION + + # Detect motion + with patch("homeassistant.helpers.condition.state", return_value=True): + try_motion_condition = await send_motion_change_event(entity, True, False, now) + # simulate the call to try_window_condition + await try_motion_condition(None) + + assert entity.motion_state is STATE_ON + + # Simulate the event reception + await motion_binary_sensor.async_my_climate_changed() + assert motion_binary_sensor.state == STATE_ON + + # Undetect motion + with patch("homeassistant.helpers.condition.state", return_value=True): + try_motion_condition = await send_motion_change_event(entity, False, True, now) + # simulate the call to try_motion_condition + await try_motion_condition(None) + + assert entity.motion_state is STATE_OFF + + # Simulate the event reception + await motion_binary_sensor.async_my_climate_changed() + assert motion_binary_sensor.state == STATE_OFF + + +async def test_presence_binary_sensors( + hass: HomeAssistant, + skip_hass_states_is_state, + skip_turn_on_off_heater, + skip_send_event, +): + """Test the presence binary sensors in thermostat type""" + + entry = MockConfigEntry( + domain=DOMAIN, + title="TheOverSwitchMockName", + unique_id="uniqueId", + data={ + CONF_NAME: "TheOverSwitchMockName", + CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_SWITCH, + CONF_TEMP_SENSOR: "sensor.mock_temp_sensor", + CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor", + CONF_CYCLE_MIN: 5, + CONF_TEMP_MIN: 15, + CONF_TEMP_MAX: 30, + "eco_temp": 17, + "comfort_temp": 18, + "boost_temp": 19, + "eco_away_temp": 12, + "comfort_away_temp": 13, + "boost_away_temp": 14, + CONF_USE_WINDOW_FEATURE: False, + CONF_USE_MOTION_FEATURE: False, + CONF_USE_POWER_FEATURE: False, + CONF_USE_PRESENCE_FEATURE: True, + CONF_HEATER: "switch.mock_switch", + CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI, + CONF_TPI_COEF_INT: 0.3, + CONF_TPI_COEF_EXT: 0.01, + CONF_MINIMAL_ACTIVATION_DELAY: 30, + CONF_SECURITY_DELAY_MIN: 5, + CONF_SECURITY_MIN_ON_PERCENT: 0.3, + CONF_PRESENCE_SENSOR: "binary_sensor.mock_presence_sensor", + }, + ) + + entity: VersatileThermostat = await create_thermostat( + hass, entry, "climate.theoverswitchmockname" + ) + assert entity + + presence_binary_sensor: PresenceBinarySensor = search_entity( + hass, "binary_sensor.theoverswitchmockname_presence_state", "binary_sensor" + ) + assert presence_binary_sensor + + now: datetime = datetime.now(tz=get_tz(hass)) + + # Overpowering should be not set because poer have not been received + 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.presence_state is None + + await presence_binary_sensor.async_my_climate_changed() + assert presence_binary_sensor.state is STATE_OFF + assert presence_binary_sensor.device_class == BinarySensorDeviceClass.PRESENCE + + # Detect motion + await send_presence_change_event(entity, True, False, now) + + assert entity.presence_state is STATE_ON + + # Simulate the event reception + await presence_binary_sensor.async_my_climate_changed() + assert presence_binary_sensor.state == STATE_ON + + # Undetect motion + await send_presence_change_event(entity, False, True, now) + + assert entity.presence_state is STATE_OFF + + # Simulate the event reception + await presence_binary_sensor.async_my_climate_changed() + assert presence_binary_sensor.state == STATE_OFF + + +async def test_binary_sensors_over_climate_minimal( + hass: HomeAssistant, + skip_hass_states_is_state, + skip_turn_on_off_heater, + skip_send_event, +): + """Test the binary sensors with thermostat over climate type""" + + the_mock_underlying = MagicMockClimate() + with patch( + "custom_components.versatile_thermostat.climate.VersatileThermostat.find_underlying_climate", + return_value=the_mock_underlying, + ): + entry = MockConfigEntry( + domain=DOMAIN, + title="TheOverClimateMockName", + unique_id="uniqueId", + data={ + CONF_NAME: "TheOverClimateMockName", + CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_CLIMATE, + CONF_TEMP_SENSOR: "sensor.mock_temp_sensor", + CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor", + CONF_CYCLE_MIN: 5, + CONF_TEMP_MIN: 15, + CONF_TEMP_MAX: 30, + "eco_temp": 17, + "comfort_temp": 18, + "boost_temp": 19, + CONF_USE_WINDOW_FEATURE: False, + CONF_USE_MOTION_FEATURE: False, + CONF_USE_POWER_FEATURE: False, + CONF_USE_PRESENCE_FEATURE: False, + CONF_CLIMATE: "climate.mock_climate", + CONF_MINIMAL_ACTIVATION_DELAY: 30, + CONF_SECURITY_DELAY_MIN: 5, + CONF_SECURITY_MIN_ON_PERCENT: 0.3, + }, + ) + + entity: VersatileThermostat = await create_thermostat( + hass, entry, "climate.theoverclimatemockname" + ) + assert entity + assert entity.is_over_climate + + security_binary_sensor: SecurityBinarySensor = search_entity( + hass, "binary_sensor.theoverclimatemockname_security_state", "binary_sensor" + ) + assert security_binary_sensor is not None + + overpowering_binary_sensor: OverpoweringBinarySensor = search_entity( + hass, "binary_sensor.theoverclimatemockname_overpowering_state", "binary_sensor" + ) + assert overpowering_binary_sensor is None + + window_binary_sensor: WindowBinarySensor = search_entity( + hass, "binary_sensor.theoverclimatemockname_window_state", "binary_sensor" + ) + assert window_binary_sensor is None + + motion_binary_sensor: MotionBinarySensor = search_entity( + hass, "binary_sensor.theoverclimatemockname_motion_state", "binary_sensor" + ) + assert motion_binary_sensor is None + + presence_binary_sensor: PresenceBinarySensor = search_entity( + hass, "binary_sensor.theoverclimatemockname_presence_state", "binary_sensor" + ) + assert presence_binary_sensor is None diff --git a/custom_components/versatile_thermostat/tests/test_config_flow.py b/custom_components/versatile_thermostat/tests/test_config_flow.py index 3195db1..c386bf2 100644 --- a/custom_components/versatile_thermostat/tests/test_config_flow.py +++ b/custom_components/versatile_thermostat/tests/test_config_flow.py @@ -4,11 +4,7 @@ from homeassistant import data_entry_flow from homeassistant.core import HomeAssistant from homeassistant.config_entries import SOURCE_USER, ConfigEntry -import pytest -from pytest_homeassistant_custom_component.common import MockConfigEntry, load_fixture - from custom_components.versatile_thermostat.const import DOMAIN -from custom_components.versatile_thermostat import VersatileThermostatAPI from .const import ( MOCK_TH_OVER_SWITCH_USER_CONFIG, @@ -41,7 +37,7 @@ async def test_show_form(hass: HomeAssistant) -> None: assert result["step_id"] == SOURCE_USER -async def test_user_config_flow_over_switch(hass, skip_hass_states_get): +async def test_user_config_flow_over_switch(hass: HomeAssistant, skip_hass_states_get): """Test the config flow with all thermostat_over_switch features""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -138,7 +134,7 @@ async def test_user_config_flow_over_switch(hass, skip_hass_states_get): assert isinstance(result["result"], ConfigEntry) -async def test_user_config_flow_over_climate(hass, skip_hass_states_get): +async def test_user_config_flow_over_climate(hass: HomeAssistant, skip_hass_states_get): """Test the config flow with all thermostat_over_climate features and no additional features""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} diff --git a/custom_components/versatile_thermostat/tests/test_power.py b/custom_components/versatile_thermostat/tests/test_power.py index f8a2f15..561a05c 100644 --- a/custom_components/versatile_thermostat/tests/test_power.py +++ b/custom_components/versatile_thermostat/tests/test_power.py @@ -1,5 +1,5 @@ """ Test the Power management """ -from unittest.mock import patch, call, MagicMock +from unittest.mock import patch, call from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import from datetime import datetime, timedelta @@ -44,7 +44,7 @@ async def test_power_management_hvac_off( CONF_POWER_SENSOR: "sensor.mock_power_sensor", CONF_MAX_POWER_SENSOR: "sensor.mock_power_max_sensor", CONF_DEVICE_POWER: 100, - CONF_PRESET_POWER: "eco", + CONF_PRESET_POWER: 12, }, ) @@ -394,7 +394,7 @@ async def test_power_management_energy_over_climate( hass, entry, "climate.theoverclimatemockname" ) assert entity - assert entity._is_over_climate + assert entity.is_over_climate now = datetime.now(tz=get_tz(hass)) await send_temperature_change_event(entity, 15, now) diff --git a/custom_components/versatile_thermostat/tests/test_security.py b/custom_components/versatile_thermostat/tests/test_security.py index 1468560..a89fca2 100644 --- a/custom_components/versatile_thermostat/tests/test_security.py +++ b/custom_components/versatile_thermostat/tests/test_security.py @@ -19,7 +19,7 @@ async def test_security_feature(hass: HomeAssistant, skip_hass_states_is_state): 6. check that security is off and preset is changed to boost """ - tz = get_tz(hass) + tz = get_tz(hass) # pylint: disable=invalid-name entry = MockConfigEntry( domain=DOMAIN, @@ -93,7 +93,7 @@ async def test_security_feature(hass: HomeAssistant, skip_hass_states_is_state): # set temperature to 15 so that on_percent will be > security_min_on_percent (0.2) await send_temperature_change_event(entity, 15, event_timestamp) - assert entity._security_state is True + assert entity.security_state is True assert entity.preset_mode == PRESET_SECURITY assert entity._saved_preset_mode == PRESET_COMFORT assert entity._prop_algorithm.on_percent == 0.1 diff --git a/custom_components/versatile_thermostat/tests/test_sensors.py b/custom_components/versatile_thermostat/tests/test_sensors.py new file mode 100644 index 0000000..bc569a8 --- /dev/null +++ b/custom_components/versatile_thermostat/tests/test_sensors.py @@ -0,0 +1,375 @@ +""" Test the normal start of a Thermostat """ +from datetime import timedelta, datetime + +from homeassistant.core import HomeAssistant +from homeassistant.components.climate import HVACMode + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorStateClass, +) +from homeassistant.const import UnitOfTime, UnitOfPower, UnitOfEnergy, PERCENTAGE + +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from ..climate import VersatileThermostat +from ..sensor import ( + EnergySensor, + MeanPowerSensor, + OnPercentSensor, + OnTimeSensor, + OffTimeSensor, + LastTemperatureSensor, + LastExtTemperatureSensor, +) + +from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import + + +async def test_sensors_over_switch( + hass: HomeAssistant, + skip_hass_states_is_state, + skip_turn_on_off_heater, + skip_send_event, +): + """Test the sensors with a thermostat avec switch type""" + + entry = MockConfigEntry( + domain=DOMAIN, + title="TheOverSwitchMockName", + unique_id="uniqueId", + data={ + CONF_NAME: "TheOverSwitchMockName", + CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_SWITCH, + CONF_TEMP_SENSOR: "sensor.mock_temp_sensor", + CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor", + CONF_CYCLE_MIN: 5, + CONF_TEMP_MIN: 15, + CONF_TEMP_MAX: 30, + "eco_temp": 17, + "comfort_temp": 18, + "boost_temp": 19, + CONF_USE_WINDOW_FEATURE: False, + CONF_USE_MOTION_FEATURE: False, + CONF_USE_POWER_FEATURE: False, + CONF_USE_PRESENCE_FEATURE: False, + CONF_HEATER: "switch.mock_switch", + CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI, + CONF_TPI_COEF_INT: 0.3, + CONF_TPI_COEF_EXT: 0.01, + CONF_MINIMAL_ACTIVATION_DELAY: 30, + CONF_SECURITY_DELAY_MIN: 5, + CONF_SECURITY_MIN_ON_PERCENT: 0.3, + CONF_DEVICE_POWER: 200, + }, + ) + + entity: VersatileThermostat = await create_thermostat( + hass, entry, "climate.theoverswitchmockname" + ) + assert entity + + energy_sensor: EnergySensor = search_entity( + hass, "sensor.theoverswitchmockname_energy", "sensor" + ) + assert energy_sensor + + mean_power_sensor: MeanPowerSensor = search_entity( + hass, "sensor.theoverswitchmockname_mean_power_cycle", "sensor" + ) + assert mean_power_sensor + + on_percent_sensor: OnPercentSensor = search_entity( + hass, "sensor.theoverswitchmockname_power_percent", "sensor" + ) + assert on_percent_sensor + + on_time_sensor: OnTimeSensor = search_entity( + hass, "sensor.theoverswitchmockname_on_time", "sensor" + ) + assert on_time_sensor + + off_time_sensor: OffTimeSensor = search_entity( + hass, "sensor.theoverswitchmockname_off_time", "sensor" + ) + assert off_time_sensor + + last_temperature_sensor: LastTemperatureSensor = search_entity( + hass, "sensor.theoverswitchmockname_last_temperature_date", "sensor" + ) + assert last_temperature_sensor + + last_ext_temperature_sensor: LastExtTemperatureSensor = search_entity( + hass, "sensor.theoverswitchmockname_last_external_temperature_date", "sensor" + ) + assert last_ext_temperature_sensor + + # Simulate the event reception + await energy_sensor.async_my_climate_changed() + assert energy_sensor.state == 0.0 + await mean_power_sensor.async_my_climate_changed() + assert mean_power_sensor.state == 0.0 + await on_percent_sensor.async_my_climate_changed() + assert on_percent_sensor.state == 0.0 + await on_time_sensor.async_my_climate_changed() + assert on_time_sensor.state == 0.0 + await off_time_sensor.async_my_climate_changed() + assert off_time_sensor.state == 300.0 + await last_temperature_sensor.async_my_climate_changed() + assert last_temperature_sensor.state is not None + await last_ext_temperature_sensor.async_my_climate_changed() + assert last_ext_temperature_sensor.state is not None + + last_temp_date = last_temperature_sensor.state + + tz = get_tz(hass) # pylint: disable=invalid-name + now: datetime = datetime.now(tz=tz) + + event_timestamp = now - timedelta(minutes=1) + + # Start the heater to get some values + await entity.async_set_preset_mode(PRESET_COMFORT) + await entity.async_set_hvac_mode(HVACMode.HEAT) + await send_temperature_change_event(entity, 15, event_timestamp) + await send_ext_temperature_change_event(entity, 5, event_timestamp) + + entity.incremente_energy() + + await energy_sensor.async_my_climate_changed() + assert energy_sensor.state == 16.667 + assert energy_sensor.device_class == SensorDeviceClass.ENERGY + assert energy_sensor.state_class == SensorStateClass.TOTAL_INCREASING + # because device_power is 200 + assert energy_sensor.unit_of_measurement == UnitOfEnergy.WATT_HOUR + + await mean_power_sensor.async_my_climate_changed() + assert mean_power_sensor.state == 200.0 + assert mean_power_sensor.device_class == SensorDeviceClass.POWER + assert mean_power_sensor.state_class == SensorStateClass.MEASUREMENT + # because device_power is 200 + assert mean_power_sensor.unit_of_measurement == UnitOfPower.WATT + + await on_percent_sensor.async_my_climate_changed() + assert on_percent_sensor.state == 100.0 + assert on_percent_sensor.unit_of_measurement == PERCENTAGE + + await on_time_sensor.async_my_climate_changed() + assert on_time_sensor.state == 300.0 + assert on_time_sensor.device_class == SensorDeviceClass.DURATION + assert on_time_sensor.state_class == SensorStateClass.MEASUREMENT + assert on_time_sensor.unit_of_measurement == UnitOfTime.SECONDS + + await off_time_sensor.async_my_climate_changed() + assert off_time_sensor.state == 0.0 + assert off_time_sensor.device_class == SensorDeviceClass.DURATION + assert off_time_sensor.state_class == SensorStateClass.MEASUREMENT + assert off_time_sensor.unit_of_measurement == UnitOfTime.SECONDS + + await last_temperature_sensor.async_my_climate_changed() + assert ( + last_temperature_sensor.state is not None + and last_temperature_sensor.state != last_temp_date + ) + assert last_temperature_sensor.device_class == SensorDeviceClass.TIMESTAMP + + await last_ext_temperature_sensor.async_my_climate_changed() + assert ( + last_ext_temperature_sensor.state is not None + and last_ext_temperature_sensor.state != last_temp_date + ) + assert last_ext_temperature_sensor.device_class == SensorDeviceClass.TIMESTAMP + + +async def test_sensors_over_climate( + hass: HomeAssistant, + skip_hass_states_is_state, + skip_turn_on_off_heater, + skip_send_event, +): + """Test the sensors with thermostat over climate type""" + + the_mock_underlying = MagicMockClimate() + with patch( + "custom_components.versatile_thermostat.climate.VersatileThermostat.find_underlying_climate", + return_value=the_mock_underlying, + ): + entry = MockConfigEntry( + domain=DOMAIN, + title="TheOverClimateMockName", + unique_id="uniqueId", + data={ + CONF_NAME: "TheOverClimateMockName", + CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_CLIMATE, + CONF_TEMP_SENSOR: "sensor.mock_temp_sensor", + CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor", + CONF_CYCLE_MIN: 5, + CONF_TEMP_MIN: 15, + CONF_TEMP_MAX: 30, + "eco_temp": 17, + "comfort_temp": 18, + "boost_temp": 19, + CONF_USE_WINDOW_FEATURE: False, + CONF_USE_MOTION_FEATURE: False, + CONF_USE_POWER_FEATURE: True, + CONF_USE_PRESENCE_FEATURE: False, + CONF_CLIMATE: "climate.mock_climate", + CONF_MINIMAL_ACTIVATION_DELAY: 30, + CONF_SECURITY_DELAY_MIN: 5, + CONF_SECURITY_MIN_ON_PERCENT: 0.3, + CONF_POWER_SENSOR: "sensor.mock_power_sensor", + CONF_MAX_POWER_SENSOR: "sensor.mock_power_max_sensor", + CONF_DEVICE_POWER: 1.5, + CONF_PRESET_POWER: 12, + }, + ) + + entity: VersatileThermostat = await create_thermostat( + hass, entry, "climate.theoverclimatemockname" + ) + assert entity + assert entity.is_over_climate + + energy_sensor: EnergySensor = search_entity( + hass, "sensor.theoverclimatemockname_energy", "sensor" + ) + assert energy_sensor + + last_temperature_sensor: LastTemperatureSensor = search_entity( + hass, "sensor.theoverclimatemockname_last_temperature_date", "sensor" + ) + assert last_temperature_sensor + + last_ext_temperature_sensor: LastExtTemperatureSensor = search_entity( + hass, "sensor.theoverclimatemockname_last_external_temperature_date", "sensor" + ) + assert last_ext_temperature_sensor + + # Simulate the event reception + await energy_sensor.async_my_climate_changed() + assert energy_sensor.state == 0.0 + await last_temperature_sensor.async_my_climate_changed() + assert last_temperature_sensor.state is not None + await last_ext_temperature_sensor.async_my_climate_changed() + assert last_ext_temperature_sensor.state is not None + + last_temp_date = last_temperature_sensor.state + + tz = get_tz(hass) # pylint: disable=invalid-name + now: datetime = datetime.now(tz=tz) + + event_timestamp = now - timedelta(minutes=1) + + # Start the heater to get some values + await entity.async_set_preset_mode(PRESET_COMFORT) + await entity.async_set_hvac_mode(HVACMode.HEAT) + await send_temperature_change_event(entity, 15, event_timestamp) + await send_ext_temperature_change_event(entity, 5, event_timestamp) + + # to add energy we must have HVACAction underlying climate event + # Send a climate_change event with HVACAction=HEATING + event_timestamp = now - timedelta(minutes=60) + await send_climate_change_event( + entity, + new_hvac_mode=HVACMode.HEAT, + old_hvac_mode=HVACMode.HEAT, + new_hvac_action=HVACAction.HEATING, + old_hvac_action=HVACAction.OFF, + date=event_timestamp, + ) + + # Send a climate_change event with HVACAction=IDLE (end of heating) + await send_climate_change_event( + entity, + new_hvac_mode=HVACMode.HEAT, + old_hvac_mode=HVACMode.HEAT, + new_hvac_action=HVACAction.IDLE, + old_hvac_action=HVACAction.HEATING, + date=now, + ) + + # 60 minutes heating with 1.5 kW heating -> 1.5 kWh + await energy_sensor.async_my_climate_changed() + assert energy_sensor.state == 1.5 + assert energy_sensor.device_class == SensorDeviceClass.ENERGY + assert energy_sensor.state_class == SensorStateClass.TOTAL_INCREASING + # because device_power is 1.5 kW + assert energy_sensor.unit_of_measurement == UnitOfEnergy.KILO_WATT_HOUR + + entity.incremente_energy() + await energy_sensor.async_my_climate_changed() + assert energy_sensor.state == 3.0 + + await last_temperature_sensor.async_my_climate_changed() + assert ( + last_temperature_sensor.state is not None + and last_temperature_sensor.state != last_temp_date + ) + assert last_temperature_sensor.device_class == SensorDeviceClass.TIMESTAMP + + await last_ext_temperature_sensor.async_my_climate_changed() + assert ( + last_ext_temperature_sensor.state is not None + and last_ext_temperature_sensor.state != last_temp_date + ) + assert last_ext_temperature_sensor.device_class == SensorDeviceClass.TIMESTAMP + + +async def test_sensors_over_climate_minimal( + hass: HomeAssistant, + skip_hass_states_is_state, + skip_turn_on_off_heater, + skip_send_event, +): + """Test the sensors with thermostat over climate type""" + + the_mock_underlying = MagicMockClimate() + with patch( + "custom_components.versatile_thermostat.climate.VersatileThermostat.find_underlying_climate", + return_value=the_mock_underlying, + ): + entry = MockConfigEntry( + domain=DOMAIN, + title="TheOverClimateMockName", + unique_id="uniqueId", + data={ + CONF_NAME: "TheOverClimateMockName", + CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_CLIMATE, + CONF_TEMP_SENSOR: "sensor.mock_temp_sensor", + CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor", + CONF_CYCLE_MIN: 5, + CONF_TEMP_MIN: 15, + CONF_TEMP_MAX: 30, + "eco_temp": 17, + "comfort_temp": 18, + "boost_temp": 19, + CONF_USE_WINDOW_FEATURE: False, + CONF_USE_MOTION_FEATURE: False, + CONF_USE_POWER_FEATURE: False, + CONF_USE_PRESENCE_FEATURE: False, + CONF_CLIMATE: "climate.mock_climate", + CONF_MINIMAL_ACTIVATION_DELAY: 30, + CONF_SECURITY_DELAY_MIN: 5, + CONF_SECURITY_MIN_ON_PERCENT: 0.3, + }, + ) + + entity: VersatileThermostat = await create_thermostat( + hass, entry, "climate.theoverclimatemockname" + ) + assert entity + assert entity.is_over_climate + + energy_sensor: EnergySensor = search_entity( + hass, "sensor.theoverclimatemockname_energy", "sensor" + ) + assert energy_sensor is None + + last_temperature_sensor: LastTemperatureSensor = search_entity( + hass, "sensor.theoverclimatemockname_last_temperature_date", "sensor" + ) + assert last_temperature_sensor + + last_ext_temperature_sensor: LastExtTemperatureSensor = search_entity( + hass, "sensor.theoverclimatemockname_last_external_temperature_date", "sensor" + ) + assert last_ext_temperature_sensor diff --git a/custom_components/versatile_thermostat/tests/test_start.py b/custom_components/versatile_thermostat/tests/test_start.py index c6ed17a..768a0e8 100644 --- a/custom_components/versatile_thermostat/tests/test_start.py +++ b/custom_components/versatile_thermostat/tests/test_start.py @@ -12,7 +12,7 @@ from pytest_homeassistant_custom_component.common import MockConfigEntry from ..climate import VersatileThermostat -from .commons import * +from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import async def test_over_switch_full_start(hass: HomeAssistant, skip_hass_states_is_state): diff --git a/custom_components/versatile_thermostat/tests/test_window.py b/custom_components/versatile_thermostat/tests/test_window.py index b9de792..8f198d6 100644 --- a/custom_components/versatile_thermostat/tests/test_window.py +++ b/custom_components/versatile_thermostat/tests/test_window.py @@ -2,7 +2,6 @@ from unittest.mock import patch, call from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import from datetime import datetime -import time import logging