diff --git a/.devcontainer/configuration.yaml b/.devcontainer/configuration.yaml index 4b316eb..263caf5 100644 --- a/.devcontainer/configuration.yaml +++ b/.devcontainer/configuration.yaml @@ -86,6 +86,7 @@ switch: climate: - platform: dual_smart_thermostat name: Heat Cool Room + unique_id: heat_cool_room heater: switch.heater cooler: switch.cooler openings: @@ -138,6 +139,8 @@ climate: min_temp: 15 max_temp: 28 target_temp: 23 + target_temp_high: 26 + target_temp_low: 23 cold_tolerance: 0.3 hot_tolerance: 0 min_cycle_duration: diff --git a/.github/workflows/hacs-validate.yaml b/.github/workflows/hacs-validate.yaml index 4e4af3b..1cf4041 100644 --- a/.github/workflows/hacs-validate.yaml +++ b/.github/workflows/hacs-validate.yaml @@ -2,7 +2,12 @@ name: Validate with HACS on: push: + branches: + - master + pull_request: + branches: '*' + schedule: - cron: '0 0 * * *' diff --git a/.github/workflows/linting.yaml b/.github/workflows/linting.yaml index e7a607f..db8335b 100644 --- a/.github/workflows/linting.yaml +++ b/.github/workflows/linting.yaml @@ -1,7 +1,13 @@ name: Linting -on: [push, pull_request] +on: + push: + branches: + - master + + pull_request: + branches: '*' jobs: lint: diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 32cb5de..0a3e704 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -1,6 +1,12 @@ name: Python tests -on: [push, pull_request] +on: + push: + branches: + - master + + pull_request: + branches: '*' jobs: tests: diff --git a/custom_components/dual_smart_thermostat/climate.py b/custom_components/dual_smart_thermostat/climate.py index 0b90ade..76205ca 100644 --- a/custom_components/dual_smart_thermostat/climate.py +++ b/custom_components/dual_smart_thermostat/climate.py @@ -1,9 +1,12 @@ """Adds support for dual smart thermostat units.""" import asyncio +from datetime import timedelta +from distutils.log import debug import logging from tkinter import OFF from typing import List +from sqlalchemy import false import voluptuous as vol @@ -211,7 +214,7 @@ def __init__( self.sensor_floor_entity_id = sensor_floor_entity_id self.opening_entities: List = opening_entities self.ac_mode = ac_mode - self.min_cycle_duration = min_cycle_duration + self.min_cycle_duration: timedelta = min_cycle_duration self._cold_tolerance = cold_tolerance self._hot_tolerance = hot_tolerance self._keep_alive = keep_alive @@ -495,7 +498,7 @@ async def async_set_hvac_mode(self, hvac_mode): elif hvac_mode == HVACMode.COOL: self._hvac_mode = HVACMode.COOL self._support_flags = SUPPORT_TARGET_TEMPERATURE - await self._async_control_heating(force=True) + await self._async_control_cooling(force=True) elif hvac_mode == HVACMode.HEAT_COOL: self._hvac_mode = HVACMode.HEAT_COOL self._support_flags = SUPPORT_TARGET_TEMPERATURE_RANGE @@ -514,21 +517,23 @@ async def async_set_hvac_mode(self, hvac_mode): async def async_set_temperature(self, **kwargs): """Set new target temperature.""" temperature = kwargs.get(ATTR_TEMPERATURE) + temp_low = kwargs.get(ATTR_TARGET_TEMP_LOW) + temp_high = kwargs.get(ATTR_TARGET_TEMP_HIGH) + if ( self._hvac_mode not in (HVACMode.HEAT_COOL, HVACMode.OFF) and temperature is None ): return - temp_low = kwargs.get(ATTR_TARGET_TEMP_LOW) - temp_high = kwargs.get(ATTR_TARGET_TEMP_HIGH) self._target_temp = temperature - if self._hvac_mode == HVACMode.HEAT_COOL and ( - temp_high is None or temp_low is None - ): + if temp_high is None or temp_low is None: + await self._async_control_climate() + self.async_write_ha_state() return + self._target_temp = temp_low self._target_temp_high = temp_high self._target_temp_low = temp_low @@ -596,10 +601,14 @@ async def _async_opening_changed(self, event): self.async_write_ha_state() async def _async_control_climate(self, time=None, force=False): - if self.cooler_entity_id is not None: - await self._async_control_heat_cool(force) + if self.cooler_entity_id is not None and self.hvac_mode == HVACMode.HEAT_COOL: + await self._async_control_heat_cool(time, force) + elif self.ac_mode is True or ( + self.cooler_entity_id is not None and self.hvac_mode == HVACMode.COOL + ): + await self._async_control_cooling(time, force) else: - await self._async_control_heating(force) + await self._async_control_heating(time, force) @callback def _async_switch_changed(self, event): @@ -636,47 +645,17 @@ def _async_update_floor_temp(self, state): async def _async_control_heating(self, time=None, force=False): """Check if we need to turn heating on or off.""" async with self._temp_lock: - if not self._active and None not in (self._cur_temp, self._target_temp): - self._active = True - _LOGGER.info( - "Obtained current and target temperature. " - "Dual smart thermostat active. %s, %s", - self._cur_temp, - self._target_temp, - ) + _LOGGER.debug("_async_control_heating") + self.set_self_active() - if not self._active or self._hvac_mode == HVACMode.OFF: + if not self._needs_control(time, force): return - 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: - if self._is_device_active: - current_state = STATE_ON - else: - current_state = HVACMode.OFF - long_enough = condition.state( - self.hass, - self.heater_entity_id, - current_state, - self.min_cycle_duration, - ) - if not long_enough: - return - - too_cold = self._target_temp >= self._cur_temp + self._cold_tolerance - too_hot = self._cur_temp >= self._target_temp + self._hot_tolerance + too_cold = self._is_too_cold() + too_hot = self._is_too_hot() if self._is_device_active: - if ( - (self.ac_mode and too_cold) - or (not self.ac_mode and too_hot) - or (not self.ac_mode and self._is_floor_hot) - or self._is_opening_open - ): + if too_hot or self._is_floor_hot or self._is_opening_open: _LOGGER.info("Turning off heater %s", self.heater_entity_id) await self._async_heater_turn_off() elif ( @@ -691,12 +670,7 @@ async def _async_control_heating(self, time=None, force=False): ) await self._async_heater_turn_on() else: - if (self.ac_mode and too_hot and not self._is_opening_open) or ( - not self.ac_mode - and too_cold - and not self._is_opening_open - and not self._is_floor_hot - ): + if too_cold and not self._is_opening_open and not self._is_floor_hot: _LOGGER.info( "Turning on heater (from inactive) %s", self.heater_entity_id ) @@ -708,40 +682,53 @@ async def _async_control_heating(self, time=None, force=False): ) await self._async_heater_turn_off() - async def _async_control_heat_cool(self, time=None, force=False): + async def _async_control_cooling(self, time=None, force=False): """Check if we need to turn heating on or off.""" async with self._temp_lock: - if not self._active and None not in ( - self._cur_temp, - self._target_temp_high, - self._target_temp_low, - ): - self._active = True - if not self._active or self._hvac_mode == HVACMode.OFF: + _LOGGER.debug("_async_control_cooling") + self.set_self_active() + + if not self._needs_control(time, force): return - 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: - if self._is_device_active: - current_state = STATE_ON - else: - current_state = HVACMode.OFF - long_enough = condition.state( - self.hass, + too_cold = self._is_too_cold() + too_hot = self._is_too_hot() + + if self._is_device_active: + if too_cold or self._is_opening_open: + _LOGGER.info("Turning off cooler %s", self.heater_entity_id) + await self._async_heater_turn_off() + elif time is not None and not self._is_opening_open: + # The time argument is passed only in keep-alive case + _LOGGER.info( + "Keep-alive - Turning on cooler (from active) %s", self.heater_entity_id, - self.cooler_entity_id, - current_state, - self.min_cycle_duration, ) - if not long_enough: - return + await self._async_heater_turn_on() + else: + if too_hot and not self._is_opening_open: + _LOGGER.info( + "Turning on cooler (from inactive) %s", self.heater_entity_id + ) + await self._async_heater_turn_on() + elif time is not None or self._is_opening_open or self._is_floor_hot: + # The time argument is passed only in keep-alive case + _LOGGER.info( + "Keep-alive - Turning off cooler %s", self.heater_entity_id + ) + await self._async_heater_turn_off() - too_cold = self._target_temp_low >= self._cur_temp + self._cold_tolerance - too_hot = self._cur_temp >= self._target_temp_high + self._hot_tolerance + async def _async_control_heat_cool(self, time=None, force=False): + """Check if we need to turn heating on or off.""" + async with self._temp_lock: + _LOGGER.debug("_async_control_heat_cool") + if not self._active and self._is_configured_for_heat_cool(): + self._active = True + if not self._needs_control(time, force, True): + return + + too_cold = self._is_too_cold("_target_temp_low") + too_hot = self._is_too_hot("_target_temp_high") if self._is_opening_open: await self._async_heater_turn_off() @@ -864,10 +851,90 @@ async def async_set_preset_mode(self, preset_mode: str): self._is_away = True self._saved_target_temp = self._target_temp self._target_temp = self._away_temp - await self._async_control_heating(force=True) + await self._async_control_climate(force=True) elif preset_mode == PRESET_NONE and self._is_away: self._is_away = False self._target_temp = self._saved_target_temp - await self._async_control_heating(force=True) + await self._async_control_climate(force=True) self.async_write_ha_state() + + def set_self_active(self): + """checks if active state needs to be set true""" + if ( + not self._active + and None not in (self._cur_temp, self._target_temp) + and self._hvac_mode != HVACMode.OFF + ): + self._active = True + _LOGGER.info( + "Obtained current and target temperature. " + "Dual smart thermostat active. %s, %s", + self._cur_temp, + self._target_temp, + ) + + def _needs_control(self, time=None, force=False, dual=False): + """checks if the controller needs to continue""" + if not self._active or self._hvac_mode == HVACMode.OFF: + 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: + return self._needs_cycle(dual) + return True + + def _needs_cycle(self, dual=False): + long_enough = self._ran_long_enough() + if not dual and not long_enough: + return False + + if self.cooler_entity_id is not None: + long_enough_cooler = self._ran_long_enough(self.cooler_entity_id) + if True not in (long_enough, long_enough_cooler): + return False + + return True + + def _is_too_cold(self, target_attr="_target_temp") -> bool: + """checks if the current temperature is below target""" + target_temp = getattr(self, target_attr) + 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""" + target_temp = getattr(self, target_attr) + return self._cur_temp >= target_temp + self._hot_tolerance + + def _is_configured_for_heat_cool(self) -> bool: + """checks if the configuration is complete for heat/cool mode""" + return None not in ( + self._cur_temp, + self._target_temp_high, + self._target_temp_low, + ) + + def _ran_long_enough(self, entity_id=None): + """determines if a switch with the passed property name has run long enough""" + if entity_id is None: + switch_entity_id = self.heater_entity_id + else: + switch_entity_id = entity_id + + if self._is_device_active: + current_state = STATE_ON + else: + current_state = HVACMode.OFF + + long_enough = condition.state( + self.hass, + switch_entity_id, + current_state, + self.min_cycle_duration, + ) + + return long_enough diff --git a/tests/test_thermostat.py b/tests/test_thermostat.py index 1854a51..6e2b8bf 100644 --- a/tests/test_thermostat.py +++ b/tests/test_thermostat.py @@ -11,6 +11,8 @@ from homeassistant.util import dt from homeassistant.components.climate.const import ( DOMAIN as CLIMATE, + HVAC_MODE_HEAT, + HVAC_MODE_COOL, ) from homeassistant.const import ( ATTR_ENTITY_ID, @@ -55,6 +57,7 @@ TARGET_TEMP_STEP = 0.5 SERVICE_SET_TEMPERATURE = "set_temperature" +SERVICE_SET_HVAC_MODE = "set_hvac_mode" INPUT_SET_VALUE = "set_value" ENTITY_MATCH_ALL: Final = "all" @@ -255,6 +258,57 @@ async def test_heater_cooler_mode(hass, setup_comp_1): assert hass.states.get(cooler_switch).state == STATE_OFF +async def test_heater_cooler_switch_hvac_modes(hass, setup_comp_1): + """Test thermostat heater and cooler switch to heater only mode.""" + + heater_switch = "input_boolean.heater" + cooler_switch = "input_boolean.cooler" + assert await async_setup_component( + hass, + input_boolean.DOMAIN, + {"input_boolean": {"heater": None, "cooler": None}}, + ) + + temp_input = "input_number.temp" + 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": DUAL_SMART_THERMOSTAT, + "name": "test", + "cooler": cooler_switch, + "heater": heater_switch, + "target_sensor": temp_input, + "initial_hvac_mode": HVACMode.HEAT_COOL, + } + }, + ) + await hass.async_block_till_done() + + assert hass.states.get(heater_switch).state == STATE_OFF + assert hass.states.get(cooler_switch).state == STATE_OFF + + _setup_sensor(hass, temp_input, 26) + await hass.async_block_till_done() + await async_set_hvac_mode(hass, "all", HVACMode.HEAT) + + assert hass.states.get("climate.test").state == HVAC_MODE_HEAT + + await async_set_hvac_mode(hass, "all", HVACMode.COOL) + assert hass.states.get("climate.test").state == HVAC_MODE_COOL + + def _setup_sensor(hass, sensor, temp): """Set up the test sensor.""" hass.states.async_set(sensor, temp) @@ -284,3 +338,23 @@ async def async_set_temperature( await hass.services.async_call( CLIMATE, SERVICE_SET_TEMPERATURE, kwargs, blocking=True ) + + +async def async_set_hvac_mode( + hass, + entity_id="all", + hvac_mode=HVACMode.OFF, +): + """Set new HVAC mode.""" + kwargs = { + key: value + for key, value in [ + (ATTR_ENTITY_ID, entity_id), + (ATTR_HVAC_MODE, hvac_mode), + ] + if value is not None + } + _LOGGER.debug("%s start data=%s", SERVICE_SET_HVAC_MODE, kwargs) + await hass.services.async_call( + CLIMATE, SERVICE_SET_HVAC_MODE, kwargs, blocking=True + )