mirror of
https://github.com/home-assistant/core.git
synced 2025-07-25 22:27:07 +00:00
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:
parent
e2314565bb
commit
7c5824b4f3
@ -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__)
|
||||||
|
|
||||||
|
134
homeassistant/components/tessie/climate.py
Normal file
134
homeassistant/components/tessie/climate.py
Normal 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],
|
||||||
|
),
|
||||||
|
)
|
@ -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"
|
||||||
|
@ -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()
|
||||||
|
@ -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"
|
||||||
|
124
tests/components/tessie/test_climate.py
Normal file
124
tests/components/tessie/test_climate.py
Normal 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
|
Loading…
x
Reference in New Issue
Block a user