From 09bbff9682c8aba6944f5e76f0104d5946df9b6b Mon Sep 17 00:00:00 2001 From: gozdzickip Date: Thu, 18 Jan 2024 13:47:33 +0100 Subject: [PATCH 01/27] Update car_models.yml Add Citroen C4 2021 model --- psa_car_controller/psacc/resources/car_models.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/psa_car_controller/psacc/resources/car_models.yml b/psa_car_controller/psacc/resources/car_models.yml index f22f518..212f69a 100644 --- a/psa_car_controller/psacc/resources/car_models.yml +++ b/psa_car_controller/psacc/resources/car_models.yml @@ -350,6 +350,14 @@ reg: VF73ABHZMJ.* max_elec_consumption: 70 max_fuel_consumption: 30 +- !CarModel + name: C4 2021 + battery_power: 0 + fuel_capacity: 50 + abrp_name: + reg: VR7BAHNSAM.* + max_elec_consumption: 0 + max_fuel_consumption: 30 - !ElecModel name: Combo-e Cargo battery_power: 50 From 26accbe0976e899cee38c38be5c48c04684712cb Mon Sep 17 00:00:00 2001 From: Sunnytur <75636963+Sunnytur@users.noreply.github.com> Date: Sat, 20 Jan 2024 11:54:28 +0100 Subject: [PATCH 02/27] Update car_models.yml Added 508 BlueHDI 130 --- psa_car_controller/psacc/resources/car_models.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/psa_car_controller/psacc/resources/car_models.yml b/psa_car_controller/psacc/resources/car_models.yml index f22f518..34da2b7 100644 --- a/psa_car_controller/psacc/resources/car_models.yml +++ b/psa_car_controller/psacc/resources/car_models.yml @@ -278,6 +278,14 @@ reg: VR3F45GG.* max_elec_consumption: 70 max_fuel_consumption: 30 +- !CarModel + name: 508 BlueHDI 130 + battery_power: 0 + fuel_capacity: 55 + abrp_name: + reg: VR3FBYHZ.* + max_elec_consumption: 0 + max_fuel_consumption: 30 - !CarModel name: Grandland X Hybrid battery_power: 13.2 From ec90fd046f2131a61ea359407067dd38545c6020 Mon Sep 17 00:00:00 2001 From: Juan Carlos Ameijeiras Neu <60329458+juancameijeiras@users.noreply.github.com> Date: Mon, 22 Jan 2024 03:43:47 +0100 Subject: [PATCH 03/27] Update car_models.yml Added C4 Shine 2023 --- psa_car_controller/psacc/resources/car_models.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/psa_car_controller/psacc/resources/car_models.yml b/psa_car_controller/psacc/resources/car_models.yml index f22f518..ef55282 100644 --- a/psa_car_controller/psacc/resources/car_models.yml +++ b/psa_car_controller/psacc/resources/car_models.yml @@ -525,3 +525,10 @@ reg: VF3M45GFUL.* max_elec_consumption: 70 max_fuel_consumption: 30 +- !CarModel + name: C4 Shine 2023 + battery_power: 0 + fuel_capacity: 50 + abrp_name: + reg: VR7BAHNSBP.* + From 1c5d7e6d93138958b96e6546c5d0c076702ea115 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Casper=20J=C3=B8rgensen?= <33053434+CasperJoergensen@users.noreply.github.com> Date: Tue, 23 Jan 2024 18:03:15 +0100 Subject: [PATCH 04/27] Add support for Peugeot 208 - 2023 Add support for Peugeot 208 - 2023 --- psa_car_controller/psacc/resources/car_models.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/psa_car_controller/psacc/resources/car_models.yml b/psa_car_controller/psacc/resources/car_models.yml index f22f518..6d923cb 100644 --- a/psa_car_controller/psacc/resources/car_models.yml +++ b/psa_car_controller/psacc/resources/car_models.yml @@ -166,6 +166,14 @@ reg: VF3CCBHY6H.* max_elec_consumption: 70 max_fuel_consumption: 30 +- !CarModel + name: '208' + battery_power: 0 + fuel_capacity: 44 + abrp_name: + reg: VR3UPHNEKP.* + max_elec_consumption: 70 + max_fuel_consumption: 30 - !CarModel name: '2008' battery_power: 0 From 9dc714f23eade6264a1c4d76cc1b27e38bf33692 Mon Sep 17 00:00:00 2001 From: colas-sebastien <51092544+colas-sebastien@users.noreply.github.com> Date: Wed, 24 Jan 2024 18:58:42 +0100 Subject: [PATCH 05/27] New model e208 2023 --- psa_car_controller/psacc/resources/car_models.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/psa_car_controller/psacc/resources/car_models.yml b/psa_car_controller/psacc/resources/car_models.yml index f22f518..aec277b 100644 --- a/psa_car_controller/psacc/resources/car_models.yml +++ b/psa_car_controller/psacc/resources/car_models.yml @@ -31,7 +31,7 @@ battery_power: 46 fuel_capacity: 0 abrp_name: peugeot:e208:20:50 - reg: VR3UHZKX.* + reg: VR3UHZK[WX].* max_elec_consumption: 70 - !ElecModel name: e-2008 From 10e115cba3b6fb4c6b8988a5952ba21443d3c07d Mon Sep 17 00:00:00 2001 From: 0x3d Date: Wed, 7 Feb 2024 00:19:55 +0100 Subject: [PATCH 06/27] Accomodate for Stellantis' Jan 23 OAuth changes (still one manual step involved during setup but at least the error 500 is gone) --- psa_car_controller/psa/constants.py | 2 +- psa_car_controller/psa/oauth.py | 26 +++++++++++++++++-- psa_car_controller/psa/setup/app_decoder.py | 2 +- .../psacc/application/psa_client.py | 4 +-- 4 files changed, 28 insertions(+), 6 deletions(-) diff --git a/psa_car_controller/psa/constants.py b/psa_car_controller/psa/constants.py index e10fbd4..9c7e848 100644 --- a/psa_car_controller/psa/constants.py +++ b/psa_car_controller/psa/constants.py @@ -28,7 +28,7 @@ DEFAULT_PRECONDITIONING_PROGRAM = { "program3": {"day": [0, 0, 0, 0, 0, 0, 0], "hour": 34, "minute": 7, "on": 0}, "program4": {"day": [0, 0, 0, 0, 0, 0, 0], "hour": 34, "minute": 7, "on": 0} } -AUTHORIZE_SERVICE = "https://api.mpsa.com/api/connectedcar/v2/oauth/authorize" +AUTHORIZE_SERVICE = "https://idpcvs.opel.com/am/oauth2/authorize" REMOTE_URL = "https://api.groupe-psa.com/connectedcar/v4/virtualkey/remoteaccess/token?client_id=" BRAND = {"com.psa.mym.myopel": {"realm": "clientsB2COpel", "brand_code": "OP", "app_name": "MyOpel"}, "com.psa.mym.mypeugeot": {"realm": "clientsB2CPeugeot", "brand_code": "AP", "app_name": "MyPeugeot"}, diff --git a/psa_car_controller/psa/oauth.py b/psa_car_controller/psa/oauth.py index 26a4590..e4aad02 100644 --- a/psa_car_controller/psa/oauth.py +++ b/psa_car_controller/psa/oauth.py @@ -1,4 +1,9 @@ import logging +import hashlib +import secrets +import base64 +from typing import Tuple + from http import HTTPStatus from typing import Optional @@ -22,8 +27,25 @@ class OpenIdCredentialManager(CredentialManager): return {"grant_type": 'password', "username": login, "scope": ' '.join(self.service_information.scopes), "password": password, "realm": realm} - def init_with_user_credentials_realm(self, login: str, password: str, realm: str): - self._token_request(self._grant_password_request_realm(login, password, realm), True) + def generate_sha256_pkce(self, length: int) -> Tuple[str, str]: + if not (43 <= length <= 128): + raise Exception("Invalid length: " % str(length)) + verifier = secrets.token_urlsafe(length) + encoded = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode('ascii')).digest()) + challenge = encoded.decode('ascii')[:-1] + return verifier, challenge + + def init_with_user_credentials_realm(self, country_code: str, realm: str): + redir_uri = "mymop://oauth2redirect/" + country_code.lower() + code_verifier, code_challenge = self.generate_sha256_pkce(64) + url = self.generate_authorize_url(redir_uri, secrets.token_urlsafe(16), code_challenge=code_challenge, code_challenge_method="S256") + + ret = "" + while len(ret) != 36: + logger.info("Now login to this URL in a browser: " + url) + ret = input("\nCopy+paste the resulting mymop-code (in F12 > Network when you hit the final OK button, 36 chars, UUID format): ") + + self._token_request(dict(grant_type='authorization_code', code=ret, redirect_uri=redir_uri, code_verifier=code_verifier), False) @staticmethod def _is_token_expired(response: Response) -> bool: diff --git a/psa_car_controller/psa/setup/app_decoder.py b/psa_car_controller/psa/setup/app_decoder.py index c6f4128..3694fd9 100755 --- a/psa_car_controller/psa/setup/app_decoder.py +++ b/psa_car_controller/psa/setup/app_decoder.py @@ -92,7 +92,7 @@ def firstLaunchConfig(package_name, client_email, client_password, country_code, psacc = PSAClient(None, apk_parser.client_id, apk_parser.client_secret, None, customer_id, BRAND[package_name]["realm"], country_code) - psacc.connect(client_email, client_password) + psacc.connect(country_code) psacc.save_config(name=config_prefix + "config.json") res = psacc.get_vehicles() diff --git a/psa_car_controller/psacc/application/psa_client.py b/psa_car_controller/psacc/application/psa_client.py index 8df313f..dbd47b1 100644 --- a/psa_car_controller/psacc/application/psa_client.py +++ b/psa_car_controller/psacc/application/psa_client.py @@ -31,8 +31,8 @@ logger = CustomLogger.getLogger(__name__) class PSAClient: - def connect(self, user, password): - self.manager.init_with_user_credentials_realm(user, password, self.realm) + def connect(self, country_code): + self.manager.init_with_user_credentials_realm(country_code, self.realm) # pylint: disable=too-many-arguments def __init__(self, refresh_token, client_id, client_secret, remote_refresh_token, customer_id, realm, country_code, From cdf7f85bf997257ad4307028f1e41c5698b70ec8 Mon Sep 17 00:00:00 2001 From: 0x3d Date: Wed, 7 Feb 2024 00:19:55 +0100 Subject: [PATCH 07/27] Accomodate for Stellantis' Jan 23 OAuth changes (still one manual step involved during setup but at least the error 500 is gone) --- psa_car_controller/psa/constants.py | 2 +- psa_car_controller/psa/oauth.py | 28 +++++++++++++++++-- psa_car_controller/psa/setup/app_decoder.py | 2 +- .../psacc/application/psa_client.py | 4 +-- 4 files changed, 30 insertions(+), 6 deletions(-) diff --git a/psa_car_controller/psa/constants.py b/psa_car_controller/psa/constants.py index e10fbd4..9c7e848 100644 --- a/psa_car_controller/psa/constants.py +++ b/psa_car_controller/psa/constants.py @@ -28,7 +28,7 @@ DEFAULT_PRECONDITIONING_PROGRAM = { "program3": {"day": [0, 0, 0, 0, 0, 0, 0], "hour": 34, "minute": 7, "on": 0}, "program4": {"day": [0, 0, 0, 0, 0, 0, 0], "hour": 34, "minute": 7, "on": 0} } -AUTHORIZE_SERVICE = "https://api.mpsa.com/api/connectedcar/v2/oauth/authorize" +AUTHORIZE_SERVICE = "https://idpcvs.opel.com/am/oauth2/authorize" REMOTE_URL = "https://api.groupe-psa.com/connectedcar/v4/virtualkey/remoteaccess/token?client_id=" BRAND = {"com.psa.mym.myopel": {"realm": "clientsB2COpel", "brand_code": "OP", "app_name": "MyOpel"}, "com.psa.mym.mypeugeot": {"realm": "clientsB2CPeugeot", "brand_code": "AP", "app_name": "MyPeugeot"}, diff --git a/psa_car_controller/psa/oauth.py b/psa_car_controller/psa/oauth.py index 26a4590..4ad5098 100644 --- a/psa_car_controller/psa/oauth.py +++ b/psa_car_controller/psa/oauth.py @@ -1,4 +1,9 @@ import logging +import hashlib +import secrets +import base64 +from typing import Tuple + from http import HTTPStatus from typing import Optional @@ -22,8 +27,27 @@ class OpenIdCredentialManager(CredentialManager): return {"grant_type": 'password', "username": login, "scope": ' '.join(self.service_information.scopes), "password": password, "realm": realm} - def init_with_user_credentials_realm(self, login: str, password: str, realm: str): - self._token_request(self._grant_password_request_realm(login, password, realm), True) + def generate_sha256_pkce(self, length: int) -> Tuple[str, str]: + if not (43 <= length <= 128): + raise ValueError("Invalid length: " % str(length)) + verifier = secrets.token_urlsafe(length) + encoded = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode('ascii')).digest()) + challenge = encoded.decode('ascii')[:-1] + return verifier, challenge + + def init_with_user_credentials_realm(self, country_code: str): + redir_uri = "mymop://oauth2redirect/" + country_code.lower() + code_verifier, code_challenge = self.generate_sha256_pkce(64) + url = self.generate_authorize_url(redir_uri, secrets.token_urlsafe(16), + code_challenge=code_challenge, code_challenge_method="S256") + + ret = "" + while len(ret) != 36: + logger.info("Now login to this URL in a browser: " + url) + ret = input("\nCopy+paste the resulting mymop-code (in F12 > Network when you hit the final OK button, 36 chars, UUID format): ") + + self._token_request({ "grant_type": 'authorization_code', "code": ret, + "redirect_uri": redir_uri, "code_verifier": code_verifier), False) @staticmethod def _is_token_expired(response: Response) -> bool: diff --git a/psa_car_controller/psa/setup/app_decoder.py b/psa_car_controller/psa/setup/app_decoder.py index c6f4128..3694fd9 100755 --- a/psa_car_controller/psa/setup/app_decoder.py +++ b/psa_car_controller/psa/setup/app_decoder.py @@ -92,7 +92,7 @@ def firstLaunchConfig(package_name, client_email, client_password, country_code, psacc = PSAClient(None, apk_parser.client_id, apk_parser.client_secret, None, customer_id, BRAND[package_name]["realm"], country_code) - psacc.connect(client_email, client_password) + psacc.connect(country_code) psacc.save_config(name=config_prefix + "config.json") res = psacc.get_vehicles() diff --git a/psa_car_controller/psacc/application/psa_client.py b/psa_car_controller/psacc/application/psa_client.py index 8df313f..dbd47b1 100644 --- a/psa_car_controller/psacc/application/psa_client.py +++ b/psa_car_controller/psacc/application/psa_client.py @@ -31,8 +31,8 @@ logger = CustomLogger.getLogger(__name__) class PSAClient: - def connect(self, user, password): - self.manager.init_with_user_credentials_realm(user, password, self.realm) + def connect(self, country_code): + self.manager.init_with_user_credentials_realm(country_code, self.realm) # pylint: disable=too-many-arguments def __init__(self, refresh_token, client_id, client_secret, remote_refresh_token, customer_id, realm, country_code, From be3238f85bd9031fd894b6cb747b0c125084e753 Mon Sep 17 00:00:00 2001 From: 0x3d Date: Wed, 7 Feb 2024 00:38:16 +0100 Subject: [PATCH 08/27] Happy linter --- psa_car_controller/psa/oauth.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/psa_car_controller/psa/oauth.py b/psa_car_controller/psa/oauth.py index 4ad5098..ae56125 100644 --- a/psa_car_controller/psa/oauth.py +++ b/psa_car_controller/psa/oauth.py @@ -44,10 +44,11 @@ class OpenIdCredentialManager(CredentialManager): ret = "" while len(ret) != 36: logger.info("Now login to this URL in a browser: " + url) - ret = input("\nCopy+paste the resulting mymop-code (in F12 > Network when you hit the final OK button, 36 chars, UUID format): ") + ret = input("\nCopy+paste the resulting mymop-code (in F12 > Network" \ + "when you hit the final OK button, 36 chars, UUID format): ") self._token_request({ "grant_type": 'authorization_code', "code": ret, - "redirect_uri": redir_uri, "code_verifier": code_verifier), False) + "redirect_uri": redir_uri, "code_verifier": code_verifier}, False) @staticmethod def _is_token_expired(response: Response) -> bool: From 034ce5d3cc847549ea1158d050deee88664d43cd Mon Sep 17 00:00:00 2001 From: 0x3d Date: Wed, 7 Feb 2024 00:42:36 +0100 Subject: [PATCH 09/27] more linitng issues --- psa_car_controller/psa/oauth.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/psa_car_controller/psa/oauth.py b/psa_car_controller/psa/oauth.py index ae56125..d0e9b43 100644 --- a/psa_car_controller/psa/oauth.py +++ b/psa_car_controller/psa/oauth.py @@ -29,7 +29,7 @@ class OpenIdCredentialManager(CredentialManager): def generate_sha256_pkce(self, length: int) -> Tuple[str, str]: if not (43 <= length <= 128): - raise ValueError("Invalid length: " % str(length)) + raise ValueError("Invalid length: %s", str(length)) verifier = secrets.token_urlsafe(length) encoded = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode('ascii')).digest()) challenge = encoded.decode('ascii')[:-1] @@ -43,7 +43,7 @@ class OpenIdCredentialManager(CredentialManager): ret = "" while len(ret) != 36: - logger.info("Now login to this URL in a browser: " + url) + logger.info("Now login to this URL in a browser: %s", url) ret = input("\nCopy+paste the resulting mymop-code (in F12 > Network" \ "when you hit the final OK button, 36 chars, UUID format): ") From 82a3df1febd4403d2d4059d91e1ac3e778be2c93 Mon Sep 17 00:00:00 2001 From: 0x3d Date: Wed, 7 Feb 2024 00:47:09 +0100 Subject: [PATCH 10/27] Derp. Starting to think 2 days of OAuth deep-diving was the easy part :-D --- psa_car_controller/psa/oauth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/psa_car_controller/psa/oauth.py b/psa_car_controller/psa/oauth.py index d0e9b43..07eee23 100644 --- a/psa_car_controller/psa/oauth.py +++ b/psa_car_controller/psa/oauth.py @@ -29,7 +29,7 @@ class OpenIdCredentialManager(CredentialManager): def generate_sha256_pkce(self, length: int) -> Tuple[str, str]: if not (43 <= length <= 128): - raise ValueError("Invalid length: %s", str(length)) + raise ValueError("Invalid length: %d" % length) verifier = secrets.token_urlsafe(length) encoded = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode('ascii')).digest()) challenge = encoded.decode('ascii')[:-1] From 9bcf2aeaf582dc8ea16edc009e570e4c31dd5eae Mon Sep 17 00:00:00 2001 From: 0x3d Date: Wed, 7 Feb 2024 01:17:44 +0100 Subject: [PATCH 11/27] Linters don't code --- psa_car_controller/psa/oauth.py | 5 +++-- psa_car_controller/psacc/application/psa_client.py | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/psa_car_controller/psa/oauth.py b/psa_car_controller/psa/oauth.py index 07eee23..900636d 100644 --- a/psa_car_controller/psa/oauth.py +++ b/psa_car_controller/psa/oauth.py @@ -41,10 +41,11 @@ class OpenIdCredentialManager(CredentialManager): url = self.generate_authorize_url(redir_uri, secrets.token_urlsafe(16), code_challenge=code_challenge, code_challenge_method="S256") + logger.info("Now login to this URL in a browser: %s", url) + ret = "" while len(ret) != 36: - logger.info("Now login to this URL in a browser: %s", url) - ret = input("\nCopy+paste the resulting mymop-code (in F12 > Network" \ + ret = input("\nCopy+paste the resulting mymop-code (in F12 > Network, " \ "when you hit the final OK button, 36 chars, UUID format): ") self._token_request({ "grant_type": 'authorization_code', "code": ret, diff --git a/psa_car_controller/psacc/application/psa_client.py b/psa_car_controller/psacc/application/psa_client.py index dbd47b1..e9a9ef2 100644 --- a/psa_car_controller/psacc/application/psa_client.py +++ b/psa_car_controller/psacc/application/psa_client.py @@ -32,7 +32,7 @@ logger = CustomLogger.getLogger(__name__) class PSAClient: def connect(self, country_code): - self.manager.init_with_user_credentials_realm(country_code, self.realm) + self.manager.init_with_user_credentials_realm(country_code) # pylint: disable=too-many-arguments def __init__(self, refresh_token, client_id, client_secret, remote_refresh_token, customer_id, realm, country_code, From 2721ac6a85f33691f3f022df8a964ec33475113e Mon Sep 17 00:00:00 2001 From: 0x3d Date: Wed, 7 Feb 2024 11:00:42 +0100 Subject: [PATCH 12/27] Use a different AUTHORIZE_SERVICE per brand/realm --- psa_car_controller/psa/connected_car_api/configuration.py | 2 +- psa_car_controller/psa/constants.py | 7 ++++++- psa_car_controller/psacc/application/psa_client.py | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/psa_car_controller/psa/connected_car_api/configuration.py b/psa_car_controller/psa/connected_car_api/configuration.py index 8f1a4a4..4b2fe62 100644 --- a/psa_car_controller/psa/connected_car_api/configuration.py +++ b/psa_car_controller/psa/connected_car_api/configuration.py @@ -72,7 +72,7 @@ class Configuration(object): # Debug file location self.logger_file = None # Debug switch - self.debug = False + self.debug = True # SSL/TLS verification # Set this to false to skip verifying SSL certificate when calling API diff --git a/psa_car_controller/psa/constants.py b/psa_car_controller/psa/constants.py index 9c7e848..a41a858 100644 --- a/psa_car_controller/psa/constants.py +++ b/psa_car_controller/psa/constants.py @@ -28,7 +28,12 @@ DEFAULT_PRECONDITIONING_PROGRAM = { "program3": {"day": [0, 0, 0, 0, 0, 0, 0], "hour": 34, "minute": 7, "on": 0}, "program4": {"day": [0, 0, 0, 0, 0, 0, 0], "hour": 34, "minute": 7, "on": 0} } -AUTHORIZE_SERVICE = "https://idpcvs.opel.com/am/oauth2/authorize" +AUTHORIZE_SERVICE = {"clientsB2COpel": "https://idpcvs.opel.com/am/oauth2/authorize", + "clientsB2CPeugeot": "https://idpcvs.peugeot.com/am/oauth2/authorize", + "clientsB2CCitroen": "https://idpcvs.citroen.com/am/oauth2/authorize", + "clientsB2CDS": "https://idpcvs.driveds.com/am/oauth2/authorize", + "clientsB2CVauxhall": "https://idpcvs.vauxhall.co.uk/am/oauth2/authorize" + } REMOTE_URL = "https://api.groupe-psa.com/connectedcar/v4/virtualkey/remoteaccess/token?client_id=" BRAND = {"com.psa.mym.myopel": {"realm": "clientsB2COpel", "brand_code": "OP", "app_name": "MyOpel"}, "com.psa.mym.mypeugeot": {"realm": "clientsB2CPeugeot", "brand_code": "AP", "app_name": "MyPeugeot"}, diff --git a/psa_car_controller/psacc/application/psa_client.py b/psa_car_controller/psacc/application/psa_client.py index e9a9ef2..b510fec 100644 --- a/psa_car_controller/psacc/application/psa_client.py +++ b/psa_car_controller/psacc/application/psa_client.py @@ -38,7 +38,7 @@ class PSAClient: def __init__(self, refresh_token, client_id, client_secret, remote_refresh_token, customer_id, realm, country_code, proxies=None, weather_api=None, abrp=None, co2_signal_api=None): self.realm = realm - self.service_information = ServiceInformation(AUTHORIZE_SERVICE, + self.service_information = ServiceInformation(AUTHORIZE_SERVICE[self.realm], realm_info[self.realm]['oauth_url'], client_id, client_secret, From 66c188f18cee5270da90c0dfbf42bc6567128a48 Mon Sep 17 00:00:00 2001 From: 0x3d Date: Wed, 7 Feb 2024 11:05:01 +0100 Subject: [PATCH 13/27] Proper labelling --- psa_car_controller/psa/oauth.py | 2 +- psa_car_controller/psacc/application/psa_client.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/psa_car_controller/psa/oauth.py b/psa_car_controller/psa/oauth.py index 900636d..bd15f96 100644 --- a/psa_car_controller/psa/oauth.py +++ b/psa_car_controller/psa/oauth.py @@ -35,7 +35,7 @@ class OpenIdCredentialManager(CredentialManager): challenge = encoded.decode('ascii')[:-1] return verifier, challenge - def init_with_user_credentials_realm(self, country_code: str): + def init_with_country_code(self, country_code: str): redir_uri = "mymop://oauth2redirect/" + country_code.lower() code_verifier, code_challenge = self.generate_sha256_pkce(64) url = self.generate_authorize_url(redir_uri, secrets.token_urlsafe(16), diff --git a/psa_car_controller/psacc/application/psa_client.py b/psa_car_controller/psacc/application/psa_client.py index b510fec..4fefd3f 100644 --- a/psa_car_controller/psacc/application/psa_client.py +++ b/psa_car_controller/psacc/application/psa_client.py @@ -32,7 +32,7 @@ logger = CustomLogger.getLogger(__name__) class PSAClient: def connect(self, country_code): - self.manager.init_with_user_credentials_realm(country_code) + self.manager.init_with_country_code(country_code) # pylint: disable=too-many-arguments def __init__(self, refresh_token, client_id, client_secret, remote_refresh_token, customer_id, realm, country_code, From bd46b52f3b90d579cae217df3bc005a9b6c54d78 Mon Sep 17 00:00:00 2001 From: 0x3d Date: Wed, 7 Feb 2024 12:36:22 +0100 Subject: [PATCH 14/27] =?UTF-8?q?The=20"op"=20in=20"mym**op**"=20stands=20?= =?UTF-8?q?for=20Opel=20=F0=9F=98=85=20=F0=9F=A4=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- psa_car_controller/psa/oauth.py | 4 ++-- psa_car_controller/psa/setup/app_decoder.py | 4 ++-- psa_car_controller/psacc/application/psa_client.py | 7 ++++--- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/psa_car_controller/psa/oauth.py b/psa_car_controller/psa/oauth.py index bd15f96..6577f0a 100644 --- a/psa_car_controller/psa/oauth.py +++ b/psa_car_controller/psa/oauth.py @@ -35,8 +35,8 @@ class OpenIdCredentialManager(CredentialManager): challenge = encoded.decode('ascii')[:-1] return verifier, challenge - def init_with_country_code(self, country_code: str): - redir_uri = "mymop://oauth2redirect/" + country_code.lower() + def init_with_brand_country_code(self, brand: str, country_code: str): + redir_uri = "mym" + brand.lower() + "://oauth2redirect/" + country_code.lower() code_verifier, code_challenge = self.generate_sha256_pkce(64) url = self.generate_authorize_url(redir_uri, secrets.token_urlsafe(16), code_challenge=code_challenge, code_challenge_method="S256") diff --git a/psa_car_controller/psa/setup/app_decoder.py b/psa_car_controller/psa/setup/app_decoder.py index 3694fd9..cd475ec 100755 --- a/psa_car_controller/psa/setup/app_decoder.py +++ b/psa_car_controller/psa/setup/app_decoder.py @@ -91,8 +91,8 @@ def firstLaunchConfig(package_name, client_email, client_password, country_code, # Psacc psacc = PSAClient(None, apk_parser.client_id, apk_parser.client_secret, None, customer_id, BRAND[package_name]["realm"], - country_code) - psacc.connect(country_code) + country_code, BRAND[package_name]["brand_code"]) + psacc.connect() psacc.save_config(name=config_prefix + "config.json") res = psacc.get_vehicles() diff --git a/psa_car_controller/psacc/application/psa_client.py b/psa_car_controller/psacc/application/psa_client.py index 4fefd3f..67c3c62 100644 --- a/psa_car_controller/psacc/application/psa_client.py +++ b/psa_car_controller/psacc/application/psa_client.py @@ -31,12 +31,12 @@ logger = CustomLogger.getLogger(__name__) class PSAClient: - def connect(self, country_code): - self.manager.init_with_country_code(country_code) + def connect(self): + self.manager.init_with_brand_country_code(self.brand, self.country_code) # pylint: disable=too-many-arguments def __init__(self, refresh_token, client_id, client_secret, remote_refresh_token, customer_id, realm, country_code, - proxies=None, weather_api=None, abrp=None, co2_signal_api=None): + brand=None, proxies=None, weather_api=None, abrp=None, co2_signal_api=None): self.realm = realm self.service_information = ServiceInformation(AUTHORIZE_SERVICE[self.realm], realm_info[self.realm]['oauth_url'], @@ -60,6 +60,7 @@ class PSAClient: self._record_enabled = False self.weather_api = weather_api self.country_code = country_code + self.brand = brand self.info_callback = [] self.info_refresh_rate = 120 if abrp is None: From 073fb1828ed01b81c5bb6e54494471c79057cf37 Mon Sep 17 00:00:00 2001 From: 0x3d Date: Wed, 7 Feb 2024 12:46:20 +0100 Subject: [PATCH 15/27] mymop -> mymXX --- psa_car_controller/psa/oauth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/psa_car_controller/psa/oauth.py b/psa_car_controller/psa/oauth.py index 6577f0a..607c0d2 100644 --- a/psa_car_controller/psa/oauth.py +++ b/psa_car_controller/psa/oauth.py @@ -45,7 +45,7 @@ class OpenIdCredentialManager(CredentialManager): ret = "" while len(ret) != 36: - ret = input("\nCopy+paste the resulting mymop-code (in F12 > Network, " \ + ret = input("\nCopy+paste the resulting mymXX-code (in F12 > Network, " \ "when you hit the final OK button, 36 chars, UUID format): ") self._token_request({ "grant_type": 'authorization_code', "code": ret, From cdf65fa99949d9019b5430b97e4d47b55618d8cd Mon Sep 17 00:00:00 2001 From: kireffoh Date: Sat, 10 Feb 2024 17:07:35 +0100 Subject: [PATCH 16/27] Update car_models.yml Added VIN VR7EZZKXZM of e-berlingo --- psa_car_controller/psacc/resources/car_models.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/psa_car_controller/psacc/resources/car_models.yml b/psa_car_controller/psacc/resources/car_models.yml index f22f518..4fa256b 100644 --- a/psa_car_controller/psacc/resources/car_models.yml +++ b/psa_car_controller/psacc/resources/car_models.yml @@ -12,6 +12,13 @@ abrp_name: Citroen;e-Berlingo;2022+ (alpha) reg: VR7EZZKXZP.* max_elec_consumption: 70 +- !ElecModel + name: e-berlingo 2022+ + battery_power: 46 + fuel_capacity: 0 + abrp_name: Citroen;e-Berlingo;2022+ (alpha) + reg: VR7EZZKXZM.* + max_elec_consumption: 70 - !ElecModel name: e-berlingo 2022+ battery_power: 46 From 0fc116fbb524effbd954713087f1fdbb7b7d4a96 Mon Sep 17 00:00:00 2001 From: Gernot Hillier Date: Thu, 15 Feb 2024 21:48:47 +0100 Subject: [PATCH 17/27] fix: stay with paho-mqtt 1.x due to breaking change in 2.0 This fixes psacc crashing with: TypeError: Client.__init__() missing 1 required positional argument: 'callback_api_version' paho-mqtt 2.0, released on Feb, 10th, requires additional arguments to mqtt.Client(), see https://github.com/eclipse/paho.mqtt.python/blob/v2.0.0/docs/migrations.rst. So stay with 1.x for now. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 19d4c5f..831291d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ include = [ [tool.poetry.dependencies] python = ">=3.7.2, <4.0.0" -paho-mqtt = ">=1.5.0" +paho-mqtt = ">=1.5.0, <2.0.0" dash = ">=2.9.0, <3.0.0" dash-daq = "^0.5.0" plotly = ">=5" From c67286ac4af442dbd819762e8affdc598519e126 Mon Sep 17 00:00:00 2001 From: Florian Bezannier Date: Sat, 27 Jan 2024 13:28:32 +0100 Subject: [PATCH 18/27] fix: object is not subscriptable #744 --- psa_car_controller/psacc/utils/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/psa_car_controller/psacc/utils/utils.py b/psa_car_controller/psacc/utils/utils.py index 5dc3ce3..be4ecc1 100644 --- a/psa_car_controller/psacc/utils/utils.py +++ b/psa_car_controller/psacc/utils/utils.py @@ -9,7 +9,7 @@ TIMEOUT_IN_S = 10 def get_temp(latitude: str, longitude: str, api_key: str) -> float: try: - if not (latitude is None or longitude is None or api_key is None): + if latitude and longitude and api_key: weather_rep = requests.get("https://api.openweathermap.org/data/2.5/onecall", params={"lat": latitude, "lon": longitude, "exclude": "minutely,hourly,daily,alerts", @@ -17,7 +17,7 @@ def get_temp(latitude: str, longitude: str, api_key: str) -> float: "units": "metric"}, timeout=TIMEOUT_IN_S) weather_res_json = weather_rep.json() - temp = weather_res_json.get("current", weather_res_json.get("main"))["temp"] + temp = weather_res_json.get("current", weather_res_json["main"])["temp"] logger.debug("Temperature :%fc", temp) return temp except ConnectionError: From 18a93626923d4d708b0cd666153f6d25d67e5f5e Mon Sep 17 00:00:00 2001 From: Florian Bezannier Date: Sat, 27 Jan 2024 13:28:32 +0100 Subject: [PATCH 19/27] fix: object is not subscriptable #744 --- psa_car_controller/psacc/utils/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/psa_car_controller/psacc/utils/utils.py b/psa_car_controller/psacc/utils/utils.py index 5dc3ce3..be4ecc1 100644 --- a/psa_car_controller/psacc/utils/utils.py +++ b/psa_car_controller/psacc/utils/utils.py @@ -9,7 +9,7 @@ TIMEOUT_IN_S = 10 def get_temp(latitude: str, longitude: str, api_key: str) -> float: try: - if not (latitude is None or longitude is None or api_key is None): + if latitude and longitude and api_key: weather_rep = requests.get("https://api.openweathermap.org/data/2.5/onecall", params={"lat": latitude, "lon": longitude, "exclude": "minutely,hourly,daily,alerts", @@ -17,7 +17,7 @@ def get_temp(latitude: str, longitude: str, api_key: str) -> float: "units": "metric"}, timeout=TIMEOUT_IN_S) weather_res_json = weather_rep.json() - temp = weather_res_json.get("current", weather_res_json.get("main"))["temp"] + temp = weather_res_json.get("current", weather_res_json["main"])["temp"] logger.debug("Temperature :%fc", temp) return temp except ConnectionError: From 29c542fd3a5b59c51e89522c3af5d4c2139dfa77 Mon Sep 17 00:00:00 2001 From: HenningS81 <43236351+HenningS81@users.noreply.github.com> Date: Fri, 23 Feb 2024 22:57:07 +0100 Subject: [PATCH 20/27] Update README.md Broken link to API document in chapter 2 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index cb8f357..5103b80 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ The official API is documented [here](https://developer.groupe-psa.io/webapi/b2c - [Installation on Raspberry Pi with docker-compose (external Tutorial)](https://return2.net/opel-peugeot-electric-vehicle-set-charging-threshold-limit/) ## II. Use the API -Look at [API documentation](./docs/psacc_api) +Look at [API documentation](./docs/psacc_api.md) ## III. Use the dashboard From ce3c7a4ae4a02d2b7d8c3e9b5eb8195c5f48fff3 Mon Sep 17 00:00:00 2001 From: Florian Bezannier Date: Sat, 24 Feb 2024 11:36:32 +0100 Subject: [PATCH 21/27] feat: retrieve redirect scheme from constant and allow retry auth Signed-off-by: Florian Bezannier --- psa_car_controller/psa/constants.py | 18 +++++++--- psa_car_controller/psa/oauth.py | 35 +++++++++++-------- .../psacc/application/psa_client.py | 2 +- 3 files changed, 35 insertions(+), 20 deletions(-) diff --git a/psa_car_controller/psa/constants.py b/psa_car_controller/psa/constants.py index a41a858..9e86ba7 100644 --- a/psa_car_controller/psa/constants.py +++ b/psa_car_controller/psa/constants.py @@ -3,12 +3,20 @@ IMMEDIATE_CHARGE = "immediate" PSA_CORRELATION_DATE_FORMAT = "%Y%m%d%H%M%S%f" PSA_DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ" realm_info = { - "clientsB2CPeugeot": {"oauth_url": "https://idpcvs.peugeot.com/am/oauth2/access_token", "app_name": "MyPeugeot"}, - "clientsB2CCitroen": {"oauth_url": "https://idpcvs.citroen.com/am/oauth2/access_token", "app_name": "MyCitroen"}, - "clientsB2CDS": {"oauth_url": "https://idpcvs.driveds.com/am/oauth2/access_token", "app_name": "MyDS"}, - "clientsB2COpel": {"oauth_url": "https://idpcvs.opel.com/am/oauth2/access_token", "app_name": "MyOpel"}, + "clientsB2CPeugeot": {"oauth_url": "https://idpcvs.peugeot.com/am/oauth2/access_token", "app_name": "MyPeugeot", + "scheme": "mymap"}, + "clientsB2CCitroen": {"oauth_url": "https://idpcvs.citroen.com/am/oauth2/access_token", "app_name": "MyCitroen", + "scheme": "mymacsdk"}, + "clientsB2CDS": {"oauth_url": "https://idpcvs.driveds.com/am/oauth2/access_token", "app_name": "MyDS", + "scheme": "mymdssdk"}, + + "clientsB2COpel": {"oauth_url": "https://idpcvs.opel.com/am/oauth2/access_token", "app_name": "MyOpel", + "scheme": "mymopsdk"}, + "clientsB2CVauxhall": {"oauth_url": "https://idpcvs.vauxhall.co.uk/am/oauth2/access_token", - "app_name": "MyVauxhall"} + "app_name": "MyVauxhall", + "scheme": "mymvxsdk", + } } MQTT_BRANDCODE = {"AP": "AP", "AC": "AC", diff --git a/psa_car_controller/psa/oauth.py b/psa_car_controller/psa/oauth.py index 607c0d2..079fea4 100644 --- a/psa_car_controller/psa/oauth.py +++ b/psa_car_controller/psa/oauth.py @@ -7,7 +7,7 @@ from typing import Tuple from http import HTTPStatus from typing import Optional -from oauth2_client.credentials_manager import CredentialManager, ServiceInformation +from oauth2_client.credentials_manager import CredentialManager, ServiceInformation, OAuthError from requests import Response, RequestException from psa_car_controller.common.utils import rate_limit @@ -35,21 +35,28 @@ class OpenIdCredentialManager(CredentialManager): challenge = encoded.decode('ascii')[:-1] return verifier, challenge - def init_with_brand_country_code(self, brand: str, country_code: str): - redir_uri = "mym" + brand.lower() + "://oauth2redirect/" + country_code.lower() - code_verifier, code_challenge = self.generate_sha256_pkce(64) - url = self.generate_authorize_url(redir_uri, secrets.token_urlsafe(16), - code_challenge=code_challenge, code_challenge_method="S256") - - logger.info("Now login to this URL in a browser: %s", url) - + def init_with_oauth2_redirect(self, scheme: str, country_code: str): ret = "" - while len(ret) != 36: - ret = input("\nCopy+paste the resulting mymXX-code (in F12 > Network, " \ - "when you hit the final OK button, 36 chars, UUID format): ") + while True: + redir_uri = scheme + "://oauth2redirect/" + country_code.lower() + code_verifier, code_challenge = self.generate_sha256_pkce(64) + url = self.generate_authorize_url(redir_uri, secrets.token_urlsafe(16), + code_challenge=code_challenge, code_challenge_method="S256") - self._token_request({ "grant_type": 'authorization_code', "code": ret, - "redirect_uri": redir_uri, "code_verifier": code_verifier}, False) + logger.info("Now login to this URL in a browser: %s", url) + + try: + ret = input("\nCopy+paste the resulting mymXX-code (in F12 > Network, " + "when you hit the final OK button, 36 chars, UUID format): ") + logger.info("Try getting a token with code %s", ret) + assert len(ret) == 36, "Invalid code length" + self._token_request({"grant_type": 'authorization_code', "code": ret, + "redirect_uri": redir_uri, "code_verifier": code_verifier}, False) + except (OAuthError, AssertionError): + logger.exception("Failed to get a token") + if input("Retry ? yes/NO") == "yes": + continue + break @staticmethod def _is_token_expired(response: Response) -> bool: diff --git a/psa_car_controller/psacc/application/psa_client.py b/psa_car_controller/psacc/application/psa_client.py index 67c3c62..7b08ffa 100644 --- a/psa_car_controller/psacc/application/psa_client.py +++ b/psa_car_controller/psacc/application/psa_client.py @@ -32,7 +32,7 @@ logger = CustomLogger.getLogger(__name__) class PSAClient: def connect(self): - self.manager.init_with_brand_country_code(self.brand, self.country_code) + self.manager.init_with_oauth2_redirect(realm_info[self.realm]["scheme"], self.country_code) # pylint: disable=too-many-arguments def __init__(self, refresh_token, client_id, client_secret, remote_refresh_token, customer_id, realm, country_code, From 6ded92adaea7487112a07740614ac485f9dce506 Mon Sep 17 00:00:00 2001 From: Florian Bezannier Date: Sat, 24 Feb 2024 12:09:12 +0100 Subject: [PATCH 22/27] chore: add how-to auth with new method --- psa_car_controller/web/view/config_views.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/psa_car_controller/web/view/config_views.py b/psa_car_controller/web/view/config_views.py index c07fb7d..5634d58 100644 --- a/psa_car_controller/web/view/config_views.py +++ b/psa_car_controller/web/view/config_views.py @@ -61,10 +61,16 @@ login_config_layout = dbc.Row(dbc.Col(md=12, lg=2, className="m-3", children=[ color="secondary", )]), dbc.Row(dbc.Button("Submit", color="primary", id="submit-form")), - dbc.Row( - dbc.FormText( - "After submit be patient it can take some time...", - color="secondary")), + html.Div(html.P([ + "1. After submit switch back to the CLI and wait for the login URL to be shown", html.Br(), + "2. Open that URL in a browser", html.Br(), + "3. Complete the login procedure there too", html.Br(), + "4. Open your browser's DevTools (F12) and then the 'Network' tab", html.Br(), + "5. Hit the final 'OK' button, under 'LOGIN SUCCESSFUL'", html.Br(), + "6. Find xxxx://oauth2redirect....?code=&scope=openid... " + "in the DevTools and paste it into the command line interface", html.Br(), + "7. Switch back to the browser and do the OTP config", html.Br()] + )), dcc.Loading( id="loading-2", children=[html.Div([html.Div(id="form_result")])], From b477e4a34c02727fee632a47a2c0251723a24276 Mon Sep 17 00:00:00 2001 From: Florian Bezannier Date: Sun, 25 Feb 2024 12:58:00 +0100 Subject: [PATCH 23/27] fix: #776 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 831291d..d187456 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ Werkzeug = ">=1.0.0" Flask = ">=1.0.4" dash-bootstrap-components = ">=1" ConfigUpdater = ">=3.0" -oauth2-client = "^1.2.1" +oauth2-client = "^1.3.0" requests = "^2.27.1" pytz = "^2021.0" argparse = "^1.4.0" From 6f81149f4539db356ffa368c8542b4008cbb98e5 Mon Sep 17 00:00:00 2001 From: Florian Bezannier Date: Mon, 26 Feb 2024 11:56:59 +0100 Subject: [PATCH 24/27] feat: add UI to provide oauth2 code --- .../psa/connected_car_api/configuration.py | 2 +- psa_car_controller/psa/oauth.py | 58 +++--- psa_car_controller/psa/setup/apk_parser.py | 3 + psa_car_controller/psa/setup/app_decoder.py | 181 ++++++++++-------- .../psacc/application/car_controller.py | 18 +- .../psacc/application/psa_client.py | 9 +- psa_car_controller/web/view/config_oauth.py | 63 ++++++ psa_car_controller/web/view/config_views.py | 33 ++-- psa_car_controller/web/view/views.py | 3 + 9 files changed, 224 insertions(+), 146 deletions(-) create mode 100644 psa_car_controller/web/view/config_oauth.py diff --git a/psa_car_controller/psa/connected_car_api/configuration.py b/psa_car_controller/psa/connected_car_api/configuration.py index 4b2fe62..8f1a4a4 100644 --- a/psa_car_controller/psa/connected_car_api/configuration.py +++ b/psa_car_controller/psa/connected_car_api/configuration.py @@ -72,7 +72,7 @@ class Configuration(object): # Debug file location self.logger_file = None # Debug switch - self.debug = True + self.debug = False # SSL/TLS verification # Set this to false to skip verifying SSL certificate when calling API diff --git a/psa_car_controller/psa/oauth.py b/psa_car_controller/psa/oauth.py index 079fea4..c3a959e 100644 --- a/psa_car_controller/psa/oauth.py +++ b/psa_car_controller/psa/oauth.py @@ -7,7 +7,7 @@ from typing import Tuple from http import HTTPStatus from typing import Optional -from oauth2_client.credentials_manager import CredentialManager, ServiceInformation, OAuthError +from oauth2_client.credentials_manager import CredentialManager, ServiceInformation from requests import Response, RequestException from psa_car_controller.common.utils import rate_limit @@ -18,45 +18,43 @@ from psa_car_controller.psa.connected_car_api.rest import ApiException logger = logging.getLogger(__name__) +def generate_sha256_pkce(length: int) -> Tuple[str, str]: + if not (43 <= length <= 128): + raise ValueError("Invalid length: %d" % length) + verifier = secrets.token_urlsafe(length) + encoded = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode('ascii')).digest()) + challenge = encoded.decode('ascii')[:-1] + return verifier, challenge + + class OpenIdCredentialManager(CredentialManager): + @staticmethod + def create(service_information: ServiceInformation, scheme: str, country_code: str, + proxies: Optional[dict] = None): + manager = OpenIdCredentialManager(service_information, proxies) + manager.redirect_uri = scheme + "://oauth2redirect/" + country_code.lower() + return manager + def __init__(self, service_information: ServiceInformation, proxies: Optional[dict] = None): super().__init__(service_information, proxies) self.refresh_callbacks = [] + self.code_verifier = None + self.redirect_uri = None def _grant_password_request_realm(self, login: str, password: str, realm: str) -> dict: return {"grant_type": 'password', "username": login, "scope": ' '.join(self.service_information.scopes), "password": password, "realm": realm} - def generate_sha256_pkce(self, length: int) -> Tuple[str, str]: - if not (43 <= length <= 128): - raise ValueError("Invalid length: %d" % length) - verifier = secrets.token_urlsafe(length) - encoded = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode('ascii')).digest()) - challenge = encoded.decode('ascii')[:-1] - return verifier, challenge + def generate_redirect_url(self): + self.code_verifier, code_challenge = generate_sha256_pkce(64) + return self.generate_authorize_url(self.redirect_uri, secrets.token_urlsafe(16), + code_challenge=code_challenge, code_challenge_method="S256") - def init_with_oauth2_redirect(self, scheme: str, country_code: str): - ret = "" - while True: - redir_uri = scheme + "://oauth2redirect/" + country_code.lower() - code_verifier, code_challenge = self.generate_sha256_pkce(64) - url = self.generate_authorize_url(redir_uri, secrets.token_urlsafe(16), - code_challenge=code_challenge, code_challenge_method="S256") - - logger.info("Now login to this URL in a browser: %s", url) - - try: - ret = input("\nCopy+paste the resulting mymXX-code (in F12 > Network, " - "when you hit the final OK button, 36 chars, UUID format): ") - logger.info("Try getting a token with code %s", ret) - assert len(ret) == 36, "Invalid code length" - self._token_request({"grant_type": 'authorization_code', "code": ret, - "redirect_uri": redir_uri, "code_verifier": code_verifier}, False) - except (OAuthError, AssertionError): - logger.exception("Failed to get a token") - if input("Retry ? yes/NO") == "yes": - continue - break + def connect_with_code(self, code: str): + assert len(code) == 36, "Invalid code length" + self._token_request({"grant_type": 'authorization_code', "code": code, + "redirect_uri": self.redirect_uri, "code_verifier": self.code_verifier}, + False) @staticmethod def _is_token_expired(response: Response) -> bool: diff --git a/psa_car_controller/psa/setup/apk_parser.py b/psa_car_controller/psa/setup/apk_parser.py index f9a8286..6a3e3b8 100644 --- a/psa_car_controller/psa/setup/apk_parser.py +++ b/psa_car_controller/psa/setup/apk_parser.py @@ -1,4 +1,5 @@ import json +import logging import os from androguard.core.bytecodes.apk import APK @@ -8,6 +9,8 @@ from cryptography.hazmat.primitives.serialization import pkcs12 from psa_car_controller.psa.constants import BRAND +logging.getLogger("androguard").setLevel(logging.ERROR) + class ApkParser: def __init__(self, filename, country_code): diff --git a/psa_car_controller/psa/setup/app_decoder.py b/psa_car_controller/psa/setup/app_decoder.py index cd475ec..3fcf797 100755 --- a/psa_car_controller/psa/setup/app_decoder.py +++ b/psa_car_controller/psa/setup/app_decoder.py @@ -8,6 +8,7 @@ import requests from psa_car_controller.psa.constants import BRAND from psa_car_controller.psa.setup.apk_parser import ApkParser from psa_car_controller.psa.setup.github import urlretrieve_from_github +from psa_car_controller.psacc.application.car_controller import PSACarController from psa_car_controller.psacc.application.psa_client import PSAClient from psa_car_controller.psacc.application.charge_control import ChargeControl, ChargeControls @@ -17,6 +18,7 @@ APP_VERSION = "1.33.0" GITHUB_USER = "flobz" GITHUB_REPO = "psa_apk" TIMEOUT_IN_S = 10 +app = PSACarController() def get_content_from_apk(filename: str, country_code: str) -> ApkParser: @@ -26,91 +28,108 @@ def get_content_from_apk(filename: str, country_code: str) -> ApkParser: return apk_parser -def firstLaunchConfig(package_name, client_email, client_password, country_code, # pylint: disable=too-many-locals - config_prefix=""): - filename = package_name.split(".")[-1] + ".apk" - apk_parser = get_content_from_apk(filename, country_code) - - try: - res = requests.post(apk_parser.host_brandid_prod + "/GetAccessToken", - headers={ - "Connection": "Keep-Alive", - "Content-Type": "application/json", - "User-Agent": "okhttp/2.3.0" - }, - params={"jsonRequest": json.dumps( - {"siteCode": apk_parser.site_code, "culture": "fr-FR", "action": "authenticate", - "fields": {"USR_EMAIL": {"value": client_email}, - "USR_PASSWORD": {"value": client_password}} - } - )}, - timeout=TIMEOUT_IN_S - ) - - token = res.json()["accessToken"] - except Exception as ex: - msg = traceback.format_exc() + f"\nHOST_BRANDID : {apk_parser.host_brandid_prod} " \ - f"sitecode: {apk_parser.site_code}" +class InitialSetup: + def __init__(self, package_name, client_email, client_password, country_code): + self.package_name = package_name + filename = package_name.split(".")[-1] + ".apk" + apk_parser = get_content_from_apk(filename, country_code) + self.culture = apk_parser.culture + self.site_code = apk_parser.site_code + self.client_id = apk_parser.client_id + self.client_secret = apk_parser.client_secret + self.country_code = country_code + self.user_info = None + self.customer_id = None try: - msg += res.text - except BaseException: - pass - logger.error(msg) - raise ConnectionError(msg) from ex - try: - res2 = requests.post( - f"https://mw-{BRAND[package_name]['brand_code'].lower()}-m2c.mym.awsmpsa.com/api/v1/user", - params={ - "culture": apk_parser.culture, - "width": 1080, - "version": APP_VERSION - }, - data=json.dumps({"site_code": apk_parser.site_code, "ticket": token}), - headers={ - "Connection": "Keep-Alive", - "Content-Type": "application/json;charset=UTF-8", - "Source-Agent": "App-Android", - "Token": token, - "User-Agent": "okhttp/4.8.0", - "Version": APP_VERSION - }, - cert=("certs/public.pem", "certs/private.pem"), - timeout=TIMEOUT_IN_S - ) + res = requests.post(apk_parser.host_brandid_prod + "/GetAccessToken", + headers={ + "Connection": "Keep-Alive", + "Content-Type": "application/json", + "User-Agent": "okhttp/2.3.0" + }, + params={"jsonRequest": json.dumps( + {"siteCode": apk_parser.site_code, "culture": "fr-FR", "action": "authenticate", + "fields": {"USR_EMAIL": {"value": client_email}, + "USR_PASSWORD": {"value": client_password}} + } + )}, + timeout=TIMEOUT_IN_S + ) - res_dict = res2.json()["success"] - customer_id = BRAND[package_name]["brand_code"] + "-" + res_dict["id"] - except Exception as ex: - msg = traceback.format_exc() + self.token = res.json()["accessToken"] + except Exception as ex: + msg = traceback.format_exc() + f"\nHOST_BRANDID : {apk_parser.host_brandid_prod} " \ + f"sitecode: {apk_parser.site_code}" + try: + msg += res.text + except BaseException: + pass + logger.error(msg) + raise ConnectionError(msg) from ex + + # Psacc + self.user_info = self.__fetch_user_info() + self.customer_id = BRAND[self.package_name]["brand_code"] + "-" + self.user_info["id"] + + self.psacc = PSAClient(None, self.client_id, self.client_secret, + None, self.customer_id, BRAND[self.package_name]["realm"], + self.country_code, BRAND[self.package_name]["brand_code"]) + + def __fetch_user_info(self): try: - msg += res2.text - except BaseException: - pass - logger.error(msg) - raise ConnectionError(msg) from ex - # Psacc - psacc = PSAClient(None, apk_parser.client_id, apk_parser.client_secret, - None, customer_id, BRAND[package_name]["realm"], - country_code, BRAND[package_name]["brand_code"]) - psacc.connect() - psacc.save_config(name=config_prefix + "config.json") - res = psacc.get_vehicles() + res2 = requests.post( + f"https://mw-{BRAND[self.package_name]['brand_code'].lower()}-m2c.mym.awsmpsa.com/api/v1/user", + params={ + "culture": self.culture, + "width": 1080, + "version": APP_VERSION + }, + data=json.dumps({"site_code": self.site_code, "ticket": self.token}), + headers={ + "Connection": "Keep-Alive", + "Content-Type": "application/json;charset=UTF-8", + "Source-Agent": "App-Android", + "Token": self.token, + "User-Agent": "okhttp/4.8.0", + "Version": APP_VERSION + }, + cert=("certs/public.pem", "certs/private.pem"), + timeout=TIMEOUT_IN_S + ) - if len(res) == 0: - raise ValueError("No vehicle in your account is compatible with this API, you vehicle is probably too old...") + res_dict = res2.json()["success"] + except Exception as ex: + msg = traceback.format_exc() + try: + msg += res2.text + except BaseException: + pass + logger.error(msg) + raise ConnectionError(msg) from ex + return res_dict - for vehicle in res_dict["vehicles"]: - car = psacc.vehicles_list.get_car_by_vin(vehicle["vin"]) - if car is not None and "short_label" in vehicle and car.label == "unknown": - car.label = vehicle["short_label"].split(" ")[-1] # remove new, nouvelle, neu word.... - psacc.vehicles_list.save_cars() + def connect(self, code, config_prefix=""): + self.psacc.connect(code) + self.psacc.save_config(name=config_prefix + "config.json") + res = self.psacc.get_vehicles() - logger.info("\nYour vehicles: %s", res) + if len(res) == 0: + raise ValueError( + "No vehicle in your account is compatible with this API, you vehicle is probably too old...") - # Charge control - charge_controls = ChargeControls(config_prefix + "charge_config.json") - for vehicle in res: - chc = ChargeControl(psacc, vehicle.vin, 100, [0, 0]) - charge_controls[vehicle.vin] = chc - charge_controls.save_config() - return "Success !!!" + for vehicle in self.user_info["vehicles"]: + car = self.psacc.vehicles_list.get_car_by_vin(vehicle["vin"]) + if car is not None and "short_label" in vehicle and car.label == "unknown": + car.label = vehicle["short_label"].split(" ")[-1] # remove new, nouvelle, neu word.... + self.psacc.vehicles_list.save_cars() + + logger.info("\nYour vehicles: %s", res) + + # Charge control + charge_controls = ChargeControls(config_prefix + "charge_config.json") + for vehicle in res: + chc = ChargeControl(self.psacc, vehicle.vin, 100, [0, 0]) + charge_controls[vehicle.vin] = chc + charge_controls.save_config() + app.load_app() + app.start_remote_control() diff --git a/psa_car_controller/psacc/application/car_controller.py b/psa_car_controller/psacc/application/car_controller.py index 64e6e7a..cc49fe1 100644 --- a/psa_car_controller/psacc/application/car_controller.py +++ b/psa_car_controller/psacc/application/car_controller.py @@ -4,7 +4,7 @@ import logging import socket import sys import threading -from os import environ, path +from os import path import psa_car_controller from oauth2_client.credentials_manager import OAuthError @@ -35,8 +35,6 @@ def parse_args(): parser.add_argument("-p", "--port", help="change server listen port", default="5000") parser.add_argument("-r", "--record", help="save vehicle data to db", action='store_true') parser.add_argument("-R", "--refresh", help="refresh vehicles status every x min", type=int) - parser.add_argument("-m", "--mail", default=environ.get('USER_EMAIL', None), help="set the email address") - parser.add_argument("-P", "--password", default=environ.get('USER_PASSWORD', None), help="set the password") parser.add_argument("--remote-disable", help="disable remote control", action='store_true') parser.add_argument("--offline", help="offline limited mode", action='store_true') parser.add_argument("--web-conf", help="ignore if config files not existing yet", action='store_true') @@ -76,7 +74,6 @@ class PSACarController(metaclass=Singleton): logger.error("start_remote_control failed redo otp config") def load_app(self) -> bool: - # pylint: disable=too-many-branches my_logger(handler_level=int(self.args.debug)) logger.info("App version %s", __version__) @@ -105,16 +102,11 @@ class PSACarController(metaclass=Singleton): if self.is_good: logger.info(str(self.myp.get_vehicles())) except OAuthError: - if self.args.mail and self.args.password: - self.myp.connect(self.args.mail, self.args.password) - logger.info(str(self.myp.get_vehicles())) - self.is_good = True + self.is_good = False + if self.args.web_conf: + logger.error("Please reconnect by going to config web page") else: - self.is_good = False - if self.args.web_conf: - logger.error("Please reconnect by going to config web page") - else: - logger.error("Connection need to be updated, Please redo authentication process.") + logger.error("Connection need to be updated, Please redo authentication process.") if self.args.refresh: self.myp.info_refresh_rate = self.args.refresh * 60 if self.is_good: diff --git a/psa_car_controller/psacc/application/psa_client.py b/psa_car_controller/psacc/application/psa_client.py index 7b08ffa..1dc3458 100644 --- a/psa_car_controller/psacc/application/psa_client.py +++ b/psa_car_controller/psacc/application/psa_client.py @@ -31,8 +31,8 @@ logger = CustomLogger.getLogger(__name__) class PSAClient: - def connect(self): - self.manager.init_with_oauth2_redirect(realm_info[self.realm]["scheme"], self.country_code) + def connect(self, code: str): + self.manager.connect_with_code(code) # pylint: disable=too-many-arguments def __init__(self, refresh_token, client_id, client_secret, remote_refresh_token, customer_id, realm, country_code, @@ -44,7 +44,9 @@ class PSAClient: client_secret, SCOPE, True) self.client_id = client_id - self.manager = OpenIdCredentialManager(self.service_information) + self.country_code = country_code + self.manager = OpenIdCredentialManager.create(self.service_information, + realm_info[self.realm]["scheme"], self.country_code) self.api_config = Oauth2PSACCApiConfig() self.api_config.set_refresh_callback(self.manager.refresh_token_now) self.manager.refresh_token = refresh_token @@ -59,7 +61,6 @@ class PSAClient: self.remote_token_last_update = None self._record_enabled = False self.weather_api = weather_api - self.country_code = country_code self.brand = brand self.info_callback = [] self.info_refresh_rate = 120 diff --git a/psa_car_controller/web/view/config_oauth.py b/psa_car_controller/web/view/config_oauth.py new file mode 100644 index 0000000..0be4487 --- /dev/null +++ b/psa_car_controller/web/view/config_oauth.py @@ -0,0 +1,63 @@ +import logging + +from dash import callback_context, html, dcc +from dash.exceptions import PreventUpdate +from flask import request + +from psa_car_controller.web.app import dash_app +import dash_bootstrap_components as dbc +from dash.dependencies import Output, Input, State + +from psa_car_controller.web.view import config_views + +logger = logging.getLogger(__name__) + + +def get_oauth_config_layout(redirect_url): + return dbc.Row(dbc.Col(md=12, lg=2, className="m-3", children=[ + dbc.Row(html.H2('Connection to PSA')), + dbc.Row(className="ms-2", children=[ + html.Div(html.P([ + html.A("1. Click here", href=redirect_url, target="_blank"), html.Br(), + "2. Complete the login procedure there too until you see 'LOGIN SUCCESSFUL'", html.Br(), + "3. Open your browser's DevTools (F12) and then the click on 'Network' tab", html.Br(), + "4. Hit the final 'OK' button, under 'LOGIN SUCCESSFUL'", html.Br(), + "5. Find in the network tab: xxxx://oauth2redirect....?code=&scope=openid... ", + html.Br(), + html.A("You can find more info here", + href="https://github.com/flobz/psa_car_controller/discussions/779"), html.Br()] + )), + dbc.Form([ + html.Div([ + dbc.Label("Code", html_for="psa-oauth-code"), + dbc.Input(type="text", id="psa-oauth-code", placeholder="Enter login code"), + dbc.FormText( + "PSA code from step above", + color="secondary", + )]), + dbc.Row(dbc.Button("Submit", color="primary", id="finish-oauth")), + dcc.Loading( + id="loading-2", + children=[html.Div([html.Div(id="oauth-result")])], + type="circle", + ), + ]) + ])])) + + +@dash_app.callback( + Output("oauth-result", "children"), + Input("finish-oauth", "n_clicks"), + State("psa-oauth-code", "value")) +def finish_oauth(n_clicks, code): # pylint: disable=unused-argument + ctx = callback_context + if ctx.triggered: + try: + config_views.INITIAL_SETUP.connect(code) + return dbc.Alert(["PSA login finish !", + html.A(" Go to otp config", href=request.url_root + "config_otp")], + color="success") + except Exception as e: + logger.exception("finish_oauth:") + return dbc.Alert(str(e), color="danger") + raise PreventUpdate() diff --git a/psa_car_controller/web/view/config_views.py b/psa_car_controller/web/view/config_views.py index 5634d58..bc8c065 100644 --- a/psa_car_controller/web/view/config_views.py +++ b/psa_car_controller/web/view/config_views.py @@ -1,4 +1,5 @@ import logging +from urllib import parse from dash import callback_context, html, dcc from dash.exceptions import PreventUpdate @@ -6,7 +7,7 @@ from flask import request from psa_car_controller.psa.otp.otp import new_otp_session from psa_car_controller.psacc.application.car_controller import PSACarController -from psa_car_controller.psa.setup.app_decoder import firstLaunchConfig +from psa_car_controller.psa.setup.app_decoder import InitialSetup from psa_car_controller.common.mylogger import LOG_FILE from psa_car_controller.web.app import dash_app import dash_bootstrap_components as dbc @@ -15,7 +16,9 @@ from dash.dependencies import Output, Input, State logger = logging.getLogger(__name__) app = PSACarController() -login_config_layout = dbc.Row(dbc.Col(md=12, lg=2, className="m-3", children=[ +INITIAL_SETUP: InitialSetup = None + +setup_config_layout = dbc.Row(dbc.Col(md=12, lg=2, className="m-3", children=[ dbc.Row(html.H2('Config')), dbc.Row(className="ms-2", children=[ dbc.Form([ @@ -61,16 +64,9 @@ login_config_layout = dbc.Row(dbc.Col(md=12, lg=2, className="m-3", children=[ color="secondary", )]), dbc.Row(dbc.Button("Submit", color="primary", id="submit-form")), - html.Div(html.P([ - "1. After submit switch back to the CLI and wait for the login URL to be shown", html.Br(), - "2. Open that URL in a browser", html.Br(), - "3. Complete the login procedure there too", html.Br(), - "4. Open your browser's DevTools (F12) and then the 'Network' tab", html.Br(), - "5. Hit the final 'OK' button, under 'LOGIN SUCCESSFUL'", html.Br(), - "6. Find xxxx://oauth2redirect....?code=&scope=openid... " - "in the DevTools and paste it into the command line interface", html.Br(), - "7. Switch back to the browser and do the OTP config", html.Br()] - )), + dbc.Row(dbc.FormText( + "After submit be patient it can take some time...", + color="secondary")), dcc.Loading( id="loading-2", children=[html.Div([html.Div(id="form_result")])], @@ -121,7 +117,7 @@ def log_layout(): def config_layout(activeTabs="log"): return dbc.Tabs(active_tab=activeTabs, children=[ dbc.Tab([log_layout()], label="Log", tab_id="log"), - dbc.Tab([login_config_layout], label="User config", tab_id="login"), + dbc.Tab([setup_config_layout], label="User config", tab_id="login"), dbc.Tab([config_otp_layout], label="OTP config", tab_id="otp")]) @@ -135,11 +131,14 @@ def config_layout(activeTabs="log"): def connectPSA(n_clicks, app_name, email, password, countrycode): # pylint: disable=unused-argument ctx = callback_context if ctx.triggered: + logger.info("Initial setup...") try: - res = firstLaunchConfig(app_name, email, password, countrycode) - app.load_app() - app.start_remote_control() - return dbc.Alert([res, html.A(" Go to otp config", href=request.url_root + "config_otp")], color="success") + global INITIAL_SETUP + INITIAL_SETUP = InitialSetup(app_name, email, password, countrycode) + redirect_uri = parse.quote(INITIAL_SETUP.psacc.manager.generate_redirect_url()) + return dbc.Alert(["Success !", html.A(" Go to login", + href=f"{request.url_root}config_connect?url={redirect_uri}")], + color="success") except Exception as e: res = str(e) logger.exception(e) diff --git a/psa_car_controller/web/view/views.py b/psa_car_controller/web/view/views.py index 4990873..7ccdad2 100644 --- a/psa_car_controller/web/view/views.py +++ b/psa_car_controller/web/view/views.py @@ -22,6 +22,7 @@ from psa_car_controller.web import figures from psa_car_controller.web.app import dash_app from psa_car_controller.psacc.repository.db import Database from psa_car_controller.web.tools.utils import diff_dashtable, unix_time_millis, get_marks_from_start_end, create_card +from psa_car_controller.web.view.config_oauth import get_oauth_config_layout from psa_car_controller.web.view.config_views import log_layout, config_layout # pylint: disable=invalid-name @@ -75,6 +76,8 @@ def display_page(pathname, search): page = config_layout() elif pathname == "/config_login": page = config_layout("login") + elif pathname == "/config_connect": + page = get_oauth_config_layout(query_params["url"]) elif pathname == "/log": page = log_layout() elif not APP.is_good: From 9a93f735fc99cf5fa189253a3fc0392ecfe1e711 Mon Sep 17 00:00:00 2001 From: Florian Bezannier Date: Mon, 26 Feb 2024 12:05:57 +0100 Subject: [PATCH 25/27] fix: get_temp KeyError --- psa_car_controller/psacc/utils/utils.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/psa_car_controller/psacc/utils/utils.py b/psa_car_controller/psacc/utils/utils.py index be4ecc1..1c396e6 100644 --- a/psa_car_controller/psacc/utils/utils.py +++ b/psa_car_controller/psacc/utils/utils.py @@ -17,13 +17,13 @@ def get_temp(latitude: str, longitude: str, api_key: str) -> float: "units": "metric"}, timeout=TIMEOUT_IN_S) weather_res_json = weather_rep.json() - temp = weather_res_json.get("current", weather_res_json["main"])["temp"] + temp = weather_res_json.get("current", weather_res_json.get("main"))["temp"] logger.debug("Temperature :%fc", temp) return temp except ConnectionError: logger.error("Can't connect to openweathermap :", exc_info=True) - except KeyError: - logger.error("Unable to get temperature from openweathermap :", exc_info=True) + except (KeyError, TypeError): + logger.exception("Unable to get temperature from openweathermap :") return None From 1e974a977f8872d5f682d249513ac1ab5a174244 Mon Sep 17 00:00:00 2001 From: Florian Bezannier Date: Mon, 26 Feb 2024 12:26:21 +0100 Subject: [PATCH 26/27] fix: handling otp error --- psa_car_controller/psa/otp/otp.py | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/psa_car_controller/psa/otp/otp.py b/psa_car_controller/psa/otp/otp.py index 7dc0187..5607338 100644 --- a/psa_car_controller/psa/otp/otp.py +++ b/psa_car_controller/psa/otp/otp.py @@ -266,17 +266,19 @@ class Otp: def get_otp_code(self): self.mode = Otp.OTP_MODE otp_code = None - if self.activation_start(): - res = self.activation_finalyze() - if res != Otp.NOK: - if res == Otp.OTP_TWICE: - self.mode = Otp.OTP_MODE - self.activation_start() - self.activation_finalyze() - otp_code = self._get_otp_code() - logger.debug("otp code: %s", otp_code) - if otp_code is None: - raise ConfigException("Can't get otp code") + try: + if self.activation_start(): + res = self.activation_finalyze() + if res != Otp.NOK: + if res == Otp.OTP_TWICE: + self.mode = Otp.OTP_MODE + self.activation_start() + assert self.activation_finalyze() == Otp.OK + otp_code = self._get_otp_code() + assert otp_code is not None + logger.debug("otp code: %s", otp_code) + except AssertionError as e: + raise ConfigException("Can't get otp code") from e return otp_code def __getstate__(self): From 232cb2c7f0b30d005509169b5dfd807aaf0a5a63 Mon Sep 17 00:00:00 2001 From: Florian Bezannier Date: Mon, 26 Feb 2024 13:48:35 +0100 Subject: [PATCH 27/27] Revert "Merge pull request #740 from CasperJoergensen/patch-2" This reverts commit 836e9f18e6ed4e48e4e100c523ed3d3c34e0f720, reversing changes made to 030d02a7808844debde38d161ecd0bfaabba2ef0. --- psa_car_controller/psacc/resources/car_models.yml | 8 -------- 1 file changed, 8 deletions(-) diff --git a/psa_car_controller/psacc/resources/car_models.yml b/psa_car_controller/psacc/resources/car_models.yml index 5c4c490..b817bce 100644 --- a/psa_car_controller/psacc/resources/car_models.yml +++ b/psa_car_controller/psacc/resources/car_models.yml @@ -173,14 +173,6 @@ reg: VF3CCBHY6H.* max_elec_consumption: 70 max_fuel_consumption: 30 -- !CarModel - name: '208' - battery_power: 0 - fuel_capacity: 44 - abrp_name: - reg: VR3UPHNEKP.* - max_elec_consumption: 70 - max_fuel_consumption: 30 - !CarModel name: '2008' battery_power: 0