python-aidot-cameras 0.5.10__tar.gz → 0.5.12__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.10/src/python_aidot_cameras.egg-info → python_aidot_cameras-0.5.12}/PKG-INFO +1 -1
  2. {python_aidot_cameras-0.5.10 → python_aidot_cameras-0.5.12}/pyproject.toml +1 -1
  3. {python_aidot_cameras-0.5.10 → python_aidot_cameras-0.5.12}/src/aidot/client.py +53 -0
  4. {python_aidot_cameras-0.5.10 → python_aidot_cameras-0.5.12}/src/aidot/device_client.py +62 -34
  5. {python_aidot_cameras-0.5.10 → python_aidot_cameras-0.5.12/src/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  6. {python_aidot_cameras-0.5.10 → python_aidot_cameras-0.5.12}/LICENSE +0 -0
  7. {python_aidot_cameras-0.5.10 → python_aidot_cameras-0.5.12}/README.md +0 -0
  8. {python_aidot_cameras-0.5.10 → python_aidot_cameras-0.5.12}/setup.cfg +0 -0
  9. {python_aidot_cameras-0.5.10 → python_aidot_cameras-0.5.12}/src/aidot/__init__.py +0 -0
  10. {python_aidot_cameras-0.5.10 → python_aidot_cameras-0.5.12}/src/aidot/aes_utils.py +0 -0
  11. {python_aidot_cameras-0.5.10 → python_aidot_cameras-0.5.12}/src/aidot/const.py +0 -0
  12. {python_aidot_cameras-0.5.10 → python_aidot_cameras-0.5.12}/src/aidot/credentials.py +0 -0
  13. {python_aidot_cameras-0.5.10 → python_aidot_cameras-0.5.12}/src/aidot/discover.py +0 -0
  14. {python_aidot_cameras-0.5.10 → python_aidot_cameras-0.5.12}/src/aidot/exceptions.py +0 -0
  15. {python_aidot_cameras-0.5.10 → python_aidot_cameras-0.5.12}/src/aidot/g711.py +0 -0
  16. {python_aidot_cameras-0.5.10 → python_aidot_cameras-0.5.12}/src/python_aidot_cameras.egg-info/SOURCES.txt +0 -0
  17. {python_aidot_cameras-0.5.10 → python_aidot_cameras-0.5.12}/src/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  18. {python_aidot_cameras-0.5.10 → python_aidot_cameras-0.5.12}/src/python_aidot_cameras.egg-info/requires.txt +0 -0
  19. {python_aidot_cameras-0.5.10 → python_aidot_cameras-0.5.12}/src/python_aidot_cameras.egg-info/top_level.txt +0 -0
  20. {python_aidot_cameras-0.5.10 → python_aidot_cameras-0.5.12}/tests/test_alarm_event.py +0 -0
  21. {python_aidot_cameras-0.5.10 → python_aidot_cameras-0.5.12}/tests/test_highport_nomination.py +0 -0
  22. {python_aidot_cameras-0.5.10 → python_aidot_cameras-0.5.12}/tests/test_motion_poll.py +0 -0
  23. {python_aidot_cameras-0.5.10 → python_aidot_cameras-0.5.12}/tests/test_no_undefined_names.py +0 -0
  24. {python_aidot_cameras-0.5.10 → python_aidot_cameras-0.5.12}/tests/test_sdes_idle_release.py +0 -0
  25. {python_aidot_cameras-0.5.10 → python_aidot_cameras-0.5.12}/tests/test_sdes_sprop.py +0 -0
  26. {python_aidot_cameras-0.5.10 → python_aidot_cameras-0.5.12}/tests/test_sdes_talk.py +0 -0
  27. {python_aidot_cameras-0.5.10 → python_aidot_cameras-0.5.12}/tests/test_sdes_watchdog.py +0 -0
  28. {python_aidot_cameras-0.5.10 → python_aidot_cameras-0.5.12}/tests/test_speak.py +0 -0
  29. {python_aidot_cameras-0.5.10 → python_aidot_cameras-0.5.12}/tests/test_stream_cap.py +0 -0
  30. {python_aidot_cameras-0.5.10 → python_aidot_cameras-0.5.12}/tests/test_talk.py +0 -0
  31. {python_aidot_cameras-0.5.10 → python_aidot_cameras-0.5.12}/tests/test_terminal_ack.py +0 -0
  32. {python_aidot_cameras-0.5.10 → python_aidot_cameras-0.5.12}/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.10
3
+ Version: 0.5.12
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.10"
7
+ version = "0.5.12"
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
 
@@ -6214,6 +6218,21 @@ class DeviceClient(object):
6214
6218
  "AIDOT_FAST_CONNECT: skipping getIceConfigResp wait"
6215
6219
  " (~2.5s) - STUN/host candidates only, LAN-direct"
6216
6220
  )
6221
+ # Second livePlayResp check: catches late rejections that arrived
6222
+ # while we were waiting for ice_config (fast-ICE + slow-reject path).
6223
+ if not _fast_connect and liveplay_resp_fut.done():
6224
+ try:
6225
+ _lp_resp2 = liveplay_resp_fut.result()
6226
+ _lp_code2 = int(_lp_resp2.get("code", 200))
6227
+ _lp_on2 = int(_lp_resp2.get("livePlay", 1))
6228
+ if _lp_code2 not in (0, 200) or _lp_on2 == 0:
6229
+ raise RuntimeError(
6230
+ f"livePlay rejected by camera (code={_lp_code2}, livePlay={_lp_on2})"
6231
+ )
6232
+ except RuntimeError:
6233
+ raise
6234
+ except Exception:
6235
+ pass
6217
6236
 
6218
6237
  # ------------------------------------------------------------------ #
6219
6238
  # Branch: SDES-SRTP cameras use ffmpeg; DTLS cameras use aiortc
@@ -11911,39 +11930,48 @@ class DeviceClient(object):
11911
11930
  # promptly after stream_source() returns; PLI re-arms an IDR so a
11912
11931
  # late consumer still gets decodable frames within ~5 s.
11913
11932
  #
11914
- # Audio MUST be transcoded: the camera sends G.711 PCMA (PT=8), which
11915
- # MPEG-TS cannot carry as a recognized stream - a raw `-c copy` writes
11916
- # it as an undecodable private-data stream and the player plays video
11917
- # with NO sound. Transcode PCMA->AAC@48k (matching the DTLS serve's
11918
- # mux), `-c:v copy` the H.264 untouched.
11919
- #
11920
- # `volume`: this camera's PCMA mic runs hot (validated live: "a bit
11921
- # too loud" vs the -15 dBFS the DTLS AGC targets). Trim with a
11922
- # STATELESS `volume` filter (per-sample gain, no look-ahead/buffer) -
11923
- # safe on the real-time RTP->mpegts serve, unlike the dynamic
11924
- # normalizers (dynaudnorm/loudnorm) whose buffering broke the DTLS
11925
- # pipe path. Env-tunable; default -8 dB. (SDES has no adaptive AGC
11926
- # like DTLS's PyAV mux - that lives in numpy on decoded samples and
11927
- # isn't reusable in this ffmpeg-only serve.)
11933
+ # VIDEO-ONLY BY DEFAULT (the AAC audio transcode was DEADLOCKING the
11934
+ # serve under loss; root-caused live 2026-06-08). The mpegts muxer
11935
+ # cannot write its PAT/PMT until every mapped output stream has
11936
+ # produced a first packet. `-c:v copy` knows the H.264 params from
11937
+ # the input SDP immediately, but `-c:a aac` must ENCODE a first AAC
11938
+ # frame, which needs real PCMA samples. On a battery camera over a
11939
+ # weak uplink the PCMA (PT=8) audio arrives sparse/late, the encoder
11940
+ # yields "No filtered frames for output stream", the PMT is never
11941
+ # written, and the consumer (go2rtc/HA) receives ZERO bytes -> the
11942
+ # dashboard spins forever with no error and no timeout. Proven by a
11943
+ # serve-path A/B: production AAC config -> 0 bytes; `-an` -> 3.8 MB /
11944
+ # 408 frames / 10 IDRs in 25 s. (`-max_interleave_delta 0` does NOT
11945
+ # rescue it - the block is the PMT, not interleaving.) So drop audio
11946
+ # by default and let video flow; the H.264 is `-c:v copy` untouched.
11928
11947
  #
11929
- # (NOTE: `-max_interleave_delta 0` was tried here to keep AAC-encode
11930
- # latency from holding video back, but it made the mpegts muxer flush
11931
- # audio with gaps -> repeating ~100 ms audio CUTOUTS (validated live).
11932
- # Reverted to the default interleave; video stutter is handled by the
11933
- # bounded startup-PLI burst instead, not by starving the muxer.)
11934
- try:
11935
- _sdes_gain_db = float(
11936
- os.environ.get("AIDOT_SDES_AUDIO_GAIN_DB", "-8")
11937
- )
11938
- except (ValueError, TypeError):
11939
- _sdes_gain_db = -8.0
11940
- dest_args = [
11941
- "-c:v", "copy",
11942
- "-af", f"volume={_sdes_gain_db}dB",
11943
- "-c:a", "aac", "-ar", "48000", "-b:a", "128k",
11944
- "-f", "mpegts", "-listen", "1",
11945
- rtsp_push_url,
11946
- ]
11948
+ # Opt back into audio with AIDOT_SDES_SERVE_AUDIO=1 (e.g. a strong-
11949
+ # signal/mains camera where PCMA arrives densely enough to keep the
11950
+ # AAC encoder fed). When enabled: transcode PCMA->AAC@48k with a
11951
+ # STATELESS `volume` trim (the mic runs hot; default -8 dB, env-tunable
11952
+ # via AIDOT_SDES_AUDIO_GAIN_DB). Dynamic normalizers (dynaudnorm/
11953
+ # loudnorm) and `-max_interleave_delta 0` are avoided - both regressed
11954
+ # the pipe (buffering / ~100 ms audio cutouts) in earlier live tests.
11955
+ if os.environ.get("AIDOT_SDES_SERVE_AUDIO", "0") == "1":
11956
+ try:
11957
+ _sdes_gain_db = float(
11958
+ os.environ.get("AIDOT_SDES_AUDIO_GAIN_DB", "-8")
11959
+ )
11960
+ except (ValueError, TypeError):
11961
+ _sdes_gain_db = -8.0
11962
+ dest_args = [
11963
+ "-c:v", "copy",
11964
+ "-af", f"volume={_sdes_gain_db}dB",
11965
+ "-c:a", "aac", "-ar", "48000", "-b:a", "128k",
11966
+ "-f", "mpegts", "-listen", "1",
11967
+ rtsp_push_url,
11968
+ ]
11969
+ else:
11970
+ dest_args = [
11971
+ "-c:v", "copy", "-an",
11972
+ "-f", "mpegts", "-listen", "1",
11973
+ rtsp_push_url,
11974
+ ]
11947
11975
  elif rtsp_push_url:
11948
11976
  # Legacy PUSH model: publish to an RTSP server (e.g. go2rtc at :8554).
11949
11977
  # Requires the stream to be pre-registered in go2rtc; retained for
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-aidot-cameras
3
- Version: 0.5.10
3
+ Version: 0.5.12
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