carconnectivity-connector-seatcupra 0.1a1__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,686 @@
1
+ """Module implements the connector to interact with the Seat/Cupra API."""
2
+ from __future__ import annotations
3
+ from typing import TYPE_CHECKING
4
+
5
+ import threading
6
+
7
+ import json
8
+ import os
9
+ import logging
10
+ import netrc
11
+ from datetime import datetime, timezone, timedelta
12
+ import requests
13
+
14
+ from carconnectivity.garage import Garage
15
+ from carconnectivity.errors import AuthenticationError, TooManyRequestsError, RetrievalError, APIError, APICompatibilityError, \
16
+ TemporaryAuthenticationError, SetterError, CommandError
17
+ from carconnectivity.util import robust_time_parse, log_extra_keys, config_remove_credentials
18
+ from carconnectivity.units import Length, Power, Speed
19
+ from carconnectivity.vehicle import GenericVehicle, ElectricVehicle, CombustionVehicle, HybridVehicle
20
+ from carconnectivity.doors import Doors
21
+ from carconnectivity.windows import Windows
22
+ from carconnectivity.lights import Lights
23
+ from carconnectivity.drive import GenericDrive, ElectricDrive, CombustionDrive
24
+ from carconnectivity.attributes import BooleanAttribute, DurationAttribute, GenericAttribute, TemperatureAttribute
25
+ from carconnectivity.units import Temperature
26
+ from carconnectivity.command_impl import ClimatizationStartStopCommand, WakeSleepCommand, HonkAndFlashCommand, LockUnlockCommand, ChargingStartStopCommand
27
+ from carconnectivity.climatization import Climatization
28
+ from carconnectivity.commands import Commands
29
+ from carconnectivity.charging import Charging
30
+ from carconnectivity.position import Position
31
+
32
+ from carconnectivity_connectors.base.connector import BaseConnector
33
+ from carconnectivity_connectors.seatcupra.auth.session_manager import SessionManager, SessionUser, Service
34
+ from carconnectivity_connectors.seatcupra.auth.my_cupra_session import MyCupraSession
35
+ from carconnectivity_connectors.seatcupra._version import __version__
36
+ from carconnectivity_connectors.seatcupra.charging import SeatCupraCharging, mapping_seatcupra_charging_state
37
+
38
+ SUPPORT_IMAGES = False
39
+ try:
40
+ from PIL import Image
41
+ import base64
42
+ import io
43
+ SUPPORT_IMAGES = True
44
+ from carconnectivity.attributes import ImageAttribute
45
+ except ImportError:
46
+ pass
47
+
48
+ if TYPE_CHECKING:
49
+ from typing import Dict, List, Optional, Any, Union
50
+
51
+ from carconnectivity.carconnectivity import CarConnectivity
52
+
53
+ LOG: logging.Logger = logging.getLogger("carconnectivity.connectors.seatcupra")
54
+ LOG_API: logging.Logger = logging.getLogger("carconnectivity.connectors.seatcupra-api-debug")
55
+
56
+
57
+ # pylint: disable=too-many-lines
58
+ class Connector(BaseConnector):
59
+ """
60
+ Connector class for Seat/Cupra API connectivity.
61
+ Args:
62
+ car_connectivity (CarConnectivity): An instance of CarConnectivity.
63
+ config (Dict): Configuration dictionary containing connection details.
64
+ Attributes:
65
+ max_age (Optional[int]): Maximum age for cached data in seconds.
66
+ """
67
+ def __init__(self, connector_id: str, car_connectivity: CarConnectivity, config: Dict) -> None:
68
+ BaseConnector.__init__(self, connector_id=connector_id, car_connectivity=car_connectivity, config=config, log=LOG, api_log=LOG_API)
69
+
70
+ self._background_thread: Optional[threading.Thread] = None
71
+ self._stop_event = threading.Event()
72
+
73
+ self.connected: BooleanAttribute = BooleanAttribute(name="connected", parent=self, tags={'connector_custom'})
74
+ self.interval: DurationAttribute = DurationAttribute(name="interval", parent=self, tags={'connector_custom'})
75
+ self.commands: Commands = Commands(parent=self)
76
+
77
+ LOG.info("Loading seatcupra connector with config %s", config_remove_credentials(config))
78
+
79
+ if 'spin' in config and config['spin'] is not None:
80
+ self.active_config['spin'] = config['spin']
81
+ else:
82
+ self.active_config['spin'] = None
83
+
84
+ self.active_config['username'] = None
85
+ self.active_config['password'] = None
86
+ if 'username' in config and 'password' in config:
87
+ self.active_config['username'] = config['username']
88
+ self.active_config['password'] = config['password']
89
+ else:
90
+ if 'netrc' in config:
91
+ self.active_config['netrc'] = config['netrc']
92
+ else:
93
+ self.active_config['netrc'] = os.path.join(os.path.expanduser("~"), ".netrc")
94
+ try:
95
+ secrets = netrc.netrc(file=self.active_config['netrc'])
96
+ secret: tuple[str, str, str] | None = secrets.authenticators("seatcupra")
97
+ if secret is None:
98
+ raise AuthenticationError(f'Authentication using {self.active_config["netrc"]} failed: seatcupra not found in netrc')
99
+ self.active_config['username'], account, self.active_config['password'] = secret
100
+
101
+ if self.active_config['spin'] is None and account is not None:
102
+ try:
103
+ self.active_config['spin'] = account
104
+ except ValueError as err:
105
+ LOG.error('Could not parse spin from netrc: %s', err)
106
+ except netrc.NetrcParseError as err:
107
+ LOG.error('Authentification using %s failed: %s', self.active_config['netrc'], err)
108
+ raise AuthenticationError(f'Authentication using {self.active_config["netrc"]} failed: {err}') from err
109
+ except TypeError as err:
110
+ if 'username' not in config:
111
+ raise AuthenticationError(f'"seatcupra" entry was not found in {self.active_config["netrc"]} netrc-file.'
112
+ ' Create it or provide username and password in config') from err
113
+ except FileNotFoundError as err:
114
+ raise AuthenticationError(f'{self.active_config["netrc"]} netrc-file was not found. Create it or provide username and password in config') \
115
+ from err
116
+
117
+ self.active_config['interval'] = 300
118
+ if 'interval' in config:
119
+ self.active_config['interval'] = config['interval']
120
+ if self.active_config['interval'] < 180:
121
+ raise ValueError('Intervall must be at least 180 seconds')
122
+ self.active_config['max_age'] = self.active_config['interval'] - 1
123
+ if 'max_age' in config:
124
+ self.active_config['max_age'] = config['max_age']
125
+ self.interval._set_value(timedelta(seconds=self.active_config['interval'])) # pylint: disable=protected-access
126
+
127
+ if self.active_config['username'] is None or self.active_config['password'] is None:
128
+ raise AuthenticationError('Username or password not provided')
129
+
130
+ self._manager: SessionManager = SessionManager(tokenstore=car_connectivity.get_tokenstore(), cache=car_connectivity.get_cache())
131
+ session: requests.Session = self._manager.get_session(Service.MY_CUPRA, SessionUser(username=self.active_config['username'],
132
+ password=self.active_config['password']))
133
+ if not isinstance(session, MyCupraSession):
134
+ raise AuthenticationError('Could not create session')
135
+ self.session: MyCupraSession = session
136
+ self.session.retries = 3
137
+ self.session.timeout = 180
138
+ self.session.refresh()
139
+
140
+ self._elapsed: List[timedelta] = []
141
+
142
+ def startup(self) -> None:
143
+ self._background_thread = threading.Thread(target=self._background_loop, daemon=False)
144
+ self._background_thread.start()
145
+
146
+ def _background_loop(self) -> None:
147
+ self._stop_event.clear()
148
+ fetch: bool = True
149
+ while not self._stop_event.is_set():
150
+ interval = 300
151
+ try:
152
+ try:
153
+ if fetch:
154
+ self.fetch_all()
155
+ fetch = False
156
+ else:
157
+ self.update_vehicles()
158
+ self.last_update._set_value(value=datetime.now(tz=timezone.utc)) # pylint: disable=protected-access
159
+ if self.interval.value is not None:
160
+ interval: float = self.interval.value.total_seconds()
161
+ except Exception:
162
+ if self.interval.value is not None:
163
+ interval: float = self.interval.value.total_seconds()
164
+ raise
165
+ except TooManyRequestsError as err:
166
+ LOG.error('Retrieval error during update. Too many requests from your account (%s). Will try again after 15 minutes', str(err))
167
+ self._stop_event.wait(900)
168
+ except RetrievalError as err:
169
+ LOG.error('Retrieval error during update (%s). Will try again after configured interval of %ss', str(err), interval)
170
+ self._stop_event.wait(interval)
171
+ except APIError as err:
172
+ LOG.error('API error during update (%s). Will try again after configured interval of %ss', str(err), interval)
173
+ self._stop_event.wait(interval)
174
+ except APICompatibilityError as err:
175
+ LOG.error('API compatability error during update (%s). Will try again after configured interval of %ss', str(err), interval)
176
+ self._stop_event.wait(interval)
177
+ except TemporaryAuthenticationError as err:
178
+ LOG.error('Temporary authentification error during update (%s). Will try again after configured interval of %ss', str(err), interval)
179
+ self._stop_event.wait(interval)
180
+ else:
181
+ self._stop_event.wait(interval)
182
+
183
+ def persist(self) -> None:
184
+ """
185
+ Persists the current state using the manager's persist method.
186
+
187
+ This method calls the `persist` method of the `_manager` attribute to save the current state.
188
+ """
189
+ self._manager.persist()
190
+
191
+ def shutdown(self) -> None:
192
+ """
193
+ Shuts down the connector by persisting current state, closing the session,
194
+ and cleaning up resources.
195
+
196
+ This method performs the following actions:
197
+ 1. Persists the current state.
198
+ 2. Closes the session.
199
+ 3. Sets the session and manager to None.
200
+ 4. Calls the shutdown method of the base connector.
201
+
202
+ Returns:
203
+ None
204
+ """
205
+ # Disable and remove all vehicles managed soley by this connector
206
+ for vehicle in self.car_connectivity.garage.list_vehicles():
207
+ if len(vehicle.managing_connectors) == 1 and self in vehicle.managing_connectors:
208
+ self.car_connectivity.garage.remove_vehicle(vehicle.id)
209
+ vehicle.enabled = False
210
+ self._stop_event.set()
211
+ if self._background_thread is not None:
212
+ self._background_thread.join()
213
+ self.persist()
214
+ self.session.close()
215
+ BaseConnector.shutdown(self)
216
+
217
+ def fetch_all(self) -> None:
218
+ """
219
+ Fetches all necessary data for the connector.
220
+
221
+ This method calls the `fetch_vehicles` method to retrieve vehicle data.
222
+ """
223
+ self.fetch_vehicles()
224
+ self.car_connectivity.transaction_end()
225
+
226
+ def update_vehicles(self) -> None:
227
+ """
228
+ Updates the status of all vehicles in the garage managed by this connector.
229
+
230
+ This method iterates through all vehicle VINs in the garage, and for each vehicle that is
231
+ managed by this connector and is an instance of SkodaVehicle, it updates the vehicle's status
232
+ by fetching data from various APIs. If the vehicle is an instance of SkodaElectricVehicle,
233
+ it also fetches charging information.
234
+
235
+ Returns:
236
+ None
237
+ """
238
+ garage: Garage = self.car_connectivity.garage
239
+ for vin in set(garage.list_vehicle_vins()):
240
+ vehicle_to_update: Optional[GenericVehicle] = garage.get_vehicle(vin)
241
+ if vehicle_to_update is not None and vehicle_to_update.is_managed_by_connector(self):
242
+ vehicle_to_update = self.fetch_vehicle_status(vehicle_to_update)
243
+ vehicle_to_update = self.fetch_vehicle_mycar_status(vehicle_to_update)
244
+ # TODO check for parking capability
245
+ vehicle_to_update = self.fetch_parking_position(vehicle_to_update)
246
+
247
+ def fetch_vehicles(self) -> None:
248
+ """
249
+ Fetches the list of vehicles from the Skoda Connect API and updates the garage with new vehicles.
250
+ This method sends a request to the Skoda Connect API to retrieve the list of vehicles associated with the user's account.
251
+ If new vehicles are found in the response, they are added to the garage.
252
+
253
+ Returns:
254
+ None
255
+ """
256
+ garage: Garage = self.car_connectivity.garage
257
+ url = f'https://ola.prod.code.seat.cloud.vwgroup.com/v2/users/{self.session.user_id}/garage/vehicles'
258
+ data: Dict[str, Any] | None = self._fetch_data(url, session=self.session)
259
+
260
+ seen_vehicle_vins: set[str] = set()
261
+ if data is not None:
262
+ if 'vehicles' in data and data['vehicles'] is not None:
263
+ for vehicle_dict in data['vehicles']:
264
+ if 'vin' in vehicle_dict and vehicle_dict['vin'] is not None:
265
+ seen_vehicle_vins.add(vehicle_dict['vin'])
266
+ vehicle: Optional[GenericVehicle] = garage.get_vehicle(vehicle_dict['vin']) # pyright: ignore[reportAssignmentType]
267
+ if vehicle is None:
268
+ vehicle = GenericVehicle(vin=vehicle_dict['vin'], garage=garage, managing_connector=self)
269
+ garage.add_vehicle(vehicle_dict['vin'], vehicle)
270
+
271
+ if 'vehicleNickname' in vehicle_dict and vehicle_dict['vehicleNickname'] is not None:
272
+ vehicle.name._set_value(vehicle_dict['vehicleNickname']) # pylint: disable=protected-access
273
+ else:
274
+ vehicle.name._set_value(None) # pylint: disable=protected-access
275
+
276
+ if 'specifications' in vehicle_dict and vehicle_dict['specifications'] is not None:
277
+ if 'steeringRight' in vehicle_dict['specifications'] and vehicle_dict['specifications']['steeringRight'] is not None:
278
+ if vehicle_dict['specifications']['steeringRight']:
279
+ # pylint: disable-next=protected-access
280
+ vehicle.specification.steering_wheel_position._set_value(GenericVehicle.VehicleSpecification.SteeringPosition.RIGHT)
281
+ else:
282
+ # pylint: disable-next=protected-access
283
+ vehicle.specification.steering_wheel_position._set_value(GenericVehicle.VehicleSpecification.SteeringPosition.LEFT)
284
+ else:
285
+ vehicle.specification.steering_wheel_position._set_value(None) # pylint: disable=protected-access
286
+ if 'factoryModel' in vehicle_dict['specifications'] and vehicle_dict['specifications']['factoryModel'] is not None:
287
+ factory_model: Dict = vehicle_dict['specifications']['factoryModel']
288
+ if 'vehicleBrand' in factory_model and factory_model['vehicleBrand'] is not None:
289
+ vehicle.manufacturer._set_value(factory_model['vehicleBrand']) # pylint: disable=protected-access
290
+ else:
291
+ vehicle.manufacturer._set_value(None) # pylint: disable=protected-access
292
+ if 'vehicleModel' in factory_model and factory_model['vehicleModel'] is not None:
293
+ vehicle.model._set_value(factory_model['vehicleModel']) # pylint: disable=protected-access
294
+ else:
295
+ vehicle.model._set_value(None) # pylint: disable=protected-access
296
+ if 'modYear' in factory_model and factory_model['modYear'] is not None:
297
+ vehicle.model_year._set_value(factory_model['modYear']) # pylint: disable=protected-access
298
+ else:
299
+ vehicle.model_year._set_value(None) # pylint: disable=protected-access
300
+ log_extra_keys(LOG_API, 'factoryModel', factory_model, {'vehicleBrand', 'vehicleModel', 'modYear'})
301
+ log_extra_keys(LOG_API, 'specifications', vehicle_dict['specifications'], {'steeringRight', 'factoryModel'})
302
+
303
+
304
+ #TODO: https://ola.prod.code.seat.cloud.vwgroup.com/vehicles/{{VIN}}/connection
305
+
306
+ #TODO: https://ola.prod.code.seat.cloud.vwgroup.com/v2/vehicles/{{VIN}}/capabilities
307
+ else:
308
+ raise APIError('Could not fetch vehicle data, VIN missing')
309
+ for vin in set(garage.list_vehicle_vins()) - seen_vehicle_vins:
310
+ vehicle_to_remove = garage.get_vehicle(vin)
311
+ if vehicle_to_remove is not None and vehicle_to_remove.is_managed_by_connector(self):
312
+ garage.remove_vehicle(vin)
313
+ self.update_vehicles()
314
+
315
+ def fetch_vehicle_status(self, vehicle: GenericVehicle, no_cache: bool = False) -> GenericVehicle:
316
+ """
317
+ Fetches the status of a vehicle from seat/cupra API.
318
+
319
+ Args:
320
+ vehicle (GenericVehicle): The vehicle object containing the VIN.
321
+
322
+ Returns:
323
+ None
324
+ """
325
+ vin = vehicle.vin.value
326
+ if vin is None:
327
+ raise APIError('VIN is missing')
328
+ url = f'https://ola.prod.code.seat.cloud.vwgroup.com/v2/vehicles/{vin}/status'
329
+ vehicle_status_data: Dict[str, Any] | None = self._fetch_data(url=url, session=self.session, no_cache=no_cache)
330
+ if vehicle_status_data:
331
+ if 'updatedAt' in vehicle_status_data and vehicle_status_data['updatedAt'] is not None:
332
+ captured_at: Optional[datetime] = robust_time_parse(vehicle_status_data['updatedAt'])
333
+ else:
334
+ captured_at: Optional[datetime] = None
335
+ if 'locked' in vehicle_status_data and vehicle_status_data['locked'] is not None:
336
+ if vehicle_status_data['locked']:
337
+ vehicle.doors.lock_state._set_value(Doors.LockState.LOCKED, measured=captured_at) # pylint: disable=protected-access
338
+ else:
339
+ vehicle.doors.lock_state._set_value(Doors.LockState.UNLOCKED, measured=captured_at) # pylint: disable=protected-access
340
+ if 'lights' in vehicle_status_data and vehicle_status_data['lights'] is not None:
341
+ if vehicle_status_data['lights'] == 'on':
342
+ vehicle.lights.light_state._set_value(Lights.LightState.ON, measured=captured_at) # pylint: disable=protected-access
343
+ elif vehicle_status_data['lights'] == 'off':
344
+ vehicle.lights.light_state._set_value(Lights.LightState.OFF, measured=captured_at) # pylint: disable=protected-access
345
+ else:
346
+ vehicle.lights.light_state._set_value(Lights.LightState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
347
+ LOG_API.info('Unknown lights state %s', vehicle_status_data['lights'])
348
+ else:
349
+ vehicle.lights.light_state._set_value(None) # pylint: disable=protected-access
350
+
351
+ if 'hood' in vehicle_status_data and vehicle_status_data['hood'] is not None:
352
+ vehicle_status_data['doors']['hood'] = vehicle_status_data['hood']
353
+ if 'trunk' in vehicle_status_data and vehicle_status_data['trunk'] is not None:
354
+ vehicle_status_data['doors']['trunk'] = vehicle_status_data['trunk']
355
+
356
+ if 'doors' in vehicle_status_data and vehicle_status_data['doors'] is not None:
357
+ all_doors_closed = True
358
+ seen_door_ids: set[str] = set()
359
+ for door_id, door_status in vehicle_status_data['doors'].items():
360
+ seen_door_ids.add(door_id)
361
+ if door_id in vehicle.doors.doors:
362
+ door: Doors.Door = vehicle.doors.doors[door_id]
363
+ else:
364
+ door = Doors.Door(door_id=door_id, doors=vehicle.doors)
365
+ vehicle.doors.doors[door_id] = door
366
+ if 'open' in door_status and door_status['open'] is not None:
367
+ if door_status['open'] == 'true':
368
+ door.open_state._set_value(Doors.OpenState.OPEN, measured=captured_at) # pylint: disable=protected-access
369
+ all_doors_closed = False
370
+ elif door_status['open'] == 'false':
371
+ door.open_state._set_value(Doors.OpenState.CLOSED, measured=captured_at) # pylint: disable=protected-access
372
+ else:
373
+ door.open_state._set_value(Doors.OpenState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
374
+ LOG_API.info('Unknown door open state %s', door_status['open'])
375
+ else:
376
+ door.open_state._set_value(None) # pylint: disable=protected-access
377
+ if 'locked' in door_status and door_status['locked'] is not None:
378
+ if door_status['locked'] == 'true':
379
+ door.lock_state._set_value(Doors.LockState.LOCKED, measured=captured_at) # pylint: disable=protected-access
380
+ elif door_status['locked'] == 'false':
381
+ door.lock_state._set_value(Doors.LockState.UNLOCKED, measured=captured_at) # pylint: disable=protected-access
382
+ else:
383
+ door.lock_state._set_value(Doors.LockState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
384
+ LOG_API.info('Unknown door lock state %s', door_status['locked'])
385
+ else:
386
+ door.lock_state._set_value(None) # pylint: disable=protected-access
387
+ log_extra_keys(LOG_API, 'door', door_status, {'open', 'locked'})
388
+ for door_id in vehicle.doors.doors.keys() - seen_door_ids:
389
+ vehicle.doors.doors[door_id].enabled = False
390
+ if all_doors_closed:
391
+ vehicle.doors.open_state._set_value(Doors.OpenState.CLOSED, measured=captured_at) # pylint: disable=protected-access
392
+ else:
393
+ vehicle.doors.open_state._set_value(Doors.OpenState.OPEN, measured=captured_at) # pylint: disable=protected-access
394
+ seen_window_ids: set[str] = set()
395
+ if 'windows' in vehicle_status_data and vehicle_status_data['windows'] is not None:
396
+ all_windows_closed = True
397
+ for window_id, window_status in vehicle_status_data['windows'].items():
398
+ seen_window_ids.add(window_id)
399
+ if window_id in vehicle.windows.windows:
400
+ window: Windows.Window = vehicle.windows.windows[window_id]
401
+ else:
402
+ window = Windows.Window(window_id=window_id, windows=vehicle.windows)
403
+ vehicle.windows.windows[window_id] = window
404
+ if window_status in Windows.OpenState:
405
+ open_state: Windows.OpenState = Windows.OpenState(window_status)
406
+ if open_state == Windows.OpenState.OPEN:
407
+ all_windows_closed = False
408
+ window.open_state._set_value(open_state, measured=captured_at) # pylint: disable=protected-access
409
+ else:
410
+ LOG_API.info('Unknown window status %s', window_status)
411
+ window.open_state._set_value(Windows.OpenState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
412
+ if all_windows_closed:
413
+ vehicle.windows.open_state._set_value(Windows.OpenState.CLOSED, measured=captured_at) # pylint: disable=protected-access
414
+ else:
415
+ vehicle.windows.open_state._set_value(Windows.OpenState.OPEN, measured=captured_at) # pylint: disable=protected-access
416
+ else:
417
+ vehicle.windows.open_state._set_value(None) # pylint: disable=protected-access
418
+ for window_id in vehicle.windows.windows.keys() - seen_window_ids:
419
+ vehicle.windows.windows[window_id].enabled = False
420
+ log_extra_keys(LOG_API, f'/api/v2/vehicle-status/{vin}', vehicle_status_data, {'updatedAt', 'locked', 'lights', 'hood', 'trunk', 'doors',
421
+ 'windows'})
422
+ return vehicle
423
+
424
+ def fetch_vehicle_mycar_status(self, vehicle: GenericVehicle, no_cache: bool = False) -> GenericVehicle:
425
+ """
426
+ Fetches the status of a vehicle from seat/cupra API.
427
+
428
+ Args:
429
+ vehicle (GenericVehicle): The vehicle object containing the VIN.
430
+
431
+ Returns:
432
+ None
433
+ """
434
+ vin = vehicle.vin.value
435
+ if vin is None:
436
+ raise APIError('VIN is missing')
437
+ url = f'https://ola.prod.code.seat.cloud.vwgroup.com/v5/users/{self.session.user_id}/vehicles/{vin}/mycar'
438
+ vehicle_status_data: Dict[str, Any] | None = self._fetch_data(url=url, session=self.session, no_cache=no_cache)
439
+ if vehicle_status_data:
440
+ if 'engines' in vehicle_status_data and vehicle_status_data['engines'] is not None:
441
+ drive_ids: set[str] = {'primary', 'secondary'}
442
+ total_range: float = 0.0
443
+ for drive_id in drive_ids:
444
+ if drive_id in vehicle_status_data['engines'] and vehicle_status_data['engines'][drive_id] is not None \
445
+ and 'fuelType' in vehicle_status_data['engines'][drive_id] and vehicle_status_data['engines'][drive_id]['fuelType'] is not None:
446
+ try:
447
+ engine_type: GenericDrive.Type = GenericDrive.Type(vehicle_status_data['engines'][drive_id]['fuelType'])
448
+ except ValueError:
449
+ LOG_API.warning('Unknown fuelType type %s', vehicle_status_data['engines'][drive_id]['fuelType'])
450
+ engine_type: GenericDrive.Type = GenericDrive.Type.UNKNOWN
451
+
452
+ if drive_id in vehicle.drives.drives:
453
+ drive: GenericDrive = vehicle.drives.drives[drive_id]
454
+ else:
455
+ if engine_type == GenericDrive.Type.ELECTRIC:
456
+ drive = ElectricDrive(drive_id=drive_id, drives=vehicle.drives)
457
+ elif engine_type in [GenericDrive.Type.FUEL,
458
+ GenericDrive.Type.GASOLINE,
459
+ GenericDrive.Type.PETROL,
460
+ GenericDrive.Type.DIESEL,
461
+ GenericDrive.Type.CNG,
462
+ GenericDrive.Type.LPG]:
463
+ drive = CombustionDrive(drive_id=drive_id, drives=vehicle.drives)
464
+ else:
465
+ drive = GenericDrive(drive_id=drive_id, drives=vehicle.drives)
466
+ drive.type._set_value(engine_type) # pylint: disable=protected-access
467
+ vehicle.drives.add_drive(drive)
468
+ if 'levelPct' in vehicle_status_data['engines'][drive_id] and vehicle_status_data['engines'][drive_id]['levelPct'] is not None:
469
+ # pylint: disable-next=protected-access
470
+ drive.level._set_value(value=vehicle_status_data['engines'][drive_id]['levelPct'])
471
+ else:
472
+ drive.level._set_value(None) # pylint: disable=protected-access
473
+ if 'rangeKm' in vehicle_status_data['engines'][drive_id] and vehicle_status_data['engines'][drive_id]['rangeKm'] is not None:
474
+ # pylint: disable-next=protected-access
475
+ drive.range._set_value(value=vehicle_status_data['engines'][drive_id]['rangeKm'], unit=Length.KM)
476
+ total_range += vehicle_status_data['engines'][drive_id]['rangeKm']
477
+ else:
478
+ drive.range._set_value(None, unit=Length.KM) # pylint: disable=protected-access
479
+ log_extra_keys(LOG_API, drive_id, vehicle_status_data['engines'][drive_id], {'fuelType',
480
+ 'levelPct',
481
+ 'rangeKm'})
482
+ vehicle.drives.total_range._set_value(total_range, unit=Length.KM) # pylint: disable=protected-access
483
+ else:
484
+ vehicle.drives.enabled = False
485
+ if len(vehicle.drives.drives) > 0:
486
+ has_electric = False
487
+ has_combustion = False
488
+ for drive in vehicle.drives.drives.values():
489
+ if isinstance(drive, ElectricDrive):
490
+ has_electric = True
491
+ elif isinstance(drive, CombustionDrive):
492
+ has_combustion = True
493
+ if has_electric and not has_combustion and not isinstance(vehicle, ElectricVehicle):
494
+ LOG.debug('Promoting %s to ElectricVehicle object for %s', vehicle.__class__.__name__, vin)
495
+ vehicle = ElectricVehicle(origin=vehicle)
496
+ self.car_connectivity.garage.replace_vehicle(vin, vehicle)
497
+ elif has_combustion and not has_electric and not isinstance(vehicle, CombustionVehicle):
498
+ LOG.debug('Promoting %s to CombustionVehicle object for %s', vehicle.__class__.__name__, vin)
499
+ vehicle = CombustionVehicle(origin=vehicle)
500
+ self.car_connectivity.garage.replace_vehicle(vin, vehicle)
501
+ elif has_combustion and has_electric and not isinstance(vehicle, HybridVehicle):
502
+ LOG.debug('Promoting %s to HybridVehicle object for %s', vehicle.__class__.__name__, vin)
503
+ vehicle = HybridVehicle(origin=vehicle)
504
+ self.car_connectivity.garage.replace_vehicle(vin, vehicle)
505
+ if 'services' in vehicle_status_data and vehicle_status_data['services'] is not None:
506
+ if 'charging' in vehicle_status_data['services'] and vehicle_status_data['services']['charging'] is not None:
507
+ charging_status: Dict = vehicle_status_data['services']['charging']
508
+ if 'status' in charging_status and charging_status['status'] is not None:
509
+ if charging_status['status'] in SeatCupraCharging.SeatCupraChargingState:
510
+ volkswagen_charging_state = SeatCupraCharging.SeatCupraChargingState(charging_status['status'])
511
+ charging_state: Charging.ChargingState = mapping_seatcupra_charging_state[volkswagen_charging_state]
512
+ else:
513
+ LOG_API.info('Unkown charging state %s not in %s', charging_status['status'],
514
+ str(SeatCupraCharging.SeatCupraChargingState))
515
+ charging_state = Charging.ChargingState.UNKNOWN
516
+ if isinstance(vehicle, ElectricVehicle):
517
+ vehicle.charging.state._set_value(value=charging_state) # pylint: disable=protected-access
518
+ else:
519
+ LOG_API.warning('Vehicle is not an electric or hybrid vehicle, but charging state was fetched')
520
+ else:
521
+ if isinstance(vehicle, ElectricVehicle):
522
+ vehicle.charging.state._set_value(None) # pylint: disable=protected-access
523
+ else:
524
+ LOG_API.warning('Vehicle is not an electric or hybrid vehicle, but charging state was fetched')
525
+ if 'targetPct' in charging_status and charging_status['targetPct'] is not None:
526
+ if isinstance(vehicle, ElectricVehicle):
527
+ vehicle.charging.settings.target_level._set_value(charging_status['targetPct']) # pylint: disable=protected-access
528
+ if 'chargeMode' in charging_status and charging_status['chargeMode'] is not None:
529
+ if charging_status['chargeMode'] in Charging.ChargingType:
530
+ if isinstance(vehicle, ElectricVehicle):
531
+ vehicle.charging.type._set_value(value=Charging.ChargingType(charging_status['chargeMode'])) # pylint: disable=protected-access
532
+ else:
533
+ LOG_API.info('Unknown charge type %s', charging_status['chargeMode'])
534
+ if isinstance(vehicle, ElectricVehicle):
535
+ vehicle.charging.type._set_value(Charging.ChargingType.UNKNOWN) # pylint: disable=protected-access
536
+ else:
537
+ if isinstance(vehicle, ElectricVehicle):
538
+ vehicle.charging.type._set_value(None) # pylint: disable=protected-access
539
+ if 'remainingTime' in charging_status and charging_status['remainingTime'] is not None:
540
+ remaining_duration: timedelta = timedelta(minutes=charging_status['remainingTime'])
541
+ estimated_date_reached: datetime = datetime.now(tz=timezone.utc) + remaining_duration
542
+ estimated_date_reached = estimated_date_reached.replace(second=0, microsecond=0)
543
+ if isinstance(vehicle, ElectricVehicle):
544
+ vehicle.charging.estimated_date_reached._set_value(value=estimated_date_reached) # pylint: disable=protected-access
545
+ else:
546
+ if isinstance(vehicle, ElectricVehicle):
547
+ vehicle.charging.estimated_date_reached._set_value(None) # pylint: disable=protected-access
548
+ log_extra_keys(LOG_API, 'charging', charging_status, {'status', 'targetPct', 'currentPct', 'chargeMode', 'remainingTime'})
549
+ else:
550
+ if isinstance(vehicle, ElectricVehicle):
551
+ vehicle.charging.enabled = False
552
+ if 'climatisation' in vehicle_status_data['services'] and vehicle_status_data['services']['climatisation'] is not None:
553
+ climatisation_status: Dict = vehicle_status_data['services']['climatisation']
554
+ if 'status' in climatisation_status and climatisation_status['status'] is not None:
555
+ if climatisation_status['status'].lower() in Climatization.ClimatizationState:
556
+ climatization_state: Climatization.ClimatizationState = Climatization.ClimatizationState(climatisation_status['status'].lower())
557
+ else:
558
+ LOG_API.info('Unknown climatization state %s not in %s', climatisation_status['status'],
559
+ str(Climatization.ClimatizationState))
560
+ climatization_state = Climatization.ClimatizationState.UNKNOWN
561
+ vehicle.climatization.state._set_value(value=climatization_state) # pylint: disable=protected-access
562
+ else:
563
+ vehicle.climatization.state._set_value(None) # pylint: disable=protected-access
564
+ if 'targetTemperatureCelsius' in climatisation_status and climatisation_status['targetTemperatureCelsius'] is not None:
565
+ target_temperature: Optional[float] = climatisation_status['targetTemperatureCelsius']
566
+ vehicle.climatization.settings.target_temperature._set_value(value=target_temperature, # pylint: disable=protected-access
567
+ unit=Temperature.C)
568
+ elif 'targetTemperatureFahrenheit' in climatisation_status and climatisation_status['targetTemperatureFahrenheit'] is not None:
569
+ target_temperature = climatisation_status['targetTemperatureFahrenheit']
570
+ vehicle.climatization.settings.target_temperature._set_value(value=target_temperature, # pylint: disable=protected-access
571
+ unit=Temperature.F)
572
+ else:
573
+ vehicle.climatization.settings.target_temperature._set_value(None) # pylint: disable=protected-access
574
+ if 'remainingTime' in climatisation_status and climatisation_status['remainingTime'] is not None:
575
+ remaining_duration: timedelta = timedelta(minutes=climatisation_status['remainingTime'])
576
+ estimated_date_reached: datetime = datetime.now(tz=timezone.utc) + remaining_duration
577
+ estimated_date_reached = estimated_date_reached.replace(second=0, microsecond=0)
578
+ vehicle.charging.estimated_date_reached._set_value(value=estimated_date_reached) # pylint: disable=protected-access
579
+ else:
580
+ vehicle.charging.estimated_date_reached._set_value(None) # pylint: disable=protected-access
581
+ log_extra_keys(LOG_API, 'climatisation', climatisation_status, {'status', 'targetTemperatureCelsius', 'targetTemperatureFahrenheit',
582
+ 'remainingTime'})
583
+ return vehicle
584
+
585
+ def fetch_parking_position(self, vehicle: GenericVehicle, no_cache: bool = False) -> GenericVehicle:
586
+ """
587
+ Fetches the position of the given vehicle and updates its position attributes.
588
+
589
+ Args:
590
+ vehicle (SkodaVehicle): The vehicle object containing the VIN and position attributes.
591
+
592
+ Returns:
593
+ SkodaVehicle: The updated vehicle object with the fetched position data.
594
+
595
+ Raises:
596
+ APIError: If the VIN is missing.
597
+ ValueError: If the vehicle has no position object.
598
+ """
599
+ vin = vehicle.vin.value
600
+ if vin is None:
601
+ raise APIError('VIN is missing')
602
+ if vehicle.position is None:
603
+ raise ValueError('Vehicle has no charging object')
604
+ url = f'https://ola.prod.code.seat.cloud.vwgroup.com/v1/vehicles/{vin}/parkingposition'
605
+ data: Dict[str, Any] | None = self._fetch_data(url=url, session=self.session, no_cache=no_cache)
606
+ if data is not None:
607
+ if 'lat' in data and data['lat'] is not None:
608
+ latitude: Optional[float] = data['lat']
609
+ else:
610
+ latitude = None
611
+ if 'lon' in data and data['lon'] is not None:
612
+ longitude: Optional[float] = data['lon']
613
+ else:
614
+ longitude = None
615
+ vehicle.position.latitude._set_value(latitude) # pylint: disable=protected-access
616
+ vehicle.position.longitude._set_value(longitude) # pylint: disable=protected-access
617
+ vehicle.position.position_type._set_value(Position.PositionType.PARKING) # pylint: disable=protected-access
618
+ log_extra_keys(LOG_API, 'parkingposition', data, {'lat', 'lon'})
619
+ else:
620
+ vehicle.position.latitude._set_value(None) # pylint: disable=protected-access
621
+ vehicle.position.longitude._set_value(None) # pylint: disable=protected-access
622
+ vehicle.position.position_type._set_value(None) # pylint: disable=protected-access
623
+ return vehicle
624
+
625
+ def _record_elapsed(self, elapsed: timedelta) -> None:
626
+ """
627
+ Records the elapsed time.
628
+
629
+ Args:
630
+ elapsed (timedelta): The elapsed time to record.
631
+ """
632
+ self._elapsed.append(elapsed)
633
+
634
+ def _fetch_data(self, url, session, no_cache=False, allow_empty=False, allow_http_error=False,
635
+ allowed_errors=None) -> Optional[Dict[str, Any]]: # noqa: C901
636
+ data: Optional[Dict[str, Any]] = None
637
+ cache_date: Optional[datetime] = None
638
+ if not no_cache and (self.active_config['max_age'] is not None and session.cache is not None and url in session.cache):
639
+ data, cache_date_string = session.cache[url]
640
+ cache_date = datetime.fromisoformat(cache_date_string)
641
+ if data is None or self.active_config['max_age'] is None \
642
+ or (cache_date is not None and cache_date < (datetime.utcnow() - timedelta(seconds=self.active_config['max_age']))):
643
+ try:
644
+ status_response: requests.Response = session.get(url, allow_redirects=False)
645
+ self._record_elapsed(status_response.elapsed)
646
+ if status_response.status_code in (requests.codes['ok'], requests.codes['multiple_status']):
647
+ data = status_response.json()
648
+ if session.cache is not None:
649
+ session.cache[url] = (data, str(datetime.utcnow()))
650
+ elif status_response.status_code == requests.codes['too_many_requests']:
651
+ raise TooManyRequestsError('Could not fetch data due to too many requests from your account. '
652
+ f'Status Code was: {status_response.status_code}')
653
+ elif status_response.status_code == requests.codes['unauthorized']:
654
+ LOG.info('Server asks for new authorization')
655
+ session.login()
656
+ status_response = session.get(url, allow_redirects=False)
657
+
658
+ if status_response.status_code in (requests.codes['ok'], requests.codes['multiple_status']):
659
+ data = status_response.json()
660
+ if session.cache is not None:
661
+ session.cache[url] = (data, str(datetime.utcnow()))
662
+ elif not allow_http_error or (allowed_errors is not None and status_response.status_code not in allowed_errors):
663
+ raise RetrievalError(f'Could not fetch data even after re-authorization. Status Code was: {status_response.status_code}')
664
+ elif not allow_http_error or (allowed_errors is not None and status_response.status_code not in allowed_errors):
665
+ raise RetrievalError(f'Could not fetch data. Status Code was: {status_response.status_code}')
666
+ except requests.exceptions.ConnectionError as connection_error:
667
+ raise RetrievalError(f'Connection error: {connection_error}.'
668
+ ' If this happens frequently, please check if other applications communicate with the Skoda server.') from connection_error
669
+ except requests.exceptions.ChunkedEncodingError as chunked_encoding_error:
670
+ raise RetrievalError(f'Error: {chunked_encoding_error}') from chunked_encoding_error
671
+ except requests.exceptions.ReadTimeout as timeout_error:
672
+ raise RetrievalError(f'Timeout during read: {timeout_error}') from timeout_error
673
+ except requests.exceptions.RetryError as retry_error:
674
+ raise RetrievalError(f'Retrying failed: {retry_error}') from retry_error
675
+ except requests.exceptions.JSONDecodeError as json_error:
676
+ if allow_empty:
677
+ data = None
678
+ else:
679
+ raise RetrievalError(f'JSON decode error: {json_error}') from json_error
680
+ return data
681
+
682
+ def get_version(self) -> str:
683
+ return __version__
684
+
685
+ def get_type(self) -> str:
686
+ return "carconnectivity-connector-seatcupra"