python-aidot-cameras 0.6.1__tar.gz → 0.7.1__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.6.1/src/python_aidot_cameras.egg-info → python_aidot_cameras-0.7.1}/PKG-INFO +1 -1
- {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.1}/pyproject.toml +1 -1
- {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.1}/src/aidot/camera/client.py +23 -0
- python_aidot_cameras-0.7.1/src/aidot/camera/lan_control.py +311 -0
- {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.1/src/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
- {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.1}/src/python_aidot_cameras.egg-info/SOURCES.txt +2 -0
- python_aidot_cameras-0.7.1/tests/test_lan_control.py +82 -0
- {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.1}/LICENSE +0 -0
- {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.1}/README.md +0 -0
- {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.1}/setup.cfg +0 -0
- {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.1}/src/aidot/__init__.py +0 -0
- {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.1}/src/aidot/aes_utils.py +0 -0
- {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.1}/src/aidot/camera/__init__.py +0 -0
- {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.1}/src/aidot/client.py +0 -0
- {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.1}/src/aidot/const.py +0 -0
- {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.1}/src/aidot/credentials.py +0 -0
- {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.1}/src/aidot/device_client.py +0 -0
- {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.1}/src/aidot/discover.py +0 -0
- {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.1}/src/aidot/exceptions.py +0 -0
- {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.1}/src/aidot/g711.py +0 -0
- {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.1}/src/aidot/login_const.py +0 -0
- {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.1}/src/aidot/models/__init__.py +0 -0
- {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.1}/src/aidot/models/device_client_model.py +0 -0
- {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.1}/src/aidot/models/device_model.py +0 -0
- {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.1}/src/aidot/models/discover_model.py +0 -0
- {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.1}/src/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
- {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.1}/src/python_aidot_cameras.egg-info/requires.txt +0 -0
- {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.1}/src/python_aidot_cameras.egg-info/top_level.txt +0 -0
- {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.1}/tests/test_alarm_event.py +0 -0
- {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.1}/tests/test_highport_nomination.py +0 -0
- {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.1}/tests/test_motion_poll.py +0 -0
- {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.1}/tests/test_no_undefined_names.py +0 -0
- {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.1}/tests/test_post_merge_hardening.py +0 -0
- {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.1}/tests/test_sdes_idle_release.py +0 -0
- {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.1}/tests/test_sdes_sprop.py +0 -0
- {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.1}/tests/test_sdes_talk.py +0 -0
- {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.1}/tests/test_sdes_watchdog.py +0 -0
- {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.1}/tests/test_speak.py +0 -0
- {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.1}/tests/test_stream_cap.py +0 -0
- {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.1}/tests/test_talk.py +0 -0
- {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.1}/tests/test_terminal_ack.py +0 -0
- {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.1}/tests/test_token_refresh.py +0 -0
{python_aidot_cameras-0.6.1/src/python_aidot_cameras.egg-info → python_aidot_cameras-0.7.1}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-aidot-cameras
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.1
|
|
4
4
|
Summary: Control AiDot/Leedarson WiFi lights and cameras (WebRTC streaming, two-way audio, PTZ, controls)
|
|
5
5
|
Author-email: cbrightly <chris.brightly@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-aidot-cameras"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.7.1"
|
|
8
8
|
description = "Control AiDot/Leedarson WiFi lights and cameras (WebRTC streaming, two-way audio, PTZ, controls)"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -2689,6 +2689,17 @@ class CameraMixin:
|
|
|
2689
2689
|
# default it so get_send_packet and friends see None instead of raising.
|
|
2690
2690
|
aes_key = None
|
|
2691
2691
|
|
|
2692
|
+
# Optional LAN control client; when attached, attribute writes go local-first.
|
|
2693
|
+
_lan_client = None
|
|
2694
|
+
|
|
2695
|
+
def attach_lan_client(self, lan_client) -> None:
|
|
2696
|
+
"""Route camera attribute writes through ``lan_client`` (local-first,
|
|
2697
|
+
cloud-fallback). Pass an ``aidot.camera.lan_control.CameraLanClient``."""
|
|
2698
|
+
self._lan_client = lan_client
|
|
2699
|
+
|
|
2700
|
+
def detach_lan_client(self) -> None:
|
|
2701
|
+
self._lan_client = None
|
|
2702
|
+
|
|
2692
2703
|
def _init_camera_state(self, device: dict, user_info: dict) -> None:
|
|
2693
2704
|
"""Camera-side state init, called once at the end of DeviceClient.__init__.
|
|
2694
2705
|
|
|
@@ -3501,10 +3512,22 @@ class CameraMixin:
|
|
|
3501
3512
|
we add it ourselves. onlyPubAck=false means camera sends
|
|
3502
3513
|
setDevAttrResp matched by seq. _mqtt_device_cmd sends a
|
|
3503
3514
|
lowPowerActiveStateReq wake signal before this command.
|
|
3515
|
+
Local-first: if a LAN control client is attached (see
|
|
3516
|
+
:meth:`attach_lan_client`) and reachable, the attribute is set over the
|
|
3517
|
+
LAN and the cloud round-trip is skipped; any LAN failure falls through to
|
|
3518
|
+
the MQTT path below.
|
|
3504
3519
|
"""
|
|
3505
3520
|
import json as _json
|
|
3506
3521
|
import random as _random
|
|
3507
3522
|
|
|
3523
|
+
lan = getattr(self, "_lan_client", None)
|
|
3524
|
+
if lan is not None:
|
|
3525
|
+
try:
|
|
3526
|
+
if await lan.async_set_attributes({attr: value}):
|
|
3527
|
+
return True
|
|
3528
|
+
except Exception as _exc: # noqa: BLE001 - any LAN error -> cloud fallback
|
|
3529
|
+
_LOGGER.debug("LAN set %s failed (%s); falling back to cloud", attr, _exc)
|
|
3530
|
+
|
|
3508
3531
|
device_id = self.device_id
|
|
3509
3532
|
user_id = self.user_id
|
|
3510
3533
|
# camera's local password (from device API dict 'password' field)
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""Offline LAN device-control for AiDot cameras.
|
|
2
|
+
|
|
3
|
+
This is the **control plane only** — settings, toggles, PTZ-adjacent attributes and
|
|
4
|
+
status — over the camera's local TCP:10000 channel. It does NOT do video; video is
|
|
5
|
+
WebRTC (``liveType=2``) and stays cloud-MQTT-signaled.
|
|
6
|
+
|
|
7
|
+
Design constraints (validated against LK.IPC.A000088 firmware):
|
|
8
|
+
|
|
9
|
+
* The control channel speaks the lights' local protocol: an 8-byte header
|
|
10
|
+
(``magic 0x1EED`` + ``msgtype:int16`` + ``bodysize:int32``) followed by an
|
|
11
|
+
AES-ECB-encrypted JSON body, keyed by the device's 16-char ``aesKey``.
|
|
12
|
+
* It is **single-session**: a second ``loginReq`` evicts the first. So this client
|
|
13
|
+
never holds a socket open — every operation is a short-lived
|
|
14
|
+
*connect → login → command(s) → close*, serialized by a per-camera lock.
|
|
15
|
+
* The camera acks changes with ``setDevAttrResp`` but emits no ``setDevAttrNotif``,
|
|
16
|
+
so there is no push; status is obtained by polling :meth:`async_get_attributes`.
|
|
17
|
+
* Only cameras that advertise ``localCtrFlag == 1`` on unicast discovery AND are
|
|
18
|
+
mains-powered are eligible. Battery models sleep and do not answer unicast.
|
|
19
|
+
|
|
20
|
+
Credentials (``aesKey`` + ``password``) come from the cloud device dict once and are
|
|
21
|
+
stable per device; cache them and this client runs with no cloud contact.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import asyncio
|
|
27
|
+
import json
|
|
28
|
+
import logging
|
|
29
|
+
import socket
|
|
30
|
+
import struct
|
|
31
|
+
import time
|
|
32
|
+
from datetime import datetime
|
|
33
|
+
from typing import Any, Optional
|
|
34
|
+
|
|
35
|
+
from ..aes_utils import aes_encrypt, aes_decrypt
|
|
36
|
+
|
|
37
|
+
_LOGGER = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
_CONTROL_PORT = 10000
|
|
40
|
+
_DISCOVER_PORT = 6666
|
|
41
|
+
_MAGIC = 0x1EED
|
|
42
|
+
# Well-known AES key for the unprovisioned discovery exchange (lights + cameras),
|
|
43
|
+
# zero-padded to 32 bytes. Matches aidot.discover.BroadcastProtocol.
|
|
44
|
+
_DISCOVER_KEY = bytearray(b"T54uednca587".ljust(32, b"\x00"))
|
|
45
|
+
|
|
46
|
+
# Camera attribute keys that are safe, mains-relevant device-control toggles.
|
|
47
|
+
# (Maps a friendly name -> on-the-wire attr key.)
|
|
48
|
+
ATTR_KEYS = {
|
|
49
|
+
"status_led": "LedOnOff",
|
|
50
|
+
"motion_detection": "MotionDetection_Enable",
|
|
51
|
+
"motion_sensitivity": "MotionDetection_Sen",
|
|
52
|
+
"night_vision_mode": "nightVisionMode",
|
|
53
|
+
"ir_light": "nightVisionIRLight",
|
|
54
|
+
"microphone": "micEnable",
|
|
55
|
+
"speaker_volume": "SoundLevel",
|
|
56
|
+
"ptz_tracking": "trackingMode",
|
|
57
|
+
"floodlight": "LightOnOff",
|
|
58
|
+
"siren": "sirenRing",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _pack(msgtype: int, body: bytes) -> bytes:
|
|
63
|
+
return struct.pack(">Hhi", _MAGIC, msgtype, len(body)) + body
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def _read_frame(reader: asyncio.StreamReader, timeout: float) -> dict:
|
|
67
|
+
"""Read one 8-byte-header AES frame; return the decoded JSON dict."""
|
|
68
|
+
header = await asyncio.wait_for(reader.readexactly(8), timeout)
|
|
69
|
+
_magic, _mtype, bodysize = struct.unpack(">HHI", header)
|
|
70
|
+
body = await asyncio.wait_for(reader.readexactly(bodysize), timeout)
|
|
71
|
+
return body # caller decrypts (key is on the client)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def discover_unicast(ip: str, timeout: float = 2.0) -> Optional[dict]:
|
|
75
|
+
"""Send a unicast ``devDiscoveryReq`` to ``ip`` and return the reply payload.
|
|
76
|
+
|
|
77
|
+
Cameras ignore the broadcast sweep but answer a unicast probe. Returns the
|
|
78
|
+
``payload`` dict (``devId``, ``mac``, ``productModel``, ``lanMode``,
|
|
79
|
+
``localCtrFlag`` …) or ``None`` if nothing answered.
|
|
80
|
+
"""
|
|
81
|
+
msg = {
|
|
82
|
+
"protocolVer": "2.0.0",
|
|
83
|
+
"service": "device",
|
|
84
|
+
"method": "devDiscoveryReq",
|
|
85
|
+
"seq": str(int(time.time() * 1000))[-9:],
|
|
86
|
+
"srcAddr": "0.lan",
|
|
87
|
+
"tst": int(time.time() * 1000),
|
|
88
|
+
"payload": {"extends": {}, "localCtrFlag": 1,
|
|
89
|
+
"timestamp": str(int(time.time() * 1000))},
|
|
90
|
+
}
|
|
91
|
+
enc = aes_encrypt(json.dumps(msg).encode(), _DISCOVER_KEY)
|
|
92
|
+
|
|
93
|
+
loop = asyncio.get_running_loop()
|
|
94
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
95
|
+
sock.setblocking(False)
|
|
96
|
+
try:
|
|
97
|
+
await loop.sock_sendto(sock, enc, (ip, _DISCOVER_PORT))
|
|
98
|
+
try:
|
|
99
|
+
data = await asyncio.wait_for(loop.sock_recv(sock, 8192), timeout)
|
|
100
|
+
except asyncio.TimeoutError:
|
|
101
|
+
return None
|
|
102
|
+
try:
|
|
103
|
+
return json.loads(aes_decrypt(data, _DISCOVER_KEY)).get("payload")
|
|
104
|
+
except Exception: # noqa: BLE001 - undecodable / foreign packet
|
|
105
|
+
return None
|
|
106
|
+
finally:
|
|
107
|
+
sock.close()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _local_ipv4() -> Optional[str]:
|
|
111
|
+
"""Best-effort primary IPv4 of this host (no traffic sent)."""
|
|
112
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
113
|
+
try:
|
|
114
|
+
s.connect(("192.0.2.1", 9)) # TEST-NET-1; just selects an interface
|
|
115
|
+
return s.getsockname()[0]
|
|
116
|
+
except OSError:
|
|
117
|
+
return None
|
|
118
|
+
finally:
|
|
119
|
+
s.close()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
async def discover_subnet(cidr24: Optional[str] = None, timeout: float = 3.0,
|
|
123
|
+
concurrency: int = 64) -> dict[str, str]:
|
|
124
|
+
"""Unicast-sweep a /24 and return ``{devId: ip}`` for every camera that answers.
|
|
125
|
+
|
|
126
|
+
Cameras ignore the broadcast discovery sweep but answer a unicast probe, so the
|
|
127
|
+
only way to map a camera's devId to its LAN IP locally is to probe each host.
|
|
128
|
+
``cidr24`` is the first three octets (e.g. ``"192.168.1"``); if omitted it is
|
|
129
|
+
derived from this host's primary IPv4. Cheap (254 UDP probes, ~timeout wall).
|
|
130
|
+
"""
|
|
131
|
+
if cidr24 is None:
|
|
132
|
+
ip = _local_ipv4()
|
|
133
|
+
if not ip:
|
|
134
|
+
return {}
|
|
135
|
+
cidr24 = ip.rsplit(".", 1)[0]
|
|
136
|
+
|
|
137
|
+
sem = asyncio.Semaphore(concurrency)
|
|
138
|
+
found: dict[str, str] = {}
|
|
139
|
+
|
|
140
|
+
async def probe(host: str) -> None:
|
|
141
|
+
async with sem:
|
|
142
|
+
payload = await discover_unicast(host, timeout=timeout)
|
|
143
|
+
if payload and payload.get("devId"):
|
|
144
|
+
found[payload["devId"]] = host
|
|
145
|
+
|
|
146
|
+
await asyncio.gather(*(probe(f"{cidr24}.{n}") for n in range(1, 255)))
|
|
147
|
+
return found
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class CameraLanError(Exception):
|
|
151
|
+
"""A local control operation failed (connect, login, or protocol error)."""
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class CameraLanClient:
|
|
155
|
+
"""Short-lived local device-control client for one camera.
|
|
156
|
+
|
|
157
|
+
Build from the cloud device dict + login info; optionally pass a known LAN ``ip``
|
|
158
|
+
(otherwise call :meth:`async_resolve` to unicast-discover it). All control calls
|
|
159
|
+
are serialized and each opens/closes its own socket.
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
def __init__(self, device: dict[str, Any], user_info: dict[str, Any],
|
|
163
|
+
ip: Optional[str] = None) -> None:
|
|
164
|
+
self.device_id: str = device.get("id") or device.get("devId")
|
|
165
|
+
_aes = device.get("aesKey") or []
|
|
166
|
+
key_str = _aes[0] if isinstance(_aes, list) and _aes else (_aes or "")
|
|
167
|
+
if not key_str:
|
|
168
|
+
raise CameraLanError(f"{self.device_id}: no aesKey in device dict")
|
|
169
|
+
self._key = bytearray(16)
|
|
170
|
+
kb = key_str.encode()
|
|
171
|
+
self._key[: len(kb)] = kb
|
|
172
|
+
self._password = device.get("password") or ""
|
|
173
|
+
self._user_id = user_info.get("id")
|
|
174
|
+
self._model_id = device.get("modelId") or ""
|
|
175
|
+
self._ip = ip
|
|
176
|
+
self._lock = asyncio.Lock() # single in-flight session per camera
|
|
177
|
+
self._eligible: Optional[bool] = None # set by async_resolve
|
|
178
|
+
|
|
179
|
+
# -- discovery / eligibility ------------------------------------------- #
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def ip(self) -> Optional[str]:
|
|
183
|
+
return self._ip
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def eligible(self) -> Optional[bool]:
|
|
187
|
+
"""True/False once resolved, else None. Eligible = local control advertised
|
|
188
|
+
and the device is mains-powered (battery models excluded)."""
|
|
189
|
+
return self._eligible
|
|
190
|
+
|
|
191
|
+
async def async_resolve(self, ip: Optional[str] = None) -> bool:
|
|
192
|
+
"""Unicast-discover the camera (at ``ip`` or the configured ip) and gate
|
|
193
|
+
eligibility on ``localCtrFlag``. Returns True if locally controllable."""
|
|
194
|
+
ip = ip or self._ip
|
|
195
|
+
if ip is None:
|
|
196
|
+
self._eligible = False
|
|
197
|
+
return False
|
|
198
|
+
payload = await discover_unicast(ip)
|
|
199
|
+
if not payload or payload.get("devId") != self.device_id:
|
|
200
|
+
self._eligible = False
|
|
201
|
+
return False
|
|
202
|
+
self._ip = ip
|
|
203
|
+
# localCtrFlag==1 / lanMode==1 advertise local control. Battery gating is
|
|
204
|
+
# confirmed separately on the first getDevAttr (battery_remaining is None).
|
|
205
|
+
self._eligible = bool(payload.get("localCtrFlag")) or bool(payload.get("lanMode"))
|
|
206
|
+
return self._eligible
|
|
207
|
+
|
|
208
|
+
# -- low-level session ------------------------------------------------- #
|
|
209
|
+
|
|
210
|
+
async def _session(self, messages):
|
|
211
|
+
"""Open one short-lived session, send each built message, collect replies.
|
|
212
|
+
|
|
213
|
+
``messages`` is a callable ``(user_id, ascNumber) -> list[(msgtype, dict)]``
|
|
214
|
+
built after login so it can use the camera's ascNumber. Returns the list of
|
|
215
|
+
decoded reply dicts (excluding the login response).
|
|
216
|
+
"""
|
|
217
|
+
if self._ip is None:
|
|
218
|
+
raise CameraLanError(f"{self.device_id}: no LAN ip (call async_resolve)")
|
|
219
|
+
async with self._lock:
|
|
220
|
+
try:
|
|
221
|
+
reader, writer = await asyncio.wait_for(
|
|
222
|
+
asyncio.open_connection(self._ip, _CONTROL_PORT), timeout=5.0
|
|
223
|
+
)
|
|
224
|
+
except (OSError, asyncio.TimeoutError) as exc:
|
|
225
|
+
raise CameraLanError(f"{self.device_id}: connect failed: {exc}") from exc
|
|
226
|
+
try:
|
|
227
|
+
asc = await self._login(reader, writer)
|
|
228
|
+
replies = []
|
|
229
|
+
for msgtype, body_obj in messages(self._user_id, asc):
|
|
230
|
+
writer.write(_pack(msgtype, aes_encrypt(
|
|
231
|
+
json.dumps(body_obj).encode(), self._key)))
|
|
232
|
+
await writer.drain()
|
|
233
|
+
# The camera may emit an intermediate frame before the
|
|
234
|
+
# ``*Resp`` (e.g. a status echo before ``setDevAttrResp``), so
|
|
235
|
+
# drain until a response method arrives or the read times out.
|
|
236
|
+
for _ in range(4):
|
|
237
|
+
try:
|
|
238
|
+
raw = await _read_frame(reader, timeout=4.0)
|
|
239
|
+
except (asyncio.TimeoutError, asyncio.IncompleteReadError):
|
|
240
|
+
break
|
|
241
|
+
frame = json.loads(aes_decrypt(raw, self._key))
|
|
242
|
+
replies.append(frame)
|
|
243
|
+
if str(frame.get("method", "")).endswith("Resp"):
|
|
244
|
+
break
|
|
245
|
+
return replies
|
|
246
|
+
finally:
|
|
247
|
+
writer.close()
|
|
248
|
+
try:
|
|
249
|
+
await writer.wait_closed()
|
|
250
|
+
except Exception: # noqa: BLE001
|
|
251
|
+
pass
|
|
252
|
+
|
|
253
|
+
async def _login(self, reader, writer) -> int:
|
|
254
|
+
seq = str(int(time.time() * 1000))[-9:]
|
|
255
|
+
msg = {
|
|
256
|
+
"service": "device", "method": "loginReq", "seq": seq,
|
|
257
|
+
"srcAddr": self._user_id, "deviceId": self.device_id,
|
|
258
|
+
"payload": {"userId": self._user_id, "password": self._password,
|
|
259
|
+
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S.000"),
|
|
260
|
+
"ascNumber": 1},
|
|
261
|
+
}
|
|
262
|
+
writer.write(_pack(1, aes_encrypt(json.dumps(msg).encode(), self._key)))
|
|
263
|
+
await writer.drain()
|
|
264
|
+
resp = json.loads(aes_decrypt(await _read_frame(reader, timeout=8.0), self._key))
|
|
265
|
+
ack = (resp.get("ack") or {}).get("code")
|
|
266
|
+
if ack != 200:
|
|
267
|
+
raise CameraLanError(f"{self.device_id}: login rejected ack={ack}")
|
|
268
|
+
return (resp.get("payload") or {}).get("ascNumber", 1)
|
|
269
|
+
|
|
270
|
+
# -- public control ---------------------------------------------------- #
|
|
271
|
+
|
|
272
|
+
async def async_get_attributes(self) -> dict:
|
|
273
|
+
"""Poll the full device attribute set over a short-lived session."""
|
|
274
|
+
def build(uid, asc):
|
|
275
|
+
return [(1, {"method": "getDevAttrReq", "service": "device",
|
|
276
|
+
"clientId": "ha-" + uid, "srcAddr": "0." + uid,
|
|
277
|
+
"seq": "g" + str(int(time.time() * 1000))[-9:],
|
|
278
|
+
"payload": {"devId": self.device_id, "parentId": self.device_id,
|
|
279
|
+
"userId": uid, "password": self._password,
|
|
280
|
+
"attr": [], "channel": "tcp", "ascNumber": asc},
|
|
281
|
+
"tst": int(time.time() * 1000), "deviceId": self.device_id})]
|
|
282
|
+
replies = await self._session(build)
|
|
283
|
+
return (replies[0].get("payload") or {}).get("attr") or {}
|
|
284
|
+
|
|
285
|
+
async def async_set_attributes(self, attr: dict) -> bool:
|
|
286
|
+
"""Apply ``attr`` (on-the-wire keys, e.g. ``{"LedOnOff": 1}``) locally.
|
|
287
|
+
|
|
288
|
+
Returns True on a ``setDevAttrResp`` ack. Raises CameraLanError on failure.
|
|
289
|
+
"""
|
|
290
|
+
def build(uid, asc):
|
|
291
|
+
return [(1, {"method": "setDevAttrReq", "service": "device",
|
|
292
|
+
"clientId": "ha-" + uid, "srcAddr": "0." + uid,
|
|
293
|
+
"seq": "s" + str(int(time.time() * 1000))[-9:],
|
|
294
|
+
"payload": {"devId": self.device_id, "parentId": self.device_id,
|
|
295
|
+
"userId": uid, "password": self._password,
|
|
296
|
+
"attr": attr, "channel": "tcp", "ascNumber": asc},
|
|
297
|
+
"tst": int(time.time() * 1000), "deviceId": self.device_id})]
|
|
298
|
+
replies = await self._session(build)
|
|
299
|
+
return any(r.get("method") == "setDevAttrResp" for r in replies)
|
|
300
|
+
|
|
301
|
+
async def async_set(self, friendly: str, value: Any) -> bool:
|
|
302
|
+
"""Set a single attribute by friendly name (see ``ATTR_KEYS``)."""
|
|
303
|
+
if friendly not in ATTR_KEYS:
|
|
304
|
+
raise CameraLanError(f"unknown control attribute: {friendly}")
|
|
305
|
+
return await self.async_set_attributes({ATTR_KEYS[friendly]: value})
|
|
306
|
+
|
|
307
|
+
@staticmethod
|
|
308
|
+
def is_mains_powered(attrs: dict) -> bool:
|
|
309
|
+
"""Battery gating: cameras that report no battery level are mains-powered
|
|
310
|
+
(the eligible class). Call with the dict from :meth:`async_get_attributes`."""
|
|
311
|
+
return attrs.get("Battery_remaining") is None
|
{python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.1/src/python_aidot_cameras.egg-info}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-aidot-cameras
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.1
|
|
4
4
|
Summary: Control AiDot/Leedarson WiFi lights and cameras (WebRTC streaming, two-way audio, PTZ, controls)
|
|
5
5
|
Author-email: cbrightly <chris.brightly@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -13,6 +13,7 @@ src/aidot/g711.py
|
|
|
13
13
|
src/aidot/login_const.py
|
|
14
14
|
src/aidot/camera/__init__.py
|
|
15
15
|
src/aidot/camera/client.py
|
|
16
|
+
src/aidot/camera/lan_control.py
|
|
16
17
|
src/aidot/models/__init__.py
|
|
17
18
|
src/aidot/models/device_client_model.py
|
|
18
19
|
src/aidot/models/device_model.py
|
|
@@ -24,6 +25,7 @@ src/python_aidot_cameras.egg-info/requires.txt
|
|
|
24
25
|
src/python_aidot_cameras.egg-info/top_level.txt
|
|
25
26
|
tests/test_alarm_event.py
|
|
26
27
|
tests/test_highport_nomination.py
|
|
28
|
+
tests/test_lan_control.py
|
|
27
29
|
tests/test_motion_poll.py
|
|
28
30
|
tests/test_no_undefined_names.py
|
|
29
31
|
tests/test_post_merge_hardening.py
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Unit tests for the LAN device-control client (no camera required)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import struct
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"))
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from aidot.aes_utils import aes_decrypt, aes_encrypt
|
|
13
|
+
from aidot.camera.lan_control import (
|
|
14
|
+
ATTR_KEYS,
|
|
15
|
+
CameraLanClient,
|
|
16
|
+
CameraLanError,
|
|
17
|
+
_local_ipv4,
|
|
18
|
+
_pack,
|
|
19
|
+
discover_subnet,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
DEVICE = {"id": "dev1", "modelId": "LK.IPC.A000088", "aesKey": ["k" * 16], "password": "pw"}
|
|
23
|
+
USER = {"id": "user1"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_pack_header_is_8_bytes_and_well_formed():
|
|
27
|
+
body = b'{"x":1}'
|
|
28
|
+
frame = _pack(1, body)
|
|
29
|
+
assert len(frame) == 8 + len(body)
|
|
30
|
+
magic, mtype, blen = struct.unpack(">HHI", frame[:8])
|
|
31
|
+
assert magic == 0x1EED and mtype == 1 and blen == len(body)
|
|
32
|
+
assert frame[8:] == body
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_construction_derives_16byte_key():
|
|
36
|
+
c = CameraLanClient(DEVICE, USER, ip="192.0.2.10")
|
|
37
|
+
assert c.device_id == "dev1"
|
|
38
|
+
assert len(c._key) == 16 and c._key.startswith(b"k" * 16)
|
|
39
|
+
assert c.ip == "192.0.2.10"
|
|
40
|
+
assert c.eligible is None # not resolved yet
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_construction_requires_aeskey():
|
|
44
|
+
with pytest.raises(CameraLanError):
|
|
45
|
+
CameraLanClient({"id": "d", "password": "p"}, USER)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_friendly_attr_map_round_trips_through_aes():
|
|
49
|
+
# the keys we expose must encrypt/decrypt cleanly in a setDevAttr body
|
|
50
|
+
c = CameraLanClient(DEVICE, USER, ip="192.0.2.10")
|
|
51
|
+
body = {ATTR_KEYS["status_led"]: 1}
|
|
52
|
+
blob = aes_encrypt(json.dumps(body).encode(), c._key)
|
|
53
|
+
assert json.loads(aes_decrypt(blob, c._key)) == {"LedOnOff": 1}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_async_set_rejects_unknown_attr():
|
|
57
|
+
c = CameraLanClient(DEVICE, USER, ip="192.0.2.10")
|
|
58
|
+
import asyncio
|
|
59
|
+
with pytest.raises(CameraLanError):
|
|
60
|
+
asyncio.run(c.async_set("not_a_real_control", 1))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_battery_gating():
|
|
64
|
+
assert CameraLanClient.is_mains_powered({"Battery_remaining": None}) is True
|
|
65
|
+
assert CameraLanClient.is_mains_powered({"LedOnOff": 0}) is True # key absent -> mains
|
|
66
|
+
assert CameraLanClient.is_mains_powered({"Battery_remaining": 87}) is False
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@pytest.mark.parametrize("friendly,wire", list(ATTR_KEYS.items()))
|
|
70
|
+
def test_attr_keys_are_strings(friendly, wire):
|
|
71
|
+
assert isinstance(friendly, str) and isinstance(wire, str)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_network_helpers_are_callable():
|
|
75
|
+
# Real behaviour is covered by the live smoke test; here just assert the
|
|
76
|
+
# discovery helpers are importable and have the expected shape (the test
|
|
77
|
+
# sandbox blocks sockets, so we don't invoke them).
|
|
78
|
+
import inspect
|
|
79
|
+
assert callable(_local_ipv4)
|
|
80
|
+
assert inspect.iscoroutinefunction(discover_subnet)
|
|
81
|
+
sig = inspect.signature(discover_subnet)
|
|
82
|
+
assert list(sig.parameters) == ["cidr24", "timeout", "concurrency"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.1}/src/aidot/models/device_client_model.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.1}/src/aidot/models/discover_model.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.1}/tests/test_post_merge_hardening.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|