botslab360 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.
- botslab360/__init__.py +26 -0
- botslab360/auth.py +243 -0
- botslab360/client.py +503 -0
- botslab360/commands.py +28 -0
- botslab360/exceptions.py +36 -0
- botslab360/models.py +66 -0
- botslab360/protocol.py +311 -0
- botslab360-0.1.0.dist-info/METADATA +254 -0
- botslab360-0.1.0.dist-info/RECORD +12 -0
- botslab360-0.1.0.dist-info/WHEEL +5 -0
- botslab360-0.1.0.dist-info/licenses/LICENSE +21 -0
- botslab360-0.1.0.dist-info/top_level.txt +1 -0
botslab360/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Async authentication for Botslab/360 smart-home devices."""
|
|
2
|
+
|
|
3
|
+
from .auth import BotslabAuth, credentials_from_tokens, derive_qid
|
|
4
|
+
from .client import Botslab360Client
|
|
5
|
+
from .exceptions import (
|
|
6
|
+
ApiError,
|
|
7
|
+
AuthenticationError,
|
|
8
|
+
Botslab360Error,
|
|
9
|
+
InvalidSessionError,
|
|
10
|
+
)
|
|
11
|
+
from .models import Device, QihooCredentials, RobotStatus, SmartSession
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"ApiError",
|
|
15
|
+
"AuthenticationError",
|
|
16
|
+
"Botslab360Client",
|
|
17
|
+
"Botslab360Error",
|
|
18
|
+
"BotslabAuth",
|
|
19
|
+
"Device",
|
|
20
|
+
"InvalidSessionError",
|
|
21
|
+
"QihooCredentials",
|
|
22
|
+
"RobotStatus",
|
|
23
|
+
"SmartSession",
|
|
24
|
+
"credentials_from_tokens",
|
|
25
|
+
"derive_qid",
|
|
26
|
+
]
|
botslab360/auth.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Qihoo token handling and Botslab Smart Home authentication."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from numbers import Integral
|
|
9
|
+
from typing import Any
|
|
10
|
+
from urllib.parse import unquote
|
|
11
|
+
from uuid import uuid4
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from .exceptions import ApiError, AuthenticationError, InvalidSessionError
|
|
16
|
+
from .models import QihooCredentials, SmartSession
|
|
17
|
+
|
|
18
|
+
SMART_HOME_BASE_URL = "https://q.smart.360.cn"
|
|
19
|
+
SMART_LOGIN_PATH = "/common/user/login"
|
|
20
|
+
|
|
21
|
+
_QID_FROM_USER = re.compile(r"(?:^|[&;])u=360H(?P<qid>[0-9]+)(?=$|[&;])")
|
|
22
|
+
_QID_FROM_NUMERIC_USER = re.compile(r"(?:^|[&;])u=(?P<qid>[0-9]+)(?=$|[&;])")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def normalize_cookie_value(value: str, *, name: str) -> str:
|
|
26
|
+
"""Normalize one possibly URL-encoded cookie value exactly once."""
|
|
27
|
+
|
|
28
|
+
if not isinstance(value, str) or not value.strip():
|
|
29
|
+
raise AuthenticationError(
|
|
30
|
+
f"{name} must be a non-empty string",
|
|
31
|
+
phase="input",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
normalized = value.strip()
|
|
35
|
+
prefix = f"{name}="
|
|
36
|
+
if normalized.startswith(prefix):
|
|
37
|
+
normalized = normalized[len(prefix) :]
|
|
38
|
+
|
|
39
|
+
normalized = unquote(normalized)
|
|
40
|
+
if normalized.startswith(prefix):
|
|
41
|
+
normalized = normalized[len(prefix) :]
|
|
42
|
+
if not normalized:
|
|
43
|
+
raise AuthenticationError(f"{name} must not be empty", phase="input")
|
|
44
|
+
return normalized
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def derive_qid(q: str) -> str:
|
|
48
|
+
"""Extract the decimal qid from the Q cookie's ``u`` field."""
|
|
49
|
+
|
|
50
|
+
normalized_q = normalize_cookie_value(q, name="Q")
|
|
51
|
+
match = _QID_FROM_USER.search(normalized_q)
|
|
52
|
+
if match is None:
|
|
53
|
+
match = _QID_FROM_NUMERIC_USER.search(normalized_q)
|
|
54
|
+
if match is None:
|
|
55
|
+
raise AuthenticationError(
|
|
56
|
+
"Could not derive qid from the Q cookie",
|
|
57
|
+
phase="input",
|
|
58
|
+
)
|
|
59
|
+
return match.group("qid")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def credentials_from_tokens(q: str, t: str) -> QihooCredentials:
|
|
63
|
+
"""Build normalized credentials without exposing token values."""
|
|
64
|
+
|
|
65
|
+
qid = derive_qid(q)
|
|
66
|
+
normalized_q = normalize_cookie_value(q, name="Q")
|
|
67
|
+
normalized_t = normalize_cookie_value(t, name="T")
|
|
68
|
+
return QihooCredentials(
|
|
69
|
+
q=normalized_q,
|
|
70
|
+
t=normalized_t,
|
|
71
|
+
qid=qid,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _numeric_errno(value: Any, *, status_code: int) -> int:
|
|
76
|
+
if isinstance(value, bool):
|
|
77
|
+
raise ApiError(
|
|
78
|
+
"Smart Home response contains an invalid errno",
|
|
79
|
+
phase="response-validation",
|
|
80
|
+
status_code=status_code,
|
|
81
|
+
)
|
|
82
|
+
if isinstance(value, Integral):
|
|
83
|
+
return int(value)
|
|
84
|
+
if isinstance(value, str) and re.fullmatch(r"[+-]?[0-9]+", value.strip()):
|
|
85
|
+
return int(value)
|
|
86
|
+
raise ApiError(
|
|
87
|
+
"Smart Home response contains an invalid errno",
|
|
88
|
+
phase="response-validation",
|
|
89
|
+
status_code=status_code,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _raise_api_error(
|
|
94
|
+
errno: int,
|
|
95
|
+
*,
|
|
96
|
+
status_code: int,
|
|
97
|
+
response_errno: int,
|
|
98
|
+
error_code: int | None,
|
|
99
|
+
) -> None:
|
|
100
|
+
details = {
|
|
101
|
+
"errno": errno,
|
|
102
|
+
"status_code": status_code,
|
|
103
|
+
"phase": "api",
|
|
104
|
+
"response_errno": response_errno,
|
|
105
|
+
"error_code": error_code,
|
|
106
|
+
}
|
|
107
|
+
if errno == 102:
|
|
108
|
+
raise InvalidSessionError("Smart Home session has expired", **details)
|
|
109
|
+
if errno == 103:
|
|
110
|
+
raise AuthenticationError("Qihoo Q/T session is unauthorized", **details)
|
|
111
|
+
raise ApiError("Smart Home API rejected the login", **details)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class BotslabAuth:
|
|
115
|
+
"""Perform the Q/T to Smart Home session exchange."""
|
|
116
|
+
|
|
117
|
+
def __init__(
|
|
118
|
+
self,
|
|
119
|
+
http_client: httpx.AsyncClient,
|
|
120
|
+
*,
|
|
121
|
+
base_url: str = SMART_HOME_BASE_URL,
|
|
122
|
+
language: str = "de_DE",
|
|
123
|
+
) -> None:
|
|
124
|
+
self._http_client = http_client
|
|
125
|
+
self._base_url = base_url.rstrip("/")
|
|
126
|
+
self._language = language
|
|
127
|
+
|
|
128
|
+
async def login(
|
|
129
|
+
self,
|
|
130
|
+
credentials: QihooCredentials,
|
|
131
|
+
*,
|
|
132
|
+
client_info: Mapping[str, object] | None = None,
|
|
133
|
+
) -> SmartSession:
|
|
134
|
+
info = dict(
|
|
135
|
+
client_info
|
|
136
|
+
or {
|
|
137
|
+
"release": "appstore",
|
|
138
|
+
"brand": "iPhone",
|
|
139
|
+
"model": "iPhone10,5",
|
|
140
|
+
"notifyId": (
|
|
141
|
+
"aa0ad645269de676a5ee6a728ba13b777"
|
|
142
|
+
"ed3d4aa4d0e08a578097fbe78768b02"
|
|
143
|
+
),
|
|
144
|
+
"lang": self._language,
|
|
145
|
+
"imei": "f3bc82b802bd91a51d0dcc6499efeba3",
|
|
146
|
+
}
|
|
147
|
+
)
|
|
148
|
+
form = {
|
|
149
|
+
"clientInfo": json.dumps(info, separators=(",", ":")),
|
|
150
|
+
"lang": self._language,
|
|
151
|
+
"phoneNum": "",
|
|
152
|
+
"taskid": str(uuid4()),
|
|
153
|
+
}
|
|
154
|
+
headers = {
|
|
155
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
156
|
+
"Accept": "*/*",
|
|
157
|
+
"Connection": "keep-alive",
|
|
158
|
+
"Cookie": f"q={credentials.q};t={credentials.t};qid={credentials.qid}",
|
|
159
|
+
"User-Agent": "qhsa-iphone-11.1.0",
|
|
160
|
+
"Accept-Language": "de-DE;q=1, uk-DE;q=0.9, en-DE;q=0.8",
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
response = await self._http_client.post(
|
|
165
|
+
f"{self._base_url}{SMART_LOGIN_PATH}",
|
|
166
|
+
data=form,
|
|
167
|
+
headers=headers,
|
|
168
|
+
)
|
|
169
|
+
response.raise_for_status()
|
|
170
|
+
except httpx.HTTPStatusError as exc:
|
|
171
|
+
raise ApiError(
|
|
172
|
+
"Smart Home login returned an HTTP error",
|
|
173
|
+
status_code=exc.response.status_code,
|
|
174
|
+
phase="http",
|
|
175
|
+
) from exc
|
|
176
|
+
except httpx.RequestError as exc:
|
|
177
|
+
raise ApiError("Smart Home login request failed", phase="transport") from exc
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
payload = response.json()
|
|
181
|
+
except ValueError as exc:
|
|
182
|
+
raise ApiError(
|
|
183
|
+
"Smart Home login returned invalid JSON",
|
|
184
|
+
status_code=response.status_code,
|
|
185
|
+
phase="json",
|
|
186
|
+
) from exc
|
|
187
|
+
if not isinstance(payload, dict):
|
|
188
|
+
raise ApiError(
|
|
189
|
+
"Smart Home login returned an invalid response",
|
|
190
|
+
status_code=response.status_code,
|
|
191
|
+
phase="response-validation",
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
response_errno = _numeric_errno(
|
|
195
|
+
payload.get("errno"),
|
|
196
|
+
status_code=response.status_code,
|
|
197
|
+
)
|
|
198
|
+
errno = response_errno
|
|
199
|
+
error_code = None
|
|
200
|
+
if "errorCode" in payload:
|
|
201
|
+
error_code = _numeric_errno(
|
|
202
|
+
payload["errorCode"],
|
|
203
|
+
status_code=response.status_code,
|
|
204
|
+
)
|
|
205
|
+
if error_code != 0:
|
|
206
|
+
errno = error_code
|
|
207
|
+
if errno != 0:
|
|
208
|
+
_raise_api_error(
|
|
209
|
+
errno,
|
|
210
|
+
status_code=response.status_code,
|
|
211
|
+
response_errno=response_errno,
|
|
212
|
+
error_code=error_code,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
data = payload.get("data")
|
|
216
|
+
if not isinstance(data, dict):
|
|
217
|
+
raise ApiError(
|
|
218
|
+
"Smart Home login response is missing session data",
|
|
219
|
+
status_code=response.status_code,
|
|
220
|
+
phase="response-validation",
|
|
221
|
+
response_errno=response_errno,
|
|
222
|
+
error_code=error_code,
|
|
223
|
+
)
|
|
224
|
+
sid = data.get("sid")
|
|
225
|
+
push_key = data.get("pushKey")
|
|
226
|
+
if not isinstance(sid, str) or not sid:
|
|
227
|
+
raise ApiError(
|
|
228
|
+
"Smart Home login response is missing sid",
|
|
229
|
+
status_code=response.status_code,
|
|
230
|
+
phase="response-validation",
|
|
231
|
+
response_errno=response_errno,
|
|
232
|
+
error_code=error_code,
|
|
233
|
+
)
|
|
234
|
+
if not isinstance(push_key, str) or not push_key:
|
|
235
|
+
raise ApiError(
|
|
236
|
+
"Smart Home login response is missing pushKey",
|
|
237
|
+
status_code=response.status_code,
|
|
238
|
+
phase="response-validation",
|
|
239
|
+
response_errno=response_errno,
|
|
240
|
+
error_code=error_code,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
return SmartSession(qid=credentials.qid, sid=sid, push_key=push_key)
|