carconnectivity-connector-skoda 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.

Potentially problematic release.


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

@@ -0,0 +1,655 @@
1
+ """Module implements the MQTT client."""
2
+ from __future__ import annotations
3
+ from typing import TYPE_CHECKING
4
+
5
+ import re
6
+ import logging
7
+ import uuid
8
+ import ssl
9
+ import json
10
+ import threading
11
+ from datetime import timedelta, timezone
12
+
13
+ from paho.mqtt.client import Client
14
+ from paho.mqtt.enums import MQTTProtocolVersion, CallbackAPIVersion, MQTTErrorCode
15
+
16
+ from carconnectivity.errors import CarConnectivityError
17
+ from carconnectivity.observable import Observable
18
+ from carconnectivity.vehicle import GenericVehicle
19
+
20
+ from carconnectivity.drive import ElectricDrive
21
+ from carconnectivity.util import robust_time_parse, log_extra_keys
22
+ from carconnectivity.charging import Charging
23
+ from carconnectivity.climatization import Climatization
24
+ from carconnectivity.units import Speed, Power, Length
25
+
26
+ from carconnectivity_connectors.skoda.vehicle import SkodaVehicle, SkodaElectricVehicle
27
+ from carconnectivity_connectors.skoda.charging import SkodaCharging, mapping_skoda_charging_state
28
+
29
+
30
+ if TYPE_CHECKING:
31
+ from typing import Set, Dict, Any, Optional, List
32
+ from datetime import datetime
33
+
34
+ from paho.mqtt.client import MQTTMessage, DisconnectFlags, ConnectFlags
35
+ from paho.mqtt.reasoncodes import ReasonCode
36
+ from paho.mqtt.properties import Properties
37
+
38
+ from carconnectivity.attributes import GenericAttribute
39
+
40
+ from carconnectivity_connectors.skoda.connector import Connector
41
+
42
+
43
+ LOG: logging.Logger = logging.getLogger("carconnectivity.connectors.skoda.mqtt")
44
+ LOG_API: logging.Logger = logging.getLogger("carconnectivity.connectors.skoda-api-debug")
45
+
46
+
47
+ class SkodaMQTTClient(Client): # pylint: disable=too-many-instance-attributes
48
+ """
49
+ MQTT client for the myskoda event push service.
50
+ """
51
+ def __init__(self, skoda_connector: Connector) -> None:
52
+ super().__init__(callback_api_version=CallbackAPIVersion.VERSION2,
53
+ client_id="Id" + str(uuid.uuid4()) + "#" + str(uuid.uuid4()),
54
+ transport="tcp",
55
+ protocol=MQTTProtocolVersion.MQTTv311,
56
+ reconnect_on_failure=True,
57
+ clean_session=True)
58
+ self._skoda_connector: Connector = skoda_connector
59
+
60
+ self.username = 'android-app'
61
+
62
+ self.on_pre_connect = self._on_pre_connect_callback
63
+ self.on_connect = self._on_connect_callback
64
+ self.on_message = self._on_message_callback
65
+ self.on_disconnect = self._on_disconnect_callback
66
+ self.on_subscribe = self._on_subscribe_callback
67
+ self.subscribed_topics: Set[str] = set()
68
+
69
+ self.delayed_access_function_timers: Dict[str, threading.Timer] = {}
70
+
71
+ self.tls_set(cert_reqs=ssl.CERT_NONE)
72
+
73
+ def connect(self, *args, **kwargs) -> MQTTErrorCode:
74
+ """
75
+ Connects the MQTT client to the skoda server.
76
+
77
+ Returns:
78
+ MQTTErrorCode: The result of the connection attempt.
79
+ """
80
+ return super().connect(*args, host='mqtt.messagehub.de', port=8883, keepalive=60, **kwargs)
81
+
82
+ def _on_pre_connect_callback(self, client: Client, userdata: Any) -> None:
83
+ """
84
+ Callback function that is called before the MQTT client connects to the broker.
85
+
86
+ Sets the client's password to the access token.
87
+
88
+ Args:
89
+ client: The MQTT client instance (unused).
90
+ userdata: The user data passed to the callback (unused).
91
+
92
+ Returns:
93
+ None
94
+ """
95
+ del client
96
+ del userdata
97
+
98
+ if self._skoda_connector.session.expired or self._skoda_connector.session.access_token is None:
99
+ self._skoda_connector.session.refresh()
100
+ if not self._skoda_connector.session.expired and self._skoda_connector.session.access_token is not None:
101
+ # pylint: disable-next=attribute-defined-outside-init # this is a false positive, password has a setter in super class
102
+ self._password = self._skoda_connector.session.access_token # This is a bit hacky but if password attribute is used here there is an Exception
103
+
104
+ def _on_carconnectivity_vehicle_enabled(self, element: GenericAttribute, flags: Observable.ObserverEvent) -> None:
105
+ """
106
+ Handles the event when a vehicle is enabled or disabled in the car connectivity system.
107
+
108
+ This method is triggered when the state of a vehicle changes. It subscribes to the vehicle
109
+ if it is enabled and unsubscribes if it is disabled.
110
+
111
+ Args:
112
+ element: The element whose state has changed.
113
+ flags (Observable.ObserverEvent): The event flags indicating the state change.
114
+
115
+ Returns:
116
+ None
117
+ """
118
+ if (flags & Observable.ObserverEvent.ENABLED) and isinstance(element, GenericVehicle):
119
+ self._subscribe_vehicle(element)
120
+ elif (flags & Observable.ObserverEvent.DISABLED) and isinstance(element, GenericVehicle):
121
+ self._unsubscribe_vehicle(element)
122
+
123
+ def _subscribe_vehicles(self) -> None:
124
+ """
125
+ Subscribes to all vehicles the connector is responsible for.
126
+
127
+ This method iterates through the list of vehicles in the carconnectivity
128
+ garage and subscribes to eliable vehicles by calling the _subscribe_vehicle method.
129
+
130
+ Returns:
131
+ None
132
+ """
133
+ for vehicle in self._skoda_connector.car_connectivity.garage.list_vehicles():
134
+ self._subscribe_vehicle(vehicle)
135
+
136
+ def _unsubscribe_vehicles(self) -> None:
137
+ """
138
+ Unsubscribes from all vehicles the client is subscribed for.
139
+
140
+ This method iterates through the list of vehicles in the garage and
141
+ unsubscribes from each one by calling the _unsubscribe_vehicle method.
142
+
143
+ Returns:
144
+ None
145
+ """
146
+ for vehicle in self._skoda_connector.car_connectivity.garage.list_vehicles():
147
+ self._unsubscribe_vehicle(vehicle)
148
+
149
+ def _subscribe_vehicle(self, vehicle: GenericVehicle) -> None:
150
+ """
151
+ Subscribes to MQTT topics for a given vehicle.
152
+
153
+ This method subscribes to various MQTT topics related to the vehicle's
154
+ account events, operation requests, and service events. It ensures that
155
+ the user ID is fetched if not already available and checks if the vehicle
156
+ has a valid VIN before subscribing.
157
+
158
+ Args:
159
+ vehicle (GenericVehicle): The vehicle object containing VIN and other
160
+ relevant information.
161
+
162
+ Raises:
163
+ None
164
+
165
+ Logs:
166
+ - Warnings if the vehicle does not have a VIN.
167
+ - Info messages upon successful subscription to a topic.
168
+ - Error messages if subscription to a topic fails.
169
+ """
170
+ # to subscribe the user_id must be known
171
+ if self._skoda_connector.user_id is None:
172
+ self._skoda_connector.fetch_user()
173
+ # Can only subscribe with user_id
174
+ if self._skoda_connector.user_id is not None:
175
+ user_id: str = self._skoda_connector.user_id
176
+ if not vehicle.vin.enabled or vehicle.vin.value is None:
177
+ LOG.warning('Could not subscribe to vehicle without vin')
178
+ else:
179
+ vin: str = vehicle.vin.value
180
+ # If the skoda connector is managing this vehicle
181
+ if self._skoda_connector in vehicle.managing_connectors:
182
+ account_events: Set[str] = {'privacy'}
183
+ operation_requests: Set[str] = {
184
+ 'air-conditioning/set-air-conditioning-at-unlock',
185
+ 'air-conditioning/set-air-conditioning-seats-heating',
186
+ 'air-conditioning/set-air-conditioning-timers',
187
+ 'air-conditioning/set-air-conditioning-without-external-power',
188
+ 'air-conditioning/set-target-temperature',
189
+ 'air-conditioning/start-stop-air-conditioning',
190
+ 'auxiliary-heating/start-stop-auxiliary-heating',
191
+ 'air-conditioning/start-stop-window-heating',
192
+ 'air-conditioning/windows-heating',
193
+ 'charging/start-stop-charging',
194
+ 'charging/update-battery-support',
195
+ 'charging/update-auto-unlock-plug',
196
+ 'charging/update-care-mode',
197
+ 'charging/update-charge-limit',
198
+ 'charging/update-charge-mode',
199
+ 'charging/update-charging-profiles',
200
+ 'charging/update-charging-current',
201
+ 'departure/update-departure-timers',
202
+ 'departure/update-minimal-soc',
203
+ 'vehicle-access/honk-and-flash',
204
+ 'vehicle-access/lock-vehicle',
205
+ 'vehicle-services-backup/apply-backup',
206
+ 'vehicle-wakeup/wakeup'
207
+ }
208
+ service_events: Set[str] = {
209
+ 'air-conditioning',
210
+ 'charging',
211
+ 'departure',
212
+ 'vehicle-status/access',
213
+ 'vehicle-status/lights'
214
+ }
215
+ possible_topics: Set[str] = set()
216
+ # Compile all possible topics
217
+ for event in account_events:
218
+ possible_topics.add(f'{user_id}/{vin}/account-event/{event}')
219
+ for event in operation_requests:
220
+ possible_topics.add(f'{user_id}/{vin}/operation-request/{event}')
221
+ for event in service_events:
222
+ possible_topics.add(f'{user_id}/{vin}/service-event/{event}')
223
+
224
+ # Subscribe to all topics
225
+ for topic in possible_topics:
226
+ if topic not in self.subscribed_topics:
227
+ mqtt_err, mid = self.subscribe(topic)
228
+ if mqtt_err == MQTTErrorCode.MQTT_ERR_SUCCESS:
229
+ self.subscribed_topics.add(topic)
230
+ LOG.debug('Subscribe to topic %s with %d', topic, mid)
231
+ else:
232
+ LOG.error('Could not subscribe to topic %s (%s)', topic, mqtt_err)
233
+ else:
234
+ LOG.warning('Could not subscribe to vehicle without user_id')
235
+
236
+ def _unsubscribe_vehicle(self, vehicle: GenericVehicle) -> None:
237
+ """
238
+ Unsubscribe from all MQTT topics related to a specific vehicle.
239
+
240
+ This method checks if the vehicle's VIN (Vehicle Identification Number) is enabled and not None.
241
+ If the VIN is valid, it iterates through the list of subscribed topics and unsubscribes from
242
+ any topic that contains the VIN. It also removes the topic from the list of subscribed topics
243
+ and logs the unsubscription.
244
+
245
+ Args:
246
+ vehicle (GenericVehicle): The vehicle object containing the VIN information.
247
+
248
+ Raises:
249
+ None
250
+
251
+ Logs:
252
+ - Warning if the vehicle's VIN is not enabled or is None.
253
+ - Info for each topic successfully unsubscribed.
254
+ """
255
+ vin: str = vehicle.id
256
+ for topic in self.subscribed_topics:
257
+ if vin in topic:
258
+ self.unsubscribe(topic)
259
+ self.subscribed_topics.remove(topic)
260
+ LOG.debug('Unsubscribed from topic %s', topic)
261
+
262
+ def _on_connect_callback(self, client: Client, obj: Any, flags: ConnectFlags, reason_code: ReasonCode, properties: Optional[Properties]) -> None:
263
+ """
264
+ Callback function that is called when the MQTT client connects to the broker.
265
+
266
+ It registers a callback to observe new vehicles being added and subscribes MQTT topics for all vehicles
267
+ handled by this connector.
268
+
269
+ Args:
270
+ mqttc: The MQTT client instance (unused).
271
+ obj: User-defined object passed to the callback (unused).
272
+ flags: Response flags sent by the broker (unused).
273
+ reason_code: The connection result code.
274
+ properties: MQTT v5 properties (unused).
275
+
276
+ Returns:
277
+ None
278
+
279
+ The function logs the connection status and handles different reason codes:
280
+ - 0: Connection successful.
281
+ - 128: Unspecified error.
282
+ - 129: Malformed packet.
283
+ - 130: Protocol error.
284
+ - 131: Implementation specific error.
285
+ - 132: Unsupported protocol version.
286
+ - 133: Client identifier not valid.
287
+ - 134: Bad user name or password.
288
+ - 135: Not authorized.
289
+ - 136: Server unavailable.
290
+ - 137: Server busy. Retrying.
291
+ - 138: Banned.
292
+ - 140: Bad authentication method.
293
+ - 144: Topic name invalid.
294
+ - 149: Packet too large.
295
+ - 151: Quota exceeded.
296
+ - 154: Retain not supported.
297
+ - 155: QoS not supported.
298
+ - 156: Use another server.
299
+ - 157: Server move.
300
+ - 159: Connection rate exceeded.
301
+ - Other: Generic connection error.
302
+ """
303
+ del client # unused
304
+ del obj # unused
305
+ del flags # unused
306
+ del properties
307
+ # reason_code 0 means success
308
+ if reason_code == 0:
309
+ LOG.info('Connected to Skoda MQTT server')
310
+ self._skoda_connector.connected._set_value(value=True) # pylint: disable=protected-access
311
+ observer_flags: Observable.ObserverEvent = Observable.ObserverEvent.ENABLED | Observable.ObserverEvent.DISABLED
312
+ self._skoda_connector.car_connectivity.garage.add_observer(observer=self._on_carconnectivity_vehicle_enabled,
313
+ flag=observer_flags,
314
+ priority=Observable.ObserverPriority.USER_MID)
315
+ self._subscribe_vehicles()
316
+
317
+ # Handle different reason codes
318
+ elif reason_code == 128:
319
+ LOG.error('Could not connect (%s): Unspecified error', reason_code)
320
+ elif reason_code == 129:
321
+ LOG.error('Could not connect (%s): Malformed packet', reason_code)
322
+ elif reason_code == 130:
323
+ LOG.error('Could not connect (%s): Protocol error', reason_code)
324
+ elif reason_code == 131:
325
+ LOG.error('Could not connect (%s): Implementation specific error', reason_code)
326
+ elif reason_code == 132:
327
+ LOG.error('Could not connect (%s): Unsupported protocol version', reason_code)
328
+ elif reason_code == 133:
329
+ LOG.error('Could not connect (%s): Client identifier not valid', reason_code)
330
+ elif reason_code == 134:
331
+ LOG.error('Could not connect (%s): Bad user name or password', reason_code)
332
+ elif reason_code == 135:
333
+ LOG.error('Could not connect (%s): Not authorized', reason_code)
334
+ elif reason_code == 136:
335
+ LOG.error('Could not connect (%s): Server unavailable', reason_code)
336
+ elif reason_code == 137:
337
+ LOG.error('Could not connect (%s): Server busy. Retrying', reason_code)
338
+ elif reason_code == 138:
339
+ LOG.error('Could not connect (%s): Banned', reason_code)
340
+ elif reason_code == 140:
341
+ LOG.error('Could not connect (%s): Bad authentication method', reason_code)
342
+ elif reason_code == 144:
343
+ LOG.error('Could not connect (%s): Topic name invalid', reason_code)
344
+ elif reason_code == 149:
345
+ LOG.error('Could not connect (%s): Packet too large', reason_code)
346
+ elif reason_code == 151:
347
+ LOG.error('Could not connect (%s): Quota exceeded', reason_code)
348
+ elif reason_code == 154:
349
+ LOG.error('Could not connect (%s): Retain not supported', reason_code)
350
+ elif reason_code == 155:
351
+ LOG.error('Could not connect (%s): QoS not supported', reason_code)
352
+ elif reason_code == 156:
353
+ LOG.error('Could not connect (%s): Use another server', reason_code)
354
+ elif reason_code == 157:
355
+ LOG.error('Could not connect (%s): Server move', reason_code)
356
+ elif reason_code == 159:
357
+ LOG.error('Could not connect (%s): Connection rate exceeded', reason_code)
358
+ else:
359
+ LOG.error('Could not connect (%s)', reason_code)
360
+
361
+ def _on_disconnect_callback(self, client: Client, userdata, flags: DisconnectFlags, reason_code: ReasonCode, properties: Optional[Properties]) -> None:
362
+ """["Client", Any, DisconnectFlags, ReasonCode, Union[Properties, None]
363
+ Callback function that is called when the MQTT client disconnects.
364
+
365
+ This function handles the disconnection of the MQTT client and logs the appropriate
366
+ messages based on the reason code for the disconnection. It also removes the observer
367
+ from the garage to not get any notifications for vehicles being added or removed.
368
+
369
+ Args:
370
+ client: The MQTT client instance that disconnected.
371
+ userdata: The private user data as set in Client() or userdata_set().
372
+ flags: Response flags sent by the broker.
373
+ reason_code: The reason code for the disconnection.
374
+ properties: The properties associated with the disconnection.
375
+
376
+ Returns:
377
+ None
378
+ """
379
+ del client
380
+ del properties
381
+ del flags
382
+
383
+ self._skoda_connector.connected._set_value(value=False) # pylint: disable=protected-access
384
+ self._skoda_connector.car_connectivity.garage.remove_observer(observer=self._on_carconnectivity_vehicle_enabled)
385
+
386
+ self.subscribed_topics.clear()
387
+
388
+ if reason_code == 0:
389
+ LOG.info('Client successfully disconnected')
390
+ elif reason_code == 4:
391
+ LOG.info('Client successfully disconnected: %s', userdata)
392
+ elif reason_code == 128:
393
+ LOG.info('Client disconnected: Needs new access token, trying to reconnect')
394
+ elif reason_code == 137:
395
+ LOG.error('Client disconnected: Server busy')
396
+ elif reason_code == 139:
397
+ LOG.error('Client disconnected: Server shutting down')
398
+ elif reason_code == 160:
399
+ LOG.error('Client disconnected: Maximum connect time')
400
+ else:
401
+ LOG.error('Client unexpectedly disconnected (%d: %s), trying to reconnect', reason_code.value, reason_code.getName())
402
+
403
+ def _on_subscribe_callback(self, client: Client, obj: Any, mid: int, reason_codes: List[ReasonCode], properties: Optional[Properties]) -> None:
404
+ """
405
+ Callback function for MQTT subscription.
406
+
407
+ This method is called when the client receives a SUBACK response from the server.
408
+ It checks the reason codes to determine if the subscription was successful.
409
+
410
+ Args:
411
+ mqttc: The MQTT client instance (unused).
412
+ obj: User-defined data of any type (unused).
413
+ mid: The message ID of the subscribe request.
414
+ reason_codes: A list of reason codes indicating the result of the subscription.
415
+ properties: MQTT v5.0 properties (unused).
416
+
417
+ Returns:
418
+ None
419
+ """
420
+ del client # unused
421
+ del obj # unused
422
+ del properties # unused
423
+ if any(x in [0, 1, 2] for x in reason_codes):
424
+ LOG.debug('sucessfully subscribed to topic of mid %d', mid)
425
+ else:
426
+ LOG.error('Subscribe was not successfull (%s)', ', '.join([reason_code.getName() for reason_code in reason_codes]))
427
+
428
+ def _on_message_callback(self, client: Client, obj: Any, msg: MQTTMessage) -> None: # noqa: C901
429
+ """
430
+ Callback function for handling incoming MQTT messages.
431
+
432
+ This function is called when a message is received on a subscribed topic.
433
+ It logs an error message indicating that the message is not understood.
434
+ In the next step this needs to be implemented with real behaviour.
435
+
436
+ Args:
437
+ mqttc: The MQTT client instance (unused).
438
+ obj: The user data (unused).
439
+ msg: The MQTT message instance containing topic and payload.
440
+
441
+ Returns:
442
+ None
443
+ """
444
+ del client # unused
445
+ del obj # unused
446
+ if len(msg.payload) == 0:
447
+ LOG_API.debug('MQTT topic %s: ignoring empty message', msg.topic)
448
+ return
449
+
450
+ # service_events
451
+ match = re.match(r'^(?P<user_id>[0-9a-fA-F-]+)/(?P<vin>[A-Z0-9]+)/service-event/(?P<service_event>[a-zA-Z0-9-_/]+)$', msg.topic)
452
+ if match:
453
+ user_id: str = match.group('user_id')
454
+ vin: str = match.group('vin')
455
+ service_event: str = match.group('service_event')
456
+ data: Dict[str, Any] = json.loads(msg.payload)
457
+ if data is not None:
458
+ if 'timestamp' in data and data['timestamp'] is not None:
459
+ measured_at: datetime = robust_time_parse(data['timestamp'])
460
+ else:
461
+ measured_at: datetime = datetime.now(tz=timezone.utc)
462
+ if service_event == 'charging':
463
+ if 'name' in data and data['name'] == 'change-charge-mode' or data['name'] == 'change-soc':
464
+ if 'data' in data and data['data'] is not None:
465
+ vehicle: Optional[GenericVehicle] = self._skoda_connector.car_connectivity.garage.get_vehicle(vin)
466
+ if isinstance(vehicle, SkodaElectricVehicle):
467
+ electric_drive: ElectricDrive = vehicle.get_electric_drive()
468
+ if electric_drive is not None:
469
+ charging_state: Optional[Charging.ChargingState] = vehicle.charging.state.value
470
+ old_charging_state: Optional[Charging.ChargingState] = charging_state
471
+ if 'mode' in data['data'] and data['data']['mode'] is not None \
472
+ and vehicle.charging is not None and isinstance(vehicle.charging.settings, SkodaCharging.Settings):
473
+ if data['data']['mode'] in SkodaCharging.SkodaChargeMode:
474
+ skoda_charging_mode = SkodaCharging.SkodaChargeMode(data['data']['mode'])
475
+ else:
476
+ LOG_API.info('Unkown charging mode %s not in %s', data['data']['mode'], str(SkodaCharging.SkodaChargeMode))
477
+ skoda_charging_mode = Charging.ChargingState.UNKNOWN
478
+ # pylint: disable-next=protected-access
479
+ vehicle.charging.settings.preferred_charge_mode._set_value(value=skoda_charging_mode, measured=measured_at)
480
+ if 'state' in data['data'] and data['data']['state'] is not None:
481
+ if data['data']['state'] in [item.value for item in SkodaCharging.SkodaChargingState]:
482
+ skoda_charging_state = SkodaCharging.SkodaChargingState(data['data']['state'])
483
+ charging_state = mapping_skoda_charging_state[skoda_charging_state]
484
+ else:
485
+ LOG_API.info('Unkown charging state %s not in %s', data['data']['state'], str(SkodaCharging.SkodaChargingState))
486
+ charging_state = Charging.ChargingState.UNKNOWN
487
+ # pylint: disable-next=protected-access
488
+ vehicle.charging.state._set_value(value=charging_state, measured=measured_at)
489
+ if charging_state == Charging.ChargingState.OFF:
490
+ # pylint: disable-next=protected-access
491
+ vehicle.charging.type._set_value(value=Charging.ChargingType.OFF, measured=measured_at)
492
+ # pylint: disable-next=protected-access
493
+ vehicle.charging.rate._set_value(value=0, measured=measured_at, unit=Speed.KMH)
494
+ # pylint: disable-next=protected-access
495
+ vehicle.charging.power._set_value(value=0, measured=measured_at, unit=Power.KW)
496
+ if 'soc' in data['data'] and data['data']['soc'] is not None:
497
+ if isinstance(data['data']['soc'], str):
498
+ data['data']['soc'] = int(data['data']['soc'])
499
+ electric_drive.level._set_value(measured=measured_at, value=data['data']['soc']) # pylint: disable=protected-access
500
+ if 'chargedRange' in data['data'] and data['data']['chargedRange'] is not None:
501
+ # pylint: disable-next=protected-access
502
+ electric_drive.range._set_value(measured=measured_at, value=data['data']['chargedRange'], unit=Length.KM)
503
+ # If charging state changed, fetch charging again
504
+ if old_charging_state != charging_state:
505
+ try:
506
+ self._skoda_connector.fetch_charging(vehicle, no_cache=True)
507
+ except CarConnectivityError as e:
508
+ LOG.error('Error while fetching charging: %s', e)
509
+ if 'timeToFinish' in data['data'] and data['data']['timeToFinish'] is not None \
510
+ and vehicle.charging is not None:
511
+ try:
512
+ remaining_duration: Optional[timedelta] = timedelta(minutes=int(data['data']['timeToFinish']))
513
+ estimated_date_reached: Optional[datetime] = measured_at + remaining_duration
514
+ estimated_date_reached = estimated_date_reached.replace(second=0, microsecond=0)
515
+ except ValueError:
516
+ estimated_date_reached: Optional[datetime] = None
517
+ # pylint: disable-next=protected-access
518
+ vehicle.charging.estimated_date_reached._set_value(measured=measured_at, value=estimated_date_reached)
519
+ log_extra_keys(LOG_API, 'data', data['data'], {'vin', 'userId', 'soc', 'chargedRange', 'timeToFinish', 'state', 'mode'})
520
+ LOG.debug('Received %s event for vehicle %s from user %s', data['name'], vin, user_id)
521
+ return
522
+ else:
523
+ LOG.debug('Discarded %s event for vehicle %s from user %s: vehicle is not an electric vehicle', data['name'], vin, user_id)
524
+ LOG_API.info('Received event name %s service event %s for vehicle %s from user %s: %s', data['name'],
525
+ service_event, vin, user_id, msg.payload)
526
+ return
527
+ elif service_event == 'air-conditioning':
528
+ if 'name' in data and data['name'] == 'change-remaining-time':
529
+ if 'data' in data and data['data'] is not None:
530
+ vehicle: Optional[GenericVehicle] = self._skoda_connector.car_connectivity.garage.get_vehicle(vin)
531
+ if isinstance(vehicle, SkodaVehicle):
532
+ try:
533
+ self._skoda_connector.fetch_air_conditioning(vehicle, no_cache=True)
534
+ except CarConnectivityError as e:
535
+ LOG.error('Error while fetching air conditioning: %s', e)
536
+ elif 'name' in data and data['name'] == 'climatisation-completed':
537
+ if 'data' in data and data['data'] is not None:
538
+ vehicle: Optional[GenericVehicle] = self._skoda_connector.car_connectivity.garage.get_vehicle(vin)
539
+ if vehicle is not None and vehicle.climatization is not None:
540
+ # pylint: disable-next=protected-access
541
+ vehicle.climatization.state._set_value(value=Climatization.ClimatizationState.OFF, measured=measured_at)
542
+ # pylint: disable-next=protected-access
543
+ vehicle.climatization.estimated_date_reached._set_value(value=measured_at, measured=measured_at)
544
+ LOG_API.info('Received event name %s service event %s for vehicle %s from user %s: %s', data['name'],
545
+ service_event, vin, user_id, msg.payload)
546
+ return
547
+ elif service_event == 'vehicle-status/access':
548
+ if 'name' in data and data['name'] == 'change-access':
549
+ if 'data' in data and data['data'] is not None:
550
+ vehicle: Optional[GenericVehicle] = self._skoda_connector.car_connectivity.garage.get_vehicle(vin)
551
+ if isinstance(vehicle, SkodaVehicle):
552
+ def delayed_access_function(vehicle: SkodaVehicle):
553
+ """
554
+ Function to be executed after a delay of two seconds.
555
+ """
556
+ vin = vehicle.id
557
+ self.delayed_access_function_timers.pop(vin)
558
+ try:
559
+ self._skoda_connector.fetch_vehicle_status(vehicle, no_cache=True)
560
+ except CarConnectivityError as e:
561
+ LOG.error('Error while fetching vehicle status: %s', e)
562
+ if vehicle.capabilities is not None and vehicle.capabilities.enabled \
563
+ and vehicle.capabilities.has_capability('CHARGING') and isinstance(vehicle, SkodaElectricVehicle):
564
+ try:
565
+ self._skoda_connector.fetch_charging(vehicle, no_cache=True)
566
+ except CarConnectivityError as e:
567
+ LOG.error('Error while fetching charging: %s', e)
568
+ if vehicle.capabilities is not None and vehicle.capabilities.enabled \
569
+ and vehicle.capabilities.has_capability('PARKING_POSITION'):
570
+ try:
571
+ self._skoda_connector.fetch_position(vehicle, no_cache=True)
572
+ except CarConnectivityError as e:
573
+ LOG.error('Error while fetching position: %s', e)
574
+ if vehicle.capabilities is not None and vehicle.capabilities.enabled \
575
+ and vehicle.capabilities.has_capability('AIR_CONDITIONING'):
576
+ try:
577
+ self._skoda_connector.fetch_air_conditioning(vehicle, no_cache=True)
578
+ except CarConnectivityError as e:
579
+ LOG.error('Error while fetching air conditioning: %s', e)
580
+
581
+ if vin in self.delayed_access_function_timers:
582
+ self.delayed_access_function_timers[vin].cancel()
583
+ self.delayed_access_function_timers[vin] = threading.Timer(2.0, delayed_access_function, kwargs={'vehicle': vehicle})
584
+ self.delayed_access_function_timers[vin].start()
585
+
586
+ LOG_API.info('Received event name %s service event %s for vehicle %s from user %s: %s', data['name'],
587
+ service_event, vin, user_id, msg.payload)
588
+ return
589
+ elif service_event == 'vehicle-status/lights':
590
+ if 'name' in data and data['name'] == 'change-lights':
591
+ if 'data' in data and data['data'] is not None:
592
+ vehicle: Optional[GenericVehicle] = self._skoda_connector.car_connectivity.garage.get_vehicle(vin)
593
+ if isinstance(vehicle, SkodaVehicle):
594
+ try:
595
+ self._skoda_connector.fetch_vehicle_status(vehicle, no_cache=True)
596
+ except CarConnectivityError as e:
597
+ LOG.error('Error while fetching vehicle status: %s', e)
598
+
599
+ LOG_API.info('Received event name %s service event %s for vehicle %s from user %s: %s', data['name'],
600
+ service_event, vin, user_id, msg.payload)
601
+ return
602
+ LOG_API.info('Received unknown service event %s for vehicle %s from user %s: %s', service_event, vin, user_id, msg.payload)
603
+ return
604
+ # operation-requests
605
+ match = re.match(r'^(?P<user_id>[0-9a-fA-F-]+)/(?P<vin>[A-Z0-9]+)/operation-request/(?P<operation_request>[a-zA-Z0-9-_/]+)$', msg.topic)
606
+ if match:
607
+ user_id: str = match.group('user_id')
608
+ vin: str = match.group('vin')
609
+ operation_request: str = match.group('operation_request')
610
+ data: Dict[str, Any] = json.loads(msg.payload)
611
+ if data is not None:
612
+ vehicle: Optional[GenericVehicle] = self._skoda_connector.car_connectivity.garage.get_vehicle(vin)
613
+ if operation_request == 'air-conditioning/set-air-conditioning-at-unlock' \
614
+ or operation_request == 'air-conditioning/set-air-conditioning-seats-heating' \
615
+ or operation_request == 'air-conditioning/set-air-conditioning-timers' \
616
+ or operation_request == 'air-conditioning/set-air-conditioning-without-external-power' \
617
+ or operation_request == 'air-conditioning/set-target-temperature' \
618
+ or operation_request == 'air-conditioning/start-stop-air-conditioning' \
619
+ or operation_request == 'air-conditioning/start-stop-window-heating' \
620
+ or operation_request == 'air-conditioning/windows-heating':
621
+ if isinstance(vehicle, SkodaVehicle):
622
+ if 'status' in data and data['status'] is not None:
623
+ if data['status'] == 'COMPLETED_SUCCESS':
624
+ LOG.debug('Received %s operation request for vehicle %s from user %s', operation_request, vin, user_id)
625
+ try:
626
+ self._skoda_connector.fetch_air_conditioning(vehicle, no_cache=True)
627
+ except CarConnectivityError as e:
628
+ LOG.error('Error while fetching air-conditioning: %s', e)
629
+ return
630
+ elif data['status'] == 'IN_PROGRESS':
631
+ LOG.debug('Received %s operation request for vehicle %s from user %s', operation_request, vin, user_id)
632
+ return
633
+ elif operation_request == 'charging/start-stop-charging' \
634
+ or operation_request == 'charging/update-battery-support' \
635
+ or operation_request == 'charging/update-auto-unlock-plug' \
636
+ or operation_request == 'charging/update-care-mode' \
637
+ or operation_request == 'charging/update-charge-limit' \
638
+ or operation_request == 'charging/update-charge-mode' \
639
+ or operation_request == 'charging/update-charging-profiles' \
640
+ or operation_request == 'charging/update-charging-current':
641
+ if isinstance(vehicle, SkodaElectricVehicle):
642
+ if 'status' in data and data['status'] is not None:
643
+ if data['status'] == 'COMPLETED_SUCCESS':
644
+ LOG.debug('Received %s operation request for vehicle %s from user %s', operation_request, vin, user_id)
645
+ try:
646
+ self._skoda_connector.fetch_charging(vehicle, no_cache=True)
647
+ except CarConnectivityError as e:
648
+ LOG.error('Error while fetching charging: %s', e)
649
+ return
650
+ elif data['status'] == 'IN_PROGRESS':
651
+ LOG.debug('Received %s operation request for vehicle %s from user %s', operation_request, vin, user_id)
652
+ return
653
+ LOG_API.info('Received unknown operation request %s for vehicle %s from user %s: %s', operation_request, vin, user_id, msg.payload)
654
+ return
655
+ LOG_API.info('I don\'t understand message %s: %s', msg.topic, msg.payload)