qubopy 0.1.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.
qubopy/__init__.py ADDED
@@ -0,0 +1,65 @@
1
+ """Qubo Python Client Library & BLE Enabler."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ from qubopy.auth import QuboAuth
6
+ from qubopy.client import QuboClient
7
+ from qubopy.devices import (
8
+ QuboAirPurifier,
9
+ QuboCamera,
10
+ QuboDevice,
11
+ QuboLight,
12
+ QuboLock,
13
+ QuboPlug,
14
+ QuboSensor,
15
+ create_device,
16
+ )
17
+ from qubopy.exceptions import (
18
+ QuboAuthError,
19
+ QuboCommandError,
20
+ QuboConnectionError,
21
+ QuboDeviceNotFoundError,
22
+ QuboError,
23
+ QuboKeyExtractionError,
24
+ )
25
+ from qubopy.models import (
26
+ BleLockCredentials,
27
+ DeviceModelInfo,
28
+ DeviceShadow,
29
+ Entity,
30
+ RtspStreamInfo,
31
+ TokenPayload,
32
+ Unit,
33
+ )
34
+
35
+ try:
36
+ __version__ = version("qubopy")
37
+ except PackageNotFoundError:
38
+ __version__ = "0.1.0"
39
+
40
+ __all__ = [
41
+ "BleLockCredentials",
42
+ "DeviceModelInfo",
43
+ "DeviceShadow",
44
+ "Entity",
45
+ "QuboAirPurifier",
46
+ "QuboAuth",
47
+ "QuboAuthError",
48
+ "QuboCamera",
49
+ "QuboClient",
50
+ "QuboCommandError",
51
+ "QuboConnectionError",
52
+ "QuboDevice",
53
+ "QuboDeviceNotFoundError",
54
+ "QuboError",
55
+ "QuboKeyExtractionError",
56
+ "QuboLight",
57
+ "QuboLock",
58
+ "QuboPlug",
59
+ "QuboSensor",
60
+ "RtspStreamInfo",
61
+ "TokenPayload",
62
+ "Unit",
63
+ "__version__",
64
+ "create_device",
65
+ ]
qubopy/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Entrypoint for python -m qubopy execution."""
2
+
3
+ from qubopy.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
qubopy/auth.py ADDED
@@ -0,0 +1,486 @@
1
+ """Authentication engine for Qubo cloud services."""
2
+
3
+ import asyncio
4
+ import base64
5
+ import json
6
+ import logging
7
+ import os
8
+ import time
9
+ import uuid
10
+ from pathlib import Path
11
+ from typing import Any
12
+ from urllib.parse import urlsplit
13
+
14
+ import aiohttp
15
+
16
+ from qubopy.const import (
17
+ DEFAULT_APP_ID,
18
+ DEFAULT_BASE_URL,
19
+ DEFAULT_DEVICE_ATTRIBUTE,
20
+ DEFAULT_SP_ID,
21
+ PATH_AUTH_REFRESH,
22
+ PATH_LOGIN_EMAIL,
23
+ PATH_LOGIN_PHONE_REQUEST,
24
+ PATH_LOGIN_PHONE_VERIFY,
25
+ )
26
+ from qubopy.exceptions import QuboAuthError, QuboConnectionError
27
+ from qubopy.models import TokenPayload
28
+
29
+ _LOGGER = logging.getLogger(__name__)
30
+ DEVICE_ID_FILE = Path.home() / ".qubopy_device_id"
31
+
32
+
33
+ def _get_or_create_device_id() -> str:
34
+ """Return persistent device UUID matching Android uniqueAndroidID behavior."""
35
+ try:
36
+ if DEVICE_ID_FILE.exists():
37
+ saved_id = DEVICE_ID_FILE.read_text(encoding="utf-8").strip()
38
+ if saved_id:
39
+ return saved_id
40
+ new_id = str(uuid.uuid4())
41
+ fd = os.open(str(DEVICE_ID_FILE), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
42
+ try:
43
+ with open(fd, "w", encoding="utf-8", closefd=True) as f:
44
+ f.write(new_id)
45
+ except Exception:
46
+ try:
47
+ os.close(fd)
48
+ except OSError:
49
+ pass
50
+ raise
51
+ return new_id
52
+ except Exception as err:
53
+ _LOGGER.debug(
54
+ "Could not persist or read device ID (%s); generating ephemeral UUID", err
55
+ )
56
+ return str(uuid.uuid4())
57
+
58
+
59
+ def _decode_jwt_expiry(token: str) -> float | None:
60
+ """Extract expiry timestamp from JWT payload without verifying signature."""
61
+ try:
62
+ parts = token.split(".")
63
+ if len(parts) < 2:
64
+ return None
65
+ # Add padding if needed
66
+ payload_b64 = parts[1] + "=" * (-len(parts[1]) % 4)
67
+ payload_bytes = base64.urlsafe_b64decode(payload_b64)
68
+ payload = json.loads(payload_bytes.decode("utf-8"))
69
+ if "exp" in payload and isinstance(payload["exp"], (int, float)):
70
+ return float(payload["exp"])
71
+ except Exception as err:
72
+ _LOGGER.debug("Could not parse JWT expiry from token: %s", err)
73
+ return None
74
+
75
+
76
+ class QuboAuth:
77
+ """Manages credentials, JWT token lifecycle, and session headers."""
78
+
79
+ def __init__(
80
+ self,
81
+ base_url: str = DEFAULT_BASE_URL,
82
+ sp_id: str = DEFAULT_SP_ID,
83
+ app_id: str = DEFAULT_APP_ID,
84
+ device_id: str | None = None,
85
+ session: aiohttp.ClientSession | None = None,
86
+ ) -> None:
87
+ parsed = urlsplit(base_url)
88
+ if "@" in (parsed.netloc or ""):
89
+ raise ValueError("Insecure base_url: userinfo is not permitted")
90
+ if parsed.scheme == "https":
91
+ if not parsed.hostname:
92
+ raise ValueError(f"Invalid base_url: missing hostname in {base_url}")
93
+ elif parsed.scheme == "http":
94
+ allowed_local = ("localhost", "127.0.0.1", "::1")
95
+ if parsed.hostname not in allowed_local:
96
+ raise ValueError(
97
+ f"Insecure base_url: HTTP is only permitted for local testing "
98
+ f"({allowed_local}), got {parsed.hostname}"
99
+ )
100
+ else:
101
+ raise ValueError(f"Insecure or unsupported scheme in base_url: {parsed.scheme}")
102
+ self.base_url = base_url.rstrip("/")
103
+ self.sp_id = sp_id
104
+ self.app_id = app_id
105
+ self.device_id = device_id or _get_or_create_device_id()
106
+ self._session = session
107
+ self._owns_session = session is None
108
+ self._tokens: TokenPayload | None = None
109
+ self._login_uuid: str | None = None
110
+ self._refresh_lock = asyncio.Lock()
111
+
112
+ @property
113
+ def is_authenticated(self) -> bool:
114
+ """Return True if active tokens exist."""
115
+ return self._tokens is not None and bool(self._tokens.access_token)
116
+
117
+ @property
118
+ def user_uuid(self) -> str | None:
119
+ """Return user account UUID if authenticated."""
120
+ return self._tokens.user_uuid if self._tokens else None
121
+
122
+ @property
123
+ def tokens(self) -> TokenPayload | None:
124
+ """Return current token payload."""
125
+ return self._tokens
126
+
127
+ def set_tokens(
128
+ self,
129
+ access_token: str,
130
+ refresh_token: str | None = None,
131
+ user_uuid: str = "",
132
+ expires_at: float | None = None,
133
+ ) -> TokenPayload:
134
+ """Manually configure cached credentials."""
135
+ if expires_at is None:
136
+ expires_at = _decode_jwt_expiry(access_token)
137
+ if expires_at is None:
138
+ # Default to 24 hours expiry if absent
139
+ expires_at = time.time() + 86400
140
+
141
+ self._tokens = TokenPayload(
142
+ accessToken=access_token,
143
+ refreshToken=refresh_token,
144
+ uuid=user_uuid,
145
+ expires_at=expires_at,
146
+ )
147
+ return self._tokens
148
+
149
+ async def _get_session(self) -> aiohttp.ClientSession:
150
+ """Return an active aiohttp session."""
151
+ if self._session is None or self._session.closed:
152
+ self._session = aiohttp.ClientSession(
153
+ timeout=aiohttp.ClientTimeout(total=30)
154
+ )
155
+ self._owns_session = True
156
+ return self._session
157
+
158
+ async def login_with_email(
159
+ self,
160
+ email: str,
161
+ password: str,
162
+ device_attribute: str = DEFAULT_DEVICE_ATTRIBUTE,
163
+ ) -> TokenPayload:
164
+ """Authenticate using account email and password."""
165
+ url = f"{self.base_url}{PATH_LOGIN_EMAIL.format(sp=self.sp_id)}"
166
+ payload = {
167
+ "username": email,
168
+ "email": email,
169
+ "password": password,
170
+ "deviceAttribute": device_attribute,
171
+ }
172
+ headers = {
173
+ "Content-Type": "application/json",
174
+ "App-Id": self.app_id,
175
+ "Source-Device-Id": self.device_id,
176
+ "Source": "USER",
177
+ "Token-Type": "USER",
178
+ }
179
+
180
+ session = await self._get_session()
181
+ try:
182
+ async with session.post(url, json=payload, headers=headers) as response:
183
+ text = await response.text()
184
+ if response.status != 200:
185
+ _LOGGER.debug(
186
+ "Email login error response (HTTP %s): %s", response.status, text[:200]
187
+ )
188
+ raise QuboAuthError(f"Email login failed (HTTP {response.status})")
189
+ data = json.loads(text)
190
+ if isinstance(data, list) and data:
191
+ data = data[0]
192
+ if not isinstance(data, dict):
193
+ raise QuboAuthError(
194
+ f"Unexpected response format from login: {type(data).__name__}"
195
+ )
196
+ except aiohttp.ClientError as err:
197
+ raise QuboConnectionError(f"Network error during login: {err}") from err
198
+ except json.JSONDecodeError as err:
199
+ raise QuboAuthError(f"Invalid JSON returned by server: {err}") from err
200
+
201
+ access_token = data.get("accessToken") or data.get("token")
202
+ user_uuid = data.get("uuid") or data.get("userUUID") or data.get("userId")
203
+ if not access_token or not user_uuid:
204
+ raise QuboAuthError("Missing required token fields in login response")
205
+
206
+ refresh_token = data.get("refreshToken")
207
+ expires_at = _decode_jwt_expiry(access_token) or (time.time() + 86400)
208
+
209
+ self._tokens = TokenPayload(
210
+ accessToken=access_token,
211
+ refreshToken=refresh_token,
212
+ uuid=str(user_uuid),
213
+ expires_at=expires_at,
214
+ )
215
+ return self._tokens
216
+
217
+ async def request_phone_otp(
218
+ self,
219
+ phone: str,
220
+ isd_code: str = "+91",
221
+ device_attribute: str = DEFAULT_DEVICE_ATTRIBUTE,
222
+ ) -> dict[str, Any]:
223
+ """Request a one-time SMS password for phone login."""
224
+ url = f"{self.base_url}{PATH_LOGIN_PHONE_REQUEST.format(sp=self.sp_id)}"
225
+ clean_isd = isd_code if isd_code.startswith("+") else f"+{isd_code}"
226
+ payload = {
227
+ "phone": phone,
228
+ "isdCode": clean_isd,
229
+ "deviceAttribute": device_attribute,
230
+ }
231
+ headers = {
232
+ "Content-Type": "application/json",
233
+ "App-Id": self.app_id,
234
+ "Source-Device-Id": self.device_id,
235
+ "Source": "USER",
236
+ }
237
+
238
+ session = await self._get_session()
239
+ try:
240
+ async with session.post(url, json=payload, headers=headers) as response:
241
+ text = await response.text()
242
+ if response.status != 200:
243
+ _LOGGER.debug(
244
+ "OTP request error response (HTTP %s): %s", response.status, text[:200]
245
+ )
246
+ raise QuboAuthError(f"OTP request failed (HTTP {response.status})")
247
+ data = json.loads(text)
248
+ self._login_uuid = data.get("uuid")
249
+ return data
250
+ except aiohttp.ClientError as err:
251
+ raise QuboConnectionError(f"Network error requesting OTP: {err}") from err
252
+
253
+ async def get_phone_otp_profiles(
254
+ self,
255
+ phone: str,
256
+ otp: str,
257
+ isd_code: str = "+91",
258
+ session_uuid: str | None = None,
259
+ device_attribute: str = DEFAULT_DEVICE_ATTRIBUTE,
260
+ ) -> list[dict[str, Any]]:
261
+ """Verify SMS OTP and return all matching account profiles."""
262
+ url = f"{self.base_url}{PATH_LOGIN_PHONE_VERIFY.format(sp=self.sp_id)}"
263
+ clean_isd = isd_code if isd_code.startswith("+") else f"+{isd_code}"
264
+ target_uuid = session_uuid or self._login_uuid
265
+ if not target_uuid:
266
+ raise QuboAuthError(
267
+ "Missing login session UUID. Call request_phone_otp first or provide session_uuid."
268
+ )
269
+
270
+ payload = {
271
+ "phone": phone,
272
+ "isdCode": clean_isd,
273
+ "otp": otp,
274
+ "deviceAttribute": device_attribute,
275
+ "uuid": target_uuid,
276
+ }
277
+ headers = {
278
+ "Content-Type": "application/json",
279
+ "App-Id": self.app_id,
280
+ "Source-Device-Id": self.device_id,
281
+ "Source": "USER",
282
+ }
283
+
284
+ session = await self._get_session()
285
+ try:
286
+ async with session.post(url, json=payload, headers=headers) as response:
287
+ text = await response.text()
288
+ if response.status != 200:
289
+ _LOGGER.debug(
290
+ "OTP verification error response (HTTP %s): %s", response.status, text[:200]
291
+ )
292
+ raise QuboAuthError(f"OTP verification failed (HTTP {response.status})")
293
+ data = json.loads(text)
294
+ if isinstance(data, dict):
295
+ return [data]
296
+ if isinstance(data, list):
297
+ return data
298
+ raise QuboAuthError(f"Unexpected response format from OTP verification: {data}")
299
+ except aiohttp.ClientError as err:
300
+ raise QuboConnectionError(f"Network error verifying OTP: {err}") from err
301
+ except json.JSONDecodeError as err:
302
+ raise QuboAuthError(f"Invalid JSON returned by server: {err}") from err
303
+
304
+ async def verify_phone_otp(
305
+ self,
306
+ phone: str,
307
+ otp: str,
308
+ isd_code: str = "+91",
309
+ session_uuid: str | None = None,
310
+ device_attribute: str = DEFAULT_DEVICE_ATTRIBUTE,
311
+ profile_index: int = 0,
312
+ account_uuid: str | None = None,
313
+ ) -> TokenPayload:
314
+ """Verify received SMS OTP and obtain session tokens.
315
+
316
+ If multiple account profiles are linked to the phone number, specify
317
+ either `profile_index` (default 0) or `account_uuid` to select the account.
318
+ """
319
+ profiles = await self.get_phone_otp_profiles(
320
+ phone=phone,
321
+ otp=otp,
322
+ isd_code=isd_code,
323
+ session_uuid=session_uuid,
324
+ device_attribute=device_attribute,
325
+ )
326
+
327
+ if not profiles:
328
+ raise QuboAuthError("No account profiles returned in OTP verification response")
329
+
330
+ selected_profile: dict[str, Any] | None = None
331
+ if account_uuid:
332
+ for p in profiles:
333
+ p_uuid = str(p.get("uuid") or p.get("userUUID") or p.get("userId") or "")
334
+ if p_uuid == account_uuid:
335
+ selected_profile = p
336
+ break
337
+ if not selected_profile:
338
+ raise QuboAuthError(
339
+ f"Account UUID '{account_uuid}' not found among returned profiles"
340
+ )
341
+ else:
342
+ if profile_index < 0 or profile_index >= len(profiles):
343
+ raise QuboAuthError(
344
+ f"Profile index {profile_index} out of range (0-{len(profiles) - 1})"
345
+ )
346
+ selected_profile = profiles[profile_index]
347
+
348
+ access_token = selected_profile.get("accessToken") or selected_profile.get("token")
349
+ user_uuid = (
350
+ selected_profile.get("uuid")
351
+ or selected_profile.get("userUUID")
352
+ or selected_profile.get("userId")
353
+ )
354
+ if not access_token or not user_uuid:
355
+ raise QuboAuthError("Missing tokens in OTP response")
356
+
357
+ refresh_token = selected_profile.get("refreshToken")
358
+ expires_at = _decode_jwt_expiry(access_token) or (time.time() + 86400)
359
+
360
+ self._tokens = TokenPayload(
361
+ accessToken=access_token,
362
+ refreshToken=refresh_token,
363
+ uuid=str(user_uuid),
364
+ expires_at=expires_at,
365
+ )
366
+ return self._tokens
367
+
368
+ async def refresh_session(self, failed_token: str | None = None) -> TokenPayload:
369
+ """Renew the JWT access token using the stored refresh token."""
370
+ async with self._refresh_lock:
371
+ if not self._tokens or not self._tokens.refresh_token:
372
+ raise QuboAuthError("No refresh token available to renew session")
373
+
374
+ # Concurrency / Double-checked locking
375
+ if failed_token is not None:
376
+ # If another coroutine already refreshed the token while we waited for the lock
377
+ if self._tokens.access_token != failed_token:
378
+ return self._tokens
379
+ elif self._tokens.expires_at and (self._tokens.expires_at - time.time()) > 300:
380
+ # Proactive background refresh path: token still has > 5 min validity
381
+ return self._tokens
382
+
383
+ url = (
384
+ f"{self.base_url}"
385
+ f"{PATH_AUTH_REFRESH.format(sp=self.sp_id, user_uuid=self._tokens.user_uuid)}"
386
+ )
387
+ headers = {
388
+ "Content-Type": "application/json",
389
+ "Authorization": f"Bearer {self._tokens.access_token}",
390
+ "Subscriber-Key": self._tokens.access_token,
391
+ "User-UUID": self._tokens.user_uuid,
392
+ "Token-Type": "USER",
393
+ "Source-Device-Id": self.device_id,
394
+ "App-Id": self.app_id,
395
+ "Source": "Android",
396
+ }
397
+ payload = {
398
+ "accessToken": self._tokens.access_token,
399
+ "refreshToken": self._tokens.refresh_token,
400
+ }
401
+
402
+ session = await self._get_session()
403
+ try:
404
+ async with session.post(url, json=payload, headers=headers) as response:
405
+ text = await response.text()
406
+ if response.status != 200:
407
+ _LOGGER.debug(
408
+ "Session refresh error response (HTTP %s): %s",
409
+ response.status,
410
+ text[:200],
411
+ )
412
+ raise QuboAuthError(
413
+ f"Session refresh failed (HTTP {response.status})"
414
+ )
415
+ data = json.loads(text)
416
+ except aiohttp.ClientError as err:
417
+ raise QuboConnectionError(f"Network error during token refresh: {err}") from err
418
+ except json.JSONDecodeError as err:
419
+ raise QuboAuthError(
420
+ f"Invalid JSON returned by server during refresh: {err}"
421
+ ) from err
422
+
423
+ new_access_token = data.get("accessToken")
424
+ if not new_access_token:
425
+ raise QuboAuthError("Refresh response missing access token")
426
+
427
+ new_refresh_token = data.get("refreshToken") or self._tokens.refresh_token
428
+ expires_at = _decode_jwt_expiry(new_access_token) or (time.time() + 86400)
429
+
430
+ self._tokens = TokenPayload(
431
+ accessToken=new_access_token,
432
+ refreshToken=new_refresh_token,
433
+ uuid=self._tokens.user_uuid,
434
+ expires_at=expires_at,
435
+ )
436
+ return self._tokens
437
+
438
+ async def get_valid_access_token(self) -> str:
439
+ """Return a valid access token, proactively refreshing if near expiration."""
440
+ if not self._tokens:
441
+ raise QuboAuthError("Authentication required: No tokens present")
442
+
443
+ now = time.time()
444
+ # If token expires in less than 5 minutes (300 seconds), refresh it
445
+ if (
446
+ self._tokens.expires_at
447
+ and (self._tokens.expires_at - now) < 300
448
+ and self._tokens.refresh_token
449
+ ):
450
+ try:
451
+ await self.refresh_session()
452
+ except Exception as err:
453
+ if self._tokens.expires_at <= now:
454
+ raise QuboAuthError(
455
+ f"Session expired and automatic token renewal failed: {err}"
456
+ ) from err
457
+ _LOGGER.warning("Proactive token refresh near expiration failed: %s", err)
458
+
459
+ if (
460
+ self._tokens.expires_at
461
+ and self._tokens.expires_at <= now
462
+ and not self._tokens.refresh_token
463
+ ):
464
+ raise QuboAuthError("Session expired; please log in again")
465
+
466
+ return self._tokens.access_token
467
+
468
+ async def get_headers(self) -> dict[str, str]:
469
+ """Generate standard authenticated request headers."""
470
+ token = await self.get_valid_access_token()
471
+ return {
472
+ "Content-Type": "application/json",
473
+ "Authorization": f"Bearer {token}",
474
+ "Token-Type": "USER",
475
+ "User-UUID": self._tokens.user_uuid, # type: ignore[union-attr]
476
+ "Subscriber-Key": token,
477
+ "Source-Device-Id": self.device_id,
478
+ "App-Id": self.app_id,
479
+ "Source": "Android",
480
+ }
481
+
482
+ async def close(self) -> None:
483
+ """Close underlying HTTP session if owned."""
484
+ if self._owns_session and self._session and not self._session.closed:
485
+ await self._session.close()
486
+ self._session = None