python-aidot-cameras 0.15.8__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.15.8/python_aidot_cameras.egg-info → python_aidot_cameras-0.17.0}/PKG-INFO +1 -1
  2. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/client.py +90 -7
  3. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/controls.py +30 -5
  4. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/protocol.py +295 -1
  5. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/sdes.py +59 -3
  6. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/sdes_open.py +103 -0
  7. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/webrtc.py +26 -3
  8. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/webrtc_open.py +13 -0
  9. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/pyproject.toml +1 -1
  10. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  11. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/python_aidot_cameras.egg-info/SOURCES.txt +8 -0
  12. python_aidot_cameras-0.17.0/tests/test_avio_request.py +221 -0
  13. python_aidot_cameras-0.17.0/tests/test_avio_response_parse.py +91 -0
  14. python_aidot_cameras-0.17.0/tests/test_avio_response_router.py +207 -0
  15. python_aidot_cameras-0.17.0/tests/test_busy_backoff.py +35 -0
  16. python_aidot_cameras-0.17.0/tests/test_control_verdicts.py +277 -0
  17. python_aidot_cameras-0.17.0/tests/test_device_cmd_seq_match.py +85 -0
  18. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_resolution_persists.py +15 -2
  19. python_aidot_cameras-0.17.0/tests/test_sdes_avio_dispatch.py +117 -0
  20. python_aidot_cameras-0.17.0/tests/test_sdes_sctp_abort.py +102 -0
  21. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_sdes_terminal_ack.py +8 -2
  22. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/LICENSE +0 -0
  23. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/MANIFEST.in +0 -0
  24. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/README.md +0 -0
  25. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/__init__.py +0 -0
  26. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/__main__.py +0 -0
  27. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_upstream.py +0 -0
  28. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/__init__.py +0 -0
  29. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/__init__.py +0 -0
  30. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/clock.py +0 -0
  31. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/codecs/__init__.py +0 -0
  32. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/codecs/base.py +0 -0
  33. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/codecs/g711.py +0 -0
  34. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/codecs/g722.py +0 -0
  35. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/codecs/h264.py +0 -0
  36. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/codecs/opus.py +0 -0
  37. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/codecs/vpx.py +0 -0
  38. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/contrib/__init__.py +0 -0
  39. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/contrib/media.py +0 -0
  40. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/contrib/signaling.py +0 -0
  41. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/events.py +0 -0
  42. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/exceptions.py +0 -0
  43. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/jitterbuffer.py +0 -0
  44. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/mediastreams.py +0 -0
  45. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/rate.py +0 -0
  46. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/rtcconfiguration.py +0 -0
  47. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/rtcdatachannel.py +0 -0
  48. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/rtcdtlstransport.py +0 -0
  49. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/rtcicetransport.py +0 -0
  50. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/rtcpeerconnection.py +0 -0
  51. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/rtcrtpparameters.py +0 -0
  52. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/rtcrtpreceiver.py +0 -0
  53. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/rtcrtpsender.py +0 -0
  54. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/rtcrtptransceiver.py +0 -0
  55. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/rtcsctptransport.py +0 -0
  56. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/rtcsessiondescription.py +0 -0
  57. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/rtp.py +0 -0
  58. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/sdp.py +0 -0
  59. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/stats.py +0 -0
  60. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/utils.py +0 -0
  61. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/ble_gateway.py +0 -0
  62. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/__init__.py +0 -0
  63. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/constants.py +0 -0
  64. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/go2rtc.py +0 -0
  65. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/hwaccel.py +0 -0
  66. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/lan_control.py +0 -0
  67. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/models.py +0 -0
  68. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/playback.py +0 -0
  69. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/tutk.py +0 -0
  70. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/client.py +0 -0
  71. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/const.py +0 -0
  72. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/credentials.py +0 -0
  73. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/crypto.py +0 -0
  74. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/device_client.py +0 -0
  75. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/discover.py +0 -0
  76. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/exceptions.py +0 -0
  77. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/g711.py +0 -0
  78. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/py.typed +0 -0
  79. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  80. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
  81. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/python_aidot_cameras.egg-info/requires.txt +0 -0
  82. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/python_aidot_cameras.egg-info/top_level.txt +0 -0
  83. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/setup.cfg +0 -0
  84. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_aioice_compat.py +0 -0
  85. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_alarm_event.py +0 -0
  86. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_answer_sdp_video_pt.py +0 -0
  87. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_audio_mux_offline.py +0 -0
  88. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_backoff.py +0 -0
  89. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_battery_detection.py +0 -0
  90. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_battery_evidence_corroboration.py +0 -0
  91. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_battery_skip_turn.py +0 -0
  92. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_ble_gateway.py +0 -0
  93. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_bridge_state_sentinels.py +0 -0
  94. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_constants_imports_resolve.py +0 -0
  95. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_deferred_hardening.py +0 -0
  96. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_device_login_guard.py +0 -0
  97. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_device_user_info_cache.py +0 -0
  98. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_dormant_camera_cleanup.py +0 -0
  99. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_dtls_mux_terminal_write.py +0 -0
  100. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_dtls_not_ready_burst.py +0 -0
  101. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_dtls_pinning.py +0 -0
  102. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_dtls_serve_decode_skip.py +0 -0
  103. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_dtls_serve_open_timeout.py +0 -0
  104. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_dtls_skip_signaling_wait.py +0 -0
  105. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_dtls_slow_probe.py +0 -0
  106. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_dtls_stdout_producer.py +0 -0
  107. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_egress_guard.py +0 -0
  108. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_env_seams.py +0 -0
  109. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_go2rtc.py +0 -0
  110. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_go2rtc_cli.py +0 -0
  111. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_h264_decode_rate_limit.py +0 -0
  112. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_highport_nomination.py +0 -0
  113. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_hwaccel_probe.py +0 -0
  114. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_ice_config_cache.py +0 -0
  115. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_ice_peer_reflexive.py +0 -0
  116. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_idle_release_both_loops.py +0 -0
  117. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_keepalive_renew.py +0 -0
  118. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_key_restart_sdp.py +0 -0
  119. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_keyframe_prompter.py +0 -0
  120. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_lan_control.py +0 -0
  121. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_light_active_color_mode.py +0 -0
  122. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_live_stream_param.py +0 -0
  123. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_live_validate_policy.py +0 -0
  124. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_logging_caps.py +0 -0
  125. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_media_socket_rcvbuf.py +0 -0
  126. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_media_wait_floor.py +0 -0
  127. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_motion_poll.py +0 -0
  128. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_mqtt_credential_refetch.py +0 -0
  129. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_mqtt_credential_self_heal.py +0 -0
  130. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_mqtt_password_not_persisted.py +0 -0
  131. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_mqtt_session_reconnect.py +0 -0
  132. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_narrow_pc_ice.py +0 -0
  133. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_no_self_referential_go2rtc.py +0 -0
  134. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_no_undefined_names.py +0 -0
  135. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_offline_keepalive_pause.py +0 -0
  136. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_open_fail_logger.py +0 -0
  137. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_open_gate_delay.py +0 -0
  138. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_peerid_reuse.py +0 -0
  139. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_persistent_mqtt.py +0 -0
  140. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_persistent_mqtt_robustness.py +0 -0
  141. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_playback_tls.py +0 -0
  142. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_post_merge_hardening.py +0 -0
  143. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_restart_cycle.py +0 -0
  144. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_retry_policy.py +0 -0
  145. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_scripts_importable.py +0 -0
  146. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_sdes_adaptive.py +0 -0
  147. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_sdes_bridge_break_gate.py +0 -0
  148. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_sdes_echo_wait_timeout.py +0 -0
  149. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_sdes_fast_liveplay.py +0 -0
  150. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_sdes_ffmpeg_stderr_log.py +0 -0
  151. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_sdes_idle_release.py +0 -0
  152. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_sdes_open_cleanup.py +0 -0
  153. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_sdes_serve_audio.py +0 -0
  154. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_sdes_serve_cmd.py +0 -0
  155. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_sdes_serve_stderr_drain.py +0 -0
  156. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_sdes_sprop.py +0 -0
  157. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_sdes_talk.py +0 -0
  158. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_sdes_teardown_exit_log.py +0 -0
  159. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_sdes_terminal_ack_latency.py +0 -0
  160. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_sdes_watchdog.py +0 -0
  161. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_sdp_narrowing_properties.py +0 -0
  162. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_sdp_payload_type_narrowing.py +0 -0
  163. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_security_hardening.py +0 -0
  164. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_serializable_login_info.py +0 -0
  165. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_serve_loss_tolerance.py +0 -0
  166. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_serve_relay.py +0 -0
  167. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_session_stats.py +0 -0
  168. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_shared_house_seam.py +0 -0
  169. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_speak.py +0 -0
  170. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_sprop_staleness.py +0 -0
  171. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_stream_cap.py +0 -0
  172. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_stream_drain_ownership.py +0 -0
  173. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_stream_idle.py +0 -0
  174. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_stream_slot_cap.py +0 -0
  175. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_stream_teardown.py +0 -0
  176. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_talk.py +0 -0
  177. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_terminal_ack.py +0 -0
  178. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_token_file_robustness.py +0 -0
  179. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_token_refresh.py +0 -0
  180. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_turn_create_permission.py +0 -0
  181. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_upstream_compat.py +0 -0
  182. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_viewer_check_throttle.py +0 -0
  183. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_viewer_presence.py +0 -0
  184. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_viewer_present_push_mode.py +0 -0
  185. {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_wait_or_event.py +0 -0
  186. {python_aidot_cameras-0.15.8 → 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.15.8
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.
@@ -8,6 +8,7 @@ imports client.py -- the import edge is one-way (client -> protocol).
8
8
  """
9
9
 
10
10
  import asyncio
11
+ import concurrent.futures as _cfutures
11
12
  import hashlib
12
13
  import ipaddress
13
14
  import json
@@ -22,7 +23,7 @@ import tempfile
22
23
  import threading
23
24
  import time
24
25
  import zlib
25
- from typing import Any, Callable, Optional
26
+ from typing import Any, Callable, Optional, NamedTuple
26
27
 
27
28
  from .constants import TALK_PCM_FRAME_BYTES, TALK_PCM_RATE
28
29
 
@@ -2176,3 +2177,296 @@ def _dedup_bundle_candidates(sdp: str) -> str:
2176
2177
  else:
2177
2178
  result.extend(ln for ln in sec if not ln.startswith('a=candidate:'))
2178
2179
  return '\r\n'.join(result)
2180
+
2181
+
2182
+ #: Header of an AVIO control frame: seq, command, timestamp(ms), payload length,
2183
+ #: reserved, then 4 bytes of padding. Same layout we already build when sending.
2184
+ _AVIO_RESP_HDR = struct.Struct("<IIqII4x")
2185
+
2186
+ #: Bytes of header before an AVIO payload begins. Exported because the SDES
2187
+ #: receive path has to walk past one frame to reach the next in the same chunk.
2188
+ AVIO_HDR_LEN = _AVIO_RESP_HDR.size
2189
+
2190
+
2191
+ class AvioResponse(NamedTuple):
2192
+ """A decoded AVIO control response."""
2193
+
2194
+ seq: int
2195
+ command: int
2196
+ payload: bytes
2197
+
2198
+
2199
+ def parse_avio_response(frame: bytes) -> "Optional[AvioResponse]":
2200
+ """Decode an AVIO control response, or None if this is not a valid one.
2201
+
2202
+ Returns None rather than raising: this runs on the bridge's receive path,
2203
+ where anything can arrive, and a malformed frame must be ignored rather than
2204
+ kill the loop. A declared length that overruns the buffer is rejected too -
2205
+ trusting it would hand the caller a truncated payload and, for example, read
2206
+ a stream profile out of bytes the camera never sent.
2207
+ """
2208
+ if not frame or len(frame) < _AVIO_RESP_HDR.size:
2209
+ return None
2210
+ seq, command, _ts, length, _reserved = _AVIO_RESP_HDR.unpack_from(frame, 0)
2211
+ body = frame[_AVIO_RESP_HDR.size:]
2212
+ if len(body) < length:
2213
+ return None
2214
+ return AvioResponse(seq=seq, command=command, payload=body[:length])
2215
+
2216
+
2217
+ class _AvioWaiter:
2218
+ """A single outstanding question, handed back by ``AvioResponseRouter.expect``.
2219
+
2220
+ Built on a ``concurrent.futures.Future`` rather than an ``asyncio`` one on
2221
+ purpose: the reply is dispatched from the SDES bridge thread while the
2222
+ caller awaits on the event loop, and setting an asyncio future's result from
2223
+ a foreign thread does not wake the awaiting coroutine - the camera would
2224
+ look silent.
2225
+ """
2226
+
2227
+ def __init__(self, router: "AvioResponseRouter", command: int) -> None:
2228
+ self._router = router
2229
+ self._command = command
2230
+ self._future = _cfutures.Future()
2231
+
2232
+ def cancel(self) -> None:
2233
+ """Withdraw the question - used when the command could not be sent."""
2234
+ self._router._forget(self)
2235
+
2236
+ async def result(self, timeout: float) -> "Optional[AvioResponse]":
2237
+ """The camera's reply, or None if it did not answer within ``timeout``.
2238
+
2239
+ None is a real answer, not an error: this firmware may not implement a
2240
+ response for every command we send, and a control call must never block
2241
+ a view while we find out.
2242
+ """
2243
+ if self._future.done():
2244
+ # A wait that timed out cancels its own wrapper, and that
2245
+ # cancellation can propagate down to this future - asking again
2246
+ # would then raise where the first call returned None.
2247
+ if self._future.cancelled():
2248
+ return None
2249
+ try:
2250
+ return self._future.result()
2251
+ except Exception:
2252
+ return None
2253
+ try:
2254
+ return await asyncio.wait_for(
2255
+ asyncio.wrap_future(self._future), timeout
2256
+ )
2257
+ except TimeoutError:
2258
+ # Stop listening. _avio_cmd runs on the keepalive path, so a
2259
+ # registration left behind on every unanswered command would grow
2260
+ # for as long as the session lives.
2261
+ self._router._forget(self)
2262
+ return None
2263
+ except asyncio.CancelledError:
2264
+ # Cancellation is the caller's task being stopped (entity teardown,
2265
+ # config-entry unload), NOT the camera declining to answer. Drop
2266
+ # the registration, then let it through: swallowing it would leave
2267
+ # the caller running inside a task the loop has already given up on.
2268
+ self._router._forget(self)
2269
+ raise
2270
+
2271
+
2272
+ class AvioResponseRouter:
2273
+ """Match inbound AVIO responses to the commands that asked for them.
2274
+
2275
+ The control channel also carries traffic nobody asked for (session-mode
2276
+ notifications, track switches), so "the next frame in" is not the answer to
2277
+ the last question out. Register interest with ``expect()`` *before* sending,
2278
+ then await the waiter; ``dispatch()`` is safe to call from the bridge thread
2279
+ and never raises or blocks it.
2280
+ """
2281
+
2282
+ def __init__(self) -> None:
2283
+ self._lock = threading.Lock()
2284
+ self._waiting: "dict[int, list[_AvioWaiter]]" = {}
2285
+
2286
+ def expect(self, command: int) -> _AvioWaiter:
2287
+ """Register interest in the next response carrying ``command``.
2288
+
2289
+ Call this before sending the request: the camera can answer before the
2290
+ sender returns, and a registration made afterwards would miss the reply.
2291
+ """
2292
+ waiter = _AvioWaiter(self, command)
2293
+ with self._lock:
2294
+ self._waiting.setdefault(command, []).append(waiter)
2295
+ return waiter
2296
+
2297
+ def dispatch(self, frame: bytes) -> bool:
2298
+ """Hand ``frame`` to whoever is waiting for it.
2299
+
2300
+ True if it answered an outstanding question. False - not an error - for
2301
+ junk, for a command nobody asked about, and for a retransmitted copy of
2302
+ a reply already delivered.
2303
+ """
2304
+ try:
2305
+ resp = parse_avio_response(frame)
2306
+ if resp is None:
2307
+ return False
2308
+ with self._lock:
2309
+ queue = self._waiting.get(resp.command)
2310
+ waiter = None
2311
+ while queue:
2312
+ candidate = queue.pop(0)
2313
+ if not candidate._future.done():
2314
+ waiter = candidate
2315
+ break
2316
+ if queue is not None and not queue:
2317
+ self._waiting.pop(resp.command, None)
2318
+ if waiter is None:
2319
+ return False
2320
+ waiter._future.set_result(resp)
2321
+ return True
2322
+ except Exception:
2323
+ # Runs inline on the bridge's receive path; a bad frame must never
2324
+ # take the media loop down with it.
2325
+ return False
2326
+
2327
+ def _forget(self, waiter: "_AvioWaiter") -> None:
2328
+ with self._lock:
2329
+ queue = self._waiting.get(waiter._command)
2330
+ if not queue:
2331
+ return
2332
+ try:
2333
+ queue.remove(waiter)
2334
+ except ValueError:
2335
+ return
2336
+ if not queue:
2337
+ self._waiting.pop(waiter._command, None)
2338
+
2339
+
2340
+ def _avio_cmd_id(frame: bytes) -> "Optional[int]":
2341
+ """The command id of an AVIO frame, or None if it does not look like one.
2342
+
2343
+ For the receive-path logs. Which ids this firmware actually sends is not
2344
+ documented anywhere we trust - the list we work from was assembled from
2345
+ frames seen on the wire - so naming them as they arrive is what turns the
2346
+ next question into a log grep instead of another live session.
2347
+ """
2348
+ resp = parse_avio_response(bytes(frame))
2349
+ return None if resp is None else resp.command
2350
+
2351
+
2352
+ #: How long a control call waits for the camera before reporting "no reply".
2353
+ #: Short on purpose: these run behind Home Assistant service calls, and a
2354
+ #: firmware that implements the request but not the response must not be able to
2355
+ #: hold up a view while we find that out.
2356
+ AVIO_RESPONSE_TIMEOUT_S = 2.0
2357
+
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
+
2422
+ class AvioRequestMixin:
2423
+ """Ask an AVIO command and wait for the camera's reply.
2424
+
2425
+ Mixed into both session types. ``_avio_cmd`` itself is left exactly as it
2426
+ was - fire-and-forget, synchronous, returns a bool - because every camera on
2427
+ both transports goes through it, for the speaker, the keepalive heartbeat
2428
+ and resolution alike. Wanting an answer is opt-in and additive.
2429
+
2430
+ A session that mixes this in must create its router in ``__init__``:
2431
+ dispatch arrives from the bridge thread and creating one lazily could race
2432
+ with the event loop and lose the reply to a second router.
2433
+ """
2434
+
2435
+ _avio_responses: "AvioResponseRouter"
2436
+
2437
+ def dispatch_avio_frame(self, frame: bytes) -> bool:
2438
+ """Offer an inbound control frame to whoever is waiting for it.
2439
+
2440
+ Called from the transports' receive paths (the DTLS DataChannel message
2441
+ handler, the SDES bridge thread). False - not an error - when nothing
2442
+ was waiting for it, which is the common case: the camera sends session
2443
+ notifications and track switches nobody asked for.
2444
+ """
2445
+ return self._avio_responses.dispatch(frame)
2446
+
2447
+ async def async_avio_request(
2448
+ self,
2449
+ cmd: int,
2450
+ payload: bytes = b"",
2451
+ *,
2452
+ response_cmd: int,
2453
+ timeout: float = AVIO_RESPONSE_TIMEOUT_S,
2454
+ ) -> "Optional[AvioResponse]":
2455
+ """Send ``cmd`` and return the camera's ``response_cmd`` reply, or None.
2456
+
2457
+ None means the camera did not answer in time. That is a real result,
2458
+ not a failure: this firmware may implement a request without a response,
2459
+ and no reply at all points at the channel rather than the firmware.
2460
+ """
2461
+ waiter = self._avio_responses.expect(response_cmd)
2462
+ # Registered BEFORE the send: a camera on the LAN can answer before the
2463
+ # sending call returns, and a reply that arrives with nobody listening
2464
+ # would be reported as silence.
2465
+ try:
2466
+ sent = self._avio_cmd(cmd, payload)
2467
+ except Exception:
2468
+ sent = False
2469
+ if not sent:
2470
+ waiter.cancel()
2471
+ return None
2472
+ return await waiter.result(timeout)
@@ -9,12 +9,27 @@ import asyncio
9
9
  import logging
10
10
  from typing import Callable, Optional
11
11
 
12
- from .constants import SDES_TALK_PUMP_IDLE_TICK
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
+ )
13
19
 
14
20
  _LOGGER = logging.getLogger(__name__)
15
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
16
29
 
17
- class SdesSession:
30
+
31
+
32
+ class SdesSession(AvioRequestMixin):
18
33
  """Active SDES-SRTP stream session managed by an ffmpeg subprocess.
19
34
 
20
35
  Obtain via ``await DeviceClient.async_open_webrtc_stream(...)`` when the
@@ -39,6 +54,8 @@ class SdesSession:
39
54
  first_video_pt=None,
40
55
  first_audio_pt=None,
41
56
  device_id=None,
57
+ responses=None,
58
+ abort_chan=None,
42
59
  ) -> None:
43
60
  # Which camera this session belongs to, for logging only. Optional so
44
61
  # an existing caller that does not pass it still works; the logs then
@@ -80,6 +97,17 @@ class SdesSession:
80
97
  # Mutable one-element list shared with the bridge thread. Bridge sets
81
98
  # [0] to a callable(cmd, payload) once the SCTP channel is up.
82
99
  self._cmd_chan = cmd_chan if cmd_chan is not None else [None]
100
+ # Matches inbound AVIO replies to the commands that asked for them.
101
+ # Created here, not lazily: the bridge thread dispatches into it.
102
+ self._avio_responses = (
103
+ responses if responses is not None else AvioResponseRouter()
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]
83
111
  # Shared talk state (dict) for outbound two-way audio, or None when the
84
112
  # session was not opened talk-capable (offer stayed recvonly). Populated
85
113
  # by the bridge (camera audio addr) and by async_start_talk (provider).
@@ -131,13 +159,24 @@ class SdesSession:
131
159
  # SPEAKERSTART(848) is sent by the BRIDGE thread (not here) once the SCTP
132
160
  # command channel is up - all SCTP DATA must stay on one thread or the
133
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)
134
167
  if self._talk_thread is None or not self._talk_thread.is_alive():
135
168
  import threading
136
169
  self._talk_thread = threading.Thread(
137
170
  target=_run_sdes_talk_pump, args=(self._talk_state,), daemon=True
138
171
  )
139
172
  self._talk_thread.start()
140
- 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
141
180
 
142
181
  async def async_stop_talk(self) -> bool:
143
182
  """End two-way audio: close the camera speaker and stop sending audio."""
@@ -272,6 +311,23 @@ class SdesSession:
272
311
  await asyncio.sleep(0.3)
273
312
  self._talk_state["stop"] = True
274
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)
275
331
  # Flag this as a locally-initiated teardown BEFORE signalling ffmpeg, so
276
332
  # the bridge thread's observe loop never races a look at a stale False
277
333
  # if it polls the exit code immediately after terminate()/kill().