diff --git a/.devcontainer/configuration.yaml b/.devcontainer/configuration.yaml index 280eef1..b84cce7 100644 --- a/.devcontainer/configuration.yaml +++ b/.devcontainer/configuration.yaml @@ -60,6 +60,9 @@ input_boolean: fake_heater_switch1: name: Heater 1 icon: mdi:radiator + fake_heater_ac1: + name: Air contionner 1 + icon: mdi:air-conditioner fake_heater_4switch1: name: Heater (multiswitch1) icon: mdi:radiator @@ -114,22 +117,22 @@ climate: name: Underlying thermostat 4-1 heater: input_boolean.fake_heater_4climate1 target_sensor: input_number.fake_temperature_sensor1 - ac_mode: true + ac_mode: false - platform: generic_thermostat name: Underlying thermostat 4-2 heater: input_boolean.fake_heater_4climate2 target_sensor: input_number.fake_temperature_sensor1 - ac_mode: true + ac_mode: false - platform: generic_thermostat name: Underlying thermostat 4-3 heater: input_boolean.fake_heater_4climate3 target_sensor: input_number.fake_temperature_sensor1 - ac_mode: true + ac_mode: false - platform: generic_thermostat name: Underlying thermostat 4-4 heater: input_boolean.fake_heater_4climate4 target_sensor: input_number.fake_temperature_sensor1 - ac_mode: true + ac_mode: false - platform: generic_thermostat name: Underlying thermostat9 heater: input_boolean.fake_heater_switch3 diff --git a/.vscode/settings.json b/.vscode/settings.json index 5c3d5cf..70d273a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -14,7 +14,7 @@ "python.testing.pytestEnabled": true, "python.analysis.extraPaths": [ // "/home/vscode/core", - "/workspaces/custom_components/versatile_thermostat" + "/workspaces/versatile_thermostat/custom_components/versatile_thermostat" ], "python.formatting.provider": "none" } \ No newline at end of file diff --git a/custom_components/versatile_thermostat/climate.py b/custom_components/versatile_thermostat/climate.py index 496d744..7d62c65 100644 --- a/custom_components/versatile_thermostat/climate.py +++ b/custom_components/versatile_thermostat/climate.py @@ -428,7 +428,7 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): self._presence_on = self._presence_sensor_entity_id is not None if self._ac_mode: - self._hvac_list = [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF] + self._hvac_list = [HVACMode.COOL, HVACMode.OFF] else: self._hvac_list = [HVACMode.HEAT, HVACMode.OFF] @@ -919,6 +919,11 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): return self._hvac_list + @property + def ac_mode(self) -> bool: + """ Get the ac_mode of the Themostat""" + return self._ac_mode + @property def fan_mode(self) -> str | None: """Return the fan setting. @@ -1345,8 +1350,8 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): if preset_mode == PRESET_POWER: return self._power_temp else: - # Select _ac presets if in COOL Mode - if self._ac_mode and self._hvac_mode == HVACMode.COOL: + # Select _ac presets if in COOL Mode (or over_switch with _ac_mode) + if self._ac_mode and (self._hvac_mode == HVACMode.COOL or not self._is_over_climate): preset_mode = preset_mode + PRESET_AC_SUFFIX if self._presence_on is False or self._presence_state in [ @@ -1974,25 +1979,25 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): if self._attr_preset_mode not in [PRESET_BOOST, PRESET_COMFORT, PRESET_ECO]: return - # Change temperature with preset named _way - new_temp = None - if new_state == STATE_ON or new_state == STATE_HOME: - new_temp = self._presets[self._attr_preset_mode] - _LOGGER.info( - "%s - Someone is back home. Restoring temperature to %.2f", - self, - new_temp, - ) - else: - new_temp = self._presets_away[ - self.get_preset_away_name(self._attr_preset_mode) - ] - _LOGGER.info( - "%s - No one is at home. Apply temperature %.2f", - self, - new_temp, - ) - + # Change temperature with preset named _away + # new_temp = None + #if new_state == STATE_ON or new_state == STATE_HOME: + # new_temp = self._presets[self._attr_preset_mode] + # _LOGGER.info( + # "%s - Someone is back home. Restoring temperature to %.2f", + # self, + # new_temp, + # ) + #else: + # new_temp = self._presets_away[ + # self.get_preset_away_name(self._attr_preset_mode) + # ] + # _LOGGER.info( + # "%s - No one is at home. Apply temperature %.2f", + # self, + # new_temp, + # ) + new_temp = self.find_preset_temp(self.preset_mode) if new_temp is not None: _LOGGER.debug( "%s - presence change in temperature mode new_temp will be: %.2f", @@ -2503,6 +2508,7 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): "target_temp": self.target_temperature, "current_temp": self._cur_temp, "ext_current_temperature": self._cur_ext_temp, + "ac_mode": self._ac_mode, "current_power": self._current_power, "current_power_max": self._current_power_max, "saved_preset_mode": self._saved_preset_mode, diff --git a/custom_components/versatile_thermostat/config_flow.py b/custom_components/versatile_thermostat/config_flow.py index cf8c32f..a198771 100644 --- a/custom_components/versatile_thermostat/config_flow.py +++ b/custom_components/versatile_thermostat/config_flow.py @@ -245,6 +245,7 @@ class VersatileThermostatBaseConfigFlow(FlowHandler): vol.Optional(CONF_CLIMATE_4): selector.EntitySelector( selector.EntitySelectorConfig(domain=CLIMATE_DOMAIN), ), + vol.Optional(CONF_AC_MODE, default=False): cv.boolean, } ) diff --git a/custom_components/versatile_thermostat/underlyings.py b/custom_components/versatile_thermostat/underlyings.py index 3ac3f78..b8580a2 100644 --- a/custom_components/versatile_thermostat/underlyings.py +++ b/custom_components/versatile_thermostat/underlyings.py @@ -1,12 +1,12 @@ """ Underlying entities classes """ import logging from typing import Any +from enum import StrEnum from homeassistant.const import ATTR_ENTITY_ID, STATE_ON, UnitOfTemperature from homeassistant.exceptions import ServiceNotFound -from enum import StrEnum from homeassistant.core import HomeAssistant, DOMAIN as HA_DOMAIN, CALLBACK_TYPE from homeassistant.components.climate import ( ClimateEntity, diff --git a/tests/commons.py b/tests/commons.py index 13bcfed..c7d721c 100644 --- a/tests/commons.py +++ b/tests/commons.py @@ -5,7 +5,7 @@ from unittest.mock import patch, MagicMock import pytest # pylint: disable=unused-import from homeassistant.core import HomeAssistant, Event, EVENT_STATE_CHANGED, State -from homeassistant.const import UnitOfTemperature, STATE_ON, STATE_OFF +from homeassistant.const import UnitOfTemperature, STATE_ON, STATE_OFF, ATTR_TEMPERATURE from homeassistant.config_entries import ConfigEntryState from homeassistant.util import dt as dt_util @@ -29,14 +29,17 @@ from .const import ( # pylint: disable=unused-import MOCK_TH_OVER_4SWITCH_USER_CONFIG, MOCK_TH_OVER_CLIMATE_USER_CONFIG, MOCK_TH_OVER_SWITCH_TYPE_CONFIG, + MOCK_TH_OVER_SWITCH_AC_TYPE_CONFIG, MOCK_TH_OVER_4SWITCH_TYPE_CONFIG, MOCK_TH_OVER_CLIMATE_TYPE_CONFIG, MOCK_TH_OVER_SWITCH_TPI_CONFIG, MOCK_PRESETS_CONFIG, + MOCK_PRESETS_AC_CONFIG, MOCK_WINDOW_CONFIG, MOCK_MOTION_CONFIG, MOCK_POWER_CONFIG, MOCK_PRESENCE_CONFIG, + MOCK_PRESENCE_AC_CONFIG, MOCK_ADVANCED_CONFIG, # MOCK_DEFAULT_FEATURE_CONFIG, PRESET_BOOST, @@ -58,6 +61,19 @@ FULL_SWITCH_CONFIG = ( | MOCK_ADVANCED_CONFIG ) +FULL_SWITCH_AC_CONFIG = ( + MOCK_TH_OVER_SWITCH_USER_CONFIG + | MOCK_TH_OVER_SWITCH_AC_TYPE_CONFIG + | MOCK_TH_OVER_SWITCH_TPI_CONFIG + | MOCK_PRESETS_AC_CONFIG + | MOCK_WINDOW_CONFIG + | MOCK_MOTION_CONFIG + | MOCK_POWER_CONFIG + | MOCK_PRESENCE_AC_CONFIG + | MOCK_ADVANCED_CONFIG +) + + PARTIAL_CLIMATE_CONFIG = ( MOCK_TH_OVER_CLIMATE_USER_CONFIG | MOCK_TH_OVER_CLIMATE_TYPE_CONFIG @@ -83,7 +99,7 @@ _LOGGER = logging.getLogger(__name__) class MockClimate(ClimateEntity): """A Mock Climate class used for Underlying climate mode""" - def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos, hvac_mode:HVACMode = HVACMode.OFF) -> None: + def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos, hvac_mode:HVACMode = HVACMode.OFF) -> None: # pylint: disable=unused-argument """Initialize the thermostat.""" super().__init__() @@ -101,12 +117,13 @@ class MockClimate(ClimateEntity): self._attr_target_temperature = 20 self._attr_current_temperature = 15 - def set_temperature(self, temperature): + def set_temperature(self, **kwargs): """ Set the target temperature""" + temperature = kwargs.get(ATTR_TEMPERATURE) self._attr_target_temperature = temperature self.async_write_ha_state() - def async_set_hvac_mode(self, hvac_mode): + async def async_set_hvac_mode(self, hvac_mode): """ The hvac mode""" self._attr_hvac_mode = hvac_mode self.async_write_ha_state() @@ -114,7 +131,7 @@ class MockClimate(ClimateEntity): class MockUnavailableClimate(ClimateEntity): """A Mock Climate class used for Underlying climate mode""" - def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None: + def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None: # pylint: disable=unused-argument """Initialize the thermostat.""" super().__init__() diff --git a/tests/const.py b/tests/const.py index 1eb3a15..c935bb0 100644 --- a/tests/const.py +++ b/tests/const.py @@ -51,7 +51,6 @@ from custom_components.versatile_thermostat.const import ( PRESET_AWAY_SUFFIX, CONF_CLIMATE, ) - MOCK_TH_OVER_SWITCH_USER_CONFIG = { CONF_NAME: "TheOverSwitchMockName", CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_SWITCH, @@ -100,6 +99,12 @@ MOCK_TH_OVER_SWITCH_TYPE_CONFIG = { CONF_AC_MODE: False, } +MOCK_TH_OVER_SWITCH_AC_TYPE_CONFIG = { + CONF_HEATER: "switch.mock_air_conditioner", + CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI, + CONF_AC_MODE: True, +} + MOCK_TH_OVER_4SWITCH_TYPE_CONFIG = { CONF_HEATER: "switch.mock_4switch0", CONF_HEATER_2: "switch.mock_4switch1", @@ -116,6 +121,7 @@ MOCK_TH_OVER_SWITCH_TPI_CONFIG = { MOCK_TH_OVER_CLIMATE_TYPE_CONFIG = { CONF_CLIMATE: "climate.mock_climate", + CONF_AC_MODE: False, } MOCK_PRESETS_CONFIG = { @@ -124,6 +130,15 @@ MOCK_PRESETS_CONFIG = { PRESET_BOOST + "_temp": 18, } +MOCK_PRESETS_AC_CONFIG = { + PRESET_ECO + "_temp": 17, + PRESET_COMFORT + "_temp": 19, + PRESET_BOOST + "_temp": 20, + PRESET_ECO + "_ac_temp": 25, + PRESET_COMFORT + "_ac_temp": 23, + PRESET_BOOST + "_ac_temp": 21, +} + MOCK_WINDOW_CONFIG = { CONF_WINDOW_SENSOR: "binary_sensor.window_sensor", CONF_WINDOW_DELAY: 10, @@ -156,6 +171,16 @@ MOCK_PRESENCE_CONFIG = { PRESET_BOOST + PRESET_AWAY_SUFFIX + "_temp": 18, } +MOCK_PRESENCE_AC_CONFIG = { + CONF_PRESENCE_SENSOR: "person.presence_sensor", + PRESET_ECO + PRESET_AWAY_SUFFIX + "_temp": 16, + PRESET_COMFORT + PRESET_AWAY_SUFFIX + "_temp": 17, + PRESET_BOOST + PRESET_AWAY_SUFFIX + "_temp": 18, + PRESET_ECO + "_ac" + PRESET_AWAY_SUFFIX + "_temp": 27, + PRESET_COMFORT + "_ac" + PRESET_AWAY_SUFFIX + "_temp": 26, + PRESET_BOOST + "_ac" + PRESET_AWAY_SUFFIX + "_temp": 25, +} + MOCK_ADVANCED_CONFIG = { CONF_MINIMAL_ACTIVATION_DELAY: 10, CONF_SECURITY_DELAY_MIN: 5, diff --git a/tests/test_movement.py b/tests/test_movement.py index ab1d31c..d95ae93 100644 --- a/tests/test_movement.py +++ b/tests/test_movement.py @@ -1,10 +1,10 @@ """ Test the Window management """ import asyncio +from datetime import datetime, timedelta +import logging from unittest.mock import patch, call, PropertyMock from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import -from datetime import datetime, timedelta -import logging logging.getLogger().setLevel(logging.DEBUG) diff --git a/tests/test_switch_ac.py b/tests/test_switch_ac.py new file mode 100644 index 0000000..e263352 --- /dev/null +++ b/tests/test_switch_ac.py @@ -0,0 +1,140 @@ +""" Test the normal start of a Switch AC Thermostat """ +from unittest.mock import patch, call +from datetime import datetime, timedelta + +from homeassistant.core import HomeAssistant +from homeassistant.components.climate import HVACAction, HVACMode +from homeassistant.config_entries import ConfigEntryState + +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.components.climate import ClimateEntity, DOMAIN as CLIMATE_DOMAIN + +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.versatile_thermostat.climate import VersatileThermostat + +from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import + +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize("expected_lingering_timers", [True]) +async def test_over_switch_ac_full_start(hass: HomeAssistant, skip_hass_states_is_state): # pylint: disable=unused-argument + """Test the normal full start of a thermostat in thermostat_over_switch type""" + + entry = MockConfigEntry( + domain=DOMAIN, + title="TheOverSwitchACMockName", + unique_id="uniqueId", + data=FULL_SWITCH_AC_CONFIG, + ) + + tz = get_tz(hass) # pylint: disable=invalid-name + now: datetime = datetime.now(tz=tz) + + with patch( + "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + ) as mock_send_event: + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state is ConfigEntryState.LOADED + + def find_my_entity(entity_id) -> ClimateEntity: + """Find my new entity""" + component: EntityComponent[ClimateEntity] = hass.data[CLIMATE_DOMAIN] + for entity in component.entities: + if entity.entity_id == entity_id: + return entity + + # The name is in the CONF and not the title of the entry + entity: VersatileThermostat = find_my_entity("climate.theoverswitchmockname") + + assert entity + + assert entity.name == "TheOverSwitchMockName" + assert entity._is_over_climate is False # pylint: disable=protected-access + assert entity.ac_mode is True + assert entity.hvac_action is HVACAction.OFF + assert entity.hvac_mode is HVACMode.OFF + assert entity.hvac_modes == [HVACMode.COOL, HVACMode.OFF] + assert entity.target_temperature == entity.max_temp + assert entity.preset_modes == [ + PRESET_NONE, + PRESET_ECO, + PRESET_COMFORT, + PRESET_BOOST, + PRESET_ACTIVITY, + ] + assert entity.preset_mode is PRESET_NONE + assert entity._security_state is False # pylint: disable=protected-access + assert entity._window_state is None # pylint: disable=protected-access + assert entity._motion_state is None # pylint: disable=protected-access + assert entity._presence_state is None # pylint: disable=protected-access + assert entity._prop_algorithm is not None # pylint: disable=protected-access + + # should have been called with EventType.PRESET_EVENT and EventType.HVAC_MODE_EVENT + assert mock_send_event.call_count == 2 + + mock_send_event.assert_has_calls( + [ + call.send_event(EventType.PRESET_EVENT, {"preset": PRESET_NONE}), + call.send_event( + EventType.HVAC_MODE_EVENT, + {"hvac_mode": HVACMode.OFF}, + ), + ] + ) + + # Select a hvacmode, presence and preset + await entity.async_set_hvac_mode(HVACMode.COOL) + assert entity.hvac_mode is HVACMode.COOL + + event_timestamp = now - timedelta(minutes=4) + await send_presence_change_event(entity, True, False, event_timestamp) + assert entity._presence_state == STATE_ON # pylint: disable=protected-access + + await entity.async_set_hvac_mode(HVACMode.COOL) + assert entity.hvac_mode is HVACMode.COOL + + await entity.async_set_preset_mode(PRESET_COMFORT) + assert entity.preset_mode is PRESET_COMFORT + assert entity.target_temperature == 23 + + # switch to Eco + await entity.async_set_preset_mode(PRESET_ECO) + assert entity.preset_mode is PRESET_ECO + assert entity.target_temperature == 25 + + # Unset the presence + event_timestamp = now - timedelta(minutes=3) + await send_presence_change_event(entity, False, True, event_timestamp) + assert entity._presence_state == STATE_OFF # pylint: disable=protected-access + assert entity.target_temperature == 27 # eco_ac_away + + # Open a window + with patch( + "homeassistant.helpers.condition.state", return_value=True + ): + event_timestamp = now - timedelta(minutes=2) + try_condition = await send_window_change_event(entity, True, False, event_timestamp) + + # Confirme the window event + await try_condition(None) + + assert entity.hvac_mode is HVACMode.OFF + assert entity.hvac_action is HVACAction.OFF + assert entity.target_temperature == 27 # eco_ac_away + + # Close a window + with patch( + "homeassistant.helpers.condition.state", return_value=True + ): + event_timestamp = now - timedelta(minutes=2) + try_condition = await send_window_change_event(entity, False, True, event_timestamp) + + # Confirme the window event + await try_condition(None) + + assert entity.hvac_mode is HVACMode.COOL + assert (entity.hvac_action is HVACAction.OFF or entity.hvac_action is HVACAction.IDLE) + assert entity.target_temperature == 27 # eco_ac_away + +