python-aidot-cameras 0.13.0__tar.gz → 0.13.1__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 (158) hide show
  1. {python_aidot_cameras-0.13.0/python_aidot_cameras.egg-info → python_aidot_cameras-0.13.1}/PKG-INFO +1 -1
  2. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/aidot_cameras/camera/client.py +70 -18
  3. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/pyproject.toml +1 -1
  4. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  5. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/python_aidot_cameras.egg-info/SOURCES.txt +1 -0
  6. python_aidot_cameras-0.13.1/tests/test_viewer_present_push_mode.py +236 -0
  7. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/LICENSE +0 -0
  8. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/MANIFEST.in +0 -0
  9. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/README.md +0 -0
  10. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/aidot_cameras/__init__.py +0 -0
  11. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/aidot_cameras/__main__.py +0 -0
  12. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/aidot_cameras/_upstream.py +0 -0
  13. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/__init__.py +0 -0
  14. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/__init__.py +0 -0
  15. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/clock.py +0 -0
  16. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/codecs/__init__.py +0 -0
  17. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/codecs/base.py +0 -0
  18. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/codecs/g711.py +0 -0
  19. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/codecs/g722.py +0 -0
  20. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/codecs/h264.py +0 -0
  21. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/codecs/opus.py +0 -0
  22. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/codecs/vpx.py +0 -0
  23. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/contrib/__init__.py +0 -0
  24. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/contrib/media.py +0 -0
  25. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/contrib/signaling.py +0 -0
  26. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/events.py +0 -0
  27. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/exceptions.py +0 -0
  28. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/jitterbuffer.py +0 -0
  29. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/mediastreams.py +0 -0
  30. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/rate.py +0 -0
  31. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/rtcconfiguration.py +0 -0
  32. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/rtcdatachannel.py +0 -0
  33. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/rtcdtlstransport.py +0 -0
  34. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/rtcicetransport.py +0 -0
  35. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/rtcpeerconnection.py +0 -0
  36. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/rtcrtpparameters.py +0 -0
  37. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/rtcrtpreceiver.py +0 -0
  38. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/rtcrtpsender.py +0 -0
  39. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/rtcrtptransceiver.py +0 -0
  40. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/rtcsctptransport.py +0 -0
  41. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/rtcsessiondescription.py +0 -0
  42. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/rtp.py +0 -0
  43. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/sdp.py +0 -0
  44. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/stats.py +0 -0
  45. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/utils.py +0 -0
  46. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/aidot_cameras/camera/__init__.py +0 -0
  47. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/aidot_cameras/camera/constants.py +0 -0
  48. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/aidot_cameras/camera/controls.py +0 -0
  49. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/aidot_cameras/camera/go2rtc.py +0 -0
  50. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/aidot_cameras/camera/lan_control.py +0 -0
  51. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/aidot_cameras/camera/models.py +0 -0
  52. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/aidot_cameras/camera/playback.py +0 -0
  53. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/aidot_cameras/camera/protocol.py +0 -0
  54. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/aidot_cameras/camera/sdes.py +0 -0
  55. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/aidot_cameras/camera/sdes_open.py +0 -0
  56. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/aidot_cameras/camera/tutk.py +0 -0
  57. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/aidot_cameras/camera/webrtc.py +0 -0
  58. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/aidot_cameras/camera/webrtc_open.py +0 -0
  59. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/aidot_cameras/client.py +0 -0
  60. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/aidot_cameras/const.py +0 -0
  61. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/aidot_cameras/credentials.py +0 -0
  62. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/aidot_cameras/crypto.py +0 -0
  63. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/aidot_cameras/device_client.py +0 -0
  64. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/aidot_cameras/discover.py +0 -0
  65. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/aidot_cameras/exceptions.py +0 -0
  66. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/aidot_cameras/g711.py +0 -0
  67. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/aidot_cameras/py.typed +0 -0
  68. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  69. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
  70. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/python_aidot_cameras.egg-info/requires.txt +0 -0
  71. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/python_aidot_cameras.egg-info/top_level.txt +0 -0
  72. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/setup.cfg +0 -0
  73. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_aioice_compat.py +0 -0
  74. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_alarm_event.py +0 -0
  75. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_backoff.py +0 -0
  76. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_battery_detection.py +0 -0
  77. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_battery_skip_turn.py +0 -0
  78. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_constants_imports_resolve.py +0 -0
  79. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_deferred_hardening.py +0 -0
  80. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_device_login_guard.py +0 -0
  81. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_device_user_info_cache.py +0 -0
  82. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_dormant_camera_cleanup.py +0 -0
  83. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_dtls_mux_terminal_write.py +0 -0
  84. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_dtls_not_ready_burst.py +0 -0
  85. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_dtls_pinning.py +0 -0
  86. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_dtls_serve_decode_skip.py +0 -0
  87. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_dtls_serve_open_timeout.py +0 -0
  88. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_dtls_skip_signaling_wait.py +0 -0
  89. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_dtls_slow_probe.py +0 -0
  90. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_egress_guard.py +0 -0
  91. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_go2rtc.py +0 -0
  92. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_go2rtc_cli.py +0 -0
  93. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_h264_decode_rate_limit.py +0 -0
  94. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_highport_nomination.py +0 -0
  95. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_ice_config_cache.py +0 -0
  96. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_ice_peer_reflexive.py +0 -0
  97. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_idle_release_both_loops.py +0 -0
  98. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_keepalive_renew.py +0 -0
  99. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_key_restart_sdp.py +0 -0
  100. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_keyframe_prompter.py +0 -0
  101. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_lan_control.py +0 -0
  102. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_light_active_color_mode.py +0 -0
  103. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_live_stream_param.py +0 -0
  104. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_live_validate_policy.py +0 -0
  105. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_logging_caps.py +0 -0
  106. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_motion_poll.py +0 -0
  107. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_mqtt_credential_refetch.py +0 -0
  108. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_mqtt_credential_self_heal.py +0 -0
  109. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_mqtt_password_not_persisted.py +0 -0
  110. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_mqtt_session_reconnect.py +0 -0
  111. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_narrow_pc_ice.py +0 -0
  112. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_no_self_referential_go2rtc.py +0 -0
  113. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_no_undefined_names.py +0 -0
  114. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_offline_keepalive_pause.py +0 -0
  115. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_open_fail_logger.py +0 -0
  116. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_open_gate_delay.py +0 -0
  117. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_peerid_reuse.py +0 -0
  118. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_persistent_mqtt.py +0 -0
  119. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_persistent_mqtt_robustness.py +0 -0
  120. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_playback_tls.py +0 -0
  121. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_post_merge_hardening.py +0 -0
  122. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_retry_policy.py +0 -0
  123. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_sdes_adaptive.py +0 -0
  124. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_sdes_bridge_break_gate.py +0 -0
  125. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_sdes_echo_wait_timeout.py +0 -0
  126. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_sdes_fast_liveplay.py +0 -0
  127. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_sdes_ffmpeg_stderr_log.py +0 -0
  128. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_sdes_idle_release.py +0 -0
  129. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_sdes_open_cleanup.py +0 -0
  130. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_sdes_serve_audio.py +0 -0
  131. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_sdes_serve_cmd.py +0 -0
  132. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_sdes_sprop.py +0 -0
  133. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_sdes_talk.py +0 -0
  134. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_sdes_teardown_exit_log.py +0 -0
  135. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_sdes_terminal_ack.py +0 -0
  136. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_sdes_watchdog.py +0 -0
  137. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_sdp_payload_type_narrowing.py +0 -0
  138. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_security_hardening.py +0 -0
  139. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_serializable_login_info.py +0 -0
  140. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_serve_relay.py +0 -0
  141. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_session_stats.py +0 -0
  142. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_shared_house_seam.py +0 -0
  143. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_speak.py +0 -0
  144. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_stream_cap.py +0 -0
  145. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_stream_drain_ownership.py +0 -0
  146. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_stream_idle.py +0 -0
  147. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_stream_slot_cap.py +0 -0
  148. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_stream_teardown.py +0 -0
  149. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_talk.py +0 -0
  150. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_terminal_ack.py +0 -0
  151. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_token_file_robustness.py +0 -0
  152. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_token_refresh.py +0 -0
  153. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_turn_create_permission.py +0 -0
  154. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_upstream_compat.py +0 -0
  155. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_viewer_check_throttle.py +0 -0
  156. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_viewer_presence.py +0 -0
  157. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_wait_or_event.py +0 -0
  158. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.1}/tests/test_wake_readiness_retry.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-aidot-cameras
3
- Version: 0.13.0
3
+ Version: 0.13.1
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
@@ -854,6 +854,10 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
854
854
  self._last_frame_time: float = 0.0
855
855
  self._keepalive_rtsp_url: Optional[str] = None # local serve URL (go2rtc pulls)
856
856
  self._go2rtc_url: Optional[str] = None # go2rtc API base (prefer-go2rtc)
857
+ # Whether WE own this camera's go2rtc stream registration. False when the
858
+ # consumer registers the stream itself and passes the URL for viewer
859
+ # queries only - in which case we must neither add nor REMOVE it.
860
+ self._go2rtc_manages_stream: bool = True
857
861
  self._go2rtc_pull_url: Optional[str] = None # go2rtc pull URL once registered
858
862
  self._go2rtc_task: "Optional[asyncio.Task[None]]" = None
859
863
  # Set by the DTLS serve loop once ffmpeg is bound + serving, so
@@ -2789,6 +2793,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2789
2793
  fast_connect: Optional[bool] = None,
2790
2794
  sdes_audio: Optional[bool] = None,
2791
2795
  go2rtc_url: Optional[str] = None,
2796
+ go2rtc_register: bool = True,
2792
2797
  live_stream_param: Optional[bool] = None,
2793
2798
  serve_relay: Optional[bool] = None,
2794
2799
  stream_idle_s: Optional[float] = None,
@@ -2828,6 +2833,22 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2828
2833
  ``AIDOT_SERVE_RELAY`` env var (default on); set False to serve ffmpeg
2829
2834
  directly on the public port (the pre-0.7.18 behaviour).
2830
2835
 
2836
+ ``go2rtc_url`` points at a go2rtc API base. It does two things: register
2837
+ the local serve with go2rtc (exposing its low-latency pull URL), and let
2838
+ the idle-release logic ask go2rtc **who is watching**.
2839
+
2840
+ ``go2rtc_register=False`` keeps the second and drops the first, for a
2841
+ consumer that registers the stream itself - registering it twice
2842
+ re-points the source mid-stream and was observed leaving DTLS cameras
2843
+ producing nothing at all. Such a consumer must still pass ``go2rtc_url``:
2844
+ withholding it does not merely lose a nicety, it silently disables viewer
2845
+ detection, and in SDES **push** mode that is the only viewer signal there
2846
+ is (the serve port is go2rtc's shared RTSP port, where every camera's own
2847
+ publisher is connected, so a socket check would report a viewer for every
2848
+ camera forever). With no signal ``_viewer_present`` answers "unknown",
2849
+ ``_idle_release_due`` correctly never releases on unknown, and a battery
2850
+ camera's keepalive renews forever against a camera nobody is watching.
2851
+
2831
2852
  ``stream_idle_s`` sets the no-viewer idle-release window in seconds,
2832
2853
  overriding the ``AIDOT_STREAM_IDLE_S`` env default (120). ``0`` (or
2833
2854
  negative) keeps the warm WebRTC session forever so re-views are instant
@@ -2860,6 +2881,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2860
2881
  return
2861
2882
  self._keepalive_rtsp_url = rtsp_push_url
2862
2883
  self._go2rtc_url = go2rtc_url
2884
+ self._go2rtc_manages_stream = bool(go2rtc_register)
2863
2885
  self._streaming_active = True
2864
2886
  # App parity: keep a battery camera awake for the whole view (mains cams
2865
2887
  # never sleep, so this is a no-op there). Single-instance; cancelled in
@@ -2874,7 +2896,18 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2874
2896
  # Prefer-go2rtc: if a go2rtc server is configured, register the local
2875
2897
  # serve with it once ready and expose its pull URL (ffmpeg fallback
2876
2898
  # otherwise). Opt-in: with no go2rtc_url, behaviour is unchanged.
2877
- if go2rtc_url:
2899
+ #
2900
+ # ``go2rtc_register=False`` stores the URL for QUERIES ONLY and skips
2901
+ # registration. That exists for a consumer that registers the stream
2902
+ # itself: registering it twice re-points the source while a stream is
2903
+ # mid-flight, which was observed leaving DTLS cameras producing nothing
2904
+ # at all. Such a consumer previously had to withhold go2rtc_url
2905
+ # entirely, which silently disabled viewer detection - and in SDES push
2906
+ # mode that means a battery camera can never idle-release, because
2907
+ # ``_viewer_present`` answers "unknown" and ``_idle_release_due``
2908
+ # (correctly) never releases on unknown. The keepalive then renews
2909
+ # forever against a camera nobody is watching.
2910
+ if go2rtc_url and go2rtc_register:
2878
2911
  self._go2rtc_task = asyncio.ensure_future(self._register_with_go2rtc())
2879
2912
 
2880
2913
  @property
@@ -2934,9 +2967,17 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2934
2967
  _LOGGER.debug("camera %s: swallowed exception in %s", getattr(self, "device_id", "?"), '_register_with_go2rtc', exc_info=True)
2935
2968
 
2936
2969
  async def _deregister_go2rtc(self) -> None:
2937
- """Remove this camera's stream from go2rtc (best-effort)."""
2970
+ """Remove this camera's stream from go2rtc (best-effort).
2971
+
2972
+ Only ever removes a registration this client created. With
2973
+ ``go2rtc_register=False`` the consumer owns the stream and merely lent us
2974
+ the URL to ask who is watching; deleting its stream on stop or
2975
+ idle-release would tear down the very thing it is serving from.
2976
+ """
2938
2977
  base = self._go2rtc_url
2939
2978
  self._go2rtc_pull_url = None
2979
+ if not self._go2rtc_manages_stream:
2980
+ return
2940
2981
  # Deliberately NOT gated on `pull`: go2rtc answers the register call with
2941
2982
  # 400 when it cannot immediately validate the source (a camera that has
2942
2983
  # not started producing yet), yet it still keeps the stream registered.
@@ -3099,22 +3140,21 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3099
3140
 
3100
3141
  name = self._go2rtc_stream_name()
3101
3142
  base = self._go2rtc_url
3102
- if not base:
3103
- # No go2rtc to ask. In PUSH mode the fallback below is not merely
3104
- # unavailable, it is WRONG: the "serve port" is go2rtc's shared RTSP
3105
- # port (8554), where every camera's own publisher is connected, so the
3106
- # check reports a viewer for every camera forever and nothing ever
3107
- # releases. Answer "unknown" instead of a confident lie - the caller
3108
- # then holds the stream rather than tearing down a live view, and the
3109
- # honest way out is for the consumer to pass go2rtc_url.
3110
- if self._keepalive_rtsp_url and not str(
3111
- self._keepalive_rtsp_url).startswith("http"):
3112
- _LOGGER.debug(
3113
- "no go2rtc url and the serve is an RTSP push to a shared "
3114
- "port; cannot tell whether anyone is watching",
3115
- )
3116
- self._viewer_cache = (_now_mono, None)
3117
- return None
3143
+
3144
+ # In PUSH mode the "serve port" is go2rtc's SHARED RTSP port (8554),
3145
+ # where every camera's own publisher is connected - so the socket
3146
+ # fallback below is not merely unavailable, it is WRONG: it would report
3147
+ # a viewer for every camera forever and nothing would ever release.
3148
+ # Tracked separately from `base` because it must gate the fallback even
3149
+ # when go2rtc IS configured but the query fails.
3150
+ push_mode = bool(self._keepalive_rtsp_url) and not str(
3151
+ self._keepalive_rtsp_url).startswith("http")
3152
+
3153
+ # Ask go2rtc when we can. This is the only viewer signal that works in
3154
+ # push mode, which is why a consumer that registers its own stream
3155
+ # should still pass go2rtc_url with go2rtc_register=False rather than
3156
+ # withholding it (withholding it silently pins push-mode cameras to
3157
+ # "unknown", and unknown never releases).
3118
3158
  if base and name:
3119
3159
  try:
3120
3160
  import aiohttp
@@ -3128,6 +3168,18 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3128
3168
  return _answer
3129
3169
  except Exception as exc:
3130
3170
  _LOGGER.debug("go2rtc viewer check failed: %s", exc)
3171
+
3172
+ if push_mode:
3173
+ # Answer "unknown" instead of a confident lie - the caller then holds
3174
+ # the stream rather than tearing down a live view.
3175
+ _LOGGER.debug(
3176
+ "cannot tell whether anyone is watching: the serve is an RTSP "
3177
+ "push to a shared port and go2rtc %s",
3178
+ "did not answer" if base else "was not configured",
3179
+ )
3180
+ self._viewer_cache = (_now_mono, None)
3181
+ return None
3182
+
3131
3183
  _answer = self._sdes_serve_consumer_present(port)
3132
3184
  self._viewer_cache = (_now_mono, _answer)
3133
3185
  return _answer
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-aidot-cameras"
7
- version = "0.13.0"
7
+ version = "0.13.1"
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.13.0
3
+ Version: 0.13.1
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
@@ -151,5 +151,6 @@ tests/test_turn_create_permission.py
151
151
  tests/test_upstream_compat.py
152
152
  tests/test_viewer_check_throttle.py
153
153
  tests/test_viewer_presence.py
154
+ tests/test_viewer_present_push_mode.py
154
155
  tests/test_wait_or_event.py
155
156
  tests/test_wake_readiness_retry.py
@@ -0,0 +1,236 @@
1
+ """``_viewer_present`` in SDES push mode, and the ``go2rtc_register`` seam.
2
+
3
+ Why this file exists, in one paragraph:
4
+
5
+ In push mode the "serve port" is go2rtc's SHARED RTSP port (8554), where every
6
+ camera's own publisher is connected - so a socket check there reports a viewer
7
+ for every camera forever. ``_viewer_present`` therefore refuses to use it and
8
+ answers "unknown", and ``_idle_release_due`` correctly never releases on
9
+ unknown. That is all deliberate and right. The trap is what it composes into: a
10
+ consumer that withholds ``go2rtc_url`` (to avoid a duplicate registration that
11
+ re-points the stream mid-flight) removes the ONLY viewer signal push mode has,
12
+ so every push-mode camera is pinned to "unknown" and a battery camera's
13
+ keepalive renews forever against a camera nobody is watching. Observed live: a
14
+ sleeping battery L2 still being renewed every ~100 s.
15
+
16
+ ``go2rtc_register=False`` is the way out - keep the query, drop the
17
+ registration. These tests pin the decision table so neither half can regress
18
+ silently, since both failure modes are quiet ones.
19
+ """
20
+
21
+ import asyncio
22
+ import types
23
+
24
+ import pytest
25
+
26
+ from aidot_cameras.camera.protocol import _idle_release_due
27
+
28
+ PUSH_URL = "rtsp://127.0.0.1:8554/aidot_cam1" # go2rtc's shared RTSP port
29
+ HTTP_SERVE = "http://127.0.0.1:18000/aidot_cam1" # our own -listen socket
30
+
31
+
32
+ def _client(go2rtc_url=None, keepalive_url=PUSH_URL, viewers=None, raises=False):
33
+ """A stand-in exposing exactly what ``_viewer_present`` touches.
34
+
35
+ Built by binding the real method onto a namespace rather than constructing a
36
+ CameraDeviceClient, which would need a device record, an account and a live
37
+ loop - none of which this decision depends on.
38
+ """
39
+ from aidot_cameras.camera.client import CameraMixin
40
+
41
+ obj = types.SimpleNamespace(
42
+ # Sentinel, not None: if the throttle ever short-circuits, the test
43
+ # returns "STALE" and fails loudly instead of passing vacuously
44
+ # because the cached value happened to match the expected answer.
45
+ _viewer_cache=(0.0, "STALE"),
46
+ _go2rtc_url=go2rtc_url,
47
+ _keepalive_rtsp_url=keepalive_url,
48
+ _go2rtc_stream_name=lambda: "aidot_cam1",
49
+ _sdes_serve_consumer_present=lambda port: True, # the WRONG answer in push mode
50
+ )
51
+
52
+ async def _fake_count(self, name):
53
+ if raises:
54
+ raise RuntimeError("go2rtc unreachable")
55
+ return viewers
56
+
57
+ obj._viewer_present = CameraMixin._viewer_present.__get__(obj)
58
+ obj._fake_count = _fake_count
59
+ return obj
60
+
61
+
62
+ def _ask(obj, monkeypatch, port=8554):
63
+ """Call _viewer_present with the go2rtc client stubbed out."""
64
+ from aidot_cameras.camera import client as client_mod
65
+
66
+ class _Stub:
67
+ def __init__(self, session, base):
68
+ pass
69
+
70
+ async def viewer_count(self, name):
71
+ return await obj._fake_count(obj, name)
72
+
73
+ class _Sess:
74
+ async def __aenter__(self):
75
+ return self
76
+
77
+ async def __aexit__(self, *a):
78
+ return False
79
+
80
+ import aidot_cameras.camera.go2rtc as g2
81
+ monkeypatch.setattr(g2, "Go2rtcClient", _Stub)
82
+ monkeypatch.setattr(client_mod, "_VIEWER_CHECK_INTERVAL_S", 0, raising=False)
83
+
84
+ import aiohttp
85
+ monkeypatch.setattr(aiohttp, "ClientSession", lambda *a, **k: _Sess())
86
+ return asyncio.run(obj._viewer_present(port))
87
+
88
+
89
+ # --------------------------------------------------------------------------- #
90
+ # push mode: the socket fallback must never be consulted
91
+ # --------------------------------------------------------------------------- #
92
+
93
+ def test_push_mode_without_go2rtc_is_unknown_not_a_confident_lie(monkeypatch):
94
+ """No go2rtc + push mode -> unknown.
95
+
96
+ The stub's socket check returns True; if it were ever consulted the answer
97
+ would be True, every camera would look watched, and nothing would release.
98
+ """
99
+ obj = _client(go2rtc_url=None)
100
+ assert _ask(obj, monkeypatch) is None
101
+
102
+
103
+ def test_push_mode_falls_back_to_unknown_when_go2rtc_query_fails(monkeypatch):
104
+ """go2rtc configured but unreachable must NOT drop to the socket check.
105
+
106
+ This is the case the restructure fixed: previously the shared-port guard sat
107
+ behind `if not base`, so configuring go2rtc and having the query fail fell
108
+ straight through to the wrong check.
109
+ """
110
+ obj = _client(go2rtc_url="http://go2rtc:1984", raises=True)
111
+ assert _ask(obj, monkeypatch) is None
112
+
113
+
114
+ def test_push_mode_falls_back_to_unknown_when_go2rtc_returns_none(monkeypatch):
115
+ obj = _client(go2rtc_url="http://go2rtc:1984", viewers=None)
116
+ assert _ask(obj, monkeypatch) is None
117
+
118
+
119
+ # --------------------------------------------------------------------------- #
120
+ # push mode WITH go2rtc: real answers, which is the whole point
121
+ # --------------------------------------------------------------------------- #
122
+
123
+ def test_push_mode_with_go2rtc_reports_a_real_viewer(monkeypatch):
124
+ obj = _client(go2rtc_url="http://go2rtc:1984", viewers=2)
125
+ assert _ask(obj, monkeypatch) is True
126
+
127
+
128
+ def test_push_mode_with_go2rtc_reports_nobody_watching(monkeypatch):
129
+ """The answer that actually lets a battery camera release.
130
+
131
+ Without go2rtc this is unreachable in push mode - it answers unknown, and
132
+ unknown never releases.
133
+ """
134
+ obj = _client(go2rtc_url="http://go2rtc:1984", viewers=0)
135
+ assert _ask(obj, monkeypatch) is False
136
+
137
+
138
+ # --------------------------------------------------------------------------- #
139
+ # non-push (our own HTTP -listen serve): the socket check is legitimate there
140
+ # --------------------------------------------------------------------------- #
141
+
142
+ def test_http_serve_still_uses_the_socket_check(monkeypatch):
143
+ """On our own serve port the socket check is meaningful and must be kept."""
144
+ obj = _client(go2rtc_url=None, keepalive_url=HTTP_SERVE)
145
+ assert _ask(obj, monkeypatch) is True
146
+
147
+
148
+ # --------------------------------------------------------------------------- #
149
+ # the composition that caused the live bug
150
+ # --------------------------------------------------------------------------- #
151
+
152
+ @pytest.mark.parametrize("answer,expected_release", [
153
+ (True, False), # someone watching -> hold
154
+ (None, False), # unknown -> hold (fail-safe)
155
+ (False, True), # nobody watching -> release
156
+ ])
157
+ def test_only_a_definite_no_releases(answer, expected_release):
158
+ """`unknown` must never release - and therefore must never be the only answer.
159
+
160
+ Pinned here next to the push-mode tests because the live bug was not in
161
+ either half: `_viewer_present` answering unknown is right, and
162
+ `_idle_release_due` holding on unknown is right. The defect was that push
163
+ mode without go2rtc made unknown the *only reachable* answer, so "never
164
+ release" became unconditional.
165
+ """
166
+ assert _idle_release_due(answer, last_consumer=0.0, now=10_000.0,
167
+ idle_secs=120.0) is expected_release
168
+
169
+
170
+ # --------------------------------------------------------------------------- #
171
+ # the registration seam
172
+ # --------------------------------------------------------------------------- #
173
+
174
+ def test_deregister_never_removes_a_stream_we_did_not_register():
175
+ """The dangerous half of the seam.
176
+
177
+ ``_deregister_go2rtc`` gates on ``_go2rtc_url`` alone. Before this seam
178
+ existed a query-only consumer withheld that URL, so deregistration was
179
+ unreachable by accident. Passing the URL for queries makes it reachable - and
180
+ it would then DELETE the stream the consumer registered itself, on every stop
181
+ and every idle-release, tearing down the thing it serves from.
182
+ """
183
+ from aidot_cameras.camera.client import CameraMixin
184
+
185
+ removed = []
186
+
187
+ class _Client:
188
+ def __init__(self, session, base):
189
+ pass
190
+
191
+ async def remove_stream(self, name):
192
+ removed.append(name)
193
+
194
+ class _Sess:
195
+ async def __aenter__(self):
196
+ return self
197
+
198
+ async def __aexit__(self, *a):
199
+ return False
200
+
201
+ import aidot_cameras.camera.go2rtc as g2
202
+ import aiohttp
203
+ _orig_client, _orig_sess = g2.Go2rtcClient, aiohttp.ClientSession
204
+ g2.Go2rtcClient = _Client
205
+ aiohttp.ClientSession = lambda *a, **k: _Sess()
206
+ try:
207
+ for manages, expected in ((False, []), (True, ["aidot_cam1"])):
208
+ removed.clear()
209
+ obj = types.SimpleNamespace(
210
+ _go2rtc_url="http://go2rtc:1984",
211
+ _go2rtc_pull_url="x",
212
+ _go2rtc_manages_stream=manages,
213
+ _go2rtc_stream_name=lambda: "aidot_cam1",
214
+ device_id="cam1",
215
+ )
216
+ obj._deregister_go2rtc = CameraMixin._deregister_go2rtc.__get__(obj)
217
+ asyncio.run(obj._deregister_go2rtc())
218
+ assert removed == expected, (
219
+ f"manages={manages}: expected {expected}, got {removed}"
220
+ )
221
+ # The pull URL is cleared either way - it is ours regardless.
222
+ assert obj._go2rtc_pull_url is None
223
+ finally:
224
+ g2.Go2rtcClient, aiohttp.ClientSession = _orig_client, _orig_sess
225
+
226
+
227
+ def test_start_keepalive_exposes_go2rtc_register():
228
+ import inspect
229
+
230
+ from aidot_cameras.camera.client import CameraMixin
231
+
232
+ params = inspect.signature(CameraMixin.start_keepalive).parameters
233
+ assert "go2rtc_register" in params
234
+ assert params["go2rtc_register"].default is True, (
235
+ "default must stay True so existing callers are unaffected"
236
+ )