carconnectivity-connector-skoda 0.1a2__py3-none-any.whl → 0.1a4__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.

Potentially problematic release.


This version of carconnectivity-connector-skoda might be problematic. Click here for more details.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: carconnectivity-connector-skoda
3
- Version: 0.1a2
3
+ Version: 0.1a4
4
4
  Summary: CarConnectivity connector for Skoda services
5
5
  Author: Till Steinbach
6
6
  License: MIT License
@@ -40,6 +40,7 @@ License-File: LICENSE
40
40
  Requires-Dist: carconnectivity
41
41
  Requires-Dist: oauthlib~=3.2.2
42
42
  Requires-Dist: requests~=2.32.3
43
+ Requires-Dist: jwt~=1.3.1
43
44
 
44
45
 
45
46
 
@@ -1,17 +1,18 @@
1
1
  carconnectivity_connectors/skoda/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- carconnectivity_connectors/skoda/_version.py,sha256=X9LJfZydf4q-ykKMGj930R5S5i9bmNUrA30KznqYx0A,408
2
+ carconnectivity_connectors/skoda/_version.py,sha256=k2_F9qhiZ-QdrUSPYbgP7u-QuYMDZVq_7Ev7i0J23L0,408
3
3
  carconnectivity_connectors/skoda/capability.py,sha256=JlNEaisVYF8qWv0wNDHTaas36uIpTIQ3NVR69wesiYQ,4513
4
- carconnectivity_connectors/skoda/connector.py,sha256=kPW0NOBBQVGAgSGW3-DIttFfPn5-87VgR4cpwW5L5ok,40493
4
+ carconnectivity_connectors/skoda/connector.py,sha256=uAf1WcSQ68fkMF_R2zPjLP5vJYjk1QzGcg-ZdX4mPZo,42473
5
+ carconnectivity_connectors/skoda/mqtt_client.py,sha256=7Hn-TqBl7VsN7e7DoPLvXZQ2UsSaOl1P0bMhbQQPX7k,18933
5
6
  carconnectivity_connectors/skoda/vehicle.py,sha256=H3GRDNimMghFwFi--y9BsgoSK3pMibNf_l6SsDN6gvQ,2759
6
7
  carconnectivity_connectors/skoda/auth/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
8
  carconnectivity_connectors/skoda/auth/auth_util.py,sha256=dGLUbUre0HBsTg_Ii5vW34f8DLrCykYJYCyzEvUBBEE,4434
8
- carconnectivity_connectors/skoda/auth/my_skoda_session.py,sha256=UhCHpJqrTPa81Y2eQlWevr8NKRse207cboF7zqyH30w,10205
9
- carconnectivity_connectors/skoda/auth/openid_session.py,sha256=U4LucKWNDZtPtWfiKV7mdSI4y_s5lTNzt6QZT_lZka4,15886
10
- carconnectivity_connectors/skoda/auth/session_manager.py,sha256=Kk2QoN7IeqBhkWlvIX_1SKMZuFn9VXwtEO2Yxj2uDaA,2807
9
+ carconnectivity_connectors/skoda/auth/my_skoda_session.py,sha256=sKO73OjQzvZ70n0VLBj2vxkuLYiXabS11fh2XvvboGw,10306
10
+ carconnectivity_connectors/skoda/auth/openid_session.py,sha256=PLWSSKw9Dg7hBbhzJ_nEycNrqiG6GiEM15h2wduL8jI,16592
11
+ carconnectivity_connectors/skoda/auth/session_manager.py,sha256=Uf1vujuDBYUCAXhYToOsZkgbTtfmY3Qe0ICTfwomBpI,2899
11
12
  carconnectivity_connectors/skoda/auth/skoda_web_session.py,sha256=cjzMkzx473Sh-4RgZAQULeRRcxB1MboddldCVM_y5LE,10640
12
13
  carconnectivity_connectors/skoda/auth/helpers/blacklist_retry.py,sha256=f3wsiY5bpHDBxp7Va1Mv9nKJ4u3qnCHZZmDu78_AhMk,1251
13
- carconnectivity_connector_skoda-0.1a2.dist-info/LICENSE,sha256=PIwI1alwDyOfvEQHdGCm2u9uf_mGE8030xZDfun0xTo,1071
14
- carconnectivity_connector_skoda-0.1a2.dist-info/METADATA,sha256=styXXYFCvHsk0E88oeLCXrFs_wqtfSviOH4E6_-hO9Q,5300
15
- carconnectivity_connector_skoda-0.1a2.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
16
- carconnectivity_connector_skoda-0.1a2.dist-info/top_level.txt,sha256=KqA8GviZsDH4PtmnwSQsz0HB_w-TWkeEHLIRNo5dTaI,27
17
- carconnectivity_connector_skoda-0.1a2.dist-info/RECORD,,
14
+ carconnectivity_connector_skoda-0.1a4.dist-info/LICENSE,sha256=PIwI1alwDyOfvEQHdGCm2u9uf_mGE8030xZDfun0xTo,1071
15
+ carconnectivity_connector_skoda-0.1a4.dist-info/METADATA,sha256=uAZPqSaCZIMMbLZLSkOBAA0XJyinzwcDNzrjmtKZy18,5326
16
+ carconnectivity_connector_skoda-0.1a4.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
17
+ carconnectivity_connector_skoda-0.1a4.dist-info/top_level.txt,sha256=KqA8GviZsDH4PtmnwSQsz0HB_w-TWkeEHLIRNo5dTaI,27
18
+ carconnectivity_connector_skoda-0.1a4.dist-info/RECORD,,
@@ -12,5 +12,5 @@ __version__: str
12
12
  __version_tuple__: VERSION_TUPLE
13
13
  version_tuple: VERSION_TUPLE
14
14
 
15
- __version__ = version = '0.1a2'
15
+ __version__ = version = '0.1a4'
16
16
  __version_tuple__ = version_tuple = (0, 1)
@@ -26,10 +26,10 @@ from carconnectivity_connectors.skoda.auth.openid_session import AccessType
26
26
  from carconnectivity_connectors.skoda.auth.skoda_web_session import SkodaWebSession
27
27
 
28
28
  if TYPE_CHECKING:
29
- from typing import Tuple, Dict
29
+ from typing import Set
30
30
 
31
31
 
32
- LOG: logging.Logger = logging.getLogger("carconnectivity-connector-skoda")
32
+ LOG: logging.Logger = logging.getLogger("carconnectivity.connectors.skoda.auth")
33
33
 
34
34
 
35
35
  class MySkodaSession(SkodaWebSession):
@@ -38,7 +38,7 @@ class MySkodaSession(SkodaWebSession):
38
38
  """
39
39
  def __init__(self, session_user, **kwargs) -> None:
40
40
  super(MySkodaSession, self).__init__(client_id='7f045eee-7003-4379-9968-9355ed2adb06@apps_vw-dilab_com',
41
- refresh_url='https://tokenrefreshservice.apps.emea.vwapps.io/refreshTokens',
41
+ refresh_url='https://mysmob.api.connect.skoda-auto.cz/api/v1/authentication/refresh-token?tokenType=CONNECT',
42
42
  scope='address badge birthdate cars driversLicense dealers email mileage mbb nationalIdentifier openid phone profession profile vin',
43
43
  redirect_uri='myskoda://redirect/login/',
44
44
  session_user=session_user,
@@ -73,7 +73,7 @@ class MySkodaSession(SkodaWebSession):
73
73
  def refresh(self) -> None:
74
74
  # refresh tokens from refresh endpoint
75
75
  self.refresh_tokens(
76
- 'https://emea.bff.cariad.digital/user-login/refresh/v1',
76
+ 'https://mysmob.api.connect.skoda-auto.cz/api/v1/authentication/refresh-token?tokenType=CONNECT',
77
77
  )
78
78
 
79
79
  def fetch_tokens(
@@ -133,15 +133,20 @@ class MySkodaSession(SkodaWebSession):
133
133
  token = json.loads(token_response)
134
134
  except json.decoder.JSONDecodeError as err:
135
135
  raise TemporaryAuthenticationError('Token could not be refreshed due to temporary WeConnect failure: json could not be decoded') from err
136
+ found_tokens: Set[str] = set()
136
137
  # Fix token keys, we want access_token instead of accessToken
137
138
  if 'accessToken' in token:
139
+ found_tokens.add('accessToken')
138
140
  token['access_token'] = token.pop('accessToken')
139
141
  # Fix token keys, we want id_token instead of idToken
140
142
  if 'idToken' in token:
143
+ found_tokens.add('idToken')
141
144
  token['id_token'] = token.pop('idToken')
142
145
  # Fix token keys, we want refresh_token instead of refreshToken
143
146
  if 'refreshToken' in token:
147
+ found_tokens.add('refreshToken')
144
148
  token['refresh_token'] = token.pop('refreshToken')
149
+ LOG.info(f'Found tokens in answer: {found_tokens}')
145
150
  # generate json from fixed dict
146
151
  fixed_token_response = to_unicode(json.dumps(token)).encode("utf-8")
147
152
  # Let OAuthlib parse the token
@@ -185,33 +190,32 @@ class MySkodaSession(SkodaWebSession):
185
190
  if not is_secure_transport(token_url):
186
191
  raise InsecureTransportError()
187
192
 
188
- # Store old refresh token in case no new one is given
189
193
  refresh_token = refresh_token or self.refresh_token
190
194
 
191
- if headers is None:
192
- headers = self.headers
193
-
194
- # Request new tokens using the refresh token
195
- token_response = self.get(
196
- token_url,
197
- auth=auth,
198
- timeout=timeout,
199
- headers=headers,
200
- verify=verify,
201
- withhold_token=False, # pyright: ignore reportCallIssue
202
- proxies=proxies,
203
- access_type=AccessType.REFRESH # pyright: ignore reportCallIssue
204
- )
205
- if token_response.status_code == requests.codes['unauthorized']:
206
- raise AuthenticationError('Refreshing tokens failed: Server requests new authorization')
207
- elif token_response.status_code in (requests.codes['internal_server_error'], requests.codes['service_unavailable'], requests.codes['gateway_timeout']):
208
- raise TemporaryAuthenticationError('Token could not be refreshed due to temporary WeConnect failure: {tokenResponse.status_code}')
209
- elif token_response.status_code == requests.codes['ok']:
210
- # parse new tokens from response
211
- self.parse_from_body(token_response.text)
212
- if self.token is not None and "refresh_token" not in self.token:
213
- LOG.debug("No new refresh token given. Re-using old.")
214
- self.token["refresh_token"] = refresh_token
195
+ # Generate json body for token request
196
+ body: str = json.dumps(
197
+ {
198
+ 'token': refresh_token,
199
+ })
200
+
201
+ request_headers: CaseInsensitiveDict = self.headers # pyright: ignore reportAssignmentType
202
+ request_headers['accept'] = 'application/json'
203
+ request_headers['content-type'] = 'application/json'
204
+
205
+ try:
206
+ # request tokens from token_url
207
+ token_response = self.post(token_url, headers=request_headers, data=body, allow_redirects=False,
208
+ access_type=AccessType.NONE) # pyright: ignore reportCallIssue
209
+ if token_response.status_code == requests.codes['ok']:
210
+ # parse token from response body
211
+ token = self.parse_from_body(token_response.text)
212
+ return token
213
+ elif token_response.status_code == requests.codes['unauthorized']:
214
+ LOG.info('Refreshing tokens failed: Server requests new authorization, will login now')
215
+ self.login()
216
+ return self.token
217
+ else:
218
+ raise TemporaryAuthenticationError(f'Token could not be fetched due to temporary MySkoda failure: {token_response.status_code}')
219
+ except ConnectionError:
220
+ self.login()
215
221
  return self.token
216
- else:
217
- raise RetrievalError(f'Status Code from WeConnect while refreshing tokens was: {token_response.status_code}')
@@ -4,8 +4,10 @@ from typing import TYPE_CHECKING
4
4
 
5
5
  from enum import Enum, auto
6
6
  import time
7
+ from datetime import datetime, timezone
7
8
  import logging
8
9
  import requests
10
+ from jwt import JWT
9
11
 
10
12
  from oauthlib.common import UNICODE_ASCII_CHARACTER_SET, generate_nonce, generate_token
11
13
  from oauthlib.oauth2.rfc6749.parameters import parse_authorization_code_response, parse_token_response, prepare_grant_uri
@@ -22,7 +24,7 @@ from carconnectivity_connectors.volkswagen.auth.helpers.blacklist_retry import B
22
24
  if TYPE_CHECKING:
23
25
  from typing import Dict
24
26
 
25
- LOG = logging.getLogger("carconnectivity-connector-skoda")
27
+ LOG = logging.getLogger("carconnectivity.connectors.skoda.auth")
26
28
 
27
29
 
28
30
  class AccessType(Enum):
@@ -146,7 +148,17 @@ class OpenIDSession(requests.Session):
146
148
  if self._token is not None and 'expires_in' in self._token:
147
149
  new_token['expires_in'] = self._token['expires_in']
148
150
  else:
149
- new_token['expires_in'] = 3600
151
+ if 'id_token' in new_token:
152
+ jwt_instance = JWT()
153
+ meta_data = jwt_instance.decode(new_token['id_token'], do_verify=False)
154
+ if 'exp' in meta_data:
155
+ new_token['expires_at'] = meta_data['exp']
156
+ expires_at = datetime.fromtimestamp(meta_data['exp'], tz=timezone.utc)
157
+ new_token['expires_in'] = (expires_at - datetime.now(tz=timezone.utc)).total_seconds()
158
+ else:
159
+ new_token['expires_in'] = 3600
160
+ else:
161
+ new_token['expires_in'] = 3600
150
162
  # If expires_in is set and expires_at is not set we calculate expires_at from expires_in using the current time
151
163
  if 'expires_in' in new_token and 'expires_at' not in new_token:
152
164
  new_token['expires_at'] = time.time() + int(new_token.get('expires_in'))
@@ -14,7 +14,7 @@ from requests import Session
14
14
 
15
15
  from carconnectivity_connectors.skoda.auth.my_skoda_session import MySkodaSession
16
16
 
17
- LOG = logging.getLogger("myskoda")
17
+ LOG = logging.getLogger("carconnectivity.connectors.skoda.auth")
18
18
 
19
19
 
20
20
  class SessionUser():
@@ -74,8 +74,9 @@ class SessionManager():
74
74
 
75
75
  def persist(self) -> None:
76
76
  for (service, user), session in self.sessions.items():
77
- identifier: str = SessionManager.generate_identifier(service, user)
78
- self.tokenstore[identifier] = {}
79
- self.tokenstore[identifier]['token'] = session.token
80
- self.tokenstore[identifier]['metadata'] = session.metadata
81
- self.cache[identifier] = session.cache
77
+ if session.token is not None:
78
+ identifier: str = SessionManager.generate_identifier(service, user)
79
+ self.tokenstore[identifier] = {}
80
+ self.tokenstore[identifier]['token'] = session.token
81
+ self.tokenstore[identifier]['metadata'] = session.metadata
82
+ self.cache[identifier] = session.cache
@@ -23,15 +23,15 @@ from carconnectivity.attributes import BooleanAttribute, DurationAttribute
23
23
 
24
24
  from carconnectivity_connectors.base.connector import BaseConnector
25
25
  from carconnectivity_connectors.skoda.auth.session_manager import SessionManager, SessionUser, Service
26
+ from carconnectivity_connectors.skoda.auth.my_skoda_session import MySkodaSession
26
27
  from carconnectivity_connectors.skoda.vehicle import SkodaVehicle, SkodaElectricVehicle, SkodaCombustionVehicle, SkodaHybridVehicle
27
28
  from carconnectivity_connectors.skoda.capability import Capability
28
29
  from carconnectivity_connectors.skoda._version import __version__
30
+ from carconnectivity_connectors.skoda.mqtt_client import SkodaMQTTClient
29
31
 
30
32
  if TYPE_CHECKING:
31
33
  from typing import Dict, List, Optional, Any
32
34
 
33
- from requests import Session
34
-
35
35
  from carconnectivity.carconnectivity import CarConnectivity
36
36
 
37
37
  LOG: logging.Logger = logging.getLogger("carconnectivity.connectors.skoda")
@@ -50,12 +50,17 @@ class Connector(BaseConnector):
50
50
  def __init__(self, connector_id: str, car_connectivity: CarConnectivity, config: Dict) -> None:
51
51
  BaseConnector.__init__(self, connector_id=connector_id, car_connectivity=car_connectivity, config=config)
52
52
 
53
+ self._mqtt_client: SkodaMQTTClient = SkodaMQTTClient(skoda_connector=self)
54
+
53
55
  self._background_thread: Optional[threading.Thread] = None
56
+ self._background_connect_thread: Optional[threading.Thread] = None
54
57
  self._stop_event = threading.Event()
55
58
 
56
59
  self.connected: BooleanAttribute = BooleanAttribute(name="connected", parent=self)
57
60
  self.interval: DurationAttribute = DurationAttribute(name="interval", parent=self)
58
61
 
62
+ self.user_id: Optional[str] = None
63
+
59
64
  # Configure logging
60
65
  if 'log_level' in config and config['log_level'] is not None:
61
66
  config['log_level'] = config['log_level'].upper()
@@ -115,13 +120,33 @@ class Connector(BaseConnector):
115
120
  raise AuthenticationError('Username or password not provided')
116
121
 
117
122
  self._manager: SessionManager = SessionManager(tokenstore=car_connectivity.get_tokenstore(), cache=car_connectivity.get_cache())
118
- self._session: Session = self._manager.get_session(Service.MY_SKODA, SessionUser(username=username, password=password))
123
+ session: requests.Session = self._manager.get_session(Service.MY_SKODA, SessionUser(username=username, password=password))
124
+ if not isinstance(session, MySkodaSession):
125
+ raise AuthenticationError('Could not create session')
126
+ self.session: MySkodaSession = session
127
+ self.session.refresh()
119
128
 
120
129
  self._elapsed: List[timedelta] = []
121
130
 
122
131
  def startup(self) -> None:
132
+ self._stop_event.clear()
133
+ # Start background thread for Rest API polling
123
134
  self._background_thread = threading.Thread(target=self._background_loop, daemon=False)
124
135
  self._background_thread.start()
136
+ # Start background thread for MQTT connection
137
+ self._background_connect_thread = threading.Thread(target=self._background_connect_loop, daemon=False)
138
+ self._background_connect_thread.start()
139
+ # Start MQTT thread
140
+ self._mqtt_client.loop_start()
141
+
142
+ def _background_connect_loop(self) -> None:
143
+ while not self._stop_event.is_set():
144
+ try:
145
+ self._mqtt_client.connect()
146
+ break
147
+ except ConnectionRefusedError as e:
148
+ LOG.error('Could not connect to MQTT-Server: %s, will retry in 10 seconds', e)
149
+ self._stop_event.wait(10)
125
150
 
126
151
  def _background_loop(self) -> None:
127
152
  self._stop_event.clear()
@@ -134,7 +159,6 @@ class Connector(BaseConnector):
134
159
  if self.interval.value is not None:
135
160
  interval: int = self.interval.value.total_seconds()
136
161
  except Exception:
137
- self.connected._set_value(value=False) # pylint: disable=protected-access
138
162
  if self.interval.value is not None:
139
163
  interval: int = self.interval.value.total_seconds()
140
164
  raise
@@ -151,7 +175,6 @@ class Connector(BaseConnector):
151
175
  LOG.error('Temporary authentification error during update (%s). Will try again after configured interval of %ss', str(err), interval)
152
176
  self._stop_event.wait(interval)
153
177
  else:
154
- self.connected._set_value(value=True) # pylint: disable=protected-access
155
178
  self._stop_event.wait(interval)
156
179
 
157
180
  def persist(self) -> None:
@@ -174,6 +197,9 @@ class Connector(BaseConnector):
174
197
  3. Sets the session and manager to None.
175
198
  4. Calls the shutdown method of the base connector.
176
199
  """
200
+ self._mqtt_client.disconnect()
201
+ # Stop MQTT thread
202
+ self._mqtt_client.loop_stop()
177
203
  # Disable and remove all vehicles managed soley by this connector
178
204
  for vehicle in self.car_connectivity.garage.list_vehicles():
179
205
  if len(vehicle.managing_connectors) == 1 and self in vehicle.managing_connectors:
@@ -182,8 +208,10 @@ class Connector(BaseConnector):
182
208
  self._stop_event.set()
183
209
  if self._background_thread is not None:
184
210
  self._background_thread.join()
211
+ if self._background_connect_thread is not None:
212
+ self._background_connect_thread.join()
185
213
  self.persist()
186
- self._session.close()
214
+ self.session.close()
187
215
  return super().shutdown()
188
216
 
189
217
  def fetch_all(self) -> None:
@@ -195,6 +223,21 @@ class Connector(BaseConnector):
195
223
  self.fetch_vehicles()
196
224
  self.car_connectivity.transaction_end()
197
225
 
226
+ def fetch_user(self) -> None:
227
+ """
228
+ Fetches the user data from the Skoda Connect API.
229
+
230
+ This method sends a request to the Skoda Connect API to retrieve the user data associated with the user's account.
231
+
232
+ Returns:
233
+ None
234
+ """
235
+ url = 'https://mysmob.api.connect.skoda-auto.cz/api/v1/users'
236
+ data: Dict[str, Any] | None = self._fetch_data(url, session=self.session)
237
+ if data:
238
+ if 'id' in data and data['id'] is not None:
239
+ self.user_id = data['id']
240
+
198
241
  def fetch_vehicles(self) -> None:
199
242
  """
200
243
  Fetches the list of vehicles from the Skoda Connect API and updates the garage with new vehicles.
@@ -206,7 +249,7 @@ class Connector(BaseConnector):
206
249
  """
207
250
  garage: Garage = self.car_connectivity.garage
208
251
  url = 'https://mysmob.api.connect.skoda-auto.cz/api/v2/garage'
209
- data: Dict[str, Any] | None = self._fetch_data(url, session=self._session)
252
+ data: Dict[str, Any] | None = self._fetch_data(url, session=self.session)
210
253
  seen_vehicle_vins: set[str] = set()
211
254
  if data is not None:
212
255
  if 'vehicles' in data and data['vehicles'] is not None:
@@ -215,7 +258,7 @@ class Connector(BaseConnector):
215
258
  seen_vehicle_vins.add(vehicle_dict['vin'])
216
259
  vehicle: Optional[SkodaVehicle] = garage.get_vehicle(vehicle_dict['vin']) # pyright: ignore[reportAssignmentType]
217
260
  if not vehicle:
218
- vehicle = SkodaVehicle(vin=vehicle_dict['vin'], garage=garage)
261
+ vehicle = SkodaVehicle(vin=vehicle_dict['vin'], garage=garage, managing_connector=self)
219
262
  garage.add_vehicle(vehicle_dict['vin'], vehicle)
220
263
 
221
264
  if 'licensePlate' in vehicle_dict and vehicle_dict['licensePlate'] is not None:
@@ -247,7 +290,7 @@ class Connector(BaseConnector):
247
290
  raise APIError('VIN is missing')
248
291
  url = f'https://mysmob.api.connect.skoda-auto.cz/api/v2/garage/vehicles/{vin}?' \
249
292
  'connectivityGenerations=MOD1&connectivityGenerations=MOD2&connectivityGenerations=MOD3&connectivityGenerations=MOD4'
250
- vehicle_data: Dict[str, Any] | None = self._fetch_data(url, self._session)
293
+ vehicle_data: Dict[str, Any] | None = self._fetch_data(url, self.session)
251
294
  if vehicle_data:
252
295
  if 'softwareVersion' in vehicle_data and vehicle_data['softwareVersion'] is not None:
253
296
  vehicle.software.version._set_value(vehicle_data['softwareVersion']) # pylint: disable=protected-access
@@ -285,7 +328,7 @@ class Connector(BaseConnector):
285
328
  log_extra_keys(LOG_API, 'api/v2/garage/vehicles/VIN', vehicle_data, {'softwareVersion'})
286
329
 
287
330
  url = f'https://mysmob.api.connect.skoda-auto.cz/api/v2/vehicle-status/{vin}/driving-range'
288
- range_data: Dict[str, Any] | None = self._fetch_data(url, self._session)
331
+ range_data: Dict[str, Any] | None = self._fetch_data(url, self.session)
289
332
  if range_data:
290
333
  captured_at: datetime = robust_time_parse(range_data['carCapturedTimestamp'])
291
334
  # Check vehicle type and if it does not match the current vehicle type, create a new vehicle object using copy constructor
@@ -363,6 +406,7 @@ class Connector(BaseConnector):
363
406
 
364
407
  log_extra_keys(LOG_API, f'{drive_id}EngineRange', range_data[f'{drive_id}EngineRange'], {'engineType',
365
408
  'currentSoCInPercent',
409
+ 'currentFuelLevelInPercent',
366
410
  'remainingRangeInKm'})
367
411
  log_extra_keys(LOG_API, '/api/v2/vehicle-status/{vin}/driving-range', range_data, {'carCapturedTimestamp',
368
412
  'carType',
@@ -371,7 +415,7 @@ class Connector(BaseConnector):
371
415
  'secondaryEngineRange'})
372
416
 
373
417
  url = f'https://api.connect.skoda-auto.cz/api/v2/vehicle-status/{vin}'
374
- vehicle_status_data: Dict[str, Any] | None = self._fetch_data(url, self._session)
418
+ vehicle_status_data: Dict[str, Any] | None = self._fetch_data(url, self.session)
375
419
  if vehicle_status_data:
376
420
  if 'remote' in vehicle_status_data and vehicle_status_data['remote'] is not None:
377
421
  vehicle_status_data = vehicle_status_data['remote']
@@ -0,0 +1,422 @@
1
+ """Module implements the MQTT client."""
2
+ from __future__ import annotations
3
+ from typing import TYPE_CHECKING
4
+
5
+ import logging
6
+ import uuid
7
+ import ssl
8
+
9
+ from paho.mqtt.client import Client
10
+ from paho.mqtt.enums import MQTTProtocolVersion, CallbackAPIVersion, MQTTErrorCode
11
+
12
+ from carconnectivity.observable import Observable
13
+ from carconnectivity.vehicle import GenericVehicle
14
+
15
+
16
+ if TYPE_CHECKING:
17
+ from typing import Set
18
+
19
+ from carconnectivity_connectors.skoda.connector import Connector
20
+
21
+
22
+ LOG: logging.Logger = logging.getLogger("carconnectivity.connectors.skoda.mqtt")
23
+
24
+
25
+ class SkodaMQTTClient(Client): # pylint: disable=too-many-instance-attributes
26
+ """
27
+ MQTT client for the myskoda event push service.
28
+ """
29
+ def __init__(self, skoda_connector: Connector) -> None:
30
+ super().__init__(callback_api_version=CallbackAPIVersion.VERSION2,
31
+ client_id="Id" + str(uuid.uuid4()) + "#" + str(uuid.uuid4()),
32
+ transport="tcp",
33
+ protocol=MQTTProtocolVersion.MQTTv311,
34
+ reconnect_on_failure=True,
35
+ clean_session=True)
36
+ self._skoda_connector: Connector = skoda_connector
37
+
38
+ self.username = 'android-app'
39
+
40
+ self.on_pre_connect = self._on_pre_connect_callback
41
+ self.on_connect = self._on_connect_callback
42
+ self.on_message = self._on_message_callback
43
+ self.on_disconnect = self._on_disconnect_callback
44
+ self.on_subscribe = self._on_subscribe_callback
45
+ self.subscribed_topics: Set[str] = set()
46
+
47
+ self.tls_set(cert_reqs=ssl.CERT_NONE)
48
+
49
+ def connect(self, *args, **kwargs) -> MQTTErrorCode:
50
+ """
51
+ Connects the MQTT client to the skoda server.
52
+
53
+ Returns:
54
+ MQTTErrorCode: The result of the connection attempt.
55
+ """
56
+ return super().connect(*args, host='mqtt.messagehub.de', port=8883, keepalive=60, **kwargs)
57
+
58
+ def _on_pre_connect_callback(self, client, userdata) -> None:
59
+ """
60
+ Callback function that is called before the MQTT client connects to the broker.
61
+
62
+ Sets the client's password to the access token.
63
+
64
+ Args:
65
+ client: The MQTT client instance (unused).
66
+ userdata: The user data passed to the callback (unused).
67
+
68
+ Returns:
69
+ None
70
+ """
71
+ del client
72
+ del userdata
73
+
74
+ if self._skoda_connector.session.expired or self._skoda_connector.session.access_token is None:
75
+ self._skoda_connector.session.refresh()
76
+ if not self._skoda_connector.session.expired and self._skoda_connector.session.access_token is not None:
77
+ # pylint: disable-next=attribute-defined-outside-init # this is a false positive, password has a setter in super class
78
+ self._password = self._skoda_connector.session.access_token # This is a bit hacky but if password attribute is used here there is an Exception
79
+
80
+ def _on_carconnectivity_vehicle_enabled(self, element, flags):
81
+ """
82
+ Handles the event when a vehicle is enabled or disabled in the car connectivity system.
83
+
84
+ This method is triggered when the state of a vehicle changes. It subscribes to the vehicle
85
+ if it is enabled and unsubscribes if it is disabled.
86
+
87
+ Args:
88
+ element: The element whose state has changed.
89
+ flags (Observable.ObserverEvent): The event flags indicating the state change.
90
+
91
+ Returns:
92
+ None
93
+ """
94
+ if (flags & Observable.ObserverEvent.ENABLED) and isinstance(element, GenericVehicle):
95
+ self._subscribe_vehicle(element)
96
+ elif (flags & Observable.ObserverEvent.DISABLED) and isinstance(element, GenericVehicle):
97
+ self._subscribe_vehicle(element)
98
+
99
+ def _subscribe_vehicles(self) -> None:
100
+ """
101
+ Subscribes to all vehicles the connector is responsible for.
102
+
103
+ This method iterates through the list of vehicles in the carconnectivity
104
+ garage and subscribes to eliable vehicles by calling the _subscribe_vehicle method.
105
+
106
+ Returns:
107
+ None
108
+ """
109
+ for vehicle in self._skoda_connector.car_connectivity.garage.list_vehicles():
110
+ self._subscribe_vehicle(vehicle)
111
+
112
+ def _unsubscribe_vehicles(self) -> None:
113
+ """
114
+ Unsubscribes from all vehicles the client is subscribed for.
115
+
116
+ This method iterates through the list of vehicles in the garage and
117
+ unsubscribes from each one by calling the _unsubscribe_vehicle method.
118
+
119
+ Returns:
120
+ None
121
+ """
122
+ for vehicle in self._skoda_connector.car_connectivity.garage.list_vehicles():
123
+ self._unsubscribe_vehicle(vehicle)
124
+
125
+ def _subscribe_vehicle(self, vehicle: GenericVehicle) -> None:
126
+ """
127
+ Subscribes to MQTT topics for a given vehicle.
128
+
129
+ This method subscribes to various MQTT topics related to the vehicle's
130
+ account events, operation requests, and service events. It ensures that
131
+ the user ID is fetched if not already available and checks if the vehicle
132
+ has a valid VIN before subscribing.
133
+
134
+ Args:
135
+ vehicle (GenericVehicle): The vehicle object containing VIN and other
136
+ relevant information.
137
+
138
+ Raises:
139
+ None
140
+
141
+ Logs:
142
+ - Warnings if the vehicle does not have a VIN.
143
+ - Info messages upon successful subscription to a topic.
144
+ - Error messages if subscription to a topic fails.
145
+ """
146
+ # to subscribe the user_id must be known
147
+ if self._skoda_connector.user_id is None:
148
+ self._skoda_connector.fetch_user()
149
+ # Can only subscribe with user_id
150
+ if self._skoda_connector.user_id is not None:
151
+ user_id: str = self._skoda_connector.user_id
152
+ if not vehicle.vin.enabled or vehicle.vin.value is None:
153
+ LOG.warning('Could not subscribe to vehicle without vin')
154
+ else:
155
+ vin: str = vehicle.vin.value
156
+ # If the skoda connector is managing this vehicle
157
+ if self._skoda_connector in vehicle.managing_connectors:
158
+ account_events: Set[str] = {'privacy'}
159
+ operation_requests: Set[str] = {
160
+ 'air-conditioning/set-air-conditioning-at-unlock',
161
+ 'air-conditioning/set-air-conditioning-seats-heating',
162
+ 'air-conditioning/set-air-conditioning-timers',
163
+ 'air-conditioning/set-air-conditioning-without-external-power',
164
+ 'air-conditioning/set-target-temperature',
165
+ 'air-conditioning/start-stop-air-conditioning',
166
+ 'auxiliary-heating/start-stop-auxiliary-heating',
167
+ 'air-conditioning/start-stop-window-heating',
168
+ 'air-conditioning/windows-heating',
169
+ 'charging/start-stop-charging',
170
+ 'charging/update-battery-support',
171
+ 'charging/update-auto-unlock-plug',
172
+ 'charging/update-care-mode',
173
+ 'charging/update-charge-limit',
174
+ 'charging/update-charge-mode',
175
+ 'charging/update-charging-profiles',
176
+ 'charging/update-charging-current',
177
+ 'departure/update-departure-timers',
178
+ 'departure/update-minimal-soc',
179
+ 'vehicle-access/honk-and-flash',
180
+ 'vehicle-access/lock-vehicle',
181
+ 'vehicle-services-backup/apply-backup',
182
+ 'vehicle-wakeup/wakeup'
183
+ }
184
+ service_events: Set[str] = {
185
+ 'air-conditioning',
186
+ 'charging',
187
+ 'departure',
188
+ 'vehicle-status/access',
189
+ 'vehicle-status/lights'
190
+ }
191
+ possible_topics: Set[str] = set()
192
+ # Compile all possible topics
193
+ for event in account_events:
194
+ possible_topics.add(f'{user_id}/{vin}/account-event/{event}')
195
+ for event in operation_requests:
196
+ possible_topics.add(f'{user_id}/{vin}/operation-request/{event}')
197
+ for event in service_events:
198
+ possible_topics.add(f'{user_id}/{vin}/service-event/{event}')
199
+
200
+ # Subscribe to all topics
201
+ for topic in possible_topics:
202
+ if topic not in self.subscribed_topics:
203
+ mqtt_err, mid = self.subscribe(topic)
204
+ if mqtt_err == MQTTErrorCode.MQTT_ERR_SUCCESS:
205
+ self.subscribed_topics.add(topic)
206
+ LOG.debug('Subscribe to topic %s with %d', topic, mid)
207
+ else:
208
+ LOG.error('Could not subscribe to topic %s (%s)', topic, mqtt_err)
209
+ else:
210
+ LOG.warning('Could not subscribe to vehicle without user_id')
211
+
212
+ def _unsubscribe_vehicle(self, vehicle: GenericVehicle) -> None:
213
+ """
214
+ Unsubscribe from all MQTT topics related to a specific vehicle.
215
+
216
+ This method checks if the vehicle's VIN (Vehicle Identification Number) is enabled and not None.
217
+ If the VIN is valid, it iterates through the list of subscribed topics and unsubscribes from
218
+ any topic that contains the VIN. It also removes the topic from the list of subscribed topics
219
+ and logs the unsubscription.
220
+
221
+ Args:
222
+ vehicle (GenericVehicle): The vehicle object containing the VIN information.
223
+
224
+ Raises:
225
+ None
226
+
227
+ Logs:
228
+ - Warning if the vehicle's VIN is not enabled or is None.
229
+ - Info for each topic successfully unsubscribed.
230
+ """
231
+ if not vehicle.vin.enabled or vehicle.vin.value is None:
232
+ LOG.warning('Could not unsubscribe to vehicle without vin')
233
+ else:
234
+ vin: str = vehicle.vin.value
235
+ for topic in self.subscribed_topics:
236
+ if vin in topic:
237
+ self.unsubscribe(topic)
238
+ self.subscribed_topics.remove(topic)
239
+ LOG.debug('Unsubscribed from topic %s', topic)
240
+
241
+ def _on_connect_callback(self, mqttc, obj, flags, reason_code, properties) -> None:
242
+ """
243
+ Callback function that is called when the MQTT client connects to the broker.
244
+
245
+ It registers a callback to observe new vehicles being added and subscribes MQTT topics for all vehicles
246
+ handled by this connector.
247
+
248
+ Args:
249
+ mqttc: The MQTT client instance (unused).
250
+ obj: User-defined object passed to the callback (unused).
251
+ flags: Response flags sent by the broker (unused).
252
+ reason_code: The connection result code.
253
+ properties: MQTT v5 properties (unused).
254
+
255
+ Returns:
256
+ None
257
+
258
+ The function logs the connection status and handles different reason codes:
259
+ - 0: Connection successful.
260
+ - 128: Unspecified error.
261
+ - 129: Malformed packet.
262
+ - 130: Protocol error.
263
+ - 131: Implementation specific error.
264
+ - 132: Unsupported protocol version.
265
+ - 133: Client identifier not valid.
266
+ - 134: Bad user name or password.
267
+ - 135: Not authorized.
268
+ - 136: Server unavailable.
269
+ - 137: Server busy. Retrying.
270
+ - 138: Banned.
271
+ - 140: Bad authentication method.
272
+ - 144: Topic name invalid.
273
+ - 149: Packet too large.
274
+ - 151: Quota exceeded.
275
+ - 154: Retain not supported.
276
+ - 155: QoS not supported.
277
+ - 156: Use another server.
278
+ - 157: Server move.
279
+ - 159: Connection rate exceeded.
280
+ - Other: Generic connection error.
281
+ """
282
+ del mqttc # unused
283
+ del obj # unused
284
+ del flags # unused
285
+ del properties
286
+ # reason_code 0 means success
287
+ if reason_code == 0:
288
+ LOG.info('Connected to Skoda MQTT server')
289
+ self._skoda_connector.connected._set_value(value=True) # pylint: disable=protected-access
290
+ observer_flags: Observable.ObserverEvent = Observable.ObserverEvent.ENABLED | Observable.ObserverEvent.DISABLED
291
+ self._skoda_connector.car_connectivity.garage.add_observer(observer=self._on_carconnectivity_vehicle_enabled,
292
+ flag=observer_flags,
293
+ priority=Observable.ObserverPriority.USER_MID)
294
+ self._subscribe_vehicles()
295
+
296
+ # Handle different reason codes
297
+ elif reason_code == 128:
298
+ LOG.error('Could not connect (%s): Unspecified error', reason_code)
299
+ elif reason_code == 129:
300
+ LOG.error('Could not connect (%s): Malformed packet', reason_code)
301
+ elif reason_code == 130:
302
+ LOG.error('Could not connect (%s): Protocol error', reason_code)
303
+ elif reason_code == 131:
304
+ LOG.error('Could not connect (%s): Implementation specific error', reason_code)
305
+ elif reason_code == 132:
306
+ LOG.error('Could not connect (%s): Unsupported protocol version', reason_code)
307
+ elif reason_code == 133:
308
+ LOG.error('Could not connect (%s): Client identifier not valid', reason_code)
309
+ elif reason_code == 134:
310
+ LOG.error('Could not connect (%s): Bad user name or password', reason_code)
311
+ elif reason_code == 135:
312
+ LOG.error('Could not connect (%s): Not authorized', reason_code)
313
+ elif reason_code == 136:
314
+ LOG.error('Could not connect (%s): Server unavailable', reason_code)
315
+ elif reason_code == 137:
316
+ LOG.error('Could not connect (%s): Server busy. Retrying', reason_code)
317
+ elif reason_code == 138:
318
+ LOG.error('Could not connect (%s): Banned', reason_code)
319
+ elif reason_code == 140:
320
+ LOG.error('Could not connect (%s): Bad authentication method', reason_code)
321
+ elif reason_code == 144:
322
+ LOG.error('Could not connect (%s): Topic name invalid', reason_code)
323
+ elif reason_code == 149:
324
+ LOG.error('Could not connect (%s): Packet too large', reason_code)
325
+ elif reason_code == 151:
326
+ LOG.error('Could not connect (%s): Quota exceeded', reason_code)
327
+ elif reason_code == 154:
328
+ LOG.error('Could not connect (%s): Retain not supported', reason_code)
329
+ elif reason_code == 155:
330
+ LOG.error('Could not connect (%s): QoS not supported', reason_code)
331
+ elif reason_code == 156:
332
+ LOG.error('Could not connect (%s): Use another server', reason_code)
333
+ elif reason_code == 157:
334
+ LOG.error('Could not connect (%s): Server move', reason_code)
335
+ elif reason_code == 159:
336
+ LOG.error('Could not connect (%s): Connection rate exceeded', reason_code)
337
+ else:
338
+ LOG.error('Could not connect (%s)', reason_code)
339
+
340
+ def _on_disconnect_callback(self, client, userdata, flags, reason_code, properties) -> None:
341
+ """
342
+ Callback function that is called when the MQTT client disconnects.
343
+
344
+ This function handles the disconnection of the MQTT client and logs the appropriate
345
+ messages based on the reason code for the disconnection. It also removes the observer
346
+ from the garage to not get any notifications for vehicles being added or removed.
347
+
348
+ Args:
349
+ client: The MQTT client instance that disconnected.
350
+ userdata: The private user data as set in Client() or userdata_set().
351
+ flags: Response flags sent by the broker.
352
+ reason_code: The reason code for the disconnection.
353
+ properties: The properties associated with the disconnection.
354
+
355
+ Returns:
356
+ None
357
+ """
358
+ del client
359
+ del properties
360
+ del flags
361
+
362
+ self._skoda_connector.connected._set_value(value=False) # pylint: disable=protected-access
363
+ self._skoda_connector.car_connectivity.garage.remove_observer(observer=self._on_carconnectivity_vehicle_enabled)
364
+
365
+ if reason_code == 0:
366
+ LOG.info('Client successfully disconnected')
367
+ elif reason_code == 4:
368
+ LOG.info('Client successfully disconnected: %s', userdata)
369
+ elif reason_code == 137:
370
+ LOG.error('Client disconnected: Server busy')
371
+ elif reason_code == 139:
372
+ LOG.error('Client disconnected: Server shutting down')
373
+ elif reason_code == 160:
374
+ LOG.error('Client disconnected: Maximum connect time')
375
+ else:
376
+ LOG.error('Client unexpectedly disconnected (%s), trying to reconnect', reason_code)
377
+
378
+ def _on_subscribe_callback(self, mqttc, obj, mid, reason_codes, properties) -> None:
379
+ """
380
+ Callback function for MQTT subscription.
381
+
382
+ This method is called when the client receives a SUBACK response from the server.
383
+ It checks the reason codes to determine if the subscription was successful.
384
+
385
+ Args:
386
+ mqttc: The MQTT client instance (unused).
387
+ obj: User-defined data of any type (unused).
388
+ mid: The message ID of the subscribe request.
389
+ reason_codes: A list of reason codes indicating the result of the subscription.
390
+ properties: MQTT v5.0 properties (unused).
391
+
392
+ Returns:
393
+ None
394
+ """
395
+ del mqttc # unused
396
+ del obj # unused
397
+ del properties # unused
398
+ if any(x in [0, 1, 2] for x in reason_codes):
399
+ LOG.debug('sucessfully subscribed to topic of mid %d', mid)
400
+ else:
401
+ LOG.error('Subscribe was not successfull (%s)', ', '.join(reason_codes))
402
+
403
+ def _on_message_callback(self, mqttc, obj, msg) -> None: # noqa: C901
404
+ """
405
+ Callback function for handling incoming MQTT messages.
406
+
407
+ This function is called when a message is received on a subscribed topic.
408
+ It logs an error message indicating that the message is not understood.
409
+ In the next step this needs to be implemented with real behaviour.
410
+
411
+ Args:
412
+ mqttc: The MQTT client instance (unused).
413
+ obj: The user data (unused).
414
+ msg: The MQTT message instance containing topic and payload.
415
+
416
+ Returns:
417
+ None
418
+ """
419
+ del mqttc # unused
420
+ del obj # unused
421
+ error_message = f'I don\'t understand message {msg.topic}: {msg.payload}'
422
+ LOG.info(error_message)