carconnectivity-connector-seatcupra 0.1a1__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,239 @@
1
+ """
2
+ Module implements a VW Web session.
3
+ """
4
+ from __future__ import annotations
5
+ from typing import TYPE_CHECKING
6
+
7
+
8
+ from urllib.parse import parse_qsl, urlparse, urlsplit, urljoin
9
+
10
+ from urllib3.util.retry import Retry
11
+
12
+ import requests
13
+ from requests.adapters import HTTPAdapter
14
+ from requests.models import CaseInsensitiveDict
15
+
16
+ from carconnectivity.errors import APICompatibilityError, AuthenticationError, RetrievalError
17
+
18
+ from carconnectivity_connectors.seatcupra.auth.auth_util import CredentialsFormParser, HTMLFormParser, TermsAndConditionsFormParser
19
+ from carconnectivity_connectors.seatcupra.auth.openid_session import OpenIDSession
20
+
21
+ if TYPE_CHECKING:
22
+ from typing import Any, Dict
23
+
24
+
25
+ class VWWebSession(OpenIDSession):
26
+ """
27
+ VWWebSession handles the web authentication process for Cupra's web services.
28
+ """
29
+ def __init__(self, session_user, cache, accept_terms_on_login=False, **kwargs):
30
+ super(VWWebSession, self).__init__(**kwargs)
31
+ self.session_user = session_user
32
+ self.cache = cache
33
+ self.accept_terms_on_login: bool = accept_terms_on_login
34
+
35
+ # Set up the web session
36
+ retries = Retry(
37
+ total=self.retries,
38
+ backoff_factor=0.1,
39
+ status_forcelist=[500],
40
+ raise_on_status=False
41
+ )
42
+
43
+ self.websession: requests.Session = requests.Session()
44
+ self.websession.proxies.update(self.proxies)
45
+ self.websession.mount('https://', HTTPAdapter(max_retries=retries))
46
+ self.websession.headers = CaseInsensitiveDict({
47
+ 'user-agent': 'Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 '
48
+ 'Chrome/74.0.3729.185 Mobile Safari/537.36',
49
+ 'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,'
50
+ 'application/signed-exchange;v=b3',
51
+ 'accept-language': 'en-US,en;q=0.9',
52
+ 'accept-encoding': 'gzip, deflate',
53
+ 'x-requested-with': 'com.volkswagen.weconnect',
54
+ 'upgrade-insecure-requests': '1',
55
+ })
56
+
57
+ def do_web_auth(self, url: str) -> str:
58
+ """
59
+ Perform web authentication using the provided URL.
60
+
61
+ This method handles the web authentication process by:
62
+ 1. Retrieving the login form.
63
+ 2. Setting the email to the provided username.
64
+ 3. Retrieving the password form.
65
+ 4. Setting the credentials (email and password).
66
+ 5. Logging in and getting the redirect URL.
67
+ 6. Checking the URL for terms and conditions and handling consent if required.
68
+ 7. Following redirects until the final URL is reached.
69
+
70
+ Args:
71
+ url (str): The URL to start the authentication process.
72
+
73
+ Returns:
74
+ str: The final URL after successful authentication.
75
+
76
+ Raises:
77
+ AuthenticationError: If terms and conditions need to be accepted.
78
+ RetrievalError: If there is a temporary server error during login.
79
+ APICompatibilityError: If forwarding occurs without 'Location' in headers.
80
+ """
81
+ # Get the login form
82
+ email_form: HTMLFormParser = self._get_login_form(url)
83
+
84
+ # Set email to the provided username
85
+ email_form.data['email'] = self.session_user.username
86
+
87
+ # Get password form
88
+ password_form = self._get_password_form(
89
+ urljoin('https://identity.vwgroup.io', email_form.target),
90
+ email_form.data
91
+ )
92
+
93
+ # Set credentials
94
+ password_form.data['email'] = self.session_user.username
95
+ password_form.data['password'] = self.session_user.password
96
+
97
+ # Log in and get the redirect URL
98
+ url = self._handle_login(
99
+ f'https://identity.vwgroup.io/signin-service/v1/{self.client_id}/{password_form.target}',
100
+ password_form.data
101
+ )
102
+
103
+ if self.redirect_uri is None:
104
+ raise ValueError('Redirect URI is not set')
105
+ # Check URL for terms and conditions
106
+ while True:
107
+ if url.startswith(self.redirect_uri):
108
+ break
109
+
110
+ url = urljoin('https://identity.vwgroup.io', url)
111
+
112
+ if 'terms-and-conditions' in url:
113
+ if self.accept_terms_on_login:
114
+ url = self._handle_consent_form(url)
115
+ else:
116
+ raise AuthenticationError(f'It seems like you need to accept the terms and conditions. '
117
+ f'Try to visit the URL "{url}" or log into smartphone app.')
118
+
119
+ response = self.websession.get(url, allow_redirects=False)
120
+ if response.status_code == requests.codes['internal_server_error']:
121
+ raise RetrievalError('Temporary server error during login')
122
+
123
+ if 'Location' not in response.headers:
124
+ raise APICompatibilityError('Forwarding without Location in headers')
125
+
126
+ url = response.headers['Location']
127
+
128
+ return url.replace(self.redirect_uri + '#', 'https://egal?')
129
+
130
+ def _get_login_form(self, url: str) -> HTMLFormParser:
131
+ while True:
132
+ response = self.websession.get(url, allow_redirects=False)
133
+ if response.status_code == requests.codes['ok']:
134
+ break
135
+
136
+ if response.status_code in (requests.codes['found'], requests.codes['see_other']):
137
+ if 'Location' not in response.headers:
138
+ raise APICompatibilityError('Forwarding without Location in headers')
139
+
140
+ url = response.headers['Location']
141
+ continue
142
+
143
+ raise APICompatibilityError(f'Retrieving login page was not successful, '
144
+ f'status code: {response.status_code}')
145
+
146
+ # Find login form on page to obtain inputs
147
+ email_form = HTMLFormParser(form_id='emailPasswordForm')
148
+ email_form.feed(response.text)
149
+
150
+ if not email_form.target or not all(x in email_form.data for x in ['_csrf', 'relayState', 'hmac', 'email']):
151
+ raise APICompatibilityError('Could not find all required input fields on login page')
152
+
153
+ return email_form
154
+
155
+ def _get_password_form(self, url: str, data: Dict[str, Any]) -> CredentialsFormParser:
156
+ response = self.websession.post(url, data=data, allow_redirects=True)
157
+ if response.status_code != requests.codes['ok']:
158
+ raise APICompatibilityError(f'Retrieving credentials page was not successful, '
159
+ f'status code: {response.status_code}')
160
+
161
+ # Find login form on page to obtain inputs
162
+ credentials_form = CredentialsFormParser()
163
+ credentials_form.feed(response.text)
164
+
165
+ if not credentials_form.target or not all(x in credentials_form.data for x in ['relayState', 'hmac', '_csrf']):
166
+ raise APICompatibilityError('Could not find all required input fields on credentials page')
167
+
168
+ if credentials_form.data.get('error', None) is not None:
169
+ if credentials_form.data['error'] == 'validator.email.invalid':
170
+ raise AuthenticationError('Error during login, email invalid')
171
+ raise AuthenticationError(f'Error during login: {credentials_form.data["error"]}')
172
+
173
+ if 'errorCode' in credentials_form.data:
174
+ raise AuthenticationError('Error during login, is the username correct?')
175
+
176
+ if credentials_form.data.get('registerCredentialsPath', None) == 'register':
177
+ raise AuthenticationError(f'Error during login, account {self.session_user.username} does not exist')
178
+
179
+ return credentials_form
180
+
181
+ def _handle_login(self, url: str, data: Dict[str, Any]) -> str:
182
+ response: requests.Response = self.websession.post(url, data=data, allow_redirects=False)
183
+
184
+ if response.status_code == requests.codes['internal_server_error']:
185
+ raise RetrievalError('Temporary server error during login')
186
+
187
+ if response.status_code not in (requests.codes['found'], requests.codes['see_other']):
188
+ raise APICompatibilityError(f'Forwarding expected (status code 302), '
189
+ f'but got status code {response.status_code}')
190
+
191
+ if 'Location' not in response.headers:
192
+ raise APICompatibilityError('Forwarding without Location in headers')
193
+
194
+ # Parse parameters from forwarding url
195
+ params: Dict[str, str] = dict(parse_qsl(urlsplit(response.headers['Location']).query))
196
+
197
+ # Check for login error
198
+ if 'error' in params and params['error']:
199
+ error_messages: Dict[str, str] = {
200
+ 'login.errors.password_invalid': 'Password is invalid',
201
+ 'login.error.throttled': 'Login throttled, probably too many wrong logins. You have to wait '
202
+ 'a few minutes until a new login attempt is possible'
203
+ }
204
+
205
+ raise AuthenticationError(error_messages.get(params['error'], params['error']))
206
+
207
+ # Check for user ID
208
+ if 'userId' not in params or not params['userId']:
209
+ if 'updated' in params and params['updated'] == 'dataprivacy':
210
+ raise AuthenticationError('You have to login at https://www.cupraofficial.com and accept the terms and conditions')
211
+ raise APICompatibilityError('No user ID provided')
212
+
213
+ self.user_id = params['userId'] # pylint: disable=unused-private-member
214
+ return response.headers['Location']
215
+
216
+ def _handle_consent_form(self, url: str) -> str:
217
+ response = self.websession.get(url, allow_redirects=False)
218
+ if response.status_code == requests.codes['internal_server_error']:
219
+ raise RetrievalError('Temporary server error during login')
220
+
221
+ # Find form on page to obtain inputs
222
+ tc_form = TermsAndConditionsFormParser()
223
+ tc_form.feed(response.text)
224
+
225
+ # Remove query from URL
226
+ url = urlparse(response.url)._replace(query='').geturl()
227
+
228
+ response = self.websession.post(url, data=tc_form.data, allow_redirects=False)
229
+ if response.status_code == requests.codes['internal_server_error']:
230
+ raise RetrievalError('Temporary server error during login')
231
+
232
+ if response.status_code not in (requests.codes['found'], requests.codes['see_other']):
233
+ raise APICompatibilityError('Forwarding expected (status code 302), '
234
+ f'but got status code {response.status_code}')
235
+
236
+ if 'Location' not in response.headers:
237
+ raise APICompatibilityError('Forwarding without Location in headers')
238
+
239
+ return response.headers['Location']
@@ -0,0 +1,74 @@
1
+ """
2
+ Module for charging for Seat/Cupra vehicles.
3
+ """
4
+ from __future__ import annotations
5
+ from typing import TYPE_CHECKING
6
+
7
+ from enum import Enum
8
+
9
+ from carconnectivity.charging import Charging
10
+ from carconnectivity.vehicle import ElectricVehicle
11
+
12
+ if TYPE_CHECKING:
13
+ from typing import Optional, Dict
14
+
15
+
16
+ class SeatCupraCharging(Charging): # pylint: disable=too-many-instance-attributes
17
+ """
18
+ SeatCupraCharging class for handling SeatCupra vehicle charging information.
19
+
20
+ This class extends the Charging class and includes an enumeration of various
21
+ charging states specific to SeatCupra vehicles.
22
+ """
23
+ def __init__(self, vehicle: ElectricVehicle | None = None, origin: Optional[Charging] = None) -> None:
24
+ if origin is not None:
25
+ super().__init__(origin=origin)
26
+ else:
27
+ super().__init__(vehicle=vehicle)
28
+
29
+ class SeatCupraChargingState(Enum,):
30
+ """
31
+ Enum representing the various charging states for a SeatCupra vehicle.
32
+ """
33
+ OFF = 'off'
34
+ READY_FOR_CHARGING = 'readyForCharging'
35
+ NOT_READY_FOR_CHARGING = 'NotReadyForCharging'
36
+ CONSERVATION = 'conservation'
37
+ CHARGE_PURPOSE_REACHED_NOT_CONSERVATION_CHARGING = 'chargePurposeReachedAndNotConservationCharging'
38
+ CHARGE_PURPOSE_REACHED_CONSERVATION = 'chargePurposeReachedAndConservation'
39
+ CHARGING = 'charging'
40
+ ERROR = 'error'
41
+ UNSUPPORTED = 'unsupported'
42
+ DISCHARGING = 'discharging'
43
+ UNKNOWN = 'unknown charging state'
44
+
45
+ class SeatCupraChargeMode(Enum,):
46
+ """
47
+ Enum class representing different SeatCupra charge modes.
48
+ """
49
+ MANUAL = 'manual'
50
+ INVALID = 'invalid'
51
+ OFF = 'off'
52
+ TIMER = 'timer'
53
+ ONLY_OWN_CURRENT = 'onlyOwnCurrent'
54
+ PREFERRED_CHARGING_TIMES = 'preferredChargingTimes'
55
+ TIMER_CHARGING_WITH_CLIMATISATION = 'timerChargingWithClimatisation'
56
+ HOME_STORAGE_CHARGING = 'homeStorageCharging'
57
+ IMMEDIATE_DISCHARGING = 'immediateDischarging'
58
+ UNKNOWN = 'unknown charge mode'
59
+
60
+
61
+ # Mapping of Cupra charging states to generic charging states
62
+ mapping_seatcupra_charging_state: Dict[SeatCupraCharging.SeatCupraChargingState, Charging.ChargingState] = {
63
+ SeatCupraCharging.SeatCupraChargingState.OFF: Charging.ChargingState.OFF,
64
+ SeatCupraCharging.SeatCupraChargingState.NOT_READY_FOR_CHARGING: Charging.ChargingState.OFF,
65
+ SeatCupraCharging.SeatCupraChargingState.READY_FOR_CHARGING: Charging.ChargingState.READY_FOR_CHARGING,
66
+ SeatCupraCharging.SeatCupraChargingState.CONSERVATION: Charging.ChargingState.CONSERVATION,
67
+ SeatCupraCharging.SeatCupraChargingState.CHARGE_PURPOSE_REACHED_NOT_CONSERVATION_CHARGING: Charging.ChargingState.READY_FOR_CHARGING,
68
+ SeatCupraCharging.SeatCupraChargingState.CHARGE_PURPOSE_REACHED_CONSERVATION: Charging.ChargingState.CONSERVATION,
69
+ SeatCupraCharging.SeatCupraChargingState.CHARGING: Charging.ChargingState.CHARGING,
70
+ SeatCupraCharging.SeatCupraChargingState.ERROR: Charging.ChargingState.ERROR,
71
+ SeatCupraCharging.SeatCupraChargingState.UNSUPPORTED: Charging.ChargingState.UNSUPPORTED,
72
+ SeatCupraCharging.SeatCupraChargingState.DISCHARGING: Charging.ChargingState.DISCHARGING,
73
+ SeatCupraCharging.SeatCupraChargingState.UNKNOWN: Charging.ChargingState.UNKNOWN
74
+ }