From 3f7cc176a8a77130bec0073b623e5f31ba2ae34e Mon Sep 17 00:00:00 2001 From: stefano055415 Date: Wed, 28 Jul 2021 19:27:31 +0200 Subject: [PATCH] Add climate support to Freedompro (#52720) * Update Freedompro * fix code and add test * add check for unsupported mode * add code for unsupported hvac_mode * HVAC_INVERT_MAP and fix test * change params hass to session * set const and add ValueError * fix ValueError text --- .../components/freedompro/__init__.py | 11 +- .../components/freedompro/climate.py | 138 ++++++++++++ tests/components/freedompro/test_climate.py | 203 ++++++++++++++++++ 3 files changed, 351 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/freedompro/climate.py create mode 100644 tests/components/freedompro/test_climate.py diff --git a/homeassistant/components/freedompro/__init__.py b/homeassistant/components/freedompro/__init__.py index 650e479d027..40d440d83eb 100644 --- a/homeassistant/components/freedompro/__init__.py +++ b/homeassistant/components/freedompro/__init__.py @@ -14,7 +14,16 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -PLATFORMS = ["binary_sensor", "cover", "fan", "light", "lock", "sensor", "switch"] +PLATFORMS = [ + "binary_sensor", + "climate", + "cover", + "fan", + "light", + "lock", + "sensor", + "switch", +] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): diff --git a/homeassistant/components/freedompro/climate.py b/homeassistant/components/freedompro/climate.py new file mode 100644 index 00000000000..e37ae9dea1b --- /dev/null +++ b/homeassistant/components/freedompro/climate.py @@ -0,0 +1,138 @@ +"""Support for Freedompro climate.""" +import json +import logging + +from pyfreedompro import put_state + +from homeassistant.components.climate import ClimateEntity +from homeassistant.components.climate.const import ( + ATTR_HVAC_MODE, + HVAC_MODE_COOL, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + SUPPORT_TARGET_TEMPERATURE, +) +from homeassistant.const import ATTR_TEMPERATURE, CONF_API_KEY, TEMP_CELSIUS +from homeassistant.core import callback +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +HVAC_MAP = { + 0: HVAC_MODE_OFF, + 1: HVAC_MODE_HEAT, + 2: HVAC_MODE_COOL, +} + +HVAC_INVERT_MAP = {v: k for k, v in HVAC_MAP.items()} + +SUPPORTED_HVAC_MODES = [HVAC_MODE_OFF, HVAC_MODE_HEAT, HVAC_MODE_COOL] + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up Freedompro climate.""" + api_key = entry.data[CONF_API_KEY] + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + Device( + aiohttp_client.async_get_clientsession(hass), api_key, device, coordinator + ) + for device in coordinator.data + if device["type"] == "thermostat" + ) + + +class Device(CoordinatorEntity, ClimateEntity): + """Representation of an Freedompro climate.""" + + _attr_hvac_modes = SUPPORTED_HVAC_MODES + _attr_temperature_unit = TEMP_CELSIUS + + def __init__(self, session, api_key, device, coordinator): + """Initialize the Freedompro climate.""" + super().__init__(coordinator) + self._session = session + self._api_key = api_key + self._attr_name = device["name"] + self._attr_unique_id = device["uid"] + self._characteristics = device["characteristics"] + self._attr_device_info = { + "name": self.name, + "identifiers": { + (DOMAIN, self.unique_id), + }, + "model": device["type"], + "manufacturer": "Freedompro", + } + self._attr_supported_features = SUPPORT_TARGET_TEMPERATURE + self._attr_current_temperature = 0 + self._attr_target_temperature = 0 + self._attr_hvac_mode = HVAC_MODE_OFF + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + device = next( + ( + device + for device in self.coordinator.data + if device["uid"] == self._attr_unique_id + ), + None, + ) + if device is not None and "state" in device: + state = device["state"] + if "currentTemperature" in state: + self._attr_current_temperature = state["currentTemperature"] + if "targetTemperature" in state: + self._attr_target_temperature = state["targetTemperature"] + if "heatingCoolingState" in state: + self._attr_hvac_mode = HVAC_MAP[state["heatingCoolingState"]] + super()._handle_coordinator_update() + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self._handle_coordinator_update() + + async def async_set_hvac_mode(self, hvac_mode): + """Async function to set mode to climate.""" + if hvac_mode not in SUPPORTED_HVAC_MODES: + raise ValueError(f"Got unsupported hvac_mode {hvac_mode}") + + payload = {} + payload["heatingCoolingState"] = HVAC_INVERT_MAP[hvac_mode] + payload = json.dumps(payload) + await put_state( + self._session, + self._api_key, + self.unique_id, + payload, + ) + await self.coordinator.async_request_refresh() + + async def async_set_temperature(self, **kwargs): + """Async function to set temperarture to climate.""" + payload = {} + if ATTR_HVAC_MODE in kwargs: + if kwargs[ATTR_HVAC_MODE] not in SUPPORTED_HVAC_MODES: + _LOGGER.error( + "Got unsupported hvac_mode %s, expected one of %s", + kwargs[ATTR_HVAC_MODE], + SUPPORTED_HVAC_MODES, + ) + return + payload["heatingCoolingState"] = HVAC_INVERT_MAP[kwargs[ATTR_HVAC_MODE]] + if ATTR_TEMPERATURE in kwargs: + payload["targetTemperature"] = kwargs[ATTR_TEMPERATURE] + payload = json.dumps(payload) + await put_state( + self._session, + self._api_key, + self.unique_id, + payload, + ) + await self.coordinator.async_request_refresh() diff --git a/tests/components/freedompro/test_climate.py b/tests/components/freedompro/test_climate.py new file mode 100644 index 00000000000..36ec3309d24 --- /dev/null +++ b/tests/components/freedompro/test_climate.py @@ -0,0 +1,203 @@ +"""Tests for the Freedompro climate.""" + +from datetime import timedelta +from unittest.mock import ANY, patch + +import pytest + +from homeassistant.components.climate import ( + ATTR_CURRENT_TEMPERATURE, + ATTR_HVAC_MODE, + ATTR_HVAC_MODES, + ATTR_MAX_TEMP, + ATTR_MIN_TEMP, + ATTR_TEMPERATURE, + DOMAIN as CLIMATE_DOMAIN, + HVAC_MODE_COOL, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_TEMPERATURE, +) +from homeassistant.components.climate.const import HVAC_MODE_AUTO +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.util.dt import utcnow + +from tests.common import async_fire_time_changed +from tests.components.freedompro.const import DEVICES_STATE + +uid = "3WRRJR6RCZQZSND8VP0YTO3YXCSOFPKBMW8T51TU-LQ*TWMYQKL3UVED4HSIIB9GXJWJZBQCXG-9VE-N2IUAIWI" + + +async def test_climate_get_state(hass, init_integration): + """Test states of the climate.""" + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + device = device_registry.async_get_device({("freedompro", uid)}) + assert device is not None + assert device.identifiers == {("freedompro", uid)} + assert device.manufacturer == "Freedompro" + assert device.name == "thermostat" + assert device.model == "thermostat" + + entity_id = "climate.thermostat" + state = hass.states.get(entity_id) + assert state + assert state.attributes.get("friendly_name") == "thermostat" + + assert state.attributes[ATTR_HVAC_MODES] == [ + HVAC_MODE_OFF, + HVAC_MODE_HEAT, + HVAC_MODE_COOL, + ] + + assert state.attributes[ATTR_MIN_TEMP] == 7 + assert state.attributes[ATTR_MAX_TEMP] == 35 + assert state.attributes[ATTR_TEMPERATURE] == 14 + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 14 + + assert state.state == HVAC_MODE_HEAT + + entry = entity_registry.async_get(entity_id) + assert entry + assert entry.unique_id == uid + + get_states_response = list(DEVICES_STATE) + for state_response in get_states_response: + if state_response["uid"] == uid: + state_response["state"]["currentTemperature"] = 20 + state_response["state"]["targetTemperature"] = 21 + with patch( + "homeassistant.components.freedompro.get_states", + return_value=get_states_response, + ): + async_fire_time_changed(hass, utcnow() + timedelta(hours=2)) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.attributes.get("friendly_name") == "thermostat" + + entry = entity_registry.async_get(entity_id) + assert entry + assert entry.unique_id == uid + + assert state.attributes[ATTR_TEMPERATURE] == 21 + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 20 + + +async def test_climate_set_off(hass, init_integration): + """Test set off climate.""" + init_integration + entity_registry = er.async_get(hass) + + entity_id = "climate.thermostat" + state = hass.states.get(entity_id) + assert state + assert state.attributes.get("friendly_name") == "thermostat" + + entry = entity_registry.async_get(entity_id) + assert entry + assert entry.unique_id == uid + + with patch( + "homeassistant.components.freedompro.climate.put_state" + ) as mock_put_state: + assert await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVAC_MODE_OFF}, + blocking=True, + ) + mock_put_state.assert_called_once_with(ANY, ANY, ANY, '{"heatingCoolingState": 0}') + + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.state == HVAC_MODE_HEAT + + +async def test_climate_set_unsupported_hvac_mode(hass, init_integration): + """Test set unsupported hvac mode climate.""" + init_integration + entity_registry = er.async_get(hass) + + entity_id = "climate.thermostat" + state = hass.states.get(entity_id) + assert state + assert state.attributes.get("friendly_name") == "thermostat" + + entry = entity_registry.async_get(entity_id) + assert entry + assert entry.unique_id == uid + + with pytest.raises(ValueError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVAC_MODE_AUTO}, + blocking=True, + ) + + +async def test_climate_set_temperature(hass, init_integration): + """Test set temperature climate.""" + init_integration + entity_registry = er.async_get(hass) + + entity_id = "climate.thermostat" + state = hass.states.get(entity_id) + assert state + assert state.attributes.get("friendly_name") == "thermostat" + + entry = entity_registry.async_get(entity_id) + assert entry + assert entry.unique_id == uid + + with patch( + "homeassistant.components.freedompro.climate.put_state" + ) as mock_put_state: + assert await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: [entity_id], + ATTR_HVAC_MODE: HVAC_MODE_OFF, + ATTR_TEMPERATURE: 25, + }, + blocking=True, + ) + mock_put_state.assert_called_once_with( + ANY, ANY, ANY, '{"heatingCoolingState": 0, "targetTemperature": 25.0}' + ) + + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.attributes[ATTR_TEMPERATURE] == 21 + + +async def test_climate_set_temperature_unsupported_hvac_mode(hass, init_integration): + """Test set temperature climate unsupported hvac mode.""" + init_integration + entity_registry = er.async_get(hass) + + entity_id = "climate.thermostat" + state = hass.states.get(entity_id) + assert state + assert state.attributes.get("friendly_name") == "thermostat" + + entry = entity_registry.async_get(entity_id) + assert entry + assert entry.unique_id == uid + + assert await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: [entity_id], + ATTR_HVAC_MODE: HVAC_MODE_AUTO, + ATTR_TEMPERATURE: 25, + }, + blocking=True, + )