"""Tesla Fleet API for Python.""" from collections.abc import Awaitable, Callable from json import dumps from typing import TYPE_CHECKING, Any, ClassVar, cast import aiohttp from tesla_fleet_api import __version__ as VERSION from tesla_fleet_api.const import LOGGER, SERVERS, TOKEN_URLS, Method, Region, Scope from tesla_fleet_api.exceptions import ( InvalidRegion, LibraryError, MissingToken, ResponseError, TeslaFleetError, raise_for_status, ) from tesla_fleet_api.tesla.tesla import Tesla if TYPE_CHECKING: from tesla_fleet_api.tesla.charging import Charging from tesla_fleet_api.tesla.energysite import EnergySites from tesla_fleet_api.tesla.partner import Partner from tesla_fleet_api.tesla.user import User from tesla_fleet_api.tesla.vehicle.vehicles import Vehicles def _normalize_query_value(value: Any) -> Any: """Serialize query values into forms accepted by aiohttp/yarl.""" if isinstance(value, bool): return str(value).lower() return value def _log_request_result(command: str, transport: str, data: Any) -> None: """Log the outcome of a completed request; a logging convenience that must never raise, since the request it describes has already succeeded.""" if not isinstance(data, dict): LOGGER.debug("command=%s transport=%s result=success", command, transport) return data = cast("dict[str, Any]", data) response = data.get("response") if isinstance(response, dict) and "result" in response: result_data = cast("dict[str, Any]", response) elif "result" in data: result_data = data else: LOGGER.debug("command=%s transport=%s result=success", command, transport) return LOGGER.debug( "command=%s transport=%s result=%s reason=%s", command, transport, result_data.get("result"), result_data.get("reason"), ) # Based on https://developer.tesla.com/docs/fleet-api class TeslaFleetApi(Tesla): """Class describing the Tesla Fleet API.""" _access_token: str | Callable[[], Awaitable[str | None]] | None region: Region | None = None server: str | None = None session: aiohttp.ClientSession charging: "Charging" energySites: "EnergySites" user: "User" partner: "Partner" vehicles: "Vehicles[TeslaFleetApi]" # Transport identity for debug logging; Teslemetry/Tessie override this. _transport_name: ClassVar[str] = "fleet" def __init__( self, session: aiohttp.ClientSession | None, access_token: str | Callable[[], Awaitable[str | None]] | None = None, region: Region | None = None, server: str | None = None, charging_scope: bool = True, energy_scope: bool = True, partner_scope: bool = True, user_scope: bool = True, vehicle_scope: bool = True, ) -> None: """Initialize the Tesla Fleet API.""" self.session = session or aiohttp.ClientSession() self._access_token = access_token if server is not None: self.server = server elif region in SERVERS: self.region = region self.server = SERVERS.get(region) else: raise ValueError(f"Region must be one of {', '.join(SERVERS.keys())}") LOGGER.debug("Using server %s", self.server) if charging_scope: self.charging = self.Charging(self) if energy_scope: self.energySites = self.EnergySites(self) if user_scope: self.user = self.User(self) if partner_scope: self.partner = self.Partner(self) if vehicle_scope: self.vehicles = self.Vehicles(self) async def find_server(self) -> str: """Find the server URL for the Tesla Fleet API.""" for server in SERVERS.values(): self.server = server try: region_response = await self.user.region() response = region_response.get("response") if response: self.server = response["fleet_api_base_url"] LOGGER.debug("Using server %s", self.server) return response["region"] except InvalidRegion: continue raise LibraryError("Could not find a valid Tesla API server.") async def access_token(self) -> str: """Get the access token for the Tesla Fleet API.""" if callable(self._access_token): token = await self._access_token() else: token = self._access_token if token is None: raise MissingToken return token async def _request( self, method: Method, path: str, params: dict[str, Any] | None = None, json: dict[str, Any] | None = {}, ) -> dict[str, Any]: """Send a request to the Tesla Fleet API. Returns the decoded JSON body as provided by the service, including JSON-legal non-object bodies such as ``null``, lists, or scalars. """ # Trailing path segment (e.g. "door_lock", "vehicle_data") as the # debug-log command name; the full path (with VIN) is already logged # below via resp.url, so this adds no new exposure. command = path.rsplit("/", 1)[-1] try: if not self.server: raise ValueError( "Server was not set at init. Call find_server() first." ) if method == Method.GET: json = None access_token = await self.access_token() headers = { "Authorization": f"Bearer {access_token}", "X-Library": f"python tesla_fleet_api {VERSION}", } # Remove None values from params and json if params: params = { k: _normalize_query_value(v) for k, v in params.items() if v is not None } LOGGER.debug("Parameters: %s", params) if json: json = {k: v for k, v in json.items() if v is not None} LOGGER.debug("Body: %s", dumps(json)) headers["Content-Type"] = "application/json" async with self.session.request( method, f"{self.server}/{path}", headers=headers, json=json, params=params, ) as resp: LOGGER.debug("Status %s from %s", resp.status, resp.url) if "x-txid" in resp.headers: LOGGER.debug("Response TXID: %s", resp.headers["x-txid"]) if "RateLimit-Reset" in resp.headers: LOGGER.debug( "Rate limit reset: %s", resp.headers.get("RateLimit-Reset") ) if "Retry-After" in resp.headers: LOGGER.debug("Retry after: %s", resp.headers.get("Retry-After")) if not resp.ok: await raise_for_status(resp) if not resp.content_type.lower().startswith("application/json"): LOGGER.debug("Response type is: %s", resp.content_type) raise ResponseError(status=resp.status, data=await resp.text()) data = await resp.json() LOGGER.debug("Response JSON: %s", data) except (Exception, TeslaFleetError) as e: LOGGER.debug( "command=%s transport=%s result=error error=%s: %s", command, self._transport_name, type(e).__name__, e, ) raise _log_request_result(command, self._transport_name, data) return data async def status(self) -> str: """This endpoint returns the string "ok" if the API is operating normally. No HTTP headers are required.""" if not self.server: raise ValueError("Server was not set at init. Call find_server() first.") async with self.session.get(f"{self.server}/status") as resp: return await resp.text() async def products(self) -> dict[str, Any]: """Returns products mapped to user.""" return await self._request( Method.GET, "api/1/products", ) async def partner_login( self, client_id: str, client_secret: str, scopes: list[Scope] = [Scope.OPENID], ) -> dict[str, Any]: """Generate a partner token using client credentials authentication. Args: client_id: Partner application client ID client_secret: Partner application client secret scopes: Optional list of scopes to request Returns: Dictionary containing access token and token metadata """ # Throw if there is no server set if not self.server: raise ValueError("Server was not set at init. Call find_server() first.") data = { "grant_type": "client_credentials", "client_id": client_id, "client_secret": client_secret, "audience": self.server, "scope": " ".join(scopes), } async with self.session.post( TOKEN_URLS.get(self.region or "na", TOKEN_URLS["na"]), headers={"Content-Type": "application/x-www-form-urlencoded"}, data=data, ) as resp: if not resp.ok: await raise_for_status(resp) token_data = await resp.json() # Set the access token for subsequent API calls self._access_token = token_data["access_token"] return token_data