python-aidot-cameras 0.14.0__tar.gz → 0.15.0b2__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 (170) hide show
  1. {python_aidot_cameras-0.14.0/python_aidot_cameras.egg-info → python_aidot_cameras-0.15.0b2}/PKG-INFO +1 -1
  2. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/camera/client.py +16 -5
  3. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/camera/constants.py +52 -0
  4. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/camera/protocol.py +48 -1
  5. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/camera/sdes.py +31 -0
  6. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/camera/sdes_open.py +160 -15
  7. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/camera/webrtc_open.py +35 -9
  8. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/client.py +6 -2
  9. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/pyproject.toml +16 -1
  10. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  11. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/python_aidot_cameras.egg-info/SOURCES.txt +8 -0
  12. python_aidot_cameras-0.15.0b2/tests/test_audio_mux_offline.py +147 -0
  13. python_aidot_cameras-0.15.0b2/tests/test_env_seams.py +156 -0
  14. python_aidot_cameras-0.15.0b2/tests/test_media_wait_floor.py +38 -0
  15. python_aidot_cameras-0.15.0b2/tests/test_restart_cycle.py +103 -0
  16. python_aidot_cameras-0.15.0b2/tests/test_scripts_importable.py +50 -0
  17. python_aidot_cameras-0.15.0b2/tests/test_sdes_terminal_ack_latency.py +138 -0
  18. python_aidot_cameras-0.15.0b2/tests/test_sdp_narrowing_properties.py +133 -0
  19. python_aidot_cameras-0.15.0b2/tests/test_sprop_staleness.py +83 -0
  20. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/LICENSE +0 -0
  21. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/MANIFEST.in +0 -0
  22. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/README.md +0 -0
  23. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/__init__.py +0 -0
  24. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/__main__.py +0 -0
  25. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_upstream.py +0 -0
  26. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/__init__.py +0 -0
  27. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/__init__.py +0 -0
  28. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/clock.py +0 -0
  29. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/codecs/__init__.py +0 -0
  30. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/codecs/base.py +0 -0
  31. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/codecs/g711.py +0 -0
  32. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/codecs/g722.py +0 -0
  33. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/codecs/h264.py +0 -0
  34. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/codecs/opus.py +0 -0
  35. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/codecs/vpx.py +0 -0
  36. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/contrib/__init__.py +0 -0
  37. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/contrib/media.py +0 -0
  38. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/contrib/signaling.py +0 -0
  39. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/events.py +0 -0
  40. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/exceptions.py +0 -0
  41. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/jitterbuffer.py +0 -0
  42. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/mediastreams.py +0 -0
  43. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/rate.py +0 -0
  44. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/rtcconfiguration.py +0 -0
  45. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/rtcdatachannel.py +0 -0
  46. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/rtcdtlstransport.py +0 -0
  47. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/rtcicetransport.py +0 -0
  48. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/rtcpeerconnection.py +0 -0
  49. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/rtcrtpparameters.py +0 -0
  50. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/rtcrtpreceiver.py +0 -0
  51. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/rtcrtpsender.py +0 -0
  52. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/rtcrtptransceiver.py +0 -0
  53. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/rtcsctptransport.py +0 -0
  54. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/rtcsessiondescription.py +0 -0
  55. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/rtp.py +0 -0
  56. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/sdp.py +0 -0
  57. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/stats.py +0 -0
  58. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/utils.py +0 -0
  59. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/ble_gateway.py +0 -0
  60. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/camera/__init__.py +0 -0
  61. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/camera/controls.py +0 -0
  62. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/camera/go2rtc.py +0 -0
  63. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/camera/lan_control.py +0 -0
  64. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/camera/models.py +0 -0
  65. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/camera/playback.py +0 -0
  66. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/camera/tutk.py +0 -0
  67. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/camera/webrtc.py +0 -0
  68. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/const.py +0 -0
  69. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/credentials.py +0 -0
  70. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/crypto.py +0 -0
  71. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/device_client.py +0 -0
  72. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/discover.py +0 -0
  73. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/exceptions.py +0 -0
  74. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/g711.py +0 -0
  75. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/py.typed +0 -0
  76. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  77. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
  78. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/python_aidot_cameras.egg-info/requires.txt +0 -0
  79. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/python_aidot_cameras.egg-info/top_level.txt +0 -0
  80. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/setup.cfg +0 -0
  81. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_aioice_compat.py +0 -0
  82. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_alarm_event.py +0 -0
  83. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_answer_sdp_video_pt.py +0 -0
  84. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_backoff.py +0 -0
  85. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_battery_detection.py +0 -0
  86. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_battery_evidence_corroboration.py +0 -0
  87. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_battery_skip_turn.py +0 -0
  88. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_ble_gateway.py +0 -0
  89. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_constants_imports_resolve.py +0 -0
  90. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_deferred_hardening.py +0 -0
  91. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_device_login_guard.py +0 -0
  92. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_device_user_info_cache.py +0 -0
  93. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_dormant_camera_cleanup.py +0 -0
  94. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_dtls_mux_terminal_write.py +0 -0
  95. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_dtls_not_ready_burst.py +0 -0
  96. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_dtls_pinning.py +0 -0
  97. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_dtls_serve_decode_skip.py +0 -0
  98. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_dtls_serve_open_timeout.py +0 -0
  99. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_dtls_skip_signaling_wait.py +0 -0
  100. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_dtls_slow_probe.py +0 -0
  101. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_egress_guard.py +0 -0
  102. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_go2rtc.py +0 -0
  103. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_go2rtc_cli.py +0 -0
  104. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_h264_decode_rate_limit.py +0 -0
  105. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_highport_nomination.py +0 -0
  106. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_ice_config_cache.py +0 -0
  107. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_ice_peer_reflexive.py +0 -0
  108. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_idle_release_both_loops.py +0 -0
  109. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_keepalive_renew.py +0 -0
  110. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_key_restart_sdp.py +0 -0
  111. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_keyframe_prompter.py +0 -0
  112. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_lan_control.py +0 -0
  113. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_light_active_color_mode.py +0 -0
  114. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_live_stream_param.py +0 -0
  115. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_live_validate_policy.py +0 -0
  116. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_logging_caps.py +0 -0
  117. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_motion_poll.py +0 -0
  118. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_mqtt_credential_refetch.py +0 -0
  119. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_mqtt_credential_self_heal.py +0 -0
  120. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_mqtt_password_not_persisted.py +0 -0
  121. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_mqtt_session_reconnect.py +0 -0
  122. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_narrow_pc_ice.py +0 -0
  123. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_no_self_referential_go2rtc.py +0 -0
  124. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_no_undefined_names.py +0 -0
  125. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_offline_keepalive_pause.py +0 -0
  126. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_open_fail_logger.py +0 -0
  127. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_open_gate_delay.py +0 -0
  128. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_peerid_reuse.py +0 -0
  129. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_persistent_mqtt.py +0 -0
  130. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_persistent_mqtt_robustness.py +0 -0
  131. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_playback_tls.py +0 -0
  132. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_post_merge_hardening.py +0 -0
  133. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_retry_policy.py +0 -0
  134. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_sdes_adaptive.py +0 -0
  135. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_sdes_bridge_break_gate.py +0 -0
  136. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_sdes_echo_wait_timeout.py +0 -0
  137. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_sdes_fast_liveplay.py +0 -0
  138. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_sdes_ffmpeg_stderr_log.py +0 -0
  139. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_sdes_idle_release.py +0 -0
  140. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_sdes_open_cleanup.py +0 -0
  141. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_sdes_serve_audio.py +0 -0
  142. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_sdes_serve_cmd.py +0 -0
  143. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_sdes_sprop.py +0 -0
  144. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_sdes_talk.py +0 -0
  145. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_sdes_teardown_exit_log.py +0 -0
  146. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_sdes_terminal_ack.py +0 -0
  147. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_sdes_watchdog.py +0 -0
  148. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_sdp_payload_type_narrowing.py +0 -0
  149. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_security_hardening.py +0 -0
  150. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_serializable_login_info.py +0 -0
  151. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_serve_relay.py +0 -0
  152. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_session_stats.py +0 -0
  153. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_shared_house_seam.py +0 -0
  154. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_speak.py +0 -0
  155. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_stream_cap.py +0 -0
  156. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_stream_drain_ownership.py +0 -0
  157. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_stream_idle.py +0 -0
  158. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_stream_slot_cap.py +0 -0
  159. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_stream_teardown.py +0 -0
  160. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_talk.py +0 -0
  161. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_terminal_ack.py +0 -0
  162. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_token_file_robustness.py +0 -0
  163. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_token_refresh.py +0 -0
  164. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_turn_create_permission.py +0 -0
  165. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_upstream_compat.py +0 -0
  166. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_viewer_check_throttle.py +0 -0
  167. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_viewer_presence.py +0 -0
  168. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_viewer_present_push_mode.py +0 -0
  169. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_wait_or_event.py +0 -0
  170. {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/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.0b2
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,
@@ -1156,7 +1158,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
1156
1158
 
1157
1159
  @property
1158
1160
  def _smarthome_base(self) -> str:
1159
- return _SMARTHOME_URL_TEMPLATE.format(region=self._region)
1161
+ return smarthome_base(self._region)
1160
1162
 
1161
1163
  def set_token_refresh_cb(self, cb: "Callable") -> None:
1162
1164
  """Register the AidotClient coroutine that refreshes the access token."""
@@ -1371,6 +1373,15 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
1371
1373
  if self._mqtt_url:
1372
1374
  return self._mqtt_url
1373
1375
 
1376
+ # Test seam: point the whole client at a local broker without any cloud
1377
+ # call (paho derives ws-vs-wss and the port from the scheme, so a plain
1378
+ # "ws://127.0.0.1:PORT/mqtt" needs nothing else). Unset in production.
1379
+ _env_mqtt = os.environ.get("AIDOT_MQTT_URL")
1380
+ if _env_mqtt:
1381
+ _LOGGER.debug("AIDOT_MQTT_URL override in effect: %s", _env_mqtt)
1382
+ self._mqtt_url = _env_mqtt
1383
+ return self._mqtt_url
1384
+
1374
1385
  import aiohttp
1375
1386
 
1376
1387
  async def _fetch():
@@ -1603,11 +1614,11 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
1603
1614
 
1604
1615
  @property
1605
1616
  def _aidot_v21_base(self) -> str:
1606
- return f"https://prod-{self._region}-api.arnoo.com/v21"
1617
+ return f"{aidot_api_base(self._region)}/v21"
1607
1618
 
1608
1619
  @property
1609
1620
  def _aidot_v32_base(self) -> str:
1610
- return f"https://prod-{self._region}-api.arnoo.com/v32/api/ipc"
1621
+ return f"{aidot_api_base(self._region)}/v32/api/ipc"
1611
1622
 
1612
1623
  def _aidot_headers(self) -> dict:
1613
1624
  # Auth headers for the AiDot platform API (prod-{region}-api.arnoo.com).
@@ -4847,7 +4858,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
4847
4858
  return None
4848
4859
 
4849
4860
  url = (
4850
- f"https://prod-{self._region}-api.arnoo.com"
4861
+ f"{aidot_api_base(self._region)}"
4851
4862
  f"/v29/api/webrtc/iceConfig?forceRefresh=0"
4852
4863
  )
4853
4864
 
@@ -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
  #
@@ -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
@@ -34,7 +34,10 @@ class SdesSession:
34
34
  cmd_chan=None,
35
35
  talk_state=None,
36
36
  media_progress=None,
37
+ media_counts=None,
37
38
  teardown_requested=None,
39
+ first_video_pt=None,
40
+ first_audio_pt=None,
38
41
  ) -> None:
39
42
  self._proc = proc
40
43
  self._sdp_path = sdp_path
@@ -48,6 +51,14 @@ class SdesSession:
48
51
  # that stopped sending (battery teardown ~49-72s) and reconnect - ffmpeg
49
52
  # itself never exits on a dead UDP input, so wait_done() alone would hang.
50
53
  self._media_progress = media_progress if media_progress is not None else [0.0]
54
+ # Shared with the bridge thread: [packets, bytes] forwarded to ffmpeg.
55
+ # See media_stats() - the SDES path never calls on_frame, so this is the
56
+ # in-process signal that media is actually flowing.
57
+ self._media_counts = media_counts if media_counts is not None else [0, 0]
58
+ # Shared with the bridge thread: the payload types the camera actually
59
+ # sent (video 96/97/98, audio 0/8), or None before first media.
60
+ self._first_video_pt = first_video_pt if first_video_pt is not None else [None]
61
+ self._first_audio_pt = first_audio_pt if first_audio_pt is not None else [None]
51
62
  # Mutable one-element list shared with the bridge thread: [0] flips True
52
63
  # the moment ANY locally-initiated ffmpeg-kill path fires (this stop(),
53
64
  # the cold-open _reap(), the key-restart proc replace, or the DTLS-
@@ -146,6 +157,26 @@ class SdesSession:
146
157
  """time.monotonic() of the last media packet forwarded, 0.0 if none yet."""
147
158
  return self._media_progress[0]
148
159
 
160
+ def media_stats(self) -> dict:
161
+ """Snapshot of media actually forwarded to ffmpeg by the bridge.
162
+
163
+ The SDES path decodes nothing in-process (ffmpeg owns the media), so
164
+ ``on_frame`` is never invoked and callers cannot count frames the way
165
+ the DTLS path allows. These counters are the supported substitute:
166
+ non-zero ``packets``/``bytes`` means real media reached the consumer.
167
+
168
+ Returns ``{packets, bytes, last_media_monotonic, video_pt, audio_pt}``;
169
+ the payload types are ``None`` until the camera's first packet of that
170
+ kind arrives.
171
+ """
172
+ return {
173
+ "packets": self._media_counts[0],
174
+ "bytes": self._media_counts[1],
175
+ "last_media_monotonic": self._media_progress[0],
176
+ "video_pt": self._first_video_pt[0],
177
+ "audio_pt": self._first_audio_pt[0],
178
+ }
179
+
149
180
  def _log_ffmpeg_stderr(self, stderr_bytes: bytes) -> None:
150
181
  """Log ffmpeg's captured stderr at a level that matches what happened.
151
182
 
@@ -15,7 +15,12 @@ import random
15
15
  import time
16
16
  from typing import Callable, Optional # noqa: F401 - method annotations
17
17
 
18
- from .constants import _LIVE_PLAY_NOT_READY, SDES_SPEAKERSTART_DELAY
18
+ from ..exceptions import AidotCameraBusy
19
+ from .constants import (
20
+ _LIVE_PLAY_NOT_READY,
21
+ SDES_SPEAKERSTART_DELAY,
22
+ stun_server_uris,
23
+ )
19
24
  from .models import VideoFrame # noqa: F401 - forward-ref annotation
20
25
  from .sdes import SdesSession
21
26
  from .protocol import (
@@ -173,6 +178,52 @@ def narrow_sdp_payload_types(sdp_text: str, keep_video=None, keep_audio=None) ->
173
178
  return "".join(kept)
174
179
 
175
180
 
181
+ async def _sdes_await_answer_or_terminal(
182
+ answer_fut, terminal_error_fut, timeout: float, _status=None
183
+ ):
184
+ """Await the camera's SDES answer, but give up at once on a terminal ack.
185
+
186
+ A terminal webrtcResp ack (-50002 max-streams / -50015 SD-cap) means the
187
+ camera REFUSED this stream; the whole point of classifying it as terminal
188
+ is that neither waiting nor retrying can help. The SDES path used to watch
189
+ only ``answer_fut``, so a refusal cost the full answer budget and then a
190
+ pointless DTLS-fallback offer on top.
191
+
192
+ Raises ``AidotCameraBusy`` the moment the refusal lands, ``TimeoutError``
193
+ if neither arrives in ``timeout`` (the caller's existing no-answer path).
194
+ """
195
+ if terminal_error_fut is None:
196
+ return await asyncio.wait_for(answer_fut, timeout=timeout)
197
+
198
+ _waits = {answer_fut, terminal_error_fut}
199
+ _done, _pending = await asyncio.wait(
200
+ _waits, timeout=timeout, return_when=asyncio.FIRST_COMPLETED
201
+ )
202
+ if terminal_error_fut in _done:
203
+ code, desc = terminal_error_fut.result()
204
+ if _status is not None:
205
+ _status(f"camera refused: ack {code} {desc} - terminal, not retrying")
206
+ raise AidotCameraBusy(code, desc)
207
+ if answer_fut in _done:
208
+ return answer_fut.result()
209
+ # Timed out. ``answer_fut`` MUST be cancelled here, exactly as the
210
+ # ``asyncio.wait_for(answer_fut, ...)`` this replaced did. The MQTT handler
211
+ # routes a late webrtcResp to ``second_answer_fut`` only ``if
212
+ # answer_fut.done()`` (webrtc_open.py), and the late-ICE-credential recovery
213
+ # reads ``second_answer_fut``. Leaving it pending swallows the late answer,
214
+ # so USE-CANDIDATE is never sent, the camera stays in ICE "Checking" and the
215
+ # view shows a black frame with no media - the 0.12.16 failure mode.
216
+ #
217
+ # ``terminal_error_fut`` is deliberately NOT cancelled: it is checked again
218
+ # after the open returns (webrtc_open.py) to surface a refusal that landed
219
+ # during the fallback.
220
+ answer_fut.cancel()
221
+ # Builtin TimeoutError (asyncio.TimeoutError is an alias since 3.11); this
222
+ # is what the caller's `except TimeoutError` no-answer path already catches,
223
+ # which is what triggers the DTLS fallback.
224
+ raise TimeoutError()
225
+
226
+
176
227
  def _sdes_echo_wait_timeout(skip_liveplay: bool) -> float:
177
228
  """Seconds to block on the camera's webrtcReq echo before proceeding.
178
229
 
@@ -328,6 +379,10 @@ class _SdesOpenMixin:
328
379
  rtsp_push_url: Optional[str] = None,
329
380
  talk: bool = False,
330
381
  ice_cands_seen=None,
382
+ # So the SDES answer-wait can abandon at once when the camera says
383
+ # -50002/-50015 instead of waiting out its budget and then paying for a
384
+ # DTLS-fallback offer that cannot succeed.
385
+ terminal_error_fut=None,
331
386
  ) -> "SdesSession":
332
387
  """SDES-SRTP streaming path using a hand-crafted SDP offer and ffmpeg.
333
388
 
@@ -341,6 +396,27 @@ class _SdesOpenMixin:
341
396
  import base64
342
397
  import subprocess
343
398
 
399
+ # Stop the MQTT signaling thread on ANY abnormal exit from this impl.
400
+ # The explicit `outgoing_q.put_nowait(None)` calls further down cover the
401
+ # paths that existed when they were written, but an exception raised
402
+ # before one of them is reached (AidotCameraBusy on a terminal ack, or a
403
+ # CancelledError mid-handshake) skipped the sentinel entirely and left
404
+ # `_mqtt_session_sync` running in an executor thread for its full 3600s.
405
+ # Those orphans all reconnect with the same stable account clientId, so
406
+ # they take the session away from later opens and signaling for other
407
+ # cameras on the account stops being delivered until HA restarts.
408
+ # The wrapper only closes this stack when the impl did NOT return a
409
+ # session, so a live session is unaffected. A duplicate sentinel on the
410
+ # paths that already send one is harmless - the reader exits on the first.
411
+ if outgoing_q is not None:
412
+ def _stop_mqtt_thread() -> None:
413
+ try:
414
+ outgoing_q.put_nowait(None)
415
+ except Exception:
416
+ _LOGGER.debug("camera %s: could not signal MQTT thread to exit",
417
+ getattr(self, "device_id", "?"), exc_info=True)
418
+ _cleanup.callback(_stop_mqtt_thread)
419
+
344
420
  # SDES path: fast_connect's wait-skips / TURN-strip destabilise the SCTP
345
421
  # handshake (session churns ~every 60-90s -> live view drops to snapshot),
346
422
  # so the SDES path always uses the full, stable handshake.
@@ -1052,7 +1128,12 @@ class _SdesOpenMixin:
1052
1128
  # agent can gather its own srflx candidate. Append any TURN entries from
1053
1129
  # ice_config so the camera can allocate a relay and probe our srflx/host
1054
1130
  # candidates when direct connectivity fails (e.g. symmetric NAT).
1055
- _sdes_ice_server_list = [{"Uris": ["stun:stun.l.google.com:19302"]}]
1131
+ # Advertise an ICE server to the camera only when we actually have a
1132
+ # URI for it. With AIDOT_STUN_SERVERS="" this used to put
1133
+ # {"Uris": []} into webrtcReq - an entry the camera must parse and
1134
+ # that names no server.
1135
+ _sdes_stun_uris = stun_server_uris()
1136
+ _sdes_ice_server_list = [{"Uris": _sdes_stun_uris}] if _sdes_stun_uris else []
1056
1137
  _sdes_ice_server_list.extend(_sdes_turn_entries)
1057
1138
  # _psk_value_req was generated before the SDP offer (see above).
1058
1139
  # Reused here in webrtcReq and webrtcResp for consistency.
@@ -1893,20 +1974,42 @@ class _SdesOpenMixin:
1893
1974
  # sends SRTP no matter how long the first-media wait runs.
1894
1975
  # shield() so a timeout here cannot cancel the future the real answer
1895
1976
  # await (and the DTLS-fallback path) still consume further down.
1977
+ # A TERMINAL refusal races the harvest. A camera at its viewer cap acks
1978
+ # -50002/-50015 about a second after webrtcReq and then, by definition,
1979
+ # never answers - so without this the refusal sat unread for the whole
1980
+ # _PRE_LAUNCH_ANSWER_WAIT_S before the later checks could see it, and we
1981
+ # launched a bridge for a stream the camera had already declined.
1896
1982
  if not answer_fut.done():
1897
1983
  _ans_wait_t0 = time.monotonic()
1898
1984
  try:
1899
- await asyncio.wait_for(
1900
- asyncio.shield(answer_fut), timeout=_PRE_LAUNCH_ANSWER_WAIT_S
1901
- )
1902
- _status("webrtcResp answer harvested after %.2fs"
1903
- % (time.monotonic() - _ans_wait_t0))
1904
- except TimeoutError: # asyncio.TimeoutError is an alias since 3.11
1905
- _status(
1906
- "no webrtcResp answer within %.0fs - proceeding without ICE"
1907
- " credentials (nomination will retry during the first-media"
1908
- " wait)" % _PRE_LAUNCH_ANSWER_WAIT_S
1985
+ _harvest_waits = [asyncio.shield(answer_fut)]
1986
+ if terminal_error_fut is not None:
1987
+ _harvest_waits.append(asyncio.shield(terminal_error_fut))
1988
+ _h_done, _h_pending = await asyncio.wait(
1989
+ _harvest_waits,
1990
+ timeout=_PRE_LAUNCH_ANSWER_WAIT_S,
1991
+ return_when=asyncio.FIRST_COMPLETED,
1909
1992
  )
1993
+ # Cancel the shield WRAPPERS only; the futures they guard are
1994
+ # owned by the MQTT handler and are still consumed below.
1995
+ for _h in _h_pending:
1996
+ _h.cancel()
1997
+ if terminal_error_fut is not None and terminal_error_fut.done():
1998
+ _code, _desc = terminal_error_fut.result()
1999
+ _status(f"camera refused: ack {_code} {_desc}"
2000
+ " - terminal, not launching the bridge")
2001
+ raise AidotCameraBusy(_code, _desc)
2002
+ if answer_fut.done():
2003
+ _status("webrtcResp answer harvested after %.2fs"
2004
+ % (time.monotonic() - _ans_wait_t0))
2005
+ else:
2006
+ _status(
2007
+ "no webrtcResp answer within %.0fs - proceeding without ICE"
2008
+ " credentials (nomination will retry during the first-media"
2009
+ " wait)" % _PRE_LAUNCH_ANSWER_WAIT_S
2010
+ )
2011
+ except AidotCameraBusy:
2012
+ raise
1910
2013
  except Exception:
1911
2014
  _LOGGER.debug("camera %s: swallowed exception awaiting answer",
1912
2015
  getattr(self, "device_id", "?"), exc_info=True)
@@ -2613,6 +2716,11 @@ class _SdesOpenMixin:
2613
2716
  # media packet; the keepalive watchdog reads it via SdesSession to
2614
2717
  # restart a session the camera silently stopped feeding.
2615
2718
  _media_progress: list = [0.0]
2719
+ # Shared with the bridge thread: [packets, bytes] actually forwarded to
2720
+ # ffmpeg. The SDES path decodes nothing in-process, so on_frame never
2721
+ # fires and these counters are the only in-process proof media flowed -
2722
+ # see SdesSession.media_stats(), which the live-validation gate reads.
2723
+ _media_counts: list = [0, 0]
2616
2724
 
2617
2725
  def _bridge_fn():
2618
2726
  nonlocal _br_first_di_logged, _br_first_srtp_logged, _br_first_req_dumped
@@ -3681,6 +3789,18 @@ class _SdesOpenMixin:
3681
3789
  _rtp_hdr + _pd_plain,
3682
3790
  ('127.0.0.1', _lo_audio_port),
3683
3791
  )
3792
+ # Count it: media_stats() documents
3793
+ # itself as "media actually forwarded
3794
+ # to ffmpeg by the bridge", and
3795
+ # scripts/live_validate.py uses it as
3796
+ # the SDES release gate. This is a
3797
+ # real forward, so omitting it made a
3798
+ # camera whose audio arrives on the
3799
+ # TUTK path under-report - far enough
3800
+ # to fail an otherwise healthy release.
3801
+ _media_progress[0] = _time_br.monotonic()
3802
+ _media_counts[0] += 1
3803
+ _media_counts[1] += len(_rtp_hdr) + len(_pd_plain)
3684
3804
  if not _br_first_audio_logged:
3685
3805
  _br_first_audio_logged = True
3686
3806
  # Record the payload type this path
@@ -3921,6 +4041,8 @@ class _SdesOpenMixin:
3921
4041
  _fwd_pkt, ('127.0.0.1', _btgt)
3922
4042
  )
3923
4043
  _media_progress[0] = _time_br.monotonic()
4044
+ _media_counts[0] += 1
4045
+ _media_counts[1] += len(_fwd_pkt)
3924
4046
  except Exception:
3925
4047
  _LOGGER.debug("camera %s: swallowed exception in %s", getattr(self, "device_id", "?"), '_bridge_fn', exc_info=True)
3926
4048
  # Periodic ICE controlling check: re-send USE-CANDIDATE every 2.5 s.
@@ -4188,9 +4310,19 @@ class _SdesOpenMixin:
4188
4310
  _early_nominated = bool(_cam_ice_ufrag and _cam_ice_pwd and _cam_ice_cands)
4189
4311
  _media_deadline = time.monotonic() + _FIRST_MEDIA_WAIT_S
4190
4312
  while _first_video_pt[0] is None and time.monotonic() < _media_deadline:
4191
- if not _early_nominated and answer_fut is not None and answer_fut.done():
4313
+ if terminal_error_fut is not None and terminal_error_fut.done():
4314
+ _code, _desc = terminal_error_fut.result()
4315
+ _status(f"camera refused: ack {_code} {_desc}"
4316
+ " - terminal, abandoning the first-media wait")
4317
+ raise AidotCameraBusy(_code, _desc)
4318
+ if (not _early_nominated and answer_fut is not None
4319
+ and answer_fut.done() and not answer_fut.cancelled()):
4192
4320
  # Read-only peek: the real await below still consumes this
4193
4321
  # future and drives the answer/fallback paths unchanged.
4322
+ # ``cancelled()`` is checked explicitly because the answer-wait
4323
+ # cancels this future on timeout, and ``.exception()`` on a
4324
+ # cancelled future raises CancelledError - a BaseException that
4325
+ # the ``except Exception`` below would NOT catch.
4194
4326
  _peek_sdp = ""
4195
4327
  try:
4196
4328
  if answer_fut.exception() is None:
@@ -4214,7 +4346,8 @@ class _SdesOpenMixin:
4214
4346
  # Which single payload type (if any) each line can be narrowed to. Audio
4215
4347
  # is usable only when its type was actually observed; otherwise the "0 8"
4216
4348
  # line cannot be narrowed.
4217
- if _vpt not in _SDP_VIDEO_PTS and answer_fut is not None and answer_fut.done():
4349
+ if (_vpt not in _SDP_VIDEO_PTS and answer_fut is not None
4350
+ and answer_fut.done() and not answer_fut.cancelled()):
4218
4351
  # No video packet arrived inside the window, but the camera's answer
4219
4352
  # still names the codec it agreed to send. Narrowing on that beats
4220
4353
  # leaving both codecs advertised: an unnarrowed video line makes
@@ -4404,7 +4537,16 @@ class _SdesOpenMixin:
4404
4537
  else min(timeout, 8.0 if dtls_fallback_ok else 20.0)
4405
4538
  )
4406
4539
  try:
4407
- answer = await asyncio.wait_for(answer_fut, timeout=_sdes_answer_timeout)
4540
+ # Race the answer against a TERMINAL refusal (-50002 max-streams /
4541
+ # -50015 SD-cap). Without this the SDES path is blind to a camera
4542
+ # that explicitly said no: it waited out its whole answer budget and
4543
+ # then handed a "no answer" to the DTLS fallback, which sent a second
4544
+ # offer and only THEN surfaced AidotCameraBusy. Measured ~48s to
4545
+ # report a refusal that arrived in about one second - and two of the
4546
+ # three validated models (A001513, A001064) take this path.
4547
+ answer = await _sdes_await_answer_or_terminal(
4548
+ answer_fut, terminal_error_fut, _sdes_answer_timeout, _status,
4549
+ )
4408
4550
  _ans_sdp = answer.get("sdp", "")
4409
4551
  _ans_mlines = [ln for ln in _ans_sdp.splitlines() if ln.startswith("m=")]
4410
4552
  _status(
@@ -4709,5 +4851,8 @@ class _SdesOpenMixin:
4709
4851
  cmd_chan=_cmd_chan,
4710
4852
  talk_state=_talk_state,
4711
4853
  media_progress=_media_progress,
4854
+ media_counts=_media_counts,
4712
4855
  teardown_requested=_teardown_holder,
4856
+ first_video_pt=_first_video_pt,
4857
+ first_audio_pt=_first_audio_pt,
4713
4858
  )
@@ -17,7 +17,7 @@ import logging
17
17
  from typing import Callable, Optional
18
18
 
19
19
  from ..exceptions import AidotCameraBusy, AidotCameraNotReady
20
- from .constants import _LIVE_PLAY_NOT_READY
20
+ from .constants import _LIVE_PLAY_NOT_READY, fallback_turn_uris, stun_server_uris
21
21
  from .models import VideoFrame
22
22
  from .webrtc import WebRTCSession
23
23
  from .protocol import (
@@ -705,6 +705,26 @@ class _WebRTCOpenMixin:
705
705
  # AidotCameraBusy instead of timing out into a generic retry.
706
706
  _term = _terminal_webrtc_ack(msg)
707
707
  if _term is not None:
708
+ # Isolate per camera/stream BEFORE accepting the refusal. We
709
+ # subscribe to the account-wide topic (iot/v1/c/{user_id}/#),
710
+ # so without this a -50002 for a DIFFERENT camera aborts this
711
+ # camera's healthy open with AidotCameraBusy. That matters
712
+ # now that the SDES path polls terminal_error_fut across the
713
+ # whole first-media wait rather than reading it once.
714
+ # Same accept rule the answer branch below uses: an exact
715
+ # peerid match, or BOTH devId and dstAddr (dstAddr alone is
716
+ # the shared account id and matches every camera).
717
+ _t_pid = inner.get("peerid")
718
+ if not (_t_pid == peer_id
719
+ or (inner.get("devId") == device_id
720
+ and inner.get("dstAddr") == user_id)):
721
+ loop.call_soon_threadsafe(
722
+ lambda rp=_t_pid, c=_term[0]: _status(
723
+ f"terminal ack {c} IGNORED - belongs to another"
724
+ f" camera/stream: peerid {rp!r}"
725
+ )
726
+ )
727
+ return
708
728
  if not terminal_error_fut.done():
709
729
  loop.call_soon_threadsafe(terminal_error_fut.set_result, _term)
710
730
  return
@@ -1302,6 +1322,7 @@ class _WebRTCOpenMixin:
1302
1322
  rtsp_push_url=rtsp_push_url,
1303
1323
  talk=talk,
1304
1324
  ice_cands_seen=ice_cands_seen,
1325
+ terminal_error_fut=terminal_error_fut,
1305
1326
  )
1306
1327
  # Success: the SdesSession now owns outgoing_q + mqtt_fut and
1307
1328
  # reaps them on stop(); clear the backstop slot so a concurrent
@@ -1372,7 +1393,11 @@ class _WebRTCOpenMixin:
1372
1393
  result.append(u)
1373
1394
  return result
1374
1395
 
1375
- _ice_servers = [RTCIceServer(urls=["stun:stun.l.google.com:19302"])]
1396
+ # Omit the entry entirely when the URI list is empty (STUN disabled
1397
+ # via AIDOT_STUN_SERVERS=""): RTCIceServer(urls=[]) is not the same as
1398
+ # "no server" - it is an ICE server that advertises nothing.
1399
+ _stun_uris = stun_server_uris()
1400
+ _ice_servers = [RTCIceServer(urls=_stun_uris)] if _stun_uris else []
1376
1401
  if ice_config_fut.done():
1377
1402
  try:
1378
1403
  _ice_data = ice_config_fut.result()
@@ -1478,10 +1503,10 @@ class _WebRTCOpenMixin:
1478
1503
  # that are on a different network segment or behind strict NAT. The Arnoo
1479
1504
  # TURN server (confirmed from browser webrtc_internals - ALL candidates are
1480
1505
  # relay type) accepts connections without explicit per-session credentials.
1481
- _arnoo_stun = "stun:3.230.182.123:3478"
1482
- _arnoo_turn = "turn:3.230.182.123:5349"
1483
- if not _has_turn_in_resp:
1484
- _ice_servers.append(RTCIceServer(urls=[_arnoo_stun, _arnoo_turn]))
1506
+ # AIDOT_TURN_SERVERS overrides the hardcoded pair (empty disables it).
1507
+ _arnoo_fallback = fallback_turn_uris()
1508
+ if not _has_turn_in_resp and _arnoo_fallback:
1509
+ _ice_servers.append(RTCIceServer(urls=_arnoo_fallback))
1485
1510
  # Log the final ICE server configuration.
1486
1511
  _stun_url = (_ice_servers[0].urls[0]
1487
1512
  if _ice_servers and _ice_servers[0].urls
@@ -1512,9 +1537,10 @@ class _WebRTCOpenMixin:
1512
1537
  RTCIceServer(urls=_su, username=_srv.username,
1513
1538
  credential=_srv.credential)
1514
1539
  )
1515
- _ice_servers = _stun_only or [
1516
- RTCIceServer(urls=["stun:stun.l.google.com:19302"])
1517
- ]
1540
+ _fallback_stun = stun_server_uris()
1541
+ _ice_servers = _stun_only or (
1542
+ [RTCIceServer(urls=_fallback_stun)] if _fallback_stun else []
1543
+ )
1518
1544
  _status(
1519
1545
  "AIDOT_FAST_CONNECT: stripped TURN (STUN-only, LAN-direct) -"
1520
1546
  f" {len(_ice_servers)} ICE server(s), no relay gather stall"
@@ -635,9 +635,13 @@ class CameraClient(_UpstreamAidotClient):
635
635
  _LOGGER.warning("_async_fetch_user_config: no HTTP session")
636
636
  return
637
637
 
638
+ # Imported here, not at module scope: aidot_cameras.camera.__init__
639
+ # re-exports camera.client, which imports back through device_client -
640
+ # a top-level import would close that cycle during package init.
641
+ from .camera.constants import aidot_api_base
642
+
638
643
  region = account_region(self)
639
- base = f"https://prod-{region}-api.arnoo.com"
640
- url = f"{base}/commons/userConfig"
644
+ url = f"{aidot_api_base(region)}/commons/userConfig"
641
645
  headers = {
642
646
  "appid": _CLOUD_APP_ID,
643
647
  "owner": user_id,
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-aidot-cameras"
7
- version = "0.14.0"
7
+ version = "0.15.0b2"
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"
@@ -84,6 +84,21 @@ include = ["aidot_cameras*"]
84
84
  [tool.setuptools.package-data]
85
85
  aidot_cameras = ["py.typed"]
86
86
 
87
+ [tool.pytest.ini_options]
88
+ # Test tiers. A bare `pytest tests/` must never touch hardware: the `live`
89
+ # marker (real cameras on the LAN, release gate only) is deselected by
90
+ # default and has to be requested explicitly with `-m live`.
91
+ markers = [
92
+ "e2e: fake-lab end-to-end tests (fake cloud / MQTT broker / camera / go2rtc)",
93
+ "live: requires real cameras on the LAN; release-gate tier, never selected by default",
94
+ "mux: exercises the real ffmpeg/libavformat mux path",
95
+ ]
96
+ addopts = "-m 'not live'"
97
+ # The e2e tier uses async fixtures (fake broker / cloud servers); auto mode
98
+ # keeps them working without decorating every test.
99
+ asyncio_mode = "auto"
100
+ asyncio_default_fixture_loop_scope = "function"
101
+
87
102
  [tool.coverage.run]
88
103
  # Vendored third-party code (aiortc) is exercised via streaming, not our tests;
89
104
  # keep it out of the coverage report so the numbers reflect our own code.