From 05740beb91df76112ca1980a1ffef6793b04a0bc Mon Sep 17 00:00:00 2001 From: Elliott Balsley <3991046+llamafilm@users.noreply.github.com> Date: Sun, 11 Feb 2024 04:43:36 -0800 Subject: [PATCH 1/2] test support for HTTP Proxy --- DEVELOPERS.md | 10 +++++++++ teslajsonpy/car.py | 4 ++-- teslajsonpy/connection.py | 29 +++++++++++++++++--------- teslajsonpy/const.py | 1 + teslajsonpy/controller.py | 43 ++++++++++++++++++++++++--------------- 5 files changed, 59 insertions(+), 28 deletions(-) diff --git a/DEVELOPERS.md b/DEVELOPERS.md index 28b54e70..b7ecbc1d 100644 --- a/DEVELOPERS.md +++ b/DEVELOPERS.md @@ -25,3 +25,13 @@ If you're looking to add functionality to Home Assistant you will need to do the 2. Build a proper abstraction inheriting from the [vehicle.py](teslajsonpy/vehicle.py). Check out [lock.py](teslajsonpy/lock.py). 3. Add abstraction to the controller [_add_components](https://github.com/zabuldon/teslajsonpy/blob/dev/teslajsonpy/controller.py#L530) so it will be discoverable. 3. Add changes to Home Assistant to access your abstraction and submit a PR per HA guidelines. + +## Experimental support for Tesla HTTP Proxy +Tesla has deprecated the Owner API for modern vehicles. +https://developer.tesla.com/docs/fleet-api#2023-10-09-rest-api-vehicle-commands-endpoint-deprecation-warning +To use the HTTP Proxy, you must provide your Client ID and Proxy URL (e.g. https://tesla.example.com). If your proxy uses a self-signed certificate, you may provide the path to that certificate as a config parameter so it will be trusted. +The proxy server requires the command URL to contain the VIN, not the ID. [Reference](https://github.com/teslamotors/vehicle-command). Otherwise you get an error like this: +``` +[teslajsonpy.connection] post: https://local-tesla-http-proxy:4430/api/1/vehicles/xxxxxxxxxxxxxxxx/command/auto_conditioning_start {} +[teslajsonpy.connection] 404: {"response":null,"error":"expected 17-character VIN in path (do not user Fleet API ID)","error_description":""} +``` diff --git a/teslajsonpy/car.py b/teslajsonpy/car.py index 3e385c2d..19954930 100644 --- a/teslajsonpy/car.py +++ b/teslajsonpy/car.py @@ -751,7 +751,7 @@ async def _send_command( **kwargs: Any additional parameters for the api call """ - path_vars = {"vehicle_id": self.id} + path_vars = {"vehicle_id": self.vin} if additional_path_vars: path_vars.update(additional_path_vars) @@ -1127,7 +1127,7 @@ async def stop_charge(self) -> None: async def wake_up(self) -> None: """Send command to wake up.""" - await self._controller.wake_up(car_id=self.id) + await self._controller.wake_up(car_vin=self.vin) async def toggle_trunk(self) -> None: """Actuate rear trunk.""" diff --git a/teslajsonpy/connection.py b/teslajsonpy/connection.py index ba1ccc2c..097750b3 100644 --- a/teslajsonpy/connection.py +++ b/teslajsonpy/connection.py @@ -25,6 +25,7 @@ from teslajsonpy.const import ( API_URL, + CLIENT_ID, AUTH_DOMAIN, DRIVING_INTERVAL, DOMAIN_KEY, @@ -49,18 +50,26 @@ def __init__( authorization_token: Text = None, expiration: int = 0, auth_domain: str = AUTH_DOMAIN, + client_id: str = CLIENT_ID, + api_proxy_url: str = None, ) -> None: """Initialize connection object.""" self.user_agent: Text = "TeslaApp/4.10.0" - self.client_id: Text = ( - "81527cff06843c8634fdc09e8ac0abef" "b46ac849f38fe1e431c2ef2106796384" - ) - self.client_secret: Text = ( - "c7257eb71a564034f9419ee651c7d0e5f7" "aa6bfbd18bafb5c5c033b093bb2fa3" - ) - self.baseurl: Text = DOMAIN_KEY.get( - auth_domain[auth_domain.rfind(".") :], API_URL - ) + if client_id == "ownerapi": + self.client_id: Text = ( + "81527cff06843c8634fdc09e8ac0abef" "b46ac849f38fe1e431c2ef2106796384" + ) + self.client_secret: Text = ( + "c7257eb71a564034f9419ee651c7d0e5f7" "aa6bfbd18bafb5c5c033b093bb2fa3" + ) + else: + self.client_id = client_id + if api_proxy_url is None: + self.baseurl: Text = DOMAIN_KEY.get( + auth_domain[auth_domain.rfind(".") :], API_URL + ) + else: + self.baseurl: Text = api_proxy_url self.websocket_url: Text = WS_URL self.api: Text = "/api/1/" self.expiration: int = expiration @@ -563,7 +572,7 @@ async def refresh_access_token(self, refresh_token): return _LOGGER.debug("Refreshing access token with refresh_token") oauth = { - "client_id": "ownerapi", + "client_id": self.client_id, "grant_type": "refresh_token", "refresh_token": refresh_token, "scope": "openid email offline_access", diff --git a/teslajsonpy/const.py b/teslajsonpy/const.py index b1a1e5b1..b5bd960e 100644 --- a/teslajsonpy/const.py +++ b/teslajsonpy/const.py @@ -17,6 +17,7 @@ RELEASE_NOTES_URL = "https://teslascope.com/teslapedia/software/" AUTH_DOMAIN = "https://auth.tesla.com" API_URL = "https://owner-api.teslamotors.com" +CLIENT_ID = "ownerapi" API_URL_CN = "https://owner-api.vn.cloud.tesla.cn" DOMAIN_KEY = {".com": API_URL, ".cn": API_URL_CN} WS_URL = "wss://streaming.vn.teslamotors.com/streaming" diff --git a/teslajsonpy/controller.py b/teslajsonpy/controller.py index aec9308b..a282af54 100644 --- a/teslajsonpy/controller.py +++ b/teslajsonpy/controller.py @@ -9,6 +9,7 @@ import logging import pkgutil import time +import ssl from json import JSONDecodeError from typing import Dict, List, Optional, Set, Text @@ -32,6 +33,7 @@ UPDATE_INTERVAL, WAKE_CHECK_INTERVAL, WAKE_TIMEOUT, + CLIENT_ID, ) from teslajsonpy.energy import EnergySite, PowerwallSite, SolarPowerwallSite, SolarSite from teslajsonpy.exceptions import ( @@ -104,6 +106,9 @@ def __init__( enable_websocket: bool = False, polling_policy: Text = None, auth_domain: str = AUTH_DOMAIN, + api_proxy_url: str = None, + api_proxy_cert: str = None, + client_id: str = CLIENT_ID ) -> None: """Initialize controller. @@ -124,18 +129,30 @@ def __init__( session is complete. 'always' - Keep polling the car at all times. Will possibly never allow the car to sleep. auth_domain (str, optional): The authentication domain. Defaults to const.AUTH_DOMAIN + api_proxy_url (str, optional): HTTPS Proxy for Fleet API commands + api_proxy_cert (str, optional): Custom SSL certificate to use with proxy + client_id (str, optional): Required for modern vehicles using Fleet API """ + ssl_context = ssl.create_default_context() + if api_proxy_cert: + try: + ssl_context.load_verify_locations(api_proxy_cert) + except (FileNotFoundError, ssl.SSLError): + _LOGGER.warning("Unable to load custom SSL certificate from %s", api_proxy_cert) + self.__connection = Connection( websession=websession if websession and isinstance(websession, httpx.AsyncClient) - else httpx.AsyncClient(timeout=60), + else httpx.AsyncClient(timeout=60, verify=ssl_context), email=email, password=password, access_token=access_token, refresh_token=refresh_token, expiration=expiration, auth_domain=auth_domain, + client_id=client_id, + api_proxy_url=api_proxy_url, ) self._update_interval: int = update_interval self._update_interval_vin = {} @@ -307,7 +324,7 @@ async def get_vehicle_data(self, vin: str, wake_if_asleep: bool = False) -> dict response = ( await self.api( "VEHICLE_DATA", - path_vars={"vehicle_id": self._vin_to_id(vin)}, + path_vars={"vehicle_id": vin}, wake_if_asleep=wake_if_asleep, ) )["response"] @@ -325,7 +342,7 @@ async def get_vehicle_summary(self, vin: str) -> dict: return ( await self.api( "VEHICLE_SUMMARY", - path_vars={"vehicle_id": self._vin_to_id(vin)}, + path_vars={"vehicle_id": vin}, wake_if_asleep=False, ) )["response"] @@ -443,10 +460,8 @@ async def generate_energysite_objects(self) -> Dict[int, EnergySite]: return self.energysites - async def wake_up(self, car_id) -> bool: + async def wake_up(self, car_vin) -> bool: """Attempt to wake the car, returns True if successfully awakened.""" - car_vin = self._id_to_vin(car_id) - car_id = self._update_id(car_id) async with self.__wakeup_lock[car_vin]: wake_start_time = time.time() wake_deadline = wake_start_time + WAKE_TIMEOUT @@ -456,11 +471,11 @@ async def wake_up(self, car_id) -> bool: wake_deadline, ) result = await self.api( - "WAKE_UP", path_vars={"vehicle_id": car_id}, wake_if_asleep=False + "WAKE_UP", path_vars={"vehicle_id": car_vin}, wake_if_asleep=False ) state = result.get("response", {}).get("state") self.set_car_online( - car_id=car_id, + vin=car_vin, online_status=state == "online", ) while not self.is_car_online(vin=car_vin) and time.time() < wake_deadline: @@ -468,7 +483,7 @@ async def wake_up(self, car_id) -> bool: response = await self.get_vehicle_summary(vin=car_vin) state = response.get("state") self.set_car_online( - car_id=car_id, + vin=car_vin, online_status=state == "online", ) @@ -1120,17 +1135,13 @@ def _id_to_vin(self, car_id: Text) -> Optional[Text]: """Return vin for a car_id.""" return self.__id_vin_map.get(str(car_id)) - def _vin_to_id(self, vin: Text) -> Optional[Text]: - """Return car_id for a vin.""" - return self.__vin_id_map.get(vin) - def _vehicle_id_to_vin(self, vehicle_id: Text) -> Optional[Text]: """Return vin for a vehicle_id.""" return self.__vehicle_id_vin_map.get(vehicle_id) def _vehicle_id_to_id(self, vehicle_id: Text) -> Optional[Text]: """Return car_id for a vehicle_id.""" - return self._vin_to_id(self._vehicle_id_to_vin(vehicle_id)) + return self._vehicle_id_to_vin(vehicle_id) def vin_to_vehicle_id(self, vin: Text) -> Optional[Text]: """Return vehicle_id for a vin.""" @@ -1288,7 +1299,7 @@ async def api( ) # If we already know the car is asleep, go ahead and wake it if not self.is_car_online(car_id=car_id): - await self.wake_up(car_id=car_id) + await self.wake_up(car_vin=car_id) return await self.__post_with_retries( "", method=method, data=kwargs, url=uri ) @@ -1306,7 +1317,7 @@ async def api( # It may fail if the car slept since the last api update if not valid_result(response): # Assumed it failed because it was asleep and we didn't know it - await self.wake_up(car_id=car_id) + await self.wake_up(car_vin=car_id) response = await self.__post_with_retries( "", method=method, data=kwargs, url=uri ) From 659aca0737d643534d78768ff153408139f2700e Mon Sep 17 00:00:00 2001 From: Thierry Van Tillo Date: Sat, 24 Feb 2024 23:32:36 +0100 Subject: [PATCH 2/2] - Added support for new, official, Tesla API - Added optional parameters to send commands via tesla-http-proxy (required for new API) - All communication with Tesla's API now attempts to use the VIN instead of the car_id - removed warning that Tesla has no official API support. --- AUTHORS.md | 1 + README.md | 3 -- teslajsonpy/car.py | 4 +-- teslajsonpy/controller.py | 61 ++++++++++++++++++++++++++++++++------- 4 files changed, 53 insertions(+), 16 deletions(-) diff --git a/AUTHORS.md b/AUTHORS.md index c1a18f30..1cd920ff 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -21,3 +21,4 @@ - InTheDaylight14 [Github](https://github.com/InTheDaylight14) - Bre77 [Github](https://github.com/Bre77) - craigrouse [Github](https://github.com/craigrouse) +- thierryVT [Github](https://github.com/thierryvt) diff --git a/README.md b/README.md index 6cd6f8fa..309a5c37 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,6 @@ Async python module for Tesla API primarily for enabling Home-Assistant. -**NOTE:** Tesla has no official API; therefore, this library may stop -working at any time without warning. - # Credits Originally inspired by [this code.](https://github.com/gglockner/teslajson) diff --git a/teslajsonpy/car.py b/teslajsonpy/car.py index 19954930..3e385c2d 100644 --- a/teslajsonpy/car.py +++ b/teslajsonpy/car.py @@ -751,7 +751,7 @@ async def _send_command( **kwargs: Any additional parameters for the api call """ - path_vars = {"vehicle_id": self.vin} + path_vars = {"vehicle_id": self.id} if additional_path_vars: path_vars.update(additional_path_vars) @@ -1127,7 +1127,7 @@ async def stop_charge(self) -> None: async def wake_up(self) -> None: """Send command to wake up.""" - await self._controller.wake_up(car_vin=self.vin) + await self._controller.wake_up(car_id=self.id) async def toggle_trunk(self) -> None: """Actuate rear trunk.""" diff --git a/teslajsonpy/controller.py b/teslajsonpy/controller.py index a282af54..61c0ea24 100644 --- a/teslajsonpy/controller.py +++ b/teslajsonpy/controller.py @@ -324,7 +324,7 @@ async def get_vehicle_data(self, vin: str, wake_if_asleep: bool = False) -> dict response = ( await self.api( "VEHICLE_DATA", - path_vars={"vehicle_id": vin}, + path_vars={"vehicle_id": self._vin_to_id(vin)}, wake_if_asleep=wake_if_asleep, ) )["response"] @@ -342,7 +342,7 @@ async def get_vehicle_summary(self, vin: str) -> dict: return ( await self.api( "VEHICLE_SUMMARY", - path_vars={"vehicle_id": vin}, + path_vars={"vehicle_id": self._vin_to_id(vin)}, wake_if_asleep=False, ) )["response"] @@ -460,8 +460,10 @@ async def generate_energysite_objects(self) -> Dict[int, EnergySite]: return self.energysites - async def wake_up(self, car_vin) -> bool: + async def wake_up(self, car_id) -> bool: """Attempt to wake the car, returns True if successfully awakened.""" + car_vin = self._id_to_vin(car_id) + car_id = self._update_id(car_id) async with self.__wakeup_lock[car_vin]: wake_start_time = time.time() wake_deadline = wake_start_time + WAKE_TIMEOUT @@ -471,11 +473,12 @@ async def wake_up(self, car_vin) -> bool: wake_deadline, ) result = await self.api( - "WAKE_UP", path_vars={"vehicle_id": car_vin}, wake_if_asleep=False + "WAKE_UP", path_vars={"vehicle_id": car_id}, wake_if_asleep=False ) state = result.get("response", {}).get("state") self.set_car_online( vin=car_vin, + car_id=car_id, online_status=state == "online", ) while not self.is_car_online(vin=car_vin) and time.time() < wake_deadline: @@ -484,6 +487,7 @@ async def wake_up(self, car_vin) -> bool: state = response.get("state") self.set_car_online( vin=car_vin, + car_id=car_id, online_status=state == "online", ) @@ -493,7 +497,10 @@ async def wake_up(self, car_vin) -> bool: time.time() - wake_start_time, state, ) - return self.is_car_online(vin=car_vin) + return self.is_car_online( + vin=car_vin, + car_id=car_id, + ) def _calculate_next_interval(self, vin: Text) -> int: cur_time = round(time.time()) @@ -1135,13 +1142,17 @@ def _id_to_vin(self, car_id: Text) -> Optional[Text]: """Return vin for a car_id.""" return self.__id_vin_map.get(str(car_id)) + def _vin_to_id(self, vin: Text) -> Optional[Text]: + """Return car_id for a vin.""" + return self.__vin_id_map.get(vin) + def _vehicle_id_to_vin(self, vehicle_id: Text) -> Optional[Text]: """Return vin for a vehicle_id.""" return self.__vehicle_id_vin_map.get(vehicle_id) def _vehicle_id_to_id(self, vehicle_id: Text) -> Optional[Text]: """Return car_id for a vehicle_id.""" - return self._vehicle_id_to_vin(vehicle_id) + return self._vin_to_id(self._vehicle_id_to_vin(vehicle_id)) def vin_to_vehicle_id(self, vin: Text) -> Optional[Text]: """Return vehicle_id for a vin.""" @@ -1227,6 +1238,26 @@ def _process_websocket_disconnect(self, data): vin = self.__vehicle_id_vin_map[vehicle_id] _LOGGER.debug("Disconnected %s from websocket", vin[-5:]) + def _get_vehicle_ids_for_api(self, path_vars): + vehicle_id = path_vars.get("vehicle_id") + if not vehicle_id: + return None, None + + vehicle_id = str(vehicle_id) + if vehicle_id in self.__id_vin_map: + car_id = vehicle_id + car_vin = self.__id_vin_map.get(vehicle_id) + return car_id, car_vin + + if vehicle_id in self.__vin_id_map: + car_id = self.__vin_id_map.get(vehicle_id) + car_vin = vehicle_id + return car_id, car_vin + + _LOGGER.error("Could not determine correct vehicle ID for API communication: '%s'", vehicle_id) + return None, None + + async def api( self, name: str, @@ -1265,6 +1296,14 @@ async def api( """ path_vars = path_vars or {} + # use of car_id was deprecated on the new API but VIN can be used on both new and old so always use vin + car_id, car_vin = self._get_vehicle_ids_for_api(path_vars) + if path_vars.get("vehicle_id"): + if car_vin: + path_vars["vehicle_id"] = car_vin + else: + _LOGGER.warning("WARNING: could not set vehicle_id to car_vin, will attempt to send without overriding but this might cause issues!") + # Load API endpoints once if not self.endpoints: try: @@ -1292,14 +1331,14 @@ async def api( # Old @wake_up decorator condensed here if wake_if_asleep: - car_id = path_vars.get("vehicle_id") - if not car_id: + if not path_vars.get("vehicle_id"): raise ValueError( "wake_if_asleep only supported on endpoints with 'vehicle_id' path variable" ) + # If we already know the car is asleep, go ahead and wake it - if not self.is_car_online(car_id=car_id): - await self.wake_up(car_vin=car_id) + if not self.is_car_online(car_id=car_id, vin=car_vin): + await self.wake_up(car_id=car_id) return await self.__post_with_retries( "", method=method, data=kwargs, url=uri ) @@ -1317,7 +1356,7 @@ async def api( # It may fail if the car slept since the last api update if not valid_result(response): # Assumed it failed because it was asleep and we didn't know it - await self.wake_up(car_vin=car_id) + await self.wake_up(car_id=car_id) response = await self.__post_with_retries( "", method=method, data=kwargs, url=uri )