pycupra 0.0.1__py3-none-any.whl

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