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.
Files changed (42) hide show
  1. {python_aidot_cameras-0.6.0/src/python_aidot_cameras.egg-info → python_aidot_cameras-0.7.1}/PKG-INFO +1 -1
  2. {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/pyproject.toml +1 -1
  3. {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/src/aidot/camera/client.py +62 -21
  4. python_aidot_cameras-0.7.1/src/aidot/camera/lan_control.py +311 -0
  5. {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/src/aidot/client.py +24 -11
  6. {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/src/aidot/const.py +3 -2
  7. {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/src/aidot/device_client.py +23 -1
  8. {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/src/aidot/discover.py +12 -18
  9. {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1/src/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  10. {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/src/python_aidot_cameras.egg-info/SOURCES.txt +3 -0
  11. python_aidot_cameras-0.7.1/tests/test_lan_control.py +82 -0
  12. python_aidot_cameras-0.7.1/tests/test_post_merge_hardening.py +178 -0
  13. {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/LICENSE +0 -0
  14. {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/README.md +0 -0
  15. {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/setup.cfg +0 -0
  16. {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/src/aidot/__init__.py +0 -0
  17. {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/src/aidot/aes_utils.py +0 -0
  18. {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/src/aidot/camera/__init__.py +0 -0
  19. {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/src/aidot/credentials.py +0 -0
  20. {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/src/aidot/exceptions.py +0 -0
  21. {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/src/aidot/g711.py +0 -0
  22. {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/src/aidot/login_const.py +0 -0
  23. {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/src/aidot/models/__init__.py +0 -0
  24. {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/src/aidot/models/device_client_model.py +0 -0
  25. {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/src/aidot/models/device_model.py +0 -0
  26. {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/src/aidot/models/discover_model.py +0 -0
  27. {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/src/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  28. {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/src/python_aidot_cameras.egg-info/requires.txt +0 -0
  29. {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/src/python_aidot_cameras.egg-info/top_level.txt +0 -0
  30. {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/tests/test_alarm_event.py +0 -0
  31. {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/tests/test_highport_nomination.py +0 -0
  32. {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/tests/test_motion_poll.py +0 -0
  33. {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/tests/test_no_undefined_names.py +0 -0
  34. {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/tests/test_sdes_idle_release.py +0 -0
  35. {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/tests/test_sdes_sprop.py +0 -0
  36. {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/tests/test_sdes_talk.py +0 -0
  37. {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/tests/test_sdes_watchdog.py +0 -0
  38. {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/tests/test_speak.py +0 -0
  39. {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/tests/test_stream_cap.py +0 -0
  40. {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/tests/test_talk.py +0 -0
  41. {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/tests/test_terminal_ack.py +0 -0
  42. {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.7.1}/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.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.6.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
- from ..device_client import DeviceStatusData, DeviceInformation
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 (camera attribute / cloud-properties payloads).
82
- if attr.get(CONF_ON_OFF) is not None:
83
- self.on = attr.get(CONF_ON_OFF)
84
- if attr.get(CONF_DIMMING) is not None:
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
- # Devices without an aesKey never set this in the core constructor.
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": "1383974540041977857",
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
- return hashlib.md5(raw_id.encode()).hexdigest()
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
- if self._discover is not None:
505
- self._discover.close()
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 = "1383974540041977857"
237
- BASE_URL = "https://prod-us-api.arnoo.com/v17"
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._schedule_reconnect)
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
- candidates = _get_broadcast_candidates()
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-aidot-cameras
3
- Version: 0.6.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
+ )