From 9d3d35ad793724a39909de4aabc872bd9845d847 Mon Sep 17 00:00:00 2001 From: SukramJ Date: Thu, 7 Nov 2019 16:41:33 +0100 Subject: [PATCH] Add cool mode to HomematicIP climate (#28525) * Add cool mode to HomematicIP climate * Update test * remove preset_party * Fix profile_names check --- .../components/homematicip_cloud/climate.py | 154 ++++++++++---- .../homematicip_cloud/test_climate.py | 192 ++++++++++++++++-- tests/fixtures/homematicip_cloud.json | 6 +- 3 files changed, 293 insertions(+), 59 deletions(-) diff --git a/homeassistant/components/homematicip_cloud/climate.py b/homeassistant/components/homematicip_cloud/climate.py index 74d647c8c33..a8ea424b207 100644 --- a/homeassistant/components/homematicip_cloud/climate.py +++ b/homeassistant/components/homematicip_cloud/climate.py @@ -4,13 +4,15 @@ from typing import Awaitable from homematicip.aio.device import AsyncHeatingThermostat, AsyncHeatingThermostatCompact from homematicip.aio.group import AsyncHeatingGroup -from homematicip.base.enums import AbsenceType, GroupType +from homematicip.base.enums import AbsenceType from homematicip.functionalHomes import IndoorClimateHome from homeassistant.components.climate import ClimateDevice from homeassistant.components.climate.const import ( HVAC_MODE_AUTO, + HVAC_MODE_COOL, HVAC_MODE_HEAT, + HVAC_MODE_OFF, PRESET_AWAY, PRESET_BOOST, PRESET_ECO, @@ -30,6 +32,9 @@ COOLING_PROFILES = {"PROFILE_4": 3, "PROFILE_5": 4, "PROFILE_6": 5} _LOGGER = logging.getLogger(__name__) +ATTR_PRESET_END_TIME = "preset_end_time" +PERMANENT_END_TIME = "permanent" + HMIP_AUTOMATIC_CM = "AUTOMATIC" HMIP_MANUAL_CM = "MANUAL" HMIP_ECO_CM = "ECO" @@ -55,15 +60,20 @@ async def async_setup_entry( class HomematicipHeatingGroup(HomematicipGenericDevice, ClimateDevice): - """Representation of a HomematicIP heating group.""" + """Representation of a HomematicIP heating group. + + Heat mode is supported for all heating devices incl. their defined profiles. + Boost is available for radiator thermostats only. + Cool mode is only available for floor heating systems, if basically enabled in the hmip app. + """ def __init__(self, hap: HomematicipHAP, device: AsyncHeatingGroup) -> None: """Initialize heating group.""" device.modelType = "HmIP-Heating-Group" + super().__init__(hap, device) self._simple_heating = None if device.actualTemperature is None: - self._simple_heating = _get_first_heating_thermostat(device) - super().__init__(hap, device) + self._simple_heating = self._get_first_radiator_thermostat() @property def device_info(self): @@ -105,54 +115,66 @@ class HomematicipHeatingGroup(HomematicipGenericDevice, ClimateDevice): @property def hvac_mode(self) -> str: - """Return hvac operation ie. heat, cool mode. - - Need to be one of HVAC_MODE_*. - """ + """Return hvac operation ie.""" + if self._disabled_by_cooling_mode: + return HVAC_MODE_OFF if self._device.boostMode: return HVAC_MODE_HEAT if self._device.controlMode == HMIP_MANUAL_CM: - return HVAC_MODE_HEAT + return HVAC_MODE_HEAT if self._heat_mode_enabled else HVAC_MODE_COOL return HVAC_MODE_AUTO @property def hvac_modes(self): - """Return the list of available hvac operation modes. + """Return the list of available hvac operation modes.""" + if self._disabled_by_cooling_mode: + return [HVAC_MODE_OFF] - Need to be a subset of HVAC_MODES. - """ - return [HVAC_MODE_AUTO, HVAC_MODE_HEAT] + return ( + [HVAC_MODE_AUTO, HVAC_MODE_HEAT] + if self._heat_mode_enabled + else [HVAC_MODE_AUTO, HVAC_MODE_COOL] + ) @property def preset_mode(self): - """Return the current preset mode, e.g., home, away, temp. - - Requires SUPPORT_PRESET_MODE. - """ + """Return the current preset mode.""" if self._device.boostMode: return PRESET_BOOST - if self.hvac_mode == HVAC_MODE_HEAT: + if self.hvac_mode in (HVAC_MODE_COOL, HVAC_MODE_HEAT, HVAC_MODE_OFF): return PRESET_NONE if self._device.controlMode == HMIP_ECO_CM: - absence_type = self._home.get_functionalHome(IndoorClimateHome).absenceType - if absence_type == AbsenceType.VACATION: + if self._indoor_climate.absenceType == AbsenceType.VACATION: return PRESET_AWAY - if absence_type in [ + if self._indoor_climate.absenceType in [ + AbsenceType.PARTY, AbsenceType.PERIOD, AbsenceType.PERMANENT, - AbsenceType.PARTY, ]: return PRESET_ECO - if self._device.activeProfile: - return self._device.activeProfile.name + return ( + self._device.activeProfile.name + if self._device.activeProfile.name in self._device_profile_names + else None + ) @property def preset_modes(self): - """Return a list of available preset modes incl profiles.""" - presets = [PRESET_NONE, PRESET_BOOST] - presets.extend(self._device_profile_names) + """Return a list of available preset modes incl. hmip profiles.""" + # Boost is only available if a radiator thermostat is in the room, + # and heat mode is enabled. + profile_names = self._device_profile_names + + presets = [] + if self._heat_mode_enabled and self._has_radiator_thermostat: + if not profile_names: + presets.append(PRESET_NONE) + presets.append(PRESET_BOOST) + + presets.extend(profile_names) + return presets @property @@ -170,10 +192,15 @@ class HomematicipHeatingGroup(HomematicipGenericDevice, ClimateDevice): temperature = kwargs.get(ATTR_TEMPERATURE) if temperature is None: return - await self._device.set_point_temperature(temperature) + + if self.min_temp <= temperature <= self.max_temp: + await self._device.set_point_temperature(temperature) async def async_set_hvac_mode(self, hvac_mode: str) -> Awaitable[None]: """Set new target hvac mode.""" + if hvac_mode not in self.hvac_modes: + return + if hvac_mode == HVAC_MODE_AUTO: await self._device.set_control_mode(HMIP_AUTOMATIC_CM) else: @@ -181,18 +208,44 @@ class HomematicipHeatingGroup(HomematicipGenericDevice, ClimateDevice): async def async_set_preset_mode(self, preset_mode: str) -> Awaitable[None]: """Set new preset mode.""" + if preset_mode not in self.preset_modes: + return + if self._device.boostMode and preset_mode != PRESET_BOOST: await self._device.set_boost(False) if preset_mode == PRESET_BOOST: await self._device.set_boost() if preset_mode in self._device_profile_names: profile_idx = self._get_profile_idx_by_name(preset_mode) - await self.async_set_hvac_mode(HVAC_MODE_AUTO) + if self._device.controlMode != HMIP_AUTOMATIC_CM: + await self.async_set_hvac_mode(HVAC_MODE_AUTO) await self._device.set_active_profile(profile_idx) + @property + def device_state_attributes(self): + """Return the state attributes of the access point.""" + state_attr = super().device_state_attributes + + if self._device.controlMode == HMIP_ECO_CM: + if self._indoor_climate.absenceType in [ + AbsenceType.PARTY, + AbsenceType.PERIOD, + AbsenceType.VACATION, + ]: + state_attr[ATTR_PRESET_END_TIME] = self._indoor_climate.absenceEndTime + elif self._indoor_climate.absenceType == AbsenceType.PERMANENT: + state_attr[ATTR_PRESET_END_TIME] = PERMANENT_END_TIME + + return state_attr + + @property + def _indoor_climate(self): + """Return the hmip indoor climate functional home of this group.""" + return self._home.get_functionalHome(IndoorClimateHome) + @property def _device_profiles(self): - """Return the relevant profiles of the device.""" + """Return the relevant profiles.""" return [ profile for profile in self._device.profiles @@ -218,17 +271,36 @@ class HomematicipHeatingGroup(HomematicipGenericDevice, ClimateDevice): return relevant_index[index_name[0]] @property - def _relevant_profile_group(self): - """Return the relevant profile groups.""" - return ( - HEATING_PROFILES - if self._device.groupType == GroupType.HEATING - else COOLING_PROFILES + def _heat_mode_enabled(self): + """Return, if heating mode is enabled.""" + return not self._device.cooling + + @property + def _disabled_by_cooling_mode(self): + """Return, if group is disabled by the cooling mode.""" + return self._device.cooling and ( + self._device.coolingIgnored or not self._device.coolingAllowed ) + @property + def _relevant_profile_group(self): + """Return the relevant profile groups.""" + if self._disabled_by_cooling_mode: + return [] -def _get_first_heating_thermostat(heating_group: AsyncHeatingGroup): - """Return the first HeatingThermostat from a HeatingGroup.""" - for device in heating_group.devices: - if isinstance(device, (AsyncHeatingThermostat, AsyncHeatingThermostatCompact)): - return device + return HEATING_PROFILES if self._heat_mode_enabled else COOLING_PROFILES + + @property + def _has_radiator_thermostat(self) -> bool: + """Return, if a radiator thermostat is in the hmip heating group.""" + return bool(self._get_first_radiator_thermostat()) + + def _get_first_radiator_thermostat(self): + """Return the first radiator thermostat from the hmip heating group.""" + for device in self._device.devices: + if isinstance( + device, (AsyncHeatingThermostat, AsyncHeatingThermostatCompact) + ): + return device + + return None diff --git a/tests/components/homematicip_cloud/test_climate.py b/tests/components/homematicip_cloud/test_climate.py index 80e4e74e451..6a05a880864 100644 --- a/tests/components/homematicip_cloud/test_climate.py +++ b/tests/components/homematicip_cloud/test_climate.py @@ -10,13 +10,18 @@ from homeassistant.components.climate.const import ( ATTR_PRESET_MODE, ATTR_PRESET_MODES, HVAC_MODE_AUTO, + HVAC_MODE_COOL, HVAC_MODE_HEAT, + HVAC_MODE_OFF, PRESET_AWAY, PRESET_BOOST, PRESET_ECO, - PRESET_NONE, ) from homeassistant.components.homematicip_cloud import DOMAIN as HMIPC_DOMAIN +from homeassistant.components.homematicip_cloud.climate import ( + ATTR_PRESET_END_TIME, + PERMANENT_END_TIME, +) from homeassistant.setup import async_setup_component from .helper import HAPID, async_manipulate_test_data, get_and_check_entity_basics @@ -33,7 +38,7 @@ async def test_manually_configured_platform(hass): assert not hass.data.get(HMIPC_DOMAIN) -async def test_hmip_heating_group(hass, default_mock_hap): +async def test_hmip_heating_group_heat(hass, default_mock_hap): """Test HomematicipHeatingGroup.""" entity_id = "climate.badezimmer" entity_name = "Badezimmer" @@ -50,12 +55,7 @@ async def test_hmip_heating_group(hass, default_mock_hap): assert ha_state.attributes["temperature"] == 5.0 assert ha_state.attributes["current_humidity"] == 47 assert ha_state.attributes[ATTR_PRESET_MODE] == "STD" - assert ha_state.attributes[ATTR_PRESET_MODES] == [ - PRESET_NONE, - PRESET_BOOST, - "STD", - "Winter", - ] + assert ha_state.attributes[ATTR_PRESET_MODES] == [PRESET_BOOST, "STD", "Winter"] service_call_counter = len(hmip_device.mock_calls) @@ -114,12 +114,12 @@ async def test_hmip_heating_group(hass, default_mock_hap): await hass.services.async_call( "climate", "set_preset_mode", - {"entity_id": entity_id, "preset_mode": PRESET_NONE}, + {"entity_id": entity_id, "preset_mode": "STD"}, blocking=True, ) - assert len(hmip_device.mock_calls) == service_call_counter + 9 - assert hmip_device.mock_calls[-1][0] == "set_boost" - assert hmip_device.mock_calls[-1][1] == (False,) + assert len(hmip_device.mock_calls) == service_call_counter + 11 + assert hmip_device.mock_calls[-1][0] == "set_active_profile" + assert hmip_device.mock_calls[-1][1] == (0,) await async_manipulate_test_data(hass, hmip_device, "boostMode", False) ha_state = hass.states.get(entity_id) assert ha_state.attributes[ATTR_PRESET_MODE] == "STD" @@ -132,7 +132,7 @@ async def test_hmip_heating_group(hass, default_mock_hap): blocking=True, ) # No new service call should be in mock_calls. - assert len(hmip_device.mock_calls) == service_call_counter + 10 + assert len(hmip_device.mock_calls) == service_call_counter + 12 # Only fire event from last async_manipulate_test_data available. assert hmip_device.mock_calls[-1][0] == "fire_update_event" @@ -158,7 +158,6 @@ async def test_hmip_heating_group(hass, default_mock_hap): ha_state = hass.states.get(entity_id) assert ha_state.attributes[ATTR_PRESET_MODE] == PRESET_ECO - # Not required for hmip, but a posiblity to send no temperature. await hass.services.async_call( "climate", "set_preset_mode", @@ -166,10 +165,173 @@ async def test_hmip_heating_group(hass, default_mock_hap): blocking=True, ) - assert len(hmip_device.mock_calls) == service_call_counter + 16 + assert len(hmip_device.mock_calls) == service_call_counter + 18 assert hmip_device.mock_calls[-1][0] == "set_active_profile" assert hmip_device.mock_calls[-1][1] == (1,) + default_mock_hap.home.get_functionalHome( + IndoorClimateHome + ).absenceType = AbsenceType.PERMANENT + await async_manipulate_test_data(hass, hmip_device, "controlMode", "ECO") + + ha_state = hass.states.get(entity_id) + assert ha_state.attributes[ATTR_PRESET_END_TIME] == PERMANENT_END_TIME + + await hass.services.async_call( + "climate", + "set_hvac_mode", + {"entity_id": entity_id, "hvac_mode": HVAC_MODE_HEAT}, + blocking=True, + ) + assert len(hmip_device.mock_calls) == service_call_counter + 20 + assert hmip_device.mock_calls[-1][0] == "set_control_mode" + assert hmip_device.mock_calls[-1][1] == ("MANUAL",) + await async_manipulate_test_data(hass, hmip_device, "controlMode", "MANUAL") + ha_state = hass.states.get(entity_id) + assert ha_state.state == HVAC_MODE_HEAT + + await hass.services.async_call( + "climate", + "set_preset_mode", + {"entity_id": entity_id, "preset_mode": "Winter"}, + blocking=True, + ) + + assert len(hmip_device.mock_calls) == service_call_counter + 23 + assert hmip_device.mock_calls[-1][0] == "set_active_profile" + assert hmip_device.mock_calls[-1][1] == (1,) + hmip_device.activeProfile = hmip_device.profiles[0] + await async_manipulate_test_data(hass, hmip_device, "controlMode", "AUTOMATIC") + ha_state = hass.states.get(entity_id) + assert ha_state.state == HVAC_MODE_AUTO + + await hass.services.async_call( + "climate", + "set_hvac_mode", + {"entity_id": entity_id, "hvac_mode": "dry"}, + blocking=True, + ) + assert len(hmip_device.mock_calls) == service_call_counter + 24 + # Only fire event from last async_manipulate_test_data available. + assert hmip_device.mock_calls[-1][0] == "fire_update_event" + + +async def test_hmip_heating_group_cool(hass, default_mock_hap): + """Test HomematicipHeatingGroup.""" + entity_id = "climate.badezimmer" + entity_name = "Badezimmer" + device_model = None + + ha_state, hmip_device = get_and_check_entity_basics( + hass, default_mock_hap, entity_id, entity_name, device_model + ) + + hmip_device.activeProfile = hmip_device.profiles[3] + await async_manipulate_test_data(hass, hmip_device, "cooling", True) + await async_manipulate_test_data(hass, hmip_device, "coolingAllowed", True) + await async_manipulate_test_data(hass, hmip_device, "coolingIgnored", False) + ha_state = hass.states.get(entity_id) + + assert ha_state.state == HVAC_MODE_AUTO + assert ha_state.attributes["current_temperature"] == 23.8 + assert ha_state.attributes["min_temp"] == 5.0 + assert ha_state.attributes["max_temp"] == 30.0 + assert ha_state.attributes["temperature"] == 5.0 + assert ha_state.attributes["current_humidity"] == 47 + assert ha_state.attributes[ATTR_PRESET_MODE] == "Cool1" + assert ha_state.attributes[ATTR_PRESET_MODES] == ["Cool1", "Cool2"] + + service_call_counter = len(hmip_device.mock_calls) + + await hass.services.async_call( + "climate", + "set_hvac_mode", + {"entity_id": entity_id, "hvac_mode": HVAC_MODE_COOL}, + blocking=True, + ) + assert len(hmip_device.mock_calls) == service_call_counter + 1 + assert hmip_device.mock_calls[-1][0] == "set_control_mode" + assert hmip_device.mock_calls[-1][1] == ("MANUAL",) + await async_manipulate_test_data(hass, hmip_device, "controlMode", "MANUAL") + ha_state = hass.states.get(entity_id) + assert ha_state.state == HVAC_MODE_COOL + + await hass.services.async_call( + "climate", + "set_hvac_mode", + {"entity_id": entity_id, "hvac_mode": HVAC_MODE_AUTO}, + blocking=True, + ) + assert len(hmip_device.mock_calls) == service_call_counter + 3 + assert hmip_device.mock_calls[-1][0] == "set_control_mode" + assert hmip_device.mock_calls[-1][1] == ("AUTOMATIC",) + await async_manipulate_test_data(hass, hmip_device, "controlMode", "AUTO") + ha_state = hass.states.get(entity_id) + assert ha_state.state == HVAC_MODE_AUTO + + await hass.services.async_call( + "climate", + "set_preset_mode", + {"entity_id": entity_id, "preset_mode": "Cool2"}, + blocking=True, + ) + + assert len(hmip_device.mock_calls) == service_call_counter + 6 + assert hmip_device.mock_calls[-1][0] == "set_active_profile" + assert hmip_device.mock_calls[-1][1] == (4,) + + hmip_device.activeProfile = hmip_device.profiles[4] + await async_manipulate_test_data(hass, hmip_device, "cooling", True) + await async_manipulate_test_data(hass, hmip_device, "coolingAllowed", False) + await async_manipulate_test_data(hass, hmip_device, "coolingIgnored", False) + ha_state = hass.states.get(entity_id) + + assert ha_state.state == HVAC_MODE_OFF + assert ha_state.attributes[ATTR_PRESET_MODE] == "none" + assert ha_state.attributes[ATTR_PRESET_MODES] == [] + + hmip_device.activeProfile = hmip_device.profiles[4] + await async_manipulate_test_data(hass, hmip_device, "cooling", True) + await async_manipulate_test_data(hass, hmip_device, "coolingAllowed", True) + await async_manipulate_test_data(hass, hmip_device, "coolingIgnored", True) + ha_state = hass.states.get(entity_id) + + assert ha_state.state == HVAC_MODE_OFF + assert ha_state.attributes[ATTR_PRESET_MODE] == "none" + assert ha_state.attributes[ATTR_PRESET_MODES] == [] + + await hass.services.async_call( + "climate", + "set_preset_mode", + {"entity_id": entity_id, "preset_mode": "Cool2"}, + blocking=True, + ) + + assert len(hmip_device.mock_calls) == service_call_counter + 12 + # fire_update_event shows that set_active_profile has not been called. + assert hmip_device.mock_calls[-1][0] == "fire_update_event" + + hmip_device.activeProfile = hmip_device.profiles[4] + await async_manipulate_test_data(hass, hmip_device, "cooling", True) + await async_manipulate_test_data(hass, hmip_device, "coolingAllowed", True) + await async_manipulate_test_data(hass, hmip_device, "coolingIgnored", False) + ha_state = hass.states.get(entity_id) + + assert ha_state.state == HVAC_MODE_AUTO + assert ha_state.attributes[ATTR_PRESET_MODE] == "Cool2" + assert ha_state.attributes[ATTR_PRESET_MODES] == ["Cool1", "Cool2"] + + await hass.services.async_call( + "climate", + "set_preset_mode", + {"entity_id": entity_id, "preset_mode": "Cool2"}, + blocking=True, + ) + + assert len(hmip_device.mock_calls) == service_call_counter + 17 + assert hmip_device.mock_calls[-1][0] == "set_active_profile" + assert hmip_device.mock_calls[-1][1] == (4,) + async def test_hmip_climate_services(hass, mock_hap_with_service): """Test HomematicipHeatingGroup.""" diff --git a/tests/fixtures/homematicip_cloud.json b/tests/fixtures/homematicip_cloud.json index e17df9c2039..d1ef82f4234 100644 --- a/tests/fixtures/homematicip_cloud.json +++ b/tests/fixtures/homematicip_cloud.json @@ -4662,7 +4662,7 @@ "enabled": true, "groupId": "00000000-0000-0000-0000-000000000021", "index": "PROFILE_4", - "name": "", + "name": "Cool1", "profileId": "00000000-0000-0000-0000-000000000041", "visible": true }, @@ -4670,9 +4670,9 @@ "enabled": true, "groupId": "00000000-0000-0000-0000-000000000021", "index": "PROFILE_5", - "name": "", + "name": "Cool2", "profileId": "00000000-0000-0000-0000-000000000042", - "visible": false + "visible": true }, "PROFILE_6": { "enabled": true,