libdyson-rest 0.3.0__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.
- libdyson_rest/__init__.py +70 -0
- libdyson_rest/client.py +497 -0
- libdyson_rest/exceptions.py +33 -0
- libdyson_rest/models/__init__.py +122 -0
- libdyson_rest/models/auth.py +94 -0
- libdyson_rest/models/device.py +172 -0
- libdyson_rest/models/iot.py +64 -0
- libdyson_rest/utils/__init__.py +78 -0
- libdyson_rest-0.3.0.dist-info/METADATA +604 -0
- libdyson_rest-0.3.0.dist-info/RECORD +13 -0
- libdyson_rest-0.3.0.dist-info/WHEEL +5 -0
- libdyson_rest-0.3.0.dist-info/licenses/LICENSE +21 -0
- libdyson_rest-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""
|
|
2
|
+
libdyson-rest: Python library for interacting with the Dyson REST API.
|
|
3
|
+
|
|
4
|
+
This library provides a clean interface for communicating with Dyson devices
|
|
5
|
+
through their official REST API endpoints as documented in the OpenAPI specification.
|
|
6
|
+
|
|
7
|
+
Key Features:
|
|
8
|
+
- Full OpenAPI specification compliance
|
|
9
|
+
- Two-step authentication with OTP codes
|
|
10
|
+
- Complete device management and IoT credentials
|
|
11
|
+
- Type-safe data models
|
|
12
|
+
- Comprehensive error handling
|
|
13
|
+
- Context manager support
|
|
14
|
+
|
|
15
|
+
Basic Usage:
|
|
16
|
+
from libdyson_rest import DysonClient
|
|
17
|
+
|
|
18
|
+
client = DysonClient(email="your@email.com", password="password")
|
|
19
|
+
|
|
20
|
+
# Two-step authentication
|
|
21
|
+
challenge = client.begin_login()
|
|
22
|
+
# Check email for OTP code
|
|
23
|
+
login_info = client.complete_login(str(challenge.challenge_id), "123456")
|
|
24
|
+
|
|
25
|
+
# Get devices
|
|
26
|
+
devices = client.get_devices()
|
|
27
|
+
for device in devices:
|
|
28
|
+
print(f"Device: {device.name} ({device.serial_number})")
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
__version__ = "0.2.0"
|
|
32
|
+
__author__ = "libdyson-rest contributors"
|
|
33
|
+
__email__ = "contributors@libdyson-rest.dev"
|
|
34
|
+
|
|
35
|
+
from .client import DysonClient
|
|
36
|
+
from .exceptions import (
|
|
37
|
+
DysonAPIError,
|
|
38
|
+
DysonAuthError,
|
|
39
|
+
DysonConnectionError,
|
|
40
|
+
DysonDeviceError,
|
|
41
|
+
DysonValidationError,
|
|
42
|
+
)
|
|
43
|
+
from .models import (
|
|
44
|
+
ConnectionCategory,
|
|
45
|
+
Device,
|
|
46
|
+
DeviceCategory,
|
|
47
|
+
IoTData,
|
|
48
|
+
LoginChallenge,
|
|
49
|
+
LoginInformation,
|
|
50
|
+
UserStatus,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
__all__ = [
|
|
54
|
+
# Core client
|
|
55
|
+
"DysonClient",
|
|
56
|
+
# Exceptions
|
|
57
|
+
"DysonAPIError",
|
|
58
|
+
"DysonAuthError",
|
|
59
|
+
"DysonConnectionError",
|
|
60
|
+
"DysonDeviceError",
|
|
61
|
+
"DysonValidationError",
|
|
62
|
+
# Key models
|
|
63
|
+
"Device",
|
|
64
|
+
"DeviceCategory",
|
|
65
|
+
"ConnectionCategory",
|
|
66
|
+
"IoTData",
|
|
67
|
+
"LoginChallenge",
|
|
68
|
+
"LoginInformation",
|
|
69
|
+
"UserStatus",
|
|
70
|
+
]
|
libdyson_rest/client.py
ADDED
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Main client for interacting with the Dyson REST API.
|
|
3
|
+
|
|
4
|
+
This client implements the official Dyson App API as documented in the OpenAPI specification.
|
|
5
|
+
Authentication uses a two-step process with OTP codes.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import base64
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
from typing import Any, List, Optional
|
|
12
|
+
from urllib.parse import urljoin
|
|
13
|
+
|
|
14
|
+
import requests
|
|
15
|
+
from cryptography.hazmat.backends import default_backend
|
|
16
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
17
|
+
|
|
18
|
+
from .exceptions import DysonAPIError, DysonAuthError, DysonConnectionError
|
|
19
|
+
from .models import Device, IoTData, LoginChallenge, LoginInformation, UserStatus
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
# Dyson API hostname - this is an allowed static value
|
|
24
|
+
DYSON_API_HOST = "https://appapi.cp.dyson.com"
|
|
25
|
+
|
|
26
|
+
# Default headers required by the API
|
|
27
|
+
DEFAULT_USER_AGENT = "android client"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class DysonClient:
|
|
31
|
+
"""
|
|
32
|
+
Client for interacting with the Dyson REST API.
|
|
33
|
+
|
|
34
|
+
This client handles the complete authentication flow, device discovery, and IoT credential
|
|
35
|
+
retrieval for Dyson devices through their REST API according to the OpenAPI specification.
|
|
36
|
+
|
|
37
|
+
Authentication Flow:
|
|
38
|
+
1. provision() - Required initial call
|
|
39
|
+
2. get_user_status() - Check user account status
|
|
40
|
+
3. begin_login() - Start authentication process
|
|
41
|
+
4. complete_login() - Complete authentication with OTP code
|
|
42
|
+
5. API calls with Bearer token
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
email: Optional[str] = None,
|
|
48
|
+
password: Optional[str] = None,
|
|
49
|
+
auth_token: Optional[str] = None,
|
|
50
|
+
country: str = "US",
|
|
51
|
+
culture: str = "en-US",
|
|
52
|
+
timeout: int = 30,
|
|
53
|
+
user_agent: str = DEFAULT_USER_AGENT,
|
|
54
|
+
) -> None:
|
|
55
|
+
"""
|
|
56
|
+
Initialize the Dyson client.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
email: User email for authentication
|
|
60
|
+
password: User password for authentication
|
|
61
|
+
auth_token: Existing bearer token (skips authentication flow if provided)
|
|
62
|
+
country: Country code for API endpoint (2-letter ISO 3166-1 alpha-2)
|
|
63
|
+
culture: Locale/language code (IETF language code, e.g., 'en-US')
|
|
64
|
+
timeout: Request timeout in seconds
|
|
65
|
+
user_agent: User agent string for requests
|
|
66
|
+
|
|
67
|
+
Raises:
|
|
68
|
+
ValueError: If country or culture format is invalid
|
|
69
|
+
"""
|
|
70
|
+
# Validate country format
|
|
71
|
+
if not (country and len(country) == 2 and country.isupper()):
|
|
72
|
+
raise ValueError("Country must be a 2-character uppercase ISO 3166-1 alpha-2 code")
|
|
73
|
+
|
|
74
|
+
# Validate culture format
|
|
75
|
+
if not (
|
|
76
|
+
culture and len(culture) == 5 and culture[2] == "-" and culture[:2].islower() and culture[3:].isupper()
|
|
77
|
+
):
|
|
78
|
+
raise ValueError("Culture must be in format 'xx-YY' (e.g., 'en-US')")
|
|
79
|
+
|
|
80
|
+
self.email = email
|
|
81
|
+
self.password = password
|
|
82
|
+
self.country = country
|
|
83
|
+
self.culture = culture
|
|
84
|
+
self.timeout = timeout
|
|
85
|
+
self.user_agent = user_agent
|
|
86
|
+
|
|
87
|
+
self.session = requests.Session()
|
|
88
|
+
self.session.headers.update({"User-Agent": user_agent})
|
|
89
|
+
|
|
90
|
+
# Authentication state
|
|
91
|
+
self.auth_token: Optional[str] = auth_token
|
|
92
|
+
self.account_id: Optional[str] = None
|
|
93
|
+
self._provisioned = False
|
|
94
|
+
|
|
95
|
+
# If auth_token provided, set up session headers immediately
|
|
96
|
+
if auth_token:
|
|
97
|
+
self.session.headers.update({"Authorization": f"Bearer {auth_token}"})
|
|
98
|
+
|
|
99
|
+
def provision(self) -> str:
|
|
100
|
+
"""
|
|
101
|
+
Make the required provisioning call to the API.
|
|
102
|
+
|
|
103
|
+
This call must be made before any other API calls. The server will ignore
|
|
104
|
+
all other requests from clients which haven't made this request recently.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
Version string from the API
|
|
108
|
+
|
|
109
|
+
Raises:
|
|
110
|
+
DysonConnectionError: If connection fails
|
|
111
|
+
DysonAPIError: If API request fails
|
|
112
|
+
"""
|
|
113
|
+
url = urljoin(DYSON_API_HOST, "/v1/provisioningservice/application/Android/version")
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
response = self.session.get(url, timeout=self.timeout)
|
|
117
|
+
response.raise_for_status()
|
|
118
|
+
except requests.RequestException as e:
|
|
119
|
+
raise DysonConnectionError(f"Failed to provision API access: {e}") from e
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
version_data = response.json()
|
|
123
|
+
self._provisioned = True
|
|
124
|
+
version = str(version_data) if version_data is not None else ""
|
|
125
|
+
logger.info(f"API provisioned successfully, version: {version}")
|
|
126
|
+
return version
|
|
127
|
+
except json.JSONDecodeError as e:
|
|
128
|
+
raise DysonAPIError(f"Invalid JSON response from provision: {e}") from e
|
|
129
|
+
|
|
130
|
+
def get_user_status(self, email: Optional[str] = None) -> UserStatus:
|
|
131
|
+
"""
|
|
132
|
+
Get the status of a user account.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
email: Email address to check. If None, uses client's email.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
UserStatus object with account status and authentication method
|
|
139
|
+
|
|
140
|
+
Raises:
|
|
141
|
+
DysonConnectionError: If connection fails
|
|
142
|
+
DysonAPIError: If API request fails
|
|
143
|
+
"""
|
|
144
|
+
if not self._provisioned:
|
|
145
|
+
self.provision()
|
|
146
|
+
|
|
147
|
+
target_email = email or self.email
|
|
148
|
+
if not target_email:
|
|
149
|
+
raise DysonAPIError("Email address is required")
|
|
150
|
+
|
|
151
|
+
url = urljoin(DYSON_API_HOST, "/v3/userregistration/email/userstatus")
|
|
152
|
+
params = {"country": self.country}
|
|
153
|
+
payload = {"email": target_email}
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
response = self.session.post(url, params=params, json=payload, timeout=self.timeout)
|
|
157
|
+
response.raise_for_status()
|
|
158
|
+
except requests.RequestException as e:
|
|
159
|
+
raise DysonConnectionError(f"Failed to get user status: {e}") from e
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
data = response.json()
|
|
163
|
+
return UserStatus.from_dict(data)
|
|
164
|
+
except (json.JSONDecodeError, KeyError, ValueError) as e:
|
|
165
|
+
raise DysonAPIError(f"Invalid user status response: {e}") from e
|
|
166
|
+
|
|
167
|
+
def begin_login(self, email: Optional[str] = None) -> LoginChallenge:
|
|
168
|
+
"""
|
|
169
|
+
Begin the login process by requesting a challenge ID.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
email: Email address for login. If None, uses client's email.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
LoginChallenge object with challenge ID for completing login
|
|
176
|
+
|
|
177
|
+
Raises:
|
|
178
|
+
DysonConnectionError: If connection fails
|
|
179
|
+
DysonAPIError: If API request fails
|
|
180
|
+
"""
|
|
181
|
+
if not self._provisioned:
|
|
182
|
+
self.provision()
|
|
183
|
+
|
|
184
|
+
target_email = email or self.email
|
|
185
|
+
if not target_email:
|
|
186
|
+
raise DysonAPIError("Email address is required")
|
|
187
|
+
|
|
188
|
+
url = urljoin(DYSON_API_HOST, "/v3/userregistration/email/auth")
|
|
189
|
+
params = {"country": self.country, "culture": self.culture}
|
|
190
|
+
payload = {"email": target_email}
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
response = self.session.post(url, params=params, json=payload, timeout=self.timeout)
|
|
194
|
+
response.raise_for_status()
|
|
195
|
+
except requests.RequestException as e:
|
|
196
|
+
raise DysonConnectionError(f"Failed to begin login: {e}") from e
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
data = response.json()
|
|
200
|
+
return LoginChallenge.from_dict(data)
|
|
201
|
+
except (json.JSONDecodeError, KeyError, ValueError) as e:
|
|
202
|
+
raise DysonAPIError(f"Invalid login challenge response: {e}") from e
|
|
203
|
+
|
|
204
|
+
def complete_login(
|
|
205
|
+
self, challenge_id: str, otp_code: str, email: Optional[str] = None, password: Optional[str] = None
|
|
206
|
+
) -> LoginInformation:
|
|
207
|
+
"""
|
|
208
|
+
Complete the login process with the challenge response.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
challenge_id: Challenge ID from begin_login()
|
|
212
|
+
otp_code: One-time password code (usually from email or SMS)
|
|
213
|
+
email: Email address for login. If None, uses client's email.
|
|
214
|
+
password: Password for login. If None, uses client's password.
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
LoginInformation object with account ID and bearer token
|
|
218
|
+
|
|
219
|
+
Raises:
|
|
220
|
+
DysonAuthError: If authentication fails
|
|
221
|
+
DysonConnectionError: If connection fails
|
|
222
|
+
DysonAPIError: If API request fails
|
|
223
|
+
"""
|
|
224
|
+
if not self._provisioned:
|
|
225
|
+
self.provision()
|
|
226
|
+
|
|
227
|
+
target_email = email or self.email
|
|
228
|
+
target_password = password or self.password
|
|
229
|
+
|
|
230
|
+
if not target_email or not target_password:
|
|
231
|
+
raise DysonAuthError("Email and password are required for authentication")
|
|
232
|
+
|
|
233
|
+
url = urljoin(DYSON_API_HOST, "/v3/userregistration/email/verify")
|
|
234
|
+
params = {"country": self.country, "culture": self.culture}
|
|
235
|
+
payload = {
|
|
236
|
+
"challengeId": challenge_id,
|
|
237
|
+
"email": target_email,
|
|
238
|
+
"otpCode": otp_code,
|
|
239
|
+
"password": target_password,
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
try:
|
|
243
|
+
response = self.session.post(url, params=params, json=payload, timeout=self.timeout)
|
|
244
|
+
response.raise_for_status()
|
|
245
|
+
except requests.RequestException as e:
|
|
246
|
+
if hasattr(e, "response") and e.response is not None and e.response.status_code == 401:
|
|
247
|
+
raise DysonAuthError("Invalid credentials or OTP code") from e
|
|
248
|
+
raise DysonConnectionError(f"Failed to complete login: {e}") from e
|
|
249
|
+
|
|
250
|
+
try:
|
|
251
|
+
data = response.json()
|
|
252
|
+
login_info = LoginInformation.from_dict(data)
|
|
253
|
+
|
|
254
|
+
# Store authentication details
|
|
255
|
+
self.auth_token = login_info.token
|
|
256
|
+
self.account_id = str(login_info.account)
|
|
257
|
+
|
|
258
|
+
# Set authorization header for future requests
|
|
259
|
+
self.session.headers.update({"Authorization": f"Bearer {self.auth_token}"})
|
|
260
|
+
|
|
261
|
+
logger.info(f"Authentication successful for account: {self.account_id}")
|
|
262
|
+
return login_info
|
|
263
|
+
|
|
264
|
+
except (json.JSONDecodeError, KeyError, ValueError) as e:
|
|
265
|
+
raise DysonAPIError(f"Invalid login response: {e}") from e
|
|
266
|
+
|
|
267
|
+
def authenticate(self, otp_code: Optional[str] = None) -> bool:
|
|
268
|
+
"""
|
|
269
|
+
Convenience method for complete authentication flow.
|
|
270
|
+
|
|
271
|
+
This method handles the full authentication process:
|
|
272
|
+
1. Provision API access
|
|
273
|
+
2. Check user status
|
|
274
|
+
3. Begin login process
|
|
275
|
+
4. Complete login with OTP (if provided)
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
otp_code: One-time password code. If None, only completes up to begin_login()
|
|
279
|
+
|
|
280
|
+
Returns:
|
|
281
|
+
True if authentication successful (or if OTP required and not provided)
|
|
282
|
+
|
|
283
|
+
Raises:
|
|
284
|
+
DysonAuthError: If authentication fails
|
|
285
|
+
DysonConnectionError: If connection fails
|
|
286
|
+
DysonAPIError: If API request fails
|
|
287
|
+
"""
|
|
288
|
+
if not self.email or not self.password:
|
|
289
|
+
raise DysonAuthError("Email and password are required for authentication")
|
|
290
|
+
|
|
291
|
+
# Provision API access
|
|
292
|
+
self.provision()
|
|
293
|
+
|
|
294
|
+
# Check user status
|
|
295
|
+
user_status = self.get_user_status()
|
|
296
|
+
logger.info(f"User status: {user_status.account_status.value}")
|
|
297
|
+
|
|
298
|
+
# Begin login process
|
|
299
|
+
challenge = self.begin_login()
|
|
300
|
+
logger.info(f"Login challenge received: {challenge.challenge_id}")
|
|
301
|
+
|
|
302
|
+
# If OTP code provided, complete the login
|
|
303
|
+
if otp_code:
|
|
304
|
+
self.complete_login(str(challenge.challenge_id), otp_code)
|
|
305
|
+
return True
|
|
306
|
+
|
|
307
|
+
# OTP code required - user needs to provide it via complete_login()
|
|
308
|
+
logger.info("OTP code required to complete authentication")
|
|
309
|
+
return True
|
|
310
|
+
|
|
311
|
+
def get_devices(self) -> List[Device]:
|
|
312
|
+
"""
|
|
313
|
+
Get list of devices associated with the authenticated account.
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
List of Device objects
|
|
317
|
+
|
|
318
|
+
Raises:
|
|
319
|
+
DysonAuthError: If not authenticated
|
|
320
|
+
DysonConnectionError: If connection fails
|
|
321
|
+
DysonAPIError: If API request fails
|
|
322
|
+
"""
|
|
323
|
+
if not self.auth_token:
|
|
324
|
+
raise DysonAuthError("Must authenticate before getting devices")
|
|
325
|
+
|
|
326
|
+
url = urljoin(DYSON_API_HOST, "/v3/manifest")
|
|
327
|
+
|
|
328
|
+
try:
|
|
329
|
+
response = self.session.get(url, timeout=self.timeout)
|
|
330
|
+
response.raise_for_status()
|
|
331
|
+
except requests.RequestException as e:
|
|
332
|
+
if hasattr(e, "response") and e.response is not None and e.response.status_code == 401:
|
|
333
|
+
raise DysonAuthError("Authentication token expired or invalid") from e
|
|
334
|
+
raise DysonConnectionError(f"Failed to get devices: {e}") from e
|
|
335
|
+
|
|
336
|
+
try:
|
|
337
|
+
devices_data = response.json()
|
|
338
|
+
if not isinstance(devices_data, list):
|
|
339
|
+
raise DysonAPIError("Expected list of devices in response")
|
|
340
|
+
|
|
341
|
+
return [Device.from_dict(device_data) for device_data in devices_data]
|
|
342
|
+
except (json.JSONDecodeError, KeyError, ValueError) as e:
|
|
343
|
+
raise DysonAPIError(f"Invalid devices response: {e}") from e
|
|
344
|
+
|
|
345
|
+
def get_iot_credentials(self, serial_number: str) -> IoTData:
|
|
346
|
+
"""
|
|
347
|
+
Get AWS IoT connection credentials for a specific device.
|
|
348
|
+
|
|
349
|
+
Args:
|
|
350
|
+
serial_number: Device serial number
|
|
351
|
+
|
|
352
|
+
Returns:
|
|
353
|
+
IoTData object with endpoint and credentials
|
|
354
|
+
|
|
355
|
+
Raises:
|
|
356
|
+
DysonAuthError: If not authenticated
|
|
357
|
+
DysonConnectionError: If connection fails
|
|
358
|
+
DysonAPIError: If API request fails
|
|
359
|
+
"""
|
|
360
|
+
if not self.auth_token:
|
|
361
|
+
raise DysonAuthError("Must authenticate before getting IoT credentials")
|
|
362
|
+
|
|
363
|
+
url = urljoin(DYSON_API_HOST, "/v2/authorize/iot-credentials")
|
|
364
|
+
payload = {"Serial": serial_number}
|
|
365
|
+
|
|
366
|
+
try:
|
|
367
|
+
response = self.session.post(url, json=payload, timeout=self.timeout)
|
|
368
|
+
response.raise_for_status()
|
|
369
|
+
except requests.RequestException as e:
|
|
370
|
+
if hasattr(e, "response") and e.response is not None and e.response.status_code == 401:
|
|
371
|
+
raise DysonAuthError("Authentication token expired or invalid") from e
|
|
372
|
+
raise DysonConnectionError(f"Failed to get IoT credentials: {e}") from e
|
|
373
|
+
|
|
374
|
+
try:
|
|
375
|
+
data = response.json()
|
|
376
|
+
return IoTData.from_dict(data)
|
|
377
|
+
except (json.JSONDecodeError, KeyError, ValueError) as e:
|
|
378
|
+
raise DysonAPIError(f"Invalid IoT credentials response: {e}") from e
|
|
379
|
+
|
|
380
|
+
def decrypt_local_credentials(self, encrypted_password: str, serial_number: str) -> str:
|
|
381
|
+
"""
|
|
382
|
+
Decrypt the local MQTT broker credentials for direct device connection.
|
|
383
|
+
|
|
384
|
+
This method decrypts the MQTT password needed to connect to the device's
|
|
385
|
+
local MQTT broker when on the same network.
|
|
386
|
+
|
|
387
|
+
Args:
|
|
388
|
+
encrypted_password: Base64 encoded encrypted password from
|
|
389
|
+
device.connected_configuration.mqtt.local_broker_credentials
|
|
390
|
+
serial_number: Device serial number used as decryption key
|
|
391
|
+
|
|
392
|
+
Returns:
|
|
393
|
+
Decrypted MQTT password for local broker connection
|
|
394
|
+
|
|
395
|
+
Raises:
|
|
396
|
+
DysonAPIError: If decryption fails
|
|
397
|
+
"""
|
|
398
|
+
try:
|
|
399
|
+
# Fixed AES key used by Dyson (from Go implementation)
|
|
400
|
+
aes_key = bytes(
|
|
401
|
+
[
|
|
402
|
+
1,
|
|
403
|
+
2,
|
|
404
|
+
3,
|
|
405
|
+
4,
|
|
406
|
+
5,
|
|
407
|
+
6,
|
|
408
|
+
7,
|
|
409
|
+
8,
|
|
410
|
+
9,
|
|
411
|
+
10,
|
|
412
|
+
11,
|
|
413
|
+
12,
|
|
414
|
+
13,
|
|
415
|
+
14,
|
|
416
|
+
15,
|
|
417
|
+
16,
|
|
418
|
+
17,
|
|
419
|
+
18,
|
|
420
|
+
19,
|
|
421
|
+
20,
|
|
422
|
+
21,
|
|
423
|
+
22,
|
|
424
|
+
23,
|
|
425
|
+
24,
|
|
426
|
+
25,
|
|
427
|
+
26,
|
|
428
|
+
27,
|
|
429
|
+
28,
|
|
430
|
+
29,
|
|
431
|
+
30,
|
|
432
|
+
31,
|
|
433
|
+
32,
|
|
434
|
+
]
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
# Zero-filled 16-byte IV
|
|
438
|
+
iv = bytes(16)
|
|
439
|
+
|
|
440
|
+
# Decode the base64 encrypted password
|
|
441
|
+
encrypted_bytes = base64.b64decode(encrypted_password)
|
|
442
|
+
|
|
443
|
+
# Create AES-CBC cipher
|
|
444
|
+
cipher = Cipher(algorithms.AES(aes_key), modes.CBC(iv), backend=default_backend())
|
|
445
|
+
decryptor = cipher.decryptor()
|
|
446
|
+
|
|
447
|
+
# Decrypt the data
|
|
448
|
+
decrypted_bytes = decryptor.update(encrypted_bytes) + decryptor.finalize()
|
|
449
|
+
|
|
450
|
+
# Remove padding (trim backspace characters)
|
|
451
|
+
decrypted_text = decrypted_bytes.decode("utf-8").rstrip("\b").rstrip("\x00")
|
|
452
|
+
|
|
453
|
+
# Parse JSON to extract password
|
|
454
|
+
password_data = json.loads(decrypted_text)
|
|
455
|
+
return str(password_data["apPasswordHash"])
|
|
456
|
+
|
|
457
|
+
except Exception as e:
|
|
458
|
+
raise DysonAPIError(f"Failed to decrypt local credentials: {e}") from e
|
|
459
|
+
|
|
460
|
+
def get_auth_token(self) -> Optional[str]:
|
|
461
|
+
"""
|
|
462
|
+
Get the current authentication token.
|
|
463
|
+
|
|
464
|
+
Returns:
|
|
465
|
+
The current bearer token if authenticated, None otherwise
|
|
466
|
+
"""
|
|
467
|
+
return self.auth_token
|
|
468
|
+
|
|
469
|
+
def set_auth_token(self, token: str) -> None:
|
|
470
|
+
"""
|
|
471
|
+
Set the authentication token directly.
|
|
472
|
+
|
|
473
|
+
This allows reusing an existing token without going through the full
|
|
474
|
+
authentication flow. The token should be obtained from a previous
|
|
475
|
+
authentication session.
|
|
476
|
+
|
|
477
|
+
Args:
|
|
478
|
+
token: Bearer token from previous authentication
|
|
479
|
+
"""
|
|
480
|
+
self.auth_token = token
|
|
481
|
+
self.session.headers.update({"Authorization": f"Bearer {token}"})
|
|
482
|
+
logger.info("Authentication token set directly")
|
|
483
|
+
|
|
484
|
+
def close(self) -> None:
|
|
485
|
+
"""Close the session and clear authentication state."""
|
|
486
|
+
self.session.close()
|
|
487
|
+
self.auth_token = None
|
|
488
|
+
self.account_id = None
|
|
489
|
+
self._provisioned = False
|
|
490
|
+
|
|
491
|
+
def __enter__(self) -> "DysonClient":
|
|
492
|
+
"""Context manager entry."""
|
|
493
|
+
return self
|
|
494
|
+
|
|
495
|
+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
496
|
+
"""Context manager exit."""
|
|
497
|
+
self.close()
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Custom exceptions for libdyson-rest.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class DysonAPIError(Exception):
|
|
7
|
+
"""Base exception for all Dyson API related errors."""
|
|
8
|
+
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DysonConnectionError(DysonAPIError):
|
|
13
|
+
"""Raised when connection to Dyson API fails."""
|
|
14
|
+
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DysonAuthError(DysonAPIError):
|
|
19
|
+
"""Raised when authentication with Dyson API fails."""
|
|
20
|
+
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DysonDeviceError(DysonAPIError):
|
|
25
|
+
"""Raised when device operation fails."""
|
|
26
|
+
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class DysonValidationError(DysonAPIError):
|
|
31
|
+
"""Raised when input validation fails."""
|
|
32
|
+
|
|
33
|
+
pass
|