carconnectivity-connector-skoda 0.1a11__py3-none-any.whl → 0.8.2__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.
@@ -1,28 +1,39 @@
1
- """Module implements the connector to interact with the Skoda API."""
1
+ """Module implements the connector to interact with the Skoda API.""" # pylint: disable=too-many-lines
2
2
  from __future__ import annotations
3
3
  from typing import TYPE_CHECKING
4
4
 
5
5
  import threading
6
6
  import os
7
+ import traceback
7
8
  import logging
8
9
  import netrc
9
10
  from datetime import datetime, timedelta, timezone
11
+ import json
12
+
10
13
  import requests
11
14
 
15
+
12
16
  from carconnectivity.garage import Garage
13
17
  from carconnectivity.vehicle import GenericVehicle
14
18
  from carconnectivity.errors import AuthenticationError, TooManyRequestsError, RetrievalError, APIError, APICompatibilityError, \
15
- TemporaryAuthenticationError, ConfigurationError
19
+ TemporaryAuthenticationError, SetterError, CommandError
16
20
  from carconnectivity.util import robust_time_parse, log_extra_keys, config_remove_credentials
17
21
  from carconnectivity.units import Length, Speed, Power, Temperature
18
22
  from carconnectivity.doors import Doors
19
23
  from carconnectivity.windows import Windows
20
24
  from carconnectivity.lights import Lights
21
- from carconnectivity.drive import GenericDrive, ElectricDrive, CombustionDrive
22
- from carconnectivity.attributes import BooleanAttribute, DurationAttribute
25
+ from carconnectivity.drive import GenericDrive, ElectricDrive, CombustionDrive, DieselDrive
26
+ from carconnectivity.attributes import BooleanAttribute, DurationAttribute, TemperatureAttribute, EnumAttribute, LevelAttribute, \
27
+ CurrentAttribute
23
28
  from carconnectivity.charging import Charging
24
29
  from carconnectivity.position import Position
25
30
  from carconnectivity.climatization import Climatization
31
+ from carconnectivity.charging_connector import ChargingConnector
32
+ from carconnectivity.commands import Commands
33
+ from carconnectivity.command_impl import ClimatizationStartStopCommand, ChargingStartStopCommand, HonkAndFlashCommand, LockUnlockCommand, WakeSleepCommand, \
34
+ WindowHeatingStartStopCommand
35
+ from carconnectivity.enums import ConnectionState
36
+ from carconnectivity.window_heating import WindowHeatings
26
37
 
27
38
  from carconnectivity_connectors.base.connector import BaseConnector
28
39
  from carconnectivity_connectors.skoda.auth.session_manager import SessionManager, SessionUser, Service
@@ -30,11 +41,24 @@ from carconnectivity_connectors.skoda.auth.my_skoda_session import MySkodaSessio
30
41
  from carconnectivity_connectors.skoda.vehicle import SkodaVehicle, SkodaElectricVehicle, SkodaCombustionVehicle, SkodaHybridVehicle
31
42
  from carconnectivity_connectors.skoda.capability import Capability
32
43
  from carconnectivity_connectors.skoda.charging import SkodaCharging, mapping_skoda_charging_state
44
+ from carconnectivity_connectors.skoda.climatization import SkodaClimatization
45
+ from carconnectivity_connectors.skoda.error import Error
33
46
  from carconnectivity_connectors.skoda._version import __version__
34
47
  from carconnectivity_connectors.skoda.mqtt_client import SkodaMQTTClient
48
+ from carconnectivity_connectors.skoda.command_impl import SpinCommand
49
+
50
+ SUPPORT_IMAGES = False
51
+ try:
52
+ from PIL import Image
53
+ import base64
54
+ import io
55
+ SUPPORT_IMAGES = True
56
+ from carconnectivity.attributes import ImageAttribute
57
+ except ImportError:
58
+ pass
35
59
 
36
60
  if TYPE_CHECKING:
37
- from typing import Dict, List, Optional, Any
61
+ from typing import Dict, List, Optional, Any, Set, Union
38
62
 
39
63
  from carconnectivity.carconnectivity import CarConnectivity
40
64
 
@@ -52,7 +76,7 @@ class Connector(BaseConnector):
52
76
  max_age (Optional[int]): Maximum age for cached data in seconds.
53
77
  """
54
78
  def __init__(self, connector_id: str, car_connectivity: CarConnectivity, config: Dict) -> None:
55
- BaseConnector.__init__(self, connector_id=connector_id, car_connectivity=car_connectivity, config=config)
79
+ BaseConnector.__init__(self, connector_id=connector_id, car_connectivity=car_connectivity, config=config, log=LOG, api_log=LOG_API)
56
80
 
57
81
  self._mqtt_client: SkodaMQTTClient = SkodaMQTTClient(skoda_connector=self)
58
82
 
@@ -60,74 +84,79 @@ class Connector(BaseConnector):
60
84
  self._background_connect_thread: Optional[threading.Thread] = None
61
85
  self._stop_event = threading.Event()
62
86
 
63
- self.connected: BooleanAttribute = BooleanAttribute(name="connected", parent=self)
64
- self.interval: DurationAttribute = DurationAttribute(name="interval", parent=self)
87
+ self.connection_state: EnumAttribute = EnumAttribute(name="connection_state", parent=self, value_type=ConnectionState,
88
+ value=ConnectionState.DISCONNECTED, tags={'connector_custom'})
89
+ self.rest_connected: bool = False
90
+ self.mqtt_connected: bool = False
91
+ self.interval: DurationAttribute = DurationAttribute(name="interval", parent=self, tags={'connector_custom'})
92
+ self.interval.minimum = timedelta(seconds=180)
93
+ self.interval._is_changeable = True # pylint: disable=protected-access
94
+
95
+ self.commands: Commands = Commands(parent=self)
65
96
 
66
97
  self.user_id: Optional[str] = None
67
98
 
68
- # Configure logging
69
- if 'log_level' in config and config['log_level'] is not None:
70
- config['log_level'] = config['log_level'].upper()
71
- if config['log_level'] in logging.getLevelNamesMapping():
72
- LOG.setLevel(config['log_level'])
73
- self.log_level._set_value(config['log_level']) # pylint: disable=protected-access
74
- logging.getLogger('requests').setLevel(config['log_level'])
75
- logging.getLogger('urllib3').setLevel(config['log_level'])
76
- logging.getLogger('oauthlib').setLevel(config['log_level'])
77
- else:
78
- raise ConfigurationError(f'Invalid log level: "{config["log_level"]}" not in {list(logging.getLevelNamesMapping().keys())}')
79
- if 'api_log_level' in config and config['api_log_level'] is not None:
80
- config['api_log_level'] = config['api_log_level'].upper()
81
- if config['api_log_level'] in logging.getLevelNamesMapping():
82
- LOG_API.setLevel(config['api_log_level'])
83
- else:
84
- raise ConfigurationError(f'Invalid log level: "{config["log_level"]}" not in {list(logging.getLevelNamesMapping().keys())}')
85
- LOG.info("Loading skoda connector with config %s", config_remove_credentials(self.config))
86
-
87
- username: Optional[str] = None
88
- password: Optional[str] = None
89
- if 'username' in self.config and 'password' in self.config:
90
- username = self.config['username']
91
- password = self.config['password']
99
+ LOG.info("Loading skoda connector with config %s", config_remove_credentials(config))
100
+
101
+ if 'spin' in config and config['spin'] is not None:
102
+ self.active_config['spin'] = config['spin']
103
+ else:
104
+ self.active_config['spin'] = None
105
+
106
+ self.active_config['username'] = None
107
+ self.active_config['password'] = None
108
+ if 'username' in config and 'password' in config:
109
+ self.active_config['username'] = config['username']
110
+ self.active_config['password'] = config['password']
92
111
  else:
93
- if 'netrc' in self.config:
94
- netrc_filename: str = self.config['netrc']
112
+ if 'netrc' in config:
113
+ self.active_config['netrc'] = config['netrc']
95
114
  else:
96
- netrc_filename = os.path.join(os.path.expanduser("~"), ".netrc")
115
+ self.active_config['netrc'] = os.path.join(os.path.expanduser("~"), ".netrc")
97
116
  try:
98
- secrets = netrc.netrc(file=netrc_filename)
117
+ secrets = netrc.netrc(file=self.active_config['netrc'])
99
118
  secret: tuple[str, str, str] | None = secrets.authenticators("skoda")
100
119
  if secret is None:
101
- raise AuthenticationError(f'Authentication using {netrc_filename} failed: skoda not found in netrc')
102
- username, _, password = secret
120
+ raise AuthenticationError(f'Authentication using {self.active_config["netrc"]} failed: skoda not found in netrc')
121
+ self.active_config['username'], account, self.active_config['password'] = secret
122
+
123
+ if self.active_config['spin'] is None and account is not None:
124
+ try:
125
+ self.active_config['spin'] = account
126
+ except ValueError as err:
127
+ LOG.error('Could not parse spin from netrc: %s', err)
103
128
  except netrc.NetrcParseError as err:
104
- LOG.error('Authentification using %s failed: %s', netrc_filename, err)
105
- raise AuthenticationError(f'Authentication using {netrc_filename} failed: {err}') from err
129
+ LOG.error('Authentification using %s failed: %s', self.active_config['netrc'], err)
130
+ raise AuthenticationError(f'Authentication using {self.active_config["netrc"]} failed: {err}') from err
106
131
  except TypeError as err:
107
- if 'username' not in self.config:
108
- raise AuthenticationError(f'"skoda" entry was not found in {netrc_filename} netrc-file.'
132
+ if 'username' not in config:
133
+ raise AuthenticationError(f'"skoda" entry was not found in {self.active_config["netrc"]} netrc-file.'
109
134
  ' Create it or provide username and password in config') from err
110
135
  except FileNotFoundError as err:
111
- raise AuthenticationError(f'{netrc_filename} netrc-file was not found. Create it or provide username and password in config') from err
112
-
113
- interval: int = 300
114
- if 'interval' in self.config:
115
- interval = self.config['interval']
116
- if interval < 300:
117
- raise ValueError('Intervall must be at least 300 seconds')
118
- self.max_age: int = interval - 1
119
- if 'max_age' in self.config:
120
- self.max_age = self.config['max_age']
121
- self.interval._set_value(timedelta(seconds=interval)) # pylint: disable=protected-access
122
-
123
- if username is None or password is None:
136
+ raise AuthenticationError(f'{self.active_config["netrc"]} netrc-file was not found. Create it or provide username and password in config') \
137
+ from err
138
+
139
+ self.active_config['interval'] = 300
140
+ if 'interval' in config:
141
+ self.active_config['interval'] = config['interval']
142
+ if self.active_config['interval'] < 180:
143
+ raise ValueError('Intervall must be at least 180 seconds')
144
+ self.active_config['max_age'] = self.active_config['interval'] - 1
145
+ if 'max_age' in config:
146
+ self.active_config['max_age'] = config['max_age']
147
+ self.interval._set_value(timedelta(seconds=self.active_config['interval'])) # pylint: disable=protected-access
148
+
149
+ if self.active_config['username'] is None or self.active_config['password'] is None:
124
150
  raise AuthenticationError('Username or password not provided')
125
151
 
126
152
  self._manager: SessionManager = SessionManager(tokenstore=car_connectivity.get_tokenstore(), cache=car_connectivity.get_cache())
127
- session: requests.Session = self._manager.get_session(Service.MY_SKODA, SessionUser(username=username, password=password))
153
+ session: requests.Session = self._manager.get_session(Service.MY_SKODA, SessionUser(username=self.active_config['username'],
154
+ password=self.active_config['password']))
128
155
  if not isinstance(session, MySkodaSession):
129
156
  raise AuthenticationError('Could not create session')
130
157
  self.session: MySkodaSession = session
158
+ self.session.retries = 3
159
+ self.session.timeout = 180
131
160
  self.session.refresh()
132
161
 
133
162
  self._elapsed: List[timedelta] = []
@@ -136,12 +165,15 @@ class Connector(BaseConnector):
136
165
  self._stop_event.clear()
137
166
  # Start background thread for Rest API polling
138
167
  self._background_thread = threading.Thread(target=self._background_loop, daemon=False)
168
+ self._background_thread.name = 'carconnectivity.connectors.skoda-background'
139
169
  self._background_thread.start()
140
170
  # Start background thread for MQTT connection
141
171
  self._background_connect_thread = threading.Thread(target=self._background_connect_loop, daemon=False)
172
+ self._background_connect_thread.name = 'carconnectivity.connectors.skoda-background_connect'
142
173
  self._background_connect_thread.start()
143
174
  # Start MQTT thread
144
175
  self._mqtt_client.loop_start()
176
+ self.healthy._set_value(value=True) # pylint: disable=protected-access
145
177
 
146
178
  def _background_connect_loop(self) -> None:
147
179
  while not self._stop_event.is_set():
@@ -155,6 +187,7 @@ class Connector(BaseConnector):
155
187
  def _background_loop(self) -> None:
156
188
  self._stop_event.clear()
157
189
  fetch: bool = True
190
+ self.connection_state._set_value(value=ConnectionState.CONNECTING) # pylint: disable=protected-access
158
191
  while not self._stop_event.is_set():
159
192
  interval = 300
160
193
  try:
@@ -166,25 +199,50 @@ class Connector(BaseConnector):
166
199
  self.update_vehicles()
167
200
  self.last_update._set_value(value=datetime.now(tz=timezone.utc)) # pylint: disable=protected-access
168
201
  if self.interval.value is not None:
169
- interval: int = self.interval.value.total_seconds()
202
+ interval: float = self.interval.value.total_seconds()
170
203
  except Exception:
171
204
  if self.interval.value is not None:
172
- interval: int = self.interval.value.total_seconds()
205
+ interval: float = self.interval.value.total_seconds()
173
206
  raise
174
207
  except TooManyRequestsError as err:
175
208
  LOG.error('Retrieval error during update. Too many requests from your account (%s). Will try again after 15 minutes', str(err))
209
+ self.connection_state._set_value(value=ConnectionState.ERROR) # pylint: disable=protected-access
210
+ self.rest_connected = False
176
211
  self._stop_event.wait(900)
177
212
  except RetrievalError as err:
178
213
  LOG.error('Retrieval error during update (%s). Will try again after configured interval of %ss', str(err), interval)
214
+ self.connection_state._set_value(value=ConnectionState.ERROR) # pylint: disable=protected-access
215
+ self.rest_connected = False
216
+ self._stop_event.wait(interval)
217
+ except APIError as err:
218
+ LOG.error('API error during update (%s). Will try again after configured interval of %ss', str(err), interval)
219
+ self.connection_state._set_value(value=ConnectionState.ERROR) # pylint: disable=protected-access
220
+ self.rest_connected = False
179
221
  self._stop_event.wait(interval)
180
222
  except APICompatibilityError as err:
181
223
  LOG.error('API compatability error during update (%s). Will try again after configured interval of %ss', str(err), interval)
224
+ self.connection_state._set_value(value=ConnectionState.ERROR) # pylint: disable=protected-access
225
+ self.rest_connected = False
182
226
  self._stop_event.wait(interval)
183
227
  except TemporaryAuthenticationError as err:
184
228
  LOG.error('Temporary authentification error during update (%s). Will try again after configured interval of %ss', str(err), interval)
229
+ self.connection_state._set_value(value=ConnectionState.ERROR) # pylint: disable=protected-access
230
+ self.rest_connected = False
185
231
  self._stop_event.wait(interval)
232
+ except Exception as err:
233
+ LOG.critical('Critical error during update: %s', traceback.format_exc())
234
+ self.connection_state._set_value(value=ConnectionState.ERROR) # pylint: disable=protected-access
235
+ self.rest_connected = False
236
+ self.healthy._set_value(value=False) # pylint: disable=protected-access
237
+ raise err
186
238
  else:
239
+ self.rest_connected = True
240
+ if self.mqtt_connected:
241
+ self.connection_state._set_value(value=ConnectionState.CONNECTED) # pylint: disable=protected-access
187
242
  self._stop_event.wait(interval)
243
+ # When leaving the loop, set the connection state to disconnected
244
+ self.connection_state._set_value(value=ConnectionState.DISCONNECTED) # pylint: disable=protected-access
245
+ self.rest_connected = False
188
246
 
189
247
  def persist(self) -> None:
190
248
  """
@@ -215,12 +273,12 @@ class Connector(BaseConnector):
215
273
  self.car_connectivity.garage.remove_vehicle(vehicle.id)
216
274
  vehicle.enabled = False
217
275
  self._stop_event.set()
276
+ self.session.close()
218
277
  if self._background_thread is not None:
219
278
  self._background_thread.join()
220
279
  if self._background_connect_thread is not None:
221
280
  self._background_connect_thread.join()
222
281
  self.persist()
223
- self.session.close()
224
282
  return super().shutdown()
225
283
 
226
284
  def fetch_all(self) -> None:
@@ -229,6 +287,12 @@ class Connector(BaseConnector):
229
287
 
230
288
  This method calls the `fetch_vehicles` method to retrieve vehicle data.
231
289
  """
290
+ # Add spin command
291
+ if self.commands is not None and not self.commands.contains_command('spin'):
292
+ spin_command = SpinCommand(parent=self.commands)
293
+ spin_command._add_on_set_hook(self.__on_spin) # pylint: disable=protected-access
294
+ spin_command.enabled = True
295
+ self.commands.add_command(spin_command)
232
296
  self.fetch_vehicles()
233
297
  self.car_connectivity.transaction_end()
234
298
 
@@ -264,6 +328,9 @@ class Connector(BaseConnector):
264
328
  if 'vehicles' in data and data['vehicles'] is not None:
265
329
  for vehicle_dict in data['vehicles']:
266
330
  if 'vin' in vehicle_dict and vehicle_dict['vin'] is not None:
331
+ if vehicle_dict['vin'] in self.active_config['hide_vins']:
332
+ LOG.info('Vehicle %s filtered out due to configuration', vehicle_dict['vin'])
333
+ continue
267
334
  seen_vehicle_vins.add(vehicle_dict['vin'])
268
335
  vehicle: Optional[SkodaVehicle] = garage.get_vehicle(vehicle_dict['vin']) # pyright: ignore[reportAssignmentType]
269
336
  if not vehicle:
@@ -275,9 +342,16 @@ class Connector(BaseConnector):
275
342
  else:
276
343
  vehicle.license_plate._set_value(None) # pylint: disable=protected-access
277
344
 
278
- log_extra_keys(LOG_API, 'vehicles', vehicle_dict, {'vin', 'licensePlate'})
345
+ if 'name' in vehicle_dict and vehicle_dict['name'] is not None:
346
+ vehicle.name._set_value(vehicle_dict['name']) # pylint: disable=protected-access
347
+ else:
348
+ vehicle.name._set_value(None) # pylint: disable=protected-access
349
+
350
+ log_extra_keys(LOG_API, 'vehicles', vehicle_dict, {'vin', 'licensePlate', 'name'})
279
351
 
280
352
  vehicle = self.fetch_vehicle_details(vehicle)
353
+ if SUPPORT_IMAGES:
354
+ vehicle = self.fetch_vehicle_images(vehicle)
281
355
  else:
282
356
  raise APIError('Could not parse vehicle, vin missing')
283
357
  for vin in set(garage.list_vehicle_vins()) - seen_vehicle_vins:
@@ -302,15 +376,46 @@ class Connector(BaseConnector):
302
376
  for vin in set(garage.list_vehicle_vins()):
303
377
  vehicle_to_update: Optional[GenericVehicle] = garage.get_vehicle(vin)
304
378
  if vehicle_to_update is not None and isinstance(vehicle_to_update, SkodaVehicle) and vehicle_to_update.is_managed_by_connector(self):
305
- vehicle_to_update = self.fetch_vehicle_status_second_api(vehicle_to_update)
306
- vehicle_to_update = self.fetch_driving_range(vehicle_to_update)
379
+ vehicle_to_update = self.fetch_vehicle_status(vehicle_to_update)
307
380
  if vehicle_to_update.capabilities is not None and vehicle_to_update.capabilities.enabled:
308
- if vehicle_to_update.capabilities.has_capability('PARKING_POSITION'):
381
+ if vehicle_to_update.capabilities.has_capability('MEASUREMENTS', check_status_ok=True) or \
382
+ vehicle_to_update.capabilities.has_capability('CHARGING', check_status_ok=True):
383
+ vehicle_to_update = self.fetch_driving_range(vehicle_to_update)
384
+ if vehicle_to_update.capabilities.has_capability('READINESS', check_status_ok=True):
385
+ vehicle_to_update = self.fetch_connection_status(vehicle_to_update)
386
+ if vehicle_to_update.capabilities.has_capability('PARKING_POSITION', check_status_ok=True):
309
387
  vehicle_to_update = self.fetch_position(vehicle_to_update)
310
- if vehicle_to_update.capabilities.has_capability('CHARGING') and isinstance(vehicle_to_update, SkodaElectricVehicle):
388
+ if vehicle_to_update.capabilities.has_capability('CHARGING', check_status_ok=True) and isinstance(vehicle_to_update, SkodaElectricVehicle):
311
389
  vehicle_to_update = self.fetch_charging(vehicle_to_update)
312
- if vehicle_to_update.capabilities.has_capability('AIR_CONDITIONING'):
390
+ if vehicle_to_update.capabilities.has_capability('AIR_CONDITIONING', check_status_ok=True):
313
391
  vehicle_to_update = self.fetch_air_conditioning(vehicle_to_update)
392
+ if vehicle_to_update.capabilities.has_capability('VEHICLE_HEALTH_INSPECTION', check_status_ok=True):
393
+ vehicle_to_update = self.fetch_maintenance(vehicle_to_update)
394
+ vehicle_to_update = self.decide_state(vehicle_to_update)
395
+ self.car_connectivity.transaction_end()
396
+
397
+ def decide_state(self, vehicle: SkodaVehicle) -> SkodaVehicle:
398
+ """
399
+ Decides the state of the vehicle based on the current data.
400
+
401
+ Args:
402
+ vehicle (SkodaVehicle): The Skoda vehicle object.
403
+
404
+ Returns:
405
+ SkodaVehicle: The Skoda vehicle object with the updated state.
406
+ """
407
+ if vehicle is not None:
408
+ if vehicle.connection_state is not None and vehicle.connection_state.enabled \
409
+ and vehicle.connection_state.value == GenericVehicle.ConnectionState.OFFLINE:
410
+ vehicle.state._set_value(GenericVehicle.State.OFFLINE) # pylint: disable=protected-access
411
+ elif vehicle.in_motion is not None and vehicle.in_motion.enabled and vehicle.in_motion.value:
412
+ vehicle.state._set_value(GenericVehicle.State.IGNITION_ON) # pylint: disable=protected-access
413
+ elif vehicle.position is not None and vehicle.position.enabled and vehicle.position.position_type is not None \
414
+ and vehicle.position.position_type.enabled and vehicle.position.position_type.value == Position.PositionType.PARKING:
415
+ vehicle.state._set_value(GenericVehicle.State.PARKED) # pylint: disable=protected-access
416
+ else:
417
+ vehicle.state._set_value(GenericVehicle.State.UNKNOWN) # pylint: disable=protected-access
418
+ return vehicle
314
419
 
315
420
  def fetch_charging(self, vehicle: SkodaElectricVehicle, no_cache: bool = False) -> SkodaElectricVehicle:
316
421
  """
@@ -331,10 +436,21 @@ class Connector(BaseConnector):
331
436
  url = f'https://mysmob.api.connect.skoda-auto.cz/api/v1/charging/{vin}'
332
437
  data: Dict[str, Any] | None = self._fetch_data(url=url, session=self.session, no_cache=no_cache)
333
438
  if data is not None:
439
+ if not vehicle.charging.commands.contains_command('start-stop'):
440
+ start_stop_command: ChargingStartStopCommand = ChargingStartStopCommand(parent=vehicle.charging.commands)
441
+ start_stop_command._add_on_set_hook(self.__on_charging_start_stop) # pylint: disable=protected-access
442
+ start_stop_command.enabled = True
443
+ vehicle.charging.commands.add_command(start_stop_command)
334
444
  if 'carCapturedTimestamp' in data and data['carCapturedTimestamp'] is not None:
335
445
  captured_at: datetime = robust_time_parse(data['carCapturedTimestamp'])
336
446
  else:
337
447
  raise APIError('Could not fetch charging, carCapturedTimestamp missing')
448
+ if 'isVehicleInSavedLocation' in data and data['isVehicleInSavedLocation'] is not None:
449
+ if vehicle.charging is not None:
450
+ if not isinstance(vehicle.charging, SkodaCharging):
451
+ vehicle.charging = SkodaCharging(origin=vehicle.charging)
452
+ # pylint: disable-next=protected-access
453
+ vehicle.charging.is_in_saved_location._set_value(data['isVehicleInSavedLocation'], measured=captured_at)
338
454
  if 'status' in data and data['status'] is not None:
339
455
  if 'state' in data['status'] and data['status']['state'] is not None:
340
456
  if data['status']['state'] in [item.name for item in SkodaCharging.SkodaChargingState]:
@@ -361,15 +477,201 @@ class Connector(BaseConnector):
361
477
  if 'remainingTimeToFullyChargedInMinutes' in data['status'] and data['status']['remainingTimeToFullyChargedInMinutes'] is not None:
362
478
  remaining_duration: timedelta = timedelta(minutes=data['status']['remainingTimeToFullyChargedInMinutes'])
363
479
  estimated_date_reached: datetime = captured_at + remaining_duration
480
+ estimated_date_reached = estimated_date_reached.replace(second=0, microsecond=0)
364
481
  # pylint: disable-next=protected-access
365
482
  vehicle.charging.estimated_date_reached._set_value(value=estimated_date_reached, measured=captured_at)
366
483
  else:
367
484
  vehicle.charging.estimated_date_reached._set_value(None, measured=captured_at) # pylint: disable=protected-access
485
+ if 'chargeType' in data['status'] and data['status']['chargeType'] is not None:
486
+ if data['status']['chargeType'] in [item.name for item in Charging.ChargingType]:
487
+ charge_type: Charging.ChargingType = Charging.ChargingType[data['status']['chargeType']]
488
+ else:
489
+ LOG_API.info('Unknown charge type %s not in %s', data['status']['chargeType'], str(Charging.ChargingType))
490
+ charge_type = Charging.ChargingType.UNKNOWN
491
+ # pylint: disable-next=protected-access
492
+ vehicle.charging.type._set_value(value=charge_type, measured=captured_at)
493
+ else:
494
+ # pylint: disable-next=protected-access
495
+ vehicle.charging.type._set_value(None, measured=captured_at)
496
+ if 'battery' in data['status'] and data['status']['battery'] is not None:
497
+ for drive in vehicle.drives.drives.values():
498
+ # Assume first electric drive is the right one
499
+ if isinstance(drive, ElectricDrive):
500
+ if 'remainingCruisingRangeInMeters' in data['status']['battery'] \
501
+ and data['status']['battery']['remainingCruisingRangeInMeters'] is not None:
502
+ cruising_range_in_km: float = data['status']['battery']['remainingCruisingRangeInMeters'] / 1000
503
+ # pylint: disable-next=protected-access
504
+ drive.range._set_value(value=cruising_range_in_km, measured=captured_at, unit=Length.KM)
505
+ drive.range.precision = 1
506
+ if 'stateOfChargeInPercent' in data['status']['battery'] \
507
+ and data['status']['battery']['stateOfChargeInPercent'] is not None:
508
+ # pylint: disable-next=protected-access
509
+ drive.level._set_value(value=data['status']['battery']['stateOfChargeInPercent'], measured=captured_at)
510
+ drive.level.precision = 1
511
+ log_extra_keys(LOG_API, 'status', data['status']['battery'], {'remainingCruisingRangeInMeters',
512
+ 'stateOfChargeInPercent'})
513
+ break
368
514
  log_extra_keys(LOG_API, 'status', data['status'], {'chargingRateInKilometersPerHour',
369
515
  'chargePowerInKw',
370
516
  'remainingTimeToFullyChargedInMinutes',
371
- 'state'})
372
- log_extra_keys(LOG_API, 'charging data', data, {'carCapturedTimestamp', 'status'})
517
+ 'state',
518
+ 'chargeType',
519
+ 'battery'})
520
+ if 'settings' in data and data['settings'] is not None:
521
+ if 'targetStateOfChargeInPercent' in data['settings'] and data['settings']['targetStateOfChargeInPercent'] is not None \
522
+ and vehicle.charging is not None and vehicle.charging.settings is not None:
523
+ vehicle.charging.settings.target_level.minimum = 50.0
524
+ vehicle.charging.settings.target_level.maximum = 100.0
525
+ vehicle.charging.settings.target_level.precision = 10.0
526
+ vehicle.charging.settings.target_level._add_on_set_hook(self.__on_charging_target_level_change) # pylint: disable=protected-access
527
+ vehicle.charging.settings.target_level._is_changeable = True # pylint: disable=protected-access
528
+ # pylint: disable-next=protected-access
529
+ vehicle.charging.settings.target_level._set_value(value=data['settings']['targetStateOfChargeInPercent'], measured=captured_at)
530
+ else:
531
+ vehicle.charging.settings.target_level._set_value(None, measured=captured_at) # pylint: disable=protected-access
532
+ if 'maxChargeCurrentAc' in data['settings'] and data['settings']['maxChargeCurrentAc'] is not None \
533
+ and vehicle.charging is not None and vehicle.charging.settings is not None:
534
+ vehicle.charging.settings.maximum_current.minimum = 6.0
535
+ vehicle.charging.settings.maximum_current.maximum = 16.0
536
+ vehicle.charging.settings.maximum_current.precision = 1.0
537
+ vehicle.charging.settings.maximum_current._add_on_set_hook(self.__on_charging_maximum_current_change) # pylint: disable=protected-access
538
+ vehicle.charging.settings.maximum_current._is_changeable = True # pylint: disable=protected-access
539
+ if data['settings']['maxChargeCurrentAc'] == 'MAXIMUM':
540
+ vehicle.charging.settings.maximum_current._set_value(value=16, measured=captured_at) # pylint: disable=protected-access
541
+ elif data['settings']['maxChargeCurrentAc'] == 'REDUCED':
542
+ vehicle.charging.settings.maximum_current._set_value(value=6, measured=captured_at) # pylint: disable=protected-access
543
+ else:
544
+ LOG_API.info('Unknown maxChargeCurrentAc %s not in %s', data['settings']['maxChargeCurrentAc'], ['MAXIMUM', 'REDUCED'])
545
+ vehicle.charging.settings.maximum_current._set_value(None, measured=captured_at) # pylint: disable=protected-access
546
+ else:
547
+ vehicle.charging.settings.maximum_current._set_value(None, measured=captured_at) # pylint: disable=protected-access
548
+ if 'autoUnlockPlugWhenCharged' in data['settings'] and data['settings']['autoUnlockPlugWhenCharged'] is not None:
549
+ vehicle.charging.settings.auto_unlock._add_on_set_hook(self.__on_charging_auto_unlock_change) # pylint: disable=protected-access
550
+ vehicle.charging.settings.auto_unlock._is_changeable = True # pylint: disable=protected-access
551
+ if data['settings']['autoUnlockPlugWhenCharged'] in ['ON', 'PERMANENT']:
552
+ vehicle.charging.settings.auto_unlock._set_value(True, measured=captured_at) # pylint: disable=protected-access
553
+ elif data['settings']['autoUnlockPlugWhenCharged'] == 'OFF':
554
+ vehicle.charging.settings.auto_unlock._set_value(False, measured=captured_at) # pylint: disable=protected-access
555
+ else:
556
+ LOG_API.info('Unknown autoUnlockPlugWhenCharged %s not in %s', data['settings']['autoUnlockPlugWhenCharged'],
557
+ ['ON', 'PERMANENT', 'OFF'])
558
+ vehicle.charging.settings.auto_unlock._set_value(None, measured=captured_at) # pylint: disable=protected-access
559
+ if 'preferredChargeMode' in data['settings'] and data['settings']['preferredChargeMode'] is not None:
560
+ if not isinstance(vehicle.charging, SkodaCharging):
561
+ vehicle.charging = SkodaCharging(origin=vehicle.charging)
562
+ if data['settings']['preferredChargeMode'] in [item.name for item in SkodaCharging.SkodaChargeMode]:
563
+ preferred_charge_mode: SkodaCharging.SkodaChargeMode = SkodaCharging.SkodaChargeMode[data['settings']['preferredChargeMode']]
564
+ else:
565
+ LOG_API.info('Unkown charge mode %s not in %s', data['settings']['preferredChargeMode'], str(SkodaCharging.SkodaChargeMode))
566
+ preferred_charge_mode = SkodaCharging.SkodaChargeMode.UNKNOWN
567
+
568
+ if isinstance(vehicle.charging.settings, SkodaCharging.Settings):
569
+ # pylint: disable-next=protected-access
570
+ vehicle.charging.settings.preferred_charge_mode._set_value(value=preferred_charge_mode, measured=captured_at)
571
+ else:
572
+ if vehicle.charging is not None and isinstance(vehicle.charging.settings, SkodaCharging.Settings):
573
+ vehicle.charging.settings.preferred_charge_mode._set_value(None, measured=captured_at) # pylint: disable=protected-access
574
+ if 'availableChargeModes' in data['settings'] and data['settings']['availableChargeModes'] is not None:
575
+ if not isinstance(vehicle.charging, SkodaCharging):
576
+ vehicle.charging = SkodaCharging(origin=vehicle.charging)
577
+ available_charge_modes: list[str] = data['settings']['availableChargeModes']
578
+ if vehicle.charging is not None and isinstance(vehicle.charging.settings, SkodaCharging.Settings):
579
+ # pylint: disable-next=protected-access
580
+ vehicle.charging.settings.available_charge_modes._set_value('.'.join(available_charge_modes), measured=captured_at)
581
+ else:
582
+ if vehicle.charging is not None and isinstance(vehicle.charging.settings, SkodaCharging.Settings):
583
+ vehicle.charging.settings.available_charge_modes._set_value(None, measured=captured_at) # pylint: disable=protected-access
584
+ if 'chargingCareMode' in data['settings'] and data['settings']['chargingCareMode'] is not None:
585
+ if not isinstance(vehicle.charging, SkodaCharging):
586
+ vehicle.charging = SkodaCharging(origin=vehicle.charging)
587
+ if data['settings']['chargingCareMode'] in [item.name for item in SkodaCharging.SkodaChargingCareMode]:
588
+ charge_mode: SkodaCharging.SkodaChargingCareMode = SkodaCharging.SkodaChargingCareMode[data['settings']['chargingCareMode']]
589
+ else:
590
+ LOG_API.info('Unknown charging care mode %s not in %s', data['settings']['chargingCareMode'], str(SkodaCharging.SkodaChargingCareMode))
591
+ charge_mode = SkodaCharging.SkodaChargingCareMode.UNKNOWN
592
+ if vehicle.charging is not None and isinstance(vehicle.charging.settings, SkodaCharging.Settings):
593
+ # pylint: disable-next=protected-access
594
+ vehicle.charging.settings.charging_care_mode._set_value(value=charge_mode, measured=captured_at)
595
+ else:
596
+ if vehicle.charging is not None and isinstance(vehicle.charging.settings, SkodaCharging.Settings):
597
+ vehicle.charging.settings.charging_care_mode._set_value(None, measured=captured_at) # pylint: disable=protected-access
598
+ if 'batterySupport' in data['settings'] and data['settings']['batterySupport'] is not None:
599
+ if not isinstance(vehicle.charging, SkodaCharging):
600
+ vehicle.charging = SkodaCharging(origin=vehicle.charging)
601
+ if data['settings']['batterySupport'] in [item.name for item in SkodaCharging.SkodaBatterySupport]:
602
+ battery_support: SkodaCharging.SkodaBatterySupport = SkodaCharging.SkodaBatterySupport[data['settings']['batterySupport']]
603
+ else:
604
+ LOG_API.info('Unknown battery support %s not in %s', data['settings']['batterySupport'], str(SkodaCharging.SkodaBatterySupport))
605
+ battery_support = SkodaCharging.SkodaBatterySupport.UNKNOWN
606
+ if vehicle.charging is not None and isinstance(vehicle.charging.settings, SkodaCharging.Settings):
607
+ # pylint: disable-next=protected-access
608
+ vehicle.charging.settings.battery_support._set_value(value=battery_support, measured=captured_at)
609
+ else:
610
+ if vehicle.charging is not None and isinstance(vehicle.charging.settings, SkodaCharging.Settings):
611
+ vehicle.charging.settings.battery_support._set_value(None, measured=captured_at) # pylint: disable=protected-access
612
+ log_extra_keys(LOG_API, 'settings', data['settings'], {'targetStateOfChargeInPercent', 'maxChargeCurrentAc', 'autoUnlockPlugWhenCharged',
613
+ 'preferredChargeMode', 'availableChargeModes', 'chargingCareMode', 'batterySupport'})
614
+ if 'errors' in data and data['errors'] is not None:
615
+ found_errors: Set[str] = set()
616
+ if not isinstance(vehicle.charging, SkodaCharging):
617
+ vehicle.charging = SkodaCharging(origin=vehicle.charging)
618
+ for error_dict in data['errors']:
619
+ if 'type' in error_dict and error_dict['type'] is not None:
620
+ if error_dict['type'] not in vehicle.charging.errors:
621
+ error: Error = Error(object_id=error_dict['type'])
622
+ else:
623
+ error = vehicle.charging.errors[error_dict['type']]
624
+ if error_dict['type'] in [item.name for item in Error.ChargingError]:
625
+ error_type: Error.ChargingError = Error.ChargingError[error_dict['type']]
626
+ else:
627
+ LOG_API.info('Unknown charging error type %s not in %s', error_dict['type'], str(Error.ChargingError))
628
+ error_type = Error.ChargingError.UNKNOWN
629
+ error.type._set_value(error_type, measured=captured_at) # pylint: disable=protected-access
630
+ if 'description' in error_dict and error_dict['description'] is not None:
631
+ error.description._set_value(error_dict['description'], measured=captured_at) # pylint: disable=protected-access
632
+ log_extra_keys(LOG_API, 'errors', error_dict, {'type', 'description'})
633
+ if vehicle.charging is not None and vehicle.charging.errors is not None and len(vehicle.charging.errors) > 0:
634
+ for error_id in vehicle.charging.errors.keys()-found_errors:
635
+ vehicle.charging.errors.pop(error_id)
636
+ else:
637
+ if isinstance(vehicle.charging, SkodaCharging):
638
+ vehicle.charging.errors.clear()
639
+ log_extra_keys(LOG_API, 'charging data', data, {'carCapturedTimestamp', 'status', 'isVehicleInSavedLocation', 'errors', 'settings'})
640
+ return vehicle
641
+
642
+ def fetch_connection_status(self, vehicle: SkodaVehicle, no_cache: bool = False) -> SkodaVehicle:
643
+ """
644
+ Fetches the connection status of the given Skoda vehicle and updates its connection attributes.
645
+
646
+ Args:
647
+ vehicle (SkodaVehicle): The Skoda vehicle object containing the VIN and connection attributes.
648
+
649
+ Returns:
650
+ SkodaVehicle: The updated Skoda vehicle object with the fetched connection data.
651
+
652
+ Raises:
653
+ APIError: If the VIN is missing.
654
+ ValueError: If the vehicle has no connection object.
655
+ """
656
+ vin = vehicle.vin.value
657
+ if vin is None:
658
+ raise APIError('VIN is missing')
659
+ url = f'https://mysmob.api.connect.skoda-auto.cz/api/v2/connection-status/{vin}/readiness'
660
+ data: Dict[str, Any] | None = self._fetch_data(url=url, session=self.session, no_cache=no_cache)
661
+ # {'unreachable': False, 'inMotion': False, 'batteryProtectionLimitOn': False}
662
+ if data is not None:
663
+ if 'unreachable' in data and data['unreachable'] is not None:
664
+ if data['unreachable']:
665
+ vehicle.connection_state._set_value(vehicle.ConnectionState.OFFLINE) # pylint: disable=protected-access
666
+ else:
667
+ vehicle.connection_state._set_value(vehicle.ConnectionState.REACHABLE) # pylint: disable=protected-access
668
+ else:
669
+ vehicle.connection_state._set_value(None) # pylint: disable=protected-access
670
+ if 'inMotion' in data and data['inMotion'] is not None:
671
+ vehicle.in_motion._set_value(data['inMotion']) # pylint: disable=protected-access
672
+ else:
673
+ vehicle.in_motion._set_value(None) # pylint: disable=protected-access
674
+ log_extra_keys(LOG_API, 'connection status', data, {'unreachable', 'inMotion'})
373
675
  return vehicle
374
676
 
375
677
  def fetch_position(self, vehicle: SkodaVehicle, no_cache: bool = False) -> SkodaVehicle:
@@ -392,7 +694,7 @@ class Connector(BaseConnector):
392
694
  if vehicle.position is None:
393
695
  raise ValueError('Vehicle has no charging object')
394
696
  url = f'https://mysmob.api.connect.skoda-auto.cz/api/v1/maps/positions?vin={vin}'
395
- data: Dict[str, Any] | None = self._fetch_data(url=url, session=self.session, no_cache=no_cache)
697
+ data: Dict[str, Any] | None = self._fetch_data(url=url, session=self.session, no_cache=no_cache, allow_empty=True)
396
698
  if data is not None:
397
699
  if 'positions' in data and data['positions'] is not None:
398
700
  for position_dict in data['positions']:
@@ -407,7 +709,9 @@ class Connector(BaseConnector):
407
709
  else:
408
710
  longitude = None
409
711
  vehicle.position.latitude._set_value(latitude) # pylint: disable=protected-access
712
+ vehicle.position.latitude.precision = 0.000001
410
713
  vehicle.position.longitude._set_value(longitude) # pylint: disable=protected-access
714
+ vehicle.position.longitude.precision = 0.000001
411
715
  vehicle.position.position_type._set_value(Position.PositionType.PARKING) # pylint: disable=protected-access
412
716
  else:
413
717
  vehicle.position.latitude._set_value(None) # pylint: disable=protected-access
@@ -424,6 +728,58 @@ class Connector(BaseConnector):
424
728
  vehicle.position.latitude._set_value(None) # pylint: disable=protected-access
425
729
  vehicle.position.longitude._set_value(None) # pylint: disable=protected-access
426
730
  vehicle.position.position_type._set_value(None) # pylint: disable=protected-access
731
+ else:
732
+ vehicle.position.latitude._set_value(None) # pylint: disable=protected-access
733
+ vehicle.position.longitude._set_value(None) # pylint: disable=protected-access
734
+ vehicle.position.position_type._set_value(None) # pylint: disable=protected-access
735
+ return vehicle
736
+
737
+ def fetch_maintenance(self, vehicle: SkodaVehicle, no_cache: bool = False) -> SkodaVehicle:
738
+ """
739
+ Fetches the maintenance information for a given Skoda vehicle.
740
+
741
+ Args:
742
+ vehicle (SkodaVehicle): The vehicle object for which maintenance information is to be fetched.
743
+ no_cache (bool, optional): If True, bypasses the cache and fetches fresh data. Defaults to False.
744
+
745
+ Returns:
746
+ SkodaVehicle: The vehicle object with updated maintenance information.
747
+
748
+ Raises:
749
+ APIError: If the VIN is missing or if the 'capturedAt' field is missing in the fetched data.
750
+ ValueError: If the vehicle has no charging object.
751
+ """
752
+ vin = vehicle.vin.value
753
+ if vin is None:
754
+ raise APIError('VIN is missing')
755
+ if vehicle.position is None:
756
+ raise ValueError('Vehicle has no charging object')
757
+ url = f'https://mysmob.api.connect.skoda-auto.cz/api/v3/vehicle-maintenance/vehicles/{vin}/report'
758
+ data: Dict[str, Any] | None = self._fetch_data(url=url, session=self.session, no_cache=no_cache)
759
+ #{'capturedAt': '2025-02-24T19:54:32.728Z', 'inspectionDueInDays': 620, 'mileageInKm': 2512}
760
+ if data is not None:
761
+ if 'capturedAt' in data and data['capturedAt'] is not None:
762
+ captured_at: datetime = robust_time_parse(data['capturedAt'])
763
+ else:
764
+ raise APIError('Could not fetch maintenance, capturedAt missing')
765
+ if 'mileageInKm' in data and data['mileageInKm'] is not None:
766
+ vehicle.odometer._set_value(value=data['mileageInKm'], measured=captured_at, unit=Length.KM) # pylint: disable=protected-access
767
+ vehicle.odometer.precision = 1
768
+ else:
769
+ vehicle.odometer._set_value(None) # pylint: disable=protected-access
770
+ if 'inspectionDueInDays' in data and data['inspectionDueInDays'] is not None:
771
+ inspection_due: timedelta = timedelta(days=data['inspectionDueInDays'])
772
+ inspection_date: datetime = captured_at + inspection_due
773
+ inspection_date = inspection_date.replace(hour=0, minute=0, second=0, microsecond=0)
774
+ # pylint: disable-next=protected-access
775
+ vehicle.maintenance.inspection_due_at._set_value(value=inspection_date, measured=captured_at)
776
+ else:
777
+ vehicle.maintenance.inspection_due_at._set_value(None) # pylint: disable=protected-access
778
+ log_extra_keys(LOG_API, 'maintenance', data, {'capturedAt', 'mileageInKm', 'inspectionDueInDays'})
779
+
780
+ #url = f'https://mysmob.api.connect.skoda-auto.cz/api/v1/vehicle-health-report/warning-lights/{vin}'
781
+ #data: Dict[str, Any] | None = self._fetch_data(url=url, session=self.session, no_cache=no_cache)
782
+ #{'capturedAt': '2025-02-24T15:32:35.032Z', 'mileageInKm': 2512, 'warningLights': [{'category': 'ASSISTANCE', 'defects': []}, {'category': 'COMFORT', 'defects': []}, {'category': 'BRAKE', 'defects': []}, {'category': 'ELECTRIC_ENGINE', 'defects': []}, {'category': 'LIGHTING', 'defects': []}, {'category': 'TIRE', 'defects': []}, {'category': 'OTHER', 'defects': []}]}
427
783
  return vehicle
428
784
 
429
785
  def fetch_air_conditioning(self, vehicle: SkodaVehicle, no_cache: bool = False) -> SkodaVehicle:
@@ -453,6 +809,13 @@ class Connector(BaseConnector):
453
809
  url = f'https://mysmob.api.connect.skoda-auto.cz/api/v2/air-conditioning/{vin}'
454
810
  data: Dict[str, Any] | None = self._fetch_data(url=url, session=self.session, no_cache=no_cache)
455
811
  if data is not None:
812
+ if vehicle.climatization is not None and vehicle.climatization.commands is not None \
813
+ and not vehicle.climatization.commands.contains_command('start-stop'):
814
+ start_stop_command = ClimatizationStartStopCommand(parent=vehicle.climatization.commands)
815
+ start_stop_command._add_on_set_hook(self.__on_air_conditioning_start_stop) # pylint: disable=protected-access
816
+ start_stop_command.enabled = True
817
+ vehicle.climatization.commands.add_command(start_stop_command)
818
+
456
819
  if 'carCapturedTimestamp' in data and data['carCapturedTimestamp'] is not None:
457
820
  captured_at: datetime = robust_time_parse(data['carCapturedTimestamp'])
458
821
  else:
@@ -475,27 +838,42 @@ class Connector(BaseConnector):
475
838
  else:
476
839
  vehicle.climatization.estimated_date_reached._set_value(value=None, measured=captured_at) # pylint: disable=protected-access
477
840
  if 'targetTemperature' in data and data['targetTemperature'] is not None:
841
+ # pylint: disable-next=protected-access
842
+ vehicle.climatization.settings.target_temperature._add_on_set_hook(self.__on_air_conditioning_target_temperature_change)
843
+ vehicle.climatization.settings.target_temperature._is_changeable = True # pylint: disable=protected-access
844
+ precision: float = 0.5
845
+ min_temperature: Optional[float] = None
846
+ max_temperature: Optional[float] = None
478
847
  unit: Temperature = Temperature.UNKNOWN
479
848
  if 'unitInCar' in data['targetTemperature'] and data['targetTemperature']['unitInCar'] is not None:
480
849
  if data['targetTemperature']['unitInCar'] == 'CELSIUS':
481
850
  unit = Temperature.C
851
+ min_temperature: Optional[float] = 16
852
+ max_temperature: Optional[float] = 29.5
482
853
  elif data['targetTemperature']['unitInCar'] == 'FAHRENHEIT':
483
854
  unit = Temperature.F
855
+ min_temperature: Optional[float] = 61
856
+ max_temperature: Optional[float] = 85
484
857
  elif data['targetTemperature']['unitInCar'] == 'KELVIN':
485
858
  unit = Temperature.K
486
859
  else:
487
860
  LOG_API.info('Unknown temperature unit for targetTemperature in air-conditioning %s', data['targetTemperature']['unitInCar'])
488
861
  if 'temperatureValue' in data['targetTemperature'] and data['targetTemperature']['temperatureValue'] is not None:
489
862
  # pylint: disable-next=protected-access
490
- vehicle.climatization.target_temperature._set_value(value=data['targetTemperature']['temperatureValue'],
491
- measured=captured_at,
492
- unit=unit)
863
+ vehicle.climatization.settings.target_temperature._set_value(value=data['targetTemperature']['temperatureValue'],
864
+ measured=captured_at,
865
+ unit=unit)
866
+ vehicle.climatization.settings.target_temperature.precision = precision
867
+ vehicle.climatization.settings.target_temperature.minimum = min_temperature
868
+ vehicle.climatization.settings.target_temperature.maximum = max_temperature
869
+
493
870
  else:
494
- vehicle.climatization.target_temperature._set_value(value=None, measured=captured_at, unit=unit) # pylint: disable=protected-access
871
+ # pylint: disable-next=protected-access
872
+ vehicle.climatization.settings.target_temperature._set_value(value=None, measured=captured_at, unit=unit)
495
873
  log_extra_keys(LOG_API, 'targetTemperature', data['targetTemperature'], {'unitInCar', 'temperatureValue'})
496
874
  else:
497
875
  # pylint: disable-next=protected-access
498
- vehicle.climatization.target_temperature._set_value(value=None, measured=captured_at, unit=Temperature.UNKNOWN)
876
+ vehicle.climatization.settings.target_temperature._set_value(value=None, measured=captured_at, unit=Temperature.UNKNOWN)
499
877
  if 'outsideTemperature' in data and data['outsideTemperature'] is not None:
500
878
  if 'carCapturedTimestamp' in data['outsideTemperature'] and data['outsideTemperature']['carCapturedTimestamp'] is not None:
501
879
  outside_captured_at: datetime = robust_time_parse(data['outsideTemperature']['carCapturedTimestamp'])
@@ -510,7 +888,7 @@ class Connector(BaseConnector):
510
888
  elif data['outsideTemperature']['temperatureUnit'] == 'KELVIN':
511
889
  unit = Temperature.K
512
890
  else:
513
- LOG_API.info('Unknown temperature unit for outsideTemperature in air-conditioning %s', data['targetTemperature']['temperatureUnit'])
891
+ LOG_API.info('Unknown temperature unit for outsideTemperature in air-conditioning %s', data['outsideTemperature']['temperatureUnit'])
514
892
  if 'temperatureValue' in data['outsideTemperature'] and data['outsideTemperature']['temperatureValue'] is not None:
515
893
  # pylint: disable-next=protected-access
516
894
  vehicle.outside_temperature._set_value(value=data['outsideTemperature']['temperatureValue'],
@@ -525,8 +903,171 @@ class Connector(BaseConnector):
525
903
  log_extra_keys(LOG_API, 'targetTemperature', data['outsideTemperature'], {'carCapturedTimestamp', 'temperatureUnit', 'temperatureValue'})
526
904
  else:
527
905
  vehicle.outside_temperature._set_value(value=None, measured=None, unit=Temperature.UNKNOWN) # pylint: disable=protected-access
528
- log_extra_keys(LOG_API, 'air-condition', data, {'carCapturedTimestamp', 'state', 'estimatedDateTimeToReachTargetTemperature'
529
- 'targetTemperature', 'outsideTemperature'})
906
+ if 'airConditioningAtUnlock' in data and data['airConditioningAtUnlock'] is not None:
907
+ if vehicle.climatization is not None and vehicle.climatization.settings is not None:
908
+ # pylint: disable-next=protected-access
909
+ vehicle.climatization.settings.climatization_at_unlock._add_on_set_hook(self.__on_air_conditioning_at_unlock_change)
910
+ vehicle.climatization.settings.climatization_at_unlock._is_changeable = True # pylint: disable=protected-access
911
+ if data['airConditioningAtUnlock'] is True:
912
+ # pylint: disable-next=protected-access
913
+ vehicle.climatization.settings.climatization_at_unlock._set_value(True, measured=captured_at)
914
+ elif data['airConditioningAtUnlock'] is False:
915
+ # pylint: disable-next=protected-access
916
+ vehicle.climatization.settings.climatization_at_unlock._set_value(False, measured=captured_at)
917
+ else:
918
+ # pylint: disable-next=protected-access
919
+ vehicle.climatization.settings.climatization_at_unlock._set_value(None, measured=captured_at)
920
+ else:
921
+ if vehicle.climatization is not None and vehicle.climatization.settings is not None:
922
+ # pylint: disable-next=protected-access
923
+ vehicle.climatization.settings.climatization_at_unlock._set_value(None, measured=captured_at)
924
+ if 'steeringWheelPosition' in data and data['steeringWheelPosition'] is not None:
925
+ if vehicle.specification is not None:
926
+ if data['steeringWheelPosition'] in [item.name for item in GenericVehicle.VehicleSpecification.SteeringPosition]:
927
+ steering_wheel_position: GenericVehicle.VehicleSpecification.SteeringPosition = \
928
+ GenericVehicle.VehicleSpecification.SteeringPosition[data['steeringWheelPosition']]
929
+ else:
930
+ LOG_API.info('Unknown steering wheel position %s not in %s', data['steeringWheelPosition'],
931
+ str(GenericVehicle.VehicleSpecification.SteeringPosition))
932
+ steering_wheel_position = GenericVehicle.VehicleSpecification.SteeringPosition.UNKNOWN
933
+ # pylint: disable-next=protected-access
934
+ vehicle.specification.steering_wheel_position._set_value(value=steering_wheel_position, measured=captured_at)
935
+ else:
936
+ if vehicle.specification is not None:
937
+ # pylint: disable-next=protected-access
938
+ vehicle.specification.steering_wheel_position._set_value(None, measured=captured_at)
939
+ if 'windowHeatingEnabled' in data and data['windowHeatingEnabled'] is not None:
940
+ if vehicle.climatization is not None and vehicle.climatization.settings is not None:
941
+ # pylint: disable-next=protected-access
942
+ vehicle.climatization.settings.window_heating._add_on_set_hook(self.__on_air_conditioning_window_heating_change)
943
+ vehicle.climatization.settings.window_heating._is_changeable = True # pylint: disable=protected-access
944
+ if data['windowHeatingEnabled'] is True:
945
+ # pylint: disable-next=protected-access
946
+ vehicle.climatization.settings.window_heating._set_value(True, measured=captured_at)
947
+ elif data['windowHeatingEnabled'] is False:
948
+ # pylint: disable-next=protected-access
949
+ vehicle.climatization.settings.window_heating._set_value(False, measured=captured_at)
950
+ else:
951
+ # pylint: disable-next=protected-access
952
+ vehicle.climatization.settings.window_heating._set_value(None, measured=captured_at)
953
+ else:
954
+ if vehicle.climatization is not None and vehicle.climatization.settings is not None:
955
+ # pylint: disable-next=protected-access
956
+ vehicle.climatization.settings.window_heating._set_value(None, measured=captured_at)
957
+ if 'seatHeatingActivated' in data and data['seatHeatingActivated'] is not None:
958
+ if vehicle.climatization is not None and vehicle.climatization.settings is not None:
959
+ if data['seatHeatingActivated'] is True:
960
+ # pylint: disable-next=protected-access
961
+ vehicle.climatization.settings.seat_heating._set_value(True, measured=captured_at)
962
+ elif data['seatHeatingActivated'] is False:
963
+ # pylint: disable-next=protected-access
964
+ vehicle.climatization.settings.seat_heating._set_value(False, measured=captured_at)
965
+ else:
966
+ # pylint: disable-next=protected-access
967
+ vehicle.climatization.settings.seat_heating._set_value(None, measured=captured_at)
968
+ else:
969
+ if vehicle.climatization is not None and vehicle.climatization.settings is not None:
970
+ # pylint: disable-next=protected-access
971
+ vehicle.climatization.settings.seat_heating._set_value(None, measured=captured_at)
972
+ if isinstance(vehicle, SkodaElectricVehicle):
973
+ if 'chargerConnectionState' in data and data['chargerConnectionState'] is not None \
974
+ and vehicle.charging is not None and vehicle.charging.connector is not None:
975
+ if data['chargerConnectionState'] in [item.name for item in ChargingConnector.ChargingConnectorConnectionState]:
976
+ charging_connector_state: ChargingConnector.ChargingConnectorConnectionState = \
977
+ ChargingConnector.ChargingConnectorConnectionState[data['chargerConnectionState']]
978
+ # pylint: disable-next=protected-access
979
+ vehicle.charging.connector.connection_state._set_value(value=charging_connector_state, measured=captured_at)
980
+ else:
981
+ LOG_API.info('Unkown connector state %s not in %s', data['chargerConnectionState'],
982
+ str(ChargingConnector.ChargingConnectorConnectionState))
983
+ # pylint: disable-next=protected-access
984
+ vehicle.charging.connector.connection_state._set_value(value=SkodaCharging.SkodaChargingState.UNKNOWN, measured=captured_at)
985
+ else:
986
+ # pylint: disable-next=protected-access
987
+ vehicle.charging.connector.connection_state._set_value(value=None, measured=captured_at)
988
+ if 'chargerLockState' in data and data['chargerLockState'] is not None \
989
+ and vehicle.charging is not None and vehicle.charging.connector is not None:
990
+ if data['chargerLockState'] in [item.name for item in ChargingConnector.ChargingConnectorLockState]:
991
+ charging_connector_lockstate: ChargingConnector.ChargingConnectorLockState = \
992
+ ChargingConnector.ChargingConnectorLockState[data['chargerLockState']]
993
+ # pylint: disable-next=protected-access
994
+ vehicle.charging.connector.lock_state._set_value(value=charging_connector_lockstate, measured=captured_at)
995
+ else:
996
+ LOG_API.info('Unkown connector lock state %s not in %s', data['chargerLockState'],
997
+ str(ChargingConnector.ChargingConnectorLockState))
998
+ # pylint: disable-next=protected-access
999
+ vehicle.charging.connector.lock_state._set_value(value=SkodaCharging.SkodaChargingState.UNKNOWN, measured=captured_at)
1000
+ else:
1001
+ # pylint: disable-next=protected-access
1002
+ vehicle.charging.connector.lock_state._set_value(value=None, measured=captured_at)
1003
+ if 'windowHeatingState' in data and data['windowHeatingState'] is not None:
1004
+ heating_on: bool = False
1005
+ all_heating_invalid: bool = True
1006
+ for window_id, state in data['windowHeatingState'].items():
1007
+ if window_id != 'unspecified':
1008
+ if window_id in vehicle.window_heatings.windows:
1009
+ window: WindowHeatings.WindowHeating = vehicle.window_heatings.windows[window_id]
1010
+ else:
1011
+ window = WindowHeatings.WindowHeating(window_id=window_id, window_heatings=vehicle.window_heatings)
1012
+ vehicle.window_heatings.windows[window_id] = window
1013
+
1014
+ if state.lower() in [item.value for item in WindowHeatings.HeatingState]:
1015
+ window_heating_state: WindowHeatings.HeatingState = WindowHeatings.HeatingState(state.lower())
1016
+ if window_heating_state == WindowHeatings.HeatingState.ON:
1017
+ heating_on = True
1018
+ if window_heating_state in [WindowHeatings.HeatingState.ON,
1019
+ WindowHeatings.HeatingState.OFF]:
1020
+ all_heating_invalid = False
1021
+ window.heating_state._set_value(window_heating_state, measured=captured_at) # pylint: disable=protected-access
1022
+ else:
1023
+ LOG_API.info('Unknown window heating state %s not in %s', state.lower(), str(WindowHeatings.HeatingState))
1024
+ # pylint: disable-next=protected-access
1025
+ window.heating_state._set_value(WindowHeatings.HeatingState.UNKNOWN, measured=captured_at)
1026
+ if all_heating_invalid:
1027
+ # pylint: disable-next=protected-access
1028
+ vehicle.window_heatings.heating_state._set_value(WindowHeatings.HeatingState.INVALID, measured=captured_at)
1029
+ else:
1030
+ if heating_on:
1031
+ # pylint: disable-next=protected-access
1032
+ vehicle.window_heatings.heating_state._set_value(WindowHeatings.HeatingState.ON, measured=captured_at)
1033
+ else:
1034
+ # pylint: disable-next=protected-access
1035
+ vehicle.window_heatings.heating_state._set_value(WindowHeatings.HeatingState.OFF, measured=captured_at)
1036
+ if vehicle.window_heatings is not None and vehicle.window_heatings.commands is not None \
1037
+ and not vehicle.window_heatings.commands.contains_command('start-stop'):
1038
+ start_stop_command = WindowHeatingStartStopCommand(parent=vehicle.window_heatings.commands)
1039
+ start_stop_command._add_on_set_hook(self.__on_window_heating_start_stop) # pylint: disable=protected-access
1040
+ start_stop_command.enabled = True
1041
+ vehicle.window_heatings.commands.add_command(start_stop_command)
1042
+ if 'errors' in data and data['errors'] is not None:
1043
+ found_errors: Set[str] = set()
1044
+ if not isinstance(vehicle.climatization, SkodaClimatization):
1045
+ vehicle.climatization = SkodaClimatization(origin=vehicle.climatization)
1046
+ for error_dict in data['errors']:
1047
+ if 'type' in error_dict and error_dict['type'] is not None:
1048
+ if error_dict['type'] not in vehicle.climatization.errors:
1049
+ error: Error = Error(object_id=error_dict['type'])
1050
+ else:
1051
+ error = vehicle.climatization.errors[error_dict['type']]
1052
+ if error_dict['type'] in [item.name for item in Error.ClimatizationError]:
1053
+ error_type: Error.ClimatizationError = Error.ClimatizationError[error_dict['type']]
1054
+ else:
1055
+ LOG_API.info('Unknown climatization error type %s not in %s', error_dict['type'], str(Error.ClimatizationError))
1056
+ error_type = Error.ClimatizationError.UNKNOWN
1057
+ error.type._set_value(error_type, measured=captured_at) # pylint: disable=protected-access
1058
+ if 'description' in error_dict and error_dict['description'] is not None:
1059
+ error.description._set_value(error_dict['description'], measured=captured_at) # pylint: disable=protected-access
1060
+ log_extra_keys(LOG_API, 'errors', error_dict, {'type', 'description'})
1061
+ if vehicle.climatization is not None and vehicle.climatization.errors is not None and len(vehicle.climatization.errors) > 0:
1062
+ for error_id in vehicle.climatization.errors.keys()-found_errors:
1063
+ vehicle.climatization.errors.pop(error_id)
1064
+ else:
1065
+ if isinstance(vehicle.climatization, SkodaClimatization):
1066
+ vehicle.climatization.errors.clear()
1067
+ log_extra_keys(LOG_API, 'air-condition', data, {'carCapturedTimestamp', 'state', 'estimatedDateTimeToReachTargetTemperature',
1068
+ 'targetTemperature', 'outsideTemperature', 'chargerConnectionState',
1069
+ 'chargerLockState', 'airConditioningAtUnlock', 'steeringWheelPosition',
1070
+ 'windowHeatingEnabled', 'seatHeatingActivated', 'windowHeatingState', 'errors'})
530
1071
  return vehicle
531
1072
 
532
1073
  def fetch_vehicle_details(self, vehicle: SkodaVehicle, no_cache: bool = False) -> SkodaVehicle:
@@ -562,6 +1103,20 @@ class Connector(BaseConnector):
562
1103
  else:
563
1104
  capability = Capability(capability_id=capability_id, capabilities=vehicle.capabilities)
564
1105
  vehicle.capabilities.add_capability(capability_id, capability)
1106
+ if 'statuses' in capability_dict and capability_dict['statuses'] is not None:
1107
+ statuses = capability_dict['statuses']
1108
+ if isinstance(statuses, list):
1109
+ for status in statuses:
1110
+ if status in [item.name for item in Capability.Status]:
1111
+ capability.status.value.append(Capability.Status[status])
1112
+ else:
1113
+ LOG_API.warning('Capability status unkown %s', status)
1114
+ capability.status.value.append(Capability.Status.UNKNOWN)
1115
+ else:
1116
+ LOG_API.warning('Capability status not a list in %s', statuses)
1117
+ else:
1118
+ capability.status.value.clear()
1119
+ log_extra_keys(LOG_API, 'capability', capability_dict, {'id', 'statuses'})
565
1120
  else:
566
1121
  raise APIError('Could not parse capability, id missing')
567
1122
  for capability_id in vehicle.capabilities.capabilities.keys() - found_capabilities:
@@ -571,6 +1126,33 @@ class Connector(BaseConnector):
571
1126
  else:
572
1127
  vehicle.capabilities.clear_capabilities()
573
1128
 
1129
+ if vehicle.capabilities.has_capability('VEHICLE_WAKE_UP_TRIGGER', check_status_ok=True):
1130
+ if vehicle.commands is not None and vehicle.commands.commands is not None \
1131
+ and not vehicle.commands.contains_command('wake-sleep'):
1132
+ wake_sleep_command = WakeSleepCommand(parent=vehicle.commands)
1133
+ wake_sleep_command._add_on_set_hook(self.__on_wake_sleep) # pylint: disable=protected-access
1134
+ wake_sleep_command.enabled = True
1135
+ vehicle.commands.add_command(wake_sleep_command)
1136
+
1137
+ # Add HONK_AND_FLASH command if necessary capabilities are available
1138
+ if vehicle.capabilities.has_capability('HONK_AND_FLASH', check_status_ok=True) \
1139
+ and vehicle.capabilities.has_capability('PARKING_POSITION', check_status_ok=True):
1140
+ if vehicle.commands is not None and vehicle.commands.commands is not None \
1141
+ and not vehicle.commands.contains_command('honk-flash'):
1142
+ honk_flash_command = HonkAndFlashCommand(parent=vehicle.commands)
1143
+ honk_flash_command._add_on_set_hook(self.__on_honk_flash) # pylint: disable=protected-access
1144
+ honk_flash_command.enabled = True
1145
+ vehicle.commands.add_command(honk_flash_command)
1146
+
1147
+ # Add lock and unlock command
1148
+ if vehicle.capabilities.has_capability('ACCESS', check_status_ok=True):
1149
+ if vehicle.doors is not None and vehicle.doors.commands is not None and vehicle.doors.commands.commands is not None \
1150
+ and not vehicle.doors.commands.contains_command('lock-unlock'):
1151
+ lock_unlock_command = LockUnlockCommand(parent=vehicle.doors.commands)
1152
+ lock_unlock_command._add_on_set_hook(self.__on_lock_unlock) # pylint: disable=protected-access
1153
+ lock_unlock_command.enabled = True
1154
+ vehicle.doors.commands.add_command(lock_unlock_command)
1155
+
574
1156
  if 'specification' in vehicle_data and vehicle_data['specification'] is not None:
575
1157
  if 'model' in vehicle_data['specification'] and vehicle_data['specification']['model'] is not None:
576
1158
  vehicle.model._set_value(vehicle_data['specification']['model']) # pylint: disable=protected-access
@@ -582,6 +1164,68 @@ class Connector(BaseConnector):
582
1164
  log_extra_keys(LOG_API, 'api/v2/garage/vehicles/VIN', vehicle_data, {'softwareVersion'})
583
1165
  return vehicle
584
1166
 
1167
+ def fetch_vehicle_images(self, vehicle: SkodaVehicle, no_cache: bool = False) -> SkodaVehicle:
1168
+ if SUPPORT_IMAGES:
1169
+ url: str = f'https://mysmob.api.connect.skoda-auto.cz/api/v1/vehicle-information/{vehicle.vin.value}/renders'
1170
+ data = self._fetch_data(url, session=self.session, allow_http_error=True)
1171
+ if data is not None and 'compositeRenders' in data: # pylint: disable=too-many-nested-blocks
1172
+ for image in data['compositeRenders']:
1173
+ if 'layers' not in image or image['layers'] is None or len(image['layers']) == 0:
1174
+ continue
1175
+ image_url: Optional[str] = None
1176
+ for layer in image['layers']:
1177
+ if 'url' in layer and layer['url'] is not None:
1178
+ image_url = layer['url']
1179
+ break
1180
+ if image_url is None:
1181
+ continue
1182
+ img = None
1183
+ cache_date = None
1184
+ if self.active_config['max_age'] is not None and self.session.cache is not None and image_url in self.session.cache:
1185
+ img, cache_date_string = self.session.cache[image_url]
1186
+ img = base64.b64decode(img) # pyright: ignore[reportPossiblyUnboundVariable]
1187
+ img = Image.open(io.BytesIO(img)) # pyright: ignore[reportPossiblyUnboundVariable]
1188
+ cache_date = datetime.fromisoformat(cache_date_string)
1189
+ if img is None or self.active_config['max_age'] is None \
1190
+ or (cache_date is not None and cache_date < (datetime.utcnow() - timedelta(seconds=self.active_config['max_age']))):
1191
+ try:
1192
+ image_download_response = requests.get(image_url, stream=True)
1193
+ if image_download_response.status_code == requests.codes['ok']:
1194
+ img = Image.open(image_download_response.raw) # pyright: ignore[reportPossiblyUnboundVariable]
1195
+ if self.session.cache is not None:
1196
+ buffered = io.BytesIO() # pyright: ignore[reportPossiblyUnboundVariable]
1197
+ img.save(buffered, format="PNG")
1198
+ img_str = base64.b64encode(buffered.getvalue()).decode("utf-8") # pyright: ignore[reportPossiblyUnboundVariable]
1199
+ self.session.cache[image_url] = (img_str, str(datetime.utcnow()))
1200
+ elif image_download_response.status_code == requests.codes['unauthorized']:
1201
+ LOG.info('Server asks for new authorization')
1202
+ self.session.login()
1203
+ image_download_response = self.session.get(image_url, stream=True)
1204
+ if image_download_response.status_code == requests.codes['ok']:
1205
+ img = Image.open(image_download_response.raw) # pyright: ignore[reportPossiblyUnboundVariable]
1206
+ if self.session.cache is not None:
1207
+ buffered = io.BytesIO() # pyright: ignore[reportPossiblyUnboundVariable]
1208
+ img.save(buffered, format="PNG")
1209
+ img_str = base64.b64encode(buffered.getvalue()).decode("utf-8") # pyright: ignore[reportPossiblyUnboundVariable]
1210
+ self.session.cache[image_url] = (img_str, str(datetime.utcnow()))
1211
+ except requests.exceptions.ConnectionError as connection_error:
1212
+ raise RetrievalError(f'Connection error: {connection_error}') from connection_error
1213
+ except requests.exceptions.ChunkedEncodingError as chunked_encoding_error:
1214
+ raise RetrievalError(f'Error: {chunked_encoding_error}') from chunked_encoding_error
1215
+ except requests.exceptions.ReadTimeout as timeout_error:
1216
+ raise RetrievalError(f'Timeout during read: {timeout_error}') from timeout_error
1217
+ except requests.exceptions.RetryError as retry_error:
1218
+ raise RetrievalError(f'Retrying failed: {retry_error}') from retry_error
1219
+ if img is not None:
1220
+ vehicle._car_images[image['viewType']] = img # pylint: disable=protected-access
1221
+ if image['viewType'] == 'UNMODIFIED_EXTERIOR_FRONT':
1222
+ if 'car_picture' in vehicle.images.images:
1223
+ vehicle.images.images['car_picture']._set_value(img) # pylint: disable=protected-access
1224
+ else:
1225
+ vehicle.images.images['car_picture'] = ImageAttribute(name="car_picture", parent=vehicle.images,
1226
+ value=img, tags={'carconnectivity'})
1227
+ return vehicle
1228
+
585
1229
  def fetch_driving_range(self, vehicle: SkodaVehicle, no_cache: bool = False) -> SkodaVehicle:
586
1230
  """
587
1231
  Fetches the driving range data for a given Skoda vehicle and updates the vehicle object accordingly.
@@ -607,6 +1251,8 @@ class Connector(BaseConnector):
607
1251
  url = f'https://mysmob.api.connect.skoda-auto.cz/api/v2/vehicle-status/{vin}/driving-range'
608
1252
  range_data: Dict[str, Any] | None = self._fetch_data(url=url, session=self.session, no_cache=no_cache)
609
1253
  if range_data:
1254
+ if 'carCapturedTimestamp' not in range_data or range_data['carCapturedTimestamp'] is None:
1255
+ raise APIError('Could not fetch driving range, carCapturedTimestamp missing')
610
1256
  captured_at: datetime = robust_time_parse(range_data['carCapturedTimestamp'])
611
1257
  # Check vehicle type and if it does not match the current vehicle type, create a new vehicle object using copy constructor
612
1258
  if 'carType' in range_data and range_data['carType'] is not None:
@@ -614,7 +1260,7 @@ class Connector(BaseConnector):
614
1260
  car_type = GenericVehicle.Type(range_data['carType'])
615
1261
  if car_type == GenericVehicle.Type.ELECTRIC and not isinstance(vehicle, SkodaElectricVehicle):
616
1262
  LOG.debug('Promoting %s to SkodaElectricVehicle object for %s', vehicle.__class__.__name__, vin)
617
- vehicle = SkodaElectricVehicle(origin=vehicle)
1263
+ vehicle = SkodaElectricVehicle(garage=self.car_connectivity.garage, origin=vehicle)
618
1264
  self.car_connectivity.garage.replace_vehicle(vin, vehicle)
619
1265
  elif car_type in [GenericVehicle.Type.FUEL,
620
1266
  GenericVehicle.Type.GASOLINE,
@@ -624,11 +1270,11 @@ class Connector(BaseConnector):
624
1270
  GenericVehicle.Type.LPG] \
625
1271
  and not isinstance(vehicle, SkodaCombustionVehicle):
626
1272
  LOG.debug('Promoting %s to SkodaCombustionVehicle object for %s', vehicle.__class__.__name__, vin)
627
- vehicle = SkodaCombustionVehicle(origin=vehicle)
1273
+ vehicle = SkodaCombustionVehicle(garage=self.car_connectivity.garage, origin=vehicle)
628
1274
  self.car_connectivity.garage.replace_vehicle(vin, vehicle)
629
1275
  elif car_type == GenericVehicle.Type.HYBRID and not isinstance(vehicle, SkodaHybridVehicle):
630
1276
  LOG.debug('Promoting %s to SkodaHybridVehicle object for %s', vehicle.__class__.__name__, vin)
631
- vehicle = SkodaHybridVehicle(origin=vehicle)
1277
+ vehicle = SkodaHybridVehicle(garage=self.car_connectivity.garage, origin=vehicle)
632
1278
  self.car_connectivity.garage.replace_vehicle(vin, vehicle)
633
1279
  except ValueError:
634
1280
  LOG_API.warning('Unknown car type %s', range_data['carType'])
@@ -637,6 +1283,7 @@ class Connector(BaseConnector):
637
1283
  if 'totalRangeInKm' in range_data and range_data['totalRangeInKm'] is not None:
638
1284
  # pylint: disable-next=protected-access
639
1285
  vehicle.drives.total_range._set_value(value=range_data['totalRangeInKm'], measured=captured_at, unit=Length.KM)
1286
+ vehicle.drives.total_range.precision = 1
640
1287
  else:
641
1288
  vehicle.drives.total_range._set_value(None, measured=captured_at, unit=Length.KM) # pylint: disable=protected-access
642
1289
 
@@ -654,10 +1301,11 @@ class Connector(BaseConnector):
654
1301
  else:
655
1302
  if engine_type == GenericDrive.Type.ELECTRIC:
656
1303
  drive = ElectricDrive(drive_id=drive_id, drives=vehicle.drives)
1304
+ elif engine_type == GenericDrive.Type.DIESEL:
1305
+ drive = DieselDrive(drive_id=drive_id, drives=vehicle.drives)
657
1306
  elif engine_type in [GenericDrive.Type.FUEL,
658
1307
  GenericDrive.Type.GASOLINE,
659
1308
  GenericDrive.Type.PETROL,
660
- GenericDrive.Type.DIESEL,
661
1309
  GenericDrive.Type.CNG,
662
1310
  GenericDrive.Type.LPG]:
663
1311
  drive = CombustionDrive(drive_id=drive_id, drives=vehicle.drives)
@@ -669,15 +1317,18 @@ class Connector(BaseConnector):
669
1317
  and range_data[f'{drive_id}EngineRange']['currentSoCInPercent'] is not None:
670
1318
  # pylint: disable-next=protected-access
671
1319
  drive.level._set_value(value=range_data[f'{drive_id}EngineRange']['currentSoCInPercent'], measured=captured_at)
1320
+ drive.level.precision = 1
672
1321
  elif 'currentFuelLevelInPercent' in range_data[f'{drive_id}EngineRange'] \
673
1322
  and range_data[f'{drive_id}EngineRange']['currentFuelLevelInPercent'] is not None:
674
1323
  # pylint: disable-next=protected-access
675
1324
  drive.level._set_value(value=range_data[f'{drive_id}EngineRange']['currentFuelLevelInPercent'], measured=captured_at)
1325
+ drive.level.precision = 1
676
1326
  else:
677
1327
  drive.level._set_value(None, measured=captured_at) # pylint: disable=protected-access
678
1328
  if 'remainingRangeInKm' in range_data[f'{drive_id}EngineRange'] and range_data[f'{drive_id}EngineRange']['remainingRangeInKm'] is not None:
679
1329
  # pylint: disable-next=protected-access
680
1330
  drive.range._set_value(value=range_data[f'{drive_id}EngineRange']['remainingRangeInKm'], measured=captured_at, unit=Length.KM)
1331
+ drive.range.precision = 1
681
1332
  else:
682
1333
  drive.range._set_value(None, measured=captured_at, unit=Length.KM) # pylint: disable=protected-access
683
1334
 
@@ -685,14 +1336,27 @@ class Connector(BaseConnector):
685
1336
  'currentSoCInPercent',
686
1337
  'currentFuelLevelInPercent',
687
1338
  'remainingRangeInKm'})
1339
+ if 'adBlueRange' in range_data and range_data['adBlueRange'] is not None:
1340
+ # pylint: disable-next=protected-access
1341
+ for drive in vehicle.drives.drives.values():
1342
+ if isinstance(drive, DieselDrive):
1343
+ # pylint: disable-next=protected-access
1344
+ drive.adblue_range._set_value(value=range_data['adBlueRange'], measured=captured_at, unit=Length.KM)
1345
+ drive.adblue_range.precision = 1
1346
+ else:
1347
+ for drive in vehicle.drives.drives.values():
1348
+ if isinstance(drive, DieselDrive):
1349
+ # pylint: disable-next=protected-access
1350
+ drive.adblue_range._set_value(value=None, measured=captured_at, unit=Length.KM)
688
1351
  log_extra_keys(LOG_API, '/api/v2/vehicle-status/{vin}/driving-range', range_data, {'carCapturedTimestamp',
689
1352
  'carType',
690
1353
  'totalRangeInKm',
1354
+ 'adBlueRange',
691
1355
  'primaryEngineRange',
692
1356
  'secondaryEngineRange'})
693
1357
  return vehicle
694
1358
 
695
- def fetch_vehicle_status_second_api(self, vehicle: SkodaVehicle, no_cache: bool = False) -> SkodaVehicle:
1359
+ def fetch_vehicle_status(self, vehicle: SkodaVehicle, no_cache: bool = False) -> SkodaVehicle:
696
1360
  """
697
1361
  Fetches the status of a vehicle from other Skoda API.
698
1362
 
@@ -705,171 +1369,88 @@ class Connector(BaseConnector):
705
1369
  vin = vehicle.vin.value
706
1370
  if vin is None:
707
1371
  raise APIError('VIN is missing')
708
- url = f'https://api.connect.skoda-auto.cz/api/v2/vehicle-status/{vin}'
1372
+ url = f'https://mysmob.api.connect.skoda-auto.cz/api/v2/vehicle-status/{vin}'
709
1373
  vehicle_status_data: Dict[str, Any] | None = self._fetch_data(url=url, session=self.session, no_cache=no_cache)
710
1374
  if vehicle_status_data:
711
- if 'remote' in vehicle_status_data and vehicle_status_data['remote'] is not None:
712
- vehicle_status_data = vehicle_status_data['remote']
713
- if vehicle_status_data:
714
- if 'capturedAt' in vehicle_status_data and vehicle_status_data['capturedAt'] is not None:
715
- captured_at: datetime = robust_time_parse(vehicle_status_data['capturedAt'])
1375
+ if 'carCapturedTimestamp' in vehicle_status_data and vehicle_status_data['carCapturedTimestamp'] is not None:
1376
+ captured_at: Optional[datetime] = robust_time_parse(vehicle_status_data['carCapturedTimestamp'])
716
1377
  else:
717
- raise APIError('Could not fetch vehicle status, capturedAt missing')
718
- if 'mileageInKm' in vehicle_status_data and vehicle_status_data['mileageInKm'] is not None:
719
- vehicle.odometer._set_value(value=vehicle_status_data['mileageInKm'], measured=captured_at, unit=Length.KM) # pylint: disable=protected-access
720
- else:
721
- vehicle.odometer._set_value(value=None, measured=captured_at, unit=Length.KM) # pylint: disable=protected-access
722
- if 'status' in vehicle_status_data and vehicle_status_data['status'] is not None:
723
- if 'open' in vehicle_status_data['status'] and vehicle_status_data['status']['open'] is not None:
724
- if vehicle_status_data['status']['open'] == 'YES':
1378
+ captured_at: Optional[datetime] = None
1379
+ if 'overall' in vehicle_status_data and vehicle_status_data['overall'] is not None:
1380
+ if 'doorsLocked' in vehicle_status_data['overall'] and vehicle_status_data['overall']['doorsLocked'] is not None \
1381
+ and vehicle.doors is not None:
1382
+ if vehicle_status_data['overall']['doorsLocked'] == 'YES':
1383
+ vehicle.doors.lock_state._set_value(Doors.LockState.LOCKED, measured=captured_at) # pylint: disable=protected-access
1384
+ vehicle.doors.open_state._set_value(Doors.OpenState.CLOSED, measured=captured_at) # pylint: disable=protected-access
1385
+ elif vehicle_status_data['overall']['doorsLocked'] == 'NO':
1386
+ vehicle.doors.lock_state._set_value(Doors.LockState.UNLOCKED, measured=captured_at) # pylint: disable=protected-access
1387
+ vehicle.doors.open_state._set_value(Doors.OpenState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
1388
+ elif vehicle_status_data['overall']['doorsLocked'] == 'OPENED':
1389
+ vehicle.doors.lock_state._set_value(Doors.LockState.UNLOCKED, measured=captured_at) # pylint: disable=protected-access
725
1390
  vehicle.doors.open_state._set_value(Doors.OpenState.OPEN, measured=captured_at) # pylint: disable=protected-access
726
- elif vehicle_status_data['status']['open'] == 'NO':
1391
+ elif vehicle_status_data['overall']['doorsLocked'] == 'UNLOCKED':
1392
+ vehicle.doors.lock_state._set_value(Doors.LockState.UNLOCKED, measured=captured_at) # pylint: disable=protected-access
727
1393
  vehicle.doors.open_state._set_value(Doors.OpenState.CLOSED, measured=captured_at) # pylint: disable=protected-access
1394
+ elif vehicle_status_data['overall']['doorsLocked'] == 'TRUNK_OPENED':
1395
+ vehicle.doors.lock_state._set_value(Doors.LockState.UNLOCKED, measured=captured_at) # pylint: disable=protected-access
1396
+ vehicle.doors.open_state._set_value(Doors.OpenState.OPEN, measured=captured_at) # pylint: disable=protected-access
1397
+ elif vehicle_status_data['overall']['doorsLocked'] == 'UNKNOWN':
1398
+ vehicle.doors.lock_state._set_value(Doors.LockState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
1399
+ vehicle.doors.open_state._set_value(Doors.OpenState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
728
1400
  else:
1401
+ LOG_API.info('Unknown doorsLocked state %s', vehicle_status_data['overall']['doorsLocked'])
1402
+ vehicle.doors.lock_state._set_value(Doors.LockState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
729
1403
  vehicle.doors.open_state._set_value(Doors.OpenState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
730
- LOG_API.info('Unknown door open state: %s', vehicle_status_data['status']['open'])
731
- else:
732
- vehicle.doors.open_state._set_value(None, measured=captured_at) # pylint: disable=protected-access
733
- if 'locked' in vehicle_status_data['status'] and vehicle_status_data['status']['locked'] is not None:
734
- if vehicle_status_data['status']['locked'] == 'YES':
1404
+ if 'locked' in vehicle_status_data['overall'] and vehicle_status_data['overall']['locked'] is not None:
1405
+ if vehicle_status_data['overall']['locked'] == 'YES':
735
1406
  vehicle.doors.lock_state._set_value(Doors.LockState.LOCKED, measured=captured_at) # pylint: disable=protected-access
736
- elif vehicle_status_data['status']['locked'] == 'NO':
1407
+ elif vehicle_status_data['overall']['locked'] == 'NO':
737
1408
  vehicle.doors.lock_state._set_value(Doors.LockState.UNLOCKED, measured=captured_at) # pylint: disable=protected-access
1409
+ elif vehicle_status_data['overall']['locked'] == 'UNKNOWN':
1410
+ vehicle.doors.lock_state._set_value(Doors.LockState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
738
1411
  else:
1412
+ LOG_API.info('Unknown locked state %s', vehicle_status_data['overall']['locked'])
739
1413
  vehicle.doors.lock_state._set_value(Doors.LockState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
740
- LOG_API.info('Unknown door lock state: %s', vehicle_status_data['status']['locked'])
741
- else:
742
- vehicle.doors.lock_state._set_value(None, measured=captured_at) # pylint: disable=protected-access
743
- else:
744
- vehicle.doors.open_state._set_value(None, measured=captured_at) # pylint: disable=protected-access
745
- vehicle.doors.lock_state._set_value(None, measured=captured_at) # pylint: disable=protected-access
746
- if 'doors' in vehicle_status_data and vehicle_status_data['doors'] is not None:
747
- seen_door_ids: set[str] = set()
748
- for door_status in vehicle_status_data['doors']:
749
- if 'name' in door_status and door_status['name'] is not None:
750
- door_id = door_status['name']
751
- seen_door_ids.add(door_id)
752
- if door_id in vehicle.doors.doors:
753
- door: Doors.Door = vehicle.doors.doors[door_id]
754
- else:
755
- door = Doors.Door(door_id=door_id, doors=vehicle.doors)
756
- vehicle.doors.doors[door_id] = door
757
- if 'status' in door_status and door_status['status'] is not None:
758
- if door_status['status'] == 'OPEN':
759
- door.lock_state._set_value(Doors.LockState.UNLOCKED, measured=captured_at) # pylint: disable=protected-access
760
- door.open_state._set_value(Doors.OpenState.OPEN, measured=captured_at) # pylint: disable=protected-access
761
- elif door_status['status'] == 'CLOSED':
762
- door.lock_state._set_value(Doors.LockState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
763
- door.open_state._set_value(Doors.OpenState.CLOSED, measured=captured_at) # pylint: disable=protected-access
764
- elif door_status['status'] == 'LOCKED':
765
- door.lock_state._set_value(Doors.LockState.LOCKED, measured=captured_at) # pylint: disable=protected-access
766
- door.open_state._set_value(Doors.OpenState.CLOSED, measured=captured_at) # pylint: disable=protected-access
767
- elif door_status['status'] == 'UNSUPPORTED':
768
- door.lock_state._set_value(Doors.LockState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
769
- door.open_state._set_value(Doors.OpenState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
770
- else:
771
- LOG_API.info('Unknown door status %s', door_status['status'])
772
- door.lock_state._set_value(Doors.LockState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
773
- door.open_state._set_value(Doors.OpenState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
774
- else:
775
- door.lock_state._set_value(None, measured=captured_at) # pylint: disable=protected-access
776
- door.open_state._set_value(None, measured=captured_at) # pylint: disable=protected-access
1414
+ if 'doors' in vehicle_status_data['overall'] and vehicle_status_data['overall']['doors'] is not None:
1415
+ if vehicle_status_data['overall']['doors'] == 'CLOSED':
1416
+ vehicle.doors.open_state._set_value(Doors.OpenState.CLOSED, measured=captured_at) # pylint: disable=protected-access
1417
+ elif vehicle_status_data['overall']['doors'] == 'OPEN':
1418
+ vehicle.doors.open_state._set_value(Doors.OpenState.OPEN, measured=captured_at) # pylint: disable=protected-access
1419
+ elif vehicle_status_data['overall']['doors'] == 'UNSUPPORTED':
1420
+ vehicle.doors.open_state._set_value(Doors.OpenState.UNSUPPORTED, measured=captured_at) # pylint: disable=protected-access
1421
+ elif vehicle_status_data['overall']['doors'] == 'UNKNOWN':
1422
+ vehicle.doors.open_state._set_value(Doors.OpenState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
777
1423
  else:
778
- raise APIError('Could not parse door, name missing')
779
- log_extra_keys(LOG_API, 'doors', door_status, {'name', 'status'})
780
- for door_to_remove in set(vehicle.doors.doors) - seen_door_ids:
781
- vehicle.doors.doors[door_to_remove].enabled = False
782
- vehicle.doors.doors.pop(door_to_remove)
783
- log_extra_keys(LOG_API, 'status', vehicle_status_data['status'], {'open', 'locked'})
784
- else:
785
- vehicle.doors.open_state._set_value(None, measured=captured_at) # pylint: disable=protected-access
786
- vehicle.doors.doors = {}
787
- if 'windows' in vehicle_status_data and vehicle_status_data['windows'] is not None:
788
- seen_window_ids: set[str] = set()
789
- all_windows_closed: bool = True
790
- for window_status in vehicle_status_data['windows']:
791
- if 'name' in window_status and window_status['name'] is not None:
792
- window_id = window_status['name']
793
- seen_window_ids.add(window_id)
794
- if window_id in vehicle.windows.windows:
795
- window: Windows.Window = vehicle.windows.windows[window_id]
796
- else:
797
- window = Windows.Window(window_id=window_id, windows=vehicle.windows)
798
- vehicle.windows.windows[window_id] = window
799
- if 'status' in window_status and window_status['status'] is not None:
800
- if window_status['status'] == 'OPEN':
801
- all_windows_closed = False
802
- window.open_state._set_value(Windows.OpenState.OPEN, measured=captured_at) # pylint: disable=protected-access
803
- elif window_status['status'] == 'CLOSED':
804
- window.open_state._set_value(Windows.OpenState.CLOSED, measured=captured_at) # pylint: disable=protected-access
805
- elif window_status['status'] == 'UNSUPPORTED':
806
- window.open_state._set_value(Windows.OpenState.UNSUPPORTED, measured=captured_at) # pylint: disable=protected-access
807
- elif window_status['status'] == 'INVALID':
808
- window.open_state._set_value(Windows.OpenState.INVALID, measured=captured_at) # pylint: disable=protected-access
809
- else:
810
- LOG_API.info('Unknown window status %s', window_status['status'])
811
- window.open_state._set_value(Windows.OpenState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
812
- else:
813
- window.open_state._set_value(None, measured=captured_at) # pylint: disable=protected-access
1424
+ LOG_API.info('Unknown doors state %s', vehicle_status_data['overall']['doors'])
1425
+ vehicle.doors.open_state._set_value(Doors.OpenState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
1426
+ if 'windows' in vehicle_status_data['overall'] and vehicle_status_data['overall']['windows'] is not None:
1427
+ if vehicle_status_data['overall']['windows'] == 'CLOSED':
1428
+ vehicle.windows.open_state._set_value(Windows.OpenState.CLOSED, measured=captured_at) # pylint: disable=protected-access
1429
+ elif vehicle_status_data['overall']['windows'] == 'OPEN':
1430
+ vehicle.windows.open_state._set_value(Windows.OpenState.OPEN, measured=captured_at) # pylint: disable=protected-access
1431
+ elif vehicle_status_data['overall']['windows'] == 'UNKNOWN':
1432
+ vehicle.windows.open_state._set_value(Windows.OpenState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
1433
+ elif vehicle_status_data['overall']['windows'] == 'UNSUPPORTED':
1434
+ vehicle.windows.open_state._set_value(Windows.OpenState.UNSUPPORTED, measured=captured_at) # pylint: disable=protected-access
814
1435
  else:
815
- raise APIError('Could not parse window, name missing')
816
- log_extra_keys(LOG_API, 'doors', window_status, {'name', 'status'})
817
- for window_to_remove in set(vehicle.windows.windows) - seen_window_ids:
818
- vehicle.windows.windows[window_to_remove].enabled = False
819
- vehicle.windows.windows.pop(window_to_remove)
820
- if all_windows_closed:
821
- vehicle.windows.open_state._set_value(Windows.OpenState.CLOSED, measured=captured_at) # pylint: disable=protected-access
822
- else:
823
- vehicle.windows.open_state._set_value(Windows.OpenState.OPEN, measured=captured_at) # pylint: disable=protected-access
824
- else:
825
- vehicle.windows.open_state._set_value(None, measured=captured_at) # pylint: disable=protected-access
826
- vehicle.windows.windows = {}
827
- if 'lights' in vehicle_status_data and vehicle_status_data['lights'] is not None:
828
- seen_light_ids: set[str] = set()
829
- if 'overallStatus' in vehicle_status_data['lights'] and vehicle_status_data['lights']['overallStatus'] is not None:
830
- if vehicle_status_data['lights']['overallStatus'] == 'ON':
1436
+ LOG_API.info('Unknown windows state %s', vehicle_status_data['overall']['windows'])
1437
+ vehicle.windows.open_state._set_value(Windows.OpenState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
1438
+ if 'lights' in vehicle_status_data['overall'] and vehicle_status_data['overall']['lights'] is not None:
1439
+ if vehicle_status_data['overall']['lights'] == 'ON':
831
1440
  vehicle.lights.light_state._set_value(Lights.LightState.ON, measured=captured_at) # pylint: disable=protected-access
832
- elif vehicle_status_data['lights']['overallStatus'] == 'OFF':
1441
+ elif vehicle_status_data['overall']['lights'] == 'OFF':
833
1442
  vehicle.lights.light_state._set_value(Lights.LightState.OFF, measured=captured_at) # pylint: disable=protected-access
834
- elif vehicle_status_data['lights']['overallStatus'] == 'INVALID':
835
- vehicle.lights.light_state._set_value(Lights.LightState.INVALID, measured=captured_at) # pylint: disable=protected-access
1443
+ elif vehicle_status_data['overall']['lights'] == 'UNKNOWN':
1444
+ vehicle.lights.light_state._set_value(Lights.LightState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
836
1445
  else:
837
- LOG_API.info('Unknown light status %s', vehicle_status_data['lights']['overallStatus'])
1446
+ LOG_API.info('Unknown lights state %s', vehicle_status_data['overall']['lights'])
838
1447
  vehicle.lights.light_state._set_value(Lights.LightState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
839
- else:
840
- vehicle.lights.light_state._set_value(None, measured=captured_at) # pylint: disable=protected-access
841
- if 'lightsStatus' in vehicle_status_data['lights'] and vehicle_status_data['lights']['lightsStatus'] is not None:
842
- for light_status in vehicle_status_data['lights']['lightsStatus']:
843
- if 'name' in light_status and light_status['name'] is not None:
844
- light_id: str = light_status['name']
845
- seen_light_ids.add(light_id)
846
- if light_id in vehicle.lights.lights:
847
- light: Lights.Light = vehicle.lights.lights[light_id]
848
- else:
849
- light = Lights.Light(light_id=light_id, lights=vehicle.lights)
850
- vehicle.lights.lights[light_id] = light
851
- if 'status' in light_status and light_status['status'] is not None:
852
- if light_status['status'] == 'ON':
853
- light.light_state._set_value(Lights.LightState.ON, measured=captured_at) # pylint: disable=protected-access
854
- elif light_status['status'] == 'OFF':
855
- light.light_state._set_value(Lights.LightState.OFF, measured=captured_at) # pylint: disable=protected-access
856
- elif light_status['status'] == 'INVALID':
857
- light.light_state._set_value(Lights.LightState.INVALID, measured=captured_at) # pylint: disable=protected-access
858
- else:
859
- LOG_API.info('Unknown light status %s', light_status['status'])
860
- light.light_state._set_value(Lights.LightState.UNKNOWN, measured=captured_at) # pylint: disable=protected-access
861
- else:
862
- light.light_state._set_value(None, measured=captured_at) # pylint: disable=protected-access
863
- else:
864
- raise APIError('Could not parse light, name missing')
865
- log_extra_keys(LOG_API, 'lights', light_status, {'name', 'status'})
866
- for light_to_remove in set(vehicle.lights.lights) - seen_light_ids:
867
- vehicle.lights.lights[light_to_remove].enabled = False
868
- vehicle.lights.lights.pop(light_to_remove)
869
- else:
870
- vehicle.lights.lights = {}
871
- log_extra_keys(LOG_API, 'lights', vehicle_status_data['lights'], {'overallStatus', 'lightsStatus'})
872
- log_extra_keys(LOG_API, 'vehicles', vehicle_status_data, {'capturedAt', 'mileageInKm', 'status', 'doors', 'windows', 'lights'})
1448
+ log_extra_keys(LOG_API, 'status overall', vehicle_status_data['overall'], {'doorsLocked',
1449
+ 'locked',
1450
+ 'doors',
1451
+ 'windows',
1452
+ 'lights'})
1453
+ log_extra_keys(LOG_API, f'/api/v2/vehicle-status/{vin}', vehicle_status_data, {'overall', 'carCapturedTimestamp'})
873
1454
  return vehicle
874
1455
 
875
1456
  def _record_elapsed(self, elapsed: timedelta) -> None:
@@ -885,11 +1466,11 @@ class Connector(BaseConnector):
885
1466
  allowed_errors=None) -> Optional[Dict[str, Any]]: # noqa: C901
886
1467
  data: Optional[Dict[str, Any]] = None
887
1468
  cache_date: Optional[datetime] = None
888
- if not no_cache and (self.max_age is not None and session.cache is not None and url in session.cache):
1469
+ if not no_cache and (self.active_config['max_age'] is not None and session.cache is not None and url in session.cache):
889
1470
  data, cache_date_string = session.cache[url]
890
1471
  cache_date = datetime.fromisoformat(cache_date_string)
891
- if data is None or self.max_age is None \
892
- or (cache_date is not None and cache_date < (datetime.utcnow() - timedelta(seconds=self.max_age))):
1472
+ if data is None or self.active_config['max_age'] is None \
1473
+ or (cache_date is not None and cache_date < (datetime.utcnow() - timedelta(seconds=self.active_config['max_age']))):
893
1474
  try:
894
1475
  status_response: requests.Response = session.get(url, allow_redirects=False)
895
1476
  self._record_elapsed(status_response.elapsed)
@@ -897,6 +1478,8 @@ class Connector(BaseConnector):
897
1478
  data = status_response.json()
898
1479
  if session.cache is not None:
899
1480
  session.cache[url] = (data, str(datetime.utcnow()))
1481
+ elif status_response.status_code == requests.codes['no_content'] and allow_empty:
1482
+ data = None
900
1483
  elif status_response.status_code == requests.codes['too_many_requests']:
901
1484
  raise TooManyRequestsError('Could not fetch data due to too many requests from your account. '
902
1485
  f'Status Code was: {status_response.status_code}')
@@ -912,7 +1495,7 @@ class Connector(BaseConnector):
912
1495
  elif not allow_http_error or (allowed_errors is not None and status_response.status_code not in allowed_errors):
913
1496
  raise RetrievalError(f'Could not fetch data even after re-authorization. Status Code was: {status_response.status_code}')
914
1497
  elif not allow_http_error or (allowed_errors is not None and status_response.status_code not in allowed_errors):
915
- raise RetrievalError(f'Could not fetch data. Status Code was: {status_response.status_code}')
1498
+ raise RetrievalError(f'Could not fetch data for {url}. Status Code was: {status_response.status_code}')
916
1499
  except requests.exceptions.ConnectionError as connection_error:
917
1500
  raise RetrievalError(f'Connection error: {connection_error}.'
918
1501
  ' If this happens frequently, please check if other applications communicate with the Skoda server.') from connection_error
@@ -931,3 +1514,541 @@ class Connector(BaseConnector):
931
1514
 
932
1515
  def get_version(self) -> str:
933
1516
  return __version__
1517
+
1518
+ def get_type(self) -> str:
1519
+ return "carconnectivity-connector-skoda"
1520
+
1521
+ def __on_air_conditioning_target_temperature_change(self, temperature_attribute: TemperatureAttribute, target_temperature: float) -> float:
1522
+ """
1523
+ Callback for the climatization target temperature change.
1524
+
1525
+ Args:
1526
+ temperature_attribute (TemperatureAttribute): The temperature attribute that changed.
1527
+ target_temperature (float): The new target temperature.
1528
+ """
1529
+ if temperature_attribute.parent is None or temperature_attribute.parent.parent is None \
1530
+ or temperature_attribute.parent.parent.parent is None or not isinstance(temperature_attribute.parent.parent.parent, SkodaVehicle):
1531
+ raise SetterError('Object hierarchy is not as expected')
1532
+ vehicle: SkodaVehicle = temperature_attribute.parent.parent.parent
1533
+ vin: Optional[str] = vehicle.vin.value
1534
+ if vin is None:
1535
+ raise SetterError('VIN in object hierarchy missing')
1536
+ setting_dict = {}
1537
+ # Round target temperature to nearest 0.5
1538
+ setting_dict['temperatureValue'] = round(target_temperature * 2) / 2
1539
+ if temperature_attribute.unit == Temperature.C:
1540
+ setting_dict['unitInCar'] = 'CELSIUS'
1541
+ elif temperature_attribute.unit == Temperature.F:
1542
+ setting_dict['unitInCar'] = 'FAHRENHEIT'
1543
+ elif temperature_attribute.unit == Temperature.K:
1544
+ setting_dict['unitInCar'] = 'KELVIN'
1545
+ else:
1546
+ raise SetterError(f'Unknown temperature unit {temperature_attribute.unit}')
1547
+
1548
+ url = f'https://mysmob.api.connect.skoda-auto.cz/api/v2/air-conditioning/{vin}/settings/target-temperature'
1549
+ try:
1550
+ settings_response: requests.Response = self.session.post(url, data=json.dumps(setting_dict), allow_redirects=True)
1551
+ if settings_response.status_code != requests.codes['accepted']:
1552
+ LOG.error('Could not set target temperature (%s)', settings_response.status_code)
1553
+ raise SetterError(f'Could not set value ({settings_response.status_code})')
1554
+ except requests.exceptions.ConnectionError as connection_error:
1555
+ raise SetterError(f'Connection error: {connection_error}.'
1556
+ ' If this happens frequently, please check if other applications communicate with the Skoda server.') from connection_error
1557
+ except requests.exceptions.ChunkedEncodingError as chunked_encoding_error:
1558
+ raise SetterError(f'Error: {chunked_encoding_error}') from chunked_encoding_error
1559
+ except requests.exceptions.ReadTimeout as timeout_error:
1560
+ raise SetterError(f'Timeout during read: {timeout_error}') from timeout_error
1561
+ except requests.exceptions.RetryError as retry_error:
1562
+ raise SetterError(f'Retrying failed: {retry_error}') from retry_error
1563
+ return target_temperature
1564
+
1565
+ def __on_air_conditioning_at_unlock_change(self, at_unlock_attribute: BooleanAttribute, at_unlock_value: bool) -> bool:
1566
+ if at_unlock_attribute.parent is None or at_unlock_attribute.parent.parent is None \
1567
+ or at_unlock_attribute.parent.parent.parent is None or not isinstance(at_unlock_attribute.parent.parent.parent, SkodaVehicle):
1568
+ raise SetterError('Object hierarchy is not as expected')
1569
+ vehicle: SkodaVehicle = at_unlock_attribute.parent.parent.parent
1570
+ vin: Optional[str] = vehicle.vin.value
1571
+ if vin is None:
1572
+ raise SetterError('VIN in object hierarchy missing')
1573
+ setting_dict = {}
1574
+ # Round target temperature to nearest 0.5
1575
+ setting_dict['airConditioningAtUnlockEnabled'] = at_unlock_value
1576
+
1577
+ url = f'https://mysmob.api.connect.skoda-auto.cz/api/v2/air-conditioning/{vin}/settings/ac-at-unlock'
1578
+ try:
1579
+ settings_response: requests.Response = self.session.post(url, data=json.dumps(setting_dict), allow_redirects=True)
1580
+ if settings_response.status_code != requests.codes['accepted']:
1581
+ LOG.error('Could not set air conditioning at unlock (%s)', settings_response.status_code)
1582
+ raise SetterError(f'Could not set value ({settings_response.status_code})')
1583
+ except requests.exceptions.ConnectionError as connection_error:
1584
+ raise SetterError(f'Connection error: {connection_error}.'
1585
+ ' If this happens frequently, please check if other applications communicate with the Skoda server.') from connection_error
1586
+ except requests.exceptions.ChunkedEncodingError as chunked_encoding_error:
1587
+ raise SetterError(f'Error: {chunked_encoding_error}') from chunked_encoding_error
1588
+ except requests.exceptions.ReadTimeout as timeout_error:
1589
+ raise SetterError(f'Timeout during read: {timeout_error}') from timeout_error
1590
+ except requests.exceptions.RetryError as retry_error:
1591
+ raise SetterError(f'Retrying failed: {retry_error}') from retry_error
1592
+ return at_unlock_value
1593
+
1594
+ def __on_air_conditioning_window_heating_change(self, window_heating_attribute: BooleanAttribute, window_heating_value: bool) -> bool:
1595
+ if window_heating_attribute.parent is None or window_heating_attribute.parent.parent is None \
1596
+ or window_heating_attribute.parent.parent.parent is None or not isinstance(window_heating_attribute.parent.parent.parent, SkodaVehicle):
1597
+ raise SetterError('Object hierarchy is not as expected')
1598
+ vehicle: SkodaVehicle = window_heating_attribute.parent.parent.parent
1599
+ vin: Optional[str] = vehicle.vin.value
1600
+ if vin is None:
1601
+ raise SetterError('VIN in object hierarchy missing')
1602
+ setting_dict = {}
1603
+ # Round target temperature to nearest 0.5
1604
+ setting_dict['windowHeatingEnabled'] = window_heating_value
1605
+
1606
+ url = f'https://mysmob.api.connect.skoda-auto.cz/api/v2/air-conditioning/{vin}/settings/ac-at-unlock'
1607
+ try:
1608
+ settings_response: requests.Response = self.session.post(url, data=json.dumps(setting_dict), allow_redirects=True)
1609
+ if settings_response.status_code != requests.codes['accepted']:
1610
+ LOG.error('Could not set air conditioning window heating (%s)', settings_response.status_code)
1611
+ raise SetterError(f'Could not set value ({settings_response.status_code})')
1612
+ except requests.exceptions.ConnectionError as connection_error:
1613
+ raise SetterError(f'Connection error: {connection_error}.'
1614
+ ' If this happens frequently, please check if other applications communicate with the Skoda server.') from connection_error
1615
+ except requests.exceptions.ChunkedEncodingError as chunked_encoding_error:
1616
+ raise SetterError(f'Error: {chunked_encoding_error}') from chunked_encoding_error
1617
+ except requests.exceptions.ReadTimeout as timeout_error:
1618
+ raise SetterError(f'Timeout during read: {timeout_error}') from timeout_error
1619
+ except requests.exceptions.RetryError as retry_error:
1620
+ raise SetterError(f'Retrying failed: {retry_error}') from retry_error
1621
+ return window_heating_value
1622
+
1623
+ def __on_air_conditioning_start_stop(self, start_stop_command: ClimatizationStartStopCommand, command_arguments: Union[str, Dict[str, Any]]) \
1624
+ -> Union[str, Dict[str, Any]]:
1625
+ if start_stop_command.parent is None or start_stop_command.parent.parent is None \
1626
+ or start_stop_command.parent.parent.parent is None or not isinstance(start_stop_command.parent.parent.parent, SkodaVehicle):
1627
+ raise CommandError('Object hierarchy is not as expected')
1628
+ if not isinstance(command_arguments, dict):
1629
+ raise CommandError('Command arguments are not a dictionary')
1630
+ vehicle: SkodaVehicle = start_stop_command.parent.parent.parent
1631
+ vin: Optional[str] = vehicle.vin.value
1632
+ if vin is None:
1633
+ raise CommandError('VIN in object hierarchy missing')
1634
+ if 'command' not in command_arguments:
1635
+ raise CommandError('Command argument missing')
1636
+ command_dict = {}
1637
+ try:
1638
+ if command_arguments['command'] == ClimatizationStartStopCommand.Command.START:
1639
+ command_dict['heaterSource'] = 'ELECTRIC'
1640
+ command_dict['targetTemperature'] = {}
1641
+ precision: float = 0.5
1642
+ if 'target_temperature' in command_arguments:
1643
+ # Round target temperature to nearest 0.5
1644
+ command_dict['targetTemperature']['temperatureValue'] = round(command_arguments['target_temperature'] / precision) * precision
1645
+ if 'target_temperature_unit' in command_arguments:
1646
+ if not isinstance(command_arguments['target_temperature_unit'], Temperature):
1647
+ raise CommandError('Temperature unit is not of type Temperature')
1648
+ if command_arguments['target_temperature_unit'] == Temperature.C:
1649
+ command_dict['targetTemperature']['unitInCar'] = 'CELSIUS'
1650
+ elif command_arguments['target_temperature_unit'] == Temperature.F:
1651
+ command_dict['targetTemperature']['unitInCar'] = 'FAHRENHEIT'
1652
+ elif command_arguments['target_temperature_unit'] == Temperature.K:
1653
+ command_dict['targetTemperature']['unitInCar'] = 'KELVIN'
1654
+ else:
1655
+ raise CommandError(f'Unknown temperature unit {command_arguments["target_temperature_unit"]}')
1656
+ else:
1657
+ command_dict['targetTemperature']['unitInCar'] = 'CELSIUS'
1658
+ elif start_stop_command.parent is not None and (climatization := start_stop_command.parent.parent) is not None \
1659
+ and isinstance(climatization, Climatization) and climatization.settings is not None \
1660
+ and climatization.settings.target_temperature is not None and climatization.settings.target_temperature.enabled \
1661
+ and climatization.settings.target_temperature.value is not None: # pylint: disable=too-many-boolean-expressions
1662
+ if climatization.settings.target_temperature.precision is not None:
1663
+ precision = climatization.settings.target_temperature.precision
1664
+ # Round target temperature to nearest 0.5
1665
+ command_dict['targetTemperature']['temperatureValue'] = round(climatization.settings.target_temperature.value / precision) * precision
1666
+ if climatization.settings.target_temperature.unit == Temperature.C:
1667
+ command_dict['targetTemperature']['unitInCar'] = 'CELSIUS'
1668
+ elif climatization.settings.target_temperature.unit == Temperature.F:
1669
+ command_dict['targetTemperature']['unitInCar'] = 'FAHRENHEIT'
1670
+ elif climatization.settings.target_temperature.unit == Temperature.K:
1671
+ command_dict['targetTemperature']['unitInCar'] = 'KELVIN'
1672
+ else:
1673
+ raise CommandError(f'Unknown temperature unit {climatization.settings.target_temperature.unit}')
1674
+ else:
1675
+ command_dict['targetTemperature']['temperatureValue'] = 25.0
1676
+ command_dict['targetTemperature']['unitInCar'] = 'CELSIUS'
1677
+ url = f'https://mysmob.api.connect.skoda-auto.cz/api/v2/air-conditioning/{vin}/start'
1678
+ command_response: requests.Response = self.session.post(url, data=json.dumps(command_dict), allow_redirects=True)
1679
+ elif command_arguments['command'] == ClimatizationStartStopCommand.Command.STOP:
1680
+ url = f'https://mysmob.api.connect.skoda-auto.cz/api/v2/air-conditioning/{vin}/stop'
1681
+ command_response: requests.Response = self.session.post(url, allow_redirects=True)
1682
+ else:
1683
+ raise CommandError(f'Unknown command {command_arguments["command"]}')
1684
+
1685
+ if command_response.status_code != requests.codes['accepted']:
1686
+ LOG.error('Could not start/stop air conditioning (%s: %s)', command_response.status_code, command_response.text)
1687
+ raise CommandError(f'Could not start/stop air conditioning ({command_response.status_code}: {command_response.text})')
1688
+ except requests.exceptions.ConnectionError as connection_error:
1689
+ raise CommandError(f'Connection error: {connection_error}.'
1690
+ ' If this happens frequently, please check if other applications communicate with the Skoda server.') from connection_error
1691
+ except requests.exceptions.ChunkedEncodingError as chunked_encoding_error:
1692
+ raise CommandError(f'Error: {chunked_encoding_error}') from chunked_encoding_error
1693
+ except requests.exceptions.ReadTimeout as timeout_error:
1694
+ raise CommandError(f'Timeout during read: {timeout_error}') from timeout_error
1695
+ except requests.exceptions.RetryError as retry_error:
1696
+ raise CommandError(f'Retrying failed: {retry_error}') from retry_error
1697
+ return command_arguments
1698
+
1699
+ def __on_charging_start_stop(self, start_stop_command: ChargingStartStopCommand, command_arguments: Union[str, Dict[str, Any]]) \
1700
+ -> Union[str, Dict[str, Any]]:
1701
+ if start_stop_command.parent is None or start_stop_command.parent.parent is None \
1702
+ or start_stop_command.parent.parent.parent is None or not isinstance(start_stop_command.parent.parent.parent, SkodaVehicle):
1703
+ raise CommandError('Object hierarchy is not as expected')
1704
+ if not isinstance(command_arguments, dict):
1705
+ raise CommandError('Command arguments are not a dictionary')
1706
+ vehicle: SkodaVehicle = start_stop_command.parent.parent.parent
1707
+ vin: Optional[str] = vehicle.vin.value
1708
+ if vin is None:
1709
+ raise CommandError('VIN in object hierarchy missing')
1710
+ if 'command' not in command_arguments:
1711
+ raise CommandError('Command argument missing')
1712
+ try:
1713
+ if command_arguments['command'] == ChargingStartStopCommand.Command.START:
1714
+ url = f'https://mysmob.api.connect.skoda-auto.cz/api/v1/charging/{vin}/start'
1715
+ command_response: requests.Response = self.session.post(url, allow_redirects=True)
1716
+ elif command_arguments['command'] == ChargingStartStopCommand.Command.STOP:
1717
+ url = f'https://mysmob.api.connect.skoda-auto.cz/api/v1/charging/{vin}/stop'
1718
+
1719
+ command_response: requests.Response = self.session.post(url, allow_redirects=True)
1720
+ else:
1721
+ raise CommandError(f'Unknown command {command_arguments["command"]}')
1722
+
1723
+ if command_response.status_code != requests.codes['accepted']:
1724
+ LOG.error('Could not start/stop charging (%s: %s)', command_response.status_code, command_response.text)
1725
+ raise CommandError(f'Could not start/stop charging ({command_response.status_code}: {command_response.text})')
1726
+ except requests.exceptions.ConnectionError as connection_error:
1727
+ raise CommandError(f'Connection error: {connection_error}.'
1728
+ ' If this happens frequently, please check if other applications communicate with the Skoda server.') from connection_error
1729
+ except requests.exceptions.ChunkedEncodingError as chunked_encoding_error:
1730
+ raise CommandError(f'Error: {chunked_encoding_error}') from chunked_encoding_error
1731
+ except requests.exceptions.ReadTimeout as timeout_error:
1732
+ raise CommandError(f'Timeout during read: {timeout_error}') from timeout_error
1733
+ except requests.exceptions.RetryError as retry_error:
1734
+ raise CommandError(f'Retrying failed: {retry_error}') from retry_error
1735
+ return command_arguments
1736
+
1737
+ def __on_honk_flash(self, honk_flash_command: HonkAndFlashCommand, command_arguments: Union[str, Dict[str, Any]]) \
1738
+ -> Union[str, Dict[str, Any]]:
1739
+ if honk_flash_command.parent is None or honk_flash_command.parent.parent is None \
1740
+ or not isinstance(honk_flash_command.parent.parent, SkodaVehicle):
1741
+ raise CommandError('Object hierarchy is not as expected')
1742
+ if not isinstance(command_arguments, dict):
1743
+ raise CommandError('Command arguments are not a dictionary')
1744
+ vehicle: SkodaVehicle = honk_flash_command.parent.parent
1745
+ vin: Optional[str] = vehicle.vin.value
1746
+ if vin is None:
1747
+ raise CommandError('VIN in object hierarchy missing')
1748
+ if 'command' not in command_arguments:
1749
+ raise CommandError('Command argument missing')
1750
+ if 'duration' in command_arguments:
1751
+ LOG.warning('Duration argument is not supported by the Skoda API')
1752
+ command_dict = {}
1753
+ if command_arguments['command'] in [HonkAndFlashCommand.Command.FLASH, HonkAndFlashCommand.Command.HONK_AND_FLASH]:
1754
+ command_dict['mode'] = command_arguments['command'].name
1755
+ command_dict['vehiclePosition'] = {}
1756
+ if vehicle.position is None or vehicle.position.latitude is None or vehicle.position.longitude is None \
1757
+ or vehicle.position.latitude.value is None or vehicle.position.longitude.value is None \
1758
+ or not vehicle.position.latitude.enabled or not vehicle.position.longitude.enabled:
1759
+ raise CommandError('Can only execute honk and flash commands if vehicle position is known')
1760
+ command_dict['vehiclePosition']['latitude'] = vehicle.position.latitude.value
1761
+ command_dict['vehiclePosition']['longitude'] = vehicle.position.longitude.value
1762
+
1763
+ url = f'https://mysmob.api.connect.skoda-auto.cz/api/v1/vehicle-access/{vin}/honk-and-flash'
1764
+ try:
1765
+ command_response: requests.Response = self.session.post(url, data=json.dumps(command_dict), allow_redirects=True)
1766
+ if command_response.status_code != requests.codes['accepted']:
1767
+ LOG.error('Could not execute honk or flash command (%s: %s)', command_response.status_code, command_response.text)
1768
+ raise CommandError(f'Could not execute honk or flash command ({command_response.status_code}: {command_response.text})')
1769
+ except requests.exceptions.ConnectionError as connection_error:
1770
+ raise CommandError(f'Connection error: {connection_error}.'
1771
+ ' If this happens frequently, please check if other applications communicate with the Skoda server.') from connection_error
1772
+ except requests.exceptions.ChunkedEncodingError as chunked_encoding_error:
1773
+ raise SetterError(f'Error: {chunked_encoding_error}') from chunked_encoding_error
1774
+ except requests.exceptions.ReadTimeout as timeout_error:
1775
+ raise CommandError(f'Timeout during read: {timeout_error}') from timeout_error
1776
+ except requests.exceptions.RetryError as retry_error:
1777
+ raise CommandError(f'Retrying failed: {retry_error}') from retry_error
1778
+ else:
1779
+ raise CommandError(f'Unknown command {command_arguments["command"]}')
1780
+ return command_arguments
1781
+
1782
+ def __on_lock_unlock(self, lock_unlock_command: LockUnlockCommand, command_arguments: Union[str, Dict[str, Any]]) \
1783
+ -> Union[str, Dict[str, Any]]:
1784
+ if lock_unlock_command.parent is None or lock_unlock_command.parent.parent is None \
1785
+ or lock_unlock_command.parent.parent.parent is None or not isinstance(lock_unlock_command.parent.parent.parent, SkodaVehicle):
1786
+ raise CommandError('Object hierarchy is not as expected')
1787
+ if not isinstance(command_arguments, dict):
1788
+ raise SetterError('Command arguments are not a dictionary')
1789
+ vehicle: SkodaVehicle = lock_unlock_command.parent.parent.parent
1790
+ vin: Optional[str] = vehicle.vin.value
1791
+ if vin is None:
1792
+ raise CommandError('VIN in object hierarchy missing')
1793
+ if 'command' not in command_arguments:
1794
+ raise CommandError('Command argument missing')
1795
+ command_dict = {}
1796
+ if 'spin' in command_arguments:
1797
+ command_dict['currentSpin'] = command_arguments['spin']
1798
+ else:
1799
+ if self.active_config['spin'] is None:
1800
+ raise CommandError('S-PIN is missing, please add S-PIN to your configuration or .netrc file')
1801
+ command_dict['currentSpin'] = self.active_config['spin']
1802
+ if command_arguments['command'] == LockUnlockCommand.Command.LOCK:
1803
+ url = f'https://mysmob.api.connect.skoda-auto.cz/api/v1/vehicle-access/{vin}/lock'
1804
+ elif command_arguments['command'] == LockUnlockCommand.Command.UNLOCK:
1805
+ url = f'https://mysmob.api.connect.skoda-auto.cz/api/v1/vehicle-access/{vin}/unlock'
1806
+ else:
1807
+ raise CommandError(f'Unknown command {command_arguments["command"]}')
1808
+ try:
1809
+ command_response: requests.Response = self.session.post(url, data=json.dumps(command_dict), allow_redirects=True)
1810
+ if command_response.status_code != requests.codes['accepted']:
1811
+ LOG.error('Could not execute locking command (%s: %s)', command_response.status_code, command_response.text)
1812
+ raise CommandError(f'Could not execute locking command ({command_response.status_code}: {command_response.text})')
1813
+ except requests.exceptions.ConnectionError as connection_error:
1814
+ raise CommandError(f'Connection error: {connection_error}.'
1815
+ ' If this happens frequently, please check if other applications communicate with the Skoda server.') from connection_error
1816
+ except requests.exceptions.ChunkedEncodingError as chunked_encoding_error:
1817
+ raise CommandError(f'Error: {chunked_encoding_error}') from chunked_encoding_error
1818
+ except requests.exceptions.ReadTimeout as timeout_error:
1819
+ raise CommandError(f'Timeout during read: {timeout_error}') from timeout_error
1820
+ except requests.exceptions.RetryError as retry_error:
1821
+ raise CommandError(f'Retrying failed: {retry_error}') from retry_error
1822
+ return command_arguments
1823
+
1824
+ def __on_wake_sleep(self, wake_sleep_command: WakeSleepCommand, command_arguments: Union[str, Dict[str, Any]]) \
1825
+ -> Union[str, Dict[str, Any]]:
1826
+ if wake_sleep_command.parent is None or wake_sleep_command.parent.parent is None \
1827
+ or not isinstance(wake_sleep_command.parent.parent, GenericVehicle):
1828
+ raise CommandError('Object hierarchy is not as expected')
1829
+ if not isinstance(command_arguments, dict):
1830
+ raise CommandError('Command arguments are not a dictionary')
1831
+ vehicle: GenericVehicle = wake_sleep_command.parent.parent
1832
+ vin: Optional[str] = vehicle.vin.value
1833
+ if vin is None:
1834
+ raise CommandError('VIN in object hierarchy missing')
1835
+ if 'command' not in command_arguments:
1836
+ raise CommandError('Command argument missing')
1837
+ if command_arguments['command'] == WakeSleepCommand.Command.WAKE:
1838
+ url = f'https://mysmob.api.connect.skoda-auto.cz/api/v1/vehicle-wakeup/{vin}?applyRequestLimiter=true'
1839
+
1840
+ try:
1841
+ command_response: requests.Response = self.session.post(url, data='{}', allow_redirects=True)
1842
+ if command_response.status_code != requests.codes['accepted']:
1843
+ LOG.error('Could not execute wake command (%s: %s)', command_response.status_code, command_response.text)
1844
+ raise CommandError(f'Could not execute wake command ({command_response.status_code}: {command_response.text})')
1845
+ except requests.exceptions.ConnectionError as connection_error:
1846
+ raise CommandError(f'Connection error: {connection_error}.'
1847
+ ' If this happens frequently, please check if other applications communicate with the Skoda server.') from connection_error
1848
+ except requests.exceptions.ChunkedEncodingError as chunked_encoding_error:
1849
+ raise CommandError(f'Error: {chunked_encoding_error}') from chunked_encoding_error
1850
+ except requests.exceptions.ReadTimeout as timeout_error:
1851
+ raise CommandError(f'Timeout during read: {timeout_error}') from timeout_error
1852
+ except requests.exceptions.RetryError as retry_error:
1853
+ raise CommandError(f'Retrying failed: {retry_error}') from retry_error
1854
+ elif command_arguments['command'] == WakeSleepCommand.Command.SLEEP:
1855
+ raise CommandError('Sleep command not supported by vehicle. Vehicle will put itself to sleep')
1856
+ else:
1857
+ raise CommandError(f'Unknown command {command_arguments["command"]}')
1858
+ return command_arguments
1859
+
1860
+ def __on_spin(self, spin_command: SpinCommand, command_arguments: Union[str, Dict[str, Any]]) \
1861
+ -> Union[str, Dict[str, Any]]:
1862
+ del spin_command
1863
+ if not isinstance(command_arguments, dict):
1864
+ raise CommandError('Command arguments are not a dictionary')
1865
+ if 'command' not in command_arguments:
1866
+ raise CommandError('Command argument missing')
1867
+ command_dict = {}
1868
+ if self.active_config['spin'] is None:
1869
+ raise CommandError('S-PIN is missing, please add S-PIN to your configuration or .netrc file')
1870
+ if 'spin' in command_arguments:
1871
+ command_dict['currentSpin'] = command_arguments['spin']
1872
+ else:
1873
+ if self.active_config['spin'] is None or self.active_config['spin'] == '':
1874
+ raise CommandError('S-PIN is missing, please add S-PIN to your configuration or .netrc file')
1875
+ command_dict['currentSpin'] = self.active_config['spin']
1876
+ if command_arguments['command'] == SpinCommand.Command.VERIFY:
1877
+ url = 'https://mysmob.api.connect.skoda-auto.cz/api/v1/spin/verify'
1878
+ else:
1879
+ raise CommandError(f'Unknown command {command_arguments["command"]}')
1880
+ try:
1881
+ command_response: requests.Response = self.session.post(url, data=json.dumps(command_dict), allow_redirects=True)
1882
+ if command_response.status_code != requests.codes['ok']:
1883
+ LOG.error('Could not execute spin command (%s: %s)', command_response.status_code, command_response.text)
1884
+ raise CommandError(f'Could not execute spin command ({command_response.status_code}: {command_response.text})')
1885
+ else:
1886
+ LOG.info('Spin verify command executed successfully')
1887
+ except requests.exceptions.ConnectionError as connection_error:
1888
+ raise CommandError(f'Connection error: {connection_error}.'
1889
+ ' If this happens frequently, please check if other applications communicate with the Skoda server.') from connection_error
1890
+ except requests.exceptions.ChunkedEncodingError as chunked_encoding_error:
1891
+ raise CommandError(f'Error: {chunked_encoding_error}') from chunked_encoding_error
1892
+ except requests.exceptions.ReadTimeout as timeout_error:
1893
+ raise CommandError(f'Timeout during read: {timeout_error}') from timeout_error
1894
+ except requests.exceptions.RetryError as retry_error:
1895
+ raise CommandError(f'Retrying failed: {retry_error}') from retry_error
1896
+ return command_arguments
1897
+
1898
+ def __on_window_heating_start_stop(self, start_stop_command: WindowHeatingStartStopCommand, command_arguments: Union[str, Dict[str, Any]]) \
1899
+ -> Union[str, Dict[str, Any]]:
1900
+ if start_stop_command.parent is None or start_stop_command.parent.parent is None \
1901
+ or start_stop_command.parent.parent.parent is None or not isinstance(start_stop_command.parent.parent.parent, SkodaVehicle):
1902
+ raise CommandError('Object hierarchy is not as expected')
1903
+ if not isinstance(command_arguments, dict):
1904
+ raise CommandError('Command arguments are not a dictionary')
1905
+ vehicle: SkodaVehicle = start_stop_command.parent.parent.parent
1906
+ vin: Optional[str] = vehicle.vin.value
1907
+ if vin is None:
1908
+ raise CommandError('VIN in object hierarchy missing')
1909
+ if 'command' not in command_arguments:
1910
+ raise CommandError('Command argument missing')
1911
+ try:
1912
+ if command_arguments['command'] == WindowHeatingStartStopCommand.Command.START:
1913
+ url = f'https://mysmob.api.connect.skoda-auto.cz/api/v2/air-conditioning/{vin}/start-window-heating'
1914
+ command_response: requests.Response = self.session.post(url, allow_redirects=True)
1915
+ elif command_arguments['command'] == WindowHeatingStartStopCommand.Command.STOP:
1916
+ url = f'https://mysmob.api.connect.skoda-auto.cz/api/v2/air-conditioning/{vin}/stop-window-heating'
1917
+
1918
+ command_response: requests.Response = self.session.post(url, allow_redirects=True)
1919
+ else:
1920
+ raise CommandError(f'Unknown command {command_arguments["command"]}')
1921
+
1922
+ if command_response.status_code != requests.codes['accepted']:
1923
+ LOG.error('Could not start/stop window heating (%s: %s)', command_response.status_code, command_response.text)
1924
+ raise CommandError(f'Could not start/stop window heating ({command_response.status_code}: {command_response.text})')
1925
+ except requests.exceptions.ConnectionError as connection_error:
1926
+ raise CommandError(f'Connection error: {connection_error}.'
1927
+ ' If this happens frequently, please check if other applications communicate with the Skoda server.') from connection_error
1928
+ except requests.exceptions.ChunkedEncodingError as chunked_encoding_error:
1929
+ raise CommandError(f'Error: {chunked_encoding_error}') from chunked_encoding_error
1930
+ except requests.exceptions.ReadTimeout as timeout_error:
1931
+ raise CommandError(f'Timeout during read: {timeout_error}') from timeout_error
1932
+ except requests.exceptions.RetryError as retry_error:
1933
+ raise CommandError(f'Retrying failed: {retry_error}') from retry_error
1934
+ return command_arguments
1935
+
1936
+ def __on_charging_target_level_change(self, level_attribute: LevelAttribute, target_level: float) -> float:
1937
+ """
1938
+ Callback for the charging target level change.
1939
+
1940
+ Args:
1941
+ level_attribute (LevelAttribute): The level attribute that changed.
1942
+ target_level (float): The new target level.
1943
+ """
1944
+ if level_attribute.parent is None or level_attribute.parent.parent is None \
1945
+ or level_attribute.parent.parent.parent is None or not isinstance(level_attribute.parent.parent.parent, SkodaVehicle):
1946
+ raise SetterError('Object hierarchy is not as expected')
1947
+ vehicle: SkodaVehicle = level_attribute.parent.parent.parent
1948
+ vin: Optional[str] = vehicle.vin.value
1949
+ if vin is None:
1950
+ raise SetterError('VIN in object hierarchy missing')
1951
+ precision: float = level_attribute.precision if level_attribute.precision is not None else 10.0
1952
+ target_level = round(target_level / precision) * precision
1953
+ setting_dict = {}
1954
+ setting_dict['targetSOCInPercent'] = target_level
1955
+
1956
+ url = f'https://mysmob.api.connect.skoda-auto.cz/api/v1/charging/{vin}/set-charge-limit'
1957
+ try:
1958
+ settings_response: requests.Response = self.session.put(url, data=json.dumps(setting_dict), allow_redirects=True)
1959
+ if settings_response.status_code != requests.codes['accepted']:
1960
+ LOG.error('Could not set target level (%s)', settings_response.status_code)
1961
+ raise SetterError(f'Could not set value ({settings_response.status_code})')
1962
+ except requests.exceptions.ConnectionError as connection_error:
1963
+ raise SetterError(f'Connection error: {connection_error}.'
1964
+ ' If this happens frequently, please check if other applications communicate with the Skoda server.') from connection_error
1965
+ except requests.exceptions.ChunkedEncodingError as chunked_encoding_error:
1966
+ raise SetterError(f'Error: {chunked_encoding_error}') from chunked_encoding_error
1967
+ except requests.exceptions.ReadTimeout as timeout_error:
1968
+ raise SetterError(f'Timeout during read: {timeout_error}') from timeout_error
1969
+ except requests.exceptions.RetryError as retry_error:
1970
+ raise SetterError(f'Retrying failed: {retry_error}') from retry_error
1971
+ return target_level
1972
+
1973
+ def __on_charging_maximum_current_change(self, current_attribute: CurrentAttribute, maximum_current: float) -> float:
1974
+ """
1975
+ Callback for the charging target level change.
1976
+
1977
+ Args:
1978
+ current_attribute (CurrentAttribute): The current attribute that changed.
1979
+ maximum_current (float): The new maximum current.
1980
+ """
1981
+ if current_attribute.parent is None or current_attribute.parent.parent is None \
1982
+ or current_attribute.parent.parent.parent is None or not isinstance(current_attribute.parent.parent.parent, SkodaVehicle):
1983
+ raise SetterError('Object hierarchy is not as expected')
1984
+ vehicle: SkodaVehicle = current_attribute.parent.parent.parent
1985
+ vin: Optional[str] = vehicle.vin.value
1986
+ if vin is None:
1987
+ raise SetterError('VIN in object hierarchy missing')
1988
+ setting_dict = {}
1989
+ precision: float = current_attribute.precision if current_attribute.precision is not None else 1.0
1990
+ maximum_current = round(maximum_current / precision) * precision
1991
+ if maximum_current < 16:
1992
+ setting_dict['chargingCurrent'] = "REDUCED"
1993
+ maximum_current = 6.0
1994
+ else:
1995
+ setting_dict['chargingCurrent'] = "MAXIMUM"
1996
+ maximum_current = 16.0
1997
+
1998
+ url = f'https://mysmob.api.connect.skoda-auto.cz/api/v1/charging/{vin}/set-charging-current'
1999
+ try:
2000
+ settings_response: requests.Response = self.session.put(url, data=json.dumps(setting_dict), allow_redirects=True)
2001
+ if settings_response.status_code != requests.codes['accepted']:
2002
+ LOG.error('Could not set target charging current (%s)', settings_response.status_code)
2003
+ raise SetterError(f'Could not set value ({settings_response.status_code})')
2004
+ except requests.exceptions.ConnectionError as connection_error:
2005
+ raise SetterError(f'Connection error: {connection_error}.'
2006
+ ' If this happens frequently, please check if other applications communicate with the Skoda server.') from connection_error
2007
+ except requests.exceptions.ChunkedEncodingError as chunked_encoding_error:
2008
+ raise SetterError(f'Error: {chunked_encoding_error}') from chunked_encoding_error
2009
+ except requests.exceptions.ReadTimeout as timeout_error:
2010
+ raise SetterError(f'Timeout during read: {timeout_error}') from timeout_error
2011
+ except requests.exceptions.RetryError as retry_error:
2012
+ raise SetterError(f'Retrying failed: {retry_error}') from retry_error
2013
+ return maximum_current
2014
+
2015
+ def __on_charging_auto_unlock_change(self, boolean_attribute: BooleanAttribute, auto_unlock: bool) -> bool:
2016
+ """
2017
+ Callback for the charging target level change.
2018
+
2019
+ Args:
2020
+ boolean_attribute (BooleanAttribute): The boolean attribute that changed.
2021
+ auto_unlock (float): The new auto_unlock setting.
2022
+ """
2023
+ if boolean_attribute.parent is None or boolean_attribute.parent.parent is None \
2024
+ or boolean_attribute.parent.parent.parent is None or not isinstance(boolean_attribute.parent.parent.parent, SkodaVehicle):
2025
+ raise SetterError('Object hierarchy is not as expected')
2026
+ vehicle: SkodaVehicle = boolean_attribute.parent.parent.parent
2027
+ vin: Optional[str] = vehicle.vin.value
2028
+ if vin is None:
2029
+ raise SetterError('VIN in object hierarchy missing')
2030
+ setting_dict = {}
2031
+ if auto_unlock:
2032
+ setting_dict['autoUnlockPlug'] = "PERMANENT"
2033
+ else:
2034
+ setting_dict['autoUnlockPlug'] = "OFF"
2035
+
2036
+ url = f'https://mysmob.api.connect.skoda-auto.cz/api/v1/charging/{vin}/set-auto-unlock-plug'
2037
+ try:
2038
+ settings_response: requests.Response = self.session.put(url, data=json.dumps(setting_dict), allow_redirects=True)
2039
+ if settings_response.status_code != requests.codes['accepted']:
2040
+ LOG.error('Could not set auto unlock setting (%s)', settings_response.status_code)
2041
+ raise SetterError(f'Could not set value ({settings_response.status_code})')
2042
+ except requests.exceptions.ConnectionError as connection_error:
2043
+ raise SetterError(f'Connection error: {connection_error}.'
2044
+ ' If this happens frequently, please check if other applications communicate with the Skoda server.') from connection_error
2045
+ except requests.exceptions.ChunkedEncodingError as chunked_encoding_error:
2046
+ raise SetterError(f'Error: {chunked_encoding_error}') from chunked_encoding_error
2047
+ except requests.exceptions.ReadTimeout as timeout_error:
2048
+ raise SetterError(f'Timeout during read: {timeout_error}') from timeout_error
2049
+ except requests.exceptions.RetryError as retry_error:
2050
+ raise SetterError(f'Retrying failed: {retry_error}') from retry_error
2051
+ return auto_unlock
2052
+
2053
+ def get_name(self) -> str:
2054
+ return "Skoda Connector"