python-aidot-cameras 0.6.1__tar.gz → 0.7.2__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 (42) hide show
  1. {python_aidot_cameras-0.6.1/src/python_aidot_cameras.egg-info → python_aidot_cameras-0.7.2}/PKG-INFO +1 -1
  2. {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.2}/pyproject.toml +1 -1
  3. {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.2}/src/aidot/camera/client.py +35 -6
  4. python_aidot_cameras-0.7.2/src/aidot/camera/lan_control.py +311 -0
  5. {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.2/src/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  6. {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.2}/src/python_aidot_cameras.egg-info/SOURCES.txt +2 -0
  7. python_aidot_cameras-0.7.2/tests/test_lan_control.py +82 -0
  8. {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.2}/LICENSE +0 -0
  9. {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.2}/README.md +0 -0
  10. {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.2}/setup.cfg +0 -0
  11. {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.2}/src/aidot/__init__.py +0 -0
  12. {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.2}/src/aidot/aes_utils.py +0 -0
  13. {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.2}/src/aidot/camera/__init__.py +0 -0
  14. {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.2}/src/aidot/client.py +0 -0
  15. {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.2}/src/aidot/const.py +0 -0
  16. {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.2}/src/aidot/credentials.py +0 -0
  17. {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.2}/src/aidot/device_client.py +0 -0
  18. {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.2}/src/aidot/discover.py +0 -0
  19. {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.2}/src/aidot/exceptions.py +0 -0
  20. {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.2}/src/aidot/g711.py +0 -0
  21. {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.2}/src/aidot/login_const.py +0 -0
  22. {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.2}/src/aidot/models/__init__.py +0 -0
  23. {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.2}/src/aidot/models/device_client_model.py +0 -0
  24. {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.2}/src/aidot/models/device_model.py +0 -0
  25. {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.2}/src/aidot/models/discover_model.py +0 -0
  26. {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.2}/src/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  27. {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.2}/src/python_aidot_cameras.egg-info/requires.txt +0 -0
  28. {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.2}/src/python_aidot_cameras.egg-info/top_level.txt +0 -0
  29. {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.2}/tests/test_alarm_event.py +0 -0
  30. {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.2}/tests/test_highport_nomination.py +0 -0
  31. {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.2}/tests/test_motion_poll.py +0 -0
  32. {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.2}/tests/test_no_undefined_names.py +0 -0
  33. {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.2}/tests/test_post_merge_hardening.py +0 -0
  34. {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.2}/tests/test_sdes_idle_release.py +0 -0
  35. {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.2}/tests/test_sdes_sprop.py +0 -0
  36. {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.2}/tests/test_sdes_talk.py +0 -0
  37. {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.2}/tests/test_sdes_watchdog.py +0 -0
  38. {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.2}/tests/test_speak.py +0 -0
  39. {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.2}/tests/test_stream_cap.py +0 -0
  40. {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.2}/tests/test_talk.py +0 -0
  41. {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.2}/tests/test_terminal_ack.py +0 -0
  42. {python_aidot_cameras-0.6.1 → python_aidot_cameras-0.7.2}/tests/test_token_refresh.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-aidot-cameras
3
- Version: 0.6.1
3
+ Version: 0.7.2
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.6.1"
7
+ version = "0.7.2"
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"
@@ -2204,7 +2204,7 @@ def _dtls_av_mux_run(vq, aq, out_fileobj, progress, stop_flag) -> None:
2204
2204
  except Exception:
2205
2205
  pass
2206
2206
 
2207
- def _flush_audio():
2207
+ def _flush_audio(drain=False):
2208
2208
  if not have_audio:
2209
2209
  try:
2210
2210
  while True:
@@ -2247,10 +2247,16 @@ def _dtls_av_mux_run(vq, aq, out_fileobj, progress, stop_flag) -> None:
2247
2247
  while True:
2248
2248
  fr = fifo.read(1024) # AAC wants 1024-sample frames
2249
2249
  if fr is None:
2250
- # 160 PCMA samples 960 resampled @ 48 kHz, but AAC needs 1024.
2251
- # Every ~17 packets (~340 ms) the FIFO falls 64 samples short
2252
- # and would skip an entire 21 ms frame - audible choppiness.
2253
- # Pad the leftover with silence so the frame is complete.
2250
+ # FIFO has < 1024 samples. The audio source is continuous
2251
+ # (measured: 100% timeline coverage, no lost packets), so the
2252
+ # remainder must stay in the FIFO and be completed by the next
2253
+ # decoded packet - NOT padded with silence here. Padding on
2254
+ # every 5 ms flush injected a micro-silence whenever the FIFO was
2255
+ # momentarily short (almost always, given ~40 ms packet spacing) -
2256
+ # that was the choppiness. Only on the final drain do we pad the
2257
+ # last partial frame so the encoder can flush it.
2258
+ if not drain:
2259
+ break
2254
2260
  s = getattr(fifo, "samples", 0)
2255
2261
  if s == 0:
2256
2262
  break
@@ -2288,7 +2294,7 @@ def _dtls_av_mux_run(vq, aq, out_fileobj, progress, stop_flag) -> None:
2288
2294
  _flush_audio()
2289
2295
  _t.sleep(0.005) # 5 ms - processes audio within 5 ms of arrival vs 20 ms
2290
2296
  _flush_video()
2291
- _flush_audio()
2297
+ _flush_audio(drain=True) # final: pad the last partial frame so it flushes
2292
2298
  try:
2293
2299
  if have_audio:
2294
2300
  for opkt in aenc.encode(None): # flush
@@ -2689,6 +2695,17 @@ class CameraMixin:
2689
2695
  # default it so get_send_packet and friends see None instead of raising.
2690
2696
  aes_key = None
2691
2697
 
2698
+ # Optional LAN control client; when attached, attribute writes go local-first.
2699
+ _lan_client = None
2700
+
2701
+ def attach_lan_client(self, lan_client) -> None:
2702
+ """Route camera attribute writes through ``lan_client`` (local-first,
2703
+ cloud-fallback). Pass an ``aidot.camera.lan_control.CameraLanClient``."""
2704
+ self._lan_client = lan_client
2705
+
2706
+ def detach_lan_client(self) -> None:
2707
+ self._lan_client = None
2708
+
2692
2709
  def _init_camera_state(self, device: dict, user_info: dict) -> None:
2693
2710
  """Camera-side state init, called once at the end of DeviceClient.__init__.
2694
2711
 
@@ -3501,10 +3518,22 @@ class CameraMixin:
3501
3518
  we add it ourselves. onlyPubAck=false means camera sends
3502
3519
  setDevAttrResp matched by seq. _mqtt_device_cmd sends a
3503
3520
  lowPowerActiveStateReq wake signal before this command.
3521
+ Local-first: if a LAN control client is attached (see
3522
+ :meth:`attach_lan_client`) and reachable, the attribute is set over the
3523
+ LAN and the cloud round-trip is skipped; any LAN failure falls through to
3524
+ the MQTT path below.
3504
3525
  """
3505
3526
  import json as _json
3506
3527
  import random as _random
3507
3528
 
3529
+ lan = getattr(self, "_lan_client", None)
3530
+ if lan is not None:
3531
+ try:
3532
+ if await lan.async_set_attributes({attr: value}):
3533
+ return True
3534
+ except Exception as _exc: # noqa: BLE001 - any LAN error -> cloud fallback
3535
+ _LOGGER.debug("LAN set %s failed (%s); falling back to cloud", attr, _exc)
3536
+
3508
3537
  device_id = self.device_id
3509
3538
  user_id = self.user_id
3510
3539
  # 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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-aidot-cameras
3
- Version: 0.6.1
3
+ Version: 0.7.2
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"]