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.
- carconnectivity_connector_seatcupra-0.1a1.dist-info/LICENSE +21 -0
- carconnectivity_connector_seatcupra-0.1a1.dist-info/METADATA +124 -0
- carconnectivity_connector_seatcupra-0.1a1.dist-info/RECORD +17 -0
- carconnectivity_connector_seatcupra-0.1a1.dist-info/WHEEL +5 -0
- carconnectivity_connector_seatcupra-0.1a1.dist-info/top_level.txt +1 -0
- carconnectivity_connectors/seatcupra/__init__.py +0 -0
- carconnectivity_connectors/seatcupra/_version.py +21 -0
- carconnectivity_connectors/seatcupra/auth/__init__.py +0 -0
- carconnectivity_connectors/seatcupra/auth/auth_util.py +141 -0
- carconnectivity_connectors/seatcupra/auth/helpers/blacklist_retry.py +29 -0
- carconnectivity_connectors/seatcupra/auth/my_cupra_session.py +244 -0
- carconnectivity_connectors/seatcupra/auth/openid_session.py +440 -0
- carconnectivity_connectors/seatcupra/auth/session_manager.py +150 -0
- carconnectivity_connectors/seatcupra/auth/vw_web_session.py +239 -0
- carconnectivity_connectors/seatcupra/charging.py +74 -0
- carconnectivity_connectors/seatcupra/connector.py +686 -0
- carconnectivity_connectors/seatcupra/ui/connector_ui.py +39 -0
@@ -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"
|