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 +65 -0
- qubopy/__main__.py +6 -0
- qubopy/auth.py +486 -0
- qubopy/cli.py +650 -0
- qubopy/client.py +583 -0
- qubopy/const.py +78 -0
- qubopy/devices/__init__.py +114 -0
- qubopy/devices/base.py +140 -0
- qubopy/devices/camera.py +152 -0
- qubopy/devices/dashcam.py +88 -0
- qubopy/devices/light.py +78 -0
- qubopy/devices/lock.py +148 -0
- qubopy/devices/plug.py +59 -0
- qubopy/devices/purifier.py +74 -0
- qubopy/devices/sensor.py +52 -0
- qubopy/exceptions.py +25 -0
- qubopy/models.py +115 -0
- qubopy/py.typed +1 -0
- qubopy-0.1.0.dist-info/METADATA +546 -0
- qubopy-0.1.0.dist-info/RECORD +24 -0
- qubopy-0.1.0.dist-info/WHEEL +5 -0
- qubopy-0.1.0.dist-info/entry_points.txt +2 -0
- qubopy-0.1.0.dist-info/licenses/LICENSE +21 -0
- qubopy-0.1.0.dist-info/top_level.txt +1 -0
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
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
|