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.
@@ -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
+ ]
@@ -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