diff --git a/config/configuration.yaml b/config/configuration.yaml index f416de7..c4f6663 100644 --- a/config/configuration.yaml +++ b/config/configuration.yaml @@ -166,8 +166,8 @@ climate: precision: 0.1 - platform: dual_smart_thermostat - name: Edge Case 1 - unique_id: edge_case_1 + name: Edge Case 80 + unique_id: edge_case_80 heater: switch.heater cooler: switch.cooler target_sensor: sensor.room_temp @@ -180,6 +180,19 @@ climate: target_temp_low: 0 target_temp_high: 50 + - platform: dual_smart_thermostat + name: Edge Case 150 + unique_id: edge_case_150 + heater: switch.heater + cooler: switch.cooler + target_sensor: sensor.room_temp + min_cycle_duration: 60 + precision: 1.0 + min_temp: 58 + max_temp: 80 + cold_tolerance: 1.0, + hot_tolerance: 1.0, + - platform: dual_smart_thermostat name: AUX Heat Room diff --git a/custom_components/dual_smart_thermostat/climate.py b/custom_components/dual_smart_thermostat/climate.py index d8c5018..8b78c14 100644 --- a/custom_components/dual_smart_thermostat/climate.py +++ b/custom_components/dual_smart_thermostat/climate.py @@ -633,6 +633,7 @@ def _set_support_flags(self) -> None: self.presets.presets, self.hvac_device.hvac_modes, self.presets.presets_range, + self._hvac_mode, ) self._attr_supported_features = self.features.supported_features _LOGGER.debug("Supported features: %s", self._attr_supported_features) @@ -647,6 +648,7 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: await self.hvac_device.async_set_hvac_mode(hvac_mode) self._hvac_mode = hvac_mode + self._set_support_flags() self._HVACActionReason = self.hvac_device.HVACActionReason @@ -663,18 +665,39 @@ async def async_set_temperature(self, **kwargs) -> None: _LOGGER.debug("Setting temperature low: %s", temp_low) _LOGGER.debug("Setting temperature high: %s", temp_high) - if self.features.is_target_mode: + if self.features.is_configured_for_heat_cool_mode: + if self.features.is_target_mode: + if temperature is None: + return + self.temperatures.set_temperature_target(temperature) + self._target_temp = self.temperatures.target_temp + + if self.hvac_device.hvac_mode == HVACMode.HEAT: + self.temperatures.set_temperature_range( + temperature, temperature, self.temperatures.target_temp_high + ) + self._target_temp_low = self.temperatures.target_temp_low + + else: + self.temperatures.set_temperature_range( + temperature, self.temperatures.target_temp_low, temperature + ) + self._target_temp_high = self.temperatures.target_temp_high + + elif self.features.is_range_mode: + self.temperatures.set_temperature_range( + temperature, temp_low, temp_high + ) + self._target_temp = self.temperatures.target_temp + self._target_temp_low = self.temperatures.target_temp_low + self._target_temp_high = self.temperatures.target_temp_high + + else: if temperature is None: return self.temperatures.set_temperature_target(temperature) self._target_temp = self.temperatures.target_temp - elif self.features.is_range_mode: - self.temperatures.set_temperature_range(temperature, temp_low, temp_high) - self._target_temp = self.temperatures.target_temp - self._target_temp_low = self.temperatures.target_temp_low - self._target_temp_high = self.temperatures.target_temp_high - await self._async_control_climate(force=True) self.async_write_ha_state() 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 78fac26..ecb22c1 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 @@ -137,6 +137,39 @@ def is_cold_or_hot(self) -> tuple[bool, bool, ToleranceDevice]: tolerance_device = ToleranceDevice.AUTO return too_cold, too_hot, tolerance_device + async def async_set_hvac_mode(self, hvac_mode: HVACMode): + _LOGGER.info("Setting hvac mode to %s of %s", hvac_mode, self.hvac_modes) + if hvac_mode in self.hvac_modes: + _LOGGER.debug("hvac mode found") + self._hvac_mode = hvac_mode + + if hvac_mode is not HVACMode.OFF: + # handles HVACmode.HEAT + if hvac_mode in self.heater_device.hvac_modes: + self.heater_device.hvac_mode = hvac_mode + elif hvac_mode == HVACMode.HEAT_COOL: + self.heater_device.hvac_mode = HVACMode.HEAT + # handles HVACmode.COOL + if hvac_mode in self.cooler_device.hvac_modes: + self.cooler_device.hvac_mode = hvac_mode + elif hvac_mode == HVACMode.HEAT_COOL: + self.cooler_device.hvac_mode = HVACMode.COOL + else: + self.heater_device.hvac_mode = hvac_mode + self.cooler_device.hvac_mode = hvac_mode + + else: + _LOGGER.debug("Hvac mode %s is not in %s", hvac_mode, self.hvac_modes) + self._hvac_mode = HVACMode.OFF + + if self._hvac_mode == HVACMode.OFF: + await self.async_turn_off() + self._HVACActionReason = HVACActionReason.NONE + else: + await self.async_control_hvac(self, force=True) + + _LOGGER.info("Hvac mode set to %s", self._hvac_mode) + async def _async_control_heat_cool(self, time=None, force=False) -> None: """Check if we need to turn heating on or off.""" diff --git a/custom_components/dual_smart_thermostat/hvac_device/specific_hvac_device.py b/custom_components/dual_smart_thermostat/hvac_device/specific_hvac_device.py index 10f541c..42e2885 100644 --- a/custom_components/dual_smart_thermostat/hvac_device/specific_hvac_device.py +++ b/custom_components/dual_smart_thermostat/hvac_device/specific_hvac_device.py @@ -13,6 +13,7 @@ ) 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, @@ -92,13 +93,18 @@ def is_active(self) -> bool: return True return False - @property + # @property def _ran_long_enough(self) -> bool: if self.is_active: current_state = STATE_ON else: current_state = HVACMode.OFF + _LOGGER.info("Checking if device ran long enough: %s", self.entity_id) + _LOGGER.info("current_state: %s", current_state) + _LOGGER.info("min_cycle_duration: %s", self.min_cycle_duration) + _LOGGER.info("time: %s", dt_util.utcnow()) + long_enough = condition.state( self.hass, self.entity_id, @@ -143,9 +149,16 @@ def _needs_control(self, time=None, force=False) -> bool: # 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._ran_long_enough + _LOGGER.debug( + "Checking if device ran long enough: %s", self._ran_long_enough() + ) + return self._ran_long_enough() return True + # def needs_cycle(self) -> bool: + # """Determines if the device needs to cycle.""" + # return self._ran_long_enough + async def async_control_hvac(self, time=None, force=False): """Controls the HVAC of the device.""" @@ -177,6 +190,7 @@ async def async_control_hvac(self, time=None, force=False): await self._async_control_when_inactive(time) async def _async_control_when_active(self, time=None) -> None: + _LOGGER.debug("%s _async_control_when_active", self.__class__.__name__) too_cold = self.temperatures.is_too_cold(self._target_temp_attr) any_opening_open = self.openings.any_opening_open diff --git a/custom_components/dual_smart_thermostat/managers/feature_manager.py b/custom_components/dual_smart_thermostat/managers/feature_manager.py index aae4397..6915f2a 100644 --- a/custom_components/dual_smart_thermostat/managers/feature_manager.py +++ b/custom_components/dual_smart_thermostat/managers/feature_manager.py @@ -11,9 +11,12 @@ from homeassistant.helpers.typing import ConfigType from custom_components.dual_smart_thermostat.const import ( + CONF_AC_MODE, CONF_AUX_HEATER, CONF_AUX_HEATING_TIMEOUT, + CONF_COOLER, CONF_HEAT_COOL_MODE, + CONF_HEATER, ) from custom_components.dual_smart_thermostat.managers.state_manager import StateManager from custom_components.dual_smart_thermostat.managers.temperature_manager import ( @@ -31,6 +34,9 @@ def __init__( self.hass = hass self.temperatures = temperatures self._supported_features = 0 + self._cooler_entity_id = config.get(CONF_COOLER) + self._heater_entity_id = config.get(CONF_HEATER) + self._ac_mode = config.get(CONF_AC_MODE) self._aux_heater_entity_id = config.get(CONF_AUX_HEATER) self._aux_heater_timeout = config.get(CONF_AUX_HEATING_TIMEOUT) @@ -63,9 +69,16 @@ def is_range_mode(self) -> bool: @property def is_configured_for_heat_cool_mode(self) -> bool: """Checks if the configuration is complete for heat/cool mode.""" - return self._heat_cool_mode or ( - self.temperatures.target_temp_high is not None - and self.temperatures.target_temp_low is not None + return ( + self._heat_cool_mode + or ( + self.temperatures.target_temp_high is not None + and self.temperatures.target_temp_low is not None + ) + or ( + self._heater_entity_id is not None + and self._cooler_entity_id is not None + ) ) @property @@ -80,7 +93,11 @@ def _is_configured_for_aux_heating_mode(self) -> bool: return True def set_support_flags( - self, presets: dict[str, Any], hvac_modes: list[HVACMode], presets_range + self, + presets: dict[str, Any], + hvac_modes: list[HVACMode], + presets_range, + current_hvac_mode: HVACMode = None, ) -> None: """Set the correct support flags based on configuration.""" _LOGGER.debug("Setting support flags") @@ -104,10 +121,24 @@ def set_support_flags( else: if self.is_target_mode and preset_mode != PRESET_NONE: self.temperatures.target_temp = self.temperatures.saved_target_temp - self._supported_features = ( - self._default_support_flags - | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE - ) + + if current_hvac_mode not in [None, HVACMode.OFF, HVACMode.HEAT_COOL]: + self._supported_features = ( + self._default_support_flags + | ClimateEntityFeature.TARGET_TEMPERATURE + ) + + if current_hvac_mode == HVACMode.HEAT: + self.temperatures.target_temp = self.temperatures.target_temp_low + + else: # can be COOL, FAN_ONLY + self.temperatures.target_temp = self.temperatures.target_temp_high + + else: + self._supported_features = ( + self._default_support_flags + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + ) _LOGGER.debug("Setting support flags to %s", self._supported_features) if len(presets_range): self._supported_features |= ClimateEntityFeature.PRESET_MODE diff --git a/tests/test_dual_mode.py b/tests/test_dual_mode.py index af02cf7..b51905c 100644 --- a/tests/test_dual_mode.py +++ b/tests/test_dual_mode.py @@ -13,6 +13,7 @@ PRESET_HOME, PRESET_NONE, PRESET_SLEEP, + HVACAction, HVACMode, ) from homeassistant.components.climate.const import ATTR_PRESET_MODE, DOMAIN as CLIMATE @@ -153,9 +154,9 @@ async def test_setup_gets_current_temp_from_sensor( # issue 80 -async def test_presets_use_case_1( - hass: HomeAssistant, -) -> None: # noqa: F811 +async def test_presets_use_case_80( + hass: HomeAssistant, setup_comp_1 # noqa: F811 +) -> None: """Test that current temperature is updated on entity addition.""" hass.config.units = METRIC_SYSTEM setup_sensor(hass, 18) @@ -195,6 +196,112 @@ async def test_presets_use_case_1( assert state.attributes[ATTR_PRESET_MODE] == PRESET_AWAY +# issue 150 +async def test_presets_use_case_150( + hass: HomeAssistant, setup_comp_1 # noqa: F811 +) -> None: # noqa: F811 + """Test that current temperature is updated on entity addition.""" + hass.config.units = METRIC_SYSTEM + setup_sensor(hass, 18) + await hass.async_block_till_done() + + assert await async_setup_component( + hass, + CLIMATE, + { + "climate": { + "platform": DOMAIN, + "name": "test", + "heater": common.ENT_HEATER, + "cooler": common.ENT_COOLER, + "target_sensor": common.ENT_SENSOR, + "min_cycle_duration": timedelta(seconds=60), + "precision": 1.0, + "min_temp": 58, + "max_temp": 80, + "cold_tolerance": 1.0, + "hot_tolerance": 1.0, + } + }, + ) + await hass.async_block_till_done() + + state = hass.states.get(common.ENTITY) + assert state.attributes["supported_features"] == 386 + + +async def test_presets_use_case_150_2( + hass: HomeAssistant, setup_comp_1 # noqa: F811 +) -> None: # noqa: F811 + """Test that current temperature is updated on entity addition.""" + hass.config.units = METRIC_SYSTEM + + 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}}, + ) + + assert await async_setup_component( + hass, + CLIMATE, + { + "climate": { + "platform": DOMAIN, + "name": "test", + "heater": heater_switch, + "cooler": cooler_switch, + "target_sensor": common.ENT_SENSOR, + # "min_cycle_duration": min_cycle_duration, + # "keep_alive": timedelta(seconds=3), + "precision": 1.0, + "min_temp": 16, + "max_temp": 32, + "target_temp": 26.5, + "target_temp_low": 23, + "target_temp_high": 26.5, + "cold_tolerance": 0.5, + "hot_tolerance": 0.5, + "initial_hvac_mode": HVACMode.OFF, + } + }, + ) + await hass.async_block_till_done() + + state = hass.states.get(common.ENTITY) + assert state.attributes["supported_features"] == 386 + + modes = state.attributes.get("hvac_modes") + assert set(modes) == set( + [HVACMode.OFF, HVACMode.HEAT, HVACMode.COOL, HVACMode.HEAT_COOL] + ) + + assert hass.states.get(heater_switch).state == STATE_OFF + assert hass.states.get(cooler_switch).state == STATE_OFF + assert hass.states.get(common.ENTITY).attributes["hvac_action"] == HVACAction.OFF + + setup_sensor(hass, 23) + await common.async_set_hvac_mode(hass, HVACMode.HEAT_COOL) + await common.async_set_temperature(hass, 18, ENTITY_MATCH_ALL, 18, 16) + await hass.async_block_till_done() + + assert hass.states.get(heater_switch).state == STATE_OFF + assert hass.states.get(cooler_switch).state == STATE_ON + + assert ( + hass.states.get(common.ENTITY).attributes["hvac_action"] == HVACAction.COOLING + ) + + setup_sensor(hass, 1) + await hass.async_block_till_done() + + assert hass.states.get(heater_switch).state == STATE_OFF + assert hass.states.get(cooler_switch).state == STATE_OFF + assert hass.states.get(common.ENTITY).attributes["hvac_action"] == HVACAction.IDLE + + async def test_default_setup_params( hass: HomeAssistant, setup_comp_dual # noqa: F811 ) -> None: @@ -470,6 +577,9 @@ async def test_hvac_mode_mode_heat_cool( assert HVACMode.HEAT_COOL in hvac_modes assert HVACMode.OFF in hvac_modes + state = hass.states.get(common.ENTITY) + assert state.attributes["supported_features"] == 386 + assert hass.states.get(heater_switch).state == STATE_OFF assert hass.states.get(cooler_switch).state == STATE_OFF @@ -478,6 +588,9 @@ async def test_hvac_mode_mode_heat_cool( setup_sensor(hass, 26) await hass.async_block_till_done() + state = hass.states.get(common.ENTITY) + assert state.attributes["supported_features"] == 386 + assert hass.states.get(heater_switch).state == STATE_OFF assert hass.states.get(cooler_switch).state == STATE_ON @@ -496,6 +609,9 @@ async def test_hvac_mode_mode_heat_cool( await common.async_set_temperature(hass, 25, ENTITY_MATCH_ALL, 25, 22) await hass.async_block_till_done() + state = hass.states.get(common.ENTITY) + assert state.attributes["supported_features"] == 385 + setup_sensor(hass, 20) await hass.async_block_till_done() @@ -519,6 +635,99 @@ async def test_hvac_mode_mode_heat_cool( await hass.async_block_till_done() assert hass.states.get(heater_switch).state == STATE_OFF + state = hass.states.get(common.ENTITY) + assert state.attributes["supported_features"] == 385 + + await common.async_set_hvac_mode(hass, HVACMode.HEAT_COOL) + await hass.async_block_till_done() + + state = hass.states.get(common.ENTITY) + assert state.attributes["supported_features"] == 386 + + +async def test_hvac_mode_mode_heat_cool_hvac_modes_temps( + hass: HomeAssistant, setup_comp_1 # noqa: F811 +): + """Test thermostat heater and cooler switch in heat/cool 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}}, + ) + + 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", + "cooler": cooler_switch, + "heater": heater_switch, + "heat_cool_mode": True, + "target_sensor": common.ENT_SENSOR, + } + }, + ) + await hass.async_block_till_done() + + state = hass.states.get(common.ENTITY) + assert state.attributes["supported_features"] == 386 + + assert hass.states.get(heater_switch).state == STATE_OFF + assert hass.states.get(cooler_switch).state == STATE_OFF + + await common.async_set_hvac_mode(hass, HVACMode.HEAT_COOL) + await common.async_set_temperature(hass, 18, ENTITY_MATCH_ALL, 25, 22) + await hass.async_block_till_done() + + state = hass.states.get(common.ENTITY) + assert state.attributes["target_temp_low"] == 22 + assert state.attributes["target_temp_high"] == 25 + assert state.attributes.get("temperature") is None + + # switch to heat only mode + await common.async_set_hvac_mode(hass, HVACMode.HEAT) + await common.async_set_temperature(hass, 24) + await hass.async_block_till_done() + + state = hass.states.get(common.ENTITY) + assert state.attributes.get("target_temp_low") is None + assert state.attributes.get("target_temp_high") is None + assert state.attributes.get("temperature") == 24 + + # switch to cool only mode + await common.async_set_hvac_mode(hass, HVACMode.COOL) + await common.async_set_temperature(hass, 26) + await hass.async_block_till_done() + + state = hass.states.get(common.ENTITY) + assert state.attributes.get("target_temp_low") is None + assert state.attributes.get("target_temp_high") is None + assert state.attributes.get("temperature") == 26 + + # switch back to heet cool mode + await common.async_set_hvac_mode(hass, HVACMode.HEAT_COOL) + await hass.async_block_till_done() + + # check if target temperatures are kept from previous steps + state = hass.states.get(common.ENTITY) + assert state.attributes["target_temp_low"] == 24 + assert state.attributes["target_temp_high"] == 26 + assert state.attributes.get("temperature") is None async def test_hvac_mode_heat_cool_floor_temp(