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 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)