From 19cc168433f137818a8dc9ed4a6c64567ece253e Mon Sep 17 00:00:00 2001 From: Yuxiang Zhu Date: Wed, 26 Aug 2020 00:56:01 +0800 Subject: [PATCH] Add HomeKit Controller heater-cooler devices (#38979) Some new HomeKit climate devices, like XiaoMi Air Conditioning Controller P3 are heater-cooler devices rather than thermostat devices. This commit adds support for the heater-cooler class via homekit_controller. --- .../components/homekit_controller/climate.py | 270 ++++++++++++++++- .../components/homekit_controller/const.py | 1 + .../homekit_controller/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../homekit_controller/test_climate.py | 285 +++++++++++++++++- 6 files changed, 553 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/homekit_controller/climate.py b/homeassistant/components/homekit_controller/climate.py index f06063c5fd2..8551f4dddd5 100644 --- a/homeassistant/components/homekit_controller/climate.py +++ b/homeassistant/components/homekit_controller/climate.py @@ -2,9 +2,13 @@ import logging from aiohomekit.model.characteristics import ( + ActivationStateValues, CharacteristicsTypes, + CurrentHeaterCoolerStateValues, HeatingCoolingCurrentValues, HeatingCoolingTargetValues, + SwingModeValues, + TargetHeaterCoolerStateValues, ) from aiohomekit.utils import clamp_enum_to_char @@ -17,12 +21,16 @@ from homeassistant.components.climate.const import ( CURRENT_HVAC_COOL, CURRENT_HVAC_HEAT, CURRENT_HVAC_IDLE, + CURRENT_HVAC_OFF, HVAC_MODE_COOL, HVAC_MODE_HEAT, HVAC_MODE_HEAT_COOL, HVAC_MODE_OFF, + SUPPORT_SWING_MODE, SUPPORT_TARGET_HUMIDITY, SUPPORT_TARGET_TEMPERATURE, + SWING_OFF, + SWING_VERTICAL, ) from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS from homeassistant.core import callback @@ -39,15 +47,39 @@ MODE_HOMEKIT_TO_HASS = { HeatingCoolingTargetValues.AUTO: HVAC_MODE_HEAT_COOL, } -# Map of hass operation modes to homekit modes -MODE_HASS_TO_HOMEKIT = {v: k for k, v in MODE_HOMEKIT_TO_HASS.items()} - CURRENT_MODE_HOMEKIT_TO_HASS = { HeatingCoolingCurrentValues.IDLE: CURRENT_HVAC_IDLE, HeatingCoolingCurrentValues.HEATING: CURRENT_HVAC_HEAT, HeatingCoolingCurrentValues.COOLING: CURRENT_HVAC_COOL, } +SWING_MODE_HOMEKIT_TO_HASS = { + SwingModeValues.DISABLED: SWING_OFF, + SwingModeValues.ENABLED: SWING_VERTICAL, +} + +CURRENT_HEATER_COOLER_STATE_HOMEKIT_TO_HASS = { + CurrentHeaterCoolerStateValues.INACTIVE: CURRENT_HVAC_OFF, + CurrentHeaterCoolerStateValues.IDLE: CURRENT_HVAC_IDLE, + CurrentHeaterCoolerStateValues.HEATING: CURRENT_HVAC_HEAT, + CurrentHeaterCoolerStateValues.COOLING: CURRENT_HVAC_COOL, +} + +TARGET_HEATER_COOLER_STATE_HOMEKIT_TO_HASS = { + TargetHeaterCoolerStateValues.AUTOMATIC: HVAC_MODE_HEAT_COOL, + TargetHeaterCoolerStateValues.HEAT: HVAC_MODE_HEAT, + TargetHeaterCoolerStateValues.COOL: HVAC_MODE_COOL, +} + +# Map of hass operation modes to homekit modes +MODE_HASS_TO_HOMEKIT = {v: k for k, v in MODE_HOMEKIT_TO_HASS.items()} + +TARGET_HEATER_COOLER_STATE_HASS_TO_HOMEKIT = { + v: k for k, v in TARGET_HEATER_COOLER_STATE_HOMEKIT_TO_HASS.items() +} + +SWING_MODE_HASS_TO_HOMEKIT = {v: k for k, v in SWING_MODE_HOMEKIT_TO_HASS.items()} + async def async_setup_entry(hass, config_entry, async_add_entities): """Set up Homekit climate.""" @@ -56,15 +88,237 @@ async def async_setup_entry(hass, config_entry, async_add_entities): @callback def async_add_service(aid, service): - if service["stype"] != "thermostat": + entity_class = ENTITY_TYPES.get(service["stype"]) + if not entity_class: return False info = {"aid": aid, "iid": service["iid"]} - async_add_entities([HomeKitClimateEntity(conn, info)], True) + async_add_entities([entity_class(conn, info)], True) return True conn.add_listener(async_add_service) +class HomeKitHeaterCoolerEntity(HomeKitEntity, ClimateEntity): + """Representation of a Homekit climate device.""" + + def get_characteristic_types(self): + """Define the homekit characteristics the entity cares about.""" + return [ + CharacteristicsTypes.ACTIVE, + CharacteristicsTypes.CURRENT_HEATER_COOLER_STATE, + CharacteristicsTypes.TARGET_HEATER_COOLER_STATE, + CharacteristicsTypes.TEMPERATURE_COOLING_THRESHOLD, + CharacteristicsTypes.TEMPERATURE_HEATING_THRESHOLD, + CharacteristicsTypes.SWING_MODE, + CharacteristicsTypes.TEMPERATURE_CURRENT, + ] + + async def async_set_temperature(self, **kwargs): + """Set new target temperature.""" + temp = kwargs.get(ATTR_TEMPERATURE) + state = self.service.value(CharacteristicsTypes.TARGET_HEATER_COOLER_STATE) + if state == TargetHeaterCoolerStateValues.COOL: + await self.async_put_characteristics( + {CharacteristicsTypes.TEMPERATURE_COOLING_THRESHOLD: temp} + ) + elif state == TargetHeaterCoolerStateValues.HEAT: + await self.async_put_characteristics( + {CharacteristicsTypes.TEMPERATURE_HEATING_THRESHOLD: temp} + ) + else: + hvac_mode = TARGET_HEATER_COOLER_STATE_HOMEKIT_TO_HASS.get(state) + _LOGGER.warning( + "HomeKit device %s: Setting temperature in %s mode is not supported yet." + " Consider raising a ticket if you have this device and want to help us implement this feature.", + self.entity_id, + hvac_mode, + ) + + async def async_set_hvac_mode(self, hvac_mode): + """Set new target operation mode.""" + if hvac_mode == HVAC_MODE_OFF: + await self.async_put_characteristics( + {CharacteristicsTypes.ACTIVE: ActivationStateValues.INACTIVE} + ) + return + if hvac_mode not in {HVAC_MODE_HEAT, HVAC_MODE_COOL}: + _LOGGER.warning( + "HomeKit device %s: Setting temperature in %s mode is not supported yet." + " Consider raising a ticket if you have this device and want to help us implement this feature.", + self.entity_id, + hvac_mode, + ) + await self.async_put_characteristics( + { + CharacteristicsTypes.TARGET_HEATER_COOLER_STATE: TARGET_HEATER_COOLER_STATE_HASS_TO_HOMEKIT[ + hvac_mode + ], + } + ) + + @property + def current_temperature(self): + """Return the current temperature.""" + return self.service.value(CharacteristicsTypes.TEMPERATURE_CURRENT) + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + state = self.service.value(CharacteristicsTypes.TARGET_HEATER_COOLER_STATE) + if state == TargetHeaterCoolerStateValues.COOL: + return self.service.value( + CharacteristicsTypes.TEMPERATURE_COOLING_THRESHOLD + ) + if state == TargetHeaterCoolerStateValues.HEAT: + return self.service.value( + CharacteristicsTypes.TEMPERATURE_HEATING_THRESHOLD + ) + return None + + @property + def target_temperature_step(self): + """Return the supported step of target temperature.""" + state = self.service.value(CharacteristicsTypes.TARGET_HEATER_COOLER_STATE) + if state == TargetHeaterCoolerStateValues.COOL and self.service.has( + CharacteristicsTypes.TEMPERATURE_COOLING_THRESHOLD + ): + return self.service[ + CharacteristicsTypes.TEMPERATURE_COOLING_THRESHOLD + ].minStep + if state == TargetHeaterCoolerStateValues.HEAT and self.service.has( + CharacteristicsTypes.TEMPERATURE_HEATING_THRESHOLD + ): + return self.service[ + CharacteristicsTypes.TEMPERATURE_HEATING_THRESHOLD + ].minStep + return None + + @property + def min_temp(self): + """Return the minimum target temp.""" + state = self.service.value(CharacteristicsTypes.TARGET_HEATER_COOLER_STATE) + if state == TargetHeaterCoolerStateValues.COOL and self.service.has( + CharacteristicsTypes.TEMPERATURE_COOLING_THRESHOLD + ): + return self.service[ + CharacteristicsTypes.TEMPERATURE_COOLING_THRESHOLD + ].minValue + if state == TargetHeaterCoolerStateValues.HEAT and self.service.has( + CharacteristicsTypes.TEMPERATURE_HEATING_THRESHOLD + ): + return self.service[ + CharacteristicsTypes.TEMPERATURE_HEATING_THRESHOLD + ].minValue + return super().min_temp + + @property + def max_temp(self): + """Return the maximum target temp.""" + state = self.service.value(CharacteristicsTypes.TARGET_HEATER_COOLER_STATE) + if state == TargetHeaterCoolerStateValues.COOL and self.service.has( + CharacteristicsTypes.TEMPERATURE_COOLING_THRESHOLD + ): + return self.service[ + CharacteristicsTypes.TEMPERATURE_COOLING_THRESHOLD + ].maxValue + if state == TargetHeaterCoolerStateValues.HEAT and self.service.has( + CharacteristicsTypes.TEMPERATURE_HEATING_THRESHOLD + ): + return self.service[ + CharacteristicsTypes.TEMPERATURE_HEATING_THRESHOLD + ].maxValue + return super().max_temp + + @property + def hvac_action(self): + """Return the current running hvac operation.""" + # This characteristic describes the current mode of a device, + # e.g. a thermostat is "heating" a room to 75 degrees Fahrenheit. + # Can be 0 - 3 (Off, Idle, Heat, Cool) + if ( + self.service.value(CharacteristicsTypes.ACTIVE) + == ActivationStateValues.INACTIVE + ): + return CURRENT_HVAC_OFF + value = self.service.value(CharacteristicsTypes.CURRENT_HEATER_COOLER_STATE) + return CURRENT_HEATER_COOLER_STATE_HOMEKIT_TO_HASS.get(value) + + @property + def hvac_mode(self): + """Return hvac operation ie. heat, cool mode.""" + # This characteristic describes the target mode + # E.g. should the device start heating a room if the temperature + # falls below the target temperature. + # Can be 0 - 2 (Auto, Heat, Cool) + if ( + self.service.value(CharacteristicsTypes.ACTIVE) + == ActivationStateValues.INACTIVE + ): + return HVAC_MODE_OFF + value = self.service.value(CharacteristicsTypes.TARGET_HEATER_COOLER_STATE) + return TARGET_HEATER_COOLER_STATE_HOMEKIT_TO_HASS.get(value) + + @property + def hvac_modes(self): + """Return the list of available hvac operation modes.""" + valid_values = clamp_enum_to_char( + TargetHeaterCoolerStateValues, + self.service[CharacteristicsTypes.TARGET_HEATER_COOLER_STATE], + ) + modes = [ + TARGET_HEATER_COOLER_STATE_HOMEKIT_TO_HASS[mode] for mode in valid_values + ] + modes.append(HVAC_MODE_OFF) + return modes + + @property + def swing_mode(self): + """Return the swing setting. + + Requires SUPPORT_SWING_MODE. + """ + value = self.service.value(CharacteristicsTypes.SWING_MODE) + return SWING_MODE_HOMEKIT_TO_HASS[value] + + @property + def swing_modes(self): + """Return the list of available swing modes. + + Requires SUPPORT_SWING_MODE. + """ + valid_values = clamp_enum_to_char( + SwingModeValues, self.service[CharacteristicsTypes.SWING_MODE], + ) + return [SWING_MODE_HOMEKIT_TO_HASS[mode] for mode in valid_values] + + async def async_set_swing_mode(self, swing_mode: str) -> None: + """Set new target swing operation.""" + await self.async_put_characteristics( + {CharacteristicsTypes.SWING_MODE: SWING_MODE_HASS_TO_HOMEKIT[swing_mode]} + ) + + @property + def supported_features(self): + """Return the list of supported features.""" + features = 0 + + if self.service.has(CharacteristicsTypes.TEMPERATURE_COOLING_THRESHOLD): + features |= SUPPORT_TARGET_TEMPERATURE + + if self.service.has(CharacteristicsTypes.TEMPERATURE_HEATING_THRESHOLD): + features |= SUPPORT_TARGET_TEMPERATURE + + if self.service.has(CharacteristicsTypes.SWING_MODE): + features |= SUPPORT_SWING_MODE + + return features + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return TEMP_CELSIUS + + class HomeKitClimateEntity(HomeKitEntity, ClimateEntity): """Representation of a Homekit climate device.""" @@ -196,3 +450,9 @@ class HomeKitClimateEntity(HomeKitEntity, ClimateEntity): def temperature_unit(self): """Return the unit of measurement.""" return TEMP_CELSIUS + + +ENTITY_TYPES = { + "heater-cooler": HomeKitHeaterCoolerEntity, + "thermostat": HomeKitClimateEntity, +} diff --git a/homeassistant/components/homekit_controller/const.py b/homeassistant/components/homekit_controller/const.py index 7b40863141c..394750c0688 100644 --- a/homeassistant/components/homekit_controller/const.py +++ b/homeassistant/components/homekit_controller/const.py @@ -14,6 +14,7 @@ HOMEKIT_ACCESSORY_DISPATCH = { "outlet": "switch", "switch": "switch", "thermostat": "climate", + "heater-cooler": "climate", "security-system": "alarm_control_panel", "garage-door-opener": "cover", "window": "cover", diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index a915f84510a..1ee2f16ffcf 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -3,7 +3,7 @@ "name": "HomeKit Controller", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/homekit_controller", - "requirements": ["aiohomekit[IP]==0.2.47"], + "requirements": ["aiohomekit[IP]==0.2.49"], "zeroconf": ["_hap._tcp.local."], "after_dependencies": ["zeroconf"], "codeowners": ["@Jc2k"] diff --git a/requirements_all.txt b/requirements_all.txt index 3785cff85df..3ec08978c82 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -179,7 +179,7 @@ aioguardian==1.0.1 aioharmony==0.2.6 # homeassistant.components.homekit_controller -aiohomekit[IP]==0.2.47 +aiohomekit[IP]==0.2.49 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 28739d751da..84f89b8c878 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -104,7 +104,7 @@ aioguardian==1.0.1 aioharmony==0.2.6 # homeassistant.components.homekit_controller -aiohomekit[IP]==0.2.47 +aiohomekit[IP]==0.2.49 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/tests/components/homekit_controller/test_climate.py b/tests/components/homekit_controller/test_climate.py index 9bcadb6604e..38156354cda 100644 --- a/tests/components/homekit_controller/test_climate.py +++ b/tests/components/homekit_controller/test_climate.py @@ -1,5 +1,11 @@ """Basic checks for HomeKitclimate.""" -from aiohomekit.model.characteristics import CharacteristicsTypes +from aiohomekit.model.characteristics import ( + ActivationStateValues, + CharacteristicsTypes, + CurrentHeaterCoolerStateValues, + SwingModeValues, + TargetHeaterCoolerStateValues, +) from aiohomekit.model.services import ServicesTypes from homeassistant.components.climate.const import ( @@ -10,6 +16,7 @@ from homeassistant.components.climate.const import ( HVAC_MODE_OFF, SERVICE_SET_HUMIDITY, SERVICE_SET_HVAC_MODE, + SERVICE_SET_SWING_MODE, SERVICE_SET_TEMPERATURE, ) @@ -22,6 +29,8 @@ TEMPERATURE_CURRENT = ("thermostat", "temperature.current") HUMIDITY_TARGET = ("thermostat", "relative-humidity.target") HUMIDITY_CURRENT = ("thermostat", "relative-humidity.current") +# Test thermostat devices + def create_thermostat_service(accessory): """Define thermostat characteristics.""" @@ -226,3 +235,277 @@ async def test_hvac_mode_vs_hvac_action(hass, utcnow): state = await helper.poll_and_get_state() assert state.state == "heat" assert state.attributes["hvac_action"] == "heating" + + +TARGET_HEATER_COOLER_STATE = ("heater-cooler", "heater-cooler.state.target") +CURRENT_HEATER_COOLER_STATE = ("heater-cooler", "heater-cooler.state.current") +HEATER_COOLER_ACTIVE = ("heater-cooler", "active") +HEATER_COOLER_TEMPERATURE_CURRENT = ("heater-cooler", "temperature.current") +TEMPERATURE_COOLING_THRESHOLD = ("heater-cooler", "temperature.cooling-threshold") +TEMPERATURE_HEATING_THRESHOLD = ("heater-cooler", "temperature.heating-threshold") +SWING_MODE = ("heater-cooler", "swing-mode") + + +def create_heater_cooler_service(accessory): + """Define thermostat characteristics.""" + service = accessory.add_service(ServicesTypes.HEATER_COOLER) + + char = service.add_char(CharacteristicsTypes.TARGET_HEATER_COOLER_STATE) + char.value = 0 + + char = service.add_char(CharacteristicsTypes.CURRENT_HEATER_COOLER_STATE) + char.value = 0 + + char = service.add_char(CharacteristicsTypes.ACTIVE) + char.value = 1 + + char = service.add_char(CharacteristicsTypes.TEMPERATURE_COOLING_THRESHOLD) + char.minValue = 7 + char.maxValue = 35 + char.value = 0 + + char = service.add_char(CharacteristicsTypes.TEMPERATURE_HEATING_THRESHOLD) + char.minValue = 7 + char.maxValue = 35 + char.value = 0 + + char = service.add_char(CharacteristicsTypes.TEMPERATURE_CURRENT) + char.value = 0 + + char = service.add_char(CharacteristicsTypes.SWING_MODE) + char.value = 0 + + +# Test heater-cooler devices +def create_heater_cooler_service_min_max(accessory): + """Define thermostat characteristics.""" + service = accessory.add_service(ServicesTypes.HEATER_COOLER) + char = service.add_char(CharacteristicsTypes.TARGET_HEATER_COOLER_STATE) + char.value = 1 + char.minValue = 1 + char.maxValue = 2 + + +async def test_heater_cooler_respect_supported_op_modes_1(hass, utcnow): + """Test that climate respects minValue/maxValue hints.""" + helper = await setup_test_component(hass, create_heater_cooler_service_min_max) + state = await helper.poll_and_get_state() + assert state.attributes["hvac_modes"] == ["heat", "cool", "off"] + + +def create_theater_cooler_service_valid_vals(accessory): + """Define heater-cooler characteristics.""" + service = accessory.add_service(ServicesTypes.HEATER_COOLER) + char = service.add_char(CharacteristicsTypes.TARGET_HEATER_COOLER_STATE) + char.value = 1 + char.valid_values = [1, 2] + + +async def test_heater_cooler_respect_supported_op_modes_2(hass, utcnow): + """Test that climate respects validValue hints.""" + helper = await setup_test_component(hass, create_theater_cooler_service_valid_vals) + state = await helper.poll_and_get_state() + assert state.attributes["hvac_modes"] == ["heat", "cool", "off"] + + +async def test_heater_cooler_change_thermostat_state(hass, utcnow): + """Test that we can change the operational mode.""" + helper = await setup_test_component(hass, create_heater_cooler_service) + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HVAC_MODE, + {"entity_id": "climate.testdevice", "hvac_mode": HVAC_MODE_HEAT}, + blocking=True, + ) + + assert ( + helper.characteristics[TARGET_HEATER_COOLER_STATE].value + == TargetHeaterCoolerStateValues.HEAT + ) + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HVAC_MODE, + {"entity_id": "climate.testdevice", "hvac_mode": HVAC_MODE_COOL}, + blocking=True, + ) + assert ( + helper.characteristics[TARGET_HEATER_COOLER_STATE].value + == TargetHeaterCoolerStateValues.COOL + ) + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HVAC_MODE, + {"entity_id": "climate.testdevice", "hvac_mode": HVAC_MODE_HEAT_COOL}, + blocking=True, + ) + assert ( + helper.characteristics[TARGET_HEATER_COOLER_STATE].value + == TargetHeaterCoolerStateValues.AUTOMATIC + ) + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HVAC_MODE, + {"entity_id": "climate.testdevice", "hvac_mode": HVAC_MODE_OFF}, + blocking=True, + ) + assert ( + helper.characteristics[HEATER_COOLER_ACTIVE].value + == ActivationStateValues.INACTIVE + ) + + +async def test_heater_cooler_change_thermostat_temperature(hass, utcnow): + """Test that we can change the target temperature.""" + helper = await setup_test_component(hass, create_heater_cooler_service) + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HVAC_MODE, + {"entity_id": "climate.testdevice", "hvac_mode": HVAC_MODE_HEAT}, + blocking=True, + ) + await hass.services.async_call( + DOMAIN, + SERVICE_SET_TEMPERATURE, + {"entity_id": "climate.testdevice", "temperature": 20}, + blocking=True, + ) + assert helper.characteristics[TEMPERATURE_HEATING_THRESHOLD].value == 20 + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HVAC_MODE, + {"entity_id": "climate.testdevice", "hvac_mode": HVAC_MODE_COOL}, + blocking=True, + ) + await hass.services.async_call( + DOMAIN, + SERVICE_SET_TEMPERATURE, + {"entity_id": "climate.testdevice", "temperature": 26}, + blocking=True, + ) + assert helper.characteristics[TEMPERATURE_COOLING_THRESHOLD].value == 26 + + +async def test_heater_cooler_read_thermostat_state(hass, utcnow): + """Test that we can read the state of a HomeKit thermostat accessory.""" + helper = await setup_test_component(hass, create_heater_cooler_service) + + # Simulate that heating is on + helper.characteristics[HEATER_COOLER_TEMPERATURE_CURRENT].value = 19 + helper.characteristics[TEMPERATURE_HEATING_THRESHOLD].value = 20 + helper.characteristics[ + CURRENT_HEATER_COOLER_STATE + ].value = CurrentHeaterCoolerStateValues.HEATING + helper.characteristics[ + TARGET_HEATER_COOLER_STATE + ].value = TargetHeaterCoolerStateValues.HEAT + helper.characteristics[SWING_MODE].value = SwingModeValues.DISABLED + + state = await helper.poll_and_get_state() + assert state.state == HVAC_MODE_HEAT + assert state.attributes["current_temperature"] == 19 + assert state.attributes["min_temp"] == 7 + assert state.attributes["max_temp"] == 35 + + # Simulate that cooling is on + helper.characteristics[HEATER_COOLER_TEMPERATURE_CURRENT].value = 21 + helper.characteristics[TEMPERATURE_COOLING_THRESHOLD].value = 19 + helper.characteristics[ + CURRENT_HEATER_COOLER_STATE + ].value = CurrentHeaterCoolerStateValues.COOLING + helper.characteristics[ + TARGET_HEATER_COOLER_STATE + ].value = TargetHeaterCoolerStateValues.COOL + helper.characteristics[SWING_MODE].value = SwingModeValues.DISABLED + + state = await helper.poll_and_get_state() + assert state.state == HVAC_MODE_COOL + assert state.attributes["current_temperature"] == 21 + + # Simulate that we are in auto mode + helper.characteristics[HEATER_COOLER_TEMPERATURE_CURRENT].value = 21 + helper.characteristics[TEMPERATURE_COOLING_THRESHOLD].value = 21 + helper.characteristics[ + CURRENT_HEATER_COOLER_STATE + ].value = CurrentHeaterCoolerStateValues.COOLING + helper.characteristics[ + TARGET_HEATER_COOLER_STATE + ].value = TargetHeaterCoolerStateValues.AUTOMATIC + helper.characteristics[SWING_MODE].value = SwingModeValues.DISABLED + + state = await helper.poll_and_get_state() + assert state.state == HVAC_MODE_HEAT_COOL + + +async def test_heater_cooler_hvac_mode_vs_hvac_action(hass, utcnow): + """Check that we haven't conflated hvac_mode and hvac_action.""" + helper = await setup_test_component(hass, create_heater_cooler_service) + + # Simulate that current temperature is above target temp + # Heating might be on, but hvac_action currently 'off' + helper.characteristics[HEATER_COOLER_TEMPERATURE_CURRENT].value = 22 + helper.characteristics[TEMPERATURE_HEATING_THRESHOLD].value = 21 + helper.characteristics[ + CURRENT_HEATER_COOLER_STATE + ].value = CurrentHeaterCoolerStateValues.IDLE + helper.characteristics[ + TARGET_HEATER_COOLER_STATE + ].value = TargetHeaterCoolerStateValues.HEAT + helper.characteristics[SWING_MODE].value = SwingModeValues.DISABLED + + state = await helper.poll_and_get_state() + assert state.state == "heat" + assert state.attributes["hvac_action"] == "idle" + + # Simulate that current temperature is below target temp + # Heating might be on and hvac_action currently 'heat' + helper.characteristics[HEATER_COOLER_TEMPERATURE_CURRENT].value = 19 + helper.characteristics[ + CURRENT_HEATER_COOLER_STATE + ].value = CurrentHeaterCoolerStateValues.HEATING + + state = await helper.poll_and_get_state() + assert state.state == "heat" + assert state.attributes["hvac_action"] == "heating" + + +async def test_heater_cooler_change_swing_mode(hass, utcnow): + """Test that we can change the swing mode.""" + helper = await setup_test_component(hass, create_heater_cooler_service) + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_SWING_MODE, + {"entity_id": "climate.testdevice", "swing_mode": "vertical"}, + blocking=True, + ) + assert helper.characteristics[SWING_MODE].value == SwingModeValues.ENABLED + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_SWING_MODE, + {"entity_id": "climate.testdevice", "swing_mode": "off"}, + blocking=True, + ) + assert helper.characteristics[SWING_MODE].value == SwingModeValues.DISABLED + + +async def test_heater_cooler_turn_off(hass, utcnow): + """Test that both hvac_action and hvac_mode return "off" when turned off.""" + helper = await setup_test_component(hass, create_heater_cooler_service) + # Simulate that the device is turned off but CURRENT_HEATER_COOLER_STATE still returns HEATING/COOLING + helper.characteristics[HEATER_COOLER_ACTIVE].value = ActivationStateValues.INACTIVE + helper.characteristics[ + CURRENT_HEATER_COOLER_STATE + ].value = CurrentHeaterCoolerStateValues.HEATING + helper.characteristics[ + TARGET_HEATER_COOLER_STATE + ].value = TargetHeaterCoolerStateValues.HEAT + state = await helper.poll_and_get_state() + assert state.state == "off" + assert state.attributes["hvac_action"] == "off"