python-aidot-cameras 0.12.10__tar.gz → 0.12.12__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. {python_aidot_cameras-0.12.10/python_aidot_cameras.egg-info → python_aidot_cameras-0.12.12}/PKG-INFO +1 -1
  2. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/aidot_cameras/camera/client.py +58 -2
  3. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/pyproject.toml +1 -1
  4. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  5. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/python_aidot_cameras.egg-info/SOURCES.txt +2 -0
  6. python_aidot_cameras-0.12.12/tests/test_no_self_referential_go2rtc.py +47 -0
  7. python_aidot_cameras-0.12.12/tests/test_viewer_check_throttle.py +71 -0
  8. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/LICENSE +0 -0
  9. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/MANIFEST.in +0 -0
  10. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/README.md +0 -0
  11. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/aidot_cameras/__init__.py +0 -0
  12. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/aidot_cameras/__main__.py +0 -0
  13. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/__init__.py +0 -0
  14. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/__init__.py +0 -0
  15. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/clock.py +0 -0
  16. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/codecs/__init__.py +0 -0
  17. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/codecs/base.py +0 -0
  18. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/codecs/g711.py +0 -0
  19. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/codecs/g722.py +0 -0
  20. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/codecs/h264.py +0 -0
  21. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/codecs/opus.py +0 -0
  22. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/codecs/vpx.py +0 -0
  23. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/contrib/__init__.py +0 -0
  24. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/contrib/media.py +0 -0
  25. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/contrib/signaling.py +0 -0
  26. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/events.py +0 -0
  27. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/exceptions.py +0 -0
  28. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/jitterbuffer.py +0 -0
  29. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/mediastreams.py +0 -0
  30. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/rate.py +0 -0
  31. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/rtcconfiguration.py +0 -0
  32. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/rtcdatachannel.py +0 -0
  33. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/rtcdtlstransport.py +0 -0
  34. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/rtcicetransport.py +0 -0
  35. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/rtcpeerconnection.py +0 -0
  36. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/rtcrtpparameters.py +0 -0
  37. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/rtcrtpreceiver.py +0 -0
  38. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/rtcrtpsender.py +0 -0
  39. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/rtcrtptransceiver.py +0 -0
  40. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/rtcsctptransport.py +0 -0
  41. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/rtcsessiondescription.py +0 -0
  42. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/rtp.py +0 -0
  43. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/sdp.py +0 -0
  44. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/stats.py +0 -0
  45. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/utils.py +0 -0
  46. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/aidot_cameras/camera/__init__.py +0 -0
  47. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/aidot_cameras/camera/constants.py +0 -0
  48. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/aidot_cameras/camera/controls.py +0 -0
  49. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/aidot_cameras/camera/go2rtc.py +0 -0
  50. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/aidot_cameras/camera/lan_control.py +0 -0
  51. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/aidot_cameras/camera/models.py +0 -0
  52. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/aidot_cameras/camera/playback.py +0 -0
  53. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/aidot_cameras/camera/protocol.py +0 -0
  54. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/aidot_cameras/camera/sdes.py +0 -0
  55. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/aidot_cameras/camera/sdes_open.py +0 -0
  56. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/aidot_cameras/camera/tutk.py +0 -0
  57. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/aidot_cameras/camera/webrtc.py +0 -0
  58. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/aidot_cameras/camera/webrtc_open.py +0 -0
  59. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/aidot_cameras/client.py +0 -0
  60. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/aidot_cameras/const.py +0 -0
  61. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/aidot_cameras/credentials.py +0 -0
  62. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/aidot_cameras/crypto.py +0 -0
  63. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/aidot_cameras/device_client.py +0 -0
  64. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/aidot_cameras/discover.py +0 -0
  65. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/aidot_cameras/exceptions.py +0 -0
  66. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/aidot_cameras/g711.py +0 -0
  67. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/aidot_cameras/py.typed +0 -0
  68. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  69. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
  70. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/python_aidot_cameras.egg-info/requires.txt +0 -0
  71. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/python_aidot_cameras.egg-info/top_level.txt +0 -0
  72. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/setup.cfg +0 -0
  73. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_aioice_compat.py +0 -0
  74. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_alarm_event.py +0 -0
  75. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_backoff.py +0 -0
  76. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_deferred_hardening.py +0 -0
  77. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_device_login_guard.py +0 -0
  78. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_device_user_info_cache.py +0 -0
  79. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_dtls_not_ready_burst.py +0 -0
  80. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_dtls_pinning.py +0 -0
  81. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_dtls_serve_decode_skip.py +0 -0
  82. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_dtls_serve_open_timeout.py +0 -0
  83. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_dtls_skip_signaling_wait.py +0 -0
  84. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_dtls_slow_probe.py +0 -0
  85. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_egress_guard.py +0 -0
  86. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_go2rtc.py +0 -0
  87. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_go2rtc_cli.py +0 -0
  88. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_h264_decode_rate_limit.py +0 -0
  89. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_highport_nomination.py +0 -0
  90. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_ice_config_cache.py +0 -0
  91. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_idle_release_both_loops.py +0 -0
  92. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_keepalive_renew.py +0 -0
  93. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_key_restart_sdp.py +0 -0
  94. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_keyframe_prompter.py +0 -0
  95. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_lan_control.py +0 -0
  96. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_light_active_color_mode.py +0 -0
  97. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_live_stream_param.py +0 -0
  98. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_logging_caps.py +0 -0
  99. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_motion_poll.py +0 -0
  100. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_mqtt_credential_refetch.py +0 -0
  101. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_mqtt_credential_self_heal.py +0 -0
  102. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_mqtt_password_not_persisted.py +0 -0
  103. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_mqtt_session_reconnect.py +0 -0
  104. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_narrow_pc_ice.py +0 -0
  105. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_no_undefined_names.py +0 -0
  106. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_offline_keepalive_pause.py +0 -0
  107. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_open_fail_logger.py +0 -0
  108. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_open_gate_delay.py +0 -0
  109. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_persistent_mqtt.py +0 -0
  110. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_persistent_mqtt_robustness.py +0 -0
  111. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_playback_tls.py +0 -0
  112. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_post_merge_hardening.py +0 -0
  113. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_retry_policy.py +0 -0
  114. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_sdes_adaptive.py +0 -0
  115. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_sdes_bridge_break_gate.py +0 -0
  116. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_sdes_echo_wait_timeout.py +0 -0
  117. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_sdes_fast_liveplay.py +0 -0
  118. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_sdes_ffmpeg_stderr_log.py +0 -0
  119. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_sdes_idle_release.py +0 -0
  120. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_sdes_open_cleanup.py +0 -0
  121. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_sdes_serve_audio.py +0 -0
  122. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_sdes_serve_cmd.py +0 -0
  123. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_sdes_sprop.py +0 -0
  124. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_sdes_talk.py +0 -0
  125. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_sdes_teardown_exit_log.py +0 -0
  126. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_sdes_watchdog.py +0 -0
  127. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_sdp_payload_type_narrowing.py +0 -0
  128. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_security_hardening.py +0 -0
  129. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_serializable_login_info.py +0 -0
  130. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_serve_relay.py +0 -0
  131. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_session_stats.py +0 -0
  132. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_speak.py +0 -0
  133. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_stream_cap.py +0 -0
  134. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_stream_drain_ownership.py +0 -0
  135. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_stream_idle.py +0 -0
  136. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_stream_slot_cap.py +0 -0
  137. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_stream_teardown.py +0 -0
  138. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_talk.py +0 -0
  139. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_terminal_ack.py +0 -0
  140. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_token_file_robustness.py +0 -0
  141. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_token_refresh.py +0 -0
  142. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_upstream_compat.py +0 -0
  143. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/tests/test_viewer_presence.py +0 -0
  144. {python_aidot_cameras-0.12.10 → python_aidot_cameras-0.12.12}/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.10
3
+ Version: 0.12.12
4
4
  Summary: Control AiDot/Leedarson WiFi lights and cameras (WebRTC streaming, two-way audio, PTZ, controls)
5
5
  Author-email: cbrightly <chris.brightly@gmail.com>
6
6
  License-Expression: MIT
@@ -2,6 +2,7 @@
2
2
 
3
3
  import json
4
4
  import logging
5
+ from urllib.parse import urlparse
5
6
  import os
6
7
  import random
7
8
  import time
@@ -147,6 +148,13 @@ _BG_TASKS: set = set()
147
148
  # means refetching is not helping, so back off rather than loop.
148
149
  _MQTT_CREDENTIAL_REFETCH_FLOOR = 30.0
149
150
 
151
+ # How often the "is anyone watching" question may actually reach go2rtc. The
152
+ # watchdog loops tick twice a second; asking that often opens a fresh HTTP
153
+ # session per camera per tick against the service that also has to serve the
154
+ # video. Idle release is measured in minutes, so seconds of staleness here costs
155
+ # nothing.
156
+ _VIEWER_CHECK_INTERVAL_S = 10.0
157
+
150
158
 
151
159
  def _spawn_bg(coro):
152
160
  _t = asyncio.ensure_future(coro)
@@ -337,6 +345,22 @@ def _save_frame_as_jpeg(image_data: Any, output_path: str) -> bool:
337
345
  return False
338
346
 
339
347
 
348
+ def _is_self_referential_source(source: str, stream_name: str) -> bool:
349
+ """True when registering ``source`` under ``stream_name`` would loop.
350
+
351
+ A go2rtc stream whose source is its own RTSP address becomes its own
352
+ producer: the stream lists a producer, nothing feeds it, and every consumer
353
+ gets a connection with no media. That is exactly the shape of a PUSH URL,
354
+ ``rtsp://host:8554/<stream name>`` - the address the keepalive publishes to.
355
+ """
356
+ if not source or not stream_name:
357
+ return False
358
+ if not source.lower().startswith(("rtsp://", "rtsps://")):
359
+ return False
360
+ path = urlparse(source).path.strip("/")
361
+ return path == stream_name
362
+
363
+
340
364
  def _build_sdes_serve_cmd(
341
365
  *,
342
366
  sdp_path: str,
@@ -2763,6 +2787,18 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2763
2787
  if not (self._streaming_active and self._go2rtc_url and self._keepalive_rtsp_url):
2764
2788
  return
2765
2789
  name = self._go2rtc_stream_name()
2790
+ if _is_self_referential_source(self._keepalive_rtsp_url, name):
2791
+ # PUSH mode: the keepalive publishes INTO go2rtc, so this URL is
2792
+ # go2rtc's own address for this very stream. Registering it as the
2793
+ # stream's source makes go2rtc its own producer - a loop that yields
2794
+ # a stream with a producer attached and no media coming out of it.
2795
+ # In push mode the publisher already feeds the stream; there is
2796
+ # nothing to register.
2797
+ _LOGGER.debug(
2798
+ "camera %s: not registering %s with go2rtc - the push URL is that "
2799
+ "stream's own address", self.device_id, name,
2800
+ )
2801
+ return
2766
2802
  try:
2767
2803
  async with aiohttp.ClientSession() as _s2:
2768
2804
  url = await prefer_go2rtc(
@@ -2908,6 +2944,10 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2908
2944
  register, deregister and viewer-count paths cannot drift apart."""
2909
2945
  return f"aidot_{self.device_id[:12]}"
2910
2946
 
2947
+ # Cached answer to "is anyone watching", so the watchdog loops can ask on
2948
+ # every tick without hammering go2rtc.
2949
+ _viewer_cache: "tuple[float, Optional[bool]]" = (0.0, None)
2950
+
2911
2951
  async def _viewer_present(self, port: int) -> "Optional[bool]":
2912
2952
  """Is anyone ACTUALLY watching? True / False / None (unknown).
2913
2953
 
@@ -2923,6 +2963,17 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2923
2963
  Falls back to the TCP-table check when go2rtc is not in use, and returns
2924
2964
  None (never release) if neither can answer.
2925
2965
  """
2966
+ # THROTTLE FIRST. The DTLS watchdog calls this every 0.5s and the SDES
2967
+ # one on a similar cadence, and the go2rtc query below opens a fresh HTTP
2968
+ # session each time - so an unthrottled check is two requests per second
2969
+ # per camera, forever, against the very service that has to serve the
2970
+ # video. On a small fleet that is enough load to stop go2rtc answering
2971
+ # at all, which looks exactly like "no video anywhere".
2972
+ _now_mono = time.monotonic()
2973
+ _last, _cached = self._viewer_cache
2974
+ if _now_mono - _last < _VIEWER_CHECK_INTERVAL_S:
2975
+ return _cached
2976
+
2926
2977
  name = self._go2rtc_stream_name()
2927
2978
  base = self._go2rtc_url
2928
2979
  if not base:
@@ -2939,6 +2990,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2939
2990
  "no go2rtc url and the serve is an RTSP push to a shared "
2940
2991
  "port; cannot tell whether anyone is watching",
2941
2992
  )
2993
+ self._viewer_cache = (_now_mono, None)
2942
2994
  return None
2943
2995
  if base and name:
2944
2996
  try:
@@ -2948,10 +3000,14 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2948
3000
  async with aiohttp.ClientSession() as _s:
2949
3001
  viewers = await Go2rtcClient(_s, base).viewer_count(name)
2950
3002
  if viewers is not None:
2951
- return viewers > 0
3003
+ _answer = viewers > 0
3004
+ self._viewer_cache = (_now_mono, _answer)
3005
+ return _answer
2952
3006
  except Exception as exc:
2953
3007
  _LOGGER.debug("go2rtc viewer check failed: %s", exc)
2954
- return self._sdes_serve_consumer_present(port)
3008
+ _answer = self._sdes_serve_consumer_present(port)
3009
+ self._viewer_cache = (_now_mono, _answer)
3010
+ return _answer
2955
3011
 
2956
3012
  async def _sdes_keepalive_loop(self) -> None:
2957
3013
  """Keep the cold-start serve relay alive around the SDES keepalive loop.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-aidot-cameras"
7
- version = "0.12.10"
7
+ version = "0.12.12"
8
8
  description = "Control AiDot/Leedarson WiFi lights and cameras (WebRTC streaming, two-way audio, PTZ, controls)"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-aidot-cameras
3
- Version: 0.12.10
3
+ Version: 0.12.12
4
4
  Summary: Control AiDot/Leedarson WiFi lights and cameras (WebRTC streaming, two-way audio, PTZ, controls)
5
5
  Author-email: cbrightly <chris.brightly@gmail.com>
6
6
  License-Expression: MIT
@@ -98,6 +98,7 @@ tests/test_mqtt_credential_self_heal.py
98
98
  tests/test_mqtt_password_not_persisted.py
99
99
  tests/test_mqtt_session_reconnect.py
100
100
  tests/test_narrow_pc_ice.py
101
+ tests/test_no_self_referential_go2rtc.py
101
102
  tests/test_no_undefined_names.py
102
103
  tests/test_offline_keepalive_pause.py
103
104
  tests/test_open_fail_logger.py
@@ -136,5 +137,6 @@ tests/test_terminal_ack.py
136
137
  tests/test_token_file_robustness.py
137
138
  tests/test_token_refresh.py
138
139
  tests/test_upstream_compat.py
140
+ tests/test_viewer_check_throttle.py
139
141
  tests/test_viewer_presence.py
140
142
  tests/test_wait_or_event.py
@@ -0,0 +1,47 @@
1
+ """A go2rtc stream must never be registered as its own source.
2
+
3
+ In PUSH mode the keepalive publishes INTO go2rtc, so the "keepalive rtsp url" is
4
+ go2rtc's own address for that stream. Registering it as the stream's source makes
5
+ go2rtc its own producer: the stream lists a producer, nothing feeds it, and every
6
+ consumer gets a connection with no media.
7
+
8
+ Seen live after the integration began passing go2rtc_url: one camera's stream had
9
+ two producers, one of them `rtsp://127.0.0.1:8554/aidot_12b144cb12da` - its own
10
+ address - and go2rtc returned HTTP 200 with a zero-byte frame for it.
11
+ """
12
+ from aidot_cameras.camera.client import _is_self_referential_source as loops
13
+
14
+
15
+ def test_push_url_for_the_same_stream_is_self_referential():
16
+ assert loops("rtsp://127.0.0.1:8554/aidot_12b144cb12da",
17
+ "aidot_12b144cb12da") is True
18
+
19
+
20
+ def test_push_url_for_a_different_stream_is_fine():
21
+ # Publishing camera A into stream B is odd but not a loop.
22
+ assert loops("rtsp://127.0.0.1:8554/aidot_other", "aidot_12b144cb12da") is False
23
+
24
+
25
+ def test_the_http_serve_url_is_a_real_source():
26
+ # PULL mode: go2rtc fetches from the library's own serve. Must still register.
27
+ assert loops("http://127.0.0.1:18931/ecf4937b640c4c96a102ffdc9b51c2a0.ts",
28
+ "aidot_ecf4937b640c") is False
29
+
30
+
31
+ def test_rtsps_is_covered_too():
32
+ assert loops("rtsps://127.0.0.1:8554/aidot_x", "aidot_x") is True
33
+
34
+
35
+ def test_trailing_slash_and_case_do_not_hide_it():
36
+ assert loops("RTSP://127.0.0.1:8554/aidot_x/", "aidot_x") is True
37
+
38
+
39
+ def test_missing_values_are_not_self_referential():
40
+ assert loops("", "aidot_x") is False
41
+ assert loops("rtsp://127.0.0.1:8554/aidot_x", "") is False
42
+
43
+
44
+ def test_a_remote_go2rtc_push_url_still_counts():
45
+ # The host does not matter; publishing into the stream you are registering is
46
+ # a loop wherever go2rtc lives.
47
+ assert loops("rtsp://go2rtc.lan:8554/aidot_x", "aidot_x") is True
@@ -0,0 +1,71 @@
1
+ """The viewer check must not be asked of go2rtc on every watchdog tick.
2
+
3
+ The DTLS watchdog loops twice a second, and the go2rtc query opens a fresh HTTP
4
+ session per call - so an unthrottled check is two requests per second per camera,
5
+ forever, aimed at the very service that also has to serve the video. On a small
6
+ fleet that was enough to stop go2rtc answering at all, which presents as no video
7
+ anywhere. Idle release is measured in minutes, so a few seconds of staleness in
8
+ the answer costs nothing.
9
+ """
10
+ import asyncio
11
+ import time
12
+
13
+ import aidot_cameras.camera.client as cc
14
+ from aidot_cameras.device_client import CameraDeviceClient
15
+
16
+
17
+ def _client(answer):
18
+ c = CameraDeviceClient.__new__(CameraDeviceClient)
19
+ c._go2rtc_url = None
20
+ c._keepalive_rtsp_url = "http://127.0.0.1:18931/x.ts"
21
+ c.device_id = "abc123abc123def"
22
+ c._viewer_cache = (0.0, None)
23
+ calls = []
24
+
25
+ def _probe(port):
26
+ calls.append(port)
27
+ return answer
28
+
29
+ c._sdes_serve_consumer_present = _probe
30
+ return c, calls
31
+
32
+
33
+ def test_repeated_calls_hit_the_backend_once():
34
+ c, calls = _client(True)
35
+
36
+ async def go():
37
+ for _ in range(40): # 40 watchdog ticks
38
+ await c._viewer_present(18931)
39
+ asyncio.run(go())
40
+ assert len(calls) == 1, f"backend hit {len(calls)} times, expected 1"
41
+
42
+
43
+ def test_the_cached_answer_is_returned_verbatim():
44
+ for answer in (True, False, None):
45
+ c, _ = _client(answer)
46
+
47
+ async def go():
48
+ first = await c._viewer_present(18931)
49
+ second = await c._viewer_present(18931)
50
+ return first, second
51
+ first, second = asyncio.run(go())
52
+ assert first == answer and second == answer
53
+
54
+
55
+ def test_the_cache_expires_so_the_answer_can_change():
56
+ c, calls = _client(True)
57
+
58
+ async def go():
59
+ await c._viewer_present(18931)
60
+ # Pretend the interval has elapsed.
61
+ stamp, cached = c._viewer_cache
62
+ c._viewer_cache = (stamp - cc._VIEWER_CHECK_INTERVAL_S - 1, cached)
63
+ await c._viewer_present(18931)
64
+ asyncio.run(go())
65
+ assert len(calls) == 2
66
+
67
+
68
+ def test_the_interval_is_long_enough_to_matter():
69
+ # The watchdog ticks every 0.5s; anything under a couple of seconds would
70
+ # still be a request storm.
71
+ assert cc._VIEWER_CHECK_INTERVAL_S >= 5.0