From 7c5824b4f389ae11a97361caf1f084677eca6aec Mon Sep 17 00:00:00 2001 From: Brett Adams <Bre77@users.noreply.github.com> Date: Thu, 21 Dec 2023 15:18:18 +1000 Subject: [PATCH] Add climate platform to Tessie (#105420) * Add climate platform * Other fixes * Use super native value * change to _value * Sentence case strings * Add some more type definition * Add return types * Add some more assertions * Remove VirtualKey error * Add type to args * rename climate to primary * fix min max * Use String Enum * Add PRECISION_HALVES * Fix string enum * fix str enum * Simplify run logic * Rename enum to TessieClimateKeeper --- homeassistant/components/tessie/__init__.py | 2 +- homeassistant/components/tessie/climate.py | 134 +++++++++++++++++++ homeassistant/components/tessie/const.py | 9 ++ homeassistant/components/tessie/entity.py | 29 +++- homeassistant/components/tessie/strings.json | 15 +++ tests/components/tessie/test_climate.py | 124 +++++++++++++++++ 6 files changed, 311 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/tessie/climate.py create mode 100644 tests/components/tessie/test_climate.py diff --git a/homeassistant/components/tessie/__init__.py b/homeassistant/components/tessie/__init__.py index a1553aa0c7e..fdffaffa538 100644 --- a/homeassistant/components/tessie/__init__.py +++ b/homeassistant/components/tessie/__init__.py @@ -14,7 +14,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN from .coordinator import TessieDataUpdateCoordinator -PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.SENSOR] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/tessie/climate.py b/homeassistant/components/tessie/climate.py new file mode 100644 index 00000000000..48fe73919cd --- /dev/null +++ b/homeassistant/components/tessie/climate.py @@ -0,0 +1,134 @@ +"""Climate platform for Tessie integration.""" +from __future__ import annotations + +from typing import Any + +from tessie_api import ( + set_climate_keeper_mode, + set_temperature, + start_climate_preconditioning, + stop_climate, +) + +from homeassistant.components.climate import ( + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, TessieClimateKeeper +from .coordinator import TessieDataUpdateCoordinator +from .entity import TessieEntity + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Tessie Climate platform from a config entry.""" + coordinators = hass.data[DOMAIN][entry.entry_id] + + async_add_entities(TessieClimateEntity(coordinator) for coordinator in coordinators) + + +class TessieClimateEntity(TessieEntity, ClimateEntity): + """Vehicle Location Climate Class.""" + + _attr_precision = PRECISION_HALVES + _attr_min_temp = 15 + _attr_max_temp = 28 + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_hvac_modes = [HVACMode.HEAT_COOL, HVACMode.OFF] + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + ) + _attr_preset_modes: list = [ + TessieClimateKeeper.OFF, + TessieClimateKeeper.ON, + TessieClimateKeeper.DOG, + TessieClimateKeeper.CAMP, + ] + + def __init__( + self, + coordinator: TessieDataUpdateCoordinator, + ) -> None: + """Initialize the Climate entity.""" + super().__init__(coordinator, "primary") + + @property + def hvac_mode(self) -> HVACMode | None: + """Return hvac operation ie. heat, cool mode.""" + if self.get("climate_state_is_climate_on"): + return HVACMode.HEAT_COOL + return HVACMode.OFF + + @property + def current_temperature(self) -> float | None: + """Return the current temperature.""" + return self.get("climate_state_inside_temp") + + @property + def target_temperature(self) -> float | None: + """Return the temperature we try to reach.""" + return self.get("climate_state_driver_temp_setting") + + @property + def max_temp(self) -> float: + """Return the maximum temperature.""" + return self.get("climate_state_max_avail_temp", self._attr_max_temp) + + @property + def min_temp(self) -> float: + """Return the minimum temperature.""" + return self.get("climate_state_min_avail_temp", self._attr_min_temp) + + @property + def preset_mode(self) -> str | None: + """Return the current preset mode.""" + return self.get("climate_state_climate_keeper_mode") + + async def async_turn_on(self) -> None: + """Set the climate state to on.""" + await self.run(start_climate_preconditioning) + self.set(("climate_state_is_climate_on", True)) + + async def async_turn_off(self) -> None: + """Set the climate state to off.""" + await self.run(stop_climate) + self.set( + ("climate_state_is_climate_on", False), + ("climate_state_climate_keeper_mode", "off"), + ) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set the climate temperature.""" + temp = kwargs[ATTR_TEMPERATURE] + await self.run(set_temperature, temperature=temp) + self.set(("climate_state_driver_temp_setting", temp)) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set the climate mode and state.""" + if hvac_mode == HVACMode.OFF: + await self.async_turn_off() + else: + await self.async_turn_on() + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the climate preset mode.""" + await self.run( + set_climate_keeper_mode, mode=self._attr_preset_modes.index(preset_mode) + ) + self.set( + ( + "climate_state_climate_keeper_mode", + preset_mode, + ), + ( + "climate_state_is_climate_on", + preset_mode != self._attr_preset_modes[0], + ), + ) diff --git a/homeassistant/components/tessie/const.py b/homeassistant/components/tessie/const.py index 3aa7dbb185d..b7dcaea4420 100644 --- a/homeassistant/components/tessie/const.py +++ b/homeassistant/components/tessie/const.py @@ -18,3 +18,12 @@ class TessieStatus(StrEnum): ASLEEP = "asleep" ONLINE = "online" + + +class TessieClimateKeeper(StrEnum): + """Tessie Climate Keeper Modes.""" + + OFF = "off" + ON = "on" + DOG = "dog" + CAMP = "camp" diff --git a/homeassistant/components/tessie/entity.py b/homeassistant/components/tessie/entity.py index a0263467ac2..b7c04d35306 100644 --- a/homeassistant/components/tessie/entity.py +++ b/homeassistant/components/tessie/entity.py @@ -1,8 +1,11 @@ """Tessie parent entity class.""" - +from collections.abc import Awaitable, Callable from typing import Any +from aiohttp import ClientResponseError + +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -43,3 +46,27 @@ class TessieEntity(CoordinatorEntity[TessieDataUpdateCoordinator]): def _value(self) -> Any: """Return value from coordinator data.""" return self.coordinator.data[self.key] + + def get(self, key: str | None = None, default: Any | None = None) -> Any: + """Return a specific value from coordinator data.""" + return self.coordinator.data.get(key or self.key, default) + + async def run( + self, func: Callable[..., Awaitable[dict[str, bool]]], **kargs: Any + ) -> None: + """Run a tessie_api function and handle exceptions.""" + try: + await func( + session=self.coordinator.session, + vin=self.vin, + api_key=self.coordinator.api_key, + **kargs, + ) + except ClientResponseError as e: + raise HomeAssistantError from e + + def set(self, *args: Any) -> None: + """Set a value in coordinator data.""" + for key, value in args: + self.coordinator.data[key] = value + self.async_write_ha_state() diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json index 43ddd7b4954..978e594b68f 100644 --- a/homeassistant/components/tessie/strings.json +++ b/homeassistant/components/tessie/strings.json @@ -22,6 +22,21 @@ } }, "entity": { + "climate": { + "primary": { + "name": "[%key:component::climate::title%]", + "state_attributes": { + "preset_mode": { + "state": { + "off": "Normal", + "on": "Keep mode", + "dog": "Dog mode", + "camp": "Camp mode" + } + } + } + } + }, "sensor": { "charge_state_usable_battery_level": { "name": "Battery level" diff --git a/tests/components/tessie/test_climate.py b/tests/components/tessie/test_climate.py new file mode 100644 index 00000000000..341e4714470 --- /dev/null +++ b/tests/components/tessie/test_climate.py @@ -0,0 +1,124 @@ +"""Test the Tessie climate platform.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components.climate import ( + ATTR_HVAC_MODE, + ATTR_MAX_TEMP, + ATTR_MIN_TEMP, + ATTR_PRESET_MODE, + ATTR_TEMPERATURE, + DOMAIN as CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_PRESET_MODE, + SERVICE_SET_TEMPERATURE, + SERVICE_TURN_ON, + HVACMode, +) +from homeassistant.components.tessie.const import TessieClimateKeeper +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from .common import ( + ERROR_UNKNOWN, + TEST_RESPONSE, + TEST_VEHICLE_STATE_ONLINE, + setup_platform, +) + + +async def test_climate(hass: HomeAssistant) -> None: + """Tests that the climate entity is correct.""" + + assert len(hass.states.async_all(CLIMATE_DOMAIN)) == 0 + + await setup_platform(hass) + + assert len(hass.states.async_all(CLIMATE_DOMAIN)) == 1 + + entity_id = "climate.test_climate" + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + assert ( + state.attributes.get(ATTR_MIN_TEMP) + == TEST_VEHICLE_STATE_ONLINE["climate_state"]["min_avail_temp"] + ) + assert ( + state.attributes.get(ATTR_MAX_TEMP) + == TEST_VEHICLE_STATE_ONLINE["climate_state"]["max_avail_temp"] + ) + + # Test setting climate on + with patch( + "homeassistant.components.tessie.climate.start_climate_preconditioning", + return_value=TEST_RESPONSE, + ) as mock_set: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVACMode.HEAT_COOL}, + blocking=True, + ) + mock_set.assert_called_once() + + # Test setting climate temp + with patch( + "homeassistant.components.tessie.climate.set_temperature", + return_value=TEST_RESPONSE, + ) as mock_set: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: [entity_id], ATTR_TEMPERATURE: 20}, + blocking=True, + ) + mock_set.assert_called_once() + + # Test setting climate preset + with patch( + "homeassistant.components.tessie.climate.set_climate_keeper_mode", + return_value=TEST_RESPONSE, + ) as mock_set: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: [entity_id], ATTR_PRESET_MODE: TessieClimateKeeper.ON}, + blocking=True, + ) + mock_set.assert_called_once() + + # Test setting climate off + with patch( + "homeassistant.components.tessie.climate.stop_climate", + return_value=TEST_RESPONSE, + ) as mock_set: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVACMode.OFF}, + blocking=True, + ) + mock_set.assert_called_once() + + +async def test_errors(hass: HomeAssistant) -> None: + """Tests virtual key error is handled.""" + + await setup_platform(hass) + entity_id = "climate.test_climate" + + # Test setting climate on with unknown error + with patch( + "homeassistant.components.tessie.climate.start_climate_preconditioning", + side_effect=ERROR_UNKNOWN, + ) as mock_set, pytest.raises(HomeAssistantError) as error: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + mock_set.assert_called_once() + assert error.from_exception == ERROR_UNKNOWN