python-aidot-cameras 0.12.8__tar.gz → 0.12.9__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 (140) hide show
  1. {python_aidot_cameras-0.12.8/python_aidot_cameras.egg-info → python_aidot_cameras-0.12.9}/PKG-INFO +1 -1
  2. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/aidot_cameras/camera/client.py +38 -3
  3. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/aidot_cameras/camera/go2rtc.py +23 -0
  4. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/pyproject.toml +1 -1
  5. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  6. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/python_aidot_cameras.egg-info/SOURCES.txt +1 -0
  7. python_aidot_cameras-0.12.9/tests/test_viewer_presence.py +71 -0
  8. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/LICENSE +0 -0
  9. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/MANIFEST.in +0 -0
  10. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/README.md +0 -0
  11. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/aidot_cameras/__init__.py +0 -0
  12. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/aidot_cameras/__main__.py +0 -0
  13. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/aidot_cameras/_vendor/__init__.py +0 -0
  14. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/aidot_cameras/_vendor/aiortc/__init__.py +0 -0
  15. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/aidot_cameras/_vendor/aiortc/clock.py +0 -0
  16. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/aidot_cameras/_vendor/aiortc/codecs/__init__.py +0 -0
  17. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/aidot_cameras/_vendor/aiortc/codecs/base.py +0 -0
  18. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/aidot_cameras/_vendor/aiortc/codecs/g711.py +0 -0
  19. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/aidot_cameras/_vendor/aiortc/codecs/g722.py +0 -0
  20. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/aidot_cameras/_vendor/aiortc/codecs/h264.py +0 -0
  21. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/aidot_cameras/_vendor/aiortc/codecs/opus.py +0 -0
  22. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/aidot_cameras/_vendor/aiortc/codecs/vpx.py +0 -0
  23. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/aidot_cameras/_vendor/aiortc/contrib/__init__.py +0 -0
  24. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/aidot_cameras/_vendor/aiortc/contrib/media.py +0 -0
  25. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/aidot_cameras/_vendor/aiortc/contrib/signaling.py +0 -0
  26. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/aidot_cameras/_vendor/aiortc/events.py +0 -0
  27. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/aidot_cameras/_vendor/aiortc/exceptions.py +0 -0
  28. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/aidot_cameras/_vendor/aiortc/jitterbuffer.py +0 -0
  29. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/aidot_cameras/_vendor/aiortc/mediastreams.py +0 -0
  30. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/aidot_cameras/_vendor/aiortc/rate.py +0 -0
  31. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/aidot_cameras/_vendor/aiortc/rtcconfiguration.py +0 -0
  32. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/aidot_cameras/_vendor/aiortc/rtcdatachannel.py +0 -0
  33. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/aidot_cameras/_vendor/aiortc/rtcdtlstransport.py +0 -0
  34. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/aidot_cameras/_vendor/aiortc/rtcicetransport.py +0 -0
  35. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/aidot_cameras/_vendor/aiortc/rtcpeerconnection.py +0 -0
  36. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/aidot_cameras/_vendor/aiortc/rtcrtpparameters.py +0 -0
  37. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/aidot_cameras/_vendor/aiortc/rtcrtpreceiver.py +0 -0
  38. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/aidot_cameras/_vendor/aiortc/rtcrtpsender.py +0 -0
  39. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/aidot_cameras/_vendor/aiortc/rtcrtptransceiver.py +0 -0
  40. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/aidot_cameras/_vendor/aiortc/rtcsctptransport.py +0 -0
  41. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/aidot_cameras/_vendor/aiortc/rtcsessiondescription.py +0 -0
  42. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/aidot_cameras/_vendor/aiortc/rtp.py +0 -0
  43. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/aidot_cameras/_vendor/aiortc/sdp.py +0 -0
  44. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/aidot_cameras/_vendor/aiortc/stats.py +0 -0
  45. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/aidot_cameras/_vendor/aiortc/utils.py +0 -0
  46. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/aidot_cameras/camera/__init__.py +0 -0
  47. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/aidot_cameras/camera/constants.py +0 -0
  48. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/aidot_cameras/camera/controls.py +0 -0
  49. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/aidot_cameras/camera/lan_control.py +0 -0
  50. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/aidot_cameras/camera/models.py +0 -0
  51. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/aidot_cameras/camera/playback.py +0 -0
  52. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/aidot_cameras/camera/protocol.py +0 -0
  53. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/aidot_cameras/camera/sdes.py +0 -0
  54. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/aidot_cameras/camera/sdes_open.py +0 -0
  55. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/aidot_cameras/camera/tutk.py +0 -0
  56. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/aidot_cameras/camera/webrtc.py +0 -0
  57. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/aidot_cameras/camera/webrtc_open.py +0 -0
  58. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/aidot_cameras/client.py +0 -0
  59. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/aidot_cameras/const.py +0 -0
  60. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/aidot_cameras/credentials.py +0 -0
  61. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/aidot_cameras/crypto.py +0 -0
  62. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/aidot_cameras/device_client.py +0 -0
  63. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/aidot_cameras/discover.py +0 -0
  64. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/aidot_cameras/exceptions.py +0 -0
  65. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/aidot_cameras/g711.py +0 -0
  66. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/aidot_cameras/py.typed +0 -0
  67. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  68. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
  69. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/python_aidot_cameras.egg-info/requires.txt +0 -0
  70. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/python_aidot_cameras.egg-info/top_level.txt +0 -0
  71. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/setup.cfg +0 -0
  72. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_aioice_compat.py +0 -0
  73. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_alarm_event.py +0 -0
  74. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_backoff.py +0 -0
  75. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_deferred_hardening.py +0 -0
  76. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_device_login_guard.py +0 -0
  77. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_device_user_info_cache.py +0 -0
  78. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_dtls_not_ready_burst.py +0 -0
  79. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_dtls_pinning.py +0 -0
  80. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_dtls_serve_decode_skip.py +0 -0
  81. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_dtls_serve_open_timeout.py +0 -0
  82. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_dtls_skip_signaling_wait.py +0 -0
  83. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_dtls_slow_probe.py +0 -0
  84. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_egress_guard.py +0 -0
  85. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_go2rtc.py +0 -0
  86. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_go2rtc_cli.py +0 -0
  87. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_h264_decode_rate_limit.py +0 -0
  88. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_highport_nomination.py +0 -0
  89. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_ice_config_cache.py +0 -0
  90. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_keepalive_renew.py +0 -0
  91. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_keyframe_prompter.py +0 -0
  92. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_lan_control.py +0 -0
  93. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_light_active_color_mode.py +0 -0
  94. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_live_stream_param.py +0 -0
  95. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_logging_caps.py +0 -0
  96. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_motion_poll.py +0 -0
  97. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_mqtt_credential_refetch.py +0 -0
  98. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_mqtt_credential_self_heal.py +0 -0
  99. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_mqtt_password_not_persisted.py +0 -0
  100. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_mqtt_session_reconnect.py +0 -0
  101. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_narrow_pc_ice.py +0 -0
  102. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_no_undefined_names.py +0 -0
  103. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_offline_keepalive_pause.py +0 -0
  104. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_open_fail_logger.py +0 -0
  105. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_open_gate_delay.py +0 -0
  106. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_persistent_mqtt.py +0 -0
  107. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_persistent_mqtt_robustness.py +0 -0
  108. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_playback_tls.py +0 -0
  109. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_post_merge_hardening.py +0 -0
  110. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_retry_policy.py +0 -0
  111. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_sdes_adaptive.py +0 -0
  112. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_sdes_bridge_break_gate.py +0 -0
  113. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_sdes_echo_wait_timeout.py +0 -0
  114. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_sdes_fast_liveplay.py +0 -0
  115. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_sdes_ffmpeg_stderr_log.py +0 -0
  116. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_sdes_idle_release.py +0 -0
  117. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_sdes_open_cleanup.py +0 -0
  118. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_sdes_serve_audio.py +0 -0
  119. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_sdes_serve_cmd.py +0 -0
  120. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_sdes_sprop.py +0 -0
  121. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_sdes_talk.py +0 -0
  122. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_sdes_teardown_exit_log.py +0 -0
  123. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_sdes_watchdog.py +0 -0
  124. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_sdp_payload_type_narrowing.py +0 -0
  125. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_security_hardening.py +0 -0
  126. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_serializable_login_info.py +0 -0
  127. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_serve_relay.py +0 -0
  128. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_session_stats.py +0 -0
  129. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_speak.py +0 -0
  130. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_stream_cap.py +0 -0
  131. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_stream_drain_ownership.py +0 -0
  132. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_stream_idle.py +0 -0
  133. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_stream_slot_cap.py +0 -0
  134. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_stream_teardown.py +0 -0
  135. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_talk.py +0 -0
  136. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_terminal_ack.py +0 -0
  137. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_token_file_robustness.py +0 -0
  138. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_token_refresh.py +0 -0
  139. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/tests/test_upstream_compat.py +0 -0
  140. {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.9}/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.8
3
+ Version: 0.12.9
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
@@ -2762,7 +2762,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2762
2762
  _LOGGER.debug("camera %s: swallowed exception in %s", getattr(self, "device_id", "?"), '_register_with_go2rtc', exc_info=True)
2763
2763
  if not (self._streaming_active and self._go2rtc_url and self._keepalive_rtsp_url):
2764
2764
  return
2765
- name = f"aidot_{self.device_id[:12]}"
2765
+ name = self._go2rtc_stream_name()
2766
2766
  try:
2767
2767
  async with aiohttp.ClientSession() as _s2:
2768
2768
  url = await prefer_go2rtc(
@@ -2788,7 +2788,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2788
2788
  return
2789
2789
  import aiohttp
2790
2790
  from .go2rtc import Go2rtcClient
2791
- name = f"aidot_{self.device_id[:12]}"
2791
+ name = self._go2rtc_stream_name()
2792
2792
  try:
2793
2793
  async with aiohttp.ClientSession() as _s2:
2794
2794
  await Go2rtcClient(_s2, base).remove_stream(name)
@@ -2903,6 +2903,41 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2903
2903
  found = True
2904
2904
  return found if read_any else None
2905
2905
 
2906
+ def _go2rtc_stream_name(self) -> str:
2907
+ """The go2rtc stream name for this camera - one definition, so the
2908
+ register, deregister and viewer-count paths cannot drift apart."""
2909
+ return f"aidot_{self.device_id[:12]}"
2910
+
2911
+ async def _viewer_present(self, port: int) -> "Optional[bool]":
2912
+ """Is anyone ACTUALLY watching? True / False / None (unknown).
2913
+
2914
+ Ask go2rtc first when the stream is registered with it, because the serve
2915
+ socket cannot answer this question: go2rtc attaches to the serve as its
2916
+ producer and stays attached for as long as the stream exists, viewer or
2917
+ no viewer. A TCP peer is therefore always present, idle-release never
2918
+ fires, and every camera keeps streaming indefinitely after a single view -
2919
+ which is what "why are all my cameras streaming?" looks like from outside.
2920
+ Confirmed live: five cameras, no viewers, still producing after 7 minutes
2921
+ against a 5 minute idle window.
2922
+
2923
+ Falls back to the TCP-table check when go2rtc is not in use, and returns
2924
+ None (never release) if neither can answer.
2925
+ """
2926
+ name = self._go2rtc_stream_name()
2927
+ base = self._go2rtc_url
2928
+ if base and name:
2929
+ try:
2930
+ import aiohttp
2931
+
2932
+ from .go2rtc import Go2rtcClient
2933
+ async with aiohttp.ClientSession() as _s:
2934
+ viewers = await Go2rtcClient(_s, base).viewer_count(name)
2935
+ if viewers is not None:
2936
+ return viewers > 0
2937
+ except Exception as exc:
2938
+ _LOGGER.debug("go2rtc viewer check failed: %s", exc)
2939
+ return self._sdes_serve_consumer_present(port)
2940
+
2906
2941
  async def _sdes_keepalive_loop(self) -> None:
2907
2942
  """Keep the cold-start serve relay alive around the SDES keepalive loop.
2908
2943
 
@@ -3019,7 +3054,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3019
3054
  _stalled = True
3020
3055
  break
3021
3056
  if _idle_on and _serve_port is not None:
3022
- _present = self._sdes_serve_consumer_present(_serve_port)
3057
+ _present = await self._viewer_present(_serve_port)
3023
3058
  if _present: # True -> a viewer is pulling; stay alive
3024
3059
  _last_consumer = time.monotonic()
3025
3060
  elif _idle_release_due(_present, _last_consumer,
@@ -72,6 +72,29 @@ class Go2rtcClient:
72
72
  async def has_stream(self, name: str) -> bool:
73
73
  return name in (await self.list_streams())
74
74
 
75
+ async def viewer_count(self, name: str) -> "Optional[int]":
76
+ """How many CONSUMERS go2rtc has for ``name``; None if it cannot be read.
77
+
78
+ This is the only trustworthy answer to "is anyone actually watching?".
79
+ The serve socket cannot answer it: go2rtc attaches to the serve as its
80
+ producer and stays attached, so a TCP peer is always present whether or
81
+ not a viewer exists. Idle-release keyed on the socket therefore never
82
+ fires and every camera streams forever after a single view.
83
+
84
+ None means unknown (go2rtc unreachable or the stream absent), and callers
85
+ must fail safe by not releasing on it.
86
+ """
87
+ streams = await self.list_streams()
88
+ entry = streams.get(name)
89
+ if not isinstance(entry, dict):
90
+ return None
91
+ consumers = entry.get("consumers")
92
+ if consumers is None:
93
+ return 0 # go2rtc reports null rather than [] when there are none
94
+ if isinstance(consumers, list):
95
+ return len(consumers)
96
+ return None
97
+
75
98
  async def ensure_stream(self, name: str, source: str) -> bool:
76
99
  """Register (or replace) ``name`` -> ``source``. Returns True on 200."""
77
100
  try:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-aidot-cameras"
7
- version = "0.12.8"
7
+ version = "0.12.9"
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.8
3
+ Version: 0.12.9
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
@@ -134,4 +134,5 @@ tests/test_terminal_ack.py
134
134
  tests/test_token_file_robustness.py
135
135
  tests/test_token_refresh.py
136
136
  tests/test_upstream_compat.py
137
+ tests/test_viewer_presence.py
137
138
  tests/test_wait_or_event.py
@@ -0,0 +1,71 @@
1
+ """Idle-release must key on real viewers, not on go2rtc being attached.
2
+
3
+ go2rtc attaches to the camera serve as its PRODUCER and stays attached for as long
4
+ as the stream is registered - viewer or no viewer. So the serve socket always has
5
+ an ESTABLISHED peer, the idle window never elapses, and every camera keeps
6
+ streaming forever after a single view. Confirmed live: five cameras, nobody
7
+ watching, still producing after 7 minutes against a 5 minute idle window.
8
+ """
9
+ import asyncio
10
+
11
+ from aidot_cameras.camera.go2rtc import Go2rtcClient
12
+
13
+
14
+ class _Resp:
15
+ def __init__(self, payload, status=200):
16
+ self._p, self.status = payload, status
17
+
18
+ async def __aenter__(self):
19
+ return self
20
+
21
+ async def __aexit__(self, *a):
22
+ return False
23
+
24
+ async def json(self):
25
+ return self._p
26
+
27
+
28
+ class _Session:
29
+ def __init__(self, payload, status=200):
30
+ self._p, self._status = payload, status
31
+
32
+ def get(self, *a, **kw):
33
+ return _Resp(self._p, self._status)
34
+
35
+
36
+ def _count(payload, name="aidot_abc", status=200):
37
+ client = Go2rtcClient(_Session(payload, status), "http://go2rtc:1984")
38
+ return asyncio.run(client.viewer_count(name))
39
+
40
+
41
+ def test_no_viewers_when_consumers_is_empty():
42
+ # A producer attached with no consumers is exactly the idle case that was
43
+ # being misread as "someone is watching".
44
+ assert _count({"aidot_abc": {"producers": [{"url": "x"}], "consumers": []}}) == 0
45
+
46
+
47
+ def test_no_viewers_when_go2rtc_reports_null():
48
+ # go2rtc returns null rather than [] for some streams; that is still nobody.
49
+ assert _count({"aidot_abc": {"producers": [{"url": "x"}], "consumers": None}}) == 0
50
+
51
+
52
+ def test_counts_real_viewers():
53
+ assert _count({"aidot_abc": {"consumers": [{"id": 1}, {"id": 2}]}}) == 2
54
+
55
+
56
+ def test_unknown_when_the_stream_is_absent():
57
+ # Unknown must stay unknown so the caller fails safe and does NOT release.
58
+ assert _count({"something_else": {"consumers": []}}) is None
59
+
60
+
61
+ def test_unknown_when_go2rtc_is_unreachable():
62
+ assert _count({}, status=500) is None
63
+
64
+
65
+ def test_release_policy_treats_unknown_as_do_not_release():
66
+ from aidot_cameras.camera.protocol import _idle_release_due
67
+ now = 1000.0
68
+ assert _idle_release_due(None, now - 9999, now, 300) is False # unknown
69
+ assert _idle_release_due(True, now - 9999, now, 300) is False # watching
70
+ assert _idle_release_due(False, now - 9999, now, 300) is True # idle, elapsed
71
+ assert _idle_release_due(False, now - 10, now, 300) is False # idle, too soon