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 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 e10fbd4..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", @@ -28,7 +36,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://api.mpsa.com/api/connectedcar/v2/oauth/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/psa/oauth.py b/psa_car_controller/psa/oauth.py index 26a4590..079fea4 100644 --- a/psa_car_controller/psa/oauth.py +++ b/psa_car_controller/psa/oauth.py @@ -1,8 +1,13 @@ import logging +import hashlib +import secrets +import base64 +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 @@ -22,8 +27,36 @@ 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: %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 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 @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..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(client_email, client_password) + 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 8df313f..7b08ffa 100644 --- a/psa_car_controller/psacc/application/psa_client.py +++ b/psa_car_controller/psacc/application/psa_client.py @@ -31,14 +31,14 @@ 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): + 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, - 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.service_information = ServiceInformation(AUTHORIZE_SERVICE[self.realm], realm_info[self.realm]['oauth_url'], client_id, client_secret, @@ -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: 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: 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")])], diff --git a/pyproject.toml b/pyproject.toml index 19d4c5f..d187456 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" @@ -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"