python-aidot-cameras 0.1.0__tar.gz

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.
Files changed (30) hide show
  1. python_aidot_cameras-0.1.0/LICENSE +21 -0
  2. python_aidot_cameras-0.1.0/PKG-INFO +61 -0
  3. python_aidot_cameras-0.1.0/README.md +43 -0
  4. python_aidot_cameras-0.1.0/aidot/__init__.py +31 -0
  5. python_aidot_cameras-0.1.0/aidot/aes_utils.py +46 -0
  6. python_aidot_cameras-0.1.0/aidot/client.py +405 -0
  7. python_aidot_cameras-0.1.0/aidot/const.py +241 -0
  8. python_aidot_cameras-0.1.0/aidot/credentials.py +158 -0
  9. python_aidot_cameras-0.1.0/aidot/device_client.py +12209 -0
  10. python_aidot_cameras-0.1.0/aidot/discover.py +229 -0
  11. python_aidot_cameras-0.1.0/aidot/exceptions.py +58 -0
  12. python_aidot_cameras-0.1.0/aidot/g711.py +54 -0
  13. python_aidot_cameras-0.1.0/aidot/login_const.py +13 -0
  14. python_aidot_cameras-0.1.0/pyproject.toml +22 -0
  15. python_aidot_cameras-0.1.0/python_aidot_cameras.egg-info/PKG-INFO +61 -0
  16. python_aidot_cameras-0.1.0/python_aidot_cameras.egg-info/SOURCES.txt +29 -0
  17. python_aidot_cameras-0.1.0/python_aidot_cameras.egg-info/dependency_links.txt +1 -0
  18. python_aidot_cameras-0.1.0/python_aidot_cameras.egg-info/top_level.txt +1 -0
  19. python_aidot_cameras-0.1.0/setup.cfg +7 -0
  20. python_aidot_cameras-0.1.0/setup.py +40 -0
  21. python_aidot_cameras-0.1.0/tests/test_alarm_event.py +69 -0
  22. python_aidot_cameras-0.1.0/tests/test_highport_nomination.py +181 -0
  23. python_aidot_cameras-0.1.0/tests/test_motion_poll.py +88 -0
  24. python_aidot_cameras-0.1.0/tests/test_sdes_talk.py +191 -0
  25. python_aidot_cameras-0.1.0/tests/test_sdes_watchdog.py +61 -0
  26. python_aidot_cameras-0.1.0/tests/test_speak.py +106 -0
  27. python_aidot_cameras-0.1.0/tests/test_stream_cap.py +76 -0
  28. python_aidot_cameras-0.1.0/tests/test_talk.py +105 -0
  29. python_aidot_cameras-0.1.0/tests/test_terminal_ack.py +72 -0
  30. python_aidot_cameras-0.1.0/tests/test_token_refresh.py +79 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Aidot Development Team
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,61 @@
1
+ Metadata-Version: 2.4
2
+ Name: python-aidot-cameras
3
+ Version: 0.1.0
4
+ Summary: Adds camera support to AiDot-Development-Team's python-AiDot library
5
+ Home-page: https://github.com/cbrightly/python-AiDot
6
+ Author: aidotdev2024
7
+ Author-email: Chris Brightly <chris.brightly@gmail.com>
8
+ Project-URL: Homepage, https://github.com
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Requires-Python: >=3.8
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Dynamic: author
16
+ Dynamic: home-page
17
+ Dynamic: license-file
18
+
19
+ # python-aidot
20
+
21
+ Control AIDOT WiFi lights **and cameras** from Python and Home Assistant.
22
+
23
+ This is a camera-capable fork of the upstream lights-only
24
+ [`python-aidot`](https://github.com/Aidot-Development-Team/python-aidot). It adds
25
+ live WebRTC video streaming (DTLS and SDES-SRTP paths), snapshots, PTZ, camera
26
+ controls, cloud recordings/thumbnails, and two-way (push-to-talk) audio, plus a
27
+ Home Assistant custom component that exposes all of it.
28
+
29
+ ## Library install
30
+
31
+ The camera support is **not published to PyPI** (PyPI only has the upstream
32
+ lights-only releases). Install this fork's library directly from the repo:
33
+
34
+ ```bash
35
+ # lights + camera cloud/control only:
36
+ pip install "git+https://github.com/cbrightly/python-AiDot"
37
+ # add live WebRTC streaming, snapshots, and two-way audio:
38
+ pip install "python-aidot[webrtc] @ git+https://github.com/cbrightly/python-AiDot"
39
+ ```
40
+
41
+ ## Home Assistant component
42
+
43
+ The custom component lives in `custom_components/aidot/`. Its `manifest.json`
44
+ lists the third-party Python dependencies (aiortc, av, paho-mqtt, …) so Home
45
+ Assistant installs those automatically, **but the `aidot` library itself is not
46
+ on PyPI** - install it into Home Assistant's Python environment first:
47
+
48
+ ```bash
49
+ # inside the HA venv / container
50
+ pip install "python-aidot[webrtc] @ git+https://github.com/cbrightly/python-AiDot"
51
+ ```
52
+
53
+ Then copy `custom_components/aidot/` into your HA `config/custom_components/`
54
+ folder (or add this repo to HACS as a custom repository) and restart Home
55
+ Assistant.
56
+
57
+ ## CLI
58
+
59
+ `test_camera.py` exercises the camera features directly - discovery, LAN probe,
60
+ WebRTC streaming, snapshots, recordings, attribute get/set, and two-way audio
61
+ (`--talk`). Run `python test_camera.py --help` for the full list.
@@ -0,0 +1,43 @@
1
+ # python-aidot
2
+
3
+ Control AIDOT WiFi lights **and cameras** from Python and Home Assistant.
4
+
5
+ This is a camera-capable fork of the upstream lights-only
6
+ [`python-aidot`](https://github.com/Aidot-Development-Team/python-aidot). It adds
7
+ live WebRTC video streaming (DTLS and SDES-SRTP paths), snapshots, PTZ, camera
8
+ controls, cloud recordings/thumbnails, and two-way (push-to-talk) audio, plus a
9
+ Home Assistant custom component that exposes all of it.
10
+
11
+ ## Library install
12
+
13
+ The camera support is **not published to PyPI** (PyPI only has the upstream
14
+ lights-only releases). Install this fork's library directly from the repo:
15
+
16
+ ```bash
17
+ # lights + camera cloud/control only:
18
+ pip install "git+https://github.com/cbrightly/python-AiDot"
19
+ # add live WebRTC streaming, snapshots, and two-way audio:
20
+ pip install "python-aidot[webrtc] @ git+https://github.com/cbrightly/python-AiDot"
21
+ ```
22
+
23
+ ## Home Assistant component
24
+
25
+ The custom component lives in `custom_components/aidot/`. Its `manifest.json`
26
+ lists the third-party Python dependencies (aiortc, av, paho-mqtt, …) so Home
27
+ Assistant installs those automatically, **but the `aidot` library itself is not
28
+ on PyPI** - install it into Home Assistant's Python environment first:
29
+
30
+ ```bash
31
+ # inside the HA venv / container
32
+ pip install "python-aidot[webrtc] @ git+https://github.com/cbrightly/python-AiDot"
33
+ ```
34
+
35
+ Then copy `custom_components/aidot/` into your HA `config/custom_components/`
36
+ folder (or add this repo to HACS as a custom repository) and restart Home
37
+ Assistant.
38
+
39
+ ## CLI
40
+
41
+ `test_camera.py` exercises the camera features directly - discovery, LAN probe,
42
+ WebRTC streaming, snapshots, recordings, attribute get/set, and two-way audio
43
+ (`--talk`). Run `python test_camera.py --help` for the full list.
@@ -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
+ ]
@@ -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()
@@ -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()