diff --git a/custom_components/versatile_thermostat/climate.py b/custom_components/versatile_thermostat/climate.py index b1c4433..611d510 100644 --- a/custom_components/versatile_thermostat/climate.py +++ b/custom_components/versatile_thermostat/climate.py @@ -4,8 +4,6 @@ import logging from datetime import timedelta, datetime -# from typing import Any - import voluptuous as vol from homeassistant.util import dt as dt_util @@ -23,7 +21,6 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.entity import DeviceInfo, DeviceEntryType from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.entity_component import EntityComponent import homeassistant.helpers.config_validation as cv from homeassistant.helpers.reload import async_setup_reload_service @@ -40,7 +37,6 @@ from homeassistant.helpers import ( ) # , config_validation as cv from homeassistant.components.climate import ( - DOMAIN as CLIMATE_DOMAIN, ATTR_PRESET_MODE, # ATTR_FAN_MODE, HVACMode, @@ -57,14 +53,6 @@ from homeassistant.components.climate import ( PRESET_NONE, # PRESET_SLEEP, ClimateEntityFeature, - # ClimateEntityFeature.PRESET_MODE, - # SUPPORT_TARGET_TEMPERATURE, - SERVICE_SET_FAN_MODE, - SERVICE_SET_HUMIDITY, - SERVICE_SET_HVAC_MODE, - # SERVICE_SET_PRESET_MODE, - SERVICE_SET_SWING_MODE, - SERVICE_SET_TEMPERATURE, ) # from homeassistant.components.climate import ( @@ -86,7 +74,6 @@ from homeassistant.const import ( STATE_ON, EVENT_HOMEASSISTANT_START, ATTR_ENTITY_ID, - SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_HOME, STATE_NOT_HOME, @@ -97,6 +84,9 @@ from .const import ( PLATFORMS, DEVICE_MANUFACTURER, CONF_HEATER, + CONF_HEATER_2, + CONF_HEATER_3, + CONF_HEATER_4, CONF_POWER_SENSOR, CONF_TEMP_SENSOR, CONF_EXTERNAL_TEMP_SENSOR, @@ -146,6 +136,8 @@ from .const import ( ATTR_TOTAL_ENERGY, ) +from .underlyings import UnderlyingSwitch, UnderlyingClimate, UnderlyingEntity + from .prop_algorithm import PropAlgorithm from .open_window_algorithm import WindowOpenDetectionAlgorithm @@ -212,6 +204,7 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): # The list of VersatileThermostat entities # No more needed # _registry: dict[str, object] = {} + _hass: HomeAssistant _last_temperature_mesure: datetime _last_ext_temperature_mesure: datetime _total_energy: float @@ -219,8 +212,8 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): _window_state: bool _motion_state: bool _presence_state: bool - _security_state: bool _window_auto_state: bool + _underlyings: list[UnderlyingEntity] def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None: """Initialize the thermostat.""" @@ -264,14 +257,17 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): self._security_state = None self._thermostat_type = None - self._heater_entity_id = None - self._climate_entity_id = None self._is_over_climate = False + # TODO should be delegated to underlying climate + self._heater_entity_id = None + # self._climate_entity_id = None self._underlying_climate = None self._attr_translation_key = "versatile_thermostat" self._total_energy = None + + # TODO should be delegated to underlying climate self._underlying_climate_start_hvac_action_date = None self._underlying_climate_delta_t = 0 @@ -286,18 +282,9 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): self._current_tz = dt_util.get_time_zone(self._hass.config.time_zone) - self.post_init(entry_infos) + self._underlyings = [] - @property - def device_info(self) -> DeviceInfo: - """Return the device info.""" - return DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, self._unique_id)}, - name=self._name, - manufacturer=DEVICE_MANUFACTURER, - model=DOMAIN, - ) + self.post_init(entry_infos) def post_init(self, entry_infos): """Finish the initialization of the thermostast""" @@ -335,16 +322,40 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): self._motion_call_cancel() self._motion_call_cancel = None - # Exploit usable attributs + self._cycle_min = entry_infos.get(CONF_CYCLE_MIN) + + # Initialize underlying entities self._thermostat_type = entry_infos.get(CONF_THERMOSTAT_TYPE) if self._thermostat_type == CONF_THERMOSTAT_CLIMATE: self._is_over_climate = True - self._climate_entity_id = entry_infos.get(CONF_CLIMATE) + self._underlyings.append( + UnderlyingClimate( + hass=self._hass, + thermostat_name=str(self), + climate_entity_id=entry_infos.get(CONF_CLIMATE), + ) + ) else: - self._heater_entity_id = entry_infos.get(CONF_HEATER) - self._is_over_climate = False + lst_switches = [entry_infos.get(CONF_HEATER)] + if entry_infos.get(CONF_HEATER_2): + lst_switches.append(entry_infos.get(CONF_HEATER_2)) + if entry_infos.get(CONF_HEATER_3): + lst_switches.append(entry_infos.get(CONF_HEATER_3)) + if entry_infos.get(CONF_HEATER_4): + lst_switches.append(entry_infos.get(CONF_HEATER_4)) + + delta_cycle = self._cycle_min * 60 / len(lst_switches) + self._underlyings = [] + for idx, switch in enumerate(lst_switches): + self._underlyings.append( + UnderlyingSwitch( + hass=self._hass, + thermostat_name=str(self), + switch_entity_id=switch, + initial_delay_sec=idx * delta_cycle, + ) + ) - self._cycle_min = entry_infos.get(CONF_CYCLE_MIN) self._proportional_function = entry_infos.get(CONF_PROP_FUNCTION) self._temp_sensor_entity_id = entry_infos.get(CONF_TEMP_SENSOR) self._ext_temp_sensor_entity_id = entry_infos.get(CONF_EXTERNAL_TEMP_SENSOR) @@ -523,19 +534,21 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): await super().async_added_to_hass() - # Add listener - if self._thermostat_type == CONF_THERMOSTAT_CLIMATE: - self.async_on_remove( - async_track_state_change_event( - self.hass, [self._climate_entity_id], self._async_climate_changed + # Add listener to all underlying entities + if self.is_over_climate: + for climate in self._underlyings: + self.async_on_remove( + async_track_state_change_event( + self.hass, [climate.entity_id], self._async_climate_changed + ) ) - ) else: - self.async_on_remove( - async_track_state_change_event( - self.hass, [self._heater_entity_id], self._async_switch_changed + for switch in self._underlyings: + self.async_on_remove( + async_track_state_change_event( + self.hass, [switch.entity_id], self._async_switch_changed + ) ) - ) self.async_on_remove( async_track_state_change_event( @@ -623,14 +636,6 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): self._async_cancel_cycle() self._async_cancel_cycle = None - def find_underlying_climate(self, climate_entity_id) -> ClimateEntity: - """Find the underlying climate entity""" - component: EntityComponent[ClimateEntity] = self.hass.data[CLIMATE_DOMAIN] - for entity in component.entities: - if climate_entity_id == entity.entity_id: - return entity - return None - async def async_startup(self): """Triggered on startup, used to get old state and set internal states accordingly""" _LOGGER.debug("%s - Calling async_startup", self) @@ -640,28 +645,9 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): _LOGGER.debug("%s - Calling async_startup_internal", self) need_write_state = False - # Get the underlying thermostat - if self._is_over_climate: - self._underlying_climate = self.find_underlying_climate( - self._climate_entity_id - ) - if self._underlying_climate: - _LOGGER.info( - "%s - The underlying climate entity: %s have been succesfully found", - self, - self._underlying_climate, - ) - else: - _LOGGER.error( - "%s - Cannot find the underlying climate entity: %s. Thermostat will not be operational", - self, - self._climate_entity_id, - ) - # #56 keep the over_climate and try periodically to find the underlying climate - # self._is_over_climate = False - raise UnknownEntity( - f"Underlying thermostat {self._climate_entity_id} not found" - ) + # Initialize all UnderlyingEntities + for under in self._underlyings: + under.startup() temperature_state = self.hass.states.get(self._temp_sensor_entity_id) if temperature_state and temperature_state.state not in ( @@ -888,6 +874,17 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): def __str__(self): return f"VersatileThermostat-{self.name}" + @property + def device_info(self) -> DeviceInfo: + """Return the device info.""" + return DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, self._unique_id)}, + name=self._name, + manufacturer=DEVICE_MANUFACTURER, + model=DOMAIN, + ) + @property def unique_id(self): return self._unique_id @@ -1000,17 +997,11 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): @property def _is_device_active(self): - """If the toggleable device is currently active.""" - if self._is_over_climate: - if self._underlying_climate: - return self._underlying_climate.hvac_action not in [ - HVACAction.IDLE, - HVACAction.OFF, - ] - else: - return None - else: - return self._hass.states.is_state(self._heater_entity_id, STATE_ON) + """Returns true if one underlying is active""" + for under in self._underlyings: + if under.is_device_active(): + return True + return False @property def current_temperature(self): @@ -1156,6 +1147,25 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): """True if the Window auto feature is enabled""" return self._window_auto_on + @property + def nb_underlying_entities(self) -> int: + """Returns the number of underlying entities""" + return len(self._underlyings) + + def underlying_entity_id(self, index=0) -> str | None: + """The climate_entity_id. Added for retrocompatibility reason""" + if index < self.nb_underlying_entities: + return self.underlying_entity(index).entity_id + else: + return None + + def underlying_entity(self, index=0) -> UnderlyingEntity | None: + """Get the underlying entity at specified index""" + if index < self.nb_underlying_entities: + return self._underlyings[index] + else: + return None + def turn_aux_heat_on(self) -> None: """Turn auxiliary heater on.""" if self._is_over_climate and self._underlying_climate: @@ -1191,28 +1201,13 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): if hvac_mode is None: return - if self._is_over_climate and self._underlying_climate: - data = {ATTR_ENTITY_ID: self._climate_entity_id, "hvac_mode": hvac_mode} - await self.hass.services.async_call( - CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, data, context=self._context - ) - # await self._underlying_climate.async_set_hvac_mode(hvac_mode) - self._hvac_mode = hvac_mode # self._underlying_climate.hvac_mode - else: - if hvac_mode == HVACMode.HEAT: - self._hvac_mode = HVACMode.HEAT - await self._async_control_heating(force=True) - elif hvac_mode == HVACMode.COOL: - self._hvac_mode = HVACMode.COOL - await self._async_control_heating(force=True) - elif hvac_mode == HVACMode.OFF: - self._hvac_mode = HVACMode.OFF - if self._is_device_active: - await self._async_underlying_entity_turn_off() - await self._async_control_heating(force=True) - else: - _LOGGER.error("Unrecognized hvac mode: %s", hvac_mode) - return + # Delegate to all underlying + for under in self._underlyings: + await under.set_have_mode(hvac_mode) + + self._hvac_mode = hvac_mode + await self._async_control_heating(force=True) + # Ensure we update the current operation after changing the mode self.reset_last_temperature_time() @@ -1304,52 +1299,32 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): async def async_set_fan_mode(self, fan_mode): """Set new target fan mode.""" _LOGGER.info("%s - Set fan mode: %s", self, fan_mode) - if fan_mode is None: + if fan_mode is None or not self._is_over_climate: return + + for under in self._underlyings: + await under.set_fan_mode(fan_mode) self._fan_mode = fan_mode - - if self._is_over_climate and self._underlying_climate: - data = { - ATTR_ENTITY_ID: self._climate_entity_id, - "fan_mode": fan_mode, - } - - await self.hass.services.async_call( - CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, data, context=self._context - ) self.async_write_ha_state() async def async_set_humidity(self, humidity: int): """Set new target humidity.""" _LOGGER.info("%s - Set fan mode: %s", self, humidity) - if humidity is None: + if humidity is None or not self._is_over_climate: return + for under in self._underlyings: + await under.set_humidity(humidity) self._humidity = humidity - if self._is_over_climate and self._underlying_climate: - data = { - ATTR_ENTITY_ID: self._climate_entity_id, - "humidity": humidity, - } - - await self.hass.services.async_call( - CLIMATE_DOMAIN, SERVICE_SET_HUMIDITY, data, context=self._context - ) + self.async_write_ha_state() async def async_set_swing_mode(self, swing_mode): """Set new target swing operation.""" _LOGGER.info("%s - Set fan mode: %s", self, swing_mode) - if swing_mode is None: + if swing_mode is None or not self._is_over_climate: return + for under in self._underlyings: + await under.set_swing_mode(swing_mode) self._swing_mode = swing_mode - if self._is_over_climate and self._underlying_climate: - data = { - ATTR_ENTITY_ID: self._climate_entity_id, - "swing_mode": swing_mode, - } - - await self.hass.services.async_call( - CLIMATE_DOMAIN, SERVICE_SET_SWING_MODE, data, context=self._context - ) self.async_write_ha_state() async def async_set_temperature(self, **kwargs): @@ -1366,16 +1341,12 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): async def _async_internal_set_temperature(self, temperature): """Set the target temperature and the target temperature of underlying climate if any""" self._target_temp = temperature - if self._is_over_climate and self._underlying_climate: - data = { - ATTR_ENTITY_ID: self._climate_entity_id, - "temperature": temperature, - "target_temp_high": self._attr_max_temp, - "target_temp_low": self._attr_min_temp, - } + if not self._is_over_climate: + return - await self.hass.services.async_call( - CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, data, context=self._context + for under in self._underlyings: + await under.set_temperature( + temperature, self._attr_max_temp, self._attr_min_temp ) def get_state_date_or_now(self, state: State): @@ -1865,22 +1836,9 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): async def _async_underlying_entity_turn_off(self): """Turn heater toggleable device off.""" - if not self._is_over_climate: - _LOGGER.debug( - "%s - Stopping underlying switch %s", self, self._heater_entity_id - ) - data = {ATTR_ENTITY_ID: self._heater_entity_id} - await self.hass.services.async_call( - HA_DOMAIN, SERVICE_TURN_OFF, data, context=self._context - ) - else: - _LOGGER.debug( - "%s - Stopping underlying switch %s", self, self._climate_entity_id - ) - data = {ATTR_ENTITY_ID: self._climate_entity_id} - await self.hass.services.async_call( - HA_DOMAIN, SERVICE_TURN_OFF, data, context=self._context - ) + + for under in self._underlyings: + await under.turn_off() async def _async_manage_window_auto(self): """The management of the window auto feature""" @@ -2039,7 +1997,7 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): ) ret = self._current_power + self._device_power >= self._current_power_max - if not self._overpowering_state and ret and not self._hvac_mode == HVACMode.OFF: + if not self._overpowering_state and ret and self._hvac_mode != HVACMode.OFF: _LOGGER.warning( "%s - overpowering is detected. Heater preset will be set to 'power'", self, @@ -2241,15 +2199,18 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): ) # Issue 56 in over_climate mode, if the underlying climate is not initialized, try to initialize it - if self._is_over_climate and self._underlying_climate is None: - _LOGGER.info( - "%s - Underlying climate is not initialized. Try to initialize it", self - ) - try: - await self.async_startup() - except UnknownEntity as err: - # still not found, we an stop here - raise err + for under in self._underlyings: + if not under.is_initialized: + _LOGGER.info( + "%s - Underlying %s is not initialized. Try to initialize it", + self, + under.entity_id, + ) + try: + under.startup() + except UnknownEntity as err: + # still not found, we an stop here + raise err # Check overpowering condition overpowering: bool = await self.check_overpowering() diff --git a/custom_components/versatile_thermostat/commons.py b/custom_components/versatile_thermostat/commons.py index df349f9..1d4d309 100644 --- a/custom_components/versatile_thermostat/commons.py +++ b/custom_components/versatile_thermostat/commons.py @@ -19,7 +19,7 @@ class VersatileThermostatBaseEntity(Entity): _my_climate: VersatileThermostat hass: HomeAssistant _config_id: str - _devince_name: str + _device_name: str def __init__(self, hass: HomeAssistant, config_id, device_name) -> None: """The CTOR""" diff --git a/custom_components/versatile_thermostat/config_flow.py b/custom_components/versatile_thermostat/config_flow.py index ae2017f..995c9cf 100644 --- a/custom_components/versatile_thermostat/config_flow.py +++ b/custom_components/versatile_thermostat/config_flow.py @@ -41,6 +41,9 @@ from .const import ( DOMAIN, CONF_NAME, CONF_HEATER, + CONF_HEATER_2, + CONF_HEATER_3, + CONF_HEATER_4, CONF_TEMP_SENSOR, CONF_EXTERNAL_TEMP_SENSOR, CONF_POWER_SENSOR, @@ -224,6 +227,21 @@ class VersatileThermostatBaseConfigFlow(FlowHandler): domain=[SWITCH_DOMAIN, INPUT_BOOLEAN_DOMAIN] ), ), + vol.Optional(CONF_HEATER_2): selector.EntitySelector( + selector.EntitySelectorConfig( + domain=[SWITCH_DOMAIN, INPUT_BOOLEAN_DOMAIN] + ), + ), + vol.Optional(CONF_HEATER_3): selector.EntitySelector( + selector.EntitySelectorConfig( + domain=[SWITCH_DOMAIN, INPUT_BOOLEAN_DOMAIN] + ), + ), + vol.Optional(CONF_HEATER_4): selector.EntitySelector( + selector.EntitySelectorConfig( + domain=[SWITCH_DOMAIN, INPUT_BOOLEAN_DOMAIN] + ), + ), vol.Required( CONF_PROP_FUNCTION, default=PROPORTIONAL_FUNCTION_TPI ): vol.In( diff --git a/custom_components/versatile_thermostat/const.py b/custom_components/versatile_thermostat/const.py index d8326fb..af00fe8 100644 --- a/custom_components/versatile_thermostat/const.py +++ b/custom_components/versatile_thermostat/const.py @@ -30,6 +30,9 @@ DOMAIN = "versatile_thermostat" PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.BINARY_SENSOR, Platform.SENSOR] CONF_HEATER = "heater_entity_id" +CONF_HEATER_2 = "heater_entity2_id" +CONF_HEATER_3 = "heater_entity3_id" +CONF_HEATER_4 = "heater_entity4_id" CONF_TEMP_SENSOR = "temperature_sensor_entity_id" CONF_EXTERNAL_TEMP_SENSOR = "external_temperature_sensor_entity_id" CONF_POWER_SENSOR = "power_sensor_entity_id" diff --git a/custom_components/versatile_thermostat/tests/commons.py b/custom_components/versatile_thermostat/tests/commons.py index c663d09..c975539 100644 --- a/custom_components/versatile_thermostat/tests/commons.py +++ b/custom_components/versatile_thermostat/tests/commons.py @@ -19,11 +19,14 @@ from pytest_homeassistant_custom_component.common import MockConfigEntry from ..climate import VersatileThermostat from ..const import * # pylint: disable=wildcard-import, unused-wildcard-import +from ..underlyings import * # pylint: disable=wildcard-import, unused-wildcard-import from .const import ( # pylint: disable=unused-import MOCK_TH_OVER_SWITCH_USER_CONFIG, + MOCK_TH_OVER_4SWITCH_USER_CONFIG, MOCK_TH_OVER_CLIMATE_USER_CONFIG, MOCK_TH_OVER_SWITCH_TYPE_CONFIG, + MOCK_TH_OVER_4SWITCH_TYPE_CONFIG, MOCK_TH_OVER_CLIMATE_TYPE_CONFIG, MOCK_TH_OVER_SWITCH_TPI_CONFIG, MOCK_PRESETS_CONFIG, @@ -59,6 +62,18 @@ PARTIAL_CLIMATE_CONFIG = ( | MOCK_ADVANCED_CONFIG ) +FULL_4SWITCH_CONFIG = ( + MOCK_TH_OVER_4SWITCH_USER_CONFIG + | MOCK_TH_OVER_4SWITCH_TYPE_CONFIG + | MOCK_TH_OVER_SWITCH_TPI_CONFIG + | MOCK_PRESETS_CONFIG + | MOCK_WINDOW_CONFIG + | MOCK_MOTION_CONFIG + | MOCK_POWER_CONFIG + | MOCK_PRESENCE_CONFIG + | MOCK_ADVANCED_CONFIG +) + class MockClimate(ClimateEntity): """A Mock Climate class used for Underlying climate mode""" diff --git a/custom_components/versatile_thermostat/tests/conftest.py b/custom_components/versatile_thermostat/tests/conftest.py index a7a8e16..6e6f95a 100644 --- a/custom_components/versatile_thermostat/tests/conftest.py +++ b/custom_components/versatile_thermostat/tests/conftest.py @@ -87,6 +87,15 @@ def skip_control_heating_fixture(): yield +@pytest.fixture(name="skip_find_underlying_climate") +def skip_find_underlying_climate_fixture(): + """Skip the find_underlying_climate of VersatileThermostat""" + with patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate" + ): + yield + + @pytest.fixture(name="skip_hass_states_is_state") def skip_hass_states_is_state_fixture(): """Skip the is_state in HomeAssistant""" diff --git a/custom_components/versatile_thermostat/tests/const.py b/custom_components/versatile_thermostat/tests/const.py index 51242de..4a9c8d8 100644 --- a/custom_components/versatile_thermostat/tests/const.py +++ b/custom_components/versatile_thermostat/tests/const.py @@ -9,6 +9,9 @@ from homeassistant.components.climate.const import ( # pylint: disable=unused-i from custom_components.versatile_thermostat.const import ( CONF_NAME, CONF_HEATER, + CONF_HEATER_2, + CONF_HEATER_3, + CONF_HEATER_4, CONF_THERMOSTAT_CLIMATE, CONF_THERMOSTAT_SWITCH, CONF_THERMOSTAT_TYPE, @@ -62,6 +65,21 @@ MOCK_TH_OVER_SWITCH_USER_CONFIG = { CONF_USE_PRESENCE_FEATURE: True, } +MOCK_TH_OVER_4SWITCH_USER_CONFIG = { + CONF_NAME: "TheOver4SwitchMockName", + 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: 8, + CONF_TEMP_MIN: 15, + CONF_TEMP_MAX: 30, + CONF_DEVICE_POWER: 1, + CONF_USE_WINDOW_FEATURE: True, + CONF_USE_MOTION_FEATURE: True, + CONF_USE_POWER_FEATURE: True, + CONF_USE_PRESENCE_FEATURE: True, +} + MOCK_TH_OVER_CLIMATE_USER_CONFIG = { CONF_NAME: "TheOverClimateMockName", CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_CLIMATE, @@ -79,6 +97,14 @@ MOCK_TH_OVER_SWITCH_TYPE_CONFIG = { CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI, } +MOCK_TH_OVER_4SWITCH_TYPE_CONFIG = { + CONF_HEATER: "switch.mock_4switch0", + CONF_HEATER_2: "switch.mock_4switch1", + CONF_HEATER_3: "switch.mock_4switch2", + CONF_HEATER_4: "switch.mock_4switch3", + CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI, +} + MOCK_TH_OVER_SWITCH_TPI_CONFIG = { CONF_TPI_COEF_INT: 0.3, CONF_TPI_COEF_EXT: 0.1, diff --git a/custom_components/versatile_thermostat/tests/test_binary_sensors.py b/custom_components/versatile_thermostat/tests/test_binary_sensors.py index cb8e510..bf434fc 100644 --- a/custom_components/versatile_thermostat/tests/test_binary_sensors.py +++ b/custom_components/versatile_thermostat/tests/test_binary_sensors.py @@ -442,7 +442,7 @@ async def test_binary_sensors_over_climate_minimal( the_mock_underlying = MagicMockClimate() with patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat.find_underlying_climate", + "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate", return_value=the_mock_underlying, ): entry = MockConfigEntry( diff --git a/custom_components/versatile_thermostat/tests/test_bugs.py b/custom_components/versatile_thermostat/tests/test_bugs.py index f539101..85b0ed1 100644 --- a/custom_components/versatile_thermostat/tests/test_bugs.py +++ b/custom_components/versatile_thermostat/tests/test_bugs.py @@ -18,7 +18,7 @@ async def test_bug_56( the_mock_underlying = MagicMockClimate() with patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat.find_underlying_climate", + "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate", return_value=None, # dont find the underlying climate ): entry = MockConfigEntry( @@ -70,7 +70,7 @@ async def test_bug_56( # This time the underlying will be found with patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat.find_underlying_climate", + "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate", return_value=the_mock_underlying, # dont find the underlying climate ): # try to call _async_control_heating diff --git a/custom_components/versatile_thermostat/tests/test_config_flow.py b/custom_components/versatile_thermostat/tests/test_config_flow.py index 6f50e56..be61a30 100644 --- a/custom_components/versatile_thermostat/tests/test_config_flow.py +++ b/custom_components/versatile_thermostat/tests/test_config_flow.py @@ -369,3 +369,92 @@ async def test_user_config_flow_window_auto_ko( assert result["errors"] == { "window_sensor_entity_id": "window_open_detection_method" } + + +async def test_user_config_flow_over_4_switches( + hass: HomeAssistant, skip_hass_states_get, skip_control_heating +): + """Test the config flow with 4 switchs thermostat_over_switch features""" + + SOURCE_CONFIG = { # pylint: disable=wildcard-import, invalid-name + CONF_NAME: "TheOver4SwitchMockName", + CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_SWITCH, + CONF_TEMP_SENSOR: "sensor.mock_temp_sensor", + CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor", + CONF_CYCLE_MIN: 5, + CONF_TEMP_MIN: 15, + CONF_TEMP_MAX: 30, + CONF_DEVICE_POWER: 1, + CONF_USE_WINDOW_FEATURE: False, + CONF_USE_MOTION_FEATURE: False, + CONF_USE_POWER_FEATURE: False, + CONF_USE_PRESENCE_FEATURE: False, + } + + TYPE_CONFIG = { # pylint: disable=wildcard-import, invalid-name + CONF_HEATER: "switch.mock_switch1", + CONF_HEATER_2: "switch.mock_switch2", + CONF_HEATER_3: "switch.mock_switch3", + CONF_HEATER_4: "switch.mock_switch4", + CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI, + } + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == SOURCE_USER + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=SOURCE_CONFIG, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "type" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=TYPE_CONFIG, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "tpi" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_TH_OVER_SWITCH_TPI_CONFIG + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "presets" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_PRESETS_CONFIG + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "advanced" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_ADVANCED_CONFIG + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert ( + result["data"] + == SOURCE_CONFIG + | TYPE_CONFIG + | MOCK_TH_OVER_SWITCH_TPI_CONFIG + | MOCK_PRESETS_CONFIG + | MOCK_ADVANCED_CONFIG + ) + assert result["result"] + assert result["result"].domain == DOMAIN + assert result["result"].version == 1 + assert result["result"].title == "TheOver4SwitchMockName" + assert isinstance(result["result"], ConfigEntry) diff --git a/custom_components/versatile_thermostat/tests/test_power.py b/custom_components/versatile_thermostat/tests/test_power.py index 561a05c..82e3288 100644 --- a/custom_components/versatile_thermostat/tests/test_power.py +++ b/custom_components/versatile_thermostat/tests/test_power.py @@ -357,7 +357,7 @@ async def test_power_management_energy_over_climate( the_mock_underlying = MagicMockClimate() with patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat.find_underlying_climate", + "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate", return_value=the_mock_underlying, ): entry = MockConfigEntry( diff --git a/custom_components/versatile_thermostat/tests/test_sensors.py b/custom_components/versatile_thermostat/tests/test_sensors.py index bc569a8..7f7bf58 100644 --- a/custom_components/versatile_thermostat/tests/test_sensors.py +++ b/custom_components/versatile_thermostat/tests/test_sensors.py @@ -190,7 +190,7 @@ async def test_sensors_over_climate( the_mock_underlying = MagicMockClimate() with patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat.find_underlying_climate", + "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate", return_value=the_mock_underlying, ): entry = MockConfigEntry( @@ -324,7 +324,7 @@ async def test_sensors_over_climate_minimal( the_mock_underlying = MagicMockClimate() with patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat.find_underlying_climate", + "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate", return_value=the_mock_underlying, ): entry = MockConfigEntry( diff --git a/custom_components/versatile_thermostat/tests/test_start.py b/custom_components/versatile_thermostat/tests/test_start.py index 768a0e8..9900ba2 100644 --- a/custom_components/versatile_thermostat/tests/test_start.py +++ b/custom_components/versatile_thermostat/tests/test_start.py @@ -91,7 +91,7 @@ async def test_over_climate_full_start(hass: HomeAssistant, skip_hass_states_is_ with patch( "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" ) as mock_send_event, patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat.find_underlying_climate", + "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate", return_value=fake_underlying_climate, ) as mock_find_climate: entry.add_to_hass(hass) @@ -139,7 +139,76 @@ async def test_over_climate_full_start(hass: HomeAssistant, skip_hass_states_is_ ) assert mock_find_climate.call_count == 1 - assert mock_find_climate.mock_calls[0] == call("climate.mock_climate") - mock_find_climate.assert_has_calls( - [call.find_underlying_entity("climate.mock_climate")] + assert mock_find_climate.mock_calls[0] == call() + mock_find_climate.assert_has_calls([call.find_underlying_entity()]) + + +async def test_over_4switch_full_start(hass: HomeAssistant, skip_hass_states_is_state): + """Test the normal full start of a thermostat in thermostat_over_switch with 4 switches type""" + + entry = MockConfigEntry( + domain=DOMAIN, + title="TheOver4SwitchMockName", + unique_id="uniqueId", + data=FULL_4SWITCH_CONFIG, + ) + + with patch( + "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + ) as mock_send_event: + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state is ConfigEntryState.LOADED + + def find_my_entity(entity_id) -> ClimateEntity: + """Find my new entity""" + component: EntityComponent[ClimateEntity] = hass.data[CLIMATE_DOMAIN] + for entity in component.entities: + if entity.entity_id == entity_id: + return entity + + entity: VersatileThermostat = find_my_entity("climate.theover4switchmockname") + + assert entity + + assert entity.name == "TheOver4SwitchMockName" + assert entity._is_over_climate is False + assert entity.hvac_action is HVACAction.OFF + assert entity.hvac_mode is HVACMode.OFF + assert entity.target_temperature == entity.min_temp + assert entity.preset_modes == [ + PRESET_NONE, + PRESET_ECO, + PRESET_COMFORT, + PRESET_BOOST, + PRESET_ACTIVITY, + ] + assert entity.preset_mode is PRESET_NONE + assert entity._security_state is False + assert entity._window_state is None + assert entity._motion_state is None + assert entity._presence_state is None + assert entity._prop_algorithm is not None + + assert entity.nb_underlying_entities == 4 + + # Checks that we have the 4 UnderlyingEntity correctly configured + for idx in range(4): + under = entity.underlying_entity(idx) + assert under is not None + assert isinstance(under, UnderlyingSwitch) + assert under.entity_id == "switch.mock_4switch" + str(idx) + assert under.initial_delay_sec == 8 * 60 / 4 * idx + + # should have been called with EventType.PRESET_EVENT and EventType.HVAC_MODE_EVENT + assert mock_send_event.call_count == 2 + + mock_send_event.assert_has_calls( + [ + call.send_event(EventType.PRESET_EVENT, {"preset": PRESET_NONE}), + call.send_event( + EventType.HVAC_MODE_EVENT, + {"hvac_mode": HVACMode.OFF}, + ), + ] ) diff --git a/custom_components/versatile_thermostat/underlyings.py b/custom_components/versatile_thermostat/underlyings.py new file mode 100644 index 0000000..60ba94e --- /dev/null +++ b/custom_components/versatile_thermostat/underlyings.py @@ -0,0 +1,281 @@ +""" Underlying entities classes """ +import logging + +from homeassistant.const import ATTR_ENTITY_ID, STATE_ON + +from homeassistant.backports.enum import StrEnum +from homeassistant.core import HomeAssistant, DOMAIN as HA_DOMAIN +from homeassistant.components.climate import ( + ClimateEntity, + DOMAIN as CLIMATE_DOMAIN, + HVACMode, + HVACAction, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_FAN_MODE, + SERVICE_SET_HUMIDITY, + SERVICE_SET_SWING_MODE, + SERVICE_TURN_OFF, + SERVICE_SET_TEMPERATURE, +) +from homeassistant.helpers.entity_component import EntityComponent + +from .const import UnknownEntity + +_LOGGER = logging.getLogger(__name__) + + +class UnderlyingEntityType(StrEnum): + """All underlying device type""" + + # A switch + SWITCH = "switch" + + # a climate + CLIMATE = "climate" + + +class UnderlyingEntity: + """Represent a underlying device which could be a switch or a climate""" + + _hass: HomeAssistant + _thermostat_name: str + _entity_id: str + _type: UnderlyingEntityType + + def __init__( + self, + hass: HomeAssistant, + thermostat_name: str, + entity_type: UnderlyingEntityType, + entity_id: str, + ) -> None: + """Initialize the underlying entity""" + self._hass = hass + self._thermostat_name = thermostat_name + self._type = entity_type + self._entity_id = entity_id + + def __str__(self): + return self._thermostat_name + + @property + def entity_id(self): + """The entiy id represented by this class""" + return self._entity_id + + @property + def entity_type(self) -> UnderlyingEntityType: + """The entity type represented by this class""" + return self._type + + @property + def is_initialized(self) -> bool: + """True if the underlying is initialized""" + return True + + def startup(self): + """Startup the Entity""" + return + + async def set_hvac_mode(self, hvac_mode: HVACMode): + """Set the HVACmode""" + return + + @property + def is_device_active(self) -> bool | None: + """If the toggleable device is currently active.""" + return None + + async def turn_off(self): + """Turn heater toggleable device off.""" + _LOGGER.debug("%s - Stopping underlying switch %s", self, self._entity_id) + data = {ATTR_ENTITY_ID: self._entity_id} + await self._hass.services.async_call( + HA_DOMAIN, + SERVICE_TURN_OFF, + data, # TODO needed ? context=self._context + ) + + async def set_temperature(self, temperature, max_temp, min_temp): + """Set the target temperature""" + return + + +class UnderlyingSwitch(UnderlyingEntity): + """Represent a underlying switch""" + + _initialDelaySec: int + + def __init__( + self, + hass: HomeAssistant, + thermostat_name: str, + switch_entity_id: str, + initial_delay_sec: int, + ) -> None: + """Initialize the underlying switch""" + + super().__init__( + hass=hass, + thermostat_name=thermostat_name, + entity_type=UnderlyingEntityType.SWITCH, + entity_id=switch_entity_id, + ) + self._initial_delay_sec = initial_delay_sec + + @property + def initial_delay_sec(self): + """The initial delay for this class""" + return self._initial_delay_sec + + async def set_hvac_mode(self, hvac_mode: HVACMode): + """Set the HVACmode""" + if hvac_mode == HVACMode.OFF: + if self.is_device_active: + await self.turn_off() + return + + @property + def is_device_active(self): + """If the toggleable device is currently active.""" + return self._hass.states.is_state(self._entity_id, STATE_ON) + + +class UnderlyingClimate(UnderlyingEntity): + """Represent a underlying climate""" + + _initialDelaySec: int + _underlying_climate: ClimateEntity + + def __init__( + self, hass: HomeAssistant, thermostat_name: str, climate_entity_id: str + ) -> None: + """Initialize the underlying climate""" + + super().__init__( + hass=hass, + thermostat_name=thermostat_name, + entity_type=UnderlyingEntityType.CLIMATE, + entity_id=climate_entity_id, + ) + + def find_underlying_climate(self) -> ClimateEntity: + """Find the underlying climate entity""" + component: EntityComponent[ClimateEntity] = self._hass.data[CLIMATE_DOMAIN] + for entity in component.entities: + if self.entity_id == entity.entity_id: + return entity + return None + + def startup(self): + """Startup the Entity""" + # Get the underlying climate + self._underlying_climate = self.find_underlying_climate() + if self._underlying_climate: + _LOGGER.info( + "%s - The underlying climate entity: %s have been succesfully found", + self, + self._underlying_climate, + ) + else: + _LOGGER.error( + "%s - Cannot find the underlying climate entity: %s. Thermostat will not be operational", + self, + self.entity_id, + ) + # #56 keep the over_climate and try periodically to find the underlying climate + # self._is_over_climate = False + raise UnknownEntity(f"Underlying entity {self.entity_id} not found") + return + + @property + def is_initialized(self) -> bool: + """True if the underlying climate was found""" + return self._underlying_climate is not None + + async def set_hvac_mode(self, hvac_mode: HVACMode): + """Set the HVACmode of the underlying climate""" + if not self.is_initialized: + return + + data = {ATTR_ENTITY_ID: self._entity_id, "hvac_mode": hvac_mode} + await self._hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + data, # TODO Needed ?, context=self._context + ) + + @property + def is_device_active(self): + """If the toggleable device is currently active.""" + if self.is_initialized: + return self._underlying_climate.hvac_action not in [ + HVACAction.IDLE, + HVACAction.OFF, + ] + else: + return None + + async def set_fan_mode(self, fan_mode): + """Set new target fan mode.""" + if not self.is_initialized: + return + data = { + ATTR_ENTITY_ID: self._entity_id, + "fan_mode": fan_mode, + } + + await self._hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + data, # TODO needed ? context=self._context + ) + + async def set_humidity(self, humidity: int): + """Set new target humidity.""" + _LOGGER.info("%s - Set fan mode: %s", self, humidity) + if not self.is_initialized: + return + data = { + ATTR_ENTITY_ID: self._entity_id, + "humidity": humidity, + } + + await self._hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HUMIDITY, + data, # TODO needed ? context=self._context + ) + + async def set_swing_mode(self, swing_mode): + """Set new target swing operation.""" + _LOGGER.info("%s - Set fan mode: %s", self, swing_mode) + if not self.is_initialized: + return + data = { + ATTR_ENTITY_ID: self._entity_id, + "swing_mode": swing_mode, + } + + await self._hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_SWING_MODE, + data, # TODO needed ? context=self._context + ) + + async def set_temperature(self, temperature, max_temp, min_temp): + """Set the target temperature""" + if not self.is_initialized: + return + data = { + ATTR_ENTITY_ID: self._entity_id, + "temperature": temperature, + "target_temp_high": max_temp, + "target_temp_low": min_temp, + } + + await self._hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + data, # TODO needed ? context=self._context + )