Add Tessie Integration (#104684)

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Brett Adams 2023-12-10 08:46:32 +10:00 committed by GitHub
parent 327016eaeb
commit 64a5271a51
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 1518 additions and 0 deletions

View file

@ -1303,6 +1303,8 @@ build.json @home-assistant/supervisor
/tests/components/template/ @PhracturedBlue @tetienne @home-assistant/core
/homeassistant/components/tesla_wall_connector/ @einarhauks
/tests/components/tesla_wall_connector/ @einarhauks
/homeassistant/components/tessie/ @Bre77
/tests/components/tessie/ @Bre77
/homeassistant/components/text/ @home-assistant/core
/tests/components/text/ @home-assistant/core
/homeassistant/components/tfiac/ @fredrike @mellado

View file

@ -0,0 +1,60 @@
"""Tessie integration."""
import logging
from aiohttp import ClientError, ClientResponseError
from tessie_api import get_state_of_all_vehicles
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
from .coordinator import TessieDataUpdateCoordinator
PLATFORMS = [Platform.SENSOR]
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Tessie config."""
api_key = entry.data[CONF_ACCESS_TOKEN]
try:
vehicles = await get_state_of_all_vehicles(
session=async_get_clientsession(hass),
api_key=api_key,
only_active=True,
)
except ClientResponseError as ex:
# Reauth will go here
_LOGGER.error("Setup failed, unable to connect to Tessie: %s", ex)
return False
except ClientError as e:
raise ConfigEntryNotReady from e
coordinators = [
TessieDataUpdateCoordinator(
hass,
api_key=api_key,
vin=vehicle["vin"],
data=vehicle["last_state"],
)
for vehicle in vehicles["results"]
if vehicle["last_state"] is not None
]
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinators
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload Tessie Config."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok

View file

@ -0,0 +1,56 @@
"""Config Flow for Tessie integration."""
from __future__ import annotations
from collections.abc import Mapping
from http import HTTPStatus
from typing import Any
from aiohttp import ClientConnectionError, ClientResponseError
from tessie_api import get_state_of_all_vehicles
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
TESSIE_SCHEMA = vol.Schema({vol.Required(CONF_ACCESS_TOKEN): str})
class TessieConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Config Tessie API connection."""
VERSION = 1
async def async_step_user(
self, user_input: Mapping[str, Any] | None = None
) -> FlowResult:
"""Get configuration from the user."""
errors: dict[str, str] = {}
if user_input and CONF_ACCESS_TOKEN in user_input:
try:
await get_state_of_all_vehicles(
session=async_get_clientsession(self.hass),
api_key=user_input[CONF_ACCESS_TOKEN],
only_active=True,
)
except ClientResponseError as e:
if e.status == HTTPStatus.UNAUTHORIZED:
errors[CONF_ACCESS_TOKEN] = "invalid_access_token"
else:
errors["base"] = "unknown"
except ClientConnectionError:
errors["base"] = "cannot_connect"
else:
return self.async_create_entry(
title="Tessie",
data=user_input,
)
return self.async_show_form(
step_id="user",
data_schema=TESSIE_SCHEMA,
errors=errors,
)

View file

@ -0,0 +1,21 @@
"""Constants used by Tessie integration."""
from __future__ import annotations
from enum import StrEnum
DOMAIN = "tessie"
MODELS = {
"model3": "Model 3",
"modelx": "Model X",
"modely": "Model Y",
"models": "Model S",
}
class TessieStatus(StrEnum):
"""Tessie status."""
ASLEEP = "asleep"
ONLINE = "online"
OFFLINE = "offline"

View file

@ -0,0 +1,84 @@
"""Tessie Data Coordinator."""
from datetime import timedelta
from http import HTTPStatus
import logging
from typing import Any
from aiohttp import ClientResponseError
from tessie_api import get_state
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import TessieStatus
# This matches the update interval Tessie performs server side
TESSIE_SYNC_INTERVAL = 10
_LOGGER = logging.getLogger(__name__)
class TessieDataUpdateCoordinator(DataUpdateCoordinator):
"""Class to manage fetching data from the Tessie API."""
def __init__(
self,
hass: HomeAssistant,
api_key: str,
vin: str,
data: dict[str, Any],
) -> None:
"""Initialize Tessie Data Update Coordinator."""
super().__init__(
hass,
_LOGGER,
name="Tessie",
update_method=self.async_update_data,
update_interval=timedelta(seconds=TESSIE_SYNC_INTERVAL),
)
self.api_key = api_key
self.vin = vin
self.session = async_get_clientsession(hass)
self.data = self._flattern(data)
self.did_first_update = False
async def async_update_data(self) -> dict[str, Any]:
"""Update vehicle data using Tessie API."""
try:
vehicle = await get_state(
session=self.session,
api_key=self.api_key,
vin=self.vin,
use_cache=self.did_first_update,
)
except ClientResponseError as e:
if e.status == HTTPStatus.REQUEST_TIMEOUT:
# Vehicle is offline, only update state and dont throw error
self.data["state"] = TessieStatus.OFFLINE
return self.data
# Reauth will go here
raise e
self.did_first_update = True
if vehicle["state"] == TessieStatus.ONLINE:
# Vehicle is online, all data is fresh
return self._flattern(vehicle)
# Vehicle is asleep, only update state
self.data["state"] = vehicle["state"]
return self.data
def _flattern(
self, data: dict[str, Any], parent: str | None = None
) -> dict[str, Any]:
"""Flattern the data structure."""
result = {}
for key, value in data.items():
if parent:
key = f"{parent}-{key}"
if isinstance(value, dict):
result.update(self._flattern(value, key))
else:
result[key] = value
return result

View file

@ -0,0 +1,45 @@
"""Tessie parent entity class."""
from typing import Any
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MODELS
from .coordinator import TessieDataUpdateCoordinator
class TessieEntity(CoordinatorEntity[TessieDataUpdateCoordinator]):
"""Parent class for Tessie Entities."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: TessieDataUpdateCoordinator,
key: str,
) -> None:
"""Initialize common aspects of a Tessie entity."""
super().__init__(coordinator)
self.vin = coordinator.vin
self.key = key
car_type = coordinator.data["vehicle_config-car_type"]
self._attr_translation_key = key
self._attr_unique_id = f"{self.vin}-{key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self.vin)},
manufacturer="Tesla",
configuration_url="https://my.tessie.com/",
name=coordinator.data["display_name"],
model=MODELS.get(car_type, car_type),
sw_version=coordinator.data["vehicle_state-car_version"],
hw_version=coordinator.data["vehicle_config-driver_assist"],
)
@property
def value(self) -> Any:
"""Return value from coordinator data."""
return self.coordinator.data[self.key]

View file

@ -0,0 +1,10 @@
{
"domain": "tessie",
"name": "Tessie",
"codeowners": ["@Bre77"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/tessie",
"iot_class": "cloud_polling",
"loggers": ["tessie"],
"requirements": ["tessie-api==0.0.9"]
}

View file

@ -0,0 +1,225 @@
"""Sensor platform for Tessie integration."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
PERCENTAGE,
EntityCategory,
UnitOfElectricCurrent,
UnitOfElectricPotential,
UnitOfEnergy,
UnitOfLength,
UnitOfPower,
UnitOfPressure,
UnitOfSpeed,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import DOMAIN, TessieStatus
from .coordinator import TessieDataUpdateCoordinator
from .entity import TessieEntity
PARALLEL_UPDATES = 0
@dataclass
class TessieSensorEntityDescription(SensorEntityDescription):
"""Describes Tessie Sensor entity."""
value_fn: Callable[[StateType], StateType] = lambda x: x
DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = (
TessieSensorEntityDescription(
key="state",
options=[status.value for status in TessieStatus],
device_class=SensorDeviceClass.ENUM,
),
TessieSensorEntityDescription(
key="charge_state-usable_battery_level",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.BATTERY,
),
TessieSensorEntityDescription(
key="charge_state-charge_energy_added",
state_class=SensorStateClass.TOTAL_INCREASING,
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
suggested_display_precision=1,
),
TessieSensorEntityDescription(
key="charge_state-charger_power",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.KILO_WATT,
device_class=SensorDeviceClass.POWER,
),
TessieSensorEntityDescription(
key="charge_state-charger_voltage",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
),
TessieSensorEntityDescription(
key="charge_state-charger_actual_current",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
device_class=SensorDeviceClass.CURRENT,
entity_category=EntityCategory.DIAGNOSTIC,
),
TessieSensorEntityDescription(
key="charge_state-charge_rate",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR,
device_class=SensorDeviceClass.SPEED,
entity_category=EntityCategory.DIAGNOSTIC,
),
TessieSensorEntityDescription(
key="charge_state-battery_range",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfLength.MILES,
device_class=SensorDeviceClass.DISTANCE,
suggested_display_precision=1,
),
TessieSensorEntityDescription(
key="drive_state-speed",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR,
device_class=SensorDeviceClass.SPEED,
),
TessieSensorEntityDescription(
key="drive_state-power",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.KILO_WATT,
device_class=SensorDeviceClass.POWER,
entity_category=EntityCategory.DIAGNOSTIC,
),
TessieSensorEntityDescription(
key="drive_state-shift_state",
icon="mdi:car-shift-pattern",
options=["p", "d", "r", "n"],
device_class=SensorDeviceClass.ENUM,
value_fn=lambda x: x.lower() if isinstance(x, str) else x,
),
TessieSensorEntityDescription(
key="vehicle_state-odometer",
state_class=SensorStateClass.TOTAL_INCREASING,
native_unit_of_measurement=UnitOfLength.MILES,
device_class=SensorDeviceClass.DISTANCE,
suggested_display_precision=0,
entity_category=EntityCategory.DIAGNOSTIC,
),
TessieSensorEntityDescription(
key="vehicle_state-tpms_pressure_fl",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPressure.BAR,
suggested_unit_of_measurement=UnitOfPressure.PSI,
device_class=SensorDeviceClass.PRESSURE,
suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
),
TessieSensorEntityDescription(
key="vehicle_state-tpms_pressure_fr",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPressure.BAR,
suggested_unit_of_measurement=UnitOfPressure.PSI,
device_class=SensorDeviceClass.PRESSURE,
suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
),
TessieSensorEntityDescription(
key="vehicle_state-tpms_pressure_rl",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPressure.BAR,
suggested_unit_of_measurement=UnitOfPressure.PSI,
device_class=SensorDeviceClass.PRESSURE,
suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
),
TessieSensorEntityDescription(
key="vehicle_state-tpms_pressure_rr",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPressure.BAR,
suggested_unit_of_measurement=UnitOfPressure.PSI,
device_class=SensorDeviceClass.PRESSURE,
suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
),
TessieSensorEntityDescription(
key="climate_state-inside_temp",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
suggested_display_precision=1,
),
TessieSensorEntityDescription(
key="climate_state-outside_temp",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
suggested_display_precision=1,
),
TessieSensorEntityDescription(
key="climate_state-driver_temp_setting",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
),
TessieSensorEntityDescription(
key="climate_state-passenger_temp_setting",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
),
)
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up the Tessie sensor platform from a config entry."""
coordinators = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
TessieSensorEntity(coordinator, description)
for coordinator in coordinators
for description in DESCRIPTIONS
if description.key in coordinator.data
)
class TessieSensorEntity(TessieEntity, SensorEntity):
"""Base class for Tessie metric sensors."""
entity_description: TessieSensorEntityDescription
def __init__(
self,
coordinator: TessieDataUpdateCoordinator,
description: TessieSensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator, description.key)
self.entity_description = description
@property
def native_value(self) -> StateType:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.value)

View file

@ -0,0 +1,92 @@
{
"config": {
"error": {
"invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"user": {
"data": {
"access_token": "[%key:common::config_flow::data::access_token%]"
},
"description": "Enter your access token from [my.tessie.com/settings/api](https://my.tessie.com/settings/api)."
}
}
},
"entity": {
"sensor": {
"state": {
"name": "Status",
"state": {
"online": "Online",
"asleep": "Asleep",
"offline": "Offline"
}
},
"charge_state-usable_battery_level": {
"name": "Battery Level"
},
"charge_state-charge_energy_added": {
"name": "Charge Energy Added"
},
"charge_state-charger_power": {
"name": "Charger Power"
},
"charge_state-charger_voltage": {
"name": "Charger Voltage"
},
"charge_state-charger_actual_current": {
"name": "Charger Current"
},
"charge_state-charge_rate": {
"name": "Charge Rate"
},
"charge_state-battery_range": {
"name": "Battery Range"
},
"drive_state-speed": {
"name": "Speed"
},
"drive_state-power": {
"name": "Power"
},
"drive_state-shift_state": {
"name": "Shift State",
"state": {
"p": "Park",
"d": "Drive",
"r": "Reverse",
"n": "Neutral"
}
},
"vehicle_state-odometer": {
"name": "Odometer"
},
"vehicle_state-tpms_pressure_fl": {
"name": "Tyre Pressure Front Left"
},
"vehicle_state-tpms_pressure_fr": {
"name": "Tyre Pressure Front Right"
},
"vehicle_state-tpms_pressure_rl": {
"name": "Tyre Pressure Rear Left"
},
"vehicle_state-tpms_pressure_rr": {
"name": "Tyre Pressure Rear Right"
},
"climate_state-inside_temp": {
"name": "Inside Temperature"
},
"climate_state-outside_temp": {
"name": "Outside Temperature"
},
"climate_state-driver_temp_setting": {
"name": "Driver Temperature Setting"
},
"climate_state-passenger_temp_setting": {
"name": "Passenger Temperature Setting"
}
}
}
}

View file

@ -490,6 +490,7 @@ FLOWS = {
"tautulli",
"tellduslive",
"tesla_wall_connector",
"tessie",
"thermobeacon",
"thermopro",
"thread",

View file

@ -5802,6 +5802,12 @@
}
}
},
"tessie": {
"name": "Tessie",
"integration_type": "hub",
"config_flow": true,
"iot_class": "cloud_polling"
},
"tfiac": {
"name": "Tfiac",
"integration_type": "hub",

View file

@ -2603,6 +2603,9 @@ tesla-powerwall==0.3.19
# homeassistant.components.tesla_wall_connector
tesla-wall-connector==1.0.2
# homeassistant.components.tessie
tessie-api==0.0.9
# homeassistant.components.tensorflow
# tf-models-official==2.5.0

View file

@ -1943,6 +1943,9 @@ tesla-powerwall==0.3.19
# homeassistant.components.tesla_wall_connector
tesla-wall-connector==1.0.2
# homeassistant.components.tessie
tessie-api==0.0.9
# homeassistant.components.thermobeacon
thermobeacon-ble==0.6.0

View file

@ -0,0 +1 @@
"""Tests for the Tessie integration."""

View file

@ -0,0 +1,55 @@
"""Tessie common helpers for tests."""
from http import HTTPStatus
from unittest.mock import patch
from aiohttp import ClientConnectionError, ClientResponseError
from aiohttp.client import RequestInfo
from homeassistant.components.tessie.const import DOMAIN
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry, load_json_object_fixture
TEST_STATE_OF_ALL_VEHICLES = load_json_object_fixture("vehicles.json", DOMAIN)
TEST_VEHICLE_STATE_ONLINE = load_json_object_fixture("online.json", DOMAIN)
TEST_VEHICLE_STATE_ASLEEP = load_json_object_fixture("asleep.json", DOMAIN)
TEST_CONFIG = {CONF_ACCESS_TOKEN: "1234567890"}
TESSIE_URL = "https://api.tessie.com/"
TEST_REQUEST_INFO = RequestInfo(
url=TESSIE_URL, method="GET", headers={}, real_url=TESSIE_URL
)
ERROR_AUTH = ClientResponseError(
request_info=TEST_REQUEST_INFO, history=None, status=HTTPStatus.UNAUTHORIZED
)
ERROR_TIMEOUT = ClientResponseError(
request_info=TEST_REQUEST_INFO, history=None, status=HTTPStatus.REQUEST_TIMEOUT
)
ERROR_UNKNOWN = ClientResponseError(
request_info=TEST_REQUEST_INFO, history=None, status=HTTPStatus.BAD_REQUEST
)
ERROR_CONNECTION = ClientConnectionError()
async def setup_platform(hass: HomeAssistant, side_effect=None):
"""Set up the Tessie platform."""
mock_entry = MockConfigEntry(
domain=DOMAIN,
data=TEST_CONFIG,
)
mock_entry.add_to_hass(hass)
with patch(
"homeassistant.components.tessie.get_state_of_all_vehicles",
return_value=TEST_STATE_OF_ALL_VEHICLES,
side_effect=side_effect,
):
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
return mock_entry

View file

@ -0,0 +1 @@
{ "state": "asleep" }

View file

@ -0,0 +1,276 @@
{
"user_id": 234567890,
"vehicle_id": 345678901,
"vin": "VINVINVIN",
"color": null,
"access_type": "OWNER",
"granular_access": {
"hide_private": false
},
"tokens": ["beef", "c0ffee"],
"state": "online",
"in_service": false,
"id_s": "123456789",
"calendar_enabled": true,
"api_version": 67,
"backseat_token": null,
"backseat_token_updated_at": null,
"ble_autopair_enrolled": false,
"charge_state": {
"battery_heater_on": false,
"battery_level": 75,
"battery_range": 263.68,
"charge_amps": 32,
"charge_current_request": 32,
"charge_current_request_max": 32,
"charge_enable_request": true,
"charge_energy_added": 18.47,
"charge_limit_soc": 80,
"charge_limit_soc_max": 100,
"charge_limit_soc_min": 50,
"charge_limit_soc_std": 80,
"charge_miles_added_ideal": 84,
"charge_miles_added_rated": 84,
"charge_port_cold_weather_mode": false,
"charge_port_color": "<invalid>",
"charge_port_door_open": true,
"charge_port_latch": "Engaged",
"charge_rate": 30.6,
"charger_actual_current": 32,
"charger_phases": 1,
"charger_pilot_current": 32,
"charger_power": 7,
"charger_voltage": 224,
"charging_state": "Charging",
"conn_charge_cable": "IEC",
"est_battery_range": 324.73,
"fast_charger_brand": "",
"fast_charger_present": false,
"fast_charger_type": "ACSingleWireCAN",
"ideal_battery_range": 263.68,
"max_range_charge_counter": 0,
"minutes_to_full_charge": 30,
"not_enough_power_to_heat": null,
"off_peak_charging_enabled": false,
"off_peak_charging_times": "all_week",
"off_peak_hours_end_time": 900,
"preconditioning_enabled": false,
"preconditioning_times": "all_week",
"scheduled_charging_mode": "StartAt",
"scheduled_charging_pending": false,
"scheduled_charging_start_time": 1701216000,
"scheduled_charging_start_time_app": 600,
"scheduled_charging_start_time_minutes": 600,
"scheduled_departure_time": 1694899800,
"scheduled_departure_time_minutes": 450,
"supercharger_session_trip_planner": false,
"time_to_full_charge": 0.5,
"timestamp": 1701139037461,
"trip_charging": false,
"usable_battery_level": 75,
"user_charge_enable_request": null
},
"climate_state": {
"allow_cabin_overheat_protection": true,
"auto_seat_climate_left": true,
"auto_seat_climate_right": true,
"auto_steering_wheel_heat": true,
"battery_heater": false,
"battery_heater_no_power": null,
"cabin_overheat_protection": "On",
"cabin_overheat_protection_actively_cooling": false,
"climate_keeper_mode": "off",
"cop_activation_temperature": "High",
"defrost_mode": 0,
"driver_temp_setting": 22.5,
"fan_status": 0,
"hvac_auto_request": "On",
"inside_temp": 30.4,
"is_auto_conditioning_on": false,
"is_climate_on": false,
"is_front_defroster_on": false,
"is_preconditioning": false,
"is_rear_defroster_on": false,
"left_temp_direction": 234,
"max_avail_temp": 28,
"min_avail_temp": 15,
"outside_temp": 30.5,
"passenger_temp_setting": 22.5,
"remote_heater_control_enabled": false,
"right_temp_direction": 234,
"seat_heater_left": 0,
"seat_heater_rear_center": 0,
"seat_heater_rear_left": 0,
"seat_heater_rear_right": 0,
"seat_heater_right": 0,
"side_mirror_heaters": false,
"steering_wheel_heat_level": 0,
"steering_wheel_heater": false,
"supports_fan_only_cabin_overheat_protection": true,
"timestamp": 1701139037461,
"wiper_blade_heater": false
},
"drive_state": {
"active_route_latitude": 30.2226265,
"active_route_longitude": -97.6236871,
"active_route_traffic_minutes_delay": 0,
"gps_as_of": 1701129612,
"heading": 185,
"latitude": -30.222626,
"longitude": -97.6236871,
"native_latitude": -30.222626,
"native_location_supported": 1,
"native_longitude": -97.6236871,
"native_type": "wgs",
"power": -7,
"shift_state": null,
"speed": null,
"timestamp": 1701139037461
},
"gui_settings": {
"gui_24_hour_time": false,
"gui_charge_rate_units": "kW",
"gui_distance_units": "km/hr",
"gui_range_display": "Rated",
"gui_temperature_units": "C",
"gui_tirepressure_units": "Psi",
"show_range_units": false,
"timestamp": 1701139037461
},
"vehicle_config": {
"aux_park_lamps": "Eu",
"badge_version": 1,
"can_accept_navigation_requests": true,
"can_actuate_trunks": true,
"car_special_type": "base",
"car_type": "model3",
"charge_port_type": "CCS",
"cop_user_set_temp_supported": false,
"dashcam_clip_save_supported": true,
"default_charge_to_max": false,
"driver_assist": "TeslaAP3",
"ece_restrictions": false,
"efficiency_package": "M32021",
"eu_vehicle": true,
"exterior_color": "DeepBlue",
"exterior_trim": "Black",
"exterior_trim_override": "",
"has_air_suspension": false,
"has_ludicrous_mode": false,
"has_seat_cooling": false,
"headlamp_type": "Global",
"interior_trim_type": "White2",
"key_version": 2,
"motorized_charge_port": true,
"paint_color_override": "0,9,25,0.7,0.04",
"performance_package": "Base",
"plg": true,
"pws": false,
"rear_drive_unit": "PM216MOSFET",
"rear_seat_heaters": 1,
"rear_seat_type": 0,
"rhd": true,
"roof_color": "RoofColorGlass",
"seat_type": null,
"spoiler_type": "None",
"sun_roof_installed": null,
"supports_qr_pairing": false,
"third_row_seats": "None",
"timestamp": 1701139037461,
"trim_badging": "74d",
"use_range_badging": true,
"utc_offset": 36000,
"webcam_selfie_supported": true,
"webcam_supported": true,
"wheel_type": "Pinwheel18CapKit"
},
"vehicle_state": {
"api_version": 67,
"autopark_state_v2": "unavailable",
"calendar_supported": true,
"car_version": "2023.38.6 c1f85ddb415f",
"center_display_state": 0,
"dashcam_clip_save_available": true,
"dashcam_state": "Recording",
"df": 0,
"dr": 0,
"fd_window": 0,
"feature_bitmask": "fbdffbff,7f",
"fp_window": 0,
"ft": 0,
"is_user_present": false,
"locked": true,
"media_info": {
"audio_volume": 2.3333,
"audio_volume_increment": 0.333333,
"audio_volume_max": 10.333333,
"media_playback_status": "Stopped",
"now_playing_album": "",
"now_playing_artist": "",
"now_playing_duration": 0,
"now_playing_elapsed": 0,
"now_playing_source": "Spotify",
"now_playing_station": "",
"now_playing_title": ""
},
"media_state": {
"remote_control_enabled": false
},
"notifications_supported": true,
"odometer": 5454.495383,
"parsed_calendar_supported": true,
"pf": 0,
"pr": 0,
"rd_window": 0,
"remote_start": false,
"remote_start_enabled": true,
"remote_start_supported": true,
"rp_window": 0,
"rt": 0,
"santa_mode": 0,
"sentry_mode": false,
"sentry_mode_available": true,
"service_mode": false,
"service_mode_plus": false,
"software_update": {
"download_perc": 0,
"expected_duration_sec": 2700,
"install_perc": 1,
"status": "",
"version": " "
},
"speed_limit_mode": {
"active": false,
"current_limit_mph": 74.564543,
"max_limit_mph": 120,
"min_limit_mph": 50,
"pin_code_set": true
},
"timestamp": 1701139037461,
"tpms_hard_warning_fl": false,
"tpms_hard_warning_fr": false,
"tpms_hard_warning_rl": false,
"tpms_hard_warning_rr": false,
"tpms_last_seen_pressure_time_fl": 1701062077,
"tpms_last_seen_pressure_time_fr": 1701062047,
"tpms_last_seen_pressure_time_rl": 1701062077,
"tpms_last_seen_pressure_time_rr": 1701062047,
"tpms_pressure_fl": 2.975,
"tpms_pressure_fr": 2.975,
"tpms_pressure_rl": 2.95,
"tpms_pressure_rr": 2.95,
"tpms_rcp_front_value": 2.9,
"tpms_rcp_rear_value": 2.9,
"tpms_soft_warning_fl": false,
"tpms_soft_warning_fr": false,
"tpms_soft_warning_rl": false,
"tpms_soft_warning_rr": false,
"valet_mode": false,
"valet_pin_needed": false,
"vehicle_name": "Test",
"vehicle_self_test_progress": 0,
"vehicle_self_test_requested": false,
"webcam_available": true
},
"display_name": "Test"
}

View file

@ -0,0 +1,292 @@
{
"results": [
{
"vin": "VINVINVIN",
"is_active": true,
"is_archived_manually": false,
"last_charge_created_at": null,
"last_charge_updated_at": null,
"last_drive_created_at": null,
"last_drive_updated_at": null,
"last_idle_created_at": null,
"last_idle_updated_at": null,
"last_state": {
"id": 123456789,
"user_id": 234567890,
"vehicle_id": 345678901,
"vin": "VINVINVIN",
"color": null,
"access_type": "OWNER",
"granular_access": {
"hide_private": false
},
"tokens": ["beef", "c0ffee"],
"state": "online",
"in_service": false,
"id_s": "123456789",
"calendar_enabled": true,
"api_version": 67,
"backseat_token": null,
"backseat_token_updated_at": null,
"ble_autopair_enrolled": false,
"charge_state": {
"battery_heater_on": false,
"battery_level": 75,
"battery_range": 263.68,
"charge_amps": 32,
"charge_current_request": 32,
"charge_current_request_max": 32,
"charge_enable_request": true,
"charge_energy_added": 18.47,
"charge_limit_soc": 80,
"charge_limit_soc_max": 100,
"charge_limit_soc_min": 50,
"charge_limit_soc_std": 80,
"charge_miles_added_ideal": 84,
"charge_miles_added_rated": 84,
"charge_port_cold_weather_mode": false,
"charge_port_color": "<invalid>",
"charge_port_door_open": true,
"charge_port_latch": "Engaged",
"charge_rate": 30.6,
"charger_actual_current": 32,
"charger_phases": 1,
"charger_pilot_current": 32,
"charger_power": 7,
"charger_voltage": 224,
"charging_state": "Charging",
"conn_charge_cable": "IEC",
"est_battery_range": 324.73,
"fast_charger_brand": "",
"fast_charger_present": false,
"fast_charger_type": "ACSingleWireCAN",
"ideal_battery_range": 263.68,
"max_range_charge_counter": 0,
"minutes_to_full_charge": 30,
"not_enough_power_to_heat": null,
"off_peak_charging_enabled": false,
"off_peak_charging_times": "all_week",
"off_peak_hours_end_time": 900,
"preconditioning_enabled": false,
"preconditioning_times": "all_week",
"scheduled_charging_mode": "StartAt",
"scheduled_charging_pending": false,
"scheduled_charging_start_time": 1701216000,
"scheduled_charging_start_time_app": 600,
"scheduled_charging_start_time_minutes": 600,
"scheduled_departure_time": 1694899800,
"scheduled_departure_time_minutes": 450,
"supercharger_session_trip_planner": false,
"time_to_full_charge": 0.5,
"timestamp": 1701139037461,
"trip_charging": false,
"usable_battery_level": 75,
"user_charge_enable_request": null
},
"climate_state": {
"allow_cabin_overheat_protection": true,
"auto_seat_climate_left": true,
"auto_seat_climate_right": true,
"auto_steering_wheel_heat": true,
"battery_heater": false,
"battery_heater_no_power": null,
"cabin_overheat_protection": "On",
"cabin_overheat_protection_actively_cooling": false,
"climate_keeper_mode": "off",
"cop_activation_temperature": "High",
"defrost_mode": 0,
"driver_temp_setting": 22.5,
"fan_status": 0,
"hvac_auto_request": "On",
"inside_temp": 30.4,
"is_auto_conditioning_on": false,
"is_climate_on": false,
"is_front_defroster_on": false,
"is_preconditioning": false,
"is_rear_defroster_on": false,
"left_temp_direction": 234,
"max_avail_temp": 28,
"min_avail_temp": 15,
"outside_temp": 30.5,
"passenger_temp_setting": 22.5,
"remote_heater_control_enabled": false,
"right_temp_direction": 234,
"seat_heater_left": 0,
"seat_heater_rear_center": 0,
"seat_heater_rear_left": 0,
"seat_heater_rear_right": 0,
"seat_heater_right": 0,
"side_mirror_heaters": false,
"steering_wheel_heat_level": 0,
"steering_wheel_heater": false,
"supports_fan_only_cabin_overheat_protection": true,
"timestamp": 1701139037461,
"wiper_blade_heater": false
},
"drive_state": {
"active_route_latitude": 30.2226265,
"active_route_longitude": -97.6236871,
"active_route_traffic_minutes_delay": 0,
"gps_as_of": 1701129612,
"heading": 185,
"latitude": -30.222626,
"longitude": -97.6236871,
"native_latitude": -30.222626,
"native_location_supported": 1,
"native_longitude": -97.6236871,
"native_type": "wgs",
"power": -7,
"shift_state": null,
"speed": null,
"timestamp": 1701139037461
},
"gui_settings": {
"gui_24_hour_time": false,
"gui_charge_rate_units": "kW",
"gui_distance_units": "km/hr",
"gui_range_display": "Rated",
"gui_temperature_units": "C",
"gui_tirepressure_units": "Psi",
"show_range_units": false,
"timestamp": 1701139037461
},
"vehicle_config": {
"aux_park_lamps": "Eu",
"badge_version": 1,
"can_accept_navigation_requests": true,
"can_actuate_trunks": true,
"car_special_type": "base",
"car_type": "model3",
"charge_port_type": "CCS",
"cop_user_set_temp_supported": false,
"dashcam_clip_save_supported": true,
"default_charge_to_max": false,
"driver_assist": "TeslaAP3",
"ece_restrictions": false,
"efficiency_package": "M32021",
"eu_vehicle": true,
"exterior_color": "DeepBlue",
"exterior_trim": "Black",
"exterior_trim_override": "",
"has_air_suspension": false,
"has_ludicrous_mode": false,
"has_seat_cooling": false,
"headlamp_type": "Global",
"interior_trim_type": "White2",
"key_version": 2,
"motorized_charge_port": true,
"paint_color_override": "0,9,25,0.7,0.04",
"performance_package": "Base",
"plg": true,
"pws": false,
"rear_drive_unit": "PM216MOSFET",
"rear_seat_heaters": 1,
"rear_seat_type": 0,
"rhd": true,
"roof_color": "RoofColorGlass",
"seat_type": null,
"spoiler_type": "None",
"sun_roof_installed": null,
"supports_qr_pairing": false,
"third_row_seats": "None",
"timestamp": 1701139037461,
"trim_badging": "74d",
"use_range_badging": true,
"utc_offset": 36000,
"webcam_selfie_supported": true,
"webcam_supported": true,
"wheel_type": "Pinwheel18CapKit"
},
"vehicle_state": {
"api_version": 67,
"autopark_state_v2": "unavailable",
"calendar_supported": true,
"car_version": "2023.38.6 c1f85ddb415f",
"center_display_state": 0,
"dashcam_clip_save_available": true,
"dashcam_state": "Recording",
"df": 0,
"dr": 0,
"fd_window": 0,
"feature_bitmask": "fbdffbff,7f",
"fp_window": 0,
"ft": 0,
"is_user_present": false,
"locked": true,
"media_info": {
"audio_volume": 2.3333,
"audio_volume_increment": 0.333333,
"audio_volume_max": 10.333333,
"media_playback_status": "Stopped",
"now_playing_album": "",
"now_playing_artist": "",
"now_playing_duration": 0,
"now_playing_elapsed": 0,
"now_playing_source": "Spotify",
"now_playing_station": "",
"now_playing_title": ""
},
"media_state": {
"remote_control_enabled": false
},
"notifications_supported": true,
"odometer": 5454.495383,
"parsed_calendar_supported": true,
"pf": 0,
"pr": 0,
"rd_window": 0,
"remote_start": false,
"remote_start_enabled": true,
"remote_start_supported": true,
"rp_window": 0,
"rt": 0,
"santa_mode": 0,
"sentry_mode": false,
"sentry_mode_available": true,
"service_mode": false,
"service_mode_plus": false,
"software_update": {
"download_perc": 0,
"expected_duration_sec": 2700,
"install_perc": 1,
"status": "",
"version": " "
},
"speed_limit_mode": {
"active": false,
"current_limit_mph": 74.564543,
"max_limit_mph": 120,
"min_limit_mph": 50,
"pin_code_set": true
},
"timestamp": 1701139037461,
"tpms_hard_warning_fl": false,
"tpms_hard_warning_fr": false,
"tpms_hard_warning_rl": false,
"tpms_hard_warning_rr": false,
"tpms_last_seen_pressure_time_fl": 1701062077,
"tpms_last_seen_pressure_time_fr": 1701062047,
"tpms_last_seen_pressure_time_rl": 1701062077,
"tpms_last_seen_pressure_time_rr": 1701062047,
"tpms_pressure_fl": 2.975,
"tpms_pressure_fr": 2.975,
"tpms_pressure_rl": 2.95,
"tpms_pressure_rr": 2.95,
"tpms_rcp_front_value": 2.9,
"tpms_rcp_rear_value": 2.9,
"tpms_soft_warning_fl": false,
"tpms_soft_warning_fr": false,
"tpms_soft_warning_rl": false,
"tpms_soft_warning_rr": false,
"valet_mode": false,
"valet_pin_needed": false,
"vehicle_name": "Test",
"vehicle_self_test_progress": 0,
"vehicle_self_test_requested": false,
"webcam_available": true
},
"display_name": "Test"
}
}
]
}

View file

@ -0,0 +1,139 @@
"""Test the Tessie config flow."""
from unittest.mock import patch
from homeassistant import config_entries
from homeassistant.components.tessie.const import DOMAIN
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from .common import (
ERROR_AUTH,
ERROR_CONNECTION,
ERROR_UNKNOWN,
TEST_CONFIG,
TEST_STATE_OF_ALL_VEHICLES,
)
async def test_form(hass: HomeAssistant) -> None:
"""Test we get the form."""
result1 = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result1["type"] == FlowResultType.FORM
assert not result1["errors"]
with patch(
"homeassistant.components.tessie.config_flow.get_state_of_all_vehicles",
return_value=TEST_STATE_OF_ALL_VEHICLES,
) as mock_get_state_of_all_vehicles, patch(
"homeassistant.components.tessie.async_setup_entry",
return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result1["flow_id"],
TEST_CONFIG,
)
await hass.async_block_till_done()
assert len(mock_setup_entry.mock_calls) == 1
assert len(mock_get_state_of_all_vehicles.mock_calls) == 1
assert result2["type"] == FlowResultType.CREATE_ENTRY
assert result2["title"] == "Tessie"
assert result2["data"] == TEST_CONFIG
async def test_form_invalid_access_token(hass: HomeAssistant) -> None:
"""Test invalid auth is handled."""
result1 = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"homeassistant.components.tessie.config_flow.get_state_of_all_vehicles",
side_effect=ERROR_AUTH,
):
result2 = await hass.config_entries.flow.async_configure(
result1["flow_id"],
TEST_CONFIG,
)
assert result2["type"] == FlowResultType.FORM
assert result2["errors"] == {CONF_ACCESS_TOKEN: "invalid_access_token"}
# Complete the flow
with patch(
"homeassistant.components.tessie.config_flow.get_state_of_all_vehicles",
return_value=TEST_STATE_OF_ALL_VEHICLES,
):
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"],
TEST_CONFIG,
)
assert result3["type"] == FlowResultType.CREATE_ENTRY
async def test_form_invalid_response(hass: HomeAssistant) -> None:
"""Test invalid auth is handled."""
result1 = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"homeassistant.components.tessie.config_flow.get_state_of_all_vehicles",
side_effect=ERROR_UNKNOWN,
):
result2 = await hass.config_entries.flow.async_configure(
result1["flow_id"],
TEST_CONFIG,
)
assert result2["type"] == FlowResultType.FORM
assert result2["errors"] == {"base": "unknown"}
# Complete the flow
with patch(
"homeassistant.components.tessie.config_flow.get_state_of_all_vehicles",
return_value=TEST_STATE_OF_ALL_VEHICLES,
):
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"],
TEST_CONFIG,
)
assert result3["type"] == FlowResultType.CREATE_ENTRY
async def test_form_network_issue(hass: HomeAssistant) -> None:
"""Test network issues are handled."""
result1 = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"homeassistant.components.tessie.config_flow.get_state_of_all_vehicles",
side_effect=ERROR_CONNECTION,
):
result2 = await hass.config_entries.flow.async_configure(
result1["flow_id"],
TEST_CONFIG,
)
assert result2["type"] == FlowResultType.FORM
assert result2["errors"] == {"base": "cannot_connect"}
# Complete the flow
with patch(
"homeassistant.components.tessie.config_flow.get_state_of_all_vehicles",
return_value=TEST_STATE_OF_ALL_VEHICLES,
):
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"],
TEST_CONFIG,
)
assert result3["type"] == FlowResultType.CREATE_ENTRY

View file

@ -0,0 +1,92 @@
"""Test the Tessie sensor platform."""
from datetime import timedelta
from unittest.mock import patch
import pytest
from homeassistant.components.tessie.coordinator import TESSIE_SYNC_INTERVAL
from homeassistant.components.tessie.sensor import TessieStatus
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from homeassistant.util.dt import utcnow
from .common import (
ERROR_CONNECTION,
ERROR_TIMEOUT,
ERROR_UNKNOWN,
TEST_VEHICLE_STATE_ASLEEP,
TEST_VEHICLE_STATE_ONLINE,
setup_platform,
)
from tests.common import async_fire_time_changed
WAIT = timedelta(seconds=TESSIE_SYNC_INTERVAL)
@pytest.fixture
def mock_get_state():
"""Mock get_state function."""
with patch(
"homeassistant.components.tessie.coordinator.get_state",
) as mock_get_state:
yield mock_get_state
async def test_coordinator_online(hass: HomeAssistant, mock_get_state) -> None:
"""Tests that the coordinator handles online vehciles."""
mock_get_state.return_value = TEST_VEHICLE_STATE_ONLINE
await setup_platform(hass)
async_fire_time_changed(hass, utcnow() + WAIT)
await hass.async_block_till_done()
mock_get_state.assert_called_once()
assert hass.states.get("sensor.test_status").state == TessieStatus.ONLINE
async def test_coordinator_asleep(hass: HomeAssistant, mock_get_state) -> None:
"""Tests that the coordinator handles asleep vehicles."""
mock_get_state.return_value = TEST_VEHICLE_STATE_ASLEEP
await setup_platform(hass)
async_fire_time_changed(hass, utcnow() + WAIT)
await hass.async_block_till_done()
mock_get_state.assert_called_once()
assert hass.states.get("sensor.test_status").state == TessieStatus.ASLEEP
async def test_coordinator_clienterror(hass: HomeAssistant, mock_get_state) -> None:
"""Tests that the coordinator handles client errors."""
mock_get_state.side_effect = ERROR_UNKNOWN
await setup_platform(hass)
async_fire_time_changed(hass, utcnow() + WAIT)
await hass.async_block_till_done()
mock_get_state.assert_called_once()
assert hass.states.get("sensor.test_status").state == STATE_UNAVAILABLE
async def test_coordinator_timeout(hass: HomeAssistant, mock_get_state) -> None:
"""Tests that the coordinator handles timeout errors."""
mock_get_state.side_effect = ERROR_TIMEOUT
await setup_platform(hass)
async_fire_time_changed(hass, utcnow() + WAIT)
await hass.async_block_till_done()
mock_get_state.assert_called_once()
assert hass.states.get("sensor.test_status").state == TessieStatus.OFFLINE
async def test_coordinator_connection(hass: HomeAssistant, mock_get_state) -> None:
"""Tests that the coordinator handles connection errors."""
mock_get_state.side_effect = ERROR_CONNECTION
await setup_platform(hass)
async_fire_time_changed(hass, utcnow() + WAIT)
await hass.async_block_till_done()
mock_get_state.assert_called_once()
assert hass.states.get("sensor.test_status").state == STATE_UNAVAILABLE

View file

@ -0,0 +1,30 @@
"""Test the Tessie init."""
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from .common import ERROR_CONNECTION, ERROR_UNKNOWN, setup_platform
async def test_load_unload(hass: HomeAssistant) -> None:
"""Test load and unload."""
entry = await setup_platform(hass)
assert entry.state is ConfigEntryState.LOADED
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.NOT_LOADED
async def test_unknown_failure(hass: HomeAssistant) -> None:
"""Test init with an authentication failure."""
entry = await setup_platform(hass, side_effect=ERROR_UNKNOWN)
assert entry.state is ConfigEntryState.SETUP_ERROR
async def test_connection_failure(hass: HomeAssistant) -> None:
"""Test init with a network connection failure."""
entry = await setup_platform(hass, side_effect=ERROR_CONNECTION)
assert entry.state is ConfigEntryState.SETUP_RETRY

View file

@ -0,0 +1,24 @@
"""Test the Tessie sensor platform."""
from homeassistant.components.tessie.sensor import DESCRIPTIONS
from homeassistant.const import STATE_UNKNOWN
from homeassistant.core import HomeAssistant
from .common import TEST_VEHICLE_STATE_ONLINE, setup_platform
async def test_sensors(hass: HomeAssistant) -> None:
"""Tests that the sensors are correct."""
assert len(hass.states.async_all("sensor")) == 0
await setup_platform(hass)
assert len(hass.states.async_all("sensor")) == len(DESCRIPTIONS)
assert hass.states.get("sensor.test_battery_level").state == str(
TEST_VEHICLE_STATE_ONLINE["charge_state"]["battery_level"]
)
assert hass.states.get("sensor.test_charge_energy_added").state == str(
TEST_VEHICLE_STATE_ONLINE["charge_state"]["charge_energy_added"]
)
assert hass.states.get("sensor.test_shift_state").state == STATE_UNKNOWN