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:
parent
114bf0da34
commit
909cdc2e5c
19 changed files with 621 additions and 0 deletions
|
@ -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/*
|
||||||
|
|
|
@ -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
|
||||||
|
|
77
homeassistant/components/teslemetry/__init__.py
Normal file
77
homeassistant/components/teslemetry/__init__.py
Normal 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
|
130
homeassistant/components/teslemetry/climate.py
Normal file
130
homeassistant/components/teslemetry/climate.py
Normal 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],
|
||||||
|
),
|
||||||
|
)
|
63
homeassistant/components/teslemetry/config_flow.py
Normal file
63
homeassistant/components/teslemetry/config_flow.py
Normal 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,
|
||||||
|
)
|
31
homeassistant/components/teslemetry/const.py
Normal file
31
homeassistant/components/teslemetry/const.py
Normal 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"
|
16
homeassistant/components/teslemetry/context.py
Normal file
16
homeassistant/components/teslemetry/context.py
Normal 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
|
67
homeassistant/components/teslemetry/coordinator.py
Normal file
67
homeassistant/components/teslemetry/coordinator.py
Normal 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
|
62
homeassistant/components/teslemetry/entity.py
Normal file
62
homeassistant/components/teslemetry/entity.py
Normal 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()
|
10
homeassistant/components/teslemetry/manifest.json
Normal file
10
homeassistant/components/teslemetry/manifest.json
Normal 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"]
|
||||||
|
}
|
17
homeassistant/components/teslemetry/models.py
Normal file
17
homeassistant/components/teslemetry/models.py
Normal 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
|
35
homeassistant/components/teslemetry/strings.json
Normal file
35
homeassistant/components/teslemetry/strings.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -515,6 +515,7 @@ FLOWS = {
|
||||||
"tedee",
|
"tedee",
|
||||||
"tellduslive",
|
"tellduslive",
|
||||||
"tesla_wall_connector",
|
"tesla_wall_connector",
|
||||||
|
"teslemetry",
|
||||||
"tessie",
|
"tessie",
|
||||||
"thermobeacon",
|
"thermobeacon",
|
||||||
"thermopro",
|
"thermopro",
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
1
tests/components/teslemetry/__init__.py
Normal file
1
tests/components/teslemetry/__init__.py
Normal file
|
@ -0,0 +1 @@
|
||||||
|
"""Tests for the Teslemetry integration."""
|
5
tests/components/teslemetry/const.py
Normal file
5
tests/components/teslemetry/const.py
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
"""Constants for the teslemetry tests."""
|
||||||
|
|
||||||
|
from homeassistant.const import CONF_ACCESS_TOKEN
|
||||||
|
|
||||||
|
CONFIG = {CONF_ACCESS_TOKEN: "1234567890"}
|
87
tests/components/teslemetry/test_config_flow.py
Normal file
87
tests/components/teslemetry/test_config_flow.py
Normal 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
|
Loading…
Add table
Add a link
Reference in a new issue