Skip to content

Commit

Permalink
Add Tesla Fleet integration (#122019)
Browse files Browse the repository at this point in the history
* Add Tesla Fleet

* Remove debug

* Improvements

* Fix refresh and stage tests

* Working oauth flow test

* Config Flow tests

* Fixes

* fixes

* Remove comment

Co-authored-by: G Johansson <goran.johansson@shiftit.se>

* Remove TYPE_CHECKING

* Add more tests

* More tests

* More tests

* revert teslemetry change

---------

Co-authored-by: G Johansson <goran.johansson@shiftit.se>
  • Loading branch information
Bre77 and gjohansson-ST authored Jul 19, 2024
1 parent 474e8b7 commit a2c2488
Show file tree
Hide file tree
Showing 31 changed files with 6,887 additions and 1 deletion.
2 changes: 2 additions & 0 deletions CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -1432,6 +1432,8 @@ build.json @home-assistant/supervisor
/tests/components/tellduslive/ @fredrike
/homeassistant/components/template/ @PhracturedBlue @tetienne @home-assistant/core
/tests/components/template/ @PhracturedBlue @tetienne @home-assistant/core
/homeassistant/components/tesla_fleet/ @Bre77
/tests/components/tesla_fleet/ @Bre77
/homeassistant/components/tesla_wall_connector/ @einarhauks
/tests/components/tesla_wall_connector/ @einarhauks
/homeassistant/components/teslemetry/ @Bre77
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/brands/tesla.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"domain": "tesla",
"name": "Tesla",
"integrations": ["powerwall", "tesla_wall_connector"]
"integrations": ["powerwall", "tesla_wall_connector", "tesla_fleet"]
}
169 changes: 169 additions & 0 deletions homeassistant/components/tesla_fleet/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
"""Tesla Fleet integration."""

import asyncio
from typing import Final

import jwt
from tesla_fleet_api import EnergySpecific, TeslaFleetApi, VehicleSpecific
from tesla_fleet_api.const import Scope
from tesla_fleet_api.exceptions import (
InvalidToken,
LoginRequired,
OAuthExpired,
TeslaFleetError,
)

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import (
OAuth2Session,
async_get_config_entry_implementation,
)
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.device_registry import DeviceInfo

from .const import DOMAIN, MODELS
from .coordinator import (
TeslaFleetEnergySiteInfoCoordinator,
TeslaFleetEnergySiteLiveCoordinator,
TeslaFleetVehicleDataCoordinator,
)
from .models import TeslaFleetData, TeslaFleetEnergyData, TeslaFleetVehicleData

PLATFORMS: Final = [Platform.SENSOR]

type TeslaFleetConfigEntry = ConfigEntry[TeslaFleetData]

CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)


async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) -> bool:
"""Set up TeslaFleet config."""

access_token = entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN]
session = async_get_clientsession(hass)

token = jwt.decode(access_token, options={"verify_signature": False})
scopes = token["scp"]
region = token["ou_code"].lower()

implementation = await async_get_config_entry_implementation(hass, entry)
oauth_session = OAuth2Session(hass, entry, implementation)
refresh_lock = asyncio.Lock()

async def _refresh_token() -> str:
async with refresh_lock:
await oauth_session.async_ensure_token_valid()
token: str = oauth_session.token[CONF_ACCESS_TOKEN]
return token

# Create API connection
tesla = TeslaFleetApi(
session=session,
access_token=access_token,
region=region,
charging_scope=False,
partner_scope=False,
user_scope=False,
energy_scope=Scope.ENERGY_DEVICE_DATA in scopes,
vehicle_scope=Scope.VEHICLE_DEVICE_DATA in scopes,
refresh_hook=_refresh_token,
)
try:
products = (await tesla.products())["response"]
except (InvalidToken, OAuthExpired, LoginRequired) as e:
raise ConfigEntryAuthFailed from e
except TeslaFleetError as e:
raise ConfigEntryNotReady from e

device_registry = dr.async_get(hass)

# Create array of classes
vehicles: list[TeslaFleetVehicleData] = []
energysites: list[TeslaFleetEnergyData] = []
for product in products:
if "vin" in product and tesla.vehicle:
# Remove the protobuff 'cached_data' that we do not use to save memory
product.pop("cached_data", None)
vin = product["vin"]
api = VehicleSpecific(tesla.vehicle, vin)
coordinator = TeslaFleetVehicleDataCoordinator(hass, api, product)

await coordinator.async_config_entry_first_refresh()

device = DeviceInfo(
identifiers={(DOMAIN, vin)},
manufacturer="Tesla",
name=product["display_name"],
model=MODELS.get(vin[3]),
serial_number=vin,
)

vehicles.append(
TeslaFleetVehicleData(
api=api,
coordinator=coordinator,
vin=vin,
device=device,
)
)
elif "energy_site_id" in product and tesla.energy:
site_id = product["energy_site_id"]
api = EnergySpecific(tesla.energy, site_id)

live_coordinator = TeslaFleetEnergySiteLiveCoordinator(hass, api)
info_coordinator = TeslaFleetEnergySiteInfoCoordinator(hass, api, product)

await live_coordinator.async_config_entry_first_refresh()
await info_coordinator.async_config_entry_first_refresh()

# Create energy site model
model = None
models = set()
for gateway in info_coordinator.data.get("components_gateways", []):
if gateway.get("part_name"):
models.add(gateway["part_name"])
for battery in info_coordinator.data.get("components_batteries", []):
if battery.get("part_name"):
models.add(battery["part_name"])
if models:
model = ", ".join(sorted(models))

device = DeviceInfo(
identifiers={(DOMAIN, str(site_id))},
manufacturer="Tesla",
name=product.get("site_name", "Energy Site"),
model=model,
serial_number=str(site_id),
)

# Create the energy site device regardless of it having entities
# This is so users with a Wall Connector but without a Powerwall can still make service calls
device_registry.async_get_or_create(
config_entry_id=entry.entry_id, **device
)

energysites.append(
TeslaFleetEnergyData(
api=api,
live_coordinator=live_coordinator,
info_coordinator=info_coordinator,
id=site_id,
device=device,
)
)

# Setup Platforms
entry.runtime_data = TeslaFleetData(vehicles, energysites, scopes)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

return True


async def async_unload_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) -> bool:
"""Unload TeslaFleet Config."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
71 changes: 71 additions & 0 deletions homeassistant/components/tesla_fleet/application_credentials.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""Application Credentials platform the Tesla Fleet integration."""

import base64
import hashlib
import secrets
from typing import Any

from homeassistant.components.application_credentials import ClientCredential
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_entry_oauth2_flow

from .const import DOMAIN, SCOPES

CLIENT_ID = "71b813eb-4a2e-483a-b831-4dec5cb9bf0d"
AUTHORIZE_URL = "https://auth.tesla.com/oauth2/v3/authorize"
TOKEN_URL = "https://auth.tesla.com/oauth2/v3/token"


async def async_get_auth_implementation(
hass: HomeAssistant, auth_domain: str, credential: ClientCredential
) -> config_entry_oauth2_flow.AbstractOAuth2Implementation:
"""Return auth implementation."""
return TeslaOAuth2Implementation(
hass,
DOMAIN,
)


class TeslaOAuth2Implementation(config_entry_oauth2_flow.LocalOAuth2Implementation):
"""Tesla Fleet API Open Source Oauth2 implementation."""

_name = "Tesla Fleet API"

def __init__(self, hass: HomeAssistant, domain: str) -> None:
"""Initialize local auth implementation."""
self.hass = hass
self._domain = domain

# Setup PKCE
self.code_verifier = secrets.token_urlsafe(32)
hashed_verifier = hashlib.sha256(self.code_verifier.encode()).digest()
self.code_challenge = (
base64.urlsafe_b64encode(hashed_verifier).decode().replace("=", "")
)
super().__init__(
hass,
domain,
CLIENT_ID,
"", # Implementation has no client secret
AUTHORIZE_URL,
TOKEN_URL,
)

@property
def extra_authorize_data(self) -> dict[str, Any]:
"""Extra data that needs to be appended to the authorize url."""
return {
"scope": " ".join(SCOPES),
"code_challenge": self.code_challenge, # PKCE
}

async def async_resolve_external_data(self, external_data: Any) -> dict:
"""Resolve the authorization code to tokens."""
return await self._token_request(
{
"grant_type": "authorization_code",
"code": external_data["code"],
"redirect_uri": external_data["state"]["redirect_uri"],
"code_verifier": self.code_verifier, # PKCE
}
)
75 changes: 75 additions & 0 deletions homeassistant/components/tesla_fleet/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"""Config Flow for Tesla Fleet integration."""

from __future__ import annotations

from collections.abc import Mapping
import logging
from typing import Any

import jwt

from homeassistant.config_entries import ConfigEntry, ConfigFlowResult
from homeassistant.helpers import config_entry_oauth2_flow

from .const import DOMAIN, LOGGER


class OAuth2FlowHandler(
config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN
):
"""Config flow to handle Tesla Fleet API OAuth2 authentication."""

DOMAIN = DOMAIN
reauth_entry: ConfigEntry | None = None

@property
def logger(self) -> logging.Logger:
"""Return logger."""
return LOGGER

async def async_oauth_create_entry(
self,
data: dict[str, Any],
) -> ConfigFlowResult:
"""Handle the initial step."""

token = jwt.decode(
data["token"]["access_token"], options={"verify_signature": False}
)
uid = token["sub"]

if not self.reauth_entry:
await self.async_set_unique_id(uid)
self._abort_if_unique_id_configured()

return self.async_create_entry(title=uid, data=data)

if self.reauth_entry.unique_id == uid:
self.hass.config_entries.async_update_entry(
self.reauth_entry,
data=data,
)
await self.hass.config_entries.async_reload(self.reauth_entry.entry_id)
return self.async_abort(reason="reauth_successful")

return self.async_abort(
reason="reauth_account_mismatch",
description_placeholders={"title": self.reauth_entry.title},
)

async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Perform reauth upon an API authentication error."""
self.reauth_entry = self.hass.config_entries.async_get_entry(
self.context["entry_id"]
)
return await self.async_step_reauth_confirm()

async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm reauth dialog."""
if user_input is None:
return self.async_show_form(step_id="reauth_confirm")
return await self.async_step_user()
39 changes: 39 additions & 0 deletions homeassistant/components/tesla_fleet/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""Constants used by Tesla Fleet integration."""

from __future__ import annotations

from enum import StrEnum
import logging

from tesla_fleet_api.const import Scope

DOMAIN = "tesla_fleet"

CONF_REFRESH_TOKEN = "refresh_token"

LOGGER = logging.getLogger(__package__)

SCOPES = [
Scope.OPENID,
Scope.OFFLINE_ACCESS,
Scope.VEHICLE_DEVICE_DATA,
Scope.VEHICLE_CMDS,
Scope.VEHICLE_CHARGING_CMDS,
Scope.ENERGY_DEVICE_DATA,
Scope.ENERGY_CMDS,
]

MODELS = {
"S": "Model S",
"3": "Model 3",
"X": "Model X",
"Y": "Model Y",
}


class TeslaFleetState(StrEnum):
"""Teslemetry Vehicle States."""

ONLINE = "online"
ASLEEP = "asleep"
OFFLINE = "offline"
Loading

0 comments on commit a2c2488

Please sign in to comment.