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 +31 -0
- aidot/aes_utils.py +46 -0
- aidot/client.py +405 -0
- aidot/const.py +241 -0
- aidot/credentials.py +158 -0
- aidot/device_client.py +12209 -0
- aidot/discover.py +229 -0
- aidot/exceptions.py +58 -0
- aidot/g711.py +54 -0
- aidot/login_const.py +13 -0
- python_aidot_cameras-0.1.0.dist-info/METADATA +61 -0
- python_aidot_cameras-0.1.0.dist-info/RECORD +15 -0
- python_aidot_cameras-0.1.0.dist-info/WHEEL +5 -0
- python_aidot_cameras-0.1.0.dist-info/licenses/LICENSE +21 -0
- python_aidot_cameras-0.1.0.dist-info/top_level.txt +1 -0
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()
|