python-aidot-cameras 0.17.0__tar.gz → 0.17.2__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 (197) hide show
  1. {python_aidot_cameras-0.17.0/python_aidot_cameras.egg-info → python_aidot_cameras-0.17.2}/PKG-INFO +4 -1
  2. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/README.md +3 -0
  3. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/camera/client.py +56 -0
  4. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/camera/constants.py +7 -3
  5. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/camera/controls.py +11 -3
  6. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/camera/protocol.py +127 -0
  7. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/camera/sdes_open.py +184 -2
  8. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/camera/webrtc_open.py +41 -11
  9. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/client.py +26 -6
  10. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/device_client.py +61 -11
  11. python_aidot_cameras-0.17.2/aidot_cameras/lan_retry.py +198 -0
  12. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/pyproject.toml +1 -1
  13. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2/python_aidot_cameras.egg-info}/PKG-INFO +4 -1
  14. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/python_aidot_cameras.egg-info/SOURCES.txt +11 -0
  15. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_dormant_camera_cleanup.py +52 -0
  16. python_aidot_cameras-0.17.2/tests/test_futile_keepalive.py +59 -0
  17. python_aidot_cameras-0.17.2/tests/test_lan_retry_reaches_the_storming_devices.py +252 -0
  18. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_light_active_color_mode.py +15 -6
  19. python_aidot_cameras-0.17.2/tests/test_login_connect_deadline.py +113 -0
  20. python_aidot_cameras-0.17.2/tests/test_login_reconnect_backoff.py +83 -0
  21. python_aidot_cameras-0.17.2/tests/test_offer_keeps_rtcp_feedback.py +137 -0
  22. python_aidot_cameras-0.17.2/tests/test_remb.py +96 -0
  23. python_aidot_cameras-0.17.2/tests/test_sdes_offer_video_pin.py +107 -0
  24. python_aidot_cameras-0.17.2/tests/test_sprop_capture_gate.py +63 -0
  25. python_aidot_cameras-0.17.2/tests/test_video_profile_log.py +61 -0
  26. python_aidot_cameras-0.17.2/tests/test_wake_gate_camera_evidence.py +120 -0
  27. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/LICENSE +0 -0
  28. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/MANIFEST.in +0 -0
  29. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/__init__.py +0 -0
  30. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/__main__.py +0 -0
  31. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/_upstream.py +0 -0
  32. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/__init__.py +0 -0
  33. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/__init__.py +0 -0
  34. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/clock.py +0 -0
  35. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/codecs/__init__.py +0 -0
  36. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/codecs/base.py +0 -0
  37. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/codecs/g711.py +0 -0
  38. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/codecs/g722.py +0 -0
  39. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/codecs/h264.py +0 -0
  40. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/codecs/opus.py +0 -0
  41. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/codecs/vpx.py +0 -0
  42. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/contrib/__init__.py +0 -0
  43. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/contrib/media.py +0 -0
  44. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/contrib/signaling.py +0 -0
  45. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/events.py +0 -0
  46. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/exceptions.py +0 -0
  47. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/jitterbuffer.py +0 -0
  48. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/mediastreams.py +0 -0
  49. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/rate.py +0 -0
  50. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/rtcconfiguration.py +0 -0
  51. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/rtcdatachannel.py +0 -0
  52. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/rtcdtlstransport.py +0 -0
  53. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/rtcicetransport.py +0 -0
  54. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/rtcpeerconnection.py +0 -0
  55. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/rtcrtpparameters.py +0 -0
  56. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/rtcrtpreceiver.py +0 -0
  57. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/rtcrtpsender.py +0 -0
  58. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/rtcrtptransceiver.py +0 -0
  59. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/rtcsctptransport.py +0 -0
  60. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/rtcsessiondescription.py +0 -0
  61. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/rtp.py +0 -0
  62. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/sdp.py +0 -0
  63. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/stats.py +0 -0
  64. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/utils.py +0 -0
  65. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/ble_gateway.py +0 -0
  66. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/camera/__init__.py +0 -0
  67. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/camera/go2rtc.py +0 -0
  68. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/camera/hwaccel.py +0 -0
  69. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/camera/lan_control.py +0 -0
  70. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/camera/models.py +0 -0
  71. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/camera/playback.py +0 -0
  72. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/camera/sdes.py +0 -0
  73. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/camera/tutk.py +0 -0
  74. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/camera/webrtc.py +0 -0
  75. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/const.py +0 -0
  76. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/credentials.py +0 -0
  77. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/crypto.py +0 -0
  78. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/discover.py +0 -0
  79. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/exceptions.py +0 -0
  80. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/g711.py +0 -0
  81. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/aidot_cameras/py.typed +0 -0
  82. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  83. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
  84. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/python_aidot_cameras.egg-info/requires.txt +0 -0
  85. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/python_aidot_cameras.egg-info/top_level.txt +0 -0
  86. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/setup.cfg +0 -0
  87. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_aioice_compat.py +0 -0
  88. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_alarm_event.py +0 -0
  89. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_answer_sdp_video_pt.py +0 -0
  90. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_audio_mux_offline.py +0 -0
  91. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_avio_request.py +0 -0
  92. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_avio_response_parse.py +0 -0
  93. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_avio_response_router.py +0 -0
  94. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_backoff.py +0 -0
  95. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_battery_detection.py +0 -0
  96. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_battery_evidence_corroboration.py +0 -0
  97. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_battery_skip_turn.py +0 -0
  98. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_ble_gateway.py +0 -0
  99. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_bridge_state_sentinels.py +0 -0
  100. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_busy_backoff.py +0 -0
  101. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_constants_imports_resolve.py +0 -0
  102. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_control_verdicts.py +0 -0
  103. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_deferred_hardening.py +0 -0
  104. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_device_cmd_seq_match.py +0 -0
  105. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_device_login_guard.py +0 -0
  106. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_device_user_info_cache.py +0 -0
  107. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_dtls_mux_terminal_write.py +0 -0
  108. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_dtls_not_ready_burst.py +0 -0
  109. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_dtls_pinning.py +0 -0
  110. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_dtls_serve_decode_skip.py +0 -0
  111. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_dtls_serve_open_timeout.py +0 -0
  112. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_dtls_skip_signaling_wait.py +0 -0
  113. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_dtls_slow_probe.py +0 -0
  114. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_dtls_stdout_producer.py +0 -0
  115. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_egress_guard.py +0 -0
  116. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_env_seams.py +0 -0
  117. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_go2rtc.py +0 -0
  118. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_go2rtc_cli.py +0 -0
  119. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_h264_decode_rate_limit.py +0 -0
  120. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_highport_nomination.py +0 -0
  121. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_hwaccel_probe.py +0 -0
  122. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_ice_config_cache.py +0 -0
  123. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_ice_peer_reflexive.py +0 -0
  124. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_idle_release_both_loops.py +0 -0
  125. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_keepalive_renew.py +0 -0
  126. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_key_restart_sdp.py +0 -0
  127. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_keyframe_prompter.py +0 -0
  128. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_lan_control.py +0 -0
  129. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_live_stream_param.py +0 -0
  130. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_live_validate_policy.py +0 -0
  131. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_logging_caps.py +0 -0
  132. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_media_socket_rcvbuf.py +0 -0
  133. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_media_wait_floor.py +0 -0
  134. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_motion_poll.py +0 -0
  135. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_mqtt_credential_refetch.py +0 -0
  136. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_mqtt_credential_self_heal.py +0 -0
  137. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_mqtt_password_not_persisted.py +0 -0
  138. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_mqtt_session_reconnect.py +0 -0
  139. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_narrow_pc_ice.py +0 -0
  140. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_no_self_referential_go2rtc.py +0 -0
  141. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_no_undefined_names.py +0 -0
  142. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_offline_keepalive_pause.py +0 -0
  143. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_open_fail_logger.py +0 -0
  144. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_open_gate_delay.py +0 -0
  145. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_peerid_reuse.py +0 -0
  146. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_persistent_mqtt.py +0 -0
  147. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_persistent_mqtt_robustness.py +0 -0
  148. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_playback_tls.py +0 -0
  149. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_post_merge_hardening.py +0 -0
  150. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_resolution_persists.py +0 -0
  151. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_restart_cycle.py +0 -0
  152. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_retry_policy.py +0 -0
  153. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_scripts_importable.py +0 -0
  154. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_sdes_adaptive.py +0 -0
  155. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_sdes_avio_dispatch.py +0 -0
  156. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_sdes_bridge_break_gate.py +0 -0
  157. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_sdes_echo_wait_timeout.py +0 -0
  158. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_sdes_fast_liveplay.py +0 -0
  159. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_sdes_ffmpeg_stderr_log.py +0 -0
  160. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_sdes_idle_release.py +0 -0
  161. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_sdes_open_cleanup.py +0 -0
  162. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_sdes_sctp_abort.py +0 -0
  163. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_sdes_serve_audio.py +0 -0
  164. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_sdes_serve_cmd.py +0 -0
  165. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_sdes_serve_stderr_drain.py +0 -0
  166. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_sdes_sprop.py +0 -0
  167. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_sdes_talk.py +0 -0
  168. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_sdes_teardown_exit_log.py +0 -0
  169. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_sdes_terminal_ack.py +0 -0
  170. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_sdes_terminal_ack_latency.py +0 -0
  171. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_sdes_watchdog.py +0 -0
  172. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_sdp_narrowing_properties.py +0 -0
  173. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_sdp_payload_type_narrowing.py +0 -0
  174. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_security_hardening.py +0 -0
  175. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_serializable_login_info.py +0 -0
  176. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_serve_loss_tolerance.py +0 -0
  177. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_serve_relay.py +0 -0
  178. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_session_stats.py +0 -0
  179. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_shared_house_seam.py +0 -0
  180. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_speak.py +0 -0
  181. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_sprop_staleness.py +0 -0
  182. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_stream_cap.py +0 -0
  183. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_stream_drain_ownership.py +0 -0
  184. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_stream_idle.py +0 -0
  185. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_stream_slot_cap.py +0 -0
  186. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_stream_teardown.py +0 -0
  187. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_talk.py +0 -0
  188. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_terminal_ack.py +0 -0
  189. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_token_file_robustness.py +0 -0
  190. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_token_refresh.py +0 -0
  191. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_turn_create_permission.py +0 -0
  192. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_upstream_compat.py +0 -0
  193. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_viewer_check_throttle.py +0 -0
  194. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_viewer_presence.py +0 -0
  195. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_viewer_present_push_mode.py +0 -0
  196. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/tests/test_wait_or_event.py +0 -0
  197. {python_aidot_cameras-0.17.0 → python_aidot_cameras-0.17.2}/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.17.0
3
+ Version: 0.17.2
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
@@ -178,6 +178,9 @@ audio, idle release, the sprop cache path) are documented in
178
178
  | `AIDOT_DTLS_FAST_LIVEPLAY` | The DTLS (A000088) analogue: skip the `livePlayReq`-echo and `livePlayResp` waits (the dominant LAN cold-start cost) while keeping the full ICE/TURN/DTLS handshake, so remote/relay viewing is unaffected. **On by default**; set to `0`/`false`/`no`/`off` to disable. | enabled (on) |
179
179
  | `AIDOT_PERSISTENT_MQTT` | Reuse ONE account-level persistent MQTT connection for commands, attribute fetches, and stream-open signaling (matching the official app) instead of connecting per operation. **On by default** (live soaks cut SDES NO_MEDIA from ~57% to ~11-19%); set to `0`/`false`/`no`/`off` to disable. | enabled (on) |
180
180
  | `AIDOT_SERVE_RELAY` | Hold the public stream port via an internal relay that proxies to ffmpeg, so the first (cold) view connects instead of failing while ffmpeg can't pre-bind the port. Set to `0` to serve ffmpeg directly. | `1` (enabled) |
181
+ | `AIDOT_LOGIN_RETRY_LIMIT` | Consecutive failed LAN logins after which a device is left alone (it is retried again the next time something asks for it). Applies to every device, camera or not - the devices that hit this are lights. | `6` |
182
+ | `AIDOT_LOGIN_RETRY_CAP_S` | Ceiling on the exponential delay between those retries. | `60` |
183
+ | `AIDOT_LOGIN_CONNECT_TIMEOUT_S` | Ceiling on one LAN connect+login attempt. A device that completes the TCP handshake and then stops answering is abandoned and its socket closed rather than parking the attempt forever. | `20` |
181
184
  | `AIDOT_LIVESTREAM_PARAM` | **No-op (ignored).** Formerly requested the cloud `liveStreamParam` pre-connect for battery cameras. That call provisions the camera toward AWS KVS, so it sends its media there instead of to this library and the live view negotiates and then shows nothing - and battery cameras were the only ones it applied to. Setting it (or `start_keepalive(live_stream_param=True)`) logs one warning and changes nothing. | ignored |
182
185
 
183
186
  ### Security hardening
@@ -147,6 +147,9 @@ audio, idle release, the sprop cache path) are documented in
147
147
  | `AIDOT_DTLS_FAST_LIVEPLAY` | The DTLS (A000088) analogue: skip the `livePlayReq`-echo and `livePlayResp` waits (the dominant LAN cold-start cost) while keeping the full ICE/TURN/DTLS handshake, so remote/relay viewing is unaffected. **On by default**; set to `0`/`false`/`no`/`off` to disable. | enabled (on) |
148
148
  | `AIDOT_PERSISTENT_MQTT` | Reuse ONE account-level persistent MQTT connection for commands, attribute fetches, and stream-open signaling (matching the official app) instead of connecting per operation. **On by default** (live soaks cut SDES NO_MEDIA from ~57% to ~11-19%); set to `0`/`false`/`no`/`off` to disable. | enabled (on) |
149
149
  | `AIDOT_SERVE_RELAY` | Hold the public stream port via an internal relay that proxies to ffmpeg, so the first (cold) view connects instead of failing while ffmpeg can't pre-bind the port. Set to `0` to serve ffmpeg directly. | `1` (enabled) |
150
+ | `AIDOT_LOGIN_RETRY_LIMIT` | Consecutive failed LAN logins after which a device is left alone (it is retried again the next time something asks for it). Applies to every device, camera or not - the devices that hit this are lights. | `6` |
151
+ | `AIDOT_LOGIN_RETRY_CAP_S` | Ceiling on the exponential delay between those retries. | `60` |
152
+ | `AIDOT_LOGIN_CONNECT_TIMEOUT_S` | Ceiling on one LAN connect+login attempt. A device that completes the TCP handshake and then stops answering is abandoned and its socket closed rather than parking the attempt forever. | `20` |
150
153
  | `AIDOT_LIVESTREAM_PARAM` | **No-op (ignored).** Formerly requested the cloud `liveStreamParam` pre-connect for battery cameras. That call provisions the camera toward AWS KVS, so it sends its media there instead of to this library and the live view negotiates and then shows nothing - and battery cameras were the only ones it applied to. Setting it (or `start_keepalive(live_stream_param=True)`) logs one warning and changes nothing. | ignored |
151
154
 
152
155
  ### Security hardening
@@ -388,6 +388,32 @@ _SERVE_MAX_DELAY_US = int(os.environ.get("AIDOT_SERVE_MAX_DELAY_US", "500000"))
388
388
  #: has none free, and on a battery model risks a wake-then-sleep loop.
389
389
  _BUSY_BACKOFF_S = float(os.environ.get("AIDOT_BUSY_BACKOFF_S", "20"))
390
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
+
391
417
 
392
418
  def _build_sdes_serve_cmd(
393
419
  *,
@@ -3357,6 +3383,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3357
3383
  # camera (or a fleet reconnecting at once) isn't hammered into further
3358
3384
  # degradation / cloud rate-limiting; resets after a session that delivered
3359
3385
  # media (see end of loop).
3386
+ _no_media_streak = 0
3360
3387
  _pacer = ReconnectPacer(_MIN_DELAY, _MAX_DELAY)
3361
3388
 
3362
3389
  # Adaptive fast-with-fallback (default on): the first open tries the fast
@@ -3579,6 +3606,35 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3579
3606
  _loop_peer_id = self.generate_webrtc_peer_id(
3580
3607
  live_type=2, stream_id=0, sdes=True)
3581
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
+ # Same teardown the idle-release exit does. Leaving the
3626
+ # go2rtc stream registered points it at a serve port with
3627
+ # nothing listening, so a viewer attaching to a dormant
3628
+ # camera gets "connection refused" instead of a clean miss -
3629
+ # exactly what the idle-release path exists to avoid.
3630
+ self._cancel_keepalive_renew()
3631
+ try:
3632
+ await self._deregister_go2rtc()
3633
+ except Exception:
3634
+ _LOGGER.debug("camera %s: go2rtc deregister after "
3635
+ "abandon failed", self.device_id,
3636
+ exc_info=True)
3637
+ return
3582
3638
  if _use_fast and not _healthy and not _fast_failed:
3583
3639
  _LOGGER.info(
3584
3640
  "SDES adaptive[%s]: fast attempt delivered no media - "
@@ -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
@@ -172,9 +172,17 @@ class _CameraControlsMixin:
172
172
  "will be applied when a session next starts", quality)
173
173
  return True
174
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.
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.
178
186
  #
179
187
  # The RETURN value deliberately still means "the command went out". An
180
188
  # A001064 answers SPEAKERSTART but does not implement GETSTREAMCTRL at
@@ -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
 
@@ -25,7 +25,9 @@ from .models import VideoFrame # noqa: F401 - forward-ref annotation
25
25
  from .sdes import SdesSession
26
26
  from .protocol import (
27
27
  AVIO_HDR_LEN,
28
+ REMB_TARGET_BPS,
28
29
  AvioResponseRouter,
30
+ build_remb,
29
31
  _build_sprop,
30
32
  parse_avio_response,
31
33
  _build_stun_binding_success_response,
@@ -174,6 +176,33 @@ _SDP_AUDIO_PTS = (0, 8)
174
176
  # even when the camera numbers that codec differently on the wire - it is our own
175
177
  # SDP being rewritten, not the camera's.
176
178
  _SDP_VIDEO_PT_BY_CODEC = {"H264": 96, "H265": 97}
179
+ _SDP_CODEC_BY_VIDEO_PT = {pt: codec for codec, pt in _SDP_VIDEO_PT_BY_CODEC.items()}
180
+
181
+
182
+ def describe_video_profile(pt) -> str:
183
+ """Name the video profile a session negotiated, for the log.
184
+
185
+ An A001064 was measured serving two profiles for identical requests - H264
186
+ 1280x720 at 2.5-4.0 Mbps and H265 2560x1440 at ~1.1 Mbps - varying per
187
+ session with nothing on our side asking for either. What selects it is
188
+ unknown, and it is unknown because nothing recorded it: bitrate figures
189
+ gathered before 2026-08-07 cannot be compared, since the codec that produced
190
+ each one was never written down.
191
+
192
+ Instrumentation, not a fix. It asserts no cause.
193
+
194
+ Deliberately limited to the payload type and its codec name, which is what
195
+ the bridge knows when the first video packet lands. Frame dimensions would
196
+ mean parsing H264/H265 parameter sets out of the stream - far more than the
197
+ question needs, and on the camera measured codec and resolution moved
198
+ together in 11 of 11 sessions, so the codec stratifies it today.
199
+
200
+ Never raises: this runs on the media path, and nothing here may be able to
201
+ break an otherwise healthy stream. An unmapped payload type is reported as
202
+ ``unknown``, which is a finding worth having rather than a reason to skip
203
+ the record.
204
+ """
205
+ return f"pt={pt} codec={_SDP_CODEC_BY_VIDEO_PT.get(pt) or 'unknown'}"
177
206
 
178
207
  # How long to wait for the FIRST media of a session before launching the serve.
179
208
  # Nothing useful can happen before then: the payload types are unknown, so the SDP
@@ -245,6 +274,77 @@ def video_pt_from_answer_sdp(sdp_text: str) -> Optional[int]:
245
274
  return None
246
275
 
247
276
 
277
+ #: Video payload types the SDES answer template advertises (H264, H265).
278
+ _SDES_ANSWER_VIDEO_PTS = (96, 97)
279
+
280
+
281
+ def _should_capture_sprop(kind, decrypted: bool, sprop_done: bool) -> bool:
282
+ """May this packet's payload be mined for SPS/PPS and cached to disk?
283
+
284
+ ``decrypted`` is the load-bearing term and it used to be missing. The
285
+ bridge's ``_fwd_pkt`` starts as the raw inbound packet and is rebound only
286
+ when ``unprotect`` succeeds, so it stays ciphertext whenever there is no
287
+ SRTP receive session - pylibsrtp is in the optional ``webrtc`` extra, so a
288
+ base install never has one - or when ``unprotect`` raised, since that
289
+ handler logs a few lines and falls through.
290
+
291
+ SRTP leaves the RTP header in clear, so the payload offset comes out right
292
+ and random ciphertext reaches the NAL demux; ``payload[0] & 0x1F`` draws 7
293
+ and 8 at about 1/32 each, and on a 30 fps stream both appear within seconds.
294
+
295
+ The result is written to ``~/.config/aidot/sprop/<devid>.sprop`` and injected
296
+ as ``sprop-parameter-sets=`` by every later session, so a poisoned cache
297
+ outlives the session that created it: installing pylibsrtp afterwards does
298
+ not clear the file, and the first correct capture then differs from it and
299
+ trips the ``.unstable`` marker, disabling injection for that camera for good.
300
+ A bad frame is cheap; a bad file is not.
301
+
302
+ Returning False for a packet nobody decrypted is right even where ffmpeg
303
+ does its own decryption: what we hold is still ciphertext, so anything mined
304
+ from it would be noise either way.
305
+ """
306
+ return bool(kind == "video" and decrypted and not sprop_done)
307
+
308
+
309
+ def _resolve_sdes_video_pt() -> Optional[int]:
310
+ """EXPERIMENTAL (opt-in, default off): pin the OFFER to one video codec.
311
+
312
+ The offer sent in webrtcReq advertises BOTH 96 (H264) and 97 (H265) and
313
+ expresses no preference, so the camera chooses which to send in its answer.
314
+ Measured on an A001064 across eleven sessions in one afternoon it chose H264
315
+ nine times and H265 twice for an otherwise identical request, and the codec
316
+ it chose determined the resolution (H264 -> 1280x720, H265 -> 2560x1440, 11
317
+ of 11). A consumer that cannot decode a sudden 2560x1440 H265 stream, or
318
+ cannot absorb the bitrate change either way, has no means today of
319
+ preventing the flip. Pinning removes the choice: measured 2026-08-07, an
320
+ offer pinned to 96 produced h264 1280x720 in 4 of 4 sessions.
321
+
322
+ It is the OFFER that matters, not the answer. Traced live with every status
323
+ line printed: this path sends webrtcReq carrying our offer and then reports
324
+ "Using camera's video SRTP key from answer" - the camera answers, we do not.
325
+ An earlier version of this pinned the answer builder instead and changed
326
+ nothing at all, while the arms still came out looking like it had worked.
327
+
328
+ **Do not set this to 97.** An H265-only offer returned NO VIDEO - audio
329
+ only, no video stream in the recording - in 3 of 3 interleaved rounds
330
+ against 3 of 3 successes for 96 in the same run. The efficient H265 profile
331
+ is real and reproducible, but only when BOTH codecs are offered; narrowing
332
+ to it removes the option rather than selecting it.
333
+
334
+ Left unset this returns None and the offer is byte-identical to today. The
335
+ SDES offer path is shared by every SDES camera, and this project's CHANGELOG
336
+ records fleet-wide blackouts caused by changes to shared paths, so the
337
+ default has to be inert. Anything unparseable, or a payload type the
338
+ template does not advertise, also returns None: narrowing to a payload type
339
+ the camera was never offered would leave it nothing to send.
340
+ """
341
+ raw = (os.environ.get("AIDOT_SDES_VIDEO_PT", "") or "").strip()
342
+ if not raw.isdigit():
343
+ return None
344
+ pt = int(raw)
345
+ return pt if pt in _SDES_ANSWER_VIDEO_PTS else None
346
+
347
+
248
348
  def narrow_sdp_payload_types(sdp_text: str, keep_video=None, keep_audio=None) -> str:
249
349
  """Rewrite an SDP to advertise a single payload type per media line.
250
350
 
@@ -1086,6 +1186,23 @@ class _SdesOpenMixin:
1086
1186
  )
1087
1187
  )
1088
1188
 
1189
+ # Opt-in: express a video-codec preference in the OFFER rather than
1190
+ # advertising both 96/97 and letting the camera decide in its answer.
1191
+ #
1192
+ # The offer is the SDP that matters here. Traced live 2026-08-07 with
1193
+ # every status line printed: this path sends webrtcReq carrying OUR
1194
+ # offer and then reports "Using camera's video SRTP key from answer" -
1195
+ # the camera answers, we do not. The answer builder further down runs
1196
+ # only on the branch where the camera offers first, which this camera
1197
+ # did not take, so pinning there changed nothing at all while the
1198
+ # arms still came out looking like the pin had worked. Inert unless set.
1199
+ _pin_video_pt = _resolve_sdes_video_pt()
1200
+ if _pin_video_pt is not None:
1201
+ sdes_offer_sdp = narrow_sdp_payload_types(
1202
+ sdes_offer_sdp, keep_video=_pin_video_pt)
1203
+ if _status:
1204
+ _status(f"SDES: offer pinned to video pt={_pin_video_pt}")
1205
+
1089
1206
  _relay_str = (
1090
1207
  f" relay-audio={_relay_addrs[_audio_sock][0]}:{_relay_addrs[_audio_sock][1]}"
1091
1208
  if _audio_sock in _relay_addrs else ""
@@ -1351,6 +1468,18 @@ class _SdesOpenMixin:
1351
1468
  "dstAddr": device_id,
1352
1469
  "encOffer": 1,
1353
1470
  "liveMqtt": 1,
1471
+ # powerType / p2pCache ride on the webrtcReq payload, alongside
1472
+ # encOffer and liveMqtt - the same object the reference client
1473
+ # puts them on (LDSMQTTClient.sendSdpOffer, smali :2967-2969,
1474
+ # put at :3017/:3022). They are STRINGS on the wire there: the
1475
+ # app stringifies the ints it reads from the IPC device info,
1476
+ # and its no-device-info fallback puts the literals "1" / "0".
1477
+ # The DTLS webrtcReq has carried both for a long time
1478
+ # (webrtc_open.py, per docs/official_camera_network_calls.md
1479
+ # section 5.2) and sends them as ints; that path is fleet-proven,
1480
+ # so it is deliberately left alone rather than churned to match.
1481
+ "powerType": str(_live_power_type),
1482
+ "p2pCache": str(_live_p2p_cache),
1354
1483
  # wPayload: newer firmware parses wPayload for ICE credentials
1355
1484
  # and PSK. Fields match reference app o.java (signaling/tyrus).
1356
1485
  "wPayload": {
@@ -3061,6 +3190,43 @@ class _SdesOpenMixin:
3061
3190
  except ValueError:
3062
3191
  _pli_gaps = (0.0, 1.5, 2.0, 3.0)
3063
3192
  _bridge_fn._pli_gaps = _pli_gaps
3193
+ # REMB names the VIDEO stream, which is the one to
3194
+ # limit. An earlier version rode the audio Receiver Report
3195
+ # and so named the audio SSRC - it asked the camera to keep
3196
+ # PCMA under the target, which it already was, and left the
3197
+ # video untouched. Measured no change, correctly.
3198
+ #
3199
+ # Own cadence: the PLI timer backs off to 30s once the
3200
+ # stream is up, which is too slow to hold a rate.
3201
+ if (REMB_TARGET_BPS > 0
3202
+ and hasattr(_bridge_fn, '_cam_video_ssrc')
3203
+ and hasattr(_bridge_fn, '_cam_srtp_sock')
3204
+ and _time_br.time() - getattr(
3205
+ _bridge_fn, '_last_remb_ts', 0.0) >= 1.0):
3206
+ _bridge_fn._last_remb_ts = _time_br.time()
3207
+ try:
3208
+ _remb_raw = build_remb(
3209
+ 0xAB12CD34,
3210
+ [_bridge_fn._cam_video_ssrc],
3211
+ REMB_TARGET_BPS)
3212
+ _remb_sess = getattr(_bridge_fn, '_pli_tx_sess', None)
3213
+ _bridge_fn._cam_srtp_sock.sendto(
3214
+ _remb_sess.protect_rtcp(_remb_raw)
3215
+ if _remb_sess is not None else _remb_raw,
3216
+ _bridge_fn._cam_srtp_src,
3217
+ )
3218
+ if not getattr(_bridge_fn, '_remb_logged', False):
3219
+ _bridge_fn._remb_logged = True
3220
+ _status(
3221
+ f"SDES: sent REMB {REMB_TARGET_BPS // 1000} kbps"
3222
+ f" for video SSRC"
3223
+ f" 0x{_bridge_fn._cam_video_ssrc:08x}")
3224
+ except Exception:
3225
+ _LOGGER.debug(
3226
+ "camera %s: swallowed exception in %s",
3227
+ getattr(self, "device_id", "?"), '_remb',
3228
+ exc_info=True)
3229
+
3064
3230
  _pli_done = getattr(_bridge_fn, '_pli_count', 0)
3065
3231
  _pli_interval = (_pli_gaps[_pli_done]
3066
3232
  if _pli_done < len(_pli_gaps) else 30.0)
@@ -4123,6 +4289,14 @@ class _SdesOpenMixin:
4123
4289
  _br_first_video_logged = True
4124
4290
  _first_video_pt[0] = _pt
4125
4291
  _status(f"bridge: first video RTP pt={_pt}")
4292
+ # At INFO, not DEBUG: this is the one line that
4293
+ # makes a bitrate figure comparable to another
4294
+ # one, and it is emitted once per session.
4295
+ _LOGGER.info(
4296
+ "camera %s: video profile %s",
4297
+ getattr(self, "device_id", "?"),
4298
+ describe_video_profile(_pt),
4299
+ )
4126
4300
  # Camera answers BUNDLE (all media on one 5-tuple),
4127
4301
  # so the talk destination is the same address as
4128
4302
  # video. Capture it here too - the camera may send
@@ -4149,6 +4323,12 @@ class _SdesOpenMixin:
4149
4323
  # from TUTK SFrames to standard SRTP, so we decrypt here
4150
4324
  # before forwarding plain RTP to ffmpeg.
4151
4325
  _fwd_pkt = _bpkt
4326
+ # Whether _fwd_pkt is plaintext by the time anything
4327
+ # below reads its PAYLOAD. False until something has
4328
+ # actually decrypted it: forwarding ciphertext is
4329
+ # inert (ffmpeg discards it), but MINING it is not -
4330
+ # see _should_capture_sprop.
4331
+ _decrypted = False
4152
4332
  if _use_plain_rtp:
4153
4333
  if not hasattr(_bridge_fn, '_srtp_rx_sess'):
4154
4334
  _bridge_fn._srtp_rx_sess = None
@@ -4173,6 +4353,7 @@ class _SdesOpenMixin:
4173
4353
  if _bridge_fn._srtp_rx_sess is not None:
4174
4354
  try:
4175
4355
  _fwd_pkt = _bridge_fn._srtp_rx_sess.unprotect(_bpkt)
4356
+ _decrypted = True
4176
4357
  except Exception as _srx_dec_e:
4177
4358
  _ec = getattr(
4178
4359
  _bridge_fn, '_decrypt_err_n', 0)
@@ -4195,8 +4376,9 @@ class _SdesOpenMixin:
4195
4376
  # can inject sprop-parameter-sets (out-of-band decoder
4196
4377
  # init, robust to in-band SPS loss). Parses only until
4197
4378
  # both are seen; then _sprop_done short-circuits.
4198
- if (_kind == "video"
4199
- and not getattr(_bridge_fn, "_sprop_done", False)):
4379
+ if _should_capture_sprop(
4380
+ _kind, _decrypted,
4381
+ getattr(_bridge_fn, "_sprop_done", False)):
4200
4382
  _ps = _extract_param_sets_from_rtp(_fwd_pkt)
4201
4383
  if _ps:
4202
4384
  _psc = getattr(_bridge_fn, "_ps_cache", None)
@@ -145,6 +145,43 @@ async def _wait_or_event(ev: "asyncio.Event", timeout: float) -> bool:
145
145
  return False
146
146
 
147
147
 
148
+ def _is_camera_present_signal(topic, msg, device_id):
149
+ """Whether this inbound MQTT message releases the pre-offer wake wait.
150
+
151
+ Two of these clauses recognise the camera's own device-channel traffic and
152
+ were missing. The client subscribes to iot/v1/cb/{device_id}/# but only
153
+ ever checked iot/v1/c/{device_id}/ ("c", no "b"), so no device-channel
154
+ topic matched; and wakeupStatus - the camera announcing it is awake -
155
+ carries no devId at top level or in payload, identifying itself only via
156
+ srcAddr ("2.{device_id}"), which nothing read. Both additions are strictly
157
+ additive: they can only recognise more real camera traffic.
158
+
159
+ lowPowerActiveStateResp stays sufficient on its own, deliberately. It is a
160
+ cloud ack, not the camera: published by the server (clientId "server-..."),
161
+ addressed to the user channel, carrying no devId, and arriving in ~60 ms
162
+ whatever the camera is doing. Waiting for the camera instead was tried and
163
+ rejected - the official app does not do it. In the decompiled client,
164
+ DeviceWakeUpRepos.wakeUpOrSleep fires the MQTT wake fire-and-forget (its
165
+ handler has no emitter at all) and completes the wake step from the HTTP
166
+ lowPowerActiveState response: DeviceWakeUpRepos$1.onSuccess calls
167
+ onNext + onComplete on BOTH the valid and invalid branches. The string
168
+ "wakeupStatus" does not appear anywhere in the app. So publishing the
169
+ offer on a cloud ack is app behaviour, and holding a battery camera back
170
+ for device evidence would be a new, unvalidated divergence on the shared
171
+ open path.
172
+ """
173
+ inner = msg.get("payload") or {}
174
+ return bool(
175
+ topic.startswith(f"iot/v1/cb/{device_id}/")
176
+ or topic.startswith(f"iot/v1/c/{device_id}/")
177
+ or topic.startswith(f"lds/v1/cb/{device_id}/")
178
+ or inner.get("devId") == device_id
179
+ or msg.get("devId") == device_id
180
+ or str(msg.get("srcAddr") or "").endswith(device_id)
181
+ or (msg.get("method") or "") == "lowPowerActiveStateResp"
182
+ )
183
+
184
+
148
185
  class _WebRTCOpenMixin:
149
186
  async def _async_open_webrtc_stream_impl(
150
187
  self,
@@ -668,17 +705,10 @@ class _WebRTCOpenMixin:
668
705
  return
669
706
  method = msg.get("method") or ""
670
707
  inner = msg.get("payload") or {}
671
- # Fire camera_ready_ev the moment the camera appears on MQTT - either via its
672
- # explicit wake-ACK (lowPowerActiveStateResp), any message on the device channel,
673
- # OR any message on the user channel whose payload identifies our device.
674
- # LK.IPC.A001064 (and similar) responds on the user channel (iot/v1/c/{userId}/...)
675
- # rather than the device channel, so the old topic-prefix check never fired -
676
- # causing the 17-second getIceConfigReq timeout overhead every session.
677
- if (method == "lowPowerActiveStateResp"
678
- or topic.startswith(f"iot/v1/c/{device_id}/")
679
- or topic.startswith(f"lds/v1/cb/{device_id}/")
680
- or inner.get("devId") == device_id
681
- or msg.get("devId") == device_id):
708
+ # Fire camera_ready_ev the moment the camera appears on MQTT. See
709
+ # _is_camera_present_signal for what counts as evidence and why the
710
+ # server's wake-ACK is not enough for a battery camera.
711
+ if _is_camera_present_signal(topic, msg, device_id):
682
712
  loop.call_soon_threadsafe(camera_ready_ev.set)
683
713
  # livePlayResp: explicit camera ack/nack for start-play command.
684
714
  # The camera echoes our peer_id (verified live); its payload has NO