python-aidot-cameras 0.13.0__tar.gz → 0.13.2__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 (159) hide show
  1. {python_aidot_cameras-0.13.0/python_aidot_cameras.egg-info → python_aidot_cameras-0.13.2}/PKG-INFO +1 -1
  2. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/aidot_cameras/camera/client.py +91 -27
  3. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/pyproject.toml +1 -1
  4. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  5. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/python_aidot_cameras.egg-info/SOURCES.txt +2 -0
  6. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_battery_detection.py +30 -3
  7. python_aidot_cameras-0.13.2/tests/test_battery_evidence_corroboration.py +139 -0
  8. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_live_validate_policy.py +55 -0
  9. python_aidot_cameras-0.13.2/tests/test_viewer_present_push_mode.py +236 -0
  10. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/LICENSE +0 -0
  11. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/MANIFEST.in +0 -0
  12. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/README.md +0 -0
  13. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/aidot_cameras/__init__.py +0 -0
  14. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/aidot_cameras/__main__.py +0 -0
  15. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/aidot_cameras/_upstream.py +0 -0
  16. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/aidot_cameras/_vendor/__init__.py +0 -0
  17. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/aidot_cameras/_vendor/aiortc/__init__.py +0 -0
  18. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/aidot_cameras/_vendor/aiortc/clock.py +0 -0
  19. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/aidot_cameras/_vendor/aiortc/codecs/__init__.py +0 -0
  20. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/aidot_cameras/_vendor/aiortc/codecs/base.py +0 -0
  21. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/aidot_cameras/_vendor/aiortc/codecs/g711.py +0 -0
  22. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/aidot_cameras/_vendor/aiortc/codecs/g722.py +0 -0
  23. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/aidot_cameras/_vendor/aiortc/codecs/h264.py +0 -0
  24. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/aidot_cameras/_vendor/aiortc/codecs/opus.py +0 -0
  25. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/aidot_cameras/_vendor/aiortc/codecs/vpx.py +0 -0
  26. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/aidot_cameras/_vendor/aiortc/contrib/__init__.py +0 -0
  27. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/aidot_cameras/_vendor/aiortc/contrib/media.py +0 -0
  28. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/aidot_cameras/_vendor/aiortc/contrib/signaling.py +0 -0
  29. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/aidot_cameras/_vendor/aiortc/events.py +0 -0
  30. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/aidot_cameras/_vendor/aiortc/exceptions.py +0 -0
  31. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/aidot_cameras/_vendor/aiortc/jitterbuffer.py +0 -0
  32. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/aidot_cameras/_vendor/aiortc/mediastreams.py +0 -0
  33. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/aidot_cameras/_vendor/aiortc/rate.py +0 -0
  34. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/aidot_cameras/_vendor/aiortc/rtcconfiguration.py +0 -0
  35. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/aidot_cameras/_vendor/aiortc/rtcdatachannel.py +0 -0
  36. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/aidot_cameras/_vendor/aiortc/rtcdtlstransport.py +0 -0
  37. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/aidot_cameras/_vendor/aiortc/rtcicetransport.py +0 -0
  38. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/aidot_cameras/_vendor/aiortc/rtcpeerconnection.py +0 -0
  39. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/aidot_cameras/_vendor/aiortc/rtcrtpparameters.py +0 -0
  40. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/aidot_cameras/_vendor/aiortc/rtcrtpreceiver.py +0 -0
  41. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/aidot_cameras/_vendor/aiortc/rtcrtpsender.py +0 -0
  42. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/aidot_cameras/_vendor/aiortc/rtcrtptransceiver.py +0 -0
  43. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/aidot_cameras/_vendor/aiortc/rtcsctptransport.py +0 -0
  44. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/aidot_cameras/_vendor/aiortc/rtcsessiondescription.py +0 -0
  45. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/aidot_cameras/_vendor/aiortc/rtp.py +0 -0
  46. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/aidot_cameras/_vendor/aiortc/sdp.py +0 -0
  47. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/aidot_cameras/_vendor/aiortc/stats.py +0 -0
  48. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/aidot_cameras/_vendor/aiortc/utils.py +0 -0
  49. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/aidot_cameras/camera/__init__.py +0 -0
  50. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/aidot_cameras/camera/constants.py +0 -0
  51. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/aidot_cameras/camera/controls.py +0 -0
  52. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/aidot_cameras/camera/go2rtc.py +0 -0
  53. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/aidot_cameras/camera/lan_control.py +0 -0
  54. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/aidot_cameras/camera/models.py +0 -0
  55. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/aidot_cameras/camera/playback.py +0 -0
  56. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/aidot_cameras/camera/protocol.py +0 -0
  57. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/aidot_cameras/camera/sdes.py +0 -0
  58. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/aidot_cameras/camera/sdes_open.py +0 -0
  59. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/aidot_cameras/camera/tutk.py +0 -0
  60. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/aidot_cameras/camera/webrtc.py +0 -0
  61. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/aidot_cameras/camera/webrtc_open.py +0 -0
  62. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/aidot_cameras/client.py +0 -0
  63. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/aidot_cameras/const.py +0 -0
  64. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/aidot_cameras/credentials.py +0 -0
  65. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/aidot_cameras/crypto.py +0 -0
  66. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/aidot_cameras/device_client.py +0 -0
  67. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/aidot_cameras/discover.py +0 -0
  68. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/aidot_cameras/exceptions.py +0 -0
  69. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/aidot_cameras/g711.py +0 -0
  70. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/aidot_cameras/py.typed +0 -0
  71. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  72. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
  73. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/python_aidot_cameras.egg-info/requires.txt +0 -0
  74. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/python_aidot_cameras.egg-info/top_level.txt +0 -0
  75. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/setup.cfg +0 -0
  76. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_aioice_compat.py +0 -0
  77. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_alarm_event.py +0 -0
  78. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_backoff.py +0 -0
  79. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_battery_skip_turn.py +0 -0
  80. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_constants_imports_resolve.py +0 -0
  81. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_deferred_hardening.py +0 -0
  82. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_device_login_guard.py +0 -0
  83. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_device_user_info_cache.py +0 -0
  84. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_dormant_camera_cleanup.py +0 -0
  85. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_dtls_mux_terminal_write.py +0 -0
  86. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_dtls_not_ready_burst.py +0 -0
  87. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_dtls_pinning.py +0 -0
  88. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_dtls_serve_decode_skip.py +0 -0
  89. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_dtls_serve_open_timeout.py +0 -0
  90. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_dtls_skip_signaling_wait.py +0 -0
  91. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_dtls_slow_probe.py +0 -0
  92. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_egress_guard.py +0 -0
  93. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_go2rtc.py +0 -0
  94. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_go2rtc_cli.py +0 -0
  95. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_h264_decode_rate_limit.py +0 -0
  96. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_highport_nomination.py +0 -0
  97. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_ice_config_cache.py +0 -0
  98. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_ice_peer_reflexive.py +0 -0
  99. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_idle_release_both_loops.py +0 -0
  100. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_keepalive_renew.py +0 -0
  101. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_key_restart_sdp.py +0 -0
  102. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_keyframe_prompter.py +0 -0
  103. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_lan_control.py +0 -0
  104. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_light_active_color_mode.py +0 -0
  105. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_live_stream_param.py +0 -0
  106. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_logging_caps.py +0 -0
  107. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_motion_poll.py +0 -0
  108. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_mqtt_credential_refetch.py +0 -0
  109. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_mqtt_credential_self_heal.py +0 -0
  110. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_mqtt_password_not_persisted.py +0 -0
  111. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_mqtt_session_reconnect.py +0 -0
  112. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_narrow_pc_ice.py +0 -0
  113. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_no_self_referential_go2rtc.py +0 -0
  114. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_no_undefined_names.py +0 -0
  115. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_offline_keepalive_pause.py +0 -0
  116. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_open_fail_logger.py +0 -0
  117. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_open_gate_delay.py +0 -0
  118. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_peerid_reuse.py +0 -0
  119. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_persistent_mqtt.py +0 -0
  120. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_persistent_mqtt_robustness.py +0 -0
  121. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_playback_tls.py +0 -0
  122. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_post_merge_hardening.py +0 -0
  123. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_retry_policy.py +0 -0
  124. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_sdes_adaptive.py +0 -0
  125. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_sdes_bridge_break_gate.py +0 -0
  126. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_sdes_echo_wait_timeout.py +0 -0
  127. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_sdes_fast_liveplay.py +0 -0
  128. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_sdes_ffmpeg_stderr_log.py +0 -0
  129. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_sdes_idle_release.py +0 -0
  130. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_sdes_open_cleanup.py +0 -0
  131. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_sdes_serve_audio.py +0 -0
  132. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_sdes_serve_cmd.py +0 -0
  133. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_sdes_sprop.py +0 -0
  134. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_sdes_talk.py +0 -0
  135. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_sdes_teardown_exit_log.py +0 -0
  136. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_sdes_terminal_ack.py +0 -0
  137. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_sdes_watchdog.py +0 -0
  138. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_sdp_payload_type_narrowing.py +0 -0
  139. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_security_hardening.py +0 -0
  140. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_serializable_login_info.py +0 -0
  141. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_serve_relay.py +0 -0
  142. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_session_stats.py +0 -0
  143. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_shared_house_seam.py +0 -0
  144. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_speak.py +0 -0
  145. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_stream_cap.py +0 -0
  146. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_stream_drain_ownership.py +0 -0
  147. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_stream_idle.py +0 -0
  148. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_stream_slot_cap.py +0 -0
  149. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_stream_teardown.py +0 -0
  150. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_talk.py +0 -0
  151. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_terminal_ack.py +0 -0
  152. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_token_file_robustness.py +0 -0
  153. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_token_refresh.py +0 -0
  154. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_turn_create_permission.py +0 -0
  155. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_upstream_compat.py +0 -0
  156. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_viewer_check_throttle.py +0 -0
  157. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_viewer_presence.py +0 -0
  158. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/tests/test_wait_or_event.py +0 -0
  159. {python_aidot_cameras-0.13.0 → python_aidot_cameras-0.13.2}/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.2
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
@@ -956,15 +960,27 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
956
960
  for _key in ("Battery_remaining", "batteryRemaining", "batteryLevel"):
957
961
  if _as_int(_props.get(_key)) is not None:
958
962
  return True
959
- # The low-power flag: batteryMode=2 is what a camera that sleeps between
960
- # sessions reports (see _async_send_camera_command). Deliberately NOT
961
- # keyed on ``powerType``: that name is also the value we SEND in
962
- # livePlayReq, we have never observed the cloud returning it as a device
963
- # property, and p2pCache - the field right next to it - reads 2 on every
964
- # camera including mains ones. Reading it here would risk classifying a
965
- # mains camera as battery, which would put powerType=2 on ITS wire payload
966
- # and can leave an A000088 un-armed. Evidence has to be unambiguous.
967
- if _as_int(_props.get("batteryMode")) == 2:
963
+ # The low-power flag, but ONLY when corroborated. ``batteryMode=2`` on
964
+ # its own is not evidence: measured against the live cloud payload, every
965
+ # MAINS A000088 on this fleet reports ``batteryMode: '2'`` and nothing
966
+ # else battery-related, while a real battery model reports it alongside
967
+ # actual telemetry (``Battery_remaining``, ``lowPowerStatus``,
968
+ # ``charging``). Trusting it alone classified four mains cameras as
969
+ # battery, which put powerType=2 on their wire payload and gave them the
970
+ # battery idle window instead of the mains warm-hold.
971
+ #
972
+ # This is the same trap the paragraph below describes for ``powerType`` /
973
+ # ``p2pCache`` - a field that reads 2 on every camera - so it gets the
974
+ # same treatment rather than being taken at face value. Corroboration is
975
+ # kept (instead of dropping the flag) so a future battery model that
976
+ # names its level differently is still caught by the flag plus any one
977
+ # battery-only field.
978
+ _BATTERY_TELEMETRY = (
979
+ "Battery_remaining", "batteryRemaining", "batteryLevel",
980
+ "lowPowerStatus", "charging",
981
+ )
982
+ if (_as_int(_props.get("batteryMode")) == 2
983
+ and any(_props.get(k) is not None for k in _BATTERY_TELEMETRY)):
968
984
  return True
969
985
  # Already-parsed status, for a camera refreshed from an attribute push
970
986
  # rather than from a device-list dict.
@@ -2789,6 +2805,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2789
2805
  fast_connect: Optional[bool] = None,
2790
2806
  sdes_audio: Optional[bool] = None,
2791
2807
  go2rtc_url: Optional[str] = None,
2808
+ go2rtc_register: bool = True,
2792
2809
  live_stream_param: Optional[bool] = None,
2793
2810
  serve_relay: Optional[bool] = None,
2794
2811
  stream_idle_s: Optional[float] = None,
@@ -2828,6 +2845,22 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2828
2845
  ``AIDOT_SERVE_RELAY`` env var (default on); set False to serve ffmpeg
2829
2846
  directly on the public port (the pre-0.7.18 behaviour).
2830
2847
 
2848
+ ``go2rtc_url`` points at a go2rtc API base. It does two things: register
2849
+ the local serve with go2rtc (exposing its low-latency pull URL), and let
2850
+ the idle-release logic ask go2rtc **who is watching**.
2851
+
2852
+ ``go2rtc_register=False`` keeps the second and drops the first, for a
2853
+ consumer that registers the stream itself - registering it twice
2854
+ re-points the source mid-stream and was observed leaving DTLS cameras
2855
+ producing nothing at all. Such a consumer must still pass ``go2rtc_url``:
2856
+ withholding it does not merely lose a nicety, it silently disables viewer
2857
+ detection, and in SDES **push** mode that is the only viewer signal there
2858
+ is (the serve port is go2rtc's shared RTSP port, where every camera's own
2859
+ publisher is connected, so a socket check would report a viewer for every
2860
+ camera forever). With no signal ``_viewer_present`` answers "unknown",
2861
+ ``_idle_release_due`` correctly never releases on unknown, and a battery
2862
+ camera's keepalive renews forever against a camera nobody is watching.
2863
+
2831
2864
  ``stream_idle_s`` sets the no-viewer idle-release window in seconds,
2832
2865
  overriding the ``AIDOT_STREAM_IDLE_S`` env default (120). ``0`` (or
2833
2866
  negative) keeps the warm WebRTC session forever so re-views are instant
@@ -2860,6 +2893,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2860
2893
  return
2861
2894
  self._keepalive_rtsp_url = rtsp_push_url
2862
2895
  self._go2rtc_url = go2rtc_url
2896
+ self._go2rtc_manages_stream = bool(go2rtc_register)
2863
2897
  self._streaming_active = True
2864
2898
  # App parity: keep a battery camera awake for the whole view (mains cams
2865
2899
  # never sleep, so this is a no-op there). Single-instance; cancelled in
@@ -2874,7 +2908,18 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2874
2908
  # Prefer-go2rtc: if a go2rtc server is configured, register the local
2875
2909
  # serve with it once ready and expose its pull URL (ffmpeg fallback
2876
2910
  # otherwise). Opt-in: with no go2rtc_url, behaviour is unchanged.
2877
- if go2rtc_url:
2911
+ #
2912
+ # ``go2rtc_register=False`` stores the URL for QUERIES ONLY and skips
2913
+ # registration. That exists for a consumer that registers the stream
2914
+ # itself: registering it twice re-points the source while a stream is
2915
+ # mid-flight, which was observed leaving DTLS cameras producing nothing
2916
+ # at all. Such a consumer previously had to withhold go2rtc_url
2917
+ # entirely, which silently disabled viewer detection - and in SDES push
2918
+ # mode that means a battery camera can never idle-release, because
2919
+ # ``_viewer_present`` answers "unknown" and ``_idle_release_due``
2920
+ # (correctly) never releases on unknown. The keepalive then renews
2921
+ # forever against a camera nobody is watching.
2922
+ if go2rtc_url and go2rtc_register:
2878
2923
  self._go2rtc_task = asyncio.ensure_future(self._register_with_go2rtc())
2879
2924
 
2880
2925
  @property
@@ -2934,9 +2979,17 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2934
2979
  _LOGGER.debug("camera %s: swallowed exception in %s", getattr(self, "device_id", "?"), '_register_with_go2rtc', exc_info=True)
2935
2980
 
2936
2981
  async def _deregister_go2rtc(self) -> None:
2937
- """Remove this camera's stream from go2rtc (best-effort)."""
2982
+ """Remove this camera's stream from go2rtc (best-effort).
2983
+
2984
+ Only ever removes a registration this client created. With
2985
+ ``go2rtc_register=False`` the consumer owns the stream and merely lent us
2986
+ the URL to ask who is watching; deleting its stream on stop or
2987
+ idle-release would tear down the very thing it is serving from.
2988
+ """
2938
2989
  base = self._go2rtc_url
2939
2990
  self._go2rtc_pull_url = None
2991
+ if not self._go2rtc_manages_stream:
2992
+ return
2940
2993
  # Deliberately NOT gated on `pull`: go2rtc answers the register call with
2941
2994
  # 400 when it cannot immediately validate the source (a camera that has
2942
2995
  # not started producing yet), yet it still keeps the stream registered.
@@ -3099,22 +3152,21 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3099
3152
 
3100
3153
  name = self._go2rtc_stream_name()
3101
3154
  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
3155
+
3156
+ # In PUSH mode the "serve port" is go2rtc's SHARED RTSP port (8554),
3157
+ # where every camera's own publisher is connected - so the socket
3158
+ # fallback below is not merely unavailable, it is WRONG: it would report
3159
+ # a viewer for every camera forever and nothing would ever release.
3160
+ # Tracked separately from `base` because it must gate the fallback even
3161
+ # when go2rtc IS configured but the query fails.
3162
+ push_mode = bool(self._keepalive_rtsp_url) and not str(
3163
+ self._keepalive_rtsp_url).startswith("http")
3164
+
3165
+ # Ask go2rtc when we can. This is the only viewer signal that works in
3166
+ # push mode, which is why a consumer that registers its own stream
3167
+ # should still pass go2rtc_url with go2rtc_register=False rather than
3168
+ # withholding it (withholding it silently pins push-mode cameras to
3169
+ # "unknown", and unknown never releases).
3118
3170
  if base and name:
3119
3171
  try:
3120
3172
  import aiohttp
@@ -3128,6 +3180,18 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3128
3180
  return _answer
3129
3181
  except Exception as exc:
3130
3182
  _LOGGER.debug("go2rtc viewer check failed: %s", exc)
3183
+
3184
+ if push_mode:
3185
+ # Answer "unknown" instead of a confident lie - the caller then holds
3186
+ # the stream rather than tearing down a live view.
3187
+ _LOGGER.debug(
3188
+ "cannot tell whether anyone is watching: the serve is an RTSP "
3189
+ "push to a shared port and go2rtc %s",
3190
+ "did not answer" if base else "was not configured",
3191
+ )
3192
+ self._viewer_cache = (_now_mono, None)
3193
+ return None
3194
+
3131
3195
  _answer = self._sdes_serve_consumer_present(port)
3132
3196
  self._viewer_cache = (_now_mono, _answer)
3133
3197
  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.2"
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.2
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
@@ -71,6 +71,7 @@ tests/test_aioice_compat.py
71
71
  tests/test_alarm_event.py
72
72
  tests/test_backoff.py
73
73
  tests/test_battery_detection.py
74
+ tests/test_battery_evidence_corroboration.py
74
75
  tests/test_battery_skip_turn.py
75
76
  tests/test_constants_imports_resolve.py
76
77
  tests/test_deferred_hardening.py
@@ -151,5 +152,6 @@ tests/test_turn_create_permission.py
151
152
  tests/test_upstream_compat.py
152
153
  tests/test_viewer_check_throttle.py
153
154
  tests/test_viewer_presence.py
155
+ tests/test_viewer_present_push_mode.py
154
156
  tests/test_wait_or_event.py
155
157
  tests/test_wake_readiness_retry.py
@@ -91,9 +91,36 @@ def test_numeric_string_battery_level_counts():
91
91
  assert _cam("LK.IPC.A009999", props={"Battery_remaining": "38"}).is_battery_camera
92
92
 
93
93
 
94
- def test_battery_mode_two_counts():
95
- assert _cam("LK.IPC.A009999", props={"batteryMode": 2}).is_battery_camera is True
96
- assert _cam("LK.IPC.A009999", props={"batteryMode": "2"}).is_battery_camera is True
94
+ def test_battery_mode_two_alone_is_not_evidence():
95
+ """REVISED 2026-08-02 against the live cloud payload.
96
+
97
+ This test used to assert that ``batteryMode=2`` alone proves a battery, on
98
+ the reasonable reading of the field's name. The account's actual device list
99
+ disproves it: every MAINS A000088 reports ``batteryMode: '2'`` and no other
100
+ battery field, while real battery models report it alongside real telemetry
101
+ (``Battery_remaining``, ``lowPowerStatus``, ``charging``).
102
+
103
+ Trusting it alone classified four mains cameras as battery, which put
104
+ powerType=2 on their wire payload and gave them the battery idle window
105
+ instead of the mains warm-hold - the same trap
106
+ ``test_power_type_property_is_deliberately_not_evidence`` guards for
107
+ ``powerType``/``p2pCache``, which also read 2 on every camera.
108
+ """
109
+ for value in (2, "2"):
110
+ assert _cam("LK.IPC.A009999",
111
+ props={"batteryMode": value}).is_battery_camera is False
112
+
113
+
114
+ def test_battery_mode_two_counts_when_corroborated():
115
+ """The flag is still a signal - it just needs one real battery field.
116
+
117
+ Kept (rather than dropping the flag) so a battery model whose level field we
118
+ have not seen is still caught, provided it reports any battery-only field.
119
+ """
120
+ for extra in ({"charging": "0"}, {"lowPowerStatus": "0"},
121
+ {"Battery_remaining": "100"}):
122
+ assert _cam("LK.IPC.A009999",
123
+ props={"batteryMode": "2", **extra}).is_battery_camera is True
97
124
 
98
125
 
99
126
  def test_battery_mode_one_is_not_evidence():
@@ -0,0 +1,139 @@
1
+ """``batteryMode=2`` alone must not make a mains camera look like a battery one.
2
+
3
+ Every property payload below is the REAL one from the live cloud account
4
+ (2026-08-02), not a construction. That matters: the bug was believing a field
5
+ meant what its name suggests, and only the actual payload disproves it.
6
+
7
+ A001513 (real battery): batteryMode=2, Battery_remaining, lowPowerStatus, charging
8
+ A000088 (MAINS): batteryMode=2 <- and nothing else
9
+ A001064 (mains PTZ): {}
10
+
11
+ Misclassification is costly in BOTH directions, which is why the corroboration
12
+ rule is "flag AND telemetry" rather than dropping the flag or trusting it:
13
+
14
+ * battery read as mains loses the SDES TURN pre-allocation (a cloud-woken
15
+ battery camera has no LAN host candidate, so the relay is its ONLY return
16
+ path), the keep-alive renew and the HTTP wake;
17
+ * mains read as battery puts powerType=2 on its wire payload and hands it the
18
+ battery idle window instead of the mains warm-hold - which is what shipped.
19
+ """
20
+
21
+ import types
22
+
23
+ import pytest
24
+
25
+ from aidot_cameras.camera.client import CameraMixin
26
+
27
+ # --- real payloads, verbatim from the cloud device list --------------------- #
28
+ L2_BATTERY = {"batteryMode": "2", "lowPowerStatus": "0",
29
+ "Battery_remaining": "100", "charging": "0"}
30
+ M3_PRO_MAINS = {"batteryMode": "2"}
31
+ PTZ_MAINS: dict = {}
32
+
33
+
34
+ class _Cam:
35
+ """The real classification code on a minimal object.
36
+
37
+ The methods and properties are taken straight off CameraMixin rather than
38
+ reimplemented, so these tests exercise shipping logic; only the three
39
+ attributes it reads are stubbed.
40
+ """
41
+
42
+ _BATTERY_MODELS = CameraMixin._BATTERY_MODELS
43
+ _battery_evidence = CameraMixin._battery_evidence
44
+ is_battery_camera = CameraMixin.is_battery_camera
45
+ live_power_type = CameraMixin.live_power_type
46
+
47
+ def __init__(self, props, model="LK.IPC.A000088"):
48
+ self._raw_device = {"properties": props}
49
+ self.info = types.SimpleNamespace(model_id=model)
50
+ self.status = None
51
+
52
+
53
+ def _cam(props, model="LK.IPC.A000088"):
54
+ return _Cam(props, model)
55
+
56
+
57
+ # --------------------------------------------------------------------------- #
58
+ # the regression
59
+ # --------------------------------------------------------------------------- #
60
+
61
+ @pytest.mark.parametrize("name", ["M3 Pro_180", "Family Room Cam",
62
+ "Bedroom M3 Pro", "Deck"])
63
+ def test_mains_a000088_reporting_batterymode_is_not_a_battery_camera(name):
64
+ """The shipped bug: four mains cameras classified as battery.
65
+
66
+ They report `batteryMode: '2'` and no other battery field. Trusting the flag
67
+ alone gave them powerType=2 and the battery idle window.
68
+ """
69
+ cam = _cam(M3_PRO_MAINS, model="LK.IPC.A000088")
70
+ assert cam._battery_evidence() is None, name
71
+ assert cam.is_battery_camera is False, name
72
+
73
+
74
+ def test_mains_ptz_with_no_battery_fields_stays_mains():
75
+ cam = _cam(PTZ_MAINS, model="LK.IPC.A001064")
76
+ assert cam.is_battery_camera is False
77
+
78
+
79
+ # --------------------------------------------------------------------------- #
80
+ # the thing that must NOT regress
81
+ # --------------------------------------------------------------------------- #
82
+
83
+ def test_a_real_battery_camera_is_still_detected():
84
+ """A001513 must stay battery - losing this costs it the TURN pre-alloc."""
85
+ cam = _cam(L2_BATTERY, model="LK.IPC.A001513")
86
+ assert cam._battery_evidence() is True
87
+ assert cam.is_battery_camera is True
88
+
89
+
90
+ def test_battery_detection_does_not_lean_on_the_model_list_alone():
91
+ """Evidence must carry it, so an unlisted battery revision still works."""
92
+ cam = _cam(L2_BATTERY, model="LK.IPC.A999999-2")
93
+ assert cam.is_battery_camera is True
94
+
95
+
96
+ def test_a_listed_model_with_no_properties_is_still_battery():
97
+ """The model list is the backstop when the payload carries nothing."""
98
+ cam = _cam({}, model="LK.IPC.A001513")
99
+ assert cam._battery_evidence() is None
100
+ assert cam.is_battery_camera is True
101
+
102
+
103
+ # --------------------------------------------------------------------------- #
104
+ # the corroboration rule itself
105
+ # --------------------------------------------------------------------------- #
106
+
107
+ @pytest.mark.parametrize("corroborating", [
108
+ {"Battery_remaining": "55"},
109
+ {"batteryRemaining": "55"},
110
+ {"batteryLevel": "55"},
111
+ {"lowPowerStatus": "0"},
112
+ {"charging": "1"},
113
+ ])
114
+ def test_batterymode_counts_when_any_battery_field_corroborates(corroborating):
115
+ """The flag is kept as a signal, not discarded - it just needs backing.
116
+
117
+ This is what catches a future battery model whose level field we have not
118
+ seen, provided it reports any battery-only field at all.
119
+ """
120
+ cam = _cam({"batteryMode": "2", **corroborating}, model="LK.IPC.A000088")
121
+ assert cam._battery_evidence() is True
122
+
123
+
124
+ def test_a_zero_valued_battery_field_still_corroborates():
125
+ """`charging: '0'` is a report, not an absence - presence is the signal."""
126
+ cam = _cam({"batteryMode": "2", "charging": "0"}, model="LK.IPC.A000088")
127
+ assert cam._battery_evidence() is True
128
+
129
+
130
+ def test_evidence_never_returns_false_so_it_cannot_demote_a_known_model():
131
+ """It may only ADD to the battery set, never remove - preserved property."""
132
+ for props in (M3_PRO_MAINS, PTZ_MAINS, {"batteryMode": "1"}):
133
+ assert _cam(props)._battery_evidence() in (True, None)
134
+
135
+
136
+ def test_power_type_follows_the_classification():
137
+ """powerType on the wire must never disagree with the battery guards."""
138
+ assert _cam(M3_PRO_MAINS, model="LK.IPC.A000088").live_power_type == 1
139
+ assert _cam(L2_BATTERY, model="LK.IPC.A001513").live_power_type == 2
@@ -279,3 +279,58 @@ def test_media_seen_accepts_dtls_frames(lv):
279
279
  ok, evidence = lv._media_seen(object(), frames=42, out_path=None)
280
280
  assert ok
281
281
  assert evidence["frames"] == 42
282
+
283
+
284
+ # --------------------------------------------------------------------------- #
285
+ # absent-camera early exit
286
+ # --------------------------------------------------------------------------- #
287
+
288
+ def test_slotless_budget_is_smaller_than_the_dtls_budget(lv):
289
+ """The early exit must actually save attempts, or it is decoration."""
290
+ assert lv.SLOTLESS_MAX_ATTEMPTS < lv.ATTEMPTS_DTLS
291
+
292
+
293
+ def test_one_retry_is_still_allowed_for_a_single_missed_response(lv):
294
+ """A camera silent ONCE may just have missed a webrtcResp on a busy account.
295
+
296
+ Dropping to a single attempt would turn one unlucky timeout into a failed
297
+ release, so the budget must be >1.
298
+ """
299
+ assert lv.SLOTLESS_MAX_ATTEMPTS > 1
300
+
301
+
302
+ def test_only_the_no_session_verdict_is_treated_as_absent(lv):
303
+ """ERROR alone means no session was opened, so nothing was probabilistic.
304
+
305
+ NO_MEDIA and BUSY DID reach the camera - those are exactly the flaky cases
306
+ the retry budget exists for, and they must keep their full allowance.
307
+ """
308
+ assert lv._SLOTLESS_VERDICTS == frozenset({"ERROR"})
309
+ for reached_the_camera in ("PASS", "NO_MEDIA", "BUSY"):
310
+ assert reached_the_camera not in lv._SLOTLESS_VERDICTS
311
+
312
+
313
+ def test_a_camera_that_reached_us_once_keeps_its_full_budget(lv):
314
+ """The early exit requires EVERY attempt to be slotless.
315
+
316
+ A camera that returns NO_MEDIA then ERROR is flaky, not absent, and must not
317
+ be cut short - which is why the loop checks all() rather than the last one.
318
+ """
319
+ mixed = [{"verdict": "NO_MEDIA"}, {"verdict": "ERROR"}]
320
+ assert not all(a["verdict"] in lv._SLOTLESS_VERDICTS for a in mixed)
321
+
322
+ absent = [{"verdict": "ERROR"}, {"verdict": "ERROR"}]
323
+ assert all(a["verdict"] in lv._SLOTLESS_VERDICTS for a in absent)
324
+
325
+
326
+ def test_an_absent_camera_still_reaches_a_failing_verdict(lv, tmp_path):
327
+ """Skipping attempts must not soften the verdict - absent is still ERROR.
328
+
329
+ The saving is wall clock, not leniency: a required model with no working
330
+ camera must still fail the release.
331
+ """
332
+ report = {"cameras": [_cam(lv, "LK.IPC.A000088", "ERROR", name="Deck")]}
333
+ report["cameras"][0]["attempts_used"] = lv.SLOTLESS_MAX_ATTEMPTS
334
+ assert lv._summarize(report, _args(tmp_path)) == 1
335
+ assert report["verdict"] == "FAIL"
336
+ assert report["required_failed"] == ["Deck"]