python-aidot-cameras 1.0.0b26__tar.gz → 1.0.0b28__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 (265) hide show
  1. {python_aidot_cameras-1.0.0b26/python_aidot_cameras.egg-info → python_aidot_cameras-1.0.0b28}/PKG-INFO +26 -2
  2. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/README.md +25 -1
  3. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/__main__.py +8 -113
  4. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/_upstream.py +2 -2
  5. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/camera/controls.py +24 -0
  6. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/camera/protocol.py +49 -0
  7. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/camera/sdes_open.py +199 -10
  8. python_aidot_cameras-1.0.0b28/aidot_cameras/cloud_auth.py +139 -0
  9. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/pyproject.toml +1 -1
  10. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28/python_aidot_cameras.egg-info}/PKG-INFO +26 -2
  11. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/python_aidot_cameras.egg-info/SOURCES.txt +7 -0
  12. python_aidot_cameras-1.0.0b28/tests/test_avio_ctrl_uses_the_session_dseq.py +81 -0
  13. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_battery_evidence_corroboration.py +2 -2
  14. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_canary_log_names_the_camera.py +2 -2
  15. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_cloud_count.py +1 -1
  16. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_cloud_plan.py +1 -1
  17. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_direct_ts_serve.py +1 -1
  18. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_go2rtc_cli.py +3 -3
  19. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_ice_peer_reflexive.py +4 -4
  20. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_live_validate_policy.py +6 -6
  21. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_no_self_referential_go2rtc.py +4 -4
  22. python_aidot_cameras-1.0.0b28/tests/test_offer_bandwidth_line.py +139 -0
  23. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_recent_recordings.py +2 -2
  24. python_aidot_cameras-1.0.0b28/tests/test_remb_goes_out_the_relay_aware_sender.py +98 -0
  25. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_sdes_ffmpeg_stderr_log.py +2 -2
  26. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_sdes_first_media_stall_report.py +15 -15
  27. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_sdes_sprop.py +1 -1
  28. python_aidot_cameras-1.0.0b28/tests/test_sdes_tmmbr_bridge.py +204 -0
  29. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_self_peer_is_an_address_not_an_ip.py +2 -2
  30. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_serve_pt_respects_the_pin.py +1 -1
  31. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_stall_report_says_whether_the_trigger_was_answered.py +1 -1
  32. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_stall_report_separates_silence_from_undecryptable.py +2 -2
  33. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_stall_report_survives_a_caller_giving_up.py +1 -1
  34. python_aidot_cameras-1.0.0b28/tests/test_tmmbr.py +116 -0
  35. python_aidot_cameras-1.0.0b28/tests/test_validator_shares_one_token.py +123 -0
  36. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_wake_gate_camera_evidence.py +4 -4
  37. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/LICENSE +0 -0
  38. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/MANIFEST.in +0 -0
  39. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/__init__.py +0 -0
  40. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/_vendor/__init__.py +0 -0
  41. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/_vendor/aiortc/LICENSE +0 -0
  42. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/_vendor/aiortc/__init__.py +0 -0
  43. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/_vendor/aiortc/clock.py +0 -0
  44. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/_vendor/aiortc/codecs/__init__.py +0 -0
  45. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/_vendor/aiortc/codecs/base.py +0 -0
  46. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/_vendor/aiortc/codecs/g711.py +0 -0
  47. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/_vendor/aiortc/codecs/g722.py +0 -0
  48. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/_vendor/aiortc/codecs/h264.py +0 -0
  49. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/_vendor/aiortc/codecs/opus.py +0 -0
  50. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/_vendor/aiortc/codecs/vpx.py +0 -0
  51. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/_vendor/aiortc/contrib/__init__.py +0 -0
  52. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/_vendor/aiortc/contrib/media.py +0 -0
  53. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/_vendor/aiortc/contrib/signaling.py +0 -0
  54. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/_vendor/aiortc/events.py +0 -0
  55. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/_vendor/aiortc/exceptions.py +0 -0
  56. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/_vendor/aiortc/jitterbuffer.py +0 -0
  57. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/_vendor/aiortc/mediastreams.py +0 -0
  58. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/_vendor/aiortc/rate.py +0 -0
  59. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/_vendor/aiortc/rtcconfiguration.py +0 -0
  60. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/_vendor/aiortc/rtcdatachannel.py +0 -0
  61. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/_vendor/aiortc/rtcdtlstransport.py +0 -0
  62. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/_vendor/aiortc/rtcicetransport.py +0 -0
  63. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/_vendor/aiortc/rtcpeerconnection.py +0 -0
  64. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/_vendor/aiortc/rtcrtpparameters.py +0 -0
  65. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/_vendor/aiortc/rtcrtpreceiver.py +0 -0
  66. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/_vendor/aiortc/rtcrtpsender.py +0 -0
  67. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/_vendor/aiortc/rtcrtptransceiver.py +0 -0
  68. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/_vendor/aiortc/rtcsctptransport.py +0 -0
  69. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/_vendor/aiortc/rtcsessiondescription.py +0 -0
  70. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/_vendor/aiortc/rtp.py +0 -0
  71. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/_vendor/aiortc/sdp.py +0 -0
  72. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/_vendor/aiortc/stats.py +0 -0
  73. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/_vendor/aiortc/utils.py +0 -0
  74. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/ble_gateway.py +0 -0
  75. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/camera/__init__.py +0 -0
  76. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/camera/client.py +0 -0
  77. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/camera/constants.py +0 -0
  78. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/camera/go2rtc.py +0 -0
  79. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/camera/hwaccel.py +0 -0
  80. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/camera/lan_control.py +0 -0
  81. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/camera/models.py +0 -0
  82. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/camera/playback.py +0 -0
  83. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/camera/sd_events.py +0 -0
  84. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/camera/sd_listing.py +0 -0
  85. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/camera/sd_playback.py +0 -0
  86. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/camera/sdes.py +0 -0
  87. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/camera/tutk.py +0 -0
  88. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/camera/webrtc.py +0 -0
  89. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/camera/webrtc_open.py +0 -0
  90. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/client.py +0 -0
  91. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/const.py +0 -0
  92. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/credentials.py +0 -0
  93. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/crypto.py +0 -0
  94. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/device_client.py +0 -0
  95. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/discover.py +0 -0
  96. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/exceptions.py +0 -0
  97. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/g711.py +0 -0
  98. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/lan_retry.py +0 -0
  99. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/aidot_cameras/py.typed +0 -0
  100. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  101. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
  102. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/python_aidot_cameras.egg-info/requires.txt +0 -0
  103. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/python_aidot_cameras.egg-info/top_level.txt +0 -0
  104. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/setup.cfg +0 -0
  105. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_aioice_compat.py +0 -0
  106. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_alarm_event.py +0 -0
  107. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_answer_sdp_video_pt.py +0 -0
  108. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_answer_section_selection.py +0 -0
  109. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_audio_mux_offline.py +0 -0
  110. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_avio_request.py +0 -0
  111. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_avio_response_parse.py +0 -0
  112. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_avio_response_router.py +0 -0
  113. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_backoff.py +0 -0
  114. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_battery_detection.py +0 -0
  115. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_battery_skip_turn.py +0 -0
  116. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_bitrate_campaign_arms.py +0 -0
  117. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_ble_gateway.py +0 -0
  118. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_bridge_state_sentinels.py +0 -0
  119. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_busy_backoff.py +0 -0
  120. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_constants_imports_resolve.py +0 -0
  121. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_control_verdicts.py +0 -0
  122. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_deferred_hardening.py +0 -0
  123. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_device_cmd_seq_match.py +0 -0
  124. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_device_login_guard.py +0 -0
  125. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_device_user_info_cache.py +0 -0
  126. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_dormant_camera_cleanup.py +0 -0
  127. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_drain_is_cancellable.py +0 -0
  128. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_dtls_mux_terminal_write.py +0 -0
  129. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_dtls_not_ready_burst.py +0 -0
  130. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_dtls_pinning.py +0 -0
  131. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_dtls_resolution_ack.py +0 -0
  132. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_dtls_serve_decode_skip.py +0 -0
  133. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_dtls_serve_ice_wait.py +0 -0
  134. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_dtls_serve_open_timeout.py +0 -0
  135. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_dtls_skip_signaling_wait.py +0 -0
  136. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_dtls_slow_probe.py +0 -0
  137. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_dtls_stdout_producer.py +0 -0
  138. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_dtls_video_presence.py +0 -0
  139. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_egress_guard.py +0 -0
  140. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_env_seams.py +0 -0
  141. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_feature_probe_needs_a_live_session.py +0 -0
  142. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_ffmpeg_signal_exit_is_not_a_crash.py +0 -0
  143. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_futile_keepalive.py +0 -0
  144. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_gate_requires_a_decoded_frame.py +0 -0
  145. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_go2rtc.py +0 -0
  146. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_h264_decode_rate_limit.py +0 -0
  147. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_highport_nomination.py +0 -0
  148. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_hwaccel_probe.py +0 -0
  149. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_ice_config_cache.py +0 -0
  150. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_idle_release_both_loops.py +0 -0
  151. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_keepalive_renew.py +0 -0
  152. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_key_restart_sdp.py +0 -0
  153. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_keyframe_prompter.py +0 -0
  154. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_lan_control.py +0 -0
  155. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_lan_retry_reaches_the_storming_devices.py +0 -0
  156. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_late_repeats_are_dropped.py +0 -0
  157. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_light_active_color_mode.py +0 -0
  158. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_live_session_carries_talk.py +0 -0
  159. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_live_stream_param.py +0 -0
  160. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_live_video_canary.py +0 -0
  161. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_local_only_controls.py +0 -0
  162. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_logging_caps.py +0 -0
  163. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_login_connect_deadline.py +0 -0
  164. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_login_reconnect_backoff.py +0 -0
  165. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_media_counted_only_when_plaintext.py +0 -0
  166. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_media_socket_rcvbuf.py +0 -0
  167. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_media_wait_floor.py +0 -0
  168. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_motion_poll.py +0 -0
  169. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_mqtt_client_id_is_exact.py +0 -0
  170. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_mqtt_credential_refetch.py +0 -0
  171. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_mqtt_credential_self_heal.py +0 -0
  172. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_mqtt_password_not_persisted.py +0 -0
  173. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_mqtt_session_reconnect.py +0 -0
  174. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_mqtt_thread_not_orphaned.py +0 -0
  175. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_nack.py +0 -0
  176. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_nack_cannot_add_load_in_a_burst.py +0 -0
  177. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_narrow_pc_ice.py +0 -0
  178. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_no_key_material_in_logs.py +0 -0
  179. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_no_undefined_names.py +0 -0
  180. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_offer_keeps_rtcp_feedback.py +0 -0
  181. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_offline_keepalive_pause.py +0 -0
  182. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_on_frame_is_an_av_frame.py +0 -0
  183. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_open_fail_logger.py +0 -0
  184. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_open_gate_delay.py +0 -0
  185. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_peerid_reuse.py +0 -0
  186. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_persistent_mqtt.py +0 -0
  187. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_persistent_mqtt_robustness.py +0 -0
  188. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_playback_tls.py +0 -0
  189. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_post_merge_hardening.py +0 -0
  190. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_ptz_probe_reads_what_it_is_told.py +0 -0
  191. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_public_signature_matches_impl.py +0 -0
  192. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_quality_campaign_in_session.py +0 -0
  193. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_remb.py +0 -0
  194. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_resent_frames_are_dropped.py +0 -0
  195. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_resolution_persists.py +0 -0
  196. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_restart_cycle.py +0 -0
  197. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_retry_policy.py +0 -0
  198. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_scripts_importable.py +0 -0
  199. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_sctp_init_ack_tsn.py +0 -0
  200. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_sd_card_presence.py +0 -0
  201. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_sd_event_decode.py +0 -0
  202. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_sd_event_probe_payloads.py +0 -0
  203. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_sd_listing.py +0 -0
  204. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_sd_playback.py +0 -0
  205. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_sdes_adaptive.py +0 -0
  206. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_sdes_avio_dispatch.py +0 -0
  207. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_sdes_bridge_break_gate.py +0 -0
  208. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_sdes_echo_wait_timeout.py +0 -0
  209. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_sdes_fast_liveplay.py +0 -0
  210. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_sdes_idle_release.py +0 -0
  211. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_sdes_nack_bridge.py +0 -0
  212. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_sdes_offer_video_order.py +0 -0
  213. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_sdes_offer_video_pin.py +0 -0
  214. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_sdes_open_cleanup.py +0 -0
  215. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_sdes_sctp_abort.py +0 -0
  216. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_sdes_serve_audio.py +0 -0
  217. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_sdes_serve_cmd.py +0 -0
  218. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_sdes_serve_stderr_drain.py +0 -0
  219. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_sdes_talk.py +0 -0
  220. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_sdes_teardown_exit_log.py +0 -0
  221. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_sdes_terminal_ack.py +0 -0
  222. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_sdes_terminal_ack_latency.py +0 -0
  223. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_sdes_watchdog.py +0 -0
  224. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_sdp_narrowing_properties.py +0 -0
  225. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_sdp_payload_type_narrowing.py +0 -0
  226. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_security_hardening.py +0 -0
  227. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_serializable_login_info.py +0 -0
  228. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_serve_loss_tolerance.py +0 -0
  229. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_serve_relay.py +0 -0
  230. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_serve_stderr_keeps_the_reason.py +0 -0
  231. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_session_stats.py +0 -0
  232. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_shared_house_seam.py +0 -0
  233. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_snapshot_cancellation.py +0 -0
  234. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_speak.py +0 -0
  235. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_sprop_capture_gate.py +0 -0
  236. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_sprop_staleness.py +0 -0
  237. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_sprop_unstable_public.py +0 -0
  238. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_sprop_write_is_reported_truthfully.py +0 -0
  239. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_srtp_rx_rekey.py +0 -0
  240. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_srtp_tx_key_note.py +0 -0
  241. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_stall_report_says_what_the_answer_carried.py +0 -0
  242. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_stall_reports_reach_the_artifact.py +0 -0
  243. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_stream_cap.py +0 -0
  244. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_stream_drain_ownership.py +0 -0
  245. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_stream_idle.py +0 -0
  246. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_stream_slot_cap.py +0 -0
  247. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_stream_teardown.py +0 -0
  248. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_talk.py +0 -0
  249. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_talk_reports_a_speaker_that_never_opened.py +0 -0
  250. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_tap_to_mux_end_to_end.py +0 -0
  251. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_terminal_ack.py +0 -0
  252. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_token_file_robustness.py +0 -0
  253. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_token_refresh.py +0 -0
  254. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_turn_create_permission.py +0 -0
  255. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_tutk_path_is_out_of_reach.py +0 -0
  256. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_upstream_compat.py +0 -0
  257. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_video_profile_log.py +0 -0
  258. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_video_pts_dts.py +0 -0
  259. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_video_ts_unwrap.py +0 -0
  260. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_viewer_check_throttle.py +0 -0
  261. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_viewer_presence.py +0 -0
  262. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_viewer_present_push_mode.py +0 -0
  263. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_wait_or_event.py +0 -0
  264. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_wake_readiness_retry.py +0 -0
  265. {python_aidot_cameras-1.0.0b26 → python_aidot_cameras-1.0.0b28}/tests/test_webrtc_answer_race.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-aidot-cameras
3
- Version: 1.0.0b26
3
+ Version: 1.0.0b28
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
@@ -119,6 +119,27 @@ mid-session on the same transport, and it does not change the rate; the app's
119
119
  does. Streaming is correct either way and the cost is bandwidth, which matters
120
120
  on a metered or congested link.
121
121
 
122
+ **That 2:1 is reachable by another route: the codec.** Ten sessions on this
123
+ camera on 2026-08-23 separate perfectly by the video payload type the session
124
+ negotiated - H264 (pt=96) at 1597-1685 Kbps, H265 (pt=97) at 766-774 Kbps, no
125
+ overlap. An H.265 session costs about half an H.264 one, which is the same
126
+ ratio the app's SD tap achieves, and unlike a control byte the camera ignores,
127
+ the codec is chosen in our own offer.
128
+
129
+ Two things stand between that and a saving you can switch on:
130
+
131
+ - **We cannot select it.** Narrowing the offer to 97 does not select it, it
132
+ removes the option - no video at all in 3 of 3 rounds (see
133
+ `AIDOT_SDES_VIDEO_PT` below). Reordering the preference list without
134
+ narrowing it (`AIDOT_SDES_VIDEO_PT_ORDER=97,96`) was then tested directly:
135
+ eight sessions in blocks of two returned H265 in 2 of 4 with the reorder and
136
+ 2 of 4 without, with receipts confirming the reordered list reached the SDP.
137
+ The camera chooses, roughly a coin flip, and neither knob moves it.
138
+ - **H.265 may not suit the consumer.** Browser HEVC support over Media Source
139
+ Extensions is far narrower than H.264, and MSE playback is the reason the
140
+ bitrate matters here at all. Halving the bitrate is no help if the client
141
+ that was failing cannot decode the result.
142
+
122
143
  ## Local (LAN) control and account ownership
123
144
 
124
145
  Local control over the devices' TCP:10000 channel - both the light/plug protocol
@@ -398,7 +419,10 @@ audio, idle release, the sprop cache path) are documented in
398
419
  | `AIDOT_SDES_FAST_LIVEPLAY` | Don't block on the `livePlayResp` wait for eligible SDES cameras (~4.5 s faster cold start). Role-reversal models (A001064 PTZ) always excluded for correctness. **On by default**; set to `0`/`false`/`no`/`off` to disable. | enabled (on) |
399
420
  | `AIDOT_SDES_NACK` | Ask the camera to resend video RTP packets that never arrived (RTCP Generic NACK). A camera losing packets on a weak link otherwise delivers truncated H.264 slices, which a browser's WebRTC decoder conceals but Media Source Extensions treats as fatal. Measured on an A001064 at ~1-2% loss: 98.4% of losses recovered against none at all without it. Costs nothing on a clean link, where no requests are generated. **On by default**; set to `0`/`false`/`no`/`off` to disable. | enabled (on) |
400
421
  | `AIDOT_SDES_VIDEO_PT` | Pin the SDES offer to ONE video codec by payload type, so the camera cannot choose. The offer advertises 96 (H264) and 97 (H265) and the camera decides which to send; on an A001064 that means the same request comes back h264 1280x720 most sessions and hevc 2560x1440 occasionally, at a third of the bitrate. Set to `96` for H264 only (measured h264 720p in 4 of 4 sessions). **Do not set it to `97`** - an H265-only offer returned no video at all in 3 of 3 rounds; narrowing to H265 removes the option rather than selecting it. | unset (both offered) |
401
- | `AIDOT_SDES_VIDEO_PT_ORDER` | Reorder the SDES offer's video codec list without narrowing it, as a comma-separated preference list (`97,96`). RFC 3264 makes the `m=video` payload-type list a preference list, most-preferred first, and ours has always read `96 97` - not by decision, it has simply never been varied. Whatever is named leads and the rest is appended, so this can express a preference and can never produce a narrowed or empty video m-line. **Experimental and untested on hardware:** whether the camera acts on the order is the open question, so the default is deliberately unchanged. | unset (`96 97`) |
422
+ | `AIDOT_SDES_VIDEO_PT_ORDER` | Reorder the SDES offer's video codec list without narrowing it, as a comma-separated preference list (`97,96`). RFC 3264 makes the `m=video` payload-type list a preference list, most-preferred first, and ours has always read `96 97` - not by decision, it has simply never been varied. Whatever is named leads and the rest is appended, so this can express a preference and can never produce a narrowed or empty video m-line. **Experimental and untested on hardware:** whether the camera acts on the order is the open question, so the default is deliberately unchanged. **Tested on hardware 2026-08-23 and it does not select the codec.** Eight sessions on an A001064 in blocks of two: H265 came back in 2 of 4 with the order reversed and 2 of 4 without it, receipts confirming the reordered list reached the SDP. The motive was real - an H265 session costs about half an H264 one (766-774 vs 1597-1685 Kbps) - but the camera picks the codec itself, roughly a coin flip, and RFC 3264 preference order does not move it. | unset (`96 97`) |
423
+ | `AIDOT_SDES_OFFER_BANDWIDTH_KBPS` | Add a `b=AS:<kbps>` receive-bandwidth ceiling (RFC 4566) to the video section of the SDES offer. **Measured to do nothing** on an A001064 over ten sessions; kept, off, alongside `AIDOT_REMB_TARGET_BPS` for the same reason. Run as a strict ABAB campaign it appeared to give a decisive 2.1x reduction - that was a codec split, not the knob. | unset (no `b=` line) |
424
+ | `AIDOT_SDES_TMMBR_BPS` | Ask the camera not to exceed a bitrate, in bits per second, via RTCP TMMBR (RFC 5104). Distinct from `AIDOT_REMB_TARGET_BPS`: REMB reports an *estimate* of available bandwidth, TMMBR states a *bound*, and firmware can honour one without the other. This camera acts on RTCP feedback it never negotiated (our offer advertises no `a=rtcp-fb` at all, yet NACK recovers 98.4% of losses), so the absence of `ccm tmmbr` from its answer is not a reason to assume it is ignored. **Measured on an A001064 and it does nothing** - within-session, 0.748 against a control of 0.705, i.e. marginally higher. Shipped off. | unset (off) |
425
+ | `AIDOT_SDES_TMMBR_AFTER_S` | Seconds of media to let pass before the first TMMBR, so a bound can be measured *within* one session - window A before it, window B after - instead of between sessions. Measured from the first video packet, not from the open, so a slow-waking camera does not spend window A already capped. | `0` (send from the first video packet) |
402
426
  | `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) |
403
427
  | `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) |
404
428
  | `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) |
@@ -79,6 +79,27 @@ mid-session on the same transport, and it does not change the rate; the app's
79
79
  does. Streaming is correct either way and the cost is bandwidth, which matters
80
80
  on a metered or congested link.
81
81
 
82
+ **That 2:1 is reachable by another route: the codec.** Ten sessions on this
83
+ camera on 2026-08-23 separate perfectly by the video payload type the session
84
+ negotiated - H264 (pt=96) at 1597-1685 Kbps, H265 (pt=97) at 766-774 Kbps, no
85
+ overlap. An H.265 session costs about half an H.264 one, which is the same
86
+ ratio the app's SD tap achieves, and unlike a control byte the camera ignores,
87
+ the codec is chosen in our own offer.
88
+
89
+ Two things stand between that and a saving you can switch on:
90
+
91
+ - **We cannot select it.** Narrowing the offer to 97 does not select it, it
92
+ removes the option - no video at all in 3 of 3 rounds (see
93
+ `AIDOT_SDES_VIDEO_PT` below). Reordering the preference list without
94
+ narrowing it (`AIDOT_SDES_VIDEO_PT_ORDER=97,96`) was then tested directly:
95
+ eight sessions in blocks of two returned H265 in 2 of 4 with the reorder and
96
+ 2 of 4 without, with receipts confirming the reordered list reached the SDP.
97
+ The camera chooses, roughly a coin flip, and neither knob moves it.
98
+ - **H.265 may not suit the consumer.** Browser HEVC support over Media Source
99
+ Extensions is far narrower than H.264, and MSE playback is the reason the
100
+ bitrate matters here at all. Halving the bitrate is no help if the client
101
+ that was failing cannot decode the result.
102
+
82
103
  ## Local (LAN) control and account ownership
83
104
 
84
105
  Local control over the devices' TCP:10000 channel - both the light/plug protocol
@@ -358,7 +379,10 @@ audio, idle release, the sprop cache path) are documented in
358
379
  | `AIDOT_SDES_FAST_LIVEPLAY` | Don't block on the `livePlayResp` wait for eligible SDES cameras (~4.5 s faster cold start). Role-reversal models (A001064 PTZ) always excluded for correctness. **On by default**; set to `0`/`false`/`no`/`off` to disable. | enabled (on) |
359
380
  | `AIDOT_SDES_NACK` | Ask the camera to resend video RTP packets that never arrived (RTCP Generic NACK). A camera losing packets on a weak link otherwise delivers truncated H.264 slices, which a browser's WebRTC decoder conceals but Media Source Extensions treats as fatal. Measured on an A001064 at ~1-2% loss: 98.4% of losses recovered against none at all without it. Costs nothing on a clean link, where no requests are generated. **On by default**; set to `0`/`false`/`no`/`off` to disable. | enabled (on) |
360
381
  | `AIDOT_SDES_VIDEO_PT` | Pin the SDES offer to ONE video codec by payload type, so the camera cannot choose. The offer advertises 96 (H264) and 97 (H265) and the camera decides which to send; on an A001064 that means the same request comes back h264 1280x720 most sessions and hevc 2560x1440 occasionally, at a third of the bitrate. Set to `96` for H264 only (measured h264 720p in 4 of 4 sessions). **Do not set it to `97`** - an H265-only offer returned no video at all in 3 of 3 rounds; narrowing to H265 removes the option rather than selecting it. | unset (both offered) |
361
- | `AIDOT_SDES_VIDEO_PT_ORDER` | Reorder the SDES offer's video codec list without narrowing it, as a comma-separated preference list (`97,96`). RFC 3264 makes the `m=video` payload-type list a preference list, most-preferred first, and ours has always read `96 97` - not by decision, it has simply never been varied. Whatever is named leads and the rest is appended, so this can express a preference and can never produce a narrowed or empty video m-line. **Experimental and untested on hardware:** whether the camera acts on the order is the open question, so the default is deliberately unchanged. | unset (`96 97`) |
382
+ | `AIDOT_SDES_VIDEO_PT_ORDER` | Reorder the SDES offer's video codec list without narrowing it, as a comma-separated preference list (`97,96`). RFC 3264 makes the `m=video` payload-type list a preference list, most-preferred first, and ours has always read `96 97` - not by decision, it has simply never been varied. Whatever is named leads and the rest is appended, so this can express a preference and can never produce a narrowed or empty video m-line. **Experimental and untested on hardware:** whether the camera acts on the order is the open question, so the default is deliberately unchanged. **Tested on hardware 2026-08-23 and it does not select the codec.** Eight sessions on an A001064 in blocks of two: H265 came back in 2 of 4 with the order reversed and 2 of 4 without it, receipts confirming the reordered list reached the SDP. The motive was real - an H265 session costs about half an H264 one (766-774 vs 1597-1685 Kbps) - but the camera picks the codec itself, roughly a coin flip, and RFC 3264 preference order does not move it. | unset (`96 97`) |
383
+ | `AIDOT_SDES_OFFER_BANDWIDTH_KBPS` | Add a `b=AS:<kbps>` receive-bandwidth ceiling (RFC 4566) to the video section of the SDES offer. **Measured to do nothing** on an A001064 over ten sessions; kept, off, alongside `AIDOT_REMB_TARGET_BPS` for the same reason. Run as a strict ABAB campaign it appeared to give a decisive 2.1x reduction - that was a codec split, not the knob. | unset (no `b=` line) |
384
+ | `AIDOT_SDES_TMMBR_BPS` | Ask the camera not to exceed a bitrate, in bits per second, via RTCP TMMBR (RFC 5104). Distinct from `AIDOT_REMB_TARGET_BPS`: REMB reports an *estimate* of available bandwidth, TMMBR states a *bound*, and firmware can honour one without the other. This camera acts on RTCP feedback it never negotiated (our offer advertises no `a=rtcp-fb` at all, yet NACK recovers 98.4% of losses), so the absence of `ccm tmmbr` from its answer is not a reason to assume it is ignored. **Measured on an A001064 and it does nothing** - within-session, 0.748 against a control of 0.705, i.e. marginally higher. Shipped off. | unset (off) |
385
+ | `AIDOT_SDES_TMMBR_AFTER_S` | Seconds of media to let pass before the first TMMBR, so a bound can be measured *within* one session - window A before it, window B after - instead of between sessions. Measured from the first video packet, not from the open, so a slow-waking camera does not spend window A already capped. | `0` (send from the first video packet) |
362
386
  | `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) |
363
387
  | `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) |
364
388
  | `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) |
@@ -46,14 +46,14 @@ Optional stream knobs:
46
46
  go2rtc.yaml example (SDES camera - native RTSP push, video + audio):
47
47
 
48
48
  streams:
49
- frontdoor: exec:aidot-go2rtc 8d2521ea... {output}
49
+ frontdoor: exec:aidot-go2rtc 0a1b2c3d... {output}
50
50
 
51
51
  A DTLS camera (A000088) has no RTSP-push path, but a stdout producer works the
52
52
  same lazy way. Pass ``-`` as the output and this process writes MPEG-TS (video
53
53
  plus AAC audio) to stdout, which go2rtc's exec: source reads directly:
54
54
 
55
55
  streams:
56
- driveway: exec:aidot-go2rtc 7c89a5c1... -
56
+ garage: exec:aidot-go2rtc 0a1b2c3d... -
57
57
 
58
58
  go2rtc then owns the lifecycle - spawn on the first viewer, kill when idle -
59
59
  exactly as it does with ``{output}`` for an SDES camera. An
@@ -66,18 +66,22 @@ from __future__ import annotations
66
66
 
67
67
  import argparse
68
68
  import asyncio
69
- import json
70
69
  import logging
71
70
  import os
72
71
  import re
73
72
  import signal
74
73
  import sys
75
- import tempfile
76
74
  from typing import Optional
77
75
 
78
76
  import aiohttp
79
77
 
80
78
  from .client import AidotClient
79
+ from .cloud_auth import ( # noqa: F401 (re-exported for callers and tests)
80
+ _install_token_cache,
81
+ _make_client,
82
+ _read_token_file,
83
+ _write_token_file,
84
+ )
81
85
  from .const import CONF_DEVICE_LIST, CONF_ID, CONF_MODEL_ID, CONF_NAME
82
86
 
83
87
  _LOGGER = logging.getLogger("aidot.go2rtc")
@@ -93,115 +97,6 @@ def _env_bool(name: str) -> bool | None:
93
97
  return raw == "1"
94
98
 
95
99
 
96
- def _read_token_file(path: str) -> dict:
97
- """Blocking read of a stored login_info dict (run via executor from async)."""
98
- with open(path, encoding="utf-8") as fh:
99
- return json.load(fh)
100
-
101
-
102
- def _write_token_file(path: str, data: dict) -> None:
103
- """Atomic, 0600-secure write of login_info to ``path``.
104
-
105
- Serialize into a temp file in the same directory (mkstemp creates it 0600,
106
- closing the world-readable window os.chmod-after-write leaves) and only then
107
- os.replace it over ``path`` - so a serialization failure never truncates the
108
- previously-valid token. On any error the temp file is removed and re-raised.
109
- """
110
- directory = os.path.dirname(path) or "."
111
- fd, tmp = tempfile.mkstemp(dir=directory, prefix=".token-", suffix=".tmp")
112
- try:
113
- with os.fdopen(fd, "w", encoding="utf-8") as fh:
114
- json.dump(data, fh)
115
- os.replace(tmp, path)
116
- except BaseException:
117
- try:
118
- os.unlink(tmp)
119
- except OSError:
120
- pass
121
- raise
122
-
123
-
124
- def _install_token_cache(client: AidotClient, path: str) -> None:
125
- """Persist rotated tokens to ``path`` so refreshes survive across restarts.
126
-
127
- The library calls this no-arg callback after every successful token refresh
128
- (client.py), having already updated ``client.login_info``. Mirrors HA's
129
- coordinator.token_fresh_cb, but writes to our own file instead of an HA
130
- config entry - so a standalone run never loses auth to a rotation and never
131
- fights HA over a shared refresh token.
132
- """
133
- def _cb() -> None:
134
- try:
135
- _write_token_file(path, client.serializable_login_info())
136
- _LOGGER.debug("Cached refreshed token to %s", path)
137
- except (OSError, TypeError) as exc:
138
- # TypeError: a defensive belt-and-suspenders catch alongside
139
- # serializable_login_info() itself - if a future runtime-only key
140
- # is ever added to login_info without also being added to
141
- # RUNTIME_ONLY_LOGIN_INFO_KEYS, this keeps a caching bug from
142
- # ever propagating out of a callback and interrupting a token
143
- # refresh that had otherwise already succeeded (confirmed live:
144
- # this exact TypeError - "Object of type Lock is not JSON
145
- # serializable" - previously escaped this callback uncaught).
146
- _LOGGER.warning("Could not cache refreshed token to %s: %s", path, exc)
147
-
148
- client.set_token_fresh_cb(_cb)
149
-
150
-
151
- async def _make_client(session: aiohttp.ClientSession) -> AidotClient:
152
- """Authenticate to the AiDot cloud from the environment.
153
-
154
- Prefers a stored token (AIDOT_TOKEN_FILE) - the same login_info dict the HA
155
- integration persists - which carries access/refresh tokens so no password
156
- round-trip is needed. Falls back to username/password login otherwise. In
157
- both cases, if AIDOT_TOKEN_FILE is set we register a write-back cache so
158
- rotations persist.
159
- """
160
- loop = asyncio.get_running_loop()
161
- token_file = os.environ.get("AIDOT_TOKEN_FILE")
162
- username = os.environ.get("AIDOT_USERNAME")
163
- password = os.environ.get("AIDOT_PASSWORD")
164
-
165
- if token_file and os.path.exists(token_file):
166
- try:
167
- token = await loop.run_in_executor(None, _read_token_file, token_file)
168
- except (OSError, ValueError) as exc:
169
- # ValueError covers json.JSONDecodeError (empty/partial/corrupt
170
- # cache). Fall through to the username/password path rather than
171
- # crash CLI startup.
172
- _LOGGER.warning("ignoring unreadable token cache %s: %s", token_file, exc)
173
- else:
174
- client = AidotClient(session=session, token=token)
175
- _install_token_cache(client, token_file)
176
- return client
177
-
178
- if not username or not password:
179
- sys.exit(
180
- "Set AIDOT_TOKEN_FILE, or AIDOT_USERNAME and AIDOT_PASSWORD, in the "
181
- "environment."
182
- )
183
-
184
- client = AidotClient(
185
- session=session,
186
- country_code=DEFAULT_COUNTRY,
187
- username=username,
188
- password=password,
189
- )
190
- await client.async_post_login()
191
- # If a token file path was given (but didn't exist yet), seed it now and
192
- # register the write-back cache so a dedicated-login run persists its
193
- # rotations across restarts.
194
- if token_file:
195
- _install_token_cache(client, token_file)
196
- try:
197
- await loop.run_in_executor(
198
- None, _write_token_file, token_file, client.serializable_login_info()
199
- )
200
- except (OSError, TypeError) as exc:
201
- _LOGGER.warning("Could not seed token cache %s: %s", token_file, exc)
202
- return client
203
-
204
-
205
100
  async def _cameras(client: AidotClient) -> list[dict]:
206
101
  """Return the raw device dicts for every camera on the account."""
207
102
  data = await client.async_get_all_device()
@@ -93,8 +93,8 @@ def rsa_encrypt(message: str, public_key: Any = None) -> str:
93
93
  with ``aidot.client.rsa_password_encrypt(message)``, which bakes the key in
94
94
  and takes one argument. Neither signature is available on both shapes, and
95
95
  ``aidot_cameras.crypto.rsa_encrypt`` is public surface the integration repo
96
- may import (see the back-compat rule in CLAUDE.md), so the two-argument form
97
- is kept and satisfied locally. ``public_key`` defaults to upstream's
96
+ may import, and public surface is not changed to track an upstream rename,
97
+ so the two-argument form is kept and satisfied locally. ``public_key`` defaults to upstream's
98
98
  ``PUBLIC_KEY_PEM``, which is the only key any caller ever passed.
99
99
  """
100
100
  from cryptography.hazmat.backends import default_backend
@@ -196,8 +196,32 @@ class _CameraControlsMixin:
196
196
  had been applied. Nothing else re-sent it, so "it will apply on the next
197
197
  session" was not true of any code path.
198
198
 
199
+ **On the A001064 this is acked and inert - it does not change the
200
+ rate, on either codec.** Measured 2026-08-23 with the framing gap
201
+ closed (1.0.0b27, so the AVIO header including the live-play dSeq is
202
+ byte-identical to the app's), in-session with a control arm that waits
203
+ the same gap and sends nothing: sd 0.885 against control 0.863, where
204
+ a working SD is about 2:1. Re-measured the same night on the H265
205
+ encode specifically - the codec the vendor app's working toggle rides -
206
+ because every earlier measurement had landed on H264 sessions: hd on
207
+ H265 scored 0.703 against controls at 0.743-0.776. Inert there too. The camera answers `801 payload=0000000000000000` every time. The
208
+ vendor app's own SD tap DOES halve the rate on the same camera, so this
209
+ is not a firmware that cannot do SD; we cannot trigger it. See
210
+ docs/ROAD-TO-1.0.md item 2 for the full record, including the five
211
+ other bitrate controls that turned out to be dead and the codec
212
+ difference that is worth about the same 2:1.
213
+
214
+ The call is kept because it is correct, it is what the app sends, and
215
+ other models are not covered by that measurement. Callers should not
216
+ rely on it changing the bitrate.
217
+
199
218
  Source: f0.java g3() -> X2(800, SetStreamCtrlReq.parseContent(0, quality)).
200
219
  Payload <IB3x> = channel(0) + quality byte + 3 reserved.
220
+ The app has three settings, not two: Auto, SD and HD, which
221
+ `NewLivePresenter.setDefaultResolution` maps from the persistent
222
+ `props.StreamType` property to quality 16, 5 and 1. This library
223
+ implements only hd=1 and sd=5; 16 (Auto) was swept and did nothing, and
224
+ `StreamType` itself is acked-and-ignored on every write.
201
225
  """
202
226
  q = _STREAM_QUALITY.get(quality.lower())
203
227
  if q is None:
@@ -2993,6 +2993,55 @@ def build_nack(sender_ssrc: int, media_ssrc: int, lost_seqs: "list") -> bytes:
2993
2993
  return pkt[:2] + struct.pack("!H", len(pkt) // 4 - 1) + pkt[4:]
2994
2994
 
2995
2995
 
2996
+ #: RTPFB feedback message types (RFC 4585 s6.1, RFC 5104 s4.2).
2997
+ _RTPFB_PT = 205
2998
+ _FMT_NACK = 1
2999
+ _FMT_TMMBR = 3
3000
+
3001
+
3002
+ def build_tmmbr(sender_ssrc: int, media_ssrc: int, bitrate_bps: int,
3003
+ overhead: int = 60) -> bytes:
3004
+ """A TMMBR (RFC 5104 s4.2.1) telling ``media_ssrc`` not to exceed a rate.
3005
+
3006
+ Distinct from REMB, which this camera was measured to ignore: REMB reports
3007
+ an *estimate* of what the path will bear, TMMBR states a *bound* the sender
3008
+ must respect. They are different messages and a firmware can honour one
3009
+ without the other.
3010
+
3011
+ The bound is encoded as a 17-bit mantissa scaled by a 6-bit exponent, so
3012
+ not every rate is representable. We round the mantissa DOWN, never up: a
3013
+ TMMBR that permits more than the caller asked for is worse than sending
3014
+ none at all on a link that is already saturating.
3015
+
3016
+ ``overhead`` is Measured Overhead, per-packet transport overhead in bytes
3017
+ (9 bits). 60 is the usual IP+UDP+RTP+SRTP figure and is what browsers send.
3018
+ """
3019
+ if bitrate_bps <= 0:
3020
+ raise ValueError(
3021
+ f"a TMMBR must name a positive bitrate, got {bitrate_bps}; a bound "
3022
+ f"of zero asks the camera to stop sending")
3023
+ exp = 0
3024
+ mantissa = int(bitrate_bps)
3025
+ while mantissa >= (1 << 17):
3026
+ mantissa >>= 1
3027
+ exp += 1
3028
+ fci = struct.pack("!II",
3029
+ media_ssrc & 0xFFFFFFFF,
3030
+ (exp << 26) | (mantissa << 9) | (max(0, overhead) & 0x1FF))
3031
+ # The header's media-source field is unused for TMMBR and SHALL be zero
3032
+ # (RFC 5104 s4.2.1.2); the limited SSRC travels in the FCI above.
3033
+ pkt = (struct.pack("!BBH", 0x80 | _FMT_TMMBR, _RTPFB_PT, 0)
3034
+ + struct.pack("!II", sender_ssrc & 0xFFFFFFFF, 0)
3035
+ + fci)
3036
+ return pkt[:2] + struct.pack("!H", len(pkt) // 4 - 1) + pkt[4:]
3037
+
3038
+
3039
+ def decode_tmmbr_bitrate(pkt: bytes) -> int:
3040
+ """The bound carried by a TMMBR built above, in bits per second."""
3041
+ word = struct.unpack("!I", pkt[16:20])[0]
3042
+ return ((word >> 9) & 0x1FFFF) << (word >> 26)
3043
+
3044
+
2996
3045
  def decode_nack_seqs(pkt: bytes) -> "list":
2997
3046
  """The sequence numbers a Generic NACK asks for. For tests and diagnostics."""
2998
3047
  out = []
@@ -30,6 +30,7 @@ from .protocol import (
30
30
  NackTracker,
31
31
  build_nack,
32
32
  build_remb,
33
+ build_tmmbr,
33
34
  _build_sprop,
34
35
  parse_avio_response,
35
36
  _build_stun_binding_success_response,
@@ -240,6 +241,42 @@ def _widen_media_rcvbuf(sock, kind: str, device_id: str = "?") -> int:
240
241
  return got
241
242
 
242
243
 
244
+ def _sdes_offer_bandwidth_kbps():
245
+ """A receive-bandwidth ceiling for the offer, in kbps, or None for none.
246
+
247
+ Off unless ``AIDOT_SDES_OFFER_BANDWIDTH_KBPS`` names a positive integer.
248
+ An unparseable value is off rather than an error: this is read while an
249
+ offer is being built, and a typo must not take a camera off the air.
250
+ """
251
+ raw = os.environ.get("AIDOT_SDES_OFFER_BANDWIDTH_KBPS")
252
+ try:
253
+ kbps = int(str(raw).strip())
254
+ except (TypeError, ValueError, AttributeError):
255
+ return None
256
+ return kbps if kbps > 0 else None
257
+
258
+
259
+ def _offer_bandwidth_line(kbps) -> str:
260
+ """``b=AS:<kbps>`` (RFC 4566 s5.8) for the offer, or "" for no ceiling.
261
+
262
+ A receiver telling a sender how much it is willing to accept. This is the
263
+ last standards-defined bitrate control left after the others were killed
264
+ on evidence, and the only one we emit ourselves rather than mirror from
265
+ the app -- the app sends no ``b=`` line at all.
266
+
267
+ Anything that is not a positive integer count of kilobits yields no line.
268
+ ``b=AS:0`` is NOT "unlimited" in RFC 4566; it asks for zero bandwidth, so
269
+ the off case must omit the line entirely.
270
+ """
271
+ try:
272
+ value = int(kbps)
273
+ except (TypeError, ValueError):
274
+ return ""
275
+ if value <= 0:
276
+ return ""
277
+ return f"b=AS:{value}\r\n"
278
+
279
+
243
280
  def _sdes_nack_enabled() -> bool:
244
281
  """Whether to ask the camera to resend video packets that never arrived.
245
282
 
@@ -353,6 +390,71 @@ def _send_video_nack(send, srtcp_sess, sender_ssrc: int,
353
390
  return False
354
391
 
355
392
 
393
+ def _sdes_tmmbr_bps():
394
+ """The bitrate bound to ask the camera for, in bits/s, or None for none.
395
+
396
+ Off unless ``AIDOT_SDES_TMMBR_BPS`` names a positive integer. Unparseable
397
+ is off rather than an error: this is read while a session is running.
398
+ """
399
+ try:
400
+ bps = int(str(os.environ.get("AIDOT_SDES_TMMBR_BPS")).strip())
401
+ except (TypeError, ValueError, AttributeError):
402
+ return None
403
+ return bps if bps > 0 else None
404
+
405
+
406
+ def _sdes_tmmbr_after_s() -> float:
407
+ """Seconds of MEDIA to let pass before the first TMMBR. 0 = immediately.
408
+
409
+ Exists so the bound can be measured within a session -- window A before it,
410
+ window B after -- instead of between sessions. On this camera a
411
+ between-session comparison has twice produced a wrong answer: it read a
412
+ codec split as a bandwidth-cap effect, and the encoder's own drift as a
413
+ working SD control. Both windows of one session share the codec and the
414
+ scene, so neither can confound it.
415
+ """
416
+ try:
417
+ after = float(str(os.environ.get("AIDOT_SDES_TMMBR_AFTER_S")).strip())
418
+ except (TypeError, ValueError, AttributeError):
419
+ return 0.0
420
+ return after if after > 0 else 0.0
421
+
422
+
423
+ def _tmmbr_ready(first_video_ts, now: float, after_s: float) -> bool:
424
+ """Whether enough MEDIA has passed to start asking for the bound.
425
+
426
+ Measured from the first video packet, not from the open: a camera that
427
+ takes twelve seconds to wake would otherwise spend all of window A capped.
428
+ """
429
+ if first_video_ts is None:
430
+ return False
431
+ return (now - first_video_ts) >= after_s
432
+
433
+
434
+ def _send_video_tmmbr(send, srtcp_sess, sender_ssrc: int,
435
+ media_ssrc: int, bitrate_bps) -> bool:
436
+ """Put one TMMBR on the camera's RTCP path. True if it went out.
437
+
438
+ ``send`` is the bridge's relay-aware sender, NOT a raw socket, for the
439
+ reason spelled out on :func:`_send_video_nack`: via TURN the address media
440
+ arrived from is the relay, and a raw write there is dropped as a malformed
441
+ STUN message while still reporting success. (``REMB`` still writes to the
442
+ socket directly and is inert on a relayed session for exactly that reason;
443
+ it is latent only because its target defaults to 0.)
444
+
445
+ Never raises: this runs inside the bridge's packet loop.
446
+ """
447
+ if not bitrate_bps or bitrate_bps <= 0:
448
+ return False
449
+ try:
450
+ raw = build_tmmbr(sender_ssrc, media_ssrc, bitrate_bps)
451
+ send(srtcp_sess.protect_rtcp(raw) if srtcp_sess is not None else raw)
452
+ return True
453
+ except Exception:
454
+ _LOGGER.debug("TMMBR send failed", exc_info=True)
455
+ return False
456
+
457
+
356
458
  #: Sender SSRC on every RTCP we send the camera. Load-bearing, not cosmetic:
357
459
  #: the SRTP TX policy is keyed `ssrc_value=_CAM_RTCP_SENDER_SSRC`, so the PLI,
358
460
  #: REMB, RR and NACK must all agree or the camera drops the packet.
@@ -1073,7 +1175,7 @@ def _record_peer_reflexive(known, discovered, observed, is_self=None):
1073
1175
  route to, the nomination goes into a black hole, and the session sits in
1074
1176
  ICE "Checking" while the camera's probes keep arriving from an address we
1075
1177
  never nominate. Observed on the A001064 PTZ while it was on a separate
1076
- 192.168.100.0/24: it advertised 192.168.100.13 as its only candidate.
1178
+ 192.168.9.0/24: it advertised 192.168.9.13 as its only candidate.
1077
1179
 
1078
1180
  Returns the new discovered list, or ``discovered`` unchanged when there is
1079
1181
  nothing to learn. Callers must REBIND rather than mutate in place - the
@@ -1948,7 +2050,8 @@ class _SdesOpenMixin:
1948
2050
  # video m-section
1949
2051
  + f"m=video {_offer_video_port} RTP/SAVPF {_video_pt_list}\r\n"
1950
2052
  f"c=IN IP4 {_offer_video_ip}\r\n"
1951
- "a=recvonly\r\n"
2053
+ + _offer_bandwidth_line(_sdes_offer_bandwidth_kbps())
2054
+ + "a=recvonly\r\n"
1952
2055
  "a=mid:1\r\n"
1953
2056
  f"a=crypto:1 AES_CM_128_HMAC_SHA1_80 inline:{srtp_key_video}\r\n"
1954
2057
  + _video_codec_attrs
@@ -1995,6 +2098,15 @@ class _SdesOpenMixin:
1995
2098
  if _video_pt_order != _SDES_OFFER_VIDEO_PT_ORDER and _status:
1996
2099
  _status(f"SDES: offer video codec order={_video_pt_list}")
1997
2100
 
2101
+ # Same receipt, same reason, for the receive-bandwidth ceiling: the log
2102
+ # carries the camera's ANSWER, not our offer, so "the env var was set"
2103
+ # is not evidence the line reached the wire. Measured 2026-08-23: a
2104
+ # b=AS arm scored identically to its control and the only available
2105
+ # check for the knob was reading the env back out of the harness.
2106
+ _bw_kbps = _sdes_offer_bandwidth_kbps()
2107
+ if _bw_kbps and _status:
2108
+ _status(f"SDES: offer receive-bandwidth ceiling b=AS:{_bw_kbps}")
2109
+
1998
2110
  # Opt-in: NARROW the OFFER to one video codec rather than advertising
1999
2111
  # both 96/97 and letting the camera decide in its answer. Distinct from
2000
2112
  # the ordering above, which only states which of the two we would
@@ -4076,23 +4188,33 @@ class _SdesOpenMixin:
4076
4188
  #
4077
4189
  # Own cadence: the PLI timer backs off to 30s once the
4078
4190
  # stream is up, which is too slow to hold a rate.
4191
+ # Goes out the relay-aware sender, NOT the socket: via TURN
4192
+ # the address media arrived from is the relay, and a raw
4193
+ # write there is dropped as a malformed STUN message while
4194
+ # sendto still reports success. That is the bug the NACK
4195
+ # path already had and had fixed; REMB was left behind, so
4196
+ # on a relayed camera it never arrived -- which also means
4197
+ # "REMB was measured to do nothing" cannot have been a valid
4198
+ # measurement on such a session. No fallback to the socket:
4199
+ # skipping a tick until the sender exists costs a second,
4200
+ # falling back costs correctness on the very sessions this
4201
+ # is for.
4202
+ _remb_send = getattr(_bridge_fn, '_send_to_cam', None)
4079
4203
  if (REMB_TARGET_BPS > 0
4080
4204
  and hasattr(_bridge_fn, '_cam_video_ssrc')
4081
- and hasattr(_bridge_fn, '_cam_srtp_sock')
4205
+ and _remb_send is not None
4082
4206
  and _time_br.time() - getattr(
4083
4207
  _bridge_fn, '_last_remb_ts', 0.0) >= 1.0):
4084
4208
  _bridge_fn._last_remb_ts = _time_br.time()
4085
4209
  try:
4086
4210
  _remb_raw = build_remb(
4087
- 0xAB12CD34,
4211
+ _CAM_RTCP_SENDER_SSRC,
4088
4212
  [_bridge_fn._cam_video_ssrc],
4089
4213
  REMB_TARGET_BPS)
4090
4214
  _remb_sess = getattr(_bridge_fn, '_pli_tx_sess', None)
4091
- _bridge_fn._cam_srtp_sock.sendto(
4215
+ _remb_send(
4092
4216
  _remb_sess.protect_rtcp(_remb_raw)
4093
- if _remb_sess is not None else _remb_raw,
4094
- _bridge_fn._cam_srtp_src,
4095
- )
4217
+ if _remb_sess is not None else _remb_raw)
4096
4218
  if not getattr(_bridge_fn, '_remb_logged', False):
4097
4219
  _bridge_fn._remb_logged = True
4098
4220
  _status(
@@ -4105,6 +4227,39 @@ class _SdesOpenMixin:
4105
4227
  getattr(self, "device_id", "?"), '_remb',
4106
4228
  exc_info=True)
4107
4229
 
4230
+ # TMMBR: a BOUND, where REMB above is an ESTIMATE. Two
4231
+ # different RFC 5104 / 4585 messages, and this firmware can
4232
+ # honour one without the other -- it already acts on NACKs
4233
+ # it never negotiated (our SDES offer carries no a=rtcp-fb
4234
+ # line at all), so "the answer does not advertise ccm
4235
+ # tmmbr" is not a reason to withhold it. Off by default;
4236
+ # every bitrate control tried on this camera so far has
4237
+ # been acked and ignored, and an unmeasured one must not
4238
+ # reach the four cameras that stream fine today.
4239
+ _tmmbr_bps = getattr(_bridge_fn, '_tmmbr_bps', None)
4240
+ _tmmbr_send = getattr(_bridge_fn, '_send_to_cam', None)
4241
+ if (_tmmbr_bps and _tmmbr_send is not None
4242
+ and hasattr(_bridge_fn, '_cam_video_ssrc')
4243
+ and _tmmbr_ready(
4244
+ getattr(_bridge_fn, '_first_video_ts', None),
4245
+ _time_br.time(),
4246
+ getattr(_bridge_fn, '_tmmbr_after_s', 0.0))
4247
+ and _time_br.time() - getattr(
4248
+ _bridge_fn, '_last_tmmbr_ts', 0.0) >= 1.0):
4249
+ _bridge_fn._last_tmmbr_ts = _time_br.time()
4250
+ if _send_video_tmmbr(
4251
+ _tmmbr_send,
4252
+ getattr(_bridge_fn, '_pli_tx_sess', None),
4253
+ _CAM_RTCP_SENDER_SSRC,
4254
+ _bridge_fn._cam_video_ssrc,
4255
+ _tmmbr_bps,
4256
+ ) and not getattr(_bridge_fn, '_tmmbr_logged', False):
4257
+ _bridge_fn._tmmbr_logged = True
4258
+ _status(
4259
+ f"SDES: sent TMMBR {_tmmbr_bps // 1000} kbps"
4260
+ f" for video SSRC"
4261
+ f" 0x{_bridge_fn._cam_video_ssrc:08x}")
4262
+
4108
4263
  _pli_done = getattr(_bridge_fn, '_pli_count', 0)
4109
4264
  _pli_interval = (_pli_gaps[_pli_done]
4110
4265
  if _pli_done < len(_pli_gaps) else 30.0)
@@ -4208,10 +4363,20 @@ class _SdesOpenMixin:
4208
4363
  # pattern as the LIVING send above). dcep_sock/dcep_src are the
4209
4364
  # socket+addr the SCTP handshake (and LIVING) used.
4210
4365
  import struct as _st_pcmd
4211
- import random as _r_pcmd
4212
4366
 
4213
4367
  def _persistent_sdes_cmd(_cmd, _extra=b''):
4214
- _seq = _r_pcmd.randint(0, 0x7FFFFFFF)
4368
+ # dSeq, not a random number. The app's
4369
+ # sendCtrl() takes this from a per-client
4370
+ # counter that starts at 100 and increments
4371
+ # on every control command, and we keep the
4372
+ # same counter for livePlayReq already.
4373
+ # Filling the slot randomly was the only
4374
+ # field in the 28-byte header that differed
4375
+ # from the app's. Parity, not a fix: PTZ
4376
+ # (AVIO 0x1001) pans through this very
4377
+ # builder with a random dSeq, so the camera
4378
+ # is not validating it.
4379
+ _seq = self._next_dseq()
4215
4380
  _ts = int(_time_br.time() * 1000)
4216
4381
  _avio = _st_pcmd.pack('<IIqII4x', _seq, _cmd, _ts,
4217
4382
  len(_extra), 0) + _extra
@@ -5312,6 +5477,23 @@ class _SdesOpenMixin:
5312
5477
  '!I', _bpkt, 8)[0]
5313
5478
  _bridge_fn._cam_srtp_src = _bsrc
5314
5479
  _bridge_fn._cam_srtp_sock = _bs
5480
+ # Publish the relay-aware sender so the RTCP
5481
+ # cadence (which runs outside this loop and has
5482
+ # no socket/peer in scope) can reach the camera
5483
+ # the same way the NACK does. Rebuilt only when
5484
+ # the address changes: this runs ~300x/s per
5485
+ # camera, which is why _nack_on is resolved once
5486
+ # per session rather than read per packet.
5487
+ if getattr(_bridge_fn, '_first_video_ts',
5488
+ None) is None:
5489
+ _bridge_fn._first_video_ts = _time_br.time()
5490
+ if getattr(_bridge_fn, '_send_to_cam_src',
5491
+ None) != _bsrc:
5492
+ _bridge_fn._send_to_cam_src = _bsrc
5493
+ _bridge_fn._send_to_cam = (
5494
+ lambda _d, _s=_bs, _a=_bsrc,
5495
+ _p=_br_cam_peer: _br_send_to_cam(
5496
+ _s, _d, _a, _p))
5315
5497
  # Schedule immediate PLI so IDR+SPS arrives in
5316
5498
  # the analyzeduration window.
5317
5499
  _bridge_fn._last_pli_ts = 0.0
@@ -5320,6 +5502,13 @@ class _SdesOpenMixin:
5320
5502
  # os.environ.get costs ~250ns of it. A flip
5321
5503
  # now takes effect on the next stream open.
5322
5504
  _bridge_fn._nack_on = _sdes_nack_enabled()
5505
+ # Same reason, same place: the RTCP cadence
5506
+ # below sits on this loop, so its switches are
5507
+ # resolved once here rather than read from the
5508
+ # environment ~300x/s. A flip takes effect on
5509
+ # the next stream open, as the NACK switch does.
5510
+ _bridge_fn._tmmbr_bps = _sdes_tmmbr_bps()
5511
+ _bridge_fn._tmmbr_after_s = _sdes_tmmbr_after_s()
5323
5512
  # For TUTK cameras (_use_plain_rtp) the ffmpeg SDP uses
5324
5513
  # RTP/AVP (no crypto). After LIVING the camera switches
5325
5514
  # from TUTK SFrames to standard SRTP, so we decrypt here