-
-
Notifications
You must be signed in to change notification settings - Fork 32.5k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add Tesla Fleet integration (#122019)
* 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
1 parent
474e8b7
commit a2c2488
Showing
31 changed files
with
6,887 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"] | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
71
homeassistant/components/tesla_fleet/application_credentials.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" |
Oops, something went wrong.