├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .gitignore ├── LICENSE ├── README.md ├── examples ├── charge_offpeak.py ├── max_min_soc.py └── sample.jlrpy.ini ├── jlrpy.py ├── requirements.txt └── setup.py /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | # See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.231.6/containers/python-3/.devcontainer/base.Dockerfile 2 | 3 | # [Choice] Python version (use -bullseye variants on local arm64/Apple Silicon): 3, 3.10, 3.9, 3.8, 3.7, 3.6, 3-bullseye, 3.10-bullseye, 3.9-bullseye, 3.8-bullseye, 3.7-bullseye, 3.6-bullseye, 3-buster, 3.10-buster, 3.9-buster, 3.8-buster, 3.7-buster, 3.6-buster 4 | ARG VARIANT="3.10-bullseye" 5 | FROM mcr.microsoft.com/vscode/devcontainers/python:0-${VARIANT} 6 | 7 | # [Choice] Node.js version: none, lts/*, 16, 14, 12, 10 8 | ARG NODE_VERSION="none" 9 | RUN if [ "${NODE_VERSION}" != "none" ]; then su vscode -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi 10 | 11 | RUN apt update && apt install -y npm 12 | # [Optional] If your pip requirements rarely change, uncomment this section to add them to the image. 13 | # COPY requirements.txt /tmp/pip-tmp/ 14 | # RUN pip3 --disable-pip-version-check --no-cache-dir install -r /tmp/pip-tmp/requirements.txt \ 15 | # && rm -rf /tmp/pip-tmp 16 | 17 | # [Optional] Uncomment this section to install additional OS packages. 18 | # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 19 | # && apt-get -y install --no-install-recommends 20 | 21 | # [Optional] Uncomment this line to install global node packages. 22 | # RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g " 2>&1 23 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Python 3", 3 | "build": { 4 | "dockerfile": "Dockerfile", 5 | "context": "..", 6 | "args": { 7 | // Update 'VARIANT' to pick a Python version: 3, 3.10, 3.9, 3.8, 3.7, 3.6 8 | // Append -bullseye or -buster to pin to an OS version. 9 | // Use -bullseye variants on local on arm64/Apple Silicon. 10 | "VARIANT": "3.10-bullseye", 11 | // Options 12 | "NODE_VERSION": "lts/*" 13 | } 14 | }, 15 | 16 | // Configure tool-specific properties. 17 | "customizations": { 18 | // Configure properties specific to VS Code. 19 | "vscode": { 20 | // Set *default* container specific settings.json values on container create. 21 | "settings": { 22 | "python.defaultInterpreterPath": "/usr/local/bin/python", 23 | "python.linting.enabled": true, 24 | "python.linting.pylintEnabled": true, 25 | "python.formatting.autopep8Path": "/usr/local/py-utils/bin/autopep8", 26 | "python.formatting.blackPath": "/usr/local/py-utils/bin/black", 27 | "python.formatting.yapfPath": "/usr/local/py-utils/bin/yapf", 28 | "python.linting.banditPath": "/usr/local/py-utils/bin/bandit", 29 | "python.linting.flake8Path": "/usr/local/py-utils/bin/flake8", 30 | "python.linting.mypyPath": "/usr/local/py-utils/bin/mypy", 31 | "python.linting.pycodestylePath": "/usr/local/py-utils/bin/pycodestyle", 32 | "python.linting.pydocstylePath": "/usr/local/py-utils/bin/pydocstyle", 33 | "python.linting.pylintPath": "/usr/local/py-utils/bin/pylint" 34 | }, 35 | 36 | // Add the IDs of extensions you want installed when the container is created. 37 | "extensions": [ 38 | "ms-python.python", 39 | "ms-python.vscode-pylance" 40 | ] 41 | } 42 | }, 43 | 44 | // Use 'postCreateCommand' to run commands after the container is created. 45 | "postCreateCommand": "pip3 install --user -r requirements.txt", 46 | 47 | // Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. 48 | "remoteUser": "vscode", 49 | "containerUser": "vscode" 50 | } 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 ardevd 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # jlrpy 2 | 3 | [![Tip me - LNURL](https://img.shields.io/badge/Tip_me-LNURL-dc9e57?logo=lightning&logoColor=dc9e57)](https://tipybit.com/ardevd) 4 | 5 | [![Join the chat at https://gitter.im/jlrpy/community](https://badges.gitter.im/jlrpy/community.svg)](https://gitter.im/jlrpy/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 6 | 7 | Python 3 library for interacting with the JLR Remote car API. 8 | 9 | ## Documentation 10 | The associated API documentation for the JLR InControl API is a good read for anyone wanting to make use of this project. It's currently available [here](https://documenter.getpostman.com/view/6250319/RznBMzqo) 11 | 12 | ## Installation 13 | 14 | Either check out this repository directly or install through pip (for Python3). 15 | 16 | `pip install jlrpy` 17 | 18 | ## Usage 19 | To get started, instantiate a `Connection` object and pass along the email address and password associated with your Jaguar InControl account. 20 | 21 | There are two ways to authenticate to InControl. Using the user name and password or with a valid refresh token. 22 | 23 | The JLR API requires a device ID to be registered (UUID4 formatted). If you do not specify one when instantiating the `Connection` object it will generate a new one for your automatically. 24 | 25 | ```python 26 | import jlrpy 27 | # Authenticate using the username and password 28 | c = jlrpy.Connection('my@email.com', 'password') 29 | v = c.vehicles[0] 30 | 31 | # Authenticate using a refresh token (username must still be specified) 32 | c = jlrpy.Connection(email='my@email.com', refresh_token='124c3f21-42ds-2e4d-86f8-221v32392a1d') 33 | ``` 34 | 35 | `Connection.vehicles` will list all vehicles associated with your account. 36 | 37 | ```python 38 | # Get user information 39 | c.get_user_info() 40 | # Update user information. 41 | p = c.get_user_info() 42 | p['contact']['userPreferences']['unitsOfMeasurement'] = "Km Litre Celsius VolPerDist Wh DistPerkWh" 43 | c.update_user_info(p) 44 | # Refresh access token 45 | c.refresh_tokens() 46 | # Get attributes associated with vehicle 47 | v.get_attributes() 48 | # Get current status of vehicle 49 | v.get_status() 50 | # Get current active services 51 | v.get_services() 52 | # Optionally, you can also specify a status value key 53 | v.get_status("EV_STATE_OF_CHARGE") 54 | # Get subscription packes 55 | v.get_subscription_packages() 56 | # Get trip data (last 1000 trips). 57 | v.get_trips() 58 | # Get data for a single trip (specified with trip id) 59 | v.get_trip(121655021) 60 | # Get vehicle health status 61 | v.get_health_status() 62 | # Get departure timers 63 | v.get_departure_timers() 64 | # Get configured wakeup time 65 | v.get_wakeup_time() 66 | # Honk horn and blink lights 67 | v.honk_blink() 68 | # Get current position of vehicle 69 | v.get_position() 70 | # Start preconditioning at 21.0C 71 | v.preconditioning_start("210") 72 | # Stop preconditioning 73 | v.preconditioning_stop() 74 | # Set vehicle nickname and registration number 75 | v.set_attributes("Name", "reg-number") 76 | # Lock vehicle 77 | v.lock(pin) # pin being the personal master pin 78 | # Unlock vehicle 79 | v.unlock(pin) 80 | # Reset alarm 81 | v.reset_alarm(pin) 82 | # Start charging 83 | v.charging_start() 84 | # Stop charging 85 | v.charging_stop() 86 | # Set max soc at 80% (Requires upcoming OTA update) 87 | v.set_max_soc(80) 88 | # Set max soc for current charging session to 90% (Requires upcoming OTA update) 89 | v.set_one_off_max_soc(90) 90 | # Add single departure timer (index, year, month, day, hour, minute) 91 | v.add_departure_timer(10, 2019, 1, 30, 20, 30) 92 | # Delete a single departure timer index. 93 | v.delete_departure_timer(10) 94 | # Schedule repeated departure timer. 95 | schedule = {"friday":False,"monday":True,"saturday":False,"sunday":False,"thursday":False,"tuesday":True,"wednesday":True} 96 | v.add_repeated_departure_timer(10, 20, 30, schedule) 97 | # Set wakeup timer (epoch millis) 98 | v.set_wakeup_time(1547845200000) 99 | # Cancel wakeup timer 100 | v.delete_wakeup_time() 101 | # Enable service mode (requires personal PIN) 102 | v.enable_service_mode("1234", 1547551847000) 103 | # Enable transport mode (requires personal PIN) 104 | v.enable_transport_mode("1234", 1547551847000) 105 | # Enable privacy mode 106 | v.enable_privacy_mode("1234") 107 | # Disable privacy mode 108 | v.disable_privacy_mode("1234") 109 | # Add charging period with specified index identifier value. 110 | v.add_charging_period(1, schedule, 0, 30, 8, 45) 111 | # Reverse geocode 112 | c.reverse_geocode(59.915475,10.733054) 113 | ``` 114 | 115 | ## Examples 116 | The examples directory contains example scripts that put jlrpy to good use. 117 | 118 | ### max_min_soc.py 119 | The `max_min_soc.py` script allows you to specify a desired maximum and minimum state of charge for the vehicle. Charging will be stopped once the maximum state of charge is reached and it will be started if the minimum state of charge is reached. 120 | 121 | ### charge_offpeak.py 122 | The `charge_offpeak.py` script allows you to specify a desired (off-peak) charging time period and maximum state of charge for the vehicle. Charging will be stopped if the vehicle is charging outside of the specified time period or once the maximum state of charge is reached and it will be started during the specified time period if the state of charge is below the maximum. 123 | -------------------------------------------------------------------------------- /examples/charge_offpeak.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Charge Off-Peak script. 3 | 4 | This script will check the charging level once every minute 5 | if the vehicle is at home (100 meters or less from the location 6 | specified in the config file) and if the current battery level 7 | meets or exceeds the specified maximum value, the charging will 8 | stop if the vehicle is currently charging. 9 | 10 | If the vehicle is charging during off-peak hours, the charging will stop. 11 | 12 | """ 13 | 14 | import jlrpy 15 | import threading 16 | import datetime 17 | import math 18 | from datetime import date 19 | import os 20 | import configparser 21 | 22 | 23 | # login info (email and password) are read from $HOME/.jlrpy.cfg 24 | # which contains a single line with email and password separated by ':' 25 | # email@example.com:PassW0rd 26 | # passwords containing a ':' are not allowed 27 | 28 | logger = jlrpy.logger 29 | 30 | def distance(origin, destination): 31 | """ 32 | From https://stackoverflow.com/questions/19412462 33 | 34 | Calculate the Haversine distance. 35 | 36 | Parameters 37 | ---------- 38 | origin : tuple of float 39 | (lat, long) 40 | destination : tuple of float 41 | (lat, long) 42 | 43 | Returns 44 | ------- 45 | distance_in_km : float 46 | 47 | Examples 48 | -------- 49 | >>> origin = (48.1372, 11.5756) # Munich 50 | >>> destination = (52.5186, 13.4083) # Berlin 51 | >>> round(distance(origin, destination), 1) 52 | 504.2 53 | """ 54 | lat1, lon1 = origin 55 | lat2, lon2 = destination 56 | radius = 6371 # km 57 | 58 | dlat = math.radians(lat2 - lat1) 59 | dlon = math.radians(lon2 - lon1) 60 | a = (math.sin(dlat / 2) * math.sin(dlat / 2) + 61 | math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * 62 | math.sin(dlon / 2) * math.sin(dlon / 2)) 63 | c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) 64 | d = radius * c 65 | return d 66 | 67 | def check_soc(): 68 | """Retrieve vehicle status and stop or start charging if 69 | current charging level matches or exceeds specified max level and 70 | the vehicle is currently charging. 71 | """ 72 | threading.Timer(60.0, check_soc).start() # Called every minute 73 | 74 | p = v.get_position() 75 | position = (p['position']['latitude'], p['position']['longitude']) 76 | d = int(1000*distance(home,position)) 77 | if (d > 100): 78 | logger.info("car is "+str(d)+"m from home") 79 | return 80 | 81 | t = datetime.datetime.now() 82 | # offpeak: M-F (0-4) 0:00- 7:00, 23:00-23:59 83 | # S-S (5-6) 0:00-15:00, 19:00-23:59 84 | today = date.weekday(t) 85 | offpeak = ( (t.hour < peak[today][0] or t.hour >= peak[today][1])) 86 | 87 | # getting health status forces a status update 88 | healthstatus = v.get_health_status() 89 | vehicleStatus = v.get_status()['vehicleStatus'] 90 | status = { d['key'] : d['value'] for d in vehicleStatus['evStatus'] } 91 | 92 | current_soc = int(status['EV_STATE_OF_CHARGE']) 93 | charging_status = status['EV_CHARGING_STATUS'] 94 | logger.info("current SoC is "+str(current_soc)+"%"+", offpeak is "+str(offpeak)) 95 | 96 | if status['EV_CHARGING_METHOD'] == "WIRED": 97 | logger.info("car is plugged in") 98 | logger.info("charging status is "+charging_status) 99 | if offpeak: 100 | # allow for SoC to drop a little to avoid restarting too often 101 | if current_soc < max_soc-1 and charging_status == "PAUSED": 102 | logger.info("sending start charging request") 103 | v.charging_start() 104 | elif current_soc >= max_soc and charging_status == "CHARGING": 105 | # Stop charging if max SoC is reached 106 | logger.info("sending stop charging request") 107 | v.charging_stop() 108 | elif charging_status == "CHARGING": 109 | # Stop charging if we are charging during peak 110 | logger.info("sending stop charging request") 111 | v.charging_stop() 112 | else: 113 | logger.info("car is not plugged in") 114 | 115 | config = configparser.ConfigParser() 116 | configfile = (os.path.expanduser('~')+"/.jlrpy.ini") #make platform independent 117 | config.read(configfile) 118 | username = config['jlrpy']['email'] 119 | password = config['jlrpy']['password'] 120 | home = (float(config['jlrpy']['home_latitude']), float(config['jlrpy']['home_longitude'])) 121 | max_soc = int(config['jlrpy']['max_soc']) 122 | 123 | peak = [ [int(config['jlrpy']['peak_start_mon']),int(config['jlrpy']['peak_end_mon'])], 124 | [int(config['jlrpy']['peak_start_tue']),int(config['jlrpy']['peak_end_tue'])], 125 | [int(config['jlrpy']['peak_start_wed']),int(config['jlrpy']['peak_end_wed'])], 126 | [int(config['jlrpy']['peak_start_thu']),int(config['jlrpy']['peak_end_thu'])], 127 | [int(config['jlrpy']['peak_start_fri']),int(config['jlrpy']['peak_end_fri'])], 128 | [int(config['jlrpy']['peak_start_sat']),int(config['jlrpy']['peak_end_sat'])], 129 | [int(config['jlrpy']['peak_start_sun']),int(config['jlrpy']['peak_end_sun'])]] 130 | 131 | c = jlrpy.Connection(username, password) 132 | v = c.vehicles[0] 133 | 134 | logger.info("[*] Enforcing offpeak charging to max soc of %d%%" % max_soc) 135 | check_soc() 136 | -------------------------------------------------------------------------------- /examples/max_min_soc.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Max SOC script. 3 | 4 | This script will check the charging level once every minute 5 | and if the current battery level meets or exceeds the specified 6 | maximum value, the charging will stop if the vehicle is currently charging. 7 | 8 | You can also specify a minimum charging level and ensure charging is started 9 | if the battery level drops below the specified minimum state of charge. 10 | """ 11 | 12 | import jlrpy 13 | import threading 14 | 15 | 16 | max_soc = 80 # SET MAX SOC LEVEL HERE (percentage) 17 | min_soc = 20 # SET MIN SOC LEVEL HERE 18 | interval = 300.0 # SET INTERVAL (IN SECONDS) HERE. 19 | 20 | 21 | def check_soc(): 22 | """Retrieve vehicle status and stop or start charging if 23 | current charging level matches or exceeds specified max/min level and 24 | the vehicle is currently charging. 25 | """ 26 | threading.Timer(interval, check_soc).start() # Called every minute 27 | 28 | # Get current soc 29 | current_soc = v.get_status("EV_STATE_OF_CHARGE") 30 | # Get current charging state 31 | charging_state = v.get_status("EV_CHARGING_STATUS") 32 | if current_soc >= max_soc and charging_state is "CHARGING": 33 | # Stop charging if we are currently charging 34 | v.charging_stop() 35 | elif current_soc < min_soc and charging_state is "NOT CHARGING": 36 | # Start charging 37 | v.charging_start() 38 | 39 | 40 | c = jlrpy.Connection('user@email.com', 'password') 41 | v = c.vehicles[0] 42 | 43 | print("[*] Enforcing max soc of %d%%" % max_soc) 44 | print("[*] Enforcing min soc of %d%%" % min_soc) 45 | check_soc() 46 | 47 | -------------------------------------------------------------------------------- /examples/sample.jlrpy.ini: -------------------------------------------------------------------------------- 1 | # configuration parameters for jlrpy script examples/charge_offpeak.py 2 | # Change the values in this file as needed and 3 | # save the file as $HOME/.jlrpy.ini 4 | 5 | [jlrpy] 6 | # Jaguar InControl login credentials 7 | email = email@example.com 8 | password = PassW0rd 9 | 10 | # max_soc: stop charging once state of charge percentage has reached max_soc 11 | max_soc = 80 12 | 13 | # latitude and longitude coordinates of home location 14 | # used by examples/charge_offpeak.py to calculate the distance 15 | # between home and last reported position of the vehicle 16 | # to determine if it is plugged in at home 17 | # example: 1600 Pennsylvania Avenue NW, Washington, D.C., 20500, USA 18 | home_latitude = 38.8976 19 | home_longitude = -77.0365 20 | 21 | # start and end of peak electrical rate per day 22 | # in 24 hour format, whole hour only 23 | # if peak doesn't start or end on the hour, round the start down and 24 | # the end up to avoid charging during peak hours. 25 | # used by examples/charge_offpeak.py 26 | peak_start_mon = 7 27 | peak_end_mon = 23 28 | peak_start_tue = 7 29 | peak_end_tue = 23 30 | peak_start_wed = 7 31 | peak_end_wed = 23 32 | peak_start_thu = 7 33 | peak_end_thu = 23 34 | peak_start_fri = 7 35 | peak_end_fri = 23 36 | peak_start_sat = 15 37 | peak_end_sat = 19 38 | peak_start_sun = 15 39 | peak_end_sun = 19 40 | -------------------------------------------------------------------------------- /jlrpy.py: -------------------------------------------------------------------------------- 1 | """ Simple Python class to access the JLR Remote Car API 2 | https://github.com/ardevd/jlrpy 3 | """ 4 | 5 | 6 | import calendar 7 | import json 8 | import logging 9 | import time 10 | import uuid 11 | from datetime import datetime 12 | 13 | import requests 14 | from requests.exceptions import HTTPError 15 | 16 | logger = logging.getLogger('jlrpy') 17 | 18 | 19 | class BaseURLs: 20 | """Rest Of World Base URLs""" 21 | IFAS = "https://ifas.prod-row.jlrmotor.com/ifas/jlr" 22 | IFOP = "https://ifop.prod-row.jlrmotor.com/ifop/jlr" 23 | IF9 = "https://if9.prod-row.jlrmotor.com/if9/jlr" 24 | 25 | 26 | class ChinaBaseURLs: 27 | """China Base URLs""" 28 | IFAS = "https://ifas.prod-chn.jlrmotor.com/ifas/jlr" 29 | IFOP = "https://ifop.prod-chn.jlrmotor.com/ifop/jlr" 30 | IF9 = "https://ifoa.prod-chn.jlrmotor.com/if9/jlr" 31 | 32 | 33 | TIMEOUT = 15 34 | 35 | 36 | class Connection: 37 | """Connection to the JLR Remote Car API""" 38 | 39 | def __init__(self, 40 | email='', 41 | password='', 42 | device_id='', 43 | refresh_token='', 44 | use_china_servers=False): 45 | """Init the connection object 46 | 47 | The email address and password associated with your Jaguar InControl account is required. 48 | A device Id can optionally be specified. If not one will be generated at runtime. 49 | A refresh token can be supplied for authentication instead of a password 50 | """ 51 | self.email: str = email 52 | self.expiration: int = 0 # force credential refresh 53 | self.access_token: str 54 | self.auth_token: str 55 | self.head: dict = {} 56 | self.refresh_token: str 57 | self.user_id: str 58 | self.vehicles: list = [] 59 | 60 | if use_china_servers: 61 | self.base = ChinaBaseURLs 62 | else: 63 | self.base = BaseURLs 64 | 65 | if device_id: 66 | self.device_id = device_id 67 | else: 68 | self.device_id = str(uuid.uuid4()) 69 | 70 | if refresh_token: 71 | self.oauth = { 72 | "grant_type": "refresh_token", 73 | "refresh_token": refresh_token} 74 | else: 75 | self.oauth = { 76 | "grant_type": "password", 77 | "username": email, 78 | "password": password} 79 | 80 | self.connect() 81 | 82 | try: 83 | for vehicle in self.get_vehicles(self.head)['vehicles']: 84 | self.vehicles.append(Vehicle(vehicle, self)) 85 | except TypeError: 86 | logger.error("No vehicles associated with this account") 87 | 88 | def validate_token(self): 89 | """Is token still valid""" 90 | now = calendar.timegm(datetime.now().timetuple()) 91 | if now > self.expiration: 92 | # Auth expired, reconnect 93 | self.connect() 94 | 95 | def get(self, command, url, headers): 96 | """GET data from API""" 97 | self.validate_token() 98 | if headers['Authorization']: 99 | headers['Authorization'] = self.head['Authorization'] 100 | return self._request(f"{url}/{command}", headers=headers, method="GET") 101 | 102 | def post(self, command, url, headers, data=None): 103 | """POST data to API""" 104 | self.validate_token() 105 | if headers['Authorization']: 106 | headers['Authorization'] = self.head['Authorization'] 107 | return self._request(f"{url}/{command}", headers=headers, data=data, method="POST") 108 | 109 | def delete(self, command, url, headers): 110 | """DELETE data from api""" 111 | self.validate_token() 112 | if headers['Authorization']: 113 | headers['Authorization'] = self.head['Authorization'] 114 | if headers["Accept"]: 115 | del headers["Accept"] 116 | return self._request(url=f"{url}/{command}", headers=headers, method="DELETE") 117 | 118 | def connect(self): 119 | """Connect to JLR API""" 120 | logger.info("Connecting...") 121 | auth = self._authenticate(data=self.oauth) 122 | self._register_auth(auth) 123 | self._set_header(auth['access_token']) 124 | logger.info("[+] authenticated") 125 | self._register_device_and_log_in() 126 | 127 | def _register_device_and_log_in(self): 128 | self._register_device(self.head) 129 | logger.info("1/2 device id registered") 130 | self._login_user(self.head) 131 | logger.info("2/2 user logged in, user id retrieved") 132 | 133 | def _request(self, url, headers=None, data=None, method="GET"): 134 | ret = requests.request(method=method, url=url, headers=headers, json=data, timeout=TIMEOUT) 135 | if ret.text: 136 | try: 137 | return json.loads(ret.text) 138 | except json.JSONDecodeError: 139 | return None 140 | return None 141 | 142 | def _register_auth(self, auth): 143 | self.access_token = auth['access_token'] 144 | now = calendar.timegm(datetime.now().timetuple()) 145 | self.expiration = now + int(auth['expires_in']) 146 | self.auth_token = auth['authorization_token'] 147 | self.refresh_token = auth['refresh_token'] 148 | 149 | def _set_header(self, access_token): 150 | """Set HTTP header fields""" 151 | self.head = { 152 | "Authorization": f"Bearer {access_token}", 153 | "X-Device-Id": self.device_id, 154 | "x-telematicsprogramtype": "jlrpy", 155 | "x-App-Id": "ICR_JAGUAR_ANDROID", 156 | "x-App-Secret": "7bf6f544-1926-4714-8066-ceceb40d538d", 157 | "Content-Type": "application/json"} 158 | 159 | def _authenticate(self, data=None): 160 | """Raw urlopen command to the auth url""" 161 | url = f"{self.base.IFAS}/tokens/tokensSSO" 162 | auth_headers = { 163 | "Authorization": "Basic YXM6YXNwYXNz", 164 | "Content-Type": "application/json", 165 | "user-agent": "jlrpy"} 166 | 167 | return self._request(url, auth_headers, data, "POST") 168 | 169 | def _register_device(self, headers=None): 170 | """Register the device Id""" 171 | url = f"{self.base.IFOP}/users/{self.email}/clients" 172 | data = { 173 | "access_token": self.access_token, 174 | "authorization_token": self.auth_token, 175 | "expires_in": "86400", 176 | "deviceID": self.device_id 177 | } 178 | 179 | return self._request(url, headers, data, "POST") 180 | 181 | def _login_user(self, headers=None): 182 | """Login the user""" 183 | url = f"{self.base.IF9}/users?loginName={self.email}" 184 | user_login_header = headers.copy() 185 | user_login_header["Accept"] = "application/vnd.wirelesscar.ngtp.if9.User-v3+json" 186 | 187 | user_data = self._request(url, user_login_header) 188 | self.user_id = user_data['userId'] 189 | return user_data 190 | 191 | def refresh_tokens(self): 192 | """Refresh tokens.""" 193 | self.oauth = { 194 | "grant_type": "refresh_token", 195 | "refresh_token": self.refresh_token} 196 | 197 | auth = self._authenticate(self.oauth) 198 | self._register_auth(auth) 199 | self._set_header(auth['access_token']) 200 | logger.info("[+] Tokens refreshed") 201 | self._register_device_and_log_in() 202 | 203 | def get_vehicles(self, headers): 204 | """Get vehicles for user""" 205 | url = f"{self.base.IF9}/users/{self.user_id}/vehicles?primaryOnly=true" 206 | return self._request(url, headers) 207 | 208 | def get_user_info(self): 209 | """Get user information""" 210 | headers = self.head.copy() 211 | headers["Accept"] = "application/vnd.wirelesscar.ngtp.if9.User-v3+json" 212 | headers["Content-Type"] = "application/json" 213 | return self.get("", f"{self.base.IF9}/users?loginName={self.email}", self.head) 214 | 215 | def update_user_info(self, user_info_data): 216 | """Update user information""" 217 | headers = self.head.copy() 218 | headers["Content-Type"] = "application/vnd.wirelesscar.ngtp.if9.User-v3+json; charset=utf-8" 219 | return self.post(self.user_id, f"{self.base.IF9}/users", headers, user_info_data) 220 | 221 | def reverse_geocode(self, lat, lon): 222 | """Get geocode information""" 223 | headers = self.head.copy() 224 | headers["Accept"] = "application/json" 225 | return self.get("en", f"{self.base.IF9}/geocode/reverse/{lat}/{lon}", headers) 226 | 227 | 228 | class Vehicle(dict): 229 | """Vehicle class. 230 | 231 | You can request data or send commands to vehicle. Consult the JLR API documentation for details 232 | """ 233 | 234 | def __init__(self, data, connection): 235 | """Initialize the vehicle class.""" 236 | 237 | super().__init__(data) 238 | self.connection = connection 239 | self.vin = data['vin'] 240 | 241 | def get_contact_info(self, mcc): 242 | """ Get contact info for the specified mobile country code""" 243 | headers = self.connection.head.copy() 244 | return self.get(f"contactinfo/{mcc}", headers) 245 | 246 | def get_attributes(self): 247 | """Get vehicle attributes""" 248 | headers = self.connection.head.copy() 249 | headers["Accept"] = "application/vnd.ngtp.org.VehicleAttributes-v8+json" 250 | return self.get('attributes', headers) 251 | 252 | def get_status(self, key=None): 253 | """Get vehicle status""" 254 | headers = self.connection.head.copy() 255 | headers["Accept"] = "application/vnd.ngtp.org.if9.healthstatus-v4+json" 256 | result = self.get('status?includeInactive=true', headers) 257 | 258 | if key: 259 | core_status = result['vehicleStatus']['coreStatus'] 260 | ev_status = result['vehicleStatus']['evStatus'] 261 | core_status = core_status + ev_status 262 | return {d['key']: d['value'] for d in core_status}[key] 263 | 264 | return result 265 | 266 | def get_health_status(self): 267 | """Get vehicle health status""" 268 | headers = self.connection.head.copy() 269 | headers["Accept"] = "application/vnd.wirelesscar.ngtp.if9.ServiceStatus-v4+json" 270 | headers["Content-Type"] = "application/vnd.wirelesscar.ngtp.if9.StartServiceConfiguration-v3+json; charset=utf-8" # noqa: E501, pylint: disable=line-too-long 271 | 272 | vhs_data = self._authenticate_vhs() 273 | 274 | return self.post('healthstatus', headers, vhs_data) 275 | 276 | def get_departure_timers(self): 277 | """Get vehicle departure timers""" 278 | headers = self.connection.head.copy() 279 | headers["Accept"] = "application/vnd.wirelesscar.ngtp.if9.DepartureTimerSettings-v1+json" 280 | return self.get("departuretimers", headers) 281 | 282 | def get_wakeup_time(self): 283 | """Get configured wakeup time for vehicle""" 284 | headers = self.connection.head.copy() 285 | headers["Accept"] = "application/vnd.wirelesscar.ngtp.if9.VehicleWakeupTime-v2+json" 286 | return self.get("wakeuptime", headers) 287 | 288 | def get_subscription_packages(self): 289 | """Get vehicle status""" 290 | headers = self.connection.head.copy() 291 | headers["Accept"] = "application/vnd.wirelesscar.ngtp.if9.SubscriptionPackages-v2+json" 292 | result = self.get('subscriptionpackages', headers) 293 | return result 294 | 295 | def get_trips(self, count=1000, start=None, stop=None): 296 | """Get the last 1000 trips associated with vehicle. Start/Stop strings in ISO 8601 format.""" 297 | headers = self.connection.head.copy() 298 | headers["Accept"] = "application/vnd.ngtp.org.triplist-v2+json" 299 | path = 'trips?' 300 | if start != None: 301 | s = datetime.fromisoformat(start).isoformat(timespec='minutes') 302 | utc_tz = datetime.fromisoformat(s[:16]+'+0000').tzinfo 303 | s = datetime.fromisoformat(start).astimezone(utc_tz).isoformat(timespec='seconds') 304 | s = s[:19]+'+0000' 305 | path += 'startDate='+requests.utils.quote(s)+'&' 306 | if stop != None: 307 | s = datetime.fromisoformat(stop).isoformat(timespec='minutes') 308 | utc_tz = datetime.fromisoformat(s[:16]+'+0000').tzinfo 309 | s = datetime.fromisoformat(stop).astimezone(utc_tz).isoformat(timespec='seconds') 310 | s = s[:19]+'+0000' 311 | path += 'stopDate='+requests.utils.quote(s)+'&' 312 | path += f"count={count}" 313 | return self.get(path, headers) 314 | 315 | def get_guardian_mode_alarms(self): 316 | """Get Guardian Mode Alarms""" 317 | headers = self.connection.head.copy() 318 | headers["Accept"] = "application/vnd.wirelesscar.ngtp.if9.GuardianStatus-v1+json" 319 | headers["Accept-Encoding"] = "gzip,deflate" 320 | return self.get('gm/alarms', headers) 321 | 322 | def get_guardian_mode_alerts(self): 323 | """Get Guardian Mode Alerts""" 324 | headers = self.connection.head.copy() 325 | headers["Accept"] = "application/wirelesscar.GuardianAlert-v1+json" 326 | headers["Accept-Encoding"] = "gzip,deflate" 327 | return self.get('gm/alerts', headers) 328 | 329 | def get_guardian_mode_status(self): 330 | """Get Guardian Mode Status""" 331 | headers = self.connection.head.copy() 332 | headers["Accept"] = "application/vnd.wirelesscar.ngtp.if9.GuardianStatus-v1+json" 333 | return self.get('gm/status', headers) 334 | 335 | def get_guardian_mode_settings_user(self): 336 | """Get Guardian Mode User Settings""" 337 | headers = self.connection.head.copy() 338 | headers["Accept"] = "application/vnd.wirelesscar.ngtp.if9.GuardianUserSettings-v1+json" 339 | return self.get('gm/settings/user', headers) 340 | 341 | def get_guardian_mode_settings_system(self): 342 | """Get Guardian Mode System Settings""" 343 | headers = self.connection.head.copy() 344 | headers["Accept"] = "application/vnd.wirelesscar.ngtp.if9.GuardianSystemSettings-v1+json" 345 | return self.get('gm/settings/system', headers) 346 | 347 | def get_trip(self, trip_id, section=1): 348 | """Get info on a specific trip""" 349 | return self.get(f"trips/{trip_id}/route?pageSize=1000&page={section}", self.connection.head) 350 | 351 | def get_position(self): 352 | """Get current vehicle position""" 353 | return self.get('position', self.connection.head) 354 | 355 | def get_service_status(self, service_id): 356 | """Get service status""" 357 | headers = self.connection.head.copy() 358 | headers["Accept"] = "application/vnd.wirelesscar.ngtp.if9.ServiceStatus-v4+json" 359 | return self.get(f"services/{service_id}", headers) 360 | 361 | def get_services(self): 362 | """Get active services""" 363 | headers = self.connection.head.copy() 364 | return self.get("services", headers) 365 | 366 | def get_rcc_target_value(self): 367 | """Get Remote Climate Target Value""" 368 | headers = self.connection.head.copy() 369 | try: 370 | return self.get('settings/ClimateControlRccTargetTemp', headers) 371 | except HTTPError: 372 | return None 373 | 374 | def set_attributes(self, nickname, registration_number): 375 | """Set vehicle nickname and registration number""" 376 | attributes_data = {"nickname": nickname, 377 | "registrationNumber": registration_number} 378 | return self.post("attributes", self.connection.head, attributes_data) 379 | 380 | def lock(self, pin): 381 | """Lock vehicle. Requires personal PIN for authentication""" 382 | headers = self.connection.head.copy() 383 | headers["Content-Type"] = "application/vnd.wirelesscar.ngtp.if9.StartServiceConfiguration-v3+json" 384 | rdl_data = self.authenticate_rdl(pin) 385 | 386 | return self.post("lock", headers, rdl_data) 387 | 388 | def unlock(self, pin): 389 | """Unlock vehicle. Requires personal PIN for authentication""" 390 | headers = self.connection.head.copy() 391 | headers["Content-Type"] = "application/vnd.wirelesscar.ngtp.if9.StartServiceConfiguration-v3+json" 392 | rdu_data = self.authenticate_rdu(pin) 393 | 394 | return self.post("unlock", headers, rdu_data) 395 | 396 | def reset_alarm(self, pin): 397 | """Reset vehicle alarm""" 398 | headers = self.connection.head.copy() 399 | headers["Content-Type"] = "application/vnd.wirelesscar.ngtp.if9.StartServiceConfiguration-v3+json; charset=utf-8" # noqa: E501, pylint: disable=line-too-long 400 | headers["Accept"] = "application/vnd.wirelesscar.ngtp.if9.ServiceStatus-v4+json" 401 | aloff_data = self.authenticate_aloff(pin) 402 | 403 | return self.post("unlock", headers, aloff_data) 404 | 405 | def honk_blink(self): 406 | """Sound the horn and blink lights""" 407 | headers = self.connection.head.copy() 408 | headers["Accept"] = "application/vnd.wirelesscar.ngtp.if9.ServiceStatus-v4+json" 409 | headers["Content-Type"] = "application/vnd.wirelesscar.ngtp.if9.StartServiceConfiguration-v3+json; charset=utf-8" # noqa: E501, pylint: disable=line-too-long 410 | 411 | hblf_data = self.authenticate_hblf() 412 | return self.post("honkBlink", headers, hblf_data) 413 | 414 | def remote_engine_start(self, pin, target_value): 415 | """Start Remote Engine preconditioning""" 416 | headers = self.connection.head.copy() 417 | headers["Content-Type"] = "application/vnd.wirelesscar.ngtp.if9.StartServiceConfiguration-v3+json" 418 | self.set_rcc_target_value(pin, target_value) 419 | reon_data = self.authenticate_reon(pin) 420 | 421 | return self.post("engineOn", headers, reon_data) 422 | 423 | def remote_engine_stop(self, pin): 424 | """Stop Remote Engine preconditioning""" 425 | headers = self.connection.head.copy() 426 | headers["Content-Type"] = "application/vnd.wirelesscar.ngtp.if9.StartServiceConfiguration-v3+json" 427 | reoff_data = self.authenticate_reoff(pin) 428 | 429 | return self.post("engineOff", headers, reoff_data) 430 | 431 | def set_rcc_target_value(self, pin, target_value): 432 | """Set Remote Climate Target Value (value between 31-57, 31 is LO 57 is HOT)""" 433 | headers = self.connection.head.copy() 434 | self.enable_provisioning_mode(pin) 435 | service_parameters = { 436 | "key": "ClimateControlRccTargetTemp", 437 | "value": str(target_value), 438 | "applied": 1 439 | } 440 | self.post("settings", headers, service_parameters) 441 | 442 | def get_waua_status(self): 443 | """Get WAUA status.""" 444 | headers = self.connection.head.copy() 445 | headers["Accept"] = "application/wirelesscar.WauaStatus-v1+json" 446 | return self.get("waua/status", headers) 447 | 448 | def preconditioning_start(self, target_temp): 449 | """Start pre-conditioning for specified temperature (celsius)""" 450 | service_parameters = [ 451 | {"key": "PRECONDITIONING", "value": "START"}, 452 | {"key": "TARGET_TEMPERATURE_CELSIUS", "value": str(target_temp)} 453 | ] 454 | return self._preconditioning_control(service_parameters) 455 | 456 | def preconditioning_stop(self): 457 | """Stop climate preconditioning""" 458 | service_parameters = [{"key": "PRECONDITIONING", 459 | "value": "STOP"}] 460 | return self._preconditioning_control(service_parameters) 461 | 462 | def climate_prioritize(self, priority): 463 | """Optimize climate controls for comfort or range""" 464 | service_parameters = [{"key": "PRIORITY_SETTING", "value": priority}] 465 | return self._preconditioning_control(service_parameters) 466 | 467 | def _preconditioning_control(self, service_parameters): 468 | """Control the climate preconditioning""" 469 | headers = self.connection.head.copy() 470 | headers["Accept"] = "application/vnd.wirelesscar.ngtp.if9.ServiceStatus-v5+json" 471 | headers["Content-Type"] = "application/vnd.wirelesscar.ngtp.if9.PhevService-v1+json; charset=utf-8" 472 | 473 | ecc_data = self.authenticate_ecc() 474 | ecc_data['serviceParameters'] = service_parameters 475 | return self.post("preconditioning", headers, ecc_data) 476 | 477 | def charging_stop(self): 478 | """Stop charging""" 479 | service_parameters = [{"key": "CHARGE_NOW_SETTING", 480 | "value": "FORCE_OFF"}] 481 | 482 | return self._charging_profile_control("serviceParameters", service_parameters) 483 | 484 | def charging_start(self): 485 | """Start charging""" 486 | service_parameters = [{"key": "CHARGE_NOW_SETTING", 487 | "value": "FORCE_ON"}] 488 | 489 | return self._charging_profile_control("serviceParameters", service_parameters) 490 | 491 | def set_max_soc(self, max_charge_level): 492 | """Set max state of charge in percentage""" 493 | service_parameters = [{"key": "SET_PERMANENT_MAX_SOC", 494 | "value": max_charge_level}] 495 | 496 | return self._charging_profile_control("serviceParameters", service_parameters) 497 | 498 | def set_one_off_max_soc(self, max_charge_level): 499 | """Set one off max state of charge in percentage""" 500 | service_parameters = [{"key": "SET_ONE_OFF_MAX_SOC", 501 | "value": max_charge_level}] 502 | 503 | return self._charging_profile_control("serviceParameters", service_parameters) 504 | 505 | def add_departure_timer(self, index, year, month, day, hour, minute): 506 | """Add a single departure timer with the specified index""" 507 | departure_timer_setting = {"timers": [ 508 | {"departureTime": {"hour": hour, "minute": minute}, 509 | "timerIndex": index, "timerTarget": 510 | {"singleDay": {"day": day, "month": month, "year": year}}, 511 | "timerType": {"key": "BOTHCHARGEANDPRECONDITION", "value": True}}]} 512 | 513 | return self._charging_profile_control("departureTimerSetting", departure_timer_setting) 514 | 515 | def add_repeated_departure_timer(self, index, schedule, hour, minute): 516 | """Add repeated departure timer.""" 517 | departure_timer_setting = {"timers": [ 518 | {"departureTime": {"hour": hour, "minute": minute}, 519 | "timerIndex": index, "timerTarget": 520 | {"repeatSchedule": schedule}, 521 | "timerType": {"key": "BOTHCHARGEANDPRECONDITION", "value": True}}]} 522 | 523 | return self._charging_profile_control("departureTimerSetting", departure_timer_setting) 524 | 525 | def delete_departure_timer(self, index): 526 | """Delete a single departure timer associated with the specified index""" 527 | departure_timer_setting = {"timers": [{"timerIndex": index}]} 528 | 529 | return self._charging_profile_control("departureTimerSetting", departure_timer_setting) 530 | 531 | def add_charging_period(self, index, schedule, hour_from, minute_from, hour_to, minute_to): 532 | """Add charging period""" 533 | tariff_settings = {"tariffs": [ 534 | {"tariffIndex": index, "tariffDefinition": {"enabled": True, 535 | "repeatSchedule": schedule, 536 | "tariffZone": [ 537 | {"zoneName": "TARIFF_ZONE_A", 538 | "bandType": "PEAK", 539 | "endTime": { 540 | "hour": hour_from, 541 | "minute": minute_from}}, 542 | {"zoneName": "TARIFF_ZONE_B", 543 | "bandType": "OFFPEAK", 544 | "endTime": {"hour": hour_to, 545 | "minute": minute_to}}, 546 | {"zoneName": "TARIFF_ZONE_C", 547 | "bandType": "PEAK", 548 | "endTime": {"hour": 0, 549 | "minute": 0}}]}}]} 550 | 551 | return self._charging_profile_control("tariffSettings", tariff_settings) 552 | 553 | def _charging_profile_control(self, service_parameter_key, service_parameters): 554 | """Charging profile API""" 555 | headers = self.connection.head.copy() 556 | headers["Accept"] = "application/vnd.wirelesscar.ngtp.if9.ServiceStatus-v5+json" 557 | headers["Content-Type"] = "application/vnd.wirelesscar.ngtp.if9.PhevService-v1+json; charset=utf-8" 558 | 559 | cp_data = self.authenticate_cp() 560 | cp_data[service_parameter_key] = service_parameters 561 | 562 | return self.post("chargeProfile", headers, cp_data) 563 | 564 | def set_wakeup_time(self, wakeup_time): 565 | """Set the wakeup time for the specified time (epoch milliseconds)""" 566 | swu_data = self.authenticate_swu() 567 | swu_data["serviceCommand"] = "START" 568 | swu_data["startTime"] = wakeup_time 569 | return self._swu(swu_data) 570 | 571 | def delete_wakeup_time(self): 572 | """Stop the wakeup time""" 573 | swu_data = self.authenticate_swu() 574 | swu_data["serviceCommand"] = "END" 575 | return self._swu(swu_data) 576 | 577 | def _swu(self, swu_data): 578 | """Set the wakeup time for the specified time (epoch milliseconds)""" 579 | headers = self.connection.head.copy() 580 | headers["Accept"] = "application/vnd.wirelesscar.ngtp.if9.ServiceStatus-v3+json" 581 | headers["Content-Type"] = "application/vnd.wirelesscar.ngtp.if9.StartServiceConfiguration-v3+json; charset=utf-8" # noqa: E501, pylint: disable=line-too-long 582 | return self.post("swu", headers, swu_data) 583 | 584 | def enable_provisioning_mode(self, pin): 585 | """Enable provisioning mode """ 586 | self._prov_command(pin, None, "provisioning") 587 | 588 | def enable_service_mode(self, pin, expiration_time): 589 | """Enable service mode. Will disable at the specified time (epoch millis)""" 590 | return self._prov_command(pin, expiration_time, "protectionStrategy_serviceMode") 591 | 592 | def disable_service_mode(self, pin): 593 | """Disable service mode.""" 594 | exp = int(time.time() * 1000) 595 | return self._prov_command(pin, exp, "protectionStrategy_serviceMode") 596 | 597 | def enable_guardian_mode(self, pin, expiration_time): 598 | """Enable Guardian Mode until the specified time (epoch millis)""" 599 | return self._gm_command(pin, expiration_time, "ACTIVATE") 600 | 601 | def disable_guardian_mode(self, pin): 602 | """Disable Guardian Mode""" 603 | return self._gm_command(pin, 0, "DEACTIVATE") 604 | 605 | def enable_transport_mode(self, pin, expiration_time): 606 | """Enable transport mode. Will be disabled at the specified time (epoch millis)""" 607 | return self._prov_command(pin, expiration_time, "protectionStrategy_transportMode") 608 | 609 | def disable_transport_mode(self, pin): 610 | """Disable transport mode""" 611 | exp = int(time.time() * 1000) 612 | return self._prov_command(pin, exp, "protectionStrategy_transportMode") 613 | 614 | def enable_privacy_mode(self, pin): 615 | """Enable privacy mode. Will disable journey logging""" 616 | return self._prov_command(pin, None, "privacySwitch_on") 617 | 618 | def disable_privacy_mode(self, pin): 619 | """Disable privacy mode. Will enable journey logging""" 620 | return self._prov_command(pin, None, "privacySwitch_off") 621 | 622 | def _prov_command(self, pin, expiration_time, mode): 623 | """Send prov endpoint commands. Used for service/transport/privacy mode""" 624 | headers = self.connection.head.copy() 625 | headers["Content-Type"] = "application/vnd.wirelesscar.ngtp.if9.StartServiceConfiguration-v3+json" 626 | prov_data = self.authenticate_prov(pin) 627 | 628 | prov_data["serviceCommand"] = mode 629 | prov_data["startTime"] = None 630 | prov_data["endTime"] = expiration_time 631 | 632 | return self.post("prov", headers, prov_data) 633 | 634 | def _gm_command(self, pin, expiration_time, action): 635 | """Send GM toggle command""" 636 | headers = self.connection.head.copy() 637 | headers["Accept"] = "application/vnd.wirelesscar.ngtp.if9.GuardianAlarmList-v1+json" 638 | gm_data = self.authenticate_gm(pin) 639 | if action == "ACTIVATE": 640 | gm_data["endTime"] = expiration_time 641 | gm_data["status"] = "ACTIVE" 642 | return self.post("gm/alarms", headers, gm_data) 643 | if action == "DEACTIVATE": 644 | headers["X-servicetoken"] = gm_data.get("token") 645 | return self.delete("gm/alarms/INSTANT", headers) 646 | 647 | def _authenticate_vhs(self): 648 | """Authenticate to vhs and get token""" 649 | return self._authenticate_empty_pin_protected_service("VHS") 650 | 651 | def _authenticate_empty_pin_protected_service(self, service_name): 652 | return self._authenticate_service("", service_name) 653 | 654 | def authenticate_hblf(self): 655 | """Authenticate to hblf""" 656 | return self._authenticate_vin_protected_service("HBLF") 657 | 658 | def authenticate_ecc(self): 659 | """Authenticate to ecc""" 660 | return self._authenticate_vin_protected_service("ECC") 661 | 662 | def authenticate_cp(self): 663 | """Authenticate to cp""" 664 | return self._authenticate_vin_protected_service("CP") 665 | 666 | def authenticate_swu(self): 667 | """Authenticate to swu""" 668 | return self._authenticate_empty_pin_protected_service("SWU") 669 | 670 | def _authenticate_vin_protected_service(self, service_name): 671 | """Authenticate to specified service and return associated token""" 672 | return self._authenticate_service(self.vin[-4:], service_name) 673 | 674 | def authenticate_rdl(self, pin): 675 | """Authenticate to rdl""" 676 | return self._authenticate_service(pin, "RDL") 677 | 678 | def authenticate_rdu(self, pin): 679 | """Authenticate to rdu""" 680 | return self._authenticate_service(pin, "RDU") 681 | 682 | def authenticate_aloff(self, pin): 683 | """Authenticate to aloff""" 684 | return self._authenticate_service(pin, "ALOFF") 685 | 686 | def authenticate_reon(self, pin): 687 | """Authenticate to reon""" 688 | return self._authenticate_service(pin, "REON") 689 | 690 | def authenticate_reoff(self, pin): 691 | """Authenticate to reoff""" 692 | return self._authenticate_service(pin, "REOFF") 693 | 694 | def authenticate_prov(self, pin): 695 | """Authenticate to PROV service""" 696 | return self._authenticate_service(pin, "PROV") 697 | 698 | def authenticate_gm(self, pin): 699 | """Authenticate to GM service""" 700 | return self._authenticate_service(pin, "GM") 701 | 702 | def _authenticate_service(self, pin, service_name): 703 | """Authenticate to specified service with the provided PIN""" 704 | data = { 705 | "serviceName": service_name, 706 | "pin": str(pin) 707 | } 708 | headers = self.connection.head.copy() 709 | headers["Content-Type"] = "application/vnd.wirelesscar.ngtp.if9.AuthenticateRequest-v2+json; charset=utf-8" 710 | return self.post(f"users/{self.connection.user_id}/authenticate", headers, data) 711 | 712 | def get(self, command, headers): 713 | """Utility command to get vehicle data from API""" 714 | return self.connection.get(command, f"{self.connection.base.IF9}/vehicles/{self.vin}", headers) 715 | 716 | def post(self, command, headers, data): 717 | """Utility command to post data to VHS""" 718 | return self.connection.post(command, f"{self.connection.base.IF9}/vehicles/{self.vin}", headers, data) 719 | 720 | def delete(self, command, headers): 721 | """Utility command to delete active service entry""" 722 | return self.connection.delete(command, f"{self.connection.base.IF9}/vehicles/{self.vin}", headers) 723 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests>=2.26.0 -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | 4 | with open("README.md", "r", encoding='UTF-8') as fh: 5 | long_description = fh.read() 6 | 7 | setuptools.setup( 8 | name="jlrpy", 9 | version="1.7.1", 10 | author="ardevd", 11 | author_email="5gk633atf@relay.firefox.com", 12 | description="Control your Jaguar I-Pace", 13 | long_description=long_description, 14 | long_description_content_type="text/markdown", 15 | url="https://github.com/ardevd/jlrpy", 16 | py_modules=['jlrpy'], 17 | install_requires=['requests>=2.26.0'], 18 | classifiers=[ 19 | "Programming Language :: Python :: 3", 20 | "License :: OSI Approved :: MIT License", 21 | "Operating System :: OS Independent", 22 | "Intended Audience :: Developers", 23 | "Development Status :: 5 - Production/Stable", 24 | ], 25 | ) 26 | --------------------------------------------------------------------------------