python-aidot-cameras 0.12.11__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.11/python_aidot_cameras.egg-info → python_aidot_cameras-0.12.12}/PKG-INFO +1 -1
  2. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/aidot_cameras/camera/client.py +29 -2
  3. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/pyproject.toml +1 -1
  4. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  5. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/python_aidot_cameras.egg-info/SOURCES.txt +1 -0
  6. python_aidot_cameras-0.12.12/tests/test_viewer_check_throttle.py +71 -0
  7. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/LICENSE +0 -0
  8. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/MANIFEST.in +0 -0
  9. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/README.md +0 -0
  10. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/aidot_cameras/__init__.py +0 -0
  11. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/aidot_cameras/__main__.py +0 -0
  12. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/__init__.py +0 -0
  13. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/__init__.py +0 -0
  14. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/clock.py +0 -0
  15. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/codecs/__init__.py +0 -0
  16. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/codecs/base.py +0 -0
  17. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/codecs/g711.py +0 -0
  18. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/codecs/g722.py +0 -0
  19. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/codecs/h264.py +0 -0
  20. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/codecs/opus.py +0 -0
  21. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/codecs/vpx.py +0 -0
  22. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/contrib/__init__.py +0 -0
  23. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/contrib/media.py +0 -0
  24. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/contrib/signaling.py +0 -0
  25. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/events.py +0 -0
  26. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/exceptions.py +0 -0
  27. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/jitterbuffer.py +0 -0
  28. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/mediastreams.py +0 -0
  29. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/rate.py +0 -0
  30. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/rtcconfiguration.py +0 -0
  31. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/rtcdatachannel.py +0 -0
  32. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/rtcdtlstransport.py +0 -0
  33. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/rtcicetransport.py +0 -0
  34. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/rtcpeerconnection.py +0 -0
  35. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/rtcrtpparameters.py +0 -0
  36. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/rtcrtpreceiver.py +0 -0
  37. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/rtcrtpsender.py +0 -0
  38. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/rtcrtptransceiver.py +0 -0
  39. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/rtcsctptransport.py +0 -0
  40. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/rtcsessiondescription.py +0 -0
  41. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/rtp.py +0 -0
  42. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/sdp.py +0 -0
  43. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/stats.py +0 -0
  44. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/aidot_cameras/_vendor/aiortc/utils.py +0 -0
  45. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/aidot_cameras/camera/__init__.py +0 -0
  46. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/aidot_cameras/camera/constants.py +0 -0
  47. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/aidot_cameras/camera/controls.py +0 -0
  48. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/aidot_cameras/camera/go2rtc.py +0 -0
  49. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/aidot_cameras/camera/lan_control.py +0 -0
  50. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/aidot_cameras/camera/models.py +0 -0
  51. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/aidot_cameras/camera/playback.py +0 -0
  52. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/aidot_cameras/camera/protocol.py +0 -0
  53. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/aidot_cameras/camera/sdes.py +0 -0
  54. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/aidot_cameras/camera/sdes_open.py +0 -0
  55. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/aidot_cameras/camera/tutk.py +0 -0
  56. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/aidot_cameras/camera/webrtc.py +0 -0
  57. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/aidot_cameras/camera/webrtc_open.py +0 -0
  58. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/aidot_cameras/client.py +0 -0
  59. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/aidot_cameras/const.py +0 -0
  60. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/aidot_cameras/credentials.py +0 -0
  61. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/aidot_cameras/crypto.py +0 -0
  62. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/aidot_cameras/device_client.py +0 -0
  63. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/aidot_cameras/discover.py +0 -0
  64. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/aidot_cameras/exceptions.py +0 -0
  65. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/aidot_cameras/g711.py +0 -0
  66. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/aidot_cameras/py.typed +0 -0
  67. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  68. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
  69. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/python_aidot_cameras.egg-info/requires.txt +0 -0
  70. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/python_aidot_cameras.egg-info/top_level.txt +0 -0
  71. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/setup.cfg +0 -0
  72. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_aioice_compat.py +0 -0
  73. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_alarm_event.py +0 -0
  74. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_backoff.py +0 -0
  75. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_deferred_hardening.py +0 -0
  76. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_device_login_guard.py +0 -0
  77. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_device_user_info_cache.py +0 -0
  78. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_dtls_not_ready_burst.py +0 -0
  79. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_dtls_pinning.py +0 -0
  80. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_dtls_serve_decode_skip.py +0 -0
  81. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_dtls_serve_open_timeout.py +0 -0
  82. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_dtls_skip_signaling_wait.py +0 -0
  83. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_dtls_slow_probe.py +0 -0
  84. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_egress_guard.py +0 -0
  85. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_go2rtc.py +0 -0
  86. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_go2rtc_cli.py +0 -0
  87. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_h264_decode_rate_limit.py +0 -0
  88. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_highport_nomination.py +0 -0
  89. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_ice_config_cache.py +0 -0
  90. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_idle_release_both_loops.py +0 -0
  91. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_keepalive_renew.py +0 -0
  92. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_key_restart_sdp.py +0 -0
  93. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_keyframe_prompter.py +0 -0
  94. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_lan_control.py +0 -0
  95. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_light_active_color_mode.py +0 -0
  96. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_live_stream_param.py +0 -0
  97. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_logging_caps.py +0 -0
  98. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_motion_poll.py +0 -0
  99. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_mqtt_credential_refetch.py +0 -0
  100. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_mqtt_credential_self_heal.py +0 -0
  101. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_mqtt_password_not_persisted.py +0 -0
  102. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_mqtt_session_reconnect.py +0 -0
  103. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_narrow_pc_ice.py +0 -0
  104. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_no_self_referential_go2rtc.py +0 -0
  105. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_no_undefined_names.py +0 -0
  106. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_offline_keepalive_pause.py +0 -0
  107. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_open_fail_logger.py +0 -0
  108. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_open_gate_delay.py +0 -0
  109. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_persistent_mqtt.py +0 -0
  110. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_persistent_mqtt_robustness.py +0 -0
  111. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_playback_tls.py +0 -0
  112. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_post_merge_hardening.py +0 -0
  113. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_retry_policy.py +0 -0
  114. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_sdes_adaptive.py +0 -0
  115. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_sdes_bridge_break_gate.py +0 -0
  116. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_sdes_echo_wait_timeout.py +0 -0
  117. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_sdes_fast_liveplay.py +0 -0
  118. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_sdes_ffmpeg_stderr_log.py +0 -0
  119. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_sdes_idle_release.py +0 -0
  120. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_sdes_open_cleanup.py +0 -0
  121. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_sdes_serve_audio.py +0 -0
  122. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_sdes_serve_cmd.py +0 -0
  123. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_sdes_sprop.py +0 -0
  124. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_sdes_talk.py +0 -0
  125. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_sdes_teardown_exit_log.py +0 -0
  126. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_sdes_watchdog.py +0 -0
  127. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_sdp_payload_type_narrowing.py +0 -0
  128. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_security_hardening.py +0 -0
  129. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_serializable_login_info.py +0 -0
  130. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_serve_relay.py +0 -0
  131. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_session_stats.py +0 -0
  132. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_speak.py +0 -0
  133. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_stream_cap.py +0 -0
  134. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_stream_drain_ownership.py +0 -0
  135. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_stream_idle.py +0 -0
  136. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_stream_slot_cap.py +0 -0
  137. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_stream_teardown.py +0 -0
  138. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_talk.py +0 -0
  139. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_terminal_ack.py +0 -0
  140. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_token_file_robustness.py +0 -0
  141. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_token_refresh.py +0 -0
  142. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_upstream_compat.py +0 -0
  143. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.12}/tests/test_viewer_presence.py +0 -0
  144. {python_aidot_cameras-0.12.11 → 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.11
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
@@ -148,6 +148,13 @@ _BG_TASKS: set = set()
148
148
  # means refetching is not helping, so back off rather than loop.
149
149
  _MQTT_CREDENTIAL_REFETCH_FLOOR = 30.0
150
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
+
151
158
 
152
159
  def _spawn_bg(coro):
153
160
  _t = asyncio.ensure_future(coro)
@@ -2937,6 +2944,10 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2937
2944
  register, deregister and viewer-count paths cannot drift apart."""
2938
2945
  return f"aidot_{self.device_id[:12]}"
2939
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
+
2940
2951
  async def _viewer_present(self, port: int) -> "Optional[bool]":
2941
2952
  """Is anyone ACTUALLY watching? True / False / None (unknown).
2942
2953
 
@@ -2952,6 +2963,17 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2952
2963
  Falls back to the TCP-table check when go2rtc is not in use, and returns
2953
2964
  None (never release) if neither can answer.
2954
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
+
2955
2977
  name = self._go2rtc_stream_name()
2956
2978
  base = self._go2rtc_url
2957
2979
  if not base:
@@ -2968,6 +2990,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2968
2990
  "no go2rtc url and the serve is an RTSP push to a shared "
2969
2991
  "port; cannot tell whether anyone is watching",
2970
2992
  )
2993
+ self._viewer_cache = (_now_mono, None)
2971
2994
  return None
2972
2995
  if base and name:
2973
2996
  try:
@@ -2977,10 +3000,14 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2977
3000
  async with aiohttp.ClientSession() as _s:
2978
3001
  viewers = await Go2rtcClient(_s, base).viewer_count(name)
2979
3002
  if viewers is not None:
2980
- return viewers > 0
3003
+ _answer = viewers > 0
3004
+ self._viewer_cache = (_now_mono, _answer)
3005
+ return _answer
2981
3006
  except Exception as exc:
2982
3007
  _LOGGER.debug("go2rtc viewer check failed: %s", exc)
2983
- 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
2984
3011
 
2985
3012
  async def _sdes_keepalive_loop(self) -> None:
2986
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.11"
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.11
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
@@ -137,5 +137,6 @@ tests/test_terminal_ack.py
137
137
  tests/test_token_file_robustness.py
138
138
  tests/test_token_refresh.py
139
139
  tests/test_upstream_compat.py
140
+ tests/test_viewer_check_throttle.py
140
141
  tests/test_viewer_presence.py
141
142
  tests/test_wait_or_event.py
@@ -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