python-aidot-cameras 1.0.0rc64__tar.gz → 1.0.0rc65__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 (365) hide show
  1. {python_aidot_cameras-1.0.0rc64/python_aidot_cameras.egg-info → python_aidot_cameras-1.0.0rc65}/PKG-INFO +1 -1
  2. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/camera/client.py +5 -1
  3. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/camera/sd_playback.py +169 -0
  4. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/camera/sdes.py +74 -1
  5. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/camera/sdes_open.py +38 -0
  6. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/pyproject.toml +1 -1
  7. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  8. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/python_aidot_cameras.egg-info/SOURCES.txt +1 -0
  9. python_aidot_cameras-1.0.0rc65/tests/test_sd_playback_clock.py +225 -0
  10. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/LICENSE +0 -0
  11. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/MANIFEST.in +0 -0
  12. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/README.md +0 -0
  13. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/__init__.py +0 -0
  14. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/__main__.py +0 -0
  15. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/_log_redaction.py +0 -0
  16. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/_upstream.py +0 -0
  17. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/_vendor/__init__.py +0 -0
  18. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/_vendor/aiortc/LICENSE +0 -0
  19. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/_vendor/aiortc/__init__.py +0 -0
  20. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/_vendor/aiortc/clock.py +0 -0
  21. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/_vendor/aiortc/codecs/__init__.py +0 -0
  22. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/_vendor/aiortc/codecs/base.py +0 -0
  23. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/_vendor/aiortc/codecs/g711.py +0 -0
  24. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/_vendor/aiortc/codecs/g722.py +0 -0
  25. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/_vendor/aiortc/codecs/h264.py +0 -0
  26. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/_vendor/aiortc/codecs/opus.py +0 -0
  27. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/_vendor/aiortc/codecs/vpx.py +0 -0
  28. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/_vendor/aiortc/contrib/__init__.py +0 -0
  29. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/_vendor/aiortc/contrib/media.py +0 -0
  30. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/_vendor/aiortc/contrib/signaling.py +0 -0
  31. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/_vendor/aiortc/events.py +0 -0
  32. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/_vendor/aiortc/exceptions.py +0 -0
  33. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/_vendor/aiortc/jitterbuffer.py +0 -0
  34. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/_vendor/aiortc/mediastreams.py +0 -0
  35. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/_vendor/aiortc/rate.py +0 -0
  36. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/_vendor/aiortc/rtcconfiguration.py +0 -0
  37. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/_vendor/aiortc/rtcdatachannel.py +0 -0
  38. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/_vendor/aiortc/rtcdtlstransport.py +0 -0
  39. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/_vendor/aiortc/rtcicetransport.py +0 -0
  40. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/_vendor/aiortc/rtcpeerconnection.py +0 -0
  41. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/_vendor/aiortc/rtcrtpparameters.py +0 -0
  42. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/_vendor/aiortc/rtcrtpreceiver.py +0 -0
  43. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/_vendor/aiortc/rtcrtpsender.py +0 -0
  44. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/_vendor/aiortc/rtcrtptransceiver.py +0 -0
  45. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/_vendor/aiortc/rtcsctptransport.py +0 -0
  46. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/_vendor/aiortc/rtcsessiondescription.py +0 -0
  47. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/_vendor/aiortc/rtp.py +0 -0
  48. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/_vendor/aiortc/sdp.py +0 -0
  49. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/_vendor/aiortc/stats.py +0 -0
  50. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/_vendor/aiortc/utils.py +0 -0
  51. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/camera/__init__.py +0 -0
  52. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/camera/aac_track.py +0 -0
  53. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/camera/agc.py +0 -0
  54. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/camera/constants.py +0 -0
  55. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/camera/controls.py +0 -0
  56. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/camera/go2rtc.py +0 -0
  57. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/camera/h264_sps.py +0 -0
  58. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/camera/health.py +0 -0
  59. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/camera/hls_ts.py +0 -0
  60. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/camera/hwaccel.py +0 -0
  61. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/camera/lan_control.py +0 -0
  62. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/camera/models.py +0 -0
  63. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/camera/protocol.py +0 -0
  64. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/camera/recording.py +0 -0
  65. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/camera/rtp_h264.py +0 -0
  66. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/camera/rtsp_publish.py +0 -0
  67. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/camera/sd_events.py +0 -0
  68. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/camera/sd_listing.py +0 -0
  69. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/camera/ts_fanout.py +0 -0
  70. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/camera/ts_tee.py +0 -0
  71. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/camera/webrtc.py +0 -0
  72. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/camera/webrtc_open.py +0 -0
  73. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/client.py +0 -0
  74. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/cloud_auth.py +0 -0
  75. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/const.py +0 -0
  76. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/credentials.py +0 -0
  77. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/crypto.py +0 -0
  78. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/device_client.py +0 -0
  79. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/discover.py +0 -0
  80. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/exceptions.py +0 -0
  81. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/g711.py +0 -0
  82. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/lan_retry.py +0 -0
  83. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/py.typed +0 -0
  84. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/aidot_cameras/settings.py +0 -0
  85. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  86. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
  87. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/python_aidot_cameras.egg-info/requires.txt +0 -0
  88. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/python_aidot_cameras.egg-info/top_level.txt +0 -0
  89. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/setup.cfg +0 -0
  90. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_aac_track.py +0 -0
  91. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_abandoned_attempt_skips_the_doomed_serve.py +0 -0
  92. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_abandoned_wait_still_maps_audio.py +0 -0
  93. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_aioice_compat.py +0 -0
  94. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_alarm_event.py +0 -0
  95. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_answer_pt_kinds.py +0 -0
  96. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_answer_sdp_video_pt.py +0 -0
  97. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_answer_section_selection.py +0 -0
  98. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_arrival_timestamps.py +0 -0
  99. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_audio_mux_offline.py +0 -0
  100. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_auth_error_routing.py +0 -0
  101. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_auto_alarm.py +0 -0
  102. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_auto_light_settings.py +0 -0
  103. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_avio_ctrl_uses_the_session_dseq.py +0 -0
  104. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_avio_header_single_layout.py +0 -0
  105. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_avio_request.py +0 -0
  106. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_avio_response_parse.py +0 -0
  107. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_avio_response_router.py +0 -0
  108. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_backoff.py +0 -0
  109. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_battery_cap_spans_sessions.py +0 -0
  110. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_battery_detection.py +0 -0
  111. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_battery_evidence_corroboration.py +0 -0
  112. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_battery_stale_offer_is_abandoned_early.py +0 -0
  113. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_battery_wake_settles_before_the_command.py +0 -0
  114. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_ble_gateway_is_gone.py +0 -0
  115. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_bridge_state_sentinels.py +0 -0
  116. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_busy_backoff.py +0 -0
  117. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_camera_properties.py +0 -0
  118. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_canary_log_names_the_camera.py +0 -0
  119. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_close_mid_ice_leaves_nothing_pending.py +0 -0
  120. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_cloud_count.py +0 -0
  121. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_cloud_error_log_names_the_exception.py +0 -0
  122. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_cloud_error_severity.py +0 -0
  123. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_cloud_plan.py +0 -0
  124. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_constants_imports_resolve.py +0 -0
  125. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_control_verdicts.py +0 -0
  126. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_declared_properties.py +0 -0
  127. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_deferred_hardening.py +0 -0
  128. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_detection_types.py +0 -0
  129. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_device_cmd_seq_match.py +0 -0
  130. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_device_login_guard.py +0 -0
  131. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_device_user_info_cache.py +0 -0
  132. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_direct_publish_attaches_negotiated_audio.py +0 -0
  133. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_direct_ts_keyframe_signal.py +0 -0
  134. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_direct_ts_serve.py +0 -0
  135. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_direct_ts_serve_only_for_http.py +0 -0
  136. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_dormant_camera_cleanup.py +0 -0
  137. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_drain_is_cancellable.py +0 -0
  138. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_dtls_mux_terminal_write.py +0 -0
  139. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_dtls_not_ready_burst.py +0 -0
  140. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_dtls_open_abort_covers_every_exit.py +0 -0
  141. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_dtls_pinning.py +0 -0
  142. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_dtls_recorder_wiring.py +0 -0
  143. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_dtls_refusal_after_the_echo.py +0 -0
  144. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_dtls_resolution_ack.py +0 -0
  145. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_dtls_serve_decode_skip.py +0 -0
  146. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_dtls_serve_ice_wait.py +0 -0
  147. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_dtls_serve_open_timeout.py +0 -0
  148. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_dtls_skip_signaling_wait.py +0 -0
  149. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_dtls_slow_probe.py +0 -0
  150. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_dtls_stdout_producer.py +0 -0
  151. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_dtls_tap_states_no_reorder.py +0 -0
  152. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_dtls_ts_copy_recording.py +0 -0
  153. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_dtls_video_presence.py +0 -0
  154. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_egress_guard.py +0 -0
  155. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_env_seams.py +0 -0
  156. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_every_exception_is_exported.py +0 -0
  157. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_feature_probe_needs_a_live_session.py +0 -0
  158. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_ffmpeg_rx_sdp_single_template.py +0 -0
  159. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_ffmpeg_signal_exit_is_not_a_crash.py +0 -0
  160. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_first_media_abandon_rules.py +0 -0
  161. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_futile_keepalive.py +0 -0
  162. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_gate_requires_a_decoded_frame.py +0 -0
  163. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_go2rtc.py +0 -0
  164. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_go2rtc_cli.py +0 -0
  165. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_h264_decode_rate_limit.py +0 -0
  166. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_h264_sps.py +0 -0
  167. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_health_line.py +0 -0
  168. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_health_line_counts_pictures.py +0 -0
  169. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_highport_nomination.py +0 -0
  170. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_hls_ts_end_to_end.py +0 -0
  171. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_hls_ts_wiring.py +0 -0
  172. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_hwaccel_probe.py +0 -0
  173. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_ice_config_cache.py +0 -0
  174. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_ice_credentials_match_the_app.py +0 -0
  175. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_ice_peer_reflexive.py +0 -0
  176. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_idle_release_both_loops.py +0 -0
  177. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_ipc_envelope_single_builder.py +0 -0
  178. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_keepalive_renew.py +0 -0
  179. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_key_restart_sdp.py +0 -0
  180. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_keyframe_prompter.py +0 -0
  181. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_lan_control.py +0 -0
  182. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_lan_login_accessors.py +0 -0
  183. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_lan_retry_reaches_the_storming_devices.py +0 -0
  184. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_lan_retry_short_sessions.py +0 -0
  185. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_lan_retry_silences_upstream_over_ceiling.py +0 -0
  186. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_lan_retry_stops_on_close.py +0 -0
  187. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_lan_sweep_only_with_lights.py +0 -0
  188. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_late_repeats_are_dropped.py +0 -0
  189. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_light_active_color_mode.py +0 -0
  190. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_live_session_carries_talk.py +0 -0
  191. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_live_stream_param.py +0 -0
  192. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_live_validate_policy.py +0 -0
  193. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_live_validate_token_refresh.py +0 -0
  194. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_live_video_canary.py +0 -0
  195. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_liveplay_echo_timeout.py +0 -0
  196. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_local_only_controls.py +0 -0
  197. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_log_hygiene.py +0 -0
  198. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_logging_caps.py +0 -0
  199. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_login_connect_deadline.py +0 -0
  200. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_login_reconnect_backoff.py +0 -0
  201. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_loop_break_ends_the_loop.py +0 -0
  202. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_media_counted_only_when_plaintext.py +0 -0
  203. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_media_path_is_reported.py +0 -0
  204. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_media_socket_rcvbuf.py +0 -0
  205. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_media_wait_floor.py +0 -0
  206. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_motion_poll.py +0 -0
  207. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_mqtt_client_id_is_exact.py +0 -0
  208. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_mqtt_credential_refetch.py +0 -0
  209. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_mqtt_credential_self_heal.py +0 -0
  210. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_mqtt_password_not_persisted.py +0 -0
  211. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_mqtt_session_reconnect.py +0 -0
  212. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_mqtt_thread_not_orphaned.py +0 -0
  213. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_mypy_new_findings.py +0 -0
  214. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_nack.py +0 -0
  215. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_nack_cannot_add_load_in_a_burst.py +0 -0
  216. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_narrow_pc_ice.py +0 -0
  217. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_no_key_material_in_logs.py +0 -0
  218. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_no_private_notes_on_the_public_surface.py +0 -0
  219. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_no_self_referential_go2rtc.py +0 -0
  220. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_no_unbounded_hex_dumps.py +0 -0
  221. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_no_undefined_names.py +0 -0
  222. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_nominates_as_soon_as_the_answer_lands.py +0 -0
  223. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_off_subnet_candidate.py +0 -0
  224. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_offer_keeps_rtcp_feedback.py +0 -0
  225. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_offline_keepalive_pause.py +0 -0
  226. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_on_frame_is_an_av_frame.py +0 -0
  227. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_open_errors_are_aidot_errors.py +0 -0
  228. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_open_fail_logger.py +0 -0
  229. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_open_gate_delay.py +0 -0
  230. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_peer_id_client_type.py +0 -0
  231. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_peerid_reuse.py +0 -0
  232. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_persistent_mqtt.py +0 -0
  233. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_persistent_mqtt_robustness.py +0 -0
  234. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_phase_returns_reach_the_caller.py +0 -0
  235. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_phase_state_is_complete.py +0 -0
  236. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_plaintext_mqtt_refused.py +0 -0
  237. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_post_merge_hardening.py +0 -0
  238. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_ptz_probe_reads_what_it_is_told.py +0 -0
  239. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_public_signature_matches_impl.py +0 -0
  240. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_publish_target_readiness.py +0 -0
  241. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_quality_campaign_in_session.py +0 -0
  242. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_reboot_action.py +0 -0
  243. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_recent_recordings.py +0 -0
  244. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_region_allowlist.py +0 -0
  245. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_remb.py +0 -0
  246. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_remb_goes_out_the_relay_aware_sender.py +0 -0
  247. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_removed_keepalive_arguments_are_tolerated.py +0 -0
  248. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_resent_frames_are_dropped.py +0 -0
  249. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_resolution_persists.py +0 -0
  250. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_restart_cycle.py +0 -0
  251. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_retry_policy.py +0 -0
  252. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_review_lan_retry_and_graces.py +0 -0
  253. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_rtp_h264.py +0 -0
  254. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_rtsp_publish.py +0 -0
  255. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_rtsp_publish_go2rtc.py +0 -0
  256. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_scripts_importable.py +0 -0
  257. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_sctp_init_ack_tsn.py +0 -0
  258. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_sctp_reassembly.py +0 -0
  259. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_sctp_sack.py +0 -0
  260. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_sd_card_presence.py +0 -0
  261. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_sd_event_decode.py +0 -0
  262. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_sd_event_probe_payloads.py +0 -0
  263. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_sd_listing.py +0 -0
  264. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_sd_playback.py +0 -0
  265. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_sd_playback_controls.py +0 -0
  266. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_sd_playback_dtls.py +0 -0
  267. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_sd_playback_entry.py +0 -0
  268. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_sd_playback_media_hold.py +0 -0
  269. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_sd_playback_open.py +0 -0
  270. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_sdes_avio_dispatch.py +0 -0
  271. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_sdes_bridge_break_gate.py +0 -0
  272. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_sdes_connection_mode.py +0 -0
  273. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_sdes_echo_wait_is_not_dead_time.py +0 -0
  274. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_sdes_fast_liveplay.py +0 -0
  275. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_sdes_ffmpeg_stderr_log.py +0 -0
  276. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_sdes_first_media_stall_report.py +0 -0
  277. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_sdes_ice_teardown.py +0 -0
  278. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_sdes_idle_release.py +0 -0
  279. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_sdes_nack_bridge.py +0 -0
  280. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_sdes_offer_video_pin.py +0 -0
  281. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_sdes_open_cleanup.py +0 -0
  282. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_sdes_open_yields_the_loop.py +0 -0
  283. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_sdes_pinned_codec_retry.py +0 -0
  284. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_sdes_sctp_abort.py +0 -0
  285. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_sdes_serve_audio.py +0 -0
  286. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_sdes_serve_cmd.py +0 -0
  287. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_sdes_serve_stderr_drain.py +0 -0
  288. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_sdes_sprop.py +0 -0
  289. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_sdes_talk.py +0 -0
  290. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_sdes_teardown_exit_log.py +0 -0
  291. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_sdes_terminal_ack.py +0 -0
  292. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_sdes_terminal_ack_latency.py +0 -0
  293. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_sdes_watchdog.py +0 -0
  294. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_sdp_narrowing_properties.py +0 -0
  295. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_sdp_payload_type_narrowing.py +0 -0
  296. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_security_hardening.py +0 -0
  297. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_self_peer_is_an_address_not_an_ip.py +0 -0
  298. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_serializable_login_info.py +0 -0
  299. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_serve_bound_survives_arrival_stamping.py +0 -0
  300. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_serve_input_timeout.py +0 -0
  301. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_serve_loop_releases_on_error.py +0 -0
  302. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_serve_loss_tolerance.py +0 -0
  303. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_serve_pt_respects_the_pin.py +0 -0
  304. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_serve_relay.py +0 -0
  305. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_serve_stderr_keeps_the_reason.py +0 -0
  306. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_session_mode_seq.py +0 -0
  307. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_session_stats.py +0 -0
  308. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_session_stop_twice.py +0 -0
  309. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_settings_registry.py +0 -0
  310. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_shared_house_seam.py +0 -0
  311. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_signalling_is_attributable_to_a_camera.py +0 -0
  312. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_skip_guard.py +0 -0
  313. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_snapshot_cancellation.py +0 -0
  314. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_speak.py +0 -0
  315. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_sprop_capture_gate.py +0 -0
  316. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_sprop_staleness.py +0 -0
  317. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_sprop_unstable_public.py +0 -0
  318. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_sprop_write_is_reported_truthfully.py +0 -0
  319. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_srtp_rx_rekey.py +0 -0
  320. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_srtp_tx_key_note.py +0 -0
  321. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_stable_terminal_id.py +0 -0
  322. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_stall_nudge_policy.py +0 -0
  323. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_stall_report_says_what_the_answer_carried.py +0 -0
  324. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_stall_report_says_whether_the_trigger_was_answered.py +0 -0
  325. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_stall_report_separates_silence_from_undecryptable.py +0 -0
  326. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_stall_report_survives_a_caller_giving_up.py +0 -0
  327. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_stall_reports_reach_the_artifact.py +0 -0
  328. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_status_and_trace_channels.py +0 -0
  329. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_stream_cap.py +0 -0
  330. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_stream_drain_ownership.py +0 -0
  331. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_stream_idle.py +0 -0
  332. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_stream_slot_cap.py +0 -0
  333. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_stream_teardown.py +0 -0
  334. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_stun_attr_single_builder.py +0 -0
  335. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_swallowed_exception_names.py +0 -0
  336. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_talk.py +0 -0
  337. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_talk_reports_a_speaker_that_never_opened.py +0 -0
  338. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_tap_to_mux_end_to_end.py +0 -0
  339. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_terminal_ack.py +0 -0
  340. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_tmmbr.py +0 -0
  341. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_token_file_robustness.py +0 -0
  342. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_token_refresh.py +0 -0
  343. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_transport_failure_reporting.py +0 -0
  344. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_ts_fanout.py +0 -0
  345. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_ts_tee.py +0 -0
  346. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_turn_create_permission.py +0 -0
  347. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_turn_target_shared.py +0 -0
  348. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_unbuildable_summary_repeats_at_debug.py +0 -0
  349. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_unreachable_nominee_is_abandoned_early.py +0 -0
  350. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_upstream_compat.py +0 -0
  351. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_upstream_seam_adapter.py +0 -0
  352. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_upstream_secrets_redacted.py +0 -0
  353. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_validator_shares_one_token.py +0 -0
  354. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_vendored_aiortc_integrity.py +0 -0
  355. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_video_presence_needs_a_keyframe.py +0 -0
  356. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_video_profile_log.py +0 -0
  357. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_video_pts_dts.py +0 -0
  358. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_video_ts_unwrap.py +0 -0
  359. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_viewer_check_throttle.py +0 -0
  360. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_viewer_presence.py +0 -0
  361. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_viewer_present_push_mode.py +0 -0
  362. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_wait_or_event.py +0 -0
  363. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_wake_gate_camera_evidence.py +0 -0
  364. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/tests/test_wake_readiness_retry.py +0 -0
  365. {python_aidot_cameras-1.0.0rc64 → python_aidot_cameras-1.0.0rc65}/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.0rc64
3
+ Version: 1.0.0rc65
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
@@ -1041,9 +1041,13 @@ def _build_sdes_serve_cmd(
1041
1041
  # instead. Moving `-t` to the input side does not help - both placements
1042
1042
  # fail. Only a bounded run gives this up, and a bounded run is a
1043
1043
  # snapshot or the `-f null` drain, never the live stream.
1044
+ # Nor for SD playback (``keep_probe_packets``): a recording arrives
1045
+ # faster than it plays, and its timing is put right afterwards from
1046
+ # the camera's per-frame recording time (sd_playback.PlaybackClock),
1047
+ # which needs the camera's own RTP timestamps in the file.
1044
1048
  *(
1045
1049
  ["-use_wallclock_as_timestamps", "1"]
1046
- if _SERVE_ARRIVAL_TS and not max_seconds
1050
+ if _SERVE_ARRIVAL_TS and not max_seconds and not keep_probe_packets
1047
1051
  else []
1048
1052
  ),
1049
1053
  # ``nobuffer`` drops what ffmpeg reads while probing the input (up to
@@ -305,6 +305,175 @@ def playback_peer_id(camera: Any, *, sdes: bool) -> str:
305
305
  )
306
306
 
307
307
 
308
+ # --- The recording's own clock (SDES) ---------------------------------------
309
+ #
310
+ # The camera stamps a recording's RTP as if it had been recorded at its live
311
+ # frame rate and sends it at that pace: an A001064 night recording made at
312
+ # 5 fps (200 ms a frame) went out at 20 fps (50 ms a frame), its 60 s in 15 s,
313
+ # and its sound - sent in real time - stopped when the video ran out
314
+ # (2026-10-29). The vendor app plays it at the right speed because every
315
+ # packet also carries the frame's time in the recording, in an RTP header
316
+ # extension the camera adds once the offer names transport-cc and
317
+ # rtp-stream-id (OfferUtils.processRtp keeps exactly those two extmap lines;
318
+ # the answer then says ``supportRtpExt: 1``). The 11-byte value is a 2-byte
319
+ # playback channel, a flag byte (0xf0) and the time in Unix milliseconds, all
320
+ # big-endian; all ones marks the end of the recording, on the packets the
321
+ # camera sends after its last frame.
322
+
323
+ #: The two header extensions the vendor app's offer keeps, with the ids this
324
+ #: library offers them under.
325
+ EXT_TRANSPORT_CC = (
326
+ "http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01"
327
+ )
328
+ EXT_FRAME_TIME = "urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id"
329
+ EXT_TRANSPORT_CC_ID = 3
330
+ EXT_FRAME_TIME_ID = 4
331
+ #: The frame-time value the camera sends once the recording has run out.
332
+ FRAME_TIME_END = 0xFFFFFFFFFFFFFFFF
333
+
334
+ _AUDIO_CLOCKS = {0: 8000, 8: 8000}
335
+
336
+
337
+ def offer_with_frame_times(sdp: str) -> str:
338
+ """``sdp`` asking for the per-frame recording time: both extmap lines in
339
+ every audio and video section, after its mid, and transport-cc feedback
340
+ on each video payload type."""
341
+ extmaps = [
342
+ f"a=extmap:{EXT_TRANSPORT_CC_ID} {EXT_TRANSPORT_CC}",
343
+ f"a=extmap:{EXT_FRAME_TIME_ID} {EXT_FRAME_TIME}",
344
+ ]
345
+ out = []
346
+ kind, pts = "", []
347
+ for line in sdp.split("\r\n"):
348
+ if line.startswith("m="):
349
+ kind = line[2:].split(" ", 1)[0]
350
+ pts = line.split()[3:]
351
+ out.append(line)
352
+ if line.startswith("a=mid:") and kind in ("audio", "video"):
353
+ out.extend(extmaps)
354
+ if kind == "video":
355
+ out.extend(f"a=rtcp-fb:{pt} transport-cc" for pt in pts)
356
+ return "\r\n".join(out)
357
+
358
+
359
+ def rtp_extension(pkt: bytes, ext_id: int) -> "Optional[bytes]":
360
+ """The value of header extension ``ext_id`` in RTP packet ``pkt`` (RFC
361
+ 8285, one- or two-byte form), or None. The header is in the clear under
362
+ SRTP, so this reads a packet still encrypted."""
363
+ if len(pkt) < 12 or not pkt[0] & 0x10:
364
+ return None
365
+ off = 12 + 4 * (pkt[0] & 0x0F)
366
+ if len(pkt) < off + 4:
367
+ return None
368
+ profile = int.from_bytes(pkt[off : off + 2], "big")
369
+ end = off + 4 + 4 * int.from_bytes(pkt[off + 2 : off + 4], "big")
370
+ if end > len(pkt):
371
+ return None
372
+ i = off + 4
373
+ while i < end:
374
+ if profile == 0xBEDE:
375
+ b = pkt[i]
376
+ if b == 0:
377
+ i += 1
378
+ continue
379
+ eid, n, i = b >> 4, (b & 0x0F) + 1, i + 1
380
+ elif profile >> 4 == 0x100 and i + 1 < end:
381
+ if pkt[i] == 0:
382
+ i += 1
383
+ continue
384
+ eid, n, i = pkt[i], pkt[i + 1], i + 2
385
+ else:
386
+ return None
387
+ if eid == ext_id:
388
+ return bytes(pkt[i : i + n])
389
+ i += n
390
+ return None
391
+
392
+
393
+ def frame_time_ms(value: "Optional[bytes]") -> "Optional[int]":
394
+ """The recording time (Unix ms) in a frame-time extension value;
395
+ ``FRAME_TIME_END`` at the end of the recording; None if absent."""
396
+ if value is None or len(value) != 11:
397
+ return None
398
+ return int.from_bytes(value[3:11], "big")
399
+
400
+
401
+ class PlaybackClock:
402
+ """Pairs each stream's RTP timestamps with the recording's own time.
403
+
404
+ Fed every packet the playback forwards; keeps one (rtp timestamp, ms)
405
+ pair per change of recording time, per payload type. ``ended`` turns
406
+ True on the first end-of-recording mark. ``timing()`` is what
407
+ ``retime_playback_ts`` needs.
408
+ """
409
+
410
+ def __init__(self) -> None:
411
+ self._pairs: "dict[int, list[tuple[int, int]]]" = {}
412
+ self.ended = False
413
+
414
+ def observe(self, pkt: bytes) -> bool:
415
+ """Note ``pkt``; True if it is an end-of-recording packet (forward
416
+ nothing of those: the recording is over)."""
417
+ ms = frame_time_ms(rtp_extension(pkt, EXT_FRAME_TIME_ID))
418
+ if ms is None:
419
+ return False
420
+ if ms == FRAME_TIME_END:
421
+ self.ended = True
422
+ return True
423
+ pairs = self._pairs.setdefault(pkt[1] & 0x7F, [])
424
+ if not pairs or pairs[-1][1] != ms:
425
+ pairs.append((int.from_bytes(pkt[4:8], "big"), ms))
426
+ return False
427
+
428
+ def timing(self) -> "dict[str, tuple[float, float]]":
429
+ """Per stream kind (``"video"``, ``"audio"``): (scale, start_s) - each
430
+ second of RTP time is ``scale`` seconds of recording, and the stream
431
+ starts ``start_s`` after the earliest stream does. Empty when no
432
+ frame times arrived."""
433
+ # One stream per kind: the payload type with the most frame times
434
+ # (a stray second video type would otherwise decide by dict order).
435
+ best: "dict[str, int]" = {}
436
+ for pt, pairs in self._pairs.items():
437
+ kind = "audio" if pt in _AUDIO_CLOCKS else "video"
438
+ if len(pairs) > len(self._pairs.get(best.get(kind, -1), ())):
439
+ best[kind] = pt
440
+ firsts = [self._pairs[pt][0][1] for pt in best.values()]
441
+ if not firsts:
442
+ return {}
443
+ origin = min(firsts)
444
+ out = {}
445
+ for kind, pt in best.items():
446
+ pairs = self._pairs[pt]
447
+ clock = _AUDIO_CLOCKS.get(pt, 90000)
448
+ (r0, m0), (r1, m1) = pairs[0], pairs[-1]
449
+ rtp_s = ((r1 - r0) & 0xFFFFFFFF) / clock
450
+ scale = (m1 - m0) / 1000 / rtp_s if rtp_s > 0 else 1.0
451
+ out[kind] = (scale, (m0 - origin) / 1000)
452
+ return out
453
+
454
+
455
+ def retime_argv(
456
+ binary: str, src: str, dst: str, timing: "dict[str, tuple[float, float]]"
457
+ ) -> "list[str]":
458
+ """ffmpeg command that copies ``src`` to ``dst`` with each stream moved
459
+ onto the recording's clock (``PlaybackClock.timing``). An SDES capture's
460
+ G.711 rides a data stream, which takes the audio timing."""
461
+
462
+ def setts(scale: float, start_s: float) -> str:
463
+ def at(ts: str) -> str:
464
+ return f"({ts}-START{ts})*{scale:.6f}+{start_s:.6f}/TB"
465
+
466
+ return f"setts=pts={at('PTS')}:dts={at('DTS')}"
467
+
468
+ argv = [binary, "-hide_banner", "-loglevel", "error", "-nostdin", "-y"]
469
+ argv += ["-i", src, "-map", "0", "-c", "copy"]
470
+ if "video" in timing:
471
+ argv += ["-bsf:v", setts(*timing["video"])]
472
+ if "audio" in timing:
473
+ argv += ["-bsf:a", setts(*timing["audio"]), "-bsf:d", setts(*timing["audio"])]
474
+ return [*argv, "-f", "mpegts", dst]
475
+
476
+
308
477
  # --- DTLS (A000088): the recording's H265 is taken raw ----------------------
309
478
  #
310
479
  # aiortc has no H265, and the camera sends a recording it holds as H265 on a
@@ -63,6 +63,8 @@ class SdesSession(_PlaybackControls, AvioRequestMixin):
63
63
  abort_chan=None,
64
64
  playback_waiter=None,
65
65
  playback=None,
66
+ playback_clock=None,
67
+ playback_output=None,
66
68
  ) -> None:
67
69
  # Which camera this session belongs to, for logging only. Optional so
68
70
  # an existing caller that does not pass it still works; the logs then
@@ -135,6 +137,11 @@ class SdesSession(_PlaybackControls, AvioRequestMixin):
135
137
  # channel), for pause, resume and seek; None for live.
136
138
  self._sd_playback = playback
137
139
  self._sd_seek_count = 0
140
+ # SD playback: the recording's own clock as the bridge saw it
141
+ # (sd_playback.PlaybackClock) and the .ts it was written to, which
142
+ # stop() moves onto that clock.
143
+ self._playback_clock = playback_clock
144
+ self._playback_output = playback_output
138
145
  # Shared talk state (dict) for outbound two-way audio, or None when the
139
146
  # session was not opened talk-capable (offer stayed recvonly). Populated
140
147
  # by the bridge (camera audio addr) and by async_start_talk (provider).
@@ -254,6 +261,13 @@ class SdesSession(_PlaybackControls, AvioRequestMixin):
254
261
  """time.monotonic() of the camera's last STUN answer, 0.0 if none yet."""
255
262
  return self._ice_progress[0]
256
263
 
264
+ @property
265
+ def playback_ended(self) -> bool:
266
+ """True once the camera has marked the end of the recording this
267
+ session plays (False for live, and before the end)."""
268
+ clock = self._playback_clock
269
+ return bool(clock is not None and clock.ended)
270
+
257
271
  def media_stats(self) -> dict:
258
272
  """Snapshot of media actually forwarded to ffmpeg by the bridge.
259
273
 
@@ -372,6 +386,62 @@ class SdesSession(_PlaybackControls, AvioRequestMixin):
372
386
  # others are waiting on.
373
387
  await asyncio.shield(task)
374
388
 
389
+ async def _retime_playback(self) -> None:
390
+ """Move a played recording's .ts onto the recording's own clock.
391
+
392
+ The camera's RTP clock runs at the live frame rate even for a
393
+ recording made slower (sd_playback.PlaybackClock), so the file ffmpeg
394
+ wrote plays fast; the frame-time extension says by how much. Left as
395
+ written when there is nothing to correct (no frame times, a live
396
+ session, an output that is not a .ts) or when the copy fails.
397
+ """
398
+ clock, path = self._playback_clock, self._playback_output
399
+ if clock is None or not path or not str(path).endswith(".ts"):
400
+ return
401
+ timing = clock.timing()
402
+ if not timing or all(
403
+ abs(s - 1.0) < 0.02 and o < 0.05 for s, o in timing.values()
404
+ ):
405
+ return
406
+ import os
407
+ import subprocess
408
+
409
+ from .sd_playback import retime_argv
410
+
411
+ tmp = f"{path}.retime.ts"
412
+ argv = retime_argv("ffmpeg", path, tmp, timing)
413
+
414
+ def _run() -> bool:
415
+ try:
416
+ done = subprocess.run(argv, capture_output=True, timeout=120)
417
+ except Exception:
418
+ return False
419
+ if done.returncode != 0 or not os.path.exists(tmp):
420
+ return False
421
+ os.replace(tmp, path)
422
+ return True
423
+
424
+ try:
425
+ ok = await asyncio.get_running_loop().run_in_executor(None, _run)
426
+ except Exception:
427
+ ok = False
428
+ if not ok:
429
+ try:
430
+ os.unlink(tmp)
431
+ except OSError:
432
+ pass
433
+ _LOGGER.warning(
434
+ "camera %s: the played recording could not be put on its own"
435
+ " clock and plays at the camera's send rate",
436
+ self._device_id,
437
+ )
438
+ return
439
+ _LOGGER.info(
440
+ "camera %s: played recording put on its own clock (%s)",
441
+ self._device_id,
442
+ ", ".join(f"{k} x{s:.2f} from +{o:.1f}s" for k, (s, o) in timing.items()),
443
+ )
444
+
375
445
  async def _stop_once(self) -> None:
376
446
  if self._talk_state is not None:
377
447
  # Ask the bridge to close the camera speaker, then give it a brief
@@ -454,6 +524,7 @@ class SdesSession(_PlaybackControls, AvioRequestMixin):
454
524
  except Exception:
455
525
  _LOGGER.debug("swallowed exception in %s", "_stop_once", exc_info=True)
456
526
  self._log_ffmpeg_stderr(stderr_bytes or self._drained_stderr_tail())
527
+ await self._retime_playback()
457
528
  import os
458
529
 
459
530
  try:
@@ -465,7 +536,9 @@ class SdesSession(_PlaybackControls, AvioRequestMixin):
465
536
  try:
466
537
  _sock.close()
467
538
  except Exception:
468
- _LOGGER.debug("swallowed exception in %s", "_stop_once", exc_info=True)
539
+ _LOGGER.debug(
540
+ "swallowed exception in %s", "_stop_once", exc_info=True
541
+ )
469
542
  self._outgoing_q.put_nowait(None)
470
543
  try:
471
544
  await asyncio.wait_for(self._mqtt_fut, timeout=_STOP_MQTT_WAIT_S)
@@ -36,6 +36,7 @@ from .constants import (
36
36
  )
37
37
  from .models import VideoFrame # noqa: F401 - forward-ref annotation
38
38
  from .sdes import SdesSession
39
+ from .sd_playback import EXT_FRAME_TIME, EXT_TRANSPORT_CC
39
40
  from .rtsp_publish import (
40
41
  redact_url,
41
42
  LoopbackRtpPublisher,
@@ -1146,6 +1147,11 @@ class _MediaHold:
1146
1147
  self.dropped = 0
1147
1148
  self.duplicates = 0
1148
1149
  self._seen: "dict" = {}
1150
+ # The recording's own clock, from each packet's frame-time extension
1151
+ # (sd_playback.PlaybackClock); its end mark ends the recording.
1152
+ from .sd_playback import PlaybackClock
1153
+
1154
+ self.clock = PlaybackClock()
1149
1155
 
1150
1156
  def _repeat(self, pkt: bytes, port: int) -> bool:
1151
1157
  """Is ``pkt`` an RTP packet this hold has already passed on?"""
@@ -1172,6 +1178,16 @@ class _MediaHold:
1172
1178
  return self._released and self._held is not None
1173
1179
 
1174
1180
  def send(self, sock, pkt: bytes, port: int, *, pace=None, log=None) -> None:
1181
+ was_ended = self.clock.ended
1182
+ if self.clock.observe(pkt):
1183
+ # After its last frame the camera marks packets as the end of the
1184
+ # recording; there is nothing more of it to serve.
1185
+ if not was_ended and log is not None:
1186
+ try:
1187
+ log("bridge: the camera marked the end of the recording")
1188
+ except Exception:
1189
+ pass
1190
+ return
1175
1191
  if self._repeat(pkt, port):
1176
1192
  self.duplicates += 1
1177
1193
  if self.duplicates == 1 and log is not None:
@@ -2100,6 +2116,11 @@ def turn_install_permissions(
2100
2116
  return _perm_ok
2101
2117
 
2102
2118
 
2119
+ #: The extmap lines the vendor app's offer filter keeps (OfferUtils.processRtp):
2120
+ #: the two a playback offer adds (sd_playback.offer_with_frame_times).
2121
+ _KEPT_EXTMAPS = (EXT_TRANSPORT_CC, EXT_FRAME_TIME)
2122
+
2123
+
2103
2124
  def compress_sdp_req(device_id, _sdp: str) -> str:
2104
2125
  """g.b() equivalent - selective SDP filter for wPayload."""
2105
2126
  _out: list = []
@@ -2128,6 +2149,14 @@ def compress_sdp_req(device_id, _sdp: str) -> str:
2128
2149
  if any(_d in _ln for _d in ("sendrecv", "recvonly", "sendonly")):
2129
2150
  _k(_ln)
2130
2151
  continue
2152
+ if _ln.startswith("a=extmap"):
2153
+ # OfferUtils.processRtp keeps these two, once each across the
2154
+ # SDP; firmware that reads wPayload sees no others. A live offer
2155
+ # names neither, so this changes nothing for it.
2156
+ for _uri in _KEPT_EXTMAPS:
2157
+ if _uri in _ln and _seen.get(_uri) is None:
2158
+ _k(_ln, _uri)
2159
+ continue
2131
2160
  for _ak in (
2132
2161
  "ice-ufrag",
2133
2162
  "ice-pwd",
@@ -6699,6 +6728,13 @@ async def _sdes_open_build_offer(self, ps: _SdesOpenState) -> None:
6699
6728
  )
6700
6729
  if ps._status:
6701
6730
  ps._status(f"SDES: offer pinned to video pt={_pin_video_pt}")
6731
+ if ps.sd_playback is not None:
6732
+ # The per-frame recording time the camera adds once the offer names
6733
+ # transport-cc and rtp-stream-id, as the vendor app's does: its RTP
6734
+ # clock runs at the live frame rate even for a slower recording.
6735
+ from .sd_playback import offer_with_frame_times
6736
+
6737
+ ps.sdes_offer_sdp = offer_with_frame_times(ps.sdes_offer_sdp)
6702
6738
 
6703
6739
  _relay_str = (
6704
6740
  f" relay-audio={ps._relay_addrs[ps._audio_sock][0]}:{ps._relay_addrs[ps._audio_sock][1]}"
@@ -9854,4 +9890,6 @@ class _SdesOpenMixin:
9854
9890
  # holding the started stream back for it.
9855
9891
  playback_waiter=_sd_start_waiter,
9856
9892
  playback=ps.sd_playback,
9893
+ playback_clock=_media_hold.clock if _media_hold is not None else None,
9894
+ playback_output=output_path if ps.sd_playback is not None else None,
9857
9895
  )
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-aidot-cameras"
7
- version = "1.0.0rc64"
7
+ version = "1.0.0rc65"
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.0rc64
3
+ Version: 1.0.0rc65
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
@@ -259,6 +259,7 @@ tests/test_sd_event_decode.py
259
259
  tests/test_sd_event_probe_payloads.py
260
260
  tests/test_sd_listing.py
261
261
  tests/test_sd_playback.py
262
+ tests/test_sd_playback_clock.py
262
263
  tests/test_sd_playback_controls.py
263
264
  tests/test_sd_playback_dtls.py
264
265
  tests/test_sd_playback_entry.py
@@ -0,0 +1,225 @@
1
+ """SD playback puts a recording on its own clock.
2
+
3
+ An A001064 night recording made at 5 fps went out stamped and paced at the
4
+ live 20 fps (2026-10-29): 60 s of video in 15 s, its sound sent in real time
5
+ and cut off when the video ran out. Every packet carries the frame's time in
6
+ the recording in an RTP header extension once the offer names transport-cc
7
+ and rtp-stream-id, as the vendor app's does; the library offers both, keeps
8
+ them through the wPayload filter, reads the time off each packet and moves
9
+ the written file onto it.
10
+ """
11
+
12
+ import os
13
+ import shutil
14
+ import subprocess
15
+
16
+ import pytest
17
+
18
+ from aidot_cameras.camera.sd_playback import (
19
+ EXT_FRAME_TIME,
20
+ EXT_FRAME_TIME_ID,
21
+ EXT_TRANSPORT_CC,
22
+ EXT_TRANSPORT_CC_ID,
23
+ FRAME_TIME_END,
24
+ PlaybackClock,
25
+ frame_time_ms,
26
+ offer_with_frame_times,
27
+ retime_argv,
28
+ rtp_extension,
29
+ )
30
+ from aidot_cameras.camera.sdes_open import _MediaHold, compress_sdp_req
31
+
32
+ T0 = 1_792_988_619_319 # 2026-10-26T04:23:39.319Z, a played night clip
33
+
34
+
35
+ def _value(ms, channel=0x14D4):
36
+ return channel.to_bytes(2, "big") + b"\xf0" + ms.to_bytes(8, "big")
37
+
38
+
39
+ def _rtp(pt, seq, ts, ms=None, *, two_byte=False):
40
+ hdr = bytes([0x80, pt]) + seq.to_bytes(2, "big") + ts.to_bytes(4, "big")
41
+ hdr += (0x1259).to_bytes(4, "big")
42
+ if ms is None:
43
+ return hdr + b"payload"
44
+ if two_byte:
45
+ ext = bytes([EXT_TRANSPORT_CC_ID, 2]) + seq.to_bytes(2, "big")
46
+ ext += bytes([EXT_FRAME_TIME_ID, 11]) + _value(ms)
47
+ profile = b"\x10\x00"
48
+ else:
49
+ ext = bytes([(EXT_TRANSPORT_CC_ID << 4) | 1]) + seq.to_bytes(2, "big")
50
+ ext += bytes([(EXT_FRAME_TIME_ID << 4) | 10]) + _value(ms)
51
+ profile = b"\xbe\xde"
52
+ ext += b"\x00" * (-len(ext) % 4)
53
+ hdr = bytes([0x90]) + hdr[1:]
54
+ return hdr + profile + (len(ext) // 4).to_bytes(2, "big") + ext + b"payload"
55
+
56
+
57
+ def test_the_frame_time_is_read_in_either_extension_form():
58
+ for two in (False, True):
59
+ pkt = _rtp(97, 7, 1000, T0, two_byte=two)
60
+ assert frame_time_ms(rtp_extension(pkt, EXT_FRAME_TIME_ID)) == T0
61
+ assert rtp_extension(pkt, EXT_TRANSPORT_CC_ID) == (7).to_bytes(2, "big")
62
+ assert rtp_extension(_rtp(97, 7, 1000), EXT_FRAME_TIME_ID) is None
63
+ assert frame_time_ms(None) is None
64
+ assert frame_time_ms(b"short") is None
65
+
66
+
67
+ def test_a_night_clip_is_stretched_to_the_recording_and_its_sound_placed():
68
+ # Measured: 200 ms of recording per frame, 50 ms of RTP (4500 ticks);
69
+ # the sound starts 6 s into the clip and runs in real time.
70
+ clock = PlaybackClock()
71
+ for i in range(301):
72
+ clock.observe(_rtp(97, i, 1000 + i * 4500, T0 + i * 200))
73
+ for i in range(200):
74
+ clock.observe(_rtp(8, i, 50 + i * 512, T0 + 6002 + i * 64))
75
+ timing = clock.timing()
76
+ assert timing["video"] == (pytest.approx(4.0), 0.0)
77
+ assert timing["audio"] == (pytest.approx(1.0), pytest.approx(6.002))
78
+ assert not clock.ended
79
+ assert clock.observe(_rtp(97, 999, 0, FRAME_TIME_END)) is True
80
+ assert clock.ended
81
+
82
+
83
+ def test_no_frame_times_means_nothing_to_correct():
84
+ clock = PlaybackClock()
85
+ clock.observe(_rtp(97, 1, 1000))
86
+ assert clock.timing() == {}
87
+
88
+
89
+ def test_the_playback_offer_names_both_extensions_in_each_media_section():
90
+ sdp = (
91
+ "v=0\r\nm=audio 9 RTP/SAVPF 8\r\na=mid:0\r\na=rtpmap:8 PCMA/8000\r\n"
92
+ "m=video 9 RTP/SAVPF 96 97\r\na=mid:1\r\n"
93
+ "m=application 9 SCTP webrtc-datachannel\r\na=mid:2\r\n"
94
+ )
95
+ out = offer_with_frame_times(sdp)
96
+ for mid in ("a=mid:0", "a=mid:1"):
97
+ after = out.split(mid + "\r\n")[1]
98
+ assert after.startswith(
99
+ f"a=extmap:{EXT_TRANSPORT_CC_ID} {EXT_TRANSPORT_CC}\r\n"
100
+ f"a=extmap:{EXT_FRAME_TIME_ID} {EXT_FRAME_TIME}\r\n"
101
+ )
102
+ assert "a=rtcp-fb:97 transport-cc" in out
103
+ assert out.split("a=mid:2")[1].count("extmap") == 0
104
+
105
+
106
+ def test_the_wpayload_filter_keeps_the_two_extmaps_once_as_the_app_does():
107
+ sdp = offer_with_frame_times(
108
+ "v=0\r\ns=-\r\nm=audio 9 RTP/SAVPF 8\r\na=mid:0\r\na=rtpmap:8 PCMA/8000\r\n"
109
+ "a=extmap:9 urn:ietf:params:rtp-hdrext:ssrc-audio-level\r\n"
110
+ "m=video 9 RTP/SAVPF 97\r\na=mid:1\r\na=rtpmap:97 H265/90000\r\n"
111
+ )
112
+ out = compress_sdp_req("dev", sdp)
113
+ assert out.count(EXT_TRANSPORT_CC) == 1 and out.count(EXT_FRAME_TIME) == 1
114
+ assert "ssrc-audio-level" not in out
115
+ # A live offer names neither, and comes out as it always did.
116
+ assert "extmap" not in compress_sdp_req("dev", sdp.replace("a=extmap", "a=x"))
117
+
118
+
119
+ class _Sock:
120
+ def __init__(self):
121
+ self.sent = []
122
+
123
+ def sendto(self, pkt, addr):
124
+ self.sent.append(pkt)
125
+
126
+
127
+ def test_the_hold_reads_the_clock_and_forwards_nothing_after_the_end():
128
+ sock, lines = _Sock(), []
129
+ hold = _MediaHold()
130
+ hold.release()
131
+ hold.send(sock, _rtp(97, 1, 1000, T0), 5000, log=lines.append)
132
+ hold.send(sock, _rtp(97, 2, 5500, T0 + 200), 5000, log=lines.append)
133
+ for seq in (3, 4):
134
+ hold.send(sock, _rtp(97, seq, 0, FRAME_TIME_END), 5000, log=lines.append)
135
+ assert len(sock.sent) == 2
136
+ assert hold.clock.ended
137
+ assert lines.count("bridge: the camera marked the end of the recording") == 1
138
+
139
+
140
+ def test_a_playback_serve_keeps_the_camera_s_timestamps():
141
+ from aidot_cameras.camera import client
142
+
143
+ def argv(**kw):
144
+ return client._build_sdes_serve_cmd(
145
+ sdp_path="in.sdp", output_path="out.ts", **kw
146
+ )
147
+
148
+ if not client._SERVE_ARRIVAL_TS:
149
+ pytest.skip("arrival stamping is off in this environment")
150
+ assert "-use_wallclock_as_timestamps" in argv()
151
+ assert "-use_wallclock_as_timestamps" not in argv(keep_probe_packets=True)
152
+
153
+
154
+ @pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="needs ffmpeg")
155
+ def test_the_retime_command_stretches_video_and_places_the_data_stream(tmp_path):
156
+ src, dst = tmp_path / "in.ts", tmp_path / "out.ts"
157
+ subprocess.run(
158
+ [
159
+ "ffmpeg",
160
+ "-loglevel",
161
+ "error",
162
+ "-y",
163
+ "-f",
164
+ "lavfi",
165
+ "-i",
166
+ "testsrc=size=64x64:rate=20:duration=3",
167
+ "-f",
168
+ "lavfi",
169
+ "-i",
170
+ "sine=frequency=440:sample_rate=8000:duration=3",
171
+ "-c:v",
172
+ "libx264",
173
+ "-c:a",
174
+ "pcm_alaw",
175
+ "-f",
176
+ "mpegts",
177
+ str(src),
178
+ ],
179
+ check=True,
180
+ )
181
+ timing = {"video": (4.0, 0.0), "audio": (1.0, 2.0)}
182
+ subprocess.run(retime_argv("ffmpeg", str(src), str(dst), timing), check=True)
183
+ out = subprocess.run(
184
+ [
185
+ "ffprobe",
186
+ "-v",
187
+ "error",
188
+ "-show_entries",
189
+ "stream=codec_type,start_time,duration",
190
+ "-of",
191
+ "csv=p=0",
192
+ str(dst),
193
+ ],
194
+ capture_output=True,
195
+ text=True,
196
+ check=True,
197
+ ).stdout.split()
198
+ rows = {r.split(",")[0]: [float(x) for x in r.split(",")[1:]] for r in out}
199
+ assert rows["video"][1] == pytest.approx(12.0, abs=0.3) # 3 s x4
200
+ # MPEG-TS has no type for A-law: like an SDES capture, it is a data stream.
201
+ sound = rows.get("audio") or rows["data"]
202
+ assert sound[0] - rows["video"][0] == pytest.approx(2.0, abs=0.1)
203
+ assert os.path.getsize(dst) > 0
204
+
205
+
206
+ def test_a_stray_second_video_type_does_not_decide_the_timing():
207
+ clock = PlaybackClock()
208
+ for i in range(50):
209
+ clock.observe(_rtp(97, i, 1000 + i * 4500, T0 + i * 200))
210
+ for i in range(3): # a few packets of another type, its own pace
211
+ clock.observe(_rtp(96, i, 5000 + i * 90000, T0 + i * 1000))
212
+ assert clock.timing()["video"][0] == pytest.approx(4.0)
213
+
214
+
215
+ def test_the_offer_finds_sections_by_media_type_not_by_mid():
216
+ sdp = (
217
+ "v=0\r\nm=video 9 RTP/SAVPF 97\r\na=mid:20\r\n"
218
+ "m=audio 9 RTP/SAVPF 8\r\na=mid:video\r\n"
219
+ "m=application 9 SCTP webrtc-datachannel\r\na=mid:1\r\n"
220
+ )
221
+ out = offer_with_frame_times(sdp)
222
+ assert out.count(f"a=extmap:{EXT_FRAME_TIME_ID}") == 2
223
+ assert "a=rtcp-fb:97 transport-cc" in out
224
+ assert "a=rtcp-fb:8 transport-cc" not in out
225
+ assert out.split("m=application")[1].count("extmap") == 0