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
This commit is contained in:
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 .const import DOMAIN
from .coordinator import TessieDataUpdateCoordinator from .coordinator import TessieDataUpdateCoordinator
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] PLATFORMS = [Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.SENSOR]
_LOGGER = logging.getLogger(__name__) _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" ASLEEP = "asleep"
ONLINE = "online" 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.""" """Tessie parent entity class."""
from collections.abc import Awaitable, Callable
from typing import Any from typing import Any
from aiohttp import ClientResponseError
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
@ -43,3 +46,27 @@ class TessieEntity(CoordinatorEntity[TessieDataUpdateCoordinator]):
def _value(self) -> Any: def _value(self) -> Any:
"""Return value from coordinator data.""" """Return value from coordinator data."""
return self.coordinator.data[self.key] 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": { "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": { "sensor": {
"charge_state_usable_battery_level": { "charge_state_usable_battery_level": {
"name": "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