python-aidot-cameras 0.12.11__tar.gz → 0.12.13__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 (145) hide show
  1. {python_aidot_cameras-0.12.11/python_aidot_cameras.egg-info → python_aidot_cameras-0.12.13}/PKG-INFO +1 -1
  2. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/aidot_cameras/camera/client.py +50 -2
  3. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/aidot_cameras/camera/sdes_open.py +11 -0
  4. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/pyproject.toml +1 -1
  5. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  6. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/python_aidot_cameras.egg-info/SOURCES.txt +2 -0
  7. python_aidot_cameras-0.12.13/tests/test_dormant_camera_cleanup.py +65 -0
  8. python_aidot_cameras-0.12.13/tests/test_viewer_check_throttle.py +70 -0
  9. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/LICENSE +0 -0
  10. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/MANIFEST.in +0 -0
  11. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/README.md +0 -0
  12. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/aidot_cameras/__init__.py +0 -0
  13. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/aidot_cameras/__main__.py +0 -0
  14. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/aidot_cameras/_vendor/__init__.py +0 -0
  15. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/aidot_cameras/_vendor/aiortc/__init__.py +0 -0
  16. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/aidot_cameras/_vendor/aiortc/clock.py +0 -0
  17. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/aidot_cameras/_vendor/aiortc/codecs/__init__.py +0 -0
  18. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/aidot_cameras/_vendor/aiortc/codecs/base.py +0 -0
  19. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/aidot_cameras/_vendor/aiortc/codecs/g711.py +0 -0
  20. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/aidot_cameras/_vendor/aiortc/codecs/g722.py +0 -0
  21. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/aidot_cameras/_vendor/aiortc/codecs/h264.py +0 -0
  22. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/aidot_cameras/_vendor/aiortc/codecs/opus.py +0 -0
  23. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/aidot_cameras/_vendor/aiortc/codecs/vpx.py +0 -0
  24. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/aidot_cameras/_vendor/aiortc/contrib/__init__.py +0 -0
  25. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/aidot_cameras/_vendor/aiortc/contrib/media.py +0 -0
  26. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/aidot_cameras/_vendor/aiortc/contrib/signaling.py +0 -0
  27. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/aidot_cameras/_vendor/aiortc/events.py +0 -0
  28. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/aidot_cameras/_vendor/aiortc/exceptions.py +0 -0
  29. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/aidot_cameras/_vendor/aiortc/jitterbuffer.py +0 -0
  30. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/aidot_cameras/_vendor/aiortc/mediastreams.py +0 -0
  31. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/aidot_cameras/_vendor/aiortc/rate.py +0 -0
  32. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/aidot_cameras/_vendor/aiortc/rtcconfiguration.py +0 -0
  33. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/aidot_cameras/_vendor/aiortc/rtcdatachannel.py +0 -0
  34. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/aidot_cameras/_vendor/aiortc/rtcdtlstransport.py +0 -0
  35. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/aidot_cameras/_vendor/aiortc/rtcicetransport.py +0 -0
  36. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/aidot_cameras/_vendor/aiortc/rtcpeerconnection.py +0 -0
  37. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/aidot_cameras/_vendor/aiortc/rtcrtpparameters.py +0 -0
  38. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/aidot_cameras/_vendor/aiortc/rtcrtpreceiver.py +0 -0
  39. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/aidot_cameras/_vendor/aiortc/rtcrtpsender.py +0 -0
  40. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/aidot_cameras/_vendor/aiortc/rtcrtptransceiver.py +0 -0
  41. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/aidot_cameras/_vendor/aiortc/rtcsctptransport.py +0 -0
  42. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/aidot_cameras/_vendor/aiortc/rtcsessiondescription.py +0 -0
  43. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/aidot_cameras/_vendor/aiortc/rtp.py +0 -0
  44. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/aidot_cameras/_vendor/aiortc/sdp.py +0 -0
  45. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/aidot_cameras/_vendor/aiortc/stats.py +0 -0
  46. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/aidot_cameras/_vendor/aiortc/utils.py +0 -0
  47. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/aidot_cameras/camera/__init__.py +0 -0
  48. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/aidot_cameras/camera/constants.py +0 -0
  49. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/aidot_cameras/camera/controls.py +0 -0
  50. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/aidot_cameras/camera/go2rtc.py +0 -0
  51. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/aidot_cameras/camera/lan_control.py +0 -0
  52. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/aidot_cameras/camera/models.py +0 -0
  53. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/aidot_cameras/camera/playback.py +0 -0
  54. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/aidot_cameras/camera/protocol.py +0 -0
  55. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/aidot_cameras/camera/sdes.py +0 -0
  56. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/aidot_cameras/camera/tutk.py +0 -0
  57. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/aidot_cameras/camera/webrtc.py +0 -0
  58. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/aidot_cameras/camera/webrtc_open.py +0 -0
  59. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/aidot_cameras/client.py +0 -0
  60. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/aidot_cameras/const.py +0 -0
  61. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/aidot_cameras/credentials.py +0 -0
  62. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/aidot_cameras/crypto.py +0 -0
  63. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/aidot_cameras/device_client.py +0 -0
  64. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/aidot_cameras/discover.py +0 -0
  65. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/aidot_cameras/exceptions.py +0 -0
  66. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/aidot_cameras/g711.py +0 -0
  67. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/aidot_cameras/py.typed +0 -0
  68. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  69. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
  70. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/python_aidot_cameras.egg-info/requires.txt +0 -0
  71. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/python_aidot_cameras.egg-info/top_level.txt +0 -0
  72. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/setup.cfg +0 -0
  73. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_aioice_compat.py +0 -0
  74. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_alarm_event.py +0 -0
  75. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_backoff.py +0 -0
  76. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_deferred_hardening.py +0 -0
  77. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_device_login_guard.py +0 -0
  78. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_device_user_info_cache.py +0 -0
  79. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_dtls_not_ready_burst.py +0 -0
  80. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_dtls_pinning.py +0 -0
  81. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_dtls_serve_decode_skip.py +0 -0
  82. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_dtls_serve_open_timeout.py +0 -0
  83. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_dtls_skip_signaling_wait.py +0 -0
  84. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_dtls_slow_probe.py +0 -0
  85. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_egress_guard.py +0 -0
  86. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_go2rtc.py +0 -0
  87. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_go2rtc_cli.py +0 -0
  88. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_h264_decode_rate_limit.py +0 -0
  89. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_highport_nomination.py +0 -0
  90. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_ice_config_cache.py +0 -0
  91. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_idle_release_both_loops.py +0 -0
  92. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_keepalive_renew.py +0 -0
  93. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_key_restart_sdp.py +0 -0
  94. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_keyframe_prompter.py +0 -0
  95. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_lan_control.py +0 -0
  96. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_light_active_color_mode.py +0 -0
  97. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_live_stream_param.py +0 -0
  98. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_logging_caps.py +0 -0
  99. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_motion_poll.py +0 -0
  100. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_mqtt_credential_refetch.py +0 -0
  101. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_mqtt_credential_self_heal.py +0 -0
  102. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_mqtt_password_not_persisted.py +0 -0
  103. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_mqtt_session_reconnect.py +0 -0
  104. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_narrow_pc_ice.py +0 -0
  105. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_no_self_referential_go2rtc.py +0 -0
  106. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_no_undefined_names.py +0 -0
  107. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_offline_keepalive_pause.py +0 -0
  108. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_open_fail_logger.py +0 -0
  109. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_open_gate_delay.py +0 -0
  110. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_persistent_mqtt.py +0 -0
  111. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_persistent_mqtt_robustness.py +0 -0
  112. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_playback_tls.py +0 -0
  113. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_post_merge_hardening.py +0 -0
  114. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_retry_policy.py +0 -0
  115. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_sdes_adaptive.py +0 -0
  116. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_sdes_bridge_break_gate.py +0 -0
  117. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_sdes_echo_wait_timeout.py +0 -0
  118. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_sdes_fast_liveplay.py +0 -0
  119. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_sdes_ffmpeg_stderr_log.py +0 -0
  120. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_sdes_idle_release.py +0 -0
  121. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_sdes_open_cleanup.py +0 -0
  122. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_sdes_serve_audio.py +0 -0
  123. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_sdes_serve_cmd.py +0 -0
  124. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_sdes_sprop.py +0 -0
  125. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_sdes_talk.py +0 -0
  126. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_sdes_teardown_exit_log.py +0 -0
  127. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_sdes_watchdog.py +0 -0
  128. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_sdp_payload_type_narrowing.py +0 -0
  129. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_security_hardening.py +0 -0
  130. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_serializable_login_info.py +0 -0
  131. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_serve_relay.py +0 -0
  132. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_session_stats.py +0 -0
  133. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_speak.py +0 -0
  134. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_stream_cap.py +0 -0
  135. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_stream_drain_ownership.py +0 -0
  136. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_stream_idle.py +0 -0
  137. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_stream_slot_cap.py +0 -0
  138. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_stream_teardown.py +0 -0
  139. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_talk.py +0 -0
  140. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_terminal_ack.py +0 -0
  141. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_token_file_robustness.py +0 -0
  142. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_token_refresh.py +0 -0
  143. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_upstream_compat.py +0 -0
  144. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/tests/test_viewer_presence.py +0 -0
  145. {python_aidot_cameras-0.12.11 → python_aidot_cameras-0.12.13}/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.13
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.
@@ -3128,6 +3155,19 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3128
3155
  self._cancel_keepalive_renew()
3129
3156
  self._keepalive_rtsp_url = None
3130
3157
  self._serve_ready.clear()
3158
+ # Take the stream out of go2rtc too. Leaving it registered
3159
+ # points go2rtc at a source that no longer exists, so a viewer
3160
+ # attaching while the camera is dormant gets a hard refusal
3161
+ # ("connection refused" on the serve port) instead of a clean
3162
+ # "no such stream" - and in push mode that source was never
3163
+ # dialable in the first place. stream_source() re-registers on
3164
+ # the next view.
3165
+ try:
3166
+ await self._deregister_go2rtc()
3167
+ except Exception:
3168
+ _LOGGER.debug("camera %s: swallowed exception in %s",
3169
+ getattr(self, "device_id", "?"),
3170
+ "_deregister_go2rtc", exc_info=True)
3131
3171
  _LOGGER.debug(
3132
3172
  "SDES serve: %s idle (no viewer) - released until next view",
3133
3173
  self.device_id,
@@ -4089,6 +4129,14 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
4089
4129
  self._cancel_keepalive_renew()
4090
4130
  self._keepalive_rtsp_url = None
4091
4131
  self._serve_ready.clear()
4132
+ # Same as the SDES path: do not leave go2rtc holding a stream
4133
+ # whose source no longer exists.
4134
+ try:
4135
+ await self._deregister_go2rtc()
4136
+ except Exception:
4137
+ _LOGGER.debug("camera %s: swallowed exception in %s",
4138
+ getattr(self, "device_id", "?"),
4139
+ "_deregister_go2rtc", exc_info=True)
4092
4140
  _LOGGER.debug(
4093
4141
  "DTLS serve: %s idle (no viewer) - released until next view",
4094
4142
  self.device_id,
@@ -102,6 +102,11 @@ def _sdes_echo_wait_timeout(skip_liveplay: bool) -> float:
102
102
  return 0.0 if skip_liveplay else 2.0
103
103
 
104
104
 
105
+ # ffmpeg returns AVERROR(EPIPE) = -32 when its output consumer disappears, and a
106
+ # process exit status is truncated to an unsigned byte: -32 & 0xFF = 224.
107
+ _FFMPEG_EXIT_EPIPE = 224
108
+
109
+
105
110
  def _classify_ffmpeg_exit(rc: int, teardown_requested: bool) -> int:
106
111
  """Log level for the bridge observe loop's "ffmpeg exited" line.
107
112
 
@@ -116,6 +121,12 @@ def _classify_ffmpeg_exit(rc: int, teardown_requested: bool) -> int:
116
121
  """
117
122
  if rc < 0 and teardown_requested:
118
123
  return logging.DEBUG
124
+ if rc == _FFMPEG_EXIT_EPIPE:
125
+ # Broken pipe: the consumer went away. That is the NORMAL end of a
126
+ # `-listen 1` serve - go2rtc disconnects and ffmpeg exits - so warning
127
+ # about it trains the reader to ignore real failures. A process exit
128
+ # status is an unsigned byte, so AVERROR(EPIPE) = -32 surfaces as 224.
129
+ return logging.DEBUG
119
130
  return logging.WARNING
120
131
 
121
132
 
@@ -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.13"
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.13
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
@@ -72,6 +72,7 @@ tests/test_backoff.py
72
72
  tests/test_deferred_hardening.py
73
73
  tests/test_device_login_guard.py
74
74
  tests/test_device_user_info_cache.py
75
+ tests/test_dormant_camera_cleanup.py
75
76
  tests/test_dtls_not_ready_burst.py
76
77
  tests/test_dtls_pinning.py
77
78
  tests/test_dtls_serve_decode_skip.py
@@ -137,5 +138,6 @@ tests/test_terminal_ack.py
137
138
  tests/test_token_file_robustness.py
138
139
  tests/test_token_refresh.py
139
140
  tests/test_upstream_compat.py
141
+ tests/test_viewer_check_throttle.py
140
142
  tests/test_viewer_presence.py
141
143
  tests/test_wait_or_event.py
@@ -0,0 +1,65 @@
1
+ """A dormant camera must not leave a dead stream registered in go2rtc.
2
+
3
+ Idle-release stops the serve but used to leave the go2rtc stream in place,
4
+ pointing at a source that no longer exists. A viewer attaching then gets a hard
5
+ "connection refused" on the serve port rather than a clean miss - and in PUSH mode
6
+ that source was never dialable at all, because the keepalive publishes INTO go2rtc
7
+ rather than serving over HTTP.
8
+
9
+ Observed live: four go2rtc streams registered against serve ports with nothing
10
+ listening, and go2rtc reporting
11
+ `dial tcp 127.0.0.1:18981: connect: connection refused`.
12
+ """
13
+ import inspect
14
+
15
+ import aidot_cameras.camera.client as cc
16
+ from aidot_cameras.camera.sdes_open import (
17
+ _FFMPEG_EXIT_EPIPE,
18
+ _classify_ffmpeg_exit,
19
+ )
20
+ import logging
21
+
22
+
23
+ def _src(name):
24
+ return inspect.getsource(getattr(cc.CameraMixin, name))
25
+
26
+
27
+ def test_sdes_idle_release_deregisters_from_go2rtc():
28
+ src = _src("_sdes_keepalive_loop_inner")
29
+ idle = src[src.index("if _idle_release:"):]
30
+ assert "_deregister_go2rtc" in idle
31
+
32
+
33
+ def test_dtls_idle_release_deregisters_from_go2rtc():
34
+ src = _src("_dtls_serve_loop_inner")
35
+ idle = src[src.index("if idle_release:"):]
36
+ assert "_deregister_go2rtc" in idle
37
+
38
+
39
+ def test_deregistration_failure_cannot_break_the_release():
40
+ # Going dormant must still complete if go2rtc is unreachable.
41
+ for name, marker in (("_sdes_keepalive_loop_inner", "if _idle_release:"),
42
+ ("_dtls_serve_loop_inner", "if idle_release:")):
43
+ idle = _src(name)
44
+ idle = idle[idle.index(marker):]
45
+ block = idle[idle.index("_deregister_go2rtc") - 200:
46
+ idle.index("_deregister_go2rtc") + 300]
47
+ assert "try:" in block and "except Exception" in block
48
+
49
+
50
+ def test_consumer_disconnect_is_not_logged_as_a_failure():
51
+ # ffmpeg returns AVERROR(EPIPE) = -32 when its consumer goes away, and an exit
52
+ # status is an unsigned byte, so it surfaces as 224. That is the normal end of
53
+ # a `-listen 1` serve, not a fault.
54
+ assert _FFMPEG_EXIT_EPIPE == 224
55
+ assert _classify_ffmpeg_exit(224, teardown_requested=False) == logging.DEBUG
56
+ assert _classify_ffmpeg_exit(224, teardown_requested=True) == logging.DEBUG
57
+
58
+
59
+ def test_real_failures_still_warn():
60
+ assert _classify_ffmpeg_exit(1, teardown_requested=False) == logging.WARNING
61
+ assert _classify_ffmpeg_exit(255, teardown_requested=False) == logging.WARNING
62
+ # A signal death with no teardown in flight is still unexpected.
63
+ assert _classify_ffmpeg_exit(-9, teardown_requested=False) == logging.WARNING
64
+ # ...but an expected teardown is quiet.
65
+ assert _classify_ffmpeg_exit(-9, teardown_requested=True) == logging.DEBUG
@@ -0,0 +1,70 @@
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
+
12
+ import aidot_cameras.camera.client as cc
13
+ from aidot_cameras.device_client import CameraDeviceClient
14
+
15
+
16
+ def _client(answer):
17
+ c = CameraDeviceClient.__new__(CameraDeviceClient)
18
+ c._go2rtc_url = None
19
+ c._keepalive_rtsp_url = "http://127.0.0.1:18931/x.ts"
20
+ c.device_id = "abc123abc123def"
21
+ c._viewer_cache = (0.0, None)
22
+ calls = []
23
+
24
+ def _probe(port):
25
+ calls.append(port)
26
+ return answer
27
+
28
+ c._sdes_serve_consumer_present = _probe
29
+ return c, calls
30
+
31
+
32
+ def test_repeated_calls_hit_the_backend_once():
33
+ c, calls = _client(True)
34
+
35
+ async def go():
36
+ for _ in range(40): # 40 watchdog ticks
37
+ await c._viewer_present(18931)
38
+ asyncio.run(go())
39
+ assert len(calls) == 1, f"backend hit {len(calls)} times, expected 1"
40
+
41
+
42
+ def test_the_cached_answer_is_returned_verbatim():
43
+ for answer in (True, False, None):
44
+ c, _ = _client(answer)
45
+
46
+ async def go():
47
+ first = await c._viewer_present(18931)
48
+ second = await c._viewer_present(18931)
49
+ return first, second
50
+ first, second = asyncio.run(go())
51
+ assert first == answer and second == answer
52
+
53
+
54
+ def test_the_cache_expires_so_the_answer_can_change():
55
+ c, calls = _client(True)
56
+
57
+ async def go():
58
+ await c._viewer_present(18931)
59
+ # Pretend the interval has elapsed.
60
+ stamp, cached = c._viewer_cache
61
+ c._viewer_cache = (stamp - cc._VIEWER_CHECK_INTERVAL_S - 1, cached)
62
+ await c._viewer_present(18931)
63
+ asyncio.run(go())
64
+ assert len(calls) == 2
65
+
66
+
67
+ def test_the_interval_is_long_enough_to_matter():
68
+ # The watchdog ticks every 0.5s; anything under a couple of seconds would
69
+ # still be a request storm.
70
+ assert cc._VIEWER_CHECK_INTERVAL_S >= 5.0