python-aidot-cameras 0.14.0__tar.gz → 0.15.0__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 (172) hide show
  1. {python_aidot_cameras-0.14.0/python_aidot_cameras.egg-info → python_aidot_cameras-0.15.0}/PKG-INFO +1 -1
  2. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/camera/client.py +32 -5
  3. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/camera/constants.py +52 -0
  4. python_aidot_cameras-0.15.0/aidot_cameras/camera/hwaccel.py +447 -0
  5. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/camera/protocol.py +48 -1
  6. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/camera/sdes.py +31 -0
  7. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/camera/sdes_open.py +171 -15
  8. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/camera/webrtc_open.py +35 -9
  9. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/client.py +6 -2
  10. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/pyproject.toml +16 -1
  11. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  12. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/python_aidot_cameras.egg-info/SOURCES.txt +10 -0
  13. python_aidot_cameras-0.15.0/tests/test_audio_mux_offline.py +147 -0
  14. python_aidot_cameras-0.15.0/tests/test_env_seams.py +156 -0
  15. python_aidot_cameras-0.15.0/tests/test_hwaccel_probe.py +303 -0
  16. python_aidot_cameras-0.15.0/tests/test_media_wait_floor.py +38 -0
  17. python_aidot_cameras-0.15.0/tests/test_restart_cycle.py +103 -0
  18. python_aidot_cameras-0.15.0/tests/test_scripts_importable.py +50 -0
  19. python_aidot_cameras-0.15.0/tests/test_sdes_terminal_ack_latency.py +138 -0
  20. python_aidot_cameras-0.15.0/tests/test_sdp_narrowing_properties.py +133 -0
  21. python_aidot_cameras-0.15.0/tests/test_sprop_staleness.py +83 -0
  22. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/LICENSE +0 -0
  23. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/MANIFEST.in +0 -0
  24. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/README.md +0 -0
  25. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/__init__.py +0 -0
  26. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/__main__.py +0 -0
  27. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_upstream.py +0 -0
  28. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/__init__.py +0 -0
  29. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/__init__.py +0 -0
  30. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/clock.py +0 -0
  31. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/codecs/__init__.py +0 -0
  32. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/codecs/base.py +0 -0
  33. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/codecs/g711.py +0 -0
  34. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/codecs/g722.py +0 -0
  35. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/codecs/h264.py +0 -0
  36. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/codecs/opus.py +0 -0
  37. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/codecs/vpx.py +0 -0
  38. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/contrib/__init__.py +0 -0
  39. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/contrib/media.py +0 -0
  40. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/contrib/signaling.py +0 -0
  41. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/events.py +0 -0
  42. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/exceptions.py +0 -0
  43. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/jitterbuffer.py +0 -0
  44. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/mediastreams.py +0 -0
  45. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/rate.py +0 -0
  46. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/rtcconfiguration.py +0 -0
  47. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/rtcdatachannel.py +0 -0
  48. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/rtcdtlstransport.py +0 -0
  49. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/rtcicetransport.py +0 -0
  50. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/rtcpeerconnection.py +0 -0
  51. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/rtcrtpparameters.py +0 -0
  52. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/rtcrtpreceiver.py +0 -0
  53. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/rtcrtpsender.py +0 -0
  54. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/rtcrtptransceiver.py +0 -0
  55. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/rtcsctptransport.py +0 -0
  56. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/rtcsessiondescription.py +0 -0
  57. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/rtp.py +0 -0
  58. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/sdp.py +0 -0
  59. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/stats.py +0 -0
  60. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/utils.py +0 -0
  61. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/ble_gateway.py +0 -0
  62. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/camera/__init__.py +0 -0
  63. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/camera/controls.py +0 -0
  64. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/camera/go2rtc.py +0 -0
  65. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/camera/lan_control.py +0 -0
  66. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/camera/models.py +0 -0
  67. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/camera/playback.py +0 -0
  68. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/camera/tutk.py +0 -0
  69. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/camera/webrtc.py +0 -0
  70. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/const.py +0 -0
  71. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/credentials.py +0 -0
  72. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/crypto.py +0 -0
  73. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/device_client.py +0 -0
  74. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/discover.py +0 -0
  75. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/exceptions.py +0 -0
  76. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/g711.py +0 -0
  77. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/py.typed +0 -0
  78. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  79. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
  80. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/python_aidot_cameras.egg-info/requires.txt +0 -0
  81. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/python_aidot_cameras.egg-info/top_level.txt +0 -0
  82. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/setup.cfg +0 -0
  83. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_aioice_compat.py +0 -0
  84. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_alarm_event.py +0 -0
  85. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_answer_sdp_video_pt.py +0 -0
  86. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_backoff.py +0 -0
  87. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_battery_detection.py +0 -0
  88. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_battery_evidence_corroboration.py +0 -0
  89. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_battery_skip_turn.py +0 -0
  90. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_ble_gateway.py +0 -0
  91. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_constants_imports_resolve.py +0 -0
  92. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_deferred_hardening.py +0 -0
  93. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_device_login_guard.py +0 -0
  94. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_device_user_info_cache.py +0 -0
  95. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_dormant_camera_cleanup.py +0 -0
  96. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_dtls_mux_terminal_write.py +0 -0
  97. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_dtls_not_ready_burst.py +0 -0
  98. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_dtls_pinning.py +0 -0
  99. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_dtls_serve_decode_skip.py +0 -0
  100. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_dtls_serve_open_timeout.py +0 -0
  101. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_dtls_skip_signaling_wait.py +0 -0
  102. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_dtls_slow_probe.py +0 -0
  103. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_egress_guard.py +0 -0
  104. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_go2rtc.py +0 -0
  105. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_go2rtc_cli.py +0 -0
  106. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_h264_decode_rate_limit.py +0 -0
  107. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_highport_nomination.py +0 -0
  108. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_ice_config_cache.py +0 -0
  109. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_ice_peer_reflexive.py +0 -0
  110. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_idle_release_both_loops.py +0 -0
  111. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_keepalive_renew.py +0 -0
  112. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_key_restart_sdp.py +0 -0
  113. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_keyframe_prompter.py +0 -0
  114. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_lan_control.py +0 -0
  115. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_light_active_color_mode.py +0 -0
  116. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_live_stream_param.py +0 -0
  117. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_live_validate_policy.py +0 -0
  118. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_logging_caps.py +0 -0
  119. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_motion_poll.py +0 -0
  120. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_mqtt_credential_refetch.py +0 -0
  121. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_mqtt_credential_self_heal.py +0 -0
  122. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_mqtt_password_not_persisted.py +0 -0
  123. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_mqtt_session_reconnect.py +0 -0
  124. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_narrow_pc_ice.py +0 -0
  125. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_no_self_referential_go2rtc.py +0 -0
  126. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_no_undefined_names.py +0 -0
  127. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_offline_keepalive_pause.py +0 -0
  128. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_open_fail_logger.py +0 -0
  129. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_open_gate_delay.py +0 -0
  130. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_peerid_reuse.py +0 -0
  131. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_persistent_mqtt.py +0 -0
  132. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_persistent_mqtt_robustness.py +0 -0
  133. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_playback_tls.py +0 -0
  134. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_post_merge_hardening.py +0 -0
  135. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_retry_policy.py +0 -0
  136. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_sdes_adaptive.py +0 -0
  137. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_sdes_bridge_break_gate.py +0 -0
  138. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_sdes_echo_wait_timeout.py +0 -0
  139. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_sdes_fast_liveplay.py +0 -0
  140. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_sdes_ffmpeg_stderr_log.py +0 -0
  141. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_sdes_idle_release.py +0 -0
  142. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_sdes_open_cleanup.py +0 -0
  143. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_sdes_serve_audio.py +0 -0
  144. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_sdes_serve_cmd.py +0 -0
  145. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_sdes_sprop.py +0 -0
  146. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_sdes_talk.py +0 -0
  147. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_sdes_teardown_exit_log.py +0 -0
  148. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_sdes_terminal_ack.py +0 -0
  149. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_sdes_watchdog.py +0 -0
  150. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_sdp_payload_type_narrowing.py +0 -0
  151. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_security_hardening.py +0 -0
  152. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_serializable_login_info.py +0 -0
  153. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_serve_relay.py +0 -0
  154. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_session_stats.py +0 -0
  155. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_shared_house_seam.py +0 -0
  156. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_speak.py +0 -0
  157. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_stream_cap.py +0 -0
  158. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_stream_drain_ownership.py +0 -0
  159. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_stream_idle.py +0 -0
  160. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_stream_slot_cap.py +0 -0
  161. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_stream_teardown.py +0 -0
  162. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_talk.py +0 -0
  163. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_terminal_ack.py +0 -0
  164. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_token_file_robustness.py +0 -0
  165. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_token_refresh.py +0 -0
  166. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_turn_create_permission.py +0 -0
  167. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_upstream_compat.py +0 -0
  168. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_viewer_check_throttle.py +0 -0
  169. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_viewer_presence.py +0 -0
  170. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_viewer_present_push_mode.py +0 -0
  171. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_wait_or_event.py +0 -0
  172. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/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.14.0
3
+ Version: 0.15.0
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
@@ -22,9 +22,11 @@ from ..const import (
22
22
  from .constants import (
23
23
  _LEEDARSON_APP_ID,
24
24
  _LIVE_PLAY_NOT_READY,
25
- _SMARTHOME_URL_TEMPLATE,
25
+ _SMARTHOME_URL_TEMPLATE, # noqa: F401 - re-exported for back-compat; reads go through smarthome_base()
26
26
  GETSTREAMCTRL_CMD, # noqa: F401 - re-exported for back-compat (public pair; unused in-module)
27
27
  SDES_SPEAKERSTART_DELAY, # noqa: F401 - used by the sdes_open mixin; kept re-exported
28
+ aidot_api_base,
29
+ smarthome_base,
28
30
  )
29
31
  from .constants import ( # noqa: F401 - re-exported for device_client / back-compat
30
32
  TALK_PCM_FRAME_BYTES,
@@ -372,6 +374,7 @@ def _build_sdes_serve_cmd(
372
374
  sdes_audio: bool = False,
373
375
  audio_gain_db: float = -8.0,
374
376
  push_video_only: bool = False,
377
+ video_decoder: Optional[list] = None,
375
378
  ) -> list:
376
379
  """Build the ffmpeg argv for the SDES bridge serve.
377
380
 
@@ -445,10 +448,25 @@ def _build_sdes_serve_cmd(
445
448
  dest_args = ["-c", "copy", *time_args, output_path]
446
449
  else:
447
450
  dest_args = [*time_args, "-f", "null", "/dev/null"]
451
+
452
+ # Decoder selection applies to the ``-f null`` drain alone - it is the only
453
+ # destination that decodes; every other one is ``-c copy``. The caller
454
+ # passes ready-made input arguments, either ``["-hwaccel", m]`` or
455
+ # ``["-c:v", name]``, and they must sit BEFORE ``-i``: after ``-i`` ffmpeg
456
+ # reads ``-c:v`` as an ENCODER, and ``-hwaccel`` is an input option that has
457
+ # no meaning there at all. Empty or None means ffmpeg chooses, as always. Keep the decode: it is what proves frames are not merely
458
+ # arriving but decodable, which is what catches a parameter-set mismatch
459
+ # that otherwise presents as a permanently black picture.
460
+ decode_args = (
461
+ list(video_decoder)
462
+ if video_decoder and not rtsp_push_url and not output_path
463
+ else []
464
+ )
448
465
  return [
449
466
  "ffmpeg", "-y",
450
467
  "-loglevel", "warning",
451
468
  "-protocol_whitelist", "file,rtp,udp,srtp",
469
+ *decode_args,
452
470
  # 2 s analyzeduration: the camera sends SPS+PPS+IDR in the first burst;
453
471
  # 15 s consumed nearly all packets during analysis. PLI re-arms an IDR
454
472
  # every 5 s so parameters always re-arrive.
@@ -1156,7 +1174,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
1156
1174
 
1157
1175
  @property
1158
1176
  def _smarthome_base(self) -> str:
1159
- return _SMARTHOME_URL_TEMPLATE.format(region=self._region)
1177
+ return smarthome_base(self._region)
1160
1178
 
1161
1179
  def set_token_refresh_cb(self, cb: "Callable") -> None:
1162
1180
  """Register the AidotClient coroutine that refreshes the access token."""
@@ -1371,6 +1389,15 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
1371
1389
  if self._mqtt_url:
1372
1390
  return self._mqtt_url
1373
1391
 
1392
+ # Test seam: point the whole client at a local broker without any cloud
1393
+ # call (paho derives ws-vs-wss and the port from the scheme, so a plain
1394
+ # "ws://127.0.0.1:PORT/mqtt" needs nothing else). Unset in production.
1395
+ _env_mqtt = os.environ.get("AIDOT_MQTT_URL")
1396
+ if _env_mqtt:
1397
+ _LOGGER.debug("AIDOT_MQTT_URL override in effect: %s", _env_mqtt)
1398
+ self._mqtt_url = _env_mqtt
1399
+ return self._mqtt_url
1400
+
1374
1401
  import aiohttp
1375
1402
 
1376
1403
  async def _fetch():
@@ -1603,11 +1630,11 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
1603
1630
 
1604
1631
  @property
1605
1632
  def _aidot_v21_base(self) -> str:
1606
- return f"https://prod-{self._region}-api.arnoo.com/v21"
1633
+ return f"{aidot_api_base(self._region)}/v21"
1607
1634
 
1608
1635
  @property
1609
1636
  def _aidot_v32_base(self) -> str:
1610
- return f"https://prod-{self._region}-api.arnoo.com/v32/api/ipc"
1637
+ return f"{aidot_api_base(self._region)}/v32/api/ipc"
1611
1638
 
1612
1639
  def _aidot_headers(self) -> dict:
1613
1640
  # Auth headers for the AiDot platform API (prod-{region}-api.arnoo.com).
@@ -4847,7 +4874,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
4847
4874
  return None
4848
4875
 
4849
4876
  url = (
4850
- f"https://prod-{self._region}-api.arnoo.com"
4877
+ f"{aidot_api_base(self._region)}"
4851
4878
  f"/v29/api/webrtc/iceConfig?forceRefresh=0"
4852
4879
  )
4853
4880
 
@@ -4,6 +4,7 @@ Extracted verbatim from client.py (behavior-preserving). client.py
4
4
  re-imports these names, so the public/runtime surface is unchanged.
5
5
  """
6
6
 
7
+ import os
7
8
  import struct
8
9
 
9
10
  # AppKey from LDSAppOpenSDK CocoaPods docs (kLDSAppOpenSDKKey = "appa070")
@@ -17,6 +18,57 @@ _LEEDARSON_APP_ID = "1392315867093508098"
17
18
  # e.g. "us" -> "https://us-smarthome.arnoo.com:443"
18
19
  _SMARTHOME_URL_TEMPLATE = "https://{region}-smarthome.arnoo.com:443"
19
20
 
21
+ # AiDot platform API base (the prod-{region}-api.arnoo.com family).
22
+ _AIDOT_API_BASE_TEMPLATE = "https://prod-{region}-api.arnoo.com"
23
+
24
+ # --------------------------------------------------------------------------- #
25
+ # Test-environment seams.
26
+ #
27
+ # Every cloud endpoint the camera layer contacts routes through one of these
28
+ # helpers, so an end-to-end test can point the WHOLE client at a local fake
29
+ # environment via env vars (the same opt-in pattern as
30
+ # AIDOT_SDES_HOLEPUNCH_HOST). Read at call time, never cached at import, and
31
+ # defaulting to today's production URLs - unset means byte-identical behavior.
32
+ #
33
+ # These exist so the e2e tier CANNOT reach the real cloud: without them the
34
+ # fake-lab tests fall through to prod-{region}-api.arnoo.com and the live MQTT
35
+ # broker, which is real egress with test credentials from CI.
36
+
37
+
38
+ def smarthome_base(region: str) -> str:
39
+ """Smarthome API base for ``region`` (AIDOT_SMARTHOME_URL_TEMPLATE override)."""
40
+ tmpl = os.environ.get("AIDOT_SMARTHOME_URL_TEMPLATE") or _SMARTHOME_URL_TEMPLATE
41
+ return tmpl.format(region=region)
42
+
43
+
44
+ def aidot_api_base(region: str) -> str:
45
+ """Platform API base for ``region`` (AIDOT_API_BASE_TEMPLATE override)."""
46
+ tmpl = os.environ.get("AIDOT_API_BASE_TEMPLATE") or _AIDOT_API_BASE_TEMPLATE
47
+ return tmpl.format(region=region)
48
+
49
+
50
+ def _ice_uris_from_env(var: str, default: list) -> list:
51
+ """Comma-separated ICE URI list from ``var``; unset -> default, "" -> []."""
52
+ env = os.environ.get(var)
53
+ if env is None:
54
+ return list(default)
55
+ return [u.strip() for u in env.split(",") if u.strip()]
56
+
57
+
58
+ def stun_server_uris() -> list:
59
+ """Default STUN server URIs (AIDOT_STUN_SERVERS override; "" disables)."""
60
+ return _ice_uris_from_env("AIDOT_STUN_SERVERS", ["stun:stun.l.google.com:19302"])
61
+
62
+
63
+ def fallback_turn_uris() -> list:
64
+ """TURN relay URIs appended when the cloud ICE config carries none
65
+ (AIDOT_TURN_SERVERS override; "" disables the hardcoded Arnoo fallback)."""
66
+ return _ice_uris_from_env(
67
+ "AIDOT_TURN_SERVERS",
68
+ ["stun:3.230.182.123:3478", "turn:3.230.182.123:5349"],
69
+ )
70
+
71
+
20
72
  # --------------------------------------------------------------------------- #
21
73
  # Playback TCP binary framing constants
22
74
  #
@@ -0,0 +1,447 @@
1
+ """Pick the fastest video decoder this host can actually use.
2
+
3
+ Enumeration is not enough. ``ffmpeg -decoders`` lists what the binary was
4
+ COMPILED with, not what works on the machine: a Raspberry Pi 4 happily advertises
5
+ ``h264_cuvid`` and ``hevc_cuvid`` with no Nvidia hardware anywhere, and the same
6
+ Pi advertises ``h264_v4l2m2m`` while failing to open it ("No such file or
7
+ directory") even with the kernel device present and the user in the ``video``
8
+ group. Selecting a decoder from that list would not accelerate decoding, it would
9
+ stop decoding working at all.
10
+
11
+ So every candidate is PROVEN before it is offered: a clip at the resolution the
12
+ cameras actually send is generated and decoded with it, and only a clean run
13
+ that produces frames qualifies. The winner is the fastest qualifying candidate,
14
+ which is a hardware path when one genuinely works and software otherwise.
15
+
16
+ Nor is hardware automatically faster. Measured on an Apple M1, VideoToolbox
17
+ decodes H.264 about three times SLOWER than software, in the same pipeline
18
+ production runs. Ranking by measured time rather than by preference is what
19
+ keeps that from becoming a regression sold as an optimisation.
20
+
21
+ Probing costs a second or two per codec, so the verdict is cached on disk. The
22
+ cache key includes the ffmpeg build identity, so upgrading ffmpeg re-probes
23
+ rather than trusting a verdict made about a different binary.
24
+
25
+ Where this matters: the streaming paths use ``-c copy`` and never decode, so they
26
+ are unaffected by design. The decode cost is in the diagnostic/validation path
27
+ (``-f null``), which decodes deliberately - that is what proves a stream is not
28
+ merely arriving but actually decodable, and it is what catches a parameter-set
29
+ mismatch that would otherwise present as a permanently black picture.
30
+ """
31
+
32
+ import hashlib
33
+ import json
34
+ import logging
35
+ import os
36
+ import glob
37
+ import subprocess
38
+ import sys
39
+ import threading
40
+ import time
41
+ from typing import List, Optional
42
+
43
+ from .protocol import _SPROP_DIR # reuse the same per-user cache root
44
+
45
+ _LOGGER = logging.getLogger(__name__)
46
+
47
+ # Ways to decode, best first; the empty list is plain software and always works.
48
+ #
49
+ # There are TWO forms and they are not interchangeable:
50
+ #
51
+ # ["-c:v", "<name>"] a named decoder, e.g. h264_v4l2m2m on a Pi
52
+ # ["-hwaccel", "<method>"] an acceleration method, e.g. videotoolbox on macOS
53
+ #
54
+ # Getting this wrong silently costs the whole feature. VideoToolbox and VAAPI
55
+ # expose no decoder at all - ffmpeg lists h264_videotoolbox and h264_vaapi as
56
+ # ENCODERS - so naming them with -c:v can never work, and every macOS and VAAPI
57
+ # machine would fall back to software while genuinely having hardware decoding
58
+ # available through -hwaccel. Verified on an Apple M1 (videotoolbox appears in
59
+ # -hwaccels, nowhere in -decoders) and on a Pi 4 (same for vaapi).
60
+ #
61
+ # Both forms go before -i. Ordering is only a starting preference; whichever
62
+ # proves fastest wins, and anything that cannot decode is dropped.
63
+ _CANDIDATES = {
64
+ "h264": [
65
+ ["-hwaccel", "videotoolbox"], # macOS / Apple silicon
66
+ ["-hwaccel", "vaapi"], # generic Linux (Intel/AMD)
67
+ ["-c:v", "h264_v4l2m2m"], # Raspberry Pi 4 / many ARM SoCs
68
+ ["-c:v", "h264_rkmpp"], # Rockchip
69
+ ["-c:v", "h264_qsv"], # Intel QuickSync
70
+ ["-c:v", "h264_cuvid"], # Nvidia
71
+ [], # software
72
+ ],
73
+ "hevc": [
74
+ ["-hwaccel", "videotoolbox"],
75
+ ["-hwaccel", "vaapi"],
76
+ ["-c:v", "hevc_v4l2m2m"],
77
+ ["-c:v", "hevc_rkmpp"],
78
+ ["-c:v", "hevc_qsv"],
79
+ ["-c:v", "hevc_cuvid"],
80
+ [],
81
+ ],
82
+ }
83
+
84
+ # Bumped when the shape of a cached verdict changes, so an older entry is
85
+ # re-probed instead of being read as something it is not.
86
+ _CACHE_SCHEMA = 2
87
+
88
+ # Force a choice and skip probing. Takes either form: a bare name is a decoder
89
+ # ("h264_v4l2m2m"), a "hwaccel:" prefix an acceleration method
90
+ # ("hwaccel:videotoolbox"). The prefix is needed because the two are not
91
+ # interchangeable - VideoToolbox and VAAPI have no decoder to name.
92
+ _ENV_OVERRIDE = "AIDOT_VIDEO_DECODER"
93
+ _ENV_DISABLE = "AIDOT_DISABLE_HWACCEL" # stay on software decoding
94
+
95
+ _PROBE_TIMEOUT_S = 40.0
96
+ _cache_mem: dict = {}
97
+ _warm_thread: Optional["threading.Thread"] = None
98
+ _warm_lock = threading.Lock()
99
+
100
+
101
+ def _ffmpeg() -> str:
102
+ return os.environ.get("AIDOT_FFMPEG", "ffmpeg")
103
+
104
+
105
+ _identity_memo: Optional[str] = None
106
+
107
+
108
+ def _ffmpeg_identity() -> str:
109
+ """Identify the ffmpeg build, so a verdict never outlives the binary.
110
+
111
+ Derived by STATTING the binary (path, size, mtime) rather than running
112
+ ``ffmpeg -version``. Reading a cached verdict has to be nearly free: this is
113
+ consulted on a startup path, and shelling out cost ~3.4s per process on a
114
+ loaded Pi - which is most of a cache hit's budget spent proving the cache is
115
+ still valid. An upgrade changes size or mtime and re-probes, which is the
116
+ property that actually matters.
117
+ """
118
+ global _identity_memo
119
+ if _identity_memo is not None:
120
+ return _identity_memo
121
+ import shutil
122
+ ident = "unknown"
123
+ try:
124
+ path = shutil.which(_ffmpeg()) or _ffmpeg()
125
+ st = os.stat(path)
126
+ ident = f"{path}:{st.st_size}:{int(st.st_mtime)}"
127
+ except Exception:
128
+ # No resolvable binary: fall back to the version string so a verdict is
129
+ # still keyed to something, at the cost of one subprocess.
130
+ try:
131
+ ident = subprocess.run(
132
+ [_ffmpeg(), "-hide_banner", "-version"],
133
+ capture_output=True, text=True, timeout=10,
134
+ ).stdout.splitlines()[0]
135
+ except Exception:
136
+ pass
137
+ _identity_memo = hashlib.sha256(ident.encode("utf-8", "replace")).hexdigest()[:16]
138
+ return _identity_memo
139
+
140
+
141
+ def _cache_path() -> str:
142
+ return os.path.join(_SPROP_DIR, "decoders.json")
143
+
144
+
145
+ def _load_cache() -> dict:
146
+ try:
147
+ with open(_cache_path()) as fh:
148
+ return json.load(fh)
149
+ except (OSError, ValueError):
150
+ return {}
151
+
152
+
153
+ def _save_cache(data: dict) -> None:
154
+ try:
155
+ os.makedirs(_SPROP_DIR, exist_ok=True)
156
+ tmp = _cache_path() + ".tmp"
157
+ with open(tmp, "w") as fh:
158
+ json.dump(data, fh)
159
+ os.replace(tmp, _cache_path())
160
+ except OSError as exc:
161
+ _LOGGER.debug("decoder cache write failed: %s", exc)
162
+
163
+
164
+ _decoders_memo: Optional[set] = None
165
+ _hwaccels_memo: Optional[set] = None
166
+
167
+
168
+ def _list_names(flag: str) -> set:
169
+ try:
170
+ out = subprocess.run(
171
+ [_ffmpeg(), "-hide_banner", flag],
172
+ capture_output=True, text=True, timeout=10,
173
+ ).stdout
174
+ except Exception:
175
+ return set()
176
+ names = set()
177
+ for ln in out.splitlines():
178
+ parts = ln.split()
179
+ if not parts or ln.startswith(" -") or ln.rstrip().endswith(":"):
180
+ continue
181
+ # -decoders lines are " V....D name description"; -hwaccels is one
182
+ # bare name per line under a heading.
183
+ names.add(parts[1] if flag == "-decoders" and len(parts) > 1 else parts[0])
184
+ return names
185
+
186
+
187
+ def _plausible(cand: List[str]) -> bool:
188
+ """Could this candidate possibly work, judged without spawning anything?
189
+
190
+ A build advertises support compiled in, not hardware present. Trying a
191
+ candidate costs an ffmpeg process and a couple of seconds on a small board,
192
+ and on a machine with no video hardware at all EVERY trial is wasted - as is
193
+ encoding the sample they would have decoded. Checking for the device first
194
+ costs a stat.
195
+
196
+ Deliberately permissive: it rules out only what is impossible, never what is
197
+ merely unlikely. Anything that survives still has to prove itself.
198
+ """
199
+ if not cand:
200
+ return True # software
201
+ name = cand[1]
202
+ if name == "videotoolbox":
203
+ return sys.platform == "darwin"
204
+ if name == "vaapi" or name.endswith("_vaapi") or name.endswith("_qsv"):
205
+ return bool(glob.glob("/dev/dri/render*"))
206
+ if name.endswith("_cuvid") or name.endswith("_nvdec"):
207
+ return bool(glob.glob("/dev/nvidia*"))
208
+ if name.endswith("_v4l2m2m"):
209
+ return bool(glob.glob("/dev/video*"))
210
+ if name.endswith("_rkmpp"):
211
+ return bool(glob.glob("/dev/mpp_service") or glob.glob("/dev/rga"))
212
+ return True
213
+
214
+
215
+ def _available(cand: List[str]) -> bool:
216
+ """Cheap pre-filter. Never sufficient on its own - a Pi advertises
217
+ h264_cuvid with no Nvidia hardware present, which is why everything that
218
+ passes here still has to prove it can decode."""
219
+ global _decoders_memo, _hwaccels_memo
220
+ if not cand:
221
+ return True # software
222
+ if cand[0] == "-c:v":
223
+ if _decoders_memo is None:
224
+ _decoders_memo = _list_names("-decoders")
225
+ return cand[1] in _decoders_memo
226
+ if _hwaccels_memo is None:
227
+ _hwaccels_memo = _list_names("-hwaccels")
228
+ return cand[1] in _hwaccels_memo
229
+
230
+
231
+ def _compiled_in(name: str) -> bool:
232
+ """Back-compat shim: is this named decoder in the build?"""
233
+ return _available(["-c:v", name])
234
+
235
+
236
+ # The cameras send 1280x720, and the probe clip matches that deliberately.
237
+ # A smaller clip is cheaper but ranks candidates badly: hardware decoding pays a
238
+ # fixed session-setup cost that dominates a tiny job, so a 320x240 clip can make
239
+ # a decoder that wins comfortably on real frames look like a loser. Measured on
240
+ # a Pi 4: 2.75s per candidate at 720p against 1.73s at 320x240 - a fair price
241
+ # for a measurement that reflects the actual workload, and it is paid once per
242
+ # host in the background.
243
+ _SAMPLE = "testsrc=size=1280x720:rate=15:duration=1"
244
+
245
+
246
+ def _make_sample(codec: str, path: str) -> bool:
247
+ """Generate a clip to decode. Encoding is software and that is fine -
248
+ it happens once per probe, not per stream."""
249
+ enc = "libx264" if codec == "h264" else "libx265"
250
+ try:
251
+ r = subprocess.run(
252
+ [_ffmpeg(), "-hide_banner", "-loglevel", "error",
253
+ "-f", "lavfi", "-i", _SAMPLE,
254
+ "-c:v", enc, "-preset", "ultrafast", "-g", "15",
255
+ "-f", codec, "-y", path],
256
+ capture_output=True, text=True, timeout=_PROBE_TIMEOUT_S,
257
+ )
258
+ return r.returncode == 0 and os.path.getsize(path) > 0
259
+ except Exception:
260
+ return False
261
+
262
+
263
+ def _try_decoder(cand: List[str], sample: str) -> Optional[float]:
264
+ """Decode the sample with ``cand``; seconds taken, or None if it cannot.
265
+
266
+ Decodes to rawvideo and requires bytes to come out, not merely a zero exit.
267
+ A decoder that opens, consumes the input and emits nothing would otherwise
268
+ look like a pass - and that is precisely the failure that matters here,
269
+ since it presents downstream as a permanently black picture rather than as
270
+ an error. A non-zero exit or anything on stderr also disqualifies it: a
271
+ decoder that warns its way through is not one to rely on.
272
+ """
273
+ t0 = time.monotonic()
274
+ try:
275
+ r = subprocess.run(
276
+ [_ffmpeg(), "-hide_banner", "-loglevel", "error",
277
+ *cand, "-i", sample,
278
+ "-f", "rawvideo", "-pix_fmt", "yuv420p", "-"],
279
+ capture_output=True, timeout=_PROBE_TIMEOUT_S,
280
+ )
281
+ except Exception:
282
+ return None
283
+ if r.returncode != 0 or r.stderr.strip() or not r.stdout:
284
+ return None
285
+ return time.monotonic() - t0
286
+
287
+
288
+ def probe_decoder(codec: str, force: bool = False) -> List[str]:
289
+ """ffmpeg input arguments for the fastest way to decode ``codec`` here.
290
+
291
+ Returns ``["-hwaccel", m]`` or ``["-c:v", name]`` for a hardware path that
292
+ was proven to work on this machine, or ``[]`` for plain software decoding -
293
+ which is also the answer when acceleration is disabled or nothing else
294
+ qualifies. The result always belongs BEFORE ``-i``.
295
+ """
296
+ codec = codec.lower()
297
+ if codec not in _CANDIDATES:
298
+ return []
299
+
300
+ forced = os.environ.get(_ENV_OVERRIDE, "").strip()
301
+ if forced:
302
+ # Accept either form: a bare name is a decoder, "hwaccel:x" a method.
303
+ if forced.startswith("hwaccel:"):
304
+ return ["-hwaccel", forced.split(":", 1)[1]]
305
+ return ["-c:v", forced]
306
+ if os.environ.get(_ENV_DISABLE, "").strip().lower() in ("1", "true", "yes", "on"):
307
+ return []
308
+
309
+ key = f"v{_CACHE_SCHEMA}:{codec}:{_ffmpeg_identity()}"
310
+ if not force and key in _cache_mem:
311
+ return list(_cache_mem[key])
312
+
313
+ cache = _load_cache()
314
+ if not force and isinstance(cache.get(key), list):
315
+ _cache_mem[key] = list(cache[key])
316
+ return list(cache[key])
317
+
318
+ # Everything below spawns processes, so decide first whether it is worth
319
+ # spawning any. A machine with no video hardware - a VPS, a container, most
320
+ # cloud installs - can be answered here for free, and encoding a sample it
321
+ # would never decode is the single largest cost in this module.
322
+ hw = [c for c in _CANDIDATES[codec] if c and _plausible(c) and _available(c)]
323
+ if not hw:
324
+ _LOGGER.debug("decoder probe: no hardware candidate is possible for %s "
325
+ "on this machine; software, nothing probed", codec)
326
+ cache[key] = []
327
+ _cache_mem[key] = []
328
+ _save_cache(cache)
329
+ return []
330
+
331
+ import tempfile
332
+ winner: List[str] = []
333
+ best = None
334
+ with tempfile.TemporaryDirectory() as td:
335
+ sample = os.path.join(td, f"probe.{codec}")
336
+ if not _make_sample(codec, sample):
337
+ _LOGGER.debug("decoder probe: could not build a %s sample", codec)
338
+ return []
339
+ for cand in hw:
340
+ took = _try_decoder(cand, sample)
341
+ if took is None:
342
+ _LOGGER.debug("decoder probe: %s cannot decode %s here",
343
+ " ".join(cand), codec)
344
+ continue
345
+ if best is None or took < best:
346
+ winner, best = list(cand), took
347
+ # Software is only worth timing if something beat it to the line. When
348
+ # every hardware candidate failed - the common case on small boards -
349
+ # software wins by default and the measurement would change nothing.
350
+ if winner:
351
+ sw = _try_decoder([], sample)
352
+ if sw is not None and sw <= (best or 0.0):
353
+ _LOGGER.debug("decoder probe: software (%.2fs) beats %s (%.2fs) "
354
+ "for %s", sw, " ".join(winner), best or 0.0, codec)
355
+ winner, best = [], sw
356
+
357
+ if winner:
358
+ _LOGGER.info("decoder probe: using %s for %s (%.2fs on the probe clip)",
359
+ " ".join(winner), codec, best or 0.0)
360
+ else:
361
+ _LOGGER.debug("decoder probe: software decoding for %s", codec)
362
+ cache[key] = winner
363
+ _cache_mem[key] = list(winner)
364
+ _save_cache(cache)
365
+ return list(winner)
366
+
367
+
368
+ def warm_decoder_cache(codecs: tuple = ("h264",)) -> "threading.Thread":
369
+ """Probe in the background so the first real use is already cached.
370
+
371
+ Probing costs about ten seconds per codec the first time on a small ARM
372
+ board, which is far too long to block a startup path - but it only ever
373
+ happens once per host per ffmpeg build, and the answer is wanted before the
374
+ first stream rather than during it. So run it on a daemon thread and let the
375
+ caller carry on: anything asking for a decoder meanwhile simply computes it
376
+ inline, and the result is shared through the same cache either way.
377
+
378
+ Only H.264 is warmed by default, because only H.264 is ingested: probing
379
+ H.265 as well doubled the cost of this module on a Pi 4 (22s of 47s wall,
380
+ 15s of 26s CPU) to answer a question nothing asks. If H.265 ingest lands,
381
+ add it here - and until then a stray H.265 stream still works, it just lets
382
+ ffmpeg choose on the first session while the answer is worked out.
383
+
384
+ Idempotent: a second call while the first is still running is a no-op, so
385
+ every config entry can call it at setup without spawning a probe each.
386
+
387
+ Returns the thread so a caller that wants to can join it; ignoring it is the
388
+ expected use. Returns the in-flight thread if one is already running.
389
+ """
390
+ global _warm_thread
391
+ with _warm_lock:
392
+ if _warm_thread is not None and _warm_thread.is_alive():
393
+ return _warm_thread
394
+
395
+ def _run() -> None:
396
+ for codec in codecs:
397
+ try:
398
+ probe_decoder(codec)
399
+ except Exception:
400
+ _LOGGER.debug("decoder probe failed for %s", codec, exc_info=True)
401
+
402
+ with _warm_lock:
403
+ if _warm_thread is not None and _warm_thread.is_alive():
404
+ return _warm_thread
405
+ _warm_thread = threading.Thread(
406
+ target=_run, name="aidot-decoder-probe", daemon=True)
407
+ _warm_thread.start()
408
+ return _warm_thread
409
+
410
+
411
+ def cached_decoder(codec: str) -> Optional[List[str]]:
412
+ """The cached verdict for ``codec``, or None if it has not been probed yet.
413
+
414
+ Never probes, so it is safe to call from an event loop - probing shells out
415
+ for several seconds on a first run and would stall every other camera. A
416
+ caller that gets None should simply let ffmpeg choose, which is the
417
+ behaviour that existed before any of this; ``warm_decoder_cache()`` fills
418
+ the cache in the background so the answer is normally already there.
419
+
420
+ An empty list is a real answer meaning "software decoding", and is
421
+ deliberately distinct from None.
422
+ """
423
+ codec = codec.lower()
424
+ if codec not in _CANDIDATES:
425
+ return None
426
+
427
+ forced = os.environ.get(_ENV_OVERRIDE, "").strip()
428
+ if forced:
429
+ if forced.startswith("hwaccel:"):
430
+ return ["-hwaccel", forced.split(":", 1)[1]]
431
+ return ["-c:v", forced]
432
+ if os.environ.get(_ENV_DISABLE, "").strip().lower() in ("1", "true", "yes", "on"):
433
+ return []
434
+
435
+ key = f"v{_CACHE_SCHEMA}:{codec}:{_ffmpeg_identity()}"
436
+ if key in _cache_mem:
437
+ return list(_cache_mem[key])
438
+ val = _load_cache().get(key)
439
+ if isinstance(val, list):
440
+ _cache_mem[key] = list(val)
441
+ return list(val)
442
+ return None
443
+
444
+
445
+ def decoder_args(codec: str) -> List[str]:
446
+ """Input arguments for decoding ``codec``, probing if necessary."""
447
+ return probe_decoder(codec)
@@ -583,6 +583,26 @@ def _sprop_cache_path(devid: str) -> str:
583
583
  return os.path.join(_SPROP_DIR, f"{safe}.sprop")
584
584
 
585
585
 
586
+ def _sprop_unstable_path(devid: str) -> str:
587
+ """Marker file: this camera does NOT keep its parameter sets stable."""
588
+ return _sprop_cache_path(devid) + ".unstable"
589
+
590
+
591
+ def _sprop_is_unstable(devid: str) -> bool:
592
+ """True once this camera has been seen to change its parameter sets.
593
+
594
+ Injecting a cached ``sprop-parameter-sets`` is only safe while the camera
595
+ keeps sending the SAME sets. An A001064 was measured changing its
596
+ ``seq_parameter_set_id`` between sessions (cache held id 0, the stream then
597
+ referenced id 3), and out-of-band parameter sets that name the wrong id are
598
+ far worse than none: ffmpeg initialises its decoder from them, every slice
599
+ then references an id the decoder does not have, and the whole session
600
+ decodes nothing - "sps_id 3 out of range", "non-existing PPS 0 referenced",
601
+ "no frame!". A MISSING set only costs the wait for the next in-band IDR.
602
+ """
603
+ return os.path.exists(_sprop_unstable_path(devid))
604
+
605
+
586
606
  def _load_sprop(devid: str) -> "Optional[str]":
587
607
  """Cached ``sprop-parameter-sets`` for ``devid`` (captured from a prior
588
608
  stream), or None. Fail-safe: any error -> None (SDP omits sprop = today's
@@ -596,8 +616,33 @@ def _load_sprop(devid: str) -> "Optional[str]":
596
616
 
597
617
 
598
618
  def _save_sprop(devid: str, sprop: str) -> None:
599
- """Persist the ``sprop-parameter-sets`` string for ``devid`` (best-effort)."""
619
+ """Persist the ``sprop-parameter-sets`` string for ``devid`` (best-effort).
620
+
621
+ If a DIFFERENT value was already cached, this camera does not keep its
622
+ parameter sets stable: mark it and drop the cache, so nothing is injected
623
+ for it again (see _sprop_is_unstable). The caller only reaches here from
624
+ in-band capture, so such a camera demonstrably sends its sets in-band and
625
+ loses nothing by the injection being skipped.
626
+ """
627
+ if _sprop_is_unstable(devid):
628
+ return # already known unstable; nothing to cache for this camera
600
629
  try:
630
+ prev = _load_sprop(devid)
631
+ if prev is not None and prev != sprop:
632
+ _LOGGER.warning(
633
+ "camera %s: in-band parameter sets differ from the cached ones - "
634
+ "this camera changes them between sessions, so out-of-band "
635
+ "injection is now disabled for it (a stale set decodes nothing)",
636
+ devid,
637
+ )
638
+ try:
639
+ os.makedirs(_SPROP_DIR, exist_ok=True)
640
+ with open(_sprop_unstable_path(devid), "w") as fh:
641
+ fh.write("parameter sets changed between sessions\n")
642
+ os.remove(_sprop_cache_path(devid))
643
+ except OSError:
644
+ pass
645
+ return
601
646
  os.makedirs(_SPROP_DIR, exist_ok=True)
602
647
  tmp = _sprop_cache_path(devid) + ".tmp"
603
648
  with open(tmp, "w") as fh:
@@ -616,6 +661,8 @@ def _inject_sprop(sdp: str, devid: str) -> str:
616
661
  in-band SPS loss). No-op if nothing is cached yet or sprop is already
617
662
  present. Applied at every ffmpeg-input SDP write so both the serve path and
618
663
  the snapshot/file path benefit."""
664
+ if _sprop_is_unstable(devid):
665
+ return sdp
619
666
  sprop = _load_sprop(devid)
620
667
  if not sprop or "sprop-parameter-sets=" in sdp:
621
668
  return sdp