python-aidot-cameras 1.0.0rc3__tar.gz → 1.0.0rc4__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 (281) hide show
  1. {python_aidot_cameras-1.0.0rc3/python_aidot_cameras.egg-info → python_aidot_cameras-1.0.0rc4}/PKG-INFO +1 -1
  2. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/camera/client.py +127 -4
  3. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/camera/controls.py +127 -0
  4. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/camera/sdes_open.py +66 -0
  5. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/pyproject.toml +1 -1
  6. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  7. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/python_aidot_cameras.egg-info/SOURCES.txt +3 -0
  8. python_aidot_cameras-1.0.0rc4/tests/test_camera_properties.py +106 -0
  9. python_aidot_cameras-1.0.0rc4/tests/test_publish_target_readiness.py +74 -0
  10. python_aidot_cameras-1.0.0rc4/tests/test_reboot_action.py +67 -0
  11. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/LICENSE +0 -0
  12. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/MANIFEST.in +0 -0
  13. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/README.md +0 -0
  14. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/__init__.py +0 -0
  15. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/__main__.py +0 -0
  16. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/_upstream.py +0 -0
  17. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/_vendor/__init__.py +0 -0
  18. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/_vendor/aiortc/LICENSE +0 -0
  19. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/_vendor/aiortc/__init__.py +0 -0
  20. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/_vendor/aiortc/clock.py +0 -0
  21. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/_vendor/aiortc/codecs/__init__.py +0 -0
  22. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/_vendor/aiortc/codecs/base.py +0 -0
  23. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/_vendor/aiortc/codecs/g711.py +0 -0
  24. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/_vendor/aiortc/codecs/g722.py +0 -0
  25. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/_vendor/aiortc/codecs/h264.py +0 -0
  26. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/_vendor/aiortc/codecs/opus.py +0 -0
  27. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/_vendor/aiortc/codecs/vpx.py +0 -0
  28. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/_vendor/aiortc/contrib/__init__.py +0 -0
  29. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/_vendor/aiortc/contrib/media.py +0 -0
  30. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/_vendor/aiortc/contrib/signaling.py +0 -0
  31. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/_vendor/aiortc/events.py +0 -0
  32. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/_vendor/aiortc/exceptions.py +0 -0
  33. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/_vendor/aiortc/jitterbuffer.py +0 -0
  34. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/_vendor/aiortc/mediastreams.py +0 -0
  35. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/_vendor/aiortc/rate.py +0 -0
  36. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/_vendor/aiortc/rtcconfiguration.py +0 -0
  37. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/_vendor/aiortc/rtcdatachannel.py +0 -0
  38. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/_vendor/aiortc/rtcdtlstransport.py +0 -0
  39. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/_vendor/aiortc/rtcicetransport.py +0 -0
  40. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/_vendor/aiortc/rtcpeerconnection.py +0 -0
  41. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/_vendor/aiortc/rtcrtpparameters.py +0 -0
  42. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/_vendor/aiortc/rtcrtpreceiver.py +0 -0
  43. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/_vendor/aiortc/rtcrtpsender.py +0 -0
  44. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/_vendor/aiortc/rtcrtptransceiver.py +0 -0
  45. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/_vendor/aiortc/rtcsctptransport.py +0 -0
  46. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/_vendor/aiortc/rtcsessiondescription.py +0 -0
  47. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/_vendor/aiortc/rtp.py +0 -0
  48. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/_vendor/aiortc/sdp.py +0 -0
  49. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/_vendor/aiortc/stats.py +0 -0
  50. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/_vendor/aiortc/utils.py +0 -0
  51. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/ble_gateway.py +0 -0
  52. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/camera/__init__.py +0 -0
  53. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/camera/constants.py +0 -0
  54. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/camera/go2rtc.py +0 -0
  55. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/camera/hwaccel.py +0 -0
  56. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/camera/lan_control.py +0 -0
  57. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/camera/models.py +0 -0
  58. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/camera/playback.py +0 -0
  59. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/camera/protocol.py +0 -0
  60. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/camera/sd_events.py +0 -0
  61. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/camera/sd_listing.py +0 -0
  62. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/camera/sd_playback.py +0 -0
  63. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/camera/sdes.py +0 -0
  64. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/camera/tutk.py +0 -0
  65. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/camera/webrtc.py +0 -0
  66. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/camera/webrtc_open.py +0 -0
  67. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/client.py +0 -0
  68. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/cloud_auth.py +0 -0
  69. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/const.py +0 -0
  70. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/credentials.py +0 -0
  71. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/crypto.py +0 -0
  72. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/device_client.py +0 -0
  73. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/discover.py +0 -0
  74. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/exceptions.py +0 -0
  75. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/g711.py +0 -0
  76. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/lan_retry.py +0 -0
  77. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/aidot_cameras/py.typed +0 -0
  78. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  79. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
  80. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/python_aidot_cameras.egg-info/requires.txt +0 -0
  81. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/python_aidot_cameras.egg-info/top_level.txt +0 -0
  82. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/setup.cfg +0 -0
  83. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_aioice_compat.py +0 -0
  84. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_alarm_event.py +0 -0
  85. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_answer_pt_kinds.py +0 -0
  86. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_answer_sdp_video_pt.py +0 -0
  87. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_answer_section_selection.py +0 -0
  88. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_audio_mux_offline.py +0 -0
  89. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_avio_ctrl_uses_the_session_dseq.py +0 -0
  90. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_avio_request.py +0 -0
  91. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_avio_response_parse.py +0 -0
  92. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_avio_response_router.py +0 -0
  93. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_backoff.py +0 -0
  94. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_battery_detection.py +0 -0
  95. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_battery_evidence_corroboration.py +0 -0
  96. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_battery_skip_turn.py +0 -0
  97. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_bitrate_campaign_arms.py +0 -0
  98. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_ble_gateway.py +0 -0
  99. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_bridge_state_sentinels.py +0 -0
  100. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_busy_backoff.py +0 -0
  101. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_canary_log_names_the_camera.py +0 -0
  102. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_cloud_count.py +0 -0
  103. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_cloud_plan.py +0 -0
  104. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_constants_imports_resolve.py +0 -0
  105. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_control_verdicts.py +0 -0
  106. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_deferred_hardening.py +0 -0
  107. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_device_cmd_seq_match.py +0 -0
  108. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_device_login_guard.py +0 -0
  109. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_device_user_info_cache.py +0 -0
  110. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_direct_ts_keyframe_signal.py +0 -0
  111. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_direct_ts_serve.py +0 -0
  112. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_dormant_camera_cleanup.py +0 -0
  113. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_drain_is_cancellable.py +0 -0
  114. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_dtls_mux_terminal_write.py +0 -0
  115. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_dtls_not_ready_burst.py +0 -0
  116. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_dtls_pinning.py +0 -0
  117. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_dtls_resolution_ack.py +0 -0
  118. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_dtls_serve_decode_skip.py +0 -0
  119. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_dtls_serve_ice_wait.py +0 -0
  120. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_dtls_serve_open_timeout.py +0 -0
  121. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_dtls_skip_signaling_wait.py +0 -0
  122. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_dtls_slow_probe.py +0 -0
  123. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_dtls_stdout_producer.py +0 -0
  124. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_dtls_video_presence.py +0 -0
  125. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_egress_guard.py +0 -0
  126. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_env_seams.py +0 -0
  127. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_feature_probe_needs_a_live_session.py +0 -0
  128. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_ffmpeg_signal_exit_is_not_a_crash.py +0 -0
  129. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_futile_keepalive.py +0 -0
  130. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_gate_requires_a_decoded_frame.py +0 -0
  131. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_go2rtc.py +0 -0
  132. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_go2rtc_cli.py +0 -0
  133. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_h264_decode_rate_limit.py +0 -0
  134. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_highport_nomination.py +0 -0
  135. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_hwaccel_probe.py +0 -0
  136. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_ice_config_cache.py +0 -0
  137. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_ice_peer_reflexive.py +0 -0
  138. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_idle_release_both_loops.py +0 -0
  139. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_keepalive_renew.py +0 -0
  140. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_key_restart_sdp.py +0 -0
  141. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_keyframe_prompter.py +0 -0
  142. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_lan_control.py +0 -0
  143. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_lan_retry_reaches_the_storming_devices.py +0 -0
  144. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_late_repeats_are_dropped.py +0 -0
  145. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_light_active_color_mode.py +0 -0
  146. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_live_session_carries_talk.py +0 -0
  147. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_live_stream_param.py +0 -0
  148. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_live_validate_policy.py +0 -0
  149. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_live_video_canary.py +0 -0
  150. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_liveplay_echo_timeout.py +0 -0
  151. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_local_only_controls.py +0 -0
  152. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_logging_caps.py +0 -0
  153. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_login_connect_deadline.py +0 -0
  154. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_login_reconnect_backoff.py +0 -0
  155. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_media_counted_only_when_plaintext.py +0 -0
  156. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_media_path_is_reported.py +0 -0
  157. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_media_socket_rcvbuf.py +0 -0
  158. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_media_wait_floor.py +0 -0
  159. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_motion_poll.py +0 -0
  160. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_mqtt_client_id_is_exact.py +0 -0
  161. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_mqtt_credential_refetch.py +0 -0
  162. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_mqtt_credential_self_heal.py +0 -0
  163. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_mqtt_password_not_persisted.py +0 -0
  164. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_mqtt_session_reconnect.py +0 -0
  165. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_mqtt_thread_not_orphaned.py +0 -0
  166. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_nack.py +0 -0
  167. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_nack_cannot_add_load_in_a_burst.py +0 -0
  168. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_narrow_pc_ice.py +0 -0
  169. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_no_key_material_in_logs.py +0 -0
  170. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_no_self_referential_go2rtc.py +0 -0
  171. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_no_undefined_names.py +0 -0
  172. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_offer_bandwidth_line.py +0 -0
  173. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_offer_keeps_rtcp_feedback.py +0 -0
  174. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_offline_keepalive_pause.py +0 -0
  175. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_on_frame_is_an_av_frame.py +0 -0
  176. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_open_fail_logger.py +0 -0
  177. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_open_gate_delay.py +0 -0
  178. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_peer_id_client_type.py +0 -0
  179. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_peerid_reuse.py +0 -0
  180. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_persistent_mqtt.py +0 -0
  181. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_persistent_mqtt_robustness.py +0 -0
  182. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_playback_tls.py +0 -0
  183. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_post_merge_hardening.py +0 -0
  184. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_ptz_probe_reads_what_it_is_told.py +0 -0
  185. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_public_signature_matches_impl.py +0 -0
  186. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_quality_campaign_in_session.py +0 -0
  187. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_recent_recordings.py +0 -0
  188. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_remb.py +0 -0
  189. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_remb_goes_out_the_relay_aware_sender.py +0 -0
  190. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_resent_frames_are_dropped.py +0 -0
  191. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_resolution_persists.py +0 -0
  192. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_restart_cycle.py +0 -0
  193. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_retry_policy.py +0 -0
  194. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_scripts_importable.py +0 -0
  195. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_sctp_init_ack_tsn.py +0 -0
  196. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_sctp_reassembly.py +0 -0
  197. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_sctp_sack.py +0 -0
  198. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_sd_card_presence.py +0 -0
  199. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_sd_event_decode.py +0 -0
  200. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_sd_event_probe_payloads.py +0 -0
  201. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_sd_listing.py +0 -0
  202. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_sd_playback.py +0 -0
  203. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_sdes_adaptive.py +0 -0
  204. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_sdes_avio_dispatch.py +0 -0
  205. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_sdes_bridge_break_gate.py +0 -0
  206. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_sdes_connection_mode.py +0 -0
  207. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_sdes_echo_wait_timeout.py +0 -0
  208. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_sdes_fast_liveplay.py +0 -0
  209. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_sdes_ffmpeg_stderr_log.py +0 -0
  210. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_sdes_first_media_stall_report.py +0 -0
  211. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_sdes_ice_teardown.py +0 -0
  212. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_sdes_idle_release.py +0 -0
  213. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_sdes_nack_bridge.py +0 -0
  214. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_sdes_offer_video_order.py +0 -0
  215. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_sdes_offer_video_pin.py +0 -0
  216. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_sdes_open_cleanup.py +0 -0
  217. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_sdes_sctp_abort.py +0 -0
  218. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_sdes_serve_audio.py +0 -0
  219. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_sdes_serve_cmd.py +0 -0
  220. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_sdes_serve_stderr_drain.py +0 -0
  221. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_sdes_session_cap.py +0 -0
  222. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_sdes_sprop.py +0 -0
  223. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_sdes_talk.py +0 -0
  224. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_sdes_teardown_exit_log.py +0 -0
  225. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_sdes_terminal_ack.py +0 -0
  226. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_sdes_terminal_ack_latency.py +0 -0
  227. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_sdes_tmmbr_bridge.py +0 -0
  228. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_sdes_watchdog.py +0 -0
  229. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_sdp_narrowing_properties.py +0 -0
  230. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_sdp_payload_type_narrowing.py +0 -0
  231. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_security_hardening.py +0 -0
  232. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_self_peer_is_an_address_not_an_ip.py +0 -0
  233. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_serializable_login_info.py +0 -0
  234. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_serve_input_timeout.py +0 -0
  235. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_serve_loss_tolerance.py +0 -0
  236. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_serve_pt_respects_the_pin.py +0 -0
  237. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_serve_relay.py +0 -0
  238. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_serve_stderr_keeps_the_reason.py +0 -0
  239. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_session_stats.py +0 -0
  240. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_shared_house_seam.py +0 -0
  241. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_snapshot_cancellation.py +0 -0
  242. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_speak.py +0 -0
  243. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_sprop_capture_gate.py +0 -0
  244. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_sprop_staleness.py +0 -0
  245. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_sprop_unstable_public.py +0 -0
  246. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_sprop_write_is_reported_truthfully.py +0 -0
  247. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_srtp_rx_rekey.py +0 -0
  248. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_srtp_tx_key_note.py +0 -0
  249. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_stable_terminal_id.py +0 -0
  250. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_stall_nudge_policy.py +0 -0
  251. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_stall_report_says_what_the_answer_carried.py +0 -0
  252. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_stall_report_says_whether_the_trigger_was_answered.py +0 -0
  253. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_stall_report_separates_silence_from_undecryptable.py +0 -0
  254. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_stall_report_survives_a_caller_giving_up.py +0 -0
  255. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_stall_reports_reach_the_artifact.py +0 -0
  256. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_stream_cap.py +0 -0
  257. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_stream_drain_ownership.py +0 -0
  258. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_stream_idle.py +0 -0
  259. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_stream_slot_cap.py +0 -0
  260. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_stream_teardown.py +0 -0
  261. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_talk.py +0 -0
  262. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_talk_reports_a_speaker_that_never_opened.py +0 -0
  263. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_tap_to_mux_end_to_end.py +0 -0
  264. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_terminal_ack.py +0 -0
  265. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_tmmbr.py +0 -0
  266. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_token_file_robustness.py +0 -0
  267. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_token_refresh.py +0 -0
  268. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_turn_create_permission.py +0 -0
  269. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_tutk_path_is_out_of_reach.py +0 -0
  270. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_upstream_compat.py +0 -0
  271. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_validator_shares_one_token.py +0 -0
  272. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_video_profile_log.py +0 -0
  273. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_video_pts_dts.py +0 -0
  274. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_video_ts_unwrap.py +0 -0
  275. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_viewer_check_throttle.py +0 -0
  276. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_viewer_presence.py +0 -0
  277. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_viewer_present_push_mode.py +0 -0
  278. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_wait_or_event.py +0 -0
  279. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_wake_gate_camera_evidence.py +0 -0
  280. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_wake_readiness_retry.py +0 -0
  281. {python_aidot_cameras-1.0.0rc3 → python_aidot_cameras-1.0.0rc4}/tests/test_webrtc_answer_race.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-aidot-cameras
3
- Version: 1.0.0rc3
3
+ Version: 1.0.0rc4
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
@@ -2376,9 +2376,15 @@ class CameraMixin(_CameraControlsMixin, _CameraSdMixin, _WebRTCOpenMixin, _SdesO
2376
2376
  timeout: float = 8.0,
2377
2377
  ack_keyword: str = "setDevAttr",
2378
2378
  seq: "Optional[str]" = None,
2379
+ expect_ack: bool = True,
2379
2380
  ) -> bool:
2380
2381
  """Connect MQTT, optionally wake battery camera, publish command, wait for ack.
2381
2382
 
2383
+ ``expect_ack=False`` reports whether the command was PUBLISHED, not
2384
+ whether the camera answered. That is the honest result for an action
2385
+ whose success removes the camera from the network -- a reboot acks by
2386
+ going away, so waiting for one turns a working command into a False.
2387
+
2382
2388
  All setDevAttrReq callers in the APK (b6.java, LiveCameraView, etc.)
2383
2389
  are in live-view screens where the camera is already awake and MQTT-
2384
2390
  connected. For battery cameras we send lowPowerActiveStateReq first so
@@ -2454,6 +2460,17 @@ class CameraMixin(_CameraControlsMixin, _CameraSdMixin, _WebRTCOpenMixin, _SdesO
2454
2460
  duration=timeout,
2455
2461
  )
2456
2462
 
2463
+ # A fire-and-forget caller only needs to know the publish left the host.
2464
+ # `_st` separates that from "no ack came back", which the ack scan below
2465
+ # cannot: it returns False for a failed publish and for a camera that
2466
+ # rebooted before answering.
2467
+ if not expect_ack:
2468
+ _published = not (_st and _st.get("error"))
2469
+ _LOGGER.info(
2470
+ "_mqtt_device_cmd: published without waiting for an ack "
2471
+ "(topic=%s published=%s)", pub_topic, _published)
2472
+ return _published
2473
+
2457
2474
  # Two passes, because being sure is worth more than being quick. A
2458
2475
  # response carrying OUR seq is proof; anything else on these topics is
2459
2476
  # only evidence that SOMETHING was acked - and several things are in
@@ -2546,6 +2563,7 @@ class CameraMixin(_CameraControlsMixin, _CameraSdMixin, _WebRTCOpenMixin, _SdesO
2546
2563
  """
2547
2564
  import json as _json
2548
2565
  import random as _random
2566
+ import time as _time
2549
2567
 
2550
2568
  lan = getattr(self, "_lan_client", None)
2551
2569
  if lan is not None:
@@ -2567,11 +2585,17 @@ class CameraMixin(_CameraControlsMixin, _CameraSdMixin, _WebRTCOpenMixin, _SdesO
2567
2585
  inner["userId"] = str(user_id)
2568
2586
  if cam_pwd:
2569
2587
  inner["password"] = cam_pwd
2588
+ # `tst` matches the app, which stamps every setDevAttrReq and
2589
+ # devActionReq it builds. The camera accepts the message without it --
2590
+ # this path is proven and reads back within one poll -- so this is
2591
+ # parity rather than a fix; it costs nothing and removes one more way
2592
+ # our traffic differs from the vendor's.
2570
2593
  payload = _json.dumps({
2571
2594
  "id": device_id,
2572
2595
  "method": "setDevAttrReq",
2573
2596
  "service": "device",
2574
2597
  "seq": seq,
2598
+ "tst": int(_time.time() * 1000),
2575
2599
  "payload": inner,
2576
2600
  })
2577
2601
  pub_topic = f"iot/v1/c/{device_id}/device/setDevAttrReq"
@@ -2592,12 +2616,92 @@ class CameraMixin(_CameraControlsMixin, _CameraSdMixin, _WebRTCOpenMixin, _SdesO
2592
2616
  _LOGGER.debug("camera %s: swallowed exception in %s", getattr(self, "device_id", "?"), 'async_set_device_attribute', exc_info=True)
2593
2617
  return ok
2594
2618
 
2619
+ async def async_query_device_action(
2620
+ self,
2621
+ action: str,
2622
+ params=None,
2623
+ *,
2624
+ timeout: float = 8.0,
2625
+ ):
2626
+ """Send a read-only devActionReq and return the camera's ``out`` payload.
2627
+
2628
+ :meth:`async_trigger_device_action` answers "did it go", which is all a
2629
+ control needs. But the camera *answers* its ``get`` actions with real
2630
+ data -- wifi details, SD capacity, which sound algorithms are armed --
2631
+ and none of that was reachable while the only helper returned a bool.
2632
+
2633
+ Returns the decoded ``out`` exactly as the camera sent it (list or
2634
+ dict), or ``None`` when nothing came back. ``None`` means UNKNOWN, not
2635
+ "off": several of these actions answer with ``out: null`` on models that
2636
+ do not implement them, and a caller that reads that as a disabled
2637
+ feature will report a camera as having something switched off when it
2638
+ simply cannot say.
2639
+
2640
+ Deliberately built on the module-level session helper rather than on
2641
+ :meth:`_mqtt_device_cmd`: that method reduces a reply to a bool and is
2642
+ the proven control path, so it is left alone.
2643
+ """
2644
+ import json as _json
2645
+ import random as _random
2646
+ import time as _time
2647
+
2648
+ user_id = self.user_id
2649
+ device_id = self.device_id
2650
+ smarthome_auth = await self._async_get_smarthome_auth()
2651
+ mqtt_url = await self._async_get_mqtt_url()
2652
+ if not mqtt_url:
2653
+ _LOGGER.debug("async_query_device_action: no MQTT URL for %s", device_id)
2654
+ return None
2655
+ mqtt_user = (smarthome_auth or {}).get("mqttUser") or str(user_id)
2656
+ mqtt_pwd = (smarthome_auth or {}).get("mqttPassword") or ""
2657
+ client_id = (self._user_info.get("mqttClientId") or f"app-{mqtt_user}")
2658
+
2659
+ _dev = self._camera_properties() or {}
2660
+ parent_id = (getattr(getattr(self, "info", None), "direct_id", None)
2661
+ or _dev.get("directId") or _dev.get("parentId") or device_id)
2662
+ seq = f"ap{_random.randint(1000000, 9999999)}"
2663
+ payload = _json.dumps({
2664
+ "method": "devActionReq",
2665
+ "service": "device",
2666
+ "seq": seq,
2667
+ "tst": int(_time.time() * 1000),
2668
+ "payload": {
2669
+ "devId": device_id,
2670
+ "parentId": parent_id,
2671
+ "action": action,
2672
+ "in": params if params is not None else [],
2673
+ },
2674
+ })
2675
+ publish_items = [
2676
+ (f"iot/v1/s/{user_id}/device/devActionReq", payload),
2677
+ ]
2678
+ messages, _st = await _mqtt_session_with_status(
2679
+ mqtt_url, mqtt_user, mqtt_pwd, client_id,
2680
+ subscribe_topics=[f"iot/v1/c/{user_id}/#", f"iot/v1/cb/{user_id}/#"],
2681
+ publish_items=publish_items,
2682
+ duration=timeout,
2683
+ )
2684
+ for topic, raw in messages:
2685
+ if "devAction" not in topic:
2686
+ continue
2687
+ try:
2688
+ msg = _json.loads(raw)
2689
+ except Exception:
2690
+ continue
2691
+ body = msg.get("payload") or {}
2692
+ if body.get("action") != action:
2693
+ continue
2694
+ return body.get("out")
2695
+ _LOGGER.debug("devActionReq %s: no reply from %s", action, device_id)
2696
+ return None
2697
+
2595
2698
  async def async_trigger_device_action(
2596
2699
  self,
2597
2700
  action: str,
2598
2701
  params,
2599
2702
  *,
2600
2703
  timeout: float = 4.0,
2704
+ expect_ack: bool = True,
2601
2705
  ) -> bool:
2602
2706
  """Send a devActionReq (e.g. siren/alarm trigger).
2603
2707
 
@@ -2607,23 +2711,42 @@ class CameraMixin(_CameraControlsMixin, _CameraSdMixin, _WebRTCOpenMixin, _SdesO
2607
2711
  "payload": {"devId": deviceId, "action": action, "in": params}}
2608
2712
  """
2609
2713
  import json as _json
2714
+ import random as _random
2715
+ import time as _time
2610
2716
 
2611
2717
  user_id = self.user_id
2612
2718
  device_id = self.device_id
2613
2719
 
2720
+ # The app's own builder (main chunk, DeviceControl) sends five things we
2721
+ # did not: `seq`, `tst`, and a `parentId` alongside `devId`. The camera
2722
+ # answers nothing at all to a devActionReq without them -- a RebootFunc
2723
+ # sent in the short form drew zero replies and did not reboot, while the
2724
+ # same action with these fields is what the settings page uses.
2725
+ #
2726
+ # `password` is deliberately NOT sent: the H5 passes it to its wrapper,
2727
+ # but the wrapper does not put it on the wire, and a read-only
2728
+ # `WifiBaseInfo` acks without it.
2729
+ _dev = self._camera_properties() or {}
2730
+ parent_id = (getattr(getattr(self, "info", None), "direct_id", None)
2731
+ or _dev.get("directId") or _dev.get("parentId") or device_id)
2732
+ seq = f"ap{_random.randint(1000000, 9999999)}"
2614
2733
  payload = _json.dumps({
2615
2734
  "method": "devActionReq",
2616
2735
  "service": "device",
2736
+ "seq": seq,
2737
+ "tst": int(_time.time() * 1000),
2617
2738
  "payload": {
2618
- "devId": device_id,
2619
- "action": action,
2620
- "in": params,
2739
+ "devId": device_id,
2740
+ "parentId": parent_id,
2741
+ "action": action,
2742
+ "in": params,
2621
2743
  },
2622
2744
  })
2623
2745
  pub_topic = f"iot/v1/s/{user_id}/device/devActionReq"
2624
2746
  _LOGGER.info("devActionReq: %s %s -> %s", action, params, device_id)
2625
2747
  return await self._mqtt_device_cmd(
2626
- pub_topic, payload, timeout=timeout, ack_keyword="devAction")
2748
+ pub_topic, payload, timeout=timeout, ack_keyword="devAction",
2749
+ seq=seq, expect_ack=expect_ack)
2627
2750
 
2628
2751
  # Convenience wrappers - confirmed attribute names/types from APK source
2629
2752
 
@@ -9,6 +9,8 @@ mixin must be combined into CameraMixin (it is not usable standalone).
9
9
  import logging
10
10
  import struct
11
11
 
12
+ from typing import Optional
13
+
12
14
  from .constants import _PTZ_DIR_CODES, _STREAM_QUALITY, SETSTREAMCTRL_CMD
13
15
 
14
16
  _LOGGER = logging.getLogger(__name__)
@@ -87,6 +89,131 @@ class _CameraControlsMixin:
87
89
  self.status.siren = on
88
90
  return result
89
91
 
92
+ #: Sound-detection algorithms, in the order the camera reports them. The
93
+ #: camera answers ``soundAlgorithmGet`` with a list of single-key dicts --
94
+ #: ``[{"sound_enable": 0}, {"glass_Break": 0}, {"smoke_T3": 0}, ...]`` --
95
+ #: so the key names come from the camera rather than from us. The vendor
96
+ #: app's own strings name baby_cry and dog_bark alongside these.
97
+ #: ``sound_enable`` is the master switch; the rest are individual detectors.
98
+ SOUND_MASTER_KEY = "sound_enable"
99
+
100
+ async def async_get_sound_detection(self) -> "Optional[dict]":
101
+ """Which sound detectors are armed, as ``{key: bool}``.
102
+
103
+ ``None`` means the camera did not answer -- unknown, NOT all-off. A
104
+ model that does not implement this replies with a null ``out``, and
105
+ reporting that as a set of disabled switches would invent a state the
106
+ camera never claimed.
107
+ """
108
+ out = await self.async_query_device_action("soundAlgorithmGet")
109
+ if not isinstance(out, list):
110
+ return None
111
+ flags: dict = {}
112
+ for item in out:
113
+ if isinstance(item, dict):
114
+ for key, value in item.items():
115
+ flags[key] = bool(value)
116
+ return flags or None
117
+
118
+ async def async_set_sound_detection(self, key: str, enabled: bool) -> bool:
119
+ """Arm or disarm one sound detector.
120
+
121
+ Read-modify-write: the camera is asked for the current list and gets
122
+ the same structure back with one flag changed. The alternative is
123
+ inventing a payload schema for ``soundAlgorithmSet``, and the camera
124
+ reports the shape it wants -- so echo it rather than guess it.
125
+ """
126
+ out = await self.async_query_device_action("soundAlgorithmGet")
127
+ if not isinstance(out, list):
128
+ _LOGGER.warning(
129
+ "sound detection: %s did not report its algorithms; not writing",
130
+ self.device_id)
131
+ return False
132
+ found = False
133
+ payload = []
134
+ for item in out:
135
+ if isinstance(item, dict) and key in item:
136
+ payload.append({key: 1 if enabled else 0})
137
+ found = True
138
+ else:
139
+ payload.append(item)
140
+ if not found:
141
+ _LOGGER.warning("sound detection: %s does not report %r", self.device_id, key)
142
+ return False
143
+ return await self.async_trigger_device_action("soundAlgorithmSet", payload)
144
+
145
+ async def async_get_wifi_info(self) -> "Optional[dict]":
146
+ """``{"ssid": str, "rssi": int}`` for the camera's own WiFi link.
147
+
148
+ The camera answers ``WifiBaseInfo`` with a positional list whose first
149
+ two entries are the SSID and signal strength. Only those two are named
150
+ here -- the third is an integer that looks like an encoded address, and
151
+ naming a field we have not confirmed is how a guess becomes a fact.
152
+ """
153
+ out = await self.async_query_device_action("WifiBaseInfo")
154
+ if not isinstance(out, list) or not out:
155
+ return None
156
+ info: dict = {"ssid": out[0] if isinstance(out[0], str) else None}
157
+ if len(out) > 1 and isinstance(out[1], (int, float)):
158
+ info["rssi"] = int(out[1])
159
+ return info
160
+
161
+ async def async_get_sd_card_info(self) -> "Optional[dict]":
162
+ """``{"present": bool, "total": int, "used": int, "raw": list}``.
163
+
164
+ ``SDcardBaseInfo`` answers positionally: ``[False, 0, 0, 0, 0]`` with no
165
+ card, ``[True, 29838, 28848, 3, ...]`` on a nearly-full one.
166
+
167
+ Only the first three fields are named. The fourth was almost called
168
+ "free" -- but 29838 - 28848 is 990, not 3, so it is not a remainder;
169
+ 28848/29838 is 96.7%, which makes 3 look like a percentage. "Looks
170
+ like" is not confirmation, so it stays unnamed in ``raw`` rather than
171
+ shipping a field that reports 3 MB free on a 30 GB card.
172
+
173
+ Units for ``total`` and ``used`` are unconfirmed too (MB is plausible
174
+ at these magnitudes), so the numbers are passed through exactly as the
175
+ camera reports them rather than converted.
176
+ """
177
+ out = await self.async_query_device_action("SDcardBaseInfo")
178
+ if not isinstance(out, list) or not out:
179
+ return None
180
+ def _n(i):
181
+ return int(out[i]) if len(out) > i and isinstance(out[i], (int, float)) else None
182
+ return {"present": bool(out[0]), "total": _n(1), "used": _n(2),
183
+ "raw": list(out)}
184
+
185
+ async def async_reboot(self) -> bool:
186
+ """Ask the camera to reboot. True means the request was SENT.
187
+
188
+ The vendor app's camera settings page does exactly this -- its H5
189
+ bundle calls ``devActionReq({action: "RebootFunc", in: []})`` -- and
190
+ ``RebootFunc`` also appears in the native ``NewLivePresenter``. It is
191
+ the same ``devActionReq`` transport the siren already uses.
192
+
193
+ Two deliberate choices:
194
+
195
+ * **No ack is awaited.** A reboot succeeds by taking the camera off the
196
+ network, so a camera that reboots before answering would otherwise be
197
+ reported as a failure. The app passes no timeout for this action
198
+ either, unlike the reads beside it. So this reports "requested", and
199
+ the caller must not read it as "rebooted".
200
+ * **Refused when the cloud says the camera is offline.** The app only
201
+ renders its reboot button when the device is reachable
202
+ (``isCanUseStatus``), and a wake-then-reboot on a sleeping battery
203
+ camera is not something to fire blind.
204
+
205
+ The camera is gone for about a minute afterwards (the app's own wording
206
+ is "The device Needs a minute to reboot"), during which a viewer sees a
207
+ session that connects and delivers nothing.
208
+ """
209
+ if (not self.status.online) and getattr(self, "_cloud_online_explicit", False):
210
+ _LOGGER.warning(
211
+ "reboot refused for %s: the cloud reports it offline", self.device_id)
212
+ return False
213
+ _LOGGER.info("reboot requested for %s", self.device_id)
214
+ return await self.async_trigger_device_action(
215
+ "RebootFunc", [], expect_ack=False)
216
+
90
217
  async def async_set_night_vision(self, mode: str) -> bool:
91
218
  """mode: 'auto' (0), 'on' (1), 'off' (2)"""
92
219
  _modes = {"auto": 0, "on": 1, "off": 2}
@@ -108,6 +108,70 @@ def _sctp_reassemble(flags: int, stream_id: int, payload: bytes,
108
108
  return None
109
109
 
110
110
 
111
+ async def _await_rtsp_publish_target(url: "Optional[str]", *,
112
+ timeout: float = 20.0) -> bool:
113
+ """Wait until an ``rtsp://host:port`` target accepts a connection.
114
+
115
+ Publishing into go2rtc is a race after a Home Assistant restart: the serve
116
+ ffmpeg is launched as soon as media arrives, and go2rtc's RTSP listener may
117
+ not be back yet. ffmpeg then fails its very first connect with
118
+ ``Connection refused``, reports ``Could not write header (incorrect codec
119
+ parameters ?)``, and exits 145 -- which reads as "stream ended" and takes
120
+ that camera's view down until something retries.
121
+
122
+ Measured on a live box: every occurrence of that failure sat inside a
123
+ one-minute window around an HA restart (11:32, 11:43, 11:49, 11:52, 11:54,
124
+ 11:56, 11:57 on 2026-09-01, and the same shape the night before), and none
125
+ in steady state.
126
+
127
+ Returns True when the port accepts, False on timeout. False is not fatal --
128
+ the caller launches ffmpeg anyway, because a target that never opens is a
129
+ different problem and should surface as ffmpeg's own error rather than as a
130
+ silent refusal to start.
131
+ """
132
+ if not url or not url.startswith("rtsp://"):
133
+ return True
134
+ try:
135
+ rest = url[len("rtsp://"):]
136
+ hostport = rest.split("/", 1)[0]
137
+ if "@" in hostport:
138
+ hostport = hostport.rsplit("@", 1)[1]
139
+ if hostport.startswith("["): # IPv6 literal
140
+ if "]" not in hostport:
141
+ return True # malformed: do not block
142
+ host, _, tail = hostport[1:].partition("]")
143
+ port = int(tail.lstrip(":") or 554)
144
+ else:
145
+ host, _, port_s = hostport.partition(":")
146
+ port = int(port_s or 554)
147
+ except (ValueError, IndexError):
148
+ return True # unparseable: do not block
149
+
150
+ _deadline = time.monotonic() + timeout
151
+ _attempt = 0
152
+ while time.monotonic() < _deadline:
153
+ _attempt += 1
154
+ try:
155
+ _r, _w = await asyncio.wait_for(
156
+ asyncio.open_connection(host, port), timeout=3.0)
157
+ _w.close()
158
+ try:
159
+ await _w.wait_closed()
160
+ except Exception:
161
+ pass
162
+ if _attempt > 1:
163
+ _LOGGER.info(
164
+ "publish target %s:%s accepted after %d attempts",
165
+ host, port, _attempt)
166
+ return True
167
+ except (OSError, TimeoutError):
168
+ await asyncio.sleep(0.5)
169
+ _LOGGER.warning(
170
+ "publish target %s:%s did not accept a connection within %.0fs - "
171
+ "launching ffmpeg anyway so its own error surfaces", host, port, timeout)
172
+ return False
173
+
174
+
111
175
  def _sctp_sack_chunk(cum_tsn: int, a_rwnd: int = 131072) -> bytes:
112
176
  """An SCTP SACK (RFC 4960 s3.3.4) with no gap blocks and no duplicates.
113
177
 
@@ -6820,6 +6884,8 @@ class _SdesOpenMixin:
6820
6884
  if _keep_a is not None else ""))
6821
6885
  except Exception:
6822
6886
  _LOGGER.debug("camera %s: swallowed exception in %s", getattr(self, "device_id", "?"), '_open_sdes_stream', exc_info=True)
6887
+ # Do not launch the publisher into a listener that is not up yet.
6888
+ await _await_rtsp_publish_target(rtsp_push_url)
6823
6889
  _LOGGER.info("SDES ffmpeg cmd: %s", " ".join(cmd))
6824
6890
  if _ffmpeg_path() is None:
6825
6891
  # ffmpeg is not installed - clean up and surface a clear error
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-aidot-cameras"
7
- version = "1.0.0rc3"
7
+ version = "1.0.0rc4"
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: 1.0.0rc3
3
+ Version: 1.0.0rc4
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
@@ -93,6 +93,7 @@ tests/test_bitrate_campaign_arms.py
93
93
  tests/test_ble_gateway.py
94
94
  tests/test_bridge_state_sentinels.py
95
95
  tests/test_busy_backoff.py
96
+ tests/test_camera_properties.py
96
97
  tests/test_canary_log_names_the_camera.py
97
98
  tests/test_cloud_count.py
98
99
  tests/test_cloud_plan.py
@@ -178,7 +179,9 @@ tests/test_playback_tls.py
178
179
  tests/test_post_merge_hardening.py
179
180
  tests/test_ptz_probe_reads_what_it_is_told.py
180
181
  tests/test_public_signature_matches_impl.py
182
+ tests/test_publish_target_readiness.py
181
183
  tests/test_quality_campaign_in_session.py
184
+ tests/test_reboot_action.py
182
185
  tests/test_recent_recordings.py
183
186
  tests/test_remb.py
184
187
  tests/test_remb_goes_out_the_relay_aware_sender.py
@@ -0,0 +1,106 @@
1
+ """Sound detection, WiFi and SD-card properties read over devActionReq.
2
+
3
+ The camera answers these `get` actions with real data. The behaviour worth
4
+ pinning is what happens when it does NOT: a camera that cannot answer must read
5
+ as unknown, never as a set of switched-off features.
6
+ """
7
+ import asyncio
8
+
9
+ from aidot_cameras.camera.controls import _CameraControlsMixin
10
+
11
+
12
+ class _Cam:
13
+ def __init__(self, out=None, set_ok=True):
14
+ self.device_id = "dev1"
15
+ self._out = out
16
+ self._set_ok = set_ok
17
+ self.queries = []
18
+ self.actions = []
19
+
20
+ async def async_query_device_action(self, action, params=None, *, timeout=8.0):
21
+ self.queries.append(action)
22
+ return self._out
23
+
24
+ async def async_trigger_device_action(self, action, params, *,
25
+ timeout=4.0, expect_ack=True):
26
+ self.actions.append((action, params))
27
+ return self._set_ok
28
+
29
+ async_get_sound_detection = _CameraControlsMixin.async_get_sound_detection
30
+ async_set_sound_detection = _CameraControlsMixin.async_set_sound_detection
31
+ async_get_wifi_info = _CameraControlsMixin.async_get_wifi_info
32
+ async_get_sd_card_info = _CameraControlsMixin.async_get_sd_card_info
33
+
34
+
35
+ _SOUND = [{"sound_enable": 0}, {"glass_Break": 1}, {"smoke_T3": 0}]
36
+
37
+
38
+ class TestSoundDetection:
39
+ def test_it_flattens_the_camera_s_list_of_single_key_dicts(self):
40
+ cam = _Cam(_SOUND)
41
+ assert asyncio.run(cam.async_get_sound_detection()) == {
42
+ "sound_enable": False, "glass_Break": True, "smoke_T3": False}
43
+
44
+ def test_no_reply_is_unknown_not_all_off(self):
45
+ """A null `out` must not be reported as a set of disabled detectors."""
46
+ assert asyncio.run(_Cam(None).async_get_sound_detection()) is None
47
+
48
+ def test_a_write_echoes_the_camera_s_own_structure(self):
49
+ cam = _Cam(_SOUND)
50
+ assert asyncio.run(cam.async_set_sound_detection("smoke_T3", True)) is True
51
+ action, payload = cam.actions[0]
52
+ assert action == "soundAlgorithmSet"
53
+ assert payload == [{"sound_enable": 0}, {"glass_Break": 1}, {"smoke_T3": 1}]
54
+
55
+ def test_it_leaves_the_other_flags_untouched(self):
56
+ cam = _Cam(_SOUND)
57
+ asyncio.run(cam.async_set_sound_detection("sound_enable", True))
58
+ assert cam.actions[0][1] == [
59
+ {"sound_enable": 1}, {"glass_Break": 1}, {"smoke_T3": 0}]
60
+
61
+ def test_it_refuses_to_write_a_key_the_camera_does_not_report(self):
62
+ cam = _Cam(_SOUND)
63
+ assert asyncio.run(cam.async_set_sound_detection("nope", True)) is False
64
+ assert cam.actions == []
65
+
66
+ def test_it_refuses_to_write_when_the_camera_did_not_answer(self):
67
+ """Without a current list there is nothing to echo - guessing a payload
68
+ here is how an unrelated detector gets switched off."""
69
+ cam = _Cam(None)
70
+ assert asyncio.run(cam.async_set_sound_detection("smoke_T3", True)) is False
71
+ assert cam.actions == []
72
+
73
+
74
+ class TestWifiInfo:
75
+ def test_it_names_only_the_confirmed_fields(self):
76
+ cam = _Cam(["Brightly", 63, 1984564238])
77
+ assert asyncio.run(cam.async_get_wifi_info()) == {
78
+ "ssid": "Brightly", "rssi": 63}
79
+
80
+ def test_no_reply_is_none(self):
81
+ assert asyncio.run(_Cam(None).async_get_wifi_info()) is None
82
+
83
+
84
+ class TestSdCardInfo:
85
+ def test_absent_card(self):
86
+ cam = _Cam([False, 0, 0, 0, 0])
87
+ got = asyncio.run(cam.async_get_sd_card_info())
88
+ assert got["present"] is False and got["total"] == 0 and got["used"] == 0
89
+
90
+ def test_present_card_passes_numbers_through_unconverted(self):
91
+ cam = _Cam([True, 29838, 28848, 3, 0])
92
+ got = asyncio.run(cam.async_get_sd_card_info())
93
+ assert got["present"] is True
94
+ assert got["total"] == 29838 and got["used"] == 28848
95
+
96
+ def test_the_fourth_field_is_not_reported_as_free_space(self):
97
+ """Measured on a real camera: total 29838, used 28848, fourth field 3.
98
+ That is not a remainder (990), so naming it "free" would report 3 units
99
+ left on a nearly-full 30 GB card. It stays raw until confirmed."""
100
+ cam = _Cam([True, 29838, 28848, 3, 0])
101
+ got = asyncio.run(cam.async_get_sd_card_info())
102
+ assert "free" not in got
103
+ assert got["raw"] == [True, 29838, 28848, 3, 0]
104
+
105
+ def test_no_reply_is_none(self):
106
+ assert asyncio.run(_Cam(None).async_get_sd_card_info()) is None
@@ -0,0 +1,74 @@
1
+ """Waiting for the publish target before launching the serve ffmpeg.
2
+
3
+ After a Home Assistant restart the serve ffmpeg can be launched before go2rtc's
4
+ RTSP listener is back. ffmpeg's first connect is then refused, it exits 145, and
5
+ that reads as "stream ended" -- the camera's view is down until something
6
+ retries. Every observed occurrence on a live box sat within a minute of a
7
+ restart.
8
+ """
9
+ import asyncio
10
+ import socket
11
+
12
+ from aidot_cameras.camera.sdes_open import _await_rtsp_publish_target
13
+
14
+
15
+ def _free_port() -> int:
16
+ s = socket.socket()
17
+ s.bind(("127.0.0.1", 0))
18
+ port = s.getsockname()[1]
19
+ s.close()
20
+ return port
21
+
22
+
23
+ class TestItWaitsForTheTarget:
24
+ def test_an_open_port_returns_immediately(self):
25
+ srv = socket.socket()
26
+ srv.bind(("127.0.0.1", 0))
27
+ srv.listen(1)
28
+ port = srv.getsockname()[1]
29
+ try:
30
+ ok = asyncio.run(_await_rtsp_publish_target(
31
+ f"rtsp://127.0.0.1:{port}/aidot_x", timeout=5.0))
32
+ assert ok is True
33
+ finally:
34
+ srv.close()
35
+
36
+ def test_a_closed_port_times_out_but_does_not_raise(self):
37
+ """False, not an exception: the caller still launches ffmpeg so a target
38
+ that never opens surfaces as ffmpeg's own error, not a silent refusal."""
39
+ ok = asyncio.run(_await_rtsp_publish_target(
40
+ f"rtsp://127.0.0.1:{_free_port()}/aidot_x", timeout=1.0))
41
+ assert ok is False
42
+
43
+
44
+ class TestItNeverBlocksTheWrongThing:
45
+ def test_a_non_rtsp_url_is_not_probed(self):
46
+ assert asyncio.run(_await_rtsp_publish_target(
47
+ "http://127.0.0.1:1/x.ts", timeout=0.1)) is True
48
+
49
+ def test_none_is_not_probed(self):
50
+ assert asyncio.run(_await_rtsp_publish_target(None, timeout=0.1)) is True
51
+
52
+ def test_an_unparseable_url_does_not_block(self):
53
+ assert asyncio.run(_await_rtsp_publish_target(
54
+ "rtsp://[unclosed", timeout=0.1)) is True
55
+
56
+
57
+ class TestUrlForms:
58
+ def test_credentials_in_the_url_are_stripped_before_the_host(self):
59
+ srv = socket.socket()
60
+ srv.bind(("127.0.0.1", 0))
61
+ srv.listen(1)
62
+ port = srv.getsockname()[1]
63
+ try:
64
+ ok = asyncio.run(_await_rtsp_publish_target(
65
+ f"rtsp://user:pw@127.0.0.1:{port}/aidot_x", timeout=5.0))
66
+ assert ok is True
67
+ finally:
68
+ srv.close()
69
+
70
+ def test_a_missing_port_defaults_to_554(self):
71
+ # 554 is almost certainly closed here; the point is it parses and
72
+ # returns rather than raising.
73
+ assert asyncio.run(_await_rtsp_publish_target(
74
+ "rtsp://127.0.0.1/aidot_x", timeout=0.5)) in (True, False)