python-aidot-cameras 0.16.0__tar.gz → 0.17.1__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 (193) hide show
  1. {python_aidot_cameras-0.16.0/python_aidot_cameras.egg-info → python_aidot_cameras-0.17.1}/PKG-INFO +1 -1
  2. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/camera/client.py +134 -7
  3. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/camera/constants.py +7 -3
  4. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/camera/controls.py +38 -5
  5. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/camera/protocol.py +190 -0
  6. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/camera/sdes.py +52 -3
  7. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/camera/sdes_open.py +227 -2
  8. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/camera/webrtc.py +17 -3
  9. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/camera/webrtc_open.py +41 -11
  10. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/pyproject.toml +1 -1
  11. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  12. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/python_aidot_cameras.egg-info/SOURCES.txt +11 -0
  13. python_aidot_cameras-0.17.1/tests/test_busy_backoff.py +35 -0
  14. python_aidot_cameras-0.17.1/tests/test_control_verdicts.py +277 -0
  15. python_aidot_cameras-0.17.1/tests/test_device_cmd_seq_match.py +85 -0
  16. python_aidot_cameras-0.17.1/tests/test_futile_keepalive.py +59 -0
  17. python_aidot_cameras-0.17.1/tests/test_offer_keeps_rtcp_feedback.py +137 -0
  18. python_aidot_cameras-0.17.1/tests/test_remb.py +96 -0
  19. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_resolution_persists.py +15 -2
  20. python_aidot_cameras-0.17.1/tests/test_sdes_offer_video_pin.py +107 -0
  21. python_aidot_cameras-0.17.1/tests/test_sdes_sctp_abort.py +102 -0
  22. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_sdes_terminal_ack.py +8 -2
  23. python_aidot_cameras-0.17.1/tests/test_sprop_capture_gate.py +63 -0
  24. python_aidot_cameras-0.17.1/tests/test_video_profile_log.py +61 -0
  25. python_aidot_cameras-0.17.1/tests/test_wake_gate_camera_evidence.py +120 -0
  26. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/LICENSE +0 -0
  27. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/MANIFEST.in +0 -0
  28. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/README.md +0 -0
  29. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/__init__.py +0 -0
  30. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/__main__.py +0 -0
  31. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_upstream.py +0 -0
  32. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/__init__.py +0 -0
  33. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/__init__.py +0 -0
  34. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/clock.py +0 -0
  35. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/codecs/__init__.py +0 -0
  36. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/codecs/base.py +0 -0
  37. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/codecs/g711.py +0 -0
  38. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/codecs/g722.py +0 -0
  39. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/codecs/h264.py +0 -0
  40. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/codecs/opus.py +0 -0
  41. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/codecs/vpx.py +0 -0
  42. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/contrib/__init__.py +0 -0
  43. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/contrib/media.py +0 -0
  44. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/contrib/signaling.py +0 -0
  45. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/events.py +0 -0
  46. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/exceptions.py +0 -0
  47. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/jitterbuffer.py +0 -0
  48. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/mediastreams.py +0 -0
  49. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/rate.py +0 -0
  50. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/rtcconfiguration.py +0 -0
  51. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/rtcdatachannel.py +0 -0
  52. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/rtcdtlstransport.py +0 -0
  53. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/rtcicetransport.py +0 -0
  54. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/rtcpeerconnection.py +0 -0
  55. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/rtcrtpparameters.py +0 -0
  56. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/rtcrtpreceiver.py +0 -0
  57. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/rtcrtpsender.py +0 -0
  58. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/rtcrtptransceiver.py +0 -0
  59. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/rtcsctptransport.py +0 -0
  60. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/rtcsessiondescription.py +0 -0
  61. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/rtp.py +0 -0
  62. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/sdp.py +0 -0
  63. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/stats.py +0 -0
  64. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/utils.py +0 -0
  65. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/ble_gateway.py +0 -0
  66. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/camera/__init__.py +0 -0
  67. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/camera/go2rtc.py +0 -0
  68. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/camera/hwaccel.py +0 -0
  69. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/camera/lan_control.py +0 -0
  70. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/camera/models.py +0 -0
  71. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/camera/playback.py +0 -0
  72. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/camera/tutk.py +0 -0
  73. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/client.py +0 -0
  74. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/const.py +0 -0
  75. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/credentials.py +0 -0
  76. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/crypto.py +0 -0
  77. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/device_client.py +0 -0
  78. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/discover.py +0 -0
  79. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/exceptions.py +0 -0
  80. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/g711.py +0 -0
  81. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/py.typed +0 -0
  82. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  83. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
  84. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/python_aidot_cameras.egg-info/requires.txt +0 -0
  85. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/python_aidot_cameras.egg-info/top_level.txt +0 -0
  86. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/setup.cfg +0 -0
  87. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_aioice_compat.py +0 -0
  88. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_alarm_event.py +0 -0
  89. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_answer_sdp_video_pt.py +0 -0
  90. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_audio_mux_offline.py +0 -0
  91. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_avio_request.py +0 -0
  92. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_avio_response_parse.py +0 -0
  93. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_avio_response_router.py +0 -0
  94. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_backoff.py +0 -0
  95. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_battery_detection.py +0 -0
  96. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_battery_evidence_corroboration.py +0 -0
  97. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_battery_skip_turn.py +0 -0
  98. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_ble_gateway.py +0 -0
  99. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_bridge_state_sentinels.py +0 -0
  100. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_constants_imports_resolve.py +0 -0
  101. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_deferred_hardening.py +0 -0
  102. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_device_login_guard.py +0 -0
  103. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_device_user_info_cache.py +0 -0
  104. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_dormant_camera_cleanup.py +0 -0
  105. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_dtls_mux_terminal_write.py +0 -0
  106. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_dtls_not_ready_burst.py +0 -0
  107. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_dtls_pinning.py +0 -0
  108. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_dtls_serve_decode_skip.py +0 -0
  109. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_dtls_serve_open_timeout.py +0 -0
  110. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_dtls_skip_signaling_wait.py +0 -0
  111. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_dtls_slow_probe.py +0 -0
  112. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_dtls_stdout_producer.py +0 -0
  113. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_egress_guard.py +0 -0
  114. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_env_seams.py +0 -0
  115. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_go2rtc.py +0 -0
  116. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_go2rtc_cli.py +0 -0
  117. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_h264_decode_rate_limit.py +0 -0
  118. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_highport_nomination.py +0 -0
  119. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_hwaccel_probe.py +0 -0
  120. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_ice_config_cache.py +0 -0
  121. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_ice_peer_reflexive.py +0 -0
  122. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_idle_release_both_loops.py +0 -0
  123. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_keepalive_renew.py +0 -0
  124. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_key_restart_sdp.py +0 -0
  125. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_keyframe_prompter.py +0 -0
  126. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_lan_control.py +0 -0
  127. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_light_active_color_mode.py +0 -0
  128. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_live_stream_param.py +0 -0
  129. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_live_validate_policy.py +0 -0
  130. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_logging_caps.py +0 -0
  131. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_media_socket_rcvbuf.py +0 -0
  132. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_media_wait_floor.py +0 -0
  133. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_motion_poll.py +0 -0
  134. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_mqtt_credential_refetch.py +0 -0
  135. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_mqtt_credential_self_heal.py +0 -0
  136. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_mqtt_password_not_persisted.py +0 -0
  137. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_mqtt_session_reconnect.py +0 -0
  138. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_narrow_pc_ice.py +0 -0
  139. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_no_self_referential_go2rtc.py +0 -0
  140. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_no_undefined_names.py +0 -0
  141. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_offline_keepalive_pause.py +0 -0
  142. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_open_fail_logger.py +0 -0
  143. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_open_gate_delay.py +0 -0
  144. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_peerid_reuse.py +0 -0
  145. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_persistent_mqtt.py +0 -0
  146. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_persistent_mqtt_robustness.py +0 -0
  147. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_playback_tls.py +0 -0
  148. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_post_merge_hardening.py +0 -0
  149. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_restart_cycle.py +0 -0
  150. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_retry_policy.py +0 -0
  151. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_scripts_importable.py +0 -0
  152. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_sdes_adaptive.py +0 -0
  153. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_sdes_avio_dispatch.py +0 -0
  154. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_sdes_bridge_break_gate.py +0 -0
  155. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_sdes_echo_wait_timeout.py +0 -0
  156. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_sdes_fast_liveplay.py +0 -0
  157. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_sdes_ffmpeg_stderr_log.py +0 -0
  158. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_sdes_idle_release.py +0 -0
  159. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_sdes_open_cleanup.py +0 -0
  160. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_sdes_serve_audio.py +0 -0
  161. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_sdes_serve_cmd.py +0 -0
  162. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_sdes_serve_stderr_drain.py +0 -0
  163. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_sdes_sprop.py +0 -0
  164. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_sdes_talk.py +0 -0
  165. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_sdes_teardown_exit_log.py +0 -0
  166. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_sdes_terminal_ack_latency.py +0 -0
  167. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_sdes_watchdog.py +0 -0
  168. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_sdp_narrowing_properties.py +0 -0
  169. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_sdp_payload_type_narrowing.py +0 -0
  170. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_security_hardening.py +0 -0
  171. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_serializable_login_info.py +0 -0
  172. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_serve_loss_tolerance.py +0 -0
  173. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_serve_relay.py +0 -0
  174. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_session_stats.py +0 -0
  175. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_shared_house_seam.py +0 -0
  176. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_speak.py +0 -0
  177. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_sprop_staleness.py +0 -0
  178. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_stream_cap.py +0 -0
  179. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_stream_drain_ownership.py +0 -0
  180. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_stream_idle.py +0 -0
  181. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_stream_slot_cap.py +0 -0
  182. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_stream_teardown.py +0 -0
  183. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_talk.py +0 -0
  184. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_terminal_ack.py +0 -0
  185. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_token_file_robustness.py +0 -0
  186. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_token_refresh.py +0 -0
  187. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_turn_create_permission.py +0 -0
  188. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_upstream_compat.py +0 -0
  189. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_viewer_check_throttle.py +0 -0
  190. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_viewer_presence.py +0 -0
  191. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_viewer_present_push_mode.py +0 -0
  192. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_wait_or_event.py +0 -0
  193. {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/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.1
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,44 @@ _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
+
391
+ #: Consecutive background sessions that deliver NO media before the keepalive
392
+ #: stops reopening a battery camera. Observed 2026-08-07 on an A001513: 22 opens
393
+ #: across ~8 hours, none of them delivering media, because the loop escalated
394
+ #: its backoff but had no condition under which it ever stopped. On a mains
395
+ #: camera that is wasteful; on a battery one it spends charge, and a unit has
396
+ #: already been drained to 5% that way.
397
+ #: 0 disables the ceiling entirely.
398
+ _FUTILE_KEEPALIVE_LIMIT = int(os.environ.get("AIDOT_FUTILE_KEEPALIVE_LIMIT", "5"))
399
+
400
+
401
+ def _should_abandon_keepalive(no_media_streak: int, *, is_battery: bool,
402
+ limit: int = _FUTILE_KEEPALIVE_LIMIT) -> bool:
403
+ """Should the background keepalive stop reopening this camera?
404
+
405
+ Only for battery cameras. A mains camera has no charge to protect, and the
406
+ same persistence is what recovers a stream after a router reboot or a
407
+ power-cycle - there the loop is the feature.
408
+
409
+ Scoped to the BACKGROUND loop. A view still opens a session on demand: the
410
+ user asking to see the camera is new information, and a retry ceiling should
411
+ not decide on their behalf that it cannot work.
412
+ """
413
+ if not is_battery or limit <= 0:
414
+ return False
415
+ return no_media_streak >= limit
416
+
379
417
 
380
418
  def _build_sdes_serve_cmd(
381
419
  *,
@@ -773,6 +811,39 @@ _KEEPALIVE_WINDOW_S = 25
773
811
  _KEEPALIVE_RENEW_S = 20
774
812
 
775
813
 
814
+ def _ack_matches_seq(msg, seq):
815
+ """Is ``msg`` the device's answer to the command we sent as ``seq``?
816
+
817
+ ``True`` for our own 200, the integer response code for our own non-200,
818
+ and ``False`` for anything that is not attributable to us - another
819
+ command's ack, the battery wake response, a message with no seq at all.
820
+
821
+ Correlating matters because several messages are in flight on the same four
822
+ wildcard subscriptions: battery cameras get a ``lowPowerActiveStateReq``
823
+ published alongside the write, and Home Assistant writes attributes in
824
+ bursts. Accepting a stranger's 200 reports that one control landed on the
825
+ evidence of a different one landing.
826
+ """
827
+ if not isinstance(msg, dict) or seq is None:
828
+ return False
829
+ got = msg.get("seq")
830
+ if got is None:
831
+ return False
832
+ # We send a string; nothing obliges the camera to echo the same type.
833
+ if str(got) != str(seq):
834
+ return False
835
+ code = (msg.get("ack") or {}).get("code")
836
+ if code is None:
837
+ code = msg.get("code")
838
+ if code is None:
839
+ inner = msg.get("payload") or {}
840
+ if isinstance(inner, dict):
841
+ code = inner.get("code")
842
+ if code == 200:
843
+ return True
844
+ return code if isinstance(code, int) else False
845
+
846
+
776
847
  class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
777
848
  """All camera/streaming methods, mixed into DeviceClient via inheritance."""
778
849
 
@@ -1902,6 +1973,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
1902
1973
  *,
1903
1974
  timeout: float = 8.0,
1904
1975
  ack_keyword: str = "setDevAttr",
1976
+ seq: "Optional[str]" = None,
1905
1977
  ) -> bool:
1906
1978
  """Connect MQTT, optionally wake battery camera, publish command, wait for ack.
1907
1979
 
@@ -1980,6 +2052,36 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
1980
2052
  duration=timeout,
1981
2053
  )
1982
2054
 
2055
+ # Two passes, because being sure is worth more than being quick. A
2056
+ # response carrying OUR seq is proof; anything else on these topics is
2057
+ # only evidence that SOMETHING was acked - and several things are in
2058
+ # flight here (the battery wake goes out alongside the write, and Home
2059
+ # Assistant writes attributes in bursts).
2060
+ for topic, raw in messages:
2061
+ if ack_keyword and ack_keyword not in topic:
2062
+ continue
2063
+ try:
2064
+ msg = _json.loads(raw)
2065
+ except Exception:
2066
+ continue
2067
+ mine = _ack_matches_seq(msg, seq)
2068
+ if mine is True:
2069
+ _LOGGER.debug("device cmd ack 200 for seq=%s: topic=%s", seq, topic)
2070
+ return True
2071
+ if mine is not False:
2072
+ # The camera answered OUR command with a non-200. Logged, not
2073
+ # acted on: whether this firmware ever sends one is unknown, and
2074
+ # turning it into a failure on a guess would report every
2075
+ # working write as broken the moment the guess is wrong. If
2076
+ # these show up in the wild, that is the evidence needed to make
2077
+ # it a real error.
2078
+ _LOGGER.warning(
2079
+ "device cmd for %s answered with code %s (seq=%s, topic=%s) "
2080
+ "- reporting the command as sent anyway; please report this "
2081
+ "log line, it is the evidence needed to handle it properly",
2082
+ device_id, mine, seq, topic,
2083
+ )
2084
+
1983
2085
  for topic, raw in messages:
1984
2086
  if ack_keyword and ack_keyword not in topic:
1985
2087
  continue
@@ -1987,14 +2089,17 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
1987
2089
  msg = _json.loads(raw)
1988
2090
  except Exception:
1989
2091
  continue
2092
+ if _ack_matches_seq(msg, seq) is not False:
2093
+ continue # already considered above
1990
2094
  code = (msg.get("ack") or {}).get("code") or msg.get("code")
1991
2095
  if code == 200:
1992
- _LOGGER.debug("device cmd ack 200: topic=%s", topic)
2096
+ _LOGGER.debug("device cmd ack 200 (unattributed): topic=%s", topic)
1993
2097
  return True
1994
2098
  # Some firmware ACKs with code in payload.payload
1995
2099
  inner = msg.get("payload") or {}
1996
2100
  if isinstance(inner, dict) and inner.get("code") == 200:
1997
- _LOGGER.debug("device cmd ack 200 (inner): topic=%s", topic)
2101
+ _LOGGER.debug(
2102
+ "device cmd ack 200 (inner, unattributed): topic=%s", topic)
1998
2103
  return True
1999
2104
 
2000
2105
  # Fire-and-forget fallback: the official app uses a delivery callback,
@@ -2070,7 +2175,8 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2070
2175
  pub_topic = f"iot/v1/c/{device_id}/device/setDevAttrReq"
2071
2176
  _LOGGER.info("setDevAttrReq: %s=%s -> %s seq=%s", attr, value, device_id, seq)
2072
2177
  ok = await self._mqtt_device_cmd(
2073
- pub_topic, payload, timeout=timeout, ack_keyword="setDevAttr")
2178
+ pub_topic, payload, timeout=timeout, ack_keyword="setDevAttr",
2179
+ seq=seq)
2074
2180
  if ok:
2075
2181
  # Reflect the change in local status right away (optimistic), so HA
2076
2182
  # control entities show the new value immediately instead of waiting
@@ -3277,6 +3383,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3277
3383
  # camera (or a fleet reconnecting at once) isn't hammered into further
3278
3384
  # degradation / cloud rate-limiting; resets after a session that delivered
3279
3385
  # media (see end of loop).
3386
+ _no_media_streak = 0
3280
3387
  _pacer = ReconnectPacer(_MIN_DELAY, _MAX_DELAY)
3281
3388
 
3282
3389
  # Adaptive fast-with-fallback (default on): the first open tries the fast
@@ -3352,14 +3459,17 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3352
3459
  # wake-then-sleep loop that serves nothing. Wait out the release
3353
3460
  # window instead. (The DTLS loops have always done this; the SDES
3354
3461
  # loop silently did not.)
3462
+ #
3463
+ # That release window is now measured rather than assumed: 8s is
3464
+ # enough, 2s is not. See _BUSY_BACKOFF_S.
3355
3465
  self._fast_attempt_override = None
3356
3466
  _LOGGER.warning(
3357
3467
  "SDES keepalive: camera %s refused the stream (%s) - "
3358
3468
  "backing off %.0fs so its sessions can be released",
3359
- self.device_id, busy, _MAX_DELAY,
3469
+ self.device_id, busy, _BUSY_BACKOFF_S,
3360
3470
  )
3361
3471
  try:
3362
- await asyncio.sleep(_MAX_DELAY)
3472
+ await asyncio.sleep(_BUSY_BACKOFF_S)
3363
3473
  except asyncio.CancelledError:
3364
3474
  return
3365
3475
  continue
@@ -3496,6 +3606,23 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3496
3606
  _loop_peer_id = self.generate_webrtc_peer_id(
3497
3607
  live_type=2, stream_id=0, sdes=True)
3498
3608
  _peer_reuses = 0
3609
+ _no_media_streak = 0
3610
+ else:
3611
+ _no_media_streak += 1
3612
+ if _should_abandon_keepalive(
3613
+ _no_media_streak, is_battery=self.is_battery_camera):
3614
+ # Every open so far has cost the camera a wake and returned
3615
+ # nothing. Continuing spends charge to learn the same thing
3616
+ # again; a view will still open a session on demand.
3617
+ _LOGGER.warning(
3618
+ "camera %s: %d consecutive keepalive sessions delivered no "
3619
+ "media - stopping the background keepalive to stop waking "
3620
+ "it. A live view will still open a session; set "
3621
+ "AIDOT_FUTILE_KEEPALIVE_LIMIT=0 to keep retrying.",
3622
+ self.device_id, _no_media_streak,
3623
+ )
3624
+ self._streaming_active = False
3625
+ return
3499
3626
  if _use_fast and not _healthy and not _fast_failed:
3500
3627
  _LOGGER.info(
3501
3628
  "SDES adaptive[%s]: fast attempt delivered no media - "
@@ -3577,10 +3704,10 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3577
3704
  # before trying again rather than hammering it.
3578
3705
  _LOGGER.warning(
3579
3706
  "Stream refused for %s (%s) - backing off %.0fs before retry",
3580
- self.device_id, busy, _MAX_DELAY,
3707
+ self.device_id, busy, _BUSY_BACKOFF_S,
3581
3708
  )
3582
3709
  try:
3583
- await asyncio.sleep(_MAX_DELAY)
3710
+ await asyncio.sleep(_BUSY_BACKOFF_S)
3584
3711
  except asyncio.CancelledError:
3585
3712
  return
3586
3713
  continue
@@ -131,9 +131,13 @@ _PTZ_DIR_CODES: dict = {
131
131
  "zoom_in": 23, "zoom_out": 24,
132
132
  }
133
133
 
134
- # Live-stream resolution (AVIOCTRL_QUALITY_*). The official app's HD/SD toggle
135
- # switches between MAX(1) and MIDDLE(5) (f0.java g3() / LiveFragment d6); we
136
- # mirror those two. Sent as SETSTREAMCTRL (cmd 800) over the active session,
134
+ # Live-stream resolution (AVIOCTRL_QUALITY_*). The official app's picker has
135
+ # THREE arms, not two: MAX(1), MIDDLE(5), and 0x10 = 16, whose label is
136
+ # _auto_resulution (HorLiveController.smali:629-631). 16 is deliberately absent
137
+ # here: it is outside AVIOCTRL_QUALITY, and measured 2026-08-07 it behaves like
138
+ # every other value - acked, and the encode unchanged in that session and the
139
+ # next. Adding it would offer a control that does nothing. The two arms below
140
+ # are the ones with enum names, kept for parity with the command the app sends. Sent as SETSTREAMCTRL (cmd 800) over the active session,
137
141
  # payload <IB3x> = channel(0) + quality byte (SMsgAVIoctrlSetStreamCtrlReq).
138
142
  SETSTREAMCTRL_CMD = 800
139
143
  GETSTREAMCTRL_CMD = 802
@@ -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,40 @@ 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.19s and reports the new value through GETSTREAMCTRL afterwards
176
+ # (where it implements 802), so the command is genuinely accepted - it
177
+ # simply does not change the encode.
178
+ #
179
+ # Settled 2026-08-07 across both transports and every value the enum
180
+ # defines, not just hd/sd: an A001064 was swept over all six
181
+ # AVIOCTRL_QUALITY values, each followed by a session that sent nothing
182
+ # to catch a setting that applies only to the NEXT session. Twelve
183
+ # sessions, twelve times h264 1280x720, no dimension change in any of
184
+ # them. Four of those values had never been sent to any camera here.
185
+ # See docs/APP-PARITY-STATUS.md for the table.
186
+ #
187
+ # The RETURN value deliberately still means "the command went out". An
188
+ # A001064 answers SPEAKERSTART but does not implement GETSTREAMCTRL at
189
+ # all, so how much a given firmware answers is model-dependent, and
190
+ # turning silence into a failure would break this call on exactly the
191
+ # quieter cameras. Reading the reply is what we could not do before; it
192
+ # makes a refusal visible instead of indistinguishable from success.
193
+ reply = await session.async_avio_request(
194
+ SETSTREAMCTRL_CMD, payload,
195
+ response_cmd=SETSTREAMCTRL_RESP_CMD, timeout=_SETSTREAMCTRL_ACK_S,
173
196
  )
174
- return ok
197
+ if reply is None:
198
+ _LOGGER.debug(
199
+ "set resolution %s (quality=%d): sent, no ack within %.1fs",
200
+ quality, q, _SETSTREAMCTRL_ACK_S,
201
+ )
202
+ else:
203
+ _LOGGER.debug(
204
+ "set resolution %s (quality=%d): camera acked %d payload=%s",
205
+ quality, q, reply.command, reply.payload.hex() or "<empty>",
206
+ )
207
+ return True
175
208
 
176
209
  def _apply_pending_resolution(self) -> None:
177
210
  """Re-send the remembered resolution now that a session exists.
@@ -235,6 +235,14 @@ def _parse_alarm_event(msg: dict):
235
235
  return {"device_id": dev, "type": kind, "alarm_type": code}
236
236
 
237
237
 
238
+ def _section_of(lines: list) -> str:
239
+ """The m-section the given emitted lines currently end inside, or ""."""
240
+ for ln in reversed(lines):
241
+ if ln.startswith("m="):
242
+ return ln.split(" ")[0]
243
+ return ""
244
+
245
+
238
246
  def _compress_sdp_for_camera(sdp: str) -> str:
239
247
  """Selective SDP filter matching official Java client's g.b() behaviour.
240
248
 
@@ -258,6 +266,14 @@ def _compress_sdp_for_camera(sdp: str) -> str:
258
266
  seen: dict = {}
259
267
  media_type = ""
260
268
  before_m = True
269
+ # Payload types that survived narrowing. rtcp-fb is kept for these and only
270
+ # these: feedback describing a codec we dropped is bytes spent on something
271
+ # that will never be sent, in an offer whose size is why this function
272
+ # exists.
273
+ _kept_pts: set = set()
274
+ # rtcp-fb lines are buffered because they can appear before the rtpmap that
275
+ # decides whether their payload type survives.
276
+ _fb_lines: list = []
261
277
 
262
278
  def keep(ln: str, key: str = "") -> None:
263
279
  out.append(ln + "\r\n")
@@ -283,6 +299,11 @@ def _compress_sdp_for_camera(sdp: str) -> str:
283
299
  if ln.startswith("a=mid:"):
284
300
  keep(ln)
285
301
  continue
302
+ if ln.startswith("a=rtcp-fb:"):
303
+ # Buffered, not decided here: an rtcp-fb line can precede the
304
+ # rtpmap that determines whether its payload type survives.
305
+ _fb_lines.append((media_type, ln))
306
+ continue
286
307
  if ln.startswith("a=ssrc") and "cname" in ln:
287
308
  if seen.get(f"ssrc-cname-{media_type}") is None:
288
309
  keep(ln, f"ssrc-cname-{media_type}")
@@ -303,17 +324,26 @@ def _compress_sdp_for_camera(sdp: str) -> str:
303
324
  if media_type == "m=audio":
304
325
  if any(c in ln for c in ("opus", "PCMU", "PCMA", "AAC")):
305
326
  keep(ln)
327
+ # Remember the payload type so its rtcp-fb can be kept too.
328
+ if ln.startswith("a=rtpmap:"):
329
+ try:
330
+ _kept_pts.add(ln.split(":")[1].split(" ")[0])
331
+ except Exception:
332
+ _LOGGER.debug("swallowed exception in %s", 'keep',
333
+ exc_info=True)
306
334
  elif media_type == "m=video":
307
335
  if "H264/90000" in ln and seen.get("H264/90000") is None:
308
336
  keep(ln, "H264/90000")
309
337
  try:
310
338
  seen["H264/90000_pt"] = ln.split(":")[1].split(" ")[0]
339
+ _kept_pts.add(seen["H264/90000_pt"])
311
340
  except Exception:
312
341
  _LOGGER.debug("swallowed exception in %s", 'keep', exc_info=True)
313
342
  elif "H265/90000" in ln and seen.get("H265/90000") is None:
314
343
  keep(ln, "H265/90000")
315
344
  try:
316
345
  seen["H265/90000_pt"] = ln.split(":")[1].split(" ")[0]
346
+ _kept_pts.add(seen["H265/90000_pt"])
317
347
  except Exception:
318
348
  _LOGGER.debug("swallowed exception in %s", 'keep', exc_info=True)
319
349
  elif "apt=" in ln:
@@ -332,6 +362,22 @@ def _compress_sdp_for_camera(sdp: str) -> str:
332
362
  elif media_type == "m=application":
333
363
  if "sctp-port" in ln:
334
364
  keep(ln)
365
+ # Re-insert the surviving feedback at the end of its own media section, so
366
+ # the offer stays well-formed. Kept only for payload types that survived
367
+ # narrowing - see _kept_pts.
368
+ if _fb_lines:
369
+ kept_fb = [(mt, ln) for mt, ln in _fb_lines
370
+ if ln.split(":", 1)[1].split(" ")[0] in _kept_pts]
371
+ if kept_fb:
372
+ rebuilt: list = []
373
+ for line in out:
374
+ if line.startswith("m=") and rebuilt:
375
+ prev = _section_of(rebuilt)
376
+ rebuilt.extend(l + "\r\n" for mt, l in kept_fb if mt == prev)
377
+ rebuilt.append(line)
378
+ prev = _section_of(rebuilt)
379
+ rebuilt.extend(l + "\r\n" for mt, l in kept_fb if mt == prev)
380
+ out = rebuilt
335
381
  return "".join(out)
336
382
 
337
383
 
@@ -2337,6 +2383,87 @@ class AvioResponseRouter:
2337
2383
  self._waiting.pop(waiter._command, None)
2338
2384
 
2339
2385
 
2386
+ #: What we ask the camera to send, in bits per second. Zero disables sending
2387
+ #: REMB at all, which is the default.
2388
+ #:
2389
+ #: OFF by default because it was measured and did not work. An A001064 was A/B'd
2390
+ #: with REMB transmitting at 500 Kbps and correctly naming the video SSRC -
2391
+ #: receipts per session, interleaved arms against a control - and its bitrate
2392
+ #: did not fall. The one clean like-for-like pair had the REMB arm HIGHER than
2393
+ #: its control (3859 vs 3355 Kbps). No effect was demonstrated on the only model
2394
+ #: tested.
2395
+ #:
2396
+ #: That alone would argue for opt-in. The stronger argument is the models NOT
2397
+ #: tested: an A000088 or A001513 that does honour REMB would have had its
2398
+ #: picture capped at 500 Kbps fleet-wide, by default, on the strength of a
2399
+ #: measurement taken elsewhere that showed no benefit. Shipping a bitrate cap
2400
+ #: enabled by default, validated on one camera that ignores it, risks degrading
2401
+ #: precisely the cameras nobody measured.
2402
+ #:
2403
+ #: The negotiation, the encoder and the decoder are all kept and tested: the
2404
+ #: camera does advertise goog-remb in its answer, so this is a working
2405
+ #: instrument for anyone who wants to measure their own hardware. Set
2406
+ #: AIDOT_REMB_TARGET_BPS to a bitrate to turn it on.
2407
+ REMB_TARGET_BPS = int(os.environ.get("AIDOT_REMB_TARGET_BPS", "0"))
2408
+
2409
+
2410
+ def build_remb(sender_ssrc: int, media_ssrcs: "list", bitrate_bps: int) -> bytes:
2411
+ """An RTCP REMB packet asking the sender for at most ``bitrate_bps``.
2412
+
2413
+ Format is the Google draft (draft-alvestrand-rmcat-remb) carried as a
2414
+ Payload-Specific Feedback message: FMT 15, PT 206, the ASCII identifier
2415
+ ``REMB``, then a count of SSRCs and the bitrate as a 6-bit exponent with an
2416
+ 18-bit mantissa, then the SSRCs themselves.
2417
+
2418
+ The camera negotiates ``goog-remb`` in its answer (confirmed live on an
2419
+ A001064 over SDES), which means it will accept this. It has no effect until
2420
+ one is actually sent - negotiation alone changes nothing, which is why this
2421
+ is a separate step from keeping the attribute in the offer.
2422
+ """
2423
+ if bitrate_bps <= 0:
2424
+ raise ValueError("REMB bitrate must be positive - zero would ask the "
2425
+ "camera to stop sending entirely")
2426
+ if not media_ssrcs:
2427
+ raise ValueError("REMB must name at least one stream")
2428
+
2429
+ # 18-bit mantissa, 6-bit exponent: shift right until the value fits.
2430
+ exp = 0
2431
+ mantissa = int(bitrate_bps)
2432
+ while mantissa > 0x3FFFF:
2433
+ mantissa >>= 1
2434
+ exp += 1
2435
+ if exp > 63:
2436
+ raise ValueError("REMB bitrate too large to encode")
2437
+
2438
+ header = struct.pack(
2439
+ "!BBH",
2440
+ 0x80 | 15, # V=2, P=0, FMT=15
2441
+ 206, # PT: payload-specific feedback
2442
+ 0, # length, filled in below
2443
+ )
2444
+ body = (struct.pack("!II", sender_ssrc & 0xFFFFFFFF, 0)
2445
+ + b"REMB"
2446
+ + struct.pack("!BBBB",
2447
+ len(media_ssrcs) & 0xFF,
2448
+ ((exp << 2) | (mantissa >> 16)) & 0xFF,
2449
+ (mantissa >> 8) & 0xFF,
2450
+ mantissa & 0xFF)
2451
+ + b"".join(struct.pack("!I", s & 0xFFFFFFFF) for s in media_ssrcs))
2452
+ pkt = header + body
2453
+ # RTCP length is in 32-bit words, minus one.
2454
+ return pkt[:2] + struct.pack("!H", len(pkt) // 4 - 1) + pkt[4:]
2455
+
2456
+
2457
+ def decode_remb_bitrate(pkt: bytes) -> int:
2458
+ """The bitrate a REMB packet asks for. For tests and diagnostics."""
2459
+ # Layout: 0-3 header, 4-7 sender SSRC, 8-11 media SSRC, 12-15 "REMB",
2460
+ # 16 num-SSRC, 17-19 exponent+mantissa, 20+ the SSRCs.
2461
+ b = pkt[17]
2462
+ exp = b >> 2
2463
+ mantissa = ((b & 0x03) << 16) | (pkt[18] << 8) | pkt[19]
2464
+ return mantissa << exp
2465
+
2466
+
2340
2467
  def _avio_cmd_id(frame: bytes) -> "Optional[int]":
2341
2468
  """The command id of an AVIO frame, or None if it does not look like one.
2342
2469
 
@@ -2356,6 +2483,69 @@ def _avio_cmd_id(frame: bytes) -> "Optional[int]":
2356
2483
  AVIO_RESPONSE_TIMEOUT_S = 2.0
2357
2484
 
2358
2485
 
2486
+ #: How long to wait for the camera's SPEAKERSTART ack before opening the mic
2487
+ #: anyway. Measured 0.01-0.86s across both transports; this is generous enough
2488
+ #: to cover a slow SDES open without making a press-to-talk feel stuck.
2489
+ SPEAKER_ACK_TIMEOUT_S = 2.0
2490
+
2491
+ #: Ack payloads observed for SPEAKERSTART, all of them on speakers that opened:
2492
+ #: 0x0064 (M3 Pro / DTLS, PTZ / SDES, and an L2) and 0x00c8 (the same L2, on the
2493
+ #: very next session). One camera returning both across consecutive successful
2494
+ #: opens is what settles it - the payload is not a verdict, so there is nothing
2495
+ #: here to read as a refusal.
2496
+ _SPEAKER_ACKS_SEEN = (b"\x00\x64", b"\x00\xc8")
2497
+
2498
+
2499
+ async def _speaker_opened(session, cmd: int, resp_cmd: int, timeout: float) -> bool:
2500
+ """Send SPEAKERSTART and decide whether the camera opened its speaker.
2501
+
2502
+ Everything currently counts as success, and that is the honest position
2503
+ rather than a placeholder. Silence does, because an A001064 answers 848 but
2504
+ does not implement 802 at all - a firmware with no response for a command is
2505
+ a real and common state, and failing on it would remove two-way audio from
2506
+ every camera quieter than the one measured. And any ack does, because no
2507
+ refusal has ever been observed: the payload varies across models AND across
2508
+ consecutive sessions on one camera, so it carries no verdict to read.
2509
+
2510
+ An earlier version treated 0x0064 as "the" success value and any other
2511
+ two-byte payload as a refusal. That was a discriminator invented from a
2512
+ single sample, and it would have disabled talk outright on an L2, which
2513
+ answers 0x00c8 about half the time. The payload is logged instead, so a
2514
+ genuine refusal can be recognised from evidence when one appears.
2515
+ """
2516
+ waiter = session._avio_responses.expect(resp_cmd)
2517
+ try:
2518
+ sent = session._avio_cmd(cmd, b"\x00" * 8)
2519
+ except Exception:
2520
+ sent = False
2521
+ if not sent:
2522
+ waiter.cancel()
2523
+ return True # no channel to refuse on; unchanged from before
2524
+ return await _speaker_ack_accepted(waiter, timeout)
2525
+
2526
+
2527
+ async def _speaker_ack_accepted(waiter, timeout: float) -> bool:
2528
+ """Read a SPEAKERSTART ack, defaulting to acceptance on anything unfamiliar.
2529
+
2530
+ Shared by both transports because the decision is the same, even though the
2531
+ send is not: on SDES the command leaves from the bridge thread and this only
2532
+ ever waits.
2533
+ """
2534
+ reply = await waiter.result(timeout)
2535
+ if reply is None:
2536
+ _LOGGER.debug("SPEAKERSTART: no ack within %.1fs - treating as opened", timeout)
2537
+ return True
2538
+ if reply.payload not in _SPEAKER_ACKS_SEEN:
2539
+ _LOGGER.info(
2540
+ "SPEAKERSTART acked with an unrecorded payload %s - treating the "
2541
+ "speaker as open. Please report this: the payloads seen so far all "
2542
+ "belong to speakers that opened, so a genuine refusal has never "
2543
+ "been identified and this may be the first",
2544
+ reply.payload.hex() or "<empty>",
2545
+ )
2546
+ return True
2547
+
2548
+
2359
2549
  class AvioRequestMixin:
2360
2550
  """Ask an AVIO command and wait for the camera's reply.
2361
2551
 
@@ -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().