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.
- python_aidot_cameras-0.1.0/LICENSE +21 -0
- python_aidot_cameras-0.1.0/PKG-INFO +61 -0
- python_aidot_cameras-0.1.0/README.md +43 -0
- python_aidot_cameras-0.1.0/aidot/__init__.py +31 -0
- python_aidot_cameras-0.1.0/aidot/aes_utils.py +46 -0
- python_aidot_cameras-0.1.0/aidot/client.py +405 -0
- python_aidot_cameras-0.1.0/aidot/const.py +241 -0
- python_aidot_cameras-0.1.0/aidot/credentials.py +158 -0
- python_aidot_cameras-0.1.0/aidot/device_client.py +12209 -0
- python_aidot_cameras-0.1.0/aidot/discover.py +229 -0
- python_aidot_cameras-0.1.0/aidot/exceptions.py +58 -0
- python_aidot_cameras-0.1.0/aidot/g711.py +54 -0
- python_aidot_cameras-0.1.0/aidot/login_const.py +13 -0
- python_aidot_cameras-0.1.0/pyproject.toml +22 -0
- python_aidot_cameras-0.1.0/python_aidot_cameras.egg-info/PKG-INFO +61 -0
- python_aidot_cameras-0.1.0/python_aidot_cameras.egg-info/SOURCES.txt +29 -0
- python_aidot_cameras-0.1.0/python_aidot_cameras.egg-info/dependency_links.txt +1 -0
- python_aidot_cameras-0.1.0/python_aidot_cameras.egg-info/top_level.txt +1 -0
- python_aidot_cameras-0.1.0/setup.cfg +7 -0
- python_aidot_cameras-0.1.0/setup.py +40 -0
- python_aidot_cameras-0.1.0/tests/test_alarm_event.py +69 -0
- python_aidot_cameras-0.1.0/tests/test_highport_nomination.py +181 -0
- python_aidot_cameras-0.1.0/tests/test_motion_poll.py +88 -0
- python_aidot_cameras-0.1.0/tests/test_sdes_talk.py +191 -0
- python_aidot_cameras-0.1.0/tests/test_sdes_watchdog.py +61 -0
- python_aidot_cameras-0.1.0/tests/test_speak.py +106 -0
- python_aidot_cameras-0.1.0/tests/test_stream_cap.py +76 -0
- python_aidot_cameras-0.1.0/tests/test_talk.py +105 -0
- python_aidot_cameras-0.1.0/tests/test_terminal_ack.py +72 -0
- 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()
|