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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,242 @@
1
+ """
2
+ Module implements a VW Web session.
3
+ """
4
+ from __future__ import annotations
5
+ from typing import TYPE_CHECKING
6
+
7
+
8
+ from urllib.parse import parse_qsl, urlparse, urlsplit, urljoin
9
+
10
+ from urllib3.util.retry import Retry
11
+
12
+ import requests
13
+ from requests.adapters import HTTPAdapter
14
+ from requests.models import CaseInsensitiveDict
15
+
16
+ from carconnectivity.errors import APICompatibilityError, AuthenticationError, RetrievalError
17
+
18
+ from carconnectivity_connectors.seatcupra.auth.auth_util import CredentialsFormParser, HTMLFormParser, TermsAndConditionsFormParser
19
+ from carconnectivity_connectors.seatcupra.auth.openid_session import OpenIDSession
20
+
21
+ if TYPE_CHECKING:
22
+ from typing import Any, Dict
23
+
24
+
25
+ class VWWebSession(OpenIDSession):
26
+ """
27
+ VWWebSession handles the web authentication process for Cupra's web services.
28
+ """
29
+ def __init__(self, session_user, cache, accept_terms_on_login=False, **kwargs):
30
+ super(VWWebSession, self).__init__(**kwargs)
31
+ self.session_user = session_user
32
+ self.cache = cache
33
+ self.accept_terms_on_login: bool = accept_terms_on_login
34
+
35
+ # Set up the web session
36
+ retries = Retry(
37
+ total=self.retries,
38
+ backoff_factor=0.1,
39
+ status_forcelist=[500],
40
+ raise_on_status=False
41
+ )
42
+
43
+ self.websession: requests.Session = requests.Session()
44
+ self.websession.proxies.update(self.proxies)
45
+ self.websession.mount('https://', HTTPAdapter(max_retries=retries))
46
+ self.websession.headers = CaseInsensitiveDict({
47
+ 'user-agent': 'Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 '
48
+ 'Chrome/74.0.3729.185 Mobile Safari/537.36',
49
+ 'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,'
50
+ 'application/signed-exchange;v=b3',
51
+ 'accept-language': 'en-US,en;q=0.9',
52
+ 'accept-encoding': 'gzip, deflate',
53
+ 'x-requested-with': 'com.volkswagen.weconnect',
54
+ 'upgrade-insecure-requests': '1',
55
+ })
56
+
57
+ def do_web_auth(self, url: str) -> str:
58
+ """
59
+ Perform web authentication using the provided URL.
60
+
61
+ This method handles the web authentication process by:
62
+ 1. Retrieving the login form.
63
+ 2. Setting the email to the provided username.
64
+ 3. Retrieving the password form.
65
+ 4. Setting the credentials (email and password).
66
+ 5. Logging in and getting the redirect URL.
67
+ 6. Checking the URL for terms and conditions and handling consent if required.
68
+ 7. Following redirects until the final URL is reached.
69
+
70
+ Args:
71
+ url (str): The URL to start the authentication process.
72
+
73
+ Returns:
74
+ str: The final URL after successful authentication.
75
+
76
+ Raises:
77
+ AuthenticationError: If terms and conditions need to be accepted.
78
+ RetrievalError: If there is a temporary server error during login.
79
+ APICompatibilityError: If forwarding occurs without 'Location' in headers.
80
+ """
81
+ # Get the login form
82
+ email_form: HTMLFormParser = self._get_login_form(url)
83
+
84
+ # Set email to the provided username
85
+ email_form.data['email'] = self.session_user.username
86
+
87
+ # Get password form
88
+ password_form = self._get_password_form(
89
+ urljoin('https://identity.vwgroup.io', email_form.target),
90
+ email_form.data
91
+ )
92
+
93
+ # Set credentials
94
+ password_form.data['email'] = self.session_user.username
95
+ password_form.data['password'] = self.session_user.password
96
+
97
+ # Log in and get the redirect URL
98
+ url = self._handle_login(
99
+ f'https://identity.vwgroup.io/signin-service/v1/{self.client_id}/{password_form.target}',
100
+ password_form.data
101
+ )
102
+
103
+ if self.redirect_uri is None:
104
+ raise ValueError('Redirect URI is not set')
105
+ # Check URL for terms and conditions
106
+ while True:
107
+ if url.startswith(self.redirect_uri):
108
+ break
109
+
110
+ url = urljoin('https://identity.vwgroup.io', url)
111
+
112
+ if 'terms-and-conditions' in url:
113
+ if self.accept_terms_on_login:
114
+ url = self._handle_consent_form(url)
115
+ else:
116
+ raise AuthenticationError(f'It seems like you need to accept the terms and conditions. '
117
+ f'Try to visit the URL "{url}" or log into smartphone app.')
118
+
119
+ response = self.websession.get(url, allow_redirects=False)
120
+ if response.status_code == requests.codes['internal_server_error']:
121
+ raise RetrievalError('Temporary server error during login')
122
+
123
+ if 'Location' not in response.headers:
124
+ if 'consent' in url:
125
+ raise AuthenticationError('Could not find Location in headers, probably due to missing consent. '
126
+ 'Check that you configured the correct brand or try visiting: ' + url)
127
+ raise APICompatibilityError('Forwarding without Location in headers')
128
+
129
+ url = response.headers['Location']
130
+
131
+ return url.replace(self.redirect_uri + '#', 'https://egal?')
132
+
133
+ def _get_login_form(self, url: str) -> HTMLFormParser:
134
+ while True:
135
+ response = self.websession.get(url, allow_redirects=False)
136
+ if response.status_code == requests.codes['ok']:
137
+ break
138
+
139
+ if response.status_code in (requests.codes['found'], requests.codes['see_other']):
140
+ if 'Location' not in response.headers:
141
+ raise APICompatibilityError('Forwarding without Location in headers')
142
+
143
+ url = response.headers['Location']
144
+ continue
145
+
146
+ raise APICompatibilityError(f'Retrieving login page was not successful, '
147
+ f'status code: {response.status_code}')
148
+
149
+ # Find login form on page to obtain inputs
150
+ email_form = HTMLFormParser(form_id='emailPasswordForm')
151
+ email_form.feed(response.text)
152
+
153
+ if not email_form.target or not all(x in email_form.data for x in ['_csrf', 'relayState', 'hmac', 'email']):
154
+ raise APICompatibilityError('Could not find all required input fields on login page')
155
+
156
+ return email_form
157
+
158
+ def _get_password_form(self, url: str, data: Dict[str, Any]) -> CredentialsFormParser:
159
+ response = self.websession.post(url, data=data, allow_redirects=True)
160
+ if response.status_code != requests.codes['ok']:
161
+ raise APICompatibilityError(f'Retrieving credentials page was not successful, '
162
+ f'status code: {response.status_code}')
163
+
164
+ # Find login form on page to obtain inputs
165
+ credentials_form = CredentialsFormParser()
166
+ credentials_form.feed(response.text)
167
+
168
+ if not credentials_form.target or not all(x in credentials_form.data for x in ['relayState', 'hmac', '_csrf']):
169
+ raise APICompatibilityError('Could not find all required input fields on credentials page')
170
+
171
+ if credentials_form.data.get('error', None) is not None:
172
+ if credentials_form.data['error'] == 'validator.email.invalid':
173
+ raise AuthenticationError('Error during login, email invalid')
174
+ raise AuthenticationError(f'Error during login: {credentials_form.data["error"]}')
175
+
176
+ if 'errorCode' in credentials_form.data:
177
+ raise AuthenticationError('Error during login, is the username correct?')
178
+
179
+ if credentials_form.data.get('registerCredentialsPath', None) == 'register':
180
+ raise AuthenticationError(f'Error during login, account {self.session_user.username} does not exist')
181
+
182
+ return credentials_form
183
+
184
+ def _handle_login(self, url: str, data: Dict[str, Any]) -> str:
185
+ response: requests.Response = self.websession.post(url, data=data, allow_redirects=False)
186
+
187
+ if response.status_code == requests.codes['internal_server_error']:
188
+ raise RetrievalError('Temporary server error during login')
189
+
190
+ if response.status_code not in (requests.codes['found'], requests.codes['see_other']):
191
+ raise APICompatibilityError(f'Forwarding expected (status code 302), '
192
+ f'but got status code {response.status_code}')
193
+
194
+ if 'Location' not in response.headers:
195
+ raise APICompatibilityError('Forwarding without Location in headers')
196
+
197
+ # Parse parameters from forwarding url
198
+ params: Dict[str, str] = dict(parse_qsl(urlsplit(response.headers['Location']).query))
199
+
200
+ # Check for login error
201
+ if 'error' in params and params['error']:
202
+ error_messages: Dict[str, str] = {
203
+ 'login.errors.password_invalid': 'Password is invalid',
204
+ 'login.error.throttled': 'Login throttled, probably too many wrong logins. You have to wait '
205
+ 'a few minutes until a new login attempt is possible'
206
+ }
207
+
208
+ raise AuthenticationError(error_messages.get(params['error'], params['error']))
209
+
210
+ # Check for user ID
211
+ if 'userId' not in params or not params['userId']:
212
+ if 'updated' in params and params['updated'] == 'dataprivacy':
213
+ raise AuthenticationError('You have to login at https://www.cupraofficial.com and accept the terms and conditions')
214
+ raise APICompatibilityError('No user ID provided')
215
+
216
+ self.user_id = params['userId'] # pylint: disable=unused-private-member
217
+ return response.headers['Location']
218
+
219
+ def _handle_consent_form(self, url: str) -> str:
220
+ response = self.websession.get(url, allow_redirects=False)
221
+ if response.status_code == requests.codes['internal_server_error']:
222
+ raise RetrievalError('Temporary server error during login')
223
+
224
+ # Find form on page to obtain inputs
225
+ tc_form = TermsAndConditionsFormParser()
226
+ tc_form.feed(response.text)
227
+
228
+ # Remove query from URL
229
+ url = urlparse(response.url)._replace(query='').geturl()
230
+
231
+ response = self.websession.post(url, data=tc_form.data, allow_redirects=False)
232
+ if response.status_code == requests.codes['internal_server_error']:
233
+ raise RetrievalError('Temporary server error during login')
234
+
235
+ if response.status_code not in (requests.codes['found'], requests.codes['see_other']):
236
+ raise APICompatibilityError('Forwarding expected (status code 302), '
237
+ f'but got status code {response.status_code}')
238
+
239
+ if 'Location' not in response.headers:
240
+ raise APICompatibilityError('Forwarding without Location in headers')
241
+
242
+ return response.headers['Location']
@@ -0,0 +1,138 @@
1
+ """Module for seat/cupra vehicle capability class."""
2
+ from __future__ import annotations
3
+ from typing import TYPE_CHECKING
4
+
5
+ from enum import IntEnum
6
+
7
+ from carconnectivity.objects import GenericObject
8
+ from carconnectivity.attributes import StringAttribute, BooleanAttribute, DateAttribute
9
+
10
+ if TYPE_CHECKING:
11
+ from typing import Dict, Optional, List
12
+ from carconnectivity_connectors.seatcupra.vehicle import SeatCupraVehicle
13
+
14
+
15
+ class Capabilities(GenericObject):
16
+ """
17
+ Represents the capabilities of a Seat/Cupra vehicle.
18
+ """
19
+ def __init__(self, vehicle: SeatCupraVehicle) -> None:
20
+ super().__init__(object_id='capabilities', parent=vehicle)
21
+ self.__capabilities: Dict[str, Capability] = {}
22
+
23
+ @property
24
+ def capabilities(self) -> Dict[str, Capability]:
25
+ """
26
+ Retrieve the capabilities of the vehicle.
27
+
28
+ Returns:
29
+ Dict[str, Capability]: A dictionary of capabilities.
30
+ """
31
+ return self.__capabilities
32
+
33
+ def add_capability(self, capability_id: str, capability: Capability) -> None:
34
+ """
35
+ Adds a capability to the Capabilities of the vehicle.
36
+
37
+ Args:
38
+ capability_id (str): The unique identifier of the capability.
39
+ capability (Capability): The capability object to be added.
40
+
41
+ Returns:
42
+ None
43
+ """
44
+ self.__capabilities[capability_id] = capability
45
+
46
+ def remove_capability(self, capability_id: str) -> None:
47
+ """
48
+ Remove a capability from the Capabilities by its capability ID.
49
+
50
+ Args:
51
+ capability_id (str): The ID of the capability to be removed.
52
+
53
+ Returns:
54
+ None
55
+ """
56
+ if capability_id in self.__capabilities:
57
+ del self.__capabilities[capability_id]
58
+
59
+ def clear_capabilities(self) -> None:
60
+ """
61
+ Remove all capabilities from the Capabilities.
62
+
63
+ Returns:
64
+ None
65
+ """
66
+ self.__capabilities.clear()
67
+
68
+ def get_capability(self, capability_id: str) -> Optional[Capability]:
69
+ """
70
+ Retrieve a capability from the Capabilities by its capability ID.
71
+
72
+ Args:
73
+ capability_id (str): The unique identifier of the capability to retrieve.
74
+
75
+ Returns:
76
+ Capability: The capability object if found, otherwise None.
77
+ """
78
+ return self.__capabilities.get(capability_id)
79
+
80
+ def has_capability(self, capability_id: str) -> bool:
81
+ """
82
+ Check if the Capabilities contains a capability with the specified ID.
83
+
84
+ Args:
85
+ capability_id (str): The unique identifier of the capability to check.
86
+
87
+ Returns:
88
+ bool: True if the capability exists, otherwise False.
89
+ """
90
+ return capability_id in self.__capabilities
91
+
92
+
93
+ class Capability(GenericObject):
94
+ """
95
+ Represents a capability of a SeatCupra vehicle.
96
+ """
97
+
98
+ def __init__(self, capability_id: str, capabilities: Capabilities) -> None:
99
+ if capabilities is None:
100
+ raise ValueError('Cannot create capability without capabilities')
101
+ if id is None:
102
+ raise ValueError('Capability ID cannot be None')
103
+ super().__init__(object_id=capability_id, parent=capabilities)
104
+ self.delay_notifications = True
105
+ self.capability_id = StringAttribute("id", self, capability_id, tags={'connector_custom'})
106
+ self.expiration_date = DateAttribute("expiration_date", self, tags={'connector_custom'})
107
+ self.editable = BooleanAttribute("editable", self, tags={'connector_custom'})
108
+ self.statuses: List[Capability.Status] = []
109
+ self.parameters: Dict[str, bool] = {}
110
+ self.enabled = True
111
+ self.delay_notifications = False
112
+
113
+ class Status(IntEnum):
114
+ """
115
+ Enum for capability status.
116
+ """
117
+ UNKNOWN = 0
118
+ DEACTIVATED = 1001
119
+ INITIALLY_DISABLED = 1003
120
+ DISABLED_BY_USER = 1004
121
+ OFFLINE_MODE = 1005
122
+ WORKSHOP_MODE = 1006
123
+ MISSING_OPERATION = 1007
124
+ MISSING_SERVICE = 1008
125
+ PLAY_PROTECTION = 1009
126
+ POWER_BUDGET_REACHED = 1010
127
+ DEEP_SLEEP = 1011
128
+ LOCATION_DATA_DISABLED = 1013
129
+ LICENSE_INACTIVE = 2001
130
+ LICENSE_EXPIRED = 2002
131
+ MISSING_LICENSE = 2003
132
+ USER_NOT_VERIFIED = 3001
133
+ TERMS_AND_CONDITIONS_NOT_ACCEPTED = 3002
134
+ INSUFFICIENT_RIGHTS = 3003
135
+ CONSENT_MISSING = 3004
136
+ LIMITED_FEATURE = 3005
137
+ AUTH_APP_CERT_ERROR = 3006
138
+ STATUS_UNSUPPORTED = 4001
@@ -0,0 +1,74 @@
1
+ """
2
+ Module for charging for Seat/Cupra vehicles.
3
+ """
4
+ from __future__ import annotations
5
+ from typing import TYPE_CHECKING
6
+
7
+ from enum import Enum
8
+
9
+ from carconnectivity.charging import Charging
10
+ from carconnectivity.vehicle import ElectricVehicle
11
+
12
+ if TYPE_CHECKING:
13
+ from typing import Optional, Dict
14
+
15
+
16
+ class SeatCupraCharging(Charging): # pylint: disable=too-many-instance-attributes
17
+ """
18
+ SeatCupraCharging class for handling SeatCupra vehicle charging information.
19
+
20
+ This class extends the Charging class and includes an enumeration of various
21
+ charging states specific to SeatCupra vehicles.
22
+ """
23
+ def __init__(self, vehicle: ElectricVehicle | None = None, origin: Optional[Charging] = None) -> None:
24
+ if origin is not None:
25
+ super().__init__(origin=origin)
26
+ else:
27
+ super().__init__(vehicle=vehicle)
28
+
29
+ class SeatCupraChargingState(Enum,):
30
+ """
31
+ Enum representing the various charging states for a SeatCupra vehicle.
32
+ """
33
+ OFF = 'off'
34
+ READY_FOR_CHARGING = 'readyForCharging'
35
+ NOT_READY_FOR_CHARGING = 'notReadyForCharging'
36
+ CONSERVATION = 'conservation'
37
+ CHARGE_PURPOSE_REACHED_NOT_CONSERVATION_CHARGING = 'chargePurposeReachedAndNotConservationCharging'
38
+ CHARGE_PURPOSE_REACHED_CONSERVATION = 'chargePurposeReachedAndConservation'
39
+ CHARGING = 'charging'
40
+ ERROR = 'error'
41
+ UNSUPPORTED = 'unsupported'
42
+ DISCHARGING = 'discharging'
43
+ UNKNOWN = 'unknown charging state'
44
+
45
+ class SeatCupraChargeMode(Enum,):
46
+ """
47
+ Enum class representing different SeatCupra charge modes.
48
+ """
49
+ MANUAL = 'manual'
50
+ INVALID = 'invalid'
51
+ OFF = 'off'
52
+ TIMER = 'timer'
53
+ ONLY_OWN_CURRENT = 'onlyOwnCurrent'
54
+ PREFERRED_CHARGING_TIMES = 'preferredChargingTimes'
55
+ TIMER_CHARGING_WITH_CLIMATISATION = 'timerChargingWithClimatisation'
56
+ HOME_STORAGE_CHARGING = 'homeStorageCharging'
57
+ IMMEDIATE_DISCHARGING = 'immediateDischarging'
58
+ UNKNOWN = 'unknown charge mode'
59
+
60
+
61
+ # Mapping of Cupra charging states to generic charging states
62
+ mapping_seatcupra_charging_state: Dict[SeatCupraCharging.SeatCupraChargingState, Charging.ChargingState] = {
63
+ SeatCupraCharging.SeatCupraChargingState.OFF: Charging.ChargingState.OFF,
64
+ SeatCupraCharging.SeatCupraChargingState.NOT_READY_FOR_CHARGING: Charging.ChargingState.OFF,
65
+ SeatCupraCharging.SeatCupraChargingState.READY_FOR_CHARGING: Charging.ChargingState.READY_FOR_CHARGING,
66
+ SeatCupraCharging.SeatCupraChargingState.CONSERVATION: Charging.ChargingState.CONSERVATION,
67
+ SeatCupraCharging.SeatCupraChargingState.CHARGE_PURPOSE_REACHED_NOT_CONSERVATION_CHARGING: Charging.ChargingState.READY_FOR_CHARGING,
68
+ SeatCupraCharging.SeatCupraChargingState.CHARGE_PURPOSE_REACHED_CONSERVATION: Charging.ChargingState.CONSERVATION,
69
+ SeatCupraCharging.SeatCupraChargingState.CHARGING: Charging.ChargingState.CHARGING,
70
+ SeatCupraCharging.SeatCupraChargingState.ERROR: Charging.ChargingState.ERROR,
71
+ SeatCupraCharging.SeatCupraChargingState.UNSUPPORTED: Charging.ChargingState.UNSUPPORTED,
72
+ SeatCupraCharging.SeatCupraChargingState.DISCHARGING: Charging.ChargingState.DISCHARGING,
73
+ SeatCupraCharging.SeatCupraChargingState.UNKNOWN: Charging.ChargingState.UNKNOWN
74
+ }
@@ -0,0 +1,39 @@
1
+ """
2
+ Module for charging for Seat/Cupra vehicles.
3
+ """
4
+ from __future__ import annotations
5
+ from typing import TYPE_CHECKING
6
+
7
+ from carconnectivity.climatization import Climatization
8
+ from carconnectivity.objects import GenericObject
9
+ from carconnectivity.vehicle import GenericVehicle
10
+
11
+ if TYPE_CHECKING:
12
+ from typing import Optional
13
+
14
+
15
+ class SeatCupraClimatization(Climatization): # pylint: disable=too-many-instance-attributes
16
+ """
17
+ SeatCupraClimatization class for handling Seat/Cupra vehicle climatization information.
18
+
19
+ This class extends the Climatization class and includes an enumeration of various
20
+ climatization states specific to Volkswagen vehicles.
21
+ """
22
+ def __init__(self, vehicle: GenericVehicle | None = None, origin: Optional[Climatization] = None) -> None:
23
+ if origin is not None:
24
+ super().__init__(vehicle=vehicle, origin=origin)
25
+ if not isinstance(self.settings, SeatCupraClimatization.Settings):
26
+ self.settings: Climatization.Settings = SeatCupraClimatization.Settings(parent=self, origin=origin.settings)
27
+ else:
28
+ super().__init__(vehicle=vehicle)
29
+ self.settings: Climatization.Settings = SeatCupraClimatization.Settings(parent=self, origin=self.settings)
30
+
31
+ class Settings(Climatization.Settings):
32
+ """
33
+ This class represents the settings for a skoda car climatiation.
34
+ """
35
+ def __init__(self, parent: Optional[GenericObject] = None, origin: Optional[Climatization.Settings] = None) -> None:
36
+ if origin is not None:
37
+ super().__init__(parent=parent, origin=origin)
38
+ else:
39
+ super().__init__(parent=parent)
@@ -0,0 +1,74 @@
1
+ """This module defines the classes that represent attributes in the CarConnectivity system."""
2
+ from __future__ import annotations
3
+ from typing import TYPE_CHECKING, Dict, Union
4
+
5
+ from enum import Enum
6
+ import argparse
7
+ import logging
8
+
9
+ from carconnectivity.commands import GenericCommand
10
+ from carconnectivity.objects import GenericObject
11
+ from carconnectivity.errors import SetterError
12
+ from carconnectivity.util import ThrowingArgumentParser
13
+
14
+ if TYPE_CHECKING:
15
+ from carconnectivity.objects import Optional
16
+
17
+ LOG: logging.Logger = logging.getLogger("carconnectivity.connectors.seatcupra")
18
+
19
+
20
+ class SpinCommand(GenericCommand):
21
+ """
22
+ SpinCommand is a command class for verifying the spin
23
+
24
+ """
25
+ def __init__(self, name: str = 'spin', parent: Optional[GenericObject] = None) -> None:
26
+ super().__init__(name=name, parent=parent)
27
+
28
+ @property
29
+ def value(self) -> Optional[Union[str, Dict]]:
30
+ return super().value
31
+
32
+ @value.setter
33
+ def value(self, new_value: Optional[Union[str, Dict]]) -> None:
34
+ # Execute early hooks before parsing the value
35
+ new_value = self._execute_on_set_hook(new_value, early_hook=True)
36
+ if isinstance(new_value, str):
37
+ parser = ThrowingArgumentParser(prog='', add_help=False, exit_on_error=False)
38
+ parser.add_argument('command', help='Command to execute', type=SpinCommand.Command,
39
+ choices=list(SpinCommand.Command))
40
+ parser.add_argument('--spin', dest='spin', help='Spin to be used instead of spin from config or .netrc', type=str, required=False,
41
+ default=None)
42
+ try:
43
+ args = parser.parse_args(new_value.split(sep=' '))
44
+ except argparse.ArgumentError as e:
45
+ raise SetterError(f'Invalid format for SpinCommand: {e.message} {parser.format_usage()}') from e
46
+
47
+ newvalue_dict = {}
48
+ newvalue_dict['command'] = args.command
49
+ if args.spin is not None:
50
+ newvalue_dict['spin'] = args.spin
51
+ new_value = newvalue_dict
52
+ elif isinstance(new_value, dict):
53
+ if 'command' in new_value and isinstance(new_value['command'], str):
54
+ if new_value['command'] in SpinCommand.Command:
55
+ new_value['command'] = SpinCommand.Command(new_value['command'])
56
+ else:
57
+ raise ValueError('Invalid value for SpinCommand. '
58
+ f'Command must be one of {SpinCommand.Command}')
59
+ if self._is_changeable:
60
+ # Execute late hooks before setting the value
61
+ new_value = self._execute_on_set_hook(new_value, early_hook=False)
62
+ self._set_value(new_value)
63
+ else:
64
+ raise TypeError('You cannot set this attribute. Attribute is not mutable.')
65
+
66
+ class Command(Enum):
67
+ """
68
+ Enum class representing different commands for SPIN.
69
+
70
+ """
71
+ VERIFY = 'verify'
72
+
73
+ def __str__(self) -> str:
74
+ return self.value