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
pull/106153/head^2
Brett Adams 2023-12-21 15:18:18 +10:00 committed by GitHub
parent e2314565bb
commit 7c5824b4f3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 311 additions and 2 deletions

View File

@ -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__)

View File

@ -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],
),
)

View File

@ -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"

View File

@ -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()

View File

@ -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"

View File

@ -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