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