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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1470 @@
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 traceback
10
+ import logging
11
+ import netrc
12
+ from datetime import datetime, timezone, timedelta
13
+ import requests
14
+
15
+ from carconnectivity.garage import Garage
16
+ from carconnectivity.errors import AuthenticationError, TooManyRequestsError, RetrievalError, APIError, APICompatibilityError, \
17
+ TemporaryAuthenticationError, SetterError, CommandError
18
+ from carconnectivity.util import robust_time_parse, log_extra_keys, config_remove_credentials
19
+ from carconnectivity.units import Length, Current
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.vehicle import GenericVehicle, ElectricVehicle
25
+ from carconnectivity.attributes import BooleanAttribute, DurationAttribute, GenericAttribute, TemperatureAttribute, EnumAttribute
26
+ from carconnectivity.units import Temperature
27
+ from carconnectivity.command_impl import ClimatizationStartStopCommand, WakeSleepCommand, HonkAndFlashCommand, LockUnlockCommand, ChargingStartStopCommand
28
+ from carconnectivity.climatization import Climatization
29
+ from carconnectivity.commands import Commands
30
+ from carconnectivity.charging import Charging
31
+ from carconnectivity.charging_connector import ChargingConnector
32
+ from carconnectivity.position import Position
33
+ from carconnectivity.enums import ConnectionState
34
+
35
+ from carconnectivity_connectors.base.connector import BaseConnector
36
+ from carconnectivity_connectors.seatcupra.auth.session_manager import SessionManager, SessionUser, Service
37
+ from carconnectivity_connectors.seatcupra.auth.my_cupra_session import MyCupraSession
38
+ from carconnectivity_connectors.seatcupra._version import __version__
39
+ from carconnectivity_connectors.seatcupra.capability import Capability
40
+ from carconnectivity_connectors.seatcupra.vehicle import SeatCupraVehicle, SeatCupraElectricVehicle, SeatCupraCombustionVehicle, SeatCupraHybridVehicle
41
+ from carconnectivity_connectors.seatcupra.charging import SeatCupraCharging, mapping_seatcupra_charging_state
42
+ from carconnectivity_connectors.seatcupra.climatization import SeatCupraClimatization
43
+ from carconnectivity_connectors.seatcupra.command_impl import SpinCommand
44
+
45
+ SUPPORT_IMAGES = False
46
+ try:
47
+ from PIL import Image
48
+ import base64
49
+ import io
50
+ SUPPORT_IMAGES = True
51
+ from carconnectivity.attributes import ImageAttribute
52
+ except ImportError:
53
+ pass
54
+
55
+ if TYPE_CHECKING:
56
+ from typing import Dict, List, Optional, Any, Union
57
+
58
+ from carconnectivity.carconnectivity import CarConnectivity
59
+
60
+ LOG: logging.Logger = logging.getLogger("carconnectivity.connectors.seatcupra")
61
+ LOG_API: logging.Logger = logging.getLogger("carconnectivity.connectors.seatcupra-api-debug")
62
+
63
+
64
+ # pylint: disable=too-many-lines
65
+ class Connector(BaseConnector):
66
+ """
67
+ Connector class for Seat/Cupra API connectivity.
68
+ Args:
69
+ car_connectivity (CarConnectivity): An instance of CarConnectivity.
70
+ config (Dict): Configuration dictionary containing connection details.
71
+ Attributes:
72
+ max_age (Optional[int]): Maximum age for cached data in seconds.
73
+ """
74
+ def __init__(self, connector_id: str, car_connectivity: CarConnectivity, config: Dict) -> None:
75
+ BaseConnector.__init__(self, connector_id=connector_id, car_connectivity=car_connectivity, config=config, log=LOG, api_log=LOG_API)
76
+
77
+ self._background_thread: Optional[threading.Thread] = None
78
+ self._stop_event = threading.Event()
79
+
80
+ self.connection_state: EnumAttribute = EnumAttribute(name="connection_state", parent=self, value_type=ConnectionState,
81
+ value=ConnectionState.DISCONNECTED, tags={'connector_custom'})
82
+ self.interval: DurationAttribute = DurationAttribute(name="interval", parent=self, tags={'connector_custom'})
83
+ self.interval.minimum = timedelta(seconds=180)
84
+ self.interval._is_changeable = True # pylint: disable=protected-access
85
+
86
+ self.commands: Commands = Commands(parent=self)
87
+
88
+ LOG.info("Loading seatcupra connector with config %s", config_remove_credentials(config))
89
+
90
+ if 'spin' in config and config['spin'] is not None:
91
+ self.active_config['spin'] = config['spin']
92
+ else:
93
+ self.active_config['spin'] = None
94
+
95
+ self.active_config['username'] = None
96
+ self.active_config['password'] = None
97
+ if 'username' in config and 'password' in config:
98
+ self.active_config['username'] = config['username']
99
+ self.active_config['password'] = config['password']
100
+ else:
101
+ if 'netrc' in config:
102
+ self.active_config['netrc'] = config['netrc']
103
+ else:
104
+ self.active_config['netrc'] = os.path.join(os.path.expanduser("~"), ".netrc")
105
+ try:
106
+ secrets = netrc.netrc(file=self.active_config['netrc'])
107
+ secret: tuple[str, str, str] | None = secrets.authenticators("seatcupra")
108
+ if secret is None:
109
+ raise AuthenticationError(f'Authentication using {self.active_config["netrc"]} failed: seatcupra not found in netrc')
110
+ self.active_config['username'], account, self.active_config['password'] = secret
111
+
112
+ if self.active_config['spin'] is None and account is not None:
113
+ try:
114
+ self.active_config['spin'] = account
115
+ except ValueError as err:
116
+ LOG.error('Could not parse spin from netrc: %s', err)
117
+ except netrc.NetrcParseError as err:
118
+ LOG.error('Authentification using %s failed: %s', self.active_config['netrc'], err)
119
+ raise AuthenticationError(f'Authentication using {self.active_config["netrc"]} failed: {err}') from err
120
+ except TypeError as err:
121
+ if 'username' not in config:
122
+ raise AuthenticationError(f'"seatcupra" entry was not found in {self.active_config["netrc"]} netrc-file.'
123
+ ' Create it or provide username and password in config') from err
124
+ except FileNotFoundError as err:
125
+ raise AuthenticationError(f'{self.active_config["netrc"]} netrc-file was not found. Create it or provide username and password in config') \
126
+ from err
127
+
128
+ self.active_config['interval'] = 300
129
+ if 'interval' in config:
130
+ self.active_config['interval'] = config['interval']
131
+ if self.active_config['interval'] < 180:
132
+ raise ValueError('Intervall must be at least 180 seconds')
133
+ self.active_config['max_age'] = self.active_config['interval'] - 1
134
+ if 'max_age' in config:
135
+ self.active_config['max_age'] = config['max_age']
136
+ self.interval._set_value(timedelta(seconds=self.active_config['interval'])) # pylint: disable=protected-access
137
+
138
+ if 'brand' in config:
139
+ if config['brand'] not in ['seat', 'cupra']:
140
+ raise ValueError('Brand must be either "seat" or "cupra"')
141
+ self.active_config['brand'] = config['brand']
142
+ else:
143
+ self.active_config['brand'] = 'cupra'
144
+
145
+ if self.active_config['username'] is None or self.active_config['password'] is None:
146
+ raise AuthenticationError('Username or password not provided')
147
+
148
+ if self.active_config['brand'] == 'cupra':
149
+ service = Service.MY_CUPRA
150
+ elif self.active_config['brand'] == 'seat':
151
+ service = Service.MY_SEAT
152
+ else:
153
+ raise ValueError('Brand must be either "seat" or "cupra"')
154
+ self._manager: SessionManager = SessionManager(tokenstore=car_connectivity.get_tokenstore(), cache=car_connectivity.get_cache())
155
+ session: requests.Session = self._manager.get_session(service, SessionUser(username=self.active_config['username'],
156
+ password=self.active_config['password']))
157
+ if not isinstance(session, MyCupraSession):
158
+ raise AuthenticationError('Could not create session')
159
+ self.session: MyCupraSession = session
160
+ self.session.retries = 3
161
+ self.session.timeout = 30
162
+ self.session.refresh()
163
+
164
+ self._elapsed: List[timedelta] = []
165
+
166
+ def startup(self) -> None:
167
+ self._background_thread = threading.Thread(target=self._background_loop, daemon=False)
168
+ self._background_thread.name = 'carconnectivity.connectors.seatcupra-background'
169
+ self._background_thread.start()
170
+ self.healthy._set_value(value=True) # pylint: disable=protected-access
171
+
172
+ def _background_loop(self) -> None:
173
+ self._stop_event.clear()
174
+ fetch: bool = True
175
+ self.connection_state._set_value(value=ConnectionState.CONNECTING) # pylint: disable=protected-access
176
+ while not self._stop_event.is_set():
177
+ interval = 300
178
+ try:
179
+ try:
180
+ if fetch:
181
+ self.fetch_all()
182
+ fetch = False
183
+ else:
184
+ self.update_vehicles()
185
+ self.last_update._set_value(value=datetime.now(tz=timezone.utc)) # pylint: disable=protected-access
186
+ if self.interval.value is not None:
187
+ interval: float = self.interval.value.total_seconds()
188
+ except Exception:
189
+ if self.interval.value is not None:
190
+ interval: float = self.interval.value.total_seconds()
191
+ raise
192
+ except TooManyRequestsError as err:
193
+ LOG.error('Retrieval error during update. Too many requests from your account (%s). Will try again after 15 minutes', str(err))
194
+ self.connection_state._set_value(value=ConnectionState.ERROR) # pylint: disable=protected-access
195
+ self._stop_event.wait(900)
196
+ except RetrievalError as err:
197
+ LOG.error('Retrieval error during update (%s). Will try again after configured interval of %ss', str(err), interval)
198
+ self.connection_state._set_value(value=ConnectionState.ERROR) # pylint: disable=protected-access
199
+ self._stop_event.wait(interval)
200
+ except APIError as err:
201
+ LOG.error('API error during update (%s). Will try again after configured interval of %ss', str(err), interval)
202
+ self.connection_state._set_value(value=ConnectionState.ERROR) # pylint: disable=protected-access
203
+ self._stop_event.wait(interval)
204
+ except APICompatibilityError as err:
205
+ LOG.error('API compatability error during update (%s). Will try again after configured interval of %ss', str(err), interval)
206
+ self.connection_state._set_value(value=ConnectionState.ERROR) # pylint: disable=protected-access
207
+ self._stop_event.wait(interval)
208
+ except TemporaryAuthenticationError as err:
209
+ LOG.error('Temporary authentification error during update (%s). Will try again after configured interval of %ss', str(err), interval)
210
+ self.connection_state._set_value(value=ConnectionState.ERROR) # pylint: disable=protected-access
211
+ self._stop_event.wait(interval)
212
+ except Exception as err:
213
+ LOG.critical('Critical error during update: %s', traceback.format_exc())
214
+ self.connection_state._set_value(value=ConnectionState.ERROR) # pylint: disable=protected-access
215
+ self.healthy._set_value(value=False) # pylint: disable=protected-access
216
+ raise err
217
+ else:
218
+ self.connection_state._set_value(value=ConnectionState.CONNECTED) # pylint: disable=protected-access
219
+ self._stop_event.wait(interval)
220
+ # When leaving the loop, set the connection state to disconnected
221
+ self.connection_state._set_value(value=ConnectionState.DISCONNECTED) # pylint: disable=protected-access
222
+
223
+ def persist(self) -> None:
224
+ """
225
+ Persists the current state using the manager's persist method.
226
+
227
+ This method calls the `persist` method of the `_manager` attribute to save the current state.
228
+ """
229
+ self._manager.persist()
230
+
231
+ def shutdown(self) -> None:
232
+ """
233
+ Shuts down the connector by persisting current state, closing the session,
234
+ and cleaning up resources.
235
+
236
+ This method performs the following actions:
237
+ 1. Persists the current state.
238
+ 2. Closes the session.
239
+ 3. Sets the session and manager to None.
240
+ 4. Calls the shutdown method of the base connector.
241
+
242
+ Returns:
243
+ None
244
+ """
245
+ # Disable and remove all vehicles managed soley by this connector
246
+ for vehicle in self.car_connectivity.garage.list_vehicles():
247
+ if len(vehicle.managing_connectors) == 1 and self in vehicle.managing_connectors:
248
+ self.car_connectivity.garage.remove_vehicle(vehicle.id)
249
+ vehicle.enabled = False
250
+ self._stop_event.set()
251
+ self.session.close()
252
+ if self._background_thread is not None:
253
+ self._background_thread.join()
254
+ self.persist()
255
+ BaseConnector.shutdown(self)
256
+
257
+ def fetch_all(self) -> None:
258
+ """
259
+ Fetches all necessary data for the connector.
260
+
261
+ This method calls the `fetch_vehicles` method to retrieve vehicle data.
262
+ """
263
+ # Add spin command
264
+ if self.commands is not None and not self.commands.contains_command('spin'):
265
+ spin_command = SpinCommand(parent=self.commands)
266
+ spin_command._add_on_set_hook(self.__on_spin) # pylint: disable=protected-access
267
+ spin_command.enabled = True
268
+ self.commands.add_command(spin_command)
269
+ self.fetch_vehicles()
270
+ self.car_connectivity.transaction_end()
271
+
272
+ def update_vehicles(self) -> None:
273
+ """
274
+ Updates the status of all vehicles in the garage managed by this connector.
275
+
276
+ This method iterates through all vehicle VINs in the garage, and for each vehicle that is
277
+ managed by this connector and is an instance of Seat/CupraVehicle, it updates the vehicle's status
278
+ by fetching data from various APIs. If the vehicle is an instance of Seat/CupraElectricVehicle,
279
+ it also fetches charging information.
280
+
281
+ Returns:
282
+ None
283
+ """
284
+ garage: Garage = self.car_connectivity.garage
285
+ for vin in set(garage.list_vehicle_vins()):
286
+ vehicle_to_update: Optional[GenericVehicle] = garage.get_vehicle(vin)
287
+ if vehicle_to_update is not None and vehicle_to_update.is_managed_by_connector(self) and isinstance(vehicle_to_update, SeatCupraVehicle):
288
+ vehicle_to_update = self.fetch_vehicle_status(vehicle_to_update)
289
+ vehicle_to_update = self.fetch_vehicle_mycar_status(vehicle_to_update)
290
+ vehicle_to_update = self.fetch_mileage(vehicle_to_update)
291
+ if vehicle_to_update.capabilities.has_capability('climatisation'):
292
+ vehicle_to_update = self.fetch_climatisation(vehicle_to_update)
293
+ if vehicle_to_update.capabilities.has_capability('charging'):
294
+ vehicle_to_update = self.fetch_charging(vehicle_to_update)
295
+ if vehicle_to_update.capabilities.has_capability('parkingPosition'):
296
+ vehicle_to_update = self.fetch_parking_position(vehicle_to_update)
297
+ if vehicle_to_update.capabilities.has_capability('vehicleHealthInspection'):
298
+ vehicle_to_update = self.fetch_maintenance(vehicle_to_update)
299
+ self.car_connectivity.transaction_end()
300
+
301
+ def fetch_vehicles(self) -> None:
302
+ """
303
+ Fetches the list of vehicles from the Seat/Cupra Connect API and updates the garage with new vehicles.
304
+ This method sends a request to the Seat/Cupra Connect API to retrieve the list of vehicles associated with the user's account.
305
+ If new vehicles are found in the response, they are added to the garage.
306
+
307
+ Returns:
308
+ None
309
+ """
310
+ garage: Garage = self.car_connectivity.garage
311
+ url = f'https://ola.prod.code.seat.cloud.vwgroup.com/v2/users/{self.session.user_id}/garage/vehicles'
312
+ data: Dict[str, Any] | None = self._fetch_data(url, session=self.session)
313
+
314
+ seen_vehicle_vins: set[str] = set()
315
+ if data is not None:
316
+ if 'vehicles' in data and data['vehicles'] is not None:
317
+ for vehicle_dict in data['vehicles']:
318
+ if 'vin' in vehicle_dict and vehicle_dict['vin'] is not None:
319
+ vin: str = vehicle_dict['vin']
320
+ seen_vehicle_vins.add(vin)
321
+ vehicle: Optional[GenericVehicle] = garage.get_vehicle(vin) # pyright: ignore[reportAssignmentType]
322
+ if vehicle is None:
323
+ vehicle = SeatCupraVehicle(vin=vin, garage=garage, managing_connector=self)
324
+ garage.add_vehicle(vin, vehicle)
325
+
326
+ if 'vehicleNickname' in vehicle_dict and vehicle_dict['vehicleNickname'] is not None:
327
+ vehicle.name._set_value(vehicle_dict['vehicleNickname']) # pylint: disable=protected-access
328
+ else:
329
+ vehicle.name._set_value(None) # pylint: disable=protected-access
330
+
331
+ if 'specifications' in vehicle_dict and vehicle_dict['specifications'] is not None:
332
+ if 'steeringRight' in vehicle_dict['specifications'] and vehicle_dict['specifications']['steeringRight'] is not None:
333
+ if vehicle_dict['specifications']['steeringRight']:
334
+ # pylint: disable-next=protected-access
335
+ vehicle.specification.steering_wheel_position._set_value(GenericVehicle.VehicleSpecification.SteeringPosition.RIGHT)
336
+ else:
337
+ # pylint: disable-next=protected-access
338
+ vehicle.specification.steering_wheel_position._set_value(GenericVehicle.VehicleSpecification.SteeringPosition.LEFT)
339
+ else:
340
+ vehicle.specification.steering_wheel_position._set_value(None) # pylint: disable=protected-access
341
+ if 'factoryModel' in vehicle_dict['specifications'] and vehicle_dict['specifications']['factoryModel'] is not None:
342
+ factory_model: Dict = vehicle_dict['specifications']['factoryModel']
343
+ if 'vehicleBrand' in factory_model and factory_model['vehicleBrand'] is not None:
344
+ vehicle.manufacturer._set_value(factory_model['vehicleBrand']) # pylint: disable=protected-access
345
+ else:
346
+ vehicle.manufacturer._set_value(None) # pylint: disable=protected-access
347
+ if 'vehicleModel' in factory_model and factory_model['vehicleModel'] is not None:
348
+ vehicle.model._set_value(factory_model['vehicleModel']) # pylint: disable=protected-access
349
+ else:
350
+ vehicle.model._set_value(None) # pylint: disable=protected-access
351
+ if 'modYear' in factory_model and factory_model['modYear'] is not None:
352
+ vehicle.model_year._set_value(factory_model['modYear']) # pylint: disable=protected-access
353
+ else:
354
+ vehicle.model_year._set_value(None) # pylint: disable=protected-access
355
+ log_extra_keys(LOG_API, 'factoryModel', factory_model, {'vehicleBrand', 'vehicleModel', 'modYear'})
356
+ log_extra_keys(LOG_API, 'specifications', vehicle_dict['specifications'], {'steeringRight', 'factoryModel'})
357
+
358
+ if isinstance(vehicle, SeatCupraVehicle):
359
+ url = f'https://ola.prod.code.seat.cloud.vwgroup.com/v2/vehicles/{vin}/capabilities'
360
+ capabilities_data: Dict[str, Any] | None = self._fetch_data(url, session=self.session)
361
+ if capabilities_data is not None and 'capabilities' in capabilities_data and capabilities_data['capabilities'] is not None:
362
+ found_capabilities = set()
363
+ for capability_dict in capabilities_data['capabilities']:
364
+ if 'id' in capability_dict and capability_dict['id'] is not None:
365
+ capability_id = capability_dict['id']
366
+ found_capabilities.add(capability_id)
367
+ if vehicle.capabilities.has_capability(capability_id):
368
+ capability: Capability = vehicle.capabilities.get_capability(capability_id) # pyright: ignore[reportAssignmentType]
369
+ else:
370
+ capability = Capability(capability_id=capability_id, capabilities=vehicle.capabilities)
371
+ vehicle.capabilities.add_capability(capability_id, capability)
372
+ if 'expirationDate' in capability_dict and capability_dict['expirationDate'] is not None \
373
+ and capability_dict['expirationDate'] != '':
374
+ expiration_date: datetime = robust_time_parse(capability_dict['expirationDate'])
375
+ capability.expiration_date._set_value(expiration_date) # pylint: disable=protected-access
376
+ else:
377
+ capability.expiration_date._set_value(None) # pylint: disable=protected-access
378
+ if 'editable' in capability_dict and capability_dict['editable'] is not None:
379
+ # pylint: disable-next=protected-access
380
+ capability.editable._set_value(capability_dict['editable'])
381
+ else:
382
+ capability.editable._set_value(None) # pylint: disable=protected-access
383
+ if 'parameters' in capability_dict and capability_dict['parameters'] is not None:
384
+ for parameter, value in capability_dict['parameters'].items():
385
+ capability.parameters[parameter] = value
386
+ else:
387
+ raise APIError('Could not fetch capabilities, capability ID missing')
388
+ log_extra_keys(LOG_API, 'capability', capability_dict, {'id', 'expirationDate', 'editable', 'parameters'})
389
+
390
+ for capability_id in vehicle.capabilities.capabilities.keys() - found_capabilities:
391
+ vehicle.capabilities.remove_capability(capability_id)
392
+
393
+ if vehicle.capabilities.has_capability('charging'):
394
+ if not isinstance(vehicle, SeatCupraElectricVehicle):
395
+ LOG.debug('Promoting %s to SeatCupraElectricVehicle object for %s', vehicle.__class__.__name__, vin)
396
+ vehicle = SeatCupraElectricVehicle(origin=vehicle)
397
+ self.car_connectivity.garage.replace_vehicle(vin, vehicle)
398
+ if not vehicle.charging.commands.contains_command('start-stop'):
399
+ charging_start_stop_command: ChargingStartStopCommand = ChargingStartStopCommand(parent=vehicle.charging.commands)
400
+ charging_start_stop_command._add_on_set_hook(self.__on_charging_start_stop) # pylint: disable=protected-access
401
+ charging_start_stop_command.enabled = True
402
+ vehicle.charging.commands.add_command(charging_start_stop_command)
403
+
404
+ if vehicle.capabilities.has_capability('climatisation'):
405
+ if vehicle.climatization is not None and vehicle.climatization.commands is not None \
406
+ and not vehicle.climatization.commands.contains_command('start-stop'):
407
+ climatisation_start_stop_command: ClimatizationStartStopCommand = \
408
+ ClimatizationStartStopCommand(parent=vehicle.climatization.commands)
409
+ # pylint: disable-next=protected-access
410
+ climatisation_start_stop_command._add_on_set_hook(self.__on_air_conditioning_start_stop)
411
+ climatisation_start_stop_command.enabled = True
412
+ vehicle.climatization.commands.add_command(climatisation_start_stop_command)
413
+
414
+ if vehicle.capabilities.has_capability('vehicleWakeUpTrigger'):
415
+ if vehicle.commands is not None and vehicle.commands.commands is not None \
416
+ and not vehicle.commands.contains_command('wake-sleep'):
417
+ wake_sleep_command = WakeSleepCommand(parent=vehicle.commands)
418
+ wake_sleep_command._add_on_set_hook(self.__on_wake_sleep) # pylint: disable=protected-access
419
+ wake_sleep_command.enabled = True
420
+ vehicle.commands.add_command(wake_sleep_command)
421
+
422
+ # Add honkAndFlash command if necessary capabilities are available
423
+ if vehicle.capabilities.has_capability('honkAndFlash'):
424
+ if vehicle.commands is not None and vehicle.commands.commands is not None \
425
+ and not vehicle.commands.contains_command('honk-flash'):
426
+ honk_flash_command = HonkAndFlashCommand(parent=vehicle.commands, with_duration=True)
427
+ honk_flash_command._add_on_set_hook(self.__on_honk_flash) # pylint: disable=protected-access
428
+ honk_flash_command.enabled = True
429
+ vehicle.commands.add_command(honk_flash_command)
430
+
431
+ # Add lock and unlock command
432
+ if vehicle.capabilities.has_capability('access'):
433
+ if vehicle.doors is not None and vehicle.doors.commands is not None and vehicle.doors.commands.commands is not None \
434
+ and not vehicle.doors.commands.contains_command('lock-unlock'):
435
+ lock_unlock_command = LockUnlockCommand(parent=vehicle.doors.commands)
436
+ lock_unlock_command._add_on_set_hook(self.__on_lock_unlock) # pylint: disable=protected-access
437
+ lock_unlock_command.enabled = True
438
+ vehicle.doors.commands.add_command(lock_unlock_command)
439
+ else:
440
+ vehicle.capabilities.clear_capabilities()
441
+ if isinstance(vehicle, SeatCupraVehicle):
442
+ vehicle = self.fetch_image(vehicle)
443
+ else:
444
+ raise APIError('Could not fetch vehicle data, VIN missing')
445
+ for vin in set(garage.list_vehicle_vins()) - seen_vehicle_vins:
446
+ vehicle_to_remove = garage.get_vehicle(vin)
447
+ if vehicle_to_remove is not None and vehicle_to_remove.is_managed_by_connector(self):
448
+ garage.remove_vehicle(vin)
449
+ self.update_vehicles()
450
+
451
+ def fetch_vehicle_status(self, vehicle: SeatCupraVehicle, no_cache: bool = False) -> SeatCupraVehicle:
452
+ """
453
+ Fetches the status of a vehicle from seat/cupra API.
454
+
455
+ Args:
456
+ vehicle (GenericVehicle): The vehicle object containing the VIN.
457
+
458
+ Returns:
459
+ None
460
+ """
461
+ vin = vehicle.vin.value
462
+ if vin is None:
463
+ raise APIError('VIN is missing')
464
+
465
+ url = f'https://ola.prod.code.seat.cloud.vwgroup.com/vehicles/{vin}/connection'
466
+ vehicle_connection_data: Dict[str, Any] | None = self._fetch_data(url=url, session=self.session, no_cache=no_cache)
467
+ if vehicle_connection_data is not None:
468
+ if 'connection' in vehicle_connection_data and vehicle_connection_data['connection'] is not None \
469
+ and 'mode' in vehicle_connection_data['connection'] and vehicle_connection_data['connection']['mode'] is not None:
470
+ if vehicle_connection_data['connection']['mode'] in [item.value for item in GenericVehicle.ConnectionState]:
471
+ connection_state: GenericVehicle.ConnectionState = GenericVehicle.ConnectionState(vehicle_connection_data['connection']['mode'])
472
+ vehicle.connection_state._set_value(connection_state) # pylint: disable=protected-access
473
+ else:
474
+ vehicle.connection_state._set_value(GenericVehicle.ConnectionState.UNKNOWN) # pylint: disable=protected-access
475
+ LOG_API.info('Unknown connection state %s', vehicle_connection_data['connection']['mode'])
476
+ log_extra_keys(LOG_API, f'/api/v2/vehicles/{vin}/connection', vehicle_connection_data['connection'], {'mode'})
477
+ else:
478
+ vehicle.connection_state._set_value(None) # pylint: disable=protected-access
479
+ log_extra_keys(LOG_API, f'/api/v2/vehicles/{vin}/connection', vehicle_connection_data, {'connection'})
480
+ else:
481
+ vehicle.connection_state._set_value(None) # pylint: disable=protected-access
482
+
483
+ url = f'https://ola.prod.code.seat.cloud.vwgroup.com/v2/vehicles/{vin}/status'
484
+ vehicle_status_data: Dict[str, Any] | None = self._fetch_data(url=url, session=self.session, no_cache=no_cache)
485
+ if vehicle_status_data:
486
+ if 'updatedAt' in vehicle_status_data and vehicle_status_data['updatedAt'] is not None:
487
+ captured_at: Optional[datetime] = robust_time_parse(vehicle_status_data['updatedAt'])
488
+ else:
489
+ captured_at: Optional[datetime] = None
490
+ if 'locked' in vehicle_status_data and vehicle_status_data['locked'] is not None:
491
+ if vehicle_status_data['locked']:
492
+ vehicle.doors.lock_state._set_value(Doors.LockState.LOCKED, measured=captured_at) # pylint: disable=protected-access
493
+ else:
494
+ vehicle.doors.lock_state._set_value(Doors.LockState.UNLOCKED, measured=captured_at) # pylint: disable=protected-access
495
+ if 'lights' in vehicle_status_data and vehicle_status_data['lights'] is not None:
496
+ if vehicle_status_data['lights'] == 'on':
497
+ vehicle.lights.light_state._set_value(Lights.LightState.ON, measured=captured_at) # pylint: disable=protected-access
498
+ elif vehicle_status_data['lights'] == 'off':
499
+ vehicle.lights.light_state._set_value(Lights.LightState.OFF, measured=captured_at) # pylint: disable=protected-access
500
+ else:
501
+ vehicle.lights.light_state._set_value(Lights.LightState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
502
+ LOG_API.info('Unknown lights state %s', vehicle_status_data['lights'])
503
+ else:
504
+ vehicle.lights.light_state._set_value(None) # pylint: disable=protected-access
505
+
506
+ if 'hood' in vehicle_status_data and vehicle_status_data['hood'] is not None:
507
+ vehicle_status_data['doors']['hood'] = vehicle_status_data['hood']
508
+ if 'trunk' in vehicle_status_data and vehicle_status_data['trunk'] is not None:
509
+ vehicle_status_data['doors']['trunk'] = vehicle_status_data['trunk']
510
+
511
+ if 'doors' in vehicle_status_data and vehicle_status_data['doors'] is not None:
512
+ all_doors_closed = True
513
+ seen_door_ids: set[str] = set()
514
+ for door_id, door_status in vehicle_status_data['doors'].items():
515
+ seen_door_ids.add(door_id)
516
+ if door_id in vehicle.doors.doors:
517
+ door: Doors.Door = vehicle.doors.doors[door_id]
518
+ else:
519
+ door = Doors.Door(door_id=door_id, doors=vehicle.doors)
520
+ vehicle.doors.doors[door_id] = door
521
+ if 'open' in door_status and door_status['open'] is not None:
522
+ if door_status['open'] == 'true':
523
+ door.open_state._set_value(Doors.OpenState.OPEN, measured=captured_at) # pylint: disable=protected-access
524
+ all_doors_closed = False
525
+ elif door_status['open'] == 'false':
526
+ door.open_state._set_value(Doors.OpenState.CLOSED, measured=captured_at) # pylint: disable=protected-access
527
+ else:
528
+ door.open_state._set_value(Doors.OpenState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
529
+ LOG_API.info('Unknown door open state %s', door_status['open'])
530
+ else:
531
+ door.open_state._set_value(None) # pylint: disable=protected-access
532
+ if 'locked' in door_status and door_status['locked'] is not None:
533
+ if door_status['locked'] == 'true':
534
+ door.lock_state._set_value(Doors.LockState.LOCKED, measured=captured_at) # pylint: disable=protected-access
535
+ elif door_status['locked'] == 'false':
536
+ door.lock_state._set_value(Doors.LockState.UNLOCKED, measured=captured_at) # pylint: disable=protected-access
537
+ else:
538
+ door.lock_state._set_value(Doors.LockState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
539
+ LOG_API.info('Unknown door lock state %s', door_status['locked'])
540
+ else:
541
+ door.lock_state._set_value(None) # pylint: disable=protected-access
542
+ log_extra_keys(LOG_API, 'door', door_status, {'open', 'locked'})
543
+ for door_id in vehicle.doors.doors.keys() - seen_door_ids:
544
+ vehicle.doors.doors[door_id].enabled = False
545
+ if all_doors_closed:
546
+ vehicle.doors.open_state._set_value(Doors.OpenState.CLOSED, measured=captured_at) # pylint: disable=protected-access
547
+ else:
548
+ vehicle.doors.open_state._set_value(Doors.OpenState.OPEN, measured=captured_at) # pylint: disable=protected-access
549
+ seen_window_ids: set[str] = set()
550
+ if 'sunRoof' in vehicle_status_data and vehicle_status_data['sunRoof'] is not None \
551
+ and 'windows' in vehicle_status_data and vehicle_status_data['windows'] is not None:
552
+ vehicle_status_data['windows']['sunRoof'] = vehicle_status_data['sunRoof']
553
+
554
+ if 'windows' in vehicle_status_data and vehicle_status_data['windows'] is not None:
555
+ all_windows_closed = True
556
+ for window_id, window_status in vehicle_status_data['windows'].items():
557
+ seen_window_ids.add(window_id)
558
+ if window_id in vehicle.windows.windows:
559
+ window: Windows.Window = vehicle.windows.windows[window_id]
560
+ else:
561
+ window = Windows.Window(window_id=window_id, windows=vehicle.windows)
562
+ vehicle.windows.windows[window_id] = window
563
+ if window_status in [item.value for item in Windows.OpenState]:
564
+ open_state: Windows.OpenState = Windows.OpenState(window_status)
565
+ if open_state == Windows.OpenState.OPEN:
566
+ all_windows_closed = False
567
+ window.open_state._set_value(open_state, measured=captured_at) # pylint: disable=protected-access
568
+ else:
569
+ LOG_API.info('Unknown window status %s', window_status)
570
+ window.open_state._set_value(Windows.OpenState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
571
+ if all_windows_closed:
572
+ vehicle.windows.open_state._set_value(Windows.OpenState.CLOSED, measured=captured_at) # pylint: disable=protected-access
573
+ else:
574
+ vehicle.windows.open_state._set_value(Windows.OpenState.OPEN, measured=captured_at) # pylint: disable=protected-access
575
+ else:
576
+ vehicle.windows.open_state._set_value(None) # pylint: disable=protected-access
577
+ for window_id in vehicle.windows.windows.keys() - seen_window_ids:
578
+ vehicle.windows.windows[window_id].enabled = False
579
+ log_extra_keys(LOG_API, f'/api/v2/vehicle-status/{vin}', vehicle_status_data, {'updatedAt', 'locked', 'lights', 'hood', 'trunk', 'doors',
580
+ 'windows', 'sunRoof'})
581
+ return vehicle
582
+
583
+ def fetch_vehicle_mycar_status(self, vehicle: SeatCupraVehicle, no_cache: bool = False) -> SeatCupraVehicle:
584
+ """
585
+ Fetches the status of a vehicle from seat/cupra API.
586
+
587
+ Args:
588
+ vehicle (GenericVehicle): The vehicle object containing the VIN.
589
+
590
+ Returns:
591
+ None
592
+ """
593
+ vin = vehicle.vin.value
594
+ if vin is None:
595
+ raise APIError('VIN is missing')
596
+ # url = f'https://ola.prod.code.seat.cloud.vwgroup.com/v1/vehicles/{vin}/measurements/engines'
597
+ # vehicle_status_data: Dict[str, Any] | None = self._fetch_data(url=url, session=self.session, no_cache=no_cache)
598
+ # measurements
599
+ # {'primary': {'fuelType': 'gasoline', 'rangeInKm': 120.0}, 'secondary': {'fuelType': 'electric', 'rangeInKm': 40.0}}
600
+ url = f'https://ola.prod.code.seat.cloud.vwgroup.com/v5/users/{self.session.user_id}/vehicles/{vin}/mycar'
601
+ vehicle_status_data: Dict[str, Any] | None = self._fetch_data(url=url, session=self.session, no_cache=no_cache)
602
+ if vehicle_status_data:
603
+ if 'engines' in vehicle_status_data and vehicle_status_data['engines'] is not None:
604
+ drive_ids: set[str] = {'primary', 'secondary'}
605
+ total_range: float = 0.0
606
+ for drive_id in drive_ids:
607
+ if drive_id in vehicle_status_data['engines'] and vehicle_status_data['engines'][drive_id] is not None \
608
+ and 'fuelType' in vehicle_status_data['engines'][drive_id] and vehicle_status_data['engines'][drive_id]['fuelType'] is not None:
609
+ try:
610
+ engine_type: GenericDrive.Type = GenericDrive.Type(vehicle_status_data['engines'][drive_id]['fuelType'])
611
+ except ValueError:
612
+ LOG_API.warning('Unknown fuelType type %s', vehicle_status_data['engines'][drive_id]['fuelType'])
613
+ engine_type: GenericDrive.Type = GenericDrive.Type.UNKNOWN
614
+
615
+ if drive_id in vehicle.drives.drives:
616
+ drive: GenericDrive = vehicle.drives.drives[drive_id]
617
+ else:
618
+ if engine_type == GenericDrive.Type.ELECTRIC:
619
+ drive = ElectricDrive(drive_id=drive_id, drives=vehicle.drives)
620
+ elif engine_type in [GenericDrive.Type.FUEL,
621
+ GenericDrive.Type.GASOLINE,
622
+ GenericDrive.Type.PETROL,
623
+ GenericDrive.Type.DIESEL,
624
+ GenericDrive.Type.CNG,
625
+ GenericDrive.Type.LPG]:
626
+ drive = CombustionDrive(drive_id=drive_id, drives=vehicle.drives)
627
+ else:
628
+ drive = GenericDrive(drive_id=drive_id, drives=vehicle.drives)
629
+ drive.type._set_value(engine_type) # pylint: disable=protected-access
630
+ vehicle.drives.add_drive(drive)
631
+ if 'levelPct' in vehicle_status_data['engines'][drive_id] and vehicle_status_data['engines'][drive_id]['levelPct'] is not None:
632
+ # pylint: disable-next=protected-access
633
+ drive.level._set_value(value=vehicle_status_data['engines'][drive_id]['levelPct'])
634
+ else:
635
+ drive.level._set_value(None) # pylint: disable=protected-access
636
+ if 'rangeKm' in vehicle_status_data['engines'][drive_id] and vehicle_status_data['engines'][drive_id]['rangeKm'] is not None:
637
+ # pylint: disable-next=protected-access
638
+ drive.range._set_value(value=vehicle_status_data['engines'][drive_id]['rangeKm'], unit=Length.KM)
639
+ total_range += vehicle_status_data['engines'][drive_id]['rangeKm']
640
+ else:
641
+ drive.range._set_value(None, unit=Length.KM) # pylint: disable=protected-access
642
+ log_extra_keys(LOG_API, drive_id, vehicle_status_data['engines'][drive_id], {'fuelType',
643
+ 'levelPct',
644
+ 'rangeKm'})
645
+ vehicle.drives.total_range._set_value(total_range, unit=Length.KM) # pylint: disable=protected-access
646
+ else:
647
+ vehicle.drives.enabled = False
648
+ if len(vehicle.drives.drives) > 0:
649
+ has_electric = False
650
+ has_combustion = False
651
+ for drive in vehicle.drives.drives.values():
652
+ if isinstance(drive, ElectricDrive):
653
+ has_electric = True
654
+ elif isinstance(drive, CombustionDrive):
655
+ has_combustion = True
656
+ if has_electric and not has_combustion and not isinstance(vehicle, SeatCupraElectricVehicle):
657
+ LOG.debug('Promoting %s to SeatCupraElectricVehicle object for %s', vehicle.__class__.__name__, vin)
658
+ vehicle = SeatCupraElectricVehicle(origin=vehicle)
659
+ self.car_connectivity.garage.replace_vehicle(vin, vehicle)
660
+ elif has_combustion and not has_electric and not isinstance(vehicle, SeatCupraCombustionVehicle):
661
+ LOG.debug('Promoting %s to SeatCupraCombustionVehicle object for %s', vehicle.__class__.__name__, vin)
662
+ vehicle = SeatCupraCombustionVehicle(origin=vehicle)
663
+ self.car_connectivity.garage.replace_vehicle(vin, vehicle)
664
+ elif has_combustion and has_electric and not isinstance(vehicle, SeatCupraHybridVehicle):
665
+ LOG.debug('Promoting %s to SeatCupraHybridVehicle object for %s', vehicle.__class__.__name__, vin)
666
+ vehicle = SeatCupraHybridVehicle(origin=vehicle)
667
+ self.car_connectivity.garage.replace_vehicle(vin, vehicle)
668
+ if 'services' in vehicle_status_data and vehicle_status_data['services'] is not None:
669
+ if 'charging' in vehicle_status_data['services'] and vehicle_status_data['services']['charging'] is not None:
670
+ charging_status: Dict = vehicle_status_data['services']['charging']
671
+ if 'targetPct' in charging_status and charging_status['targetPct'] is not None:
672
+ if isinstance(vehicle, ElectricVehicle):
673
+ vehicle.charging.settings.target_level._set_value(charging_status['targetPct']) # pylint: disable=protected-access
674
+ if 'chargeMode' in charging_status and charging_status['chargeMode'] is not None:
675
+ if charging_status['chargeMode'] in [item.value for item in Charging.ChargingType]:
676
+ if isinstance(vehicle, ElectricVehicle):
677
+ vehicle.charging.type._set_value(value=Charging.ChargingType(charging_status['chargeMode'])) # pylint: disable=protected-access
678
+ else:
679
+ LOG_API.info('Unknown charge type %s', charging_status['chargeMode'])
680
+ if isinstance(vehicle, ElectricVehicle):
681
+ vehicle.charging.type._set_value(Charging.ChargingType.UNKNOWN) # pylint: disable=protected-access
682
+ else:
683
+ if isinstance(vehicle, ElectricVehicle):
684
+ vehicle.charging.type._set_value(None) # pylint: disable=protected-access
685
+ if 'remainingTime' in charging_status and charging_status['remainingTime'] is not None:
686
+ remaining_duration: timedelta = timedelta(minutes=charging_status['remainingTime'])
687
+ estimated_date_reached: datetime = datetime.now(tz=timezone.utc) + remaining_duration
688
+ estimated_date_reached = estimated_date_reached.replace(second=0, microsecond=0)
689
+ if isinstance(vehicle, ElectricVehicle):
690
+ vehicle.charging.estimated_date_reached._set_value(value=estimated_date_reached) # pylint: disable=protected-access
691
+ else:
692
+ if isinstance(vehicle, ElectricVehicle):
693
+ vehicle.charging.estimated_date_reached._set_value(None) # pylint: disable=protected-access
694
+ log_extra_keys(LOG_API, 'charging', charging_status, {'status', 'targetPct', 'currentPct', 'chargeMode', 'remainingTime'})
695
+ else:
696
+ if isinstance(vehicle, ElectricVehicle):
697
+ vehicle.charging.enabled = False
698
+ if 'climatisation' in vehicle_status_data['services'] and vehicle_status_data['services']['climatisation'] is not None:
699
+ climatisation_status: Dict = vehicle_status_data['services']['climatisation']
700
+
701
+ if 'remainingTime' in climatisation_status and climatisation_status['remainingTime'] is not None:
702
+ remaining_duration: timedelta = timedelta(minutes=climatisation_status['remainingTime'])
703
+ estimated_date_reached: datetime = datetime.now(tz=timezone.utc) + remaining_duration
704
+ estimated_date_reached = estimated_date_reached.replace(second=0, microsecond=0)
705
+ vehicle.climatization.estimated_date_reached._set_value(value=estimated_date_reached) # pylint: disable=protected-access
706
+ else:
707
+ vehicle.climatization.estimated_date_reached._set_value(None) # pylint: disable=protected-access
708
+ # we take status, targetTemperatureCelsius, targetTemperatureFahrenheit, from climatization request
709
+ log_extra_keys(LOG_API, 'climatisation', climatisation_status, {'status', 'targetTemperatureCelsius', 'targetTemperatureFahrenheit',
710
+ 'remainingTime'})
711
+ return vehicle
712
+
713
+ def fetch_parking_position(self, vehicle: SeatCupraVehicle, no_cache: bool = False) -> SeatCupraVehicle:
714
+ """
715
+ Fetches the position of the given vehicle and updates its position attributes.
716
+
717
+ Args:
718
+ vehicle (Seat/CupraVehicle): The vehicle object containing the VIN and position attributes.
719
+
720
+ Returns:
721
+ Seat/CupraVehicle: The updated vehicle object with the fetched position data.
722
+
723
+ Raises:
724
+ APIError: If the VIN is missing.
725
+ ValueError: If the vehicle has no position object.
726
+ """
727
+ vin = vehicle.vin.value
728
+ if vin is None:
729
+ raise APIError('VIN is missing')
730
+ if vehicle.position is None:
731
+ raise ValueError('Vehicle has no position object')
732
+ url = f'https://ola.prod.code.seat.cloud.vwgroup.com/v1/vehicles/{vin}/parkingposition'
733
+ data: Dict[str, Any] | None = self._fetch_data(url=url, session=self.session, no_cache=no_cache, allow_empty=True)
734
+ if data is not None:
735
+ if 'lat' in data and data['lat'] is not None:
736
+ latitude: Optional[float] = data['lat']
737
+ else:
738
+ latitude = None
739
+ if 'lon' in data and data['lon'] is not None:
740
+ longitude: Optional[float] = data['lon']
741
+ else:
742
+ longitude = None
743
+ vehicle.position.latitude._set_value(latitude) # pylint: disable=protected-access
744
+ vehicle.position.longitude._set_value(longitude) # pylint: disable=protected-access
745
+ vehicle.position.position_type._set_value(Position.PositionType.PARKING) # pylint: disable=protected-access
746
+ log_extra_keys(LOG_API, 'parkingposition', data, {'lat', 'lon'})
747
+ else:
748
+ vehicle.position.latitude._set_value(None) # pylint: disable=protected-access
749
+ vehicle.position.longitude._set_value(None) # pylint: disable=protected-access
750
+ vehicle.position.position_type._set_value(None) # pylint: disable=protected-access
751
+ return vehicle
752
+
753
+ def fetch_mileage(self, vehicle: SeatCupraVehicle, no_cache: bool = False) -> SeatCupraVehicle:
754
+ """
755
+ Fetches the mileage of the given vehicle and updates its mileage attributes.
756
+
757
+ Args:
758
+ vehicle (Seat/CupraVehicle): The vehicle object containing the VIN and mileage attributes.
759
+
760
+ Returns:
761
+ Seat/CupraVehicle: The updated vehicle object with the fetched mileage data.
762
+
763
+ Raises:
764
+ APIError: If the VIN is missing.
765
+ ValueError: If the vehicle has no position object.
766
+ """
767
+ vin = vehicle.vin.value
768
+ if vin is None:
769
+ raise APIError('VIN is missing')
770
+ url = f'https://ola.prod.code.seat.cloud.vwgroup.com/v1/vehicles/{vin}/mileage'
771
+ data: Dict[str, Any] | None = self._fetch_data(url=url, session=self.session, no_cache=no_cache)
772
+ if data is not None:
773
+ if 'mileageKm' in data and data['mileageKm'] is not None:
774
+ vehicle.odometer._set_value(data['mileageKm'], unit=Length.KM) # pylint: disable=protected-access
775
+ else:
776
+ vehicle.odometer._set_value(None) # pylint: disable=protected-access
777
+ log_extra_keys(LOG_API, f'https://ola.prod.code.seat.cloud.vwgroup.com/v1/vehicles/{vin}/mileage', data, {'mileageKm'})
778
+ else:
779
+ vehicle.odometer._set_value(None) # pylint: disable=protected-access
780
+ return vehicle
781
+
782
+ def fetch_maintenance(self, vehicle: SeatCupraVehicle, no_cache: bool = False) -> SeatCupraVehicle:
783
+ vin = vehicle.vin.value
784
+ if vin is None:
785
+ raise APIError('VIN is missing')
786
+ url = f'https://ola.prod.code.seat.cloud.vwgroup.com/v1/vehicles/{vin}/maintenance'
787
+ data: Dict[str, Any] | None = self._fetch_data(url=url, session=self.session, no_cache=no_cache)
788
+ if data is not None:
789
+ if 'inspectionDueDays' in data and data['inspectionDueDays'] is not None:
790
+ inspection_due: timedelta = timedelta(days=data['inspectionDueDays'])
791
+ inspection_date: datetime = datetime.now(tz=timezone.utc) + inspection_due
792
+ inspection_date = inspection_date.replace(hour=0, minute=0, second=0, microsecond=0)
793
+ # pylint: disable-next=protected-access
794
+ vehicle.maintenance.inspection_due_at._set_value(value=inspection_date)
795
+ else:
796
+ vehicle.maintenance.inspection_due_at._set_value(None) # pylint: disable=protected-access
797
+ if 'inspectionDueKm' in data and data['inspectionDueKm'] is not None:
798
+ vehicle.maintenance.inspection_due_after._set_value(data['inspectionDueKm'], unit=Length.KM) # pylint: disable=protected-access
799
+ else:
800
+ vehicle.maintenance.inspection_due_after._set_value(None) # pylint: disable=protected-access
801
+ if 'oilServiceDueDays' in data and data['oilServiceDueDays'] is not None:
802
+ oil_service_due: timedelta = timedelta(days=data['oilServiceDueDays'])
803
+ oil_service_date: datetime = datetime.now(tz=timezone.utc) + oil_service_due
804
+ oil_service_date = oil_service_date.replace(hour=0, minute=0, second=0, microsecond=0)
805
+ # pylint: disable-next=protected-access
806
+ vehicle.maintenance.oil_service_due_at._set_value(value=oil_service_date)
807
+ else:
808
+ vehicle.maintenance.oil_service_due_at._set_value(None) # pylint: disable=protected-access
809
+ if 'oilServiceDueKm' in data and data['oilServiceDueKm'] is not None:
810
+ vehicle.maintenance.oil_service_due_after._set_value(data['oilServiceDueKm'], unit=Length.KM) # pylint: disable=protected-access
811
+ else:
812
+ vehicle.maintenance.oil_service_due_after._set_value(None) # pylint: disable=protected-access
813
+ log_extra_keys(LOG_API, f'/v1/vehicles/{vin}/maintenance', data, {'inspectionDueDays', 'inspectionDueKm', 'oilServiceDueDays', 'oilServiceDueKm'})
814
+ else:
815
+ vehicle.odometer._set_value(None) # pylint: disable=protected-access
816
+ return vehicle
817
+
818
+ def fetch_climatisation(self, vehicle: SeatCupraVehicle, no_cache: bool = False) -> SeatCupraVehicle:
819
+ """
820
+ Fetches the mileage of the given vehicle and updates its mileage attributes.
821
+
822
+ Args:
823
+ vehicle (Seat/CupraVehicle): The vehicle object containing the VIN and mileage attributes.
824
+
825
+ Returns:
826
+ Seat/CupraVehicle: The updated vehicle object with the fetched mileage data.
827
+
828
+ Raises:
829
+ APIError: If the VIN is missing.
830
+ ValueError: If the vehicle has no position object.
831
+ """
832
+ vin = vehicle.vin.value
833
+ if vin is None:
834
+ raise APIError('VIN is missing')
835
+ url = f'https://ola.prod.code.seat.cloud.vwgroup.com/v1/vehicles/{vin}/climatisation/status'
836
+ data: Dict[str, Any] | None = self._fetch_data(url=url, session=self.session, no_cache=no_cache)
837
+ # {'climatisationStatus': {'carCapturedTimestamp': '2025-02-18T17:24:02Z', 'climatisationState': 'off', 'climatisationTrigger': 'unsupported'}, 'windowHeatingStatus': {'carCapturedTimestamp': '2025-02-18T16:57:51Z', 'windowHeatingStatus': [{'windowLocation': 'front', 'windowHeatingState': 'off'}, {'windowLocation': 'rear', 'windowHeatingState': 'off'}]}}
838
+ if data is not None:
839
+ if 'climatisationStatus' in data and data['climatisationStatus'] is not None:
840
+ climatisation_status: Dict = data['climatisationStatus']
841
+ if 'carCapturedTimestamp' not in climatisation_status or climatisation_status['carCapturedTimestamp'] is None:
842
+ raise APIError('Could not fetch vehicle status, carCapturedTimestamp missing')
843
+ captured_at: datetime = robust_time_parse(climatisation_status['carCapturedTimestamp'])
844
+ if 'climatisationState' in climatisation_status and climatisation_status['climatisationState'] is not None:
845
+ if climatisation_status['climatisationState'].lower() in [item.value for item in Climatization.ClimatizationState]:
846
+ climatization_state: Climatization.ClimatizationState = \
847
+ Climatization.ClimatizationState(climatisation_status['climatisationState'].lower())
848
+ else:
849
+ LOG_API.info('Unknown climatization state %s not in %s', climatisation_status['climatisationState'],
850
+ str(Climatization.ClimatizationState))
851
+ climatization_state = Climatization.ClimatizationState.UNKNOWN
852
+ vehicle.climatization.state._set_value(value=climatization_state, measured=captured_at) # pylint: disable=protected-access
853
+ else:
854
+ vehicle.climatization.state._set_value(None) # pylint: disable=protected-access
855
+ log_extra_keys(LOG_API, 'climatisationStatus', data['climatisationStatus'], {'carCapturedTimestamp', 'climatisationState'})
856
+ else:
857
+ vehicle.climatization.state._set_value(None) # pylint: disable=protected-access
858
+ log_extra_keys(LOG_API, 'climatisation', data, {'climatisationStatus'})
859
+ url = f'https://ola.prod.code.seat.cloud.vwgroup.com/v2/vehicles/{vin}/climatisation/settings'
860
+ data: Dict[str, Any] | None = self._fetch_data(url=url, session=self.session, no_cache=no_cache)
861
+ if data is not None:
862
+ if not isinstance(vehicle.climatization, SeatCupraClimatization):
863
+ vehicle.climatization = SeatCupraClimatization(vehicle=vehicle, origin=vehicle.climatization)
864
+ if 'carCapturedTimestamp' not in data or data['carCapturedTimestamp'] is None:
865
+ raise APIError('Could not fetch vehicle status, carCapturedTimestamp missing')
866
+ captured_at: datetime = robust_time_parse(data['carCapturedTimestamp'])
867
+ if 'targetTemperatureInCelsius' in data and data['targetTemperatureInCelsius'] is not None:
868
+ # pylint: disable-next=protected-access
869
+ vehicle.climatization.settings.target_temperature._add_on_set_hook(self.__on_air_conditioning_settings_change)
870
+ vehicle.climatization.settings.target_temperature._is_changeable = True # pylint: disable=protected-access
871
+
872
+ target_temperature: Optional[float] = data['targetTemperatureInCelsius']
873
+ vehicle.climatization.settings.target_temperature._set_value(value=target_temperature, # pylint: disable=protected-access
874
+ measured=captured_at,
875
+ unit=Temperature.C)
876
+ vehicle.climatization.settings.target_temperature.precision = 0.5
877
+ vehicle.climatization.settings.target_temperature.minimum = 16.0
878
+ vehicle.climatization.settings.target_temperature.maximum = 29.5
879
+ elif 'targetTemperatureInFahrenheit' in data and data['targetTemperatureInFahrenheit'] is not None:
880
+ # pylint: disable-next=protected-access
881
+ vehicle.climatization.settings.target_temperature._add_on_set_hook(self.__on_air_conditioning_settings_change)
882
+ vehicle.climatization.settings.target_temperature._is_changeable = True # pylint: disable=protected-access
883
+
884
+ target_temperature = data['targetTemperatureInFahrenheit']
885
+ vehicle.climatization.settings.target_temperature._set_value(value=target_temperature, # pylint: disable=protected-access
886
+ measured=captured_at,
887
+ unit=Temperature.F)
888
+ vehicle.climatization.settings.target_temperature.precision = 0.5
889
+ vehicle.climatization.settings.target_temperature.minimum = 61.0
890
+ vehicle.climatization.settings.target_temperature.maximum = 85.5
891
+ else:
892
+ vehicle.climatization.settings.target_temperature._set_value(None) # pylint: disable=protected-access
893
+ if 'climatisationWithoutExternalPower' in data and data['climatisationWithoutExternalPower'] is not None:
894
+ # pylint: disable-next=protected-access
895
+ vehicle.climatization.settings.climatization_without_external_power._add_on_set_hook(self.__on_air_conditioning_settings_change)
896
+ vehicle.climatization.settings.climatization_without_external_power._is_changeable = True # pylint: disable=protected-access
897
+
898
+ # pylint: disable-next=protected-access
899
+ vehicle.climatization.settings.climatization_without_external_power._set_value(data['climatisationWithoutExternalPower'],
900
+ measured=captured_at)
901
+ else:
902
+ vehicle.climatization.settings.climatization_without_external_power._set_value(None) # pylint: disable=protected-access
903
+ log_extra_keys(LOG_API, f'https://ola.prod.code.seat.cloud.vwgroup.com/v2/vehicles/{vin}/climatisation/settings', data,
904
+ {'carCapturedTimestamp', 'targetTemperatureInCelsius', 'targetTemperatureInFahrenheit', 'climatisationWithoutExternalPower'})
905
+ return vehicle
906
+
907
+ def fetch_charging(self, vehicle: SeatCupraVehicle, no_cache: bool = False) -> SeatCupraVehicle:
908
+ """
909
+ Fetches the mileage of the given vehicle and updates its mileage attributes.
910
+
911
+ Args:
912
+ vehicle (Seat/CupraVehicle): The vehicle object containing the VIN and mileage attributes.
913
+
914
+ Returns:
915
+ Seat/CupraVehicle: The updated vehicle object with the fetched mileage data.
916
+
917
+ Raises:
918
+ APIError: If the VIN is missing.
919
+ ValueError: If the vehicle has no position object.
920
+ """
921
+ vin = vehicle.vin.value
922
+ if vin is None:
923
+ raise APIError('VIN is missing')
924
+ if isinstance(vehicle, ElectricVehicle):
925
+ url = f'https://ola.prod.code.seat.cloud.vwgroup.com/v1/vehicles/{vin}/charging/status'
926
+ data: Dict[str, Any] | None = self._fetch_data(url=url, session=self.session, no_cache=no_cache)
927
+
928
+ if data is not None:
929
+ if 'charging' in data and data['charging'] is not None:
930
+ if 'state' in data['charging'] and data['charging']['state'] is not None:
931
+ if data['charging']['state'] in [item.value for item in SeatCupraCharging.SeatCupraChargingState]:
932
+ volkswagen_charging_state = SeatCupraCharging.SeatCupraChargingState(data['charging']['state'])
933
+ charging_state: Charging.ChargingState = mapping_seatcupra_charging_state[volkswagen_charging_state]
934
+ else:
935
+ LOG_API.info('Unkown charging state %s not in %s', data['charging']['state'],
936
+ str(SeatCupraCharging.SeatCupraChargingState))
937
+ charging_state = Charging.ChargingState.UNKNOWN
938
+ vehicle.charging.state._set_value(value=charging_state) # pylint: disable=protected-access
939
+ else:
940
+ vehicle.charging.state._set_value(None) # pylint: disable=protected-access
941
+ log_extra_keys(LOG_API, 'charging', data['charging'], {'state'})
942
+ if 'plug' in data and data['plug'] is not None:
943
+ if 'connection' in data['plug'] and data['plug']['connection'] is not None:
944
+ if data['plug']['connection'] in [item.value for item in ChargingConnector.ChargingConnectorConnectionState]:
945
+ plug_state: ChargingConnector.ChargingConnectorConnectionState = \
946
+ ChargingConnector.ChargingConnectorConnectionState(data['plug']['connection'])
947
+ else:
948
+ LOG_API.info('Unknown plug state %s', data['plug']['connection'])
949
+ plug_state = ChargingConnector.ChargingConnectorConnectionState.UNKNOWN
950
+ vehicle.charging.connector.connection_state._set_value(value=plug_state) # pylint: disable=protected-access
951
+ else:
952
+ vehicle.charging.connector.connection_state._set_value(value=None) # pylint: disable=protected-access
953
+ if 'externalPower' in data['plug'] and data['plug']['externalPower'] is not None:
954
+ if data['plug']['externalPower'] in [item.value for item in ChargingConnector.ExternalPower]:
955
+ plug_power_state: ChargingConnector.ExternalPower = \
956
+ ChargingConnector.ExternalPower(data['plug']['externalPower'])
957
+ else:
958
+ if data['plug']['externalPower'] == 'ready':
959
+ plug_power_state = ChargingConnector.ExternalPower.AVAILABLE
960
+ else:
961
+ LOG_API.info('Unknown plug power state %s', data['plug']['externalPower'])
962
+ plug_power_state = ChargingConnector.ExternalPower.UNKNOWN
963
+ vehicle.charging.connector.external_power._set_value(value=plug_power_state) # pylint: disable=protected-access
964
+ else:
965
+ vehicle.charging.connector.external_power._set_value(None) # pylint: disable=protected-access
966
+ if 'lock' in data['plug'] and data['plug']['lock'] is not None:
967
+ if data['plug']['lock'] in [item.value for item in ChargingConnector.ChargingConnectorLockState]:
968
+ plug_lock_state: ChargingConnector.ChargingConnectorLockState = \
969
+ ChargingConnector.ChargingConnectorLockState(data['plug']['lock'])
970
+ else:
971
+ LOG_API.info('Unknown plug lock state %s', data['plug']['lock'])
972
+ plug_lock_state = ChargingConnector.ChargingConnectorLockState.UNKNOWN
973
+ vehicle.charging.connector.lock_state._set_value(value=plug_lock_state) # pylint: disable=protected-access
974
+ else:
975
+ vehicle.charging.connector.lock_state._set_value(None) # pylint: disable=protected-access
976
+ log_extra_keys(LOG_API, 'plug', data['plug'], {'connection', 'externalPower', 'lock'})
977
+ log_extra_keys(LOG_API, f'https://ola.prod.code.seat.cloud.vwgroup.com/v1/vehicles/{vin}/charging/status', data,
978
+ {'state', 'battery', 'charging', 'plug'})
979
+
980
+ url = f'https://ola.prod.code.seat.cloud.vwgroup.com/v1/vehicles/{vin}/charging/settings'
981
+ data: Dict[str, Any] | None = self._fetch_data(url=url, session=self.session, no_cache=no_cache)
982
+ if data is not None:
983
+ if 'maxChargeCurrentAc' in data and data['maxChargeCurrentAc'] is not None:
984
+ if data['maxChargeCurrentAc']:
985
+ vehicle.charging.settings.maximum_current._set_value(value=16, # pylint: disable=protected-access
986
+ unit=Current.A)
987
+ else:
988
+ vehicle.charging.settings.maximum_current._set_value(value=6, # pylint: disable=protected-access
989
+ unit=Current.A)
990
+ else:
991
+ vehicle.charging.settings.maximum_current._set_value(None) # pylint: disable=protected-access
992
+ if 'defaultMaxTargetSocPercentage' in data and data['defaultMaxTargetSocPercentage'] is not None:
993
+ vehicle.charging.settings.target_level._set_value(data['defaultMaxTargetSocPercentage']) # pylint: disable=protected-access
994
+ else:
995
+ vehicle.charging.settings.target_level._set_value(None) # pylint: disable=protected-access
996
+ return vehicle
997
+
998
+ def fetch_image(self, vehicle: SeatCupraVehicle, no_cache: bool = False) -> SeatCupraVehicle:
999
+ """
1000
+ Fetches the image of a given SeatCupraVehicle.
1001
+
1002
+ This method retrieves the image of the vehicle from a remote server. It supports caching to avoid redundant downloads.
1003
+ If caching is enabled and the image is found in the cache and is not expired, it will be loaded from the cache.
1004
+ Otherwise, it will be downloaded from the server.
1005
+
1006
+ Args:
1007
+ vehicle (SeatCupraVehicle): The vehicle object for which the image is to be fetched.
1008
+ no_cache (bool, optional): If True, bypasses the cache and fetches the image directly from the server. Defaults to False.
1009
+
1010
+ Returns:
1011
+ SeatCupraVehicle: The vehicle object with the fetched image added to its attributes.
1012
+
1013
+ Raises:
1014
+ RetrievalError: If there is a connection error, chunked encoding error, read timeout, or retry error during the image retrieval process.
1015
+ """
1016
+ if SUPPORT_IMAGES:
1017
+ url: str = f'https://ola.prod.code.seat.cloud.vwgroup.com/v1/vehicles/{vehicle.vin.value}/renders'
1018
+ data = self._fetch_data(url, session=self.session, allow_http_error=True, no_cache=no_cache)
1019
+ if data is not None: # pylint: disable=too-many-nested-blocks
1020
+ for image_id, image_url in data.items():
1021
+ if image_id == 'isDefault':
1022
+ continue
1023
+ img = None
1024
+ cache_date = None
1025
+ if self.active_config['max_age'] is not None and self.session.cache is not None and image_url in self.session.cache:
1026
+ img, cache_date_string = self.session.cache[image_url]
1027
+ img = base64.b64decode(img) # pyright: ignore[reportPossiblyUnboundVariable]
1028
+ img = Image.open(io.BytesIO(img)) # pyright: ignore[reportPossiblyUnboundVariable]
1029
+ cache_date = datetime.fromisoformat(cache_date_string)
1030
+ if img is None or self.active_config['max_age'] is None \
1031
+ or (cache_date is not None and cache_date < (datetime.utcnow() - timedelta(seconds=self.active_config['max_age']))):
1032
+ try:
1033
+ image_download_response = requests.get(image_url, stream=True, timeout=10)
1034
+ if image_download_response.status_code == requests.codes['ok']:
1035
+ img = Image.open(image_download_response.raw) # pyright: ignore[reportPossiblyUnboundVariable]
1036
+ if self.session.cache is not None:
1037
+ buffered = io.BytesIO() # pyright: ignore[reportPossiblyUnboundVariable]
1038
+ img.save(buffered, format="PNG")
1039
+ img_str = base64.b64encode(buffered.getvalue()).decode("utf-8") # pyright: ignore[reportPossiblyUnboundVariable]
1040
+ self.session.cache[image_url] = (img_str, str(datetime.utcnow()))
1041
+ elif image_download_response.status_code == requests.codes['unauthorized']:
1042
+ LOG.info('Server asks for new authorization')
1043
+ self.session.login()
1044
+ image_download_response = self.session.get(image_url, stream=True)
1045
+ if image_download_response.status_code == requests.codes['ok']:
1046
+ img = Image.open(image_download_response.raw) # pyright: ignore[reportPossiblyUnboundVariable]
1047
+ if self.session.cache is not None:
1048
+ buffered = io.BytesIO() # pyright: ignore[reportPossiblyUnboundVariable]
1049
+ img.save(buffered, format="PNG")
1050
+ img_str = base64.b64encode(buffered.getvalue()).decode("utf-8") # pyright: ignore[reportPossiblyUnboundVariable]
1051
+ self.session.cache[image_url] = (img_str, str(datetime.utcnow()))
1052
+ except requests.exceptions.ConnectionError as connection_error:
1053
+ raise RetrievalError(f'Connection error: {connection_error}') from connection_error
1054
+ except requests.exceptions.ChunkedEncodingError as chunked_encoding_error:
1055
+ raise RetrievalError(f'Error: {chunked_encoding_error}') from chunked_encoding_error
1056
+ except requests.exceptions.ReadTimeout as timeout_error:
1057
+ raise RetrievalError(f'Timeout during read: {timeout_error}') from timeout_error
1058
+ except requests.exceptions.RetryError as retry_error:
1059
+ raise RetrievalError(f'Retrying failed: {retry_error}') from retry_error
1060
+ if img is not None:
1061
+ vehicle._car_images[image_id] = img # pylint: disable=protected-access
1062
+ if image_id == 'side':
1063
+ if 'car_picture' in vehicle.images.images:
1064
+ vehicle.images.images['car_picture']._set_value(img) # pylint: disable=protected-access
1065
+ else:
1066
+ vehicle.images.images['car_picture'] = ImageAttribute(name="car_picture", parent=vehicle.images,
1067
+ value=img, tags={'carconnectivity'})
1068
+ return vehicle
1069
+
1070
+ def _record_elapsed(self, elapsed: timedelta) -> None:
1071
+ """
1072
+ Records the elapsed time.
1073
+
1074
+ Args:
1075
+ elapsed (timedelta): The elapsed time to record.
1076
+ """
1077
+ self._elapsed.append(elapsed)
1078
+
1079
+ def _fetch_data(self, url, session, no_cache=False, allow_empty=False, allow_http_error=False,
1080
+ allowed_errors=None) -> Optional[Dict[str, Any]]: # noqa: C901
1081
+ data: Optional[Dict[str, Any]] = None
1082
+ cache_date: Optional[datetime] = None
1083
+ if not no_cache and (self.active_config['max_age'] is not None and session.cache is not None and url in session.cache):
1084
+ data, cache_date_string = session.cache[url]
1085
+ cache_date = datetime.fromisoformat(cache_date_string)
1086
+ if data is None or self.active_config['max_age'] is None \
1087
+ or (cache_date is not None and cache_date < (datetime.utcnow() - timedelta(seconds=self.active_config['max_age']))):
1088
+ try:
1089
+ status_response: requests.Response = session.get(url, allow_redirects=False)
1090
+ self._record_elapsed(status_response.elapsed)
1091
+ if status_response.status_code in (requests.codes['ok'], requests.codes['multiple_status']):
1092
+ data = status_response.json()
1093
+ if session.cache is not None:
1094
+ session.cache[url] = (data, str(datetime.utcnow()))
1095
+ elif status_response.status_code == requests.codes['no_content'] and allow_empty:
1096
+ data = None
1097
+ elif status_response.status_code == requests.codes['too_many_requests']:
1098
+ raise TooManyRequestsError('Could not fetch data due to too many requests from your account. '
1099
+ f'Status Code was: {status_response.status_code}')
1100
+ elif status_response.status_code == requests.codes['unauthorized']:
1101
+ LOG.info('Server asks for new authorization')
1102
+ session.login()
1103
+ status_response = session.get(url, allow_redirects=False)
1104
+
1105
+ if status_response.status_code in (requests.codes['ok'], requests.codes['multiple_status']):
1106
+ data = status_response.json()
1107
+ if session.cache is not None:
1108
+ session.cache[url] = (data, str(datetime.utcnow()))
1109
+ elif not allow_http_error or (allowed_errors is not None and status_response.status_code not in allowed_errors):
1110
+ raise RetrievalError(f'Could not fetch data even after re-authorization. Status Code was: {status_response.status_code}')
1111
+ elif not allow_http_error or (allowed_errors is not None and status_response.status_code not in allowed_errors):
1112
+ raise RetrievalError(f'Could not fetch data. Status Code was: {status_response.status_code}')
1113
+ except requests.exceptions.ConnectionError as connection_error:
1114
+ raise RetrievalError(f'Connection error: {connection_error}.'
1115
+ ' If this happens frequently, please check if other applications communicate with the Seat/Cupra server.') from connection_error
1116
+ except requests.exceptions.ChunkedEncodingError as chunked_encoding_error:
1117
+ raise RetrievalError(f'Error: {chunked_encoding_error}') from chunked_encoding_error
1118
+ except requests.exceptions.ReadTimeout as timeout_error:
1119
+ raise RetrievalError(f'Timeout during read: {timeout_error}') from timeout_error
1120
+ except requests.exceptions.RetryError as retry_error:
1121
+ raise RetrievalError(f'Retrying failed: {retry_error}') from retry_error
1122
+ except requests.exceptions.JSONDecodeError as json_error:
1123
+ if allow_empty:
1124
+ data = None
1125
+ else:
1126
+ raise RetrievalError(f'JSON decode error: {json_error}') from json_error
1127
+ return data
1128
+
1129
+ def __on_charging_start_stop(self, start_stop_command: ChargingStartStopCommand, command_arguments: Union[str, Dict[str, Any]]) \
1130
+ -> Union[str, Dict[str, Any]]:
1131
+ if start_stop_command.parent is None or start_stop_command.parent.parent is None \
1132
+ or start_stop_command.parent.parent.parent is None or not isinstance(start_stop_command.parent.parent.parent, SeatCupraVehicle):
1133
+ raise CommandError('Object hierarchy is not as expected')
1134
+ if not isinstance(command_arguments, dict):
1135
+ raise CommandError('Command arguments are not a dictionary')
1136
+ vehicle: SeatCupraVehicle = start_stop_command.parent.parent.parent
1137
+ vin: Optional[str] = vehicle.vin.value
1138
+ if vin is None:
1139
+ raise CommandError('VIN in object hierarchy missing')
1140
+ if 'command' not in command_arguments:
1141
+ raise CommandError('Command argument missing')
1142
+ command_dict: Dict = {}
1143
+ try:
1144
+ if command_arguments['command'] == ChargingStartStopCommand.Command.START:
1145
+ url = f'https://ola.prod.code.seat.cloud.vwgroup.com/vehicles/{vin}/charging/requests/start'
1146
+ command_response: requests.Response = self.session.post(url, allow_redirects=True)
1147
+ elif command_arguments['command'] == ChargingStartStopCommand.Command.STOP:
1148
+ url = f'https://ola.prod.code.seat.cloud.vwgroup.com/vehicles/{vin}/charging/requests/stop'
1149
+ command_response: requests.Response = self.session.post(url, allow_redirects=True)
1150
+ else:
1151
+ raise CommandError(f'Unknown command {command_arguments["command"]}')
1152
+
1153
+ if command_response.status_code not in [requests.codes['ok'], requests.codes['created']]:
1154
+ LOG.error('Could not start/stop charging (%s: %s)', command_response.status_code, command_response.text)
1155
+ raise CommandError(f'Could not start/stop charging ({command_response.status_code}: {command_response.text})')
1156
+ except requests.exceptions.ConnectionError as connection_error:
1157
+ raise CommandError(f'Connection error: {connection_error}.'
1158
+ ' If this happens frequently, please check if other applications communicate with the Seat/Cupra server.') from connection_error
1159
+ except requests.exceptions.ChunkedEncodingError as chunked_encoding_error:
1160
+ raise CommandError(f'Error: {chunked_encoding_error}') from chunked_encoding_error
1161
+ except requests.exceptions.ReadTimeout as timeout_error:
1162
+ raise CommandError(f'Timeout during read: {timeout_error}') from timeout_error
1163
+ except requests.exceptions.RetryError as retry_error:
1164
+ raise CommandError(f'Retrying failed: {retry_error}') from retry_error
1165
+ return command_arguments
1166
+
1167
+ def __on_air_conditioning_start_stop(self, start_stop_command: ClimatizationStartStopCommand, command_arguments: Union[str, Dict[str, Any]]) \
1168
+ -> Union[str, Dict[str, Any]]:
1169
+ if start_stop_command.parent is None or start_stop_command.parent.parent is None \
1170
+ or start_stop_command.parent.parent.parent is None or not isinstance(start_stop_command.parent.parent.parent, SeatCupraVehicle):
1171
+ raise CommandError('Object hierarchy is not as expected')
1172
+ if not isinstance(command_arguments, dict):
1173
+ raise CommandError('Command arguments are not a dictionary')
1174
+ vehicle: SeatCupraVehicle = start_stop_command.parent.parent.parent
1175
+ vin: Optional[str] = vehicle.vin.value
1176
+ if vin is None:
1177
+ raise CommandError('VIN in object hierarchy missing')
1178
+ if 'command' not in command_arguments:
1179
+ raise CommandError('Command argument missing')
1180
+ command_dict = {}
1181
+ if command_arguments['command'] == ClimatizationStartStopCommand.Command.START:
1182
+ url = f'https://ola.prod.code.seat.cloud.vwgroup.com/v2/vehicles/{vin}/climatisation/start'
1183
+ if vehicle.climatization.settings is None:
1184
+ raise CommandError('Could not control climatisation, there are no climatisation settings for the vehicle available.')
1185
+ precision: float = 0.5
1186
+ if 'target_temperature' in command_arguments:
1187
+ # Round target temperature to nearest 0.5
1188
+ command_dict['targetTemperature'] = round(command_arguments['target_temperature'] / precision) * precision
1189
+ elif vehicle.climatization.settings.target_temperature is not None and vehicle.climatization.settings.target_temperature.enabled \
1190
+ and vehicle.climatization.settings.target_temperature.value is not None:
1191
+ temperature_value = vehicle.climatization.settings.target_temperature.value
1192
+ if vehicle.climatization.settings.target_temperature.precision is not None:
1193
+ precision = vehicle.climatization.settings.target_temperature.precision
1194
+ if vehicle.climatization.settings.target_temperature.unit == Temperature.C:
1195
+ command_dict['targetTemperatureUnit'] = 'celsius'
1196
+ elif vehicle.climatization.settings.target_temperature.unit == Temperature.F:
1197
+ command_dict['targetTemperatureUnit'] = 'farenheit'
1198
+ else:
1199
+ command_dict['targetTemperatureUnit'] = 'celsius'
1200
+ if temperature_value is not None:
1201
+ command_dict['targetTemperature'] = round(temperature_value / precision) * precision
1202
+ if 'target_temperature_unit' in command_arguments:
1203
+ if command_arguments['target_temperature_unit'] == Temperature.C:
1204
+ command_dict['targetTemperatureUnit'] = 'celsius'
1205
+ elif command_arguments['target_temperature_unit'] == Temperature.F:
1206
+ command_dict['targetTemperatureUnit'] = 'farenheit'
1207
+ else:
1208
+ command_dict['targetTemperatureUnit'] = 'celsius'
1209
+ elif command_arguments['command'] == ClimatizationStartStopCommand.Command.STOP:
1210
+ url: str = f'https://ola.prod.code.seat.cloud.vwgroup.com/vehicles/{vin}/climatisation/requests/stop'
1211
+ else:
1212
+ raise CommandError(f'Unknown command {command_arguments["command"]}')
1213
+ try:
1214
+ command_response: requests.Response = self.session.post(url, data=json.dumps(command_dict), allow_redirects=True)
1215
+ if command_response.status_code not in [requests.codes['ok'], requests.codes['created']]:
1216
+ LOG.error('Could not start/stop air conditioning (%s: %s)', command_response.status_code, command_response.text)
1217
+ raise CommandError(f'Could not start/stop air conditioning ({command_response.status_code}: {command_response.text})')
1218
+ except requests.exceptions.ConnectionError as connection_error:
1219
+ raise CommandError(f'Connection error: {connection_error}.'
1220
+ ' If this happens frequently, please check if other applications communicate with the Seat/Cupra server.') from connection_error
1221
+ except requests.exceptions.ChunkedEncodingError as chunked_encoding_error:
1222
+ raise CommandError(f'Error: {chunked_encoding_error}') from chunked_encoding_error
1223
+ except requests.exceptions.ReadTimeout as timeout_error:
1224
+ raise CommandError(f'Timeout during read: {timeout_error}') from timeout_error
1225
+ except requests.exceptions.RetryError as retry_error:
1226
+ raise CommandError(f'Retrying failed: {retry_error}') from retry_error
1227
+ return command_arguments
1228
+
1229
+ def __fetchSecurityToken(self, spin: str) -> str:
1230
+ """
1231
+ Fetches the security token from the server.
1232
+
1233
+ Returns:
1234
+ str: The security token.
1235
+ """
1236
+ command_dict = {'spin': spin}
1237
+ url = f'https://ola.prod.code.seat.cloud.vwgroup.com/v2/users/{self.session.user_id}/spin/verify'
1238
+ spin_verify_response: requests.Response = self.session.post(url, data=json.dumps(command_dict), allow_redirects=True)
1239
+ if spin_verify_response.status_code != requests.codes['created']:
1240
+ raise AuthenticationError(f'Could not fetch security token ({spin_verify_response.status_code}: {spin_verify_response.text})')
1241
+ data = spin_verify_response.json()
1242
+ if 'securityToken' in data:
1243
+ return data['securityToken']
1244
+ raise AuthenticationError('Could not fetch security token')
1245
+
1246
+ def __on_spin(self, spin_command: SpinCommand, command_arguments: Union[str, Dict[str, Any]]) \
1247
+ -> Union[str, Dict[str, Any]]:
1248
+ del spin_command
1249
+ if not isinstance(command_arguments, dict):
1250
+ raise CommandError('Command arguments are not a dictionary')
1251
+ if 'command' not in command_arguments:
1252
+ raise CommandError('Command argument missing')
1253
+ command_dict = {}
1254
+ if self.active_config['spin'] is None:
1255
+ raise CommandError('S-PIN is missing, please add S-PIN to your configuration or .netrc file')
1256
+ if 'spin' in command_arguments:
1257
+ command_dict['spin'] = command_arguments['spin']
1258
+ else:
1259
+ if self.active_config['spin'] is None or self.active_config['spin'] == '':
1260
+ raise CommandError('S-PIN is missing, please add S-PIN to your configuration or .netrc file')
1261
+ command_dict['spin'] = self.active_config['spin']
1262
+ if command_arguments['command'] == SpinCommand.Command.VERIFY:
1263
+ url = f'https://ola.prod.code.seat.cloud.vwgroup.com/v2/users/{self.session.user_id}/spin/verify'
1264
+ else:
1265
+ raise CommandError(f'Unknown command {command_arguments["command"]}')
1266
+ try:
1267
+ command_response: requests.Response = self.session.post(url, data=json.dumps(command_dict), allow_redirects=True)
1268
+ if command_response.status_code != requests.codes['created']:
1269
+ LOG.error('Could not execute spin command (%s: %s)', command_response.status_code, command_response.text)
1270
+ raise CommandError(f'Could not execute spin command ({command_response.status_code}: {command_response.text})')
1271
+ else:
1272
+ LOG.info('Spin verify command executed successfully')
1273
+ except requests.exceptions.ConnectionError as connection_error:
1274
+ raise CommandError(f'Connection error: {connection_error}.'
1275
+ ' If this happens frequently, please check if other applications communicate with the Seat/Cupra server.') from connection_error
1276
+ except requests.exceptions.ChunkedEncodingError as chunked_encoding_error:
1277
+ raise CommandError(f'Error: {chunked_encoding_error}') from chunked_encoding_error
1278
+ except requests.exceptions.ReadTimeout as timeout_error:
1279
+ raise CommandError(f'Timeout during read: {timeout_error}') from timeout_error
1280
+ except requests.exceptions.RetryError as retry_error:
1281
+ raise CommandError(f'Retrying failed: {retry_error}') from retry_error
1282
+ return command_arguments
1283
+
1284
+ def __on_wake_sleep(self, wake_sleep_command: WakeSleepCommand, command_arguments: Union[str, Dict[str, Any]]) \
1285
+ -> Union[str, Dict[str, Any]]:
1286
+ if wake_sleep_command.parent is None or wake_sleep_command.parent.parent is None \
1287
+ or not isinstance(wake_sleep_command.parent.parent, GenericVehicle):
1288
+ raise CommandError('Object hierarchy is not as expected')
1289
+ if not isinstance(command_arguments, dict):
1290
+ raise CommandError('Command arguments are not a dictionary')
1291
+ vehicle: GenericVehicle = wake_sleep_command.parent.parent
1292
+ vin: Optional[str] = vehicle.vin.value
1293
+ if vin is None:
1294
+ raise CommandError('VIN in object hierarchy missing')
1295
+ if 'command' not in command_arguments:
1296
+ raise CommandError('Command argument missing')
1297
+ if command_arguments['command'] == WakeSleepCommand.Command.WAKE:
1298
+ url = f'https://ola.prod.code.seat.cloud.vwgroup.com/v1/vehicles/{vin}/vehicle-wakeup/request'
1299
+
1300
+ try:
1301
+ command_response: requests.Response = self.session.post(url, data='{}', allow_redirects=True)
1302
+ if command_response.status_code not in (requests.codes['ok'], requests.codes['no_content']):
1303
+ LOG.error('Could not execute wake command (%s: %s)', command_response.status_code, command_response.text)
1304
+ raise CommandError(f'Could not execute wake command ({command_response.status_code}: {command_response.text})')
1305
+ except requests.exceptions.ConnectionError as connection_error:
1306
+ raise CommandError(f'Connection error: {connection_error}.'
1307
+ ' If this happens frequently, please check if other applications communicate with the Seat/Cupra server.') from connection_error
1308
+ except requests.exceptions.ChunkedEncodingError as chunked_encoding_error:
1309
+ raise CommandError(f'Error: {chunked_encoding_error}') from chunked_encoding_error
1310
+ except requests.exceptions.ReadTimeout as timeout_error:
1311
+ raise CommandError(f'Timeout during read: {timeout_error}') from timeout_error
1312
+ except requests.exceptions.RetryError as retry_error:
1313
+ raise CommandError(f'Retrying failed: {retry_error}') from retry_error
1314
+ elif command_arguments['command'] == WakeSleepCommand.Command.SLEEP:
1315
+ raise CommandError('Sleep command not supported by vehicle. Vehicle will put itself to sleep')
1316
+ else:
1317
+ raise CommandError(f'Unknown command {command_arguments["command"]}')
1318
+ return command_arguments
1319
+
1320
+ def __on_honk_flash(self, honk_flash_command: HonkAndFlashCommand, command_arguments: Union[str, Dict[str, Any]]) \
1321
+ -> Union[str, Dict[str, Any]]:
1322
+ if honk_flash_command.parent is None or honk_flash_command.parent.parent is None \
1323
+ or not isinstance(honk_flash_command.parent.parent, GenericVehicle):
1324
+ raise CommandError('Object hierarchy is not as expected')
1325
+ if not isinstance(command_arguments, dict):
1326
+ raise CommandError('Command arguments are not a dictionary')
1327
+ vehicle: GenericVehicle = honk_flash_command.parent.parent
1328
+ vin: Optional[str] = vehicle.vin.value
1329
+ if vin is None:
1330
+ raise CommandError('VIN in object hierarchy missing')
1331
+ if 'command' not in command_arguments:
1332
+ raise CommandError('Command argument missing')
1333
+ command_dict = {}
1334
+ if command_arguments['command'] in [HonkAndFlashCommand.Command.FLASH, HonkAndFlashCommand.Command.HONK_AND_FLASH]:
1335
+ if 'duration' in command_arguments:
1336
+ command_dict['durationInSeconds'] = command_arguments['duration']
1337
+ else:
1338
+ command_dict['durationInSeconds'] = 10
1339
+ command_dict['mode'] = command_arguments['command'].value
1340
+ command_dict['userPosition'] = {}
1341
+ if vehicle.position is None or vehicle.position.latitude is None or vehicle.position.longitude is None \
1342
+ or vehicle.position.latitude.value is None or vehicle.position.longitude.value is None \
1343
+ or not vehicle.position.latitude.enabled or not vehicle.position.longitude.enabled:
1344
+ raise CommandError('Can only execute honk and flash commands if vehicle position is known')
1345
+ command_dict['userPosition']['latitude'] = vehicle.position.latitude.value
1346
+ command_dict['userPosition']['longitude'] = vehicle.position.longitude.value
1347
+
1348
+ url = f'https://ola.prod.code.seat.cloud.vwgroup.com/v1/vehicles/{vin}/honk-and-flash'
1349
+ try:
1350
+ command_response: requests.Response = self.session.post(url, data=json.dumps(command_dict), allow_redirects=True)
1351
+ if command_response.status_code not in (requests.codes['ok'], requests.codes['no_content']):
1352
+ LOG.error('Could not execute honk or flash command (%s: %s)', command_response.status_code, command_response.text)
1353
+ raise CommandError(f'Could not execute honk or flash command ({command_response.status_code}: {command_response.text})')
1354
+ except requests.exceptions.ConnectionError as connection_error:
1355
+ raise CommandError(f'Connection error: {connection_error}.'
1356
+ ' If this happens frequently, please check if other applications communicate with the Seat/Cupra server.') from connection_error
1357
+ except requests.exceptions.ChunkedEncodingError as chunked_encoding_error:
1358
+ raise CommandError(f'Error: {chunked_encoding_error}') from chunked_encoding_error
1359
+ except requests.exceptions.ReadTimeout as timeout_error:
1360
+ raise CommandError(f'Timeout during read: {timeout_error}') from timeout_error
1361
+ except requests.exceptions.RetryError as retry_error:
1362
+ raise CommandError(f'Retrying failed: {retry_error}') from retry_error
1363
+ else:
1364
+ raise CommandError(f'Unknown command {command_arguments["command"]}')
1365
+ return command_arguments
1366
+
1367
+ def __on_lock_unlock(self, lock_unlock_command: LockUnlockCommand, command_arguments: Union[str, Dict[str, Any]]) \
1368
+ -> Union[str, Dict[str, Any]]:
1369
+ if lock_unlock_command.parent is None or lock_unlock_command.parent.parent is None \
1370
+ or lock_unlock_command.parent.parent.parent is None or not isinstance(lock_unlock_command.parent.parent.parent, GenericVehicle):
1371
+ raise CommandError('Object hierarchy is not as expected')
1372
+ if not isinstance(command_arguments, dict):
1373
+ raise SetterError('Command arguments are not a dictionary')
1374
+ vehicle: GenericVehicle = lock_unlock_command.parent.parent.parent
1375
+ vin: Optional[str] = vehicle.vin.value
1376
+ if vin is None:
1377
+ raise CommandError('VIN in object hierarchy missing')
1378
+ if 'command' not in command_arguments:
1379
+ raise CommandError('Command argument missing')
1380
+ command_dict = {}
1381
+ if 'spin' in command_arguments:
1382
+ spin = command_arguments['spin']
1383
+ else:
1384
+ if self.active_config['spin'] is None:
1385
+ raise CommandError('S-PIN is missing, please add S-PIN to your configuration or .netrc file')
1386
+ spin = self.active_config['spin']
1387
+ sec_token = self.__fetchSecurityToken(spin)
1388
+ if command_arguments['command'] == LockUnlockCommand.Command.LOCK:
1389
+ url = f'https://ola.prod.code.seat.cloud.vwgroup.com/v1/vehicles/{vin}/access/lock'
1390
+ elif command_arguments['command'] == LockUnlockCommand.Command.UNLOCK:
1391
+ url = f'https://ola.prod.code.seat.cloud.vwgroup.com/v1/vehicles/{vin}/access/unlock'
1392
+ else:
1393
+ raise CommandError(f'Unknown command {command_arguments["command"]}')
1394
+ try:
1395
+ headers = self.session.headers.copy()
1396
+ headers['SecToken'] = sec_token
1397
+ command_response: requests.Response = self.session.post(url, data=json.dumps(command_dict), allow_redirects=True, headers=headers)
1398
+ if command_response.status_code != requests.codes['ok']:
1399
+ LOG.error('Could not execute locking command (%s: %s)', command_response.status_code, command_response.text)
1400
+ raise CommandError(f'Could not execute locking command ({command_response.status_code}: {command_response.text})')
1401
+ except requests.exceptions.ConnectionError as connection_error:
1402
+ raise CommandError(f'Connection error: {connection_error}.'
1403
+ ' If this happens frequently, please check if other applications communicate with the Seat/Cupra server.') from connection_error
1404
+ except requests.exceptions.ChunkedEncodingError as chunked_encoding_error:
1405
+ raise CommandError(f'Error: {chunked_encoding_error}') from chunked_encoding_error
1406
+ except requests.exceptions.ReadTimeout as timeout_error:
1407
+ raise CommandError(f'Timeout during read: {timeout_error}') from timeout_error
1408
+ except requests.exceptions.RetryError as retry_error:
1409
+ raise CommandError(f'Retrying failed: {retry_error}') from retry_error
1410
+ except AuthenticationError as auth_error:
1411
+ raise CommandError(f'Authentication error: {auth_error}') from auth_error
1412
+ return command_arguments
1413
+
1414
+ def __on_air_conditioning_settings_change(self, attribute: GenericAttribute, value: Any) -> Any:
1415
+ """
1416
+ Callback for the climatization setting change.
1417
+ """
1418
+ if attribute.parent is None or not isinstance(attribute.parent, SeatCupraClimatization.Settings) \
1419
+ or attribute.parent.parent is None \
1420
+ or attribute.parent.parent.parent is None or not isinstance(attribute.parent.parent.parent, SeatCupraVehicle):
1421
+ raise SetterError('Object hierarchy is not as expected')
1422
+ settings: SeatCupraClimatization.Settings = attribute.parent
1423
+ vehicle: SeatCupraVehicle = attribute.parent.parent.parent
1424
+ vin: Optional[str] = vehicle.vin.value
1425
+ if vin is None:
1426
+ raise SetterError('VIN in object hierarchy missing')
1427
+ setting_dict = {}
1428
+ if settings.target_temperature.enabled and settings.target_temperature.value is not None:
1429
+ # Round target temperature to nearest 0.5
1430
+ # Check if the attribute changed is the target_temperature attribute
1431
+ if isinstance(attribute, TemperatureAttribute) and attribute.id == 'target_temperature':
1432
+ setting_dict['targetTemperature'] = round(value * 2) / 2
1433
+ else:
1434
+ setting_dict['targetTemperature'] = round(settings.target_temperature.value * 2) / 2
1435
+ if settings.target_temperature.unit == Temperature.C:
1436
+ setting_dict['targetTemperatureUnit'] = 'celsius'
1437
+ elif settings.target_temperature.unit == Temperature.F:
1438
+ setting_dict['targetTemperatureUnit'] = 'farenheit'
1439
+ else:
1440
+ setting_dict['targetTemperatureUnit'] = 'celsius'
1441
+ if isinstance(attribute, BooleanAttribute) and attribute.id == 'climatisation_without_external_power':
1442
+ setting_dict['climatisationWithoutExternalPower'] = value
1443
+ elif settings.climatization_without_external_power.enabled and settings.climatization_without_external_power.value is not None:
1444
+ setting_dict['climatisationWithoutExternalPower'] = settings.climatization_without_external_power.value
1445
+
1446
+ url: str = f'https://ola.prod.code.seat.cloud.vwgroup.com/v2/vehicles/{vin}/climatisation/settings'
1447
+ try:
1448
+ settings_response: requests.Response = self.session.post(url, data=json.dumps(setting_dict), allow_redirects=True)
1449
+ if settings_response.status_code not in [requests.codes['ok'], requests.codes['created']]:
1450
+ LOG.error('Could not set climatization settings (%s) %s', settings_response.status_code, settings_response.text)
1451
+ raise SetterError(f'Could not set value ({settings_response.status_code}): {settings_response.text}')
1452
+ except requests.exceptions.ConnectionError as connection_error:
1453
+ raise SetterError(f'Connection error: {connection_error}.'
1454
+ ' If this happens frequently, please check if other applications communicate with the Seat/Cupra server.') from connection_error
1455
+ except requests.exceptions.ChunkedEncodingError as chunked_encoding_error:
1456
+ raise SetterError(f'Error: {chunked_encoding_error}') from chunked_encoding_error
1457
+ except requests.exceptions.ReadTimeout as timeout_error:
1458
+ raise SetterError(f'Timeout during read: {timeout_error}') from timeout_error
1459
+ except requests.exceptions.RetryError as retry_error:
1460
+ raise SetterError(f'Retrying failed: {retry_error}') from retry_error
1461
+ return value
1462
+
1463
+ def get_version(self) -> str:
1464
+ return __version__
1465
+
1466
+ def get_type(self) -> str:
1467
+ return "carconnectivity-connector-seatcupra"
1468
+
1469
+ def get_name(self) -> str:
1470
+ return "Seat/Cupra Connector"