carconnectivity-connector-skoda 0.1a1__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,597 @@
1
+ """Module implements the connector to interact with the Skoda API."""
2
+ from __future__ import annotations
3
+ from typing import TYPE_CHECKING
4
+
5
+ import threading
6
+ import os
7
+ import logging
8
+ import netrc
9
+ from datetime import datetime, timedelta
10
+ import requests
11
+
12
+ from carconnectivity.garage import Garage
13
+ from carconnectivity.vehicle import GenericVehicle
14
+ from carconnectivity.errors import AuthenticationError, TooManyRequestsError, RetrievalError, APIError, APICompatibilityError, \
15
+ TemporaryAuthenticationError, ConfigurationError
16
+ from carconnectivity.util import robust_time_parse, log_extra_keys, config_remove_credentials
17
+ from carconnectivity.units import Length
18
+ from carconnectivity.doors import Doors
19
+ from carconnectivity.windows import Windows
20
+ from carconnectivity.lights import Lights
21
+ from carconnectivity.drive import GenericDrive, ElectricDrive, CombustionDrive
22
+ from carconnectivity.attributes import BooleanAttribute, DurationAttribute, DateAttribute
23
+
24
+ from carconnectivity_connectors.base.connector import BaseConnector
25
+ from carconnectivity_connectors.skoda.auth.session_manager import SessionManager, SessionUser, Service
26
+ from carconnectivity_connectors.skoda.vehicle import SkodaVehicle, SkodaElectricVehicle, SkodaCombustionVehicle, SkodaHybridVehicle
27
+ from carconnectivity_connectors.skoda.capability import Capability
28
+ from carconnectivity_connectors.skoda._version import __version__
29
+
30
+ if TYPE_CHECKING:
31
+ from typing import Dict, List, Optional, Any
32
+
33
+ from requests import Session
34
+
35
+ from carconnectivity.carconnectivity import CarConnectivity
36
+
37
+ LOG: logging.Logger = logging.getLogger("carconnectivity.connectors.skoda")
38
+ LOG_API: logging.Logger = logging.getLogger("carconnectivity.connectors.skoda-api-debug")
39
+
40
+
41
+ class Connector(BaseConnector):
42
+ """
43
+ Connector class for Skoda API connectivity.
44
+ Args:
45
+ car_connectivity (CarConnectivity): An instance of CarConnectivity.
46
+ config (Dict): Configuration dictionary containing connection details.
47
+ Attributes:
48
+ max_age (Optional[int]): Maximum age for cached data in seconds.
49
+ """
50
+ def __init__(self, connector_id: str, car_connectivity: CarConnectivity, config: Dict) -> None:
51
+ BaseConnector.__init__(self, connector_id=connector_id, car_connectivity=car_connectivity, config=config)
52
+
53
+ self._background_thread: Optional[threading.Thread] = None
54
+ self._stop_event = threading.Event()
55
+
56
+ self.connected: BooleanAttribute = BooleanAttribute(name="connected", parent=self)
57
+ self.interval: DurationAttribute = DurationAttribute(name="interval", parent=self)
58
+ self.last_update: DateAttribute = DateAttribute(name="last_update", parent=self)
59
+
60
+ # Configure logging
61
+ if 'log_level' in config and config['log_level'] is not None:
62
+ config['log_level'] = config['log_level'].upper()
63
+ if config['log_level'] in logging.getLevelNamesMapping():
64
+ LOG.setLevel(config['log_level'])
65
+ self.log_level._set_value(config['log_level']) # pylint: disable=protected-access
66
+ logging.getLogger('requests').setLevel(config['log_level'])
67
+ logging.getLogger('urllib3').setLevel(config['log_level'])
68
+ logging.getLogger('oauthlib').setLevel(config['log_level'])
69
+ else:
70
+ raise ConfigurationError(f'Invalid log level: "{config["log_level"]}" not in {list(logging.getLevelNamesMapping().keys())}')
71
+ if 'api_log_level' in config and config['api_log_level'] is not None:
72
+ config['api_log_level'] = config['api_log_level'].upper()
73
+ if config['api_log_level'] in logging.getLevelNamesMapping():
74
+ LOG_API.setLevel(config['api_log_level'])
75
+ else:
76
+ raise ConfigurationError(f'Invalid log level: "{config["log_level"]}" not in {list(logging.getLevelNamesMapping().keys())}')
77
+ LOG.info("Loading skoda connector with config %s", config_remove_credentials(self.config))
78
+
79
+ username: Optional[str] = None
80
+ password: Optional[str] = None
81
+ if 'username' in self.config and 'password' in self.config:
82
+ username = self.config['username']
83
+ password = self.config['password']
84
+ else:
85
+ if 'netrc' in self.config:
86
+ netrc_filename: str = self.config['netrc']
87
+ else:
88
+ netrc_filename = os.path.join(os.path.expanduser("~"), ".netrc")
89
+ try:
90
+ secrets = netrc.netrc(file=netrc_filename)
91
+ secret: tuple[str, str, str] | None = secrets.authenticators("skoda")
92
+ if secret is None:
93
+ raise AuthenticationError(f'Authentication using {netrc_filename} failed: skoda not found in netrc')
94
+ username, _, password = secret
95
+ except netrc.NetrcParseError as err:
96
+ LOG.error('Authentification using %s failed: %s', netrc_filename, err)
97
+ raise AuthenticationError(f'Authentication using {netrc_filename} failed: {err}') from err
98
+ except TypeError as err:
99
+ if 'username' not in self.config:
100
+ raise AuthenticationError(f'"skoda" entry was not found in {netrc_filename} netrc-file.'
101
+ ' Create it or provide username and password in config') from err
102
+ except FileNotFoundError as err:
103
+ raise AuthenticationError(f'{netrc_filename} netrc-file was not found. Create it or provide username and password in config') from err
104
+
105
+ interval: int = 300
106
+ if 'interval' in self.config:
107
+ interval = self.config['interval']
108
+ if interval < 300:
109
+ raise ValueError('Intervall must be at least 300 seconds')
110
+ self.max_age: int = interval - 1
111
+ if 'max_age' in self.config:
112
+ self.max_age = self.config['max_age']
113
+ self.interval._set_value(timedelta(seconds=interval)) # pylint: disable=protected-access
114
+
115
+ if username is None or password is None:
116
+ raise AuthenticationError('Username or password not provided')
117
+
118
+ self._manager: SessionManager = SessionManager(tokenstore=car_connectivity.get_tokenstore(), cache=car_connectivity.get_cache())
119
+ self._session: Session = self._manager.get_session(Service.MY_SKODA, SessionUser(username=username, password=password))
120
+
121
+ self._elapsed: List[timedelta] = []
122
+
123
+ def startup(self) -> None:
124
+ self._background_thread = threading.Thread(target=self._background_loop, daemon=False)
125
+ self._background_thread.start()
126
+
127
+ def _background_loop(self) -> None:
128
+ self._stop_event.clear()
129
+ while not self._stop_event.is_set():
130
+ interval = 300
131
+ try:
132
+ try:
133
+ self.fetch_all()
134
+ self.last_update._set_value(value=datetime.now()) # pylint: disable=protected-access
135
+ if self.interval.value is not None:
136
+ interval: int = self.interval.value.total_seconds()
137
+ except Exception:
138
+ self.connected._set_value(value=False) # pylint: disable=protected-access
139
+ if self.interval.value is not None:
140
+ interval: int = self.interval.value.total_seconds()
141
+ raise
142
+ except TooManyRequestsError as err:
143
+ LOG.error('Retrieval error during update. Too many requests from your account (%s). Will try again after 15 minutes', str(err))
144
+ self._stop_event.wait(900)
145
+ except RetrievalError as err:
146
+ LOG.error('Retrieval error during update (%s). Will try again after configured interval of %ss', str(err), interval)
147
+ self._stop_event.wait(interval)
148
+ except APICompatibilityError as err:
149
+ LOG.error('API compatability error during update (%s). Will try again after configured interval of %ss', str(err), interval)
150
+ self._stop_event.wait(interval)
151
+ except TemporaryAuthenticationError as err:
152
+ LOG.error('Temporary authentification error during update (%s). Will try again after configured interval of %ss', str(err), interval)
153
+ self._stop_event.wait(interval)
154
+ else:
155
+ self.connected._set_value(value=True) # pylint: disable=protected-access
156
+ self._stop_event.wait(interval)
157
+
158
+ def persist(self) -> None:
159
+ """
160
+ Persists the current state using the manager's persist method.
161
+
162
+ This method calls the `persist` method of the `_manager` attribute to save the current state.
163
+ """
164
+ self._manager.persist()
165
+ return
166
+
167
+ def shutdown(self) -> None:
168
+ """
169
+ Shuts down the connector by persisting current state, closing the session,
170
+ and cleaning up resources.
171
+
172
+ This method performs the following actions:
173
+ 1. Persists the current state.
174
+ 2. Closes the session.
175
+ 3. Sets the session and manager to None.
176
+ 4. Calls the shutdown method of the base connector.
177
+ """
178
+ # Disable and remove all vehicles managed soley by this connector
179
+ for vehicle in self.car_connectivity.garage.list_vehicles():
180
+ if len(vehicle.managing_connectors) == 1 and self in vehicle.managing_connectors:
181
+ self.car_connectivity.garage.remove_vehicle(vehicle.id)
182
+ vehicle.enabled = False
183
+ self._stop_event.set()
184
+ if self._background_thread is not None:
185
+ self._background_thread.join()
186
+ self.persist()
187
+ self._session.close()
188
+ return super().shutdown()
189
+
190
+ def fetch_all(self) -> None:
191
+ """
192
+ Fetches all necessary data for the connector.
193
+
194
+ This method calls the `fetch_vehicles` method to retrieve vehicle data.
195
+ """
196
+ self.fetch_vehicles()
197
+ self.car_connectivity.transaction_end()
198
+
199
+ def fetch_vehicles(self) -> None:
200
+ """
201
+ Fetches the list of vehicles from the Skoda Connect API and updates the garage with new vehicles.
202
+ This method sends a request to the Skoda Connect API to retrieve the list of vehicles associated with the user's account.
203
+ If new vehicles are found in the response, they are added to the garage.
204
+
205
+ Returns:
206
+ None
207
+ """
208
+ garage: Garage = self.car_connectivity.garage
209
+ url = 'https://mysmob.api.connect.skoda-auto.cz/api/v2/garage'
210
+ data: Dict[str, Any] | None = self._fetch_data(url, session=self._session)
211
+ seen_vehicle_vins: set[str] = set()
212
+ if data is not None:
213
+ if 'vehicles' in data and data['vehicles'] is not None:
214
+ for vehicle_dict in data['vehicles']:
215
+ if 'vin' in vehicle_dict and vehicle_dict['vin'] is not None:
216
+ seen_vehicle_vins.add(vehicle_dict['vin'])
217
+ vehicle: Optional[SkodaVehicle] = garage.get_vehicle(vehicle_dict['vin']) # pyright: ignore[reportAssignmentType]
218
+ if not vehicle:
219
+ vehicle = SkodaVehicle(vin=vehicle_dict['vin'], garage=garage)
220
+ garage.add_vehicle(vehicle_dict['vin'], vehicle)
221
+
222
+ if 'licensePlate' in vehicle_dict and vehicle_dict['licensePlate'] is not None:
223
+ vehicle.license_plate._set_value(vehicle_dict['licensePlate']) # pylint: disable=protected-access
224
+ else:
225
+ vehicle.license_plate._set_value(None) # pylint: disable=protected-access
226
+
227
+ log_extra_keys(LOG_API, 'vehicles', vehicle_dict, {'vin', 'licensePlate'})
228
+ self.fetch_vehicle_status(vehicle)
229
+ else:
230
+ raise APIError('Could not parse vehicle, vin missing')
231
+ for vin in set(garage.list_vehicle_vins()) - seen_vehicle_vins:
232
+ vehicle_to_remove = garage.get_vehicle(vin)
233
+ if vehicle_to_remove is not None and vehicle_to_remove.is_managed_by_connector(self):
234
+ garage.remove_vehicle(vin)
235
+
236
+ def fetch_vehicle_status(self, vehicle: SkodaVehicle) -> None:
237
+ """
238
+ Fetches the status of a vehicle from the Skoda API.
239
+
240
+ Args:
241
+ vehicle (GenericVehicle): The vehicle object containing the VIN.
242
+
243
+ Returns:
244
+ None
245
+ """
246
+ vin = vehicle.vin.value
247
+ if vin is None:
248
+ raise APIError('VIN is missing')
249
+ url = f'https://mysmob.api.connect.skoda-auto.cz/api/v2/garage/vehicles/{vin}?' \
250
+ 'connectivityGenerations=MOD1&connectivityGenerations=MOD2&connectivityGenerations=MOD3&connectivityGenerations=MOD4'
251
+ vehicle_data: Dict[str, Any] | None = self._fetch_data(url, self._session)
252
+ if vehicle_data:
253
+ if 'softwareVersion' in vehicle_data and vehicle_data['softwareVersion'] is not None:
254
+ vehicle.software.version._set_value(vehicle_data['softwareVersion']) # pylint: disable=protected-access
255
+ else:
256
+ vehicle.software.version._set_value(None) # pylint: disable=protected-access
257
+ if 'capabilities' in vehicle_data and vehicle_data['capabilities'] is not None:
258
+ if 'capabilities' in vehicle_data['capabilities'] and vehicle_data['capabilities']['capabilities'] is not None:
259
+ found_capabilities = set()
260
+ for capability_dict in vehicle_data['capabilities']['capabilities']:
261
+ if 'id' in capability_dict and capability_dict['id'] is not None:
262
+ capability_id = capability_dict['id']
263
+ found_capabilities.add(capability_id)
264
+ if vehicle.capabilities.has_capability(capability_id):
265
+ capability: Capability = vehicle.capabilities.get_capability(capability_id) # pyright: ignore[reportAssignmentType]
266
+ else:
267
+ capability = Capability(capability_id=capability_id, capabilities=vehicle.capabilities)
268
+ vehicle.capabilities.add_capability(capability_id, capability)
269
+ else:
270
+ raise APIError('Could not parse capability, id missing')
271
+ for capability_id in vehicle.capabilities.capabilities.keys() - found_capabilities:
272
+ vehicle.capabilities.remove_capability(capability_id)
273
+ else:
274
+ vehicle.capabilities.clear_capabilities()
275
+ else:
276
+ vehicle.capabilities.clear_capabilities()
277
+
278
+ if 'specification' in vehicle_data and vehicle_data['specification'] is not None:
279
+ if 'model' in vehicle_data['specification'] and vehicle_data['specification']['model'] is not None:
280
+ vehicle.model._set_value(vehicle_data['specification']['model']) # pylint: disable=protected-access
281
+ else:
282
+ vehicle.model._set_value(None) # pylint: disable=protected-access
283
+ log_extra_keys(LOG_API, 'specification', vehicle_data['specification'], {'model'})
284
+ else:
285
+ vehicle.model._set_value(None) # pylint: disable=protected-access
286
+ log_extra_keys(LOG_API, 'api/v2/garage/vehicles/VIN', vehicle_data, {'softwareVersion'})
287
+
288
+ url = f'https://mysmob.api.connect.skoda-auto.cz/api/v2/vehicle-status/{vin}/driving-range'
289
+ range_data: Dict[str, Any] | None = self._fetch_data(url, self._session)
290
+ if range_data:
291
+ captured_at: datetime = robust_time_parse(range_data['carCapturedTimestamp'])
292
+ # Check vehicle type and if it does not match the current vehicle type, create a new vehicle object using copy constructor
293
+ if 'carType' in range_data and range_data['carType'] is not None:
294
+ try:
295
+ car_type = GenericVehicle.Type(range_data['carType'])
296
+ if car_type == GenericVehicle.Type.ELECTRIC and not isinstance(vehicle, SkodaElectricVehicle):
297
+ LOG.debug('Promoting %s to SkodaElectricVehicle object for %s', vehicle.__class__.__name__, vin)
298
+ vehicle = SkodaElectricVehicle(origin=vehicle)
299
+ self.car_connectivity.garage.replace_vehicle(vin, vehicle)
300
+ elif car_type in [GenericVehicle.Type.FUEL,
301
+ GenericVehicle.Type.GASOLINE,
302
+ GenericVehicle.Type.PETROL,
303
+ GenericVehicle.Type.DIESEL,
304
+ GenericVehicle.Type.CNG,
305
+ GenericVehicle.Type.LPG] \
306
+ and not isinstance(vehicle, SkodaCombustionVehicle):
307
+ LOG.debug('Promoting %s to SkodaCombustionVehicle object for %s', vehicle.__class__.__name__, vin)
308
+ vehicle = SkodaCombustionVehicle(origin=vehicle)
309
+ self.car_connectivity.garage.replace_vehicle(vin, vehicle)
310
+ elif car_type == GenericVehicle.Type.HYBRID and not isinstance(vehicle, SkodaHybridVehicle):
311
+ LOG.debug('Promoting %s to SkodaHybridVehicle object for %s', vehicle.__class__.__name__, vin)
312
+ vehicle = SkodaHybridVehicle(origin=vehicle)
313
+ self.car_connectivity.garage.replace_vehicle(vin, vehicle)
314
+ except ValueError:
315
+ LOG_API.warning('Unknown car type %s', range_data['carType'])
316
+ car_type = GenericVehicle.Type.UNKNOWN
317
+ vehicle.type._set_value(car_type) # pylint: disable=protected-access
318
+ if 'totalRangeInKm' in range_data and range_data['totalRangeInKm'] is not None:
319
+ # pylint: disable-next=protected-access
320
+ vehicle.drives.total_range._set_value(value=range_data['totalRangeInKm'], measured=captured_at, unit=Length.KM)
321
+ else:
322
+ vehicle.drives.total_range._set_value(None, measured=captured_at, unit=Length.KM) # pylint: disable=protected-access
323
+
324
+ drive_ids: set[str] = {'primary', 'secondary'}
325
+ for drive_id in drive_ids:
326
+ if f'{drive_id}EngineRange' in range_data and range_data[f'{drive_id}EngineRange'] is not None:
327
+ try:
328
+ engine_type: GenericDrive.Type = GenericDrive.Type(range_data[f'{drive_id}EngineRange']['engineType'])
329
+ except ValueError:
330
+ LOG_API.warning('Unknown engine_type type %s', range_data[f'{drive_id}EngineRange']['engineType'])
331
+ engine_type: GenericDrive.Type = GenericDrive.Type.UNKNOWN
332
+
333
+ if drive_id in vehicle.drives.drives:
334
+ drive: GenericDrive = vehicle.drives.drives[drive_id]
335
+ else:
336
+ if engine_type == GenericDrive.Type.ELECTRIC:
337
+ drive = ElectricDrive(drive_id=drive_id, drives=vehicle.drives)
338
+ elif engine_type in [GenericDrive.Type.FUEL,
339
+ GenericDrive.Type.GASOLINE,
340
+ GenericDrive.Type.PETROL,
341
+ GenericDrive.Type.DIESEL,
342
+ GenericDrive.Type.CNG,
343
+ GenericDrive.Type.LPG]:
344
+ drive = CombustionDrive(drive_id=drive_id, drives=vehicle.drives)
345
+ else:
346
+ drive = GenericDrive(drive_id=drive_id, drives=vehicle.drives)
347
+ drive.type._set_value(engine_type) # pylint: disable=protected-access
348
+ vehicle.drives.add_drive(drive)
349
+ if 'currentSoCInPercent' in range_data[f'{drive_id}EngineRange'] \
350
+ and range_data[f'{drive_id}EngineRange']['currentSoCInPercent'] is not None:
351
+ # pylint: disable-next=protected-access
352
+ drive.level._set_value(value=range_data[f'{drive_id}EngineRange']['currentSoCInPercent'], measured=captured_at)
353
+ elif 'currentFuelLevelInPercent' in range_data[f'{drive_id}EngineRange'] \
354
+ and range_data[f'{drive_id}EngineRange']['currentFuelLevelInPercent'] is not None:
355
+ # pylint: disable-next=protected-access
356
+ drive.level._set_value(value=range_data[f'{drive_id}EngineRange']['currentFuelLevelInPercent'], measured=captured_at)
357
+ else:
358
+ drive.level._set_value(None, measured=captured_at) # pylint: disable=protected-access
359
+ if 'remainingRangeInKm' in range_data[f'{drive_id}EngineRange'] and range_data[f'{drive_id}EngineRange']['remainingRangeInKm'] is not None:
360
+ # pylint: disable-next=protected-access
361
+ drive.range._set_value(value=range_data[f'{drive_id}EngineRange']['remainingRangeInKm'], measured=captured_at, unit=Length.KM)
362
+ else:
363
+ drive.range._set_value(None, measured=captured_at, unit=Length.KM) # pylint: disable=protected-access
364
+
365
+ log_extra_keys(LOG_API, f'{drive_id}EngineRange', range_data[f'{drive_id}EngineRange'], {'engineType',
366
+ 'currentSoCInPercent',
367
+ 'remainingRangeInKm'})
368
+ log_extra_keys(LOG_API, '/api/v2/vehicle-status/{vin}/driving-range', range_data, {'carCapturedTimestamp',
369
+ 'carType',
370
+ 'totalRangeInKm',
371
+ 'primaryEngineRange',
372
+ 'secondaryEngineRange'})
373
+
374
+ url = f'https://api.connect.skoda-auto.cz/api/v2/vehicle-status/{vin}'
375
+ vehicle_status_data: Dict[str, Any] | None = self._fetch_data(url, self._session)
376
+ if vehicle_status_data:
377
+ if 'remote' in vehicle_status_data and vehicle_status_data['remote'] is not None:
378
+ vehicle_status_data = vehicle_status_data['remote']
379
+ if vehicle_status_data:
380
+ if 'capturedAt' in vehicle_status_data and vehicle_status_data['capturedAt'] is not None:
381
+ captured_at: datetime = robust_time_parse(vehicle_status_data['capturedAt'])
382
+ else:
383
+ raise APIError('Could not fetch vehicle status, capturedAt missing')
384
+ if 'mileageInKm' in vehicle_status_data and vehicle_status_data['mileageInKm'] is not None:
385
+ vehicle.odometer._set_value(value=vehicle_status_data['mileageInKm'], measured=captured_at, unit=Length.KM) # pylint: disable=protected-access
386
+ else:
387
+ vehicle.odometer._set_value(value=None, measured=captured_at, unit=Length.KM) # pylint: disable=protected-access
388
+ if 'status' in vehicle_status_data and vehicle_status_data['status'] is not None:
389
+ if 'open' in vehicle_status_data['status'] and vehicle_status_data['status']['open'] is not None:
390
+ if vehicle_status_data['status']['open'] == 'YES':
391
+ vehicle.doors.open_state._set_value(Doors.OpenState.OPEN, measured=captured_at) # pylint: disable=protected-access
392
+ elif vehicle_status_data['status']['open'] == 'NO':
393
+ vehicle.doors.open_state._set_value(Doors.OpenState.CLOSED, measured=captured_at) # pylint: disable=protected-access
394
+ else:
395
+ vehicle.doors.open_state._set_value(Doors.OpenState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
396
+ LOG_API.info('Unknown door open state: %s', vehicle_status_data['status']['open'])
397
+ else:
398
+ vehicle.doors.open_state._set_value(None, measured=captured_at) # pylint: disable=protected-access
399
+ if 'locked' in vehicle_status_data['status'] and vehicle_status_data['status']['locked'] is not None:
400
+ if vehicle_status_data['status']['locked'] == 'YES':
401
+ vehicle.doors.lock_state._set_value(Doors.LockState.LOCKED, measured=captured_at) # pylint: disable=protected-access
402
+ elif vehicle_status_data['status']['locked'] == 'NO':
403
+ vehicle.doors.lock_state._set_value(Doors.LockState.UNLOCKED, measured=captured_at) # pylint: disable=protected-access
404
+ else:
405
+ vehicle.doors.lock_state._set_value(Doors.LockState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
406
+ LOG_API.info('Unknown door lock state: %s', vehicle_status_data['status']['locked'])
407
+ else:
408
+ vehicle.doors.lock_state._set_value(None, measured=captured_at) # pylint: disable=protected-access
409
+ else:
410
+ vehicle.doors.open_state._set_value(None, measured=captured_at) # pylint: disable=protected-access
411
+ vehicle.doors.lock_state._set_value(None, measured=captured_at) # pylint: disable=protected-access
412
+ if 'doors' in vehicle_status_data and vehicle_status_data['doors'] is not None:
413
+ seen_door_ids: set[str] = set()
414
+ for door_status in vehicle_status_data['doors']:
415
+ if 'name' in door_status and door_status['name'] is not None:
416
+ door_id = door_status['name']
417
+ seen_door_ids.add(door_id)
418
+ if door_id in vehicle.doors.doors:
419
+ door: Doors.Door = vehicle.doors.doors[door_id]
420
+ else:
421
+ door = Doors.Door(door_id=door_id, doors=vehicle.doors)
422
+ vehicle.doors.doors[door_id] = door
423
+ if 'status' in door_status and door_status['status'] is not None:
424
+ if door_status['status'] == 'OPEN':
425
+ door.lock_state._set_value(Doors.LockState.UNLOCKED, measured=captured_at) # pylint: disable=protected-access
426
+ door.open_state._set_value(Doors.OpenState.OPEN, measured=captured_at) # pylint: disable=protected-access
427
+ elif door_status['status'] == 'CLOSED':
428
+ door.lock_state._set_value(Doors.LockState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
429
+ door.open_state._set_value(Doors.OpenState.CLOSED, measured=captured_at) # pylint: disable=protected-access
430
+ elif door_status['status'] == 'LOCKED':
431
+ door.lock_state._set_value(Doors.LockState.LOCKED, measured=captured_at) # pylint: disable=protected-access
432
+ door.open_state._set_value(Doors.OpenState.CLOSED, measured=captured_at) # pylint: disable=protected-access
433
+ elif door_status['status'] == 'UNSUPPORTED':
434
+ door.lock_state._set_value(Doors.LockState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
435
+ door.open_state._set_value(Doors.OpenState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
436
+ else:
437
+ LOG_API.info('Unknown door status %s', door_status['status'])
438
+ door.lock_state._set_value(Doors.LockState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
439
+ door.open_state._set_value(Doors.OpenState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
440
+ else:
441
+ door.lock_state._set_value(None, measured=captured_at) # pylint: disable=protected-access
442
+ door.open_state._set_value(None, measured=captured_at) # pylint: disable=protected-access
443
+ else:
444
+ raise APIError('Could not parse door, name missing')
445
+ log_extra_keys(LOG_API, 'doors', door_status, {'name', 'status'})
446
+ for door_to_remove in set(vehicle.doors.doors) - seen_door_ids:
447
+ vehicle.doors.doors[door_to_remove].enabled = False
448
+ vehicle.doors.doors.pop(door_to_remove)
449
+ log_extra_keys(LOG_API, 'status', vehicle_status_data['status'], {'open', 'locked'})
450
+ else:
451
+ vehicle.doors.open_state._set_value(None, measured=captured_at) # pylint: disable=protected-access
452
+ vehicle.doors.doors = {}
453
+ if 'windows' in vehicle_status_data and vehicle_status_data['windows'] is not None:
454
+ seen_window_ids: set[str] = set()
455
+ all_windows_closed: bool = True
456
+ for window_status in vehicle_status_data['windows']:
457
+ if 'name' in window_status and window_status['name'] is not None:
458
+ window_id = window_status['name']
459
+ seen_window_ids.add(window_id)
460
+ if window_id in vehicle.windows.windows:
461
+ window: Windows.Window = vehicle.windows.windows[window_id]
462
+ else:
463
+ window = Windows.Window(window_id=window_id, windows=vehicle.windows)
464
+ vehicle.windows.windows[window_id] = window
465
+ if 'status' in window_status and window_status['status'] is not None:
466
+ if window_status['status'] == 'OPEN':
467
+ all_windows_closed = False
468
+ window.open_state._set_value(Windows.OpenState.OPEN, measured=captured_at) # pylint: disable=protected-access
469
+ elif window_status['status'] == 'CLOSED':
470
+ window.open_state._set_value(Windows.OpenState.CLOSED, measured=captured_at) # pylint: disable=protected-access
471
+ elif window_status['status'] == 'UNSUPPORTED':
472
+ window.open_state._set_value(Windows.OpenState.UNSUPPORTED, measured=captured_at) # pylint: disable=protected-access
473
+ elif window_status['status'] == 'INVALID':
474
+ window.open_state._set_value(Windows.OpenState.INVALID, measured=captured_at) # pylint: disable=protected-access
475
+ else:
476
+ LOG_API.info('Unknown window status %s', window_status['status'])
477
+ window.open_state._set_value(Windows.OpenState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
478
+ else:
479
+ window.open_state._set_value(None, measured=captured_at) # pylint: disable=protected-access
480
+ else:
481
+ raise APIError('Could not parse window, name missing')
482
+ log_extra_keys(LOG_API, 'doors', window_status, {'name', 'status'})
483
+ for window_to_remove in set(vehicle.windows.windows) - seen_window_ids:
484
+ vehicle.windows.windows[window_to_remove].enabled = False
485
+ vehicle.windows.windows.pop(window_to_remove)
486
+ if all_windows_closed:
487
+ vehicle.windows.open_state._set_value(Windows.OpenState.CLOSED, measured=captured_at) # pylint: disable=protected-access
488
+ else:
489
+ vehicle.windows.open_state._set_value(Windows.OpenState.OPEN, measured=captured_at) # pylint: disable=protected-access
490
+ else:
491
+ vehicle.windows.open_state._set_value(None, measured=captured_at) # pylint: disable=protected-access
492
+ vehicle.windows.windows = {}
493
+ if 'lights' in vehicle_status_data and vehicle_status_data['lights'] is not None:
494
+ seen_light_ids: set[str] = set()
495
+ if 'overallStatus' in vehicle_status_data['lights'] and vehicle_status_data['lights']['overallStatus'] is not None:
496
+ if vehicle_status_data['lights']['overallStatus'] == 'ON':
497
+ vehicle.lights.light_state._set_value(Lights.LightState.ON, measured=captured_at) # pylint: disable=protected-access
498
+ elif vehicle_status_data['lights']['overallStatus'] == 'OFF':
499
+ vehicle.lights.light_state._set_value(Lights.LightState.OFF, measured=captured_at) # pylint: disable=protected-access
500
+ elif vehicle_status_data['lights']['overallStatus'] == 'INVALID':
501
+ vehicle.lights.light_state._set_value(Lights.LightState.INVALID, measured=captured_at) # pylint: disable=protected-access
502
+ else:
503
+ LOG_API.info('Unknown light status %s', vehicle_status_data['lights']['overallStatus'])
504
+ vehicle.lights.light_state._set_value(Lights.LightState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
505
+ else:
506
+ vehicle.lights.light_state._set_value(None, measured=captured_at) # pylint: disable=protected-access
507
+ if 'lightsStatus' in vehicle_status_data['lights'] and vehicle_status_data['lights']['lightsStatus'] is not None:
508
+ for light_status in vehicle_status_data['lights']['lightsStatus']:
509
+ if 'name' in light_status and light_status['name'] is not None:
510
+ light_id: str = light_status['name']
511
+ seen_light_ids.add(light_id)
512
+ if light_id in vehicle.lights.lights:
513
+ light: Lights.Light = vehicle.lights.lights[light_id]
514
+ else:
515
+ light = Lights.Light(light_id=light_id, lights=vehicle.lights)
516
+ vehicle.lights.lights[light_id] = light
517
+ if 'status' in light_status and light_status['status'] is not None:
518
+ if light_status['status'] == 'ON':
519
+ light.light_state._set_value(Lights.LightState.ON, measured=captured_at) # pylint: disable=protected-access
520
+ elif light_status['status'] == 'OFF':
521
+ light.light_state._set_value(Lights.LightState.OFF, measured=captured_at) # pylint: disable=protected-access
522
+ elif light_status['status'] == 'INVALID':
523
+ light.light_state._set_value(Lights.LightState.INVALID, measured=captured_at) # pylint: disable=protected-access
524
+ else:
525
+ LOG_API.info('Unknown light status %s', light_status['status'])
526
+ light.light_state._set_value(Lights.LightState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
527
+ else:
528
+ light.light_state._set_value(None, measured=captured_at) # pylint: disable=protected-access
529
+ else:
530
+ raise APIError('Could not parse light, name missing')
531
+ log_extra_keys(LOG_API, 'lights', light_status, {'name', 'status'})
532
+ for light_to_remove in set(vehicle.lights.lights) - seen_light_ids:
533
+ vehicle.lights.lights[light_to_remove].enabled = False
534
+ vehicle.lights.lights.pop(light_to_remove)
535
+ else:
536
+ vehicle.lights.lights = {}
537
+ log_extra_keys(LOG_API, 'lights', vehicle_status_data['lights'], {'overallStatus', 'lightsStatus'})
538
+ log_extra_keys(LOG_API, 'vehicles', vehicle_status_data, {'capturedAt', 'mileageInKm', 'status', 'doors', 'windows', 'lights'})
539
+
540
+ def _record_elapsed(self, elapsed: timedelta) -> None:
541
+ """
542
+ Records the elapsed time.
543
+
544
+ Args:
545
+ elapsed (timedelta): The elapsed time to record.
546
+ """
547
+ self._elapsed.append(elapsed)
548
+
549
+ def _fetch_data(self, url, session, force=False, allow_empty=False, allow_http_error=False, allowed_errors=None) -> Optional[Dict[str, Any]]: # noqa: C901
550
+ data: Optional[Dict[str, Any]] = None
551
+ cache_date: Optional[datetime] = None
552
+ if not force and (self.max_age is not None and session.cache is not None and url in session.cache):
553
+ data, cache_date_string = session.cache[url]
554
+ cache_date = datetime.fromisoformat(cache_date_string)
555
+ if data is None or self.max_age is None \
556
+ or (cache_date is not None and cache_date < (datetime.utcnow() - timedelta(seconds=self.max_age))):
557
+ try:
558
+ status_response: requests.Response = session.get(url, allow_redirects=False)
559
+ self._record_elapsed(status_response.elapsed)
560
+ if status_response.status_code in (requests.codes['ok'], requests.codes['multiple_status']):
561
+ data = status_response.json()
562
+ if session.cache is not None:
563
+ session.cache[url] = (data, str(datetime.utcnow()))
564
+ elif status_response.status_code == requests.codes['too_many_requests']:
565
+ raise TooManyRequestsError('Could not fetch data due to too many requests from your account. '
566
+ f'Status Code was: {status_response.status_code}')
567
+ elif status_response.status_code == requests.codes['unauthorized']:
568
+ LOG.info('Server asks for new authorization')
569
+ session.login()
570
+ status_response = session.get(url, allow_redirects=False)
571
+
572
+ if status_response.status_code in (requests.codes['ok'], requests.codes['multiple_status']):
573
+ data = status_response.json()
574
+ if session.cache is not None:
575
+ session.cache[url] = (data, str(datetime.utcnow()))
576
+ elif not allow_http_error or (allowed_errors is not None and status_response.status_code not in allowed_errors):
577
+ raise RetrievalError(f'Could not fetch data even after re-authorization. Status Code was: {status_response.status_code}')
578
+ elif not allow_http_error or (allowed_errors is not None and status_response.status_code not in allowed_errors):
579
+ raise RetrievalError(f'Could not fetch data. Status Code was: {status_response.status_code}')
580
+ except requests.exceptions.ConnectionError as connection_error:
581
+ raise RetrievalError(f'Connection error: {connection_error}.'
582
+ ' If this happens frequently, please check if other applications communicate with the Skoda server.') from connection_error
583
+ except requests.exceptions.ChunkedEncodingError as chunked_encoding_error:
584
+ raise RetrievalError(f'Error: {chunked_encoding_error}') from chunked_encoding_error
585
+ except requests.exceptions.ReadTimeout as timeout_error:
586
+ raise RetrievalError(f'Timeout during read: {timeout_error}') from timeout_error
587
+ except requests.exceptions.RetryError as retry_error:
588
+ raise RetrievalError(f'Retrying failed: {retry_error}') from retry_error
589
+ except requests.exceptions.JSONDecodeError as json_error:
590
+ if allow_empty:
591
+ data = None
592
+ else:
593
+ raise RetrievalError(f'JSON decode error: {json_error}') from json_error
594
+ return data
595
+
596
+ def get_version(self) -> str:
597
+ return __version__