pycupra 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pycupra/__init__.py +7 -0
- pycupra/__version__.py +6 -0
- pycupra/connection.py +1617 -0
- pycupra/const.py +180 -0
- pycupra/dashboard.py +1258 -0
- pycupra/exceptions.py +87 -0
- pycupra/utilities.py +116 -0
- pycupra/vehicle.py +2556 -0
- pycupra-0.0.1.dist-info/METADATA +57 -0
- pycupra-0.0.1.dist-info/RECORD +13 -0
- pycupra-0.0.1.dist-info/WHEEL +5 -0
- pycupra-0.0.1.dist-info/licenses/LICENSE +201 -0
- pycupra-0.0.1.dist-info/top_level.txt +1 -0
pycupra/connection.py
ADDED
@@ -0,0 +1,1617 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
# -*- coding: utf-8 -*-
|
3
|
+
"""Communicate with the My Cupra portal."""
|
4
|
+
"""First fork from https://github.com/robinostlund/volkswagencarnet where it was modified to support also Skoda Connect"""
|
5
|
+
"""Then forked from https://github.com/lendy007/skodaconnect for adaptation to Seat Connect"""
|
6
|
+
"""Then forked from https://github.com/Farfar/seatconnect for adaptation to the new API of My Cupra and My Seat"""
|
7
|
+
import re
|
8
|
+
import os
|
9
|
+
import json
|
10
|
+
import logging
|
11
|
+
import asyncio
|
12
|
+
import hashlib
|
13
|
+
import jwt
|
14
|
+
import string
|
15
|
+
import secrets
|
16
|
+
import xmltodict
|
17
|
+
|
18
|
+
from sys import version_info, argv
|
19
|
+
from datetime import timedelta, datetime, timezone
|
20
|
+
from urllib.parse import urljoin, parse_qs, urlparse, urlencode
|
21
|
+
from json import dumps as to_json
|
22
|
+
from jwt.exceptions import ExpiredSignatureError
|
23
|
+
import aiohttp
|
24
|
+
from bs4 import BeautifulSoup
|
25
|
+
from base64 import b64decode, b64encode
|
26
|
+
from .__version__ import __version__ as lib_version
|
27
|
+
from .utilities import read_config, json_loads
|
28
|
+
from .vehicle import Vehicle
|
29
|
+
from .exceptions import (
|
30
|
+
SeatConfigException,
|
31
|
+
SeatAuthenticationException,
|
32
|
+
SeatAccountLockedException,
|
33
|
+
SeatTokenExpiredException,
|
34
|
+
SeatException,
|
35
|
+
SeatEULAException,
|
36
|
+
SeatThrottledException,
|
37
|
+
SeatLoginFailedException,
|
38
|
+
SeatInvalidRequestException,
|
39
|
+
SeatRequestInProgressException,
|
40
|
+
SeatServiceUnavailable
|
41
|
+
)
|
42
|
+
|
43
|
+
from requests_oauthlib import OAuth2Session
|
44
|
+
import base64
|
45
|
+
from oauthlib.oauth2.rfc6749.parameters import parse_authorization_code_response, parse_token_response, prepare_grant_uri
|
46
|
+
|
47
|
+
from aiohttp import ClientSession, ClientTimeout
|
48
|
+
from aiohttp.hdrs import METH_GET, METH_POST, METH_PUT
|
49
|
+
|
50
|
+
from .const import (
|
51
|
+
HEADERS_SESSION,
|
52
|
+
HEADERS_AUTH,
|
53
|
+
TOKEN_HEADERS,
|
54
|
+
BASE_SESSION,
|
55
|
+
BASE_AUTH,
|
56
|
+
CLIENT_LIST,
|
57
|
+
XCLIENT_ID,
|
58
|
+
XAPPVERSION,
|
59
|
+
XAPPNAME,
|
60
|
+
AUTH_OIDCONFIG,
|
61
|
+
AUTH_TOKEN,
|
62
|
+
AUTH_TOKENKEYS,
|
63
|
+
AUTH_REFRESH,
|
64
|
+
APP_URI,
|
65
|
+
API_MBB_STATUSDATA,
|
66
|
+
API_PERSONAL_DATA,
|
67
|
+
API_USER_INFO,
|
68
|
+
API_CONNECTION,
|
69
|
+
API_PSP,
|
70
|
+
API_VEHICLES,
|
71
|
+
API_MYCAR,
|
72
|
+
API_STATUS,
|
73
|
+
API_CHARGING,
|
74
|
+
API_POSITION,
|
75
|
+
API_POS_TO_ADDRESS,
|
76
|
+
API_TRIP,
|
77
|
+
API_CLIMATER_STATUS,
|
78
|
+
API_CLIMATER,
|
79
|
+
API_DEPARTURE_TIMERS,
|
80
|
+
API_MILEAGE,
|
81
|
+
API_CAPABILITIES,
|
82
|
+
#API_CAPABILITIES_MANAGEMENT,
|
83
|
+
API_MAINTENANCE,
|
84
|
+
API_WARNINGLIGHTS,
|
85
|
+
API_MEASUREMENTS,
|
86
|
+
API_RELATION_STATUS,
|
87
|
+
API_INVITATIONS,
|
88
|
+
#API_ACTION,
|
89
|
+
API_IMAGE,
|
90
|
+
API_HONK_AND_FLASH,
|
91
|
+
API_ACCESS,
|
92
|
+
API_SECTOKEN,
|
93
|
+
API_REQUESTS,
|
94
|
+
API_REFRESH,
|
95
|
+
API_DESTINATION,
|
96
|
+
|
97
|
+
PUBLIC_MODEL_IMAGES_SERVER,
|
98
|
+
)
|
99
|
+
|
100
|
+
version_info >= (3, 0) or exit('Python 3 required')
|
101
|
+
|
102
|
+
_LOGGER = logging.getLogger(__name__)
|
103
|
+
BRAND_CUPRA = 'cupra'
|
104
|
+
TIMEOUT = timedelta(seconds=90)
|
105
|
+
|
106
|
+
class Connection:
|
107
|
+
""" Connection to Connect services """
|
108
|
+
# Init connection class
|
109
|
+
def __init__(self, session, brand='cupra', username='', password='', fulldebug=False, **optional):
|
110
|
+
""" Initialize """
|
111
|
+
self._session = session
|
112
|
+
self._lock = asyncio.Lock()
|
113
|
+
self._session_fulldebug = fulldebug
|
114
|
+
self._session_headers = HEADERS_SESSION.get(brand).copy()
|
115
|
+
self._session_base = BASE_SESSION
|
116
|
+
self._session_auth_headers = HEADERS_AUTH.copy()
|
117
|
+
self._session_token_headers = TOKEN_HEADERS.copy()
|
118
|
+
self._session_cookies = ""
|
119
|
+
self._session_nonce = self._getNonce()
|
120
|
+
self._session_state = self._getState()
|
121
|
+
|
122
|
+
self._session_auth_ref_url = BASE_SESSION
|
123
|
+
self._session_spin_ref_url = BASE_SESSION
|
124
|
+
self._session_first_update = False
|
125
|
+
self._session_auth_brand = brand
|
126
|
+
self._session_auth_username = username
|
127
|
+
self._session_auth_password = password
|
128
|
+
self._session_tokens = {}
|
129
|
+
|
130
|
+
self._vehicles = []
|
131
|
+
self._userData = {}
|
132
|
+
|
133
|
+
_LOGGER.info(f'Init PyCupra library, version {lib_version}')
|
134
|
+
_LOGGER.debug(f'Using service {self._session_base}')
|
135
|
+
|
136
|
+
|
137
|
+
def _clear_cookies(self):
|
138
|
+
self._session._cookie_jar._cookies.clear()
|
139
|
+
self._session_cookies = ''
|
140
|
+
|
141
|
+
def _getNonce(self):
|
142
|
+
chars = string.ascii_letters + string.digits
|
143
|
+
text = ''.join(secrets.choice(chars) for i in range(10))
|
144
|
+
sha256 = hashlib.sha256()
|
145
|
+
sha256.update(text.encode())
|
146
|
+
return (b64encode(sha256.digest()).decode('utf-8')[:-1])
|
147
|
+
|
148
|
+
def _getState(self):
|
149
|
+
return self._getNonce()
|
150
|
+
|
151
|
+
def readTokenFile(self, brand):
|
152
|
+
try:
|
153
|
+
if os.path.isfile(self._tokenFile):
|
154
|
+
with open(self._tokenFile, "r") as f:
|
155
|
+
tokenString=f.read()
|
156
|
+
tokens=json.loads(tokenString)
|
157
|
+
self._session_tokens[brand]=tokens
|
158
|
+
self._user_id=tokens['user_id']
|
159
|
+
return True
|
160
|
+
_LOGGER.info('No token file present. readTokenFile() returns False.')
|
161
|
+
return False
|
162
|
+
except:
|
163
|
+
_LOGGER.warning('readTokenFile() not successful.')
|
164
|
+
return False
|
165
|
+
|
166
|
+
def writeTokenFile(self, brand):
|
167
|
+
if hasattr(self, '_tokenfile'):
|
168
|
+
_LOGGER.info('No token file name provided. Cannot write tokens to file.')
|
169
|
+
return False
|
170
|
+
self._session_tokens[brand]['user_id']=self._user_id
|
171
|
+
try:
|
172
|
+
with open(self._tokenFile, "w") as f:
|
173
|
+
f.write(json.dumps(self._session_tokens[brand]))
|
174
|
+
return True
|
175
|
+
except Exception as e:
|
176
|
+
_LOGGER.warning(f'writeTokenFile() not successful. Error: {e}')
|
177
|
+
return False
|
178
|
+
|
179
|
+
def deleteTokenFile(self):
|
180
|
+
if hasattr(self, '_tokenfile'):
|
181
|
+
_LOGGER.debug('No token file name provided. Cannot delete token file.')
|
182
|
+
return False
|
183
|
+
try:
|
184
|
+
os.remove(self._tokenFile)
|
185
|
+
_LOGGER.info(f'Deleted token file.')
|
186
|
+
return True
|
187
|
+
except Exception as e:
|
188
|
+
_LOGGER.warning(f'deleteTokenFile() not successful. Error: {e}')
|
189
|
+
return False
|
190
|
+
|
191
|
+
def writeImageFile(self, imageName, imageData, imageDict):
|
192
|
+
try:
|
193
|
+
with open(f'./www/image_{imageName}.png', "wb") as f:
|
194
|
+
f.write(imageData)
|
195
|
+
imageDict[imageName]=f'/local/image_{imageName}.png'
|
196
|
+
return True
|
197
|
+
except:
|
198
|
+
_LOGGER.warning('writeImageFile() not successful. Ignoring this problem.')
|
199
|
+
return False
|
200
|
+
|
201
|
+
# API login/logout/authorization
|
202
|
+
async def doLogin(self,**data):
|
203
|
+
"""Login method, clean login or use token from file and refresh it"""
|
204
|
+
#if len(self._session_tokens) > 0:
|
205
|
+
# _LOGGER.info('Revoking old tokens.')
|
206
|
+
# try:
|
207
|
+
# await self.logout()
|
208
|
+
# except:
|
209
|
+
# pass
|
210
|
+
_LOGGER.info('doLogin() first tries to read tokens from file and to refresh them.')
|
211
|
+
|
212
|
+
# Remove cookies and re-init session
|
213
|
+
self._clear_cookies()
|
214
|
+
self._vehicles.clear()
|
215
|
+
self._session_tokens = {}
|
216
|
+
self._session_headers = HEADERS_SESSION.get(self._session_auth_brand).copy()
|
217
|
+
self._session_auth_headers = HEADERS_AUTH.copy()
|
218
|
+
self._session_nonce = self._getNonce()
|
219
|
+
self._session_state = self._getState()
|
220
|
+
|
221
|
+
if data.get('apiKey',None)!=None:
|
222
|
+
self._googleApiKey=data.get('apiKey')
|
223
|
+
if data.get('tokenFile',None)!=None:
|
224
|
+
self._tokenFile=data.get('tokenFile')
|
225
|
+
loop = asyncio.get_running_loop()
|
226
|
+
result = await loop.run_in_executor(None, self.readTokenFile, self._session_auth_brand)
|
227
|
+
if result:
|
228
|
+
rc=await self.refresh_token(self._session_auth_brand)
|
229
|
+
if rc:
|
230
|
+
_LOGGER.info('Successfully read tokens from file and refreshed them.')
|
231
|
+
return True
|
232
|
+
_LOGGER.info('Initiating new login with user name and password.')
|
233
|
+
return await self._authorize(self._session_auth_brand)
|
234
|
+
|
235
|
+
async def _authorize(self, client=BRAND_CUPRA):
|
236
|
+
""""Login" function. Authorize a certain client type and get tokens."""
|
237
|
+
# Helper functions
|
238
|
+
def extract_csrf(req):
|
239
|
+
return re.compile('<meta name="_csrf" content="([^"]*)"/>').search(req).group(1)
|
240
|
+
|
241
|
+
def extract_guest_language_id(req):
|
242
|
+
return req.split('_')[1].lower()
|
243
|
+
|
244
|
+
# Login/Authorization starts here
|
245
|
+
try:
|
246
|
+
#self._session_headers = HEADERS_SESSION.get(client).copy()
|
247
|
+
#self._session_auth_headers = HEADERS_AUTH.copy()
|
248
|
+
|
249
|
+
_LOGGER.debug(f'Starting authorization process for client {client}')
|
250
|
+
req = await self._session.get(
|
251
|
+
url=AUTH_OIDCONFIG
|
252
|
+
)
|
253
|
+
if req.status != 200:
|
254
|
+
_LOGGER.debug(f'Get request to {AUTH_OIDCONFIG} was not successful. Response: {req}')
|
255
|
+
return False
|
256
|
+
response_data = await req.json()
|
257
|
+
authorizationEndpoint = response_data['authorization_endpoint']
|
258
|
+
authissuer = response_data['issuer']
|
259
|
+
oauthClient = OAuth2Session(client_id=CLIENT_LIST[client].get('CLIENT_ID'), scope=CLIENT_LIST[client].get('SCOPE'), redirect_uri=CLIENT_LIST[client].get('REDIRECT_URL'))
|
260
|
+
code_verifier = base64.urlsafe_b64encode(os.urandom(40)).decode('utf-8')
|
261
|
+
code_verifier = re.sub('[^a-zA-Z0-9]+', '', code_verifier)
|
262
|
+
code_challenge = hashlib.sha256(code_verifier.encode("utf-8")).digest()
|
263
|
+
code_challenge = base64.urlsafe_b64encode(code_challenge).decode("utf-8")
|
264
|
+
code_challenge = code_challenge.replace("=", "")
|
265
|
+
authorization_url, state = oauthClient.authorization_url(authorizationEndpoint, code_challenge=code_challenge, code_challenge_method='S256',
|
266
|
+
nonce=self._session_nonce, state=self._session_state)
|
267
|
+
|
268
|
+
# Get authorization page (login page)
|
269
|
+
if self._session_fulldebug:
|
270
|
+
_LOGGER.debug(f'Get authorization page: "{authorization_url}"')
|
271
|
+
try:
|
272
|
+
req = await self._session.get(
|
273
|
+
url=authorization_url,
|
274
|
+
headers=self._session_auth_headers.get(client),
|
275
|
+
allow_redirects=False
|
276
|
+
)
|
277
|
+
if req.headers.get('Location', False):
|
278
|
+
ref = req.headers.get('Location', '')
|
279
|
+
if 'error' in ref:
|
280
|
+
error = parse_qs(urlparse(ref).query).get('error', '')[0]
|
281
|
+
if 'error_description' in ref:
|
282
|
+
error = parse_qs(urlparse(ref).query).get('error_description', '')[0]
|
283
|
+
_LOGGER.info(f'Unable to login, {error}')
|
284
|
+
else:
|
285
|
+
_LOGGER.info(f'Unable to login.')
|
286
|
+
raise SeatException(error)
|
287
|
+
else:
|
288
|
+
if self._session_fulldebug:
|
289
|
+
_LOGGER.debug(f'Got authorization endpoint: "{ref}"')
|
290
|
+
req = await self._session.get(
|
291
|
+
url=ref,
|
292
|
+
headers=self._session_auth_headers.get(client),
|
293
|
+
allow_redirects=False
|
294
|
+
)
|
295
|
+
else:
|
296
|
+
_LOGGER.warning(f'Unable to fetch authorization endpoint')
|
297
|
+
raise SeatException('Missing "location" header')
|
298
|
+
except (SeatException):
|
299
|
+
raise
|
300
|
+
except Exception as error:
|
301
|
+
_LOGGER.warning(f'Failed to get authorization endpoint. {error}')
|
302
|
+
raise SeatException(error)
|
303
|
+
|
304
|
+
# If we need to sign in (first token)
|
305
|
+
if 'signin-service' in ref:
|
306
|
+
_LOGGER.debug("Got redirect to signin-service")
|
307
|
+
location = await self._signin_service(req, authissuer, authorizationEndpoint, client)
|
308
|
+
else:
|
309
|
+
# We are already logged on, shorter authorization flow
|
310
|
+
location = req.headers.get('Location', None)
|
311
|
+
|
312
|
+
# Follow all redirects until we reach the callback URL
|
313
|
+
try:
|
314
|
+
maxDepth = 10
|
315
|
+
while not location.startswith(CLIENT_LIST[client].get('REDIRECT_URL')):
|
316
|
+
if location is None:
|
317
|
+
raise SeatException('Login failed')
|
318
|
+
if 'error' in location:
|
319
|
+
error = parse_qs(urlparse(location).query).get('error', '')[0]
|
320
|
+
if error == 'login.error.throttled':
|
321
|
+
timeout = parse_qs(urlparse(location).query).get('enableNextButtonAfterSeconds', '')[0]
|
322
|
+
raise SeatAccountLockedException(f'Account is locked for another {timeout} seconds')
|
323
|
+
elif error == 'login.errors.password_invalid':
|
324
|
+
raise SeatAuthenticationException('Invalid credentials')
|
325
|
+
else:
|
326
|
+
_LOGGER.warning(f'Login failed: {error}')
|
327
|
+
raise SeatLoginFailedException(error)
|
328
|
+
if 'terms-and-conditions' in location:
|
329
|
+
raise SeatEULAException('The terms and conditions must be accepted first at your local SEAT/Cupra site, e.g. "https://cupraofficial.se/"')
|
330
|
+
if 'user_id' in location: # Get the user_id which is needed for some later requests
|
331
|
+
self._user_id=parse_qs(urlparse(location).query).get('user_id')[0]
|
332
|
+
_LOGGER.debug('Got user_id: %s' % self._user_id)
|
333
|
+
if self._session_fulldebug:
|
334
|
+
_LOGGER.debug(f'Following redirect to "{location}"')
|
335
|
+
response = await self._session.get(
|
336
|
+
url=location,
|
337
|
+
headers=self._session_auth_headers.get(client),
|
338
|
+
allow_redirects=False
|
339
|
+
)
|
340
|
+
if response.headers.get('Location', False) is False:
|
341
|
+
_LOGGER.debug(f'Unexpected response: {await req.text()}')
|
342
|
+
raise SeatAuthenticationException('User appears unauthorized')
|
343
|
+
location = response.headers.get('Location', None)
|
344
|
+
# Set a max limit on requests to prevent forever loop
|
345
|
+
maxDepth -= 1
|
346
|
+
if maxDepth == 0:
|
347
|
+
raise SeatException('Too many redirects')
|
348
|
+
except (SeatException, SeatEULAException, SeatAuthenticationException, SeatAccountLockedException, SeatLoginFailedException):
|
349
|
+
raise
|
350
|
+
except Exception as e:
|
351
|
+
# If we get an unhandled exception it should be because we can't redirect to the APP_URI URL and thus we have our auth code
|
352
|
+
if 'code' in location:
|
353
|
+
if self._session_fulldebug:
|
354
|
+
_LOGGER.debug('Got code: %s' % location)
|
355
|
+
pass
|
356
|
+
else:
|
357
|
+
_LOGGER.debug(f'Exception occured while logging in.')
|
358
|
+
raise SeatLoginFailedException(e)
|
359
|
+
|
360
|
+
_LOGGER.debug('Received authorization code, exchange for tokens.')
|
361
|
+
# Extract code and tokens
|
362
|
+
auth_code = parse_qs(urlparse(location).query).get('code')[0]
|
363
|
+
# Save access, identity and refresh tokens according to requested client"""
|
364
|
+
if client=='cupra':
|
365
|
+
# oauthClient.fetch_token() does not work in home assistant, using POST request instead
|
366
|
+
#token_data= oauthClient.fetch_token(token_url=AUTH_TOKEN,
|
367
|
+
# client_id=CLIENT_LIST[client].get('CLIENT_ID'), client_secret=CLIENT_LIST[client].get('CLIENT_SECRET'), authorization_response=location,
|
368
|
+
# code_verifier=code_verifier, code=auth_code)
|
369
|
+
data = {
|
370
|
+
'redirect_uri': CLIENT_LIST[client].get('REDIRECT_URL'),
|
371
|
+
'client_id': CLIENT_LIST[client].get('CLIENT_ID'),
|
372
|
+
'client_secret': CLIENT_LIST[client].get('CLIENT_SECRET'),
|
373
|
+
'authorization_response': location,
|
374
|
+
'code': auth_code,
|
375
|
+
'code_verifier': code_verifier,
|
376
|
+
'grant_type': 'authorization_code'
|
377
|
+
}
|
378
|
+
req = await self._session.post(
|
379
|
+
url=AUTH_TOKEN,
|
380
|
+
data = data,
|
381
|
+
headers=self._session_auth_headers.get(client),
|
382
|
+
allow_redirects=False
|
383
|
+
)
|
384
|
+
token_data = await req.json()
|
385
|
+
else:
|
386
|
+
data = {
|
387
|
+
'redirect_uri': CLIENT_LIST[client].get('REDIRECT_URL'),
|
388
|
+
'client_id': CLIENT_LIST[client].get('CLIENT_ID'),
|
389
|
+
'code': auth_code,
|
390
|
+
'code_verifier': code_verifier,
|
391
|
+
'grant_type': 'authorization_code'
|
392
|
+
}
|
393
|
+
req = await self._session.post(
|
394
|
+
url=AUTH_REFRESH,
|
395
|
+
data = data,
|
396
|
+
headers=self._session_auth_headers.get(client),
|
397
|
+
allow_redirects=False
|
398
|
+
)
|
399
|
+
token_data = await req.json()
|
400
|
+
self._session_tokens[client] = {}
|
401
|
+
for key in token_data:
|
402
|
+
if '_token' in key:
|
403
|
+
self._session_tokens[client][key] = token_data[key]
|
404
|
+
if 'error' in self._session_tokens[client]:
|
405
|
+
error = self._session_tokens[client].get('error', '')
|
406
|
+
if 'error_description' in self._session_tokens[client]:
|
407
|
+
error_description = self._session_tokens[client].get('error_description', '')
|
408
|
+
raise SeatException(f'{error} - {error_description}')
|
409
|
+
else:
|
410
|
+
raise SeatException(error)
|
411
|
+
if self._session_fulldebug:
|
412
|
+
for key in self._session_tokens.get(client, {}):
|
413
|
+
if 'token' in key:
|
414
|
+
_LOGGER.debug(f'Got {key} for client {CLIENT_LIST[client].get("CLIENT_ID","")}, token: "{self._session_tokens.get(client, {}).get(key, None)}"')
|
415
|
+
# Verify token, warn if problems are found
|
416
|
+
verify = await self.verify_token(self._session_tokens[client].get('id_token', ''))
|
417
|
+
if verify is False:
|
418
|
+
_LOGGER.warning(f'Token for {client} is invalid!')
|
419
|
+
elif verify is True:
|
420
|
+
_LOGGER.debug(f'Token for {client} verified OK.')
|
421
|
+
else:
|
422
|
+
_LOGGER.warning(f'Token for {client} could not be verified, verification returned {verify}.')
|
423
|
+
loop = asyncio.get_running_loop()
|
424
|
+
rt = await loop.run_in_executor(None, self.writeTokenFile, client)
|
425
|
+
except (SeatEULAException):
|
426
|
+
_LOGGER.warning('Login failed, the terms and conditions might have been updated and need to be accepted. Login to your local SEAT/Cupra site, e.g. "https://cupraofficial.se/" and accept the new terms before trying again')
|
427
|
+
raise
|
428
|
+
except (SeatAccountLockedException):
|
429
|
+
_LOGGER.warning('Your account is locked, probably because of too many incorrect login attempts. Make sure that your account is not in use somewhere with incorrect password')
|
430
|
+
raise
|
431
|
+
except (SeatAuthenticationException):
|
432
|
+
_LOGGER.warning('Invalid credentials or invalid configuration. Make sure you have entered the correct credentials')
|
433
|
+
raise
|
434
|
+
except (SeatException):
|
435
|
+
_LOGGER.error('An API error was encountered during login, try again later')
|
436
|
+
raise
|
437
|
+
except (TypeError):
|
438
|
+
_LOGGER.warning(f'Login failed for {self._session_auth_username}. The server might be temporarily unavailable, try again later. If the problem persists, verify your account at your local SEAT/Cupra site, e.g. "https://cupraofficial.se/"')
|
439
|
+
except Exception as error:
|
440
|
+
_LOGGER.error(f'Login failed for {self._session_auth_username}, {error}')
|
441
|
+
return False
|
442
|
+
return True
|
443
|
+
|
444
|
+
async def _signin_service(self, html, authissuer, authorizationEndpoint, client=BRAND_CUPRA):
|
445
|
+
"""Method to signin to Connect portal."""
|
446
|
+
# Extract login form and extract attributes
|
447
|
+
try:
|
448
|
+
response_data = await html.text()
|
449
|
+
responseSoup = BeautifulSoup(response_data, 'html.parser')
|
450
|
+
form_data = dict()
|
451
|
+
if responseSoup is None:
|
452
|
+
raise SeatLoginFailedException('Login failed, server did not return a login form')
|
453
|
+
for t in responseSoup.find('form', id='emailPasswordForm').find_all('input', type='hidden'):
|
454
|
+
if self._session_fulldebug:
|
455
|
+
_LOGGER.debug(f'Extracted form attribute: {t["name"], t["value"]}')
|
456
|
+
form_data[t['name']] = t['value']
|
457
|
+
form_data['email'] = self._session_auth_username
|
458
|
+
pe_url = authissuer+responseSoup.find('form', id='emailPasswordForm').get('action')
|
459
|
+
except Exception as e:
|
460
|
+
_LOGGER.error('Failed to extract user login form.')
|
461
|
+
raise
|
462
|
+
|
463
|
+
# POST email
|
464
|
+
self._session_auth_headers[client]['Referer'] = authorizationEndpoint
|
465
|
+
self._session_auth_headers[client]['Origin'] = authissuer
|
466
|
+
_LOGGER.debug(f"Start authorization for user {self._session_auth_username}")
|
467
|
+
req = await self._session.post(
|
468
|
+
url = pe_url,
|
469
|
+
headers = self._session_auth_headers.get(client),
|
470
|
+
data = form_data
|
471
|
+
)
|
472
|
+
if req.status != 200:
|
473
|
+
raise SeatException('Authorization request failed')
|
474
|
+
try:
|
475
|
+
response_data = await req.text()
|
476
|
+
responseSoup = BeautifulSoup(response_data, 'html.parser')
|
477
|
+
pwform = {}
|
478
|
+
credentials_form = responseSoup.find('form', id='credentialsForm')
|
479
|
+
all_scripts = responseSoup.find_all('script', {'src': False})
|
480
|
+
if credentials_form is not None:
|
481
|
+
_LOGGER.debug('Found HTML credentials form, extracting attributes')
|
482
|
+
for t in credentials_form.find_all('input', type='hidden'):
|
483
|
+
if self._session_fulldebug:
|
484
|
+
_LOGGER.debug(f'Extracted form attribute: {t["name"], t["value"]}')
|
485
|
+
pwform[t['name']] = t['value']
|
486
|
+
form_data = pwform
|
487
|
+
post_action = responseSoup.find('form', id='credentialsForm').get('action')
|
488
|
+
elif all_scripts is not None:
|
489
|
+
_LOGGER.debug('Found dynamic credentials form, extracting attributes')
|
490
|
+
pattern = re.compile("templateModel: (.*?),\n")
|
491
|
+
for sc in all_scripts:
|
492
|
+
if(pattern.search(sc.string)):
|
493
|
+
import json
|
494
|
+
data = pattern.search(sc.string)
|
495
|
+
jsondata = json.loads(data.groups()[0])
|
496
|
+
_LOGGER.debug(f'JSON: {jsondata}')
|
497
|
+
if not jsondata.get('hmac', False):
|
498
|
+
raise SeatLoginFailedException('Failed to extract login hmac attribute')
|
499
|
+
if not jsondata.get('postAction', False):
|
500
|
+
raise SeatLoginFailedException('Failed to extract login post action attribute')
|
501
|
+
if jsondata.get('error', None) is not None:
|
502
|
+
raise SeatLoginFailedException(f'Login failed with error: {jsondata.get("error", None)}')
|
503
|
+
form_data['hmac'] = jsondata.get('hmac', '')
|
504
|
+
post_action = jsondata.get('postAction')
|
505
|
+
else:
|
506
|
+
raise SeatLoginFailedException('Failed to extract login form data')
|
507
|
+
form_data['password'] = self._session_auth_password
|
508
|
+
except (SeatLoginFailedException) as e:
|
509
|
+
raise
|
510
|
+
except Exception as e:
|
511
|
+
raise SeatAuthenticationException("Invalid username or service unavailable")
|
512
|
+
|
513
|
+
# POST password
|
514
|
+
self._session_auth_headers[client]['Referer'] = pe_url
|
515
|
+
self._session_auth_headers[client]['Origin'] = authissuer
|
516
|
+
_LOGGER.debug(f"Finalizing login")
|
517
|
+
|
518
|
+
client_id = CLIENT_LIST[client].get('CLIENT_ID')
|
519
|
+
pp_url = authissuer+'/'+post_action
|
520
|
+
if not 'signin-service' in pp_url or not client_id in pp_url:
|
521
|
+
pp_url = authissuer+'/signin-service/v1/'+client_id+"/"+post_action
|
522
|
+
|
523
|
+
if self._session_fulldebug:
|
524
|
+
_LOGGER.debug(f'Using login action url: "{pp_url}"')
|
525
|
+
req = await self._session.post(
|
526
|
+
url=pp_url,
|
527
|
+
headers=self._session_auth_headers.get(client),
|
528
|
+
data = form_data,
|
529
|
+
allow_redirects=False
|
530
|
+
)
|
531
|
+
return req.headers.get('Location', None)
|
532
|
+
|
533
|
+
async def terminate(self):
|
534
|
+
"""Log out from connect services"""
|
535
|
+
await self.logout()
|
536
|
+
|
537
|
+
async def logout(self):
|
538
|
+
"""Logout, revoke tokens."""
|
539
|
+
_LOGGER.info(f'Initiating logout.')
|
540
|
+
self._session_headers.pop('Authorization', None)
|
541
|
+
self._session_headers.pop('tokentype', None)
|
542
|
+
self._session_headers['Content-Type'] = 'application/x-www-form-urlencoded'
|
543
|
+
|
544
|
+
for client in self._session_tokens:
|
545
|
+
# Ignore identity tokens
|
546
|
+
for token_type in (
|
547
|
+
token_type
|
548
|
+
for token_type in self._session_tokens[client]
|
549
|
+
if token_type in ['refresh_token', 'access_token']
|
550
|
+
):
|
551
|
+
self._session_tokens[client][token_type] = None
|
552
|
+
loop = asyncio.get_running_loop()
|
553
|
+
await loop.run_in_executor(None, self.deleteTokenFile,)
|
554
|
+
|
555
|
+
# HTTP methods to API
|
556
|
+
async def get(self, url, vin=''):
|
557
|
+
"""Perform a HTTP GET."""
|
558
|
+
try:
|
559
|
+
response = await self._request(METH_GET, url)
|
560
|
+
return response
|
561
|
+
except aiohttp.client_exceptions.ClientResponseError as error:
|
562
|
+
data = {
|
563
|
+
'status_code': error.status,
|
564
|
+
'error': error.code,
|
565
|
+
'error_description': error.message,
|
566
|
+
'response_headers': error.headers,
|
567
|
+
'request_info': error.request_info
|
568
|
+
}
|
569
|
+
if error.status == 401:
|
570
|
+
_LOGGER.warning('Received "Unauthorized" while fetching data.\nThis can occur if tokens expired or refresh service is unavailable.')
|
571
|
+
elif error.status == 400:
|
572
|
+
_LOGGER.error('Received "Bad Request" from server.\nThe request might be malformed or not implemented correctly for this vehicle.')
|
573
|
+
elif error.status == 412:
|
574
|
+
_LOGGER.debug('Received "Pre-condition failed".\nService might be temporarily unavailable.')
|
575
|
+
elif error.status == 500:
|
576
|
+
_LOGGER.info('Received "Internal server error".\nThe service is temporarily unavailable.')
|
577
|
+
elif error.status == 502:
|
578
|
+
_LOGGER.info('Received "Bad gateway".\nEither the endpoint is temporarily unavailable or not supported for this vehicle.')
|
579
|
+
elif 400 <= error.status <= 499:
|
580
|
+
_LOGGER.error('Received unhandled error indicating client-side problem.\nRestart or try again later.')
|
581
|
+
elif 500 <= error.status <= 599:
|
582
|
+
_LOGGER.error('Received unhandled error indicating server-side problem.\nThe service might be temporarily unavailable.')
|
583
|
+
else:
|
584
|
+
_LOGGER.error('Received unhandled error while requesting API endpoint.')
|
585
|
+
_LOGGER.debug(f'HTTP request information: {data}')
|
586
|
+
return data
|
587
|
+
except Exception as e:
|
588
|
+
_LOGGER.debug(f'Got non HTTP related error: {e}')
|
589
|
+
|
590
|
+
async def post(self, url, **data):
|
591
|
+
"""Perform a HTTP POST."""
|
592
|
+
if data:
|
593
|
+
return await self._request(METH_POST, url, **data)
|
594
|
+
else:
|
595
|
+
return await self._request(METH_POST, url)
|
596
|
+
|
597
|
+
async def _request(self, method, url, **kwargs):
|
598
|
+
"""Perform a HTTP query"""
|
599
|
+
if self._session_fulldebug:
|
600
|
+
_LOGGER.debug(f'HTTP {method} "{url}"')
|
601
|
+
async with self._session.request(
|
602
|
+
method,
|
603
|
+
url,
|
604
|
+
headers=self._session_headers if (PUBLIC_MODEL_IMAGES_SERVER not in url) else {}, # Set headers to {} when reading from PUBLIC_MODEL_IMAGES_SERVER
|
605
|
+
timeout=ClientTimeout(total=TIMEOUT.seconds),
|
606
|
+
cookies=self._session_cookies,
|
607
|
+
raise_for_status=False,
|
608
|
+
**kwargs
|
609
|
+
) as response:
|
610
|
+
response.raise_for_status()
|
611
|
+
|
612
|
+
# Update cookie jar
|
613
|
+
if self._session_cookies != '':
|
614
|
+
self._session_cookies.update(response.cookies)
|
615
|
+
else:
|
616
|
+
self._session_cookies = response.cookies
|
617
|
+
|
618
|
+
try:
|
619
|
+
if response.status == 204:
|
620
|
+
res = {'status_code': response.status}
|
621
|
+
elif response.status >= 200 or response.status <= 300:
|
622
|
+
# If this is a revoke token url, expect Content-Length 0 and return
|
623
|
+
if int(response.headers.get('Content-Length', 0)) == 0 and 'revoke' in url:
|
624
|
+
if response.status == 200:
|
625
|
+
return True
|
626
|
+
else:
|
627
|
+
return False
|
628
|
+
else:
|
629
|
+
if 'xml' in response.headers.get('Content-Type', ''):
|
630
|
+
res = xmltodict.parse(await response.text())
|
631
|
+
elif 'image/png' in response.headers.get('Content-Type', ''):
|
632
|
+
res = await response.content.read()
|
633
|
+
else:
|
634
|
+
res = await response.json(loads=json_loads)
|
635
|
+
else:
|
636
|
+
res = {}
|
637
|
+
_LOGGER.debug(f'Not success status code [{response.status}] response: {response}')
|
638
|
+
if 'X-RateLimit-Remaining' in response.headers:
|
639
|
+
res['rate_limit_remaining'] = response.headers.get('X-RateLimit-Remaining', '')
|
640
|
+
except Exception as e:
|
641
|
+
res = {}
|
642
|
+
_LOGGER.debug(f'Something went wrong [{response.status}] response: {response}, error: {e}')
|
643
|
+
return res
|
644
|
+
|
645
|
+
if self._session_fulldebug:
|
646
|
+
if 'image/png' in response.headers.get('Content-Type', ''):
|
647
|
+
_LOGGER.debug(f'Request for "{url}" returned with status code [{response.status}]. Not showing response for Content-Type image/png.')
|
648
|
+
else:
|
649
|
+
_LOGGER.debug(f'Request for "{url}" returned with status code [{response.status}], response: {res}')
|
650
|
+
else:
|
651
|
+
_LOGGER.debug(f'Request for "{url}" returned with status code [{response.status}]')
|
652
|
+
return res
|
653
|
+
|
654
|
+
async def _data_call(self, query, **data):
|
655
|
+
"""Function for POST actions with error handling."""
|
656
|
+
try:
|
657
|
+
response = await self.post(query, **data)
|
658
|
+
_LOGGER.debug(f'Data call returned: {response}')
|
659
|
+
return response
|
660
|
+
except aiohttp.client_exceptions.ClientResponseError as error:
|
661
|
+
_LOGGER.debug(f'Request failed. Data: {data}, HTTP request headers: {self._session_headers}')
|
662
|
+
if error.status == 401:
|
663
|
+
_LOGGER.error('Unauthorized')
|
664
|
+
elif error.status == 400:
|
665
|
+
_LOGGER.error(f'Bad request')
|
666
|
+
elif error.status == 429:
|
667
|
+
_LOGGER.warning('Too many requests. Further requests can only be made after the end of next trip in order to protect your vehicles battery.')
|
668
|
+
return 429
|
669
|
+
elif error.status == 500:
|
670
|
+
_LOGGER.error('Internal server error, server might be temporarily unavailable')
|
671
|
+
elif error.status == 502:
|
672
|
+
_LOGGER.error('Bad gateway, this function may not be implemented for this vehicle')
|
673
|
+
else:
|
674
|
+
_LOGGER.error(f'Unhandled HTTP exception: {error}')
|
675
|
+
#return False
|
676
|
+
except Exception as error:
|
677
|
+
_LOGGER.error(f'Failure to execute: {error}')
|
678
|
+
return False
|
679
|
+
|
680
|
+
# Class get data functions
|
681
|
+
async def update_all(self):
|
682
|
+
"""Update status."""
|
683
|
+
try:
|
684
|
+
# Get all Vehicle objects and update in parallell
|
685
|
+
update_list = []
|
686
|
+
for vehicle in self.vehicles:
|
687
|
+
if vehicle.vin not in update_list:
|
688
|
+
_LOGGER.debug(f'Adding {vehicle.vin} for data refresh')
|
689
|
+
update_list.append(vehicle.update())
|
690
|
+
else:
|
691
|
+
_LOGGER.debug(f'VIN {vehicle.vin} is already queued for data refresh')
|
692
|
+
|
693
|
+
# Wait for all data updates to complete
|
694
|
+
if len(update_list) == 0:
|
695
|
+
_LOGGER.info('No vehicles in account to update')
|
696
|
+
else:
|
697
|
+
_LOGGER.debug('Calling update function for all vehicles')
|
698
|
+
await asyncio.gather(*update_list)
|
699
|
+
return True
|
700
|
+
except (IOError, OSError, LookupError, Exception) as error:
|
701
|
+
_LOGGER.warning(f'An error was encountered during interaction with the API: {error}')
|
702
|
+
except:
|
703
|
+
raise
|
704
|
+
return False
|
705
|
+
|
706
|
+
async def get_userData(self):
|
707
|
+
"""Fetch user profile."""
|
708
|
+
await self.set_token(self._session_auth_brand)
|
709
|
+
userData={}
|
710
|
+
#API_PERSONAL_DATA liefert fast das gleiche wie API_USER_INFO aber etwas weniger
|
711
|
+
try:
|
712
|
+
response = await self.get(eval(f"f'{API_PERSONAL_DATA}'"))
|
713
|
+
if response.get('nickname'):
|
714
|
+
userData= response
|
715
|
+
else:
|
716
|
+
_LOGGER.debug('Could not retrieve profile information')
|
717
|
+
except:
|
718
|
+
_LOGGER.debug('Could not fetch personal information.')
|
719
|
+
|
720
|
+
try:
|
721
|
+
response = await self.get(eval(f"f'{API_USER_INFO}'"))
|
722
|
+
if response.get('name'):
|
723
|
+
userData = response
|
724
|
+
else:
|
725
|
+
_LOGGER.debug('Could not retrieve profile information')
|
726
|
+
except:
|
727
|
+
_LOGGER.debug('Could not fetch personal information.')
|
728
|
+
self._userData=userData
|
729
|
+
return userData
|
730
|
+
|
731
|
+
async def get_vehicles(self):
|
732
|
+
"""Fetch vehicle information from user profile."""
|
733
|
+
api_vehicles = []
|
734
|
+
# Check if user needs to update consent
|
735
|
+
try:
|
736
|
+
await self.set_token(self._session_auth_brand)
|
737
|
+
_LOGGER.debug('Achtung! getConsentInfo auskommentiert')
|
738
|
+
response = await self.get(eval(f"f'{API_MBB_STATUSDATA}'"))
|
739
|
+
if response.get('profileCompleted','incomplete'):
|
740
|
+
if response.get('profileCompleted',False):
|
741
|
+
_LOGGER.debug('User consent is valid, no missing information for profile')
|
742
|
+
else:
|
743
|
+
_LOGGER.debug('Profile incomplete. Please visit the web portal')
|
744
|
+
else:
|
745
|
+
_LOGGER.debug('Could not retrieve profile information')
|
746
|
+
"""consent = await self.getConsentInfo()
|
747
|
+
if isinstance(consent, dict):
|
748
|
+
_LOGGER.debug(f'Consent returned {consent}')
|
749
|
+
if 'status' in consent.get('mandatoryConsentInfo', []):
|
750
|
+
if consent.get('mandatoryConsentInfo', [])['status'] != 'VALID':
|
751
|
+
_LOGGER.error(f'The user needs to update consent for {consent.get("mandatoryConsentInfo", [])["id"]}. If problems are encountered please visit the web portal first and accept terms and conditions.')
|
752
|
+
elif len(consent.get('missingMandatoryFields', [])) > 0:
|
753
|
+
_LOGGER.error(f'Missing mandatory field for user: {consent.get("missingMandatoryFields", [])[0].get("name", "")}. If problems are encountered please visit the web portal first and accept terms and conditions.')
|
754
|
+
else:
|
755
|
+
_LOGGER.debug('User consent is valid, no missing information for profile')
|
756
|
+
else:
|
757
|
+
_LOGGER.debug('Could not retrieve consent information')"""
|
758
|
+
except:
|
759
|
+
_LOGGER.debug('Could not fetch consent information. If problems are encountered please visit the web portal first and make sure that no new terms and conditions need to be accepted.')
|
760
|
+
|
761
|
+
# Fetch vehicles
|
762
|
+
try:
|
763
|
+
legacy_vehicles = await self.get(eval(f"f'{API_VEHICLES}'"))
|
764
|
+
if legacy_vehicles.get('vehicles', False):
|
765
|
+
_LOGGER.debug('Found vehicle(s) associated with account.')
|
766
|
+
for vehicle in legacy_vehicles.get('vehicles'):
|
767
|
+
vin = vehicle.get('vin', '')
|
768
|
+
response = await self.get(eval(f"f'{API_CAPABILITIES}'"))
|
769
|
+
#self._session_headers['Accept'] = 'application/json'
|
770
|
+
if response.get('capabilities', False):
|
771
|
+
vehicle["capabilities"]=response.get('capabilities')
|
772
|
+
else:
|
773
|
+
_LOGGER.warning(f"Failed to aquire capabilities information about vehicle with VIN {vehicle}")
|
774
|
+
response = await self.get(eval(f"f'{API_CONNECTION}'"))
|
775
|
+
#self._session_headers['Accept'] = 'application/json'
|
776
|
+
if response.get('connection', False):
|
777
|
+
vehicle["connectivities"]=response.get('connection')
|
778
|
+
else:
|
779
|
+
_LOGGER.warning(f"Failed to aquire connection information about vehicle with VIN {vehicle}")
|
780
|
+
api_vehicles.append(vehicle)
|
781
|
+
except:
|
782
|
+
raise
|
783
|
+
|
784
|
+
# If neither API returns any vehicles, raise an error
|
785
|
+
if len(api_vehicles) == 0:
|
786
|
+
raise SeatConfigException("No vehicles were found for given account!")
|
787
|
+
# Get vehicle connectivity information
|
788
|
+
else:
|
789
|
+
try:
|
790
|
+
for vehicle in api_vehicles:
|
791
|
+
_LOGGER.debug(f'Checking vehicle {vehicle}')
|
792
|
+
vin = vehicle.get('vin', '')
|
793
|
+
#for service in vehicle.get('connectivities', []):
|
794
|
+
# if isinstance(service, str):
|
795
|
+
# connectivity.append(service)
|
796
|
+
# elif isinstance(service, dict):
|
797
|
+
# connectivity.append(service.get('type', ''))
|
798
|
+
|
799
|
+
properties={}
|
800
|
+
for key in vehicle:
|
801
|
+
if not(key in {'capabilities', 'vin', 'specifications', 'connectivities'}):
|
802
|
+
properties[key]=vehicle.get(key)
|
803
|
+
|
804
|
+
newVehicle = {
|
805
|
+
'vin': vin,
|
806
|
+
'connectivities': vehicle.get('connectivities'),
|
807
|
+
'capabilities': vehicle.get('capabilities'),
|
808
|
+
'specification': vehicle.get('specifications'),
|
809
|
+
'properties': properties,
|
810
|
+
}
|
811
|
+
# Check if object already exist
|
812
|
+
_LOGGER.debug(f'Check if vehicle exists')
|
813
|
+
if self.vehicle(vin) is not None:
|
814
|
+
_LOGGER.debug(f'Vehicle with VIN number {vin} already exist.')
|
815
|
+
car = Vehicle(self, newVehicle)
|
816
|
+
if not car == self.vehicle(newVehicle):
|
817
|
+
_LOGGER.debug(f'Updating {newVehicle} object')
|
818
|
+
self._vehicles.pop(newVehicle)
|
819
|
+
self._vehicles.append(Vehicle(self, newVehicle))
|
820
|
+
else:
|
821
|
+
_LOGGER.debug(f'Adding vehicle {vin}, with connectivities: {vehicle.get('connectivities')}')
|
822
|
+
self._vehicles.append(Vehicle(self, newVehicle))
|
823
|
+
except:
|
824
|
+
raise SeatLoginFailedException("Unable to fetch associated vehicles for account")
|
825
|
+
# Update data for all vehicles
|
826
|
+
await self.update_all()
|
827
|
+
|
828
|
+
return api_vehicles
|
829
|
+
|
830
|
+
#### API get data functions ####
|
831
|
+
# Profile related functions
|
832
|
+
#async def getConsentInfo(self):
|
833
|
+
"""Get consent information for user."""
|
834
|
+
"""try:
|
835
|
+
await self.set_token(self._session_auth_brand)
|
836
|
+
atoken = self._session_tokens[self._session_auth_brand]['access_token']
|
837
|
+
# Try old pyJWT syntax first
|
838
|
+
try:
|
839
|
+
subject = jwt.decode(atoken, verify=False).get('sub', None)
|
840
|
+
except:
|
841
|
+
subject = None
|
842
|
+
# Try new pyJWT syntax if old fails
|
843
|
+
if subject is None:
|
844
|
+
try:
|
845
|
+
exp = jwt.decode(atoken, options={'verify_signature': False}).get('sub', None)
|
846
|
+
subject = exp
|
847
|
+
except:
|
848
|
+
raise Exception("Could not extract sub attribute from token")
|
849
|
+
|
850
|
+
data = {'scopeId': 'commonMandatoryFields'}
|
851
|
+
response = await self.post(f'https://profileintegrityservice.apps.emea.vwapps.io/iaa/pic/v1/users/{subject}/check-profile', json=data)
|
852
|
+
if response.get('mandatoryConsentInfo', False):
|
853
|
+
data = {
|
854
|
+
'consentInfo': response
|
855
|
+
}
|
856
|
+
return data
|
857
|
+
elif response.get('status_code', {}):
|
858
|
+
_LOGGER.warning(f'Could not fetch realCarData, HTTP status code: {response.get("status_code")}')
|
859
|
+
else:
|
860
|
+
_LOGGER.info('Unhandled error while trying to fetch consent information')
|
861
|
+
except Exception as error:
|
862
|
+
_LOGGER.debug(f'Could not get consent information, error {error}')
|
863
|
+
return False"""
|
864
|
+
|
865
|
+
async def getBasicCarData(self, vin, baseurl):
|
866
|
+
"""Get car information from customer profile, VIN, nickname, etc."""
|
867
|
+
await self.set_token(self._session_auth_brand)
|
868
|
+
data={}
|
869
|
+
try:
|
870
|
+
response = await self.get(eval(f"f'{API_MYCAR}'"))
|
871
|
+
if response.get('engines', {}):
|
872
|
+
data['mycar']= response
|
873
|
+
elif response.get('status_code', {}):
|
874
|
+
_LOGGER.warning(f'Could not fetch vehicle mycar report, HTTP status code: {response.get("status_code")}')
|
875
|
+
else:
|
876
|
+
_LOGGER.info('Unhandled error while trying to fetch mycar data')
|
877
|
+
except Exception as error:
|
878
|
+
_LOGGER.warning(f'Could not fetch mycar report, error: {error}')
|
879
|
+
try:
|
880
|
+
response = await self.get(eval(f"f'{API_WARNINGLIGHTS}'"))
|
881
|
+
if 'statuses' in response:
|
882
|
+
data['warninglights'] = response
|
883
|
+
elif response.get('status_code', {}):
|
884
|
+
_LOGGER.warning(f'Could not fetch warnlights, HTTP status code: {response.get("status_code")}')
|
885
|
+
else:
|
886
|
+
_LOGGER.info('Unhandled error while trying to fetch warnlights')
|
887
|
+
except Exception as error:
|
888
|
+
_LOGGER.warning(f'Could not fetch warnlights, error: {error}')
|
889
|
+
try:
|
890
|
+
response = await self.get(eval(f"f'{API_MILEAGE}'"))
|
891
|
+
if response.get('mileageKm', {}):
|
892
|
+
data['mileage'] = response
|
893
|
+
elif response.get('status_code', {}):
|
894
|
+
_LOGGER.warning(f'Could not fetch mileage information, HTTP status code: {response.get("status_code")}')
|
895
|
+
else:
|
896
|
+
_LOGGER.info('Unhandled error while trying to fetch mileage information')
|
897
|
+
except Exception as error:
|
898
|
+
_LOGGER.warning(f'Could not fetch mileage information, error: {error}')
|
899
|
+
if data=={}:
|
900
|
+
return False
|
901
|
+
return data
|
902
|
+
|
903
|
+
#async def getOperationList(self, vin, baseurl):
|
904
|
+
"""Collect operationlist for VIN, supported/licensed functions."""
|
905
|
+
"""try:
|
906
|
+
#await self.set_token('vwg')
|
907
|
+
response = await self.get(f'{baseurl}/api/rolesrights/operationlist/v3/vehicles/{vin}')
|
908
|
+
if response.get('operationList', False):
|
909
|
+
data = response.get('operationList', {})
|
910
|
+
elif response.get('status_code', {}):
|
911
|
+
_LOGGER.warning(f'Could not fetch operation list, HTTP status code: {response.get("status_code")}')
|
912
|
+
data = response
|
913
|
+
else:
|
914
|
+
_LOGGER.info(f'Could not fetch operation list: {response}')
|
915
|
+
data = {'error': 'unknown'}
|
916
|
+
except Exception as error:
|
917
|
+
_LOGGER.warning(f'Could not fetch operation list, error: {error}')
|
918
|
+
data = {'error': 'unknown'}
|
919
|
+
return data"""
|
920
|
+
|
921
|
+
async def getModelImageURL(self, vin, baseurl):
|
922
|
+
"""Construct the URL for the model image."""
|
923
|
+
await self.set_token(self._session_auth_brand)
|
924
|
+
try:
|
925
|
+
try:
|
926
|
+
response = await self.get(
|
927
|
+
url=eval(f"f'{API_IMAGE}'"),
|
928
|
+
)
|
929
|
+
if response.get('front',False):
|
930
|
+
images={}
|
931
|
+
for pos in {'front', 'side', 'top', 'rear'}:
|
932
|
+
if pos in response:
|
933
|
+
pic = await self._request(
|
934
|
+
METH_GET,
|
935
|
+
url=response.get(pos,False),
|
936
|
+
)
|
937
|
+
if len(pic)>0:
|
938
|
+
loop = asyncio.get_running_loop()
|
939
|
+
await loop.run_in_executor(None, self.writeImageFile, pos,pic, images)
|
940
|
+
_LOGGER.debug('Read images from web site and wrote them to file.')
|
941
|
+
response['images']=images
|
942
|
+
return response
|
943
|
+
else:
|
944
|
+
_LOGGER.debug(f'Could not fetch Model image URL, request returned with status code {response.status_code}')
|
945
|
+
except:
|
946
|
+
_LOGGER.debug('Could not fetch Model image URL')
|
947
|
+
except:
|
948
|
+
_LOGGER.debug('Could not fetch Model image URL, message signing failed.')
|
949
|
+
return None
|
950
|
+
|
951
|
+
async def getVehicleStatusReport(self, vin, baseurl):
|
952
|
+
"""Get stored vehicle status report (Connect services)."""
|
953
|
+
data={}
|
954
|
+
await self.set_token(self._session_auth_brand)
|
955
|
+
try:
|
956
|
+
response = await self.get(eval(f"f'{API_STATUS}'"))
|
957
|
+
if response.get('doors', False):
|
958
|
+
data['status']= response
|
959
|
+
elif response.get('status_code', {}):
|
960
|
+
_LOGGER.warning(f'Could not fetch vehicle status report, HTTP status code: {response.get("status_code")}')
|
961
|
+
else:
|
962
|
+
_LOGGER.info('Unhandled error while trying to fetch status data')
|
963
|
+
except Exception as error:
|
964
|
+
_LOGGER.warning(f'Could not fetch status report, error: {error}')
|
965
|
+
if data=={}:
|
966
|
+
return False
|
967
|
+
return data
|
968
|
+
|
969
|
+
async def getMaintenance(self, vin, baseurl):
|
970
|
+
"""Get stored vehicle status report (Connect services)."""
|
971
|
+
data={}
|
972
|
+
await self.set_token(self._session_auth_brand)
|
973
|
+
try:
|
974
|
+
response = await self.get(eval(f"f'{API_MAINTENANCE}'"))
|
975
|
+
if response.get('inspectionDueDays', {}):
|
976
|
+
data['maintenance'] = response
|
977
|
+
elif response.get('status_code', {}):
|
978
|
+
_LOGGER.warning(f'Could not fetch maintenance information, HTTP status code: {response.get("status_code")}')
|
979
|
+
else:
|
980
|
+
_LOGGER.info('Unhandled error while trying to fetch maintenance information')
|
981
|
+
except Exception as error:
|
982
|
+
_LOGGER.warning(f'Could not fetch maintenance information, error: {error}')
|
983
|
+
if data=={}:
|
984
|
+
return False
|
985
|
+
return data
|
986
|
+
|
987
|
+
async def getTripStatistics(self, vin, baseurl):
|
988
|
+
"""Get short term and cyclic trip statistics."""
|
989
|
+
await self.set_token(self._session_auth_brand)
|
990
|
+
try:
|
991
|
+
data={'tripstatistics': {}}
|
992
|
+
dataType='CYCLIC'
|
993
|
+
response = await self.get(eval(f"f'{API_TRIP}'"))
|
994
|
+
if response.get('data', []):
|
995
|
+
data['tripstatistics']['cyclic']= response.get('data', [])
|
996
|
+
elif response.get('status_code', {}):
|
997
|
+
_LOGGER.warning(f'Could not fetch trip statistics, HTTP status code: {response.get("status_code")}')
|
998
|
+
else:
|
999
|
+
_LOGGER.info(f'Unhandled error while trying to fetch trip statistics')
|
1000
|
+
dataType='SHORT'
|
1001
|
+
response = await self.get(eval(f"f'{API_TRIP}'"))
|
1002
|
+
if response.get('data', []):
|
1003
|
+
data['tripstatistics']['short']= response.get('data', [])
|
1004
|
+
elif response.get('status_code', {}):
|
1005
|
+
_LOGGER.warning(f'Could not fetch trip statistics, HTTP status code: {response.get("status_code")}')
|
1006
|
+
else:
|
1007
|
+
_LOGGER.info(f'Unhandled error while trying to fetch trip statistics')
|
1008
|
+
if data.get('tripstatistics',{}) != {}:
|
1009
|
+
return data
|
1010
|
+
except Exception as error:
|
1011
|
+
_LOGGER.warning(f'Could not fetch trip statistics, error: {error}')
|
1012
|
+
return False
|
1013
|
+
|
1014
|
+
async def getPosition(self, vin, baseurl):
|
1015
|
+
"""Get position data."""
|
1016
|
+
await self.set_token(self._session_auth_brand)
|
1017
|
+
try:
|
1018
|
+
response = await self.get(eval(f"f'{API_POSITION}'"))
|
1019
|
+
if response.get('lat', {}):
|
1020
|
+
data = {
|
1021
|
+
'findCarResponse': response,
|
1022
|
+
'isMoving': False
|
1023
|
+
}
|
1024
|
+
if hasattr(self, '_googleApiKey'):
|
1025
|
+
apiKeyForGoogle= self._googleApiKey
|
1026
|
+
lat= response.get('lat', 0)
|
1027
|
+
lon= response.get('lon', 0)
|
1028
|
+
response = await self.get(eval(f"f'{API_POS_TO_ADDRESS}'"))
|
1029
|
+
if response.get('routes', []):
|
1030
|
+
if response.get('routes', [])[0].get('legs', False):
|
1031
|
+
data['findCarResponse']['position_to_address'] = response.get('routes', [])[0].get('legs',[])[0].get('start_address','')
|
1032
|
+
return data
|
1033
|
+
elif response.get('status_code', {}):
|
1034
|
+
if response.get('status_code', 0) == 204:
|
1035
|
+
_LOGGER.debug(f'Seems car is moving, HTTP 204 received from position')
|
1036
|
+
data = {
|
1037
|
+
'isMoving': True,
|
1038
|
+
'rate_limit_remaining': 15
|
1039
|
+
}
|
1040
|
+
return data
|
1041
|
+
else:
|
1042
|
+
_LOGGER.warning(f'Could not fetch position, HTTP status code: {response.get("status_code")}')
|
1043
|
+
else:
|
1044
|
+
_LOGGER.info('Unhandled error while trying to fetch positional data')
|
1045
|
+
except Exception as error:
|
1046
|
+
_LOGGER.warning(f'Could not fetch position, error: {error}')
|
1047
|
+
return False
|
1048
|
+
|
1049
|
+
async def getDeparturetimer(self, vin, baseurl):
|
1050
|
+
"""Get departure timers."""
|
1051
|
+
await self.set_token(self._session_auth_brand)
|
1052
|
+
try:
|
1053
|
+
response = await self.get(eval(f"f'{API_DEPARTURE_TIMERS}'"))
|
1054
|
+
if response.get('timers', {}):
|
1055
|
+
data={}
|
1056
|
+
data['departureTimers'] = response
|
1057
|
+
return data
|
1058
|
+
elif response.get('status_code', {}):
|
1059
|
+
_LOGGER.warning(f'Could not fetch timers, HTTP status code: {response.get("status_code")}')
|
1060
|
+
else:
|
1061
|
+
_LOGGER.info('Unknown error while trying to fetch data for departure timers')
|
1062
|
+
except Exception as error:
|
1063
|
+
_LOGGER.warning(f'Could not fetch timers, error: {error}')
|
1064
|
+
return False
|
1065
|
+
|
1066
|
+
async def getClimater(self, vin, baseurl):
|
1067
|
+
"""Get climatisation data."""
|
1068
|
+
data={}
|
1069
|
+
data['climater']={}
|
1070
|
+
await self.set_token(self._session_auth_brand)
|
1071
|
+
try:
|
1072
|
+
response = await self.get(eval(f"f'{API_CLIMATER_STATUS}'"))
|
1073
|
+
if response.get('climatisationStatus', {}):
|
1074
|
+
data['climater']['status']=response
|
1075
|
+
elif response.get('status_code', {}):
|
1076
|
+
_LOGGER.warning(f'Could not fetch climatisation status, HTTP status code: {response.get("status_code")}')
|
1077
|
+
else:
|
1078
|
+
_LOGGER.info('Unhandled error while trying to fetch climatisation status')
|
1079
|
+
except Exception as error:
|
1080
|
+
_LOGGER.warning(f'Could not fetch climatisation status, error: {error}')
|
1081
|
+
try:
|
1082
|
+
response = await self.get(eval(f"f'{API_CLIMATER}/settings'"))
|
1083
|
+
if response.get('targetTemperatureInCelsius', {}):
|
1084
|
+
data['climater']['settings']=response
|
1085
|
+
elif response.get('status_code', {}):
|
1086
|
+
_LOGGER.warning(f'Could not fetch climatisation settings, HTTP status code: {response.get("status_code")}')
|
1087
|
+
else:
|
1088
|
+
_LOGGER.info('Unhandled error while trying to fetch climatisation settings')
|
1089
|
+
except Exception as error:
|
1090
|
+
_LOGGER.warning(f'Could not fetch climatisation settings, error: {error}')
|
1091
|
+
if data['climater']=={}:
|
1092
|
+
return False
|
1093
|
+
return data
|
1094
|
+
|
1095
|
+
async def getCharger(self, vin, baseurl):
|
1096
|
+
"""Get charger data."""
|
1097
|
+
await self.set_token(self._session_auth_brand)
|
1098
|
+
try:
|
1099
|
+
response = await self.get(eval(f"f'{API_CHARGING}/status'"))
|
1100
|
+
if response.get('battery', {}):
|
1101
|
+
chargingStatus = response
|
1102
|
+
elif response.get('status_code', {}):
|
1103
|
+
_LOGGER.warning(f'Could not fetch charging status, HTTP status code: {response.get("status_code")}')
|
1104
|
+
else:
|
1105
|
+
_LOGGER.info('Unhandled error while trying to fetch charging status')
|
1106
|
+
response = await self.get(eval(f"f'{API_CHARGING}/info'"))
|
1107
|
+
if response.get('settings', {}):
|
1108
|
+
chargingInfo = response
|
1109
|
+
elif response.get('status_code', {}):
|
1110
|
+
_LOGGER.warning(f'Could not fetch charging info, HTTP status code: {response.get("status_code")}')
|
1111
|
+
else:
|
1112
|
+
_LOGGER.info('Unhandled error while trying to fetch charging info')
|
1113
|
+
"""response = await self.get(eval(f"f'{API_CHARGING}/modes'"))
|
1114
|
+
if response.get('battery', {}):
|
1115
|
+
chargingModes = response
|
1116
|
+
elif response.get('status_code', {}):
|
1117
|
+
_LOGGER.warning(f'Could not fetch charging modes, HTTP status code: {response.get("status_code")}')
|
1118
|
+
else:
|
1119
|
+
_LOGGER.info('Unhandled error while trying to fetch charging modes')"""
|
1120
|
+
data = {'charging': {
|
1121
|
+
'status': chargingStatus,
|
1122
|
+
'info' : chargingInfo,
|
1123
|
+
#'modes' : chargingModes,
|
1124
|
+
}
|
1125
|
+
}
|
1126
|
+
return data
|
1127
|
+
except Exception as error:
|
1128
|
+
_LOGGER.warning(f'Could not fetch charger, error: {error}')
|
1129
|
+
return False
|
1130
|
+
|
1131
|
+
async def getPreHeater(self, vin, baseurl):
|
1132
|
+
"""Get parking heater data."""
|
1133
|
+
await self.set_token(self._session_auth_brand)
|
1134
|
+
try:
|
1135
|
+
response = await self.get(eval(f"f'URL_not_yet_known'"))
|
1136
|
+
if response.get('statusResponse', {}):
|
1137
|
+
data = {'heating': response.get('statusResponse', {})}
|
1138
|
+
return data
|
1139
|
+
elif response.get('status_code', {}):
|
1140
|
+
_LOGGER.warning(f'Could not fetch pre-heating, HTTP status code: {response.get("status_code")}')
|
1141
|
+
else:
|
1142
|
+
_LOGGER.info('Unhandled error while trying to fetch pre-heating data')
|
1143
|
+
except Exception as error:
|
1144
|
+
_LOGGER.warning(f'Could not fetch pre-heating, error: {error}')
|
1145
|
+
return False
|
1146
|
+
|
1147
|
+
#### API data set functions ####
|
1148
|
+
#async def get_request_status(self, vin, sectionId, requestId, baseurl):
|
1149
|
+
"""Return status of a request ID for a given section ID."""
|
1150
|
+
"""try:
|
1151
|
+
error_code = None
|
1152
|
+
# Requests for VW-Group API
|
1153
|
+
if sectionId == 'climatisation':
|
1154
|
+
capability='climatisation'
|
1155
|
+
url = eval(f"f'{API_REQUESTS}'")
|
1156
|
+
elif sectionId == 'batterycharge':
|
1157
|
+
capability='charging'
|
1158
|
+
url = eval(f"f'{API_REQUESTS}'")
|
1159
|
+
elif sectionId == 'departuretimer':
|
1160
|
+
capability='departure-timers'
|
1161
|
+
url = eval(f"f'{API_REQUESTS}'")
|
1162
|
+
elif sectionId == 'vsr':
|
1163
|
+
capability='status'
|
1164
|
+
url = eval(f"f'{API_REQUESTS}'")
|
1165
|
+
elif sectionId == 'rhf':
|
1166
|
+
capability='honkandflash'
|
1167
|
+
url = eval(f"f'{API_REQUESTS}/status'")
|
1168
|
+
else:
|
1169
|
+
capability='unknown'
|
1170
|
+
url = eval(f"f'{API_REQUESTS}'")
|
1171
|
+
|
1172
|
+
response = await self.get(url)
|
1173
|
+
# Pre-heater on older cars
|
1174
|
+
if response.get('requestStatusResponse', {}).get('status', False):
|
1175
|
+
result = response.get('requestStatusResponse', {}).get('status', False)
|
1176
|
+
# Electric charging, climatisation and departure timers
|
1177
|
+
elif response.get('action', {}).get('actionState', False):
|
1178
|
+
result = response.get('action', {}).get('actionState', False)
|
1179
|
+
error_code = response.get('action', {}).get('errorCode', None)
|
1180
|
+
else:
|
1181
|
+
result = 'Unknown'
|
1182
|
+
# Translate status messages to meaningful info
|
1183
|
+
if result in ['request_in_progress', 'queued', 'fetched', 'InProgress', 'Waiting']:
|
1184
|
+
status = 'In progress'
|
1185
|
+
elif result in ['request_fail', 'failed']:
|
1186
|
+
status = 'Failed'
|
1187
|
+
if error_code is not None:
|
1188
|
+
# Identified error code for charging, 11 = not connected
|
1189
|
+
if sectionId == 'charging' and error_code == 11:
|
1190
|
+
_LOGGER.info(f'Request failed, charger is not connected')
|
1191
|
+
else:
|
1192
|
+
_LOGGER.info(f'Request failed with error code: {error_code}')
|
1193
|
+
elif result in ['unfetched', 'delayed', 'PollingTimeout']:
|
1194
|
+
status = 'No response'
|
1195
|
+
elif result in [ "FailPlugDisconnected", "FailTimerChargingActive" ]:
|
1196
|
+
status = "Unavailable"
|
1197
|
+
elif result in ['request_successful', 'succeeded', "Successful"]:
|
1198
|
+
status = 'Success'
|
1199
|
+
else:
|
1200
|
+
status = result
|
1201
|
+
return status
|
1202
|
+
except Exception as error:
|
1203
|
+
_LOGGER.warning(f'Failure during get request status: {error}')
|
1204
|
+
raise SeatException(f'Failure during get request status: {error}')"""
|
1205
|
+
|
1206
|
+
async def get_sec_token(self, spin, baseurl):
|
1207
|
+
"""Get a security token, required for certain set functions."""
|
1208
|
+
data = {'spin': spin}
|
1209
|
+
url = eval(f"f'{API_SECTOKEN}'")
|
1210
|
+
response = await self.post(url, json=data, allow_redirects=True)
|
1211
|
+
if response.get('securityToken', False):
|
1212
|
+
return response['securityToken']
|
1213
|
+
else:
|
1214
|
+
raise SeatException('Did not receive a valid security token. Maybewrong SPIN?' )
|
1215
|
+
|
1216
|
+
async def _setViaAPI(self, endpoint, **data):
|
1217
|
+
"""Data call to API to set a value or to start an action."""
|
1218
|
+
await self.set_token(self._session_auth_brand)
|
1219
|
+
try:
|
1220
|
+
url = endpoint
|
1221
|
+
response = await self._data_call(url, **data)
|
1222
|
+
if not response:
|
1223
|
+
raise SeatException(f'Invalid or no response for endpoint {endpoint}')
|
1224
|
+
elif response == 429:
|
1225
|
+
raise SeatThrottledException('Action rate limit reached. Start the car to reset the action limit')
|
1226
|
+
else:
|
1227
|
+
data = {'id': '', 'state' : ''}
|
1228
|
+
if 'requestId' in response:
|
1229
|
+
data['state'] = 'Request accepted'
|
1230
|
+
for key in response:
|
1231
|
+
if isinstance(response.get(key), dict):
|
1232
|
+
for k in response.get(key):
|
1233
|
+
if 'id' in k.lower():
|
1234
|
+
data['id'] = str(response.get(key).get(k))
|
1235
|
+
if 'state' in k.lower():
|
1236
|
+
data['state'] = response.get(key).get(k)
|
1237
|
+
else:
|
1238
|
+
if 'Id' in key:
|
1239
|
+
data['id'] = str(response.get(key))
|
1240
|
+
if 'State' in key:
|
1241
|
+
data['state'] = response.get(key)
|
1242
|
+
if response.get('rate_limit_remaining', False):
|
1243
|
+
data['rate_limit_remaining'] = response.get('rate_limit_remaining', None)
|
1244
|
+
return data
|
1245
|
+
except:
|
1246
|
+
raise
|
1247
|
+
return False
|
1248
|
+
|
1249
|
+
async def setCharger(self, vin, baseurl, mode, data):
|
1250
|
+
"""Start/Stop charger."""
|
1251
|
+
if mode in {'start', 'stop'}:
|
1252
|
+
capability='charging'
|
1253
|
+
return await self._setViaAPI(eval(f"f'{API_REQUESTS}/{mode}'"))
|
1254
|
+
else:
|
1255
|
+
_LOGGER.error(f'Not yet implemented. Mode: {mode}. Command ignored')
|
1256
|
+
raise
|
1257
|
+
|
1258
|
+
async def setClimater(self, vin, baseurl, mode, data, spin):
|
1259
|
+
"""Execute climatisation actions."""
|
1260
|
+
try:
|
1261
|
+
# Only get security token if auxiliary heater is to be started
|
1262
|
+
if data.get('action', {}).get('settings', {}).get('heaterSource', None) == 'auxiliary':
|
1263
|
+
_LOGGER.error(f'This action is not yet implemented: {data.get('action', {}).get('settings', {}).get('heaterSource', None)}. Command ignored')
|
1264
|
+
#self._session_headers['X-securityToken'] = await self.get_sec_token(vin=vin, spin=spin, action='rclima', baseurl=baseurl)
|
1265
|
+
pass
|
1266
|
+
if mode == "stop": # Stop climatisation
|
1267
|
+
capability='climatisation'
|
1268
|
+
return await self._setViaAPI(eval(f"f'{API_REQUESTS}/stop'"))
|
1269
|
+
elif mode == "settings": # Set target temperature
|
1270
|
+
capability='climatisation'
|
1271
|
+
return await self._setViaAPI(eval(f"f'{API_CLIMATER}/settings'"), json=data)
|
1272
|
+
elif mode == "windowHeater stop": # Stop window heater
|
1273
|
+
capability='windowheating'
|
1274
|
+
return await self._setViaAPI(eval(f"f'{API_REQUESTS}/stop'"))
|
1275
|
+
elif mode == "windowHeater start": # Stop window heater
|
1276
|
+
capability='windowheating'
|
1277
|
+
return await self._setViaAPI(eval(f"f'{API_REQUESTS}/start'"))
|
1278
|
+
elif mode == "start": # Start climatisation
|
1279
|
+
return await self._setViaAPI(eval(f"f'{API_CLIMATER}/start'"), json = data)
|
1280
|
+
else: # Unknown modes
|
1281
|
+
_LOGGER.error(f'Unbekannter setClimater mode: {mode}. Command ignored')
|
1282
|
+
return False
|
1283
|
+
except:
|
1284
|
+
raise
|
1285
|
+
return False
|
1286
|
+
|
1287
|
+
async def setDeparturetimer(self, vin, baseurl, data, spin):
|
1288
|
+
"""Set departure timers."""
|
1289
|
+
try:
|
1290
|
+
url= eval(f"f'{API_DEPARTURE_TIMERS}'")
|
1291
|
+
if data:
|
1292
|
+
if data.get('minSocPercentage',False):
|
1293
|
+
url=url+'/settings'
|
1294
|
+
return await self._setViaAPI(url, json = data)
|
1295
|
+
except:
|
1296
|
+
raise
|
1297
|
+
return False
|
1298
|
+
|
1299
|
+
async def sendDestination(self, vin, baseurl, data, spin):
|
1300
|
+
"""Send destination to vehicle."""
|
1301
|
+
|
1302
|
+
await self.set_token(self._session_auth_brand)
|
1303
|
+
try:
|
1304
|
+
url= eval(f"f'{API_DESTINATION}'")
|
1305
|
+
response = await self._session.request(
|
1306
|
+
METH_PUT,
|
1307
|
+
url,
|
1308
|
+
headers=self._session_headers,
|
1309
|
+
timeout=ClientTimeout(total=TIMEOUT.seconds),
|
1310
|
+
cookies=self._session_cookies,
|
1311
|
+
raise_for_status=False,
|
1312
|
+
json=data
|
1313
|
+
)
|
1314
|
+
if response.status==202: #[202 Accepted]
|
1315
|
+
_LOGGER.debug(f'Destination {data[0]} successfully sent to API.')
|
1316
|
+
return response
|
1317
|
+
else:
|
1318
|
+
_LOGGER.debug(f'API did not successfully receive destination.')
|
1319
|
+
raise SeatException(f'Invalid or no response for endpoint {url}')
|
1320
|
+
return response
|
1321
|
+
except aiohttp.client_exceptions.ClientResponseError as error:
|
1322
|
+
_LOGGER.debug(f'Request failed. Data: {data}, HTTP request headers: {self._session_headers}')
|
1323
|
+
if error.status == 401:
|
1324
|
+
_LOGGER.error('Unauthorized')
|
1325
|
+
elif error.status == 400:
|
1326
|
+
_LOGGER.error(f'Bad request')
|
1327
|
+
elif error.status == 429:
|
1328
|
+
_LOGGER.warning('Too many requests. Further requests can only be made after the end of next trip in order to protect your vehicles battery.')
|
1329
|
+
return 429
|
1330
|
+
elif error.status == 500:
|
1331
|
+
_LOGGER.error('Internal server error, server might be temporarily unavailable')
|
1332
|
+
elif error.status == 502:
|
1333
|
+
_LOGGER.error('Bad gateway, this function may not be implemented for this vehicle')
|
1334
|
+
else:
|
1335
|
+
_LOGGER.error(f'Unhandled HTTP exception: {error}')
|
1336
|
+
#return False
|
1337
|
+
except Exception as error:
|
1338
|
+
_LOGGER.error(f'Error: {error}')
|
1339
|
+
raise
|
1340
|
+
return False
|
1341
|
+
|
1342
|
+
async def setHonkAndFlash(self, vin, baseurl, data):
|
1343
|
+
"""Execute honk and flash actions."""
|
1344
|
+
return await self._setViaAPI(eval(f"f'{API_HONK_AND_FLASH}'"), json = data)
|
1345
|
+
|
1346
|
+
async def setLock(self, vin, baseurl, action, data, spin):
|
1347
|
+
"""Remote lock and unlock actions."""
|
1348
|
+
try:
|
1349
|
+
# Fetch security token
|
1350
|
+
self._session_headers['SecToken']= await self.get_sec_token(spin=spin, baseurl=baseurl)
|
1351
|
+
|
1352
|
+
response = await self._setViaAPI(eval(f"f'{API_ACCESS}'"))
|
1353
|
+
|
1354
|
+
# Clean up headers
|
1355
|
+
self._session_headers.pop('SecToken')
|
1356
|
+
|
1357
|
+
return response
|
1358
|
+
|
1359
|
+
except:
|
1360
|
+
self._session_headers.pop('SecToken')
|
1361
|
+
raise
|
1362
|
+
return False
|
1363
|
+
|
1364
|
+
async def setPreHeater(self, vin, baseurl, data, spin):
|
1365
|
+
"""Petrol/diesel parking heater actions."""
|
1366
|
+
try:
|
1367
|
+
# Fetch security token
|
1368
|
+
self._session_headers['SecToken']= await self.get_sec_token(spin=spin, baseurl=baseurl)
|
1369
|
+
|
1370
|
+
response = await self._setViaAPI(eval(f"f'url_not_yet_known'"), json = data)
|
1371
|
+
|
1372
|
+
# Clean up headers
|
1373
|
+
self._session_headers.pop('SecToken')
|
1374
|
+
|
1375
|
+
return response
|
1376
|
+
|
1377
|
+
except:
|
1378
|
+
self._session_headers.pop('SecToken')
|
1379
|
+
raise
|
1380
|
+
return False
|
1381
|
+
|
1382
|
+
async def setRefresh(self, vin, baseurl):
|
1383
|
+
""""Force vehicle data update."""
|
1384
|
+
return await self._setViaAPI(eval(f"f'{API_REFRESH}'"))
|
1385
|
+
|
1386
|
+
#### Token handling ####
|
1387
|
+
async def validate_token(self, token):
|
1388
|
+
"""Function to validate a single token."""
|
1389
|
+
try:
|
1390
|
+
now = datetime.now()
|
1391
|
+
# Try old pyJWT syntax first
|
1392
|
+
try:
|
1393
|
+
exp = jwt.decode(token, verify=False).get('exp', None)
|
1394
|
+
except:
|
1395
|
+
exp = None
|
1396
|
+
# Try new pyJWT syntax if old fails
|
1397
|
+
if exp is None:
|
1398
|
+
try:
|
1399
|
+
exp = jwt.decode(token, options={'verify_signature': False}).get('exp', None)
|
1400
|
+
except:
|
1401
|
+
raise Exception("Could not extract exp attribute")
|
1402
|
+
|
1403
|
+
expires = datetime.fromtimestamp(int(exp))
|
1404
|
+
|
1405
|
+
# Lazy check but it's very inprobable that the token expires the very second we want to use it
|
1406
|
+
if expires > now:
|
1407
|
+
return expires
|
1408
|
+
else:
|
1409
|
+
_LOGGER.debug(f'Token expired at {expires.strftime("%Y-%m-%d %H:%M:%S")})')
|
1410
|
+
return False
|
1411
|
+
except Exception as e:
|
1412
|
+
_LOGGER.info(f'Token validation failed, {e}')
|
1413
|
+
return False
|
1414
|
+
|
1415
|
+
async def verify_token(self, token):
|
1416
|
+
"""Function to verify a single token."""
|
1417
|
+
try:
|
1418
|
+
req = None
|
1419
|
+
# Try old pyJWT syntax first
|
1420
|
+
try:
|
1421
|
+
aud = jwt.decode(token, verify=False).get('aud', None)
|
1422
|
+
except:
|
1423
|
+
aud = None
|
1424
|
+
# Try new pyJWT syntax if old fails
|
1425
|
+
if aud is None:
|
1426
|
+
try:
|
1427
|
+
aud = jwt.decode(token, options={'verify_signature': False}).get('aud', None)
|
1428
|
+
except:
|
1429
|
+
raise Exception("Could not extract exp attribute")
|
1430
|
+
|
1431
|
+
if not isinstance(aud, str):
|
1432
|
+
aud = next(iter(aud))
|
1433
|
+
_LOGGER.debug(f"Verifying token for {aud}")
|
1434
|
+
# If audience indicates a client from https://identity.vwgroup.io
|
1435
|
+
for client in CLIENT_LIST:
|
1436
|
+
if self._session_fulldebug:
|
1437
|
+
_LOGGER.debug(f"Matching {aud} against {CLIENT_LIST[client].get('CLIENT_ID', '')}")
|
1438
|
+
if aud == CLIENT_LIST[client].get('CLIENT_ID', ''):
|
1439
|
+
req = await self._session.get(url = AUTH_TOKENKEYS)
|
1440
|
+
break
|
1441
|
+
|
1442
|
+
# Fetch key list
|
1443
|
+
keys = await req.json()
|
1444
|
+
pubkeys = {}
|
1445
|
+
# Convert all RSA keys and store in list
|
1446
|
+
for jwk in keys['keys']:
|
1447
|
+
kid = jwk['kid']
|
1448
|
+
if jwk['kty'] == 'RSA':
|
1449
|
+
pubkeys[kid] = jwt.algorithms.RSAAlgorithm.from_jwk(to_json(jwk))
|
1450
|
+
# Get key ID from token and get match from key list
|
1451
|
+
token_kid = jwt.get_unverified_header(token)['kid']
|
1452
|
+
if self._session_fulldebug:
|
1453
|
+
try:
|
1454
|
+
_LOGGER.debug(f'Token Key ID is {token_kid}, match from public keys: {keys["keys"][token_kid]}')
|
1455
|
+
except:
|
1456
|
+
pass
|
1457
|
+
pubkey = pubkeys[token_kid]
|
1458
|
+
|
1459
|
+
# Verify token with public key
|
1460
|
+
if jwt.decode(token, key=pubkey, algorithms=['RS256'], audience=aud):
|
1461
|
+
return True
|
1462
|
+
except ExpiredSignatureError:
|
1463
|
+
return False
|
1464
|
+
except Exception as error:
|
1465
|
+
_LOGGER.debug(f'Failed to verify {aud} token, error: {error}')
|
1466
|
+
return error
|
1467
|
+
|
1468
|
+
async def refresh_token(self, client):
|
1469
|
+
"""Function to refresh tokens for a client."""
|
1470
|
+
try:
|
1471
|
+
# Refresh API tokens
|
1472
|
+
_LOGGER.debug(f'Refreshing tokens for client "{client}"')
|
1473
|
+
if client != 'vwg':
|
1474
|
+
body = {
|
1475
|
+
'grant_type': 'refresh_token',
|
1476
|
+
'client_id': CLIENT_LIST[client].get('CLIENT_ID'),
|
1477
|
+
'client_secret': CLIENT_LIST[client].get('CLIENT_SECRET'),
|
1478
|
+
'refresh_token': self._session_tokens[client]['refresh_token']
|
1479
|
+
}
|
1480
|
+
url = AUTH_REFRESH
|
1481
|
+
self._session_token_headers[client]['User-ID']=self._user_id
|
1482
|
+
self._session_headers['User-ID']=self._user_id
|
1483
|
+
else:
|
1484
|
+
_LOGGER.error(f'refresh_token() does not support client \'{client}\' ')
|
1485
|
+
raise
|
1486
|
+
# body = {
|
1487
|
+
# 'grant_type': 'refresh_token',
|
1488
|
+
# 'scope': 'sc2:fal',
|
1489
|
+
# 'token': self._session_tokens[client]['refresh_token']
|
1490
|
+
# }
|
1491
|
+
# url = 'https://mbboauth-1d.prd.ece.vwg-connect.com/mbbcoauth/mobile/oauth2/v1/token'
|
1492
|
+
|
1493
|
+
try:
|
1494
|
+
response = await self._session.post(
|
1495
|
+
url=url,
|
1496
|
+
headers=self._session_token_headers.get(client),
|
1497
|
+
data = body,
|
1498
|
+
)
|
1499
|
+
except:
|
1500
|
+
raise
|
1501
|
+
|
1502
|
+
if response.status == 200:
|
1503
|
+
tokens = await response.json()
|
1504
|
+
# Verify access_token
|
1505
|
+
if 'access_token' in tokens:
|
1506
|
+
if not await self.verify_token(tokens['access_token']):
|
1507
|
+
_LOGGER.warning('Tokens could not be verified!')
|
1508
|
+
for token in tokens:
|
1509
|
+
self._session_tokens[client][token] = tokens[token]
|
1510
|
+
return True
|
1511
|
+
elif response.status == 400:
|
1512
|
+
error = await response.json()
|
1513
|
+
if error.get('error', {}) == 'invalid_grant':
|
1514
|
+
_LOGGER.debug(f'VW-Group API token refresh failed: {error.get("error_description", {})}')
|
1515
|
+
#if client == 'vwg':
|
1516
|
+
# return await self._getAPITokens()
|
1517
|
+
else:
|
1518
|
+
resp = await response.json()
|
1519
|
+
_LOGGER.warning(f'Something went wrong when refreshing tokens for "{client}".')
|
1520
|
+
_LOGGER.debug(f'Headers: {TOKEN_HEADERS.get(client)}')
|
1521
|
+
_LOGGER.debug(f'Request Body: {body}')
|
1522
|
+
_LOGGER.warning(f'Something went wrong when refreshing VW-Group API tokens.')
|
1523
|
+
except Exception as error:
|
1524
|
+
_LOGGER.warning(f'Could not refresh tokens: {error}')
|
1525
|
+
return False
|
1526
|
+
|
1527
|
+
async def set_token(self, client):
|
1528
|
+
"""Switch between tokens."""
|
1529
|
+
# Lock to prevent multiple instances updating tokens simultaneously
|
1530
|
+
async with self._lock:
|
1531
|
+
# If no tokens are available for client, try to authorize
|
1532
|
+
tokens = self._session_tokens.get(client, None)
|
1533
|
+
if tokens is None:
|
1534
|
+
_LOGGER.debug(f'Client "{client}" token is missing, call to authorize the client.')
|
1535
|
+
try:
|
1536
|
+
# Try to authorize client and get tokens
|
1537
|
+
if client != 'vwg':
|
1538
|
+
result = await self._authorize(client)
|
1539
|
+
else:
|
1540
|
+
_LOGGER.error('getAPITokens() commented out.')
|
1541
|
+
result = False
|
1542
|
+
#result = await self._getAPITokens()
|
1543
|
+
|
1544
|
+
# If authorization wasn't successful
|
1545
|
+
if result is not True:
|
1546
|
+
raise SeatAuthenticationException(f'Failed to authorize client {client}')
|
1547
|
+
except:
|
1548
|
+
raise
|
1549
|
+
try:
|
1550
|
+
# Validate access token for client, refresh if validation fails
|
1551
|
+
valid = await self.validate_token(self._session_tokens.get(client, {}).get('access_token', ''))
|
1552
|
+
if not valid:
|
1553
|
+
_LOGGER.debug(f'Tokens for "{client}" are invalid')
|
1554
|
+
# Try to refresh tokens for client
|
1555
|
+
if await self.refresh_token(client) is not True:
|
1556
|
+
raise SeatTokenExpiredException(f'Tokens for client {client} are invalid')
|
1557
|
+
else:
|
1558
|
+
_LOGGER.debug(f'Tokens refreshed successfully for client "{client}"')
|
1559
|
+
pass
|
1560
|
+
else:
|
1561
|
+
try:
|
1562
|
+
dt = datetime.fromtimestamp(valid)
|
1563
|
+
_LOGGER.debug(f'Access token for "{client}" is valid until {dt.strftime("%Y-%m-%d %H:%M:%S")}')
|
1564
|
+
except:
|
1565
|
+
pass
|
1566
|
+
# Assign token to authorization header
|
1567
|
+
self._session_headers['Authorization'] = 'Bearer ' + self._session_tokens[client]['access_token']
|
1568
|
+
except:
|
1569
|
+
raise SeatException(f'Failed to set token for "{client}"')
|
1570
|
+
return True
|
1571
|
+
|
1572
|
+
#### Class helpers ####
|
1573
|
+
@property
|
1574
|
+
def vehicles(self):
|
1575
|
+
"""Return list of Vehicle objects."""
|
1576
|
+
return self._vehicles
|
1577
|
+
|
1578
|
+
def vehicle(self, vin):
|
1579
|
+
"""Return vehicle object for given vin."""
|
1580
|
+
return next(
|
1581
|
+
(
|
1582
|
+
vehicle
|
1583
|
+
for vehicle in self.vehicles
|
1584
|
+
if vehicle.unique_id.lower() == vin.lower()
|
1585
|
+
), None
|
1586
|
+
)
|
1587
|
+
|
1588
|
+
def hash_spin(self, challenge, spin):
|
1589
|
+
"""Convert SPIN and challenge to hash."""
|
1590
|
+
spinArray = bytearray.fromhex(spin);
|
1591
|
+
byteChallenge = bytearray.fromhex(challenge);
|
1592
|
+
spinArray.extend(byteChallenge)
|
1593
|
+
return hashlib.sha512(spinArray).hexdigest()
|
1594
|
+
|
1595
|
+
async def main():
|
1596
|
+
"""Main method."""
|
1597
|
+
if '-v' in argv:
|
1598
|
+
logging.basicConfig(level=logging.INFO)
|
1599
|
+
elif '-vv' in argv:
|
1600
|
+
logging.basicConfig(level=logging.DEBUG)
|
1601
|
+
else:
|
1602
|
+
logging.basicConfig(level=logging.ERROR)
|
1603
|
+
|
1604
|
+
async with ClientSession(headers={'Connection': 'keep-alive'}) as session:
|
1605
|
+
connection = Connection(session, **read_config())
|
1606
|
+
if await connection.doLogin():
|
1607
|
+
if await connection.get_vehicles():
|
1608
|
+
for vehicle in connection.vehicles:
|
1609
|
+
print(f'Vehicle id: {vehicle}')
|
1610
|
+
print('Supported sensors:')
|
1611
|
+
for instrument in vehicle.dashboard().instruments:
|
1612
|
+
print(f' - {instrument.name} (domain:{instrument.component}) - {instrument.str_state}')
|
1613
|
+
|
1614
|
+
|
1615
|
+
if __name__ == '__main__':
|
1616
|
+
loop = asyncio.get_event_loop()
|
1617
|
+
loop.run_until_complete(main())
|