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