python-aidot-cameras 0.11.3__tar.gz → 0.11.5__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 (125) hide show
  1. {python_aidot_cameras-0.11.3/python_aidot_cameras.egg-info → python_aidot_cameras-0.11.5}/PKG-INFO +2 -2
  2. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/camera/client.py +81 -0
  3. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/camera/protocol.py +6 -1
  4. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/camera/webrtc_open.py +6 -25
  5. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/pyproject.toml +2 -2
  6. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5/python_aidot_cameras.egg-info}/PKG-INFO +2 -2
  7. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/python_aidot_cameras.egg-info/SOURCES.txt +1 -0
  8. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/python_aidot_cameras.egg-info/requires.txt +1 -1
  9. python_aidot_cameras-0.11.5/tests/test_keepalive_renew.py +119 -0
  10. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/LICENSE +0 -0
  11. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/MANIFEST.in +0 -0
  12. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/README.md +0 -0
  13. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/__init__.py +0 -0
  14. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/__main__.py +0 -0
  15. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/_vendor/__init__.py +0 -0
  16. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/__init__.py +0 -0
  17. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/clock.py +0 -0
  18. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/codecs/__init__.py +0 -0
  19. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/codecs/base.py +0 -0
  20. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/codecs/g711.py +0 -0
  21. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/codecs/g722.py +0 -0
  22. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/codecs/h264.py +0 -0
  23. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/codecs/opus.py +0 -0
  24. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/codecs/vpx.py +0 -0
  25. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/contrib/__init__.py +0 -0
  26. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/contrib/media.py +0 -0
  27. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/contrib/signaling.py +0 -0
  28. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/events.py +0 -0
  29. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/exceptions.py +0 -0
  30. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/jitterbuffer.py +0 -0
  31. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/mediastreams.py +0 -0
  32. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/rate.py +0 -0
  33. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/rtcconfiguration.py +0 -0
  34. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/rtcdatachannel.py +0 -0
  35. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/rtcdtlstransport.py +0 -0
  36. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/rtcicetransport.py +0 -0
  37. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/rtcpeerconnection.py +0 -0
  38. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/rtcrtpparameters.py +0 -0
  39. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/rtcrtpreceiver.py +0 -0
  40. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/rtcrtpsender.py +0 -0
  41. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/rtcrtptransceiver.py +0 -0
  42. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/rtcsctptransport.py +0 -0
  43. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/rtcsessiondescription.py +0 -0
  44. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/rtp.py +0 -0
  45. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/sdp.py +0 -0
  46. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/stats.py +0 -0
  47. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/_vendor/aiortc/utils.py +0 -0
  48. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/aes_utils.py +0 -0
  49. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/camera/__init__.py +0 -0
  50. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/camera/constants.py +0 -0
  51. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/camera/controls.py +0 -0
  52. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/camera/go2rtc.py +0 -0
  53. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/camera/lan_control.py +0 -0
  54. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/camera/models.py +0 -0
  55. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/camera/playback.py +0 -0
  56. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/camera/sdes.py +0 -0
  57. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/camera/sdes_open.py +0 -0
  58. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/camera/tutk.py +0 -0
  59. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/camera/webrtc.py +0 -0
  60. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/client.py +0 -0
  61. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/const.py +0 -0
  62. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/credentials.py +0 -0
  63. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/device_client.py +0 -0
  64. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/discover.py +0 -0
  65. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/exceptions.py +0 -0
  66. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/g711.py +0 -0
  67. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/login_const.py +0 -0
  68. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/models/__init__.py +0 -0
  69. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/models/device_client_model.py +0 -0
  70. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/models/device_model.py +0 -0
  71. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/models/discover_model.py +0 -0
  72. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/aidot/py.typed +0 -0
  73. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  74. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
  75. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/python_aidot_cameras.egg-info/top_level.txt +0 -0
  76. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/setup.cfg +0 -0
  77. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/tests/test_aioice_compat.py +0 -0
  78. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/tests/test_alarm_event.py +0 -0
  79. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/tests/test_backoff.py +0 -0
  80. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/tests/test_deferred_hardening.py +0 -0
  81. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/tests/test_device_login_guard.py +0 -0
  82. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/tests/test_device_user_info_cache.py +0 -0
  83. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/tests/test_dtls_not_ready_burst.py +0 -0
  84. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/tests/test_dtls_pinning.py +0 -0
  85. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/tests/test_dtls_skip_signaling_wait.py +0 -0
  86. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/tests/test_egress_guard.py +0 -0
  87. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/tests/test_go2rtc.py +0 -0
  88. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/tests/test_go2rtc_cli.py +0 -0
  89. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/tests/test_highport_nomination.py +0 -0
  90. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/tests/test_ice_config_cache.py +0 -0
  91. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/tests/test_keyframe_prompter.py +0 -0
  92. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/tests/test_lan_control.py +0 -0
  93. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/tests/test_live_stream_param.py +0 -0
  94. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/tests/test_motion_poll.py +0 -0
  95. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/tests/test_narrow_pc_ice.py +0 -0
  96. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/tests/test_no_undefined_names.py +0 -0
  97. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/tests/test_offline_keepalive_pause.py +0 -0
  98. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/tests/test_open_gate_delay.py +0 -0
  99. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/tests/test_persistent_mqtt.py +0 -0
  100. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/tests/test_persistent_mqtt_robustness.py +0 -0
  101. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/tests/test_playback_tls.py +0 -0
  102. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/tests/test_post_merge_hardening.py +0 -0
  103. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/tests/test_retry_policy.py +0 -0
  104. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/tests/test_sdes_adaptive.py +0 -0
  105. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/tests/test_sdes_echo_wait_timeout.py +0 -0
  106. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/tests/test_sdes_fast_liveplay.py +0 -0
  107. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/tests/test_sdes_idle_release.py +0 -0
  108. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/tests/test_sdes_serve_audio.py +0 -0
  109. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/tests/test_sdes_serve_cmd.py +0 -0
  110. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/tests/test_sdes_sprop.py +0 -0
  111. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/tests/test_sdes_talk.py +0 -0
  112. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/tests/test_sdes_watchdog.py +0 -0
  113. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/tests/test_security_hardening.py +0 -0
  114. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/tests/test_serializable_login_info.py +0 -0
  115. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/tests/test_serve_relay.py +0 -0
  116. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/tests/test_session_stats.py +0 -0
  117. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/tests/test_speak.py +0 -0
  118. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/tests/test_stream_cap.py +0 -0
  119. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/tests/test_stream_idle.py +0 -0
  120. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/tests/test_stream_teardown.py +0 -0
  121. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/tests/test_talk.py +0 -0
  122. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/tests/test_terminal_ack.py +0 -0
  123. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/tests/test_token_file_robustness.py +0 -0
  124. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/tests/test_token_refresh.py +0 -0
  125. {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.5}/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.5
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
 
@@ -2169,6 +2177,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2169
2177
  if self._stream_task is not None and not self._stream_task.done():
2170
2178
  return
2171
2179
  self._streaming_active = True
2180
+ self._start_keepalive_renew() # battery: renew keep-alive on this path too
2172
2181
  self._stream_task = asyncio.ensure_future(self._streaming_loop())
2173
2182
 
2174
2183
  async def async_stop_streaming(self) -> None:
@@ -2196,6 +2205,13 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2196
2205
  await task
2197
2206
  except (asyncio.CancelledError, Exception):
2198
2207
  _LOGGER.debug("camera %s: swallowed exception in %s", getattr(self, "device_id", "?"), 'async_stop_streaming', exc_info=True)
2208
+ ka_task, self._keepalive_task = getattr(self, "_keepalive_task", None), None
2209
+ if ka_task is not None and not ka_task.done():
2210
+ ka_task.cancel()
2211
+ try:
2212
+ await ka_task
2213
+ except (asyncio.CancelledError, Exception):
2214
+ _LOGGER.debug("camera %s: swallowed exception in %s", getattr(self, "device_id", "?"), 'async_stop_streaming', exc_info=True)
2199
2215
  # Reap a persistent-MQTT stream drain that no session stopped (e.g. an
2200
2216
  # open cancelled mid-handshake) so its handler is removed from the shared
2201
2217
  # connection and its blocked executor thread is released.
@@ -2307,6 +2323,65 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2307
2323
  except asyncio.CancelledError:
2308
2324
  return
2309
2325
 
2326
+ async def _async_set_keep_alive(self, keep_alive_s: int = _KEEPALIVE_WINDOW_S) -> None:
2327
+ """POST setKeepAliveTime so a battery camera stays awake for keep_alive_s
2328
+ seconds. Best-effort (errors swallowed). Wire format matches the app
2329
+ (n.java: keepAliveTime=25)."""
2330
+ try:
2331
+ import aiohttp as _aiohttp
2332
+ async with _aiohttp.ClientSession() as _s:
2333
+ async with _s.post(
2334
+ f"{self._aidot_v32_base}/devices/{self.device_id}/setKeepAliveTime",
2335
+ json={"keepAliveTime": keep_alive_s},
2336
+ headers=self._aidot_headers(),
2337
+ timeout=_aiohttp.ClientTimeout(total=8),
2338
+ ) as _r:
2339
+ _LOGGER.debug(
2340
+ "setKeepAliveTime HTTP %d for %s", _r.status, self.device_id
2341
+ )
2342
+ except Exception as _exc:
2343
+ _LOGGER.debug(
2344
+ "setKeepAliveTime HTTP failed for %s: %s", self.device_id, _exc
2345
+ )
2346
+
2347
+ def _start_keepalive_renew(self) -> None:
2348
+ """Start the battery keep-alive renew loop - battery-only and
2349
+ single-instance: cancel any prior loop first so a re-view within the
2350
+ renew sleep window can't orphan one (an orphan keeps POSTing
2351
+ setKeepAliveTime until _streaming_active next clears). No-op for mains
2352
+ cameras, which never sleep."""
2353
+ if not self.is_battery_camera:
2354
+ return
2355
+ prev = getattr(self, "_keepalive_task", None)
2356
+ if prev is not None and not prev.done():
2357
+ prev.cancel()
2358
+ self._keepalive_task = asyncio.ensure_future(self._keepalive_renew_loop())
2359
+
2360
+ def _cancel_keepalive_renew(self) -> None:
2361
+ """Cancel the battery keep-alive renew loop if running (fire-and-forget)."""
2362
+ task, self._keepalive_task = getattr(self, "_keepalive_task", None), None
2363
+ if task is not None and not task.done():
2364
+ task.cancel()
2365
+
2366
+ async def _keepalive_renew_loop(self) -> None:
2367
+ """Renew setKeepAliveTime while a BATTERY camera is streaming (app parity).
2368
+
2369
+ The camera's own low-power timer returns it to sleep ~25 s after the last
2370
+ keep-alive even mid-view, so a one-shot keep-alive at open lets the stream
2371
+ drop partway through. The official app renews it throughout a live view;
2372
+ we renew every _KEEPALIVE_RENEW_S seconds (inside the 25 s window, so no
2373
+ sleep gap). Mains cameras never sleep, so this is battery-only."""
2374
+ if not self.is_battery_camera:
2375
+ return
2376
+ try:
2377
+ while self._streaming_active:
2378
+ await asyncio.sleep(_KEEPALIVE_RENEW_S)
2379
+ if not self._streaming_active:
2380
+ break
2381
+ await self._async_set_keep_alive()
2382
+ except asyncio.CancelledError:
2383
+ pass
2384
+
2310
2385
  async def start_keepalive(
2311
2386
  self,
2312
2387
  rtsp_push_url: Optional[str] = None,
@@ -2381,6 +2456,10 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2381
2456
  self._keepalive_rtsp_url = rtsp_push_url
2382
2457
  self._go2rtc_url = go2rtc_url
2383
2458
  self._streaming_active = True
2459
+ # App parity: keep a battery camera awake for the whole view (mains cams
2460
+ # never sleep, so this is a no-op there). Single-instance; cancelled in
2461
+ # stop / idle-release.
2462
+ self._start_keepalive_renew()
2384
2463
  if self.is_sdes_camera:
2385
2464
  self._stream_task = asyncio.ensure_future(self._sdes_keepalive_loop())
2386
2465
  elif rtsp_push_url and rtsp_push_url.startswith("http"):
@@ -2703,6 +2782,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2703
2782
  except Exception:
2704
2783
  _LOGGER.debug("camera %s: swallowed exception in %s", getattr(self, "device_id", "?"), '_sdes_keepalive_loop', exc_info=True)
2705
2784
  self._streaming_active = False
2785
+ self._cancel_keepalive_renew()
2706
2786
  self._keepalive_rtsp_url = None
2707
2787
  self._serve_ready.clear()
2708
2788
  _LOGGER.debug(
@@ -3490,6 +3570,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3490
3570
  # No viewer - go dormant; camera.stream_source() restarts us when
3491
3571
  # someone opens the live view again.
3492
3572
  self._streaming_active = False
3573
+ self._cancel_keepalive_renew()
3493
3574
  self._keepalive_rtsp_url = None
3494
3575
  self._serve_ready.clear()
3495
3576
  _LOGGER.debug(
@@ -1219,7 +1219,12 @@ def _mqtt_session_sync(
1219
1219
  else str(msg.payload))
1220
1220
  msg_q.put((msg.topic, payload))
1221
1221
 
1222
- def _on_disconnect(c, ud, disconnect_flags=None, reason_code=None, props=None):
1222
+ def _on_disconnect(c, ud, *args):
1223
+ # paho >=2 calls (disconnect_flags, reason_code, properties); paho <2
1224
+ # calls (rc,). Pull the reason code from whichever slot it lands in so
1225
+ # the log is truthful on both (a fixed 5-arg signature put paho 1.x's rc
1226
+ # in disconnect_flags and logged reason_code as None).
1227
+ reason_code = args[1] if len(args) >= 2 else (args[0] if args else None)
1223
1228
  # If _on_connect was never fired (WebSocket upgrade failed, auth refused
1224
1229
  # at TCP level, etc.) signal conn_ev now so the caller doesn't time out.
1225
1230
  if not conn_ev.is_set():
@@ -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)
@@ -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.5"
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.5
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,6 +85,7 @@ 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
@@ -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
@@ -0,0 +1,119 @@
1
+ """Battery-camera setKeepAliveTime renewal during streaming (app parity).
2
+
3
+ The official app re-issues setKeepAliveTime throughout a live view so a battery
4
+ camera's low-power timer doesn't return it to sleep mid-stream. We renew inside
5
+ the 25 s window; mains cameras never sleep and are skipped.
6
+ """
7
+ import asyncio
8
+ from unittest.mock import AsyncMock, MagicMock, patch
9
+
10
+ from aidot.camera.client import CameraMixin
11
+
12
+
13
+ def _obj(battery=True, streaming=True):
14
+ o = MagicMock()
15
+ o.is_battery_camera = battery
16
+ o._streaming_active = streaming
17
+ o.device_id = "cam1"
18
+ o._async_set_keep_alive = AsyncMock()
19
+ return o
20
+
21
+
22
+ def test_renew_loop_skips_mains_cameras():
23
+ o = _obj(battery=False)
24
+ asyncio.run(CameraMixin._keepalive_renew_loop(o))
25
+ o._async_set_keep_alive.assert_not_awaited()
26
+
27
+
28
+ def test_renew_loop_renews_battery_until_stream_stops():
29
+ o = _obj(battery=True)
30
+ n = {"i": 0}
31
+
32
+ async def fake_sleep(_s):
33
+ n["i"] += 1
34
+ if n["i"] >= 3:
35
+ o._streaming_active = False # stop after the 3rd sleep
36
+
37
+ with patch("aidot.camera.client.asyncio.sleep", fake_sleep):
38
+ asyncio.run(CameraMixin._keepalive_renew_loop(o))
39
+ # sleep(renew), sleep(renew), sleep(stop -> break): 2 renewals
40
+ assert o._async_set_keep_alive.await_count == 2
41
+
42
+
43
+ def test_renew_loop_swallows_cancel():
44
+ o = _obj(battery=True)
45
+
46
+ async def _run():
47
+ t = asyncio.ensure_future(CameraMixin._keepalive_renew_loop(o))
48
+ await asyncio.sleep(0) # let it reach the first (real) sleep
49
+ t.cancel() # cancel interrupts the sleep immediately
50
+ await t # CancelledError is swallowed inside the loop
51
+
52
+ asyncio.run(_run())
53
+ o._async_set_keep_alive.assert_not_awaited()
54
+
55
+
56
+ def test_start_renew_is_noop_for_mains_cameras():
57
+ # Mains cameras never sleep - no throwaway task should be scheduled.
58
+ o = _obj(battery=False)
59
+ with patch("aidot.camera.client.asyncio.ensure_future") as ef:
60
+ CameraMixin._start_keepalive_renew(o)
61
+ ef.assert_not_called()
62
+
63
+
64
+ def test_start_renew_cancels_a_still_running_prior_loop():
65
+ # Re-view within the renew sleep window must not orphan the prior loop.
66
+ o = _obj(battery=True)
67
+ prior = MagicMock()
68
+ prior.done.return_value = False
69
+ o._keepalive_task = prior
70
+ with patch("aidot.camera.client.asyncio.ensure_future", return_value="new") as ef:
71
+ CameraMixin._start_keepalive_renew(o)
72
+ prior.cancel.assert_called_once() # old loop stopped
73
+ ef.assert_called_once() # exactly one new loop
74
+ assert o._keepalive_task == "new"
75
+
76
+
77
+ def test_cancel_renew_stops_the_loop_and_clears_the_ref():
78
+ o = _obj(battery=True)
79
+ task = MagicMock()
80
+ task.done.return_value = False
81
+ o._keepalive_task = task
82
+ CameraMixin._cancel_keepalive_renew(o)
83
+ task.cancel.assert_called_once()
84
+ assert o._keepalive_task is None
85
+
86
+
87
+ def test_set_keep_alive_posts_expected_request():
88
+ o = MagicMock()
89
+ o.device_id = "cam1"
90
+ o._aidot_v32_base = "https://api.example/v3.2"
91
+ o._aidot_headers = MagicMock(return_value={"Authorization": "x"})
92
+ cap = {}
93
+
94
+ class _Resp:
95
+ status = 200
96
+
97
+ async def __aenter__(self):
98
+ return self
99
+
100
+ async def __aexit__(self, *a):
101
+ return False
102
+
103
+ class _Sess:
104
+ async def __aenter__(self):
105
+ return self
106
+
107
+ async def __aexit__(self, *a):
108
+ return False
109
+
110
+ def post(self, url, json=None, headers=None, timeout=None):
111
+ cap.update(url=url, json=json, headers=headers)
112
+ return _Resp()
113
+
114
+ with patch("aiohttp.ClientSession", lambda: _Sess()):
115
+ asyncio.run(CameraMixin._async_set_keep_alive(o))
116
+
117
+ assert cap["url"] == "https://api.example/v3.2/devices/cam1/setKeepAliveTime"
118
+ assert cap["json"] == {"keepAliveTime": 25}
119
+ assert cap["headers"] == {"Authorization": "x"}