python-aidot-cameras 1.0.0b30__tar.gz → 1.0.0b31__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 (267) hide show
  1. {python_aidot_cameras-1.0.0b30/python_aidot_cameras.egg-info → python_aidot_cameras-1.0.0b31}/PKG-INFO +2 -1
  2. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/README.md +1 -0
  3. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/camera/client.py +39 -0
  4. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/camera/hwaccel.py +3 -3
  5. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/camera/protocol.py +17 -7
  6. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/camera/sdes.py +10 -1
  7. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/camera/sdes_open.py +144 -52
  8. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/pyproject.toml +1 -1
  9. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31/python_aidot_cameras.egg-info}/PKG-INFO +2 -1
  10. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/python_aidot_cameras.egg-info/SOURCES.txt +2 -0
  11. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_env_seams.py +2 -1
  12. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_hwaccel_probe.py +1 -1
  13. python_aidot_cameras-1.0.0b31/tests/test_media_path_is_reported.py +88 -0
  14. python_aidot_cameras-1.0.0b31/tests/test_sdes_connection_mode.py +221 -0
  15. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_sdes_sprop.py +24 -5
  16. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_security_hardening.py +2 -2
  17. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_sprop_unstable_public.py +3 -3
  18. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_sprop_write_is_reported_truthfully.py +1 -1
  19. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/LICENSE +0 -0
  20. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/MANIFEST.in +0 -0
  21. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/__init__.py +0 -0
  22. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/__main__.py +0 -0
  23. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/_upstream.py +0 -0
  24. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/_vendor/__init__.py +0 -0
  25. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/_vendor/aiortc/LICENSE +0 -0
  26. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/_vendor/aiortc/__init__.py +0 -0
  27. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/_vendor/aiortc/clock.py +0 -0
  28. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/_vendor/aiortc/codecs/__init__.py +0 -0
  29. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/_vendor/aiortc/codecs/base.py +0 -0
  30. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/_vendor/aiortc/codecs/g711.py +0 -0
  31. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/_vendor/aiortc/codecs/g722.py +0 -0
  32. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/_vendor/aiortc/codecs/h264.py +0 -0
  33. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/_vendor/aiortc/codecs/opus.py +0 -0
  34. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/_vendor/aiortc/codecs/vpx.py +0 -0
  35. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/_vendor/aiortc/contrib/__init__.py +0 -0
  36. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/_vendor/aiortc/contrib/media.py +0 -0
  37. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/_vendor/aiortc/contrib/signaling.py +0 -0
  38. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/_vendor/aiortc/events.py +0 -0
  39. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/_vendor/aiortc/exceptions.py +0 -0
  40. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/_vendor/aiortc/jitterbuffer.py +0 -0
  41. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/_vendor/aiortc/mediastreams.py +0 -0
  42. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/_vendor/aiortc/rate.py +0 -0
  43. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/_vendor/aiortc/rtcconfiguration.py +0 -0
  44. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/_vendor/aiortc/rtcdatachannel.py +0 -0
  45. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/_vendor/aiortc/rtcdtlstransport.py +0 -0
  46. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/_vendor/aiortc/rtcicetransport.py +0 -0
  47. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/_vendor/aiortc/rtcpeerconnection.py +0 -0
  48. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/_vendor/aiortc/rtcrtpparameters.py +0 -0
  49. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/_vendor/aiortc/rtcrtpreceiver.py +0 -0
  50. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/_vendor/aiortc/rtcrtpsender.py +0 -0
  51. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/_vendor/aiortc/rtcrtptransceiver.py +0 -0
  52. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/_vendor/aiortc/rtcsctptransport.py +0 -0
  53. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/_vendor/aiortc/rtcsessiondescription.py +0 -0
  54. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/_vendor/aiortc/rtp.py +0 -0
  55. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/_vendor/aiortc/sdp.py +0 -0
  56. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/_vendor/aiortc/stats.py +0 -0
  57. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/_vendor/aiortc/utils.py +0 -0
  58. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/ble_gateway.py +0 -0
  59. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/camera/__init__.py +0 -0
  60. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/camera/constants.py +0 -0
  61. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/camera/controls.py +0 -0
  62. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/camera/go2rtc.py +0 -0
  63. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/camera/lan_control.py +0 -0
  64. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/camera/models.py +0 -0
  65. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/camera/playback.py +0 -0
  66. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/camera/sd_events.py +0 -0
  67. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/camera/sd_listing.py +0 -0
  68. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/camera/sd_playback.py +0 -0
  69. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/camera/tutk.py +0 -0
  70. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/camera/webrtc.py +0 -0
  71. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/camera/webrtc_open.py +0 -0
  72. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/client.py +0 -0
  73. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/cloud_auth.py +0 -0
  74. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/const.py +0 -0
  75. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/credentials.py +0 -0
  76. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/crypto.py +0 -0
  77. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/device_client.py +0 -0
  78. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/discover.py +0 -0
  79. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/exceptions.py +0 -0
  80. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/g711.py +0 -0
  81. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/lan_retry.py +0 -0
  82. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/aidot_cameras/py.typed +0 -0
  83. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  84. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
  85. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/python_aidot_cameras.egg-info/requires.txt +0 -0
  86. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/python_aidot_cameras.egg-info/top_level.txt +0 -0
  87. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/setup.cfg +0 -0
  88. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_aioice_compat.py +0 -0
  89. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_alarm_event.py +0 -0
  90. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_answer_sdp_video_pt.py +0 -0
  91. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_answer_section_selection.py +0 -0
  92. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_audio_mux_offline.py +0 -0
  93. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_avio_ctrl_uses_the_session_dseq.py +0 -0
  94. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_avio_request.py +0 -0
  95. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_avio_response_parse.py +0 -0
  96. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_avio_response_router.py +0 -0
  97. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_backoff.py +0 -0
  98. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_battery_detection.py +0 -0
  99. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_battery_evidence_corroboration.py +0 -0
  100. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_battery_skip_turn.py +0 -0
  101. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_bitrate_campaign_arms.py +0 -0
  102. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_ble_gateway.py +0 -0
  103. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_bridge_state_sentinels.py +0 -0
  104. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_busy_backoff.py +0 -0
  105. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_canary_log_names_the_camera.py +0 -0
  106. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_cloud_count.py +0 -0
  107. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_cloud_plan.py +0 -0
  108. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_constants_imports_resolve.py +0 -0
  109. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_control_verdicts.py +0 -0
  110. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_deferred_hardening.py +0 -0
  111. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_device_cmd_seq_match.py +0 -0
  112. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_device_login_guard.py +0 -0
  113. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_device_user_info_cache.py +0 -0
  114. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_direct_ts_serve.py +0 -0
  115. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_dormant_camera_cleanup.py +0 -0
  116. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_drain_is_cancellable.py +0 -0
  117. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_dtls_mux_terminal_write.py +0 -0
  118. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_dtls_not_ready_burst.py +0 -0
  119. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_dtls_pinning.py +0 -0
  120. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_dtls_resolution_ack.py +0 -0
  121. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_dtls_serve_decode_skip.py +0 -0
  122. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_dtls_serve_ice_wait.py +0 -0
  123. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_dtls_serve_open_timeout.py +0 -0
  124. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_dtls_skip_signaling_wait.py +0 -0
  125. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_dtls_slow_probe.py +0 -0
  126. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_dtls_stdout_producer.py +0 -0
  127. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_dtls_video_presence.py +0 -0
  128. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_egress_guard.py +0 -0
  129. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_feature_probe_needs_a_live_session.py +0 -0
  130. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_ffmpeg_signal_exit_is_not_a_crash.py +0 -0
  131. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_futile_keepalive.py +0 -0
  132. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_gate_requires_a_decoded_frame.py +0 -0
  133. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_go2rtc.py +0 -0
  134. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_go2rtc_cli.py +0 -0
  135. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_h264_decode_rate_limit.py +0 -0
  136. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_highport_nomination.py +0 -0
  137. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_ice_config_cache.py +0 -0
  138. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_ice_peer_reflexive.py +0 -0
  139. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_idle_release_both_loops.py +0 -0
  140. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_keepalive_renew.py +0 -0
  141. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_key_restart_sdp.py +0 -0
  142. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_keyframe_prompter.py +0 -0
  143. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_lan_control.py +0 -0
  144. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_lan_retry_reaches_the_storming_devices.py +0 -0
  145. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_late_repeats_are_dropped.py +0 -0
  146. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_light_active_color_mode.py +0 -0
  147. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_live_session_carries_talk.py +0 -0
  148. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_live_stream_param.py +0 -0
  149. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_live_validate_policy.py +0 -0
  150. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_live_video_canary.py +0 -0
  151. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_local_only_controls.py +0 -0
  152. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_logging_caps.py +0 -0
  153. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_login_connect_deadline.py +0 -0
  154. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_login_reconnect_backoff.py +0 -0
  155. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_media_counted_only_when_plaintext.py +0 -0
  156. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_media_socket_rcvbuf.py +0 -0
  157. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_media_wait_floor.py +0 -0
  158. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_motion_poll.py +0 -0
  159. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_mqtt_client_id_is_exact.py +0 -0
  160. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_mqtt_credential_refetch.py +0 -0
  161. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_mqtt_credential_self_heal.py +0 -0
  162. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_mqtt_password_not_persisted.py +0 -0
  163. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_mqtt_session_reconnect.py +0 -0
  164. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_mqtt_thread_not_orphaned.py +0 -0
  165. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_nack.py +0 -0
  166. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_nack_cannot_add_load_in_a_burst.py +0 -0
  167. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_narrow_pc_ice.py +0 -0
  168. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_no_key_material_in_logs.py +0 -0
  169. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_no_self_referential_go2rtc.py +0 -0
  170. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_no_undefined_names.py +0 -0
  171. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_offer_bandwidth_line.py +0 -0
  172. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_offer_keeps_rtcp_feedback.py +0 -0
  173. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_offline_keepalive_pause.py +0 -0
  174. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_on_frame_is_an_av_frame.py +0 -0
  175. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_open_fail_logger.py +0 -0
  176. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_open_gate_delay.py +0 -0
  177. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_peerid_reuse.py +0 -0
  178. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_persistent_mqtt.py +0 -0
  179. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_persistent_mqtt_robustness.py +0 -0
  180. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_playback_tls.py +0 -0
  181. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_post_merge_hardening.py +0 -0
  182. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_ptz_probe_reads_what_it_is_told.py +0 -0
  183. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_public_signature_matches_impl.py +0 -0
  184. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_quality_campaign_in_session.py +0 -0
  185. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_recent_recordings.py +0 -0
  186. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_remb.py +0 -0
  187. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_remb_goes_out_the_relay_aware_sender.py +0 -0
  188. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_resent_frames_are_dropped.py +0 -0
  189. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_resolution_persists.py +0 -0
  190. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_restart_cycle.py +0 -0
  191. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_retry_policy.py +0 -0
  192. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_scripts_importable.py +0 -0
  193. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_sctp_init_ack_tsn.py +0 -0
  194. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_sd_card_presence.py +0 -0
  195. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_sd_event_decode.py +0 -0
  196. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_sd_event_probe_payloads.py +0 -0
  197. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_sd_listing.py +0 -0
  198. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_sd_playback.py +0 -0
  199. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_sdes_adaptive.py +0 -0
  200. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_sdes_avio_dispatch.py +0 -0
  201. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_sdes_bridge_break_gate.py +0 -0
  202. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_sdes_echo_wait_timeout.py +0 -0
  203. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_sdes_fast_liveplay.py +0 -0
  204. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_sdes_ffmpeg_stderr_log.py +0 -0
  205. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_sdes_first_media_stall_report.py +0 -0
  206. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_sdes_idle_release.py +0 -0
  207. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_sdes_nack_bridge.py +0 -0
  208. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_sdes_offer_video_order.py +0 -0
  209. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_sdes_offer_video_pin.py +0 -0
  210. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_sdes_open_cleanup.py +0 -0
  211. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_sdes_sctp_abort.py +0 -0
  212. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_sdes_serve_audio.py +0 -0
  213. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_sdes_serve_cmd.py +0 -0
  214. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_sdes_serve_stderr_drain.py +0 -0
  215. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_sdes_talk.py +0 -0
  216. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_sdes_teardown_exit_log.py +0 -0
  217. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_sdes_terminal_ack.py +0 -0
  218. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_sdes_terminal_ack_latency.py +0 -0
  219. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_sdes_tmmbr_bridge.py +0 -0
  220. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_sdes_watchdog.py +0 -0
  221. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_sdp_narrowing_properties.py +0 -0
  222. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_sdp_payload_type_narrowing.py +0 -0
  223. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_self_peer_is_an_address_not_an_ip.py +0 -0
  224. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_serializable_login_info.py +0 -0
  225. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_serve_loss_tolerance.py +0 -0
  226. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_serve_pt_respects_the_pin.py +0 -0
  227. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_serve_relay.py +0 -0
  228. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_serve_stderr_keeps_the_reason.py +0 -0
  229. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_session_stats.py +0 -0
  230. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_shared_house_seam.py +0 -0
  231. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_snapshot_cancellation.py +0 -0
  232. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_speak.py +0 -0
  233. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_sprop_capture_gate.py +0 -0
  234. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_sprop_staleness.py +0 -0
  235. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_srtp_rx_rekey.py +0 -0
  236. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_srtp_tx_key_note.py +0 -0
  237. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_stall_report_says_what_the_answer_carried.py +0 -0
  238. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_stall_report_says_whether_the_trigger_was_answered.py +0 -0
  239. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_stall_report_separates_silence_from_undecryptable.py +0 -0
  240. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_stall_report_survives_a_caller_giving_up.py +0 -0
  241. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_stall_reports_reach_the_artifact.py +0 -0
  242. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_stream_cap.py +0 -0
  243. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_stream_drain_ownership.py +0 -0
  244. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_stream_idle.py +0 -0
  245. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_stream_slot_cap.py +0 -0
  246. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_stream_teardown.py +0 -0
  247. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_talk.py +0 -0
  248. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_talk_reports_a_speaker_that_never_opened.py +0 -0
  249. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_tap_to_mux_end_to_end.py +0 -0
  250. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_terminal_ack.py +0 -0
  251. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_tmmbr.py +0 -0
  252. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_token_file_robustness.py +0 -0
  253. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_token_refresh.py +0 -0
  254. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_turn_create_permission.py +0 -0
  255. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_tutk_path_is_out_of_reach.py +0 -0
  256. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_upstream_compat.py +0 -0
  257. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_validator_shares_one_token.py +0 -0
  258. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_video_profile_log.py +0 -0
  259. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_video_pts_dts.py +0 -0
  260. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_video_ts_unwrap.py +0 -0
  261. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_viewer_check_throttle.py +0 -0
  262. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_viewer_presence.py +0 -0
  263. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_viewer_present_push_mode.py +0 -0
  264. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_wait_or_event.py +0 -0
  265. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_wake_gate_camera_evidence.py +0 -0
  266. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/tests/test_wake_readiness_retry.py +0 -0
  267. {python_aidot_cameras-1.0.0b30 → python_aidot_cameras-1.0.0b31}/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.0b30
3
+ Version: 1.0.0b31
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
@@ -415,6 +415,7 @@ audio, idle release, the sprop cache path) are documented in
415
415
  | `AIDOT_MAX_CONCURRENT_STREAMS` | Caps how many cameras stream at once. | `3` |
416
416
  | `AIDOT_FAST_CONNECT` | Enable LAN-direct "fast connect" (STUN-only, skips several cloud signaling waits) when truthy. On-LAN only - off-subnet/strict-NAT viewers must leave it off. | unset (off) |
417
417
  | `AIDOT_SDES_SKIP_TURN_PREALLOC` | Skip the SDES TURN relay pre-allocation (~2-3 s of cold-start latency) so signaling goes straight out with the host candidate. Faster on a LAN, at the cost of no relay fallback for a camera on a different segment / behind strict NAT. Experimental, opt-in (truthy = `1`/`true`/`yes`/`on`). | unset (off) |
418
+ | `AIDOT_SDES_CONNECTION_MODE` | Which media path the SDES offer proposes: `auto` (default - every reachable candidate, and ICE priority prefers the LAN with the relay as last resort; measured on the full fleet, six of seven cameras stream direct and only the unit with no route to us rides the relay), `lan` (no relay at all - same lever as `AIDOT_SDES_SKIP_TURN_PREALLOC`, and battery cameras keep the relay regardless because a cloud-woken camera has no other path back), or `relay` (EXPERIMENTAL, and on the reference fleet it does not steer: even with `c=`/`m=` moved to the relay allocation and the WAN permission pre-installed, an A001064 and an A001513 both dialed our host address directly, learning it from our own ICE probes - forcing the relay for real would mean suppressing every direct-path outbound, which is unbuilt). Per-open `sdes_connection_mode` beats the env. Every session reports the path its media actually took as `media_stats().media_path` (`direct`/`relay`). | unset (`auto`) |
418
419
  | `AIDOT_SDES_ADAPTIVE` | Adaptive fast-with-fallback for the SDES keepalive loop: try the fast path first and fall back to the full relay path if a fast attempt delivers no media. A per-device cache skips the fast attempt on later views once it has failed. Truthy value enables. Ignored for battery cameras, where the fast path cannot win and its short grace truncates the cold start. | unset (off) |
419
420
  | `AIDOT_SDES_FAST_LIVEPLAY` | Don't block on the `livePlayResp` wait for eligible SDES cameras (~4.5 s faster cold start). Role-reversal models (A001064 PTZ) always excluded for correctness. **On by default**; set to `0`/`false`/`no`/`off` to disable. | enabled (on) |
420
421
  | `AIDOT_SDES_NACK` | Ask the camera to resend video RTP packets that never arrived (RTCP Generic NACK). A camera losing packets on a weak link otherwise delivers truncated H.264 slices, which a browser's WebRTC decoder conceals but Media Source Extensions treats as fatal. Measured on an A001064 at ~1-2% loss: 98.4% of losses recovered against none at all without it. Costs nothing on a clean link, where no requests are generated. **On by default**; set to `0`/`false`/`no`/`off` to disable. | enabled (on) |
@@ -375,6 +375,7 @@ audio, idle release, the sprop cache path) are documented in
375
375
  | `AIDOT_MAX_CONCURRENT_STREAMS` | Caps how many cameras stream at once. | `3` |
376
376
  | `AIDOT_FAST_CONNECT` | Enable LAN-direct "fast connect" (STUN-only, skips several cloud signaling waits) when truthy. On-LAN only - off-subnet/strict-NAT viewers must leave it off. | unset (off) |
377
377
  | `AIDOT_SDES_SKIP_TURN_PREALLOC` | Skip the SDES TURN relay pre-allocation (~2-3 s of cold-start latency) so signaling goes straight out with the host candidate. Faster on a LAN, at the cost of no relay fallback for a camera on a different segment / behind strict NAT. Experimental, opt-in (truthy = `1`/`true`/`yes`/`on`). | unset (off) |
378
+ | `AIDOT_SDES_CONNECTION_MODE` | Which media path the SDES offer proposes: `auto` (default - every reachable candidate, and ICE priority prefers the LAN with the relay as last resort; measured on the full fleet, six of seven cameras stream direct and only the unit with no route to us rides the relay), `lan` (no relay at all - same lever as `AIDOT_SDES_SKIP_TURN_PREALLOC`, and battery cameras keep the relay regardless because a cloud-woken camera has no other path back), or `relay` (EXPERIMENTAL, and on the reference fleet it does not steer: even with `c=`/`m=` moved to the relay allocation and the WAN permission pre-installed, an A001064 and an A001513 both dialed our host address directly, learning it from our own ICE probes - forcing the relay for real would mean suppressing every direct-path outbound, which is unbuilt). Per-open `sdes_connection_mode` beats the env. Every session reports the path its media actually took as `media_stats().media_path` (`direct`/`relay`). | unset (`auto`) |
378
379
  | `AIDOT_SDES_ADAPTIVE` | Adaptive fast-with-fallback for the SDES keepalive loop: try the fast path first and fall back to the full relay path if a fast attempt delivers no media. A per-device cache skips the fast attempt on later views once it has failed. Truthy value enables. Ignored for battery cameras, where the fast path cannot win and its short grace truncates the cold start. | unset (off) |
379
380
  | `AIDOT_SDES_FAST_LIVEPLAY` | Don't block on the `livePlayResp` wait for eligible SDES cameras (~4.5 s faster cold start). Role-reversal models (A001064 PTZ) always excluded for correctness. **On by default**; set to `0`/`false`/`no`/`off` to disable. | enabled (on) |
380
381
  | `AIDOT_SDES_NACK` | Ask the camera to resend video RTP packets that never arrived (RTCP Generic NACK). A camera losing packets on a weak link otherwise delivers truncated H.264 slices, which a browser's WebRTC decoder conceals but Media Source Extensions treats as fatal. Measured on an A001064 at ~1-2% loss: 98.4% of losses recovered against none at all without it. Costs nothing on a clean link, where no requests are generated. **On by default**; set to `0`/`false`/`no`/`off` to disable. | enabled (on) |
@@ -3540,6 +3540,7 @@ class CameraMixin(_CameraControlsMixin, _CameraSdMixin, _WebRTCOpenMixin, _SdesO
3540
3540
  stream_idle_s: Optional[float] = None,
3541
3541
  sdes_fast_liveplay: Optional[bool] = None,
3542
3542
  sdes_skip_turn: Optional[bool] = None,
3543
+ sdes_connection_mode: Optional[str] = None,
3543
3544
  sdes_adaptive: Optional[bool] = None,
3544
3545
  sdes_audio_gain_db: Optional[float] = None,
3545
3546
  ) -> None:
@@ -3616,6 +3617,8 @@ class CameraMixin(_CameraControlsMixin, _CameraSdMixin, _WebRTCOpenMixin, _SdesO
3616
3617
  self._sdes_fast_liveplay_opt = sdes_fast_liveplay
3617
3618
  if sdes_skip_turn is not None:
3618
3619
  self._sdes_skip_turn_opt = sdes_skip_turn
3620
+ if sdes_connection_mode is not None:
3621
+ self._sdes_connection_mode_opt = sdes_connection_mode
3619
3622
  if sdes_adaptive is not None:
3620
3623
  self._sdes_adaptive_opt = sdes_adaptive
3621
3624
  if self._stream_task is not None and not self._stream_task.done():
@@ -4701,6 +4704,33 @@ class CameraMixin(_CameraControlsMixin, _CameraSdMixin, _WebRTCOpenMixin, _SdesO
4701
4704
  )
4702
4705
  return False
4703
4706
 
4707
+ def _resolve_sdes_connection_mode(self) -> str:
4708
+ """Which media path the session should offer: auto, lan, or relay.
4709
+
4710
+ ``auto`` (default) offers every reachable candidate and lets ICE
4711
+ priority prefer the LAN, with the relay as last resort - the shipped
4712
+ behaviour, verified on the full fleet (six of seven direct, only the
4713
+ unreachable unit relayed). ``lan`` drops the relay entirely (routed
4714
+ through _resolve_sdes_skip_turn so the battery force-keep applies).
4715
+ ``relay`` offers only the relay candidate and force-keeps the
4716
+ pre-allocation.
4717
+
4718
+ Per-open ``sdes_connection_mode`` wins; else AIDOT_SDES_CONNECTION_MODE;
4719
+ else auto. Anything unrecognised is auto - this is read while opening
4720
+ a stream, and a typo must not stop video.
4721
+ """
4722
+ for raw in (getattr(self, "_sdes_connection_mode_opt", None),
4723
+ os.environ.get("AIDOT_SDES_CONNECTION_MODE")):
4724
+ if raw is None:
4725
+ continue
4726
+ mode = str(raw).strip().lower()
4727
+ if mode in ("auto", "lan", "relay"):
4728
+ return mode
4729
+ _LOGGER.warning(
4730
+ "camera %s: unknown sdes_connection_mode %r; using auto",
4731
+ getattr(self, "device_id", "?"), raw)
4732
+ return "auto"
4733
+
4704
4734
  def _resolve_sdes_skip_turn(self) -> bool:
4705
4735
  """EXPERIMENTAL (opt-in, default off): skip the blocking SDES TURN relay
4706
4736
  pre-allocation, for cameras reachable LAN-direct.
@@ -4730,6 +4760,15 @@ class CameraMixin(_CameraControlsMixin, _CameraSdMixin, _WebRTCOpenMixin, _SdesO
4730
4760
  candidate on the HA segment."""
4731
4761
  if getattr(self, "is_battery_camera", False):
4732
4762
  return False
4763
+ # The connection mode composes here rather than in a parallel path, so
4764
+ # everything hanging off this resolver (instrumentation, the adaptive
4765
+ # interplay) sees one answer. relay force-keeps the pre-allocation -
4766
+ # forcing the relay while skipping its allocation would offer nothing.
4767
+ _mode = self._resolve_sdes_connection_mode()
4768
+ if _mode == "lan":
4769
+ return True
4770
+ if _mode == "relay":
4771
+ return False
4733
4772
  opt = getattr(self, "_sdes_skip_turn_opt", None)
4734
4773
  if opt is not None:
4735
4774
  return bool(opt)
@@ -40,7 +40,7 @@ import threading
40
40
  import time
41
41
  from typing import List, Optional
42
42
 
43
- from .protocol import _SPROP_DIR # reuse the same per-user cache root
43
+ from .protocol import _sprop_dir # reuse the same per-user cache root
44
44
 
45
45
  _LOGGER = logging.getLogger(__name__)
46
46
 
@@ -139,7 +139,7 @@ def _ffmpeg_identity() -> str:
139
139
 
140
140
 
141
141
  def _cache_path() -> str:
142
- return os.path.join(_SPROP_DIR, "decoders.json")
142
+ return os.path.join(_sprop_dir(), "decoders.json")
143
143
 
144
144
 
145
145
  def _load_cache() -> dict:
@@ -152,7 +152,7 @@ def _load_cache() -> dict:
152
152
 
153
153
  def _save_cache(data: dict) -> None:
154
154
  try:
155
- os.makedirs(_SPROP_DIR, exist_ok=True)
155
+ os.makedirs(_sprop_dir(), exist_ok=True)
156
156
  tmp = _cache_path() + ".tmp"
157
157
  with open(tmp, "w") as fh:
158
158
  json.dump(data, fh)
@@ -684,8 +684,18 @@ _XDG_CONFIG_HOME = os.environ.get("XDG_CONFIG_HOME") or os.path.join(
684
684
  os.path.expanduser("~"), ".config")
685
685
 
686
686
 
687
- _SPROP_DIR = os.environ.get("AIDOT_SPROP_DIR") or os.path.join(
688
- _XDG_CONFIG_HOME, "aidot", "sprop")
687
+ def _sprop_dir() -> str:
688
+ """The sprop cache directory - AIDOT_SPROP_DIR read at CALL time.
689
+
690
+ A consumer embedded in a larger process (the Home Assistant integration)
691
+ can only learn its persistent storage path at setup, after this module is
692
+ long imported; a module-level snapshot of the env var silently ignored
693
+ that setting and the cache landed back in the container-ephemeral home
694
+ directory, so every container recreation cost a startup-churn window.
695
+ Same lesson as cloud_auth's country default: bind config at call time.
696
+ """
697
+ return os.environ.get("AIDOT_SPROP_DIR") or os.path.join(
698
+ _XDG_CONFIG_HOME, "aidot", "sprop")
689
699
 
690
700
 
691
701
  def _extract_param_sets_from_rtp(pkt: bytes) -> dict:
@@ -740,12 +750,12 @@ def _build_sprop(sps: bytes, pps: bytes) -> str:
740
750
  def _sprop_cache_path(devid: str) -> str:
741
751
  # `devid` originates from the cloud/device and is interpolated into a
742
752
  # filesystem path, so a value containing "/", "\\", ".." or an absolute path
743
- # could escape _SPROP_DIR (arbitrary read/write). Reduce it to a safe,
753
+ # could escape the sprop dir (arbitrary read/write). Reduce it to a safe,
744
754
  # collision-resistant filename component.
745
755
  safe = re.sub(r"[^A-Za-z0-9_-]", "_", devid or "")
746
756
  if not safe or len(devid or "") > 128:
747
757
  safe = hashlib.sha256((devid or "").encode("utf-8", "replace")).hexdigest()
748
- return os.path.join(_SPROP_DIR, f"{safe}.sprop")
758
+ return os.path.join(_sprop_dir(), f"{safe}.sprop")
749
759
 
750
760
 
751
761
  def _sprop_unstable_path(devid: str) -> str:
@@ -834,14 +844,14 @@ def _save_sprop(devid: str, sprop: str) -> bool:
834
844
  devid,
835
845
  )
836
846
  try:
837
- os.makedirs(_SPROP_DIR, exist_ok=True)
847
+ os.makedirs(_sprop_dir(), exist_ok=True)
838
848
  with open(_sprop_unstable_path(devid), "w") as fh:
839
849
  fh.write("parameter sets changed between sessions\n")
840
850
  os.remove(_sprop_cache_path(devid))
841
851
  except OSError:
842
852
  pass
843
853
  return False
844
- os.makedirs(_SPROP_DIR, exist_ok=True)
854
+ os.makedirs(_sprop_dir(), exist_ok=True)
845
855
  tmp = _sprop_cache_path(devid) + ".tmp"
846
856
  with open(tmp, "w") as fh:
847
857
  fh.write(sprop)
@@ -851,7 +861,7 @@ def _save_sprop(devid: str, sprop: str) -> bool:
851
861
  # Surface (don't swallow): if the cache dir isn't writable the whole
852
862
  # sprop feature is silently inert. AIDOT_SPROP_DIR can redirect it.
853
863
  _LOGGER.warning("sprop cache write failed (%s): %s - set AIDOT_SPROP_DIR "
854
- "to a writable path", _SPROP_DIR, exc)
864
+ "to a writable path", _sprop_dir(), exc)
855
865
  return False
856
866
 
857
867
 
@@ -53,6 +53,7 @@ class SdesSession(AvioRequestMixin):
53
53
  teardown_requested=None,
54
54
  first_video_pt=None,
55
55
  first_audio_pt=None,
56
+ media_path=None,
56
57
  device_id=None,
57
58
  responses=None,
58
59
  abort_chan=None,
@@ -81,6 +82,12 @@ class SdesSession(AvioRequestMixin):
81
82
  # sent (video 96/97/98, audio 0/8), or None before first media.
82
83
  self._first_video_pt = first_video_pt if first_video_pt is not None else [None]
83
84
  self._first_audio_pt = first_audio_pt if first_audio_pt is not None else [None]
85
+ # Shared with the bridge thread: [0] becomes "direct" or "relay" when
86
+ # the first media packet arrives, classified by its source address
87
+ # against the session's TURN servers. The connection mode is only a
88
+ # preference - the camera decides where to send from - and this is the
89
+ # receipt that says what actually happened.
90
+ self._media_path = media_path if media_path is not None else [None]
84
91
  # Mutable one-element list shared with the bridge thread: [0] flips True
85
92
  # the moment ANY locally-initiated ffmpeg-kill path fires (this stop(),
86
93
  # the cold-open _reap(), the key-restart proc replace, or the DTLS-
@@ -229,7 +236,8 @@ class SdesSession(AvioRequestMixin):
229
236
  the DTLS path allows. These counters are the supported substitute:
230
237
  non-zero ``packets``/``bytes`` means real media reached the consumer.
231
238
 
232
- Returns ``{packets, bytes, last_media_monotonic, video_pt, audio_pt}``;
239
+ Returns ``{packets, bytes, last_media_monotonic, video_pt, audio_pt,
240
+ media_path}``;
233
241
  the payload types are ``None`` until the camera's first packet of that
234
242
  kind arrives.
235
243
  """
@@ -239,6 +247,7 @@ class SdesSession(AvioRequestMixin):
239
247
  "last_media_monotonic": self._media_progress[0],
240
248
  "video_pt": self._first_video_pt[0],
241
249
  "audio_pt": self._first_audio_pt[0],
250
+ "media_path": self._media_path[0],
242
251
  }
243
252
 
244
253
  def _drained_stderr_tail(self) -> bytes:
@@ -260,6 +260,98 @@ def _env_positive(name: str, cast=int, off=None):
260
260
  return value if value > 0 else off
261
261
 
262
262
 
263
+ def _turn_entry_ips(entries) -> set:
264
+ """The server addresses named by ICE entries' ``Uris`` lists.
265
+
266
+ Entries look like ``{"Uris": ["stun:3.230.182.123:3478",
267
+ "turn:3.230.182.123:5349?transport=udp"], ...}``. The address is what
268
+ media source classification needs; scheme, port and query are noise.
269
+ """
270
+ ips = set()
271
+ for entry in entries or ():
272
+ for uri in (entry.get("Uris") or ()):
273
+ body = str(uri).split(":", 1)[-1]
274
+ host = body.split("?", 1)[0].rsplit(":", 1)[0].strip("[]")
275
+ if host:
276
+ ips.add(host)
277
+ return ips
278
+
279
+
280
+ def _classify_media_path(src_ip, turn_ips) -> "Optional[str]":
281
+ """"direct" or "relay" for a media source address, or None when unknown.
282
+
283
+ Media arriving FROM a TURN server address is relayed - that covers both
284
+ shapes on this fleet: the camera's own allocation on the vendor TURN (the
285
+ relayed A001513's media arrives from the TURN address on the camera's
286
+ allocation port, so the PORT must not participate) and a Data Indication
287
+ through ours. Anything else came to us directly.
288
+ """
289
+ if not src_ip:
290
+ return None
291
+ return "relay" if src_ip in turn_ips else "direct"
292
+
293
+
294
+ def _sdes_offer_media_endpoint(mode: str, default_ip: str, default_port: int,
295
+ relay_addr, public_ip):
296
+ """Where the offer's c=/m= points for one media section: (ip, port, is_relay).
297
+
298
+ This fleet's firmware nominates by dialing c=/m=, not by reading candidate
299
+ lines - measured 2026-08-24, an A001064 and an A001513 both dialed direct
300
+ with only the relay candidate on offer. So forcing the relay means moving
301
+ c=/m= to the allocation, which was this path's ORIGINAL design (see the
302
+ allocation helper's comment) before it was backed out over dropped media.
303
+
304
+ The missing piece back then was the permission: the camera's packets reach
305
+ the TURN server from the house WAN address - OUR srflx address - and
306
+ without a CreatePermission for it the relay drops everything. Relay mode
307
+ therefore requires a known public_ip; without one (or without an
308
+ allocation) it falls back to the direct endpoint, because a stream on the
309
+ wrong path beats no stream.
310
+ """
311
+ if mode == "relay" and relay_addr and public_ip:
312
+ return relay_addr[0], relay_addr[1], True
313
+ return default_ip, default_port, False
314
+
315
+
316
+ def _sdes_offer_candidate_lines(mode: str, local_ip: str, port: int,
317
+ public_ip, relay_addr) -> str:
318
+ """The a=candidate block for one media section, shaped by the mode.
319
+
320
+ ``auto`` - host + srflx (when a public IP is known) + relay (when the
321
+ pre-allocation succeeded), in that priority order. This is the
322
+ shipped behaviour: the LAN wins by ICE priority (host
323
+ 2130706431 > srflx 1694498815 > relay 16777215) and the relay
324
+ is the last resort - measured on the full fleet 2026-08-24,
325
+ six of seven cameras direct and only the unit with no route to
326
+ us on the relay.
327
+ ``lan`` - host + srflx only. Normally moot (lan mode also skips the
328
+ relay pre-allocation, so there is no relay_addr to omit), but
329
+ enforced here too so the two levers cannot disagree.
330
+ ``relay`` - the relay candidate ONLY, so an ICE-speaking camera has
331
+ nothing else to nominate. The relay address must still never
332
+ appear in c=/m= - TURN drops every camera packet without a
333
+ CreatePermission for the camera's public address, which is
334
+ unknown - so candidate lines are the entire steering surface,
335
+ and firmware that ignores ICE and dials c=/m= directly cannot
336
+ be steered. With no allocation to offer this falls back to
337
+ the auto block: an offer with zero candidates is a session
338
+ that cannot start, and a stream on the wrong path beats no
339
+ stream.
340
+ """
341
+ host = f"a=candidate:1 1 udp 2130706431 {local_ip} {port} typ host\r\n"
342
+ srflx = (f"a=candidate:2 1 udp 1694498815 {public_ip} {port}"
343
+ f" typ srflx raddr {local_ip} rport {port}\r\n"
344
+ if public_ip else "")
345
+ relay = (f"a=candidate:3 1 udp 16777215 {relay_addr[0]} {relay_addr[1]}"
346
+ f" typ relay raddr {local_ip} rport {port}\r\n"
347
+ if relay_addr else "")
348
+ if mode == "relay" and relay:
349
+ return relay
350
+ if mode == "lan":
351
+ return host + srflx
352
+ return host + srflx + relay
353
+
354
+
263
355
  def _sdes_offer_bandwidth_kbps():
264
356
  """A receive-bandwidth ceiling for the offer, in kbps, or None for none."""
265
357
  return _env_positive("AIDOT_SDES_OFFER_BANDWIDTH_KBPS")
@@ -1864,6 +1956,13 @@ class _SdesOpenMixin:
1864
1956
  # _resolve_sdes_skip_turn). Either way the cost is instrumented below so
1865
1957
  # the saving is measurable: grep ``signaling-wait[`` for sdes-turn-prealloc.
1866
1958
  _skip_turn_prealloc = self._resolve_sdes_skip_turn()
1959
+ # Which media path to offer (auto | lan | relay). Resolved once per
1960
+ # open; the candidate blocks below are built from it. A receipt goes
1961
+ # out whenever it is not the default, because a run that cannot show
1962
+ # the knob was applied cannot tell a result from a coincidence.
1963
+ _conn_mode = self._resolve_sdes_connection_mode()
1964
+ if _conn_mode != "auto" and _status:
1965
+ _status(f"SDES: connection mode {_conn_mode}")
1867
1966
  _turn_t0 = time.monotonic()
1868
1967
  _turn_did = False
1869
1968
  if _sdes_turn_entries and not _fast_connect and not _skip_turn_prealloc:
@@ -1990,10 +2089,18 @@ class _SdesOpenMixin:
1990
2089
  # for the camera's public IP which is unknown; relay in c= causes TURN to
1991
2090
  # drop every camera packet. Relay is still in a=candidate: for ICE.
1992
2091
  # For LAN cameras (_public_ip is None) fall back to local_ip directly.
1993
- _offer_audio_ip = _public_ip or local_ip
1994
- _offer_audio_port = audio_port
1995
- _offer_video_ip = _public_ip or local_ip
1996
- _offer_video_port = video_port
2092
+ _offer_audio_ip, _offer_audio_port, _relay_in_c_a = _sdes_offer_media_endpoint(
2093
+ _conn_mode, _public_ip or local_ip, audio_port,
2094
+ _relay_addrs.get(_audio_sock), _public_ip)
2095
+ _offer_video_ip, _offer_video_port, _relay_in_c_v = _sdes_offer_media_endpoint(
2096
+ _conn_mode, _public_ip or local_ip, video_port,
2097
+ _relay_addrs.get(_video_sock), _public_ip)
2098
+ _relay_in_c = _relay_in_c_a or _relay_in_c_v
2099
+ if _conn_mode == "relay" and not _relay_in_c and _status:
2100
+ _status("SDES: relay mode fell back to the direct endpoint"
2101
+ " (no allocation or no public ip)")
2102
+ elif _relay_in_c and _status:
2103
+ _status("SDES: c=/m= at the relay allocation (relay mode)")
1997
2104
  _bundle_hdr_line = (
1998
2105
  "a=group:BUNDLE 0 1 2\r\n" if _dc_probe_fp else "a=group:BUNDLE 0 1\r\n"
1999
2106
  )
@@ -2063,18 +2170,9 @@ class _SdesOpenMixin:
2063
2170
  # the SDP body (confirmed from logcat ground truth 2026-05-22).
2064
2171
  + f"a=ice-ufrag:{_ufrag_a}\r\n"
2065
2172
  f"a=ice-pwd:{_pwd_a}\r\n"
2066
- f"a=candidate:1 1 udp 2130706431 {local_ip} {audio_port} typ host\r\n"
2067
- + (
2068
- f"a=candidate:2 1 udp 1694498815 {_public_ip} {audio_port}"
2069
- f" typ srflx raddr {local_ip} rport {audio_port}\r\n"
2070
- if _public_ip else ""
2071
- )
2072
- + (
2073
- f"a=candidate:3 1 udp 16777215 {_relay_addrs[_audio_sock][0]}"
2074
- f" {_relay_addrs[_audio_sock][1]}"
2075
- f" typ relay raddr {local_ip} rport {audio_port}\r\n"
2076
- if _audio_sock in _relay_addrs else ""
2077
- )
2173
+ + _sdes_offer_candidate_lines(
2174
+ _conn_mode, local_ip, audio_port, _public_ip,
2175
+ _relay_addrs.get(_audio_sock))
2078
2176
  # video m-section
2079
2177
  + f"m=video {_offer_video_port} RTP/SAVPF {_video_pt_list}\r\n"
2080
2178
  f"c=IN IP4 {_offer_video_ip}\r\n"
@@ -2086,18 +2184,9 @@ class _SdesOpenMixin:
2086
2184
  + "a=rtcp-mux\r\n"
2087
2185
  f"a=ice-ufrag:{_ufrag_v}\r\n"
2088
2186
  f"a=ice-pwd:{_pwd_v}\r\n"
2089
- f"a=candidate:1 1 udp 2130706431 {local_ip} {video_port} typ host\r\n"
2090
- + (
2091
- f"a=candidate:2 1 udp 1694498815 {_public_ip} {video_port}"
2092
- f" typ srflx raddr {local_ip} rport {video_port}\r\n"
2093
- if _public_ip else ""
2094
- )
2095
- + (
2096
- f"a=candidate:3 1 udp 16777215 {_relay_addrs[_video_sock][0]}"
2097
- f" {_relay_addrs[_video_sock][1]}"
2098
- f" typ relay raddr {local_ip} rport {video_port}\r\n"
2099
- if _video_sock in _relay_addrs else ""
2100
- )
2187
+ + _sdes_offer_candidate_lines(
2188
+ _conn_mode, local_ip, video_port, _public_ip,
2189
+ _relay_addrs.get(_video_sock))
2101
2190
  # m=application SCTP DataChannel section for SDES cameras.
2102
2191
  # Ground truth from real Leedarson app logcat (2026-05-22):
2103
2192
  # m=application 9 SCTP webrtc-datachannel
@@ -2563,18 +2652,9 @@ class _SdesOpenMixin:
2563
2652
  "a=rtcp-mux\r\n"
2564
2653
  f"a=ice-ufrag:{_ufrag_a}\r\n"
2565
2654
  f"a=ice-pwd:{_pwd_a}\r\n"
2566
- f"a=candidate:1 1 udp 2130706431 {local_ip} {audio_port} typ host\r\n"
2567
- + (
2568
- f"a=candidate:2 1 udp 1694498815 {_public_ip} {audio_port}"
2569
- f" typ srflx raddr {local_ip} rport {audio_port}\r\n"
2570
- if _public_ip else ""
2571
- )
2572
- + (
2573
- f"a=candidate:3 1 udp 16777215 {_relay_addrs[_audio_sock][0]}"
2574
- f" {_relay_addrs[_audio_sock][1]}"
2575
- f" typ relay raddr {local_ip} rport {audio_port}\r\n"
2576
- if _audio_sock in _relay_addrs else ""
2577
- )
2655
+ + _sdes_offer_candidate_lines(
2656
+ _conn_mode, local_ip, audio_port, _public_ip,
2657
+ _relay_addrs.get(_audio_sock))
2578
2658
  + f"m=video {_ans_video_port} RTP/SAVPF 96 97\r\n"
2579
2659
  f"c=IN IP4 {_ans_video_ip}\r\n"
2580
2660
  "a=sendonly\r\n"
@@ -2588,18 +2668,9 @@ class _SdesOpenMixin:
2588
2668
  "a=rtcp-mux\r\n"
2589
2669
  f"a=ice-ufrag:{_ufrag_v}\r\n"
2590
2670
  f"a=ice-pwd:{_pwd_v}\r\n"
2591
- f"a=candidate:1 1 udp 2130706431 {local_ip} {video_port} typ host\r\n"
2592
- + (
2593
- f"a=candidate:2 1 udp 1694498815 {_public_ip} {video_port}"
2594
- f" typ srflx raddr {local_ip} rport {video_port}\r\n"
2595
- if _public_ip else ""
2596
- )
2597
- + (
2598
- f"a=candidate:3 1 udp 16777215 {_relay_addrs[_video_sock][0]}"
2599
- f" {_relay_addrs[_video_sock][1]}"
2600
- f" typ relay raddr {local_ip} rport {video_port}\r\n"
2601
- if _video_sock in _relay_addrs else ""
2602
- )
2671
+ + _sdes_offer_candidate_lines(
2672
+ _conn_mode, local_ip, video_port, _public_ip,
2673
+ _relay_addrs.get(_video_sock))
2603
2674
  )
2604
2675
  _compressed_sdp_ans = _compress_sdp_req(_relay_answer_sdp)
2605
2676
 
@@ -3777,6 +3848,17 @@ class _SdesOpenMixin:
3777
3848
  )
3778
3849
  return _perm_ok
3779
3850
 
3851
+ # Relay mode only: the camera dials our relay allocation, and its
3852
+ # packets arrive at the TURN server from the house WAN address - OUR
3853
+ # public address - so that address needs a permission or the path is a
3854
+ # black hole. Deliberately NOT done outside relay mode: a permission
3855
+ # for our own srflx caused TURN self-loop Data Indications and STUN
3856
+ # echo storms when it was installed indiscriminately (see the warning
3857
+ # in the allocation helper). In relay mode nothing of ours is sent to
3858
+ # our own allocation, so the loop has no driver.
3859
+ if _relay_in_c and _public_ip:
3860
+ _turn_install_permissions([(_public_ip, 9)], "relay-mode WAN")
3861
+
3780
3862
  if _cam_ice_ufrag and _cam_ice_pwd and _cam_ice_cands:
3781
3863
  _turn_install_permissions(_cam_ice_cands, "setup")
3782
3864
 
@@ -3909,6 +3991,12 @@ class _SdesOpenMixin:
3909
3991
  # bridge on the first video RTP packet so the ffmpeg SDP can be narrowed
3910
3992
  # to the matching single codec before ffmpeg is launched.
3911
3993
  _first_video_pt: list = [None]
3994
+ _media_path: list = [None]
3995
+ # Every TURN server address this session could be relayed through:
3996
+ # our allocations plus the ICE entries themselves - the camera can
3997
+ # arrive via its OWN vendor allocation even when ours was skipped.
3998
+ _bridge_turn_ips = ({a[0] for a in _relay_addrs.values()}
3999
+ | _turn_entry_ips(_sdes_turn_entries))
3912
4000
  # Fallback for the above, filled from the camera's negotiated answer SDP
3913
4001
  # when the wait below expires without a single video packet. Kept in a
3914
4002
  # list for the same reason as _first_video_pt: the serve-restart path
@@ -5504,6 +5592,9 @@ class _SdesOpenMixin:
5504
5592
  if len(_bpkt) >= 12:
5505
5593
  _bridge_fn._cam_video_ssrc = _st_br.unpack_from(
5506
5594
  '!I', _bpkt, 8)[0]
5595
+ if _media_path[0] is None:
5596
+ _media_path[0] = _classify_media_path(
5597
+ _bsrc[0], _bridge_turn_ips)
5507
5598
  _bridge_fn._cam_srtp_src = _bsrc
5508
5599
  _bridge_fn._cam_srtp_sock = _bs
5509
5600
  if getattr(_bridge_fn, '_first_video_ts',
@@ -6618,6 +6709,7 @@ class _SdesOpenMixin:
6618
6709
  talk_state=_talk_state,
6619
6710
  media_progress=_media_progress,
6620
6711
  media_counts=_media_counts,
6712
+ media_path=_media_path,
6621
6713
  teardown_requested=_teardown_holder,
6622
6714
  first_video_pt=_first_video_pt,
6623
6715
  first_audio_pt=_first_audio_pt,
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-aidot-cameras"
7
- version = "1.0.0b30"
7
+ version = "1.0.0b31"
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.0b30
3
+ Version: 1.0.0b31
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
@@ -415,6 +415,7 @@ audio, idle release, the sprop cache path) are documented in
415
415
  | `AIDOT_MAX_CONCURRENT_STREAMS` | Caps how many cameras stream at once. | `3` |
416
416
  | `AIDOT_FAST_CONNECT` | Enable LAN-direct "fast connect" (STUN-only, skips several cloud signaling waits) when truthy. On-LAN only - off-subnet/strict-NAT viewers must leave it off. | unset (off) |
417
417
  | `AIDOT_SDES_SKIP_TURN_PREALLOC` | Skip the SDES TURN relay pre-allocation (~2-3 s of cold-start latency) so signaling goes straight out with the host candidate. Faster on a LAN, at the cost of no relay fallback for a camera on a different segment / behind strict NAT. Experimental, opt-in (truthy = `1`/`true`/`yes`/`on`). | unset (off) |
418
+ | `AIDOT_SDES_CONNECTION_MODE` | Which media path the SDES offer proposes: `auto` (default - every reachable candidate, and ICE priority prefers the LAN with the relay as last resort; measured on the full fleet, six of seven cameras stream direct and only the unit with no route to us rides the relay), `lan` (no relay at all - same lever as `AIDOT_SDES_SKIP_TURN_PREALLOC`, and battery cameras keep the relay regardless because a cloud-woken camera has no other path back), or `relay` (EXPERIMENTAL, and on the reference fleet it does not steer: even with `c=`/`m=` moved to the relay allocation and the WAN permission pre-installed, an A001064 and an A001513 both dialed our host address directly, learning it from our own ICE probes - forcing the relay for real would mean suppressing every direct-path outbound, which is unbuilt). Per-open `sdes_connection_mode` beats the env. Every session reports the path its media actually took as `media_stats().media_path` (`direct`/`relay`). | unset (`auto`) |
418
419
  | `AIDOT_SDES_ADAPTIVE` | Adaptive fast-with-fallback for the SDES keepalive loop: try the fast path first and fall back to the full relay path if a fast attempt delivers no media. A per-device cache skips the fast attempt on later views once it has failed. Truthy value enables. Ignored for battery cameras, where the fast path cannot win and its short grace truncates the cold start. | unset (off) |
419
420
  | `AIDOT_SDES_FAST_LIVEPLAY` | Don't block on the `livePlayResp` wait for eligible SDES cameras (~4.5 s faster cold start). Role-reversal models (A001064 PTZ) always excluded for correctness. **On by default**; set to `0`/`false`/`no`/`off` to disable. | enabled (on) |
420
421
  | `AIDOT_SDES_NACK` | Ask the camera to resend video RTP packets that never arrived (RTCP Generic NACK). A camera losing packets on a weak link otherwise delivers truncated H.264 slices, which a browser's WebRTC decoder conceals but Media Source Extensions treats as fatal. Measured on an A001064 at ~1-2% loss: 98.4% of losses recovered against none at all without it. Costs nothing on a clean link, where no requests are generated. **On by default**; set to `0`/`false`/`no`/`off` to disable. | enabled (on) |
@@ -145,6 +145,7 @@ tests/test_logging_caps.py
145
145
  tests/test_login_connect_deadline.py
146
146
  tests/test_login_reconnect_backoff.py
147
147
  tests/test_media_counted_only_when_plaintext.py
148
+ tests/test_media_path_is_reported.py
148
149
  tests/test_media_socket_rcvbuf.py
149
150
  tests/test_media_wait_floor.py
150
151
  tests/test_motion_poll.py
@@ -191,6 +192,7 @@ tests/test_sd_playback.py
191
192
  tests/test_sdes_adaptive.py
192
193
  tests/test_sdes_avio_dispatch.py
193
194
  tests/test_sdes_bridge_break_gate.py
195
+ tests/test_sdes_connection_mode.py
194
196
  tests/test_sdes_echo_wait_timeout.py
195
197
  tests/test_sdes_fast_liveplay.py
196
198
  tests/test_sdes_ffmpeg_stderr_log.py
@@ -128,11 +128,12 @@ def test_sdes_media_stats_defaults_and_counts():
128
128
  sess._media_progress = progress
129
129
  sess._first_video_pt = vpt
130
130
  sess._first_audio_pt = apt
131
+ sess._media_path = [None]
131
132
 
132
133
  stats = sess.media_stats()
133
134
  assert stats == {
134
135
  "packets": 0, "bytes": 0, "last_media_monotonic": 0.0,
135
- "video_pt": None, "audio_pt": None,
136
+ "video_pt": None, "audio_pt": None, "media_path": None,
136
137
  }
137
138
 
138
139
  # bridge-thread updates are visible through the shared lists
@@ -29,7 +29,7 @@ from aidot_cameras.camera import hwaccel
29
29
  @pytest.fixture(autouse=True)
30
30
  def _isolate(tmp_path, monkeypatch):
31
31
  """Per-test cache dir and clean memo state."""
32
- monkeypatch.setattr(hwaccel, "_SPROP_DIR", str(tmp_path))
32
+ monkeypatch.setenv("AIDOT_SPROP_DIR", str(tmp_path))
33
33
  monkeypatch.setattr(hwaccel, "_cache_mem", {})
34
34
  monkeypatch.setattr(hwaccel, "_identity_memo", None)
35
35
  monkeypatch.setattr(hwaccel, "_decoders_memo", None)