python-aidot-cameras 0.11.3__tar.gz → 0.11.6__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 (128) hide show
  1. {python_aidot_cameras-0.11.3/python_aidot_cameras.egg-info → python_aidot_cameras-0.11.6}/PKG-INFO +2 -2
  2. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/camera/client.py +110 -5
  3. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/camera/protocol.py +38 -5
  4. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/camera/sdes_open.py +61 -1
  5. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/camera/webrtc_open.py +18 -26
  6. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/client.py +23 -4
  7. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/pyproject.toml +2 -2
  8. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6/python_aidot_cameras.egg-info}/PKG-INFO +2 -2
  9. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/python_aidot_cameras.egg-info/SOURCES.txt +4 -0
  10. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/python_aidot_cameras.egg-info/requires.txt +1 -1
  11. python_aidot_cameras-0.11.6/tests/test_keepalive_renew.py +119 -0
  12. python_aidot_cameras-0.11.6/tests/test_mqtt_session_reconnect.py +84 -0
  13. python_aidot_cameras-0.11.6/tests/test_sdes_open_cleanup.py +72 -0
  14. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_sdes_sprop.py +16 -0
  15. python_aidot_cameras-0.11.6/tests/test_stream_drain_ownership.py +73 -0
  16. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/LICENSE +0 -0
  17. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/MANIFEST.in +0 -0
  18. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/README.md +0 -0
  19. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/__init__.py +0 -0
  20. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/__main__.py +0 -0
  21. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/__init__.py +0 -0
  22. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/__init__.py +0 -0
  23. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/clock.py +0 -0
  24. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/codecs/__init__.py +0 -0
  25. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/codecs/base.py +0 -0
  26. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/codecs/g711.py +0 -0
  27. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/codecs/g722.py +0 -0
  28. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/codecs/h264.py +0 -0
  29. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/codecs/opus.py +0 -0
  30. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/codecs/vpx.py +0 -0
  31. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/contrib/__init__.py +0 -0
  32. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/contrib/media.py +0 -0
  33. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/contrib/signaling.py +0 -0
  34. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/events.py +0 -0
  35. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/exceptions.py +0 -0
  36. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/jitterbuffer.py +0 -0
  37. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/mediastreams.py +0 -0
  38. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rate.py +0 -0
  39. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rtcconfiguration.py +0 -0
  40. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rtcdatachannel.py +0 -0
  41. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rtcdtlstransport.py +0 -0
  42. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rtcicetransport.py +0 -0
  43. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rtcpeerconnection.py +0 -0
  44. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rtcrtpparameters.py +0 -0
  45. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rtcrtpreceiver.py +0 -0
  46. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rtcrtpsender.py +0 -0
  47. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rtcrtptransceiver.py +0 -0
  48. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rtcsctptransport.py +0 -0
  49. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rtcsessiondescription.py +0 -0
  50. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rtp.py +0 -0
  51. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/sdp.py +0 -0
  52. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/stats.py +0 -0
  53. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/utils.py +0 -0
  54. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/aes_utils.py +0 -0
  55. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/camera/__init__.py +0 -0
  56. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/camera/constants.py +0 -0
  57. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/camera/controls.py +0 -0
  58. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/camera/go2rtc.py +0 -0
  59. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/camera/lan_control.py +0 -0
  60. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/camera/models.py +0 -0
  61. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/camera/playback.py +0 -0
  62. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/camera/sdes.py +0 -0
  63. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/camera/tutk.py +0 -0
  64. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/camera/webrtc.py +0 -0
  65. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/const.py +0 -0
  66. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/credentials.py +0 -0
  67. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/device_client.py +0 -0
  68. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/discover.py +0 -0
  69. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/exceptions.py +0 -0
  70. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/g711.py +0 -0
  71. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/login_const.py +0 -0
  72. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/models/__init__.py +0 -0
  73. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/models/device_client_model.py +0 -0
  74. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/models/device_model.py +0 -0
  75. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/models/discover_model.py +0 -0
  76. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/py.typed +0 -0
  77. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  78. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
  79. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/python_aidot_cameras.egg-info/top_level.txt +0 -0
  80. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/setup.cfg +0 -0
  81. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_aioice_compat.py +0 -0
  82. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_alarm_event.py +0 -0
  83. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_backoff.py +0 -0
  84. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_deferred_hardening.py +0 -0
  85. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_device_login_guard.py +0 -0
  86. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_device_user_info_cache.py +0 -0
  87. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_dtls_not_ready_burst.py +0 -0
  88. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_dtls_pinning.py +0 -0
  89. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_dtls_skip_signaling_wait.py +0 -0
  90. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_egress_guard.py +0 -0
  91. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_go2rtc.py +0 -0
  92. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_go2rtc_cli.py +0 -0
  93. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_highport_nomination.py +0 -0
  94. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_ice_config_cache.py +0 -0
  95. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_keyframe_prompter.py +0 -0
  96. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_lan_control.py +0 -0
  97. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_live_stream_param.py +0 -0
  98. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_motion_poll.py +0 -0
  99. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_narrow_pc_ice.py +0 -0
  100. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_no_undefined_names.py +0 -0
  101. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_offline_keepalive_pause.py +0 -0
  102. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_open_gate_delay.py +0 -0
  103. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_persistent_mqtt.py +0 -0
  104. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_persistent_mqtt_robustness.py +0 -0
  105. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_playback_tls.py +0 -0
  106. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_post_merge_hardening.py +0 -0
  107. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_retry_policy.py +0 -0
  108. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_sdes_adaptive.py +0 -0
  109. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_sdes_echo_wait_timeout.py +0 -0
  110. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_sdes_fast_liveplay.py +0 -0
  111. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_sdes_idle_release.py +0 -0
  112. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_sdes_serve_audio.py +0 -0
  113. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_sdes_serve_cmd.py +0 -0
  114. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_sdes_talk.py +0 -0
  115. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_sdes_watchdog.py +0 -0
  116. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_security_hardening.py +0 -0
  117. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_serializable_login_info.py +0 -0
  118. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_serve_relay.py +0 -0
  119. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_session_stats.py +0 -0
  120. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_speak.py +0 -0
  121. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_stream_cap.py +0 -0
  122. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_stream_idle.py +0 -0
  123. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_stream_teardown.py +0 -0
  124. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_talk.py +0 -0
  125. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_terminal_ack.py +0 -0
  126. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_token_file_robustness.py +0 -0
  127. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_token_refresh.py +0 -0
  128. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_wait_or_event.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-aidot-cameras
3
- Version: 0.11.3
3
+ Version: 0.11.6
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
@@ -12,7 +12,7 @@ Requires-Python: >=3.11
12
12
  Description-Content-Type: text/markdown
13
13
  License-File: LICENSE
14
14
  Requires-Dist: aiohttp>=3.9
15
- Requires-Dist: paho-mqtt>=2.0
15
+ Requires-Dist: paho-mqtt>=1.6.1
16
16
  Requires-Dist: cryptography>=42.0
17
17
  Requires-Dist: pycryptodome>=3.20
18
18
  Requires-Dist: dacite>=1.8
@@ -565,6 +565,14 @@ def _get_stream_slots() -> "asyncio.Semaphore":
565
565
 
566
566
 
567
567
 
568
+ # Battery cameras re-sleep on their own low-power timer ~25 s after the last
569
+ # keep-alive, even mid-view. Renew setKeepAliveTime INSIDE that window so there
570
+ # is no sleep gap (the official app renews every 30 s, which leaves a ~5 s gap
571
+ # against the 25 s window). See CameraMixin._keepalive_renew_loop.
572
+ _KEEPALIVE_WINDOW_S = 25
573
+ _KEEPALIVE_RENEW_S = 20
574
+
575
+
568
576
  class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
569
577
  """All camera/streaming methods, mixed into DeviceClient via inheritance."""
570
578
 
@@ -976,7 +984,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
976
984
 
977
985
  Mirrors the official app's wake (DeviceWakeUpRepos.k()), which fires the
978
986
  HTTP wake so a sleeping camera (no live MQTT session) still gets the signal
979
- through the cloud, and retries on failure (n.java onError "正在重试"). The
987
+ through the cloud, and retries on failure (n.java onError "retrying"). The
980
988
  MQTT ``lowPowerActiveStateReq`` is also published by the streaming /
981
989
  attribute paths; this adds the reliable cloud-side channel. Non-battery
982
990
  cameras are always reachable, so this is a no-op for them.
@@ -1886,7 +1894,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
1886
1894
 
1887
1895
  _MIME = {1: "video/mp4", 2: "application/x-mpegURL"}
1888
1896
 
1889
- try:
1897
+ async def _fetch():
1890
1898
  async with aiohttp.ClientSession() as session:
1891
1899
  async with session.post(
1892
1900
  f"{self._aidot_v32_base}/playback/getEventVideoUrl",
@@ -1897,7 +1905,12 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
1897
1905
  headers=self._aidot_headers(),
1898
1906
  timeout=aiohttp.ClientTimeout(total=15),
1899
1907
  ) as resp:
1900
- body = await resp.json(content_type=None)
1908
+ return await resp.json(content_type=None)
1909
+
1910
+ try:
1911
+ body = await _fetch()
1912
+ if self._is_auth_error(body) and await self._async_refresh_auth_token():
1913
+ body = await _fetch() # retry once with the refreshed token
1901
1914
 
1902
1915
  _LOGGER.debug("getEventVideoUrl raw for %s uuid=%s: %s", self.device_id, event_uuid, body)
1903
1916
 
@@ -1958,7 +1971,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
1958
1971
  end_ts = int(time.time() * 1000)
1959
1972
  start_ts = end_ts - 30 * 86_400_000 # look back 30 days
1960
1973
 
1961
- try:
1974
+ async def _fetch():
1962
1975
  async with aiohttp.ClientSession() as session:
1963
1976
  async with session.post(
1964
1977
  f"{self._aidot_v32_base}/playback/eventRecordingList",
@@ -1972,7 +1985,12 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
1972
1985
  headers=self._aidot_headers(),
1973
1986
  timeout=aiohttp.ClientTimeout(total=15),
1974
1987
  ) as resp:
1975
- body = await resp.json(content_type=None)
1988
+ return await resp.json(content_type=None)
1989
+
1990
+ try:
1991
+ body = await _fetch()
1992
+ if self._is_auth_error(body) and await self._async_refresh_auth_token():
1993
+ body = await _fetch() # retry once with the refreshed token
1976
1994
 
1977
1995
  _LOGGER.debug("thumbnail eventRecordingList for %s: %s", self.device_id, body)
1978
1996
  if body.get("code") != 200:
@@ -2169,6 +2187,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2169
2187
  if self._stream_task is not None and not self._stream_task.done():
2170
2188
  return
2171
2189
  self._streaming_active = True
2190
+ self._start_keepalive_renew() # battery: renew keep-alive on this path too
2172
2191
  self._stream_task = asyncio.ensure_future(self._streaming_loop())
2173
2192
 
2174
2193
  async def async_stop_streaming(self) -> None:
@@ -2196,6 +2215,13 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2196
2215
  await task
2197
2216
  except (asyncio.CancelledError, Exception):
2198
2217
  _LOGGER.debug("camera %s: swallowed exception in %s", getattr(self, "device_id", "?"), 'async_stop_streaming', exc_info=True)
2218
+ ka_task, self._keepalive_task = getattr(self, "_keepalive_task", None), None
2219
+ if ka_task is not None and not ka_task.done():
2220
+ ka_task.cancel()
2221
+ try:
2222
+ await ka_task
2223
+ except (asyncio.CancelledError, Exception):
2224
+ _LOGGER.debug("camera %s: swallowed exception in %s", getattr(self, "device_id", "?"), 'async_stop_streaming', exc_info=True)
2199
2225
  # Reap a persistent-MQTT stream drain that no session stopped (e.g. an
2200
2226
  # open cancelled mid-handshake) so its handler is removed from the shared
2201
2227
  # connection and its blocked executor thread is released.
@@ -2228,6 +2254,20 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2228
2254
  except (asyncio.CancelledError, Exception):
2229
2255
  _LOGGER.debug("camera %s: swallowed exception in %s", getattr(self, "device_id", "?"), '_reap_stream_drain', exc_info=True)
2230
2256
 
2257
+ def _release_stream_drain_to_session(self):
2258
+ """Hand ownership of the persistent-MQTT signaling drain to the session
2259
+ that just opened successfully.
2260
+
2261
+ The returned WebRTCSession/SdesSession reaps its own outgoing_q + mqtt_fut
2262
+ on stop(); ``_stream_mqtt_drain``/``_stream_mqtt_outq`` are only a backstop
2263
+ for a drain whose open was cancelled *before* a session took ownership.
2264
+ Clearing the slot on hand-off means a concurrent open on this same camera
2265
+ (e.g. a snapshot taken during a live view) no longer reaps - and so no
2266
+ longer kills - the live session's drain; the slot from then on only ever
2267
+ holds a genuinely orphaned (cancelled mid-handshake) drain."""
2268
+ self._stream_mqtt_drain = None
2269
+ self._stream_mqtt_outq = None
2270
+
2231
2271
  async def async_start_motion_polling(
2232
2272
  self, callback: Callable, interval: float = 30.0, lookback_s: int = 600,
2233
2273
  ) -> None:
@@ -2307,6 +2347,65 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2307
2347
  except asyncio.CancelledError:
2308
2348
  return
2309
2349
 
2350
+ async def _async_set_keep_alive(self, keep_alive_s: int = _KEEPALIVE_WINDOW_S) -> None:
2351
+ """POST setKeepAliveTime so a battery camera stays awake for keep_alive_s
2352
+ seconds. Best-effort (errors swallowed). Wire format matches the app
2353
+ (n.java: keepAliveTime=25)."""
2354
+ try:
2355
+ import aiohttp as _aiohttp
2356
+ async with _aiohttp.ClientSession() as _s:
2357
+ async with _s.post(
2358
+ f"{self._aidot_v32_base}/devices/{self.device_id}/setKeepAliveTime",
2359
+ json={"keepAliveTime": keep_alive_s},
2360
+ headers=self._aidot_headers(),
2361
+ timeout=_aiohttp.ClientTimeout(total=8),
2362
+ ) as _r:
2363
+ _LOGGER.debug(
2364
+ "setKeepAliveTime HTTP %d for %s", _r.status, self.device_id
2365
+ )
2366
+ except Exception as _exc:
2367
+ _LOGGER.debug(
2368
+ "setKeepAliveTime HTTP failed for %s: %s", self.device_id, _exc
2369
+ )
2370
+
2371
+ def _start_keepalive_renew(self) -> None:
2372
+ """Start the battery keep-alive renew loop - battery-only and
2373
+ single-instance: cancel any prior loop first so a re-view within the
2374
+ renew sleep window can't orphan one (an orphan keeps POSTing
2375
+ setKeepAliveTime until _streaming_active next clears). No-op for mains
2376
+ cameras, which never sleep."""
2377
+ if not self.is_battery_camera:
2378
+ return
2379
+ prev = getattr(self, "_keepalive_task", None)
2380
+ if prev is not None and not prev.done():
2381
+ prev.cancel()
2382
+ self._keepalive_task = asyncio.ensure_future(self._keepalive_renew_loop())
2383
+
2384
+ def _cancel_keepalive_renew(self) -> None:
2385
+ """Cancel the battery keep-alive renew loop if running (fire-and-forget)."""
2386
+ task, self._keepalive_task = getattr(self, "_keepalive_task", None), None
2387
+ if task is not None and not task.done():
2388
+ task.cancel()
2389
+
2390
+ async def _keepalive_renew_loop(self) -> None:
2391
+ """Renew setKeepAliveTime while a BATTERY camera is streaming (app parity).
2392
+
2393
+ The camera's own low-power timer returns it to sleep ~25 s after the last
2394
+ keep-alive even mid-view, so a one-shot keep-alive at open lets the stream
2395
+ drop partway through. The official app renews it throughout a live view;
2396
+ we renew every _KEEPALIVE_RENEW_S seconds (inside the 25 s window, so no
2397
+ sleep gap). Mains cameras never sleep, so this is battery-only."""
2398
+ if not self.is_battery_camera:
2399
+ return
2400
+ try:
2401
+ while self._streaming_active:
2402
+ await asyncio.sleep(_KEEPALIVE_RENEW_S)
2403
+ if not self._streaming_active:
2404
+ break
2405
+ await self._async_set_keep_alive()
2406
+ except asyncio.CancelledError:
2407
+ pass
2408
+
2310
2409
  async def start_keepalive(
2311
2410
  self,
2312
2411
  rtsp_push_url: Optional[str] = None,
@@ -2381,6 +2480,10 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2381
2480
  self._keepalive_rtsp_url = rtsp_push_url
2382
2481
  self._go2rtc_url = go2rtc_url
2383
2482
  self._streaming_active = True
2483
+ # App parity: keep a battery camera awake for the whole view (mains cams
2484
+ # never sleep, so this is a no-op there). Single-instance; cancelled in
2485
+ # stop / idle-release.
2486
+ self._start_keepalive_renew()
2384
2487
  if self.is_sdes_camera:
2385
2488
  self._stream_task = asyncio.ensure_future(self._sdes_keepalive_loop())
2386
2489
  elif rtsp_push_url and rtsp_push_url.startswith("http"):
@@ -2703,6 +2806,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2703
2806
  except Exception:
2704
2807
  _LOGGER.debug("camera %s: swallowed exception in %s", getattr(self, "device_id", "?"), '_sdes_keepalive_loop', exc_info=True)
2705
2808
  self._streaming_active = False
2809
+ self._cancel_keepalive_renew()
2706
2810
  self._keepalive_rtsp_url = None
2707
2811
  self._serve_ready.clear()
2708
2812
  _LOGGER.debug(
@@ -3490,6 +3594,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3490
3594
  # No viewer - go dormant; camera.stream_source() restarts us when
3491
3595
  # someone opens the live view again.
3492
3596
  self._streaming_active = False
3597
+ self._cancel_keepalive_renew()
3493
3598
  self._keepalive_rtsp_url = None
3494
3599
  self._serve_ready.clear()
3495
3600
  _LOGGER.debug(
@@ -536,6 +536,11 @@ def _extract_param_sets_from_rtp(pkt: bytes) -> dict:
536
536
  while i + 2 <= len(payload):
537
537
  size = int.from_bytes(payload[i:i + 2], "big")
538
538
  i += 2
539
+ if i + size > len(payload):
540
+ # Truncated STAP-A: the advertised NAL size runs past the end of
541
+ # the packet, so the remaining bytes are garbage. Stop rather
542
+ # than slice a short NAL and cache a corrupt SPS/PPS.
543
+ break
539
544
  nal = payload[i:i + size]
540
545
  i += size
541
546
  if nal:
@@ -1211,6 +1216,16 @@ def _mqtt_session_sync(
1211
1216
  status["connected"] = (rc == 0)
1212
1217
  status["rc"] = rc
1213
1218
  status["rc_str"] = str(reason_code)
1219
+ if rc == 0:
1220
+ # (Re)subscribe on EVERY successful connect. paho's loop_start
1221
+ # auto-reconnects after a transient drop, but with clean_session the
1222
+ # broker retains no subscriptions, so a reconnected client would be
1223
+ # deaf unless they are re-established here rather than only once below.
1224
+ for _sub in subscribe_topics:
1225
+ try:
1226
+ c.subscribe(_sub)
1227
+ except Exception:
1228
+ _LOGGER.debug("_mqtt_session: resubscribe failed for %s", _sub, exc_info=True)
1214
1229
  conn_ev.set()
1215
1230
 
1216
1231
  def _on_message(c, ud, msg):
@@ -1219,14 +1234,32 @@ def _mqtt_session_sync(
1219
1234
  else str(msg.payload))
1220
1235
  msg_q.put((msg.topic, payload))
1221
1236
 
1222
- def _on_disconnect(c, ud, disconnect_flags=None, reason_code=None, props=None):
1237
+ def _on_disconnect(c, ud, *args):
1238
+ # paho >=2 calls (disconnect_flags, reason_code, properties); paho <2
1239
+ # calls (rc,). Pull the reason code from whichever slot it lands in so
1240
+ # the log is truthful on both (a fixed 5-arg signature put paho 1.x's rc
1241
+ # in disconnect_flags and logged reason_code as None).
1242
+ reason_code = args[1] if len(args) >= 2 else (args[0] if args else None)
1223
1243
  # If _on_connect was never fired (WebSocket upgrade failed, auth refused
1224
1244
  # at TCP level, etc.) signal conn_ev now so the caller doesn't time out.
1225
1245
  if not conn_ev.is_set():
1246
+ # Never connected (WS upgrade failed, auth refused at TCP level, ...):
1247
+ # unblock the caller and end the receive loop - the connect failed.
1226
1248
  status["connected"] = False
1227
1249
  status["rc_str"] = f"disconnect-before-connect rc={reason_code}"
1228
1250
  conn_ev.set()
1229
- msg_q.put(None) # sentinel to unblock the receive loop
1251
+ msg_q.put(None) # sentinel: end the receive loop
1252
+ return
1253
+ # Already connected once: this is a transient drop. paho's loop_start
1254
+ # auto-reconnects and _on_connect re-subscribes, so DON'T end the receive
1255
+ # loop here - a broker blip must not tear down a long-lived stream's
1256
+ # signaling (the non-persistent transport runs one session for the whole
1257
+ # stream). A real teardown ends the loop via the outgoing-queue None
1258
+ # sentinel or the duration deadline instead.
1259
+ _LOGGER.debug(
1260
+ "_mqtt_session: transient disconnect rc=%s (paho will reconnect)",
1261
+ reason_code,
1262
+ )
1230
1263
 
1231
1264
  def _on_log(c, ud, level, buf):
1232
1265
  if len(status["log"]) < 500:
@@ -1274,9 +1307,9 @@ def _mqtt_session_sync(
1274
1307
 
1275
1308
  _LOGGER.info("_mqtt_session: connected to %s:%d clientId=%s", hostname, port, client_id)
1276
1309
 
1277
- for topic in subscribe_topics:
1278
- client.subscribe(topic)
1279
- _LOGGER.debug("_mqtt_session: subscribed %s", topic)
1310
+ # Subscriptions are (re)established in _on_connect so they survive a paho
1311
+ # auto-reconnect; nothing to subscribe here.
1312
+ _LOGGER.debug("_mqtt_session: subscribed (in on_connect) %s", subscribe_topics)
1280
1313
 
1281
1314
  for pub_topic, pub_payload in publish_items:
1282
1315
  client.publish(pub_topic, pub_payload)
@@ -47,9 +47,38 @@ def _sdes_echo_wait_timeout(skip_liveplay: bool) -> float:
47
47
 
48
48
 
49
49
  class _SdesOpenMixin:
50
- async def _open_sdes_stream(
50
+ async def _open_sdes_stream(self, **kwargs) -> "SdesSession":
51
+ """Allocate-and-hand-off wrapper around _open_sdes_stream_impl.
52
+
53
+ The impl reserves two UDP sockets, starts a bridge thread, launches
54
+ ffmpeg and writes a temp SDP, then hands them to the returned
55
+ SdesSession (whose stop() releases them). If the cold open is cancelled
56
+ mid-handshake (25-70s) - or raises before that hand-off - none of that
57
+ has an owner yet, so a plain call leaks the fds, the thread and a /tmp
58
+ file on every cancelled attempt (eventually "Too many open files").
59
+ The impl registers each resource on an ExitStack as it is allocated;
60
+ close the stack unless the impl actually returned a session.
61
+ """
62
+ from contextlib import ExitStack
63
+
64
+ _cleanup = ExitStack()
65
+ _ok = False
66
+ try:
67
+ _session = await self._open_sdes_stream_impl(_cleanup=_cleanup, **kwargs)
68
+ _ok = True
69
+ return _session
70
+ finally:
71
+ if not _ok:
72
+ # Closing the reservation sockets also unblocks the bridge
73
+ # thread's recv so it exits; ffmpeg is killed and the SDP file
74
+ # unlinked. LIFO, and every callback swallows so this never
75
+ # masks the original CancelledError/exception.
76
+ _cleanup.close()
77
+
78
+ async def _open_sdes_stream_impl(
51
79
  self,
52
80
  *,
81
+ _cleanup=None,
53
82
  peer_id: str,
54
83
  user_id: str,
55
84
  device_id: str,
@@ -120,16 +149,45 @@ class _SdesOpenMixin:
120
149
  def _seq() -> str:
121
150
  return f"ap{random.randint(1000000, 9999999)}"
122
151
 
152
+ # Register a resource with the wrapper's cleanup stack so it is released
153
+ # if the cold open is cancelled/raises before the SdesSession takes
154
+ # ownership. Each step swallows so ExitStack.close() can never mask the
155
+ # original CancelledError/exception. No-op if called without a stack.
156
+ def _cl(fn, *a):
157
+ if _cleanup is None:
158
+ return
159
+ def _run():
160
+ try:
161
+ fn(*a)
162
+ except Exception:
163
+ _LOGGER.debug(
164
+ "camera %s: swallowed sdes-open cleanup step",
165
+ getattr(self, "device_id", "?"), exc_info=True,
166
+ )
167
+ _cleanup.callback(_run)
168
+
169
+ def _reap(p):
170
+ try:
171
+ p.kill()
172
+ except Exception:
173
+ pass
174
+ try:
175
+ p.poll() # reap the killed child so it does not linger as a zombie
176
+ except Exception:
177
+ pass
178
+
123
179
  # --- Allocate UDP ports and determine local IP ---------------------- #
124
180
  import socket as _socket
125
181
 
126
182
  _audio_sock = _socket.socket(_socket.AF_INET, _socket.SOCK_DGRAM)
127
183
  _audio_sock.bind(("0.0.0.0", 0))
128
184
  audio_port = _audio_sock.getsockname()[1]
185
+ _cl(_audio_sock.close) # also unblocks the bridge thread's recv on cleanup
129
186
 
130
187
  _video_sock = _socket.socket(_socket.AF_INET, _socket.SOCK_DGRAM)
131
188
  _video_sock.bind(("0.0.0.0", 0))
132
189
  video_port = _video_sock.getsockname()[1]
190
+ _cl(_video_sock.close)
133
191
 
134
192
  # Use the outbound interface toward 8.8.8.8 to find our local IP.
135
193
  # connect() on a UDP socket does not send any packet.
@@ -733,6 +791,7 @@ class _SdesOpenMixin:
733
791
 
734
792
  sdp_path = await asyncio.get_running_loop().run_in_executor(
735
793
  None, _make_sdp_tempfile, _inject_sprop(ffmpeg_sdp, self.device_id))
794
+ _cl(os.unlink, sdp_path) # released with the sockets on a cancelled open
736
795
 
737
796
  # --- Send webrtcReq BEFORE releasing reservation sockets ------------- #
738
797
  # ICE cameras (e.g. LK.IPC.A001064) send STUN binding requests to our
@@ -3384,6 +3443,7 @@ class _SdesOpenMixin:
3384
3443
  stderr=subprocess.PIPE,
3385
3444
  )
3386
3445
  _proc_holder[0] = proc
3446
+ _cl(_reap, proc) # kill ffmpeg if the open is cancelled before hand-off
3387
3447
  except FileNotFoundError:
3388
3448
  # ffmpeg is not installed - clean up and surface a clear error.
3389
3449
  for _rsock in (_audio_sock, _video_sock):
@@ -1000,32 +1000,13 @@ class _WebRTCOpenMixin:
1000
1000
  "lowPowerActiveState HTTP failed for %s: %s", device_id, _we
1001
1001
  )
1002
1002
 
1003
- async def _http_keepalive() -> None:
1004
- # setKeepAliveTime keeps the camera in active state for 25 s
1005
- # after wake (n.java:224 - keepAliveTime=25, not 20).
1006
- # Without this call the camera's built-in timer may return it
1007
- # to sleep before SCTP + LIVING completes (~10-15 s).
1008
- try:
1009
- import aiohttp as _aiohttp_k
1010
- async with _aiohttp_k.ClientSession() as _ks:
1011
- async with _ks.post(
1012
- f"{self._aidot_v32_base}/devices/{device_id}"
1013
- "/setKeepAliveTime",
1014
- json={"keepAliveTime": 25},
1015
- headers=self._aidot_headers(),
1016
- timeout=_aiohttp_k.ClientTimeout(total=8),
1017
- ) as _kr:
1018
- _LOGGER.debug(
1019
- "setKeepAliveTime HTTP %d for %s",
1020
- _kr.status, device_id,
1021
- )
1022
- except Exception as _ke:
1023
- _LOGGER.debug(
1024
- "setKeepAliveTime HTTP failed for %s: %s", device_id, _ke
1025
- )
1026
-
1027
1003
  _spawn_bg(_http_wake())
1028
- _spawn_bg(_http_keepalive())
1004
+ # setKeepAliveTime keeps the camera active for ~25 s after wake so the
1005
+ # SCTP + LIVING handshake can complete. For BATTERY cameras a renewal
1006
+ # loop (CameraMixin._keepalive_renew_loop, started by start_keepalive)
1007
+ # then re-issues it every 20 s for the whole view - app parity so the
1008
+ # camera doesn't return to sleep mid-stream.
1009
+ _spawn_bg(self._async_set_keep_alive())
1029
1010
 
1030
1011
  outgoing_q.put_nowait(
1031
1012
  (f"iot/v1/s/{user_id}/IPC/getIceConfigReq", _ice_req_payload)
@@ -1228,7 +1209,7 @@ class _WebRTCOpenMixin:
1228
1209
  # ------------------------------------------------------------------ #
1229
1210
  if use_sdes:
1230
1211
  try:
1231
- return await self._open_sdes_stream(
1212
+ _sdes_session = await self._open_sdes_stream(
1232
1213
  peer_id=peer_id,
1233
1214
  user_id=user_id,
1234
1215
  device_id=device_id,
@@ -1256,6 +1237,13 @@ class _WebRTCOpenMixin:
1256
1237
  rtsp_push_url=rtsp_push_url,
1257
1238
  talk=talk,
1258
1239
  )
1240
+ # Success: the SdesSession now owns outgoing_q + mqtt_fut and
1241
+ # reaps them on stop(); clear the backstop slot so a concurrent
1242
+ # open on this camera cannot reap this live session's drain.
1243
+ # (On _SdesNoAnswerError the await raises before this runs, so the
1244
+ # DTLS-fallback path below keeps the slot for its own hand-off.)
1245
+ self._release_stream_drain_to_session()
1246
+ return _sdes_session
1259
1247
  except CameraMixin._SdesNoAnswerError:
1260
1248
  # Camera reported enableSdes='1' but did not respond to our SDES
1261
1249
  # offer. iOS telemetry shows models such as LK.IPC.A001064 can
@@ -3591,6 +3579,10 @@ class _WebRTCOpenMixin:
3591
3579
  _LOGGER.info(
3592
3580
  "WebRTC stream open for %s (peerid=%s)", device_id, peer_id
3593
3581
  )
3582
+ # Ownership of the signaling drain passes to this session (its stop()
3583
+ # reaps outgoing_q + mqtt_fut); clear the backstop slot so a concurrent
3584
+ # open on this camera cannot reap this live session's drain.
3585
+ self._release_stream_drain_to_session()
3594
3586
  return WebRTCSession(
3595
3587
  pc=pc,
3596
3588
  outgoing_q=outgoing_q,
@@ -50,6 +50,27 @@ _LOGGER = logging.getLogger(__name__)
50
50
  # other cloud API calls that use owner+token headers.
51
51
  _CLOUD_APP_ID = "68"
52
52
 
53
+ # Strong refs to fire-and-forget tasks: asyncio only keeps weak refs, so a
54
+ # discarded task can be garbage-collected mid-flight (see camera/client.py).
55
+ _BG_TASKS: set = set()
56
+
57
+
58
+ def _spawn_bg(coro):
59
+ _t = asyncio.ensure_future(coro)
60
+ _BG_TASKS.add(_t)
61
+
62
+ def _done(task):
63
+ _BG_TASKS.discard(task)
64
+ # Retrieve the exception so a failing background task doesn't surface as
65
+ # an unhandled "Task exception was never retrieved" at GC time; log it.
66
+ if not task.cancelled():
67
+ exc = task.exception()
68
+ if exc is not None:
69
+ _LOGGER.debug("aidot background task failed: %r", exc, exc_info=exc)
70
+
71
+ _t.add_done_callback(_done)
72
+ return _t
73
+
53
74
 
54
75
  def rsa_password_encrypt(message: str) -> str:
55
76
  """RSA-encrypt the password using the AiDot public key (for loginWithFreeVerification)."""
@@ -513,9 +534,7 @@ class AidotClient:
513
534
  device_client.set_token_refresh_cb(self.async_ensure_token)
514
535
  self._device_clients[device_id] = device_client
515
536
  # Pre-warm ICE config cache so stream open does not block on this fetch.
516
- asyncio.get_running_loop().create_task(
517
- _prefetch_ice_config(device_client)
518
- )
537
+ _spawn_bg(_prefetch_ice_config(device_client))
519
538
  # Started lazily here as well: __init__ cannot start discovery when
520
539
  # constructed outside a running event loop (stored-token sync path).
521
540
  if self._discover is None:
@@ -583,7 +602,7 @@ class AidotClient:
583
602
 
584
603
  def cleanup(self) -> None:
585
604
  """Sync entry point: fire-and-forget async_close()."""
586
- asyncio.get_running_loop().create_task(self.async_close())
605
+ _spawn_bg(self.async_close())
587
606
 
588
607
  async def async_cleanup(self) -> None:
589
608
  """Async entry point: close the client and release resources."""
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-aidot-cameras"
7
- version = "0.11.3"
7
+ version = "0.11.6"
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"
@@ -22,7 +22,7 @@ classifiers = [
22
22
  # after re-validating against it.
23
23
  dependencies = [
24
24
  "aiohttp>=3.9",
25
- "paho-mqtt>=2.0",
25
+ "paho-mqtt>=1.6.1",
26
26
  "cryptography>=42.0",
27
27
  "pycryptodome>=3.20",
28
28
  "dacite>=1.8",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-aidot-cameras
3
- Version: 0.11.3
3
+ Version: 0.11.6
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
@@ -12,7 +12,7 @@ Requires-Python: >=3.11
12
12
  Description-Content-Type: text/markdown
13
13
  License-File: LICENSE
14
14
  Requires-Dist: aiohttp>=3.9
15
- Requires-Dist: paho-mqtt>=2.0
15
+ Requires-Dist: paho-mqtt>=1.6.1
16
16
  Requires-Dist: cryptography>=42.0
17
17
  Requires-Dist: pycryptodome>=3.20
18
18
  Requires-Dist: dacite>=1.8
@@ -85,10 +85,12 @@ tests/test_go2rtc.py
85
85
  tests/test_go2rtc_cli.py
86
86
  tests/test_highport_nomination.py
87
87
  tests/test_ice_config_cache.py
88
+ tests/test_keepalive_renew.py
88
89
  tests/test_keyframe_prompter.py
89
90
  tests/test_lan_control.py
90
91
  tests/test_live_stream_param.py
91
92
  tests/test_motion_poll.py
93
+ tests/test_mqtt_session_reconnect.py
92
94
  tests/test_narrow_pc_ice.py
93
95
  tests/test_no_undefined_names.py
94
96
  tests/test_offline_keepalive_pause.py
@@ -102,6 +104,7 @@ tests/test_sdes_adaptive.py
102
104
  tests/test_sdes_echo_wait_timeout.py
103
105
  tests/test_sdes_fast_liveplay.py
104
106
  tests/test_sdes_idle_release.py
107
+ tests/test_sdes_open_cleanup.py
105
108
  tests/test_sdes_serve_audio.py
106
109
  tests/test_sdes_serve_cmd.py
107
110
  tests/test_sdes_sprop.py
@@ -113,6 +116,7 @@ tests/test_serve_relay.py
113
116
  tests/test_session_stats.py
114
117
  tests/test_speak.py
115
118
  tests/test_stream_cap.py
119
+ tests/test_stream_drain_ownership.py
116
120
  tests/test_stream_idle.py
117
121
  tests/test_stream_teardown.py
118
122
  tests/test_talk.py
@@ -1,5 +1,5 @@
1
1
  aiohttp>=3.9
2
- paho-mqtt>=2.0
2
+ paho-mqtt>=1.6.1
3
3
  cryptography>=42.0
4
4
  pycryptodome>=3.20
5
5
  dacite>=1.8