├── tests ├── __init__.py ├── eu_login_test.py ├── ca_login_test.py ├── au_login_test.py ├── eu_check_response_for_errors_test.py ├── utils_test.py ├── vehicle_manager_test.py └── us_login_test.py ├── HISTORY.rst ├── docs ├── authors.rst ├── history.rst ├── readme.rst ├── contributing.rst ├── usage.rst ├── index.rst ├── Makefile ├── make.bat ├── installation.rst └── conf.py ├── requirements.txt ├── .env.example ├── AUTHORS.rst ├── MANIFEST.in ├── .editorconfig ├── hyundai_kia_connect_api ├── __init__.py ├── Token.py ├── exceptions.py ├── const.py ├── utils.py ├── ApiImpl.py ├── VehicleManager.py ├── bluelink.py ├── Vehicle.py ├── HyundaiBlueLinkApiBR.py └── KiaUvoApiAU.py ├── .github ├── ISSUE_TEMPLATE.md ├── dependabot.yml └── workflows │ ├── lintPR.yaml │ ├── scan-secret.yml │ ├── pypi.yml │ ├── python-tests.yml │ ├── release.yml │ └── codeql.yml ├── .gitignore ├── setup.cfg ├── LICENSE ├── setup.py ├── .pre-commit-config.yaml ├── Makefile ├── CONTRIBUTING.rst ├── OTP_RMTOKEN_IMPLEMENTATION.md └── README.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /HISTORY.rst: -------------------------------------------------------------------------------- 1 | no history 2 | -------------------------------------------------------------------------------- /docs/authors.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../AUTHORS.rst 2 | -------------------------------------------------------------------------------- /docs/history.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../HISTORY.rst 2 | -------------------------------------------------------------------------------- /docs/readme.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | beautifulsoup4>=4.10.0 2 | requests 3 | certifi>=2024.6.2 4 | tzdata>=2025.2 5 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | KIA_US_USERNAME=your_username_here 2 | KIA_US_PASSWORD=your_password_here 3 | KIA_US_PIN=your_pin_here 4 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | Usage 3 | ===== 4 | 5 | To use Hyundai / Kia Connect in a project:: 6 | 7 | import hyundai_kia_connect_api 8 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Credits 3 | ======= 4 | 5 | Development Lead 6 | ---------------- 7 | 8 | * Fuat Akgun 9 | 10 | Contributors 11 | ------------ 12 | 13 | None yet. Why not be the first? 14 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS.rst 2 | include CONTRIBUTING.rst 3 | include HISTORY.rst 4 | include LICENSE 5 | include README.rst 6 | include requirements.txt 7 | 8 | recursive-include tests * 9 | recursive-exclude * __pycache__ 10 | recursive-exclude * *.py[co] 11 | 12 | recursive-include docs *.rst conf.py Makefile make.bat *.jpg *.png *.gif 13 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | charset = utf-8 11 | end_of_line = lf 12 | 13 | [*.bat] 14 | indent_style = tab 15 | end_of_line = crlf 16 | 17 | [LICENSE] 18 | insert_final_newline = false 19 | 20 | [Makefile] 21 | indent_style = tab 22 | -------------------------------------------------------------------------------- /hyundai_kia_connect_api/__init__.py: -------------------------------------------------------------------------------- 1 | """Top-level package for Hyundai / Kia Connect.""" 2 | 3 | # flake8: noqa 4 | from .ApiImpl import ( 5 | ClimateRequestOptions, 6 | WindowRequestOptions, 7 | ScheduleChargingClimateRequestOptions, 8 | ) 9 | 10 | from .Token import Token 11 | from .Vehicle import Vehicle 12 | from .VehicleManager import VehicleManager 13 | 14 | from .const import WINDOW_STATE 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | - Hyundai / Kia Connect version: 2 | - Python version: 3 | - Operating System: 4 | 5 | ### Description 6 | 7 | Describe what you were trying to get done. 8 | Tell us what happened, what went wrong, and what you expected to happen. 9 | 10 | ### What I Did 11 | 12 | ``` 13 | Paste the command(s) you ran and the output. 14 | If there was a crash, please include the traceback here. 15 | ``` 16 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Enable version updates for Python 4 | - package-ecosystem: "pip" 5 | directory: "/" 6 | # Check for updates once a week 7 | schedule: 8 | interval: "weekly" 9 | - package-ecosystem: "github-actions" 10 | directory: "/" 11 | # Check for updates once a week 12 | schedule: 13 | interval: "weekly" 14 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to Hyundai / Kia Connect's documentation! 2 | ====================================== 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | :caption: Contents: 7 | 8 | readme 9 | installation 10 | usage 11 | modules 12 | contributing 13 | authors 14 | history 15 | 16 | Indices and tables 17 | ================== 18 | * :ref:`genindex` 19 | * :ref:`modindex` 20 | * :ref:`search` 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # We recommend to use a virtualenv outside of this directory. 2 | # 3 | # https://virtualenvwrapper.readthedocs.io/ is a great tool to make it easier. 4 | # 5 | # For IDE created directories, we recommended to use global git ignores. See 6 | # https://git-scm.com/docs/gitignore or 7 | # https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files#configuring-ignored-files-for-all-repositories-on-your-computer 8 | __pycache__/ 9 | .env 10 | -------------------------------------------------------------------------------- /hyundai_kia_connect_api/Token.py: -------------------------------------------------------------------------------- 1 | """Token.py""" 2 | 3 | # pylint:disable=invalid-name 4 | 5 | import datetime as dt 6 | from dataclasses import dataclass 7 | 8 | 9 | @dataclass 10 | class Token: 11 | """Token""" 12 | 13 | username: str = None 14 | password: str = None 15 | access_token: str = None 16 | refresh_token: str = None 17 | device_id: str = None 18 | valid_until: dt.datetime = dt.datetime.min 19 | stamp: str = None 20 | pin: str = None 21 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.1.0 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:setup.py] 7 | search = version='{current_version}' 8 | replace = version='{new_version}' 9 | 10 | [bumpversion:file:hyundai_kia_connect_api/__init__.py] 11 | search = __version__ = '{current_version}' 12 | replace = __version__ = '{new_version}' 13 | 14 | [bdist_wheel] 15 | universal = 1 16 | 17 | [flake8] 18 | exclude = docs 19 | max-line-length = 88 20 | [tool:pytest] 21 | addopts = --ignore=setup.py 22 | env_files = .env 23 | -------------------------------------------------------------------------------- /tests/eu_login_test.py: -------------------------------------------------------------------------------- 1 | """ 2 | import os 3 | 4 | from hyundai_kia_connect_api.VehicleManager import VehicleManager 5 | 6 | 7 | def test_EU_login(): 8 | username = os.environ["KIA_EU_FUATAKGUN_USERNAME"] 9 | password = os.environ["KIA_EU_FUATAKGUN_PASSWORD"] 10 | pin = "" 11 | vm = VehicleManager( 12 | region=1, brand=1, username=username, password=password, pin=pin 13 | ) 14 | vm.check_and_refresh_token() 15 | vm.update_all_vehicles_with_cached_state() 16 | assert len(vm.vehicles.keys()) > 0 17 | """ 18 | -------------------------------------------------------------------------------- /.github/workflows/lintPR.yaml: -------------------------------------------------------------------------------- 1 | name: "Lint PR" 2 | 3 | on: 4 | pull_request_target: 5 | types: 6 | - opened 7 | - edited 8 | - synchronize 9 | 10 | jobs: 11 | main: 12 | runs-on: ubuntu-latest 13 | steps: 14 | # Please look up the latest version from 15 | # https://github.com/amannn/action-semantic-pull-request/releases 16 | - uses: amannn/action-semantic-pull-request@v6.1.1 17 | env: 18 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 19 | -------------------------------------------------------------------------------- /tests/ca_login_test.py: -------------------------------------------------------------------------------- 1 | """ 2 | import os 3 | 4 | from hyundai_kia_connect_api.VehicleManager import VehicleManager 5 | 6 | 7 | def test_CA_login(): 8 | username = os.environ["KIA_CA_CDNNINJA_USERNAME"] 9 | password = os.environ["KIA_CA_CDNNINJA_PASSWORD"] 10 | pin = os.environ["KIA_CA_CDNNINJA_PIN"] 11 | vm = VehicleManager( 12 | region=2, 13 | brand=1, 14 | username=username, 15 | password=password, 16 | pin=pin, 17 | geocode_api_enable=True, 18 | ) 19 | vm.check_and_refresh_token() 20 | vm.check_and_force_update_vehicles(force_refresh_interval=600) 21 | assert len(vm.vehicles.keys()) > 0 22 | """ 23 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = python -msphinx 7 | SPHINXPROJ = hyundai_kia_connect_api 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /tests/au_login_test.py: -------------------------------------------------------------------------------- 1 | """ 2 | import os 3 | 4 | from hyundai_kia_connect_api.VehicleManager import VehicleManager 5 | 6 | 7 | def test_AU_login(): 8 | username = os.environ["KIA_AU_USERNAME"] 9 | password = os.environ["KIA_AU_PASSWORD"] 10 | pin = os.environ["KIA_AU_PIN"] 11 | vm = VehicleManager( 12 | region=5, 13 | brand=2, 14 | username=username, 15 | password=password, 16 | pin=pin, 17 | geocode_api_enable=False, 18 | ) 19 | vm.check_and_refresh_token() 20 | vm.check_and_force_update_vehicles(force_refresh_interval=600) 21 | print("Found: " + list(vm.vehicles.values())[0].name) 22 | assert len(vm.vehicles.keys()) > 0 23 | """ 24 | -------------------------------------------------------------------------------- /.github/workflows/scan-secret.yml: -------------------------------------------------------------------------------- 1 | name: Scan for Secrets 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | secret-scan: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@v6 17 | 18 | - name: Scan for Secrets with TruffleHog 19 | uses: trufflesecurity/trufflehog@v3 20 | with: 21 | scan-mode: entropy 22 | github-token: ${{ secrets.GITHUB_TOKEN }} 23 | 24 | - name: Remove Found Secrets (Optional) 25 | run: echo "Please review and handle secrets manually if detected." 26 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=python -msphinx 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | set SPHINXPROJ=hyundai_kia_connect_api 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The Sphinx module was not found. Make sure you have Sphinx installed, 20 | echo.then set the SPHINXBUILD environment variable to point to the full 21 | echo.path of the 'sphinx-build' executable. Alternatively you may add the 22 | echo.Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /tests/eu_check_response_for_errors_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from hyundai_kia_connect_api.KiaUvoApiEU import _check_response_for_errors 4 | from hyundai_kia_connect_api.exceptions import ( 5 | RateLimitingError, 6 | InvalidAPIResponseError, 7 | APIError, 8 | ) 9 | 10 | 11 | def test_invalid_api_response(): 12 | response = {"invalid": "response"} 13 | with pytest.raises(InvalidAPIResponseError): 14 | _check_response_for_errors(response) 15 | 16 | 17 | def test_rate_limiting(): 18 | response = { 19 | "retCode": "F", 20 | "resCode": "5091", 21 | "resMsg": "Exceeds number of requests - Exceeds Number of Requests.", 22 | } 23 | with pytest.raises(RateLimitingError): 24 | _check_response_for_errors(response) 25 | 26 | 27 | def test_unknown_error_code(): 28 | response = {"retCode": "F", "resCode": "9999", "resMsg": "New error"} 29 | with pytest.raises(APIError): 30 | _check_response_for_errors(response) 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021, Fuat Akgun 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 | -------------------------------------------------------------------------------- /tests/utils_test.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from zoneinfo import ZoneInfo 3 | from hyundai_kia_connect_api.utils import detect_timezone_for_date 4 | 5 | 6 | def test_detect_timezone_for_date(): 7 | pacific = ZoneInfo("Canada/Pacific") 8 | eastern = ZoneInfo("Canada/Eastern") 9 | # apiStartDate 20221214192418 and lastStatusDate 20221214112426 are from 10 | # https://github.com/Hyundai-Kia-Connect/kia_uvo/issues/488#issuecomment-1352038594 11 | apiStartDate = datetime.datetime( 12 | 2022, 12, 14, 19, 24, 18, tzinfo=datetime.timezone.utc 13 | ) 14 | lastStatusDate = datetime.datetime(2022, 12, 14, 11, 24, 26) 15 | assert detect_timezone_for_date(lastStatusDate, apiStartDate, [pacific]) == pacific 16 | assert detect_timezone_for_date(lastStatusDate, apiStartDate, [eastern]) is None 17 | 18 | 19 | def test_detect_timezone_for_date_newfoundland(): 20 | # Newfoundland NDT is UTC-0230 (NST is UTC-0330). Yes, half an hour. 21 | tz = ZoneInfo("Canada/Newfoundland") 22 | now_utc = datetime.datetime(2025, 9, 21, 23, 4, 30, tzinfo=datetime.timezone.utc) 23 | early = datetime.datetime(2025, 9, 21, 20, 34, 0) 24 | after = datetime.datetime(2025, 9, 21, 20, 34, 59) 25 | assert detect_timezone_for_date(early, now_utc, [tz]) == tz 26 | assert detect_timezone_for_date(after, now_utc, [tz]) == tz 27 | -------------------------------------------------------------------------------- /tests/vehicle_manager_test.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | 3 | from hyundai_kia_connect_api.ApiImpl import ApiImpl 4 | from hyundai_kia_connect_api.Token import Token 5 | from hyundai_kia_connect_api.VehicleManager import VehicleManager 6 | 7 | 8 | class DummyApi(ApiImpl): 9 | def __init__(self): 10 | super().__init__() 11 | self.login_calls = 0 12 | 13 | def login(self, username, password, token=None, otp_handler=None, pin=None): 14 | self.login_calls += 1 15 | return Token( 16 | username=username, 17 | password=password, 18 | valid_until=dt.datetime.now(dt.timezone.utc) + dt.timedelta(minutes=1), 19 | ) 20 | 21 | def get_vehicles(self, token): 22 | return [] 23 | 24 | def refresh_vehicles(self, token, vehicles): 25 | return vehicles 26 | 27 | 28 | def test_check_and_refresh_token_handles_min_datetime(monkeypatch): 29 | dummy_api = DummyApi() 30 | monkeypatch.setattr( 31 | VehicleManager, 32 | "get_implementation_by_region_brand", 33 | lambda *args, **kwargs: dummy_api, 34 | ) 35 | manager = VehicleManager( 36 | region=3, 37 | brand=1, 38 | username="user", 39 | password="pass", 40 | pin="1234", 41 | geocode_api_enable=False, 42 | ) 43 | manager.token = Token(valid_until=dt.datetime.min) 44 | assert manager.check_and_refresh_token() is True 45 | assert dummy_api.login_calls == 1 46 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: shell 2 | 3 | ============ 4 | Installation 5 | ============ 6 | 7 | 8 | Stable release 9 | -------------- 10 | 11 | To install Hyundai / Kia Connect, run this command in your terminal: 12 | 13 | .. code-block:: console 14 | 15 | $ pip install hyundai_kia_connect_api 16 | 17 | This is the preferred method to install Hyundai / Kia Connect, as it will always install the most recent stable release. 18 | 19 | If you don't have `pip`_ installed, this `Python installation guide`_ can guide 20 | you through the process. 21 | 22 | .. _pip: https://pip.pypa.io 23 | .. _Python installation guide: http://docs.python-guide.org/en/latest/starting/installation/ 24 | 25 | 26 | From sources 27 | ------------ 28 | 29 | The sources for Hyundai / Kia Connect can be downloaded from the `Github repo`_. 30 | 31 | You can either clone the public repository: 32 | 33 | .. code-block:: console 34 | 35 | $ git clone git://github.com/Hyundai-Kia-Connect/hyundai_kia_connect_api 36 | 37 | Or download the `tarball`_: 38 | 39 | .. code-block:: console 40 | 41 | $ curl -OJL https://github.com/Hyundai-Kia-Connect/hyundai_kia_connect_api/tarball/master 42 | 43 | Once you have a copy of the source, you can install it with: 44 | 45 | .. code-block:: console 46 | 47 | $ python setup.py install 48 | 49 | 50 | .. _Github repo: https://github.com/Hyundai-Kia-Connect/hyundai_kia_connect_api 51 | .. _tarball: https://github.com/Hyundai-Kia-Connect/hyundai_kia_connect_api/tarball/master 52 | -------------------------------------------------------------------------------- /.github/workflows/pypi.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: PyPI Release 4 | 5 | # Controls when the workflow will run 6 | on: 7 | release: 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 13 | jobs: 14 | deploy: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v6 18 | - name: Set up Python 19 | uses: actions/setup-python@v6 20 | with: 21 | python-version: "3.x" 22 | - name: Install dependencies 23 | run: | 24 | python -m pip install --upgrade pip 25 | pip install build 26 | - name: Build package 27 | run: python -m build 28 | - name: Publish package to PyPi Test 29 | uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e 30 | with: 31 | user: __token__ 32 | password: ${{ secrets.TEST_PYPI_API_TOKEN }} 33 | repository_url: https://test.pypi.org/legacy/ 34 | - name: Publish package to PyPi Live 35 | uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e 36 | with: 37 | user: __token__ 38 | password: ${{ secrets.PYPI_API_TOKEN }} 39 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """The setup script.""" 4 | 5 | from setuptools import setup, find_packages 6 | 7 | with open("README.rst") as readme_file: 8 | readme = readme_file.read() 9 | 10 | with open("HISTORY.rst") as history_file: 11 | history = history_file.read() 12 | 13 | with open("requirements.txt") as f: 14 | requirements = f.read().splitlines() 15 | 16 | long_description = readme + "\n\n" + history 17 | long_description = readme 18 | 19 | test_requirements = [ 20 | "pytest>=3", 21 | ] 22 | 23 | setup( 24 | author="Fuat Akgun", 25 | author_email="fuatakgun@gmail.com", 26 | python_requires=">=3.10", 27 | classifiers=[ 28 | "Development Status :: 2 - Pre-Alpha", 29 | "Intended Audience :: Developers", 30 | "License :: OSI Approved :: MIT License", 31 | "Natural Language :: English", 32 | "Programming Language :: Python :: 3.10", 33 | ], 34 | description="Python Boilerplate contains all the boilerplate you need to create a Python package.", 35 | install_requires=requirements, 36 | extras_require={ 37 | "google": ["geopy>=2.2.0"], 38 | }, 39 | license="MIT license", 40 | long_description=long_description, 41 | include_package_data=True, 42 | keywords="hyundai_kia_connect_api", 43 | name="hyundai_kia_connect_api", 44 | packages=find_packages( 45 | include=["hyundai_kia_connect_api", "hyundai_kia_connect_api.*"] 46 | ), 47 | entry_points={ 48 | "console_scripts": ["bluelink = hyundai_kia_connect_api.bluelink:main"] 49 | }, 50 | test_suite="tests", 51 | tests_require=test_requirements, 52 | url="https://github.com/Hyundai-Kia-Connect/hyundai_kia_connect_api", 53 | version="3.51.5", 54 | zip_safe=False, 55 | ) 56 | -------------------------------------------------------------------------------- /hyundai_kia_connect_api/exceptions.py: -------------------------------------------------------------------------------- 1 | """exceptions.py""" 2 | 3 | # pylint:disable=unnecessary-pass 4 | 5 | 6 | class HyundaiKiaException(Exception): 7 | """ 8 | Generic hyundaiKiaException exception. 9 | """ 10 | 11 | pass 12 | 13 | 14 | class PINMissingError(HyundaiKiaException): 15 | """ 16 | Raised upon receipt of an authentication error. 17 | """ 18 | 19 | pass 20 | 21 | 22 | class AuthenticationError(HyundaiKiaException): 23 | """ 24 | Raised upon receipt of an authentication error. 25 | """ 26 | 27 | pass 28 | 29 | 30 | class APIError(HyundaiKiaException): 31 | """ 32 | Generic API error 33 | """ 34 | 35 | pass 36 | 37 | 38 | class DeviceIDError(APIError): 39 | """ 40 | Raised upon receipt of an Invalid Device ID error. 41 | """ 42 | 43 | pass 44 | 45 | 46 | class RateLimitingError(APIError): 47 | """ 48 | Raised when we get rate limited by the server 49 | """ 50 | 51 | pass 52 | 53 | 54 | class NoDataFound(APIError): 55 | """ 56 | Raised when the API doesn't have data for that car. 57 | Disabling the car is the solution. 58 | """ 59 | 60 | pass 61 | 62 | 63 | class ServiceTemporaryUnavailable(APIError): 64 | """ 65 | Raised when Service Temporary Unavailable 66 | """ 67 | 68 | pass 69 | 70 | 71 | class DuplicateRequestError(APIError): 72 | """ 73 | Raised when (supposedly) a previous request is already queued server-side 74 | and the server temporarily rejects requests. 75 | """ 76 | 77 | pass 78 | 79 | 80 | class RequestTimeoutError(APIError): 81 | """ 82 | Raised when (supposedly) the server fails to establish a connection with the car. 83 | """ 84 | 85 | pass 86 | 87 | 88 | class InvalidAPIResponseError(APIError): 89 | """ 90 | Raised upon receipt of an invalid API response. 91 | """ 92 | 93 | pass 94 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | ci: 3 | autoupdate_commit_msg: "chore: pre-commit autoupdate" 4 | repos: 5 | - repo: https://github.com/astral-sh/ruff-pre-commit 6 | rev: v0.14.9 7 | hooks: 8 | - id: ruff 9 | args: 10 | - --fix 11 | - id: ruff-format 12 | - repo: https://github.com/codespell-project/codespell 13 | rev: v2.4.1 14 | hooks: 15 | - id: codespell 16 | args: 17 | - --ignore-words-list=fro,hass,fatc 18 | - --skip="./.*,*.csv,*.json,*.ambr" 19 | - --quiet-level=2 20 | exclude_types: [csv, json] 21 | - repo: https://github.com/pre-commit/pre-commit-hooks 22 | rev: v6.0.0 23 | hooks: 24 | - id: trailing-whitespace 25 | - id: end-of-file-fixer 26 | - id: check-executables-have-shebangs 27 | stages: [manual] 28 | - id: check-json 29 | exclude: (.vscode|.devcontainer) 30 | - repo: https://github.com/asottile/pyupgrade 31 | rev: v3.21.2 32 | hooks: 33 | - id: pyupgrade 34 | - repo: https://github.com/adrienverge/yamllint.git 35 | rev: v1.37.1 36 | hooks: 37 | - id: yamllint 38 | exclude: (.github|.vscode|.devcontainer) 39 | - repo: https://github.com/pre-commit/mirrors-prettier 40 | rev: v4.0.0-alpha.8 41 | hooks: 42 | - id: prettier 43 | - repo: https://github.com/cdce8p/python-typing-update 44 | rev: v0.8.1 45 | hooks: 46 | # Run `python-typing-update` hook manually from time to time 47 | # to update python typing syntax. 48 | # Will require manual work, before submitting changes! 49 | # pre-commit run --hook-stage manual python-typing-update --all-files 50 | - id: python-typing-update 51 | stages: [manual] 52 | args: 53 | - --py311-plus 54 | - --force 55 | - --keep-updates 56 | files: ^(/.+)?[^/]+\.py$ 57 | - repo: https://github.com/pre-commit/mirrors-mypy 58 | rev: v1.19.1 59 | hooks: 60 | - id: mypy 61 | args: [--strict, --ignore-missing-imports] 62 | files: ^(/.+)?[^/]+\.py$ 63 | -------------------------------------------------------------------------------- /.github/workflows/python-tests.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Test Builds 5 | 6 | on: 7 | pull_request_target: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | name: "test py${{matrix.python-version}}" 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | python-version: ["3.12"] 19 | 20 | steps: 21 | - uses: actions/checkout@v6 22 | with: 23 | ref: ${{github.event.pull_request.head.ref}} 24 | repository: ${{github.event.pull_request.head.repo.full_name}} 25 | - name: Set up Python ${{ matrix.python-version }} 26 | uses: actions/setup-python@v6 27 | with: 28 | python-version: ${{ matrix.python-version }} 29 | - name: Install dependencies 30 | run: | 31 | python -m pip install --upgrade pip 32 | python -m pip install flake8 pytest 33 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 34 | - name: Lint with flake8 35 | run: | 36 | # stop the build if there are Python syntax errors or undefined names 37 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 38 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 39 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 40 | - name: Test with pytest 41 | env: 42 | KIA_CA_CDNNINJA_USERNAME: ${{ secrets.KIA_CA_CDNNINJA_USERNAME }} 43 | KIA_CA_CDNNINJA_PASSWORD: ${{ secrets.KIA_CA_CDNNINJA_PASSWORD }} 44 | KIA_CA_CDNNINJA_PIN: ${{ secrets.KIA_CA_CDNNINJA_PIN }} 45 | KIA_EU_FUATAKGUN_USERNAME: ${{ secrets.KIA_EU_FUATAKGUN_USERNAME }} 46 | KIA_EU_FUATAKGUN_PASSWORD: ${{ secrets.KIA_EU_FUATAKGUN_PASSWORD }} 47 | run: | 48 | pytest 49 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean clean-build clean-pyc clean-test coverage dist docs help install lint lint/flake8 lint/black 2 | .DEFAULT_GOAL := help 3 | 4 | define BROWSER_PYSCRIPT 5 | import os, webbrowser, sys 6 | 7 | from urllib.request import pathname2url 8 | 9 | webbrowser.open("file://" + pathname2url(os.path.abspath(sys.argv[1]))) 10 | endef 11 | export BROWSER_PYSCRIPT 12 | 13 | define PRINT_HELP_PYSCRIPT 14 | import re, sys 15 | 16 | for line in sys.stdin: 17 | match = re.match(r'^([a-zA-Z_-]+):.*?## (.*)$$', line) 18 | if match: 19 | target, help = match.groups() 20 | print("%-20s %s" % (target, help)) 21 | endef 22 | export PRINT_HELP_PYSCRIPT 23 | 24 | BROWSER := python -c "$$BROWSER_PYSCRIPT" 25 | 26 | help: 27 | @python -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST) 28 | 29 | clean: clean-build clean-pyc clean-test ## remove all build, test, coverage and Python artifacts 30 | 31 | clean-build: ## remove build artifacts 32 | rm -fr build/ 33 | rm -fr dist/ 34 | rm -fr .eggs/ 35 | find . -name '*.egg-info' -exec rm -fr {} + 36 | find . -name '*.egg' -exec rm -f {} + 37 | 38 | clean-pyc: ## remove Python file artifacts 39 | find . -name '*.pyc' -exec rm -f {} + 40 | find . -name '*.pyo' -exec rm -f {} + 41 | find . -name '*~' -exec rm -f {} + 42 | find . -name '__pycache__' -exec rm -fr {} + 43 | 44 | clean-test: ## remove test and coverage artifacts 45 | rm -f .coverage 46 | rm -fr htmlcov/ 47 | rm -fr .pytest_cache 48 | 49 | lint/flake8: ## check style with flake8 50 | flake8 hyundai_kia_connect_api tests 51 | lint/black: ## check style with black 52 | black --check hyundai_kia_connect_api tests 53 | 54 | lint: lint/flake8 lint/black ## check style 55 | 56 | test: ## run tests quickly with the default Python 57 | pytest 58 | 59 | coverage: ## check code coverage quickly with the default Python 60 | coverage run --source hyundai_kia_connect_api -m pytest 61 | coverage report -m 62 | coverage html 63 | $(BROWSER) htmlcov/index.html 64 | 65 | docs: ## generate Sphinx HTML documentation, including API docs 66 | rm -f docs/hyundai_kia_connect_api.rst 67 | rm -f docs/modules.rst 68 | sphinx-apidoc -o docs/ hyundai_kia_connect_api 69 | $(MAKE) -C docs clean 70 | $(MAKE) -C docs html 71 | $(BROWSER) docs/_build/html/index.html 72 | 73 | servedocs: docs ## compile the docs watching for changes 74 | watchmedo shell-command -p '*.rst' -c '$(MAKE) -C docs html' -R -D . 75 | 76 | release: dist ## package and upload a release 77 | twine upload dist/* 78 | 79 | dist: clean ## builds source and wheel package 80 | python setup.py sdist 81 | python setup.py bdist_wheel 82 | ls -l dist 83 | 84 | install: clean ## install the package to the active Python's site-packages 85 | python setup.py install 86 | -------------------------------------------------------------------------------- /hyundai_kia_connect_api/const.py: -------------------------------------------------------------------------------- 1 | """const.py""" 2 | 3 | # pylint:disable=invalid-name,missing-class-docstring 4 | 5 | import datetime 6 | from enum import Enum, IntEnum 7 | 8 | DOMAIN: str = "hyundai_kia_connect_api" 9 | 10 | BRAND_KIA = "Kia" 11 | BRAND_HYUNDAI = "Hyundai" 12 | BRAND_GENESIS = "Genesis" 13 | BRANDS = {1: BRAND_KIA, 2: BRAND_HYUNDAI, 3: BRAND_GENESIS} 14 | 15 | GOOGLE = "google" 16 | OPENSTREETMAP = "openstreetmap" 17 | GEO_LOCATION_PROVIDERS = {1: OPENSTREETMAP, 2: GOOGLE} 18 | 19 | REGION_EUROPE = "Europe" 20 | REGION_CANADA = "Canada" 21 | REGION_USA = "USA" 22 | REGION_CHINA = "China" 23 | REGION_AUSTRALIA = "Australia" 24 | REGION_NZ = "New Zealand" 25 | REGION_INDIA = "India" 26 | REGION_BRAZIL = "Brazil" 27 | 28 | REGIONS = { 29 | 1: REGION_EUROPE, 30 | 2: REGION_CANADA, 31 | 3: REGION_USA, 32 | 4: REGION_CHINA, 33 | 5: REGION_AUSTRALIA, 34 | 6: REGION_INDIA, 35 | 7: REGION_NZ, 36 | 8: REGION_BRAZIL, 37 | } 38 | 39 | LOGIN_TOKEN_LIFETIME = datetime.timedelta(hours=23) 40 | 41 | LENGTH_KILOMETERS = "km" 42 | LENGTH_MILES = "mi" 43 | DISTANCE_UNITS = { 44 | None: None, 45 | 0: None, 46 | 1: LENGTH_KILOMETERS, 47 | 2: LENGTH_MILES, 48 | 3: LENGTH_MILES, 49 | } 50 | 51 | TEMPERATURE_C = "°C" 52 | TEMPERATURE_F = "°F" 53 | TEMPERATURE_UNITS = {None: None, 0: TEMPERATURE_C, 1: TEMPERATURE_F} 54 | 55 | SEAT_STATUS = { 56 | None: None, 57 | 0: "Off", 58 | 1: "On", 59 | 2: "Off", 60 | 3: "Low Cool", 61 | 4: "Medium Cool", 62 | 5: "High Cool", 63 | 6: "Low Heat", 64 | 7: "Medium Heat", 65 | 8: "High Heat", 66 | } 67 | 68 | HEAT_STATUS = { 69 | None: None, 70 | 0: "Off", 71 | 1: "Steering Wheel and Rear Window", 72 | 2: "Rear Window", 73 | 3: "Steering Wheel", 74 | # Seems to be the same as 1 but different region (EU): 75 | 4: "Steering Wheel and Rear Window", 76 | } 77 | 78 | 79 | class ENGINE_TYPES(Enum): 80 | ICE = "ICE" 81 | EV = "EV" 82 | PHEV = "PHEV" 83 | HEV = "HEV" 84 | 85 | 86 | class VEHICLE_LOCK_ACTION(Enum): 87 | LOCK = "close" 88 | UNLOCK = "open" 89 | 90 | 91 | class CHARGE_PORT_ACTION(Enum): 92 | CLOSE = "close" 93 | OPEN = "open" 94 | 95 | 96 | class ORDER_STATUS(Enum): 97 | # pending (waiting for response from vehicle) 98 | PENDING = "PENDING" 99 | # order executed by vehicle and response returned 100 | SUCCESS = "SUCCESS" 101 | # order refused by vehicle and response returned 102 | FAILED = "FAILED" 103 | # no response received from vehicle. 104 | # no way to know if the order was executed, but most likely not 105 | TIMEOUT = "TIMEOUT" 106 | # Used when we don't know the status of the order 107 | UNKNOWN = "UNKNOWN" 108 | 109 | 110 | class WINDOW_STATE(IntEnum): 111 | CLOSED = 0 112 | OPEN = 1 113 | VENTILATION = 2 114 | 115 | 116 | class VALET_MODE_ACTION(Enum): 117 | ACTIVATE = "activate" 118 | DEACTIVATE = "deactivate" 119 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | # Controls when the workflow will run 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: "0 8 * * *" 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v6 12 | - name: Gets semantic release info 13 | id: semantic_release_info 14 | uses: jossef/action-semantic-release-info@v3.0.0 15 | env: 16 | GITHUB_TOKEN: ${{ github.token }} 17 | - name: Update Version and Commit 18 | if: ${{steps.semantic_release_info.outputs.version != ''}} 19 | run: | 20 | echo "Version: ${{steps.semantic_release_info.outputs.version}}" 21 | sed -i "s/version=\".*\",/version=\"${{steps.semantic_release_info.outputs.version}}\",/g" setup.py 22 | git config --local user.email "action@github.com" 23 | git config --local user.name "GitHub Action" 24 | git add -A 25 | git commit -m "chore: bumping version to ${{steps.semantic_release_info.outputs.version}}" 26 | git tag ${{ steps.semantic_release_info.outputs.git_tag }} 27 | - name: Push changes 28 | if: ${{steps.semantic_release_info.outputs.version != ''}} 29 | uses: ad-m/github-push-action@v1.0.0 30 | with: 31 | github_token: ${{ github.token }} 32 | tags: true 33 | - name: Create GitHub Release 34 | if: ${{steps.semantic_release_info.outputs.version != ''}} 35 | uses: actions/create-release@v1 36 | env: 37 | GITHUB_TOKEN: ${{ github.token }} 38 | with: 39 | tag_name: ${{ steps.semantic_release_info.outputs.git_tag }} 40 | release_name: ${{ steps.semantic_release_info.outputs.git_tag }} 41 | body: ${{ steps.semantic_release_info.outputs.notes }} 42 | draft: false 43 | prerelease: false 44 | - name: Install dependencies 45 | if: ${{steps.semantic_release_info.outputs.version != ''}} 46 | run: | 47 | python -m pip install --upgrade pip 48 | pip install build 49 | - name: Build package 50 | if: ${{steps.semantic_release_info.outputs.version != ''}} 51 | run: python -m build 52 | - name: Publish package to PyPi Test 53 | if: ${{steps.semantic_release_info.outputs.version != ''}} 54 | uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e 55 | with: 56 | user: __token__ 57 | password: ${{ secrets.TEST_PYPI_API_TOKEN }} 58 | repository_url: https://test.pypi.org/legacy/ 59 | verbose: true 60 | - name: Publish package to PyPi Live 61 | if: ${{steps.semantic_release_info.outputs.version != ''}} 62 | uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e 63 | with: 64 | user: __token__ 65 | password: ${{ secrets.PYPI_API_TOKEN }} 66 | verbose: true 67 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: ["master"] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: ["master"] 20 | schedule: 21 | - cron: "28 14 * * 1" 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: ["python"] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Use only 'java' to analyze code written in Java, Kotlin or both 38 | # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both 39 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 40 | 41 | steps: 42 | - name: Checkout repository 43 | uses: actions/checkout@v6 44 | 45 | # Initializes the CodeQL tools for scanning. 46 | - name: Initialize CodeQL 47 | uses: github/codeql-action/init@v4 48 | with: 49 | languages: ${{ matrix.language }} 50 | # If you wish to specify custom queries, you can do so here or in a config file. 51 | # By default, queries listed here will override any specified in a config file. 52 | # Prefix the list here with "+" to use these queries and those in the config file. 53 | 54 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 55 | # queries: security-extended,security-and-quality 56 | 57 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). 58 | # If this step fails, then you should remove it and run the build manually (see below) 59 | - name: Autobuild 60 | uses: github/codeql-action/autobuild@v4 61 | 62 | # ℹ️ Command-line programs to run using the OS shell. 63 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 64 | 65 | # If the Autobuild fails above, remove it and uncomment the following three lines. 66 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 67 | 68 | # - run: | 69 | # echo "Run, Build Application using script" 70 | # ./location_of_script_within_repo/buildscript.sh 71 | 72 | - name: Perform CodeQL Analysis 73 | uses: github/codeql-action/analyze@v4 74 | with: 75 | category: "/language:${{matrix.language}}" 76 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: shell 2 | 3 | ============ 4 | Contributing 5 | ============ 6 | 7 | Contributions are welcome, and they are greatly appreciated! Every little bit 8 | helps, and credit will always be given. 9 | 10 | You can contribute in many ways: 11 | 12 | Types of Contributions 13 | ---------------------- 14 | 15 | Report Bugs 16 | ~~~~~~~~~~~ 17 | 18 | Report bugs at https://github.com/Hyundai-Kia-Connect/hyundai_kia_connect_api/issues. 19 | 20 | If you are reporting a bug, please include: 21 | 22 | * Your operating system name and version. 23 | * Any details about your local setup that might be helpful in troubleshooting. 24 | * Detailed steps to reproduce the bug. 25 | 26 | Fix Bugs 27 | ~~~~~~~~ 28 | 29 | Look through the GitHub issues for bugs. Anything tagged with "bug" and "help 30 | wanted" is open to whoever wants to implement it. 31 | 32 | Implement Features 33 | ~~~~~~~~~~~~~~~~~~ 34 | 35 | Look through the GitHub issues for features. Anything tagged with "enhancement" 36 | and "help wanted" is open to whoever wants to implement it. 37 | 38 | Write Documentation 39 | ~~~~~~~~~~~~~~~~~~~ 40 | 41 | Hyundai / Kia Connect could always use more documentation, whether as part of the 42 | official Hyundai / Kia Connect docs, in docstrings, or even on the web in blog posts, 43 | articles, and such. 44 | 45 | Submit Feedback 46 | ~~~~~~~~~~~~~~~ 47 | 48 | The best way to send feedback is to file an issue at https://github.com/Hyundai-Kia-Connect/hyundai_kia_connect_api/issues. 49 | 50 | If you are proposing a feature: 51 | 52 | * Explain in detail how it would work. 53 | * Keep the scope as narrow as possible, to make it easier to implement. 54 | * Remember that this is a volunteer-driven project, and that contributions 55 | are welcome :) 56 | 57 | Get Started! 58 | ------------ 59 | 60 | Ready to contribute? Here's how to set up `hyundai_kia_connect_api` for local development. 61 | 62 | 1. Fork the `hyundai_kia_connect_api` repo on GitHub. 63 | 2. Clone your fork locally:: 64 | 65 | $ git clone git@github.com:your_name_here/hyundai_kia_connect_api.git 66 | 67 | 3. Install your local copy into a virtualenv. Assuming you have virtualenvwrapper installed, this is how you set up your fork for local development:: 68 | 69 | $ mkvirtualenv hyundai_kia_connect_api 70 | $ cd hyundai_kia_connect_api/ 71 | $ python setup.py develop 72 | 73 | 4. Create a branch for local development:: 74 | 75 | $ git checkout -b name-of-your-bugfix-or-feature 76 | 77 | Now you can make your changes locally. 78 | 79 | 5. When you're done making changes, check that your changes pass flake8. Most 80 | tests require environment variables to supply the username and password and 81 | they will run as part of the PR pre-requisites: 82 | 83 | $ flake8 hyundai_kia_connect_api tests 84 | $ python setup.py test or pytest 85 | 86 | To get flake8, pip install them into your virtualenv. 87 | 88 | 6. Commit your changes and push your branch to GitHub:: 89 | 90 | $ git add . 91 | $ git commit -m "Your detailed description of your changes." 92 | $ git push origin name-of-your-bugfix-or-feature 93 | 94 | 7. Submit a pull request through the GitHub website. 95 | 96 | Pull Request Guidelines 97 | ----------------------- 98 | 99 | Before you submit a pull request, check that it meets these guidelines: 100 | 101 | 1. The pull request should include tests. 102 | 2. If the pull request adds functionality, the docs should be updated. Put 103 | your new functionality into a function with a docstring, and add the 104 | feature to the list in README.rst. 105 | 3. The pull request should work for recent Python. Check GitHub Actions checks 106 | and make sure that the tests pass for all supported Python versions. 107 | 108 | Tips 109 | ---- 110 | 111 | To run a subset of tests:: 112 | 113 | $ pytest tests.test_hyundai_kia_connect_api 114 | 115 | 116 | Deploying 117 | --------- 118 | 119 | A reminder for the maintainers on how to deploy. 120 | Make sure all your changes are committed (including an entry in HISTORY.rst). 121 | Then run:: 122 | 123 | $ bump2version patch # possible: major / minor / patch 124 | $ git push 125 | $ git push --tags 126 | 127 | Travis will then deploy to PyPI if tests pass. 128 | -------------------------------------------------------------------------------- /OTP_RMTOKEN_IMPLEMENTATION.md: -------------------------------------------------------------------------------- 1 | # OTP and rmtoken Implementation for Kia USA API 2 | 3 | ## Summary 4 | 5 | Implemented persistent authentication for Kia USA API to reduce OTP prompts in Home Assistant integration. 6 | 7 | ## Problem 8 | 9 | The Kia USA API now requires OTP (One-Time Password) authentication. Previously, every time the session expired, users had to enter a new OTP code, making it impractical for automated systems like Home Assistant. 10 | 11 | ## Solution 12 | 13 | Store and reuse the `rmtoken` (remember token) returned during OTP verification to avoid repeated OTP prompts. 14 | 15 | ## Changes Made 16 | 17 | ### 1. KiaUvoApiUSA.py 18 | 19 | #### Modified `login()` method signature: 20 | 21 | ```python 22 | def login(self, username: str, password: str, token: Token = None) -> Token: 23 | ``` 24 | 25 | #### Key features: 26 | 27 | - Accepts optional `token` parameter with stored `rmtoken` 28 | - If `token.refresh_token` (rmtoken) exists, includes it in the auth request headers 29 | - Stores `rmtoken` in `Token.refresh_token` field after OTP verification 30 | - Detects `rmTokenExpired` in API response and falls back to OTP flow 31 | - Maintains backward compatibility (token parameter is optional) 32 | 33 | #### Modified `request_with_active_session` decorator: 34 | 35 | - Passes existing token when calling `login()` for session repair 36 | - Updates `token.refresh_token` along with `access_token` and `valid_until` 37 | 38 | ### 2. VehicleManager.py 39 | 40 | #### Added import: 41 | 42 | ```python 43 | import inspect as insp 44 | ``` 45 | 46 | #### Modified `initialize()` and `check_and_refresh_token()`: 47 | 48 | - Uses `inspect.signature()` to check if API's `login()` method accepts `token` parameter 49 | - Passes token only if supported (USA API), otherwise uses old signature 50 | - **This approach ensures compatibility with all other regions WITHOUT modifying them** 51 | - Other region APIs (CA, EU, AU, CN, IN, BR, Hyundai USA) remain unchanged 52 | 53 | ### 3. tests/us_login_test.py 54 | 55 | #### Updated test to verify rmtoken persistence: 56 | 57 | - First login prompts for OTP and stores rmtoken 58 | - Forces token expiration by setting `valid_until` to past time 59 | - Second login should use stored rmtoken without OTP prompt 60 | - Verifies rmtoken is preserved across logins 61 | 62 | ## Authentication Flow 63 | 64 | ### First Login (OTP Required): 65 | 66 | 1. POST `/prof/authUser` → Returns `otpKey` in payload 67 | 2. User chooses email or phone for OTP delivery 68 | 3. POST `/cmm/sendOTP` → Sends OTP code 69 | 4. User enters OTP code 70 | 5. POST `/cmm/verifyOTP` → Returns `sid` and `rmtoken` in headers 71 | 6. POST `/prof/authUser` with `sid` and `rmtoken` → Returns final `sid` 72 | 7. Store both `sid` (as `access_token`) and `rmtoken` (as `refresh_token`) 73 | 74 | ### Subsequent Logins (rmtoken Reuse): 75 | 76 | 1. POST `/prof/authUser` with `rmtoken` in headers → Returns `sid` directly 77 | 2. No OTP prompt needed! 78 | 79 | ### When rmtoken Expires: 80 | 81 | 1. POST `/prof/authUser` with expired `rmtoken` → Returns `otpKey` and `"rmTokenExpired": true` 82 | 2. Falls back to full OTP flow 83 | 3. Gets new `rmtoken` 84 | 85 | ## Benefits for Home Assistant 86 | 87 | 1. **Reduced OTP prompts**: Users only need to enter OTP when rmtoken expires 88 | 2. **Better UX**: Integration works more seamlessly without constant user intervention 89 | 3. **Backward compatible**: Works with accounts that don't have OTP enabled 90 | 4. **No changes to other regions**: EU, CA, AU, etc. continue to work as before 91 | 92 | ## Testing 93 | 94 | Run the test: 95 | 96 | ```bash 97 | source .venv/bin/activate 98 | pytest tests/us_login_test.py -v -s 99 | ``` 100 | 101 | Expected behavior: 102 | 103 | 1. First login prompts for OTP (email or phone choice, then OTP code entry) 104 | 2. Test forces token expiration 105 | 3. Second login uses stored rmtoken without OTP prompt 106 | 4. Test passes if both logins succeed 107 | 108 | ## Notes 109 | 110 | - The `rmtoken` lifetime is not documented but appears to be longer than the session token 111 | - When `rmtoken` expires, the API returns `"rmTokenExpired": true` in the payload 112 | - The implementation gracefully falls back to OTP flow when rmtoken is invalid/expired 113 | - Home Assistant integration should persist the `Token.refresh_token` field to storage 114 | -------------------------------------------------------------------------------- /tests/us_login_test.py: -------------------------------------------------------------------------------- 1 | ''' 2 | import os 3 | import datetime as dt 4 | import time as tm 5 | import pytest 6 | import hyundai_kia_connect_api as hk 7 | 8 | 9 | @pytest.fixture 10 | def us_credentials() -> dict[str, str]: 11 | return { 12 | "username": os.getenv("KIA_US_USERNAME"), 13 | "password": os.getenv("KIA_US_PASSWORD"), 14 | "pin": os.getenv("KIA_US_PIN"), 15 | } 16 | 17 | 18 | def test_us_login(us_credentials: dict[str, str]) -> None: 19 | """Verify OTP login and rmtoken reuse in a single process. 20 | 21 | Parameters 22 | ---------- 23 | us_credentials : dict[str, str] 24 | Credentials for a US Kia account. 25 | """ 26 | vehicle_manager = hk.VehicleManager( 27 | region=3, 28 | brand=1, 29 | username=us_credentials["username"], 30 | password=us_credentials["password"], 31 | pin=us_credentials["pin"], 32 | geocode_api_enable=False, 33 | ) 34 | print("\n=== Initial Login (will prompt for OTP) ===") 35 | vehicle_manager.check_and_refresh_token() 36 | vehicle_manager.check_and_force_update_vehicles(force_refresh_interval=600) 37 | assert len(vehicle_manager.vehicles.keys()) > 0 38 | vehicle_name = list(vehicle_manager.vehicles.values())[0].name 39 | print(f"\nFound: {vehicle_name}") 40 | print(f"Initial token (sid): {vehicle_manager.token.access_token[:20]}...") 41 | initial_rmtoken = vehicle_manager.token.refresh_token 42 | if initial_rmtoken: 43 | print(f"rmtoken stored: {initial_rmtoken[:20]}...") 44 | else: 45 | print("WARNING: No rmtoken stored!") 46 | print("\n=== Testing rmtoken reuse by forcing token expiration ===") 47 | vehicle_manager.token.valid_until = dt.datetime.now(dt.timezone.utc) - dt.timedelta(seconds=60) 48 | print("Token expired manually") 49 | print("\n=== Second Login (should use rmtoken, no OTP prompt) ===") 50 | vehicle_manager.check_and_refresh_token() 51 | vehicle_manager.check_and_force_update_vehicles(force_refresh_interval=600) 52 | print(f"SUCCESS: Re-authenticated using stored rmtoken") 53 | print(f"New token (sid): {vehicle_manager.token.access_token[:20]}...") 54 | if vehicle_manager.token.refresh_token: 55 | print(f"rmtoken still stored: {vehicle_manager.token.refresh_token[:20]}...") 56 | vehicle_name_after = list(vehicle_manager.vehicles.values())[0].name 57 | print(f"Vehicle: {vehicle_name_after}") 58 | assert len(vehicle_manager.vehicles.keys()) > 0 59 | 60 | 61 | def test_rmtoken_expiration_in_5_minutes(us_credentials: dict[str, str]) -> None: 62 | """Check whether rmtoken remains valid after 5 minutes. 63 | 64 | Parameters 65 | ---------- 66 | us_credentials : dict[str, str] 67 | Credentials for a US Kia account. 68 | """ 69 | vehicle_manager = hk.VehicleManager( 70 | region=3, 71 | brand=1, 72 | username=us_credentials["username"], 73 | password=us_credentials["password"], 74 | pin=us_credentials["pin"], 75 | geocode_api_enable=False, 76 | ) 77 | print("\n=== First Login (OTP expected) ===") 78 | vehicle_manager.check_and_refresh_token() 79 | assert len(vehicle_manager.vehicles.keys()) > 0 80 | initial_rmtoken = vehicle_manager.token.refresh_token 81 | if not initial_rmtoken: 82 | pytest.skip("No rmtoken stored; cannot evaluate rmtoken expiry") 83 | print("rmtoken stored:", initial_rmtoken[:20] + "...") 84 | 85 | print("\n=== Waiting 5 minutes to evaluate rmtoken expiry ===") 86 | for i in range(5): 87 | tm.sleep(60) 88 | print(f" {i+1} minute(s) elapsed...") 89 | 90 | print("\n=== Expiring session (sid) to force re-login using rmtoken ===") 91 | vehicle_manager.token.valid_until = dt.datetime.now(dt.timezone.utc) - dt.timedelta(seconds=1) 92 | sid_before = vehicle_manager.token.access_token 93 | rmtoken_before = vehicle_manager.token.refresh_token 94 | 95 | print("\n=== Attempting login using stored rmtoken ===") 96 | vehicle_manager.check_and_refresh_token() 97 | assert len(vehicle_manager.vehicles.keys()) > 0 98 | sid_after = vehicle_manager.token.access_token 99 | rmtoken_after = vehicle_manager.token.refresh_token 100 | 101 | if rmtoken_after == rmtoken_before: 102 | print("RESULT: rmtoken appears valid after 5 minutes (no rotation observed)") 103 | else: 104 | print("RESULT: rmtoken appears to have rotated (likely expired or refreshed)") 105 | 106 | if sid_after != sid_before: 107 | print("New session was established successfully") 108 | 109 | vehicle_manager.check_and_force_update_vehicles(force_refresh_interval=600) 110 | ''' 111 | -------------------------------------------------------------------------------- /hyundai_kia_connect_api/utils.py: -------------------------------------------------------------------------------- 1 | # pylint:disable=bare-except,missing-function-docstring,invalid-name,broad-exception-caught 2 | """utils.py""" 3 | 4 | import datetime 5 | import re 6 | from typing import Optional 7 | 8 | 9 | def get_child_value(data, key): 10 | value = data 11 | for x in key.split("."): 12 | try: 13 | value = value[x] 14 | except Exception: 15 | try: 16 | value = value[int(x)] 17 | except Exception: 18 | value = None 19 | return value 20 | 21 | 22 | def get_float(value): 23 | if value is None: 24 | return None 25 | if isinstance(value, float): 26 | return value 27 | if isinstance(value, int): 28 | return float(value) 29 | if isinstance(value, str): 30 | try: 31 | return float(value) 32 | except ValueError: 33 | return value # original fallback 34 | return value # original fallback 35 | 36 | 37 | def get_hex_temp_into_index(value): 38 | if value is not None: 39 | value = value.replace("H", "") 40 | value = int(value, 16) 41 | return value 42 | else: 43 | return None 44 | 45 | 46 | def get_index_into_hex_temp(value): 47 | if value is not None: 48 | value = hex(value).split("x") 49 | value = value[1] + "H" 50 | value = value.zfill(3).upper() 51 | return value 52 | else: 53 | return None 54 | 55 | 56 | def parse_datetime(value, timezone) -> datetime.datetime: 57 | if value is None: 58 | return datetime.datetime(2000, 1, 1, tzinfo=timezone) 59 | 60 | # Try parsing the new format: Tue, 24 Jun 2025 16:18:10 GMT 61 | try: 62 | dt_object = datetime.datetime.strptime(value, "%a, %d %b %Y %H:%M:%S GMT") 63 | 64 | if timezone: 65 | # First, make it aware of UTC since 'GMT' implies UTC 66 | utc_dt = dt_object.replace(tzinfo=datetime.timezone.utc) 67 | # Then convert to the target timezone 68 | return utc_dt.astimezone(timezone) 69 | else: 70 | return dt_object 71 | except ValueError: 72 | # If the new format parsing fails, try the old format 73 | value = ( 74 | value.replace("-", "").replace("T", "").replace(":", "").replace("Z", "") 75 | ) 76 | m = re.match(r"(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})", value) 77 | if m: 78 | return datetime.datetime( 79 | year=int(m.group(1)), 80 | month=int(m.group(2)), 81 | day=int(m.group(3)), 82 | hour=int(m.group(4)), 83 | minute=int(m.group(5)), 84 | second=int(m.group(6)), 85 | tzinfo=timezone, 86 | ) 87 | else: 88 | raise ValueError(f"Unable to parse datetime value: {value}") 89 | 90 | 91 | def get_safe_local_datetime(date: datetime) -> datetime: 92 | """get safe local datetime""" 93 | if date is not None and hasattr(date, "tzinfo") and date.tzinfo is not None: 94 | date = date.astimezone() 95 | return date 96 | 97 | 98 | def detect_timezone_for_date( 99 | date: datetime.datetime, 100 | ref_date: datetime.datetime, 101 | timezones: list[datetime.timezone], 102 | ) -> Optional[datetime.timezone]: 103 | """ 104 | Guess an appropriate timezone given a date with an unknown timezone and a 105 | nearby reference time in any valid timezone. 106 | """ 107 | for tz in timezones: 108 | delta = (ref_date - date.replace(tzinfo=tz)).total_seconds() 109 | # Timezones differing by half an hour exist, 20 minutes should hopefully 110 | # be enough to cover clock skew and API processing times. 111 | if abs(delta) < 20 * 60: 112 | return tz 113 | return None 114 | 115 | 116 | def parse_date_br(date_string: str, tz: datetime.timezone) -> datetime.datetime: 117 | """Parse date string in format YYYYMMDD or YYYYMMDDHHMMSS to datetime. 118 | 119 | Used by Brazilian API to parse date strings. 120 | 121 | Args: 122 | date_string: Date string in format YYYYMMDD or YYYYMMDDHHMMSS 123 | tz: Timezone to apply to the parsed datetime 124 | 125 | Returns: 126 | Parsed datetime object with timezone, or None if parsing fails 127 | """ 128 | if not date_string: 129 | return None 130 | 131 | # Try full datetime format first (YYYYMMDDHHMMSS) 132 | if len(date_string) >= 14: 133 | m = re.match(r"(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})", date_string) 134 | if m: 135 | return datetime.datetime( 136 | year=int(m.group(1)), 137 | month=int(m.group(2)), 138 | day=int(m.group(3)), 139 | hour=int(m.group(4)), 140 | minute=int(m.group(5)), 141 | second=int(m.group(6)), 142 | tzinfo=tz, 143 | ) 144 | 145 | # Try date only format (YYYYMMDD) 146 | if len(date_string) >= 8: 147 | m = re.match(r"(\d{4})(\d{2})(\d{2})", date_string) 148 | if m: 149 | return datetime.datetime( 150 | year=int(m.group(1)), 151 | month=int(m.group(2)), 152 | day=int(m.group(3)), 153 | tzinfo=tz, 154 | ) 155 | 156 | return None 157 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # hyundai_kia_connect_api documentation build configuration file, created by 4 | # sphinx-quickstart on Fri Jun 9 13:47:02 2017. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | # If extensions (or modules to document with autodoc) are in another 16 | # directory, add these directories to sys.path here. If the directory is 17 | # relative to the documentation root, use os.path.abspath to make it 18 | # absolute, like shown here. 19 | # 20 | import os 21 | import sys 22 | 23 | sys.path.insert(0, os.path.abspath("..")) 24 | 25 | import hyundai_kia_connect_api 26 | 27 | # -- General configuration --------------------------------------------- 28 | 29 | # If your documentation needs a minimal Sphinx version, state it here. 30 | # 31 | # needs_sphinx = '1.0' 32 | 33 | # Add any Sphinx extension module names here, as strings. They can be 34 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 35 | extensions = ["sphinx.ext.autodoc", "sphinx.ext.viewcode"] 36 | 37 | # Add any paths that contain templates here, relative to this directory. 38 | templates_path = ["_templates"] 39 | 40 | # The suffix(es) of source filenames. 41 | # You can specify multiple suffix as a list of string: 42 | # 43 | # source_suffix = ['.rst', '.md'] 44 | source_suffix = ".rst" 45 | 46 | # The master toctree document. 47 | master_doc = "index" 48 | 49 | # General information about the project. 50 | project = "Hyundai / Kia Connect" 51 | copyright = "2021, Fuat Akgun" 52 | author = "Fuat Akgun" 53 | 54 | # The version info for the project you're documenting, acts as replacement 55 | # for |version| and |release|, also used in various other places throughout 56 | # the built documents. 57 | # 58 | # The short X.Y version. 59 | version = hyundai_kia_connect_api.__version__ 60 | # The full version, including alpha/beta/rc tags. 61 | release = hyundai_kia_connect_api.__version__ 62 | 63 | # The language for content autogenerated by Sphinx. Refer to documentation 64 | # for a list of supported languages. 65 | # 66 | # This is also used if you do content translation via gettext catalogs. 67 | # Usually you set "language" from the command line for these cases. 68 | language = None 69 | 70 | # List of patterns, relative to source directory, that match files and 71 | # directories to ignore when looking for source files. 72 | # This patterns also effect to html_static_path and html_extra_path 73 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 74 | 75 | # The name of the Pygments (syntax highlighting) style to use. 76 | pygments_style = "sphinx" 77 | 78 | # If true, `todo` and `todoList` produce output, else they produce nothing. 79 | todo_include_todos = False 80 | 81 | 82 | # -- Options for HTML output ------------------------------------------- 83 | 84 | # The theme to use for HTML and HTML Help pages. See the documentation for 85 | # a list of builtin themes. 86 | # 87 | html_theme = "alabaster" 88 | 89 | # Theme options are theme-specific and customize the look and feel of a 90 | # theme further. For a list of options available for each theme, see the 91 | # documentation. 92 | # 93 | # html_theme_options = {} 94 | 95 | # Add any paths that contain custom static files (such as style sheets) here, 96 | # relative to this directory. They are copied after the builtin static files, 97 | # so a file named "default.css" will overwrite the builtin "default.css". 98 | html_static_path = ["_static"] 99 | 100 | 101 | # -- Options for HTMLHelp output --------------------------------------- 102 | 103 | # Output file base name for HTML help builder. 104 | htmlhelp_basename = "hyundai_kia_connect_apidoc" 105 | 106 | 107 | # -- Options for LaTeX output ------------------------------------------ 108 | 109 | latex_elements = { 110 | # The paper size ('letterpaper' or 'a4paper'). 111 | # 112 | # 'papersize': 'letterpaper', 113 | # The font size ('10pt', '11pt' or '12pt'). 114 | # 115 | # 'pointsize': '10pt', 116 | # Additional stuff for the LaTeX preamble. 117 | # 118 | # 'preamble': '', 119 | # Latex figure (float) alignment 120 | # 121 | # 'figure_align': 'htbp', 122 | } 123 | 124 | # Grouping the document tree into LaTeX files. List of tuples 125 | # (source start file, target name, title, author, documentclass 126 | # [howto, manual, or own class]). 127 | latex_documents = [ 128 | ( 129 | master_doc, 130 | "hyundai_kia_connect_api.tex", 131 | "Hyundai / Kia Connect Documentation", 132 | "Fuat Akgun", 133 | "manual", 134 | ), 135 | ] 136 | 137 | 138 | # -- Options for manual page output ------------------------------------ 139 | 140 | # One entry per manual page. List of tuples 141 | # (source start file, name, description, authors, manual section). 142 | man_pages = [ 143 | ( 144 | master_doc, 145 | "hyundai_kia_connect_api", 146 | "Hyundai / Kia Connect Documentation", 147 | [author], 148 | 1, 149 | ) 150 | ] 151 | 152 | 153 | # -- Options for Texinfo output ---------------------------------------- 154 | 155 | # Grouping the document tree into Texinfo files. List of tuples 156 | # (source start file, target name, title, author, 157 | # dir menu entry, description, category) 158 | texinfo_documents = [ 159 | ( 160 | master_doc, 161 | "hyundai_kia_connect_api", 162 | "Hyundai / Kia Connect Documentation", 163 | author, 164 | "hyundai_kia_connect_api", 165 | "One line description of project.", 166 | "Miscellaneous", 167 | ), 168 | ] 169 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Code Maintainers Wanted 2 | ======================= 3 | 4 | I no longer have a Kia or Hyundai so don't maintain this like I used to. Others who are interested in jumping in are welcome to join the project! Even just pull requests are appreciated! 5 | 6 | Introduction 7 | ============ 8 | 9 | This is a Kia UVO, Hyundai Bluelink, Genesis Connect(Canada Only) written in python. It is primary consumed by home assistant. If you are looking for a home assistant Kia / Hyundai implementation please look here: https://github.com/Hyundai-Kia-Connect/kia_uvo. Much of this base code came from reading `bluelinky `_ and contributions to the kia_uvo home assistant project. 10 | 11 | Chat on discord:: |Discord| 12 | 13 | .. |Discord| image:: https://img.shields.io/discord/652755205041029120 14 | :target: https://discord.gg/HwnG8sY 15 | :alt: Discord 16 | 17 | API Usage 18 | ========= 19 | 20 | This package is designed to simplify the complexity of using multiple regions. It attempts to standardize the usage regardless of what brand or region the car is in. That isn't always possible though, in particular some features differ from one to the next. 21 | 22 | Europe Kia must follow unique steps: https://github.com/Hyundai-Kia-Connect/hyundai_kia_connect_api/wiki/Kia-Europe-Login-Flow 23 | 24 | Python 3.10 or newer is required to use this package. Vehicle manager is the key class that is called to manage the vehicle lists. One vehicle manager should be used per login. Key data points required to instantiate vehicle manager are:: 25 | 26 | region: int 27 | brand: int, 28 | username: str 29 | password: str 30 | pin: str (required for CA, and potentially USA, otherwise pass a blank string) 31 | 32 | Optional parameters are:: 33 | geocode_api_enable: bool 34 | geocode_api_use_email: bool 35 | geocode_provider: int 36 | geocode_api_key: str 37 | language: str 38 | 39 | Key values for the int exist in the `const.py `_ file as:: 40 | 41 | REGIONS = {1: REGION_EUROPE, 2: REGION_CANADA, 3: REGION_USA, 4: REGION_CHINA, 5: REGION_AUSTRALIA, 6: REGION_INDIA, 7: REGION_NZ, 8: REGION_BRAZIL} 42 | BRANDS = {1: BRAND_KIA, 2: BRAND_HYUNDAI, 3: BRAND_GENESIS} 43 | GEO_LOCATION_PROVIDERS = {1: OPENSTREETMAP, 2: GOOGLE} 44 | 45 | 46 | Once this is done you can now make the following calls against the vehicle manager:: 47 | 48 | #Checks the token is still valid and updates it if not. Should be called before anything else if the code has been running for any length of time. 49 | check_and_refresh_token(self) 50 | 51 | Ideal refresh command. Checks if the car has been updated since the time in seconds provided. If so does a cached update. If not force calls the car. 52 | check_and_force_update_vehicles(self, force_refresh_interval) # Interval in seconds - consider API Rate Limits https://github.com/Hacksore/bluelinky/wiki/API-Rate-Limits 53 | 54 | Used to return a specific vehicle object: 55 | get_vehicle(self, vehicle_id) 56 | 57 | #Updates all cars with what is cached in the cloud: 58 | update_all_vehicles_with_cached_state(self) 59 | 60 | Updates a specific car with cached state: 61 | update_vehicle_with_cached_state(self, vehicle_id) 62 | 63 | Force refreshes all cars: 64 | force_refresh_all_vehicles_states(self) 65 | 66 | Force refreshes a single car: 67 | force_refresh_vehicles_states(self, vehicle_id) 68 | 69 | An example call would be:: 70 | 71 | from hyundai_kia_connect_api import * 72 | vm = VehicleManager(region=2, brand=1, username="username@gmail.com", password="password", pin="1234") 73 | vm.check_and_refresh_token() 74 | vm.update_all_vehicles_with_cached_state() 75 | print(vm.vehicles) 76 | 77 | If geolocation is required you can also allow this by running:: 78 | 79 | vm = VehicleManager(region=2, brand=1, username="username@gmail.com", password="password", pin="1234", geocode_api_enable=True, geocode_api_use_email=True) 80 | 81 | This will populate the address of the vehicle in the vehicle instance. 82 | 83 | The Bluelink App is reset to English for users who have set another language in the Bluelink App in Europe when using hyundai_kia_connect_api. 84 | To avoid this, you can pass the optional parameter language (default is "en") to the constructor of VehicleManager, e.g. for Dutch:: 85 | 86 | vm = VehicleManager(region=2, brand=1, username="username@gmail.com", password="password", pin="1234", language="nl") 87 | 88 | Note: this is only implemented for Europe currently. 89 | 90 | For a list of language codes, see here: https://www.science.co.il/language/Codes.php. Currently in Europe the Bluelink App shows the following languages:: 91 | 92 | - "en" English 93 | - "de" German 94 | - "fr" French 95 | - "it" Italian 96 | - "es" Spanish 97 | - "sv" Swedish 98 | - "nl" Dutch 99 | - "no" Norwegian 100 | - "cs" Czech 101 | - "sk" Slovak 102 | - "hu" Hungarian 103 | - "da" Danish 104 | - "pl" Polish 105 | - "fi" Finnish 106 | - "pt" Portuguese 107 | 108 | 109 | In Europe and some other regions also trip info can be retrieved. For a month you can ask the days with trips. And you can ask for a specific day for all the trips of that specific day.:: 110 | - First call vm.update_month_trip_info(vehicle.id, yyyymm) before getting vehicle.month_trip_info for that month 111 | - First call vm.update_day_trip_info(vehicle.id, day.yyyymmdd) before getting vehicle.day_trip_info for that day 112 | 113 | Example of getting trip info of the current month and day (vm is VehicleManager instance):: 114 | 115 | now = datetime.now() 116 | yyyymm = now.strftime("%Y%m") 117 | yyyymmdd = now.strftime("%Y%m%d") 118 | vm.update_month_trip_info(vehicle.id, yyyymm) 119 | if vehicle.month_trip_info is not None: 120 | for day in vehicle.month_trip_info.day_list: # ordered on day 121 | if yyyymmdd == day.yyyymmdd: # in example only interested in current day 122 | vm.update_day_trip_info(vehicle.id, day.yyyymmdd) 123 | if vehicle.day_trip_info is not None: 124 | for trip in reversed(vehicle.day_trip_info.trip_list): # show oldest first 125 | print(f"{day.yyyymmdd},{trip.hhmmss},{trip.drive_time},{trip.idle_time},{trip.distance},{trip.avg_speed},{trip.max_speed}") 126 | 127 | CLI Usage 128 | ========= 129 | 130 | A tool `bluelink` is provided that enable querying the vehicles and save the 131 | state to a JSON file. Example usage: 132 | 133 | :: 134 | 135 | bluelink --region Canada --brand Hyundai --username FOO --password BAR --pin 1234 info --json infos.json 136 | 137 | Environment variables BLUELINK_XXX can be used to provide a default value for 138 | the corresponding --xxx argument. 139 | -------------------------------------------------------------------------------- /hyundai_kia_connect_api/ApiImpl.py: -------------------------------------------------------------------------------- 1 | """ApiImpl.py""" 2 | 3 | # pylint:disable=unnecessary-pass,missing-class-docstring,invalid-name,missing-function-docstring,wildcard-import,unused-wildcard-import,unused-argument,missing-timeout,logging-fstring-interpolation 4 | import datetime as dt 5 | import logging 6 | import typing as ty 7 | from dataclasses import dataclass 8 | 9 | import requests 10 | from requests.exceptions import JSONDecodeError 11 | 12 | try: 13 | from geopy.geocoders import GoogleV3 14 | except ImportError: 15 | GoogleV3 = None 16 | 17 | from .utils import get_child_value 18 | from .Token import Token 19 | from .Vehicle import Vehicle 20 | from .const import ( 21 | WINDOW_STATE, 22 | CHARGE_PORT_ACTION, 23 | ORDER_STATUS, 24 | DOMAIN, 25 | VALET_MODE_ACTION, 26 | VEHICLE_LOCK_ACTION, 27 | GEO_LOCATION_PROVIDERS, 28 | OPENSTREETMAP, 29 | GOOGLE, 30 | ) 31 | 32 | _LOGGER = logging.getLogger(__name__) 33 | 34 | 35 | @dataclass 36 | class ClimateRequestOptions: 37 | set_temp: float = None 38 | duration: int = None 39 | defrost: bool = None 40 | climate: bool = None 41 | heating: int = None 42 | front_left_seat: int = None 43 | front_right_seat: int = None 44 | rear_left_seat: int = None 45 | rear_right_seat: int = None 46 | steering_wheel: int = None 47 | 48 | 49 | @dataclass 50 | class WindowRequestOptions: 51 | back_left: WINDOW_STATE = None 52 | back_right: WINDOW_STATE = None 53 | front_left: WINDOW_STATE = None 54 | front_right: WINDOW_STATE = None 55 | 56 | 57 | @dataclass 58 | class ScheduleChargingClimateRequestOptions: 59 | @dataclass 60 | class DepartureOptions: 61 | enabled: bool = None 62 | days: list[int] = None # Sun=0, Mon=1, ..., Sat=6 63 | time: dt.time = None 64 | 65 | first_departure: DepartureOptions = None 66 | second_departure: DepartureOptions = None 67 | charging_enabled: bool = None 68 | off_peak_start_time: dt.time = None 69 | off_peak_end_time: dt.time = None 70 | off_peak_charge_only_enabled: bool = None 71 | climate_enabled: bool = None 72 | temperature: float = None 73 | temperature_unit: int = None 74 | defrost: bool = None 75 | 76 | 77 | class ApiImpl: 78 | data_timezone = dt.timezone.utc 79 | temperature_range = None 80 | previous_latitude: float = None 81 | previous_longitude: float = None 82 | supports_otp: bool = False 83 | 84 | def __init__(self) -> None: 85 | """Initialize.""" 86 | 87 | def login( 88 | self, 89 | username: str, 90 | password: str, 91 | otp_handler: ty.Callable[[dict], dict] | None = None, 92 | pin: str | None = None, 93 | ) -> Token: 94 | """Login into cloud endpoints and return Token""" 95 | pass 96 | 97 | def get_vehicles(self, token: Token) -> list[Vehicle]: 98 | """Return all Vehicle instances for a given Token""" 99 | pass 100 | 101 | def refresh_vehicles(self, token: Token, vehicles: list[Vehicle]) -> None: 102 | """Refresh the vehicle data provided in get_vehicles. 103 | Required for Kia USA as key is session specific""" 104 | return vehicles 105 | 106 | def update_vehicle_with_cached_state(self, token: Token, vehicle: Vehicle) -> None: 107 | """Get cached vehicle data and update Vehicle instance with it""" 108 | pass 109 | 110 | def test_token(self, token: Token) -> bool: 111 | """Test if token is valid 112 | Use any dummy request to test if token is still valid""" 113 | return True 114 | 115 | def check_action_status( 116 | self, 117 | token: Token, 118 | vehicle: Vehicle, 119 | action_id: str, 120 | synchronous: bool = False, 121 | timeout: int = 0, 122 | ) -> ORDER_STATUS: 123 | pass 124 | 125 | def force_refresh_vehicle_state(self, token: Token, vehicle: Vehicle) -> None: 126 | """Triggers the system to contact the car and get fresh data""" 127 | pass 128 | 129 | def update_geocoded_location( 130 | self, 131 | token: Token, 132 | vehicle: Vehicle, 133 | use_email: bool, 134 | provider: int = 1, 135 | API_KEY: str = None, 136 | ) -> None: 137 | if vehicle.location_latitude and vehicle.location_longitude: 138 | if ( 139 | vehicle.geocode 140 | and vehicle.location_latitude == self.previous_latitude 141 | and vehicle.location_longitude == self.previous_longitude 142 | ): # previous coordinates are the same, so keep last valid vehicle.geocode 143 | _LOGGER.debug(f"{DOMAIN} - Keeping last geocode location") 144 | elif GEO_LOCATION_PROVIDERS[provider] == OPENSTREETMAP: 145 | email_parameter = "" 146 | if use_email is True: 147 | email_parameter = "&email=" + token.username 148 | 149 | url = ( 150 | "https://nominatim.openstreetmap.org/reverse?lat=" 151 | + str(vehicle.location_latitude) 152 | + "&lon=" 153 | + str(vehicle.location_longitude) 154 | + "&format=json&addressdetails=1&zoom=18" 155 | + email_parameter 156 | ) 157 | headers = {"user-agent": "curl/7.81.0"} 158 | response = requests.get(url, headers=headers) 159 | try: 160 | response = response.json() 161 | except JSONDecodeError: 162 | _LOGGER.warning(f"{DOMAIN} - failed geocode openstreetmap") 163 | vehicle.geocode = None 164 | else: 165 | vehicle.geocode = ( 166 | get_child_value(response, "display_name"), 167 | get_child_value(response, "address"), 168 | ) 169 | self.previous_latitude = vehicle.location_latitude 170 | self.previous_longitude = vehicle.location_longitude 171 | _LOGGER.debug(f"{DOMAIN} - geocode openstreetmap") 172 | elif GEO_LOCATION_PROVIDERS[provider] == GOOGLE: 173 | if not API_KEY: 174 | _LOGGER.warning(f"{DOMAIN} - missing API KEY for geocode Google") 175 | vehicle.geocode = None 176 | elif GoogleV3 is None: 177 | _LOGGER.warning(f"{DOMAIN} - geopy is required for geocode Google") 178 | vehicle.geocode = None 179 | else: 180 | latlong = (vehicle.location_latitude, vehicle.location_longitude) 181 | try: 182 | geolocator = GoogleV3(api_key=API_KEY) 183 | locations = geolocator.reverse(latlong) 184 | if locations: 185 | vehicle.geocode = locations 186 | self.previous_latitude = vehicle.location_latitude 187 | self.previous_longitude = vehicle.location_longitude 188 | _LOGGER.debug(f"{DOMAIN} - geocode google") 189 | except Exception as ex: # pylint: disable=broad-except 190 | _LOGGER.warning(f"{DOMAIN} - failed geocode Google: {ex}") 191 | vehicle.geocode = None 192 | 193 | def lock_action( 194 | self, token: Token, vehicle: Vehicle, action: VEHICLE_LOCK_ACTION 195 | ) -> str: 196 | """Lock or unlocks a vehicle. Returns the tracking ID""" 197 | pass 198 | 199 | def start_climate( 200 | self, token: Token, vehicle: Vehicle, options: ClimateRequestOptions 201 | ) -> str: 202 | """Starts climate or remote start. Returns the tracking ID""" 203 | pass 204 | 205 | def stop_climate(self, token: Token, vehicle: Vehicle) -> str: 206 | """Stops climate or remote start. Returns the tracking ID""" 207 | pass 208 | 209 | def start_charge(self, token: Token, vehicle: Vehicle) -> str: 210 | """Starts charge. Returns the tracking ID""" 211 | pass 212 | 213 | def stop_charge(self, token: Token, vehicle: Vehicle) -> str: 214 | """Stops charge. Returns the tracking ID""" 215 | pass 216 | 217 | def set_charge_limits( 218 | self, token: Token, vehicle: Vehicle, ac: int, dc: int 219 | ) -> str: 220 | """Sets charge limits. Returns the tracking ID""" 221 | pass 222 | 223 | def set_charging_current(self, token: Token, vehicle: Vehicle, level: int) -> str: 224 | """ 225 | feature only available for some regions. 226 | Sets charge current level (1=100%, 2=90%, 3=60%). Returns the tracking ID 227 | """ 228 | pass 229 | 230 | def set_windows_state( 231 | self, token: Token, vehicle: Vehicle, options: WindowRequestOptions 232 | ) -> str: 233 | """Opens or closes a particular window. Returns the tracking ID""" 234 | pass 235 | 236 | def charge_port_action( 237 | self, token: Token, vehicle: Vehicle, action: CHARGE_PORT_ACTION 238 | ) -> str: 239 | """Opens or closes the charging port of the car. Returns the tracking ID""" 240 | pass 241 | 242 | def update_month_trip_info( 243 | self, token: Token, vehicle: Vehicle, yyyymm_string: str 244 | ) -> None: 245 | """ 246 | feature only available for some regions. 247 | Updates the vehicle.month_trip_info for the specified month. 248 | 249 | Default this information is None: 250 | 251 | month_trip_info: MonthTripInfo = None 252 | """ 253 | pass 254 | 255 | def update_day_trip_info( 256 | self, token: Token, vehicle: Vehicle, yyyymmdd_string: str 257 | ) -> None: 258 | """ 259 | feature only available for some regions. 260 | Updates the vehicle.day_trip_info information for the specified day. 261 | 262 | Default this information is None: 263 | 264 | day_trip_info: DayTripInfo = None 265 | """ 266 | pass 267 | 268 | def schedule_charging_and_climate( 269 | self, 270 | token: Token, 271 | vehicle: Vehicle, 272 | options: ScheduleChargingClimateRequestOptions, 273 | ) -> str: 274 | """ 275 | feature only available for some regions. 276 | Schedule charging and climate control. Returns the tracking ID 277 | """ 278 | pass 279 | 280 | def start_hazard_lights(self, token: Token, vehicle: Vehicle) -> str: 281 | """Turns on the hazard lights for 30 seconds""" 282 | pass 283 | 284 | def start_hazard_lights_and_horn(self, token: Token, vehicle: Vehicle) -> str: 285 | """Turns on the hazard lights and horn for 30 seconds""" 286 | pass 287 | 288 | def valet_mode_action( 289 | self, token: Token, vehicle: Vehicle, action: VALET_MODE_ACTION 290 | ) -> str: 291 | """ 292 | feature only available for some regions. 293 | Activate or Deactivate valet mode. Returns the tracking ID 294 | """ 295 | pass 296 | 297 | def set_vehicle_to_load_discharge_limit( 298 | self, token: Token, vehicle: Vehicle, limit: int 299 | ) -> str: 300 | """ 301 | feature only available for some regions. 302 | Set the vehicle to load limit. Returns the tracking ID 303 | """ 304 | pass 305 | -------------------------------------------------------------------------------- /hyundai_kia_connect_api/VehicleManager.py: -------------------------------------------------------------------------------- 1 | """VehicleManager.py""" 2 | 3 | # pylint:disable=logging-fstring-interpolation,missing-class-docstring,missing-function-docstring,line-too-long,invalid-name 4 | 5 | import datetime as dt 6 | import logging 7 | import typing as ty 8 | from datetime import timedelta 9 | 10 | from .ApiImpl import ( 11 | ApiImpl, 12 | ClimateRequestOptions, 13 | ScheduleChargingClimateRequestOptions, 14 | WindowRequestOptions, 15 | ) 16 | from .const import ( 17 | BRAND_GENESIS, 18 | BRAND_HYUNDAI, 19 | BRAND_KIA, 20 | BRANDS, 21 | CHARGE_PORT_ACTION, 22 | DOMAIN, 23 | ORDER_STATUS, 24 | REGION_AUSTRALIA, 25 | REGION_BRAZIL, 26 | REGION_CANADA, 27 | REGION_CHINA, 28 | REGION_EUROPE, 29 | REGION_INDIA, 30 | REGION_NZ, 31 | REGION_USA, 32 | REGIONS, 33 | VALET_MODE_ACTION, 34 | VEHICLE_LOCK_ACTION, 35 | ) 36 | from .exceptions import APIError 37 | from .HyundaiBlueLinkApiBR import HyundaiBlueLinkApiBR 38 | from .HyundaiBlueLinkApiUSA import HyundaiBlueLinkApiUSA 39 | from .KiaUvoApiAU import KiaUvoApiAU 40 | from .KiaUvoApiCA import KiaUvoApiCA 41 | from .KiaUvoApiCN import KiaUvoApiCN 42 | from .KiaUvoApiEU import KiaUvoApiEU 43 | from .KiaUvoApiIN import KiaUvoApiIN 44 | from .KiaUvoApiUSA import KiaUvoApiUSA 45 | from .Token import Token 46 | from .Vehicle import Vehicle 47 | 48 | _LOGGER = logging.getLogger(__name__) 49 | 50 | 51 | class VehicleManager: 52 | def __init__( 53 | self, 54 | region: int, 55 | brand: int, 56 | username: str, 57 | password: str, 58 | pin: str, 59 | geocode_api_enable: bool = False, 60 | geocode_api_use_email: bool = False, 61 | geocode_provider: int = 1, 62 | geocode_api_key: str = None, 63 | language: str = "en", 64 | otp_handler: ty.Callable[[dict], dict] | None = None, 65 | ): 66 | self.region: int = region 67 | self.brand: int = brand 68 | self.username: str = username 69 | self.password: str = password 70 | self.geocode_api_enable: bool = geocode_api_enable 71 | self.geocode_api_use_email: bool = geocode_api_use_email 72 | self.geocode_provider: int = geocode_provider 73 | self.pin: str = pin 74 | self.language: str = language 75 | self.geocode_api_key: str = geocode_api_key 76 | self.otp_handler = otp_handler 77 | 78 | self.api: ApiImpl = self.get_implementation_by_region_brand( 79 | self.region, self.brand, self.language 80 | ) 81 | 82 | self.token: Token = None 83 | self.vehicles: dict = {} 84 | self.vehicles_valid = False 85 | 86 | def initialize(self) -> None: 87 | self.token: Token = self.api.login( 88 | self.username, 89 | self.password, 90 | otp_handler=self.otp_handler, 91 | pin=self.pin, 92 | ) 93 | self.initialize_vehicles() 94 | 95 | @property 96 | def supports_otp(self) -> bool: 97 | """Return whether the selected API implementation supports OTP.""" 98 | return getattr(self.api, "supports_otp", False) 99 | 100 | def initialize_vehicles(self): 101 | vehicles = self.api.get_vehicles(self.token) 102 | for vehicle in vehicles: 103 | self.vehicles[vehicle.id] = vehicle 104 | self.vehicles_valid = True 105 | 106 | def get_vehicle(self, vehicle_id: str) -> Vehicle: 107 | return self.vehicles[vehicle_id] 108 | 109 | def update_all_vehicles_with_cached_state(self) -> None: 110 | for vehicle_id in self.vehicles.keys(): 111 | self.update_vehicle_with_cached_state(vehicle_id) 112 | 113 | def update_vehicle_with_cached_state(self, vehicle_id: str) -> None: 114 | vehicle = self.get_vehicle(vehicle_id) 115 | if vehicle.enabled: 116 | self.api.update_vehicle_with_cached_state(self.token, vehicle) 117 | if self.geocode_api_enable is True: 118 | self.api.update_geocoded_location( 119 | token=self.token, 120 | vehicle=vehicle, 121 | use_email=self.geocode_api_use_email, 122 | provider=self.geocode_provider, 123 | API_KEY=self.geocode_api_key, 124 | ) 125 | else: 126 | _LOGGER.debug(f"{DOMAIN} - Vehicle Disabled, skipping.") 127 | 128 | def check_and_force_update_vehicles(self, force_refresh_interval: int) -> None: 129 | for vehicle_id in self.vehicles.keys(): 130 | self.check_and_force_update_vehicle(force_refresh_interval, vehicle_id) 131 | 132 | def check_and_force_update_vehicle( 133 | self, force_refresh_interval: int, vehicle_id: str 134 | ) -> None: 135 | # Force refresh only if current data is older than the value bassed in seconds. 136 | # Otherwise runs a cached update. 137 | started_at_utc: dt.datetime = dt.datetime.now(dt.timezone.utc) 138 | vehicle = self.get_vehicle(vehicle_id) 139 | if vehicle.last_updated_at is not None: 140 | _LOGGER.debug( 141 | f"{DOMAIN} - Time differential in seconds: {(started_at_utc - vehicle.last_updated_at).total_seconds()}" # noqa 142 | ) 143 | if ( 144 | started_at_utc - vehicle.last_updated_at 145 | ).total_seconds() > force_refresh_interval: 146 | self.force_refresh_vehicle_state(vehicle_id) 147 | else: 148 | self.update_vehicle_with_cached_state(vehicle_id) 149 | else: 150 | self.update_vehicle_with_cached_state(vehicle_id) 151 | 152 | def force_refresh_all_vehicles_states(self) -> None: 153 | for vehicle_id in self.vehicles.keys(): 154 | self.force_refresh_vehicle_state(vehicle_id) 155 | 156 | def force_refresh_vehicle_state(self, vehicle_id: str) -> None: 157 | vehicle = self.get_vehicle(vehicle_id) 158 | if vehicle.enabled: 159 | self.api.force_refresh_vehicle_state(self.token, vehicle) 160 | else: 161 | _LOGGER.debug(f"{DOMAIN} - Vehicle Disabled, skipping.") 162 | 163 | def check_and_refresh_token(self) -> bool: 164 | if self.token is None: 165 | self.initialize() 166 | elif not self.vehicles_valid: 167 | self.initialize_vehicles() 168 | now_utc = dt.datetime.now(dt.timezone.utc) 169 | grace_period = timedelta(seconds=10) 170 | min_supported_datetime = dt.datetime.min.replace(tzinfo=dt.timezone.utc) 171 | valid_until = self.token.valid_until 172 | token_expired = False 173 | if not isinstance(valid_until, dt.datetime): 174 | token_expired = True 175 | else: 176 | if valid_until.tzinfo is None: 177 | valid_until = valid_until.replace(tzinfo=dt.timezone.utc) 178 | if valid_until <= min_supported_datetime + grace_period: 179 | token_expired = True 180 | else: 181 | token_expired = valid_until - grace_period <= now_utc 182 | if token_expired or self.api.test_token(self.token) is False: 183 | _LOGGER.debug(f"{DOMAIN} - Refresh token expired") 184 | self.token: Token = self.api.login( 185 | self.username, 186 | self.password, 187 | otp_handler=self.otp_handler, 188 | pin=self.pin, 189 | ) 190 | self.vehicles = self.api.refresh_vehicles(self.token, self.vehicles) 191 | return True 192 | return False 193 | 194 | def start_climate(self, vehicle_id: str, options: ClimateRequestOptions) -> str: 195 | return self.api.start_climate(self.token, self.get_vehicle(vehicle_id), options) 196 | 197 | def stop_climate(self, vehicle_id: str) -> str: 198 | return self.api.stop_climate(self.token, self.get_vehicle(vehicle_id)) 199 | 200 | def lock(self, vehicle_id: str) -> str: 201 | return self.api.lock_action( 202 | self.token, self.get_vehicle(vehicle_id), VEHICLE_LOCK_ACTION.LOCK 203 | ) 204 | 205 | def unlock(self, vehicle_id: str) -> str: 206 | return self.api.lock_action( 207 | self.token, 208 | self.get_vehicle(vehicle_id), 209 | VEHICLE_LOCK_ACTION.UNLOCK, 210 | ) 211 | 212 | def start_charge(self, vehicle_id: str) -> str: 213 | return self.api.start_charge(self.token, self.get_vehicle(vehicle_id)) 214 | 215 | def stop_charge(self, vehicle_id: str) -> str: 216 | return self.api.stop_charge(self.token, self.get_vehicle(vehicle_id)) 217 | 218 | def start_hazard_lights(self, vehicle_id: str) -> str: 219 | return self.api.start_hazard_lights(self.token, self.get_vehicle(vehicle_id)) 220 | 221 | def start_hazard_lights_and_horn(self, vehicle_id: str) -> str: 222 | return self.api.start_hazard_lights_and_horn( 223 | self.token, self.get_vehicle(vehicle_id) 224 | ) 225 | 226 | def set_charge_limits(self, vehicle_id: str, ac: int, dc: int) -> str: 227 | return self.api.set_charge_limits( 228 | self.token, self.get_vehicle(vehicle_id), ac, dc 229 | ) 230 | 231 | def set_charging_current(self, vehicle_id: str, level: int) -> str: 232 | return self.api.set_charging_current( 233 | self.token, self.get_vehicle(vehicle_id), level 234 | ) 235 | 236 | def set_windows_state(self, vehicle_id: str, options: WindowRequestOptions) -> str: 237 | return self.api.set_windows_state( 238 | self.token, self.get_vehicle(vehicle_id), options 239 | ) 240 | 241 | def check_action_status( 242 | self, 243 | vehicle_id: str, 244 | action_id: str, 245 | synchronous: bool = False, 246 | timeout: int = 120, 247 | ) -> ORDER_STATUS: 248 | """ 249 | Check for the status of a sent action/command. 250 | 251 | Actions can have 4 states: 252 | - pending: request sent to vehicle, waiting for response 253 | - success: vehicle confirmed that the action was performed 254 | - fail: vehicle could not perform the action 255 | (most likely because a condition was not met) 256 | - vehicle timeout: request sent to vehicle, no response received. 257 | 258 | In case of timeout, the API can return "pending" for up to 2 minutes before 259 | it returns a final state. 260 | 261 | :param vehicle_id: ID of the vehicle 262 | :param action_id: ID of the action 263 | :param synchronous: Whether to wait for pending actions to reach a final 264 | state (success/fail/timeout) 265 | :param timeout: 266 | Time in seconds to wait for pending actions to reach a final state. 267 | :return: status of the order 268 | """ 269 | return self.api.check_action_status( 270 | self.token, self.get_vehicle(vehicle_id), action_id, synchronous, timeout 271 | ) 272 | 273 | def open_charge_port(self, vehicle_id: str) -> str: 274 | return self.api.charge_port_action( 275 | self.token, self.get_vehicle(vehicle_id), CHARGE_PORT_ACTION.OPEN 276 | ) 277 | 278 | def close_charge_port(self, vehicle_id: str) -> str: 279 | return self.api.charge_port_action( 280 | self.token, self.get_vehicle(vehicle_id), CHARGE_PORT_ACTION.CLOSE 281 | ) 282 | 283 | def update_month_trip_info(self, vehicle_id: str, yyyymm_string: str) -> None: 284 | """ 285 | feature only available for some regions. 286 | Updates the vehicle.month_trip_info for the specified month. 287 | 288 | Default this information is None: 289 | 290 | month_trip_info: MonthTripInfo = None 291 | """ 292 | vehicle = self.get_vehicle(vehicle_id) 293 | self.api.update_month_trip_info(self.token, vehicle, yyyymm_string) 294 | 295 | def update_day_trip_info(self, vehicle_id: str, yyyymmdd_string: str) -> None: 296 | """ 297 | feature only available for some regions. 298 | Updates the vehicle.day_trip_info information for the specified day. 299 | 300 | Default this information is None: 301 | 302 | day_trip_info: DayTripInfo = None 303 | """ 304 | vehicle = self.get_vehicle(vehicle_id) 305 | self.api.update_day_trip_info(self.token, vehicle, yyyymmdd_string) 306 | 307 | def disable_vehicle(self, vehicle_id: str) -> None: 308 | self.get_vehicle(vehicle_id).enabled = False 309 | 310 | def enable_vehicle(self, vehicle_id: str) -> None: 311 | self.get_vehicle(vehicle_id).enabled = True 312 | 313 | def schedule_charging_and_climate( 314 | self, vehicle_id: str, options: ScheduleChargingClimateRequestOptions 315 | ) -> str: 316 | return self.api.schedule_charging_and_climate( 317 | self.token, self.get_vehicle(vehicle_id), options 318 | ) 319 | 320 | def start_valet_mode(self, vehicle_id: str) -> str: 321 | return self.api.valet_mode_action( 322 | self.token, self.get_vehicle(vehicle_id), VALET_MODE_ACTION.ACTIVATE 323 | ) 324 | 325 | def stop_valet_mode(self, vehicle_id: str) -> str: 326 | return self.api.valet_mode_action( 327 | self.token, self.get_vehicle(vehicle_id), VALET_MODE_ACTION.DEACTIVATE 328 | ) 329 | 330 | def set_vehicle_to_load_discharge_limit(self, vehicle_id: str, limit: int) -> str: 331 | return self.api.set_vehicle_to_load_discharge_limit( 332 | self.token, self.get_vehicle(vehicle_id), limit 333 | ) 334 | 335 | @staticmethod 336 | def get_implementation_by_region_brand( 337 | region: int, brand: int, language: str 338 | ) -> ApiImpl: 339 | if REGIONS[region] == REGION_CANADA: 340 | return KiaUvoApiCA(region, brand, language) 341 | elif REGIONS[region] == REGION_EUROPE: 342 | return KiaUvoApiEU(region, brand, language) 343 | elif REGIONS[region] == REGION_USA and ( 344 | BRANDS[brand] == BRAND_HYUNDAI or BRANDS[brand] == BRAND_GENESIS 345 | ): 346 | return HyundaiBlueLinkApiUSA(region, brand, language) 347 | elif REGIONS[region] == REGION_USA and BRANDS[brand] == BRAND_KIA: 348 | return KiaUvoApiUSA(region, brand, language) 349 | elif REGIONS[region] == REGION_CHINA: 350 | return KiaUvoApiCN(region, brand, language) 351 | elif REGIONS[region] == REGION_AUSTRALIA: 352 | return KiaUvoApiAU(region, brand, language) 353 | elif REGIONS[region] == REGION_NZ: 354 | if BRANDS[brand] == BRAND_KIA: 355 | return KiaUvoApiAU(region, brand, language) 356 | else: 357 | raise APIError( 358 | f"Unknown brand {BRANDS[brand]} for region {REGIONS[region]}" 359 | ) 360 | elif REGIONS[region] == REGION_INDIA: 361 | return KiaUvoApiIN(brand) 362 | elif REGIONS[region] == REGION_BRAZIL: 363 | return HyundaiBlueLinkApiBR(region, brand, language) 364 | else: 365 | raise APIError(f"Unknown region {region}") 366 | -------------------------------------------------------------------------------- /hyundai_kia_connect_api/bluelink.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """Connects to the Bluelink API and query the vehicle.""" 4 | 5 | import argparse 6 | import dataclasses 7 | import datetime 8 | import json 9 | import logging 10 | import os 11 | import sys 12 | import textwrap 13 | 14 | import hyundai_kia_connect_api 15 | from hyundai_kia_connect_api import const 16 | 17 | 18 | class EnhancedJSONEncoder(json.JSONEncoder): 19 | def default(self, o): 20 | if dataclasses.is_dataclass(o): 21 | return dataclasses.asdict(o) 22 | return super().default(o) 23 | 24 | 25 | def print_vehicle(vehicle): 26 | print("Identification") 27 | print(" id:", vehicle.id) 28 | print(" name:", vehicle.name) 29 | print(" model:", vehicle.model) 30 | print(" registration_date:", vehicle.registration_date) 31 | print(" year:", vehicle.year) 32 | print(" VIN:", vehicle.VIN) 33 | print(" key:", vehicle.key) 34 | print("General") 35 | print(" engine_type:", vehicle.engine_type) 36 | print(" ccu_ccs2_protocol_support:", vehicle.ccu_ccs2_protocol_support) 37 | print( 38 | " total_driving_range:", 39 | vehicle.total_driving_range, 40 | vehicle.total_driving_range_unit, 41 | ) 42 | print(" odometer:", vehicle.odometer, vehicle.odometer_unit) 43 | print(" geocode:", vehicle.geocode) 44 | print(" car_battery_percentage:", vehicle.car_battery_percentage) 45 | print(" engine_is_running:", vehicle.engine_is_running) 46 | print(" last_updated_at:", vehicle.last_updated_at) 47 | print(" timezone:", vehicle.timezone) 48 | print(" dtc_count:", vehicle.dtc_count) 49 | print(" dtc_descriptions:", vehicle.dtc_descriptions) 50 | print(" smart_key_battery_warning_is_on:", vehicle.smart_key_battery_warning_is_on) 51 | print(" washer_fluid_warning_is_on:", vehicle.washer_fluid_warning_is_on) 52 | print(" brake_fluid_warning_is_on:", vehicle.brake_fluid_warning_is_on) 53 | print("Climate") 54 | print(" air_temperature:", vehicle.air_temperature, vehicle._air_temperature_unit) 55 | print(" air_control_is_on:", vehicle.air_control_is_on) 56 | print(" defrost_is_on:", vehicle.defrost_is_on) 57 | print(" steering_wheel_heater_is_on:", vehicle.steering_wheel_heater_is_on) 58 | print(" back_window_heater_is_on:", vehicle.back_window_heater_is_on) 59 | print(" side_mirror_heater_is_on:", vehicle.side_mirror_heater_is_on) 60 | print(" front_left_seat_status:", vehicle.front_left_seat_status) 61 | print(" front_right_seat_status:", vehicle.front_right_seat_status) 62 | print(" rear_left_seat_status:", vehicle.rear_left_seat_status) 63 | print(" rear_right_seat_status:", vehicle.rear_right_seat_status) 64 | print("Doors") 65 | print(" is_locked:", vehicle.is_locked) 66 | print(" front_left_door_is_open:", vehicle.front_left_door_is_open) 67 | print(" front_right_door_is_open:", vehicle.front_right_door_is_open) 68 | print(" back_left_door_is_open:", vehicle.back_left_door_is_open) 69 | print(" back_right_door_is_open:", vehicle.back_right_door_is_open) 70 | print(" trunk_is_open:", vehicle.trunk_is_open) 71 | print(" hood_is_open:", vehicle.hood_is_open) 72 | print("Windows") 73 | print(" front_left_window_is_open:", vehicle.front_left_window_is_open) 74 | print(" front_right_window_is_open:", vehicle.front_right_window_is_open) 75 | print(" back_left_window_is_open:", vehicle.back_left_window_is_open) 76 | print(" back_right_window_is_open:", vehicle.back_right_window_is_open) 77 | print("Tire Pressure") 78 | print(" tire_pressure_all_warning_is_on:", vehicle.tire_pressure_all_warning_is_on) 79 | print( 80 | " tire_pressure_rear_left_warning_is_on:", 81 | vehicle.tire_pressure_rear_left_warning_is_on, 82 | ) 83 | print( 84 | " tire_pressure_front_left_warning_is_on:", 85 | vehicle.tire_pressure_front_left_warning_is_on, 86 | ) 87 | print( 88 | " tire_pressure_front_right_warning_is_on:", 89 | vehicle.tire_pressure_front_right_warning_is_on, 90 | ) 91 | print( 92 | " tire_pressure_rear_right_warning_is_on:", 93 | vehicle.tire_pressure_rear_right_warning_is_on, 94 | ) 95 | print("Service") 96 | print( 97 | " next_service_distance:", 98 | vehicle.next_service_distance, 99 | vehicle._next_service_distance_unit, 100 | ) 101 | print( 102 | " last_service_distance:", 103 | vehicle.last_service_distance, 104 | vehicle._last_service_distance_unit, 105 | ) 106 | print("Location") 107 | print(" location:", vehicle.location) 108 | print(" location_last_updated_at:", vehicle.location_last_updated_at) 109 | print("EV/PHEV") 110 | print(" charge_port_door_is_open:", vehicle.ev_charge_port_door_is_open) 111 | print(" charging_power:", vehicle.ev_charging_power) 112 | print(" charge_limits_dc:", vehicle.ev_charge_limits_dc) 113 | print(" charge_limits_ac:", vehicle.ev_charge_limits_ac) 114 | print(" charging_current:", vehicle.ev_charging_current) 115 | print(" v2l_discharge_limit:", vehicle.ev_v2l_discharge_limit) 116 | print(" total_power_consumed:", vehicle.total_power_consumed, "Wh") 117 | print(" total_power_regenerated:", vehicle.total_power_regenerated, "Wh") 118 | print(" power_consumption_30d:", vehicle.power_consumption_30d, "Wh") 119 | print(" battery_percentage:", vehicle.ev_battery_percentage) 120 | print(" battery_soh_percentage:", vehicle.ev_battery_soh_percentage) 121 | print(" battery_remain:", vehicle.ev_battery_remain) 122 | print(" battery_capacity:", vehicle.ev_battery_capacity) 123 | print(" battery_is_charging:", vehicle.ev_battery_is_charging) 124 | print(" battery_is_plugged_in:", vehicle.ev_battery_is_plugged_in) 125 | print(" driving_range:", vehicle.ev_driving_range, vehicle._ev_driving_range_unit) 126 | print( 127 | " estimated_current_charge_duration:", 128 | vehicle.ev_estimated_current_charge_duration, 129 | vehicle._ev_estimated_current_charge_duration_unit, 130 | ) 131 | print( 132 | " estimated_fast_charge_duration:", 133 | vehicle.ev_estimated_fast_charge_duration, 134 | vehicle._ev_estimated_fast_charge_duration_unit, 135 | ) 136 | print( 137 | " estimated_portable_charge_duration:", 138 | vehicle.ev_estimated_portable_charge_duration, 139 | vehicle._ev_estimated_portable_charge_duration_unit, 140 | ) 141 | print( 142 | " estimated_station_charge_duration:", 143 | vehicle.ev_estimated_station_charge_duration, 144 | vehicle._ev_estimated_station_charge_duration_unit, 145 | ) 146 | print( 147 | " target_range_charge_AC:", 148 | vehicle.ev_target_range_charge_AC, 149 | vehicle._ev_target_range_charge_AC_unit, 150 | ) 151 | print( 152 | " target_range_charge_DC:", 153 | vehicle.ev_target_range_charge_DC, 154 | vehicle._ev_target_range_charge_DC_unit, 155 | ) 156 | 157 | print(" first_departure_enabled:", vehicle.ev_first_departure_enabled) 158 | print( 159 | " first_departure_climate_temperature:", 160 | vehicle.ev_first_departure_climate_temperature, 161 | vehicle._ev_first_departure_climate_temperature_unit, 162 | ) 163 | print(" first_departure_days:", vehicle.ev_first_departure_days) 164 | print(" first_departure_time:", vehicle.ev_first_departure_time) 165 | print( 166 | " first_departure_climate_enabled:", vehicle.ev_first_departure_climate_enabled 167 | ) 168 | print( 169 | " first_departure_climate_defrost:", vehicle.ev_first_departure_climate_defrost 170 | ) 171 | print(" second_departure_enabled:", vehicle.ev_second_departure_enabled) 172 | print( 173 | " second_departure_climate_temperature:", 174 | vehicle.ev_second_departure_climate_temperature, 175 | vehicle._ev_second_departure_climate_temperature_unit, 176 | ) 177 | print(" second_departure_days:", vehicle.ev_second_departure_days) 178 | print(" second_departure_time:", vehicle.ev_second_departure_time) 179 | print( 180 | " second_departure_climate_enabled:", 181 | vehicle.ev_second_departure_climate_enabled, 182 | ) 183 | print( 184 | " second_departure_climate_defrost:", 185 | vehicle.ev_second_departure_climate_defrost, 186 | ) 187 | print(" off_peak_start_time:", vehicle.ev_off_peak_start_time) 188 | print(" off_peak_end_time:", vehicle.ev_off_peak_end_time) 189 | print(" off_peak_charge_only_enabled:", vehicle.ev_off_peak_charge_only_enabled) 190 | print(" schedule_charge_enabled:", vehicle.ev_schedule_charge_enabled) 191 | print("PHEV/HEV/IC") 192 | print( 193 | " fuel_driving_range:", 194 | vehicle.fuel_driving_range, 195 | vehicle._fuel_driving_range_unit, 196 | ) 197 | print(" fuel_level:", vehicle.fuel_level) 198 | print(" fuel_level_is_low:", vehicle.fuel_level_is_low) 199 | print("Trips") 200 | print(" daily_stats:", vehicle.daily_stats) 201 | print(" month_trip_info:", vehicle.month_trip_info) 202 | print(" day_trip_info:", vehicle.day_trip_info) 203 | print("Debug") 204 | print( 205 | textwrap.indent( 206 | json.dumps(vehicle.data, indent=2, sort_keys=True, cls=EnhancedJSONEncoder), 207 | " ", 208 | ) 209 | ) 210 | 211 | 212 | def vehicle_to_dict(vehicle): 213 | return { 214 | "identification": { 215 | "id": vehicle.id, 216 | "name": vehicle.name, 217 | "model": vehicle.model, 218 | "registration_date": vehicle.registration_date, 219 | "year": vehicle.year, 220 | "VIN": vehicle.VIN, 221 | "key": vehicle.key, 222 | }, 223 | "general": { 224 | "engine_type": str(vehicle.engine_type), 225 | "ccu_ccs2_protocol_support": vehicle.ccu_ccs2_protocol_support, 226 | "total_driving_range": [ 227 | vehicle.total_driving_range, 228 | vehicle.total_driving_range_unit, 229 | ], 230 | "odometer": [vehicle.odometer, vehicle.odometer_unit], 231 | "geocode": vehicle.geocode, 232 | "car_battery_percentage": vehicle.car_battery_percentage, 233 | "engine_is_running": vehicle.engine_is_running, 234 | "last_updated_at": vehicle.last_updated_at, 235 | "timezone": vehicle.timezone, 236 | "dtc_count": vehicle.dtc_count, 237 | "dtc_descriptions": vehicle.dtc_descriptions, 238 | "smart_key_battery_warning_is_on": vehicle.smart_key_battery_warning_is_on, 239 | "washer_fluid_warning_is_on": vehicle.washer_fluid_warning_is_on, 240 | "brake_fluid_warning_is_on": vehicle.brake_fluid_warning_is_on, 241 | }, 242 | "climate": { 243 | "air_temperature": [ 244 | vehicle.air_temperature, 245 | vehicle._air_temperature_unit, 246 | ], 247 | "air_control_is_on": vehicle.air_control_is_on, 248 | "defrost_is_on": vehicle.defrost_is_on, 249 | "steering_wheel_heater_is_on": vehicle.steering_wheel_heater_is_on, 250 | "back_window_heater_is_on": vehicle.back_window_heater_is_on, 251 | "side_mirror_heater_is_on": vehicle.side_mirror_heater_is_on, 252 | "front_left_seat_status": vehicle.front_left_seat_status, 253 | "front_right_seat_status": vehicle.front_right_seat_status, 254 | "rear_left_seat_status": vehicle.rear_left_seat_status, 255 | "rear_right_seat_status": vehicle.rear_right_seat_status, 256 | }, 257 | "doors": { 258 | "is_locked": vehicle.is_locked, 259 | "front_left_door_is_open": vehicle.front_left_door_is_open, 260 | "front_right_door_is_open": vehicle.front_right_door_is_open, 261 | "back_left_door_is_open": vehicle.back_left_door_is_open, 262 | "back_right_door_is_open": vehicle.back_right_door_is_open, 263 | "trunk_is_open": vehicle.trunk_is_open, 264 | "hood_is_open": vehicle.hood_is_open, 265 | }, 266 | "windows": { 267 | "front_left_window_is_open": vehicle.front_left_window_is_open, 268 | "front_right_window_is_open": vehicle.front_right_window_is_open, 269 | "back_left_window_is_open": vehicle.back_left_window_is_open, 270 | "back_right_window_is_open": vehicle.back_right_window_is_open, 271 | }, 272 | "tires": { 273 | "tire_pressure_all_warning_is_on": vehicle.tire_pressure_all_warning_is_on, 274 | "tire_pressure_rear_left_warning_is_on": vehicle.tire_pressure_rear_left_warning_is_on, 275 | "tire_pressure_front_left_warning_is_on": vehicle.tire_pressure_front_left_warning_is_on, 276 | "tire_pressure_front_right_warning_is_on": vehicle.tire_pressure_front_right_warning_is_on, 277 | "tire_pressure_rear_right_warning_is_on": vehicle.tire_pressure_rear_right_warning_is_on, 278 | }, 279 | "service": { 280 | "next_service_distance": [ 281 | vehicle.next_service_distance, 282 | vehicle._next_service_distance_unit, 283 | ], 284 | "last_service_distance": [ 285 | vehicle.last_service_distance, 286 | vehicle._last_service_distance_unit, 287 | ], 288 | }, 289 | "location": { 290 | "location": vehicle.location, 291 | "location_last_updated_at": vehicle.location_last_updated_at, 292 | }, 293 | "electric": { 294 | "charge_port_door_is_open": vehicle.ev_charge_port_door_is_open, 295 | "charging_power": vehicle.ev_charging_power, 296 | "charge_limits_dc": vehicle.ev_charge_limits_dc, 297 | "charge_limits_ac": vehicle.ev_charge_limits_ac, 298 | "charging_current": vehicle.ev_charging_current, 299 | "v2l_discharge_limit": vehicle.ev_v2l_discharge_limit, 300 | "total_power_consumed": [vehicle.total_power_consumed, "Wh"], 301 | "total_power_regenerated": [vehicle.total_power_regenerated, "Wh"], 302 | "power_consumption_30d": [vehicle.power_consumption_30d, "Wh"], 303 | "battery_percentage": vehicle.ev_battery_percentage, 304 | "battery_soh_percentage": vehicle.ev_battery_soh_percentage, 305 | "battery_remain": vehicle.ev_battery_remain, 306 | "battery_capacity": vehicle.ev_battery_capacity, 307 | "battery_is_charging": vehicle.ev_battery_is_charging, 308 | "battery_is_plugged_in": vehicle.ev_battery_is_plugged_in, 309 | "driving_range": [ 310 | vehicle.ev_driving_range, 311 | vehicle._ev_driving_range_unit, 312 | ], 313 | "estimated_current_charge_duration": [ 314 | vehicle.ev_estimated_current_charge_duration, 315 | vehicle._ev_estimated_current_charge_duration_unit, 316 | ], 317 | "estimated_fast_charge_duration": [ 318 | vehicle.ev_estimated_fast_charge_duration, 319 | vehicle._ev_estimated_fast_charge_duration_unit, 320 | ], 321 | "estimated_portable_charge_duration": [ 322 | vehicle.ev_estimated_portable_charge_duration, 323 | vehicle._ev_estimated_portable_charge_duration_unit, 324 | ], 325 | "estimated_station_charge_duration": [ 326 | vehicle.ev_estimated_station_charge_duration, 327 | vehicle._ev_estimated_station_charge_duration_unit, 328 | ], 329 | "target_range_charge_AC": [ 330 | vehicle.ev_target_range_charge_AC, 331 | vehicle._ev_target_range_charge_AC_unit, 332 | ], 333 | "target_range_charge_DC": [ 334 | vehicle.ev_target_range_charge_DC, 335 | vehicle._ev_target_range_charge_DC_unit, 336 | ], 337 | "first_departure_enabled": vehicle.ev_first_departure_enabled, 338 | "first_departure_climate_temperature": [ 339 | vehicle.ev_first_departure_climate_temperature, 340 | vehicle._ev_first_departure_climate_temperature_unit, 341 | ], 342 | "first_departure_days": vehicle.ev_first_departure_days, 343 | "first_departure_time": vehicle.ev_first_departure_time, 344 | "first_departure_climate_enabled": vehicle.ev_first_departure_climate_enabled, 345 | "first_departure_climate_defrost": vehicle.ev_first_departure_climate_defrost, 346 | "second_departure_enabled": vehicle.ev_second_departure_enabled, 347 | "second_departure_climate_temperature": [ 348 | vehicle.ev_second_departure_climate_temperature, 349 | vehicle._ev_second_departure_climate_temperature_unit, 350 | ], 351 | "second_departure_days": vehicle.ev_second_departure_days, 352 | "second_departure_time": vehicle.ev_second_departure_time, 353 | "second_departure_climate_enabled": vehicle.ev_second_departure_climate_enabled, 354 | "second_departure_climate_defrost": vehicle.ev_second_departure_climate_defrost, 355 | "off_peak_start_time": vehicle.ev_off_peak_start_time, 356 | "off_peak_end_time": vehicle.ev_off_peak_end_time, 357 | "off_peak_charge_only_enabled": vehicle.ev_off_peak_charge_only_enabled, 358 | "schedule_charge_enabled": vehicle.ev_schedule_charge_enabled, 359 | }, 360 | "ic": { 361 | "fuel_driving_range": [ 362 | vehicle.fuel_driving_range, 363 | vehicle._fuel_driving_range_unit, 364 | ], 365 | "fuel_level": vehicle.fuel_level, 366 | "fuel_level_is_low": vehicle.fuel_level_is_low, 367 | }, 368 | "trips": { 369 | "daily_stats": vehicle.daily_stats, 370 | "month_trip_info": vehicle.month_trip_info, 371 | "day_trip_info": vehicle.day_trip_info, 372 | }, 373 | "debug": vehicle.data, 374 | } 375 | 376 | 377 | class DateTimeEncoder(json.JSONEncoder): 378 | def default(self, obj): 379 | if isinstance(obj, (datetime.date, datetime.datetime)): 380 | return obj.isoformat() 381 | 382 | 383 | def cmd_info(vm, args): 384 | for vehicle_id, vehicle in vm.vehicles.items(): 385 | print_vehicle(vehicle) 386 | if args.json: 387 | data = {id: vehicle_to_dict(v) for id, v in vm.vehicles.items()} 388 | json.dump(data, args.json, separators=(",", ":"), cls=DateTimeEncoder, indent=4) 389 | return 0 390 | 391 | 392 | def main(): 393 | default_username = os.environ.get("BLUELINK_USERNAME", "") 394 | default_password = os.environ.get("BLUELINK_PASSWORD", "") 395 | default_pin = None 396 | if os.environ.get("BLUELINK_PIN", ""): 397 | try: 398 | default_pin = str(os.environ["BLUELINK_PIN"]) 399 | except ValueError: 400 | print("Invalid BLUELINK_PIN environment variable", file=sys.stderr) 401 | return 1 402 | 403 | parser = argparse.ArgumentParser(description=sys.modules[__name__].__doc__) 404 | parser.add_argument( 405 | "--region", 406 | default=os.environ.get("BLUELINK_REGION", const.REGION_CANADA), 407 | choices=sorted(const.REGIONS.values()), 408 | help="Car's region, use env var BLUELINK_REGION", 409 | ) 410 | parser.add_argument( 411 | "--brand", 412 | default=os.environ.get("BLUELINK_BRAND", const.BRAND_HYUNDAI), 413 | choices=sorted(const.BRANDS.values()), 414 | help="Car's brand, use env var BLUELINK_BRAND", 415 | ) 416 | parser.add_argument( 417 | "--username", 418 | default=default_username, 419 | help="Bluelink account username, use env var BLUELINK_USERNAME", 420 | required=not default_username, 421 | ) 422 | parser.add_argument( 423 | "--password", 424 | default=default_password, 425 | help="Bluelink account password, use env var BLUELINK_PASSWORD", 426 | required=not default_password, 427 | ) 428 | parser.add_argument( 429 | "--pin", 430 | type=str, 431 | default=default_pin, 432 | help="Bluelink account pin, use env var BLUELINK_PIN", 433 | required=not default_pin, 434 | ) 435 | parser.add_argument("-v", "--verbose", action=argparse.BooleanOptionalAction) 436 | subparsers = parser.add_subparsers(help="Commands", required=True) 437 | parser_info = subparsers.add_parser( 438 | "info", help="Prints infos about the cars found" 439 | ) 440 | parser_info.set_defaults(func=cmd_info) 441 | parser_info.add_argument( 442 | "--json", 443 | type=argparse.FileType("w", encoding="UTF-8"), 444 | help="Save data to file as JSON", 445 | ) 446 | 447 | args = parser.parse_args() 448 | logging.basicConfig(level=logging.DEBUG if args.verbose else logging.ERROR) 449 | 450 | # Reverse lookup. 451 | region = [k for k, v in const.REGIONS.items() if v == args.region][0] 452 | brand = [k for k, v in const.BRANDS.items() if v == args.brand][0] 453 | 454 | vm = hyundai_kia_connect_api.VehicleManager( 455 | region=region, 456 | brand=brand, 457 | username=args.username, 458 | password=args.password, 459 | pin=args.pin, 460 | geocode_api_enable=True, 461 | geocode_api_use_email=True, 462 | ) 463 | # TODO: Cache token. 464 | vm.check_and_refresh_token() 465 | vm.update_all_vehicles_with_cached_state() 466 | return args.func(vm, args) 467 | 468 | 469 | if __name__ == "__main__": 470 | sys.exit(main()) 471 | -------------------------------------------------------------------------------- /hyundai_kia_connect_api/Vehicle.py: -------------------------------------------------------------------------------- 1 | # pylint:disable=missing-class-docstring,missing-function-docstring,wildcard-import,unused-wildcard-import,invalid-name,logging-fstring-interpolation 2 | """Vehicle class""" 3 | 4 | import logging 5 | import datetime 6 | import typing 7 | from dataclasses import dataclass, field 8 | 9 | from .utils import get_float, get_safe_local_datetime 10 | from .const import DISTANCE_UNITS 11 | 12 | _LOGGER = logging.getLogger(__name__) 13 | 14 | 15 | @dataclass 16 | class TripInfo: 17 | """Trip Info""" 18 | 19 | hhmmss: str = None # will not be filled by summary 20 | drive_time: int = None # minutes 21 | idle_time: int = None # minutes 22 | distance: float = None 23 | avg_speed: float = None 24 | max_speed: int = None 25 | 26 | 27 | @dataclass 28 | class DayTripCounts: 29 | """Day trip counts""" 30 | 31 | yyyymmdd: str = None 32 | trip_count: int = None 33 | 34 | 35 | @dataclass 36 | class MonthTripInfo: 37 | """Month Trip Info""" 38 | 39 | yyyymm: str = None 40 | summary: TripInfo = None 41 | day_list: list[DayTripCounts] = field(default_factory=list) 42 | 43 | 44 | @dataclass 45 | class DayTripInfo: 46 | """Day Trip Info""" 47 | 48 | yyyymmdd: str = None 49 | summary: TripInfo = None 50 | trip_list: list[TripInfo] = field(default_factory=list) 51 | 52 | 53 | @dataclass 54 | class DailyDrivingStats: 55 | # energy stats are expressed in watthours (Wh) 56 | date: datetime.datetime = None 57 | total_consumed: int = None 58 | engine_consumption: int = None 59 | climate_consumption: int = None 60 | onboard_electronics_consumption: int = None 61 | battery_care_consumption: int = None 62 | regenerated_energy: int = None 63 | distance: float = None 64 | distance_unit: str = DISTANCE_UNITS[1] # set to kms by default 65 | 66 | 67 | @dataclass 68 | class Vehicle: 69 | id: str = None 70 | name: str = None 71 | model: str = None 72 | registration_date: str = None 73 | year: int = None 74 | VIN: str = None 75 | key: str = None 76 | # EU or Type 1 version: 77 | ccu_ccs2_protocol_support: int = None 78 | # Hyundai USA: 79 | generation: int = None 80 | # Not part of the API, enabled in our library for scanning. 81 | enabled: bool = True 82 | 83 | # Shared (EV/PHEV/HEV/IC) 84 | # General 85 | _total_driving_range: float = None 86 | _total_driving_range_value: float = None 87 | _total_driving_range_unit: str = None 88 | 89 | _odometer: float = None 90 | _odometer_value: float = None 91 | _odometer_unit: str = None 92 | 93 | _geocode_address: str = None 94 | _geocode_name: str = None 95 | 96 | car_battery_percentage: int = None 97 | engine_is_running: bool = None 98 | 99 | _last_updated_at: datetime.datetime = None 100 | timezone: datetime.timezone = datetime.timezone.utc # default UTC 101 | 102 | dtc_count: typing.Union[int, None] = None 103 | dtc_descriptions: typing.Union[dict, None] = None 104 | 105 | smart_key_battery_warning_is_on: bool = None 106 | washer_fluid_warning_is_on: bool = None 107 | brake_fluid_warning_is_on: bool = None 108 | 109 | # Climate 110 | _air_temperature: float = None 111 | _air_temperature_value: float = None 112 | _air_temperature_unit: str = None 113 | 114 | air_control_is_on: bool = None 115 | defrost_is_on: bool = None 116 | steering_wheel_heater_is_on: bool = None 117 | back_window_heater_is_on: bool = None 118 | side_mirror_heater_is_on: bool = None 119 | front_left_seat_status: str = None 120 | front_right_seat_status: str = None 121 | rear_left_seat_status: str = None 122 | rear_right_seat_status: str = None 123 | 124 | # Door Status 125 | is_locked: bool = None 126 | front_left_door_is_locked: bool = None 127 | front_right_door_is_locked: bool = None 128 | back_left_door_is_locked: bool = None 129 | back_right_door_is_locked: bool = None 130 | front_left_door_is_open: bool = None 131 | front_right_door_is_open: bool = None 132 | back_left_door_is_open: bool = None 133 | back_right_door_is_open: bool = None 134 | trunk_is_open: bool = None 135 | hood_is_open: bool = None 136 | 137 | # Window Status 138 | front_left_window_is_open: bool = None 139 | front_right_window_is_open: bool = None 140 | back_left_window_is_open: bool = None 141 | back_right_window_is_open: bool = None 142 | sunroof_is_open: bool = None 143 | 144 | # Tire Pressure 145 | tire_pressure_all_warning_is_on: bool = None 146 | tire_pressure_rear_left_warning_is_on: bool = None 147 | tire_pressure_front_left_warning_is_on: bool = None 148 | tire_pressure_front_right_warning_is_on: bool = None 149 | tire_pressure_rear_right_warning_is_on: bool = None 150 | 151 | # Service Data 152 | _next_service_distance: float = None 153 | _next_service_distance_value: float = None 154 | _next_service_distance_unit: str = None 155 | _last_service_distance: float = None 156 | _last_service_distance_value: float = None 157 | _last_service_distance_unit: str = None 158 | 159 | # Location 160 | _location_latitude: float = None 161 | _location_longitude: float = None 162 | _location_last_set_time: datetime.datetime = None 163 | 164 | # EV fields (EV/PHEV) 165 | 166 | ev_charge_port_door_is_open: typing.Union[bool, None] = None 167 | ev_charging_power: typing.Union[float, None] = None # Charging power in kW 168 | 169 | ev_charge_limits_dc: typing.Union[int, None] = None 170 | ev_charge_limits_ac: typing.Union[int, None] = None 171 | ev_charging_current: typing.Union[int, None] = ( 172 | None # Europe feature only, ac charging current limit 173 | ) 174 | ev_v2l_discharge_limit: typing.Union[int, None] = None 175 | 176 | # energy consumed and regenerated since the vehicle was paired with the account 177 | # (so not necessarily for the vehicle's lifetime) 178 | # expressed in watt-hours (Wh) 179 | total_power_consumed: float = None # Europe feature only 180 | total_power_regenerated: float = None # Europe feature only 181 | # energy consumed in the last ~30 days 182 | # expressed in watt-hours (Wh) 183 | power_consumption_30d: float = None # Europe feature only 184 | 185 | # feature only available for some regions (getter/setter for sorting) 186 | _daily_stats: list[DailyDrivingStats] = field(default_factory=list) 187 | 188 | # Other statuses from KiaCA logs 189 | accessory_on: bool = None 190 | ign3: bool = None 191 | remote_ignition: bool = None 192 | transmission_condition: str = None 193 | sleep_mode_check: bool = None 194 | 195 | # Lamp status fields (KiaUvoApiEU and CA) 196 | headlamp_status: str = None 197 | headlamp_left_low: bool = None 198 | headlamp_right_low: bool = None 199 | stop_lamp_left: bool = None 200 | stop_lamp_right: bool = None 201 | turn_signal_left_front: bool = None 202 | turn_signal_right_front: bool = None 203 | turn_signal_left_rear: bool = None 204 | turn_signal_right_rear: bool = None 205 | 206 | @property 207 | def daily_stats(self): 208 | return self._daily_stats 209 | 210 | @daily_stats.setter 211 | def daily_stats(self, value): 212 | result = value 213 | if result is not None and len(result) > 0: # sort on decreasing date 214 | _LOGGER.debug(f"before daily_stats: {result}") 215 | result.sort(reverse=True, key=lambda k: k.date) 216 | _LOGGER.debug(f"after daily_stats: {result}") 217 | self._daily_stats = result 218 | 219 | # feature only available for some regions (getter/setter for sorting) 220 | _month_trip_info: MonthTripInfo = None 221 | 222 | @property 223 | def month_trip_info(self): 224 | return self._month_trip_info 225 | 226 | @month_trip_info.setter 227 | def month_trip_info(self, value): 228 | result = value 229 | if ( 230 | result is not None 231 | and hasattr(result, "day_list") 232 | and len(result.day_list) > 0 233 | ): # sort on increasing yyyymmdd 234 | _LOGGER.debug(f"before month_trip_info: {result}") 235 | result.day_list.sort(key=lambda k: k.yyyymmdd) 236 | _LOGGER.debug(f"after month_trip_info: {result}") 237 | self._month_trip_info = result 238 | 239 | # feature only available for some regions (getter/setter for sorting) 240 | _day_trip_info: DayTripInfo = None 241 | 242 | @property 243 | def day_trip_info(self): 244 | return self._day_trip_info 245 | 246 | @day_trip_info.setter 247 | def day_trip_info(self, value): 248 | result = value 249 | if ( 250 | result is not None 251 | and hasattr(result, "trip_list") 252 | and len(result.trip_list) > 0 253 | ): # sort on descending hhmmss 254 | _LOGGER.debug(f"before day_trip_info: {result}") 255 | result.trip_list.sort(reverse=True, key=lambda k: k.hhmmss) 256 | _LOGGER.debug(f"after day_trip_info: {result}") 257 | self._day_trip_info = result 258 | 259 | ev_battery_percentage: int = None 260 | ev_battery_soh_percentage: int = None 261 | ev_battery_remain: int = None 262 | ev_battery_capacity: int = None 263 | ev_battery_is_charging: bool = None 264 | ev_battery_is_plugged_in: bool = None 265 | 266 | _ev_driving_range: float = None 267 | _ev_driving_range_value: float = None 268 | _ev_driving_range_unit: str = None 269 | 270 | _ev_estimated_current_charge_duration: int = None 271 | _ev_estimated_current_charge_duration_value: int = None 272 | _ev_estimated_current_charge_duration_unit: str = None 273 | 274 | _ev_estimated_fast_charge_duration: int = None 275 | _ev_estimated_fast_charge_duration_value: int = None 276 | _ev_estimated_fast_charge_duration_unit: str = None 277 | 278 | _ev_estimated_portable_charge_duration: int = None 279 | _ev_estimated_portable_charge_duration_value: int = None 280 | _ev_estimated_portable_charge_duration_unit: str = None 281 | 282 | _ev_estimated_station_charge_duration: int = None 283 | _ev_estimated_station_charge_duration_value: int = None 284 | _ev_estimated_station_charge_duration_unit: str = None 285 | 286 | _ev_target_range_charge_AC: typing.Union[float, None] = None 287 | _ev_target_range_charge_AC_value: typing.Union[float, None] = None 288 | _ev_target_range_charge_AC_unit: typing.Union[str, None] = None 289 | 290 | _ev_target_range_charge_DC: typing.Union[float, None] = None 291 | _ev_target_range_charge_DC_value: typing.Union[float, None] = None 292 | _ev_target_range_charge_DC_unit: typing.Union[str, None] = None 293 | 294 | ev_first_departure_enabled: typing.Union[bool, None] = None 295 | ev_second_departure_enabled: typing.Union[bool, None] = None 296 | 297 | ev_first_departure_days: typing.Union[list, None] = None 298 | ev_second_departure_days: typing.Union[list, None] = None 299 | 300 | ev_first_departure_time: typing.Union[datetime.time, None] = None 301 | ev_second_departure_time: typing.Union[datetime.time, None] = None 302 | 303 | ev_first_departure_climate_enabled: typing.Union[bool, None] = None 304 | ev_second_departure_climate_enabled: typing.Union[bool, None] = None 305 | 306 | _ev_first_departure_climate_temperature: typing.Union[float, None] = None 307 | _ev_first_departure_climate_temperature_value: typing.Union[float, None] = None 308 | _ev_first_departure_climate_temperature_unit: typing.Union[str, None] = None 309 | 310 | _ev_second_departure_climate_temperature: typing.Union[float, None] = None 311 | _ev_second_departure_climate_temperature_value: typing.Union[float, None] = None 312 | _ev_second_departure_climate_temperature_unit: typing.Union[str, None] = None 313 | 314 | ev_first_departure_climate_defrost: typing.Union[bool, None] = None 315 | ev_second_departure_climate_defrost: typing.Union[bool, None] = None 316 | 317 | ev_off_peak_start_time: typing.Union[datetime.time, None] = None 318 | ev_off_peak_end_time: typing.Union[datetime.time, None] = None 319 | ev_off_peak_charge_only_enabled: typing.Union[bool, None] = None 320 | 321 | ev_schedule_charge_enabled: typing.Union[bool, None] = None 322 | 323 | # IC fields (PHEV/HEV/IC) 324 | _fuel_driving_range: float = None 325 | _fuel_driving_range_value: float = None 326 | _fuel_driving_range_unit: str = None 327 | fuel_level: float = None 328 | 329 | fuel_level_is_low: bool = None 330 | 331 | # Calculated fields 332 | engine_type: str = None 333 | 334 | # Debug fields 335 | data: dict = None 336 | 337 | @property 338 | def geocode(self): 339 | return self._geocode_name, self._geocode_address 340 | 341 | @geocode.setter 342 | def geocode(self, value): 343 | if value: 344 | self._geocode_name = value[0] 345 | self._geocode_address = value[1] 346 | else: 347 | self._geocode_name = None 348 | self._geocode_address = None 349 | 350 | @property 351 | def total_driving_range(self): 352 | return self._total_driving_range 353 | 354 | @property 355 | def total_driving_range_unit(self): 356 | return self._total_driving_range_unit 357 | 358 | @total_driving_range.setter 359 | def total_driving_range(self, value): 360 | self._total_driving_range_value = value[0] 361 | self._total_driving_range_unit = value[1] 362 | self._total_driving_range = value[0] 363 | 364 | @property 365 | def next_service_distance(self): 366 | return self._next_service_distance 367 | 368 | @next_service_distance.setter 369 | def next_service_distance(self, value): 370 | self._next_service_distance_value = value[0] 371 | self._next_service_distance_unit = value[1] 372 | self._next_service_distance = value[0] 373 | 374 | @property 375 | def last_service_distance(self): 376 | return self._last_service_distance 377 | 378 | @last_service_distance.setter 379 | def last_service_distance(self, value): 380 | self._last_service_distance_value = value[0] 381 | self._last_service_distance_unit = value[1] 382 | self._last_service_distance = value[0] 383 | 384 | @property 385 | def last_updated_at(self): 386 | return self._last_updated_at 387 | 388 | @last_updated_at.setter 389 | def last_updated_at(self, value): 390 | # workaround for: Timestamp of "last_updated_at" sensor is wrong #931 391 | # https://github.com/Hyundai-Kia-Connect/kia_uvo/issues/931#issuecomment-2381569934 392 | newest_updated_at = get_safe_local_datetime(value) 393 | previous_updated_at = self._last_updated_at 394 | if newest_updated_at and previous_updated_at: # both filled 395 | if newest_updated_at < previous_updated_at: 396 | utcoffset = newest_updated_at.utcoffset() 397 | newest_updated_at_corrected = newest_updated_at + utcoffset 398 | if newest_updated_at_corrected >= previous_updated_at: 399 | newest_updated_at = newest_updated_at_corrected 400 | if newest_updated_at < previous_updated_at: 401 | newest_updated_at = previous_updated_at # keep old because newer 402 | self._last_updated_at = newest_updated_at 403 | 404 | @property 405 | def location_latitude(self): 406 | return self._location_latitude 407 | 408 | @property 409 | def location_longitude(self): 410 | return self._location_longitude 411 | 412 | @property 413 | def location(self): 414 | return self._location_longitude, self._location_latitude 415 | 416 | @property 417 | def location_last_updated_at(self): 418 | """ 419 | return last location datetime. 420 | last_updated_at and location_last_updated_at can be different. 421 | The newest of those 2 can be computed by the caller. 422 | """ 423 | return self._location_last_set_time 424 | 425 | @location.setter 426 | def location(self, value): 427 | self._location_latitude = value[0] 428 | self._location_longitude = value[1] 429 | self._location_last_set_time = get_safe_local_datetime(value[2]) 430 | 431 | @property 432 | def odometer(self): 433 | return self._odometer 434 | 435 | @property 436 | def odometer_unit(self): 437 | return self._odometer_unit 438 | 439 | @odometer.setter 440 | def odometer(self, value): 441 | float_value = get_float(value[0]) 442 | self._odometer_value = float_value 443 | self._odometer_unit = value[1] 444 | self._odometer = float_value 445 | 446 | @property 447 | def air_temperature(self): 448 | return self._air_temperature 449 | 450 | @air_temperature.setter 451 | def air_temperature(self, value): 452 | self._air_temperature_value = value[0] 453 | self._air_temperature_unit = value[1] 454 | self._air_temperature = value[0] if value[0] != "OFF" else None 455 | 456 | @property 457 | def ev_driving_range(self): 458 | return self._ev_driving_range 459 | 460 | @property 461 | def ev_driving_range_unit(self): 462 | return self._ev_driving_range_unit 463 | 464 | @ev_driving_range.setter 465 | def ev_driving_range(self, value): 466 | self._ev_driving_range_value = value[0] 467 | self._ev_driving_range_unit = value[1] 468 | self._ev_driving_range = value[0] 469 | 470 | @property 471 | def ev_estimated_current_charge_duration(self): 472 | return self._ev_estimated_current_charge_duration 473 | 474 | @ev_estimated_current_charge_duration.setter 475 | def ev_estimated_current_charge_duration(self, value): 476 | self._ev_estimated_current_charge_duration_value = value[0] 477 | self._ev_estimated_current_charge_duration_unit = value[1] 478 | self._ev_estimated_current_charge_duration = value[0] 479 | 480 | @property 481 | def ev_estimated_fast_charge_duration(self): 482 | return self._ev_estimated_fast_charge_duration 483 | 484 | @ev_estimated_fast_charge_duration.setter 485 | def ev_estimated_fast_charge_duration(self, value): 486 | self._ev_estimated_fast_charge_duration_value = value[0] 487 | self._ev_estimated_fast_charge_duration_unit = value[1] 488 | self._ev_estimated_fast_charge_duration = value[0] 489 | 490 | @property 491 | def ev_estimated_portable_charge_duration(self): 492 | return self._ev_estimated_portable_charge_duration 493 | 494 | @ev_estimated_portable_charge_duration.setter 495 | def ev_estimated_portable_charge_duration(self, value): 496 | self._ev_estimated_portable_charge_duration_value = value[0] 497 | self._ev_estimated_portable_charge_duration_unit = value[1] 498 | self._ev_estimated_portable_charge_duration = value[0] 499 | 500 | @property 501 | def ev_estimated_station_charge_duration(self): 502 | return self._ev_estimated_station_charge_duration 503 | 504 | @ev_estimated_station_charge_duration.setter 505 | def ev_estimated_station_charge_duration(self, value): 506 | self._ev_estimated_station_charge_duration_value = value[0] 507 | self._ev_estimated_station_charge_duration_unit = value[1] 508 | self._ev_estimated_station_charge_duration = value[0] 509 | 510 | @property 511 | def ev_target_range_charge_AC(self): 512 | return self._ev_target_range_charge_AC 513 | 514 | @property 515 | def ev_target_range_charge_AC_unit(self): 516 | return self._ev_target_range_charge_AC_unit 517 | 518 | @ev_target_range_charge_AC.setter 519 | def ev_target_range_charge_AC(self, value): 520 | self._ev_target_range_charge_AC_value = value[0] 521 | self._ev_target_range_charge_AC_unit = value[1] 522 | self._ev_target_range_charge_AC = value[0] 523 | 524 | @property 525 | def ev_target_range_charge_DC(self): 526 | return self._ev_target_range_charge_DC 527 | 528 | @property 529 | def ev_target_range_charge_DC_unit(self): 530 | return self._ev_target_range_charge_DC_unit 531 | 532 | @ev_target_range_charge_DC.setter 533 | def ev_target_range_charge_DC(self, value): 534 | self._ev_target_range_charge_DC_value = value[0] 535 | self._ev_target_range_charge_DC_unit = value[1] 536 | self._ev_target_range_charge_DC = value[0] 537 | 538 | @property 539 | def ev_first_departure_climate_temperature(self): 540 | return self._ev_first_departure_climate_temperature 541 | 542 | @property 543 | def ev_first_departure_climate_temperature_unit(self): 544 | return self._ev_first_departure_climate_temperature_unit 545 | 546 | @ev_first_departure_climate_temperature.setter 547 | def ev_first_departure_climate_temperature(self, value): 548 | self._ev_first_departure_climate_temperature_value = value[0] 549 | self._ev_first_departure_climate_temperature_unit = value[1] 550 | self._ev_first_departure_climate_temperature = value[0] 551 | 552 | @property 553 | def ev_second_departure_climate_temperature(self): 554 | return self._ev_second_departure_climate_temperature 555 | 556 | @property 557 | def ev_second_departure_climate_temperature_unit(self): 558 | return self._ev_second_departure_climate_temperature_unit 559 | 560 | @ev_second_departure_climate_temperature.setter 561 | def ev_second_departure_climate_temperature(self, value): 562 | self._ev_second_departure_climate_temperature_value = value[0] 563 | self._ev_second_departure_climate_temperature_unit = value[1] 564 | self._ev_second_departure_climate_temperature = value[0] 565 | 566 | @property 567 | def fuel_driving_range(self): 568 | return self._fuel_driving_range 569 | 570 | @fuel_driving_range.setter 571 | def fuel_driving_range(self, value): 572 | self._fuel_driving_range_value = value[0] 573 | self._fuel_driving_range_unit = value[1] 574 | self._fuel_driving_range = value[0] 575 | -------------------------------------------------------------------------------- /hyundai_kia_connect_api/HyundaiBlueLinkApiBR.py: -------------------------------------------------------------------------------- 1 | """HyundaiBlueLinkApiBR.py""" 2 | 3 | # pylint:disable=logging-fstring-interpolation,invalid-name,broad-exception-caught,unused-argument,missing-function-docstring,line-too-long 4 | 5 | import datetime as dt 6 | import logging 7 | import typing as ty 8 | from datetime import timedelta 9 | from time import sleep 10 | from urllib.parse import urljoin, urlparse 11 | 12 | import requests 13 | 14 | from .ApiImpl import ApiImpl, ClimateRequestOptions, WindowRequestOptions 15 | from .const import ( 16 | BRAND_HYUNDAI, 17 | BRANDS, 18 | DISTANCE_UNITS, 19 | DOMAIN, 20 | ENGINE_TYPES, 21 | ORDER_STATUS, 22 | SEAT_STATUS, 23 | VEHICLE_LOCK_ACTION, 24 | WINDOW_STATE, 25 | ) 26 | from .exceptions import APIError 27 | from .Token import Token 28 | from .utils import get_index_into_hex_temp, parse_date_br 29 | from .Vehicle import DayTripCounts, DayTripInfo, MonthTripInfo, TripInfo, Vehicle 30 | 31 | _LOGGER = logging.getLogger(__name__) 32 | 33 | 34 | class HyundaiBlueLinkApiBR(ApiImpl): 35 | """Brazilian Hyundai BlueLink API implementation.""" 36 | 37 | data_timezone = dt.timezone(dt.timedelta(hours=-3)) # Brazil (BRT/BRST) 38 | 39 | def __init__(self, region: int, brand: int, language: str = "pt-BR"): 40 | if BRANDS[brand] != BRAND_HYUNDAI: 41 | raise APIError( 42 | f"Unknown brand {BRANDS[brand]} for region Brazil. " 43 | "Only Hyundai is supported." 44 | ) 45 | 46 | self.language = language 47 | self.base_url = "br-ccapi.hyundai.com.br" 48 | self.api_url = f"https://{self.base_url}/api/v1/" 49 | self.api_v2_url = f"https://{self.base_url}/api/v2/" 50 | self.ccsp_device_id = "c6e5815b-3057-4e5e-95d5-e3d5d1d2093e" 51 | self.ccsp_service_id = "03f7df9b-7626-4853-b7bd-ad1e8d722bd5" 52 | self.ccsp_application_id = "513a491a-0d7c-4d6a-ac03-a2df127d73b0" 53 | self.basic_authorization_header = ( 54 | "Basic MDNmN2RmOWItNzYyNi00ODUzLWI3YmQtYWQxZThkNzIyYmQ1On" 55 | "lRejJiYzZDbjhPb3ZWT1I3UkRXd3hUcVZ3V0czeUtCWUZEZzBIc09Yc3l4eVBsSA==" 56 | ) 57 | 58 | self.api_headers = { 59 | "Content-Type": "application/json; charset=UTF-8", 60 | "Accept": "application/json, text/plain, */*", 61 | "Accept-Encoding": "br;q=1.0, gzip;q=0.9, deflate;q=0.8", 62 | "Accept-Language": "pt-BR;q=1.0, en-US;q=0.9", 63 | "User-Agent": "BR_BlueLink/1.0.14 (com.hyundai.bluelink.br; build:10132; iOS 18.4.0) Alamofire/5.9.1", 64 | "Host": self.base_url, 65 | "offset": "-3", 66 | "ccuCCS2ProtocolSupport": "0", 67 | } 68 | 69 | self.session = requests.Session() 70 | self.temperature_range = range(62, 82) 71 | 72 | def _build_api_url(self, path: str) -> str: 73 | """Build full API URL from path.""" 74 | return urljoin(self.api_url, path.lstrip("/")) 75 | 76 | def _build_api_v2_url(self, path: str) -> str: 77 | """Build API v2 URL from path.""" 78 | return urljoin(self.api_v2_url, path.lstrip("/")) 79 | 80 | def _get_authenticated_headers(self, token: Token) -> dict: 81 | """Get headers with authentication.""" 82 | headers = dict(self.api_headers) 83 | device_id = token.device_id or self.ccsp_device_id 84 | headers["ccsp-device-id"] = device_id 85 | headers["ccsp-application-id"] = self.ccsp_application_id 86 | headers["Authorization"] = f"Bearer {token.access_token}" 87 | return headers 88 | 89 | def _get_cookies(self) -> dict: 90 | """Request cookies from the API for authentication.""" 91 | params = { 92 | "response_type": "code", 93 | "client_id": self.ccsp_service_id, 94 | "redirect_uri": self._build_api_url("/user/oauth2/redirect"), 95 | } 96 | 97 | url = self._build_api_url("/user/oauth2/authorize") 98 | _LOGGER.debug(f"{DOMAIN} - Requesting cookies from {url}") 99 | response = self.session.get(url, params=params) 100 | response.raise_for_status() 101 | cookies = response.cookies.get_dict() 102 | _LOGGER.debug(f"{DOMAIN} - Got cookies: {cookies}") 103 | return cookies 104 | 105 | def _get_authorization_code( 106 | self, cookies: dict, username: str, password: str 107 | ) -> str: 108 | """Get authorization code from redirect URL.""" 109 | url = self._build_api_url("/user/signin") 110 | data = {"email": username, "password": password} 111 | 112 | headers = { 113 | "Referer": "https://br-ccapi.hyundai.com.br/web/v1/user/signin", 114 | "Accept-Encoding": "gzip, deflate, br", 115 | "Accept": "*/*", 116 | "Connection": "keep-alive", 117 | "Content-Type": "text/plain;charset=UTF-8", 118 | "Host": self.api_headers["Host"], 119 | "Accept-Language": "pt-BR,en-US;q=0.9,en;q=0.8", 120 | "Origin": "https://br-ccapi.hyundai.com.br", 121 | "User-Agent": ( 122 | "Mozilla/5.0 (iPhone; CPU iPhone OS 18_4 like Mac OS X) " 123 | "AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148_CCS_APP_iOS" 124 | ), 125 | } 126 | 127 | response = self.session.post(url, json=data, cookies=cookies, headers=headers) 128 | response.raise_for_status() 129 | response_data = response.json() 130 | 131 | _LOGGER.debug(f"{DOMAIN} - Got redirect URL") 132 | parsed_url = urlparse(response_data["redirectUrl"]) 133 | authorization_code = parsed_url.query.split("=")[1] 134 | return authorization_code 135 | 136 | def _get_auth_response(self, authorization_code: str) -> dict: 137 | """Request access token from the API.""" 138 | url = self._build_api_url("/user/oauth2/token") 139 | body = { 140 | "client_id": self.ccsp_service_id, 141 | "grant_type": "authorization_code", 142 | "code": authorization_code, 143 | "redirect_uri": self._build_api_url("/user/oauth2/redirect"), 144 | } 145 | headers = { 146 | "Content-Type": "application/x-www-form-urlencoded; charset=utf-8", 147 | "User-Agent": self.api_headers["User-Agent"], 148 | "Authorization": self.basic_authorization_header, 149 | } 150 | 151 | response = requests.post(url, data=body, headers=headers) 152 | response.raise_for_status() 153 | return response.json() 154 | 155 | def login( 156 | self, 157 | username: str, 158 | password: str, 159 | otp_handler: ty.Callable[[dict], dict] | None = None, 160 | pin: str | None = None, 161 | ) -> Token: 162 | """Login to Brazilian Hyundai API.""" 163 | _LOGGER.debug(f"{DOMAIN} - Logging in to Brazilian API") 164 | 165 | cookies = self._get_cookies() 166 | authorization_code = self._get_authorization_code(cookies, username, password) 167 | auth_response = self._get_auth_response(authorization_code) 168 | 169 | expires_in_seconds = auth_response["expires_in"] 170 | expires_at = dt.datetime.now(dt.timezone.utc) + timedelta( 171 | seconds=expires_in_seconds 172 | ) 173 | 174 | return Token( 175 | access_token=auth_response["access_token"], 176 | refresh_token=auth_response["refresh_token"], 177 | valid_until=expires_at, 178 | username=username, 179 | password=password, 180 | device_id=self.ccsp_device_id, 181 | pin=pin, 182 | ) 183 | 184 | def get_vehicles(self, token: Token) -> list: 185 | """Get list of vehicles.""" 186 | url = self._build_api_url("/spa/vehicles") 187 | headers = self._get_authenticated_headers(token) 188 | 189 | response = self.session.get(url, headers=headers) 190 | response.raise_for_status() 191 | response_data = response.json() 192 | 193 | _LOGGER.debug(f"{DOMAIN} - Got vehicles response") 194 | 195 | result = [] 196 | for entry in response_data["resMsg"]["vehicles"]: 197 | # Map vehicle type to engine type 198 | vehicle_type = entry["type"] 199 | if vehicle_type == "GN": 200 | entry_engine_type = ENGINE_TYPES.ICE 201 | elif vehicle_type == "EV": 202 | entry_engine_type = ENGINE_TYPES.EV 203 | elif vehicle_type in ["PHEV", "PE"]: 204 | entry_engine_type = ENGINE_TYPES.PHEV 205 | elif vehicle_type == "HV": 206 | entry_engine_type = ENGINE_TYPES.HEV 207 | else: 208 | entry_engine_type = ENGINE_TYPES.ICE 209 | 210 | vehicle = Vehicle( 211 | id=entry["vehicleId"], 212 | name=entry["nickname"], 213 | model=entry["vehicleName"], 214 | registration_date=entry["regDate"], 215 | VIN=entry["vin"], 216 | timezone=self.data_timezone, 217 | engine_type=entry_engine_type, 218 | ccu_ccs2_protocol_support=entry.get("ccuCCS2ProtocolSupport", 0), 219 | ) 220 | result.append(vehicle) 221 | 222 | return result 223 | 224 | def _get_vehicle_state( 225 | self, token: Token, vehicle: Vehicle, force_refresh: bool = False 226 | ) -> dict: 227 | """Get vehicle state (cached or forced refresh).""" 228 | url = self._build_api_url(f"/spa/vehicles/{vehicle.id}") 229 | 230 | if not vehicle.ccu_ccs2_protocol_support: 231 | url = url + "/status/latest" 232 | else: 233 | url = url + "/ccs2/carstatus/latest" 234 | 235 | headers = self._get_authenticated_headers(token) 236 | if force_refresh: 237 | headers["REFRESH"] = "true" 238 | 239 | _LOGGER.debug(f"{DOMAIN} - Getting vehicle state (force={force_refresh})") 240 | response = self.session.get(url, headers=headers) 241 | response.raise_for_status() 242 | return response.json()["resMsg"] 243 | 244 | def _get_vehicle_location(self, token: Token, vehicle: Vehicle) -> dict: 245 | """Get vehicle location.""" 246 | url = self._build_api_url(f"/spa/vehicles/{vehicle.id}/location/park") 247 | headers = self._get_authenticated_headers(token) 248 | 249 | try: 250 | response = self.session.get(url, headers=headers) 251 | response.raise_for_status() 252 | location_data = response.json()["resMsg"] 253 | _LOGGER.debug(f"{DOMAIN} - Got vehicle location") 254 | return location_data 255 | except Exception as e: 256 | _LOGGER.warning(f"{DOMAIN} - Failed to get vehicle location: {e}") 257 | return None 258 | 259 | def _update_vehicle_properties(self, vehicle: Vehicle, state: dict) -> None: 260 | """Update vehicle properties from state.""" 261 | # Parse timestamp 262 | if state.get("time"): 263 | vehicle.last_updated_at = parse_date_br(state["time"], self.data_timezone) 264 | else: 265 | vehicle.last_updated_at = dt.datetime.now(self.data_timezone) 266 | 267 | # Basic vehicle status 268 | vehicle.engine_is_running = state.get("engine", False) 269 | vehicle.air_control_is_on = state.get("airCtrlOn", False) 270 | 271 | # Battery (12V car battery, not EV battery) 272 | if battery := state.get("battery"): 273 | vehicle.car_battery_percentage = battery.get("batSoc") 274 | 275 | # Temperature 276 | if air_temp := state.get("airTemp"): 277 | temp_value = air_temp.get("value") 278 | temp_unit = air_temp.get("unit") 279 | # Handle special values: "00H" means off, or hex temperature values 280 | # For now, only set if it's a valid numeric value 281 | if temp_value and temp_value != "00H": 282 | try: 283 | # Try to parse as hex if it contains 'H' 284 | if "H" in str(temp_value): 285 | # Will be handled in future if needed 286 | pass 287 | else: 288 | vehicle.air_temperature = (temp_value, temp_unit) 289 | except (ValueError, TypeError, KeyError): 290 | pass 291 | 292 | # Fuel information 293 | vehicle.fuel_level = state.get("fuelLevel") 294 | vehicle.fuel_level_is_low = state.get("lowFuelLight", False) 295 | 296 | # Driving range (DTE = Distance To Empty) 297 | if dte := state.get("dte"): 298 | vehicle.fuel_driving_range = ( 299 | dte.get("value"), 300 | DISTANCE_UNITS.get(dte.get("unit")), 301 | ) 302 | 303 | # Doors 304 | door_state = state.get("doorOpen", {}) 305 | vehicle.is_locked = state.get("doorLock", True) 306 | vehicle.front_left_door_is_open = bool(door_state.get("frontLeft")) 307 | vehicle.front_right_door_is_open = bool(door_state.get("frontRight")) 308 | vehicle.back_left_door_is_open = bool(door_state.get("backLeft")) 309 | vehicle.back_right_door_is_open = bool(door_state.get("backRight")) 310 | vehicle.hood_is_open = state.get("hoodOpen", False) 311 | vehicle.trunk_is_open = state.get("trunkOpen", False) 312 | 313 | # Windows 314 | window_state = state.get("windowOpen", {}) 315 | vehicle.front_left_window_is_open = bool(window_state.get("frontLeft")) 316 | vehicle.front_right_window_is_open = bool(window_state.get("frontRight")) 317 | vehicle.back_left_window_is_open = bool(window_state.get("backLeft")) 318 | vehicle.back_right_window_is_open = bool(window_state.get("backRight")) 319 | 320 | # Climate control 321 | vehicle.defrost_is_on = state.get("defrost", False) 322 | 323 | # Steering wheel heat: 0=off, 1=on, 2=unknown/not available 324 | steer_heat = state.get("steerWheelHeat", 0) 325 | vehicle.steering_wheel_heater_is_on = steer_heat == 1 326 | 327 | # Side/back window heat: 0=off, 1=on, 2=unknown 328 | side_heat = state.get("sideBackWindowHeat", 0) 329 | vehicle.back_window_heater_is_on = side_heat == 1 330 | 331 | # Seat heater/ventilation status 332 | # Values: 0=off, 1=level1, 2=level2, 3=level3, etc. 333 | seat_state = state.get("seatHeaterVentState", {}) 334 | vehicle.front_left_seat_status = SEAT_STATUS.get( 335 | seat_state.get("drvSeatHeatState") 336 | ) 337 | vehicle.front_right_seat_status = SEAT_STATUS.get( 338 | seat_state.get("astSeatHeatState") 339 | ) 340 | vehicle.rear_left_seat_status = SEAT_STATUS.get( 341 | seat_state.get("rlSeatHeatState") 342 | ) 343 | vehicle.rear_right_seat_status = SEAT_STATUS.get( 344 | seat_state.get("rrSeatHeatState") 345 | ) 346 | 347 | # Tire pressure warnings 348 | # Note: Brazilian Creta only has "all" indicator, not individual sensors 349 | tire_lamp = state.get("tirePressureLamp", {}) 350 | vehicle.tire_pressure_all_warning_is_on = bool( 351 | tire_lamp.get("tirePressureLampAll") 352 | ) 353 | 354 | # Set individual tire warnings to match "all" if they don't exist 355 | # (Some vehicles may have individual sensors) 356 | tire_all = bool(tire_lamp.get("tirePressureLampAll")) 357 | vehicle.tire_pressure_rear_left_warning_is_on = bool( 358 | tire_lamp.get("tirePressureWarningLampRearLeft", tire_all) 359 | ) 360 | vehicle.tire_pressure_front_left_warning_is_on = bool( 361 | tire_lamp.get("tirePressureWarningLampFrontLeft", tire_all) 362 | ) 363 | vehicle.tire_pressure_front_right_warning_is_on = bool( 364 | tire_lamp.get("tirePressureWarningLampFrontRight", tire_all) 365 | ) 366 | vehicle.tire_pressure_rear_right_warning_is_on = bool( 367 | tire_lamp.get("tirePressureWarningLampRearRight", tire_all) 368 | ) 369 | 370 | # Warnings and alerts 371 | vehicle.washer_fluid_warning_is_on = state.get("washerFluidStatus", False) 372 | vehicle.brake_fluid_warning_is_on = state.get("breakOilStatus", False) 373 | vehicle.smart_key_battery_warning_is_on = state.get( 374 | "smartKeyBatteryWarning", False 375 | ) 376 | 377 | # Store raw data for future use 378 | vehicle.data = state 379 | 380 | def _update_vehicle_location(self, vehicle: Vehicle, location_data: dict) -> None: 381 | """Update vehicle location from location data.""" 382 | if not location_data: 383 | return 384 | 385 | coord = location_data.get("coord", {}) 386 | lat = coord.get("lat") 387 | lon = coord.get("lng") or coord.get("lon") 388 | time_str = location_data.get("time") 389 | 390 | if lat and lon: 391 | location_time = ( 392 | parse_date_br(time_str, self.data_timezone) if time_str else None 393 | ) 394 | vehicle.location = (lat, lon, location_time) 395 | 396 | def update_vehicle_with_cached_state(self, token: Token, vehicle: Vehicle) -> None: 397 | """Update vehicle with cached state from API.""" 398 | state = self._get_vehicle_state(token, vehicle, force_refresh=False) 399 | location_data = self._get_vehicle_location(token, vehicle) 400 | 401 | self._update_vehicle_properties(vehicle, state) 402 | self._update_vehicle_location(vehicle, location_data) 403 | 404 | def force_refresh_vehicle_state(self, token: Token, vehicle: Vehicle) -> None: 405 | """Force refresh vehicle state (wakes up the vehicle).""" 406 | state = self._get_vehicle_state(token, vehicle, force_refresh=True) 407 | location_data = self._get_vehicle_location(token, vehicle) 408 | 409 | self._update_vehicle_properties(vehicle, state) 410 | self._update_vehicle_location(vehicle, location_data) 411 | 412 | def _ensure_control_token(self, token: Token) -> str: 413 | """Ensure we have a valid control token for remote commands.""" 414 | control_token = getattr(token, "control_token", None) 415 | expires_at = getattr(token, "control_token_expires_at", None) 416 | if ( 417 | control_token 418 | and expires_at 419 | and expires_at - dt.timedelta(seconds=5) > dt.datetime.now(dt.timezone.utc) 420 | ): 421 | return control_token 422 | 423 | if not token.pin: 424 | raise APIError("PIN is required for remote commands.") 425 | 426 | device_id = token.device_id or self.ccsp_device_id 427 | token.device_id = device_id 428 | 429 | url = self._build_api_url("/user/pin") 430 | headers = self._get_authenticated_headers(token) 431 | payload = {"pin": token.pin, "deviceId": device_id} 432 | 433 | response = self.session.put(url, json=payload, headers=headers) 434 | response.raise_for_status() 435 | data = response.json() 436 | 437 | if data.get("controlToken") is None: 438 | raise APIError("Failed to obtain control token.") 439 | 440 | control_token = f"Bearer {data['controlToken']}" 441 | expires_in = data.get("expiresTime", 0) 442 | expires_at = dt.datetime.now(dt.timezone.utc) + dt.timedelta( 443 | seconds=expires_in or 600 444 | ) 445 | 446 | token.control_token = control_token 447 | token.control_token_expires_at = expires_at 448 | return control_token 449 | 450 | def lock_action( 451 | self, token: Token, vehicle: Vehicle, action: VEHICLE_LOCK_ACTION 452 | ) -> str: 453 | """Lock or unlock the vehicle.""" 454 | control_token = self._ensure_control_token(token) 455 | device_id = token.device_id or self.ccsp_device_id 456 | 457 | url = self._build_api_v2_url( 458 | f"spa/vehicles/{vehicle.id}/control/door", 459 | ) 460 | headers = self._get_authenticated_headers(token) 461 | headers["Authorization"] = control_token 462 | headers["ccsp-device-id"] = device_id 463 | headers["ccuCCS2ProtocolSupport"] = str(vehicle.ccu_ccs2_protocol_support or 0) 464 | 465 | payload = {"deviceId": device_id, "action": action.value} 466 | _LOGGER.debug(f"{DOMAIN} - Lock action request: %s", payload) 467 | 468 | response = self.session.post(url, json=payload, headers=headers) 469 | response.raise_for_status() 470 | data = response.json() 471 | _LOGGER.debug(f"{DOMAIN} - Lock action response: %s", data) 472 | 473 | if data.get("retCode") != "S": 474 | raise APIError( 475 | f"Lock action failed: {data.get('resCode')} {data.get('resMsg')}" 476 | ) 477 | 478 | return data.get("msgId") 479 | 480 | def check_action_status( 481 | self, 482 | token: Token, 483 | vehicle: Vehicle, 484 | action_id: str, 485 | synchronous: bool = False, 486 | timeout: int = 0, 487 | ) -> ORDER_STATUS: 488 | """Check status of a previously submitted remote command.""" 489 | if synchronous: 490 | if timeout < 1: 491 | raise APIError("Timeout must be 1 or higher for synchronous checks.") 492 | 493 | end_time = dt.datetime.now(dt.timezone.utc) + dt.timedelta(seconds=timeout) 494 | while dt.datetime.now(dt.timezone.utc) < end_time: 495 | state = self.check_action_status( 496 | token, vehicle, action_id, synchronous=False 497 | ) 498 | if state == ORDER_STATUS.PENDING: 499 | sleep(5) 500 | continue 501 | return state 502 | 503 | return ORDER_STATUS.TIMEOUT 504 | 505 | url = self._build_api_url(f"/spa/notifications/{vehicle.id}/records") 506 | headers = self._get_authenticated_headers(token) 507 | 508 | response = self.session.get(url, headers=headers) 509 | response.raise_for_status() 510 | data = response.json() 511 | _LOGGER.debug(f"{DOMAIN} - Action status response: %s", data) 512 | 513 | records = data.get("resMsg", []) 514 | for record in records: 515 | if record.get("recordId") != action_id: 516 | continue 517 | 518 | result = (record.get("result") or "").lower() 519 | if result == "success": 520 | return ORDER_STATUS.SUCCESS 521 | if result == "fail": 522 | return ORDER_STATUS.FAILED 523 | if result == "non-response": 524 | return ORDER_STATUS.TIMEOUT 525 | if result in ("", "pending", None): 526 | return ORDER_STATUS.PENDING 527 | 528 | return ORDER_STATUS.UNKNOWN 529 | 530 | def set_windows_state( 531 | self, token: Token, vehicle: Vehicle, options: WindowRequestOptions 532 | ) -> str: 533 | """Open or close all windows (BR API controls all windows together).""" 534 | control_token = self._ensure_control_token(token) 535 | device_id = token.device_id or self.ccsp_device_id 536 | 537 | url = self._build_api_v2_url(f"spa/vehicles/{vehicle.id}/control/window") 538 | 539 | # Brazilian API uses simple action for all windows at once 540 | # Check if any window should be open, otherwise close 541 | action = "open" 542 | if options.front_left == WINDOW_STATE.CLOSED: 543 | action = "close" 544 | elif options.front_right == WINDOW_STATE.CLOSED: 545 | action = "close" 546 | elif options.back_left == WINDOW_STATE.CLOSED: 547 | action = "close" 548 | elif options.back_right == WINDOW_STATE.CLOSED: 549 | action = "close" 550 | 551 | headers = self._get_authenticated_headers(token) 552 | headers["Authorization"] = control_token 553 | headers["ccsp-device-id"] = device_id 554 | headers["ccuCCS2ProtocolSupport"] = str(vehicle.ccu_ccs2_protocol_support or 0) 555 | 556 | payload = {"action": action, "deviceId": device_id} 557 | _LOGGER.debug(f"{DOMAIN} - Window action request: {payload}") 558 | 559 | response = self.session.post(url, json=payload, headers=headers) 560 | response.raise_for_status() 561 | data = response.json() 562 | _LOGGER.debug(f"{DOMAIN} - Window action response: {data}") 563 | 564 | if data.get("retCode") != "S": 565 | raise APIError( 566 | f"Window action failed: {data.get('resCode')} {data.get('resMsg')}" 567 | ) 568 | 569 | return data.get("msgId") 570 | 571 | def start_hazard_lights(self, token: Token, vehicle: Vehicle) -> str: 572 | """Turn on hazard lights (lights only, no horn).""" 573 | control_token = self._ensure_control_token(token) 574 | device_id = token.device_id or self.ccsp_device_id 575 | 576 | url = self._build_api_v2_url(f"spa/vehicles/{vehicle.id}/control/light") 577 | headers = self._get_authenticated_headers(token) 578 | headers["Authorization"] = control_token 579 | headers["ccsp-device-id"] = device_id 580 | headers["ccuCCS2ProtocolSupport"] = str(vehicle.ccu_ccs2_protocol_support or 0) 581 | 582 | _LOGGER.debug(f"{DOMAIN} - Hazard lights request") 583 | 584 | response = self.session.post(url, headers=headers) 585 | response.raise_for_status() 586 | data = response.json() 587 | _LOGGER.debug(f"{DOMAIN} - Hazard lights response: {data}") 588 | 589 | if data.get("retCode") != "S": 590 | raise APIError( 591 | f"Hazard lights failed: {data.get('resCode')} {data.get('resMsg')}" 592 | ) 593 | 594 | return data.get("msgId") 595 | 596 | def get_notification_history(self, token: Token, vehicle: Vehicle) -> list: 597 | """Get notification history (for debugging and tracking command results).""" 598 | url = self._build_api_url(f"/spa/notifications/{vehicle.id}/history") 599 | headers = self._get_authenticated_headers(token) 600 | 601 | response = self.session.get(url, headers=headers) 602 | response.raise_for_status() 603 | data = response.json() 604 | _LOGGER.debug(f"{DOMAIN} - Notification history response") 605 | 606 | return data.get("resMsg", []) 607 | 608 | def start_climate( 609 | self, token: Token, vehicle: Vehicle, options: ClimateRequestOptions 610 | ) -> str: 611 | """Start climate control with temperature and seat heating settings.""" 612 | control_token = self._ensure_control_token(token) 613 | device_id = token.device_id or self.ccsp_device_id 614 | 615 | url = self._build_api_v2_url(f"spa/vehicles/{vehicle.id}/control/engine") 616 | 617 | # Set defaults 618 | if options.set_temp is None: 619 | options.set_temp = 21 # 21°C default 620 | if options.duration is None: 621 | options.duration = 10 # 10 minutes default 622 | if options.defrost is None: 623 | options.defrost = False 624 | if options.climate is None: 625 | options.climate = True 626 | if options.heating is None: 627 | options.heating = 0 628 | if options.front_left_seat is None: 629 | options.front_left_seat = 0 630 | 631 | # Convert temperature to hex code 632 | # BR API uses direct Celsius value converted to hex 633 | temp_celsius = int(options.set_temp) 634 | temp_code = get_index_into_hex_temp(temp_celsius) 635 | 636 | # Map seat heating level (0-5 in ClimateRequestOptions to 0-8 for BR API) 637 | # 0=off, 1-3=heat levels, 4-5=cool levels (BR uses similar mapping) 638 | seat_heat_cmd = options.front_left_seat if options.front_left_seat else 0 639 | 640 | headers = self._get_authenticated_headers(token) 641 | headers["Authorization"] = control_token 642 | headers["ccsp-device-id"] = device_id 643 | headers["ccuCCS2ProtocolSupport"] = str(vehicle.ccu_ccs2_protocol_support or 0) 644 | 645 | payload = { 646 | "action": "start", 647 | "options": { 648 | "airCtrl": 1 if options.climate else 0, 649 | "heating1": int(options.heating), 650 | "seatHeaterVentCMD": {"drvSeatOptCmd": seat_heat_cmd}, 651 | "defrost": options.defrost, 652 | "igniOnDuration": options.duration, 653 | }, 654 | "hvacType": 1, 655 | "deviceId": device_id, 656 | "tempCode": temp_code, 657 | "unit": "C", 658 | } 659 | 660 | _LOGGER.debug(f"{DOMAIN} - Start climate request: {payload}") 661 | 662 | response = self.session.post(url, json=payload, headers=headers) 663 | response.raise_for_status() 664 | data = response.json() 665 | _LOGGER.debug(f"{DOMAIN} - Start climate response: {data}") 666 | 667 | if data.get("retCode") != "S": 668 | raise APIError( 669 | f"Start climate failed: {data.get('resCode')} {data.get('resMsg')}" 670 | ) 671 | 672 | return data.get("msgId") 673 | 674 | def stop_climate(self, token: Token, vehicle: Vehicle) -> str: 675 | """Stop climate control.""" 676 | control_token = self._ensure_control_token(token) 677 | device_id = token.device_id or self.ccsp_device_id 678 | 679 | url = self._build_api_v2_url(f"spa/vehicles/{vehicle.id}/control/engine") 680 | 681 | headers = self._get_authenticated_headers(token) 682 | headers["Authorization"] = control_token 683 | headers["ccsp-device-id"] = device_id 684 | headers["ccuCCS2ProtocolSupport"] = str(vehicle.ccu_ccs2_protocol_support or 0) 685 | 686 | payload = {"action": "stop", "deviceId": device_id} 687 | 688 | _LOGGER.debug(f"{DOMAIN} - Stop climate request: {payload}") 689 | 690 | response = self.session.post(url, json=payload, headers=headers) 691 | response.raise_for_status() 692 | data = response.json() 693 | _LOGGER.debug(f"{DOMAIN} - Stop climate response: {data}") 694 | 695 | if data.get("retCode") != "S": 696 | raise APIError( 697 | f"Stop climate failed: {data.get('resCode')} {data.get('resMsg')}" 698 | ) 699 | 700 | return data.get("msgId") 701 | 702 | def update_month_trip_info( 703 | self, token: Token, vehicle: Vehicle, yyyymm_string: str 704 | ) -> None: 705 | """Update monthly trip info.""" 706 | url = self._build_api_url(f"/spa/vehicles/{vehicle.id}/tripinfo") 707 | data = {"tripPeriodType": 0, "setTripMonth": yyyymm_string} 708 | 709 | headers = self._get_authenticated_headers(token) 710 | response = self.session.post(url, json=data, headers=headers) 711 | 712 | try: 713 | response.raise_for_status() 714 | trip_data = response.json()["resMsg"] 715 | 716 | if trip_data.get("monthTripDayCnt", 0) > 0: 717 | result = MonthTripInfo( 718 | yyyymm=yyyymm_string, 719 | day_list=[], 720 | summary=TripInfo( 721 | drive_time=trip_data.get("tripDrvTime"), 722 | idle_time=trip_data.get("tripIdleTime"), 723 | distance=trip_data.get("tripDist"), 724 | avg_speed=trip_data.get("tripAvgSpeed"), 725 | max_speed=trip_data.get("tripMaxSpeed"), 726 | ), 727 | ) 728 | 729 | for day in trip_data.get("tripDayList", []): 730 | processed_day = DayTripCounts( 731 | yyyymmdd=day["tripDayInMonth"], 732 | trip_count=day["tripCntDay"], 733 | ) 734 | result.day_list.append(processed_day) 735 | 736 | vehicle.month_trip_info = result 737 | except Exception as e: 738 | _LOGGER.warning(f"{DOMAIN} - Failed to get month trip info: {e}") 739 | 740 | def update_day_trip_info( 741 | self, token: Token, vehicle: Vehicle, yyyymmdd_string: str 742 | ) -> None: 743 | """Update daily trip info.""" 744 | url = self._build_api_url(f"/spa/vehicles/{vehicle.id}/tripinfo") 745 | data = {"tripPeriodType": 1, "setTripDay": yyyymmdd_string} 746 | 747 | headers = self._get_authenticated_headers(token) 748 | response = self.session.post(url, json=data, headers=headers) 749 | 750 | try: 751 | response.raise_for_status() 752 | trip_data = response.json()["resMsg"] 753 | day_trip_list = trip_data.get("dayTripList", []) 754 | 755 | if len(day_trip_list) > 0: 756 | msg = day_trip_list[0] 757 | result = DayTripInfo( 758 | yyyymmdd=yyyymmdd_string, 759 | trip_list=[], 760 | summary=TripInfo( 761 | drive_time=msg.get("tripDrvTime"), 762 | idle_time=msg.get("tripIdleTime"), 763 | distance=msg.get("tripDist"), 764 | avg_speed=msg.get("tripAvgSpeed"), 765 | max_speed=msg.get("tripMaxSpeed"), 766 | ), 767 | ) 768 | 769 | for trip in msg.get("tripList", []): 770 | processed_trip = TripInfo( 771 | hhmmss=trip.get("tripTime"), 772 | drive_time=trip.get("tripDrvTime"), 773 | idle_time=trip.get("tripIdleTime"), 774 | distance=trip.get("tripDist"), 775 | avg_speed=trip.get("tripAvgSpeed"), 776 | max_speed=trip.get("tripMaxSpeed"), 777 | ) 778 | result.trip_list.append(processed_trip) 779 | 780 | vehicle.day_trip_info = result 781 | except Exception as e: 782 | _LOGGER.warning(f"{DOMAIN} - Failed to get day trip info: {e}") 783 | -------------------------------------------------------------------------------- /hyundai_kia_connect_api/KiaUvoApiAU.py: -------------------------------------------------------------------------------- 1 | """KiaUvoApiAU""" 2 | 3 | # pylint:disable=missing-timeout,missing-class-docstring,missing-function-docstring,wildcard-import,unused-wildcard-import,invalid-name,logging-fstring-interpolation,broad-except,bare-except,super-init-not-called,unused-argument,line-too-long,too-many-lines 4 | 5 | import base64 6 | import datetime as dt 7 | import logging 8 | import random 9 | import typing as ty 10 | import uuid 11 | from urllib.parse import parse_qs, urlparse 12 | from zoneinfo import ZoneInfo 13 | 14 | import requests 15 | 16 | from .ApiImplType1 import ApiImplType1, _check_response_for_errors 17 | from .const import ( 18 | BRAND_HYUNDAI, 19 | BRAND_KIA, 20 | BRANDS, 21 | CHARGE_PORT_ACTION, 22 | DISTANCE_UNITS, 23 | DOMAIN, 24 | ENGINE_TYPES, 25 | REGION_AUSTRALIA, 26 | REGION_NZ, 27 | REGIONS, 28 | SEAT_STATUS, 29 | TEMPERATURE_UNITS, 30 | ) 31 | from .exceptions import AuthenticationError 32 | from .Token import Token 33 | from .utils import get_child_value, get_hex_temp_into_index, parse_datetime 34 | from .Vehicle import ( 35 | DailyDrivingStats, 36 | DayTripCounts, 37 | DayTripInfo, 38 | MonthTripInfo, 39 | TripInfo, 40 | Vehicle, 41 | ) 42 | 43 | _LOGGER = logging.getLogger(__name__) 44 | 45 | USER_AGENT_OK_HTTP: str = "okhttp/3.12.0" 46 | USER_AGENT_MOZILLA: str = "Mozilla/5.0 (Linux; Android 4.1.1; Galaxy Nexus Build/JRO03C) AppleWebKit/535.19 (KHTML, like Gecko) Chrome/18.0.1025.166 Mobile Safari/535.19" # noqa 47 | 48 | 49 | class KiaUvoApiAU(ApiImplType1): 50 | data_timezone = ZoneInfo("Australia/Sydney") 51 | temperature_range = [x * 0.5 for x in range(34, 54)] 52 | 53 | def __init__(self, region: int, brand: int, language: str) -> None: 54 | self.brand = brand 55 | if BRANDS[brand] == BRAND_KIA and REGIONS[region] == REGION_AUSTRALIA: 56 | self.BASE_URL: str = "au-apigw.ccs.kia.com.au:8082" 57 | self.CCSP_SERVICE_ID: str = "8acb778a-b918-4a8d-8624-73a0beb64289" 58 | self.APP_ID: str = "4ad4dcde-be23-48a8-bc1c-91b94f5c06f8" # Android app ID 59 | self.BASIC_AUTHORIZATION: str = "Basic OGFjYjc3OGEtYjkxOC00YThkLTg2MjQtNzNhMGJlYjY0Mjg5OjdTY01NbTZmRVlYZGlFUEN4YVBhUW1nZVlkbFVyZndvaDRBZlhHT3pZSVMyQ3U5VA==" # noqa 60 | self.cfb = base64.b64decode( 61 | "SGGCDRvrzmRa2WTNFQPUaNfSFdtPklZ48xUuVckigYasxmeOQqVgCAC++YNrI1vVabI=" 62 | ) 63 | elif BRANDS[brand] == BRAND_HYUNDAI: 64 | self.BASE_URL: str = "au-apigw.ccs.hyundai.com.au:8080" 65 | self.CCSP_SERVICE_ID: str = "855c72df-dfd7-4230-ab03-67cbf902bb1c" 66 | self.APP_ID: str = "f9ccfdac-a48d-4c57-bd32-9116963c24ed" # Android app ID 67 | self.BASIC_AUTHORIZATION: str = "Basic ODU1YzcyZGYtZGZkNy00MjMwLWFiMDMtNjdjYmY5MDJiYjFjOmU2ZmJ3SE0zMllOYmhRbDBwdmlhUHAzcmY0dDNTNms5MWVjZUEzTUpMZGJkVGhDTw==" # noqa 68 | self.cfb = base64.b64decode( 69 | "nGDHng3k4Cg9gWV+C+A6Yk/ecDopUNTkGmDpr2qVKAQXx9bvY2/YLoHPfObliK32mZQ=" 70 | ) 71 | elif BRANDS[brand] == BRAND_KIA and REGIONS[region] == REGION_NZ: 72 | self.BASE_URL: str = "au-apigw.ccs.kia.com.au:8082" 73 | self.CCSP_SERVICE_ID: str = "4ab606a7-cea4-48a0-a216-ed9c14a4a38c" 74 | self.APP_ID: str = "97745337-cac6-4a5b-afc3-e65ace81c994" # Android app ID 75 | self.BASIC_AUTHORIZATION: str = "Basic NGFiNjA2YTctY2VhNC00OGEwLWEyMTYtZWQ5YzE0YTRhMzhjOjBoYUZxWFRrS2t0Tktmemt4aFowYWt1MzFpNzRnMHlRRm01b2QybXo0TGRJNW1MWQ==" # noqa 76 | self.cfb = base64.b64decode( 77 | "SGGCDRvrzmRa2WTNFQPUaC1OsnAhQgPgcQETEfbY8abEjR/ICXK0p+Rayw5tHCGyiUA=" 78 | ) 79 | 80 | self.USER_API_URL: str = "https://" + self.BASE_URL + "/api/v1/user/" 81 | self.SPA_API_URL: str = "https://" + self.BASE_URL + "/api/v1/spa/" 82 | self.SPA_API_URL_V2: str = "https://" + self.BASE_URL + "/api/v2/spa/" 83 | self.CLIENT_ID: str = self.CCSP_SERVICE_ID 84 | 85 | def login( 86 | self, 87 | username: str, 88 | password: str, 89 | otp_handler: ty.Callable[[dict], dict] | None = None, 90 | pin: str | None = None, 91 | ) -> Token: 92 | stamp = self._get_stamp() 93 | device_id = self._get_device_id(stamp) 94 | cookies = self._get_cookies() 95 | # self._set_session_language(cookies) 96 | authorization_code = None 97 | try: 98 | authorization_code = self._get_authorization_code_with_redirect_url( 99 | username, password, cookies 100 | ) 101 | except Exception: 102 | _LOGGER.debug(f"{DOMAIN} - get_authorization_code_with_redirect_url failed") 103 | 104 | if authorization_code is None: 105 | raise AuthenticationError("Login Failed") 106 | 107 | _, access_token, authorization_code = self._get_access_token( 108 | authorization_code, stamp 109 | ) 110 | _, refresh_token = self._get_refresh_token(authorization_code, stamp) 111 | valid_until = dt.datetime.now(dt.timezone.utc) + dt.timedelta(hours=23) 112 | 113 | return Token( 114 | username=username, 115 | password=password, 116 | access_token=access_token, 117 | refresh_token=refresh_token, 118 | device_id=device_id, 119 | valid_until=valid_until, 120 | pin=pin, 121 | ) 122 | 123 | def update_vehicle_with_cached_state(self, token: Token, vehicle: Vehicle) -> None: 124 | url = self.SPA_API_URL + "vehicles/" + vehicle.id 125 | is_ccs2 = vehicle.ccu_ccs2_protocol_support != 0 126 | if is_ccs2: 127 | url += "/ccs2/carstatus/latest" 128 | else: 129 | url += "/status/latest" 130 | 131 | response = requests.get( 132 | url, 133 | headers=self._get_authenticated_headers( 134 | token, vehicle.ccu_ccs2_protocol_support 135 | ), 136 | ).json() 137 | 138 | _LOGGER.debug(f"{DOMAIN} - get_cached_vehicle_status response: {response}") 139 | _check_response_for_errors(response) 140 | 141 | if is_ccs2: 142 | state = response["resMsg"]["state"]["Vehicle"] 143 | self._update_vehicle_properties_ccs2(vehicle, state) 144 | else: 145 | location = self._get_location(token, vehicle) 146 | self._update_vehicle_properties( 147 | vehicle, 148 | { 149 | "status": response["resMsg"], 150 | "vehicleLocation": location, 151 | }, 152 | ) 153 | 154 | if ( 155 | vehicle.engine_type == ENGINE_TYPES.EV 156 | or vehicle.engine_type == ENGINE_TYPES.PHEV 157 | ): 158 | try: 159 | state = self._get_driving_info(token, vehicle) 160 | except Exception as e: 161 | # we don't know if all car types (ex: ICE cars) provide this 162 | # information. We also don't know what the API returns if 163 | # the info is unavailable. So, catch any exception and move on. 164 | _LOGGER.exception( 165 | """Failed to parse driving info. Possible reasons: 166 | - incompatible vehicle (ICE) 167 | - new API format 168 | - API outage 169 | """, 170 | exc_info=e, 171 | ) 172 | else: 173 | self._update_vehicle_drive_info(vehicle, state) 174 | 175 | def force_refresh_vehicle_state(self, token: Token, vehicle: Vehicle) -> None: 176 | status = self._get_forced_vehicle_state(token, vehicle) 177 | location = self._get_location(token, vehicle) 178 | self._update_vehicle_properties( 179 | vehicle, 180 | { 181 | "status": status, 182 | "vehicleLocation": location, 183 | }, 184 | ) 185 | # Only call for driving info on cars we know have a chance of supporting it. 186 | # Could be expanded if other types do support it. 187 | if ( 188 | vehicle.engine_type == ENGINE_TYPES.EV 189 | or vehicle.engine_type == ENGINE_TYPES.PHEV 190 | ): 191 | try: 192 | state = self._get_driving_info(token, vehicle) 193 | except Exception as e: 194 | # we don't know if all car types (ex: ICE cars) provide this 195 | # information. We also don't know what the API returns if 196 | # the info is unavailable. So, catch any exception and move on. 197 | _LOGGER.exception( 198 | """Failed to parse driving info. Possible reasons: 199 | - incompatible vehicle (ICE) 200 | - new API format 201 | - API outage 202 | """, 203 | exc_info=e, 204 | ) 205 | else: 206 | self._update_vehicle_drive_info(vehicle, state) 207 | 208 | def _update_vehicle_properties(self, vehicle: Vehicle, state: dict) -> None: 209 | if get_child_value(state, "status.time"): 210 | vehicle.last_updated_at = parse_datetime( 211 | get_child_value(state, "status.time"), self.data_timezone 212 | ) 213 | else: 214 | vehicle.last_updated_at = dt.datetime.now(self.data_timezone) 215 | 216 | if get_child_value(state, "status.odometer.value"): 217 | vehicle.odometer = ( 218 | get_child_value(state, "status.odometer.value"), 219 | DISTANCE_UNITS[ 220 | get_child_value( 221 | state, 222 | "status.odometer.unit", 223 | ) 224 | ], 225 | ) 226 | vehicle.car_battery_percentage = get_child_value(state, "status.battery.batSoc") 227 | vehicle.engine_is_running = get_child_value(state, "status.engine") 228 | 229 | if get_child_value(state, "status.airTemp.value"): 230 | tempIndex = get_hex_temp_into_index( 231 | get_child_value(state, "status.airTemp.value") 232 | ) 233 | 234 | vehicle.air_temperature = ( 235 | self.temperature_range[tempIndex], 236 | TEMPERATURE_UNITS[ 237 | get_child_value( 238 | state, 239 | "status.airTemp.unit", 240 | ) 241 | ], 242 | ) 243 | vehicle.defrost_is_on = get_child_value(state, "status.defrost") 244 | steer_wheel_heat = get_child_value(state, "status.steerWheelHeat") 245 | if steer_wheel_heat in [0, 2]: 246 | vehicle.steering_wheel_heater_is_on = False 247 | elif steer_wheel_heat == 1: 248 | vehicle.steering_wheel_heater_is_on = True 249 | 250 | vehicle.back_window_heater_is_on = get_child_value( 251 | state, "status.sideBackWindowHeat" 252 | ) 253 | vehicle.side_mirror_heater_is_on = get_child_value( 254 | state, "status.sideMirrorHeat" 255 | ) 256 | vehicle.front_left_seat_status = SEAT_STATUS[ 257 | get_child_value(state, "status.seatHeaterVentState.flSeatHeatState") 258 | ] 259 | vehicle.front_right_seat_status = SEAT_STATUS[ 260 | get_child_value(state, "status.seatHeaterVentState.frSeatHeatState") 261 | ] 262 | vehicle.rear_left_seat_status = SEAT_STATUS[ 263 | get_child_value(state, "status.seatHeaterVentState.rlSeatHeatState") 264 | ] 265 | vehicle.rear_right_seat_status = SEAT_STATUS[ 266 | get_child_value(state, "status.seatHeaterVentState.rrSeatHeatState") 267 | ] 268 | vehicle.is_locked = get_child_value(state, "status.doorLock") 269 | vehicle.front_left_door_is_open = get_child_value( 270 | state, "status.doorOpen.frontLeft" 271 | ) 272 | vehicle.front_right_door_is_open = get_child_value( 273 | state, "status.doorOpen.frontRight" 274 | ) 275 | vehicle.back_left_door_is_open = get_child_value( 276 | state, "status.doorOpen.backLeft" 277 | ) 278 | vehicle.back_right_door_is_open = get_child_value( 279 | state, "status.doorOpen.backRight" 280 | ) 281 | vehicle.hood_is_open = get_child_value(state, "status.hoodOpen") 282 | vehicle.front_left_window_is_open = get_child_value( 283 | state, "status.windowOpen.frontLeft" 284 | ) 285 | vehicle.front_right_window_is_open = get_child_value( 286 | state, "status.windowOpen.frontRight" 287 | ) 288 | vehicle.back_left_window_is_open = get_child_value( 289 | state, "status.windowOpen.backLeft" 290 | ) 291 | vehicle.back_right_window_is_open = get_child_value( 292 | state, "status.windowOpen.backRight" 293 | ) 294 | vehicle.tire_pressure_rear_left_warning_is_on = bool( 295 | get_child_value(state, "status.tirePressureLamp.tirePressureLampRL") 296 | ) 297 | vehicle.tire_pressure_front_left_warning_is_on = bool( 298 | get_child_value(state, "status.tirePressureLamp.tirePressureLampFL") 299 | ) 300 | vehicle.tire_pressure_front_right_warning_is_on = bool( 301 | get_child_value(state, "status.tirePressureLamp.tirePressureLampFR") 302 | ) 303 | vehicle.tire_pressure_rear_right_warning_is_on = bool( 304 | get_child_value(state, "status.tirePressureLamp.tirePressureLampRR") 305 | ) 306 | vehicle.tire_pressure_all_warning_is_on = bool( 307 | get_child_value(state, "status.tirePressureLamp.tirePressureLampAll") 308 | ) 309 | vehicle.trunk_is_open = get_child_value(state, "status.trunkOpen") 310 | vehicle.ev_battery_percentage = get_child_value( 311 | state, "status.evStatus.batteryStatus" 312 | ) 313 | vehicle.ev_battery_is_charging = get_child_value( 314 | state, "status.evStatus.batteryCharge" 315 | ) 316 | 317 | vehicle.ev_battery_is_plugged_in = get_child_value( 318 | state, "status.evStatus.batteryPlugin" 319 | ) 320 | 321 | ev_charge_port_door_is_open = get_child_value( 322 | state, "status.evStatus.chargePortDoorOpenStatus" 323 | ) 324 | 325 | if ev_charge_port_door_is_open == 1: 326 | vehicle.ev_charge_port_door_is_open = True 327 | elif ev_charge_port_door_is_open == 2: 328 | vehicle.ev_charge_port_door_is_open = False 329 | if ( 330 | get_child_value( 331 | state, 332 | "status.evStatus.drvDistance.0.rangeByFuel.totalAvailableRange.value", # noqa 333 | ) 334 | is not None 335 | ): 336 | vehicle.total_driving_range = ( 337 | round( 338 | float( 339 | get_child_value( 340 | state, 341 | "status.evStatus.drvDistance.0.rangeByFuel.totalAvailableRange.value", # noqa 342 | ) 343 | ), 344 | 1, 345 | ), 346 | DISTANCE_UNITS[ 347 | get_child_value( 348 | state, 349 | "status.evStatus.drvDistance.0.rangeByFuel.totalAvailableRange.unit", # noqa 350 | ) 351 | ], 352 | ) 353 | if ( 354 | get_child_value( 355 | state, 356 | "status.evStatus.drvDistance.0.rangeByFuel.evModeRange.value", 357 | ) 358 | is not None 359 | ): 360 | vehicle.ev_driving_range = ( 361 | round( 362 | float( 363 | get_child_value( 364 | state, 365 | "status.evStatus.drvDistance.0.rangeByFuel.evModeRange.value", # noqa 366 | ) 367 | ), 368 | 1, 369 | ), 370 | DISTANCE_UNITS[ 371 | get_child_value( 372 | state, 373 | "status.evStatus.drvDistance.0.rangeByFuel.evModeRange.unit", # noqa 374 | ) 375 | ], 376 | ) 377 | vehicle.ev_estimated_current_charge_duration = ( 378 | get_child_value(state, "status.evStatus.remainTime2.atc.value"), 379 | "m", 380 | ) 381 | vehicle.ev_estimated_fast_charge_duration = ( 382 | get_child_value(state, "status.evStatus.remainTime2.etc1.value"), 383 | "m", 384 | ) 385 | vehicle.ev_estimated_portable_charge_duration = ( 386 | get_child_value(state, "status.evStatus.remainTime2.etc2.value"), 387 | "m", 388 | ) 389 | vehicle.ev_estimated_station_charge_duration = ( 390 | get_child_value(state, "status.evStatus.remainTime2.etc3.value"), 391 | "m", 392 | ) 393 | 394 | target_soc_list = get_child_value( 395 | state, "status.evStatus.reservChargeInfos.targetSOClist" 396 | ) 397 | try: 398 | vehicle.ev_charge_limits_ac = [ 399 | x["targetSOClevel"] for x in target_soc_list if x["plugType"] == 1 400 | ][-1] 401 | vehicle.ev_charge_limits_dc = [ 402 | x["targetSOClevel"] for x in target_soc_list if x["plugType"] == 0 403 | ][-1] 404 | except Exception: 405 | _LOGGER.debug(f"{DOMAIN} - SOC Levels couldn't be found. May not be an EV.") 406 | if ( 407 | get_child_value( 408 | state, 409 | "status.evStatus.drvDistance.0.rangeByFuel.gasModeRange.value", 410 | ) 411 | is not None 412 | ): 413 | vehicle.fuel_driving_range = ( 414 | get_child_value( 415 | state, 416 | "status.evStatus.drvDistance.0.rangeByFuel.gasModeRange.value", # noqa 417 | ), 418 | DISTANCE_UNITS[ 419 | get_child_value( 420 | state, 421 | "status.evStatus.drvDistance.0.rangeByFuel.gasModeRange.unit", # noqa 422 | ) 423 | ], 424 | ) 425 | elif get_child_value( 426 | state, 427 | "status.dte.value", 428 | ): 429 | vehicle.fuel_driving_range = ( 430 | get_child_value( 431 | state, 432 | "status.dte.value", 433 | ), 434 | DISTANCE_UNITS[get_child_value(state, "status.dte.unit")], 435 | ) 436 | 437 | vehicle.ev_target_range_charge_AC = ( 438 | get_child_value( 439 | state, 440 | "status.evStatus.reservChargeInfos.targetSOClist.1.dte.rangeByFuel.totalAvailableRange.value", # noqa 441 | ), 442 | DISTANCE_UNITS[ 443 | get_child_value( 444 | state, 445 | "status.evStatus.reservChargeInfos.targetSOClist.1.dte.rangeByFuel.totalAvailableRange.unit", # noqa 446 | ) 447 | ], 448 | ) 449 | vehicle.ev_target_range_charge_DC = ( 450 | get_child_value( 451 | state, 452 | "status.evStatus.reservChargeInfos.targetSOClist.0.dte.rangeByFuel.totalAvailableRange.value", # noqa 453 | ), 454 | DISTANCE_UNITS[ 455 | get_child_value( 456 | state, 457 | "status.evStatus.reservChargeInfos.targetSOClist.0.dte.rangeByFuel.totalAvailableRange.unit", # noqa 458 | ) 459 | ], 460 | ) 461 | vehicle.ev_first_departure_enabled = get_child_value( 462 | state, 463 | "status.evStatus.reservChargeInfos.reservChargeInfo.reservChargeInfoDetail.reservChargeSet", # noqa 464 | ) 465 | vehicle.ev_second_departure_enabled = get_child_value( 466 | state, 467 | "status.evStatus.reservChargeInfos.reserveChargeInfo2.reservChargeInfoDetail.reservChargeSet", # noqa 468 | ) 469 | vehicle.ev_first_departure_days = get_child_value( 470 | state, 471 | "status.evStatus.reservChargeInfos.reservChargeInfo.reservChargeInfoDetail.reservInfo.day", # noqa 472 | ) 473 | vehicle.ev_second_departure_days = get_child_value( 474 | state, 475 | "status.evStatus.reservChargeInfos.reserveChargeInfo2.reservChargeInfoDetail.reservInfo.day", # noqa 476 | ) 477 | 478 | vehicle.ev_first_departure_time = self._get_time_from_string( 479 | get_child_value( 480 | state, 481 | "status.evStatus.reservChargeInfos.reservChargeInfo.reservChargeInfoDetail.reservInfo.time.time", # noqa 482 | ), 483 | get_child_value( 484 | state, 485 | "status.evStatus.reservChargeInfos.reservChargeInfo.reservChargeInfoDetail.reservInfo.time.timeSection", # noqa 486 | ), 487 | ) 488 | 489 | vehicle.ev_second_departure_time = self._get_time_from_string( 490 | get_child_value( 491 | state, 492 | "status.evStatus.reservChargeInfos.reserveChargeInfo2.reservChargeInfoDetail.reservInfo.time.time", # noqa 493 | ), 494 | get_child_value( 495 | state, 496 | "status.evStatus.reservChargeInfos.reserveChargeInfo2.reservChargeInfoDetail.reservInfo.time.timeSection", # noqa 497 | ), 498 | ) 499 | 500 | vehicle.ev_off_peak_start_time = self._get_time_from_string( 501 | get_child_value( 502 | state, 503 | "status.evStatus.reservChargeInfos.offpeakPowerInfo.offPeakPowerTime1.starttime.time", # noqa 504 | ), 505 | get_child_value( 506 | state, 507 | "status.evStatus.reservChargeInfos.offpeakPowerInfo.offPeakPowerTime1.starttime.timeSection", # noqa 508 | ), 509 | ) 510 | 511 | vehicle.ev_off_peak_end_time = self._get_time_from_string( 512 | get_child_value( 513 | state, 514 | "status.evStatus.reservChargeInfos.offpeakPowerInfo.offPeakPowerTime1.endtime.time", # noqa 515 | ), 516 | get_child_value( 517 | state, 518 | "status.evStatus.reservChargeInfos.offpeakPowerInfo.offPeakPowerTime1.endtime.timeSection", # noqa 519 | ), 520 | ) 521 | 522 | if get_child_value( 523 | state, 524 | "status.evStatus.reservChargeInfos.offpeakPowerInfo.offPeakPowerFlag", # noqa 525 | ): 526 | if ( 527 | get_child_value( 528 | state, 529 | "status.evStatus.reservChargeInfos.offpeakPowerInfo.offPeakPowerFlag", # noqa 530 | ) 531 | == 1 532 | ): 533 | vehicle.ev_off_peak_charge_only_enabled = True 534 | elif ( 535 | get_child_value( 536 | state, 537 | "status.evStatus.reservChargeInfos.offpeakPowerInfo.offPeakPowerFlag", # noqa 538 | ) 539 | == 2 540 | ): 541 | vehicle.ev_off_peak_charge_only_enabled = False 542 | 543 | vehicle.washer_fluid_warning_is_on = get_child_value( 544 | state, "status.washerFluidStatus" 545 | ) 546 | vehicle.brake_fluid_warning_is_on = get_child_value( 547 | state, "status.breakOilStatus" 548 | ) 549 | vehicle.fuel_level = get_child_value(state, "status.fuelLevel") 550 | vehicle.fuel_level_is_low = get_child_value(state, "status.lowFuelLight") 551 | vehicle.air_control_is_on = get_child_value(state, "status.airCtrlOn") 552 | vehicle.smart_key_battery_warning_is_on = get_child_value( 553 | state, "status.smartKeyBatteryWarning" 554 | ) 555 | 556 | if get_child_value(state, "vehicleLocation.coord.lat"): 557 | vehicle.location = ( 558 | get_child_value(state, "vehicleLocation.coord.lat"), 559 | get_child_value(state, "vehicleLocation.coord.lon"), 560 | parse_datetime( 561 | get_child_value(state, "vehicleLocation.time"), self.data_timezone 562 | ), 563 | ) 564 | vehicle.data = state 565 | 566 | def _update_vehicle_drive_info(self, vehicle: Vehicle, state: dict) -> None: 567 | vehicle.total_power_consumed = get_child_value(state, "totalPwrCsp") 568 | vehicle.power_consumption_30d = get_child_value(state, "consumption30d") 569 | vehicle.daily_stats = get_child_value(state, "dailyStats") 570 | 571 | def _get_location(self, token: Token, vehicle: Vehicle) -> dict: 572 | url = self.SPA_API_URL + "vehicles/" + vehicle.id + "/location/park" 573 | 574 | try: 575 | response = requests.get( 576 | url, headers=self._get_authenticated_headers(token) 577 | ).json() 578 | _LOGGER.debug(f"{DOMAIN} - _get_location response: {response}") 579 | _check_response_for_errors(response) 580 | return response["resMsg"] 581 | except Exception: 582 | _LOGGER.debug(f"{DOMAIN} - _get_location failed") 583 | return None 584 | 585 | def _get_forced_vehicle_state(self, token: Token, vehicle: Vehicle) -> dict: 586 | url = self.SPA_API_URL + "vehicles/" + vehicle.id + "/status" 587 | response = requests.get( 588 | url, headers=self._get_authenticated_headers(token) 589 | ).json() 590 | _LOGGER.debug(f"{DOMAIN} - Received forced vehicle data: {response}") 591 | _check_response_for_errors(response) 592 | mapped_response = {} 593 | mapped_response["vehicleStatus"] = response["resMsg"] 594 | return mapped_response 595 | 596 | def charge_port_action( 597 | self, token: Token, vehicle: Vehicle, action: CHARGE_PORT_ACTION 598 | ) -> str: 599 | # TODO: needs verification 600 | url = self.SPA_API_URL_V2 + "vehicles/" + vehicle.id + "/control/portdoor" 601 | 602 | payload = {"action": action.value, "deviceId": token.device_id} 603 | _LOGGER.debug(f"{DOMAIN} - Charge Port Action Request: {payload}") 604 | response = requests.post( 605 | url, json=payload, headers=self._get_control_headers(token, vehicle) 606 | ).json() 607 | _LOGGER.debug(f"{DOMAIN} - Charge Port Action Response: {response}") 608 | _check_response_for_errors(response) 609 | return response["msgId"] 610 | 611 | def _get_charge_limits(self, token: Token, vehicle: Vehicle) -> dict: 612 | # Not currently used as value is in the general get. 613 | # Most likely this forces the car the update it. 614 | url = f"{self.SPA_API_URL}vehicles/{vehicle.id}/charge/target" 615 | 616 | _LOGGER.debug(f"{DOMAIN} - Get Charging Limits Request") 617 | response = requests.get( 618 | url, headers=self._get_authenticated_headers(token) 619 | ).json() 620 | _LOGGER.debug(f"{DOMAIN} - Get Charging Limits Response: {response}") 621 | _check_response_for_errors(response) 622 | # API sometimes returns multiple entries per plug type and they conflict. 623 | # The car itself says the last entry per plug type is the truth when tested 624 | # (EU Ioniq Electric Facelift MY 2019) 625 | if response["resMsg"] is not None: 626 | return response["resMsg"] 627 | 628 | def _get_trip_info( 629 | self, 630 | token: Token, 631 | vehicle: Vehicle, 632 | date_string: str, 633 | trip_period_type: int, 634 | ) -> dict: 635 | url = self.SPA_API_URL + "vehicles/" + vehicle.id + "/tripinfo" 636 | if trip_period_type == 0: # month 637 | payload = {"tripPeriodType": 0, "setTripMonth": date_string} 638 | else: 639 | payload = {"tripPeriodType": 1, "setTripDay": date_string} 640 | 641 | _LOGGER.debug(f"{DOMAIN} - get_trip_info Request {payload}") 642 | response = requests.post( 643 | url, 644 | json=payload, 645 | headers=self._get_authenticated_headers(token), 646 | ) 647 | response = response.json() 648 | _LOGGER.debug(f"{DOMAIN} - get_trip_info response {response}") 649 | _check_response_for_errors(response) 650 | return response 651 | 652 | def update_month_trip_info( 653 | self, 654 | token, 655 | vehicle, 656 | yyyymm_string, 657 | ) -> None: 658 | """ 659 | feature only available for some regions. 660 | Updates the vehicle.month_trip_info for the specified month. 661 | 662 | Default this information is None: 663 | 664 | month_trip_info: MonthTripInfo = None 665 | """ 666 | vehicle.month_trip_info = None 667 | json_result = self._get_trip_info( 668 | token, 669 | vehicle, 670 | yyyymm_string, 671 | 0, # month trip info 672 | ) 673 | msg = json_result["resMsg"] 674 | if msg["monthTripDayCnt"] > 0: 675 | result = MonthTripInfo( 676 | yyyymm=yyyymm_string, 677 | day_list=[], 678 | summary=TripInfo( 679 | drive_time=msg["tripDrvTime"], 680 | idle_time=msg["tripIdleTime"], 681 | distance=msg["tripDist"], 682 | avg_speed=msg["tripAvgSpeed"], 683 | max_speed=msg["tripMaxSpeed"], 684 | ), 685 | ) 686 | 687 | for day in msg["tripDayList"]: 688 | processed_day = DayTripCounts( 689 | yyyymmdd=day["tripDayInMonth"], 690 | trip_count=day["tripCntDay"], 691 | ) 692 | result.day_list.append(processed_day) 693 | 694 | vehicle.month_trip_info = result 695 | 696 | def update_day_trip_info( 697 | self, 698 | token, 699 | vehicle, 700 | yyyymmdd_string, 701 | ) -> None: 702 | """ 703 | feature only available for some regions. 704 | Updates the vehicle.day_trip_info information for the specified day. 705 | 706 | Default this information is None: 707 | 708 | day_trip_info: DayTripInfo = None 709 | """ 710 | vehicle.day_trip_info = None 711 | json_result = self._get_trip_info( 712 | token, 713 | vehicle, 714 | yyyymmdd_string, 715 | 1, # day trip info 716 | ) 717 | day_trip_list = json_result["resMsg"]["dayTripList"] 718 | if len(day_trip_list) > 0: 719 | msg = day_trip_list[0] 720 | result = DayTripInfo( 721 | yyyymmdd=yyyymmdd_string, 722 | trip_list=[], 723 | summary=TripInfo( 724 | drive_time=msg["tripDrvTime"], 725 | idle_time=msg["tripIdleTime"], 726 | distance=msg["tripDist"], 727 | avg_speed=msg["tripAvgSpeed"], 728 | max_speed=msg["tripMaxSpeed"], 729 | ), 730 | ) 731 | for trip in msg["tripList"]: 732 | processed_trip = TripInfo( 733 | hhmmss=trip["tripTime"], 734 | drive_time=trip["tripDrvTime"], 735 | idle_time=trip["tripIdleTime"], 736 | distance=trip["tripDist"], 737 | avg_speed=trip["tripAvgSpeed"], 738 | max_speed=trip["tripMaxSpeed"], 739 | ) 740 | result.trip_list.append(processed_trip) 741 | vehicle.day_trip_info = result 742 | 743 | def _get_driving_info(self, token: Token, vehicle: Vehicle) -> dict: 744 | url = self.SPA_API_URL + "vehicles/" + vehicle.id + "/drvhistory" 745 | 746 | responseAlltime = requests.post( 747 | url, 748 | json={"periodTarget": 1}, 749 | headers=self._get_authenticated_headers(token), 750 | ) 751 | responseAlltime = responseAlltime.json() 752 | _LOGGER.debug(f"{DOMAIN} - get_driving_info responseAlltime {responseAlltime}") 753 | _check_response_for_errors(responseAlltime) 754 | 755 | response30d = requests.post( 756 | url, 757 | json={"periodTarget": 0}, 758 | headers=self._get_authenticated_headers(token), 759 | ) 760 | response30d = response30d.json() 761 | _LOGGER.debug(f"{DOMAIN} - get_driving_info response30d {response30d}") 762 | _check_response_for_errors(response30d) 763 | if get_child_value(responseAlltime, "resMsg.drivingInfoDetail.0"): 764 | drivingInfo = responseAlltime["resMsg"]["drivingInfoDetail"][0] 765 | 766 | drivingInfo["dailyStats"] = [] 767 | for day in response30d["resMsg"]["drivingInfoDetail"]: 768 | processedDay = DailyDrivingStats( 769 | date=dt.datetime.strptime(day["drivingDate"], "%Y%m%d"), 770 | total_consumed=day["totalPwrCsp"], 771 | engine_consumption=day["motorPwrCsp"], 772 | climate_consumption=day["climatePwrCsp"], 773 | onboard_electronics_consumption=day["eDPwrCsp"], 774 | battery_care_consumption=day["batteryMgPwrCsp"], 775 | regenerated_energy=day["regenPwr"], 776 | distance=day["calculativeOdo"], 777 | distance_unit=vehicle.odometer_unit, 778 | ) 779 | drivingInfo["dailyStats"].append(processedDay) 780 | 781 | for drivingInfoItem in response30d["resMsg"]["drivingInfo"]: 782 | if drivingInfoItem["drivingPeriod"] == 0: 783 | drivingInfo["consumption30d"] = round( 784 | drivingInfoItem["totalPwrCsp"] 785 | / drivingInfoItem["calculativeOdo"] 786 | ) 787 | break 788 | 789 | return drivingInfo 790 | else: 791 | _LOGGER.debug( 792 | f"{DOMAIN} - Driving info didn't return valid data. This may be normal if the car doesn't support it." # noqa 793 | ) 794 | return None 795 | 796 | def _get_stamp(self) -> str: 797 | raw_data = f"{self.APP_ID}:{int(dt.datetime.now().timestamp())}".encode() 798 | result = bytes(b1 ^ b2 for b1, b2 in zip(self.cfb, raw_data)) 799 | return base64.b64encode(result).decode("utf-8") 800 | 801 | def _get_device_id(self, stamp): 802 | my_hex = "%064x" % random.randrange( # pylint: disable=consider-using-f-string 803 | 10**80 804 | ) 805 | registration_id = my_hex[:64] 806 | url = self.SPA_API_URL + "notifications/register" 807 | payload = { 808 | # "providerDeviceId": provider_device_id, 809 | "pushRegId": registration_id, 810 | "pushType": "GCM", 811 | "uuid": str(uuid.uuid4()), 812 | } 813 | 814 | headers = { 815 | "ccsp-service-id": self.CLIENT_ID, 816 | "ccsp-application-id": self.APP_ID, 817 | "Stamp": stamp, 818 | "Content-Type": "application/json;charset=UTF-8", 819 | "Host": self.BASE_URL, 820 | "Connection": "Keep-Alive", 821 | "Accept-Encoding": "gzip", 822 | "User-Agent": USER_AGENT_OK_HTTP, 823 | } 824 | 825 | _LOGGER.debug(f"{DOMAIN} - Get Device ID request: {headers} {payload}") 826 | response = requests.post(url, headers=headers, json=payload) 827 | response = response.json() 828 | _check_response_for_errors(response) 829 | _LOGGER.debug(f"{DOMAIN} - Get Device ID response: {response}") 830 | 831 | device_id = response["resMsg"]["deviceId"] 832 | return device_id 833 | 834 | def _get_cookies(self) -> dict: 835 | # Get Cookies # 836 | url = ( 837 | self.USER_API_URL 838 | + "oauth2/authorize?response_type=code&client_id=" 839 | + self.CLIENT_ID 840 | + "&redirect_uri=" 841 | + "https://" 842 | + self.BASE_URL 843 | + "/api/v1/user/oauth2/redirect&lang=en" 844 | ) 845 | 846 | _LOGGER.debug(f"{DOMAIN} - Get cookies request: {url}") 847 | session = requests.Session() 848 | _ = session.get(url) 849 | _LOGGER.debug(f"{DOMAIN} - Get cookies response: {session.cookies.get_dict()}") 850 | return session.cookies.get_dict() 851 | 852 | def _get_authorization_code_with_redirect_url( 853 | self, username, password, cookies 854 | ) -> str: 855 | url = self.USER_API_URL + "signin" 856 | headers = {"Content-type": "application/json"} 857 | data = {"email": username, "password": password} 858 | response = requests.post( 859 | url, json=data, headers=headers, cookies=cookies 860 | ).json() 861 | parsed_url = urlparse(response["redirectUrl"]) 862 | authorization_code = "".join(parse_qs(parsed_url.query)["code"]) 863 | return authorization_code 864 | 865 | def _get_access_token(self, authorization_code, stamp): 866 | # Get Access Token # 867 | url = self.USER_API_URL + "oauth2/token" 868 | headers = { 869 | "Authorization": self.BASIC_AUTHORIZATION, 870 | "Stamp": stamp, 871 | "Content-type": "application/x-www-form-urlencoded", 872 | "Host": self.BASE_URL, 873 | "Connection": "close", 874 | "Accept-Encoding": "gzip, deflate", 875 | "User-Agent": USER_AGENT_OK_HTTP, 876 | } 877 | 878 | data = ( 879 | "grant_type=authorization_code&redirect_uri=https%3A%2F%2F" 880 | + self.BASE_URL 881 | + "%2Fapi%2Fv1%2Fuser%2Foauth2%2Fredirect&code=" 882 | + authorization_code 883 | ) 884 | response = requests.post(url, data=data, headers=headers) 885 | response = response.json() 886 | 887 | token_type = response["token_type"] 888 | access_token = token_type + " " + response["access_token"] 889 | authorization_code = response["refresh_token"] 890 | return token_type, access_token, authorization_code 891 | 892 | def _get_refresh_token(self, authorization_code, stamp): 893 | # Get Refresh Token # 894 | url = self.USER_API_URL + "oauth2/token" 895 | headers = { 896 | "Authorization": self.BASIC_AUTHORIZATION, 897 | "Stamp": stamp, 898 | "Content-type": "application/x-www-form-urlencoded", 899 | "Host": self.BASE_URL, 900 | "Connection": "close", 901 | "Accept-Encoding": "gzip, deflate", 902 | "User-Agent": USER_AGENT_OK_HTTP, 903 | } 904 | 905 | data = "grant_type=refresh_token&refresh_token=" + authorization_code 906 | response = requests.post(url, data=data, headers=headers) 907 | response = response.json() 908 | token_type = response["token_type"] 909 | refresh_token = token_type + " " + response["access_token"] 910 | return token_type, refresh_token 911 | --------------------------------------------------------------------------------