carconnectivity-connector-skoda 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.

Potentially problematic release.


This version of carconnectivity-connector-skoda might be problematic. Click here for more details.

@@ -0,0 +1,1624 @@
1
+ """Module implements the connector to interact with the Skoda API.""" # pylint: disable=too-many-lines
2
+ from __future__ import annotations
3
+ from typing import TYPE_CHECKING
4
+
5
+ import threading
6
+ import os
7
+ import logging
8
+ import netrc
9
+ from datetime import datetime, timedelta, timezone
10
+ import json
11
+
12
+ import requests
13
+
14
+
15
+ from carconnectivity.garage import Garage
16
+ from carconnectivity.vehicle import GenericVehicle
17
+ from carconnectivity.errors import AuthenticationError, TooManyRequestsError, RetrievalError, APIError, APICompatibilityError, \
18
+ TemporaryAuthenticationError, ConfigurationError, SetterError
19
+ from carconnectivity.util import robust_time_parse, log_extra_keys, config_remove_credentials
20
+ from carconnectivity.units import Length, Speed, Power, Temperature
21
+ from carconnectivity.doors import Doors
22
+ from carconnectivity.windows import Windows
23
+ from carconnectivity.lights import Lights
24
+ from carconnectivity.drive import GenericDrive, ElectricDrive, CombustionDrive
25
+ from carconnectivity.attributes import BooleanAttribute, DurationAttribute, TemperatureAttribute
26
+ from carconnectivity.charging import Charging
27
+ from carconnectivity.position import Position
28
+ from carconnectivity.climatization import Climatization
29
+ from carconnectivity.charging_connector import ChargingConnector
30
+ from carconnectivity.commands import Commands
31
+ from carconnectivity.command_impl import ClimatizationStartStopCommand, ChargingStartStopCommand, HonkAndFlashCommand, LockUnlockCommand
32
+
33
+ from carconnectivity_connectors.base.connector import BaseConnector
34
+ from carconnectivity_connectors.skoda.auth.session_manager import SessionManager, SessionUser, Service
35
+ from carconnectivity_connectors.skoda.auth.my_skoda_session import MySkodaSession
36
+ from carconnectivity_connectors.skoda.vehicle import SkodaVehicle, SkodaElectricVehicle, SkodaCombustionVehicle, SkodaHybridVehicle
37
+ from carconnectivity_connectors.skoda.capability import Capability
38
+ from carconnectivity_connectors.skoda.charging import SkodaCharging, mapping_skoda_charging_state
39
+ from carconnectivity_connectors.skoda.climatization import SkodaClimatization
40
+ from carconnectivity_connectors.skoda.error import Error
41
+ from carconnectivity_connectors.skoda._version import __version__
42
+ from carconnectivity_connectors.skoda.mqtt_client import SkodaMQTTClient
43
+ from carconnectivity_connectors.skoda.command_impl import SpinCommand
44
+
45
+ if TYPE_CHECKING:
46
+ from typing import Dict, List, Optional, Any, Set, Union
47
+
48
+ from carconnectivity.carconnectivity import CarConnectivity
49
+
50
+ LOG: logging.Logger = logging.getLogger("carconnectivity.connectors.skoda")
51
+ LOG_API: logging.Logger = logging.getLogger("carconnectivity.connectors.skoda-api-debug")
52
+
53
+
54
+ class Connector(BaseConnector):
55
+ """
56
+ Connector class for Skoda API connectivity.
57
+ Args:
58
+ car_connectivity (CarConnectivity): An instance of CarConnectivity.
59
+ config (Dict): Configuration dictionary containing connection details.
60
+ Attributes:
61
+ max_age (Optional[int]): Maximum age for cached data in seconds.
62
+ """
63
+ def __init__(self, connector_id: str, car_connectivity: CarConnectivity, config: Dict) -> None:
64
+ BaseConnector.__init__(self, connector_id=connector_id, car_connectivity=car_connectivity, config=config)
65
+
66
+ self._mqtt_client: SkodaMQTTClient = SkodaMQTTClient(skoda_connector=self)
67
+
68
+ self._background_thread: Optional[threading.Thread] = None
69
+ self._background_connect_thread: Optional[threading.Thread] = None
70
+ self._stop_event = threading.Event()
71
+
72
+ self.connected: BooleanAttribute = BooleanAttribute(name="connected", parent=self)
73
+ self.interval: DurationAttribute = DurationAttribute(name="interval", parent=self)
74
+ self.commands: Commands = Commands(parent=self)
75
+
76
+ self.user_id: Optional[str] = None
77
+
78
+ # Configure logging
79
+ if 'log_level' in config and config['log_level'] is not None:
80
+ config['log_level'] = config['log_level'].upper()
81
+ if config['log_level'] in logging._nameToLevel:
82
+ LOG.setLevel(config['log_level'])
83
+ self.log_level._set_value(config['log_level']) # pylint: disable=protected-access
84
+ logging.getLogger('requests').setLevel(config['log_level'])
85
+ logging.getLogger('urllib3').setLevel(config['log_level'])
86
+ logging.getLogger('oauthlib').setLevel(config['log_level'])
87
+ else:
88
+ raise ConfigurationError(f'Invalid log level: "{config["log_level"]}" not in {list(logging._nameToLevel.keys())}')
89
+ if 'api_log_level' in config and config['api_log_level'] is not None:
90
+ config['api_log_level'] = config['api_log_level'].upper()
91
+ if config['api_log_level'] in logging._nameToLevel:
92
+ LOG_API.setLevel(config['api_log_level'])
93
+ else:
94
+ raise ConfigurationError(f'Invalid log level: "{config["log_level"]}" not in {list(logging._nameToLevel.keys())}')
95
+ LOG.info("Loading skoda connector with config %s", config_remove_credentials(self.config))
96
+
97
+ if 'spin' in config and config['spin'] is not None:
98
+ self._spin: Optional[str] = config['spin']
99
+ else:
100
+ self._spin = None
101
+
102
+ username: Optional[str] = None
103
+ password: Optional[str] = None
104
+ if 'username' in self.config and 'password' in self.config:
105
+ username = self.config['username']
106
+ password = self.config['password']
107
+ else:
108
+ if 'netrc' in self.config:
109
+ netrc_filename: str = self.config['netrc']
110
+ else:
111
+ netrc_filename = os.path.join(os.path.expanduser("~"), ".netrc")
112
+ try:
113
+ secrets = netrc.netrc(file=netrc_filename)
114
+ secret: tuple[str, str, str] | None = secrets.authenticators("skoda")
115
+ if secret is None:
116
+ raise AuthenticationError(f'Authentication using {netrc_filename} failed: skoda not found in netrc')
117
+ username, account, password = secret
118
+
119
+ if self._spin is None and account is not None:
120
+ try:
121
+ self._spin = account
122
+ except ValueError as err:
123
+ LOG.error('Could not parse spin from netrc: %s', err)
124
+ except netrc.NetrcParseError as err:
125
+ LOG.error('Authentification using %s failed: %s', netrc_filename, err)
126
+ raise AuthenticationError(f'Authentication using {netrc_filename} failed: {err}') from err
127
+ except TypeError as err:
128
+ if 'username' not in self.config:
129
+ raise AuthenticationError(f'"skoda" entry was not found in {netrc_filename} netrc-file.'
130
+ ' Create it or provide username and password in config') from err
131
+ except FileNotFoundError as err:
132
+ raise AuthenticationError(f'{netrc_filename} netrc-file was not found. Create it or provide username and password in config') from err
133
+
134
+ interval: int = 300
135
+ if 'interval' in self.config:
136
+ interval = self.config['interval']
137
+ if interval < 300:
138
+ raise ValueError('Intervall must be at least 300 seconds')
139
+ self.max_age: int = interval - 1
140
+ if 'max_age' in self.config:
141
+ self.max_age = self.config['max_age']
142
+ self.interval._set_value(timedelta(seconds=interval)) # pylint: disable=protected-access
143
+
144
+ if username is None or password is None:
145
+ raise AuthenticationError('Username or password not provided')
146
+
147
+ self._manager: SessionManager = SessionManager(tokenstore=car_connectivity.get_tokenstore(), cache=car_connectivity.get_cache())
148
+ session: requests.Session = self._manager.get_session(Service.MY_SKODA, SessionUser(username=username, password=password))
149
+ if not isinstance(session, MySkodaSession):
150
+ raise AuthenticationError('Could not create session')
151
+ self.session: MySkodaSession = session
152
+ self.session.retries = 3
153
+ self.session.timeout = 180
154
+ self.session.refresh()
155
+
156
+ self._elapsed: List[timedelta] = []
157
+
158
+ def startup(self) -> None:
159
+ self._stop_event.clear()
160
+ # Start background thread for Rest API polling
161
+ self._background_thread = threading.Thread(target=self._background_loop, daemon=False)
162
+ self._background_thread.start()
163
+ # Start background thread for MQTT connection
164
+ self._background_connect_thread = threading.Thread(target=self._background_connect_loop, daemon=False)
165
+ self._background_connect_thread.start()
166
+ # Start MQTT thread
167
+ self._mqtt_client.loop_start()
168
+
169
+ def _background_connect_loop(self) -> None:
170
+ while not self._stop_event.is_set():
171
+ try:
172
+ self._mqtt_client.connect()
173
+ break
174
+ except ConnectionRefusedError as e:
175
+ LOG.error('Could not connect to MQTT-Server: %s, will retry in 10 seconds', e)
176
+ self._stop_event.wait(10)
177
+
178
+ def _background_loop(self) -> None:
179
+ self._stop_event.clear()
180
+ fetch: bool = True
181
+ while not self._stop_event.is_set():
182
+ interval = 300
183
+ try:
184
+ try:
185
+ if fetch:
186
+ self.fetch_all()
187
+ fetch = False
188
+ else:
189
+ self.update_vehicles()
190
+ self.last_update._set_value(value=datetime.now(tz=timezone.utc)) # pylint: disable=protected-access
191
+ if self.interval.value is not None:
192
+ interval: float = self.interval.value.total_seconds()
193
+ except Exception:
194
+ if self.interval.value is not None:
195
+ interval: float = self.interval.value.total_seconds()
196
+ raise
197
+ except TooManyRequestsError as err:
198
+ LOG.error('Retrieval error during update. Too many requests from your account (%s). Will try again after 15 minutes', str(err))
199
+ self._stop_event.wait(900)
200
+ except RetrievalError as err:
201
+ LOG.error('Retrieval error during update (%s). Will try again after configured interval of %ss', str(err), interval)
202
+ self._stop_event.wait(interval)
203
+ except APIError as err:
204
+ LOG.error('API error during update (%s). Will try again after configured interval of %ss', str(err), interval)
205
+ self._stop_event.wait(interval)
206
+ except APICompatibilityError as err:
207
+ LOG.error('API compatability error during update (%s). Will try again after configured interval of %ss', str(err), interval)
208
+ self._stop_event.wait(interval)
209
+ except TemporaryAuthenticationError as err:
210
+ LOG.error('Temporary authentification error during update (%s). Will try again after configured interval of %ss', str(err), interval)
211
+ self._stop_event.wait(interval)
212
+ else:
213
+ self._stop_event.wait(interval)
214
+
215
+ def persist(self) -> None:
216
+ """
217
+ Persists the current state using the manager's persist method.
218
+
219
+ This method calls the `persist` method of the `_manager` attribute to save the current state.
220
+ """
221
+ self._manager.persist()
222
+ return
223
+
224
+ def shutdown(self) -> None:
225
+ """
226
+ Shuts down the connector by persisting current state, closing the session,
227
+ and cleaning up resources.
228
+
229
+ This method performs the following actions:
230
+ 1. Persists the current state.
231
+ 2. Closes the session.
232
+ 3. Sets the session and manager to None.
233
+ 4. Calls the shutdown method of the base connector.
234
+ """
235
+ self._mqtt_client.disconnect()
236
+ # Stop MQTT thread
237
+ self._mqtt_client.loop_stop()
238
+ # Disable and remove all vehicles managed soley by this connector
239
+ for vehicle in self.car_connectivity.garage.list_vehicles():
240
+ if len(vehicle.managing_connectors) == 1 and self in vehicle.managing_connectors:
241
+ self.car_connectivity.garage.remove_vehicle(vehicle.id)
242
+ vehicle.enabled = False
243
+ self._stop_event.set()
244
+ if self._background_thread is not None:
245
+ self._background_thread.join()
246
+ if self._background_connect_thread is not None:
247
+ self._background_connect_thread.join()
248
+ self.persist()
249
+ self.session.close()
250
+ return super().shutdown()
251
+
252
+ def fetch_all(self) -> None:
253
+ """
254
+ Fetches all necessary data for the connector.
255
+
256
+ This method calls the `fetch_vehicles` method to retrieve vehicle data.
257
+ """
258
+ self.fetch_vehicles()
259
+ self.car_connectivity.transaction_end()
260
+
261
+ def fetch_user(self) -> None:
262
+ """
263
+ Fetches the user data from the Skoda Connect API.
264
+
265
+ This method sends a request to the Skoda Connect API to retrieve the user data associated with the user's account.
266
+
267
+ Returns:
268
+ None
269
+ """
270
+ url = 'https://mysmob.api.connect.skoda-auto.cz/api/v1/users'
271
+ data: Dict[str, Any] | None = self._fetch_data(url, session=self.session)
272
+ if data:
273
+ if 'id' in data and data['id'] is not None:
274
+ self.user_id = data['id']
275
+
276
+ def fetch_vehicles(self) -> None:
277
+ """
278
+ Fetches the list of vehicles from the Skoda Connect API and updates the garage with new vehicles.
279
+ This method sends a request to the Skoda Connect API to retrieve the list of vehicles associated with the user's account.
280
+ If new vehicles are found in the response, they are added to the garage.
281
+
282
+ Returns:
283
+ None
284
+ """
285
+ garage: Garage = self.car_connectivity.garage
286
+ url = 'https://mysmob.api.connect.skoda-auto.cz/api/v2/garage'
287
+ data: Dict[str, Any] | None = self._fetch_data(url, session=self.session)
288
+ seen_vehicle_vins: set[str] = set()
289
+ if data is not None:
290
+ if 'vehicles' in data and data['vehicles'] is not None:
291
+ for vehicle_dict in data['vehicles']:
292
+ if 'vin' in vehicle_dict and vehicle_dict['vin'] is not None:
293
+ seen_vehicle_vins.add(vehicle_dict['vin'])
294
+ vehicle: Optional[SkodaVehicle] = garage.get_vehicle(vehicle_dict['vin']) # pyright: ignore[reportAssignmentType]
295
+ if not vehicle:
296
+ vehicle = SkodaVehicle(vin=vehicle_dict['vin'], garage=garage, managing_connector=self)
297
+ garage.add_vehicle(vehicle_dict['vin'], vehicle)
298
+
299
+ if 'licensePlate' in vehicle_dict and vehicle_dict['licensePlate'] is not None:
300
+ vehicle.license_plate._set_value(vehicle_dict['licensePlate']) # pylint: disable=protected-access
301
+ else:
302
+ vehicle.license_plate._set_value(None) # pylint: disable=protected-access
303
+
304
+ if 'name' in vehicle_dict and vehicle_dict['name'] is not None:
305
+ vehicle.name._set_value(vehicle_dict['name']) # pylint: disable=protected-access
306
+ else:
307
+ vehicle.name._set_value(None) # pylint: disable=protected-access
308
+
309
+ log_extra_keys(LOG_API, 'vehicles', vehicle_dict, {'vin', 'licensePlate', 'name'})
310
+
311
+ vehicle = self.fetch_vehicle_details(vehicle)
312
+ else:
313
+ raise APIError('Could not parse vehicle, vin missing')
314
+ for vin in set(garage.list_vehicle_vins()) - seen_vehicle_vins:
315
+ vehicle_to_remove = garage.get_vehicle(vin)
316
+ if vehicle_to_remove is not None and vehicle_to_remove.is_managed_by_connector(self):
317
+ garage.remove_vehicle(vin)
318
+ self.update_vehicles()
319
+
320
+ def update_vehicles(self) -> None:
321
+ """
322
+ Updates the status of all vehicles in the garage managed by this connector.
323
+
324
+ This method iterates through all vehicle VINs in the garage, and for each vehicle that is
325
+ managed by this connector and is an instance of SkodaVehicle, it updates the vehicle's status
326
+ by fetching data from various APIs. If the vehicle is an instance of SkodaElectricVehicle,
327
+ it also fetches charging information.
328
+
329
+ Returns:
330
+ None
331
+ """
332
+ garage: Garage = self.car_connectivity.garage
333
+ for vin in set(garage.list_vehicle_vins()):
334
+ vehicle_to_update: Optional[GenericVehicle] = garage.get_vehicle(vin)
335
+ if vehicle_to_update is not None and isinstance(vehicle_to_update, SkodaVehicle) and vehicle_to_update.is_managed_by_connector(self):
336
+ vehicle_to_update = self.fetch_vehicle_status(vehicle_to_update)
337
+ vehicle_to_update = self.fetch_driving_range(vehicle_to_update)
338
+ if vehicle_to_update.capabilities is not None and vehicle_to_update.capabilities.enabled:
339
+ if vehicle_to_update.capabilities.has_capability('PARKING_POSITION'):
340
+ vehicle_to_update = self.fetch_position(vehicle_to_update)
341
+ if vehicle_to_update.capabilities.has_capability('CHARGING') and isinstance(vehicle_to_update, SkodaElectricVehicle):
342
+ vehicle_to_update = self.fetch_charging(vehicle_to_update)
343
+ if vehicle_to_update.capabilities.has_capability('AIR_CONDITIONING'):
344
+ vehicle_to_update = self.fetch_air_conditioning(vehicle_to_update)
345
+
346
+ def fetch_charging(self, vehicle: SkodaElectricVehicle, no_cache: bool = False) -> SkodaElectricVehicle:
347
+ """
348
+ Fetches the charging information for a given Skoda electric vehicle.
349
+
350
+ Args:
351
+ vehicle (SkodaElectricVehicle): The Skoda electric vehicle object.
352
+
353
+ Raises:
354
+ APIError: If the VIN is missing or if the carCapturedTimestamp is missing in the response data.
355
+ ValueError: If the vehicle has no charging object.
356
+ """
357
+ vin = vehicle.vin.value
358
+ if vin is None:
359
+ raise APIError('VIN is missing')
360
+ if vehicle.charging is None:
361
+ raise ValueError('Vehicle has no charging object')
362
+ url = f'https://mysmob.api.connect.skoda-auto.cz/api/v1/charging/{vin}'
363
+ data: Dict[str, Any] | None = self._fetch_data(url=url, session=self.session, no_cache=no_cache)
364
+ if data is not None:
365
+ if not vehicle.climatization.commands.contains_command('start-stop'):
366
+ start_stop_command: ChargingStartStopCommand = ChargingStartStopCommand(parent=vehicle.charging.commands)
367
+ start_stop_command._add_on_set_hook(self.__on_charging_start_stop) # pylint: disable=protected-access
368
+ start_stop_command.enabled = True
369
+ vehicle.charging.commands.add_command(start_stop_command)
370
+ if 'carCapturedTimestamp' in data and data['carCapturedTimestamp'] is not None:
371
+ captured_at: datetime = robust_time_parse(data['carCapturedTimestamp'])
372
+ else:
373
+ raise APIError('Could not fetch charging, carCapturedTimestamp missing')
374
+ if 'isVehicleInSavedLocation' in data and data['isVehicleInSavedLocation'] is not None:
375
+ if vehicle.charging is not None:
376
+ if not isinstance(vehicle.charging, SkodaCharging):
377
+ vehicle.charging = SkodaCharging(origin=vehicle.charging)
378
+ # pylint: disable-next=protected-access
379
+ vehicle.charging.is_in_saved_location._set_value(data['isVehicleInSavedLocation'], measured=captured_at)
380
+ if 'status' in data and data['status'] is not None:
381
+ if 'state' in data['status'] and data['status']['state'] is not None:
382
+ if data['status']['state'] in [item.name for item in SkodaCharging.SkodaChargingState]:
383
+ skoda_charging_state = SkodaCharging.SkodaChargingState[data['status']['state']]
384
+ charging_state: Charging.ChargingState = mapping_skoda_charging_state[skoda_charging_state]
385
+ else:
386
+ LOG_API.info('Unkown charging state %s not in %s', data['status']['state'], str(SkodaCharging.SkodaChargingState))
387
+ charging_state = Charging.ChargingState.UNKNOWN
388
+
389
+ # pylint: disable-next=protected-access
390
+ vehicle.charging.state._set_value(value=charging_state, measured=captured_at)
391
+ else:
392
+ vehicle.charging.state._set_value(None, measured=captured_at) # pylint: disable=protected-access
393
+ if 'chargingRateInKilometersPerHour' in data['status'] and data['status']['chargingRateInKilometersPerHour'] is not None:
394
+ # pylint: disable-next=protected-access
395
+ vehicle.charging.rate._set_value(value=data['status']['chargingRateInKilometersPerHour'], measured=captured_at, unit=Speed.KMH)
396
+ else:
397
+ vehicle.charging.rate._set_value(None, measured=captured_at, unit=Speed.KMH) # pylint: disable=protected-access
398
+ if 'chargePowerInKw' in data['status'] and data['status']['chargePowerInKw'] is not None:
399
+ # pylint: disable-next=protected-access
400
+ vehicle.charging.power._set_value(value=data['status']['chargePowerInKw'], measured=captured_at, unit=Power.KW)
401
+ else:
402
+ vehicle.charging.power._set_value(None, measured=captured_at, unit=Power.KW) # pylint: disable=protected-access
403
+ if 'remainingTimeToFullyChargedInMinutes' in data['status'] and data['status']['remainingTimeToFullyChargedInMinutes'] is not None:
404
+ remaining_duration: timedelta = timedelta(minutes=data['status']['remainingTimeToFullyChargedInMinutes'])
405
+ estimated_date_reached: datetime = captured_at + remaining_duration
406
+ estimated_date_reached = estimated_date_reached.replace(second=0, microsecond=0)
407
+ # pylint: disable-next=protected-access
408
+ vehicle.charging.estimated_date_reached._set_value(value=estimated_date_reached, measured=captured_at)
409
+ else:
410
+ vehicle.charging.estimated_date_reached._set_value(None, measured=captured_at) # pylint: disable=protected-access
411
+ if 'chargeType' in data['status'] and data['status']['chargeType'] is not None:
412
+ if data['status']['chargeType'] in [item.name for item in Charging.ChargingType]:
413
+ charge_type: Charging.ChargingType = Charging.ChargingType[data['status']['chargeType']]
414
+ else:
415
+ LOG_API.info('Unknown charge type %s not in %s', data['status']['chargeType'], str(Charging.ChargingType))
416
+ charge_type = Charging.ChargingType.UNKNOWN
417
+ # pylint: disable-next=protected-access
418
+ vehicle.charging.type._set_value(value=charge_type, measured=captured_at)
419
+ else:
420
+ # pylint: disable-next=protected-access
421
+ vehicle.charging.type._set_value(None, measured=captured_at)
422
+ if 'battery' in data['status'] and data['status']['battery'] is not None:
423
+ for drive in vehicle.drives.drives.values():
424
+ # Assume first electric drive is the right one
425
+ if isinstance(drive, ElectricDrive):
426
+ if 'remainingCruisingRangeInMeters' in data['status']['battery'] \
427
+ and data['status']['battery']['remainingCruisingRangeInMeters'] is not None:
428
+ cruising_range_in_km: float = data['status']['battery']['remainingCruisingRangeInMeters'] / 1000
429
+ # pylint: disable-next=protected-access
430
+ drive.range._set_value(value=cruising_range_in_km, measured=captured_at, unit=Length.KM)
431
+ if 'stateOfChargeInPercent' in data['status']['battery'] \
432
+ and data['status']['battery']['stateOfChargeInPercent'] is not None:
433
+ # pylint: disable-next=protected-access
434
+ drive.level._set_value(value=data['status']['battery']['stateOfChargeInPercent'], measured=captured_at)
435
+ log_extra_keys(LOG_API, 'status', data['status']['battery'], {'remainingCruisingRangeInMeters',
436
+ 'stateOfChargeInPercent'})
437
+ break
438
+ log_extra_keys(LOG_API, 'status', data['status'], {'chargingRateInKilometersPerHour',
439
+ 'chargePowerInKw',
440
+ 'remainingTimeToFullyChargedInMinutes',
441
+ 'state',
442
+ 'chargeType',
443
+ 'battery'})
444
+ if 'settings' in data and data['settings'] is not None:
445
+ if 'targetStateOfChargeInPercent' in data['settings'] and data['settings']['targetStateOfChargeInPercent'] is not None \
446
+ and vehicle.charging is not None and vehicle.charging.settings is not None:
447
+ # pylint: disable-next=protected-access
448
+ vehicle.charging.settings.target_level._set_value(value=data['settings']['targetStateOfChargeInPercent'], measured=captured_at)
449
+ else:
450
+ vehicle.charging.settings.target_level._set_value(None, measured=captured_at) # pylint: disable=protected-access
451
+ if 'maxChargeCurrentAc' in data['settings'] and data['settings']['maxChargeCurrentAc'] is not None \
452
+ and vehicle.charging is not None and vehicle.charging.settings is not None:
453
+ if data['settings']['maxChargeCurrentAc'] == 'MAXIMUM':
454
+ vehicle.charging.settings.maximum_current._set_value(value=11, measured=captured_at) # pylint: disable=protected-access
455
+ elif data['settings']['maxChargeCurrentAc'] == 'REDUCED':
456
+ vehicle.charging.settings.maximum_current._set_value(value=6, measured=captured_at) # pylint: disable=protected-access
457
+ else:
458
+ LOG_API.info('Unknown maxChargeCurrentAc %s not in %s', data['settings']['maxChargeCurrentAc'], ['MAXIMUM', 'REDUCED'])
459
+ vehicle.charging.settings.maximum_current._set_value(None, measured=captured_at) # pylint: disable=protected-access
460
+ else:
461
+ vehicle.charging.settings.maximum_current._set_value(None, measured=captured_at) # pylint: disable=protected-access
462
+ if 'autoUnlockPlugWhenCharged' in data['settings'] and data['settings']['autoUnlockPlugWhenCharged'] is not None:
463
+ if data['settings']['autoUnlockPlugWhenCharged'] in ['ON', 'PERMANENT']:
464
+ vehicle.charging.settings.auto_unlock._set_value(True, measured=captured_at) # pylint: disable=protected-access
465
+ elif data['settings']['autoUnlockPlugWhenCharged'] == 'OFF':
466
+ vehicle.charging.settings.auto_unlock._set_value(False, measured=captured_at) # pylint: disable=protected-access
467
+ else:
468
+ LOG_API.info('Unknown autoUnlockPlugWhenCharged %s not in %s', data['settings']['autoUnlockPlugWhenCharged'],
469
+ ['ON', 'PERMANENT', 'OFF'])
470
+ vehicle.charging.settings.auto_unlock._set_value(None, measured=captured_at) # pylint: disable=protected-access
471
+ if 'preferredChargeMode' in data['settings'] and data['settings']['preferredChargeMode'] is not None:
472
+ if not isinstance(vehicle.charging, SkodaCharging):
473
+ vehicle.charging = SkodaCharging(origin=vehicle.charging)
474
+ if data['settings']['preferredChargeMode'] in [item.name for item in SkodaCharging.SkodaChargeMode]:
475
+ preferred_charge_mode: SkodaCharging.SkodaChargeMode = SkodaCharging.SkodaChargeMode[data['settings']['preferredChargeMode']]
476
+ else:
477
+ LOG_API.info('Unkown charge mode %s not in %s', data['settings']['preferredChargeMode'], str(SkodaCharging.SkodaChargeMode))
478
+ preferred_charge_mode = SkodaCharging.SkodaChargeMode.UNKNOWN
479
+
480
+ if isinstance(vehicle.charging.settings, SkodaCharging.Settings):
481
+ # pylint: disable-next=protected-access
482
+ vehicle.charging.settings.preferred_charge_mode._set_value(value=preferred_charge_mode, measured=captured_at)
483
+ else:
484
+ if vehicle.charging is not None and isinstance(vehicle.charging.settings, SkodaCharging.Settings):
485
+ vehicle.charging.settings.preferred_charge_mode._set_value(None, measured=captured_at) # pylint: disable=protected-access
486
+ if 'availableChargeModes' in data['settings'] and data['settings']['availableChargeModes'] is not None:
487
+ if not isinstance(vehicle.charging, SkodaCharging):
488
+ vehicle.charging = SkodaCharging(origin=vehicle.charging)
489
+ available_charge_modes: list[str] = data['settings']['availableChargeModes']
490
+ if vehicle.charging is not None and isinstance(vehicle.charging.settings, SkodaCharging.Settings):
491
+ # pylint: disable-next=protected-access
492
+ vehicle.charging.settings.available_charge_modes._set_value('.'.join(available_charge_modes), measured=captured_at)
493
+ else:
494
+ if vehicle.charging is not None and isinstance(vehicle.charging.settings, SkodaCharging.Settings):
495
+ vehicle.charging.settings.available_charge_modes._set_value(None, measured=captured_at) # pylint: disable=protected-access
496
+ if 'chargingCareMode' in data['settings'] and data['settings']['chargingCareMode'] is not None:
497
+ if not isinstance(vehicle.charging, SkodaCharging):
498
+ vehicle.charging = SkodaCharging(origin=vehicle.charging)
499
+ if data['settings']['chargingCareMode'] in [item.name for item in SkodaCharging.SkodaChargingCareMode]:
500
+ charge_mode: SkodaCharging.SkodaChargingCareMode = SkodaCharging.SkodaChargingCareMode[data['settings']['chargingCareMode']]
501
+ else:
502
+ LOG_API.info('Unknown charging care mode %s not in %s', data['settings']['chargingCareMode'], str(SkodaCharging.SkodaChargingCareMode))
503
+ charge_mode = SkodaCharging.SkodaChargingCareMode.UNKNOWN
504
+ if vehicle.charging is not None and isinstance(vehicle.charging.settings, SkodaCharging.Settings):
505
+ # pylint: disable-next=protected-access
506
+ vehicle.charging.settings.charging_care_mode._set_value(value=charge_mode, measured=captured_at)
507
+ else:
508
+ if vehicle.charging is not None and isinstance(vehicle.charging.settings, SkodaCharging.Settings):
509
+ vehicle.charging.settings.charging_care_mode._set_value(None, measured=captured_at) # pylint: disable=protected-access
510
+ if 'batterySupport' in data['settings'] and data['settings']['batterySupport'] is not None:
511
+ if not isinstance(vehicle.charging, SkodaCharging):
512
+ vehicle.charging = SkodaCharging(origin=vehicle.charging)
513
+ if data['settings']['batterySupport'] in [item.name for item in SkodaCharging.SkodaBatterySupport]:
514
+ battery_support: SkodaCharging.SkodaBatterySupport = SkodaCharging.SkodaBatterySupport[data['settings']['batterySupport']]
515
+ else:
516
+ LOG_API.info('Unknown battery support %s not in %s', data['settings']['batterySupport'], str(SkodaCharging.SkodaBatterySupport))
517
+ battery_support = SkodaCharging.SkodaBatterySupport.UNKNOWN
518
+ if vehicle.charging is not None and isinstance(vehicle.charging.settings, SkodaCharging.Settings):
519
+ # pylint: disable-next=protected-access
520
+ vehicle.charging.settings.battery_support._set_value(value=battery_support, measured=captured_at)
521
+ else:
522
+ if vehicle.charging is not None and isinstance(vehicle.charging.settings, SkodaCharging.Settings):
523
+ vehicle.charging.settings.battery_support._set_value(None, measured=captured_at) # pylint: disable=protected-access
524
+ log_extra_keys(LOG_API, 'settings', data['settings'], {'targetStateOfChargeInPercent', 'maxChargeCurrentAc', 'autoUnlockPlugWhenCharged',
525
+ 'preferredChargeMode', 'availableChargeModes', 'chargingCareMode', 'batterySupport'})
526
+ if 'errors' in data and data['errors'] is not None:
527
+ found_errors: Set[str] = set()
528
+ if not isinstance(vehicle.charging, SkodaCharging):
529
+ vehicle.charging = SkodaCharging(origin=vehicle.charging)
530
+ for error_dict in data['errors']:
531
+ if 'type' in error_dict and error_dict['type'] is not None:
532
+ if error_dict['type'] not in vehicle.charging.errors:
533
+ error: Error = Error(object_id=error_dict['type'])
534
+ else:
535
+ error = vehicle.charging.errors[error_dict['type']]
536
+ if error_dict['type'] in [item.name for item in Error.ChargingError]:
537
+ error_type: Error.ChargingError = Error.ChargingError[error_dict['type']]
538
+ else:
539
+ LOG_API.info('Unknown charging error type %s not in %s', error_dict['type'], str(Error.ChargingError))
540
+ error_type = Error.ChargingError.UNKNOWN
541
+ error.type._set_value(error_type, measured=captured_at) # pylint: disable=protected-access
542
+ if 'description' in error_dict and error_dict['description'] is not None:
543
+ error.description._set_value(error_dict['description'], measured=captured_at) # pylint: disable=protected-access
544
+ log_extra_keys(LOG_API, 'errors', error_dict, {'type', 'description'})
545
+ if vehicle.charging is not None and vehicle.charging.errors is not None and len(vehicle.charging.errors) > 0:
546
+ for error_id in vehicle.charging.errors.keys()-found_errors:
547
+ vehicle.charging.errors.pop(error_id)
548
+ else:
549
+ if isinstance(vehicle.charging, SkodaCharging):
550
+ vehicle.charging.errors.clear()
551
+ log_extra_keys(LOG_API, 'charging data', data, {'carCapturedTimestamp', 'status', 'isVehicleInSavedLocation', 'errors', 'settings'})
552
+ return vehicle
553
+
554
+ def fetch_position(self, vehicle: SkodaVehicle, no_cache: bool = False) -> SkodaVehicle:
555
+ """
556
+ Fetches the position of the given Skoda vehicle and updates its position attributes.
557
+
558
+ Args:
559
+ vehicle (SkodaVehicle): The Skoda vehicle object containing the VIN and position attributes.
560
+
561
+ Returns:
562
+ SkodaVehicle: The updated Skoda vehicle object with the fetched position data.
563
+
564
+ Raises:
565
+ APIError: If the VIN is missing.
566
+ ValueError: If the vehicle has no position object.
567
+ """
568
+ vin = vehicle.vin.value
569
+ if vin is None:
570
+ raise APIError('VIN is missing')
571
+ if vehicle.position is None:
572
+ raise ValueError('Vehicle has no charging object')
573
+ url = f'https://mysmob.api.connect.skoda-auto.cz/api/v1/maps/positions?vin={vin}'
574
+ data: Dict[str, Any] | None = self._fetch_data(url=url, session=self.session, no_cache=no_cache)
575
+ if data is not None:
576
+ if 'positions' in data and data['positions'] is not None:
577
+ for position_dict in data['positions']:
578
+ if 'type' in position_dict and position_dict['type'] == 'VEHICLE':
579
+ if 'gpsCoordinates' in position_dict and position_dict['gpsCoordinates'] is not None:
580
+ if 'latitude' in position_dict['gpsCoordinates'] and position_dict['gpsCoordinates']['latitude'] is not None:
581
+ latitude: Optional[float] = position_dict['gpsCoordinates']['latitude']
582
+ else:
583
+ latitude = None
584
+ if 'longitude' in position_dict['gpsCoordinates'] and position_dict['gpsCoordinates']['longitude'] is not None:
585
+ longitude: Optional[float] = position_dict['gpsCoordinates']['longitude']
586
+ else:
587
+ longitude = None
588
+ vehicle.position.latitude._set_value(latitude) # pylint: disable=protected-access
589
+ vehicle.position.longitude._set_value(longitude) # pylint: disable=protected-access
590
+ vehicle.position.position_type._set_value(Position.PositionType.PARKING) # pylint: disable=protected-access
591
+ else:
592
+ vehicle.position.latitude._set_value(None) # pylint: disable=protected-access
593
+ vehicle.position.longitude._set_value(None) # pylint: disable=protected-access
594
+ vehicle.position.position_type._set_value(None) # pylint: disable=protected-access
595
+ else:
596
+ vehicle.position.latitude._set_value(None) # pylint: disable=protected-access
597
+ vehicle.position.longitude._set_value(None) # pylint: disable=protected-access
598
+ vehicle.position.position_type._set_value(None) # pylint: disable=protected-access
599
+ log_extra_keys(LOG_API, 'positions', position_dict, {'type',
600
+ 'gpsCoordinates',
601
+ 'address'})
602
+ else:
603
+ vehicle.position.latitude._set_value(None) # pylint: disable=protected-access
604
+ vehicle.position.longitude._set_value(None) # pylint: disable=protected-access
605
+ vehicle.position.position_type._set_value(None) # pylint: disable=protected-access
606
+ return vehicle
607
+
608
+ def fetch_air_conditioning(self, vehicle: SkodaVehicle, no_cache: bool = False) -> SkodaVehicle:
609
+ """
610
+ Fetches the air conditioning data for a given Skoda vehicle and updates the vehicle object with the retrieved data.
611
+
612
+ Args:
613
+ vehicle (SkodaVehicle): The vehicle object for which to fetch air conditioning data.
614
+
615
+ Returns:
616
+ SkodaVehicle: The updated vehicle object with the fetched air conditioning data.
617
+
618
+ Raises:
619
+ APIError: If the VIN is missing or if the carCapturedTimestamp is missing in the response data.
620
+ ValueError: If the vehicle has no charging object.
621
+
622
+ Notes:
623
+ - The method fetches data from the Skoda API using the vehicle's VIN.
624
+ - It updates the vehicle's climatization state, estimated date to reach target temperature, target temperature, and outside temperature.
625
+ - Logs additional keys found in the response data for debugging purposes.
626
+ """
627
+ vin = vehicle.vin.value
628
+ if vin is None:
629
+ raise APIError('VIN is missing')
630
+ if vehicle.position is None:
631
+ raise ValueError('Vehicle has no charging object')
632
+ url = f'https://mysmob.api.connect.skoda-auto.cz/api/v2/air-conditioning/{vin}'
633
+ data: Dict[str, Any] | None = self._fetch_data(url=url, session=self.session, no_cache=no_cache)
634
+ if data is not None:
635
+ if vehicle.climatization is not None and vehicle.climatization.commands is not None \
636
+ and not vehicle.climatization.commands.contains_command('start-stop'):
637
+ start_stop_command = ClimatizationStartStopCommand(parent=vehicle.climatization.commands)
638
+ start_stop_command._add_on_set_hook(self.__on_air_conditioning_start_stop) # pylint: disable=protected-access
639
+ start_stop_command.enabled = True
640
+ vehicle.climatization.commands.add_command(start_stop_command)
641
+
642
+ if 'carCapturedTimestamp' in data and data['carCapturedTimestamp'] is not None:
643
+ captured_at: datetime = robust_time_parse(data['carCapturedTimestamp'])
644
+ else:
645
+ raise APIError('Could not fetch air conditioning, carCapturedTimestamp missing')
646
+ if 'state' in data and data['state'] is not None:
647
+ if data['state'] in [item.name for item in Climatization.ClimatizationState]:
648
+ climatization_state: Climatization.ClimatizationState = Climatization.ClimatizationState[data['state']]
649
+ else:
650
+ LOG_API.info('Unknown climatization state %s not in %s', data['state'], str(Climatization.ClimatizationState))
651
+ climatization_state = Climatization.ClimatizationState.UNKNOWN
652
+ vehicle.climatization.state._set_value(value=climatization_state, measured=captured_at) # pylint: disable=protected-access
653
+ else:
654
+ vehicle.climatization.state._set_value(None, measured=captured_at) # pylint: disable=protected-access
655
+ if 'estimatedDateTimeToReachTargetTemperature' in data and data['estimatedDateTimeToReachTargetTemperature'] is not None:
656
+ estimated_reach: datetime = robust_time_parse(data['estimatedDateTimeToReachTargetTemperature'])
657
+ if estimated_reach is not None:
658
+ vehicle.climatization.estimated_date_reached._set_value(value=estimated_reach, measured=captured_at) # pylint: disable=protected-access
659
+ else:
660
+ vehicle.climatization.estimated_date_reached._set_value(value=None, measured=captured_at) # pylint: disable=protected-access
661
+ else:
662
+ vehicle.climatization.estimated_date_reached._set_value(value=None, measured=captured_at) # pylint: disable=protected-access
663
+ if 'targetTemperature' in data and data['targetTemperature'] is not None:
664
+ # pylint: disable-next=protected-access
665
+ vehicle.climatization.settings.target_temperature._add_on_set_hook(self.__on_air_conditioning_target_temperature_change)
666
+ vehicle.climatization.settings.target_temperature._is_changeable = True # pylint: disable=protected-access
667
+ unit: Temperature = Temperature.UNKNOWN
668
+ if 'unitInCar' in data['targetTemperature'] and data['targetTemperature']['unitInCar'] is not None:
669
+ if data['targetTemperature']['unitInCar'] == 'CELSIUS':
670
+ unit = Temperature.C
671
+ elif data['targetTemperature']['unitInCar'] == 'FAHRENHEIT':
672
+ unit = Temperature.F
673
+ elif data['targetTemperature']['unitInCar'] == 'KELVIN':
674
+ unit = Temperature.K
675
+ else:
676
+ LOG_API.info('Unknown temperature unit for targetTemperature in air-conditioning %s', data['targetTemperature']['unitInCar'])
677
+ if 'temperatureValue' in data['targetTemperature'] and data['targetTemperature']['temperatureValue'] is not None:
678
+ # pylint: disable-next=protected-access
679
+ vehicle.climatization.settings.target_temperature._set_value(value=data['targetTemperature']['temperatureValue'],
680
+ measured=captured_at,
681
+ unit=unit)
682
+ else:
683
+ # pylint: disable-next=protected-access
684
+ vehicle.climatization.settings.target_temperature._set_value(value=None, measured=captured_at, unit=unit)
685
+ log_extra_keys(LOG_API, 'targetTemperature', data['targetTemperature'], {'unitInCar', 'temperatureValue'})
686
+ else:
687
+ # pylint: disable-next=protected-access
688
+ vehicle.climatization.settings.target_temperature._set_value(value=None, measured=captured_at, unit=Temperature.UNKNOWN)
689
+ if 'outsideTemperature' in data and data['outsideTemperature'] is not None:
690
+ if 'carCapturedTimestamp' in data['outsideTemperature'] and data['outsideTemperature']['carCapturedTimestamp'] is not None:
691
+ outside_captured_at: datetime = robust_time_parse(data['outsideTemperature']['carCapturedTimestamp'])
692
+ else:
693
+ outside_captured_at = captured_at
694
+ if 'temperatureUnit' in data['outsideTemperature'] and data['outsideTemperature']['temperatureUnit'] is not None:
695
+ unit: Temperature = Temperature.UNKNOWN
696
+ if data['outsideTemperature']['temperatureUnit'] == 'CELSIUS':
697
+ unit = Temperature.C
698
+ elif data['outsideTemperature']['temperatureUnit'] == 'FAHRENHEIT':
699
+ unit = Temperature.F
700
+ elif data['outsideTemperature']['temperatureUnit'] == 'KELVIN':
701
+ unit = Temperature.K
702
+ else:
703
+ LOG_API.info('Unknown temperature unit for outsideTemperature in air-conditioning %s', data['outsideTemperature']['temperatureUnit'])
704
+ if 'temperatureValue' in data['outsideTemperature'] and data['outsideTemperature']['temperatureValue'] is not None:
705
+ # pylint: disable-next=protected-access
706
+ vehicle.outside_temperature._set_value(value=data['outsideTemperature']['temperatureValue'],
707
+ measured=outside_captured_at,
708
+ unit=unit)
709
+ else:
710
+ # pylint: disable-next=protected-access
711
+ vehicle.outside_temperature._set_value(value=None, measured=outside_captured_at, unit=Temperature.UNKNOWN)
712
+ else:
713
+ # pylint: disable-next=protected-access
714
+ vehicle.outside_temperature._set_value(value=None, measured=outside_captured_at, unit=Temperature.UNKNOWN)
715
+ log_extra_keys(LOG_API, 'targetTemperature', data['outsideTemperature'], {'carCapturedTimestamp', 'temperatureUnit', 'temperatureValue'})
716
+ else:
717
+ vehicle.outside_temperature._set_value(value=None, measured=None, unit=Temperature.UNKNOWN) # pylint: disable=protected-access
718
+ if 'airConditioningAtUnlock' in data and data['airConditioningAtUnlock'] is not None:
719
+ if vehicle.climatization is not None and vehicle.climatization.settings is not None:
720
+ # pylint: disable-next=protected-access
721
+ vehicle.climatization.settings.climatization_at_unlock._add_on_set_hook(self.__on_air_conditioning_at_unlock_change)
722
+ vehicle.climatization.settings.climatization_at_unlock._is_changeable = True # pylint: disable=protected-access
723
+ if data['airConditioningAtUnlock'] is True:
724
+ # pylint: disable-next=protected-access
725
+ vehicle.climatization.settings.climatization_at_unlock._set_value(True, measured=captured_at)
726
+ elif data['airConditioningAtUnlock'] is False:
727
+ # pylint: disable-next=protected-access
728
+ vehicle.climatization.settings.climatization_at_unlock._set_value(False, measured=captured_at)
729
+ else:
730
+ # pylint: disable-next=protected-access
731
+ vehicle.climatization.settings.climatization_at_unlock._set_value(None, measured=captured_at)
732
+ else:
733
+ if vehicle.climatization is not None and vehicle.climatization.settings is not None:
734
+ # pylint: disable-next=protected-access
735
+ vehicle.climatization.settings.climatization_at_unlock._set_value(None, measured=captured_at)
736
+ if 'steeringWheelPosition' in data and data['steeringWheelPosition'] is not None:
737
+ if vehicle.specification is not None:
738
+ if data['steeringWheelPosition'] in [item.name for item in GenericVehicle.VehicleSpecification.SteeringPosition]:
739
+ steering_wheel_position: GenericVehicle.VehicleSpecification.SteeringPosition = \
740
+ GenericVehicle.VehicleSpecification.SteeringPosition[data['steeringWheelPosition']]
741
+ else:
742
+ LOG_API.info('Unknown steering wheel position %s not in %s', data['steeringWheelPosition'],
743
+ str(GenericVehicle.VehicleSpecification.SteeringPosition))
744
+ steering_wheel_position = GenericVehicle.VehicleSpecification.SteeringPosition.UNKNOWN
745
+ # pylint: disable-next=protected-access
746
+ vehicle.specification.steering_wheel_position._set_value(value=steering_wheel_position, measured=captured_at)
747
+ else:
748
+ if vehicle.specification is not None:
749
+ # pylint: disable-next=protected-access
750
+ vehicle.specification.steering_wheel_position._set_value(None, measured=captured_at)
751
+ if 'windowHeatingEnabled' in data and data['windowHeatingEnabled'] is not None:
752
+ if vehicle.climatization is not None and vehicle.climatization.settings is not None:
753
+ # pylint: disable-next=protected-access
754
+ vehicle.climatization.settings.window_heating._add_on_set_hook(self.__on_air_conditioning_window_heating_change)
755
+ vehicle.climatization.settings.window_heating._is_changeable = True # pylint: disable=protected-access
756
+ if data['windowHeatingEnabled'] is True:
757
+ # pylint: disable-next=protected-access
758
+ vehicle.climatization.settings.window_heating._set_value(True, measured=captured_at)
759
+ elif data['windowHeatingEnabled'] is False:
760
+ # pylint: disable-next=protected-access
761
+ vehicle.climatization.settings.window_heating._set_value(False, measured=captured_at)
762
+ else:
763
+ # pylint: disable-next=protected-access
764
+ vehicle.climatization.settings.window_heating._set_value(None, measured=captured_at)
765
+ else:
766
+ if vehicle.climatization is not None and vehicle.climatization.settings is not None:
767
+ # pylint: disable-next=protected-access
768
+ vehicle.climatization.settings.window_heating._set_value(None, measured=captured_at)
769
+ if 'seatHeatingActivated' in data and data['seatHeatingActivated'] is not None:
770
+ if vehicle.climatization is not None and vehicle.climatization.settings is not None:
771
+ if data['seatHeatingActivated'] is True:
772
+ # pylint: disable-next=protected-access
773
+ vehicle.climatization.settings.seat_heating._set_value(True, measured=captured_at)
774
+ elif data['seatHeatingActivated'] is False:
775
+ # pylint: disable-next=protected-access
776
+ vehicle.climatization.settings.seat_heating._set_value(False, measured=captured_at)
777
+ else:
778
+ # pylint: disable-next=protected-access
779
+ vehicle.climatization.settings.seat_heating._set_value(None, measured=captured_at)
780
+ else:
781
+ if vehicle.climatization is not None and vehicle.climatization.settings is not None:
782
+ # pylint: disable-next=protected-access
783
+ vehicle.climatization.settings.seat_heating._set_value(None, measured=captured_at)
784
+ if isinstance(vehicle, SkodaElectricVehicle):
785
+ if 'chargerConnectionState' in data and data['chargerConnectionState'] is not None \
786
+ and vehicle.charging is not None and vehicle.charging.connector is not None:
787
+ if data['chargerConnectionState'] in [item.name for item in ChargingConnector.ChargingConnectorConnectionState]:
788
+ charging_connector_state: ChargingConnector.ChargingConnectorConnectionState = \
789
+ ChargingConnector.ChargingConnectorConnectionState[data['chargerConnectionState']]
790
+ # pylint: disable-next=protected-access
791
+ vehicle.charging.connector.connection_state._set_value(value=charging_connector_state, measured=captured_at)
792
+ else:
793
+ LOG_API.info('Unkown connector state %s not in %s', data['chargerConnectionState'],
794
+ str(ChargingConnector.ChargingConnectorConnectionState))
795
+ # pylint: disable-next=protected-access
796
+ vehicle.charging.connector.connection_state._set_value(value=SkodaCharging.SkodaChargingState.UNKNOWN, measured=captured_at)
797
+ else:
798
+ # pylint: disable-next=protected-access
799
+ vehicle.charging.connector.connection_state._set_value(value=None, measured=captured_at)
800
+ if 'chargerLockState' in data and data['chargerLockState'] is not None \
801
+ and vehicle.charging is not None and vehicle.charging.connector is not None:
802
+ if data['chargerLockState'] in [item.name for item in ChargingConnector.ChargingConnectorLockState]:
803
+ charging_connector_lockstate: ChargingConnector.ChargingConnectorLockState = \
804
+ ChargingConnector.ChargingConnectorLockState[data['chargerLockState']]
805
+ # pylint: disable-next=protected-access
806
+ vehicle.charging.connector.lock_state._set_value(value=charging_connector_lockstate, measured=captured_at)
807
+ else:
808
+ LOG_API.info('Unkown connector lock state %s not in %s', data['chargerLockState'],
809
+ str(ChargingConnector.ChargingConnectorLockState))
810
+ # pylint: disable-next=protected-access
811
+ vehicle.charging.connector.lock_state._set_value(value=SkodaCharging.SkodaChargingState.UNKNOWN, measured=captured_at)
812
+ else:
813
+ # pylint: disable-next=protected-access
814
+ vehicle.charging.connector.lock_state._set_value(value=None, measured=captured_at)
815
+ if 'windowHeatingState' in data and data['windowHeatingState'] is not None:
816
+ pass
817
+ if 'errors' in data and data['errors'] is not None:
818
+ found_errors: Set[str] = set()
819
+ if not isinstance(vehicle.climatization, SkodaClimatization):
820
+ vehicle.climatization = SkodaClimatization(origin=vehicle.climatization)
821
+ for error_dict in data['errors']:
822
+ if 'type' in error_dict and error_dict['type'] is not None:
823
+ if error_dict['type'] not in vehicle.climatization.errors:
824
+ error: Error = Error(object_id=error_dict['type'])
825
+ else:
826
+ error = vehicle.climatization.errors[error_dict['type']]
827
+ if error_dict['type'] in [item.name for item in Error.ClimatizationError]:
828
+ error_type: Error.ClimatizationError = Error.ClimatizationError[error_dict['type']]
829
+ else:
830
+ LOG_API.info('Unknown climatization error type %s not in %s', error_dict['type'], str(Error.ClimatizationError))
831
+ error_type = Error.ClimatizationError.UNKNOWN
832
+ error.type._set_value(error_type, measured=captured_at) # pylint: disable=protected-access
833
+ if 'description' in error_dict and error_dict['description'] is not None:
834
+ error.description._set_value(error_dict['description'], measured=captured_at) # pylint: disable=protected-access
835
+ log_extra_keys(LOG_API, 'errors', error_dict, {'type', 'description'})
836
+ if vehicle.climatization is not None and vehicle.climatization.errors is not None and len(vehicle.climatization.errors) > 0:
837
+ for error_id in vehicle.climatization.errors.keys()-found_errors:
838
+ vehicle.climatization.errors.pop(error_id)
839
+ else:
840
+ if isinstance(vehicle.climatization, SkodaClimatization):
841
+ vehicle.climatization.errors.clear()
842
+ log_extra_keys(LOG_API, 'air-condition', data, {'carCapturedTimestamp', 'state', 'estimatedDateTimeToReachTargetTemperature',
843
+ 'targetTemperature', 'outsideTemperature', 'chargerConnectionState',
844
+ 'chargerLockState', 'airConditioningAtUnlock', 'steeringWheelPosition',
845
+ 'windowHeatingEnabled', 'seatHeatingActivated', 'errors'})
846
+ return vehicle
847
+
848
+ def fetch_vehicle_details(self, vehicle: SkodaVehicle, no_cache: bool = False) -> SkodaVehicle:
849
+ """
850
+ Fetches the details of a vehicle from the Skoda API.
851
+
852
+ Args:
853
+ vehicle (GenericVehicle): The vehicle object containing the VIN.
854
+
855
+ Returns:
856
+ None
857
+ """
858
+ vin = vehicle.vin.value
859
+ if vin is None:
860
+ raise APIError('VIN is missing')
861
+ url = f'https://mysmob.api.connect.skoda-auto.cz/api/v2/garage/vehicles/{vin}?' \
862
+ 'connectivityGenerations=MOD1&connectivityGenerations=MOD2&connectivityGenerations=MOD3&connectivityGenerations=MOD4'
863
+ vehicle_data: Dict[str, Any] | None = self._fetch_data(url=url, session=self.session, no_cache=no_cache)
864
+ if vehicle_data:
865
+ if 'softwareVersion' in vehicle_data and vehicle_data['softwareVersion'] is not None:
866
+ vehicle.software.version._set_value(vehicle_data['softwareVersion']) # pylint: disable=protected-access
867
+ else:
868
+ vehicle.software.version._set_value(None) # pylint: disable=protected-access
869
+ if 'capabilities' in vehicle_data and vehicle_data['capabilities'] is not None:
870
+ if 'capabilities' in vehicle_data['capabilities'] and vehicle_data['capabilities']['capabilities'] is not None:
871
+ found_capabilities = set()
872
+ for capability_dict in vehicle_data['capabilities']['capabilities']:
873
+ if 'id' in capability_dict and capability_dict['id'] is not None:
874
+ capability_id = capability_dict['id']
875
+ found_capabilities.add(capability_id)
876
+ if vehicle.capabilities.has_capability(capability_id):
877
+ capability: Capability = vehicle.capabilities.get_capability(capability_id) # pyright: ignore[reportAssignmentType]
878
+ else:
879
+ capability = Capability(capability_id=capability_id, capabilities=vehicle.capabilities)
880
+ vehicle.capabilities.add_capability(capability_id, capability)
881
+ else:
882
+ raise APIError('Could not parse capability, id missing')
883
+ for capability_id in vehicle.capabilities.capabilities.keys() - found_capabilities:
884
+ vehicle.capabilities.remove_capability(capability_id)
885
+ else:
886
+ vehicle.capabilities.clear_capabilities()
887
+ else:
888
+ vehicle.capabilities.clear_capabilities()
889
+
890
+ # Add HONK_AND_FLASH command if necessary capabilities are available
891
+ if vehicle.capabilities.has_capability('HONK_AND_FLASH') and vehicle.capabilities.has_capability('PARKING_POSITION'):
892
+ if vehicle.commands is not None and vehicle.commands.commands is not None \
893
+ and not vehicle.commands.contains_command('honk-flash'):
894
+ honk_flash_command = HonkAndFlashCommand(parent=vehicle.commands)
895
+ honk_flash_command._add_on_set_hook(self.__on_honk_flash) # pylint: disable=protected-access
896
+ honk_flash_command.enabled = True
897
+ vehicle.commands.add_command(honk_flash_command)
898
+
899
+ # Add lock and unlock command
900
+ if vehicle.capabilities.has_capability('ACCESS'):
901
+ if vehicle.doors is not None and vehicle.doors.commands is not None and vehicle.doors.commands.commands is not None \
902
+ and not vehicle.doors.commands.contains_command('lock-unlock'):
903
+ lock_unlock_command = LockUnlockCommand(parent=vehicle.doors.commands)
904
+ lock_unlock_command._add_on_set_hook(self.__on_lock_unlock) # pylint: disable=protected-access
905
+ lock_unlock_command.enabled = True
906
+ vehicle.doors.commands.add_command(lock_unlock_command)
907
+
908
+ # Add spin command
909
+ if vehicle.commands is not None and vehicle.commands.commands is not None \
910
+ and not vehicle.commands.contains_command('spin'):
911
+ spin_command = SpinCommand(parent=self.commands)
912
+ spin_command._add_on_set_hook(self.__on_spin) # pylint: disable=protected-access
913
+ spin_command.enabled = True
914
+ vehicle.commands.add_command(spin_command)
915
+
916
+ if 'specification' in vehicle_data and vehicle_data['specification'] is not None:
917
+ if 'model' in vehicle_data['specification'] and vehicle_data['specification']['model'] is not None:
918
+ vehicle.model._set_value(vehicle_data['specification']['model']) # pylint: disable=protected-access
919
+ else:
920
+ vehicle.model._set_value(None) # pylint: disable=protected-access
921
+ log_extra_keys(LOG_API, 'specification', vehicle_data['specification'], {'model'})
922
+ else:
923
+ vehicle.model._set_value(None) # pylint: disable=protected-access
924
+ log_extra_keys(LOG_API, 'api/v2/garage/vehicles/VIN', vehicle_data, {'softwareVersion'})
925
+ return vehicle
926
+
927
+ def fetch_driving_range(self, vehicle: SkodaVehicle, no_cache: bool = False) -> SkodaVehicle:
928
+ """
929
+ Fetches the driving range data for a given Skoda vehicle and updates the vehicle object accordingly.
930
+
931
+ Args:
932
+ vehicle (SkodaVehicle): The Skoda vehicle object for which to fetch the driving range data.
933
+
934
+ Returns:
935
+ SkodaVehicle: The updated Skoda vehicle object with the fetched driving range data.
936
+
937
+ Raises:
938
+ APIError: If the vehicle's VIN is missing.
939
+
940
+ Notes:
941
+ - The method fetches data from the Skoda API using the vehicle's VIN.
942
+ - It updates the vehicle's type if the fetched data indicates a different type (e.g., electric, combustion, hybrid).
943
+ - It updates the vehicle's total range and individual drive ranges (primary and secondary) based on the fetched data.
944
+ - It logs warnings for unknown car types and engine types.
945
+ """
946
+ vin = vehicle.vin.value
947
+ if vin is None:
948
+ raise APIError('VIN is missing')
949
+ url = f'https://mysmob.api.connect.skoda-auto.cz/api/v2/vehicle-status/{vin}/driving-range'
950
+ range_data: Dict[str, Any] | None = self._fetch_data(url=url, session=self.session, no_cache=no_cache)
951
+ if range_data:
952
+ captured_at: datetime = robust_time_parse(range_data['carCapturedTimestamp'])
953
+ # Check vehicle type and if it does not match the current vehicle type, create a new vehicle object using copy constructor
954
+ if 'carType' in range_data and range_data['carType'] is not None:
955
+ try:
956
+ car_type = GenericVehicle.Type(range_data['carType'])
957
+ if car_type == GenericVehicle.Type.ELECTRIC and not isinstance(vehicle, SkodaElectricVehicle):
958
+ LOG.debug('Promoting %s to SkodaElectricVehicle object for %s', vehicle.__class__.__name__, vin)
959
+ vehicle = SkodaElectricVehicle(origin=vehicle)
960
+ self.car_connectivity.garage.replace_vehicle(vin, vehicle)
961
+ elif car_type in [GenericVehicle.Type.FUEL,
962
+ GenericVehicle.Type.GASOLINE,
963
+ GenericVehicle.Type.PETROL,
964
+ GenericVehicle.Type.DIESEL,
965
+ GenericVehicle.Type.CNG,
966
+ GenericVehicle.Type.LPG] \
967
+ and not isinstance(vehicle, SkodaCombustionVehicle):
968
+ LOG.debug('Promoting %s to SkodaCombustionVehicle object for %s', vehicle.__class__.__name__, vin)
969
+ vehicle = SkodaCombustionVehicle(origin=vehicle)
970
+ self.car_connectivity.garage.replace_vehicle(vin, vehicle)
971
+ elif car_type == GenericVehicle.Type.HYBRID and not isinstance(vehicle, SkodaHybridVehicle):
972
+ LOG.debug('Promoting %s to SkodaHybridVehicle object for %s', vehicle.__class__.__name__, vin)
973
+ vehicle = SkodaHybridVehicle(origin=vehicle)
974
+ self.car_connectivity.garage.replace_vehicle(vin, vehicle)
975
+ except ValueError:
976
+ LOG_API.warning('Unknown car type %s', range_data['carType'])
977
+ car_type = GenericVehicle.Type.UNKNOWN
978
+ vehicle.type._set_value(car_type) # pylint: disable=protected-access
979
+ if 'totalRangeInKm' in range_data and range_data['totalRangeInKm'] is not None:
980
+ # pylint: disable-next=protected-access
981
+ vehicle.drives.total_range._set_value(value=range_data['totalRangeInKm'], measured=captured_at, unit=Length.KM)
982
+ else:
983
+ vehicle.drives.total_range._set_value(None, measured=captured_at, unit=Length.KM) # pylint: disable=protected-access
984
+
985
+ drive_ids: set[str] = {'primary', 'secondary'}
986
+ for drive_id in drive_ids:
987
+ if f'{drive_id}EngineRange' in range_data and range_data[f'{drive_id}EngineRange'] is not None:
988
+ try:
989
+ engine_type: GenericDrive.Type = GenericDrive.Type(range_data[f'{drive_id}EngineRange']['engineType'])
990
+ except ValueError:
991
+ LOG_API.warning('Unknown engine_type type %s', range_data[f'{drive_id}EngineRange']['engineType'])
992
+ engine_type: GenericDrive.Type = GenericDrive.Type.UNKNOWN
993
+
994
+ if drive_id in vehicle.drives.drives:
995
+ drive: GenericDrive = vehicle.drives.drives[drive_id]
996
+ else:
997
+ if engine_type == GenericDrive.Type.ELECTRIC:
998
+ drive = ElectricDrive(drive_id=drive_id, drives=vehicle.drives)
999
+ elif engine_type in [GenericDrive.Type.FUEL,
1000
+ GenericDrive.Type.GASOLINE,
1001
+ GenericDrive.Type.PETROL,
1002
+ GenericDrive.Type.DIESEL,
1003
+ GenericDrive.Type.CNG,
1004
+ GenericDrive.Type.LPG]:
1005
+ drive = CombustionDrive(drive_id=drive_id, drives=vehicle.drives)
1006
+ else:
1007
+ drive = GenericDrive(drive_id=drive_id, drives=vehicle.drives)
1008
+ drive.type._set_value(engine_type) # pylint: disable=protected-access
1009
+ vehicle.drives.add_drive(drive)
1010
+ if 'currentSoCInPercent' in range_data[f'{drive_id}EngineRange'] \
1011
+ and range_data[f'{drive_id}EngineRange']['currentSoCInPercent'] is not None:
1012
+ # pylint: disable-next=protected-access
1013
+ drive.level._set_value(value=range_data[f'{drive_id}EngineRange']['currentSoCInPercent'], measured=captured_at)
1014
+ elif 'currentFuelLevelInPercent' in range_data[f'{drive_id}EngineRange'] \
1015
+ and range_data[f'{drive_id}EngineRange']['currentFuelLevelInPercent'] is not None:
1016
+ # pylint: disable-next=protected-access
1017
+ drive.level._set_value(value=range_data[f'{drive_id}EngineRange']['currentFuelLevelInPercent'], measured=captured_at)
1018
+ else:
1019
+ drive.level._set_value(None, measured=captured_at) # pylint: disable=protected-access
1020
+ if 'remainingRangeInKm' in range_data[f'{drive_id}EngineRange'] and range_data[f'{drive_id}EngineRange']['remainingRangeInKm'] is not None:
1021
+ # pylint: disable-next=protected-access
1022
+ drive.range._set_value(value=range_data[f'{drive_id}EngineRange']['remainingRangeInKm'], measured=captured_at, unit=Length.KM)
1023
+ else:
1024
+ drive.range._set_value(None, measured=captured_at, unit=Length.KM) # pylint: disable=protected-access
1025
+
1026
+ log_extra_keys(LOG_API, f'{drive_id}EngineRange', range_data[f'{drive_id}EngineRange'], {'engineType',
1027
+ 'currentSoCInPercent',
1028
+ 'currentFuelLevelInPercent',
1029
+ 'remainingRangeInKm'})
1030
+ log_extra_keys(LOG_API, '/api/v2/vehicle-status/{vin}/driving-range', range_data, {'carCapturedTimestamp',
1031
+ 'carType',
1032
+ 'totalRangeInKm',
1033
+ 'primaryEngineRange',
1034
+ 'secondaryEngineRange'})
1035
+ return vehicle
1036
+
1037
+ def fetch_vehicle_status(self, vehicle: SkodaVehicle, no_cache: bool = False) -> SkodaVehicle:
1038
+ """
1039
+ Fetches the status of a vehicle from other Skoda API.
1040
+
1041
+ Args:
1042
+ vehicle (GenericVehicle): The vehicle object containing the VIN.
1043
+
1044
+ Returns:
1045
+ None
1046
+ """
1047
+ vin = vehicle.vin.value
1048
+ if vin is None:
1049
+ raise APIError('VIN is missing')
1050
+ url = f'https://mysmob.api.connect.skoda-auto.cz/api/v2/vehicle-status/{vin}'
1051
+ vehicle_status_data: Dict[str, Any] | None = self._fetch_data(url=url, session=self.session, no_cache=no_cache)
1052
+ if vehicle_status_data:
1053
+ if 'carCapturedTimestamp' in vehicle_status_data and vehicle_status_data['carCapturedTimestamp'] is not None:
1054
+ captured_at: Optional[datetime] = robust_time_parse(vehicle_status_data['carCapturedTimestamp'])
1055
+ else:
1056
+ captured_at: Optional[datetime] = None
1057
+ if 'overall' in vehicle_status_data and vehicle_status_data['overall'] is not None:
1058
+ if 'doorsLocked' in vehicle_status_data['overall'] and vehicle_status_data['overall']['doorsLocked'] is not None \
1059
+ and vehicle.doors is not None:
1060
+ if vehicle_status_data['overall']['doorsLocked'] == 'YES':
1061
+ vehicle.doors.lock_state._set_value(Doors.LockState.LOCKED, measured=captured_at) # pylint: disable=protected-access
1062
+ vehicle.doors.open_state._set_value(Doors.OpenState.CLOSED, measured=captured_at) # pylint: disable=protected-access
1063
+ elif vehicle_status_data['overall']['doorsLocked'] == 'OPENED':
1064
+ vehicle.doors.lock_state._set_value(Doors.LockState.UNLOCKED, measured=captured_at) # pylint: disable=protected-access
1065
+ vehicle.doors.open_state._set_value(Doors.OpenState.OPEN, measured=captured_at) # pylint: disable=protected-access
1066
+ elif vehicle_status_data['overall']['doorsLocked'] == 'UNLOCKED':
1067
+ vehicle.doors.lock_state._set_value(Doors.LockState.UNLOCKED, measured=captured_at) # pylint: disable=protected-access
1068
+ vehicle.doors.open_state._set_value(Doors.OpenState.CLOSED, measured=captured_at) # pylint: disable=protected-access
1069
+ elif vehicle_status_data['overall']['doorsLocked'] == 'TRUNK_OPENED':
1070
+ vehicle.doors.lock_state._set_value(Doors.LockState.UNLOCKED, measured=captured_at) # pylint: disable=protected-access
1071
+ vehicle.doors.open_state._set_value(Doors.OpenState.OPEN, measured=captured_at) # pylint: disable=protected-access
1072
+ elif vehicle_status_data['overall']['doorsLocked'] == 'UNKNOWN':
1073
+ vehicle.doors.lock_state._set_value(Doors.LockState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
1074
+ vehicle.doors.open_state._set_value(Doors.OpenState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
1075
+ else:
1076
+ LOG_API.info('Unknown doorsLocked state %s', vehicle_status_data['overall']['doorsLocked'])
1077
+ vehicle.doors.lock_state._set_value(Doors.LockState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
1078
+ vehicle.doors.open_state._set_value(Doors.OpenState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
1079
+ if 'locked' in vehicle_status_data['overall'] and vehicle_status_data['overall']['locked'] is not None:
1080
+ if vehicle_status_data['overall']['locked'] == 'YES':
1081
+ vehicle.doors.lock_state._set_value(Doors.LockState.LOCKED, measured=captured_at) # pylint: disable=protected-access
1082
+ elif vehicle_status_data['overall']['locked'] == 'NO':
1083
+ vehicle.doors.lock_state._set_value(Doors.LockState.UNLOCKED, measured=captured_at) # pylint: disable=protected-access
1084
+ elif vehicle_status_data['overall']['locked'] == 'UNKNOWN':
1085
+ vehicle.doors.lock_state._set_value(Doors.LockState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
1086
+ else:
1087
+ LOG_API.info('Unknown locked state %s', vehicle_status_data['overall']['locked'])
1088
+ vehicle.doors.lock_state._set_value(Doors.LockState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
1089
+ if 'doors' in vehicle_status_data['overall'] and vehicle_status_data['overall']['doors'] is not None:
1090
+ if vehicle_status_data['overall']['doors'] == 'CLOSED':
1091
+ vehicle.doors.open_state._set_value(Doors.OpenState.CLOSED, measured=captured_at) # pylint: disable=protected-access
1092
+ elif vehicle_status_data['overall']['doors'] == 'OPEN':
1093
+ vehicle.doors.open_state._set_value(Doors.OpenState.OPEN, measured=captured_at) # pylint: disable=protected-access
1094
+ elif vehicle_status_data['overall']['doors'] == 'UNSUPPORTED':
1095
+ vehicle.doors.open_state._set_value(Doors.OpenState.UNSUPPORTED, measured=captured_at) # pylint: disable=protected-access
1096
+ elif vehicle_status_data['overall']['doors'] == 'UNKNOWN':
1097
+ vehicle.doors.open_state._set_value(Doors.OpenState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
1098
+ else:
1099
+ LOG_API.info('Unknown doors state %s', vehicle_status_data['overall']['doors'])
1100
+ vehicle.doors.open_state._set_value(Doors.OpenState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
1101
+ if 'windows' in vehicle_status_data['overall'] and vehicle_status_data['overall']['windows'] is not None:
1102
+ if vehicle_status_data['overall']['windows'] == 'CLOSED':
1103
+ vehicle.windows.open_state._set_value(Windows.OpenState.CLOSED, measured=captured_at) # pylint: disable=protected-access
1104
+ elif vehicle_status_data['overall']['windows'] == 'OPEN':
1105
+ vehicle.windows.open_state._set_value(Windows.OpenState.OPEN, measured=captured_at) # pylint: disable=protected-access
1106
+ elif vehicle_status_data['overall']['windows'] == 'UNKNOWN':
1107
+ vehicle.windows.open_state._set_value(Windows.OpenState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
1108
+ elif vehicle_status_data['overall']['windows'] == 'UNSUPPORTED':
1109
+ vehicle.windows.open_state._set_value(Windows.OpenState.UNSUPPORTED, measured=captured_at) # pylint: disable=protected-access
1110
+ else:
1111
+ LOG_API.info('Unknown windows state %s', vehicle_status_data['overall']['windows'])
1112
+ vehicle.windows.open_state._set_value(Windows.OpenState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
1113
+ if 'lights' in vehicle_status_data['overall'] and vehicle_status_data['overall']['lights'] is not None:
1114
+ if vehicle_status_data['overall']['lights'] == 'ON':
1115
+ vehicle.lights.light_state._set_value(Lights.LightState.ON, measured=captured_at) # pylint: disable=protected-access
1116
+ elif vehicle_status_data['overall']['lights'] == 'OFF':
1117
+ vehicle.lights.light_state._set_value(Lights.LightState.OFF, measured=captured_at) # pylint: disable=protected-access
1118
+ elif vehicle_status_data['overall']['lights'] == 'UNKNOWN':
1119
+ vehicle.lights.light_state._set_value(Lights.LightState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
1120
+ else:
1121
+ LOG_API.info('Unknown lights state %s', vehicle_status_data['overall']['lights'])
1122
+ vehicle.lights.light_state._set_value(Lights.LightState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
1123
+ log_extra_keys(LOG_API, 'status overall', vehicle_status_data['overall'], {'doorsLocked',
1124
+ 'locked',
1125
+ 'doors',
1126
+ 'windows',
1127
+ 'lights'})
1128
+ log_extra_keys(LOG_API, f'/api/v2/vehicle-status/{vin}', vehicle_status_data, {'overall', 'carCapturedTimestamp'})
1129
+ return vehicle
1130
+
1131
+ # def fetch_vehicle_status_second_api(self, vehicle: SkodaVehicle, no_cache: bool = False) -> SkodaVehicle:
1132
+ # """
1133
+ # Fetches the status of a vehicle from other Skoda API.
1134
+ #
1135
+ # Args:
1136
+ # vehicle (GenericVehicle): The vehicle object containing the VIN.
1137
+ #
1138
+ # Returns:
1139
+ # None
1140
+ # """
1141
+ # vin = vehicle.vin.value
1142
+ # if vin is None:
1143
+ # raise APIError('VIN is missing')
1144
+ # url = f'https://api.connect.skoda-auto.cz/api/v2/vehicle-status/{vin}'
1145
+ # vehicle_status_data: Dict[str, Any] | None = self._fetch_data(url=url, session=self.session, no_cache=no_cache)
1146
+ # if vehicle_status_data:
1147
+ # if 'remote' in vehicle_status_data and vehicle_status_data['remote'] is not None:
1148
+ # vehicle_status_data = vehicle_status_data['remote']
1149
+ # if vehicle_status_data:
1150
+ # if 'capturedAt' in vehicle_status_data and vehicle_status_data['capturedAt'] is not None:
1151
+ # captured_at: datetime = robust_time_parse(vehicle_status_data['capturedAt'])
1152
+ # else:
1153
+ # raise APIError('Could not fetch vehicle status, capturedAt missing')
1154
+ # if 'mileageInKm' in vehicle_status_data and vehicle_status_data['mileageInKm'] is not None:
1155
+ # # pylint: disable-next=protected-access
1156
+ # vehicle.odometer._set_value(value=vehicle_status_data['mileageInKm'], measured=captured_at, unit=Length.KM)
1157
+ # else:
1158
+ # vehicle.odometer._set_value(value=None, measured=captured_at, unit=Length.KM) # pylint: disable=protected-access
1159
+ # if 'status' in vehicle_status_data and vehicle_status_data['status'] is not None:
1160
+ # if 'open' in vehicle_status_data['status'] and vehicle_status_data['status']['open'] is not None:
1161
+ # if vehicle_status_data['status']['open'] == 'YES':
1162
+ # vehicle.doors.open_state._set_value(Doors.OpenState.OPEN, measured=captured_at) # pylint: disable=protected-access
1163
+ # elif vehicle_status_data['status']['open'] == 'NO':
1164
+ # vehicle.doors.open_state._set_value(Doors.OpenState.CLOSED, measured=captured_at) # pylint: disable=protected-access
1165
+ # else:
1166
+ # vehicle.doors.open_state._set_value(Doors.OpenState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
1167
+ # LOG_API.info('Unknown door open state: %s', vehicle_status_data['status']['open'])
1168
+ # else:
1169
+ # vehicle.doors.open_state._set_value(None, measured=captured_at) # pylint: disable=protected-access
1170
+ # if 'locked' in vehicle_status_data['status'] and vehicle_status_data['status']['locked'] is not None:
1171
+ # if vehicle_status_data['status']['locked'] == 'YES':
1172
+ # vehicle.doors.lock_state._set_value(Doors.LockState.LOCKED, measured=captured_at) # pylint: disable=protected-access
1173
+ # elif vehicle_status_data['status']['locked'] == 'NO':
1174
+ # vehicle.doors.lock_state._set_value(Doors.LockState.UNLOCKED, measured=captured_at) # pylint: disable=protected-access
1175
+ # else:
1176
+ # vehicle.doors.lock_state._set_value(Doors.LockState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
1177
+ # LOG_API.info('Unknown door lock state: %s', vehicle_status_data['status']['locked'])
1178
+ # else:
1179
+ # vehicle.doors.lock_state._set_value(None, measured=captured_at) # pylint: disable=protected-access
1180
+ # else:
1181
+ # vehicle.doors.open_state._set_value(None, measured=captured_at) # pylint: disable=protected-access
1182
+ # vehicle.doors.lock_state._set_value(None, measured=captured_at) # pylint: disable=protected-access
1183
+ # if 'doors' in vehicle_status_data and vehicle_status_data['doors'] is not None:
1184
+ # seen_door_ids: set[str] = set()
1185
+ # for door_status in vehicle_status_data['doors']:
1186
+ # if 'name' in door_status and door_status['name'] is not None:
1187
+ # door_id = door_status['name']
1188
+ # seen_door_ids.add(door_id)
1189
+ # if door_id in vehicle.doors.doors:
1190
+ # door: Doors.Door = vehicle.doors.doors[door_id]
1191
+ # else:
1192
+ # door = Doors.Door(door_id=door_id, doors=vehicle.doors)
1193
+ # vehicle.doors.doors[door_id] = door
1194
+ # if 'status' in door_status and door_status['status'] is not None:
1195
+ # if door_status['status'] == 'OPEN':
1196
+ # door.lock_state._set_value(Doors.LockState.UNLOCKED, measured=captured_at) # pylint: disable=protected-access
1197
+ # door.open_state._set_value(Doors.OpenState.OPEN, measured=captured_at) # pylint: disable=protected-access
1198
+ # elif door_status['status'] == 'CLOSED':
1199
+ # door.lock_state._set_value(Doors.LockState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
1200
+ # door.open_state._set_value(Doors.OpenState.CLOSED, measured=captured_at) # pylint: disable=protected-access
1201
+ # elif door_status['status'] == 'LOCKED':
1202
+ # door.lock_state._set_value(Doors.LockState.LOCKED, measured=captured_at) # pylint: disable=protected-access
1203
+ # door.open_state._set_value(Doors.OpenState.CLOSED, measured=captured_at) # pylint: disable=protected-access
1204
+ # elif door_status['status'] == 'UNSUPPORTED':
1205
+ # door.lock_state._set_value(Doors.LockState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
1206
+ # door.open_state._set_value(Doors.OpenState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
1207
+ # else:
1208
+ # LOG_API.info('Unknown door status %s', door_status['status'])
1209
+ # door.lock_state._set_value(Doors.LockState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
1210
+ # door.open_state._set_value(Doors.OpenState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
1211
+ # else:
1212
+ # door.lock_state._set_value(None, measured=captured_at) # pylint: disable=protected-access
1213
+ # door.open_state._set_value(None, measured=captured_at) # pylint: disable=protected-access
1214
+ # else:
1215
+ # raise APIError('Could not parse door, name missing')
1216
+ # log_extra_keys(LOG_API, 'doors', door_status, {'name', 'status'})
1217
+ # for door_to_remove in set(vehicle.doors.doors) - seen_door_ids:
1218
+ # vehicle.doors.doors[door_to_remove].enabled = False
1219
+ # vehicle.doors.doors.pop(door_to_remove)
1220
+ # log_extra_keys(LOG_API, 'status', vehicle_status_data['status'], {'open', 'locked'})
1221
+ # else:
1222
+ # vehicle.doors.open_state._set_value(None, measured=captured_at) # pylint: disable=protected-access
1223
+ # vehicle.doors.doors = {}
1224
+ # if 'windows' in vehicle_status_data and vehicle_status_data['windows'] is not None:
1225
+ # seen_window_ids: set[str] = set()
1226
+ # all_windows_closed: bool = True
1227
+ # for window_status in vehicle_status_data['windows']:
1228
+ # if 'name' in window_status and window_status['name'] is not None:
1229
+ # window_id = window_status['name']
1230
+ # seen_window_ids.add(window_id)
1231
+ # if window_id in vehicle.windows.windows:
1232
+ # window: Windows.Window = vehicle.windows.windows[window_id]
1233
+ # else:
1234
+ # window = Windows.Window(window_id=window_id, windows=vehicle.windows)
1235
+ # vehicle.windows.windows[window_id] = window
1236
+ # if 'status' in window_status and window_status['status'] is not None:
1237
+ # if window_status['status'] == 'OPEN':
1238
+ # all_windows_closed = False
1239
+ # window.open_state._set_value(Windows.OpenState.OPEN, measured=captured_at) # pylint: disable=protected-access
1240
+ # elif window_status['status'] == 'CLOSED':
1241
+ # window.open_state._set_value(Windows.OpenState.CLOSED, measured=captured_at) # pylint: disable=protected-access
1242
+ # elif window_status['status'] == 'UNSUPPORTED':
1243
+ # window.open_state._set_value(Windows.OpenState.UNSUPPORTED, measured=captured_at) # pylint: disable=protected-access
1244
+ # elif window_status['status'] == 'INVALID':
1245
+ # window.open_state._set_value(Windows.OpenState.INVALID, measured=captured_at) # pylint: disable=protected-access
1246
+ # else:
1247
+ # LOG_API.info('Unknown window status %s', window_status['status'])
1248
+ # window.open_state._set_value(Windows.OpenState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
1249
+ # else:
1250
+ # window.open_state._set_value(None, measured=captured_at) # pylint: disable=protected-access
1251
+ # else:
1252
+ # raise APIError('Could not parse window, name missing')
1253
+ # log_extra_keys(LOG_API, 'doors', window_status, {'name', 'status'})
1254
+ # for window_to_remove in set(vehicle.windows.windows) - seen_window_ids:
1255
+ # vehicle.windows.windows[window_to_remove].enabled = False
1256
+ # vehicle.windows.windows.pop(window_to_remove)
1257
+ # if all_windows_closed:
1258
+ # vehicle.windows.open_state._set_value(Windows.OpenState.CLOSED, measured=captured_at) # pylint: disable=protected-access
1259
+ # else:
1260
+ # vehicle.windows.open_state._set_value(Windows.OpenState.OPEN, measured=captured_at) # pylint: disable=protected-access
1261
+ # else:
1262
+ # vehicle.windows.open_state._set_value(None, measured=captured_at) # pylint: disable=protected-access
1263
+ # vehicle.windows.windows = {}
1264
+ # if 'lights' in vehicle_status_data and vehicle_status_data['lights'] is not None:
1265
+ # seen_light_ids: set[str] = set()
1266
+ # if 'overallStatus' in vehicle_status_data['lights'] and vehicle_status_data['lights']['overallStatus'] is not None:
1267
+ # if vehicle_status_data['lights']['overallStatus'] == 'ON':
1268
+ # vehicle.lights.light_state._set_value(Lights.LightState.ON, measured=captured_at) # pylint: disable=protected-access
1269
+ # elif vehicle_status_data['lights']['overallStatus'] == 'OFF':
1270
+ # vehicle.lights.light_state._set_value(Lights.LightState.OFF, measured=captured_at) # pylint: disable=protected-access
1271
+ # elif vehicle_status_data['lights']['overallStatus'] == 'INVALID':
1272
+ # vehicle.lights.light_state._set_value(Lights.LightState.INVALID, measured=captured_at) # pylint: disable=protected-access
1273
+ # else:
1274
+ # LOG_API.info('Unknown light status %s', vehicle_status_data['lights']['overallStatus'])
1275
+ # vehicle.lights.light_state._set_value(Lights.LightState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
1276
+ # else:
1277
+ # vehicle.lights.light_state._set_value(None, measured=captured_at) # pylint: disable=protected-access
1278
+ # if 'lightsStatus' in vehicle_status_data['lights'] and vehicle_status_data['lights']['lightsStatus'] is not None:
1279
+ # for light_status in vehicle_status_data['lights']['lightsStatus']:
1280
+ # if 'name' in light_status and light_status['name'] is not None:
1281
+ # light_id: str = light_status['name']
1282
+ # seen_light_ids.add(light_id)
1283
+ # if light_id in vehicle.lights.lights:
1284
+ # light: Lights.Light = vehicle.lights.lights[light_id]
1285
+ # else:
1286
+ # light = Lights.Light(light_id=light_id, lights=vehicle.lights)
1287
+ # vehicle.lights.lights[light_id] = light
1288
+ # if 'status' in light_status and light_status['status'] is not None:
1289
+ # if light_status['status'] == 'ON':
1290
+ # light.light_state._set_value(Lights.LightState.ON, measured=captured_at) # pylint: disable=protected-access
1291
+ # elif light_status['status'] == 'OFF':
1292
+ # light.light_state._set_value(Lights.LightState.OFF, measured=captured_at) # pylint: disable=protected-access
1293
+ # elif light_status['status'] == 'INVALID':
1294
+ # light.light_state._set_value(Lights.LightState.INVALID, measured=captured_at) # pylint: disable=protected-access
1295
+ # else:
1296
+ # LOG_API.info('Unknown light status %s', light_status['status'])
1297
+ # light.light_state._set_value(Lights.LightState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
1298
+ # else:
1299
+ # light.light_state._set_value(None, measured=captured_at) # pylint: disable=protected-access
1300
+ # else:
1301
+ # raise APIError('Could not parse light, name missing')
1302
+ # log_extra_keys(LOG_API, 'lights', light_status, {'name', 'status'})
1303
+ # for light_to_remove in set(vehicle.lights.lights) - seen_light_ids:
1304
+ # vehicle.lights.lights[light_to_remove].enabled = False
1305
+ # vehicle.lights.lights.pop(light_to_remove)
1306
+ # else:
1307
+ # vehicle.lights.lights = {}
1308
+ # log_extra_keys(LOG_API, 'lights', vehicle_status_data['lights'], {'overallStatus', 'lightsStatus'})
1309
+ # log_extra_keys(LOG_API, 'vehicles', vehicle_status_data, {'capturedAt', 'mileageInKm', 'status', 'doors', 'windows', 'lights'})
1310
+ # return vehicle
1311
+
1312
+ def _record_elapsed(self, elapsed: timedelta) -> None:
1313
+ """
1314
+ Records the elapsed time.
1315
+
1316
+ Args:
1317
+ elapsed (timedelta): The elapsed time to record.
1318
+ """
1319
+ self._elapsed.append(elapsed)
1320
+
1321
+ def _fetch_data(self, url, session, no_cache=False, allow_empty=False, allow_http_error=False,
1322
+ allowed_errors=None) -> Optional[Dict[str, Any]]: # noqa: C901
1323
+ data: Optional[Dict[str, Any]] = None
1324
+ cache_date: Optional[datetime] = None
1325
+ if not no_cache and (self.max_age is not None and session.cache is not None and url in session.cache):
1326
+ data, cache_date_string = session.cache[url]
1327
+ cache_date = datetime.fromisoformat(cache_date_string)
1328
+ if data is None or self.max_age is None \
1329
+ or (cache_date is not None and cache_date < (datetime.utcnow() - timedelta(seconds=self.max_age))):
1330
+ try:
1331
+ status_response: requests.Response = session.get(url, allow_redirects=False)
1332
+ self._record_elapsed(status_response.elapsed)
1333
+ if status_response.status_code in (requests.codes['ok'], requests.codes['multiple_status']):
1334
+ data = status_response.json()
1335
+ if session.cache is not None:
1336
+ session.cache[url] = (data, str(datetime.utcnow()))
1337
+ elif status_response.status_code == requests.codes['too_many_requests']:
1338
+ raise TooManyRequestsError('Could not fetch data due to too many requests from your account. '
1339
+ f'Status Code was: {status_response.status_code}')
1340
+ elif status_response.status_code == requests.codes['unauthorized']:
1341
+ LOG.info('Server asks for new authorization')
1342
+ session.login()
1343
+ status_response = session.get(url, allow_redirects=False)
1344
+
1345
+ if status_response.status_code in (requests.codes['ok'], requests.codes['multiple_status']):
1346
+ data = status_response.json()
1347
+ if session.cache is not None:
1348
+ session.cache[url] = (data, str(datetime.utcnow()))
1349
+ elif not allow_http_error or (allowed_errors is not None and status_response.status_code not in allowed_errors):
1350
+ raise RetrievalError(f'Could not fetch data even after re-authorization. Status Code was: {status_response.status_code}')
1351
+ elif not allow_http_error or (allowed_errors is not None and status_response.status_code not in allowed_errors):
1352
+ raise RetrievalError(f'Could not fetch data. Status Code was: {status_response.status_code}')
1353
+ except requests.exceptions.ConnectionError as connection_error:
1354
+ raise RetrievalError(f'Connection error: {connection_error}.'
1355
+ ' If this happens frequently, please check if other applications communicate with the Skoda server.') from connection_error
1356
+ except requests.exceptions.ChunkedEncodingError as chunked_encoding_error:
1357
+ raise RetrievalError(f'Error: {chunked_encoding_error}') from chunked_encoding_error
1358
+ except requests.exceptions.ReadTimeout as timeout_error:
1359
+ raise RetrievalError(f'Timeout during read: {timeout_error}') from timeout_error
1360
+ except requests.exceptions.RetryError as retry_error:
1361
+ raise RetrievalError(f'Retrying failed: {retry_error}') from retry_error
1362
+ except requests.exceptions.JSONDecodeError as json_error:
1363
+ if allow_empty:
1364
+ data = None
1365
+ else:
1366
+ raise RetrievalError(f'JSON decode error: {json_error}') from json_error
1367
+ return data
1368
+
1369
+ def get_version(self) -> str:
1370
+ return __version__
1371
+
1372
+ def __on_air_conditioning_target_temperature_change(self, temperature_attribute: TemperatureAttribute, target_temperature: float) -> float:
1373
+ """
1374
+ Callback for the climatization target temperature change.
1375
+
1376
+ Args:
1377
+ temperature_attribute (TemperatureAttribute): The temperature attribute that changed.
1378
+ target_temperature (float): The new target temperature.
1379
+ """
1380
+ if temperature_attribute.parent is None or temperature_attribute.parent.parent is None \
1381
+ or temperature_attribute.parent.parent.parent is None or not isinstance(temperature_attribute.parent.parent.parent, SkodaVehicle):
1382
+ raise SetterError('Object hierarchy is not as expected')
1383
+ vehicle: SkodaVehicle = temperature_attribute.parent.parent.parent
1384
+ vin: Optional[str] = vehicle.vin.value
1385
+ if vin is None:
1386
+ raise SetterError('VIN in object hierarchy missing')
1387
+ setting_dict = {}
1388
+ # Round target temperature to nearest 0.5
1389
+ setting_dict['temperatureValue'] = round(target_temperature * 2) / 2
1390
+ if temperature_attribute.unit == Temperature.C:
1391
+ setting_dict['unitInCar'] = 'CELSIUS'
1392
+ elif temperature_attribute.unit == Temperature.F:
1393
+ setting_dict['unitInCar'] = 'FAHRENHEIT'
1394
+ elif temperature_attribute.unit == Temperature.K:
1395
+ setting_dict['unitInCar'] = 'KELVIN'
1396
+ else:
1397
+ raise SetterError(f'Unknown temperature unit {temperature_attribute.unit}')
1398
+
1399
+ url = f'https://mysmob.api.connect.skoda-auto.cz/api/v2/air-conditioning/{vin}/settings/target-temperature'
1400
+ settings_response: requests.Response = self.session.post(url, data=json.dumps(setting_dict), allow_redirects=True)
1401
+ if settings_response.status_code != requests.codes['accepted']:
1402
+ LOG.error('Could not set target temperature (%s)', settings_response.status_code)
1403
+ raise SetterError(f'Could not set value ({settings_response.status_code})')
1404
+ return target_temperature
1405
+
1406
+ def __on_air_conditioning_at_unlock_change(self, at_unlock_attribute: BooleanAttribute, at_unlock_value: bool) -> bool:
1407
+ if at_unlock_attribute.parent is None or at_unlock_attribute.parent.parent is None \
1408
+ or at_unlock_attribute.parent.parent.parent is None or not isinstance(at_unlock_attribute.parent.parent.parent, SkodaVehicle):
1409
+ raise SetterError('Object hierarchy is not as expected')
1410
+ vehicle: SkodaVehicle = at_unlock_attribute.parent.parent.parent
1411
+ vin: Optional[str] = vehicle.vin.value
1412
+ if vin is None:
1413
+ raise SetterError('VIN in object hierarchy missing')
1414
+ setting_dict = {}
1415
+ # Round target temperature to nearest 0.5
1416
+ setting_dict['airConditioningAtUnlockEnabled'] = at_unlock_value
1417
+
1418
+ url = f'https://mysmob.api.connect.skoda-auto.cz/api/v2/air-conditioning/{vin}/settings/ac-at-unlock'
1419
+ settings_response: requests.Response = self.session.post(url, data=json.dumps(setting_dict), allow_redirects=True)
1420
+ if settings_response.status_code != requests.codes['accepted']:
1421
+ LOG.error('Could not set air conditioning at unlock (%s)', settings_response.status_code)
1422
+ raise SetterError(f'Could not set value ({settings_response.status_code})')
1423
+ return at_unlock_value
1424
+
1425
+ def __on_air_conditioning_window_heating_change(self, window_heating_attribute: BooleanAttribute, window_heating_value: bool) -> bool:
1426
+ if window_heating_attribute.parent is None or window_heating_attribute.parent.parent is None \
1427
+ or window_heating_attribute.parent.parent.parent is None or not isinstance(window_heating_attribute.parent.parent.parent, SkodaVehicle):
1428
+ raise SetterError('Object hierarchy is not as expected')
1429
+ vehicle: SkodaVehicle = window_heating_attribute.parent.parent.parent
1430
+ vin: Optional[str] = vehicle.vin.value
1431
+ if vin is None:
1432
+ raise SetterError('VIN in object hierarchy missing')
1433
+ setting_dict = {}
1434
+ # Round target temperature to nearest 0.5
1435
+ setting_dict['windowHeatingEnabled'] = window_heating_value
1436
+
1437
+ url = f'https://mysmob.api.connect.skoda-auto.cz/api/v2/air-conditioning/{vin}/settings/ac-at-unlock'
1438
+ settings_response: requests.Response = self.session.post(url, data=json.dumps(setting_dict), allow_redirects=True)
1439
+ if settings_response.status_code != requests.codes['accepted']:
1440
+ LOG.error('Could not set air conditioning window heating (%s)', settings_response.status_code)
1441
+ raise SetterError(f'Could not set value ({settings_response.status_code})')
1442
+ return window_heating_value
1443
+
1444
+ def __on_air_conditioning_start_stop(self, start_stop_command: ClimatizationStartStopCommand, command_arguments: Union[str, Dict[str, Any]]) \
1445
+ -> Union[str, Dict[str, Any]]:
1446
+ if start_stop_command.parent is None or start_stop_command.parent.parent is None \
1447
+ or start_stop_command.parent.parent.parent is None or not isinstance(start_stop_command.parent.parent.parent, SkodaVehicle):
1448
+ raise SetterError('Object hierarchy is not as expected')
1449
+ if not isinstance(command_arguments, dict):
1450
+ raise SetterError('Command arguments are not a dictionary')
1451
+ vehicle: SkodaVehicle = start_stop_command.parent.parent.parent
1452
+ vin: Optional[str] = vehicle.vin.value
1453
+ if vin is None:
1454
+ raise SetterError('VIN in object hierarchy missing')
1455
+ if 'command' not in command_arguments:
1456
+ raise SetterError('Command argument missing')
1457
+ command_dict = {}
1458
+ if command_arguments['command'] == ClimatizationStartStopCommand.Command.START:
1459
+ command_dict['heaterSource'] = 'ELECTRIC'
1460
+ command_dict['targetTemperature'] = {}
1461
+ if 'target_temperature' in command_arguments:
1462
+ # Round target temperature to nearest 0.5
1463
+ command_dict['targetTemperature']['temperatureValue'] = round(command_arguments['target_temperature'] * 2) / 2
1464
+ if 'target_temperature_unit' in command_arguments:
1465
+ if not isinstance(command_arguments['target_temperature_unit'], Temperature):
1466
+ raise SetterError('Temperature unit is not of type Temperature')
1467
+ if command_arguments['target_temperature_unit'] == Temperature.C:
1468
+ command_dict['targetTemperature']['unitInCar'] = 'CELSIUS'
1469
+ elif command_arguments['target_temperature_unit'] == Temperature.F:
1470
+ command_dict['targetTemperature']['unitInCar'] = 'FAHRENHEIT'
1471
+ elif command_arguments['target_temperature_unit'] == Temperature.K:
1472
+ command_dict['targetTemperature']['unitInCar'] = 'KELVIN'
1473
+ else:
1474
+ raise SetterError(f'Unknown temperature unit {command_arguments['target_temperature_unit']}')
1475
+ else:
1476
+ command_dict['targetTemperature']['unitInCar'] = 'CELSIUS'
1477
+ elif start_stop_command.parent is not None and (climatization := start_stop_command.parent.parent) is not None \
1478
+ and isinstance(climatization, Climatization) and climatization.settings is not None \
1479
+ and climatization.settings.target_temperature is not None and climatization.settings.target_temperature.enabled \
1480
+ and climatization.settings.target_temperature.value is not None: # pylint: disable=too-many-boolean-expressions
1481
+ # Round target temperature to nearest 0.5
1482
+ command_dict['targetTemperature']['temperatureValue'] = round(climatization.settings.target_temperature.value * 2) / 2
1483
+ if climatization.settings.target_temperature.unit == Temperature.C:
1484
+ command_dict['targetTemperature']['unitInCar'] = 'CELSIUS'
1485
+ elif climatization.settings.target_temperature.unit == Temperature.F:
1486
+ command_dict['targetTemperature']['unitInCar'] = 'FAHRENHEIT'
1487
+ elif climatization.settings.target_temperature.unit == Temperature.K:
1488
+ command_dict['targetTemperature']['unitInCar'] = 'KELVIN'
1489
+ else:
1490
+ raise SetterError(f'Unknown temperature unit {climatization.settings.target_temperature.unit}')
1491
+ else:
1492
+ command_dict['targetTemperature']['temperatureValue'] = 25.0
1493
+ command_dict['targetTemperature']['unitInCar'] = 'CELSIUS'
1494
+ url = f'https://mysmob.api.connect.skoda-auto.cz/api/v2/air-conditioning/{vin}/start'
1495
+ command_response: requests.Response = self.session.post(url, data=json.dumps(command_dict), allow_redirects=True)
1496
+ elif command_arguments['command'] == ClimatizationStartStopCommand.Command.STOP:
1497
+ url = f'https://mysmob.api.connect.skoda-auto.cz/api/v2/air-conditioning/{vin}/stop'
1498
+ command_response: requests.Response = self.session.post(url, allow_redirects=True)
1499
+ else:
1500
+ raise SetterError(f'Unknown command {command_arguments["command"]}')
1501
+
1502
+ if command_response.status_code != requests.codes['accepted']:
1503
+ LOG.error('Could not start/stop air conditioning (%s: %s)', command_response.status_code, command_response.text)
1504
+ raise SetterError(f'Could not start/stop air conditioning ({command_response.status_code}: {command_response.text})')
1505
+ return command_arguments
1506
+
1507
+ def __on_charging_start_stop(self, start_stop_command: ChargingStartStopCommand, command_arguments: Union[str, Dict[str, Any]]) \
1508
+ -> Union[str, Dict[str, Any]]:
1509
+ if start_stop_command.parent is None or start_stop_command.parent.parent is None \
1510
+ or start_stop_command.parent.parent.parent is None or not isinstance(start_stop_command.parent.parent.parent, SkodaVehicle):
1511
+ raise SetterError('Object hierarchy is not as expected')
1512
+ if not isinstance(command_arguments, dict):
1513
+ raise SetterError('Command arguments are not a dictionary')
1514
+ vehicle: SkodaVehicle = start_stop_command.parent.parent.parent
1515
+ vin: Optional[str] = vehicle.vin.value
1516
+ if vin is None:
1517
+ raise SetterError('VIN in object hierarchy missing')
1518
+ if 'command' not in command_arguments:
1519
+ raise SetterError('Command argument missing')
1520
+ if command_arguments['command'] == ChargingStartStopCommand.Command.START:
1521
+ url = f'https://mysmob.api.connect.skoda-auto.cz/api/v1/charging/{vin}/start'
1522
+ command_response: requests.Response = self.session.post(url, allow_redirects=True)
1523
+ elif command_arguments['command'] == ChargingStartStopCommand.Command.STOP:
1524
+ url = f'https://mysmob.api.connect.skoda-auto.cz/api/v1/charging/{vin}/stop'
1525
+ command_response: requests.Response = self.session.post(url, allow_redirects=True)
1526
+ else:
1527
+ raise SetterError(f'Unknown command {command_arguments["command"]}')
1528
+
1529
+ if command_response.status_code != requests.codes['accepted']:
1530
+ LOG.error('Could not start/stop charging (%s: %s)', command_response.status_code, command_response.text)
1531
+ raise SetterError(f'Could not start/stop charging ({command_response.status_code}: {command_response.text})')
1532
+ return command_arguments
1533
+
1534
+ def __on_honk_flash(self, honk_flash_command: HonkAndFlashCommand, command_arguments: Union[str, Dict[str, Any]]) \
1535
+ -> Union[str, Dict[str, Any]]:
1536
+ if honk_flash_command.parent is None or honk_flash_command.parent.parent is None \
1537
+ or not isinstance(honk_flash_command.parent.parent, SkodaVehicle):
1538
+ raise SetterError('Object hierarchy is not as expected')
1539
+ if not isinstance(command_arguments, dict):
1540
+ raise SetterError('Command arguments are not a dictionary')
1541
+ vehicle: SkodaVehicle = honk_flash_command.parent.parent
1542
+ vin: Optional[str] = vehicle.vin.value
1543
+ if vin is None:
1544
+ raise SetterError('VIN in object hierarchy missing')
1545
+ if 'command' not in command_arguments:
1546
+ raise SetterError('Command argument missing')
1547
+ command_dict = {}
1548
+ if command_arguments['command'] in [HonkAndFlashCommand.Command.FLASH, HonkAndFlashCommand.Command.HONK_AND_FLASH]:
1549
+ command_dict['mode'] = command_arguments['command'].name
1550
+ command_dict['vehiclePosition'] = {}
1551
+ if vehicle.position is None or vehicle.position.latitude is None or vehicle.position.longitude is None \
1552
+ or vehicle.position.latitude.value is None or vehicle.position.longitude.value is None \
1553
+ or not vehicle.position.latitude.enabled or not vehicle.position.longitude.enabled:
1554
+ raise SetterError('Can only execute honk and flash commands if vehicle position is known')
1555
+ command_dict['vehiclePosition']['latitude'] = vehicle.position.latitude.value
1556
+ command_dict['vehiclePosition']['longitude'] = vehicle.position.longitude.value
1557
+
1558
+ url = f'https://mysmob.api.connect.skoda-auto.cz/api/v1/vehicle-access/{vin}/honk-and-flash'
1559
+ command_response: requests.Response = self.session.post(url, data=json.dumps(command_dict), allow_redirects=True)
1560
+ if command_response.status_code != requests.codes['accepted']:
1561
+ LOG.error('Could not execute honk or flash command (%s: %s)', command_response.status_code, command_response.text)
1562
+ raise SetterError(f'Could not execute honk or flash command ({command_response.status_code}: {command_response.text})')
1563
+ else:
1564
+ raise SetterError(f'Unknown command {command_arguments["command"]}')
1565
+ return command_arguments
1566
+
1567
+ def __on_lock_unlock(self, lock_unlock_command: LockUnlockCommand, command_arguments: Union[str, Dict[str, Any]]) \
1568
+ -> Union[str, Dict[str, Any]]:
1569
+ if lock_unlock_command.parent is None or lock_unlock_command.parent.parent is None \
1570
+ or lock_unlock_command.parent.parent.parent is None or not isinstance(lock_unlock_command.parent.parent.parent, SkodaVehicle):
1571
+ raise SetterError('Object hierarchy is not as expected')
1572
+ if not isinstance(command_arguments, dict):
1573
+ raise SetterError('Command arguments are not a dictionary')
1574
+ vehicle: SkodaVehicle = lock_unlock_command.parent.parent.parent
1575
+ vin: Optional[str] = vehicle.vin.value
1576
+ if vin is None:
1577
+ raise SetterError('VIN in object hierarchy missing')
1578
+ if 'command' not in command_arguments:
1579
+ raise SetterError('Command argument missing')
1580
+ command_dict = {}
1581
+ if 'spin' in command_arguments:
1582
+ command_dict['currentSpin'] = command_arguments['spin']
1583
+ else:
1584
+ if self._spin is None:
1585
+ raise SetterError('S-PIN is missing, please add S-PIN to your configuration or .netrc file')
1586
+ command_dict['currentSpin'] = self._spin
1587
+ if command_arguments['command'] == LockUnlockCommand.Command.LOCK:
1588
+ url = f'https://mysmob.api.connect.skoda-auto.cz/api/v1/vehicle-access/{vin}/lock'
1589
+ elif command_arguments['command'] == LockUnlockCommand.Command.UNLOCK:
1590
+ url = f'https://mysmob.api.connect.skoda-auto.cz/api/v1/vehicle-access/{vin}/unlock'
1591
+ else:
1592
+ raise SetterError(f'Unknown command {command_arguments["command"]}')
1593
+ command_response: requests.Response = self.session.post(url, data=json.dumps(command_dict), allow_redirects=True)
1594
+ if command_response.status_code != requests.codes['accepted']:
1595
+ LOG.error('Could not execute locking command (%s: %s)', command_response.status_code, command_response.text)
1596
+ raise SetterError(f'Could not execute locking command ({command_response.status_code}: {command_response.text})')
1597
+ return command_arguments
1598
+
1599
+ def __on_spin(self, spin_command: SpinCommand, command_arguments: Union[str, Dict[str, Any]]) \
1600
+ -> Union[str, Dict[str, Any]]:
1601
+ if not isinstance(command_arguments, dict):
1602
+ raise SetterError('Command arguments are not a dictionary')
1603
+ if 'command' not in command_arguments:
1604
+ raise SetterError('Command argument missing')
1605
+ command_dict = {}
1606
+ if self._spin is None:
1607
+ raise SetterError('S-PIN is missing, please add S-PIN to your configuration or .netrc file')
1608
+ if 'spin' in command_arguments:
1609
+ command_dict['currentSpin'] = command_arguments['spin']
1610
+ else:
1611
+ if self._spin is None:
1612
+ raise SetterError('S-PIN is missing, please add S-PIN to your configuration or .netrc file')
1613
+ command_dict['currentSpin'] = self._spin
1614
+ if command_arguments['command'] == SpinCommand.Command.VERIFY:
1615
+ url = 'https://mysmob.api.connect.skoda-auto.cz/api/v1/spin/verify'
1616
+ else:
1617
+ raise SetterError(f'Unknown command {command_arguments["command"]}')
1618
+ command_response: requests.Response = self.session.post(url, data=json.dumps(command_dict), allow_redirects=True)
1619
+ if command_response.status_code != requests.codes['ok']:
1620
+ LOG.error('Could not execute spin command (%s: %s)', command_response.status_code, command_response.text)
1621
+ raise SetterError(f'Could not execute spin command ({command_response.status_code}: {command_response.text})')
1622
+ else:
1623
+ LOG.info('Spin verify command executed successfully')
1624
+ return command_arguments