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 TessieClimateKeeperpull/106153/head^2
parent
e2314565bb
commit
7c5824b4f3
|
@ -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__)
|
||||
|
||||
|
|
|
@ -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],
|
||||
),
|
||||
)
|
|
@ -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"
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
Loading…
Reference in New Issue