From c097138b6f653879f7ce69e1f86547703198adf4 Mon Sep 17 00:00:00 2001 From: = <=> Date: Mon, 15 Jul 2024 08:56:55 +0000 Subject: [PATCH] feat: heat pump mode (dual with only one switch) Fixes #143 --- .vscode/settings.json | 5 +- README.md | 57 ++ config/configuration.yaml | 29 + .../dual_smart_thermostat/climate.py | 47 + .../dual_smart_thermostat/const.py | 1 + .../hvac_action_reason/__init__.py | 1 + .../hvac_controller/__init__.py | 1 + .../hvac_controller/cooler_controller.py | 42 + .../hvac_controller/generic_controller.py | 168 ++++ .../hvac_controller/heater_controller.py | 121 +++ .../hvac_controller/hvac_controller.py | 118 +++ .../hvac_device/__init__.py | 1 + .../hvac_device/controllable_hvac_device.py | 19 +- .../hvac_device/cooler_device.py | 23 +- .../hvac_device/dryer_device.py | 35 +- ..._hvac_device.py => generic_hvac_device.py} | 234 +++-- .../hvac_device/heat_pump_device.py | 223 +++++ .../hvac_device/heater_cooler_device.py | 4 +- .../hvac_device/heater_device.py | 100 +-- .../hvac_device/hvac_device.py | 4 + .../hvac_device/hvac_device_factory.py | 20 +- .../managers/__init__.py | 1 + .../managers/environment_manager.py | 14 +- .../managers/feature_manager.py | 11 + tests/__init__.py | 18 + tests/common.py | 1 + tests/test_dry_mode.py | 7 +- tests/test_heat_pump_mode.py | 815 ++++++++++++++++++ 28 files changed, 1882 insertions(+), 238 deletions(-) create mode 100644 custom_components/dual_smart_thermostat/hvac_action_reason/__init__.py create mode 100644 custom_components/dual_smart_thermostat/hvac_controller/__init__.py create mode 100644 custom_components/dual_smart_thermostat/hvac_controller/cooler_controller.py create mode 100644 custom_components/dual_smart_thermostat/hvac_controller/generic_controller.py create mode 100644 custom_components/dual_smart_thermostat/hvac_controller/heater_controller.py create mode 100644 custom_components/dual_smart_thermostat/hvac_controller/hvac_controller.py create mode 100644 custom_components/dual_smart_thermostat/hvac_device/__init__.py rename custom_components/dual_smart_thermostat/hvac_device/{specific_hvac_device.py => generic_hvac_device.py} (59%) create mode 100644 custom_components/dual_smart_thermostat/hvac_device/heat_pump_device.py create mode 100644 custom_components/dual_smart_thermostat/managers/__init__.py create mode 100644 tests/test_heat_pump_mode.py diff --git a/.vscode/settings.json b/.vscode/settings.json index 2766bc2..d8888a6 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,10 +1,13 @@ { "python.pythonPath": "/usr/bin/python3", - "python.formatting.provider": "black", "editor.formatOnSave": true, "python.analysis.diagnosticSeverityOverrides": {}, "python.analysis.indexing": true, "python.analysis.autoImportCompletions": true, "python.testing.unittestEnabled": false, "python.testing.pytestEnabled": true, + "[python]": { + "editor.defaultFormatter": "ms-python.black-formatter", + "editor.formatOnSave": true + } } \ No newline at end of file diff --git a/README.md b/README.md index 28e87a5..7ee92e0 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ The `dual_smart_thermostat` is an enhanced version of generic thermostat impleme | **Fan With Cooler mode** | ![fan](/docs/images/fan-custom.png) ![cool](/docs/images/snowflake-custom.png) | [docs](#fan-with-cooler-mode) | | **Cooler Only mode** | ![cool](/docs/images/snowflake-custom.png) | [docs](#cooler-only-mode) | | **Dry mode** | ![humidity](docs/images/water-percent-custom.png) | [docs](#dry-mode) | +| **Heat Pump mode** | ![haet/cool](docs/images/sun-snowflake-custom.png) | [docs](#heat-pump-one-switch-heatcool-mode) | | **Floor Temperature Control** | ![heating-coil](docs/images/heating-coil-custom.png) ![snowflake-thermometer](docs/images/snowflake-thermometer-custom.png) ![thermometer-alert](docs/images/thermometer-alert-custom.png) | [docs](#floor-heating-temperature-control) | | **Window/Door sensor integration** | ![window-open](docs/images/window-open-custom.png) ![window-open](docs/images/door-open-custom.png) ![chevron-right](docs/images/chevron-right-custom.png) ![timer-cog](docs/images/timer-cog-outline-custom.png) ![chevron-right](docs/images/chevron-right-custom.png) ![hvac-off](docs/images/hvac-off-custom.png)| [docs](#openings) | | **Presets** | | [docs](#presets) | @@ -155,6 +156,57 @@ moist_tolerance: 5 dry_tolerance: 5 ``` +### Heat Pump (one switch heat/cool) mode + +This setup allows you to use a single switch for both heating and cooling. To enable this mode you define only a single switch for the heater and set the set youer heat pump's current state (heating or cooling) as for the [`heat_pump_cooling`](#heat_pump_cooling) attribute. This must be an entity id of a sensor that has a state of `heating` or `cooling`. + +The entity can be an input buulean for manual control or an entity that provided by the heat pump. + +```yaml +heater: switch.study_heat_pump +target_sensor: sensor.study_temperature +heat_pump_cooling: sensor.study_heat_pump_state +``` + +#### Heat Pump Hvac Modes + +##### Heat-Cool Mode + +```yaml +heater: switch.study_heat_pump +target_sensor: sensor.study_temperature +heat_pump_cooling: sensor.study_heat_pump_state +heat_cool_mode: true +``` + +**heating** _(heat_pump_cooling: false)_: +- heat/cool +- heat +- off + +**cooling** _(heat_pump_cooling: true)_: +- heat/cool +- cool +- off + +##### Single mode + +```yaml +heater: switch.study_heat_pump +target_sensor: sensor.study_temperature +heat_pump_cooling: sensor.study_heat_pump_state +heat_cool_mode: false # <-- or not set +``` + +**heating** _(heat_pump_cooling: false)_: +- heat +- off + +**cooling** _(heat_pump_cooling: true)_: +- cool +- off + + ## Openings The `dual_smart_thermostat` can turn off heating or cooling if a window or door is opened and turn heating or cooling back on when the door or window is closed to save energy. @@ -439,6 +491,11 @@ The internal values can be set by the component only and the external values can - `heat_cool` - `fan_only` +### heat_pump_cooling + + _(optional) (string)_ "`entity_id` for the heat pump cooling state sensor, heat_pump_cooling.state must be `heating` or `cooling`." + enables [heat pump mode](#heat-pump-one-switch-heatcool-mode) + ### min_temp _(optional) (float)_ diff --git a/config/configuration.yaml b/config/configuration.yaml index 2e55c56..9a32a5c 100644 --- a/config/configuration.yaml +++ b/config/configuration.yaml @@ -13,6 +13,8 @@ input_boolean: name: Fan toggle dryer_on: name: Fan toggle + heat_pump_cool: + name: Heat Pump Heat toggle window_open: name: Window window_open2: @@ -125,6 +127,17 @@ switch: data: entity_id: input_boolean.dryer_on + heat_pump_cool: + value_template: "{{ is_state('input_boolean.heat_pump_cool', 'on') }}" + turn_on: + service: input_boolean.turn_on + data: + entity_id: input_boolean.heat_pump_cool + turn_off: + service: input_boolean.turn_off + data: + entity_id: input_boolean.heat_pump_cool + window: value_template: "{{ is_state('input_boolean.window_open', 'on') }}" turn_on: @@ -465,6 +478,22 @@ climate: target_temp_low: 18 humidity: 60 + - platform: dual_smart_thermostat + name: Dual Heat Pump + unique_id: dual_heat_pump + heater: switch.heater + target_sensor: sensor.room_temp + heat_pump_cooling: switch.heat_pump_cool + heat_cool_mode: true + target_temp_step: 0.1 + precision: 0.1 + min_temp: 9 + max_temp: 32 + target_temp: 20 + cold_tolerance: 0.3 + hot_tolerance: 0.3 + + # - platform: dual_smart_thermostat # name: AUX Heat Room # unique_id: aux_heat_room diff --git a/custom_components/dual_smart_thermostat/climate.py b/custom_components/dual_smart_thermostat/climate.py index 2560fa1..a4e7e88 100644 --- a/custom_components/dual_smart_thermostat/climate.py +++ b/custom_components/dual_smart_thermostat/climate.py @@ -102,6 +102,7 @@ CONF_FAN_ON_WITH_AC, CONF_FLOOR_SENSOR, CONF_HEAT_COOL_MODE, + CONF_HEAT_PUMP_COOLING, CONF_HEATER, CONF_HOT_TOLERANCE, CONF_HUMIDITY_SENSOR, @@ -187,6 +188,10 @@ vol.Optional(CONF_MOIST_TOLERANCE): vol.Coerce(float), } +HEAT_PUMP_SCHEMA = { + vol.Optional(CONF_HEAT_PUMP_COOLING): cv.entity_id, +} + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_HEATER): cv.entity_id, @@ -238,6 +243,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(HYGROSTAT_SCHEMA) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(HEAT_PUMP_SCHEMA) + # Add the old presets schema to avoid breaking change PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( {vol.Optional(v): vol.Coerce(float) for (k, v) in CONF_PRESETS_OLD.items()} @@ -260,6 +267,7 @@ async def async_setup_platform( sensor_outside_entity_id = config.get(CONF_OUTSIDE_SENSOR) sensor_humidity_entity_id = config.get(CONF_HUMIDITY_SENSOR) sensor_stale_duration: timedelta | None = config.get(CONF_STALE_DURATION) + sensor_heat_pump_cooling_entity_id = config.get(CONF_HEAT_PUMP_COOLING) keep_alive = config.get(CONF_KEEP_ALIVE) precision = config.get(CONF_PRECISION) @@ -290,6 +298,7 @@ async def async_setup_platform( sensor_outside_entity_id, sensor_humidity_entity_id, sensor_stale_duration, + sensor_heat_pump_cooling_entity_id, keep_alive, precision, unit, @@ -343,6 +352,7 @@ def __init__( sensor_outside_entity_id, sensor_humidity_entity_id, sensor_stale_duration, + sensor_heat_pump_cooling_entity_id, keep_alive, precision, unit, @@ -378,6 +388,7 @@ def __init__( self.sensor_floor_entity_id = sensor_floor_entity_id self.sensor_outside_entity_id = sensor_outside_entity_id self.sensor_humidity_entity_id = sensor_humidity_entity_id + self.sensor_heat_pump_cooling_entity_id = sensor_heat_pump_cooling_entity_id self._keep_alive = keep_alive @@ -473,6 +484,19 @@ async def async_added_to_hass(self) -> None: ) ) + if self.sensor_heat_pump_cooling_entity_id is not None: + _LOGGER.debug( + "Adding heat pump cooling sensor listener: %s", + self.sensor_heat_pump_cooling_entity_id, + ) + self.async_on_remove( + async_track_state_change_event( + self.hass, + [self.sensor_heat_pump_cooling_entity_id], + self._async_entity_heat_pump_cooling_changed_event, + ) + ) + if self._keep_alive: self.async_on_remove( async_track_time_interval( @@ -868,6 +892,8 @@ def _set_temperatures_dual_mode(self, temperatures: TargetTemperatures) -> None: temp_low = temperatures.temp_low temp_high = temperatures.temp_high + self.hvac_device.on_target_temperature_change(temperatures) + if self.features.is_target_mode: if temperature is None: return @@ -1028,6 +1054,27 @@ async def _async_sensor_humidity_changed( await self._async_control_climate() self.async_write_ha_state() + async def _async_entity_heat_pump_cooling_changed_event( + self, event: Event[EventStateChangedData] + ) -> None: + data = event.data + + self.hvac_device.on_entity_state_changed(data["entity_id"], data["new_state"]) + + await self._asyn_entity_heat_pump_cooling_changed(data["new_state"]) + self._attr_hvac_modes = self.hvac_device.hvac_modes + self.async_write_ha_state() + + async def _asyn_entity_heat_pump_cooling_changed( + self, new_state: State | None, trigger_control=True + ) -> None: + """Handle heat pump cooling changes.""" + _LOGGER.info("Entity heat pump cooling change: %s", new_state) + + if trigger_control: + await self._async_control_climate() + self.async_write_ha_state() + async def _check_device_initial_state(self) -> None: """Prevent the device from keep running if HVACMode.OFF.""" _LOGGER.debug("Checking device initial state") diff --git a/custom_components/dual_smart_thermostat/const.py b/custom_components/dual_smart_thermostat/const.py index 805ecf8..dfde082 100644 --- a/custom_components/dual_smart_thermostat/const.py +++ b/custom_components/dual_smart_thermostat/const.py @@ -62,6 +62,7 @@ CONF_OPENINGS = "openings" CONF_OPENINGS_SCOPE = "openings_scope" CONF_HEAT_COOL_MODE = "heat_cool_mode" +CONF_HEAT_PUMP_COOLING = "heat_pump_cooling" ATTR_PREV_TARGET = "prev_target_temp" ATTR_PREV_TARGET_LOW = "prev_target_temp_low" diff --git a/custom_components/dual_smart_thermostat/hvac_action_reason/__init__.py b/custom_components/dual_smart_thermostat/hvac_action_reason/__init__.py new file mode 100644 index 0000000..37c18fa --- /dev/null +++ b/custom_components/dual_smart_thermostat/hvac_action_reason/__init__.py @@ -0,0 +1 @@ +"""Hvac Action Reason Module""" diff --git a/custom_components/dual_smart_thermostat/hvac_controller/__init__.py b/custom_components/dual_smart_thermostat/hvac_controller/__init__.py new file mode 100644 index 0000000..9b9dbb1 --- /dev/null +++ b/custom_components/dual_smart_thermostat/hvac_controller/__init__.py @@ -0,0 +1 @@ +"""HVAC controller module for Dual Smart Thermostat.""" diff --git a/custom_components/dual_smart_thermostat/hvac_controller/cooler_controller.py b/custom_components/dual_smart_thermostat/hvac_controller/cooler_controller.py new file mode 100644 index 0000000..f4b97a2 --- /dev/null +++ b/custom_components/dual_smart_thermostat/hvac_controller/cooler_controller.py @@ -0,0 +1,42 @@ +from datetime import timedelta +import logging +from typing import Callable + +from homeassistant.core import HomeAssistant + +from custom_components.dual_smart_thermostat.hvac_controller.generic_controller import ( + GenericHvacController, +) +from custom_components.dual_smart_thermostat.managers.environment_manager import ( + EnvironmentManager, +) +from custom_components.dual_smart_thermostat.managers.opening_manager import ( + OpeningManager, +) + +_LOGGER = logging.getLogger(__name__) + + +class CoolerHvacController(GenericHvacController): + + def __init__( + self, + hass: HomeAssistant, + entity_id, + min_cycle_duration: timedelta, + environment: EnvironmentManager, + openings: OpeningManager, + turn_on_callback: Callable, + turn_off_callback: Callable, + ) -> None: + self._controller_type = self.__class__.__name__ + + super().__init__( + hass, + entity_id, + min_cycle_duration, + environment, + openings, + turn_on_callback, + turn_off_callback, + ) diff --git a/custom_components/dual_smart_thermostat/hvac_controller/generic_controller.py b/custom_components/dual_smart_thermostat/hvac_controller/generic_controller.py new file mode 100644 index 0000000..689d105 --- /dev/null +++ b/custom_components/dual_smart_thermostat/hvac_controller/generic_controller.py @@ -0,0 +1,168 @@ +from datetime import timedelta +import logging +from typing import Callable + +from homeassistant.components.climate import HVACMode +from homeassistant.const import STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers import condition +import homeassistant.util.dt as dt_util + +from custom_components.dual_smart_thermostat.hvac_action_reason.hvac_action_reason import ( + HVACActionReason, +) +from custom_components.dual_smart_thermostat.hvac_controller.hvac_controller import ( + HvacController, + HvacEnvStrategy, +) +from custom_components.dual_smart_thermostat.managers.environment_manager import ( + EnvironmentManager, +) +from custom_components.dual_smart_thermostat.managers.opening_manager import ( + OpeningManager, +) + +_LOGGER = logging.getLogger(__name__) + + +class GenericHvacController(HvacController): + + entity_id: str + min_cycle_duration: timedelta + _hvac_action_reason: HVACActionReason + + def __init__( + self, + hass: HomeAssistant, + entity_id, + min_cycle_duration: timedelta, + environment: EnvironmentManager, + openings: OpeningManager, + turn_on_callback: Callable, + turn_off_callback: Callable, + ) -> None: + self._controller_type = self.__class__.__name__ + + super().__init__( + hass, + entity_id, + min_cycle_duration, + environment, + openings, + turn_on_callback, + turn_off_callback, + ) + + self._hvac_action_reason = HVACActionReason.NONE + + @property + def hvac_action_reason(self) -> HVACActionReason: + return self._hvac_action_reason + + @property + def is_active(self) -> bool: + """If the toggleable hvac device is currently active.""" + if self.entity_id is not None and self.hass.states.is_state( + self.entity_id, STATE_ON + ): + return True + return False + + def _ran_long_enough(self) -> bool: + if self.is_active: + current_state = STATE_ON + else: + current_state = HVACMode.OFF + + _LOGGER.debug("Checking if device ran long enough: %s", self.entity_id) + _LOGGER.debug("current_state: %s", current_state) + _LOGGER.debug("min_cycle_duration: %s", self.min_cycle_duration) + _LOGGER.debug("time: %s", dt_util.utcnow()) + + long_enough = condition.state( + self.hass, + self.entity_id, + current_state, + self.min_cycle_duration, + ) + + return long_enough + + def needs_control( + self, active: bool, hvac_mode: HVACMode, time=None, force=False + ) -> bool: + """Checks if the controller needs to continue.""" + if not active or hvac_mode == HVACMode.OFF: + _LOGGER.debug( + "Not active or hvac mode is off active: %s, _hvac_mode: %s", + active, + hvac_mode, + ) + return False + + if not force and time is None: + # If the `force` argument is True, we + # ignore `min_cycle_duration`. + # If the `time` argument is not none, we were invoked for + # keep-alive purposes, and `min_cycle_duration` is irrelevant. + if self.min_cycle_duration: + _LOGGER.debug( + "Checking if device ran long enough: %s", self._ran_long_enough() + ) + return self._ran_long_enough() + return True + + async def async_control_device_when_on( + self, + strategy: HvacEnvStrategy, + any_opening_open: bool, + time=None, + ) -> None: + """Check if we need to turn heating on or off when theheater is on.""" + + _LOGGER.debug("below_env_attr: %s", strategy.hvac_goal_reached) + + if strategy.hvac_goal_reached or any_opening_open: + _LOGGER.debug("Turning off entity %s", self.entity_id) + await self.async_turn_off_callback() + if strategy.hvac_goal_reached: + self._hvac_action_reason = strategy.goal_reached_reason() + if any_opening_open: + self._hvac_action_reason = HVACActionReason.OPENING + + elif time is not None and not any_opening_open: + # The time argument is passed only in keep-alive case + _LOGGER.debug( + "Keep-alive - Turning on entity (from active) %s", + self.entity_id, + ) + await self.async_turn_on_callback() + self._hvac_action_reason = strategy.goal_not_reached_reason() + + async def async_control_device_when_off( + self, + strategy: HvacEnvStrategy, + any_opening_open: bool, + time=None, + ) -> None: + """Check if we need to turn heating on or off when the heater is off.""" + _LOGGER.debug("%s _async_control_device_when_off", self.__class__.__name__) + _LOGGER.debug("above_env_attr: %s", strategy.hvac_goal_reached) + _LOGGER.debug("below_env_attr: %s", strategy.hvac_goal_not_reached) + _LOGGER.debug("any_opening_open: %s", any_opening_open) + _LOGGER.debug("is_active: %s", True) + _LOGGER.debug("time: %s", time) + + if strategy.hvac_goal_not_reached and not any_opening_open: + _LOGGER.debug("Turning on entity (from inactive) %s", self.entity_id) + await self.async_turn_on_callback() + self._hvac_action_reason = strategy.goal_not_reached_reason() + elif time is not None or any_opening_open: + # The time argument is passed only in keep-alive case + _LOGGER.debug("Keep-alive - Turning off entity %s", self.entity_id) + await self.async_turn_off_callback() + + if any_opening_open: + self._hvac_action_reason = HVACActionReason.OPENING + else: + _LOGGER.warning("No case matched when off") diff --git a/custom_components/dual_smart_thermostat/hvac_controller/heater_controller.py b/custom_components/dual_smart_thermostat/hvac_controller/heater_controller.py new file mode 100644 index 0000000..2b07a11 --- /dev/null +++ b/custom_components/dual_smart_thermostat/hvac_controller/heater_controller.py @@ -0,0 +1,121 @@ +from datetime import timedelta +import logging +from typing import Callable + +from homeassistant.core import HomeAssistant + +from custom_components.dual_smart_thermostat.hvac_action_reason.hvac_action_reason import ( + HVACActionReason, +) +from custom_components.dual_smart_thermostat.hvac_controller.generic_controller import ( + GenericHvacController, +) +from custom_components.dual_smart_thermostat.hvac_controller.hvac_controller import ( + HvacEnvStrategy, +) +from custom_components.dual_smart_thermostat.managers.environment_manager import ( + EnvironmentManager, +) +from custom_components.dual_smart_thermostat.managers.opening_manager import ( + OpeningManager, +) + +_LOGGER = logging.getLogger(__name__) + + +class HeaterHvacConroller(GenericHvacController): + + def __init__( + self, + hass: HomeAssistant, + heater_entity_id: str, + min_cycle_duration: timedelta, + environment: EnvironmentManager, + openings: OpeningManager, + turn_on_callback: Callable, + turn_off_callback: Callable, + ) -> None: + super().__init__( + hass, + heater_entity_id, + min_cycle_duration, + environment, + openings, + turn_on_callback, + turn_off_callback, + ) + + # override + async def async_control_device_when_on( + self, + strategy: HvacEnvStrategy, + any_opening_open: bool, + time=None, + ) -> None: + """Check if we need to turn heating on or off when theheater is on.""" + too_hot = strategy.hvac_goal_reached + is_floor_hot = self._environment.is_floor_hot + is_floor_cold = self._environment.is_floor_cold + + _LOGGER.debug("_async_control_device_when_on, floor cold: %s", is_floor_cold) + _LOGGER.debug("_async_control_device_when_on, too_hot: %s", too_hot) + + if ((too_hot or is_floor_hot) or any_opening_open) and not is_floor_cold: + _LOGGER.debug("Turning off heater %s", self.entity_id) + + await self.async_turn_off_callback() + + if too_hot: + self._hvac_action_reason = HVACActionReason.TARGET_TEMP_REACHED + if is_floor_hot: + self._hvac_action_reason = HVACActionReason.OVERHEAT + if any_opening_open: + self._hvac_action_reason = HVACActionReason.OPENING + + elif time is not None and not any_opening_open and not is_floor_hot: + # The time argument is passed only in keep-alive case + _LOGGER.info( + "Keep-alive - Turning on heater (from active) %s", + self.entity_id, + ) + self._hvac_action_reason = HVACActionReason.TARGET_TEMP_NOT_REACHED + await self.async_turn_on_callback() + + # override + async def async_control_device_when_off( + self, + strategy: HvacEnvStrategy, + any_opening_open: bool, + time=None, + ) -> None: + """Check if we need to turn heating on or off when the heater is off.""" + _LOGGER.debug("%s _async_control_device_when_off", self.__class__.__name__) + + too_cold = strategy.hvac_goal_not_reached + _LOGGER.debug("too_cold: %s", strategy.hvac_goal_reached) + + is_floor_hot = self._environment.is_floor_hot + is_floor_cold = self._environment.is_floor_cold + + if (too_cold and not any_opening_open and not is_floor_hot) or is_floor_cold: + _LOGGER.debug("Turning on heater (from inactive) %s", self.entity_id) + + await self.async_turn_on_callback() + + if is_floor_cold: + self._hvac_action_reason = HVACActionReason.LIMIT + else: + self._hvac_action_reason = HVACActionReason.TARGET_TEMP_NOT_REACHED + + elif time is not None or any_opening_open or is_floor_hot: + # The time argument is passed only in keep-alive case + _LOGGER.debug("Keep-alive - Turning off heater %s", self.entity_id) + await self.async_turn_off_callback() + + if is_floor_hot: + self._hvac_action_reason = HVACActionReason.OVERHEAT + if any_opening_open: + self._hvac_action_reason = HVACActionReason.OPENING + + else: + _LOGGER.warning("No case matched when off") diff --git a/custom_components/dual_smart_thermostat/hvac_controller/hvac_controller.py b/custom_components/dual_smart_thermostat/hvac_controller/hvac_controller.py new file mode 100644 index 0000000..f24dba0 --- /dev/null +++ b/custom_components/dual_smart_thermostat/hvac_controller/hvac_controller.py @@ -0,0 +1,118 @@ +from abc import ABC, abstractmethod +from datetime import timedelta +import enum +import logging +from typing import Callable + +from homeassistant.components.climate import HVACMode +from homeassistant.core import HomeAssistant + +from custom_components.dual_smart_thermostat.hvac_action_reason.hvac_action_reason import ( + HVACActionReason, +) +from custom_components.dual_smart_thermostat.managers.environment_manager import ( + EnvironmentManager, +) +from custom_components.dual_smart_thermostat.managers.opening_manager import ( + OpeningManager, +) + +_LOGGER = logging.getLogger(__name__) + + +class HvacGoal(enum.StrEnum): + """The environment goal of the HVAC.""" + + LOWER = "lower" + RAISE = "raise" + + +class HvacEnvStrategy: + """Strategy for controlling the HVAC based on the environment.""" + + def __init__( + self, + above: Callable[[], bool], + below: Callable[[], bool], + goal_reached_reason: Callable[[], HVACActionReason], + goal_not_reached_reason: Callable[[], HVACActionReason], + goal: HvacGoal, + ): + self.above = above + self.below = below + self.goal_reached_reason = goal_reached_reason + self.goal_not_reached_reason = goal_not_reached_reason + self.goal = goal + + @property + def hvac_goal_reached(self) -> bool: + if self.goal == HvacGoal.LOWER: + return self.above() + return self.below() + + @property + def hvac_goal_not_reached(self) -> bool: + if self.goal == HvacGoal.LOWER: + return self.below() + return self.above() + + +class HvacController(ABC): + """Abstract class for controlling an HVAC device.""" + + hass: HomeAssistant + entity_id: str + min_cycle_duration: timedelta + _hvac_action_reason: HVACActionReason + _environment: EnvironmentManager + _openings: OpeningManager + async_turn_on_callback: Callable + async_turn_off_callback: Callable + + def __init__( + self, + hass: HomeAssistant, + entity_id, + min_cycle_duration: timedelta, + environment: EnvironmentManager, + openings: OpeningManager, + turn_on_callback: Callable, + turn_off_callback: Callable, + ) -> None: + self._controller_type = self.__class__.__name__ + + self.hass = hass + self.entity_id = entity_id + self.min_cycle_duration = min_cycle_duration + self._environment = environment + self._openings = openings + self.async_turn_on_callback = turn_on_callback + self.async_turn_off_callback = turn_off_callback + + self._hvac_action_reason = HVACActionReason.NONE + + @property + def hvac_action_reason(self) -> HVACActionReason: + return self._hvac_action_reason + + @abstractmethod + def async_control_device_when_on( + self, + strategy: HvacEnvStrategy, + any_opening_open: bool, + time=None, + ) -> None: + pass + + @abstractmethod + def async_control_device_when_off( + self, + strategy: HvacEnvStrategy, + any_opening_open: bool, + time=None, + ) -> None: + pass + + @abstractmethod + def needs_control(self, active: bool, hvac_mode: HVACMode, time=None) -> bool: + pass diff --git a/custom_components/dual_smart_thermostat/hvac_device/__init__.py b/custom_components/dual_smart_thermostat/hvac_device/__init__.py new file mode 100644 index 0000000..ac8a530 --- /dev/null +++ b/custom_components/dual_smart_thermostat/hvac_device/__init__.py @@ -0,0 +1 @@ +"""Hvac Device Module.""" diff --git a/custom_components/dual_smart_thermostat/hvac_device/controllable_hvac_device.py b/custom_components/dual_smart_thermostat/hvac_device/controllable_hvac_device.py index 9cb8617..fad143f 100644 --- a/custom_components/dual_smart_thermostat/hvac_device/controllable_hvac_device.py +++ b/custom_components/dual_smart_thermostat/hvac_device/controllable_hvac_device.py @@ -2,11 +2,14 @@ import logging from homeassistant.components.climate import HVACAction, HVACMode -from homeassistant.core import CALLBACK_TYPE, Context, HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, Context, HomeAssistant, State, callback from custom_components.dual_smart_thermostat.hvac_action_reason.hvac_action_reason import ( HVACActionReason, ) +from custom_components.dual_smart_thermostat.managers.environment_manager import ( + TargetTemperatures, +) _LOGGER = logging.getLogger(__name__) @@ -67,6 +70,12 @@ def async_on_remove(self, func: CALLBACK_TYPE) -> None: self._on_remove = [] self._on_remove.append(func) + @callback + def on_entity_state_change(self, entity_id: str, new_state: State) -> None: + """Handle entity state changes. Currently only for specific cases when the devices needs + to be updated based on the state of another entity.""" + pass + @callback def call_on_remove_callbacks(self) -> None: """Call callbacks registered by async_on_remove.""" @@ -107,3 +116,11 @@ def HVACActionReason(self) -> HVACActionReason: @HVACActionReason.setter def HVACActionReason(self, hvac_action_reason: HVACActionReason): self._hvac_action_reason = hvac_action_reason + + def on_entity_state_changed(self, entity_id: str, new_state: State) -> None: + """Handle entity state changes. Currently only for specific cases when the devices needs""" + pass + + def on_target_temperature_change(self, temperatures: TargetTemperatures) -> None: + """Handle target temperature changes.""" + pass diff --git a/custom_components/dual_smart_thermostat/hvac_device/cooler_device.py b/custom_components/dual_smart_thermostat/hvac_device/cooler_device.py index 5da3948..27fcab8 100644 --- a/custom_components/dual_smart_thermostat/hvac_device/cooler_device.py +++ b/custom_components/dual_smart_thermostat/hvac_device/cooler_device.py @@ -4,8 +4,14 @@ from homeassistant.components.climate import HVACAction, HVACMode from homeassistant.core import HomeAssistant -from custom_components.dual_smart_thermostat.hvac_device.specific_hvac_device import ( - SpecificHVACDevice, +from custom_components.dual_smart_thermostat.hvac_controller.cooler_controller import ( + CoolerHvacController, +) +from custom_components.dual_smart_thermostat.hvac_controller.hvac_controller import ( + HvacGoal, +) +from custom_components.dual_smart_thermostat.hvac_device.generic_hvac_device import ( + GenericHVACDevice, ) from custom_components.dual_smart_thermostat.managers.environment_manager import ( EnvironmentManager, @@ -20,7 +26,7 @@ _LOGGER = logging.getLogger(__name__) -class CoolerDevice(SpecificHVACDevice): +class CoolerDevice(GenericHVACDevice): hvac_modes = [HVACMode.COOL, HVACMode.OFF] @@ -42,6 +48,17 @@ def __init__( environment, openings, features, + hvac_goal=HvacGoal.LOWER, + ) + + self.hvac_controller = CoolerHvacController( + hass, + entity_id, + min_cycle_duration, + environment, + openings, + self.async_turn_on, + self.async_turn_off, ) @property diff --git a/custom_components/dual_smart_thermostat/hvac_device/dryer_device.py b/custom_components/dual_smart_thermostat/hvac_device/dryer_device.py index 1f5f26e..10954da 100644 --- a/custom_components/dual_smart_thermostat/hvac_device/dryer_device.py +++ b/custom_components/dual_smart_thermostat/hvac_device/dryer_device.py @@ -1,18 +1,32 @@ +from datetime import timedelta import logging from homeassistant.components.climate import HVACAction, HVACMode +from homeassistant.core import HomeAssistant from custom_components.dual_smart_thermostat.hvac_action_reason.hvac_action_reason import ( HVACActionReason, ) -from custom_components.dual_smart_thermostat.hvac_device.specific_hvac_device import ( - SpecificHVACDevice, +from custom_components.dual_smart_thermostat.hvac_controller.hvac_controller import ( + HvacGoal, +) +from custom_components.dual_smart_thermostat.hvac_device.generic_hvac_device import ( + GenericHVACDevice, +) +from custom_components.dual_smart_thermostat.managers.environment_manager import ( + EnvironmentManager, +) +from custom_components.dual_smart_thermostat.managers.feature_manager import ( + FeatureManager, +) +from custom_components.dual_smart_thermostat.managers.opening_manager import ( + OpeningManager, ) _LOGGER = logging.getLogger(__name__) -class DryerDevice(SpecificHVACDevice): +class DryerDevice(GenericHVACDevice): _target_env_attr: str = "_target_humidity" @@ -20,13 +34,13 @@ class DryerDevice(SpecificHVACDevice): def __init__( self, - hass, - entity_id, - min_cycle_duration, - initial_hvac_mode, - environment, - openings, - features, + hass: HomeAssistant, + entity_id: str, + min_cycle_duration: timedelta, + initial_hvac_mode: HVACMode, + environment: EnvironmentManager, + openings: OpeningManager, + features: FeatureManager, ) -> None: super().__init__( hass, @@ -36,6 +50,7 @@ def __init__( environment, openings, features, + hvac_goal=HvacGoal.LOWER, ) @property diff --git a/custom_components/dual_smart_thermostat/hvac_device/specific_hvac_device.py b/custom_components/dual_smart_thermostat/hvac_device/generic_hvac_device.py similarity index 59% rename from custom_components/dual_smart_thermostat/hvac_device/specific_hvac_device.py rename to custom_components/dual_smart_thermostat/hvac_device/generic_hvac_device.py index aaf068f..7cc74c9 100644 --- a/custom_components/dual_smart_thermostat/hvac_device/specific_hvac_device.py +++ b/custom_components/dual_smart_thermostat/hvac_device/generic_hvac_device.py @@ -12,12 +12,18 @@ STATE_UNKNOWN, ) from homeassistant.core import DOMAIN as HA_DOMAIN, Context, HomeAssistant -from homeassistant.helpers import condition -import homeassistant.util.dt as dt_util from custom_components.dual_smart_thermostat.hvac_action_reason.hvac_action_reason import ( HVACActionReason, ) +from custom_components.dual_smart_thermostat.hvac_controller.generic_controller import ( + GenericHvacController, +) +from custom_components.dual_smart_thermostat.hvac_controller.hvac_controller import ( + HvacController, + HvacEnvStrategy, + HvacGoal, +) from custom_components.dual_smart_thermostat.hvac_device.controllable_hvac_device import ( ControlableHVACDevice, ) @@ -39,11 +45,13 @@ _LOGGER = logging.getLogger(__name__) -class SpecificHVACDevice( +class GenericHVACDevice( HVACDevice, ControlableHVACDevice, Switchable, TargetsEnvironmentAttribute ): _target_env_attr: str = "_target_temp" + hvac_controller: HvacController + strategy: HvacEnvStrategy def __init__( self, @@ -54,13 +62,37 @@ def __init__( environment: EnvironmentManager, openings: OpeningManager, features: FeatureManager, + hvac_goal: HvacGoal, ) -> None: super().__init__(hass, environment, openings) self._device_type = self.__class__.__name__ + + # the hvac goal controls the hvac strategy + # it will decide to raise or lower the temperature, humidity or othet target attribute + self.hvac_goal = hvac_goal + self.features = features self.entity_id = entity_id self.min_cycle_duration = min_cycle_duration + self.hvac_controller: HvacController = GenericHvacController( + hass, + entity_id, + min_cycle_duration, + environment, + openings, + self.async_turn_on, + self.async_turn_off, + ) + + self.strategy = HvacEnvStrategy( + self.is_below_target_env_attr, + self.is_above_target_env_attr, + self.target_env_attr_reached_reason, + self.target_env_attr_not_reached_reason, + self.hvac_goal, + ) + if initial_hvac_mode in self.hvac_modes: self._hvac_mode = initial_hvac_mode else: @@ -72,32 +104,6 @@ def set_context(self, context: Context): def get_device_ids(self) -> list[str]: return [self.entity_id] - async def async_turn_on(self): - _LOGGER.info( - "%s. Turning on entity %s", self.__class__.__name__, self.entity_id - ) - if self.entity_id is not None and self.hass.states.is_state( - self.entity_id, STATE_OFF - ): - - data = {ATTR_ENTITY_ID: self.entity_id} - await self.hass.services.async_call( - HA_DOMAIN, SERVICE_TURN_ON, data, context=self._context - ) - - async def async_turn_off(self): - _LOGGER.info( - "%s. Turning off entity %s", self.__class__.__name__, self.entity_id - ) - if self.entity_id is not None and self.hass.states.is_state( - self.entity_id, STATE_ON - ): - - data = {ATTR_ENTITY_ID: self.entity_id} - await self.hass.services.async_call( - HA_DOMAIN, SERVICE_TURN_OFF, data, context=self._context - ) - @property def target_env_attr(self) -> str: return self._target_env_attr @@ -111,32 +117,29 @@ def is_active(self) -> bool: return True return False - def _ran_long_enough(self) -> bool: - if self.is_active: - current_state = STATE_ON - else: - current_state = HVACMode.OFF + def is_below_target_env_attr(self) -> bool: + """is too cold?""" + return self.environment.is_too_cold(self.target_env_attr) - _LOGGER.debug("Checking if device ran long enough: %s", self.entity_id) - _LOGGER.debug("current_state: %s", current_state) - _LOGGER.debug("min_cycle_duration: %s", self.min_cycle_duration) - _LOGGER.debug("time: %s", dt_util.utcnow()) + def is_above_target_env_attr(self) -> bool: + """is too hot?""" + return self.environment.is_too_hot(self.target_env_attr) - long_enough = condition.state( - self.hass, - self.entity_id, - current_state, - self.min_cycle_duration, - ) + def target_env_attr_reached_reason(self) -> HVACActionReason: + return HVACActionReason.TARGET_TEMP_REACHED - return long_enough + def target_env_attr_not_reached_reason(self) -> HVACActionReason: + return HVACActionReason.TARGET_TEMP_NOT_REACHED def _set_self_active(self) -> None: """Checks if active state needs to be set true.""" + + target_temp = getattr(self.environment, self.target_env_attr) + _LOGGER.debug("_active: %s", self._active) _LOGGER.debug("cur_temp: %s", self.environment.cur_temp) _LOGGER.debug("target_env_attr: %s", self.target_env_attr) - target_temp = getattr(self.environment, self.target_env_attr) + _LOGGER.debug("hvac_mode: %s", self.hvac_mode) _LOGGER.debug("target_temp: %s", target_temp) if ( @@ -151,28 +154,6 @@ def _set_self_active(self) -> None: target_temp, ) - def _needs_control(self, time=None, force=False) -> bool: - """Checks if the controller needs to continue.""" - if not self._active or self._hvac_mode == HVACMode.OFF: - _LOGGER.debug( - "Not active or hvac mode is off active: %s, _hvac_mode: %s", - self._active, - self._hvac_mode, - ) - return False - - if not force and time is None: - # If the `force` argument is True, we - # ignore `min_cycle_duration`. - # If the `time` argument is not none, we were invoked for - # keep-alive purposes, and `min_cycle_duration` is irrelevant. - if self.min_cycle_duration: - _LOGGER.debug( - "Checking if device ran long enough: %s", self._ran_long_enough() - ) - return self._ran_long_enough() - return True - async def async_control_hvac(self, time=None, force=False): """Controls the HVAC of the device.""" @@ -182,83 +163,48 @@ async def async_control_hvac(self, time=None, force=False): time, force, ) + self._set_self_active() - _LOGGER.debug("_needs_control: %s", self._needs_control(time, force)) - if not self._needs_control(time, force): + _LOGGER.debug( + "needs_control: %s", + self.hvac_controller.needs_control( + self._active, self.hvac_mode, time, force + ), + ) + + if not self.hvac_controller.needs_control( + self._active, self.hvac_mode, time, force + ): return + any_opeing_open = self.openings.any_opening_open(self.hvac_mode) + _LOGGER.debug( - "%s - async_control_hvac - is device active: %s, %s, is opening open: %s", + "%s - async_control_hvac - is device active: %s, %s, strategy: %s, is opening open: %s", self._device_type, self.entity_id, self.is_active, - self.openings.any_opening_open(self.hvac_mode), + self.strategy, + any_opeing_open, ) if self.is_active: - await self._async_control_when_active(time) + await self.hvac_controller.async_control_device_when_on( + self.strategy, + any_opeing_open, + time, + ) + # await self._async_control_when_active(time) else: - await self._async_control_when_inactive(time) - - def is_below_target_env_attr(self) -> bool: - """is too cold?""" - return self.environment.is_too_cold(self.target_env_attr) - - def is_above_target_env_attr(self) -> bool: - """is too hot?""" - return self.environment.is_too_hot(self.target_env_attr) - - def target_env_attr_reached_reason(self) -> HVACActionReason: - return HVACActionReason.TARGET_TEMP_REACHED - - def target_env_attr_not_reached_reason(self) -> HVACActionReason: - return HVACActionReason.TARGET_TEMP_NOT_REACHED - - async def _async_control_when_active(self, time=None) -> None: - _LOGGER.debug("%s _async_control_when_active", self.__class__.__name__) - below_env_attr = self.is_below_target_env_attr() - any_opening_open = self.openings.any_opening_open(self.hvac_mode) - - if below_env_attr or any_opening_open: - _LOGGER.debug("Turning off entity %s", self.entity_id) - await self.async_turn_off() - if below_env_attr: - self._hvac_action_reason = self.target_env_attr_reached_reason() - if any_opening_open: - self._hvac_action_reason = HVACActionReason.OPENING - - elif time is not None and not any_opening_open: - # The time argument is passed only in keep-alive case - _LOGGER.debug( - "Keep-alive - Turning on entity (from active) %s", - self.entity_id, + await self.hvac_controller.async_control_device_when_off( + self.strategy, + any_opeing_open, + time, ) - await self.async_turn_on() - self._hvac_action_reason = self.target_env_attr_not_reached_reason() - - async def _async_control_when_inactive(self, time=None) -> None: - above_env_attr = self.is_above_target_env_attr() - any_opening_open = self.openings.any_opening_open(self.hvac_mode) - - _LOGGER.debug("above_env_attr: %s", above_env_attr) - _LOGGER.debug("any_opening_open: %s", any_opening_open) - _LOGGER.debug("is_active: %s", self.is_active) - _LOGGER.debug("time: %s", time) - - if above_env_attr and not any_opening_open: - _LOGGER.debug("Turning on entity (from inactive) %s", self.entity_id) - await self.async_turn_on() - self._hvac_action_reason = self.target_env_attr_not_reached_reason() - elif time is not None or any_opening_open: - # The time argument is passed only in keep-alive case - _LOGGER.debug("Keep-alive - Turning off entity %s", self.entity_id) - await self.async_turn_off() + # await self._async_control_when_inactive(time) - if any_opening_open: - self._hvac_action_reason = HVACActionReason.OPENING - else: - _LOGGER.debug("No case matched") + self._hvac_action_reason = self.hvac_controller.hvac_action_reason async def async_on_startup(self): entity_state = self.hass.states.get(self.entity_id) @@ -276,3 +222,29 @@ async def _async_check_device_initial_state(self) -> None: self.entity_id, ) await self.async_turn_off() + + async def async_turn_on(self): + _LOGGER.info( + "%s. Turning on entity %s", self.__class__.__name__, self.entity_id + ) + if self.entity_id is not None and self.hass.states.is_state( + self.entity_id, STATE_OFF + ): + + data = {ATTR_ENTITY_ID: self.entity_id} + await self.hass.services.async_call( + HA_DOMAIN, SERVICE_TURN_ON, data, context=self._context + ) + + async def async_turn_off(self): + _LOGGER.info( + "%s. Turning off entity %s", self.__class__.__name__, self.entity_id + ) + if self.entity_id is not None and self.hass.states.is_state( + self.entity_id, STATE_ON + ): + + data = {ATTR_ENTITY_ID: self.entity_id} + await self.hass.services.async_call( + HA_DOMAIN, SERVICE_TURN_OFF, data, context=self._context + ) diff --git a/custom_components/dual_smart_thermostat/hvac_device/heat_pump_device.py b/custom_components/dual_smart_thermostat/hvac_device/heat_pump_device.py new file mode 100644 index 0000000..4381079 --- /dev/null +++ b/custom_components/dual_smart_thermostat/hvac_device/heat_pump_device.py @@ -0,0 +1,223 @@ +import logging + +from homeassistant.components.climate import HVACMode +from homeassistant.const import STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import State, callback + +from custom_components.dual_smart_thermostat.hvac_controller.cooler_controller import ( + CoolerHvacController, +) +from custom_components.dual_smart_thermostat.hvac_controller.heater_controller import ( + HeaterHvacConroller, +) +from custom_components.dual_smart_thermostat.hvac_controller.hvac_controller import ( + HvacEnvStrategy, + HvacGoal, +) +from custom_components.dual_smart_thermostat.hvac_device.generic_hvac_device import ( + GenericHVACDevice, +) +from custom_components.dual_smart_thermostat.hvac_device.hvac_device import ( + merge_hvac_modes, +) +from custom_components.dual_smart_thermostat.managers.environment_manager import ( + TargetTemperatures, +) + +_LOGGER = logging.getLogger(__name__) + + +class HeatPumpDevice(GenericHVACDevice): + + hvac_modes = [HVACMode.OFF] + + def __init__( + self, + hass, + entity_id, + min_cycle_duration, + initial_hvac_mode, + environment, + openings, + features, + ) -> None: + super().__init__( + hass, + entity_id, + min_cycle_duration, + initial_hvac_mode, + environment, + openings, + features, + hvac_goal=HvacGoal.RAISE, # will not take effect as we will define new controllers + ) + + _LOGGER.debug("HeatPumpDevice.__init__") + + self.heating_strategy = HvacEnvStrategy( + self.is_below_target_env_attr, + self.is_above_target_env_attr, + self.target_env_attr_reached_reason, + self.target_env_attr_not_reached_reason, + HvacGoal.RAISE, + ) + + self.cooling_strategy = HvacEnvStrategy( + self.is_below_target_env_attr, + self.is_above_target_env_attr, + self.target_env_attr_reached_reason, + self.target_env_attr_not_reached_reason, + HvacGoal.LOWER, + ) + + self.heating_controller = HeaterHvacConroller( + hass, + entity_id, + min_cycle_duration, + environment, + openings, + self.async_turn_on, + self.async_turn_off, + ) + + self.cooling_controller = CoolerHvacController( + hass, + entity_id, + min_cycle_duration, + environment, + openings, + self.async_turn_on, + self.async_turn_off, + ) + + # HEAT or COOL mode availabiiity is determined by the current state of the + # het pumps current mode provided by the CONF_HEAT_PUMP_COOLING inputs' state + # If the heat pump is currently in cooling mode, then the device will support + # COOL mode, and vice versa for HEAT mode + + self._apply_heat_pump_cooling_state() + + if features.is_configured_for_heat_cool_mode: + self.hvac_modes = merge_hvac_modes(self.hvac_modes, [HVACMode.HEAT_COOL]) + + @property + def target_env_attr(self) -> str: + + if self.features.is_range_mode: + if self._heat_pump_is_cooling: + return "_target_temp_high" + else: + return "_target_temp_low" + else: + return self._target_env_attr + + @callback + def on_entity_state_changed(self, entity_id: str, new_state: State) -> None: + """Hndles state change of the heat pump cooling entity. In order to determine + if the heat pump is currently in cooling mode.""" + + super().on_entity_state_change(entity_id, new_state) + + if ( + self.features.heat_pump_cooling_entity_id is None + or entity_id != self.features.heat_pump_cooling_entity_id + ): + return + + _LOGGER.info("Handling heat_pump_cooling_entity_id state change") + + self._apply_heat_pump_cooling_state(new_state) + + def _apply_heat_pump_cooling_state(self, state: State = None) -> None: + """Applies the state of the heat pump cooling entity to the device.""" + entity_id = self.features.heat_pump_cooling_entity_id + entity_state = state or self.hass.states.get(entity_id) + + _LOGGER.debug( + "Heat pump cooling entity state: %s, %s", + entity_id, + entity_state, + ) + + if entity_state and entity_state.state not in ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + + self._heat_pump_is_cooling = entity_state.state == STATE_ON + else: + _LOGGER.warning( + "Heat pump cooling entity state is unknown or unavailable: %s", + entity_state, + ) + self._heat_pump_is_cooling = False + + self._change_hvac_strategy(self._heat_pump_is_cooling) + self._change_hvac_modes(self._heat_pump_is_cooling) + self._change_hvac_mode(self._heat_pump_is_cooling) + + def _change_hvac_strategy(self, heat_pump_is_cooling: bool) -> None: + """Changes the HVAC strategy based on the heat pump's current mode.""" + + if heat_pump_is_cooling: + self.strategy = self.cooling_strategy + self.hvac_controller = self.cooling_controller + + else: + self.strategy = self.heating_strategy + self.hvac_controller = self.heating_controller + + def _change_hvac_modes(self, heat_pump_is_cooling: bool) -> None: + """Changes the HVAC modes based on the heat pump's current mode.""" + hvac_mode_set = set(self.hvac_modes) + if heat_pump_is_cooling: + hvac_mode_set.discard(HVACMode.HEAT) + hvac_mode_set.add(HVACMode.COOL) + self.hvac_modes = list(hvac_mode_set) + + else: + hvac_mode_set.discard(HVACMode.COOL) + hvac_mode_set.add(HVACMode.HEAT) + self.hvac_modes = list(hvac_mode_set) + + def _change_hvac_mode(self, heat_pump_is_cooling: bool) -> None: + """Changes the HVAC mode based on the heat pump's current mode.""" + _LOGGER.debug("Changing hvac mode based on heat pump mode") + if ( + self.hvac_mode is not None + and self.hvac_mode is not HVACMode.OFF + and self.hvac_mode not in self.hvac_modes + ): + if heat_pump_is_cooling: + self.hvac_mode = HVACMode.COOL + else: + self.hvac_mode = HVACMode.HEAT + + # override + def on_target_temperature_change(self, temperatures: TargetTemperatures) -> None: + super().on_target_temperature_change(temperatures) + + # handle if het_pump is configured and we are in heat_cool mode + # and the range is set to the value that doesn't make sens for the current + # heat pump mode. + if not self.features.is_range_mode: + return + + current_temp = self.environment.cur_temp + if current_temp is None: + _LOGGER.warning("Current temperature is None") + return + + if self._heat_pump_is_cooling: + if temperatures.temp_low > current_temp: + _LOGGER.warning( + "Heat pump is in cooling mode, setting the lower target temperature makes no effect until the het pump switches to heating mode" + ) + else: + _LOGGER.warning( + "temp_high: %s, current_temp: %s", temperatures.temp_high, current_temp + ) + if temperatures.temp_high < current_temp: + _LOGGER.warning( + "Heat pump is in heating mode, setting the higher target temperature makes no effect until the het pump switches to cooling mode" + ) diff --git a/custom_components/dual_smart_thermostat/hvac_device/heater_cooler_device.py b/custom_components/dual_smart_thermostat/hvac_device/heater_cooler_device.py index f846bce..a741cbf 100644 --- a/custom_components/dual_smart_thermostat/hvac_device/heater_cooler_device.py +++ b/custom_components/dual_smart_thermostat/hvac_device/heater_cooler_device.py @@ -87,7 +87,7 @@ async def async_control_hvac(self, time=None, force: bool = False): await super().async_control_hvac(time, force) def is_cold_or_hot(self) -> tuple[bool, bool, ToleranceDevice]: - """Check if the floor is too cold or too hot.""" + """Check if the environment is too cold or too hot.""" _LOGGER.debug("is_cold_or_hot") _LOGGER.debug("heater_device.is_active: %s", self.heater_device.is_active) @@ -118,7 +118,7 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode): await super().async_set_hvac_mode(hvac_mode) async def _async_control_heat_cool(self, time=None, force=False) -> None: - """Check if we need to turn heating on or off.""" + """Check if we need to turn heating or cooling on or off.""" _LOGGER.info("_async_control_heat_cool. time: %s, force: %s", time, force) if not self._active and self.environment.cur_temp is not None: diff --git a/custom_components/dual_smart_thermostat/hvac_device/heater_device.py b/custom_components/dual_smart_thermostat/hvac_device/heater_device.py index 1bcb225..3aff426 100644 --- a/custom_components/dual_smart_thermostat/hvac_device/heater_device.py +++ b/custom_components/dual_smart_thermostat/hvac_device/heater_device.py @@ -4,11 +4,14 @@ from homeassistant.components.climate import HVACAction, HVACMode from homeassistant.core import HomeAssistant -from custom_components.dual_smart_thermostat.hvac_action_reason.hvac_action_reason import ( - HVACActionReason, +from custom_components.dual_smart_thermostat.hvac_controller.heater_controller import ( + HeaterHvacConroller, ) -from custom_components.dual_smart_thermostat.hvac_device.specific_hvac_device import ( - SpecificHVACDevice, +from custom_components.dual_smart_thermostat.hvac_controller.hvac_controller import ( + HvacGoal, +) +from custom_components.dual_smart_thermostat.hvac_device.generic_hvac_device import ( + GenericHVACDevice, ) from custom_components.dual_smart_thermostat.managers.environment_manager import ( EnvironmentManager, @@ -23,7 +26,7 @@ _LOGGER = logging.getLogger(__name__) -class HeaterDevice(SpecificHVACDevice): +class HeaterDevice(GenericHVACDevice): hvac_modes = [HVACMode.HEAT, HVACMode.OFF] @@ -45,6 +48,17 @@ def __init__( environment, openings, features, + hvac_goal=HvacGoal.RAISE, + ) + + self.hvac_controller = HeaterHvacConroller( + hass, + entity_id, + min_cycle_duration, + environment, + openings, + self.async_turn_on, + self.async_turn_off, ) @property @@ -60,79 +74,3 @@ def hvac_action(self) -> HVACAction: if self.is_active: return HVACAction.HEATING return HVACAction.IDLE - - async def async_control_hvac(self, time=None, force=False): - _LOGGER.debug({self.__class__.__name__}) - _LOGGER.debug("async_control_hvac") - self._set_self_active() - - if not self._needs_control(time, force): - _LOGGER.debug("No need for control") - return - - _LOGGER.debug("Needs control") - - if self.is_active: - await self._async_control_device_when_on(time) - else: - await self._async_control_device_when_off(time) - - async def _async_control_device_when_on(self, time=None) -> None: - """Check if we need to turn heating on or off when theheater is on.""" - too_hot = self.environment.is_too_hot(self.target_env_attr) - is_floor_hot = self.environment.is_floor_hot - is_floor_cold = self.environment.is_floor_cold - any_opening_open = self.openings.any_opening_open(self.hvac_mode) - - _LOGGER.debug("_async_control_device_when_on, floor cold: %s", is_floor_cold) - _LOGGER.debug("_async_control_device_when_on, too_hot: %s", too_hot) - - if ((too_hot or is_floor_hot) or any_opening_open) and not is_floor_cold: - _LOGGER.debug("Turning off heater %s", self.entity_id) - - await self.async_turn_off() - - if too_hot: - self._hvac_action_reason = HVACActionReason.TARGET_TEMP_REACHED - if is_floor_hot: - self._hvac_action_reason = HVACActionReason.OVERHEAT - if any_opening_open: - self._hvac_action_reason = HVACActionReason.OPENING - - elif time is not None and not any_opening_open and not is_floor_hot: - # The time argument is passed only in keep-alive case - _LOGGER.info( - "Keep-alive - Turning on heater (from active) %s", - self.entity_id, - ) - self._hvac_action_reason = HVACActionReason.TARGET_TEMP_NOT_REACHED - await self.async_turn_on() - - async def _async_control_device_when_off(self, time=None) -> None: - """Check if we need to turn heating on or off when the heater is off.""" - _LOGGER.debug("%s _async_control_device_when_off", self.__class__.__name__) - - too_cold = self.environment.is_too_cold(self.target_env_attr) - is_floor_hot = self.environment.is_floor_hot - is_floor_cold = self.environment.is_floor_cold - any_opening_open = self.openings.any_opening_open(self.hvac_mode) - - if (too_cold and not any_opening_open and not is_floor_hot) or is_floor_cold: - _LOGGER.debug("Turning on heater (from inactive) %s", self.entity_id) - - await self.async_turn_on() - - if is_floor_cold: - self._hvac_action_reason = HVACActionReason.LIMIT - else: - self._hvac_action_reason = HVACActionReason.TARGET_TEMP_NOT_REACHED - - elif time is not None or any_opening_open or is_floor_hot: - # The time argument is passed only in keep-alive case - _LOGGER.debug("Keep-alive - Turning off heater %s", self.entity_id) - await self.async_turn_off() - - if is_floor_hot: - self._hvac_action_reason = HVACActionReason.OVERHEAT - if any_opening_open: - self._hvac_action_reason = HVACActionReason.OPENING diff --git a/custom_components/dual_smart_thermostat/hvac_device/hvac_device.py b/custom_components/dual_smart_thermostat/hvac_device/hvac_device.py index 6feb249..c5ab1b7 100644 --- a/custom_components/dual_smart_thermostat/hvac_device/hvac_device.py +++ b/custom_components/dual_smart_thermostat/hvac_device/hvac_device.py @@ -5,6 +5,9 @@ from homeassistant.components.climate import HVACMode from homeassistant.core import Context, HomeAssistant +from custom_components.dual_smart_thermostat.hvac_controller.hvac_controller import ( + HvacGoal, +) from custom_components.dual_smart_thermostat.managers.environment_manager import ( EnvironmentManager, ) @@ -44,6 +47,7 @@ class HVACDevice: _active: bool hvac_modes: list[HVACMode] + hvac_goal: HvacGoal def __init__( self, diff --git a/custom_components/dual_smart_thermostat/hvac_device/hvac_device_factory.py b/custom_components/dual_smart_thermostat/hvac_device/hvac_device_factory.py index 7727752..67c5087 100644 --- a/custom_components/dual_smart_thermostat/hvac_device/hvac_device_factory.py +++ b/custom_components/dual_smart_thermostat/hvac_device/hvac_device_factory.py @@ -12,6 +12,7 @@ CONF_DRYER, CONF_FAN, CONF_FAN_ON_WITH_AC, + CONF_HEAT_PUMP_COOLING, CONF_HEATER, CONF_INITIAL_HVAC_MODE, CONF_MIN_DUR, @@ -27,6 +28,9 @@ ) from custom_components.dual_smart_thermostat.hvac_device.dryer_device import DryerDevice from custom_components.dual_smart_thermostat.hvac_device.fan_device import FanDevice +from custom_components.dual_smart_thermostat.hvac_device.heat_pump_device import ( + HeatPumpDevice, +) from custom_components.dual_smart_thermostat.hvac_device.heater_aux_heater_device import ( HeaterAUXHeaterDevice, ) @@ -77,6 +81,7 @@ def __init__( self._fan_on_with_cooler = config.get(CONF_FAN_ON_WITH_AC) self._dryer_entity_id = config.get(CONF_DRYER) + self._heat_pump_cooling_entity_id = config.get(CONF_HEAT_PUMP_COOLING) self._aux_heater_entity_id = config.get(CONF_AUX_HEATER) self._aux_heater_dual_mode = config.get(CONF_AUX_HEATING_DUAL_MODE) @@ -90,7 +95,6 @@ def create_device( self, environment: EnvironmentManager, openings: OpeningManager ) -> ControlableHVACDevice: - self.environment = environment dryer_device = None fan_device = None cooler_device = None @@ -118,6 +122,7 @@ def create_device( openings, self._features, ) + if self._features.is_configured_for_fan_mode: fan_device = FanDevice( self.hass, @@ -153,11 +158,24 @@ def create_device( environment, openings, cooler_entity_id, fan_device ) + if self._features.is_configured_for_heat_pump_mode: + heater_device = HeatPumpDevice( + self.hass, + self._heater_entity_id, + self._min_cycle_duration, + self._initial_hvac_mode, + environment, + openings, + self._features, + ) + if ( self._heater_entity_id and not self._features.is_configured_for_cooler_mode and not self._features.is_configured_for_fan_only_mode + and not self._features.is_configured_for_heat_pump_mode ): + """Create a heater device if no other specific device is configured""" heater_device = HeaterDevice( self.hass, self._heater_entity_id, diff --git a/custom_components/dual_smart_thermostat/managers/__init__.py b/custom_components/dual_smart_thermostat/managers/__init__.py new file mode 100644 index 0000000..857eafa --- /dev/null +++ b/custom_components/dual_smart_thermostat/managers/__init__.py @@ -0,0 +1 @@ +"""Manager Module""" diff --git a/custom_components/dual_smart_thermostat/managers/environment_manager.py b/custom_components/dual_smart_thermostat/managers/environment_manager.py index fb56c09..a937d92 100644 --- a/custom_components/dual_smart_thermostat/managers/environment_manager.py +++ b/custom_components/dual_smart_thermostat/managers/environment_manager.py @@ -331,18 +331,24 @@ def is_too_cold(self, target_attr="_target_temp") -> bool: return False target_temp = getattr(self, target_attr) _LOGGER.debug( - "Target temp attr: %s, Target temp: %s, current temp: %s", + "Target temp attr: %s, Target temp: %s, current temp: %s, tolerance: %s", target_attr, target_temp, self._cur_temp, + self._cold_tolerance, ) return target_temp >= self._cur_temp + self._cold_tolerance def is_too_hot(self, target_attr="_target_temp") -> bool: """Checks if the current temperature is above target.""" - _LOGGER.debug( - "is_too_hot, %s, %s, %s", self._cur_temp, target_attr, self._hot_tolerance - ) + # _LOGGER.debug( + # "is_too_hot, %s, %s, %s, %s, ishot: %s", + # self._cur_temp, + # target_attr, + # getattr(self, target_attr), + # self._hot_tolerance, + # self._cur_temp >= getattr(self, target_attr) + self._hot_tolerance, + # ) if self._cur_temp is None: return False target_temp = getattr(self, target_attr) diff --git a/custom_components/dual_smart_thermostat/managers/feature_manager.py b/custom_components/dual_smart_thermostat/managers/feature_manager.py index 227f6e8..6952ddc 100644 --- a/custom_components/dual_smart_thermostat/managers/feature_manager.py +++ b/custom_components/dual_smart_thermostat/managers/feature_manager.py @@ -23,6 +23,7 @@ CONF_FAN_MODE, CONF_FAN_ON_WITH_AC, CONF_HEAT_COOL_MODE, + CONF_HEAT_PUMP_COOLING, CONF_HEATER, CONF_HUMIDITY_SENSOR, ) @@ -56,6 +57,7 @@ def __init__( self._dryer_entity_id = config.get(CONF_DRYER) self._humidity_sensor_entity_id = config.get(CONF_HUMIDITY_SENSOR) + self._heat_pump_cooling_entity_id = config.get(CONF_HEAT_PUMP_COOLING) self._aux_heater_entity_id = config.get(CONF_AUX_HEATER) self._aux_heater_timeout = config.get(CONF_AUX_HEATING_TIMEOUT) @@ -66,6 +68,10 @@ def __init__( ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) + @property + def heat_pump_cooling_entity_id(self) -> str: + return self._heat_pump_cooling_entity_id + @property def supported_features(self) -> int: """Return the supported features.""" @@ -169,6 +175,11 @@ def is_configured_for_dryer_mode(self) -> bool: and self._humidity_sensor_entity_id is not None ) + @property + def is_configured_for_heat_pump_mode(self) -> bool: + """Determines if the heat pump cooling is configured.""" + return self._heat_pump_cooling_entity_id is not None + def set_support_flags( self, presets: dict[str, Any], diff --git a/tests/__init__.py b/tests/__init__.py index d1c7163..6ec4c38 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1050,6 +1050,24 @@ def log_call(call) -> None: return calls +def setup_heat_pump_cooling_status(hass: HomeAssistant, is_on: bool) -> None: + """Set up the test switch.""" + hass.states.async_set( + common.ENT_HEAT_PUMP_COOLING, STATE_ON if is_on else STATE_OFF + ) + calls = [] + + @callback + def log_call(call) -> None: + """Log service calls.""" + calls.append(call) + + hass.services.async_register(ha.DOMAIN, SERVICE_TURN_ON, log_call) + hass.services.async_register(ha.DOMAIN, SERVICE_TURN_OFF, log_call) + + return calls + + def setup_switch_dual( hass: HomeAssistant, second_switch: str, is_on: bool, is_second_on: bool ) -> None: diff --git a/tests/common.py b/tests/common.py index a7ed3b5..2e585f8 100644 --- a/tests/common.py +++ b/tests/common.py @@ -67,6 +67,7 @@ ENT_COOLER = "input_boolean.test_cooler" ENT_FAN = "switch.test_fan" ENT_DRYER = "switch.test_dryer" +ENT_HEAT_PUMP_COOLING = "switch.test_heat_pump_cooling" MIN_TEMP = 3.0 MAX_TEMP = 65.0 TARGET_TEMP = 42.0 diff --git a/tests/test_dry_mode.py b/tests/test_dry_mode.py index 0fb5a90..1d2f485 100644 --- a/tests/test_dry_mode.py +++ b/tests/test_dry_mode.py @@ -476,9 +476,8 @@ async def test_toggle( to_hvac_mode, setup_comp_heat_ac_cool_dry, # noqa: F811 ) -> None: - """Test change mode from OFF to COOL. - - Switch turns on when temp below setpoint and mode changes. + """Test change mode from from_hvac_mode to to_hvac_mode. + And toggle resumes from to_hvac_mode """ await common.async_set_hvac_mode(hass, from_hvac_mode) await common.async_toggle(hass) @@ -497,7 +496,7 @@ async def test_toggle( async def test_hvac_mode_cdry( hass: HomeAssistant, setup_comp_heat_ac_cool_dry # noqa: F811 ) -> None: - """Test change mode from OFF to COOL. + """Test change mode from OFF to DRY. Switch turns on when temp below setpoint and mode changes. """ diff --git a/tests/test_heat_pump_mode.py b/tests/test_heat_pump_mode.py new file mode 100644 index 0000000..7102263 --- /dev/null +++ b/tests/test_heat_pump_mode.py @@ -0,0 +1,815 @@ +"""The tests for the Heat Pump Mode.""" + +import logging +from tkinter import FALSE + +from homeassistant.components import input_boolean, input_number +from homeassistant.components.climate import ( + PRESET_ACTIVITY, + PRESET_AWAY, + PRESET_BOOST, + PRESET_COMFORT, + PRESET_ECO, + PRESET_HOME, + PRESET_NONE, + PRESET_SLEEP, + HVACMode, +) +from homeassistant.components.climate.const import ( + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + DOMAIN as CLIMATE, +) +from homeassistant.const import ( + ATTR_TEMPERATURE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, +) +from homeassistant.core import DOMAIN as HASS_DOMAIN, HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component +from homeassistant.util.unit_system import METRIC_SYSTEM +import pytest + +from custom_components.dual_smart_thermostat.const import DOMAIN, PRESET_ANTI_FREEZE + +from . import ( # noqa: F401 + common, + setup_comp_1, + setup_heat_pump_cooling_status, + setup_sensor, + setup_switch, +) + +_LOGGER = logging.getLogger(__name__) + +################### +# COMMON FEATURES # +################### + + +async def test_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry, setup_comp_1 # noqa: F811 +) -> None: + """Test setting a unique ID.""" + unique_id = "some_unique_id" + heater_switch = "input_boolean.test" + heat_pump_cooling_switch = "input_boolean.test2" + assert await async_setup_component( + hass, + input_boolean.DOMAIN, + {"input_boolean": {"test": None, "test2": None}}, + ) + + assert await async_setup_component( + hass, + input_number.DOMAIN, + { + "input_number": { + "temp": {"name": "test", "initial": 10, "min": 0, "max": 40, "step": 1} + } + }, + ) + assert await async_setup_component( + hass, + CLIMATE, + { + "climate": { + "platform": DOMAIN, + "name": "test", + "heater": heater_switch, + "target_sensor": common.ENT_SENSOR, + "initial_hvac_mode": HVACMode.HEAT, + "heat_pump_cooling": heat_pump_cooling_switch, + "unique_id": unique_id, + } + }, + ) + await hass.async_block_till_done() + + entry = entity_registry.async_get(common.ENTITY) + assert entry + assert entry.unique_id == unique_id + + +async def test_setup_defaults_to_unknown(hass: HomeAssistant) -> None: # noqa: F811 + """Test the setting of defaults to unknown.""" + heater_switch = "input_boolean.test" + heat_pump_cooling_switch = "input_boolean.test2" + assert await async_setup_component( + hass, + CLIMATE, + { + "climate": { + "platform": DOMAIN, + "name": "test", + "heater": heater_switch, + "heat_pump_cooling": heat_pump_cooling_switch, + "target_sensor": common.ENT_SENSOR, + } + }, + ) + await hass.async_block_till_done() + assert hass.states.get(common.ENTITY).state == HVACMode.OFF + + +async def test_setup_gets_current_temperature_from_sensor( + hass: HomeAssistant, +) -> None: # noqa: F811 + """Test that current temperature is updated on entity addition.""" + hass.config.units = METRIC_SYSTEM + setup_sensor(hass, 24) + await hass.async_block_till_done() + + assert await async_setup_component( + hass, + CLIMATE, + { + "climate": { + "platform": DOMAIN, + "name": "test", + "cold_tolerance": 2, + "hot_tolerance": 4, + "heater": common.ENT_HEATER, + "heat_pump_cooling": common.ENT_HEAT_PUMP_COOLING, + "target_sensor": common.ENT_SENSOR, + } + }, + ) + await hass.async_block_till_done() + assert hass.states.get(common.ENTITY).attributes["current_temperature"] == 24 + + +################### +# CHANGE SETTINGS # +################### + + +@pytest.fixture +async def setup_comp_heat_pump(hass: HomeAssistant) -> None: + """Initialize components.""" + hass.config.units = METRIC_SYSTEM + assert await async_setup_component( + hass, + CLIMATE, + { + "climate": { + "platform": DOMAIN, + "name": "test", + "cold_tolerance": 2, + "hot_tolerance": 4, + "heater": common.ENT_SWITCH, + "heat_pump_cooling": common.ENT_HEAT_PUMP_COOLING, + "target_sensor": common.ENT_SENSOR, + } + }, + ) + await hass.async_block_till_done() + + +@pytest.mark.parametrize( + ("dual_mode", "cooling_mode", "hvac_modes"), + [ + (False, STATE_ON, [HVACMode.COOL, HVACMode.OFF]), + (False, STATE_OFF, [HVACMode.HEAT, HVACMode.OFF]), + (True, STATE_ON, [HVACMode.COOL, HVACMode.HEAT_COOL, HVACMode.OFF]), + (True, STATE_OFF, [HVACMode.HEAT, HVACMode.HEAT_COOL, HVACMode.OFF]), + ], +) +async def test_get_hvac_modes( + hass: HomeAssistant, + setup_comp_1, # noqa: F811 + dual_mode, + cooling_mode, + hvac_modes, # noqa: F811 +) -> None: + """Test that the operation list returns the correct modes.""" + # heater_switch = "input_boolean.test" + heat_pump_cooling_switch = "input_boolean.test2" + assert await async_setup_component( + hass, + CLIMATE, + { + "climate": { + "platform": DOMAIN, + "name": "test", + "cold_tolerance": 2, + "hot_tolerance": 4, + "heater": common.ENT_SWITCH, + "heat_pump_cooling": heat_pump_cooling_switch, + "target_sensor": common.ENT_SENSOR, + "heat_cool_mode": dual_mode, + PRESET_AWAY: {"temperature": 30}, + } + }, + ) + await hass.async_block_till_done() + hass.states.async_set("input_boolean.test2", cooling_mode) + + state = hass.states.get(common.ENTITY) + modes = state.attributes.get("hvac_modes") + assert set(modes) == set(hvac_modes) + + +@pytest.fixture +async def setup_comp_heat_pump_presets(hass: HomeAssistant) -> None: + """Initialize components.""" + hass.config.units = METRIC_SYSTEM + assert await async_setup_component( + hass, + CLIMATE, + { + "climate": { + "platform": DOMAIN, + "name": "test", + "cold_tolerance": 2, + "hot_tolerance": 4, + "heater": common.ENT_SWITCH, + "heat_pump_cooling": common.ENT_HEAT_PUMP_COOLING, + "target_sensor": common.ENT_SENSOR, + PRESET_AWAY: { + "temperature": 16, + }, + PRESET_COMFORT: { + "temperature": 20, + }, + PRESET_ECO: { + "temperature": 18, + }, + PRESET_HOME: { + "temperature": 19, + }, + PRESET_SLEEP: { + "temperature": 17, + }, + PRESET_ACTIVITY: { + "temperature": 21, + }, + PRESET_BOOST: { + "temperature": 10, + }, + "anti_freeze": { + "temperature": 5, + }, + } + }, + ) + await hass.async_block_till_done() + + +@pytest.fixture +async def setup_comp_heat_pump_heat_cool_presets(hass: HomeAssistant) -> None: + """Initialize components.""" + hass.config.units = METRIC_SYSTEM + assert await async_setup_component( + hass, + CLIMATE, + { + "climate": { + "platform": DOMAIN, + "name": "test", + "cold_tolerance": 2, + "hot_tolerance": 4, + "heater": common.ENT_SWITCH, + "heat_pump_cooling": common.ENT_HEAT_PUMP_COOLING, + "target_sensor": common.ENT_SENSOR, + "heat_cool_mode": True, + PRESET_AWAY: { + "temperature": 16, + "target_temp_low": 16, + "target_temp_high": 30, + }, + PRESET_COMFORT: { + "temperature": 20, + "target_temp_low": 20, + "target_temp_high": 27, + }, + PRESET_ECO: { + "temperature": 18, + "target_temp_low": 18, + "target_temp_high": 29, + }, + PRESET_HOME: { + "temperature": 19, + "target_temp_low": 19, + "target_temp_high": 23, + }, + PRESET_SLEEP: { + "temperature": 17, + "target_temp_low": 17, + "target_temp_high": 24, + }, + PRESET_ACTIVITY: { + "temperature": 21, + "target_temp_low": 21, + "target_temp_high": 28, + }, + PRESET_BOOST: { + "temperature": 10, + "target_temp_low": 10, + "target_temp_high": 21, + }, + "anti_freeze": { + "temperature": 5, + "target_temp_low": 5, + "target_temp_high": 32, + }, + } + }, + ) + await hass.async_block_till_done() + + +@pytest.mark.parametrize( + ("preset", "temp"), + [ + (PRESET_NONE, 23), + (PRESET_AWAY, 16), + (PRESET_ACTIVITY, 21), + (PRESET_COMFORT, 20), + (PRESET_ECO, 18), + (PRESET_HOME, 19), + (PRESET_SLEEP, 17), + (PRESET_BOOST, 10), + (PRESET_ANTI_FREEZE, 5), + ], +) +async def test_set_preset_mode( + hass: HomeAssistant, + setup_comp_heat_pump_presets, + preset, + temp, # noqa: F811 +) -> None: + """Test the setting preset mode.""" + await common.async_set_temperature(hass, 23) + await common.async_set_preset_mode(hass, preset) + state = hass.states.get(common.ENTITY) + assert state.attributes.get(ATTR_TEMPERATURE) == temp + + +@pytest.mark.parametrize( + ("preset", "temp_low", "temp_high"), + [ + (PRESET_NONE, 18, 22), + (PRESET_AWAY, 16, 30), + (PRESET_COMFORT, 20, 27), + (PRESET_ECO, 18, 29), + (PRESET_HOME, 19, 23), + (PRESET_SLEEP, 17, 24), + (PRESET_ACTIVITY, 21, 28), + (PRESET_BOOST, 10, 21), + (PRESET_ANTI_FREEZE, 5, 32), + ], +) +async def test_set_preset_mode_heat_cool( + hass: HomeAssistant, + setup_comp_heat_pump_heat_cool_presets, + preset, + temp_low, + temp_high, # noqa: F811 +) -> None: + """Test the setting preset mode.""" + setup_sensor(hass, 23) + await common.async_set_temperature(hass, 23, common.ENTITY, 22, 18) + await common.async_set_preset_mode(hass, preset) + state = hass.states.get(common.ENTITY) + assert state.attributes.get(ATTR_TARGET_TEMP_LOW) == temp_low + assert state.attributes.get(ATTR_TARGET_TEMP_HIGH) == temp_high + + +@pytest.mark.parametrize( + ("preset", "temp"), + [ + (PRESET_NONE, 23), + (PRESET_AWAY, 16), + (PRESET_ACTIVITY, 21), + (PRESET_COMFORT, 20), + (PRESET_ECO, 18), + (PRESET_HOME, 19), + (PRESET_SLEEP, 17), + (PRESET_BOOST, 10), + (PRESET_ANTI_FREEZE, 5), + ], +) +async def test_set_preset_mode_and_restore_prev_temp( + hass: HomeAssistant, + setup_comp_heat_pump_presets, + preset, + temp, # noqa: F811 +) -> None: + """Test the setting preset mode.""" + await common.async_set_temperature(hass, 23) + await common.async_set_preset_mode(hass, preset) + state = hass.states.get(common.ENTITY) + assert state.attributes.get(ATTR_TEMPERATURE) == temp + + await common.async_set_preset_mode(hass, PRESET_NONE) + state = hass.states.get(common.ENTITY) + assert state.attributes.get(ATTR_TEMPERATURE) == 23 + + +@pytest.mark.parametrize( + ("preset", "temp_low", "temp_high"), + [ + (PRESET_NONE, 18, 22), + (PRESET_AWAY, 16, 30), + (PRESET_COMFORT, 20, 27), + (PRESET_ECO, 18, 29), + (PRESET_HOME, 19, 23), + (PRESET_SLEEP, 17, 24), + (PRESET_ACTIVITY, 21, 28), + (PRESET_BOOST, 10, 21), + (PRESET_ANTI_FREEZE, 5, 32), + ], +) +async def test_set_preset_mode_heat_cool_and_restore_prev_temp( + hass: HomeAssistant, + setup_comp_heat_pump_heat_cool_presets, + preset, + temp_low, + temp_high, # noqa: F811 +) -> None: + """Test the setting preset mode.""" + setup_sensor(hass, 23) + await common.async_set_temperature(hass, 23, common.ENTITY, 22, 18) + await common.async_set_preset_mode(hass, preset) + state = hass.states.get(common.ENTITY) + assert state.attributes.get(ATTR_TARGET_TEMP_LOW) == temp_low + assert state.attributes.get(ATTR_TARGET_TEMP_HIGH) == temp_high + + await common.async_set_preset_mode(hass, PRESET_NONE) + state = hass.states.get(common.ENTITY) + assert state.attributes.get(ATTR_TARGET_TEMP_LOW) == 18 + assert state.attributes.get(ATTR_TARGET_TEMP_HIGH) == 22 + + +@pytest.mark.parametrize( + ("preset", "temp"), + [ + (PRESET_NONE, 23), + (PRESET_AWAY, 16), + (PRESET_ACTIVITY, 21), + (PRESET_COMFORT, 20), + (PRESET_ECO, 18), + (PRESET_HOME, 19), + (PRESET_SLEEP, 17), + (PRESET_BOOST, 10), + (PRESET_ANTI_FREEZE, 5), + ], +) +async def test_set_preset_mode_twice_and_restore_prev_temp( + hass: HomeAssistant, + setup_comp_heat_pump_presets, + preset, + temp, # noqa: F811 +) -> None: + """Test the setting preset mode.""" + await common.async_set_temperature(hass, 23) + await common.async_set_preset_mode(hass, preset) + await common.async_set_preset_mode(hass, preset) + state = hass.states.get(common.ENTITY) + assert state.attributes.get(ATTR_TEMPERATURE) == temp + + await common.async_set_preset_mode(hass, PRESET_NONE) + state = hass.states.get(common.ENTITY) + assert state.attributes.get(ATTR_TEMPERATURE) == 23 + + +@pytest.mark.parametrize( + ("preset", "temp_low", "temp_high"), + [ + (PRESET_NONE, 18, 22), + (PRESET_AWAY, 16, 30), + (PRESET_COMFORT, 20, 27), + (PRESET_ECO, 18, 29), + (PRESET_HOME, 19, 23), + (PRESET_SLEEP, 17, 24), + (PRESET_ACTIVITY, 21, 28), + (PRESET_BOOST, 10, 21), + (PRESET_ANTI_FREEZE, 5, 32), + ], +) +async def test_set_preset_mode_heat_cool_twice_and_restore_prev_temp( + hass: HomeAssistant, + setup_comp_heat_pump_heat_cool_presets, + preset, + temp_low, + temp_high, # noqa: F811 +) -> None: + """Test the setting preset mode.""" + setup_sensor(hass, 23) + await common.async_set_temperature(hass, 23, common.ENTITY, 22, 18) + await common.async_set_preset_mode(hass, preset) + await common.async_set_preset_mode(hass, preset) + state = hass.states.get(common.ENTITY) + assert state.attributes.get(ATTR_TARGET_TEMP_LOW) == temp_low + assert state.attributes.get(ATTR_TARGET_TEMP_HIGH) == temp_high + + await common.async_set_preset_mode(hass, PRESET_NONE) + state = hass.states.get(common.ENTITY) + assert state.attributes.get(ATTR_TARGET_TEMP_LOW) == 18 + assert state.attributes.get(ATTR_TARGET_TEMP_HIGH) == 22 + + +async def test_set_preset_mode_invalid( + hass: HomeAssistant, + setup_comp_heat_pump_presets, # noqa: F811 +) -> None: + """Test the setting invalid preset mode.""" + await common.async_set_temperature(hass, 23) + await common.async_set_preset_mode(hass, PRESET_AWAY) + state = hass.states.get(common.ENTITY) + assert state.attributes.get("preset_mode") == PRESET_AWAY + await common.async_set_preset_mode(hass, PRESET_NONE) + state = hass.states.get(common.ENTITY) + assert state.attributes.get("preset_mode") == PRESET_NONE + with pytest.raises(ServiceValidationError): + await common.async_set_preset_mode(hass, "Sleep") + state = hass.states.get(common.ENTITY) + assert state.attributes.get("preset_mode") == PRESET_NONE + + +@pytest.mark.parametrize( + ("preset", "temp"), + [ + (PRESET_NONE, 23), + (PRESET_AWAY, 16), + (PRESET_ACTIVITY, 21), + (PRESET_COMFORT, 20), + (PRESET_ECO, 18), + (PRESET_HOME, 19), + (PRESET_SLEEP, 17), + (PRESET_BOOST, 10), + (PRESET_ANTI_FREEZE, 5), + ], +) +async def test_set_preset_mode_set_temp_keeps_preset_mode( + hass: HomeAssistant, + setup_comp_heat_pump_presets, + preset, + temp, # noqa: F811 +) -> None: + """Test the setting preset mode then set temperature. + + Verify preset mode preserved while temperature updated. + """ + target_temp = 32 + await common.async_set_temperature(hass, 23) + await common.async_set_preset_mode(hass, preset) + + state = hass.states.get(common.ENTITY) + assert state.attributes.get(ATTR_TEMPERATURE) == temp + + await common.async_set_temperature(hass, target_temp) + assert state.attributes.get("supported_features") == 401 + + state = hass.states.get(common.ENTITY) + assert state.attributes.get(ATTR_TEMPERATURE) == target_temp + assert state.attributes.get("preset_mode") == preset + assert state.attributes.get("supported_features") == 401 + await common.async_set_preset_mode(hass, PRESET_NONE) + + state = hass.states.get(common.ENTITY) + if preset == PRESET_NONE: + assert state.attributes.get(ATTR_TEMPERATURE) == target_temp + else: + assert state.attributes.get(ATTR_TEMPERATURE) == 23 + + +@pytest.mark.parametrize( + ("preset", "temp_low", "temp_high"), + [ + (PRESET_NONE, 18, 22), + (PRESET_AWAY, 16, 30), + (PRESET_COMFORT, 20, 27), + (PRESET_ECO, 18, 29), + (PRESET_HOME, 19, 23), + (PRESET_SLEEP, 17, 24), + (PRESET_ACTIVITY, 21, 28), + (PRESET_BOOST, 10, 21), + (PRESET_ANTI_FREEZE, 5, 32), + ], +) +async def test_set_preset_mode_heat_cool_set_temp_keeps_preset_mode( + hass: HomeAssistant, + setup_comp_heat_pump_heat_cool_presets, + preset, + temp_low, + temp_high, # noqa: F811 +) -> None: + """Test the setting preset mode then set temperature. + + Verify preset mode preserved while temperature updated. + """ + target_temp_high = 32 + target_temp_low = 18 + await common.async_set_temperature(hass, 23, common.ENTITY, 22, 18) + await common.async_set_preset_mode(hass, preset) + + state = hass.states.get(common.ENTITY) + assert state.attributes.get(ATTR_TARGET_TEMP_LOW) == temp_low + assert state.attributes.get(ATTR_TARGET_TEMP_HIGH) == temp_high + + await common.async_set_temperature( + hass, 18, common.ENTITY, target_temp_high, target_temp_low + ) + assert state.attributes.get("supported_features") == 402 + + state = hass.states.get(common.ENTITY) + assert state.attributes.get(ATTR_TARGET_TEMP_LOW) == target_temp_low + assert state.attributes.get(ATTR_TARGET_TEMP_HIGH) == target_temp_high + assert state.attributes.get("preset_mode") == preset + assert state.attributes.get("supported_features") == 402 + await common.async_set_preset_mode(hass, PRESET_NONE) + + state = hass.states.get(common.ENTITY) + if preset == PRESET_NONE: + assert state.attributes.get(ATTR_TARGET_TEMP_LOW) == target_temp_low + assert state.attributes.get(ATTR_TARGET_TEMP_HIGH) == target_temp_high + else: + assert state.attributes.get(ATTR_TARGET_TEMP_LOW) == 18 + assert state.attributes.get(ATTR_TARGET_TEMP_HIGH) == 22 + + +# async def test_set_target_temp_off( +# hass: HomeAssistant, setup_comp_heat_pump # noqa: F811 +# ) -> None: +# """Test if target temperature turn heat pump off.""" +# # setup_sensor(hass, 23) + +# setup_heat_pump_cooling_status(hass, STATE_OFF) +# await hass.async_block_till_done() +# await common.async_set_hvac_mode(hass, HVACMode.HEAT) +# calls = setup_switch(hass, True) +# await hass.async_block_till_done() +# await common.async_set_temperature(hass, 23) +# assert len(calls) == 1 +# call = calls[0] +# assert call.domain == HASS_DOMAIN +# assert call.service == SERVICE_TURN_OFF +# assert call.data["entity_id"] == common.ENT_SWITCH + +################### +# HVAC OPERATIONS # +################### + + +@pytest.mark.parametrize( + ["heat_pump_cooling", "from_hvac_mode", "to_hvac_mode"], + [ + [True, HVACMode.OFF, HVACMode.COOL], + [True, HVACMode.COOL, HVACMode.OFF], + [False, HVACMode.OFF, HVACMode.HEAT], + [False, HVACMode.HEAT, HVACMode.OFF], + ], +) +async def test_toggle( + hass: HomeAssistant, + heat_pump_cooling, + from_hvac_mode, + to_hvac_mode, + setup_comp_heat_pump, # noqa: F811 +) -> None: + """Test change mode from from_hvac_mode to to_hvac_mode. + And toggle resumes from to_hvac_mode + """ + setup_heat_pump_cooling_status(hass, heat_pump_cooling) + await common.async_set_hvac_mode(hass, from_hvac_mode) + await hass.async_block_till_done() + + await common.async_toggle(hass) + await hass.async_block_till_done() + + state = hass.states.get(common.ENTITY) + assert state.state == to_hvac_mode + + await common.async_toggle(hass) + await hass.async_block_till_done() + + state = hass.states.get(common.ENTITY) + assert state.state == from_hvac_mode + + +async def test_hvac_mode_cool( + hass: HomeAssistant, setup_comp_heat_pump # noqa: F811 +) -> None: + """Test change mode from OFF to COOL. + + Switch turns on when temp below setpoint and mode changes. + """ + setup_heat_pump_cooling_status(hass, True) + await common.async_set_hvac_mode(hass, HVACMode.OFF) + await common.async_set_temperature(hass, 23) + setup_sensor(hass, 28) + await hass.async_block_till_done() + calls = setup_switch(hass, False) + await common.async_set_hvac_mode(hass, HVACMode.COOL) + await hass.async_block_till_done() + + assert len(calls) == 1 + call = calls[0] + assert call.domain == HASS_DOMAIN + assert call.service == SERVICE_TURN_ON + assert call.data["entity_id"] == common.ENT_SWITCH + + +async def test_hvac_mode_heat( + hass: HomeAssistant, setup_comp_heat_pump # noqa: F811 +) -> None: + """Test change mode from OFF to COOL. + + Switch turns on when temp below setpoint and mode changes. + """ + setup_heat_pump_cooling_status(hass, FALSE) + await common.async_set_hvac_mode(hass, HVACMode.OFF) + await common.async_set_temperature(hass, 26) + setup_sensor(hass, 23) + await hass.async_block_till_done() + calls = setup_switch(hass, False) + await common.async_set_hvac_mode(hass, HVACMode.HEAT) + await hass.async_block_till_done() + + assert len(calls) == 1 + call = calls[0] + assert call.domain == HASS_DOMAIN + assert call.service == SERVICE_TURN_ON + assert call.data["entity_id"] == common.ENT_SWITCH + + +async def test_hvac_mode_heat_switches_to_cool( + hass: HomeAssistant, setup_comp_heat_pump # noqa: F811 +) -> None: + """Test change mode from OFF to COOL. + + Switch turns on when temp below setpoint and mode changes. + """ + setup_heat_pump_cooling_status(hass, False) + await common.async_set_hvac_mode(hass, HVACMode.OFF) + await common.async_set_temperature(hass, 26) + setup_sensor(hass, 23) + await hass.async_block_till_done() + calls = setup_switch(hass, False) + await common.async_set_hvac_mode(hass, HVACMode.HEAT) + await hass.async_block_till_done() + + assert len(calls) == 1 + call = calls[0] + assert call.domain == HASS_DOMAIN + assert call.service == SERVICE_TURN_ON + assert call.data["entity_id"] == common.ENT_SWITCH + + calls = setup_switch(hass, True) + setup_heat_pump_cooling_status(hass, True) + await hass.async_block_till_done() + state = hass.states.get(common.ENTITY) + + # hvac mode should have changed to COOL + assert state.state == HVACMode.COOL + + # switch has to be turned off + assert len(calls) == 1 + call = calls[0] + assert call.domain == HASS_DOMAIN + assert call.service == SERVICE_TURN_OFF + assert call.data["entity_id"] == common.ENT_SWITCH + + +async def test_hvac_mode_cool_switches_to_heat( + hass: HomeAssistant, setup_comp_heat_pump # noqa: F811 +) -> None: + """Test change mode from OFF to COOL. + + Switch turns on when temp below setpoint and mode changes. + """ + setup_heat_pump_cooling_status(hass, True) + await common.async_set_hvac_mode(hass, HVACMode.OFF) + await common.async_set_temperature(hass, 22) + setup_sensor(hass, 26) + await hass.async_block_till_done() + calls = setup_switch(hass, False) + await common.async_set_hvac_mode(hass, HVACMode.COOL) + await hass.async_block_till_done() + + assert len(calls) == 1 + call = calls[0] + assert call.domain == HASS_DOMAIN + assert call.service == SERVICE_TURN_ON + assert call.data["entity_id"] == common.ENT_SWITCH + + calls = setup_switch(hass, True) + setup_heat_pump_cooling_status(hass, False) + await hass.async_block_till_done() + state = hass.states.get(common.ENTITY) + + # hvac mode should have changed to COOL + assert state.state == HVACMode.HEAT + + # switch has to be turned off + assert len(calls) == 1 + call = calls[0] + assert call.domain == HASS_DOMAIN + assert call.service == SERVICE_TURN_OFF + assert call.data["entity_id"] == common.ENT_SWITCH