python-aidot-cameras 0.6.0__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.0/src/python_aidot_cameras.egg-info → python_aidot_cameras-0.7.1}/PKG-INFO +1 -1
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/pyproject.toml +1 -1
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/src/aidot/camera/client.py +62 -21
- python_aidot_cameras-0.7.1/src/aidot/camera/lan_control.py +311 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/src/aidot/client.py +24 -11
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/src/aidot/const.py +3 -2
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/src/aidot/device_client.py +23 -1
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/src/aidot/discover.py +12 -18
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1/src/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/src/python_aidot_cameras.egg-info/SOURCES.txt +3 -0
- python_aidot_cameras-0.7.1/tests/test_lan_control.py +82 -0
- python_aidot_cameras-0.7.1/tests/test_post_merge_hardening.py +178 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/LICENSE +0 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/README.md +0 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/setup.cfg +0 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/src/aidot/__init__.py +0 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/src/aidot/aes_utils.py +0 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/src/aidot/camera/__init__.py +0 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/src/aidot/credentials.py +0 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/src/aidot/exceptions.py +0 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/src/aidot/g711.py +0 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/src/aidot/login_const.py +0 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/src/aidot/models/__init__.py +0 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/src/aidot/models/device_client_model.py +0 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/src/aidot/models/device_model.py +0 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/src/aidot/models/discover_model.py +0 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/src/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/src/python_aidot_cameras.egg-info/requires.txt +0 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/src/python_aidot_cameras.egg-info/top_level.txt +0 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/tests/test_alarm_event.py +0 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/tests/test_highport_nomination.py +0 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/tests/test_motion_poll.py +0 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/tests/test_no_undefined_names.py +0 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/tests/test_sdes_idle_release.py +0 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/tests/test_sdes_sprop.py +0 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/tests/test_sdes_talk.py +0 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/tests/test_sdes_watchdog.py +0 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/tests/test_speak.py +0 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/tests/test_stream_cap.py +0 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/tests/test_talk.py +0 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/tests/test_terminal_ack.py +0 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/tests/test_token_refresh.py +0 -0
{python_aidot_cameras-0.6.0/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"
|
|
@@ -17,6 +17,7 @@ from typing import Any, Callable, List, Optional
|
|
|
17
17
|
|
|
18
18
|
from ..aes_utils import aes_encrypt, aes_decrypt, aes_ecb_encrypt_str_key, aes_ecb_decrypt_str_key
|
|
19
19
|
from ..exceptions import AidotCameraBusy
|
|
20
|
+
from ..login_const import APP_ID as _AIDOT_APP_ID
|
|
20
21
|
from ..const import (
|
|
21
22
|
CONF_AES_KEY,
|
|
22
23
|
CONF_ASCNUMBER,
|
|
@@ -49,12 +50,28 @@ _LOGGER = logging.getLogger(__name__)
|
|
|
49
50
|
# Runtime import: device_client defines these classes BEFORE importing this
|
|
50
51
|
# module (the package __init__ loads aidot.device_client first), so the
|
|
51
52
|
# partially-initialized module already exposes them here.
|
|
52
|
-
|
|
53
|
+
try:
|
|
54
|
+
from ..device_client import DeviceStatusData, DeviceInformation
|
|
55
|
+
except ImportError as _exc: # pragma: no cover - ordering contract violation
|
|
56
|
+
raise ImportError(
|
|
57
|
+
"aidot.camera.client must be imported via aidot.device_client "
|
|
58
|
+
"(import aidot first); keep the camera import block in "
|
|
59
|
+
"device_client.py below the DeviceStatusData/DeviceInformation "
|
|
60
|
+
"definitions"
|
|
61
|
+
) from _exc
|
|
53
62
|
|
|
54
63
|
|
|
55
64
|
class CameraStatusData(DeviceStatusData):
|
|
56
65
|
"""Core status plus camera fields; accepts both typed-model and dict updates."""
|
|
57
66
|
|
|
67
|
+
# Restore None-as-unknown semantics: the core defaults (red/2700K/100)
|
|
68
|
+
# would otherwise be pushed to consumers as real state before the first
|
|
69
|
+
# getDevAttrReq reply arrives.
|
|
70
|
+
rgdb: "int | None" = None
|
|
71
|
+
rgbw: "tuple[int, int, int, int] | None" = None
|
|
72
|
+
cct: "int | None" = None
|
|
73
|
+
dimming: "int | None" = None
|
|
74
|
+
|
|
58
75
|
# Camera-specific fields (None = unknown/not yet queried)
|
|
59
76
|
motion_detection: Optional[bool] = None
|
|
60
77
|
motion_sensitivity: Optional[int] = None # MotionDetection_Sen 1-5
|
|
@@ -76,23 +93,16 @@ class CameraStatusData(DeviceStatusData):
|
|
|
76
93
|
return
|
|
77
94
|
if not isinstance(attr, dict):
|
|
78
95
|
# Typed DeviceAttr model from the core local-control receive path.
|
|
96
|
+
# Guard: a malformed payload.attr (non-dict, non-model) must not
|
|
97
|
+
# kill the caller's receive loop.
|
|
98
|
+
if not hasattr(attr, "OnOff"):
|
|
99
|
+
return
|
|
79
100
|
super().update(attr)
|
|
80
101
|
return
|
|
81
|
-
# Dict path
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
self.dimming = int(attr.get(CONF_DIMMING) * 255 / 100)
|
|
86
|
-
if attr.get(CONF_RGBW) is not None:
|
|
87
|
-
self.rgdb = attr.get(CONF_RGBW)
|
|
88
|
-
rgbw = ctypes.c_uint32(self.rgdb).value
|
|
89
|
-
r = (rgbw >> 24) & 0xFF
|
|
90
|
-
g = (rgbw >> 16) & 0xFF
|
|
91
|
-
b = (rgbw >> 8) & 0xFF
|
|
92
|
-
w = rgbw & 0xFF
|
|
93
|
-
self.rgbw = (r, g, b, w)
|
|
94
|
-
if attr.get(CONF_CCT) is not None:
|
|
95
|
-
self.cct = attr.get(CONF_CCT)
|
|
102
|
+
# Dict path: camera attributes ONLY. Light keys (OnOff/Dimming/RGBW/
|
|
103
|
+
# CCT) are handled by the typed-model path in the core; the dict
|
|
104
|
+
# feeders either strip them (update_from_camera_attributes) or have
|
|
105
|
+
# already applied them via the model (receive_data raw-dict pass).
|
|
96
106
|
# Camera attributes
|
|
97
107
|
if (v := attr.get("MotionDetection_Enable")) is not None:
|
|
98
108
|
self.motion_detection = bool(int(v))
|
|
@@ -2675,15 +2685,36 @@ def _ip_looks_ascii_garbled(ip_str) -> bool:
|
|
|
2675
2685
|
class CameraMixin:
|
|
2676
2686
|
"""All camera/streaming methods, mixed into DeviceClient via inheritance."""
|
|
2677
2687
|
|
|
2688
|
+
# Devices without an aesKey never get this set by the core constructor;
|
|
2689
|
+
# default it so get_send_packet and friends see None instead of raising.
|
|
2690
|
+
aes_key = None
|
|
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
|
+
|
|
2678
2703
|
def _init_camera_state(self, device: dict, user_info: dict) -> None:
|
|
2679
2704
|
"""Camera-side state init, called once at the end of DeviceClient.__init__.
|
|
2680
2705
|
|
|
2681
2706
|
Everything camera-specific that the upstream constructor does not set
|
|
2682
2707
|
lives here, so the core __init__ body stays aligned with upstream.
|
|
2683
2708
|
"""
|
|
2684
|
-
# Swap in the camera-aware status/info supersets created by the core
|
|
2709
|
+
# Swap in the camera-aware status/info supersets created by the core,
|
|
2710
|
+
# carrying forward anything the core constructor seeded into the
|
|
2711
|
+
# originals so upstream-added seeding cannot be lost silently.
|
|
2712
|
+
_core_status, _core_info = self.status, self.info
|
|
2685
2713
|
self.status = CameraStatusData()
|
|
2714
|
+
self.status.__dict__.update(_core_status.__dict__)
|
|
2686
2715
|
self.info = CameraDeviceInformation(device)
|
|
2716
|
+
for _k, _v in _core_info.__dict__.items():
|
|
2717
|
+
self.info.__dict__.setdefault(_k, _v)
|
|
2687
2718
|
# livePlayReq dseq - app parity (Q0(): starts at 100, increments per
|
|
2688
2719
|
# request). Camera tracks the live-play sequence; 0 is not a valid seq.
|
|
2689
2720
|
self._live_dseq = 100
|
|
@@ -2702,9 +2733,7 @@ class CameraMixin:
|
|
|
2702
2733
|
self._token_refresh_cb: "Optional[Callable]" = None
|
|
2703
2734
|
# Raw device dict retained for transport-type detection (isDTLS field)
|
|
2704
2735
|
self._raw_device: dict = device
|
|
2705
|
-
|
|
2706
|
-
if not hasattr(self, "aes_key"):
|
|
2707
|
-
self.aes_key = None
|
|
2736
|
+
|
|
2708
2737
|
# Seed camera/diagnostic status from the cloud device "properties" payload
|
|
2709
2738
|
# (Battery_remaining, Occupancy, SDcardStatus, MotionDetection_*, …). This
|
|
2710
2739
|
# is the authoritative, always-current source the official app itself reads
|
|
@@ -3169,7 +3198,7 @@ class CameraMixin:
|
|
|
3169
3198
|
token = (self._user_info.get("accessToken")
|
|
3170
3199
|
or self._user_info.get("access_token") or "")
|
|
3171
3200
|
return {
|
|
3172
|
-
"Appid":
|
|
3201
|
+
"Appid": _AIDOT_APP_ID,
|
|
3173
3202
|
"Token": token,
|
|
3174
3203
|
"Terminal": "app",
|
|
3175
3204
|
"Content-Type": "application/json",
|
|
@@ -3483,10 +3512,22 @@ class CameraMixin:
|
|
|
3483
3512
|
we add it ourselves. onlyPubAck=false means camera sends
|
|
3484
3513
|
setDevAttrResp matched by seq. _mqtt_device_cmd sends a
|
|
3485
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.
|
|
3486
3519
|
"""
|
|
3487
3520
|
import json as _json
|
|
3488
3521
|
import random as _random
|
|
3489
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
|
+
|
|
3490
3531
|
device_id = self.device_id
|
|
3491
3532
|
user_id = self.user_id
|
|
3492
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
|
|
@@ -58,6 +58,14 @@ def rsa_password_encrypt(message: str) -> str:
|
|
|
58
58
|
|
|
59
59
|
|
|
60
60
|
|
|
61
|
+
def _is_camera(device_client: "DeviceClient") -> bool:
|
|
62
|
+
"""Cameras get their LAN IP from WebRTC signaling, never from the UDP
|
|
63
|
+
sweep; pushing a discovered IP would trigger light-protocol logins on
|
|
64
|
+
TCP:10000 (which can hang - the camera accepts but never answers)."""
|
|
65
|
+
model = getattr(getattr(device_client, "info", None), "model_id", "") or ""
|
|
66
|
+
return "IPC" in model
|
|
67
|
+
|
|
68
|
+
|
|
61
69
|
async def _prefetch_ice_config(dc: "DeviceClient") -> None:
|
|
62
70
|
"""Background task: warm the HTTP ICE config cache for a device."""
|
|
63
71
|
try:
|
|
@@ -157,7 +165,11 @@ class AidotClient:
|
|
|
157
165
|
def update_password(self, password: str) -> None:
|
|
158
166
|
self.password = password
|
|
159
167
|
|
|
168
|
+
_terminal_id: "Optional[str]" = None
|
|
169
|
+
|
|
160
170
|
async def get_terminal_id(self) -> str:
|
|
171
|
+
if self._terminal_id is not None:
|
|
172
|
+
return self._terminal_id
|
|
161
173
|
file_path = Path.home() / ".aidot_terminal_id"
|
|
162
174
|
|
|
163
175
|
def _read_or_create() -> str:
|
|
@@ -173,7 +185,8 @@ class AidotClient:
|
|
|
173
185
|
return 'gvz3gjae10l4zii00t7y0'
|
|
174
186
|
|
|
175
187
|
raw_id = await asyncio.to_thread(_read_or_create)
|
|
176
|
-
|
|
188
|
+
self._terminal_id = hashlib.md5(raw_id.encode()).hexdigest()
|
|
189
|
+
return self._terminal_id
|
|
177
190
|
|
|
178
191
|
async def async_post_login(self) -> dict[str, Any]:
|
|
179
192
|
"""Login via loginWithFreeVerification (RSA-encrypted password)."""
|
|
@@ -456,11 +469,15 @@ class AidotClient:
|
|
|
456
469
|
asyncio.get_running_loop().create_task(
|
|
457
470
|
_prefetch_ice_config(device_client)
|
|
458
471
|
)
|
|
472
|
+
# Started lazily here as well: __init__ cannot start discovery when
|
|
473
|
+
# constructed outside a running event loop (stored-token sync path).
|
|
474
|
+
if self._discover is None:
|
|
475
|
+
self.setup_discover()
|
|
459
476
|
# Lights get their LAN IP from UDP discovery. Cameras do NOT use the
|
|
460
477
|
# broadcast sweep: their LAN IP comes from the WebRTC signaling host
|
|
461
478
|
# candidate (iceCandidateReq), which the camera advertises and we add
|
|
462
479
|
# verbatim - the official app does the same.
|
|
463
|
-
if self._discover is not None:
|
|
480
|
+
if self._discover is not None and not _is_camera(device_client):
|
|
464
481
|
ip = self._discover.discovered_device.get(device_id)
|
|
465
482
|
device_client.update_ip_address(ip)
|
|
466
483
|
return device_client
|
|
@@ -481,7 +498,7 @@ class AidotClient:
|
|
|
481
498
|
def _discover_callback(dev_id, event: dict[str, str]) -> None:
|
|
482
499
|
device_ip = event[CONF_IPADDRESS]
|
|
483
500
|
device_client: DeviceClient = self._device_clients.get(dev_id)
|
|
484
|
-
if device_client is not None:
|
|
501
|
+
if device_client is not None and not _is_camera(device_client):
|
|
485
502
|
device_client.update_ip_address(device_ip)
|
|
486
503
|
|
|
487
504
|
try:
|
|
@@ -493,6 +510,8 @@ class AidotClient:
|
|
|
493
510
|
|
|
494
511
|
async def async_close(self) -> None:
|
|
495
512
|
"""Close the client and release resources."""
|
|
513
|
+
if self._refresh_task and not self._refresh_task.done():
|
|
514
|
+
self._refresh_task.cancel()
|
|
496
515
|
if self._discover is not None:
|
|
497
516
|
self._discover.close()
|
|
498
517
|
self._discover = None
|
|
@@ -501,14 +520,8 @@ class AidotClient:
|
|
|
501
520
|
self._device_clients.clear()
|
|
502
521
|
|
|
503
522
|
def cleanup(self) -> None:
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
self._discover = None
|
|
507
|
-
for client in self._device_clients.values():
|
|
508
|
-
asyncio.get_running_loop().create_task(client.close())
|
|
509
|
-
self._device_clients.clear()
|
|
523
|
+
"""Sync entry point: fire-and-forget async_close()."""
|
|
524
|
+
asyncio.get_running_loop().create_task(self.async_close())
|
|
510
525
|
|
|
511
526
|
async def async_cleanup(self) -> None:
|
|
512
|
-
if self._refresh_task and not self._refresh_task.done():
|
|
513
|
-
self._refresh_task.cancel()
|
|
514
527
|
await self.async_close()
|
|
@@ -233,8 +233,9 @@ class ServerErrorCode(IntEnum):
|
|
|
233
233
|
|
|
234
234
|
# ── Cloud API credentials ──────────────────────────────────────────────────── #
|
|
235
235
|
|
|
236
|
-
APP_ID
|
|
237
|
-
|
|
236
|
+
from .login_const import APP_ID, PUBLIC_KEY_PEM # noqa: E402 - back-compat re-export
|
|
237
|
+
from .login_const import API_URL_TEMPLATE as _API_URL_TEMPLATE, DEFAULT_REGION as _DEFAULT_REGION # noqa: E402
|
|
238
|
+
BASE_URL = _API_URL_TEMPLATE.format(region=_DEFAULT_REGION)
|
|
238
239
|
|
|
239
240
|
PUBLIC_KEY_PEM = b"""-----BEGIN PUBLIC KEY-----
|
|
240
241
|
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCtQAnPCi8ksPnS1Du6z96PsKfN
|
|
@@ -201,6 +201,9 @@ class DeviceClient(CameraMixin, object):
|
|
|
201
201
|
sock: socket.socket = self.writer.get_extra_info("socket")
|
|
202
202
|
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
|
203
203
|
self.seq_num = 1
|
|
204
|
+
# Cloud polls may have seeded status.online; the gate below must
|
|
205
|
+
# reflect this TCP login only.
|
|
206
|
+
self.status.online = False
|
|
204
207
|
await self.login()
|
|
205
208
|
self._connect_and_login = self.status.online
|
|
206
209
|
except Exception as e:
|
|
@@ -322,6 +325,9 @@ class DeviceClient(CameraMixin, object):
|
|
|
322
325
|
self.ascNumber = response.payload.ascNumber
|
|
323
326
|
if response.payload.attr:
|
|
324
327
|
self.status.update(response.payload.attr)
|
|
328
|
+
# Camera/floodlight keys are not in the typed DeviceAttr
|
|
329
|
+
# model; pass the raw dict so they are not dropped.
|
|
330
|
+
self.status.update((json_data.get(CONF_PAYLOAD) or {}).get(CONF_ATTR))
|
|
325
331
|
self._notify_status_update()
|
|
326
332
|
|
|
327
333
|
def _schedule_ping(self):
|
|
@@ -436,13 +442,29 @@ class DeviceClient(CameraMixin, object):
|
|
|
436
442
|
|
|
437
443
|
async def close(self) -> None:
|
|
438
444
|
self._is_close = True
|
|
445
|
+
if self._login_task is not None and not self._login_task.done():
|
|
446
|
+
self._login_task.cancel()
|
|
439
447
|
await self.async_stop_streaming()
|
|
440
448
|
await self.reset()
|
|
441
449
|
_LOGGER.info(f"{self.device_id} connect close by user")
|
|
442
450
|
|
|
451
|
+
_last_login_attempt: float = 0.0
|
|
452
|
+
|
|
443
453
|
def _schedule_reconnect(self) -> None:
|
|
444
454
|
"""延迟重连"""
|
|
455
|
+
if self._is_close:
|
|
456
|
+
return
|
|
457
|
+
if self._reconnect_handle is not None:
|
|
458
|
+
return # a reconnect chain is already armed
|
|
445
459
|
_LOGGER.info(f"{self.device_id} _schedule_reconnect")
|
|
446
460
|
loop = asyncio.get_running_loop()
|
|
447
|
-
self._reconnect_handle = loop.call_later(60, self.
|
|
461
|
+
self._reconnect_handle = loop.call_later(60, self._reconnect_fire)
|
|
462
|
+
now = time.monotonic()
|
|
463
|
+
if now - self._last_login_attempt < 30:
|
|
464
|
+
return # too soon after the last attempt; the timer above retries
|
|
465
|
+
self._last_login_attempt = now
|
|
448
466
|
self._login_task = asyncio.create_task(self.async_login())
|
|
467
|
+
|
|
468
|
+
def _reconnect_fire(self) -> None:
|
|
469
|
+
self._reconnect_handle = None
|
|
470
|
+
self._schedule_reconnect()
|
|
@@ -14,7 +14,6 @@ from .exceptions import AidotOSError
|
|
|
14
14
|
from .models.discover_model import DiscoverResponse, DiscoverRequest
|
|
15
15
|
|
|
16
16
|
_LOGGER = logging.getLogger(__name__)
|
|
17
|
-
_DISCOVER_TIME = 5 # legacy repeat_broadcast() loop interval (seconds)
|
|
18
17
|
_DISCOVER_FAST = 6 # fast discovery cadence right after startup
|
|
19
18
|
_DISCOVER_SLOW = 120 # slow maintenance cadence once stable
|
|
20
19
|
|
|
@@ -141,13 +140,16 @@ class Discover:
|
|
|
141
140
|
self._login_info = login_info
|
|
142
141
|
self._callback = callback
|
|
143
142
|
self._protocols: List[BroadcastProtocol] = []
|
|
143
|
+
self._is_close = False
|
|
144
|
+
self._broadcast_task: "asyncio.Task | None" = None
|
|
144
145
|
|
|
145
146
|
async def _ensure_sockets(self) -> None:
|
|
146
147
|
"""Create one datagram endpoint per active interface (idempotent)."""
|
|
147
|
-
if self._protocols:
|
|
148
|
+
if self._is_close or self._protocols:
|
|
148
149
|
return
|
|
149
150
|
|
|
150
|
-
|
|
151
|
+
# subprocess-based interface enumeration must not block the loop
|
|
152
|
+
candidates = await asyncio.to_thread(_get_broadcast_candidates)
|
|
151
153
|
user_id = self._login_info[CONF_ID]
|
|
152
154
|
|
|
153
155
|
for bind_ip, broadcast_ip in candidates:
|
|
@@ -192,15 +194,6 @@ class Discover:
|
|
|
192
194
|
for proto in self._protocols:
|
|
193
195
|
proto.send_broadcast()
|
|
194
196
|
|
|
195
|
-
async def repeat_broadcast(self) -> None:
|
|
196
|
-
self._is_close = False
|
|
197
|
-
while True:
|
|
198
|
-
await self.send_broadcast()
|
|
199
|
-
for _ in range(_DISCOVER_TIME):
|
|
200
|
-
await asyncio.sleep(1)
|
|
201
|
-
if self._is_close:
|
|
202
|
-
return
|
|
203
|
-
|
|
204
197
|
def start_repeat_broadcast(self) -> None:
|
|
205
198
|
"""Timer-driven discovery: a few fast rounds at startup, then slow."""
|
|
206
199
|
self._is_close = False
|
|
@@ -208,6 +201,8 @@ class Discover:
|
|
|
208
201
|
self._schedule_broadcast()
|
|
209
202
|
|
|
210
203
|
def _schedule_broadcast(self) -> None:
|
|
204
|
+
if self._is_close:
|
|
205
|
+
return
|
|
211
206
|
if self._fast_discover_count > 0:
|
|
212
207
|
interval = _DISCOVER_FAST
|
|
213
208
|
self._fast_discover_count -= 1
|
|
@@ -215,20 +210,17 @@ class Discover:
|
|
|
215
210
|
interval = _DISCOVER_SLOW
|
|
216
211
|
|
|
217
212
|
loop = asyncio.get_running_loop()
|
|
218
|
-
asyncio.create_task(self._do_broadcast())
|
|
213
|
+
self._broadcast_task = asyncio.create_task(self._do_broadcast())
|
|
219
214
|
self._timer_handle = loop.call_later(interval, self._schedule_broadcast)
|
|
220
215
|
|
|
221
216
|
async def _do_broadcast(self) -> None:
|
|
217
|
+
if self._is_close:
|
|
218
|
+
return
|
|
222
219
|
try:
|
|
223
220
|
await self.send_broadcast()
|
|
224
221
|
except Exception as e:
|
|
225
222
|
_LOGGER.error(f"Broadcast failed: {e}")
|
|
226
223
|
|
|
227
|
-
async def fetch_devices_info(self) -> dict[str, str]:
|
|
228
|
-
await self.send_broadcast()
|
|
229
|
-
await asyncio.sleep(2)
|
|
230
|
-
return self.discovered_device
|
|
231
|
-
|
|
232
224
|
def _discover_callback(self, dev_id, event: dict[str, str]) -> None:
|
|
233
225
|
self.discovered_device[dev_id] = event[CONF_IPADDRESS]
|
|
234
226
|
if self._callback:
|
|
@@ -236,6 +228,8 @@ class Discover:
|
|
|
236
228
|
|
|
237
229
|
def close(self) -> None:
|
|
238
230
|
self._is_close = True
|
|
231
|
+
if self._broadcast_task is not None and not self._broadcast_task.done():
|
|
232
|
+
self._broadcast_task.cancel()
|
|
239
233
|
if self._timer_handle is not None:
|
|
240
234
|
self._timer_handle.cancel()
|
|
241
235
|
self._timer_handle = None
|
{python_aidot_cameras-0.6.0 → 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,8 +25,10 @@ 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
|
|
31
|
+
tests/test_post_merge_hardening.py
|
|
29
32
|
tests/test_sdes_idle_release.py
|
|
30
33
|
tests/test_sdes_sprop.py
|
|
31
34
|
tests/test_sdes_talk.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"]
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Regression tests for the post-upstream-sync hardening fixes.
|
|
2
|
+
|
|
3
|
+
Covers: the connect() login gate, reconnect chain dedup/backoff, discovery
|
|
4
|
+
quiescence after close(), the camera filter on discovered IPs, status
|
|
5
|
+
None-defaults, the dict/model update dispatch, and the DeviceClient/
|
|
6
|
+
CameraMixin name-collision tripwire for future upstream merges.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
|
|
14
|
+
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"))
|
|
15
|
+
|
|
16
|
+
import pytest
|
|
17
|
+
|
|
18
|
+
from aidot.camera.client import CameraMixin, CameraStatusData, CameraDeviceInformation
|
|
19
|
+
from aidot.client import _is_camera
|
|
20
|
+
from aidot.device_client import DeviceClient, DeviceStatusData
|
|
21
|
+
from aidot.discover import Discover
|
|
22
|
+
from aidot.models.device_client_model import DeviceAttr
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
DEVICE = {
|
|
26
|
+
"id": "dev1",
|
|
27
|
+
"name": "Test Light",
|
|
28
|
+
"modelId": "LK.light.A001497",
|
|
29
|
+
"aesKey": ["k" * 16],
|
|
30
|
+
"password": "pw",
|
|
31
|
+
"online": True,
|
|
32
|
+
}
|
|
33
|
+
CAMERA = {**DEVICE, "id": "cam1", "name": "Test Cam", "modelId": "LK.IPC.A000088"}
|
|
34
|
+
USER = {"id": "user1", "region": "us"}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def make_dc(device=DEVICE):
|
|
38
|
+
return DeviceClient(dict(device), dict(USER))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# --------------------------------------------------------------------------- #
|
|
42
|
+
# connect() gate
|
|
43
|
+
# --------------------------------------------------------------------------- #
|
|
44
|
+
|
|
45
|
+
def test_connect_gate_ignores_cloud_seeded_online(monkeypatch):
|
|
46
|
+
"""A swallowed login failure must not report as connected just because
|
|
47
|
+
the cloud device dict said online=true."""
|
|
48
|
+
dc = make_dc()
|
|
49
|
+
dc.status.online = True # cloud-seeded (update_status_from_device)
|
|
50
|
+
|
|
51
|
+
class _W:
|
|
52
|
+
def get_extra_info(self, _):
|
|
53
|
+
import socket as _s
|
|
54
|
+
s = _s.socket()
|
|
55
|
+
return s
|
|
56
|
+
|
|
57
|
+
async def fake_open(*a, **k):
|
|
58
|
+
return object(), _W()
|
|
59
|
+
|
|
60
|
+
async def fake_login(self):
|
|
61
|
+
return # login fails silently (exception swallowed inside login())
|
|
62
|
+
|
|
63
|
+
monkeypatch.setattr(asyncio, "open_connection", fake_open)
|
|
64
|
+
monkeypatch.setattr(DeviceClient, "login", fake_login)
|
|
65
|
+
asyncio.run(dc.connect("127.0.0.1"))
|
|
66
|
+
assert dc.connect_and_login is False
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# --------------------------------------------------------------------------- #
|
|
70
|
+
# reconnect chain
|
|
71
|
+
# --------------------------------------------------------------------------- #
|
|
72
|
+
|
|
73
|
+
def test_schedule_reconnect_single_chain_and_backoff():
|
|
74
|
+
async def run():
|
|
75
|
+
dc = make_dc()
|
|
76
|
+
dc._ip_address = "127.0.0.1"
|
|
77
|
+
dc._last_login_attempt = time.monotonic() # recent attempt -> backoff
|
|
78
|
+
|
|
79
|
+
dc._schedule_reconnect()
|
|
80
|
+
first_handle = dc._reconnect_handle
|
|
81
|
+
assert first_handle is not None
|
|
82
|
+
assert dc._login_task is None # backoff floor suppressed immediate login
|
|
83
|
+
|
|
84
|
+
dc._schedule_reconnect() # concurrent reset path
|
|
85
|
+
assert dc._reconnect_handle is first_handle # no second chain
|
|
86
|
+
|
|
87
|
+
first_handle.cancel()
|
|
88
|
+
|
|
89
|
+
asyncio.run(run())
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_schedule_reconnect_noop_after_close():
|
|
93
|
+
async def run():
|
|
94
|
+
dc = make_dc()
|
|
95
|
+
dc._ip_address = "127.0.0.1"
|
|
96
|
+
dc._is_close = True
|
|
97
|
+
dc._schedule_reconnect()
|
|
98
|
+
assert dc._reconnect_handle is None
|
|
99
|
+
|
|
100
|
+
asyncio.run(run())
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# --------------------------------------------------------------------------- #
|
|
104
|
+
# discovery
|
|
105
|
+
# --------------------------------------------------------------------------- #
|
|
106
|
+
|
|
107
|
+
def test_discover_quiescent_after_close():
|
|
108
|
+
async def run():
|
|
109
|
+
d = Discover({"id": "user1"}, None)
|
|
110
|
+
d.close()
|
|
111
|
+
await d._do_broadcast() # in-flight task firing after close
|
|
112
|
+
await d._ensure_sockets()
|
|
113
|
+
assert d._protocols == [] # no sockets recreated
|
|
114
|
+
|
|
115
|
+
asyncio.run(run())
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_camera_filter():
|
|
119
|
+
assert _is_camera(make_dc(CAMERA)) is True
|
|
120
|
+
assert _is_camera(make_dc(DEVICE)) is False
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# --------------------------------------------------------------------------- #
|
|
124
|
+
# status semantics
|
|
125
|
+
# --------------------------------------------------------------------------- #
|
|
126
|
+
|
|
127
|
+
def test_status_defaults_are_unknown():
|
|
128
|
+
s = CameraStatusData()
|
|
129
|
+
assert s.dimming is None and s.cct is None and s.rgbw is None and s.rgdb is None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_update_dispatch_model_vs_dict():
|
|
133
|
+
s = CameraStatusData()
|
|
134
|
+
s.update(DeviceAttr(OnOff=1, CCT=4000))
|
|
135
|
+
assert s.on == 1 and s.cct == 4000
|
|
136
|
+
# dict path: camera keys applied, light keys ignored (model path owns them)
|
|
137
|
+
s.update({"Battery_remaining": "77", "CCT": 9999})
|
|
138
|
+
assert s.battery_remaining == 77
|
|
139
|
+
assert s.cct == 4000
|
|
140
|
+
# malformed model-path payload must not raise
|
|
141
|
+
s.update("garbage")
|
|
142
|
+
s.update(["garbage"])
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_camera_state_carry_forward():
|
|
146
|
+
"""State seeded into the core status object before the camera swap must
|
|
147
|
+
survive on the replacement object."""
|
|
148
|
+
dc = make_dc()
|
|
149
|
+
# simulate: as if the core constructor had seeded state pre-swap
|
|
150
|
+
core_status = DeviceStatusData()
|
|
151
|
+
core_status.online = True
|
|
152
|
+
dc.status = core_status
|
|
153
|
+
dc._init_camera_state(dict(DEVICE), dict(USER))
|
|
154
|
+
assert isinstance(dc.status, CameraStatusData)
|
|
155
|
+
assert dc.status.online is True
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# --------------------------------------------------------------------------- #
|
|
159
|
+
# upstream-merge tripwire
|
|
160
|
+
# --------------------------------------------------------------------------- #
|
|
161
|
+
|
|
162
|
+
def test_no_silent_mro_shadowing():
|
|
163
|
+
"""If upstream adds a DeviceClient method whose name CameraMixin already
|
|
164
|
+
defines, the upstream version silently shadows the camera one after a
|
|
165
|
+
conflict-free merge. Keep this intersection explicitly reviewed."""
|
|
166
|
+
intentional = {
|
|
167
|
+
# device_client.py deliberately overrides/joins these:
|
|
168
|
+
"__init__",
|
|
169
|
+
}
|
|
170
|
+
# Compare only non-dunder names: interpreter-added dunders vary by
|
|
171
|
+
# Python version (3.13 adds __firstlineno__/__static_attributes__).
|
|
172
|
+
core = {n for n in DeviceClient.__dict__ if not n.startswith("__")}
|
|
173
|
+
camera = {n for n in CameraMixin.__dict__ if not n.startswith("__")}
|
|
174
|
+
collisions = (core & camera) - intentional
|
|
175
|
+
assert collisions == set(), (
|
|
176
|
+
f"DeviceClient now shadows CameraMixin names {sorted(collisions)}; "
|
|
177
|
+
"verify intent and update this test's allowlist"
|
|
178
|
+
)
|
|
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.0 → 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.0 → 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
|
|
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
|