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.
- carconnectivity_connector_seatcupra-0.1a1.dist-info/LICENSE +21 -0
- carconnectivity_connector_seatcupra-0.1a1.dist-info/METADATA +124 -0
- carconnectivity_connector_seatcupra-0.1a1.dist-info/RECORD +17 -0
- carconnectivity_connector_seatcupra-0.1a1.dist-info/WHEEL +5 -0
- carconnectivity_connector_seatcupra-0.1a1.dist-info/top_level.txt +1 -0
- carconnectivity_connectors/seatcupra/__init__.py +0 -0
- carconnectivity_connectors/seatcupra/_version.py +21 -0
- carconnectivity_connectors/seatcupra/auth/__init__.py +0 -0
- carconnectivity_connectors/seatcupra/auth/auth_util.py +141 -0
- carconnectivity_connectors/seatcupra/auth/helpers/blacklist_retry.py +29 -0
- carconnectivity_connectors/seatcupra/auth/my_cupra_session.py +244 -0
- carconnectivity_connectors/seatcupra/auth/openid_session.py +440 -0
- carconnectivity_connectors/seatcupra/auth/session_manager.py +150 -0
- carconnectivity_connectors/seatcupra/auth/vw_web_session.py +239 -0
- carconnectivity_connectors/seatcupra/charging.py +74 -0
- carconnectivity_connectors/seatcupra/connector.py +686 -0
- carconnectivity_connectors/seatcupra/ui/connector_ui.py +39 -0
@@ -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
|
+
}
|