python-aidot-cameras 0.16.0__tar.gz → 0.17.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (186) hide show
  1. {python_aidot_cameras-0.16.0/python_aidot_cameras.egg-info → python_aidot_cameras-0.17.0}/PKG-INFO +1 -1
  2. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/client.py +90 -7
  3. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/controls.py +30 -5
  4. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/protocol.py +63 -0
  5. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/sdes.py +52 -3
  6. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/sdes_open.py +43 -0
  7. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/webrtc.py +17 -3
  8. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/pyproject.toml +1 -1
  9. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  10. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/python_aidot_cameras.egg-info/SOURCES.txt +4 -0
  11. python_aidot_cameras-0.17.0/tests/test_busy_backoff.py +35 -0
  12. python_aidot_cameras-0.17.0/tests/test_control_verdicts.py +277 -0
  13. python_aidot_cameras-0.17.0/tests/test_device_cmd_seq_match.py +85 -0
  14. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_resolution_persists.py +15 -2
  15. python_aidot_cameras-0.17.0/tests/test_sdes_sctp_abort.py +102 -0
  16. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_sdes_terminal_ack.py +8 -2
  17. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/LICENSE +0 -0
  18. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/MANIFEST.in +0 -0
  19. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/README.md +0 -0
  20. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/__init__.py +0 -0
  21. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/__main__.py +0 -0
  22. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_upstream.py +0 -0
  23. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/__init__.py +0 -0
  24. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/__init__.py +0 -0
  25. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/clock.py +0 -0
  26. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/codecs/__init__.py +0 -0
  27. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/codecs/base.py +0 -0
  28. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/codecs/g711.py +0 -0
  29. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/codecs/g722.py +0 -0
  30. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/codecs/h264.py +0 -0
  31. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/codecs/opus.py +0 -0
  32. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/codecs/vpx.py +0 -0
  33. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/contrib/__init__.py +0 -0
  34. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/contrib/media.py +0 -0
  35. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/contrib/signaling.py +0 -0
  36. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/events.py +0 -0
  37. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/exceptions.py +0 -0
  38. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/jitterbuffer.py +0 -0
  39. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/mediastreams.py +0 -0
  40. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/rate.py +0 -0
  41. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/rtcconfiguration.py +0 -0
  42. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/rtcdatachannel.py +0 -0
  43. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/rtcdtlstransport.py +0 -0
  44. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/rtcicetransport.py +0 -0
  45. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/rtcpeerconnection.py +0 -0
  46. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/rtcrtpparameters.py +0 -0
  47. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/rtcrtpreceiver.py +0 -0
  48. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/rtcrtpsender.py +0 -0
  49. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/rtcrtptransceiver.py +0 -0
  50. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/rtcsctptransport.py +0 -0
  51. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/rtcsessiondescription.py +0 -0
  52. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/rtp.py +0 -0
  53. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/sdp.py +0 -0
  54. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/stats.py +0 -0
  55. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/utils.py +0 -0
  56. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/ble_gateway.py +0 -0
  57. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/__init__.py +0 -0
  58. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/constants.py +0 -0
  59. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/go2rtc.py +0 -0
  60. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/hwaccel.py +0 -0
  61. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/lan_control.py +0 -0
  62. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/models.py +0 -0
  63. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/playback.py +0 -0
  64. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/tutk.py +0 -0
  65. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/webrtc_open.py +0 -0
  66. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/client.py +0 -0
  67. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/const.py +0 -0
  68. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/credentials.py +0 -0
  69. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/crypto.py +0 -0
  70. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/device_client.py +0 -0
  71. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/discover.py +0 -0
  72. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/exceptions.py +0 -0
  73. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/g711.py +0 -0
  74. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/py.typed +0 -0
  75. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  76. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
  77. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/python_aidot_cameras.egg-info/requires.txt +0 -0
  78. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/python_aidot_cameras.egg-info/top_level.txt +0 -0
  79. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/setup.cfg +0 -0
  80. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_aioice_compat.py +0 -0
  81. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_alarm_event.py +0 -0
  82. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_answer_sdp_video_pt.py +0 -0
  83. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_audio_mux_offline.py +0 -0
  84. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_avio_request.py +0 -0
  85. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_avio_response_parse.py +0 -0
  86. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_avio_response_router.py +0 -0
  87. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_backoff.py +0 -0
  88. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_battery_detection.py +0 -0
  89. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_battery_evidence_corroboration.py +0 -0
  90. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_battery_skip_turn.py +0 -0
  91. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_ble_gateway.py +0 -0
  92. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_bridge_state_sentinels.py +0 -0
  93. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_constants_imports_resolve.py +0 -0
  94. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_deferred_hardening.py +0 -0
  95. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_device_login_guard.py +0 -0
  96. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_device_user_info_cache.py +0 -0
  97. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_dormant_camera_cleanup.py +0 -0
  98. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_dtls_mux_terminal_write.py +0 -0
  99. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_dtls_not_ready_burst.py +0 -0
  100. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_dtls_pinning.py +0 -0
  101. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_dtls_serve_decode_skip.py +0 -0
  102. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_dtls_serve_open_timeout.py +0 -0
  103. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_dtls_skip_signaling_wait.py +0 -0
  104. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_dtls_slow_probe.py +0 -0
  105. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_dtls_stdout_producer.py +0 -0
  106. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_egress_guard.py +0 -0
  107. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_env_seams.py +0 -0
  108. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_go2rtc.py +0 -0
  109. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_go2rtc_cli.py +0 -0
  110. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_h264_decode_rate_limit.py +0 -0
  111. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_highport_nomination.py +0 -0
  112. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_hwaccel_probe.py +0 -0
  113. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_ice_config_cache.py +0 -0
  114. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_ice_peer_reflexive.py +0 -0
  115. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_idle_release_both_loops.py +0 -0
  116. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_keepalive_renew.py +0 -0
  117. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_key_restart_sdp.py +0 -0
  118. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_keyframe_prompter.py +0 -0
  119. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_lan_control.py +0 -0
  120. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_light_active_color_mode.py +0 -0
  121. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_live_stream_param.py +0 -0
  122. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_live_validate_policy.py +0 -0
  123. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_logging_caps.py +0 -0
  124. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_media_socket_rcvbuf.py +0 -0
  125. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_media_wait_floor.py +0 -0
  126. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_motion_poll.py +0 -0
  127. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_mqtt_credential_refetch.py +0 -0
  128. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_mqtt_credential_self_heal.py +0 -0
  129. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_mqtt_password_not_persisted.py +0 -0
  130. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_mqtt_session_reconnect.py +0 -0
  131. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_narrow_pc_ice.py +0 -0
  132. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_no_self_referential_go2rtc.py +0 -0
  133. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_no_undefined_names.py +0 -0
  134. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_offline_keepalive_pause.py +0 -0
  135. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_open_fail_logger.py +0 -0
  136. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_open_gate_delay.py +0 -0
  137. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_peerid_reuse.py +0 -0
  138. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_persistent_mqtt.py +0 -0
  139. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_persistent_mqtt_robustness.py +0 -0
  140. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_playback_tls.py +0 -0
  141. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_post_merge_hardening.py +0 -0
  142. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_restart_cycle.py +0 -0
  143. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_retry_policy.py +0 -0
  144. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_scripts_importable.py +0 -0
  145. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_sdes_adaptive.py +0 -0
  146. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_sdes_avio_dispatch.py +0 -0
  147. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_sdes_bridge_break_gate.py +0 -0
  148. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_sdes_echo_wait_timeout.py +0 -0
  149. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_sdes_fast_liveplay.py +0 -0
  150. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_sdes_ffmpeg_stderr_log.py +0 -0
  151. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_sdes_idle_release.py +0 -0
  152. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_sdes_open_cleanup.py +0 -0
  153. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_sdes_serve_audio.py +0 -0
  154. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_sdes_serve_cmd.py +0 -0
  155. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_sdes_serve_stderr_drain.py +0 -0
  156. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_sdes_sprop.py +0 -0
  157. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_sdes_talk.py +0 -0
  158. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_sdes_teardown_exit_log.py +0 -0
  159. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_sdes_terminal_ack_latency.py +0 -0
  160. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_sdes_watchdog.py +0 -0
  161. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_sdp_narrowing_properties.py +0 -0
  162. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_sdp_payload_type_narrowing.py +0 -0
  163. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_security_hardening.py +0 -0
  164. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_serializable_login_info.py +0 -0
  165. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_serve_loss_tolerance.py +0 -0
  166. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_serve_relay.py +0 -0
  167. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_session_stats.py +0 -0
  168. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_shared_house_seam.py +0 -0
  169. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_speak.py +0 -0
  170. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_sprop_staleness.py +0 -0
  171. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_stream_cap.py +0 -0
  172. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_stream_drain_ownership.py +0 -0
  173. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_stream_idle.py +0 -0
  174. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_stream_slot_cap.py +0 -0
  175. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_stream_teardown.py +0 -0
  176. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_talk.py +0 -0
  177. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_terminal_ack.py +0 -0
  178. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_token_file_robustness.py +0 -0
  179. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_token_refresh.py +0 -0
  180. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_turn_create_permission.py +0 -0
  181. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_upstream_compat.py +0 -0
  182. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_viewer_check_throttle.py +0 -0
  183. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_viewer_presence.py +0 -0
  184. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_viewer_present_push_mode.py +0 -0
  185. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_wait_or_event.py +0 -0
  186. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_wake_readiness_retry.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-aidot-cameras
3
- Version: 0.16.0
3
+ Version: 0.17.0
4
4
  Summary: Control AiDot/Leedarson WiFi lights and cameras (WebRTC streaming, two-way audio, PTZ, controls)
5
5
  Author-email: cbrightly <chris.brightly@gmail.com>
6
6
  License-Expression: MIT
@@ -376,6 +376,18 @@ _SERVE_REORDER_QUEUE = int(os.environ.get("AIDOT_SERVE_REORDER_QUEUE", "500"))
376
376
  #: failure this is meant to prevent.
377
377
  _SERVE_MAX_DELAY_US = int(os.environ.get("AIDOT_SERVE_MAX_DELAY_US", "500000"))
378
378
 
379
+ #: How long to wait after a camera answers -50002 / -50015 ("no free session").
380
+ #: Both loops used to wait _MAX_DELAY (300s) on the belief that the camera
381
+ #: "releases slowly". Measured 2026-08-07 on an A001064: reopening 2s after a
382
+ #: close is refused, 8s reopens cleanly, every time - and that was minting a
383
+ #: fresh peerid each attempt, the case the old comment warned about. A camera
384
+ #: that clears in seconds paired with a five-minute refusal to retry is
385
+ #: indistinguishable from one that needs a long rest, which is the likeliest
386
+ #: origin of this project's "battery cameras need 30-45 minutes" rule.
387
+ #: Still a real wait: retrying instantly would hammer a camera that genuinely
388
+ #: has none free, and on a battery model risks a wake-then-sleep loop.
389
+ _BUSY_BACKOFF_S = float(os.environ.get("AIDOT_BUSY_BACKOFF_S", "20"))
390
+
379
391
 
380
392
  def _build_sdes_serve_cmd(
381
393
  *,
@@ -773,6 +785,39 @@ _KEEPALIVE_WINDOW_S = 25
773
785
  _KEEPALIVE_RENEW_S = 20
774
786
 
775
787
 
788
+ def _ack_matches_seq(msg, seq):
789
+ """Is ``msg`` the device's answer to the command we sent as ``seq``?
790
+
791
+ ``True`` for our own 200, the integer response code for our own non-200,
792
+ and ``False`` for anything that is not attributable to us - another
793
+ command's ack, the battery wake response, a message with no seq at all.
794
+
795
+ Correlating matters because several messages are in flight on the same four
796
+ wildcard subscriptions: battery cameras get a ``lowPowerActiveStateReq``
797
+ published alongside the write, and Home Assistant writes attributes in
798
+ bursts. Accepting a stranger's 200 reports that one control landed on the
799
+ evidence of a different one landing.
800
+ """
801
+ if not isinstance(msg, dict) or seq is None:
802
+ return False
803
+ got = msg.get("seq")
804
+ if got is None:
805
+ return False
806
+ # We send a string; nothing obliges the camera to echo the same type.
807
+ if str(got) != str(seq):
808
+ return False
809
+ code = (msg.get("ack") or {}).get("code")
810
+ if code is None:
811
+ code = msg.get("code")
812
+ if code is None:
813
+ inner = msg.get("payload") or {}
814
+ if isinstance(inner, dict):
815
+ code = inner.get("code")
816
+ if code == 200:
817
+ return True
818
+ return code if isinstance(code, int) else False
819
+
820
+
776
821
  class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
777
822
  """All camera/streaming methods, mixed into DeviceClient via inheritance."""
778
823
 
@@ -1902,6 +1947,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
1902
1947
  *,
1903
1948
  timeout: float = 8.0,
1904
1949
  ack_keyword: str = "setDevAttr",
1950
+ seq: "Optional[str]" = None,
1905
1951
  ) -> bool:
1906
1952
  """Connect MQTT, optionally wake battery camera, publish command, wait for ack.
1907
1953
 
@@ -1980,6 +2026,36 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
1980
2026
  duration=timeout,
1981
2027
  )
1982
2028
 
2029
+ # Two passes, because being sure is worth more than being quick. A
2030
+ # response carrying OUR seq is proof; anything else on these topics is
2031
+ # only evidence that SOMETHING was acked - and several things are in
2032
+ # flight here (the battery wake goes out alongside the write, and Home
2033
+ # Assistant writes attributes in bursts).
2034
+ for topic, raw in messages:
2035
+ if ack_keyword and ack_keyword not in topic:
2036
+ continue
2037
+ try:
2038
+ msg = _json.loads(raw)
2039
+ except Exception:
2040
+ continue
2041
+ mine = _ack_matches_seq(msg, seq)
2042
+ if mine is True:
2043
+ _LOGGER.debug("device cmd ack 200 for seq=%s: topic=%s", seq, topic)
2044
+ return True
2045
+ if mine is not False:
2046
+ # The camera answered OUR command with a non-200. Logged, not
2047
+ # acted on: whether this firmware ever sends one is unknown, and
2048
+ # turning it into a failure on a guess would report every
2049
+ # working write as broken the moment the guess is wrong. If
2050
+ # these show up in the wild, that is the evidence needed to make
2051
+ # it a real error.
2052
+ _LOGGER.warning(
2053
+ "device cmd for %s answered with code %s (seq=%s, topic=%s) "
2054
+ "- reporting the command as sent anyway; please report this "
2055
+ "log line, it is the evidence needed to handle it properly",
2056
+ device_id, mine, seq, topic,
2057
+ )
2058
+
1983
2059
  for topic, raw in messages:
1984
2060
  if ack_keyword and ack_keyword not in topic:
1985
2061
  continue
@@ -1987,14 +2063,17 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
1987
2063
  msg = _json.loads(raw)
1988
2064
  except Exception:
1989
2065
  continue
2066
+ if _ack_matches_seq(msg, seq) is not False:
2067
+ continue # already considered above
1990
2068
  code = (msg.get("ack") or {}).get("code") or msg.get("code")
1991
2069
  if code == 200:
1992
- _LOGGER.debug("device cmd ack 200: topic=%s", topic)
2070
+ _LOGGER.debug("device cmd ack 200 (unattributed): topic=%s", topic)
1993
2071
  return True
1994
2072
  # Some firmware ACKs with code in payload.payload
1995
2073
  inner = msg.get("payload") or {}
1996
2074
  if isinstance(inner, dict) and inner.get("code") == 200:
1997
- _LOGGER.debug("device cmd ack 200 (inner): topic=%s", topic)
2075
+ _LOGGER.debug(
2076
+ "device cmd ack 200 (inner, unattributed): topic=%s", topic)
1998
2077
  return True
1999
2078
 
2000
2079
  # Fire-and-forget fallback: the official app uses a delivery callback,
@@ -2070,7 +2149,8 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2070
2149
  pub_topic = f"iot/v1/c/{device_id}/device/setDevAttrReq"
2071
2150
  _LOGGER.info("setDevAttrReq: %s=%s -> %s seq=%s", attr, value, device_id, seq)
2072
2151
  ok = await self._mqtt_device_cmd(
2073
- pub_topic, payload, timeout=timeout, ack_keyword="setDevAttr")
2152
+ pub_topic, payload, timeout=timeout, ack_keyword="setDevAttr",
2153
+ seq=seq)
2074
2154
  if ok:
2075
2155
  # Reflect the change in local status right away (optimistic), so HA
2076
2156
  # control entities show the new value immediately instead of waiting
@@ -3352,14 +3432,17 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3352
3432
  # wake-then-sleep loop that serves nothing. Wait out the release
3353
3433
  # window instead. (The DTLS loops have always done this; the SDES
3354
3434
  # loop silently did not.)
3435
+ #
3436
+ # That release window is now measured rather than assumed: 8s is
3437
+ # enough, 2s is not. See _BUSY_BACKOFF_S.
3355
3438
  self._fast_attempt_override = None
3356
3439
  _LOGGER.warning(
3357
3440
  "SDES keepalive: camera %s refused the stream (%s) - "
3358
3441
  "backing off %.0fs so its sessions can be released",
3359
- self.device_id, busy, _MAX_DELAY,
3442
+ self.device_id, busy, _BUSY_BACKOFF_S,
3360
3443
  )
3361
3444
  try:
3362
- await asyncio.sleep(_MAX_DELAY)
3445
+ await asyncio.sleep(_BUSY_BACKOFF_S)
3363
3446
  except asyncio.CancelledError:
3364
3447
  return
3365
3448
  continue
@@ -3577,10 +3660,10 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3577
3660
  # before trying again rather than hammering it.
3578
3661
  _LOGGER.warning(
3579
3662
  "Stream refused for %s (%s) - backing off %.0fs before retry",
3580
- self.device_id, busy, _MAX_DELAY,
3663
+ self.device_id, busy, _BUSY_BACKOFF_S,
3581
3664
  )
3582
3665
  try:
3583
- await asyncio.sleep(_MAX_DELAY)
3666
+ await asyncio.sleep(_BUSY_BACKOFF_S)
3584
3667
  except asyncio.CancelledError:
3585
3668
  return
3586
3669
  continue
@@ -13,6 +13,11 @@ from .constants import _PTZ_DIR_CODES, _STREAM_QUALITY, SETSTREAMCTRL_CMD
13
13
 
14
14
  _LOGGER = logging.getLogger(__name__)
15
15
 
16
+ #: The camera's ack for SETSTREAMCTRL (800).
17
+ SETSTREAMCTRL_RESP_CMD = 801
18
+ #: Measured 0.01-0.03s; short so a control call never holds up a view.
19
+ _SETSTREAMCTRL_ACK_S = 1.5
20
+
16
21
 
17
22
  class _CameraControlsMixin:
18
23
  """Device-control setters mixed into CameraMixin."""
@@ -166,12 +171,32 @@ class _CameraControlsMixin:
166
171
  "resolution %s remembered; the camera is not streaming, so it "
167
172
  "will be applied when a session next starts", quality)
168
173
  return True
169
- ok = session._avio_cmd(SETSTREAMCTRL_CMD, payload)
170
- _LOGGER.debug(
171
- "set resolution %s (quality=%d) -> %s",
172
- quality, q, "sent" if ok else "no channel yet",
174
+ # Ask, and read what comes back. The camera acks with 801 in
175
+ # 0.01-0.03s (measured 2026-08-07 on an A000088) and reports the new
176
+ # value through GETSTREAMCTRL afterwards, so the command is genuinely
177
+ # accepted - it simply does not change the encode.
178
+ #
179
+ # The RETURN value deliberately still means "the command went out". An
180
+ # A001064 answers SPEAKERSTART but does not implement GETSTREAMCTRL at
181
+ # all, so how much a given firmware answers is model-dependent, and
182
+ # turning silence into a failure would break this call on exactly the
183
+ # quieter cameras. Reading the reply is what we could not do before; it
184
+ # makes a refusal visible instead of indistinguishable from success.
185
+ reply = await session.async_avio_request(
186
+ SETSTREAMCTRL_CMD, payload,
187
+ response_cmd=SETSTREAMCTRL_RESP_CMD, timeout=_SETSTREAMCTRL_ACK_S,
173
188
  )
174
- return ok
189
+ if reply is None:
190
+ _LOGGER.debug(
191
+ "set resolution %s (quality=%d): sent, no ack within %.1fs",
192
+ quality, q, _SETSTREAMCTRL_ACK_S,
193
+ )
194
+ else:
195
+ _LOGGER.debug(
196
+ "set resolution %s (quality=%d): camera acked %d payload=%s",
197
+ quality, q, reply.command, reply.payload.hex() or "<empty>",
198
+ )
199
+ return True
175
200
 
176
201
  def _apply_pending_resolution(self) -> None:
177
202
  """Re-send the remembered resolution now that a session exists.
@@ -2356,6 +2356,69 @@ def _avio_cmd_id(frame: bytes) -> "Optional[int]":
2356
2356
  AVIO_RESPONSE_TIMEOUT_S = 2.0
2357
2357
 
2358
2358
 
2359
+ #: How long to wait for the camera's SPEAKERSTART ack before opening the mic
2360
+ #: anyway. Measured 0.01-0.86s across both transports; this is generous enough
2361
+ #: to cover a slow SDES open without making a press-to-talk feel stuck.
2362
+ SPEAKER_ACK_TIMEOUT_S = 2.0
2363
+
2364
+ #: Ack payloads observed for SPEAKERSTART, all of them on speakers that opened:
2365
+ #: 0x0064 (M3 Pro / DTLS, PTZ / SDES, and an L2) and 0x00c8 (the same L2, on the
2366
+ #: very next session). One camera returning both across consecutive successful
2367
+ #: opens is what settles it - the payload is not a verdict, so there is nothing
2368
+ #: here to read as a refusal.
2369
+ _SPEAKER_ACKS_SEEN = (b"\x00\x64", b"\x00\xc8")
2370
+
2371
+
2372
+ async def _speaker_opened(session, cmd: int, resp_cmd: int, timeout: float) -> bool:
2373
+ """Send SPEAKERSTART and decide whether the camera opened its speaker.
2374
+
2375
+ Everything currently counts as success, and that is the honest position
2376
+ rather than a placeholder. Silence does, because an A001064 answers 848 but
2377
+ does not implement 802 at all - a firmware with no response for a command is
2378
+ a real and common state, and failing on it would remove two-way audio from
2379
+ every camera quieter than the one measured. And any ack does, because no
2380
+ refusal has ever been observed: the payload varies across models AND across
2381
+ consecutive sessions on one camera, so it carries no verdict to read.
2382
+
2383
+ An earlier version treated 0x0064 as "the" success value and any other
2384
+ two-byte payload as a refusal. That was a discriminator invented from a
2385
+ single sample, and it would have disabled talk outright on an L2, which
2386
+ answers 0x00c8 about half the time. The payload is logged instead, so a
2387
+ genuine refusal can be recognised from evidence when one appears.
2388
+ """
2389
+ waiter = session._avio_responses.expect(resp_cmd)
2390
+ try:
2391
+ sent = session._avio_cmd(cmd, b"\x00" * 8)
2392
+ except Exception:
2393
+ sent = False
2394
+ if not sent:
2395
+ waiter.cancel()
2396
+ return True # no channel to refuse on; unchanged from before
2397
+ return await _speaker_ack_accepted(waiter, timeout)
2398
+
2399
+
2400
+ async def _speaker_ack_accepted(waiter, timeout: float) -> bool:
2401
+ """Read a SPEAKERSTART ack, defaulting to acceptance on anything unfamiliar.
2402
+
2403
+ Shared by both transports because the decision is the same, even though the
2404
+ send is not: on SDES the command leaves from the bridge thread and this only
2405
+ ever waits.
2406
+ """
2407
+ reply = await waiter.result(timeout)
2408
+ if reply is None:
2409
+ _LOGGER.debug("SPEAKERSTART: no ack within %.1fs - treating as opened", timeout)
2410
+ return True
2411
+ if reply.payload not in _SPEAKER_ACKS_SEEN:
2412
+ _LOGGER.info(
2413
+ "SPEAKERSTART acked with an unrecorded payload %s - treating the "
2414
+ "speaker as open. Please report this: the payloads seen so far all "
2415
+ "belong to speakers that opened, so a genuine refusal has never "
2416
+ "been identified and this may be the first",
2417
+ reply.payload.hex() or "<empty>",
2418
+ )
2419
+ return True
2420
+
2421
+
2359
2422
  class AvioRequestMixin:
2360
2423
  """Ask an AVIO command and wait for the camera's reply.
2361
2424
 
@@ -9,11 +9,25 @@ import asyncio
9
9
  import logging
10
10
  from typing import Callable, Optional
11
11
 
12
- from .constants import SDES_TALK_PUMP_IDLE_TICK
13
- from .protocol import AvioRequestMixin, AvioResponseRouter
12
+ from .constants import SDES_SPEAKERSTART_DELAY, SDES_TALK_PUMP_IDLE_TICK
13
+ from .protocol import (
14
+ SPEAKER_ACK_TIMEOUT_S,
15
+ AvioRequestMixin,
16
+ AvioResponseRouter,
17
+ _speaker_ack_accepted,
18
+ )
14
19
 
15
20
  _LOGGER = logging.getLogger(__name__)
16
21
 
22
+ #: The bridge does not send SPEAKERSTART the moment talk starts - it waits
23
+ #: SDES_SPEAKERSTART_DELAY after the command channel is up, because a
24
+ #: SPEAKERSTART sent too early is ignored and never acked. The ack budget
25
+ #: has to cover that delay as well as the round trip, or the wait expires
26
+ #: before the command has even left and every press-to-talk pays the full
27
+ #: timeout for no verdict.
28
+ SDES_SPEAKER_ACK_TIMEOUT_S = SDES_SPEAKERSTART_DELAY + SPEAKER_ACK_TIMEOUT_S
29
+
30
+
17
31
 
18
32
  class SdesSession(AvioRequestMixin):
19
33
  """Active SDES-SRTP stream session managed by an ffmpeg subprocess.
@@ -41,6 +55,7 @@ class SdesSession(AvioRequestMixin):
41
55
  first_audio_pt=None,
42
56
  device_id=None,
43
57
  responses=None,
58
+ abort_chan=None,
44
59
  ) -> None:
45
60
  # Which camera this session belongs to, for logging only. Optional so
46
61
  # an existing caller that does not pass it still works; the logs then
@@ -87,6 +102,12 @@ class SdesSession(AvioRequestMixin):
87
102
  self._avio_responses = (
88
103
  responses if responses is not None else AvioResponseRouter()
89
104
  )
105
+ # Mutable one-element list the bridge fills with a callable that sends
106
+ # an SCTP ABORT. Unlike SPEAKERSTART this is safe to call from here:
107
+ # ABORT carries no TSN, so there is nothing for it to race, and it is
108
+ # the last thing the association ever sees. None when the caller
109
+ # supplied no holder - teardown then behaves exactly as it did before.
110
+ self._abort_chan = abort_chan if abort_chan is not None else [None]
90
111
  # Shared talk state (dict) for outbound two-way audio, or None when the
91
112
  # session was not opened talk-capable (offer stayed recvonly). Populated
92
113
  # by the bridge (camera audio addr) and by async_start_talk (provider).
@@ -138,13 +159,24 @@ class SdesSession(AvioRequestMixin):
138
159
  # SPEAKERSTART(848) is sent by the BRIDGE thread (not here) once the SCTP
139
160
  # command channel is up - all SCTP DATA must stay on one thread or the
140
161
  # command races the heartbeat's TSN and the camera drops it.
162
+ # Register interest BEFORE the pump starts, so the ack cannot arrive
163
+ # while nothing is listening. Note this does NOT send 848 - the bridge
164
+ # thread does, once want_speaker is set. Moving the send here would put
165
+ # SCTP DATA on a second thread, racing the heartbeat's TSN.
166
+ waiter = self._avio_responses.expect(851)
141
167
  if self._talk_thread is None or not self._talk_thread.is_alive():
142
168
  import threading
143
169
  self._talk_thread = threading.Thread(
144
170
  target=_run_sdes_talk_pump, args=(self._talk_state,), daemon=True
145
171
  )
146
172
  self._talk_thread.start()
147
- return True
173
+ if await _speaker_ack_accepted(waiter, SDES_SPEAKER_ACK_TIMEOUT_S):
174
+ return True
175
+ # The camera refused. Stop the microphone rather than stream viewer
176
+ # audio at a speaker that never opened.
177
+ self._talk_state["want_speaker"] = False
178
+ self._talk_state["provider"] = None
179
+ return False
148
180
 
149
181
  async def async_stop_talk(self) -> bool:
150
182
  """End two-way audio: close the camera speaker and stop sending audio."""
@@ -279,6 +311,23 @@ class SdesSession(AvioRequestMixin):
279
311
  await asyncio.sleep(0.3)
280
312
  self._talk_state["stop"] = True
281
313
  self._talk_state["provider"] = None
314
+ # Close the SCTP association BEFORE anything else, while the bridge
315
+ # loop that sends it is still running - it dies with ffmpeg. Measured
316
+ # 2026-08-07: reopening 2s after a close is refused with -50002 while 8s
317
+ # is fine, because the camera holds the session until it works out we
318
+ # have gone. We were the only one of the three implementations that
319
+ # never said so; the official app disposes its data channel and our own
320
+ # DTLS path sends this same chunk.
321
+ _abort = self._abort_chan[0]
322
+ if _abort is not None:
323
+ try:
324
+ if _abort():
325
+ _LOGGER.debug("camera %s: sent SCTP ABORT at teardown",
326
+ self._device_id)
327
+ except Exception:
328
+ # Never let closing politely stop us closing at all.
329
+ _LOGGER.debug("camera %s: swallowed exception in %s",
330
+ self._device_id, 'stop', exc_info=True)
282
331
  # Flag this as a locally-initiated teardown BEFORE signalling ffmpeg, so
283
332
  # the bridge thread's observe loop never races a look at a stale False
284
333
  # if it polls the exit code immediately after terminate()/kill().
@@ -48,6 +48,22 @@ _LOGGER = logging.getLogger(__name__)
48
48
  _MEDIA_RCVBUF_BYTES = 4 * 1024 * 1024
49
49
 
50
50
 
51
+ def _sctp_abort_chunk() -> bytes:
52
+ """An SCTP ABORT chunk (RFC 4960 s3.3.7): type 6, T=0, no parameters.
53
+
54
+ Sent at teardown so the camera learns the association is over rather than
55
+ timing it out. Measured 2026-08-07: reopening 2s after a close is refused
56
+ with -50002 (session-exceed) while 8s is fine, and we were the only one of
57
+ the three implementations saying nothing - the official app disposes its
58
+ data channel and our own DTLS path (aiortc) sends this same chunk.
59
+
60
+ T=0 because we hold the peer's verification tag and send it in the common
61
+ header; the T bit is for an endpoint reflecting a tag it does not have.
62
+ """
63
+ import struct as _st_ab
64
+ return _st_ab.pack("!BBH", 6, 0, 4)
65
+
66
+
51
67
  def _dispatch_sctp_avio(responses, payload) -> bool:
52
68
  """Offer an inbound SCTP DATA payload to the AVIO response router.
53
69
 
@@ -2793,6 +2809,9 @@ class _SdesOpenMixin:
2793
2809
  # once the SCTP DataChannel is established. SdesSession reads it via
2794
2810
  # _cmd_chan[0] to dispatch PTZ / IOCtrl commands from the main thread.
2795
2811
  _cmd_chan: list = [None]
2812
+ # Bridge installs the SCTP ABORT sender here once the command
2813
+ # channel is up; teardown calls it to close the association.
2814
+ _abort_chan: list = [None]
2796
2815
  # The other direction: matches the camera's AVIO replies to the commands
2797
2816
  # that asked for them. Created here, not in SdesSession, because the
2798
2817
  # bridge thread starts dispatching into it before the session object
@@ -3153,7 +3172,30 @@ class _SdesOpenMixin:
3153
3172
  except Exception:
3154
3173
  _LOGGER.debug("camera %s: swallowed exception in %s", getattr(self, "device_id", "?"), '_persistent_sdes_cmd', exc_info=True)
3155
3174
 
3175
+ def _persistent_sdes_abort():
3176
+ """ABORT the association from the same socket the commands use.
3177
+
3178
+ Lives here rather than in the bridge's main
3179
+ loop because this is where the encrypted
3180
+ SCTP context actually is - the dcep socket,
3181
+ the peer tag and the encrypt helper. An
3182
+ earlier attempt to send it from the media
3183
+ loop referenced none of them and would not
3184
+ have compiled.
3185
+ """
3186
+ _csock = _sctp.get('dcep_sock')
3187
+ _csrc = _sctp.get('dcep_src')
3188
+ if _csock is None or _csrc is None:
3189
+ return False
3190
+ _csock.sendto(
3191
+ _enc_c8_sctp(_sctp_pkt(_sctp['peer_tag'],
3192
+ _sctp_abort_chunk())),
3193
+ _csrc,
3194
+ )
3195
+ return True
3196
+
3156
3197
  _cmd_chan[0] = _persistent_sdes_cmd
3198
+ _abort_chan[0] = _persistent_sdes_abort
3157
3199
  except Exception as _dw_e:
3158
3200
  _status(f"SDES DC: DCEP_WAIT LIVING err: {_dw_e}")
3159
3201
 
@@ -5024,4 +5066,5 @@ class _SdesOpenMixin:
5024
5066
  first_audio_pt=_first_audio_pt,
5025
5067
  device_id=getattr(self, "device_id", None),
5026
5068
  responses=_avio_responses,
5069
+ abort_chan=_abort_chan,
5027
5070
  )
@@ -12,7 +12,12 @@ import struct
12
12
  import time
13
13
  from typing import Any, Callable, Optional
14
14
 
15
- from .protocol import AvioRequestMixin, AvioResponseRouter
15
+ from .protocol import (
16
+ SPEAKER_ACK_TIMEOUT_S,
17
+ AvioRequestMixin,
18
+ AvioResponseRouter,
19
+ _speaker_opened,
20
+ )
16
21
 
17
22
  _LOGGER = logging.getLogger(__name__)
18
23
 
@@ -92,14 +97,23 @@ class WebRTCSession(AvioRequestMixin):
92
97
  """
93
98
  if not self.talk_supported:
94
99
  return False
100
+ # Ask the camera to open its speaker FIRST, and only enable the
101
+ # microphone once it has. The other order left the mic live and
102
+ # encoding for the whole round trip, so a refused speaker had already
103
+ # been sent viewer audio by the time we detached it. The verdict is
104
+ # cheap here - 0.01-0.38s measured on an A000088.
105
+ #
106
+ # IOTYPE_USER_IPCAM_SPEAKERSTART = 848 (AVIOCTRLDEFs.java), 8-byte
107
+ # channel=0 payload; the camera answers 851.
95
108
  self._talk_holder["provider"] = pcm_provider
109
+ if not await _speaker_opened(self, 848, 851, SPEAKER_ACK_TIMEOUT_S):
110
+ self._talk_holder["provider"] = None
111
+ return False
96
112
  try:
97
113
  self._audio_sender.replaceTrack(self._talk_track)
98
114
  except Exception:
99
115
  self._talk_holder["provider"] = None
100
116
  return False
101
- # IOTYPE_USER_IPCAM_SPEAKERSTART = 848 (AVIOCTRLDEFs.java), 8-byte channel=0 payload.
102
- self._avio_cmd(848, b"\x00" * 8)
103
117
  self._talk_holder["was_active"] = True # ensure stop() releases the speaker
104
118
  return True
105
119
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-aidot-cameras"
7
- version = "0.16.0"
7
+ version = "0.17.0"
8
8
  description = "Control AiDot/Leedarson WiFi lights and cameras (WebRTC streaming, two-way audio, PTZ, controls)"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-aidot-cameras
3
- Version: 0.16.0
3
+ Version: 0.17.0
4
4
  Summary: Control AiDot/Leedarson WiFi lights and cameras (WebRTC streaming, two-way audio, PTZ, controls)
5
5
  Author-email: cbrightly <chris.brightly@gmail.com>
6
6
  License-Expression: MIT
@@ -82,8 +82,11 @@ tests/test_battery_evidence_corroboration.py
82
82
  tests/test_battery_skip_turn.py
83
83
  tests/test_ble_gateway.py
84
84
  tests/test_bridge_state_sentinels.py
85
+ tests/test_busy_backoff.py
85
86
  tests/test_constants_imports_resolve.py
87
+ tests/test_control_verdicts.py
86
88
  tests/test_deferred_hardening.py
89
+ tests/test_device_cmd_seq_match.py
87
90
  tests/test_device_login_guard.py
88
91
  tests/test_device_user_info_cache.py
89
92
  tests/test_dormant_camera_cleanup.py
@@ -143,6 +146,7 @@ tests/test_sdes_fast_liveplay.py
143
146
  tests/test_sdes_ffmpeg_stderr_log.py
144
147
  tests/test_sdes_idle_release.py
145
148
  tests/test_sdes_open_cleanup.py
149
+ tests/test_sdes_sctp_abort.py
146
150
  tests/test_sdes_serve_audio.py
147
151
  tests/test_sdes_serve_cmd.py
148
152
  tests/test_sdes_serve_stderr_drain.py
@@ -0,0 +1,35 @@
1
+ """How long to wait after a camera says it has no free session.
2
+
3
+ On `-50002` both keepalive loops slept `_MAX_DELAY` - **300 seconds** - on the
4
+ reasoning that "the camera only releases slowly". Measured 2026-08-07 on a mains
5
+ A001064, that is wrong by more than an order of magnitude:
6
+
7
+ close, reopen after 2s -> -50002 refused (three times, reproducibly)
8
+ close, reopen after 8s -> reopened fine (every time)
9
+ close, reopen after 20s -> reopened fine
10
+
11
+ The probe minted a fresh peerid on every attempt - the case the comment warned
12
+ about - and still recovered inside 8 seconds.
13
+
14
+ This matters beyond a slow reconnect. A camera that clears in seconds, paired
15
+ with a five-minute refusal to retry, is indistinguishable from a camera that
16
+ needs a long rest, and that appearance is most likely where the project's
17
+ "battery cameras need 30-45 minutes between sessions" rule came from. Three
18
+ back-to-back sessions on a battery L2 all streamed fine the same day.
19
+
20
+ The wait still has to be a real wait. Retrying immediately would hammer a camera
21
+ that genuinely has no free session, and on a battery model that risks the
22
+ wake-then-sleep loop the original comment was guarding against. So: comfortably
23
+ past the measured window, nowhere near five minutes.
24
+ """
25
+ from aidot_cameras.camera.client import _BUSY_BACKOFF_S
26
+
27
+
28
+ def test_the_wait_clears_the_measured_refusal_window():
29
+ """8s reopened cleanly every time; leave real headroom above that."""
30
+ assert _BUSY_BACKOFF_S >= 15.0
31
+
32
+
33
+ def test_the_wait_is_not_a_five_minute_rest():
34
+ """The whole point: a camera that clears in seconds must not cost minutes."""
35
+ assert _BUSY_BACKOFF_S <= 60.0