python-aidot-cameras 1.0.0b31__tar.gz → 1.0.0b33__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 (270) hide show
  1. {python_aidot_cameras-1.0.0b31/python_aidot_cameras.egg-info → python_aidot_cameras-1.0.0b33}/PKG-INFO +1 -1
  2. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/camera/client.py +38 -0
  3. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/camera/sdes_open.py +270 -14
  4. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/pyproject.toml +1 -1
  5. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  6. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/python_aidot_cameras.egg-info/SOURCES.txt +3 -0
  7. python_aidot_cameras-1.0.0b33/tests/test_answer_pt_kinds.py +141 -0
  8. python_aidot_cameras-1.0.0b33/tests/test_serve_input_timeout.py +65 -0
  9. python_aidot_cameras-1.0.0b33/tests/test_stall_nudge_policy.py +51 -0
  10. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/LICENSE +0 -0
  11. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/MANIFEST.in +0 -0
  12. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/README.md +0 -0
  13. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/__init__.py +0 -0
  14. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/__main__.py +0 -0
  15. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/_upstream.py +0 -0
  16. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/_vendor/__init__.py +0 -0
  17. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/_vendor/aiortc/LICENSE +0 -0
  18. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/_vendor/aiortc/__init__.py +0 -0
  19. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/_vendor/aiortc/clock.py +0 -0
  20. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/_vendor/aiortc/codecs/__init__.py +0 -0
  21. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/_vendor/aiortc/codecs/base.py +0 -0
  22. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/_vendor/aiortc/codecs/g711.py +0 -0
  23. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/_vendor/aiortc/codecs/g722.py +0 -0
  24. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/_vendor/aiortc/codecs/h264.py +0 -0
  25. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/_vendor/aiortc/codecs/opus.py +0 -0
  26. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/_vendor/aiortc/codecs/vpx.py +0 -0
  27. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/_vendor/aiortc/contrib/__init__.py +0 -0
  28. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/_vendor/aiortc/contrib/media.py +0 -0
  29. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/_vendor/aiortc/contrib/signaling.py +0 -0
  30. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/_vendor/aiortc/events.py +0 -0
  31. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/_vendor/aiortc/exceptions.py +0 -0
  32. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/_vendor/aiortc/jitterbuffer.py +0 -0
  33. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/_vendor/aiortc/mediastreams.py +0 -0
  34. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/_vendor/aiortc/rate.py +0 -0
  35. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/_vendor/aiortc/rtcconfiguration.py +0 -0
  36. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/_vendor/aiortc/rtcdatachannel.py +0 -0
  37. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/_vendor/aiortc/rtcdtlstransport.py +0 -0
  38. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/_vendor/aiortc/rtcicetransport.py +0 -0
  39. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/_vendor/aiortc/rtcpeerconnection.py +0 -0
  40. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/_vendor/aiortc/rtcrtpparameters.py +0 -0
  41. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/_vendor/aiortc/rtcrtpreceiver.py +0 -0
  42. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/_vendor/aiortc/rtcrtpsender.py +0 -0
  43. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/_vendor/aiortc/rtcrtptransceiver.py +0 -0
  44. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/_vendor/aiortc/rtcsctptransport.py +0 -0
  45. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/_vendor/aiortc/rtcsessiondescription.py +0 -0
  46. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/_vendor/aiortc/rtp.py +0 -0
  47. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/_vendor/aiortc/sdp.py +0 -0
  48. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/_vendor/aiortc/stats.py +0 -0
  49. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/_vendor/aiortc/utils.py +0 -0
  50. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/ble_gateway.py +0 -0
  51. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/camera/__init__.py +0 -0
  52. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/camera/constants.py +0 -0
  53. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/camera/controls.py +0 -0
  54. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/camera/go2rtc.py +0 -0
  55. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/camera/hwaccel.py +0 -0
  56. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/camera/lan_control.py +0 -0
  57. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/camera/models.py +0 -0
  58. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/camera/playback.py +0 -0
  59. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/camera/protocol.py +0 -0
  60. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/camera/sd_events.py +0 -0
  61. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/camera/sd_listing.py +0 -0
  62. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/camera/sd_playback.py +0 -0
  63. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/camera/sdes.py +0 -0
  64. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/camera/tutk.py +0 -0
  65. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/camera/webrtc.py +0 -0
  66. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/camera/webrtc_open.py +0 -0
  67. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/client.py +0 -0
  68. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/cloud_auth.py +0 -0
  69. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/const.py +0 -0
  70. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/credentials.py +0 -0
  71. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/crypto.py +0 -0
  72. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/device_client.py +0 -0
  73. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/discover.py +0 -0
  74. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/exceptions.py +0 -0
  75. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/g711.py +0 -0
  76. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/lan_retry.py +0 -0
  77. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/aidot_cameras/py.typed +0 -0
  78. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  79. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
  80. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/python_aidot_cameras.egg-info/requires.txt +0 -0
  81. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/python_aidot_cameras.egg-info/top_level.txt +0 -0
  82. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/setup.cfg +0 -0
  83. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_aioice_compat.py +0 -0
  84. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_alarm_event.py +0 -0
  85. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_answer_sdp_video_pt.py +0 -0
  86. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_answer_section_selection.py +0 -0
  87. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_audio_mux_offline.py +0 -0
  88. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_avio_ctrl_uses_the_session_dseq.py +0 -0
  89. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_avio_request.py +0 -0
  90. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_avio_response_parse.py +0 -0
  91. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_avio_response_router.py +0 -0
  92. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_backoff.py +0 -0
  93. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_battery_detection.py +0 -0
  94. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_battery_evidence_corroboration.py +0 -0
  95. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_battery_skip_turn.py +0 -0
  96. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_bitrate_campaign_arms.py +0 -0
  97. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_ble_gateway.py +0 -0
  98. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_bridge_state_sentinels.py +0 -0
  99. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_busy_backoff.py +0 -0
  100. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_canary_log_names_the_camera.py +0 -0
  101. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_cloud_count.py +0 -0
  102. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_cloud_plan.py +0 -0
  103. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_constants_imports_resolve.py +0 -0
  104. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_control_verdicts.py +0 -0
  105. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_deferred_hardening.py +0 -0
  106. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_device_cmd_seq_match.py +0 -0
  107. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_device_login_guard.py +0 -0
  108. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_device_user_info_cache.py +0 -0
  109. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_direct_ts_serve.py +0 -0
  110. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_dormant_camera_cleanup.py +0 -0
  111. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_drain_is_cancellable.py +0 -0
  112. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_dtls_mux_terminal_write.py +0 -0
  113. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_dtls_not_ready_burst.py +0 -0
  114. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_dtls_pinning.py +0 -0
  115. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_dtls_resolution_ack.py +0 -0
  116. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_dtls_serve_decode_skip.py +0 -0
  117. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_dtls_serve_ice_wait.py +0 -0
  118. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_dtls_serve_open_timeout.py +0 -0
  119. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_dtls_skip_signaling_wait.py +0 -0
  120. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_dtls_slow_probe.py +0 -0
  121. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_dtls_stdout_producer.py +0 -0
  122. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_dtls_video_presence.py +0 -0
  123. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_egress_guard.py +0 -0
  124. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_env_seams.py +0 -0
  125. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_feature_probe_needs_a_live_session.py +0 -0
  126. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_ffmpeg_signal_exit_is_not_a_crash.py +0 -0
  127. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_futile_keepalive.py +0 -0
  128. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_gate_requires_a_decoded_frame.py +0 -0
  129. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_go2rtc.py +0 -0
  130. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_go2rtc_cli.py +0 -0
  131. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_h264_decode_rate_limit.py +0 -0
  132. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_highport_nomination.py +0 -0
  133. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_hwaccel_probe.py +0 -0
  134. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_ice_config_cache.py +0 -0
  135. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_ice_peer_reflexive.py +0 -0
  136. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_idle_release_both_loops.py +0 -0
  137. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_keepalive_renew.py +0 -0
  138. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_key_restart_sdp.py +0 -0
  139. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_keyframe_prompter.py +0 -0
  140. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_lan_control.py +0 -0
  141. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_lan_retry_reaches_the_storming_devices.py +0 -0
  142. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_late_repeats_are_dropped.py +0 -0
  143. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_light_active_color_mode.py +0 -0
  144. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_live_session_carries_talk.py +0 -0
  145. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_live_stream_param.py +0 -0
  146. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_live_validate_policy.py +0 -0
  147. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_live_video_canary.py +0 -0
  148. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_local_only_controls.py +0 -0
  149. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_logging_caps.py +0 -0
  150. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_login_connect_deadline.py +0 -0
  151. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_login_reconnect_backoff.py +0 -0
  152. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_media_counted_only_when_plaintext.py +0 -0
  153. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_media_path_is_reported.py +0 -0
  154. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_media_socket_rcvbuf.py +0 -0
  155. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_media_wait_floor.py +0 -0
  156. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_motion_poll.py +0 -0
  157. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_mqtt_client_id_is_exact.py +0 -0
  158. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_mqtt_credential_refetch.py +0 -0
  159. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_mqtt_credential_self_heal.py +0 -0
  160. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_mqtt_password_not_persisted.py +0 -0
  161. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_mqtt_session_reconnect.py +0 -0
  162. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_mqtt_thread_not_orphaned.py +0 -0
  163. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_nack.py +0 -0
  164. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_nack_cannot_add_load_in_a_burst.py +0 -0
  165. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_narrow_pc_ice.py +0 -0
  166. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_no_key_material_in_logs.py +0 -0
  167. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_no_self_referential_go2rtc.py +0 -0
  168. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_no_undefined_names.py +0 -0
  169. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_offer_bandwidth_line.py +0 -0
  170. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_offer_keeps_rtcp_feedback.py +0 -0
  171. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_offline_keepalive_pause.py +0 -0
  172. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_on_frame_is_an_av_frame.py +0 -0
  173. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_open_fail_logger.py +0 -0
  174. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_open_gate_delay.py +0 -0
  175. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_peerid_reuse.py +0 -0
  176. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_persistent_mqtt.py +0 -0
  177. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_persistent_mqtt_robustness.py +0 -0
  178. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_playback_tls.py +0 -0
  179. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_post_merge_hardening.py +0 -0
  180. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_ptz_probe_reads_what_it_is_told.py +0 -0
  181. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_public_signature_matches_impl.py +0 -0
  182. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_quality_campaign_in_session.py +0 -0
  183. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_recent_recordings.py +0 -0
  184. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_remb.py +0 -0
  185. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_remb_goes_out_the_relay_aware_sender.py +0 -0
  186. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_resent_frames_are_dropped.py +0 -0
  187. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_resolution_persists.py +0 -0
  188. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_restart_cycle.py +0 -0
  189. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_retry_policy.py +0 -0
  190. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_scripts_importable.py +0 -0
  191. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_sctp_init_ack_tsn.py +0 -0
  192. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_sd_card_presence.py +0 -0
  193. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_sd_event_decode.py +0 -0
  194. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_sd_event_probe_payloads.py +0 -0
  195. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_sd_listing.py +0 -0
  196. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_sd_playback.py +0 -0
  197. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_sdes_adaptive.py +0 -0
  198. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_sdes_avio_dispatch.py +0 -0
  199. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_sdes_bridge_break_gate.py +0 -0
  200. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_sdes_connection_mode.py +0 -0
  201. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_sdes_echo_wait_timeout.py +0 -0
  202. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_sdes_fast_liveplay.py +0 -0
  203. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_sdes_ffmpeg_stderr_log.py +0 -0
  204. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_sdes_first_media_stall_report.py +0 -0
  205. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_sdes_idle_release.py +0 -0
  206. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_sdes_nack_bridge.py +0 -0
  207. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_sdes_offer_video_order.py +0 -0
  208. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_sdes_offer_video_pin.py +0 -0
  209. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_sdes_open_cleanup.py +0 -0
  210. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_sdes_sctp_abort.py +0 -0
  211. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_sdes_serve_audio.py +0 -0
  212. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_sdes_serve_cmd.py +0 -0
  213. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_sdes_serve_stderr_drain.py +0 -0
  214. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_sdes_sprop.py +0 -0
  215. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_sdes_talk.py +0 -0
  216. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_sdes_teardown_exit_log.py +0 -0
  217. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_sdes_terminal_ack.py +0 -0
  218. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_sdes_terminal_ack_latency.py +0 -0
  219. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_sdes_tmmbr_bridge.py +0 -0
  220. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_sdes_watchdog.py +0 -0
  221. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_sdp_narrowing_properties.py +0 -0
  222. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_sdp_payload_type_narrowing.py +0 -0
  223. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_security_hardening.py +0 -0
  224. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_self_peer_is_an_address_not_an_ip.py +0 -0
  225. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_serializable_login_info.py +0 -0
  226. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_serve_loss_tolerance.py +0 -0
  227. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_serve_pt_respects_the_pin.py +0 -0
  228. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_serve_relay.py +0 -0
  229. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_serve_stderr_keeps_the_reason.py +0 -0
  230. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_session_stats.py +0 -0
  231. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_shared_house_seam.py +0 -0
  232. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_snapshot_cancellation.py +0 -0
  233. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_speak.py +0 -0
  234. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_sprop_capture_gate.py +0 -0
  235. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_sprop_staleness.py +0 -0
  236. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_sprop_unstable_public.py +0 -0
  237. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_sprop_write_is_reported_truthfully.py +0 -0
  238. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_srtp_rx_rekey.py +0 -0
  239. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_srtp_tx_key_note.py +0 -0
  240. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_stall_report_says_what_the_answer_carried.py +0 -0
  241. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_stall_report_says_whether_the_trigger_was_answered.py +0 -0
  242. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_stall_report_separates_silence_from_undecryptable.py +0 -0
  243. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_stall_report_survives_a_caller_giving_up.py +0 -0
  244. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_stall_reports_reach_the_artifact.py +0 -0
  245. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_stream_cap.py +0 -0
  246. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_stream_drain_ownership.py +0 -0
  247. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_stream_idle.py +0 -0
  248. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_stream_slot_cap.py +0 -0
  249. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_stream_teardown.py +0 -0
  250. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_talk.py +0 -0
  251. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_talk_reports_a_speaker_that_never_opened.py +0 -0
  252. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_tap_to_mux_end_to_end.py +0 -0
  253. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_terminal_ack.py +0 -0
  254. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_tmmbr.py +0 -0
  255. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_token_file_robustness.py +0 -0
  256. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_token_refresh.py +0 -0
  257. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_turn_create_permission.py +0 -0
  258. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_tutk_path_is_out_of_reach.py +0 -0
  259. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_upstream_compat.py +0 -0
  260. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_validator_shares_one_token.py +0 -0
  261. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_video_profile_log.py +0 -0
  262. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_video_pts_dts.py +0 -0
  263. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_video_ts_unwrap.py +0 -0
  264. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_viewer_check_throttle.py +0 -0
  265. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_viewer_presence.py +0 -0
  266. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_viewer_present_push_mode.py +0 -0
  267. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_wait_or_event.py +0 -0
  268. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_wake_gate_camera_evidence.py +0 -0
  269. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/tests/test_wake_readiness_retry.py +0 -0
  270. {python_aidot_cameras-1.0.0b31 → python_aidot_cameras-1.0.0b33}/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.0b31
3
+ Version: 1.0.0b33
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
@@ -566,6 +566,36 @@ _SERVE_REORDER_QUEUE = int(os.environ.get("AIDOT_SERVE_REORDER_QUEUE", "500"))
566
566
  #: failure this is meant to prevent.
567
567
  _SERVE_MAX_DELAY_US = int(os.environ.get("AIDOT_SERVE_MAX_DELAY_US", "500000"))
568
568
 
569
+ #: How long (seconds) the serve ffmpeg waits with NO packets on any RTP socket
570
+ #: before its SDP input errors out ("Error during demuxing: Operation timed
571
+ #: out") and the serve dies. ffmpeg's own default is 10 s (READ_PACKET_TIMEOUT_S,
572
+ #: applied through the sdp demuxer's ``listen_timeout`` option). Measured on an
573
+ #: A001064 (2026-08-24/25): the camera episodically stops transmitting for
574
+ #: >=10 s mid-session, and every reopen afterwards succeeds immediately, so the
575
+ #: link recovers on a 10-20 s scale. A mains camera therefore gets 30 s to ride
576
+ #: the gap out; a battery camera keeps ffmpeg's 10 s default, because its stops
577
+ #: are real sleeps that only a reopen (with its wake) ends - waiting longer just
578
+ #: freezes the picture. ``AIDOT_SERVE_INPUT_TIMEOUT_S`` overrides both.
579
+ _SERVE_INPUT_TIMEOUT_MAINS_S = 30
580
+ _SERVE_INPUT_TIMEOUT_BATTERY_S = 10
581
+
582
+
583
+ def _resolve_serve_input_timeout_s(is_battery: bool) -> int:
584
+ """Seconds the serve's SDP input tolerates total silence before erroring.
585
+
586
+ Pure policy (unit-testable): the env override wins for every camera; else
587
+ mains rides out a transmit gap and battery keeps the fast default.
588
+ """
589
+ _env = os.environ.get("AIDOT_SERVE_INPUT_TIMEOUT_S")
590
+ if _env:
591
+ try:
592
+ return max(1, int(_env))
593
+ except ValueError:
594
+ pass
595
+ return (_SERVE_INPUT_TIMEOUT_BATTERY_S if is_battery
596
+ else _SERVE_INPUT_TIMEOUT_MAINS_S)
597
+
598
+
569
599
  #: How long to wait after a camera answers -50002 / -50015 ("no free session").
570
600
  #: Both loops used to wait _MAX_DELAY (300s) on the belief that the camera
571
601
  #: "releases slowly". Measured 2026-08-07 on an A001064: reopening 2s after a
@@ -630,6 +660,7 @@ def _build_sdes_serve_cmd(
630
660
  audio_gain_db: float = -8.0,
631
661
  push_video_only: bool = False,
632
662
  video_decoder: Optional[list] = None,
663
+ input_timeout_s: Optional[int] = None,
633
664
  ) -> list:
634
665
  """Build the ffmpeg argv for the SDES bridge serve.
635
666
 
@@ -778,6 +809,13 @@ def _build_sdes_serve_cmd(
778
809
  # before giving up on a gap rather than stalling the pipeline.
779
810
  "-reorder_queue_size", str(_SERVE_REORDER_QUEUE),
780
811
  "-max_delay", str(_SERVE_MAX_DELAY_US),
812
+ # Input silence tolerance: the sdp demuxer's ``listen_timeout`` doubles
813
+ # as its packet-read timeout (READ_PACKET_TIMEOUT_S, ffmpeg default
814
+ # 10 s). See _resolve_serve_input_timeout_s for the mains/battery
815
+ # policy. Input option, so it must sit before -i; None keeps ffmpeg's
816
+ # default (no behavior change for callers that do not opt in).
817
+ *(["-listen_timeout", str(int(input_timeout_s))]
818
+ if input_timeout_s is not None else []),
781
819
  "-i", sdp_path,
782
820
  *dest_args,
783
821
  ]
@@ -746,6 +746,77 @@ def video_pt_from_answer_sdp(sdp_text: str) -> Optional[int]:
746
746
  return None
747
747
 
748
748
 
749
+ def answer_pt_kinds(sdp_text: str) -> dict:
750
+ """Map each payload type in the camera's answer to the kind that owns it.
751
+
752
+ The bridge demuxes a BUNDLEd stream by RTP payload type, and until this
753
+ existed it did so from a fixed table: 96/97/98 video, 0/8 audio. That
754
+ table is wrong for this fleet. Measured 2026-08-26 across 107 A001064
755
+ opens, **15 of them (14%) negotiated H265 on payload type 0** -- announced
756
+ correctly in the answer as ``m=video ... 0`` with ``a=rtpmap:0 H265/90000``
757
+ -- and then sent 2668 full-size (1222 B) video packets on pt=0 while audio
758
+ ran normally on pt=8. The fixed table posted every one of those video
759
+ packets to ffmpeg's AUDIO loopback, so no video was ever observed, the 75 s
760
+ first-media wait ran out, and the serve launched into an empty stream and
761
+ exited: 82 s wasted, then a full reopen. The other 92 opens answered
762
+ ``m=video ... 96`` and worked. Perfect separation, both directions.
763
+
764
+ The answer is the authority on this and is already in hand: PT numbering is
765
+ per-m-section by definition, so a section's fmt list names the kind of every
766
+ payload type in it. ``tests/test_answer_section_selection.py`` records the
767
+ same renumbering on the A000088 (PT 0 carrying H265), and the DTLS path
768
+ already selects sections by content for it; the SDES bridge was the one
769
+ place still trusting a static tuple.
770
+
771
+ A payload type claimed by BOTH an audio and a video section is dropped from
772
+ the map rather than guessed at -- an ambiguous answer is not better evidence
773
+ than the fallback table, and silently preferring one section would be the
774
+ same class of assumption this function exists to remove. Malformed m= lines
775
+ are skipped, never raised on: this feeds a media path.
776
+ """
777
+ kinds: dict = {}
778
+ dropped: set = set()
779
+ for raw in (sdp_text or "").splitlines():
780
+ line = raw.strip()
781
+ if not line.startswith("m=audio") and not line.startswith("m=video"):
782
+ continue
783
+ parts = line.split()
784
+ if len(parts) < 4:
785
+ continue
786
+ kind = "video" if line.startswith("m=video") else "audio"
787
+ for tok in parts[3:]:
788
+ try:
789
+ pt = int(tok)
790
+ except ValueError:
791
+ continue
792
+ if not 0 <= pt <= 127:
793
+ continue
794
+ if kinds.get(pt, kind) != kind:
795
+ dropped.add(pt)
796
+ kinds[pt] = kind
797
+ for pt in dropped:
798
+ kinds.pop(pt, None)
799
+ return kinds
800
+
801
+
802
+ def rewrite_rtp_payload_type(pkt: bytes, pt: int) -> bytes:
803
+ """Return ``pkt`` with its RTP payload type replaced, marker bit preserved.
804
+
805
+ Used only where the bridge itself decrypted the packet (``_use_plain_rtp``),
806
+ so what is being edited is plaintext on its way to our own loopback -- never
807
+ a packet ffmpeg will authenticate, where a changed byte would fail the SRTP
808
+ auth tag. RTP byte 1 is ``M(1) | PT(7)`` (RFC 3550), so only the low seven
809
+ bits move.
810
+
811
+ A packet too short to hold that byte is returned untouched rather than
812
+ rejected: this is the media path, and a malformed packet is ffmpeg's to
813
+ discard, not the bridge's to raise on.
814
+ """
815
+ if len(pkt) < 2:
816
+ return pkt
817
+ return pkt[:1] + bytes([(pkt[1] & 0x80) | (pt & 0x7F)]) + pkt[2:]
818
+
819
+
749
820
  #: Video payload types the SDES answer template advertises (H264, H265).
750
821
  _SDES_ANSWER_VIDEO_PTS = (96, 97)
751
822
 
@@ -1218,6 +1289,42 @@ def _classify_ffmpeg_exit(rc: int, teardown_requested: bool) -> int:
1218
1289
  return logging.WARNING
1219
1290
 
1220
1291
 
1292
+ #: Mid-session stall nudge (see _stall_nudge_due): enabled by default;
1293
+ #: ``AIDOT_SDES_STALL_NUDGE=0`` turns it off and
1294
+ #: ``AIDOT_SDES_STALL_NUDGE_AFTER_S`` moves the trigger point.
1295
+ _STALL_NUDGE_ENABLED = os.environ.get("AIDOT_SDES_STALL_NUDGE", "1") != "0"
1296
+ _STALL_NUDGE_AFTER_S = float(
1297
+ os.environ.get("AIDOT_SDES_STALL_NUDGE_AFTER_S", "2.5"))
1298
+
1299
+
1300
+ def _stall_nudge_due(
1301
+ *,
1302
+ silence_s: float,
1303
+ nudges_sent: int,
1304
+ since_last_nudge_s: float,
1305
+ stall_after_s: float = 2.5,
1306
+ max_nudges: int = 3,
1307
+ interval_s: float = 2.0,
1308
+ ) -> bool:
1309
+ """Whether the bridge should re-send AVIO LIVING for a mid-session stall.
1310
+
1311
+ The A001064 episodically stops transmitting mid-session with no teardown
1312
+ signal; every reopen afterwards succeeds immediately, so the camera is
1313
+ awake and answering handshakes seconds after it stopped (measured
1314
+ 2026-08-24/25). LIVING is the message that starts media on a fresh
1315
+ session, so re-sending it on the live session is the cheapest possible
1316
+ revive attempt. Bounded (a few sends, spaced out) so a camera that is
1317
+ truly asleep or out of range is not spammed - the serve's input timeout
1318
+ and the keepalive reopen remain the unchanged fallback. Pure function so
1319
+ the policy is unit-testable without a live bridge.
1320
+ """
1321
+ if silence_s <= stall_after_s:
1322
+ return False
1323
+ if nudges_sent >= max_nudges:
1324
+ return False
1325
+ return since_last_nudge_s >= interval_s
1326
+
1327
+
1221
1328
  def _bridge_should_break(rc, teardown_requested: bool) -> bool:
1222
1329
  """Whether the bridge observe loop should end on this poll() result.
1223
1330
 
@@ -1623,7 +1730,7 @@ class _SdesOpenMixin:
1623
1730
  SDP answer, writes it to a temp file, and launches ffmpeg to receive and
1624
1731
  record the SRTP stream.
1625
1732
  """
1626
- from .client import CameraMixin, _build_sdes_serve_cmd, _ffmpeg_path, _spawn_bg # lazy: break client<->sdes_open cycle
1733
+ from .client import CameraMixin, _build_sdes_serve_cmd, _ffmpeg_path, _resolve_serve_input_timeout_s, _spawn_bg # lazy: break client<->sdes_open cycle
1627
1734
  import base64
1628
1735
  import subprocess
1629
1736
 
@@ -4002,6 +4109,10 @@ class _SdesOpenMixin:
4002
4109
  # list for the same reason as _first_video_pt: the serve-restart path
4003
4110
  # reads it from a nested scope.
4004
4111
  _answer_video_pt: list = [None]
4112
+ # PT -> "video"/"audio" from the camera's answer, read by the
4113
+ # bridge demux. Empty until the answer lands (the bridge starts
4114
+ # first), which is exactly when the static-tuple fallback applies.
4115
+ _answer_pt_kinds: dict = {}
4005
4116
  # Same for audio: the ffmpeg SDP advertises BOTH PCMU (0) and PCMA (8),
4006
4117
  # and ffmpeg binds the depacketizer to the first one listed - so the line
4007
4118
  # has to be narrowed to the payload type the camera actually sends, the
@@ -4099,6 +4210,9 @@ class _SdesOpenMixin:
4099
4210
  _trigger_peer = None # camera's real addr when relayed
4100
4211
  _sdes_probe_received = False # True after first 0xC8 probe from camera
4101
4212
  _last_hb_ts = 0.0 # time of last AVIO HEARTBEAT send
4213
+ _stall_nudges_sent = 0 # LIVING re-sends for the CURRENT stall
4214
+ _stall_last_nudge = 0.0 # wall time of the last stall nudge
4215
+ _stall_active = False # media had started, then went silent
4102
4216
  # One-shot guard for the "ffmpeg exited" log below: while the held
4103
4217
  # proc keeps reporting the same stale exit code across a
4104
4218
  # teardown-window skip (see _bridge_should_break), only the first
@@ -4537,12 +4651,17 @@ class _SdesOpenMixin:
4537
4651
  except Exception as _dw_e:
4538
4652
  _status(f"SDES DC: DCEP_WAIT LIVING err: {_dw_e}")
4539
4653
 
4540
- # Periodic retrigger: resend AVIO LIVING every 2s until probe
4541
- # received (camera acknowledged our trigger).
4542
- if (_avio_living_sent
4543
- and not _sdes_probe_received
4544
- and _trigger_bs is not None
4545
- and _time_br.time() - _last_trigger_ts >= 2.0):
4654
+ # One LIVING wire-build, shared by the pre-probe retrigger
4655
+ # below and the mid-session stall nudge: resend the AVIO
4656
+ # LIVING(5376) that starts media, encrypted then plaintext
4657
+ # exactly like the original trigger.
4658
+ # Default-arg binding (not closure capture): re-defined each
4659
+ # tick, so the CURRENT trigger socket/addrs are bound at
4660
+ # definition time and B023 cannot bite.
4661
+ def _resend_avio_living(
4662
+ _tb=_trigger_bs,
4663
+ _tsrc=_trigger_bsrc,
4664
+ _tpeer=_trigger_peer) -> None:
4546
4665
  import struct as _st_re2
4547
4666
  import random as _r_re2
4548
4667
  _re_ts = int(_time_br.time() * 1000)
@@ -4568,15 +4687,63 @@ class _SdesOpenMixin:
4568
4687
  _rsz = len(_rp)
4569
4688
  try:
4570
4689
  _br_send_to_cam(
4571
- _trigger_bs,
4690
+ _tb,
4572
4691
  bytes([0xC8, 0x00, _rsz >> 8, _rsz & 0xFF]) + _rp,
4573
- _trigger_bsrc,
4574
- _trigger_peer,
4692
+ _tsrc,
4693
+ _tpeer,
4575
4694
  )
4576
4695
  except Exception:
4577
4696
  _LOGGER.debug("camera %s: swallowed exception in %s", getattr(self, "device_id", "?"), '_bridge_fn', exc_info=True)
4697
+
4698
+ # Periodic retrigger: resend AVIO LIVING every 2s until probe
4699
+ # received (camera acknowledged our trigger).
4700
+ if (_avio_living_sent
4701
+ and not _sdes_probe_received
4702
+ and _trigger_bs is not None
4703
+ and _time_br.time() - _last_trigger_ts >= 2.0):
4704
+ _resend_avio_living()
4578
4705
  _last_trigger_ts = _time_br.time()
4579
4706
 
4707
+ # Mid-session stall nudge: the camera episodically stops
4708
+ # transmitting with no teardown signal (A001064, measured
4709
+ # 2026-08-24/25), the serve's input then times out and the
4710
+ # session dies - yet the camera answers a FRESH handshake
4711
+ # immediately afterwards. So before letting it die, re-send
4712
+ # the LIVING that starts media. The AVIO HEARTBEAT alone
4713
+ # does not do this: heartbeats kept flowing right through
4714
+ # measured stalls while the media stayed silent. Bounded by
4715
+ # _stall_nudge_due; the input timeout and the keepalive
4716
+ # reopen remain the unchanged fallback.
4717
+ if (_STALL_NUDGE_ENABLED
4718
+ and _sdes_probe_received
4719
+ and _trigger_bs is not None
4720
+ and _media_progress[0] > 0.0):
4721
+ _stall_silence = (
4722
+ _time_br.monotonic() - _media_progress[0])
4723
+ if _stall_silence <= _STALL_NUDGE_AFTER_S:
4724
+ if _stall_active:
4725
+ _stall_active = False
4726
+ _stall_nudges_sent = 0
4727
+ # Ask for an immediate IDR so recovery is not
4728
+ # hostage to the 30s PLI cadence.
4729
+ _bridge_fn._last_pli_ts = 0.0
4730
+ _status(
4731
+ "SDES: media resumed after stall"
4732
+ " - PLI re-armed")
4733
+ elif _stall_nudge_due(
4734
+ silence_s=_stall_silence,
4735
+ nudges_sent=_stall_nudges_sent,
4736
+ since_last_nudge_s=(
4737
+ _time_br.time() - _stall_last_nudge),
4738
+ stall_after_s=_STALL_NUDGE_AFTER_S):
4739
+ _stall_active = True
4740
+ _stall_nudges_sent += 1
4741
+ _stall_last_nudge = _time_br.time()
4742
+ _status(
4743
+ f"SDES: no media for {_stall_silence:.1f}s"
4744
+ f" - LIVING nudge {_stall_nudges_sent}/3")
4745
+ _resend_avio_living()
4746
+
4580
4747
  for _bs in _rl:
4581
4748
  try:
4582
4749
  _bpkt, _bsrc = _bs.recvfrom(4096)
@@ -5506,7 +5673,55 @@ class _SdesOpenMixin:
5506
5673
  _LOGGER.debug("camera %s: swallowed exception in %s", getattr(self, "device_id", "?"), '_bridge_fn', exc_info=True)
5507
5674
  continue
5508
5675
  _pt = _pt_byte & 0x7F
5509
- if _pt in (96, 97, 98):
5676
+ # The ANSWER decides which payload type is which
5677
+ # kind; the tuples below are only the fallback for a
5678
+ # packet that beats the answer in. See
5679
+ # answer_pt_kinds: this camera negotiates H265 on
5680
+ # pt=0 in ~14% of sessions and the fixed tuples sent
5681
+ # every one of those video streams to the AUDIO
5682
+ # loopback.
5683
+ _kind_answered = _answer_pt_kinds.get(_pt)
5684
+ _pt_out = _pt
5685
+ if _kind_answered == "video":
5686
+ _btgt, _lo_target, _kind = (
5687
+ _lo_video_port, _lo_v, "video"
5688
+ )
5689
+ # The bridge starts before the answer lands, so
5690
+ # a video packet on an unexpected PT can beat the
5691
+ # map in by a few hundred ms and latch as the
5692
+ # session's "first audio". Left alone that
5693
+ # narrows the serve's audio line to a payload
5694
+ # type no audio uses, and the mpegts mux then
5695
+ # withholds PAT/PMT waiting for a stream that
5696
+ # never produces - costing the picture, not just
5697
+ # the sound. Undo it and let real audio latch.
5698
+ if _first_audio_pt[0] == _pt:
5699
+ _first_audio_pt[0] = None
5700
+ _br_first_audio_logged = False
5701
+ _status(
5702
+ "bridge: pt=%d re-classified as video"
5703
+ " from the answer - clearing the audio"
5704
+ " latch it took first" % _pt)
5705
+ # Our serve SDP describes video as 96=H264 /
5706
+ # 97=H265 only, so a camera-numbered payload
5707
+ # type we do not advertise must be translated
5708
+ # into our numbering before ffmpeg sees it -
5709
+ # the same translation video_pt_from_answer_sdp
5710
+ # already performs for the SDP side ("the
5711
+ # camera's own numbering need not agree").
5712
+ # Gated on _use_plain_rtp because only there is
5713
+ # the bridge the decryptor and the packet
5714
+ # reaching ffmpeg plaintext; rewriting a byte
5715
+ # ffmpeg will itself authenticate breaks SRTP.
5716
+ if (_pt not in _SDP_VIDEO_PTS
5717
+ and _use_plain_rtp
5718
+ and _answer_video_pt[0] is not None):
5719
+ _pt_out = int(_answer_video_pt[0])
5720
+ elif _kind_answered == "audio":
5721
+ _btgt, _lo_target, _kind = (
5722
+ _lo_audio_port, _lo_a, "audio"
5723
+ )
5724
+ elif _pt in (96, 97, 98):
5510
5725
  _btgt, _lo_target, _kind = (
5511
5726
  _lo_video_port, _lo_v, "video"
5512
5727
  )
@@ -5566,15 +5781,22 @@ class _SdesOpenMixin:
5566
5781
  )
5567
5782
  elif _kind == "video" and not _br_first_video_logged:
5568
5783
  _br_first_video_logged = True
5569
- _first_video_pt[0] = _pt
5570
- _status(f"bridge: first video RTP pt={_pt}")
5784
+ # _pt_out, not _pt: everything downstream (the
5785
+ # serve SDP narrowing, _serve_video_pt's
5786
+ # "observed beats pinned") speaks OUR numbering,
5787
+ # and the wire PT is preserved in the log line.
5788
+ _first_video_pt[0] = _pt_out
5789
+ _status(
5790
+ f"bridge: first video RTP pt={_pt_out}"
5791
+ + (f" (camera numbered it {_pt})"
5792
+ if _pt_out != _pt else ""))
5571
5793
  # At INFO, not DEBUG: this is the one line that
5572
5794
  # makes a bitrate figure comparable to another
5573
5795
  # one, and it is emitted once per session.
5574
5796
  _LOGGER.info(
5575
5797
  "camera %s: video profile %s",
5576
5798
  getattr(self, "device_id", "?"),
5577
- describe_video_profile(_pt),
5799
+ describe_video_profile(_pt_out),
5578
5800
  )
5579
5801
  # Camera answers BUNDLE (all media on one 5-tuple),
5580
5802
  # so the talk destination is the same address as
@@ -5799,6 +6021,9 @@ class _SdesOpenMixin:
5799
6021
  if _nk_late:
5800
6022
  _bridge_fn._nack_late_drops = getattr(
5801
6023
  _bridge_fn, '_nack_late_drops', 0) + 1
6024
+ if _pt_out != _pt and _decrypted:
6025
+ _fwd_pkt = rewrite_rtp_payload_type(
6026
+ _fwd_pkt, _pt_out)
5802
6027
  try:
5803
6028
  if not _nk_late:
5804
6029
  _lo_target.sendto(
@@ -6085,6 +6310,7 @@ class _SdesOpenMixin:
6085
6310
  _early_nominated = bool(_cam_ice_ufrag and _cam_ice_pwd and _cam_ice_cands)
6086
6311
  _media_deadline = time.monotonic() + _FIRST_MEDIA_WAIT_S
6087
6312
  _media_wait_started = time.monotonic()
6313
+ _pt_kinds_read = False
6088
6314
 
6089
6315
  def _report_first_media_stall(_waited_s, _cancelled=False):
6090
6316
  """Emit the one line that says why nothing arrived.
@@ -6150,6 +6376,34 @@ class _SdesOpenMixin:
6150
6376
  _status(f"camera refused: ack {_code} {_desc}"
6151
6377
  " - terminal, abandoning the first-media wait")
6152
6378
  raise AidotCameraBusy(_code, _desc)
6379
+ if (not _pt_kinds_read and answer_fut is not None
6380
+ and answer_fut.done() and not answer_fut.cancelled()):
6381
+ # One-shot, here rather than in the answer-logging block
6382
+ # further down: that block runs AFTER this wait, which is
6383
+ # 75 s too late for the demux that the map exists to fix.
6384
+ _pt_kinds_read = True
6385
+ try:
6386
+ if answer_fut.exception() is None:
6387
+ _pk_sdp = (
6388
+ answer_fut.result() or {}).get("sdp", "") or ""
6389
+ if _pk_sdp:
6390
+ _answer_pt_kinds.update(answer_pt_kinds(_pk_sdp))
6391
+ _answer_video_pt[0] = video_pt_from_answer_sdp(
6392
+ _pk_sdp)
6393
+ _vpts = sorted(
6394
+ pt for pt, k in _answer_pt_kinds.items()
6395
+ if k == "video")
6396
+ if any(pt not in _SDP_VIDEO_PTS for pt in _vpts):
6397
+ _status(
6398
+ "answer numbers video on payload"
6399
+ " type(s) %s - demuxing by the answer,"
6400
+ " serving as our pt=%s"
6401
+ % (_vpts, _answer_video_pt[0]))
6402
+ except Exception:
6403
+ _LOGGER.debug(
6404
+ "camera %s: swallowed exception in %s",
6405
+ getattr(self, "device_id", "?"),
6406
+ 'answer_pt_kinds', exc_info=True)
6153
6407
  if (not _early_nominated and answer_fut is not None
6154
6408
  and answer_fut.done() and not answer_fut.cancelled()):
6155
6409
  # Read-only peek: the real await below still consumes this
@@ -6278,6 +6532,8 @@ class _SdesOpenMixin:
6278
6532
  audio_gain_db=self._resolve_sdes_audio_gain_db(),
6279
6533
  push_video_only=_push_video_only,
6280
6534
  video_decoder=_video_decoder,
6535
+ input_timeout_s=_resolve_serve_input_timeout_s(
6536
+ bool(getattr(self, "is_battery_camera", False))),
6281
6537
  )
6282
6538
  # Audio matters even more than video here. A multi-PT m-line makes ffmpeg
6283
6539
  # bind the depacketizer to the FIRST payload type and silently discard the
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-aidot-cameras"
7
- version = "1.0.0b31"
7
+ version = "1.0.0b33"
8
8
  description = "Control AiDot/Leedarson WiFi lights and cameras (WebRTC streaming, two-way audio, PTZ, controls)"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-aidot-cameras
3
- Version: 1.0.0b31
3
+ Version: 1.0.0b33
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
@@ -77,6 +77,7 @@ python_aidot_cameras.egg-info/requires.txt
77
77
  python_aidot_cameras.egg-info/top_level.txt
78
78
  tests/test_aioice_compat.py
79
79
  tests/test_alarm_event.py
80
+ tests/test_answer_pt_kinds.py
80
81
  tests/test_answer_sdp_video_pt.py
81
82
  tests/test_answer_section_selection.py
82
83
  tests/test_audio_mux_offline.py
@@ -218,6 +219,7 @@ tests/test_sdp_payload_type_narrowing.py
218
219
  tests/test_security_hardening.py
219
220
  tests/test_self_peer_is_an_address_not_an_ip.py
220
221
  tests/test_serializable_login_info.py
222
+ tests/test_serve_input_timeout.py
221
223
  tests/test_serve_loss_tolerance.py
222
224
  tests/test_serve_pt_respects_the_pin.py
223
225
  tests/test_serve_relay.py
@@ -232,6 +234,7 @@ tests/test_sprop_unstable_public.py
232
234
  tests/test_sprop_write_is_reported_truthfully.py
233
235
  tests/test_srtp_rx_rekey.py
234
236
  tests/test_srtp_tx_key_note.py
237
+ tests/test_stall_nudge_policy.py
235
238
  tests/test_stall_report_says_what_the_answer_carried.py
236
239
  tests/test_stall_report_says_whether_the_trigger_was_answered.py
237
240
  tests/test_stall_report_separates_silence_from_undecryptable.py
@@ -0,0 +1,141 @@
1
+ """Demuxing the camera's media by the ANSWER's payload numbering, not a table.
2
+
3
+ The bridge splits a BUNDLEd stream into ffmpeg's audio and video loopbacks by
4
+ RTP payload type. It used to do that from a fixed table -- 96/97/98 video,
5
+ 0/8 audio -- and that table is wrong for this fleet.
6
+
7
+ Measured 2026-08-26 across 107 A001064 opens on the live camera: 15 of them
8
+ (14%) negotiated H265 on payload type 0, announced it correctly in the answer
9
+ as ``m=video ... 0`` with ``a=rtpmap:0 H265/90000``, and then sent 2668
10
+ full-size (1222 B) video packets on pt=0 while audio ran normally on pt=8
11
+ (verified on the wire, tshark by SSRC: two streams, 0x29d9/pt=0 video and
12
+ 0x29d7/pt=8 audio). The fixed table posted every one of those video packets to
13
+ the AUDIO loopback, so no video was ever observed, the 75 s first-media wait
14
+ expired, and the serve launched into an empty stream and exited -- 82 s, then a
15
+ full reopen. The other 92 opens answered ``m=video ... 96`` and worked. Perfect
16
+ separation in both directions.
17
+
18
+ The same renumbering is already on record for the A000088 in
19
+ ``test_answer_section_selection.py`` (PT 0 carrying H265), where the DTLS path
20
+ handles it by selecting sections by content. The SDES bridge was the one place
21
+ left trusting a static tuple.
22
+
23
+ The m-lines below are transcribed from the live log; the ``a=rtpmap:0
24
+ H265/90000`` line is inferred from ``video_pt_from_answer_sdp`` returning 97 on
25
+ every one of those 15 sessions (logged as "the camera's answer (97)"), which is
26
+ reachable only through the H265 branch of ``_SDP_VIDEO_PT_BY_CODEC``.
27
+ """
28
+
29
+ import os
30
+ import sys
31
+
32
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
33
+
34
+ from aidot_cameras.camera.sdes_open import (
35
+ answer_pt_kinds,
36
+ rewrite_rtp_payload_type,
37
+ video_pt_from_answer_sdp,
38
+ )
39
+
40
+ # The 92-of-107 shape: ordinary 3-section answer, video on 96.
41
+ _HEALTHY = (
42
+ "v=0\r\n"
43
+ "m=audio 9 RTP/SAVPF 8\r\n"
44
+ "a=rtpmap:8 PCMA/8000\r\n"
45
+ "m=video 9 RTP/SAVPF 96\r\n"
46
+ "a=rtpmap:96 H264/90000\r\n"
47
+ "m=application 9 SCTP webrtc-datachannel\r\n"
48
+ )
49
+
50
+ # The 15-of-107 shape: a leading video section numbering H265 as payload type 0.
51
+ _PT0_VIDEO = (
52
+ "v=0\r\n"
53
+ "m=video 9 RTP/SAVPF 0\r\n"
54
+ "a=rtpmap:0 H265/90000\r\n"
55
+ "m=audio 9 RTP/SAVPF 8\r\n"
56
+ "a=rtpmap:8 PCMA/8000\r\n"
57
+ "m=video 9 RTP/SAVPF 96\r\n"
58
+ "a=rtpmap:96 H264/90000\r\n"
59
+ "m=application 9 SCTP webrtc-datachannel\r\n"
60
+ )
61
+
62
+ # A000088 H265-capable firmware: legitimately four sections, reordered. A
63
+ # predicate keyed on the section COUNT would break this model, which works.
64
+ _A000088_REORDERED = (
65
+ "v=0\r\n"
66
+ "m=video 9 UDP/TLS/RTP/SAVPF 0\r\n"
67
+ "a=rtpmap:0 H265/90000\r\n"
68
+ "m=audio 9 UDP/TLS/RTP/SAVPF 8\r\n"
69
+ "a=rtpmap:8 PCMA/8000\r\n"
70
+ "m=video 9 UDP/TLS/RTP/SAVPF 101 102\r\n"
71
+ "a=rtpmap:101 H264/90000\r\n"
72
+ "m=application 9 DTLS/SCTP 5000\r\n"
73
+ )
74
+
75
+
76
+ def test_healthy_answer_maps_kinds():
77
+ assert answer_pt_kinds(_HEALTHY) == {8: "audio", 96: "video"}
78
+
79
+
80
+ def test_pt0_video_is_reported_as_video_not_audio():
81
+ # The whole defect in one assertion: without the answer, pt=0 reads as PCMU.
82
+ kinds = answer_pt_kinds(_PT0_VIDEO)
83
+ assert kinds[0] == "video"
84
+ assert kinds[8] == "audio"
85
+ assert kinds[96] == "video"
86
+
87
+
88
+ def test_pt0_video_answer_resolves_to_our_h265_payload_type():
89
+ # What the bridge translates the wire PT into before ffmpeg sees it.
90
+ assert video_pt_from_answer_sdp(_PT0_VIDEO) == 97
91
+
92
+
93
+ def test_a000088_reordered_four_sections_still_map_correctly():
94
+ kinds = answer_pt_kinds(_A000088_REORDERED)
95
+ assert kinds[0] == "video"
96
+ assert kinds[8] == "audio"
97
+ assert kinds[101] == "video"
98
+ assert kinds[102] == "video"
99
+
100
+
101
+ def test_payload_type_claimed_by_both_kinds_is_dropped():
102
+ # Ambiguity is not better evidence than the fallback table, so the map
103
+ # declines to answer for that payload type rather than guessing.
104
+ ambiguous = (
105
+ "m=video 9 RTP/SAVPF 0\r\n"
106
+ "m=audio 9 RTP/SAVPF 0 8\r\n"
107
+ )
108
+ kinds = answer_pt_kinds(ambiguous)
109
+ assert 0 not in kinds
110
+ assert kinds[8] == "audio"
111
+
112
+
113
+ def test_malformed_and_empty_answers_do_not_raise():
114
+ assert answer_pt_kinds("") == {}
115
+ # Out of contract, but the media path must not raise on it either.
116
+ assert answer_pt_kinds(None) == {} # type: ignore[arg-type]
117
+ assert answer_pt_kinds("m=video\r\nm=audio 9\r\n") == {}
118
+ # Non-numeric and out-of-range payload tokens are skipped, not raised on.
119
+ assert answer_pt_kinds("m=video 9 RTP/SAVPF x 300 96\r\n") == {96: "video"}
120
+
121
+
122
+ def test_rewrite_payload_type_preserves_the_marker_bit():
123
+ # byte1 = M(1) | PT(7). 0x80 is marker set + pt 0; 0x00 is marker clear.
124
+ marked = bytes([0x80, 0x80]) + b"rest"
125
+ assert rewrite_rtp_payload_type(marked, 97) == bytes([0x80, 0xE1]) + b"rest"
126
+ unmarked = bytes([0x80, 0x00]) + b"rest"
127
+ assert rewrite_rtp_payload_type(unmarked, 97) == bytes([0x80, 0x61]) + b"rest"
128
+
129
+
130
+ def test_rewrite_payload_type_leaves_the_rest_of_the_packet_alone():
131
+ pkt = bytes([0x80, 0x00]) + bytes(range(2, 40))
132
+ out = rewrite_rtp_payload_type(pkt, 96)
133
+ assert out[0] == pkt[0]
134
+ assert out[2:] == pkt[2:]
135
+ assert out[1] & 0x7F == 96
136
+
137
+
138
+ def test_rewrite_payload_type_ignores_a_runt_packet():
139
+ # Media path: a malformed packet is ffmpeg's to discard, not ours to raise.
140
+ assert rewrite_rtp_payload_type(b"", 97) == b""
141
+ assert rewrite_rtp_payload_type(b"\x80", 97) == b"\x80"