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/discover.py ADDED
@@ -0,0 +1,229 @@
1
+ import re
2
+ import socket
3
+ import json
4
+ import time
5
+ import logging
6
+ import asyncio
7
+ import subprocess
8
+ import sys
9
+ from typing import Any, List, Tuple
10
+
11
+ from .aes_utils import aes_encrypt, aes_decrypt
12
+ from .const import CONF_ID, CONF_IPADDRESS
13
+ from .exceptions import AidotOSError
14
+
15
+ _LOGGER = logging.getLogger(__name__)
16
+ _DISCOVER_TIME = 5
17
+
18
+
19
+ def _get_broadcast_candidates() -> List[Tuple[str, str]]:
20
+ """Return (bind_ip, broadcast_ip) pairs for every active IPv4 interface.
21
+
22
+ Sends a separate broadcast per interface so cameras are reachable
23
+ regardless of which interface the OS default route prefers.
24
+ Falls back to a single ("0.0.0.0", "255.255.255.255") entry if
25
+ interface enumeration is unavailable.
26
+ """
27
+ results: List[Tuple[str, str]] = []
28
+ try:
29
+ if sys.platform == "darwin":
30
+ # macOS ifconfig: "inet 192.168.1.175 netmask 0xffffff00 broadcast 192.168.1.255"
31
+ out = subprocess.check_output(
32
+ ["/sbin/ifconfig"], text=True, stderr=subprocess.DEVNULL
33
+ )
34
+ for m in re.finditer(
35
+ r"\binet\s+"
36
+ r"((?!127\.|169\.254\.)\d+\.\d+\.\d+\.\d+)"
37
+ r"\s+netmask\s+\S+"
38
+ r"\s+broadcast\s+"
39
+ r"(\d+\.\d+\.\d+\.\d+)",
40
+ out,
41
+ ):
42
+ results.append((m.group(1), m.group(2)))
43
+ else:
44
+ # Linux: "inet 192.168.1.x/24 brd 192.168.1.255"
45
+ out = subprocess.check_output(
46
+ ["ip", "addr", "show"], text=True, stderr=subprocess.DEVNULL
47
+ )
48
+ for m in re.finditer(
49
+ r"\binet\s+"
50
+ r"((?!127\.|169\.254\.)\d+\.\d+\.\d+\.\d+)/\d+"
51
+ r"\s+brd\s+"
52
+ r"(\d+\.\d+\.\d+\.\d+)",
53
+ out,
54
+ ):
55
+ results.append((m.group(1), m.group(2)))
56
+ except Exception as exc:
57
+ _LOGGER.debug("_get_broadcast_candidates: interface enumeration failed: %s", exc)
58
+
59
+ if not results:
60
+ # Fallback: let the OS pick the outgoing interface
61
+ results = [("0.0.0.0", "255.255.255.255")]
62
+
63
+ _LOGGER.debug("_get_broadcast_candidates: %s", results)
64
+ return results
65
+
66
+
67
+ class BroadcastProtocol:
68
+ _is_closed = False
69
+
70
+ def __init__(self, callback, user_id, broadcast_addr: str = "255.255.255.255") -> None:
71
+ self.aes_key = bytearray(32)
72
+ key_string = "T54uednca587"
73
+ key_bytes = key_string.encode()
74
+ self.aes_key[: len(key_bytes)] = key_bytes
75
+
76
+ self._discover_cb = callback
77
+ self.user_id = user_id
78
+ self._broadcast_addr = broadcast_addr
79
+
80
+ def connection_made(self, transport) -> None:
81
+ self.transport = transport
82
+ sock = transport.get_extra_info("socket")
83
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
84
+
85
+ def send_broadcast(self) -> None:
86
+ if self._is_closed:
87
+ _LOGGER.error("%s: connection is closed", self.user_id)
88
+ return
89
+ current_timestamp_milliseconds = int(time.time() * 1000)
90
+ seq = str(current_timestamp_milliseconds + 1)[-9:]
91
+ message = {
92
+ "protocolVer": "2.0.0",
93
+ "service": "device",
94
+ "method": "devDiscoveryReq",
95
+ "seq": seq,
96
+ "srcAddr": f"0.{self.user_id}]",
97
+ "tst": current_timestamp_milliseconds,
98
+ "payload": {
99
+ "extends": {},
100
+ "localCtrFlag": 1,
101
+ "timestamp": str(current_timestamp_milliseconds),
102
+ },
103
+ }
104
+ send_data = aes_encrypt(json.dumps(message).encode(), self.aes_key)
105
+ try:
106
+ sock = self.transport.get_extra_info("socket")
107
+ local_addr = sock.getsockname() if sock else ("?", 0)
108
+ self.transport.sendto(send_data, (self._broadcast_addr, 6666))
109
+ _LOGGER.info(
110
+ "discovery broadcast sent: %s:%s → %s:6666",
111
+ local_addr[0], local_addr[1], self._broadcast_addr,
112
+ )
113
+ except Exception as error:
114
+ _LOGGER.error("%s: send failed: %s", self.user_id, error)
115
+
116
+ def datagram_received(self, data, addr) -> None:
117
+ try:
118
+ data_str = aes_decrypt(data, self.aes_key)
119
+ data_json = json.loads(data_str)
120
+ except Exception as exc:
121
+ _LOGGER.debug("discovery: ignored undecodable packet from %s: %s", addr, exc)
122
+ return
123
+ if "payload" in data_json and "mac" in data_json["payload"]:
124
+ devId = data_json["payload"]["devId"]
125
+ if self._discover_cb:
126
+ self._discover_cb(devId, {CONF_IPADDRESS: addr[0]})
127
+
128
+ def error_received(self, exc) -> None:
129
+ _LOGGER.error("%s: error occurred: %s", self.user_id, exc)
130
+
131
+ def close(self) -> None:
132
+ try:
133
+ self.transport.close()
134
+ except Exception as error:
135
+ _LOGGER.error("close error: %s", error)
136
+
137
+ def connection_lost(self, exc) -> None:
138
+ self._is_closed = True
139
+ if exc:
140
+ _LOGGER.error("%s: connection lost: %s", self.user_id, exc)
141
+ else:
142
+ _LOGGER.info("%s: connection closed", self.user_id)
143
+
144
+
145
+ class Discover:
146
+ _login_info: dict[str, Any] = None
147
+ discovered_device: dict[str, str]
148
+ _is_close: bool = False
149
+
150
+ def __init__(self, login_info, callback):
151
+ self.discovered_device = {}
152
+ self._login_info = login_info
153
+ self._callback = callback
154
+ self._protocols: List[BroadcastProtocol] = []
155
+
156
+ async def _ensure_sockets(self) -> None:
157
+ """Create one datagram endpoint per active interface (idempotent)."""
158
+ if self._protocols:
159
+ return
160
+
161
+ candidates = _get_broadcast_candidates()
162
+ user_id = self._login_info[CONF_ID]
163
+
164
+ for bind_ip, broadcast_ip in candidates:
165
+ protocol = BroadcastProtocol(
166
+ self._discover_callback, user_id, broadcast_addr=broadcast_ip
167
+ )
168
+ try:
169
+ await asyncio.get_event_loop().create_datagram_endpoint(
170
+ lambda p=protocol: p,
171
+ local_addr=(bind_ip, 0),
172
+ )
173
+ self._protocols.append(protocol)
174
+ _LOGGER.debug(
175
+ "discovery socket: bind=%s broadcast=%s", bind_ip, broadcast_ip
176
+ )
177
+ except OSError as exc:
178
+ _LOGGER.debug(
179
+ "discovery socket bind %s failed: %s", bind_ip, exc
180
+ )
181
+
182
+ if not self._protocols:
183
+ # Last-resort fallback
184
+ protocol = BroadcastProtocol(self._discover_callback, user_id)
185
+ try:
186
+ await asyncio.get_event_loop().create_datagram_endpoint(
187
+ lambda: protocol,
188
+ local_addr=("0.0.0.0", 0),
189
+ )
190
+ self._protocols.append(protocol)
191
+ except OSError:
192
+ raise AidotOSError
193
+
194
+ # ---------------------------------------------------------------------- #
195
+ # Public API (kept compatible with existing callers)
196
+ # ---------------------------------------------------------------------- #
197
+
198
+ async def try_create_broadcast(self) -> None:
199
+ await self._ensure_sockets()
200
+
201
+ async def send_broadcast(self) -> None:
202
+ await self._ensure_sockets()
203
+ for proto in self._protocols:
204
+ proto.send_broadcast()
205
+
206
+ async def repeat_broadcast(self) -> None:
207
+ self._is_close = False
208
+ while True:
209
+ await self.send_broadcast()
210
+ for _ in range(_DISCOVER_TIME):
211
+ await asyncio.sleep(1)
212
+ if self._is_close:
213
+ return
214
+
215
+ async def fetch_devices_info(self) -> dict[str, str]:
216
+ await self.send_broadcast()
217
+ await asyncio.sleep(2)
218
+ return self.discovered_device
219
+
220
+ def _discover_callback(self, dev_id, event: dict[str, str]) -> None:
221
+ self.discovered_device[dev_id] = event[CONF_IPADDRESS]
222
+ if self._callback:
223
+ self._callback(dev_id, event)
224
+
225
+ def close(self) -> None:
226
+ self._is_close = True
227
+ for proto in self._protocols:
228
+ proto.close()
229
+ self._protocols.clear()
aidot/exceptions.py ADDED
@@ -0,0 +1,58 @@
1
+ """AiDot exception hierarchy."""
2
+
3
+
4
+ class AidotError(Exception):
5
+ """Base exception for all AiDot errors."""
6
+
7
+
8
+ class InvalidURL(AidotError):
9
+ """Invalid URL."""
10
+
11
+
12
+ class HTTPError(AidotError):
13
+ """HTTP request failed."""
14
+
15
+
16
+ class InvalidHost(AidotError):
17
+ """Invalid host."""
18
+
19
+
20
+ class AidotAuthTokenExpired(AidotError):
21
+ """Auth token is invalid or expired."""
22
+
23
+
24
+ class AidotAuthFailed(AidotError):
25
+ """Authentication failed."""
26
+
27
+
28
+ class AidotNotLogin(AidotError):
29
+ """Client is not logged in."""
30
+
31
+
32
+ class AidotUserOrPassIncorrect(AidotError):
33
+ """Username or password is incorrect."""
34
+
35
+
36
+ class AidotOSError(AidotError):
37
+ """OS-level error from the AiDot library."""
38
+
39
+
40
+ class AidotCameraBusy(AidotError):
41
+ """Camera refused the live stream with a TERMINAL ack code - retrying is futile.
42
+
43
+ Raised when a ``webrtcResp`` carries ``ack.code`` in the terminal set:
44
+ -50002 WEBRTC_ERROR_EN_RTC_ERR_CODE_SESSION_EXCEED (max concurrent streams)
45
+ -50015 LIVE_SD_MAX_CONNECT_ERROR (SD-card / connection cap)
46
+
47
+ The official app treats both as terminal (shows an error, does NOT retry -
48
+ decompiled LiveCameraView.java:765). Callers should surface the error rather
49
+ than burning their retry budget hammering a camera that already said no.
50
+ """
51
+
52
+ def __init__(self, code: int, desc: str = "") -> None:
53
+ self.code = code
54
+ self.desc = desc
55
+ msg = f"camera refused stream: ack code {code}"
56
+ if desc:
57
+ msg += f" ({desc})"
58
+ super().__init__(msg)
aidot/g711.py ADDED
@@ -0,0 +1,54 @@
1
+ """Pure-Python G.711 A-law encoder for two-way-audio (talk).
2
+
3
+ No stdlib ``audioop`` dependency - that module was removed in Python 3.13 and
4
+ Home Assistant runs on 3.12/3.13. A-law encode follows the ITU-T G.711
5
+ reference (Sun ``g711.c`` ``linear2alaw``); verified byte-identical to
6
+ ``audioop.lin2alaw`` over all 65536 samples on Python <=3.12.
7
+
8
+ The SDES talk pump (device_client) feeds 20 ms (320-byte) frames of s16le PCM
9
+ @ 8 kHz mono through :func:`pcm_to_alaw` to produce 160-byte PCMA (PT=8) RTP
10
+ payloads.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import struct
16
+
17
+ _SEG_AEND = (0x1F, 0x3F, 0x7F, 0xFF, 0x1FF, 0x3FF, 0x7FF, 0xFFF)
18
+
19
+
20
+ def _search(value: int) -> int:
21
+ for i, bound in enumerate(_SEG_AEND):
22
+ if value <= bound:
23
+ return i
24
+ return 8
25
+
26
+
27
+ def linear2alaw(pcm_val: int) -> int:
28
+ """Encode one signed 16-bit PCM sample to an 8-bit A-law byte."""
29
+ pcm_val = pcm_val >> 3 # 16-bit -> 13-bit
30
+ if pcm_val >= 0:
31
+ mask = 0xD5
32
+ else:
33
+ mask = 0x55
34
+ pcm_val = -pcm_val - 1
35
+ if pcm_val < 0:
36
+ pcm_val = 0
37
+ seg = _search(pcm_val)
38
+ if seg >= 8:
39
+ return 0x7F ^ mask
40
+ aval = seg << 4
41
+ if seg < 2:
42
+ aval |= (pcm_val >> 1) & 0x0F
43
+ else:
44
+ aval |= (pcm_val >> seg) & 0x0F
45
+ return aval ^ mask
46
+
47
+
48
+ def pcm_to_alaw(pcm: bytes) -> bytes:
49
+ """Encode little-endian signed 16-bit PCM bytes to A-law bytes."""
50
+ n = len(pcm) // 2
51
+ if n == 0:
52
+ return b""
53
+ samples = struct.unpack(f"<{n}h", pcm[: n * 2])
54
+ return bytes(linear2alaw(s) for s in samples)
aidot/login_const.py ADDED
@@ -0,0 +1,13 @@
1
+ """Constants for the aidot integration."""
2
+
3
+ APP_ID = "1383974540041977857"
4
+ BASE_URL = "https://prod-us-api.arnoo.com/v17"
5
+
6
+ PUBLIC_KEY_PEM = b"""
7
+ -----BEGIN PUBLIC KEY-----
8
+ MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCtQAnPCi8ksPnS1Du6z96PsKfN
9
+ p2Gp/f/bHwlrAdplbX3p7/TnGpnbJGkLq8uRxf6cw+vOthTsZjkPCF7CatRvRnTj
10
+ c9fcy7yE0oXa5TloYyXD6GkxgftBbN/movkJJGQCc7gFavuYoAdTRBOyQoXBtm0m
11
+ kXMSjXOldI/290b9BQIDAQAB
12
+ -----END PUBLIC KEY-----
13
+ """
@@ -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,15 @@
1
+ aidot/__init__.py,sha256=tIiGDn9iiYOeFRXbUkZdcCtc6-DvC9J1_sQZjPfPE4I,617
2
+ aidot/aes_utils.py,sha256=luqUzsTTJzZs2O861jGlAJLFZtH_6UBha4H-rvEXGtM,2175
3
+ aidot/client.py,sha256=MUKrq6U-bR9ZwJ1k_MHFJrQNixTeZDrGTyogfhpBE6Y,16967
4
+ aidot/const.py,sha256=ZL4E5jygope729oNt0ddEpaMNmPgrINHjf966gh6hzE,12811
5
+ aidot/credentials.py,sha256=OrusdjH_29Wi6Yq4IeMM1uA9swtf7EwX0-eIUBIUTl0,5784
6
+ aidot/device_client.py,sha256=zeuO1opwcw3ZyCtX1A18RVb3SA1LqWBh0HoAY-z8Oho,598731
7
+ aidot/discover.py,sha256=FBm8L18HS06VXMCtXB0OqxKN-PCEHpISIEeqWpELE0s,8165
8
+ aidot/exceptions.py,sha256=3XmliOOGVtTj96fdv87PkXqMgr7F8-e3tdPIWx1aFuU,1525
9
+ aidot/g711.py,sha256=Ls5BzApKzV0bOKrWiAw8iLqeHx4uUIoo3RJfxYMyPuA,1563
10
+ aidot/login_const.py,sha256=1Gg-hGav5AQ_DPJPIi21clXlx76EwFvhWrg1OQLdbY4,434
11
+ python_aidot_cameras-0.1.0.dist-info/licenses/LICENSE,sha256=5k6Ccd7T5u39Y8730J0qr1ahoaEkd1cahwEccuUZ2Dc,1079
12
+ python_aidot_cameras-0.1.0.dist-info/METADATA,sha256=_MNnaC8r2WOpHsq3wjSabi3xVF0HUM1p_2G9El7PG-M,2339
13
+ python_aidot_cameras-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
14
+ python_aidot_cameras-0.1.0.dist-info/top_level.txt,sha256=_doNL2OOnXeinm1X72eH2wz26wAkZwbM47KhNB6_QzI,6
15
+ python_aidot_cameras-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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 @@
1
+ aidot