python-aidot-cameras 0.6.0__tar.gz → 0.6.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.6.1}/PKG-INFO +1 -1
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.6.1}/pyproject.toml +1 -1
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.6.1}/src/aidot/camera/client.py +39 -21
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.6.1}/src/aidot/client.py +24 -11
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.6.1}/src/aidot/const.py +3 -2
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.6.1}/src/aidot/device_client.py +23 -1
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.6.1}/src/aidot/discover.py +12 -18
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.6.1/src/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.6.1}/src/python_aidot_cameras.egg-info/SOURCES.txt +1 -0
- python_aidot_cameras-0.6.1/tests/test_post_merge_hardening.py +178 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.6.1}/LICENSE +0 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.6.1}/README.md +0 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.6.1}/setup.cfg +0 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.6.1}/src/aidot/__init__.py +0 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.6.1}/src/aidot/aes_utils.py +0 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.6.1}/src/aidot/camera/__init__.py +0 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.6.1}/src/aidot/credentials.py +0 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.6.1}/src/aidot/exceptions.py +0 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.6.1}/src/aidot/g711.py +0 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.6.1}/src/aidot/login_const.py +0 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.6.1}/src/aidot/models/__init__.py +0 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.6.1}/src/aidot/models/device_client_model.py +0 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.6.1}/src/aidot/models/device_model.py +0 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.6.1}/src/aidot/models/discover_model.py +0 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.6.1}/src/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.6.1}/src/python_aidot_cameras.egg-info/requires.txt +0 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.6.1}/src/python_aidot_cameras.egg-info/top_level.txt +0 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.6.1}/tests/test_alarm_event.py +0 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.6.1}/tests/test_highport_nomination.py +0 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.6.1}/tests/test_motion_poll.py +0 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.6.1}/tests/test_no_undefined_names.py +0 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.6.1}/tests/test_sdes_idle_release.py +0 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.6.1}/tests/test_sdes_sprop.py +0 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.6.1}/tests/test_sdes_talk.py +0 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.6.1}/tests/test_sdes_watchdog.py +0 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.6.1}/tests/test_speak.py +0 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.6.1}/tests/test_stream_cap.py +0 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.6.1}/tests/test_talk.py +0 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.6.1}/tests/test_terminal_ack.py +0 -0
- {python_aidot_cameras-0.6.0 → python_aidot_cameras-0.6.1}/tests/test_token_refresh.py +0 -0
{python_aidot_cameras-0.6.0/src/python_aidot_cameras.egg-info → python_aidot_cameras-0.6.1}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-aidot-cameras
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.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.
|
|
7
|
+
version = "0.6.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,25 @@ 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
|
+
|
|
2678
2692
|
def _init_camera_state(self, device: dict, user_info: dict) -> None:
|
|
2679
2693
|
"""Camera-side state init, called once at the end of DeviceClient.__init__.
|
|
2680
2694
|
|
|
2681
2695
|
Everything camera-specific that the upstream constructor does not set
|
|
2682
2696
|
lives here, so the core __init__ body stays aligned with upstream.
|
|
2683
2697
|
"""
|
|
2684
|
-
# Swap in the camera-aware status/info supersets created by the core
|
|
2698
|
+
# Swap in the camera-aware status/info supersets created by the core,
|
|
2699
|
+
# carrying forward anything the core constructor seeded into the
|
|
2700
|
+
# originals so upstream-added seeding cannot be lost silently.
|
|
2701
|
+
_core_status, _core_info = self.status, self.info
|
|
2685
2702
|
self.status = CameraStatusData()
|
|
2703
|
+
self.status.__dict__.update(_core_status.__dict__)
|
|
2686
2704
|
self.info = CameraDeviceInformation(device)
|
|
2705
|
+
for _k, _v in _core_info.__dict__.items():
|
|
2706
|
+
self.info.__dict__.setdefault(_k, _v)
|
|
2687
2707
|
# livePlayReq dseq - app parity (Q0(): starts at 100, increments per
|
|
2688
2708
|
# request). Camera tracks the live-play sequence; 0 is not a valid seq.
|
|
2689
2709
|
self._live_dseq = 100
|
|
@@ -2702,9 +2722,7 @@ class CameraMixin:
|
|
|
2702
2722
|
self._token_refresh_cb: "Optional[Callable]" = None
|
|
2703
2723
|
# Raw device dict retained for transport-type detection (isDTLS field)
|
|
2704
2724
|
self._raw_device: dict = device
|
|
2705
|
-
|
|
2706
|
-
if not hasattr(self, "aes_key"):
|
|
2707
|
-
self.aes_key = None
|
|
2725
|
+
|
|
2708
2726
|
# Seed camera/diagnostic status from the cloud device "properties" payload
|
|
2709
2727
|
# (Battery_remaining, Occupancy, SDcardStatus, MotionDetection_*, …). This
|
|
2710
2728
|
# is the authoritative, always-current source the official app itself reads
|
|
@@ -3169,7 +3187,7 @@ class CameraMixin:
|
|
|
3169
3187
|
token = (self._user_info.get("accessToken")
|
|
3170
3188
|
or self._user_info.get("access_token") or "")
|
|
3171
3189
|
return {
|
|
3172
|
-
"Appid":
|
|
3190
|
+
"Appid": _AIDOT_APP_ID,
|
|
3173
3191
|
"Token": token,
|
|
3174
3192
|
"Terminal": "app",
|
|
3175
3193
|
"Content-Type": "application/json",
|
|
@@ -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.6.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.6.
|
|
3
|
+
Version: 0.6.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
|
|
@@ -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.6.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.6.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
|