python-aidot-cameras 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.
aidot/__init__.py ADDED
@@ -0,0 +1,31 @@
1
+ """AiDot camera and device library."""
2
+
3
+ from .client import AidotClient
4
+ from .device_client import DeviceClient
5
+ from .discover import Discover
6
+ from .exceptions import (
7
+ AidotAuthFailed,
8
+ AidotAuthTokenExpired,
9
+ AidotError,
10
+ AidotNotLogin,
11
+ AidotOSError,
12
+ AidotUserOrPassIncorrect,
13
+ HTTPError,
14
+ InvalidHost,
15
+ InvalidURL,
16
+ )
17
+
18
+ __all__ = [
19
+ "AidotClient",
20
+ "DeviceClient",
21
+ "Discover",
22
+ "AidotError",
23
+ "AidotAuthFailed",
24
+ "AidotAuthTokenExpired",
25
+ "AidotNotLogin",
26
+ "AidotOSError",
27
+ "AidotUserOrPassIncorrect",
28
+ "HTTPError",
29
+ "InvalidHost",
30
+ "InvalidURL",
31
+ ]
aidot/aes_utils.py ADDED
@@ -0,0 +1,46 @@
1
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
2
+ from cryptography.hazmat.backends import default_backend
3
+ from cryptography.hazmat.primitives import padding
4
+
5
+
6
+ def aes_encrypt(plaintext: bytes, key: bytes) -> bytes:
7
+ """AES-ECB encrypt with raw key bytes (used for LAN discovery / TCP:10000)."""
8
+ padder = padding.PKCS7(algorithms.AES.block_size).padder()
9
+ padded_data = padder.update(plaintext) + padder.finalize()
10
+ cipher = Cipher(algorithms.AES(key), modes.ECB(), backend=default_backend())
11
+ encryptor = cipher.encryptor()
12
+ return encryptor.update(padded_data) + encryptor.finalize()
13
+
14
+
15
+ def aes_decrypt(ciphertext: bytes, key: bytes) -> str:
16
+ """AES-ECB decrypt with raw key bytes; returns UTF-8 string."""
17
+ cipher = Cipher(algorithms.AES(key), modes.ECB(), backend=default_backend())
18
+ decryptor = cipher.decryptor()
19
+ decrypted_data = decryptor.update(ciphertext) + decryptor.finalize()
20
+ unpadder = padding.PKCS7(algorithms.AES.block_size).unpadder()
21
+ plaintext = unpadder.update(decrypted_data) + unpadder.finalize()
22
+ return plaintext.decode("utf-8")
23
+
24
+
25
+ def _str_key_32(key_str: str) -> bytes:
26
+ """Zero-pad a string key to 32 bytes (AESUtils.get32Key from Leedarson SDK)."""
27
+ raw = key_str.encode("utf-8")
28
+ return raw[:32].ljust(32, b"\x00")
29
+
30
+
31
+ def aes_ecb_encrypt_str_key(plaintext: bytes, key_str: str) -> bytes:
32
+ """AES-256/ECB/PKCS7 encrypt; key is a string zero-padded to 32 bytes."""
33
+ padder = padding.PKCS7(128).padder()
34
+ padded = padder.update(plaintext) + padder.finalize()
35
+ cipher = Cipher(algorithms.AES(_str_key_32(key_str)), modes.ECB(), backend=default_backend())
36
+ enc = cipher.encryptor()
37
+ return enc.update(padded) + enc.finalize()
38
+
39
+
40
+ def aes_ecb_decrypt_str_key(ciphertext: bytes, key_str: str) -> bytes:
41
+ """AES-256/ECB/PKCS7 decrypt; key is a string zero-padded to 32 bytes."""
42
+ cipher = Cipher(algorithms.AES(_str_key_32(key_str)), modes.ECB(), backend=default_backend())
43
+ dec = cipher.decryptor()
44
+ padded = dec.update(ciphertext) + dec.finalize()
45
+ unpadder = padding.PKCS7(128).unpadder()
46
+ return unpadder.update(padded) + unpadder.finalize()
aidot/client.py ADDED
@@ -0,0 +1,405 @@
1
+ """The aidot integration."""
2
+
3
+ import asyncio
4
+ import hashlib
5
+ import json
6
+ import logging
7
+ import base64
8
+ import aiohttp
9
+ from aiohttp import ClientSession
10
+ from typing import Any, Callable, Optional
11
+ from cryptography.hazmat.backends import default_backend
12
+ from cryptography.hazmat.primitives import serialization
13
+ from cryptography.hazmat.primitives.asymmetric import padding
14
+
15
+ from .exceptions import AidotAuthFailed, AidotUserOrPassIncorrect
16
+ from .device_client import DeviceClient
17
+ from .const import (
18
+ APP_ID,
19
+ BASE_URL,
20
+ PUBLIC_KEY_PEM,
21
+ CONF_ACCESS_TOKEN,
22
+ CONF_APP_ID,
23
+ CONF_CODE,
24
+ CONF_COUNTRY,
25
+ CONF_DEVICE_LIST,
26
+ CONF_ID,
27
+ CONF_PASSWORD,
28
+ CONF_PRODUCT,
29
+ CONF_PRODUCT_ID,
30
+ CONF_REFRESH_TOKEN,
31
+ CONF_REGION,
32
+ CONF_TERMINAL,
33
+ CONF_USERNAME,
34
+ DEFAULT_COUNTRY_NAME,
35
+ SUPPORTED_COUNTRYS,
36
+ DEFAULT_COUNTRY_CODE,
37
+ CONF_IS_OWNER,
38
+ ServerErrorCode,
39
+ )
40
+
41
+ _LOGGER = logging.getLogger(__name__)
42
+
43
+ # App ID used by the AiDot web/mobile client for /commons/userConfig and
44
+ # other cloud API calls that use owner+token headers.
45
+ _CLOUD_APP_ID = "68"
46
+
47
+
48
+ def rsa_password_encrypt(message: str) -> str:
49
+ """RSA-encrypt the password using the AiDot public key (for loginWithFreeVerification)."""
50
+ public_key = serialization.load_pem_public_key(
51
+ PUBLIC_KEY_PEM, backend=default_backend()
52
+ )
53
+ encrypted = public_key.encrypt(message.encode("utf-8"), padding.PKCS1v15())
54
+ return base64.b64encode(encrypted).decode("utf-8")
55
+
56
+
57
+ def md5_password(message: str) -> str:
58
+ """MD5 hex digest of the password (for /users/login web-app flow)."""
59
+ return hashlib.md5(message.encode("utf-8")).hexdigest()
60
+
61
+
62
+ class AidotClient:
63
+ _base_url: str = BASE_URL
64
+ _region: str = "us"
65
+ session: Optional[ClientSession] = None
66
+ username: str = ""
67
+ password: str = ""
68
+ country_name: str = DEFAULT_COUNTRY_NAME
69
+ country_code: str = DEFAULT_COUNTRY_CODE
70
+ _device_clients: dict[str, DeviceClient]
71
+
72
+ def __init__(
73
+ self,
74
+ session: Optional[ClientSession],
75
+ country_code: str | None = None,
76
+ username: str | None = None,
77
+ password: str | None = None,
78
+ token: dict | None = None,
79
+ ) -> None:
80
+ self.session = session
81
+ self.username = username
82
+ self.password = password
83
+ self.country_code = country_code
84
+ self.login_info: dict[str, Any] = {}
85
+ self._token_fresh_cb: Optional[Callable] = None
86
+ self._device_clients = {}
87
+ # Single-flight guard so a burst of camera 21026s (all 7 pollers at once)
88
+ # coalesces into ONE token refresh / re-login instead of 7 concurrent ones.
89
+ self._ensure_token_inflight: "Optional[asyncio.Future]" = None
90
+ for item in SUPPORTED_COUNTRYS:
91
+ if item["id"] == self.country_code:
92
+ self.country_name = item["name"]
93
+ self._region = item["region"].lower()
94
+ self._base_url = f"https://prod-{self._region}-api.arnoo.com/v17"
95
+ break
96
+ if token is not None:
97
+ self.login_info = token.copy()
98
+ self.username = token[CONF_USERNAME]
99
+ self.password = token[CONF_PASSWORD]
100
+ self._region = token[CONF_REGION]
101
+ self.country_name = token[CONF_COUNTRY]
102
+ self._base_url = f"https://prod-{self._region}-api.arnoo.com/v17"
103
+
104
+ def set_token_fresh_cb(self, callback) -> None:
105
+ self._token_fresh_cb = callback
106
+
107
+ def get_identifier(self) -> str:
108
+ return f"{self._region}-{self.username}"
109
+
110
+ def update_password(self, password: str) -> None:
111
+ self.password = password
112
+
113
+ async def async_post_login(self) -> dict[str, Any]:
114
+ """Login via loginWithFreeVerification (RSA-encrypted password)."""
115
+ url = f"{self._base_url}/users/loginWithFreeVerification"
116
+ headers = {CONF_APP_ID: APP_ID, CONF_TERMINAL: "app"}
117
+ data = {
118
+ "countryKey": f"region:{self.country_name.strip()}",
119
+ "username": self.username,
120
+ "password": rsa_password_encrypt(self.password),
121
+ "terminalId": "gvz3gjae10l4zii00t7y0",
122
+ "webVersion": "0.5.0",
123
+ "area": "Asia/Shanghai",
124
+ "UTC": "UTC+8",
125
+ }
126
+
127
+ response_data: dict = {}
128
+ try:
129
+ response = await self.session.post(url, headers=headers, json=data)
130
+ response_data = await response.json(content_type=None)
131
+ _LOGGER.debug("async_post_login HTTP=%d response: %s", response.status, response_data)
132
+ app_code = response_data.get(CONF_CODE)
133
+ if app_code == ServerErrorCode.USER_PWD_INCORRECT:
134
+ raise AidotUserOrPassIncorrect
135
+ if response.status >= 400:
136
+ raise aiohttp.ClientResponseError(
137
+ response.request_info, response.history,
138
+ status=response.status,
139
+ message=f"HTTP {response.status}: {response_data}",
140
+ )
141
+ # Update in place (clear + update) rather than rebinding, so any
142
+ # DeviceClient that captured this dict reference sees the refreshed
143
+ # token instead of holding a stale copy (camera HTTP calls otherwise
144
+ # 21026 "Please login again" after a re-login).
145
+ self.login_info.clear()
146
+ self.login_info.update(response_data)
147
+ self.login_info[CONF_PASSWORD] = self.password
148
+ self.login_info[CONF_REGION] = self._region
149
+ self.login_info[CONF_COUNTRY] = self.country_name
150
+ self.login_info[CONF_USERNAME] = self.username
151
+ # Fetch MQTT password from /commons/userConfig (separate call required).
152
+ await self._async_fetch_user_config()
153
+ return self.login_info
154
+ except AidotUserOrPassIncorrect:
155
+ raise
156
+ except aiohttp.ClientError as e:
157
+ _LOGGER.info("async_post_login ClientError %s response=%s", e, response_data)
158
+ raise
159
+
160
+ async def _async_fetch_user_config(self) -> None:
161
+ """Fetch /commons/userConfig and store mqttPassword in login_info.
162
+
163
+ The MQTT password for wss://{region}-mqtt.arnoo.com:8443/mqtt is returned
164
+ by this endpoint (changes on each login; only one MQTT connection allowed at once).
165
+ """
166
+ user_id = self.login_info.get(CONF_ID) or ""
167
+ token = self.login_info.get(CONF_ACCESS_TOKEN) or ""
168
+ if not user_id or not token:
169
+ _LOGGER.warning("_async_fetch_user_config: missing id or accessToken")
170
+ return
171
+
172
+ base = f"https://prod-{self._region}-api.arnoo.com"
173
+ url = f"{base}/commons/userConfig"
174
+ headers = {
175
+ "appid": _CLOUD_APP_ID,
176
+ "owner": user_id,
177
+ "token": token,
178
+ "terminal": "app",
179
+ "locale": "en-US",
180
+ "accept": "application/json, text/plain, */*",
181
+ }
182
+ try:
183
+ async with self.session.get(url, headers=headers,
184
+ timeout=aiohttp.ClientTimeout(total=10)) as resp:
185
+ body = await resp.json(content_type=None)
186
+ _LOGGER.debug("userConfig response: %s", body)
187
+ # The MQTT password field may be named mqttPassword or similar.
188
+ # Store the full response data alongside login_info for DeviceClient.
189
+ data = body if isinstance(body, dict) else {}
190
+ if isinstance(data.get("data"), dict):
191
+ data = data["data"]
192
+ # Always store the raw response for DeviceClient inspection.
193
+ self.login_info["_userConfigRaw"] = data
194
+ # Password may be at top level OR nested under 'mqtt' subkey.
195
+ mqtt_block = data.get("mqtt") or {}
196
+ if isinstance(mqtt_block, str):
197
+ try:
198
+ mqtt_block = json.loads(mqtt_block)
199
+ except Exception:
200
+ mqtt_block = {}
201
+ pwd = (data.get("mqttPassword")
202
+ or data.get("mqqtPwd")
203
+ or data.get("mqttPwd")
204
+ or mqtt_block.get("password")
205
+ or "")
206
+ if pwd:
207
+ self.login_info["mqttPassword"] = pwd
208
+ _LOGGER.info("_async_fetch_user_config: mqttPassword stored (len=%d)", len(pwd))
209
+ else:
210
+ _LOGGER.warning(
211
+ "_async_fetch_user_config: mqttPassword not found in response. "
212
+ "keys=%s body=%s", list(data.keys()), body
213
+ )
214
+ # Also extract MQTT clientId if provided.
215
+ client_id = data.get("mqttClientId") or mqtt_block.get("clientId") or ""
216
+ if client_id:
217
+ self.login_info["mqttClientId"] = client_id
218
+ _LOGGER.info("_async_fetch_user_config: mqttClientId stored: %s", client_id)
219
+ except Exception as exc:
220
+ _LOGGER.warning("_async_fetch_user_config failed: %s", exc)
221
+
222
+ async def async_refresh_token(self) -> dict[str, Any]:
223
+ url = f"{self._base_url}/users/refreshToken"
224
+ headers = {"appid": _CLOUD_APP_ID, "terminal": "app"}
225
+ data = {
226
+ CONF_REFRESH_TOKEN: self.login_info[CONF_REFRESH_TOKEN],
227
+ }
228
+
229
+ response_data: dict = {}
230
+ try:
231
+ response = await self.session.post(url, headers=headers, json=data)
232
+ response_data = await response.json()
233
+ response.raise_for_status()
234
+ self.login_info[CONF_ACCESS_TOKEN] = response_data[CONF_ACCESS_TOKEN]
235
+ if response_data[CONF_REFRESH_TOKEN] is not None:
236
+ self.login_info[CONF_REFRESH_TOKEN] = response_data[CONF_REFRESH_TOKEN]
237
+ _LOGGER.debug("refresh token ok code=%s", response_data.get(CONF_CODE))
238
+ if self._token_fresh_cb:
239
+ self._token_fresh_cb()
240
+ return response_data
241
+ except aiohttp.ClientError as e:
242
+ _LOGGER.info("async_refresh_token ClientError %s code=%s", e, response_data.get(CONF_CODE))
243
+ if response_data.get(CONF_CODE) == ServerErrorCode.LOGIN_INVALID:
244
+ raise AidotAuthFailed
245
+ return None
246
+
247
+ async def async_ensure_token(self) -> bool:
248
+ """Force a fresh access token for camera/smarthome HTTP calls.
249
+
250
+ Single-flight: concurrent callers (a burst of camera 21026s) share one
251
+ in-flight refresh instead of each starting their own re-login.
252
+ """
253
+ inflight = self._ensure_token_inflight
254
+ if inflight is not None and not inflight.done():
255
+ return await inflight
256
+ fut = asyncio.get_event_loop().create_future()
257
+ self._ensure_token_inflight = fut
258
+ try:
259
+ result = await self._do_ensure_token()
260
+ fut.set_result(result)
261
+ return result
262
+ except Exception as exc: # noqa: BLE001
263
+ fut.set_exception(exc)
264
+ raise
265
+ finally:
266
+ if self._ensure_token_inflight is fut:
267
+ self._ensure_token_inflight = None
268
+
269
+ async def _do_ensure_token(self) -> bool:
270
+ """Refresh the token (refresh-token first, then headless full re-login).
271
+
272
+ Updates ``login_info`` in place so DeviceClients see the new token, and
273
+ fires the token-fresh callback so HA persists it. Returns True on success.
274
+ """
275
+ try:
276
+ if self.login_info.get(CONF_REFRESH_TOKEN):
277
+ if await self.async_refresh_token() is not None:
278
+ return True
279
+ except AidotAuthFailed:
280
+ pass
281
+ except Exception as exc: # noqa: BLE001
282
+ _LOGGER.debug("async_ensure_token: refresh failed: %s", exc)
283
+ try:
284
+ await self.async_post_login()
285
+ if self._token_fresh_cb:
286
+ self._token_fresh_cb()
287
+ return True
288
+ except Exception as exc: # noqa: BLE001
289
+ _LOGGER.warning("async_ensure_token: re-login failed: %s", exc)
290
+ return False
291
+
292
+ async def async_session_get(
293
+ self, params: str, headers: dict[str, str] | None = None,
294
+ _retry: bool = True,
295
+ ) -> dict[str, Any]:
296
+ url = f"{self._base_url}{params}"
297
+ token = self.login_info[CONF_ACCESS_TOKEN]
298
+ if token is None:
299
+ raise AidotAuthFailed()
300
+ user_id = self.login_info.get(CONF_ID) or ""
301
+ if headers is None:
302
+ headers = {
303
+ "appid": _CLOUD_APP_ID,
304
+ "owner": user_id,
305
+ "token": token,
306
+ "terminal": "app",
307
+ "locale": "en-US",
308
+ }
309
+ response_data = {}
310
+ try:
311
+ response = await self.session.get(url, headers=headers)
312
+ response_data = await response.json()
313
+ response.raise_for_status()
314
+ return response_data
315
+ except aiohttp.ClientError as e:
316
+ _LOGGER.info("async_get ClientError %s %s", e, response_data)
317
+ code = response_data.get(CONF_CODE)
318
+ if code == ServerErrorCode.TOKEN_EXPIRED:
319
+ if not _retry:
320
+ raise AidotAuthFailed
321
+ await self.async_refresh_token()
322
+ return await self.async_session_get(params, _retry=False)
323
+ elif code in (ServerErrorCode.LOGIN_INVALID, 21027, 21041):
324
+ self.login_info[CONF_ACCESS_TOKEN] = None
325
+ raise AidotAuthFailed
326
+ raise
327
+
328
+ async def async_get_products(self, product_ids: str) -> list[dict[str, Any]]:
329
+ """Get device list."""
330
+ params = f"/products/{product_ids}"
331
+ return await self.async_session_get(params)
332
+
333
+ async def async_get_devices(self, house_id: str) -> list[dict[str, Any]]:
334
+ """Get device list."""
335
+ params = f"/devices?houseId={house_id}"
336
+ return await self.async_session_get(params)
337
+
338
+ async def async_get_houses(self) -> list[dict[str, Any]]:
339
+ """Get house list."""
340
+ params = "/houses"
341
+ return await self.async_session_get(params)
342
+
343
+ async def async_get_all_device(self) -> dict[str, Any]:
344
+ final_device_list: list[dict[str, Any]] = []
345
+ try:
346
+ houses = await self.async_get_houses()
347
+ for house in houses:
348
+ if house.get(CONF_IS_OWNER) is False:
349
+ continue
350
+ # get device_list
351
+ device_list = await self.async_get_devices(house[CONF_ID])
352
+ if device_list:
353
+ final_device_list.extend(device_list)
354
+
355
+ # get product_list
356
+ productIds = ",".join([item[CONF_PRODUCT_ID] for item in final_device_list])
357
+ product_list = await self.async_get_products(productIds)
358
+
359
+ for product in product_list:
360
+ for device in final_device_list:
361
+ if device[CONF_PRODUCT_ID] == product[CONF_ID]:
362
+ device[CONF_PRODUCT] = product
363
+ except Exception:
364
+ raise
365
+
366
+ # Share the full device ID list with every DeviceClient so that
367
+ # batchGetDeviceUserInfo is called with all IDs (the server may return
368
+ # empty results when only a single device ID is sent).
369
+ all_ids = [d.get(CONF_ID) for d in final_device_list if d.get(CONF_ID)]
370
+ for dc in self._device_clients.values():
371
+ dc._all_device_ids = all_ids
372
+
373
+ return {CONF_DEVICE_LIST: final_device_list}
374
+
375
+ def get_device_client(self, device: dict[str, Any]) -> DeviceClient:
376
+ device_id = device.get(CONF_ID)
377
+ device_client: DeviceClient = self._device_clients.get(device_id)
378
+ if device_client is None:
379
+ device_client = DeviceClient(device, self.login_info)
380
+ # Let the camera HTTP calls force a token refresh on 21026
381
+ # ("Please login again") and retry, like async_session_get does.
382
+ device_client.set_token_refresh_cb(self.async_ensure_token)
383
+ self._device_clients[device_id] = device_client
384
+ asyncio.get_running_loop().create_task(device_client.ping_task())
385
+ # NOTE: no LAN-broadcast discovery here. For APK parity, the camera's LAN
386
+ # IP comes from the WebRTC signaling host candidate (iceCandidateReq), which
387
+ # the camera advertises and we add verbatim - the official app does the same
388
+ # and never does a UDP discovery sweep.
389
+ return device_client
390
+
391
+ async def remove_device_client(self, dev_id: str) -> None:
392
+ device_client: DeviceClient = self._device_clients.get(dev_id)
393
+ if device_client is not None:
394
+ await device_client.close()
395
+ del self._device_clients[dev_id]
396
+
397
+ def cleanup(self) -> None:
398
+ for client in self._device_clients.values():
399
+ asyncio.get_running_loop().create_task(client.close())
400
+ self._device_clients.clear()
401
+
402
+ async def async_cleanup(self) -> None:
403
+ for client in self._device_clients.values():
404
+ await client.close()
405
+ self._device_clients.clear()