python-aidot-cameras 0.12.9__tar.gz → 0.12.10__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 (142) hide show
  1. {python_aidot_cameras-0.12.9/python_aidot_cameras.egg-info → python_aidot_cameras-0.12.10}/PKG-INFO +1 -1
  2. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/aidot_cameras/camera/client.py +50 -7
  3. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/aidot_cameras/camera/sdes_open.py +36 -5
  4. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/aidot_cameras/camera/webrtc_open.py +22 -0
  5. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/pyproject.toml +1 -1
  6. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  7. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/python_aidot_cameras.egg-info/SOURCES.txt +2 -0
  8. python_aidot_cameras-0.12.10/tests/test_idle_release_both_loops.py +48 -0
  9. python_aidot_cameras-0.12.10/tests/test_key_restart_sdp.py +61 -0
  10. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/LICENSE +0 -0
  11. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/MANIFEST.in +0 -0
  12. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/README.md +0 -0
  13. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/aidot_cameras/__init__.py +0 -0
  14. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/aidot_cameras/__main__.py +0 -0
  15. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/__init__.py +0 -0
  16. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/__init__.py +0 -0
  17. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/clock.py +0 -0
  18. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/codecs/__init__.py +0 -0
  19. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/codecs/base.py +0 -0
  20. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/codecs/g711.py +0 -0
  21. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/codecs/g722.py +0 -0
  22. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/codecs/h264.py +0 -0
  23. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/codecs/opus.py +0 -0
  24. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/codecs/vpx.py +0 -0
  25. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/contrib/__init__.py +0 -0
  26. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/contrib/media.py +0 -0
  27. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/contrib/signaling.py +0 -0
  28. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/events.py +0 -0
  29. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/exceptions.py +0 -0
  30. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/jitterbuffer.py +0 -0
  31. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/mediastreams.py +0 -0
  32. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/rate.py +0 -0
  33. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/rtcconfiguration.py +0 -0
  34. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/rtcdatachannel.py +0 -0
  35. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/rtcdtlstransport.py +0 -0
  36. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/rtcicetransport.py +0 -0
  37. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/rtcpeerconnection.py +0 -0
  38. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/rtcrtpparameters.py +0 -0
  39. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/rtcrtpreceiver.py +0 -0
  40. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/rtcrtpsender.py +0 -0
  41. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/rtcrtptransceiver.py +0 -0
  42. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/rtcsctptransport.py +0 -0
  43. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/rtcsessiondescription.py +0 -0
  44. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/rtp.py +0 -0
  45. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/sdp.py +0 -0
  46. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/stats.py +0 -0
  47. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/utils.py +0 -0
  48. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/aidot_cameras/camera/__init__.py +0 -0
  49. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/aidot_cameras/camera/constants.py +0 -0
  50. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/aidot_cameras/camera/controls.py +0 -0
  51. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/aidot_cameras/camera/go2rtc.py +0 -0
  52. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/aidot_cameras/camera/lan_control.py +0 -0
  53. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/aidot_cameras/camera/models.py +0 -0
  54. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/aidot_cameras/camera/playback.py +0 -0
  55. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/aidot_cameras/camera/protocol.py +0 -0
  56. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/aidot_cameras/camera/sdes.py +0 -0
  57. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/aidot_cameras/camera/tutk.py +0 -0
  58. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/aidot_cameras/camera/webrtc.py +0 -0
  59. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/aidot_cameras/client.py +0 -0
  60. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/aidot_cameras/const.py +0 -0
  61. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/aidot_cameras/credentials.py +0 -0
  62. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/aidot_cameras/crypto.py +0 -0
  63. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/aidot_cameras/device_client.py +0 -0
  64. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/aidot_cameras/discover.py +0 -0
  65. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/aidot_cameras/exceptions.py +0 -0
  66. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/aidot_cameras/g711.py +0 -0
  67. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/aidot_cameras/py.typed +0 -0
  68. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  69. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
  70. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/python_aidot_cameras.egg-info/requires.txt +0 -0
  71. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/python_aidot_cameras.egg-info/top_level.txt +0 -0
  72. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/setup.cfg +0 -0
  73. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_aioice_compat.py +0 -0
  74. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_alarm_event.py +0 -0
  75. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_backoff.py +0 -0
  76. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_deferred_hardening.py +0 -0
  77. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_device_login_guard.py +0 -0
  78. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_device_user_info_cache.py +0 -0
  79. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_dtls_not_ready_burst.py +0 -0
  80. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_dtls_pinning.py +0 -0
  81. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_dtls_serve_decode_skip.py +0 -0
  82. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_dtls_serve_open_timeout.py +0 -0
  83. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_dtls_skip_signaling_wait.py +0 -0
  84. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_dtls_slow_probe.py +0 -0
  85. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_egress_guard.py +0 -0
  86. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_go2rtc.py +0 -0
  87. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_go2rtc_cli.py +0 -0
  88. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_h264_decode_rate_limit.py +0 -0
  89. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_highport_nomination.py +0 -0
  90. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_ice_config_cache.py +0 -0
  91. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_keepalive_renew.py +0 -0
  92. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_keyframe_prompter.py +0 -0
  93. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_lan_control.py +0 -0
  94. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_light_active_color_mode.py +0 -0
  95. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_live_stream_param.py +0 -0
  96. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_logging_caps.py +0 -0
  97. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_motion_poll.py +0 -0
  98. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_mqtt_credential_refetch.py +0 -0
  99. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_mqtt_credential_self_heal.py +0 -0
  100. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_mqtt_password_not_persisted.py +0 -0
  101. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_mqtt_session_reconnect.py +0 -0
  102. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_narrow_pc_ice.py +0 -0
  103. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_no_undefined_names.py +0 -0
  104. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_offline_keepalive_pause.py +0 -0
  105. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_open_fail_logger.py +0 -0
  106. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_open_gate_delay.py +0 -0
  107. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_persistent_mqtt.py +0 -0
  108. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_persistent_mqtt_robustness.py +0 -0
  109. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_playback_tls.py +0 -0
  110. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_post_merge_hardening.py +0 -0
  111. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_retry_policy.py +0 -0
  112. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_sdes_adaptive.py +0 -0
  113. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_sdes_bridge_break_gate.py +0 -0
  114. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_sdes_echo_wait_timeout.py +0 -0
  115. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_sdes_fast_liveplay.py +0 -0
  116. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_sdes_ffmpeg_stderr_log.py +0 -0
  117. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_sdes_idle_release.py +0 -0
  118. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_sdes_open_cleanup.py +0 -0
  119. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_sdes_serve_audio.py +0 -0
  120. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_sdes_serve_cmd.py +0 -0
  121. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_sdes_sprop.py +0 -0
  122. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_sdes_talk.py +0 -0
  123. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_sdes_teardown_exit_log.py +0 -0
  124. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_sdes_watchdog.py +0 -0
  125. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_sdp_payload_type_narrowing.py +0 -0
  126. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_security_hardening.py +0 -0
  127. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_serializable_login_info.py +0 -0
  128. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_serve_relay.py +0 -0
  129. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_session_stats.py +0 -0
  130. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_speak.py +0 -0
  131. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_stream_cap.py +0 -0
  132. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_stream_drain_ownership.py +0 -0
  133. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_stream_idle.py +0 -0
  134. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_stream_slot_cap.py +0 -0
  135. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_stream_teardown.py +0 -0
  136. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_talk.py +0 -0
  137. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_terminal_ack.py +0 -0
  138. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_token_file_robustness.py +0 -0
  139. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_token_refresh.py +0 -0
  140. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_upstream_compat.py +0 -0
  141. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/tests/test_viewer_presence.py +0 -0
  142. {python_aidot_cameras-0.12.9 → python_aidot_cameras-0.12.10}/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.12.9
3
+ Version: 0.12.10
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
@@ -2925,6 +2925,21 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2925
2925
  """
2926
2926
  name = self._go2rtc_stream_name()
2927
2927
  base = self._go2rtc_url
2928
+ if not base:
2929
+ # No go2rtc to ask. In PUSH mode the fallback below is not merely
2930
+ # unavailable, it is WRONG: the "serve port" is go2rtc's shared RTSP
2931
+ # port (8554), where every camera's own publisher is connected, so the
2932
+ # check reports a viewer for every camera forever and nothing ever
2933
+ # releases. Answer "unknown" instead of a confident lie - the caller
2934
+ # then holds the stream rather than tearing down a live view, and the
2935
+ # honest way out is for the consumer to pass go2rtc_url.
2936
+ if self._keepalive_rtsp_url and not str(
2937
+ self._keepalive_rtsp_url).startswith("http"):
2938
+ _LOGGER.debug(
2939
+ "no go2rtc url and the serve is an RTSP push to a shared "
2940
+ "port; cannot tell whether anyone is watching",
2941
+ )
2942
+ return None
2928
2943
  if base and name:
2929
2944
  try:
2930
2945
  import aiohttp
@@ -3692,8 +3707,14 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3692
3707
  self.device_id,
3693
3708
  )
3694
3709
  await slots.acquire()
3695
- self._serve_relay = self._maybe_start_serve_relay(self._keepalive_rtsp_url)
3696
3710
  try:
3711
+ # Inside the try on purpose: _maybe_start_serve_relay catches OSError
3712
+ # but Thread.start() raises RuntimeError when the host is out of
3713
+ # threads, and escaping here would skip slots.release() and burn a
3714
+ # permit for the life of the process - the cap would silently shrink
3715
+ # until nothing could stream at all.
3716
+ self._serve_relay = self._maybe_start_serve_relay(
3717
+ self._keepalive_rtsp_url)
3697
3718
  await self._dtls_serve_loop_inner()
3698
3719
  finally:
3699
3720
  _relay = self._serve_relay
@@ -3951,6 +3972,8 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3951
3972
  _stall_pli_s = float(os.environ.get("AIDOT_STALL_PLI_S", "1.0"))
3952
3973
  _last_gop_pli = loop.time()
3953
3974
  _stall_pli_armed = True
3975
+ _last_viewer_dtls = loop.time()
3976
+ _serve_port_dtls = _sdes_serve_port(serve_url) or 0
3954
3977
  # Wait for ffmpeg to exit (go2rtc disconnect) or idle release.
3955
3978
  while self._streaming_active and proc.returncode is None:
3956
3979
  await asyncio.sleep(0.5)
@@ -3968,11 +3991,26 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3968
3991
  await self._send_video_pli(pc)
3969
3992
  _last_gop_pli = _now # also satisfies cadence
3970
3993
  _stall_pli_armed = False # one shot per stall
3971
- # No consumer -> the pipe fills, the mux blocks, progress
3972
- # goes stale. A real viewer keeps it fresh. idle_secs<=0
3973
- # disables release entirely (keep warm for instant views).
3974
- if idle_secs > 0 and _now - progress[0] > idle_secs:
3975
- idle_release = True
3994
+ # Whether anyone is WATCHING. Pipe-progress staleness
3995
+ # cannot answer that: the pipe only backs up when nothing
3996
+ # drains the serve socket, and go2rtc drains it forever as
3997
+ # the stream's producer, viewer or not. So progress never
3998
+ # goes stale, the release never fires, and every DTLS
3999
+ # camera streams for the life of the process - the same
4000
+ # defect fixed for the SDES loop, in the other loop.
4001
+ # Ask go2rtc, and only fall back to staleness when it
4002
+ # cannot answer.
4003
+ if idle_secs > 0:
4004
+ _watching = await self._viewer_present(_serve_port_dtls)
4005
+ if _watching:
4006
+ _last_viewer_dtls = _now
4007
+ elif _watching is False:
4008
+ if _now - _last_viewer_dtls > idle_secs:
4009
+ idle_release = True
4010
+ elif _now - progress[0] > idle_secs:
4011
+ # Unknown: keep the old staleness heuristic rather
4012
+ # than holding the stream open forever.
4013
+ idle_release = True
3976
4014
  break
3977
4015
  # Tear down this ffmpeg+mux cycle before the next.
3978
4016
  stop_flag.set()
@@ -3999,7 +4037,12 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3999
4037
  wfile.close()
4000
4038
  except Exception:
4001
4039
  _LOGGER.debug("camera %s: swallowed exception in %s", getattr(self, "device_id", "?"), '_pc_dead', exc_info=True)
4002
- if mux_thread is not None:
4040
+ if mux_thread is not None and mux_thread.is_alive():
4041
+ # is_alive() guards the never-started case: if Thread.start()
4042
+ # itself raised, join() raises "cannot join thread before it
4043
+ # is started", which would replace the real exception AND skip
4044
+ # the ffmpeg terminate plus session stop below - orphaning the
4045
+ # process on its serve port and leaking the camera's session.
4003
4046
  mux_thread.join(timeout=2.0)
4004
4047
  _terminate_proc(proc) # never orphan ffmpeg on teardown
4005
4048
  self._stream_session = None
@@ -45,7 +45,7 @@ _SDP_ALTERNATE_PT = {96: 97, 97: 96, 0: 8, 8: 0}
45
45
  # session started media at ~21s, which the previous 15s deadline missed entirely -
46
46
  # it launched blind with BOTH payload types still unknown, which is what left the
47
47
  # audio line advertising PCMU on a PCMA camera.
48
- _FIRST_MEDIA_WAIT_S = 45.0
48
+ _FIRST_MEDIA_WAIT_S = 75.0
49
49
 
50
50
  # Extra time for the audio payload type once video is known. Measured on an
51
51
  # A001513: audio follows video by 40-70ms, because the camera answers BUNDLE and
@@ -3843,20 +3843,44 @@ class _SdesOpenMixin:
3843
3843
  except Exception:
3844
3844
  proc.kill()
3845
3845
  _ts2 = int(time.time())
3846
+ # Match the PRIMARY SDP on both counts, or this restart
3847
+ # undoes two fixes at once.
3848
+ #
3849
+ # Transport: for _use_plain_rtp models - which is every
3850
+ # SDES camera this library supports - the bridge decrypts
3851
+ # and forwards PLAIN RTP, so the primary SDP is RTP/AVP
3852
+ # with no a=crypto. Writing RTP/SAVP here makes ffmpeg
3853
+ # try to authenticate already-decrypted packets: every one
3854
+ # fails its HMAC check and a working stream drops to zero
3855
+ # bytes mid-session.
3856
+ #
3857
+ # Payload types: hard-coding "0 8" and "96 97" throws away
3858
+ # the narrowing, and ffmpeg binds the FIRST type on each
3859
+ # line - so an H.265 camera silently loses all video (and
3860
+ # with it the PAT/PMT, hence the whole output), and a PCMA
3861
+ # camera loses its audio. Both types are known by now.
3862
+ _proto = "RTP/AVP" if _use_plain_rtp else "RTP/SAVP"
3863
+
3864
+ def _crypto(key: str) -> str:
3865
+ if _use_plain_rtp:
3866
+ return ""
3867
+ return (f"a=crypto:1 AES_CM_128_HMAC_SHA1_80 "
3868
+ f"inline:{key}\r\n")
3869
+
3846
3870
  _new_sdp = (
3847
3871
  "v=0\r\n"
3848
3872
  f"o=- {_ts2} {_ts2} IN IP4 0.0.0.0\r\n"
3849
3873
  "s=aidot-sdes-rx\r\n"
3850
3874
  "t=0 0\r\n"
3851
- f"m=audio {_lo_audio_port} RTP/SAVP 0 8\r\n"
3875
+ f"m=audio {_lo_audio_port} {_proto} 0 8\r\n"
3852
3876
  "c=IN IP4 127.0.0.1\r\n"
3853
- f"a=crypto:1 AES_CM_128_HMAC_SHA1_80 inline:{srtp_key_audio}\r\n"
3877
+ f"{_crypto(srtp_key_audio)}"
3854
3878
  "a=rtpmap:0 PCMU/8000\r\n"
3855
3879
  "a=rtpmap:8 PCMA/8000\r\n"
3856
3880
  "a=rtcp-mux\r\n"
3857
- f"m=video {_lo_video_port} RTP/SAVP 96 97\r\n"
3881
+ f"m=video {_lo_video_port} {_proto} 96 97\r\n"
3858
3882
  "c=IN IP4 127.0.0.1\r\n"
3859
- f"a=crypto:1 AES_CM_128_HMAC_SHA1_80 inline:{srtp_key_video}\r\n"
3883
+ f"{_crypto(srtp_key_video)}"
3860
3884
  "a=rtpmap:96 H264/90000\r\n"
3861
3885
  "a=fmtp:96 level-asymmetry-allowed=1;packetization-mode=1;"
3862
3886
  "profile-level-id=42e01f\r\n"
@@ -3864,6 +3888,13 @@ class _SdesOpenMixin:
3864
3888
  "a=fmtp:97 level-id=93\r\n"
3865
3889
  "a=rtcp-mux\r\n"
3866
3890
  )
3891
+ _new_sdp = narrow_sdp_payload_types(
3892
+ _new_sdp,
3893
+ keep_video=(_first_video_pt[0]
3894
+ if _first_video_pt[0] in (96, 97) else None),
3895
+ keep_audio=(_first_audio_pt[0]
3896
+ if _first_audio_pt[0] in (0, 8) else None),
3897
+ )
3867
3898
  try:
3868
3899
  # Re-apply the cached sprop-parameter-sets here too:
3869
3900
  # this serve-restart rewrite is the SDP the keepalive
@@ -1660,6 +1660,22 @@ class _WebRTCOpenMixin:
1660
1660
  _LOGGER.debug("camera %s: swallowed exception in %s", getattr(self, "device_id", "?"), '_on_remote_dc_message', exc_info=True)
1661
1661
 
1662
1662
  track_tasks: list = []
1663
+
1664
+ def _cancel_track_tasks() -> None:
1665
+ """Cancel the per-session background tasks.
1666
+
1667
+ Only ``WebRTCSession.stop()`` cancels these, and it is reached only
1668
+ when a session is actually returned. Every failure exit below closes
1669
+ the peer connection and re-raises, which leaves them running - and the
1670
+ AVIO heartbeat loop has no terminal condition (its senders swallow
1671
+ every error), so a camera that fails to open repeatedly accumulates
1672
+ one immortal 10s-tick task per attempt, each pinning the closed
1673
+ RTCPeerConnection graph in memory.
1674
+ """
1675
+ for _t in track_tasks:
1676
+ if not _t.done():
1677
+ _t.cancel()
1678
+ track_tasks.clear()
1663
1679
  _kvs_dc = None
1664
1680
  if self._offer_should_include_datachannel:
1665
1681
  # Match the official client's label exactly: f0.java:2923 uses
@@ -2346,10 +2362,12 @@ class _WebRTCOpenMixin:
2346
2362
  _code, _desc = terminal_error_fut.result()
2347
2363
  _status(f"camera refused: ack {_code} {_desc} - terminal, not retrying")
2348
2364
  outgoing_q.put_nowait(None)
2365
+ _cancel_track_tasks()
2349
2366
  await pc.close()
2350
2367
  raise AidotCameraBusy(_code, _desc)
2351
2368
  _status(f"no webrtcResp in {timeout}s")
2352
2369
  outgoing_q.put_nowait(None)
2370
+ _cancel_track_tasks()
2353
2371
  await pc.close()
2354
2372
  raise RuntimeError(
2355
2373
  f"async_open_webrtc_stream: no webrtcResp received within {timeout}s"
@@ -2556,6 +2574,7 @@ class _WebRTCOpenMixin:
2556
2574
  f" {_rr_srd_exc}"
2557
2575
  )
2558
2576
  outgoing_q.put_nowait(None)
2577
+ _cancel_track_tasks()
2559
2578
  await pc.close()
2560
2579
  raise RuntimeError(
2561
2580
  f"async_open_webrtc_stream: setRemoteDescription failed:"
@@ -3205,6 +3224,7 @@ class _WebRTCOpenMixin:
3205
3224
  # co-fail into the full 15s-gated generic retry.
3206
3225
  outgoing_q.put_nowait(None) # stop MQTT thread (avoid orphan)
3207
3226
  try:
3227
+ _cancel_track_tasks()
3208
3228
  await pc.close()
3209
3229
  except Exception:
3210
3230
  pass
@@ -3347,6 +3367,7 @@ class _WebRTCOpenMixin:
3347
3367
  )
3348
3368
  _status(f"setRemoteDescription failed: {exc}")
3349
3369
  outgoing_q.put_nowait(None)
3370
+ _cancel_track_tasks()
3350
3371
  await pc.close()
3351
3372
  raise RuntimeError(
3352
3373
  f"async_open_webrtc_stream: setRemoteDescription failed: {exc}"
@@ -3569,6 +3590,7 @@ class _WebRTCOpenMixin:
3569
3590
 
3570
3591
  if pc.connectionState not in ("connected", "completed"):
3571
3592
  outgoing_q.put_nowait(None)
3593
+ _cancel_track_tasks()
3572
3594
  await pc.close()
3573
3595
  raise RuntimeError(
3574
3596
  f"async_open_webrtc_stream: ICE connection not established "
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-aidot-cameras"
7
- version = "0.12.9"
7
+ version = "0.12.10"
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"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-aidot-cameras
3
- Version: 0.12.9
3
+ Version: 0.12.10
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
@@ -84,7 +84,9 @@ tests/test_go2rtc_cli.py
84
84
  tests/test_h264_decode_rate_limit.py
85
85
  tests/test_highport_nomination.py
86
86
  tests/test_ice_config_cache.py
87
+ tests/test_idle_release_both_loops.py
87
88
  tests/test_keepalive_renew.py
89
+ tests/test_key_restart_sdp.py
88
90
  tests/test_keyframe_prompter.py
89
91
  tests/test_lan_control.py
90
92
  tests/test_light_active_color_mode.py
@@ -0,0 +1,48 @@
1
+ """Both serve loops must decide idleness by asking who is WATCHING.
2
+
3
+ 0.12.9 fixed this for the SDES loop and left the DTLS loop on its old signal -
4
+ pipe-progress staleness - which is the same unanswerable question in disguise: the
5
+ pipe only backs up when nothing drains the serve socket, and go2rtc drains it
6
+ forever as the stream's producer. On a fleet that is mostly DTLS cameras (this one
7
+ is 4 of 5) the fix therefore did nothing.
8
+ """
9
+ import inspect
10
+
11
+ import aidot_cameras.camera.client as cc
12
+
13
+
14
+ def _src(name):
15
+ return inspect.getsource(getattr(cc.CameraMixin, name))
16
+
17
+
18
+ def test_sdes_loop_asks_who_is_watching():
19
+ assert "_viewer_present" in _src("_sdes_keepalive_loop_inner")
20
+
21
+
22
+ def test_dtls_loop_asks_who_is_watching():
23
+ # The regression: this loop used only `_now - progress[0] > idle_secs`.
24
+ assert "_viewer_present" in _src("_dtls_serve_loop_inner")
25
+
26
+
27
+ def test_dtls_loop_still_has_a_fallback_when_nobody_can_answer():
28
+ # If go2rtc cannot be reached the old staleness heuristic must remain, so an
29
+ # unreachable go2rtc does not mean "hold every stream open forever".
30
+ src = _src("_dtls_serve_loop_inner")
31
+ assert "progress[0] > idle_secs" in src
32
+
33
+
34
+ def test_the_stream_slot_is_released_even_if_the_relay_fails_to_start():
35
+ # _maybe_start_serve_relay catches OSError, but Thread.start() raises
36
+ # RuntimeError under thread exhaustion. Starting it outside the try meant the
37
+ # permit was lost for the life of the process and the cap silently shrank.
38
+ src = _src("_dtls_serve_loop")
39
+ acquire = src.index("slots.acquire()")
40
+ try_at = src.index("try:", acquire)
41
+ relay_at = src.index("_maybe_start_serve_relay", acquire)
42
+ assert try_at < relay_at, "relay start must be inside the try that releases"
43
+
44
+
45
+ def test_teardown_does_not_join_a_thread_that_never_started():
46
+ # join() on an unstarted thread raises and would skip the ffmpeg terminate
47
+ # and the session stop that follow it.
48
+ assert "mux_thread.is_alive()" in _src("_dtls_serve_loop_inner")
@@ -0,0 +1,61 @@
1
+ """The SRTP key-restart must rewrite the SDP the same way the primary path does.
2
+
3
+ It rebuilds the ffmpeg SDP from scratch and relaunches. Two things it got wrong,
4
+ each undoing a shipped fix:
5
+
6
+ - Transport. Every SDES model this library supports is in `_use_plain_rtp`: the
7
+ bridge decrypts and forwards PLAIN RTP, so the primary SDP is RTP/AVP with no
8
+ a=crypto. Writing RTP/SAVP makes ffmpeg authenticate already-decrypted packets,
9
+ every HMAC check fails, and a working stream drops to zero bytes mid-session.
10
+ - Payload types. Hard-coding "0 8" and "96 97" discards the narrowing, and ffmpeg
11
+ binds the FIRST type per line - so an H.265 camera loses all video, and with it
12
+ the PAT/PMT and therefore the entire output.
13
+ """
14
+ import re
15
+
16
+ import aidot_cameras.camera.sdes_open as so
17
+
18
+
19
+ def _restart_block() -> str:
20
+ src = so.__loader__.get_source("aidot_cameras.camera.sdes_open")
21
+ i = src.index("_new_sdp = (")
22
+ return src[i:i + 3000]
23
+
24
+
25
+ def test_restart_respects_plain_rtp_transport():
26
+ block = _restart_block()
27
+ assert "_proto" in block, "transport must follow _use_plain_rtp, not be fixed"
28
+ assert 'f"m=audio {_lo_audio_port} {_proto}' in block
29
+ assert 'f"m=video {_lo_video_port} {_proto}' in block
30
+
31
+
32
+ def test_restart_omits_crypto_for_plain_rtp():
33
+ block = _restart_block()
34
+ assert "_crypto(" in block, "a=crypto must be conditional on the transport"
35
+ assert "a=crypto:1 AES_CM_128_HMAC_SHA1_80 inline:{srtp_key_audio}" not in block
36
+
37
+
38
+ def test_restart_narrows_the_payload_types():
39
+ block = _restart_block()
40
+ assert "narrow_sdp_payload_types(" in block
41
+ assert "keep_video=" in block and "keep_audio=" in block
42
+
43
+
44
+ def test_first_media_wait_covers_the_documented_cold_start():
45
+ # The repo documents a 25-70s cold start for battery cameras in several
46
+ # places. A 45s deadline sat inside that range, so a camera at the slow end
47
+ # launched with no payload types known - the exact failure the wait exists to
48
+ # prevent.
49
+ assert so._FIRST_MEDIA_WAIT_S >= 70.0
50
+
51
+
52
+ def test_narrowing_still_produces_a_single_payload_type_per_line():
53
+ sdp = (
54
+ "m=audio 1000 RTP/AVP 0 8\r\n"
55
+ "a=rtpmap:0 PCMU/8000\r\na=rtpmap:8 PCMA/8000\r\n"
56
+ "m=video 1002 RTP/AVP 96 97\r\n"
57
+ "a=rtpmap:96 H264/90000\r\na=rtpmap:97 H265/90000\r\n"
58
+ )
59
+ out = so.narrow_sdp_payload_types(sdp, keep_video=97, keep_audio=8)
60
+ assert re.search(r"m=audio 1000 RTP/AVP 8\r\n", out)
61
+ assert re.search(r"m=video 1002 RTP/AVP 97\r\n", out)