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