python-aidot-cameras 0.5.11__tar.gz → 0.5.13__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.5.11/src/python_aidot_cameras.egg-info → python_aidot_cameras-0.5.13}/PKG-INFO +1 -1
- {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/pyproject.toml +1 -1
- {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/src/aidot/client.py +53 -0
- {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/src/aidot/device_client.py +28 -3
- {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13/src/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
- {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/LICENSE +0 -0
- {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/README.md +0 -0
- {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/setup.cfg +0 -0
- {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/src/aidot/__init__.py +0 -0
- {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/src/aidot/aes_utils.py +0 -0
- {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/src/aidot/const.py +0 -0
- {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/src/aidot/credentials.py +0 -0
- {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/src/aidot/discover.py +0 -0
- {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/src/aidot/exceptions.py +0 -0
- {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/src/aidot/g711.py +0 -0
- {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/src/python_aidot_cameras.egg-info/SOURCES.txt +0 -0
- {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/src/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
- {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/src/python_aidot_cameras.egg-info/requires.txt +0 -0
- {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/src/python_aidot_cameras.egg-info/top_level.txt +0 -0
- {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/tests/test_alarm_event.py +0 -0
- {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/tests/test_highport_nomination.py +0 -0
- {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/tests/test_motion_poll.py +0 -0
- {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/tests/test_no_undefined_names.py +0 -0
- {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/tests/test_sdes_idle_release.py +0 -0
- {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/tests/test_sdes_sprop.py +0 -0
- {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/tests/test_sdes_talk.py +0 -0
- {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/tests/test_sdes_watchdog.py +0 -0
- {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/tests/test_speak.py +0 -0
- {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/tests/test_stream_cap.py +0 -0
- {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/tests/test_talk.py +0 -0
- {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/tests/test_terminal_ack.py +0 -0
- {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/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.5.
|
|
3
|
+
Version: 0.5.13
|
|
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.5.
|
|
7
|
+
version = "0.5.13"
|
|
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"
|
|
@@ -4,6 +4,7 @@ import asyncio
|
|
|
4
4
|
import json
|
|
5
5
|
import logging
|
|
6
6
|
import base64
|
|
7
|
+
import random
|
|
7
8
|
import aiohttp
|
|
8
9
|
from aiohttp import ClientSession
|
|
9
10
|
from typing import Any, Callable, Optional
|
|
@@ -54,6 +55,14 @@ def rsa_password_encrypt(message: str) -> str:
|
|
|
54
55
|
|
|
55
56
|
|
|
56
57
|
|
|
58
|
+
async def _prefetch_ice_config(dc: "DeviceClient") -> None:
|
|
59
|
+
"""Background task: warm the HTTP ICE config cache for a device."""
|
|
60
|
+
try:
|
|
61
|
+
await dc.async_get_ice_config_http()
|
|
62
|
+
except Exception:
|
|
63
|
+
pass # best-effort; errors are logged inside async_get_ice_config_http
|
|
64
|
+
|
|
65
|
+
|
|
57
66
|
class AidotClient:
|
|
58
67
|
_base_url: str = BASE_URL
|
|
59
68
|
_region: str = "us"
|
|
@@ -79,6 +88,7 @@ class AidotClient:
|
|
|
79
88
|
self.login_info: dict[str, Any] = {}
|
|
80
89
|
self._token_fresh_cb: Optional[Callable] = None
|
|
81
90
|
self._device_clients = {}
|
|
91
|
+
self._refresh_task: "Optional[asyncio.Task]" = None
|
|
82
92
|
# Single-flight guard so a burst of camera 21026s (all 7 pollers at once)
|
|
83
93
|
# coalesces into ONE token refresh / re-login instead of 7 concurrent ones.
|
|
84
94
|
self._ensure_token_inflight: "Optional[asyncio.Future]" = None
|
|
@@ -95,10 +105,42 @@ class AidotClient:
|
|
|
95
105
|
self._region = token[CONF_REGION]
|
|
96
106
|
self.country_name = token[CONF_COUNTRY]
|
|
97
107
|
self._base_url = f"https://prod-{self._region}-api.arnoo.com/v17"
|
|
108
|
+
# Token loaded from storage: schedule a proactive refresh shortly
|
|
109
|
+
# after startup. Exact remaining TTL is unknown, so pass a short
|
|
110
|
+
# synthetic TTL (120s → 78-138s delay) to catch tokens that are
|
|
111
|
+
# already near expiry. After the refresh fires, _schedule_proactive_refresh
|
|
112
|
+
# sets the next cycle at the proper 90%-of-TTL interval.
|
|
113
|
+
_LOGGER.info("AidotClient: stored token loaded, scheduling startup proactive refresh")
|
|
114
|
+
self._schedule_proactive_refresh(120)
|
|
98
115
|
|
|
99
116
|
def set_token_fresh_cb(self, callback) -> None:
|
|
100
117
|
self._token_fresh_cb = callback
|
|
101
118
|
|
|
119
|
+
def _schedule_proactive_refresh(self, expires_in_secs: int) -> None:
|
|
120
|
+
"""Schedule a proactive token refresh at 90% of the token's TTL."""
|
|
121
|
+
if self._refresh_task and not self._refresh_task.done():
|
|
122
|
+
self._refresh_task.cancel()
|
|
123
|
+
delay = max(60.0, expires_in_secs * 0.9 + random.uniform(-30, 30))
|
|
124
|
+
_LOGGER.debug(
|
|
125
|
+
"Proactive token refresh scheduled in %.0f s (TTL=%d s)", delay, expires_in_secs
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
async def _refresh_after_delay():
|
|
129
|
+
try:
|
|
130
|
+
await asyncio.sleep(delay)
|
|
131
|
+
await self.async_ensure_token()
|
|
132
|
+
_LOGGER.debug("Proactive token refresh complete")
|
|
133
|
+
except asyncio.CancelledError:
|
|
134
|
+
pass
|
|
135
|
+
except Exception as exc:
|
|
136
|
+
_LOGGER.warning("Proactive token refresh failed: %s", exc)
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
loop = asyncio.get_running_loop()
|
|
140
|
+
self._refresh_task = loop.create_task(_refresh_after_delay())
|
|
141
|
+
except RuntimeError:
|
|
142
|
+
pass # no running loop (e.g. called from sync test context)
|
|
143
|
+
|
|
102
144
|
def get_identifier(self) -> str:
|
|
103
145
|
return f"{self._region}-{self.username}"
|
|
104
146
|
|
|
@@ -145,6 +187,9 @@ class AidotClient:
|
|
|
145
187
|
self.login_info[CONF_USERNAME] = self.username
|
|
146
188
|
# Fetch MQTT password from /commons/userConfig (separate call required).
|
|
147
189
|
await self._async_fetch_user_config()
|
|
190
|
+
# Schedule proactive refresh so tokens never expire mid-session.
|
|
191
|
+
_expires_in = int(response_data.get("expiresIn") or 7200)
|
|
192
|
+
self._schedule_proactive_refresh(_expires_in)
|
|
148
193
|
return self.login_info
|
|
149
194
|
except AidotUserOrPassIncorrect:
|
|
150
195
|
raise
|
|
@@ -232,6 +277,8 @@ class AidotClient:
|
|
|
232
277
|
_LOGGER.debug("refresh token ok code=%s", response_data.get(CONF_CODE))
|
|
233
278
|
if self._token_fresh_cb:
|
|
234
279
|
self._token_fresh_cb()
|
|
280
|
+
_expires_in = int(response_data.get("expiresIn") or 7200)
|
|
281
|
+
self._schedule_proactive_refresh(_expires_in)
|
|
235
282
|
return response_data
|
|
236
283
|
except aiohttp.ClientError as e:
|
|
237
284
|
_LOGGER.info("async_refresh_token ClientError %s code=%s", e, response_data.get(CONF_CODE))
|
|
@@ -374,6 +421,10 @@ class AidotClient:
|
|
|
374
421
|
device_client.set_token_refresh_cb(self.async_ensure_token)
|
|
375
422
|
self._device_clients[device_id] = device_client
|
|
376
423
|
asyncio.get_running_loop().create_task(device_client.ping_task())
|
|
424
|
+
# Pre-warm ICE config cache so stream open does not block on this fetch.
|
|
425
|
+
asyncio.get_running_loop().create_task(
|
|
426
|
+
_prefetch_ice_config(device_client)
|
|
427
|
+
)
|
|
377
428
|
# NOTE: no LAN-broadcast discovery here. For APK parity, the camera's LAN
|
|
378
429
|
# IP comes from the WebRTC signaling host candidate (iceCandidateReq), which
|
|
379
430
|
# the camera advertises and we add verbatim - the official app does the same
|
|
@@ -392,6 +443,8 @@ class AidotClient:
|
|
|
392
443
|
self._device_clients.clear()
|
|
393
444
|
|
|
394
445
|
async def async_cleanup(self) -> None:
|
|
446
|
+
if self._refresh_task and not self._refresh_task.done():
|
|
447
|
+
self._refresh_task.cancel()
|
|
395
448
|
for client in self._device_clients.values():
|
|
396
449
|
await client.close()
|
|
397
450
|
self._device_clients.clear()
|
|
@@ -2898,9 +2898,12 @@ class DeviceClient(object):
|
|
|
2898
2898
|
cb = self._token_refresh_cb
|
|
2899
2899
|
if cb is None:
|
|
2900
2900
|
return False
|
|
2901
|
-
# A fresh token invalidates the cached smarthome auth / MQTT URL
|
|
2901
|
+
# A fresh token invalidates the cached smarthome auth / MQTT URL and
|
|
2902
|
+
# any cached ICE/TURN config (credentials are bound to the access token).
|
|
2902
2903
|
self._smarthome_auth = None
|
|
2903
2904
|
self._mqtt_url = None
|
|
2905
|
+
self._cached_ice_config = None
|
|
2906
|
+
self._ice_config_fetched_at = 0.0
|
|
2904
2907
|
try:
|
|
2905
2908
|
return bool(await cb())
|
|
2906
2909
|
except Exception as exc: # noqa: BLE001
|
|
@@ -3701,7 +3704,8 @@ class DeviceClient(object):
|
|
|
3701
3704
|
smarthome_auth = await self._async_get_smarthome_auth()
|
|
3702
3705
|
mqtt_user = (smarthome_auth or {}).get("mqttUser") or str(self.user_id)
|
|
3703
3706
|
mqtt_pwd = (smarthome_auth or {}).get("mqttPassword") or ""
|
|
3704
|
-
|
|
3707
|
+
_base_cid = (self._user_info.get("mqttClientId") or f"app-{mqtt_user}")
|
|
3708
|
+
client_id = f"{_base_cid}-cmd"
|
|
3705
3709
|
user_id = self.user_id
|
|
3706
3710
|
device_id = self.device_id
|
|
3707
3711
|
|
|
@@ -4192,6 +4196,7 @@ class DeviceClient(object):
|
|
|
4192
4196
|
self,
|
|
4193
4197
|
rtsp_push_url: Optional[str] = None,
|
|
4194
4198
|
fast_connect: Optional[bool] = None,
|
|
4199
|
+
sdes_audio: Optional[bool] = None,
|
|
4195
4200
|
) -> None:
|
|
4196
4201
|
"""Start a persistent stream that keeps the camera session alive.
|
|
4197
4202
|
|
|
@@ -4213,6 +4218,8 @@ class DeviceClient(object):
|
|
|
4213
4218
|
"""
|
|
4214
4219
|
if fast_connect is not None:
|
|
4215
4220
|
self._fast_connect_opt = fast_connect
|
|
4221
|
+
if sdes_audio is not None:
|
|
4222
|
+
self._sdes_audio_opt = sdes_audio
|
|
4216
4223
|
if self._stream_task is not None and not self._stream_task.done():
|
|
4217
4224
|
return
|
|
4218
4225
|
self._keepalive_rtsp_url = rtsp_push_url
|
|
@@ -6214,6 +6221,21 @@ class DeviceClient(object):
|
|
|
6214
6221
|
"AIDOT_FAST_CONNECT: skipping getIceConfigResp wait"
|
|
6215
6222
|
" (~2.5s) - STUN/host candidates only, LAN-direct"
|
|
6216
6223
|
)
|
|
6224
|
+
# Second livePlayResp check: catches late rejections that arrived
|
|
6225
|
+
# while we were waiting for ice_config (fast-ICE + slow-reject path).
|
|
6226
|
+
if not _fast_connect and liveplay_resp_fut.done():
|
|
6227
|
+
try:
|
|
6228
|
+
_lp_resp2 = liveplay_resp_fut.result()
|
|
6229
|
+
_lp_code2 = int(_lp_resp2.get("code", 200))
|
|
6230
|
+
_lp_on2 = int(_lp_resp2.get("livePlay", 1))
|
|
6231
|
+
if _lp_code2 not in (0, 200) or _lp_on2 == 0:
|
|
6232
|
+
raise RuntimeError(
|
|
6233
|
+
f"livePlay rejected by camera (code={_lp_code2}, livePlay={_lp_on2})"
|
|
6234
|
+
)
|
|
6235
|
+
except RuntimeError:
|
|
6236
|
+
raise
|
|
6237
|
+
except Exception:
|
|
6238
|
+
pass
|
|
6217
6239
|
|
|
6218
6240
|
# ------------------------------------------------------------------ #
|
|
6219
6241
|
# Branch: SDES-SRTP cameras use ffmpeg; DTLS cameras use aiortc
|
|
@@ -11933,7 +11955,10 @@ class DeviceClient(object):
|
|
|
11933
11955
|
# via AIDOT_SDES_AUDIO_GAIN_DB). Dynamic normalizers (dynaudnorm/
|
|
11934
11956
|
# loudnorm) and `-max_interleave_delta 0` are avoided - both regressed
|
|
11935
11957
|
# the pipe (buffering / ~100 ms audio cutouts) in earlier live tests.
|
|
11936
|
-
|
|
11958
|
+
_sdes_audio = getattr(self, "_sdes_audio_opt", None)
|
|
11959
|
+
if _sdes_audio is None:
|
|
11960
|
+
_sdes_audio = os.environ.get("AIDOT_SDES_SERVE_AUDIO", "0") == "1"
|
|
11961
|
+
if _sdes_audio:
|
|
11937
11962
|
try:
|
|
11938
11963
|
_sdes_gain_db = float(
|
|
11939
11964
|
os.environ.get("AIDOT_SDES_AUDIO_GAIN_DB", "-8")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-aidot-cameras
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.13
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/tests/test_highport_nomination.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/tests/test_no_undefined_names.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
|