Add Teslemetry Integration (#108147)

* Copy Paste Find Replace

* Small progress

* wip

* more wip

* Add SSE listen and close

* More rework

* Fix coordinator

* Get working

* Bump to 0.1.3

* Push to 0.1.4

* Lots of fixes

* Remove stream

* Add wakeup

* Improve set temp

* Be consistent with self

* Increase polling until streaming

* Work in progress

* Move to single climate

* bump to 0.2.0

* Update entity

* Data handling

* fixes

* WIP tests

* Tests

* Delete other tests

* Update comment

* Fix init

* Update homeassistant/components/teslemetry/entity.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Add Codeowner

* Update coverage

* requirements

* Add failure for subscription required

* Add VIN to model

* Add wake

* Add context manager

* Rename to wake_up_if_asleep

* Remove context from coverage

* change lock to context

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Improving Logger

* Add url to subscription error

* Errors cannot markdown

* Fix logger

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* rename logger

* Fix error logging

* Apply suggestions from code review

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Brett Adams 2024-01-25 21:54:47 +10:00 committed by GitHub
parent 114bf0da34
commit 909cdc2e5c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 621 additions and 0 deletions

View file

@ -1362,6 +1362,11 @@ omit =
homeassistant/components/telnet/switch.py homeassistant/components/telnet/switch.py
homeassistant/components/temper/sensor.py homeassistant/components/temper/sensor.py
homeassistant/components/tensorflow/image_processing.py homeassistant/components/tensorflow/image_processing.py
homeassistant/components/teslemetry/__init__.py
homeassistant/components/teslemetry/climate.py
homeassistant/components/teslemetry/coordinator.py
homeassistant/components/teslemetry/entity.py
homeassistant/components/teslemetry/context.py
homeassistant/components/tfiac/climate.py homeassistant/components/tfiac/climate.py
homeassistant/components/thermoworks_smoke/sensor.py homeassistant/components/thermoworks_smoke/sensor.py
homeassistant/components/thethingsnetwork/* homeassistant/components/thethingsnetwork/*

View file

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

View file

@ -0,0 +1,77 @@
"""Teslemetry integration."""
import asyncio
from typing import Final
from tesla_fleet_api import Teslemetry
from tesla_fleet_api.exceptions import InvalidToken, PaymentRequired, TeslaFleetError
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, LOGGER
from .coordinator import TeslemetryVehicleDataCoordinator
from .models import TeslemetryVehicleData
PLATFORMS: Final = [
Platform.CLIMATE,
]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Teslemetry config."""
access_token = entry.data[CONF_ACCESS_TOKEN]
# Create API connection
teslemetry = Teslemetry(
session=async_get_clientsession(hass),
access_token=access_token,
)
try:
products = (await teslemetry.products())["response"]
except InvalidToken:
LOGGER.error("Access token is invalid, unable to connect to Teslemetry")
return False
except PaymentRequired:
LOGGER.error("Subscription required, unable to connect to Telemetry")
return False
except TeslaFleetError as e:
raise ConfigEntryNotReady from e
# Create array of classes
data = []
for product in products:
if "vin" not in product:
continue
vin = product["vin"]
api = teslemetry.vehicle.specific(vin)
coordinator = TeslemetryVehicleDataCoordinator(hass, api)
data.append(
TeslemetryVehicleData(
api=api,
coordinator=coordinator,
vin=vin,
)
)
# Do all coordinator first refresh simultaneously
await asyncio.gather(
*(vehicle.coordinator.async_config_entry_first_refresh() for vehicle in data)
)
# Setup Platforms
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = data
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload Teslemetry 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,130 @@
"""Climate platform for Teslemetry integration."""
from __future__ import annotations
from typing import Any
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, TeslemetryClimateSide
from .context import handle_command
from .entity import TeslemetryVehicleEntity
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up the Teslemetry Climate platform from a config entry."""
data = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
TeslemetryClimateEntity(vehicle, TeslemetryClimateSide.DRIVER)
for vehicle in data
)
class TeslemetryClimateEntity(TeslemetryVehicleEntity, 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 = ["off", "keep", "dog", "camp"]
@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(f"climate_state_{self.key}_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."""
with handle_command():
await self.wake_up_if_asleep()
await self.api.auto_conditioning_start()
self.set(("climate_state_is_climate_on", True))
async def async_turn_off(self) -> None:
"""Set the climate state to off."""
with handle_command():
await self.wake_up_if_asleep()
await self.api.auto_conditioning_stop()
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]
with handle_command():
await self.wake_up_if_asleep()
await self.api.set_temps(
driver_temp=temp,
passenger_temp=temp,
)
self.set((f"climate_state_{self.key}_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."""
with handle_command():
await self.wake_up_if_asleep()
await self.api.set_climate_keeper_mode(
climate_keeper_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

@ -0,0 +1,63 @@
"""Config Flow for Teslemetry integration."""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from aiohttp import ClientConnectionError
from tesla_fleet_api import Teslemetry
from tesla_fleet_api.exceptions import InvalidToken, PaymentRequired, TeslaFleetError
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, LOGGER
TESLEMETRY_SCHEMA = vol.Schema({vol.Required(CONF_ACCESS_TOKEN): str})
DESCRIPTION_PLACEHOLDERS = {
"short_url": "teslemetry.com/console",
"url": "[teslemetry.com/console](https://teslemetry.com/console)",
}
class TeslemetryConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Config Teslemetry 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:
teslemetry = Teslemetry(
session=async_get_clientsession(self.hass),
access_token=user_input[CONF_ACCESS_TOKEN],
)
try:
await teslemetry.test()
except InvalidToken:
errors[CONF_ACCESS_TOKEN] = "invalid_access_token"
except PaymentRequired:
errors["base"] = "subscription_required"
except ClientConnectionError:
errors["base"] = "cannot_connect"
except TeslaFleetError as e:
LOGGER.exception(str(e))
errors["base"] = "unknown"
else:
return self.async_create_entry(
title="Teslemetry",
data=user_input,
)
return self.async_show_form(
step_id="user",
data_schema=TESLEMETRY_SCHEMA,
description_placeholders=DESCRIPTION_PLACEHOLDERS,
errors=errors,
)

View file

@ -0,0 +1,31 @@
"""Constants used by Teslemetry integration."""
from __future__ import annotations
from enum import StrEnum
import logging
DOMAIN = "teslemetry"
LOGGER = logging.getLogger(__package__)
MODELS = {
"model3": "Model 3",
"modelx": "Model X",
"modely": "Model Y",
"models": "Model S",
}
class TeslemetryState(StrEnum):
"""Teslemetry Vehicle States."""
ONLINE = "online"
ASLEEP = "asleep"
OFFLINE = "offline"
class TeslemetryClimateSide(StrEnum):
"""Teslemetry Climate Keeper Modes."""
DRIVER = "driver_temp"
PASSENGER = "passenger_temp"

View file

@ -0,0 +1,16 @@
"""Teslemetry context managers."""
from contextlib import contextmanager
from tesla_fleet_api.exceptions import TeslaFleetError
from homeassistant.exceptions import HomeAssistantError
@contextmanager
def handle_command():
"""Handle wake up and errors."""
try:
yield
except TeslaFleetError as e:
raise HomeAssistantError from e

View file

@ -0,0 +1,67 @@
"""Teslemetry Data Coordinator."""
from datetime import timedelta
from typing import Any
from tesla_fleet_api.exceptions import TeslaFleetError, VehicleOffline
from tesla_fleet_api.vehiclespecific import VehicleSpecific
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import LOGGER, TeslemetryState
SYNC_INTERVAL = 60
class TeslemetryVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Class to manage fetching data from the Teslemetry API."""
def __init__(self, hass: HomeAssistant, api: VehicleSpecific) -> None:
"""Initialize Teslemetry Data Update Coordinator."""
super().__init__(
hass,
LOGGER,
name="Teslemetry Vehicle",
update_interval=timedelta(seconds=SYNC_INTERVAL),
)
self.api = api
async def async_config_entry_first_refresh(self) -> None:
"""Perform first refresh."""
try:
response = await self.api.wake_up()
if response["response"]["state"] != TeslemetryState.ONLINE:
# The first refresh will fail, so retry later
raise ConfigEntryNotReady("Vehicle is not online")
except TeslaFleetError as e:
# The first refresh will also fail, so retry later
raise ConfigEntryNotReady from e
await super().async_config_entry_first_refresh()
async def _async_update_data(self) -> dict[str, Any]:
"""Update vehicle data using Teslemetry API."""
try:
data = await self.api.vehicle_data()
except VehicleOffline:
self.data["state"] = TeslemetryState.OFFLINE
return self.data
except TeslaFleetError as e:
raise UpdateFailed(e.message) from e
return self._flatten(data["response"])
def _flatten(
self, data: dict[str, Any], parent: str | None = None
) -> dict[str, Any]:
"""Flatten the data structure."""
result = {}
for key, value in data.items():
if parent:
key = f"{parent}_{key}"
if isinstance(value, dict):
result.update(self._flatten(value, key))
else:
result[key] = value
return result

View file

@ -0,0 +1,62 @@
"""Teslemetry parent entity class."""
import asyncio
from typing import Any
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MODELS, TeslemetryState
from .coordinator import TeslemetryVehicleDataCoordinator
from .models import TeslemetryVehicleData
class TeslemetryVehicleEntity(CoordinatorEntity[TeslemetryVehicleDataCoordinator]):
"""Parent class for Teslemetry Entities."""
_attr_has_entity_name = True
_wakelock = asyncio.Lock()
def __init__(
self,
vehicle: TeslemetryVehicleData,
key: str,
) -> None:
"""Initialize common aspects of a Teslemetry entity."""
super().__init__(vehicle.coordinator)
self.key = key
self.api = vehicle.api
car_type = self.coordinator.data["vehicle_config_car_type"]
self._attr_translation_key = key
self._attr_unique_id = f"{vehicle.vin}-{key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, vehicle.vin)},
manufacturer="Tesla",
configuration_url="https://teslemetry.com/console",
name=self.coordinator.data["vehicle_state_vehicle_name"],
model=MODELS.get(car_type, car_type),
sw_version=self.coordinator.data["vehicle_state_car_version"].split(" ")[0],
hw_version=self.coordinator.data["vehicle_config_driver_assist"],
serial_number=vehicle.vin,
)
async def wake_up_if_asleep(self) -> None:
"""Wake up the vehicle if its asleep."""
async with self._wakelock:
while self.coordinator.data["state"] != TeslemetryState.ONLINE:
state = (await self.api.wake_up())["response"]["state"]
self.coordinator.data["state"] = state
if state != TeslemetryState.ONLINE:
await asyncio.sleep(5)
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)
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

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

View file

@ -0,0 +1,17 @@
"""The Teslemetry integration models."""
from __future__ import annotations
from dataclasses import dataclass
from tesla_fleet_api import VehicleSpecific
from .coordinator import TeslemetryVehicleDataCoordinator
@dataclass
class TeslemetryVehicleData:
"""Data for a vehicle in the Teslemetry integration."""
api: VehicleSpecific
coordinator: TeslemetryVehicleDataCoordinator
vin: str

View file

@ -0,0 +1,35 @@
{
"config": {
"error": {
"invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]",
"subscription_required": "Subscription required, please visit {short_url}",
"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 an access token from {url}."
}
}
},
"entity": {
"climate": {
"driver_temp": {
"name": "[%key:component::climate::title%]",
"state_attributes": {
"preset_mode": {
"state": {
"off": "Normal",
"keep": "Keep mode",
"dog": "Dog mode",
"camp": "Camp mode"
}
}
}
}
}
}
}

View file

@ -515,6 +515,7 @@ FLOWS = {
"tedee", "tedee",
"tellduslive", "tellduslive",
"tesla_wall_connector", "tesla_wall_connector",
"teslemetry",
"tessie", "tessie",
"thermobeacon", "thermobeacon",
"thermopro", "thermopro",

View file

@ -5947,6 +5947,12 @@
} }
} }
}, },
"teslemetry": {
"name": "Teslemetry",
"integration_type": "hub",
"config_flow": true,
"iot_class": "cloud_polling"
},
"tessie": { "tessie": {
"name": "Tessie", "name": "Tessie",
"integration_type": "hub", "integration_type": "hub",

View file

@ -2656,6 +2656,9 @@ temperusb==1.6.1
# homeassistant.components.tensorflow # homeassistant.components.tensorflow
# tensorflow==2.5.0 # tensorflow==2.5.0
# homeassistant.components.teslemetry
tesla-fleet-api==0.2.0
# homeassistant.components.powerwall # homeassistant.components.powerwall
tesla-powerwall==0.5.0 tesla-powerwall==0.5.0

View file

@ -2015,6 +2015,9 @@ temescal==0.5
# homeassistant.components.temper # homeassistant.components.temper
temperusb==1.6.1 temperusb==1.6.1
# homeassistant.components.teslemetry
tesla-fleet-api==0.2.0
# homeassistant.components.powerwall # homeassistant.components.powerwall
tesla-powerwall==0.5.0 tesla-powerwall==0.5.0

View file

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

View file

@ -0,0 +1,5 @@
"""Constants for the teslemetry tests."""
from homeassistant.const import CONF_ACCESS_TOKEN
CONFIG = {CONF_ACCESS_TOKEN: "1234567890"}

View file

@ -0,0 +1,87 @@
"""Test the Teslemetry config flow."""
from unittest.mock import AsyncMock, patch
from aiohttp import ClientConnectionError
import pytest
from tesla_fleet_api.exceptions import InvalidToken, PaymentRequired, TeslaFleetError
from homeassistant import config_entries
from homeassistant.components.teslemetry.const import DOMAIN
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from .const import CONFIG
@pytest.fixture(autouse=True)
def teslemetry_config_entry_mock():
"""Mock Teslemetry api class."""
with patch(
"homeassistant.components.teslemetry.config_flow.Teslemetry",
) as teslemetry_config_entry_mock:
teslemetry_config_entry_mock.return_value.test = AsyncMock()
yield teslemetry_config_entry_mock
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.teslemetry.async_setup_entry",
return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result1["flow_id"],
CONFIG,
)
await hass.async_block_till_done()
assert len(mock_setup_entry.mock_calls) == 1
assert result2["type"] == FlowResultType.CREATE_ENTRY
assert result2["data"] == CONFIG
@pytest.mark.parametrize(
("side_effect", "error"),
[
(InvalidToken, {CONF_ACCESS_TOKEN: "invalid_access_token"}),
(PaymentRequired, {"base": "subscription_required"}),
(ClientConnectionError, {"base": "cannot_connect"}),
(TeslaFleetError, {"base": "unknown"}),
],
)
async def test_form_errors(
hass: HomeAssistant, side_effect, error, teslemetry_config_entry_mock
) -> None:
"""Test errors are handled."""
result1 = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
teslemetry_config_entry_mock.return_value.test.side_effect = side_effect
result2 = await hass.config_entries.flow.async_configure(
result1["flow_id"],
CONFIG,
)
assert result2["type"] == FlowResultType.FORM
assert result2["errors"] == error
# Complete the flow
teslemetry_config_entry_mock.return_value.test.side_effect = None
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"],
CONFIG,
)
assert result3["type"] == FlowResultType.CREATE_ENTRY