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.
- carconnectivity_connector_skoda-0.1.dist-info/LICENSE +21 -0
- carconnectivity_connector_skoda-0.1.dist-info/METADATA +123 -0
- carconnectivity_connector_skoda-0.1.dist-info/RECORD +22 -0
- carconnectivity_connector_skoda-0.1.dist-info/WHEEL +5 -0
- carconnectivity_connector_skoda-0.1.dist-info/top_level.txt +1 -0
- carconnectivity_connectors/skoda/__init__.py +0 -0
- carconnectivity_connectors/skoda/_version.py +16 -0
- carconnectivity_connectors/skoda/auth/__init__.py +0 -0
- carconnectivity_connectors/skoda/auth/auth_util.py +141 -0
- carconnectivity_connectors/skoda/auth/helpers/blacklist_retry.py +29 -0
- carconnectivity_connectors/skoda/auth/my_skoda_session.py +224 -0
- carconnectivity_connectors/skoda/auth/openid_session.py +449 -0
- carconnectivity_connectors/skoda/auth/session_manager.py +82 -0
- carconnectivity_connectors/skoda/auth/skoda_web_session.py +239 -0
- carconnectivity_connectors/skoda/capability.py +135 -0
- carconnectivity_connectors/skoda/charging.py +137 -0
- carconnectivity_connectors/skoda/climatization.py +41 -0
- carconnectivity_connectors/skoda/command_impl.py +74 -0
- carconnectivity_connectors/skoda/connector.py +1624 -0
- carconnectivity_connectors/skoda/error.py +53 -0
- carconnectivity_connectors/skoda/mqtt_client.py +655 -0
- carconnectivity_connectors/skoda/vehicle.py +70 -0
|
@@ -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)
|