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