carconnectivity-connector-seatcupra 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.
@@ -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"