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.
Files changed (32) hide show
  1. {python_aidot_cameras-0.5.11/src/python_aidot_cameras.egg-info → python_aidot_cameras-0.5.13}/PKG-INFO +1 -1
  2. {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/pyproject.toml +1 -1
  3. {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/src/aidot/client.py +53 -0
  4. {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/src/aidot/device_client.py +28 -3
  5. {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13/src/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  6. {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/LICENSE +0 -0
  7. {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/README.md +0 -0
  8. {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/setup.cfg +0 -0
  9. {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/src/aidot/__init__.py +0 -0
  10. {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/src/aidot/aes_utils.py +0 -0
  11. {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/src/aidot/const.py +0 -0
  12. {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/src/aidot/credentials.py +0 -0
  13. {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/src/aidot/discover.py +0 -0
  14. {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/src/aidot/exceptions.py +0 -0
  15. {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/src/aidot/g711.py +0 -0
  16. {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/src/python_aidot_cameras.egg-info/SOURCES.txt +0 -0
  17. {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/src/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  18. {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/src/python_aidot_cameras.egg-info/requires.txt +0 -0
  19. {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/src/python_aidot_cameras.egg-info/top_level.txt +0 -0
  20. {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/tests/test_alarm_event.py +0 -0
  21. {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/tests/test_highport_nomination.py +0 -0
  22. {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/tests/test_motion_poll.py +0 -0
  23. {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/tests/test_no_undefined_names.py +0 -0
  24. {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/tests/test_sdes_idle_release.py +0 -0
  25. {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/tests/test_sdes_sprop.py +0 -0
  26. {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/tests/test_sdes_talk.py +0 -0
  27. {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/tests/test_sdes_watchdog.py +0 -0
  28. {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/tests/test_speak.py +0 -0
  29. {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/tests/test_stream_cap.py +0 -0
  30. {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/tests/test_talk.py +0 -0
  31. {python_aidot_cameras-0.5.11 → python_aidot_cameras-0.5.13}/tests/test_terminal_ack.py +0 -0
  32. {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.11
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.11"
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
- client_id = (self._user_info.get("mqttClientId") or f"app-{mqtt_user}")
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
- if os.environ.get("AIDOT_SDES_SERVE_AUDIO", "0") == "1":
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.11
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