python-aidot-cameras 1.0.0b7__tar.gz → 1.0.0b8__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 (234) hide show
  1. {python_aidot_cameras-1.0.0b7/python_aidot_cameras.egg-info → python_aidot_cameras-1.0.0b8}/PKG-INFO +1 -1
  2. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/camera/protocol.py +118 -0
  3. python_aidot_cameras-1.0.0b8/aidot_cameras/camera/sd_playback.py +112 -0
  4. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/camera/webrtc_open.py +91 -14
  5. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/pyproject.toml +1 -1
  6. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  7. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/python_aidot_cameras.egg-info/SOURCES.txt +3 -0
  8. python_aidot_cameras-1.0.0b8/tests/test_answer_section_selection.py +189 -0
  9. python_aidot_cameras-1.0.0b8/tests/test_sd_playback.py +107 -0
  10. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/LICENSE +0 -0
  11. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/MANIFEST.in +0 -0
  12. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/README.md +0 -0
  13. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/__init__.py +0 -0
  14. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/__main__.py +0 -0
  15. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_upstream.py +0 -0
  16. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/__init__.py +0 -0
  17. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/__init__.py +0 -0
  18. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/clock.py +0 -0
  19. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/codecs/__init__.py +0 -0
  20. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/codecs/base.py +0 -0
  21. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/codecs/g711.py +0 -0
  22. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/codecs/g722.py +0 -0
  23. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/codecs/h264.py +0 -0
  24. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/codecs/opus.py +0 -0
  25. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/codecs/vpx.py +0 -0
  26. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/contrib/__init__.py +0 -0
  27. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/contrib/media.py +0 -0
  28. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/contrib/signaling.py +0 -0
  29. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/events.py +0 -0
  30. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/exceptions.py +0 -0
  31. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/jitterbuffer.py +0 -0
  32. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/mediastreams.py +0 -0
  33. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/rate.py +0 -0
  34. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/rtcconfiguration.py +0 -0
  35. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/rtcdatachannel.py +0 -0
  36. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/rtcdtlstransport.py +0 -0
  37. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/rtcicetransport.py +0 -0
  38. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/rtcpeerconnection.py +0 -0
  39. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/rtcrtpparameters.py +0 -0
  40. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/rtcrtpreceiver.py +0 -0
  41. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/rtcrtpsender.py +0 -0
  42. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/rtcrtptransceiver.py +0 -0
  43. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/rtcsctptransport.py +0 -0
  44. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/rtcsessiondescription.py +0 -0
  45. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/rtp.py +0 -0
  46. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/sdp.py +0 -0
  47. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/stats.py +0 -0
  48. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/utils.py +0 -0
  49. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/ble_gateway.py +0 -0
  50. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/camera/__init__.py +0 -0
  51. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/camera/client.py +0 -0
  52. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/camera/constants.py +0 -0
  53. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/camera/controls.py +0 -0
  54. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/camera/go2rtc.py +0 -0
  55. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/camera/hwaccel.py +0 -0
  56. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/camera/lan_control.py +0 -0
  57. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/camera/models.py +0 -0
  58. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/camera/playback.py +0 -0
  59. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/camera/sd_events.py +0 -0
  60. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/camera/sd_listing.py +0 -0
  61. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/camera/sdes.py +0 -0
  62. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/camera/sdes_open.py +0 -0
  63. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/camera/tutk.py +0 -0
  64. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/camera/webrtc.py +0 -0
  65. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/client.py +0 -0
  66. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/const.py +0 -0
  67. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/credentials.py +0 -0
  68. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/crypto.py +0 -0
  69. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/device_client.py +0 -0
  70. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/discover.py +0 -0
  71. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/exceptions.py +0 -0
  72. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/g711.py +0 -0
  73. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/lan_retry.py +0 -0
  74. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/aidot_cameras/py.typed +0 -0
  75. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  76. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
  77. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/python_aidot_cameras.egg-info/requires.txt +0 -0
  78. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/python_aidot_cameras.egg-info/top_level.txt +0 -0
  79. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/setup.cfg +0 -0
  80. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_aioice_compat.py +0 -0
  81. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_alarm_event.py +0 -0
  82. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_answer_sdp_video_pt.py +0 -0
  83. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_audio_mux_offline.py +0 -0
  84. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_avio_request.py +0 -0
  85. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_avio_response_parse.py +0 -0
  86. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_avio_response_router.py +0 -0
  87. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_backoff.py +0 -0
  88. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_battery_detection.py +0 -0
  89. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_battery_evidence_corroboration.py +0 -0
  90. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_battery_skip_turn.py +0 -0
  91. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_bitrate_campaign_arms.py +0 -0
  92. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_ble_gateway.py +0 -0
  93. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_bridge_state_sentinels.py +0 -0
  94. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_busy_backoff.py +0 -0
  95. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_cloud_count.py +0 -0
  96. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_cloud_plan.py +0 -0
  97. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_constants_imports_resolve.py +0 -0
  98. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_control_verdicts.py +0 -0
  99. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_deferred_hardening.py +0 -0
  100. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_device_cmd_seq_match.py +0 -0
  101. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_device_login_guard.py +0 -0
  102. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_device_user_info_cache.py +0 -0
  103. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_dormant_camera_cleanup.py +0 -0
  104. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_dtls_mux_terminal_write.py +0 -0
  105. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_dtls_not_ready_burst.py +0 -0
  106. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_dtls_pinning.py +0 -0
  107. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_dtls_resolution_ack.py +0 -0
  108. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_dtls_serve_decode_skip.py +0 -0
  109. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_dtls_serve_open_timeout.py +0 -0
  110. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_dtls_skip_signaling_wait.py +0 -0
  111. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_dtls_slow_probe.py +0 -0
  112. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_dtls_stdout_producer.py +0 -0
  113. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_egress_guard.py +0 -0
  114. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_env_seams.py +0 -0
  115. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_feature_probe_needs_a_live_session.py +0 -0
  116. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_futile_keepalive.py +0 -0
  117. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_gate_requires_a_decoded_frame.py +0 -0
  118. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_go2rtc.py +0 -0
  119. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_go2rtc_cli.py +0 -0
  120. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_h264_decode_rate_limit.py +0 -0
  121. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_highport_nomination.py +0 -0
  122. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_hwaccel_probe.py +0 -0
  123. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_ice_config_cache.py +0 -0
  124. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_ice_peer_reflexive.py +0 -0
  125. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_idle_release_both_loops.py +0 -0
  126. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_keepalive_renew.py +0 -0
  127. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_key_restart_sdp.py +0 -0
  128. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_keyframe_prompter.py +0 -0
  129. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_lan_control.py +0 -0
  130. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_lan_retry_reaches_the_storming_devices.py +0 -0
  131. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_light_active_color_mode.py +0 -0
  132. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_live_session_carries_talk.py +0 -0
  133. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_live_stream_param.py +0 -0
  134. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_live_validate_policy.py +0 -0
  135. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_logging_caps.py +0 -0
  136. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_login_connect_deadline.py +0 -0
  137. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_login_reconnect_backoff.py +0 -0
  138. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_media_counted_only_when_plaintext.py +0 -0
  139. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_media_socket_rcvbuf.py +0 -0
  140. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_media_wait_floor.py +0 -0
  141. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_motion_poll.py +0 -0
  142. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_mqtt_credential_refetch.py +0 -0
  143. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_mqtt_credential_self_heal.py +0 -0
  144. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_mqtt_password_not_persisted.py +0 -0
  145. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_mqtt_session_reconnect.py +0 -0
  146. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_narrow_pc_ice.py +0 -0
  147. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_no_key_material_in_logs.py +0 -0
  148. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_no_self_referential_go2rtc.py +0 -0
  149. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_no_undefined_names.py +0 -0
  150. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_offer_keeps_rtcp_feedback.py +0 -0
  151. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_offline_keepalive_pause.py +0 -0
  152. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_open_fail_logger.py +0 -0
  153. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_open_gate_delay.py +0 -0
  154. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_peerid_reuse.py +0 -0
  155. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_persistent_mqtt.py +0 -0
  156. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_persistent_mqtt_robustness.py +0 -0
  157. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_playback_tls.py +0 -0
  158. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_post_merge_hardening.py +0 -0
  159. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_ptz_probe_reads_what_it_is_told.py +0 -0
  160. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_quality_campaign_in_session.py +0 -0
  161. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_recent_recordings.py +0 -0
  162. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_remb.py +0 -0
  163. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_resolution_persists.py +0 -0
  164. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_restart_cycle.py +0 -0
  165. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_retry_policy.py +0 -0
  166. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_scripts_importable.py +0 -0
  167. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_sctp_init_ack_tsn.py +0 -0
  168. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_sd_card_presence.py +0 -0
  169. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_sd_event_decode.py +0 -0
  170. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_sd_event_probe_payloads.py +0 -0
  171. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_sd_listing.py +0 -0
  172. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_sdes_adaptive.py +0 -0
  173. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_sdes_avio_dispatch.py +0 -0
  174. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_sdes_bridge_break_gate.py +0 -0
  175. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_sdes_echo_wait_timeout.py +0 -0
  176. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_sdes_fast_liveplay.py +0 -0
  177. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_sdes_ffmpeg_stderr_log.py +0 -0
  178. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_sdes_first_media_stall_report.py +0 -0
  179. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_sdes_idle_release.py +0 -0
  180. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_sdes_offer_video_order.py +0 -0
  181. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_sdes_offer_video_pin.py +0 -0
  182. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_sdes_open_cleanup.py +0 -0
  183. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_sdes_sctp_abort.py +0 -0
  184. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_sdes_serve_audio.py +0 -0
  185. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_sdes_serve_cmd.py +0 -0
  186. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_sdes_serve_stderr_drain.py +0 -0
  187. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_sdes_sprop.py +0 -0
  188. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_sdes_talk.py +0 -0
  189. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_sdes_teardown_exit_log.py +0 -0
  190. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_sdes_terminal_ack.py +0 -0
  191. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_sdes_terminal_ack_latency.py +0 -0
  192. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_sdes_watchdog.py +0 -0
  193. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_sdp_narrowing_properties.py +0 -0
  194. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_sdp_payload_type_narrowing.py +0 -0
  195. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_security_hardening.py +0 -0
  196. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_self_peer_is_an_address_not_an_ip.py +0 -0
  197. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_serializable_login_info.py +0 -0
  198. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_serve_loss_tolerance.py +0 -0
  199. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_serve_relay.py +0 -0
  200. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_session_stats.py +0 -0
  201. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_shared_house_seam.py +0 -0
  202. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_snapshot_cancellation.py +0 -0
  203. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_speak.py +0 -0
  204. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_sprop_capture_gate.py +0 -0
  205. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_sprop_staleness.py +0 -0
  206. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_sprop_write_is_reported_truthfully.py +0 -0
  207. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_srtp_rx_rekey.py +0 -0
  208. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_srtp_tx_key_note.py +0 -0
  209. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_stall_report_says_what_the_answer_carried.py +0 -0
  210. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_stall_report_says_whether_the_trigger_was_answered.py +0 -0
  211. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_stall_report_separates_silence_from_undecryptable.py +0 -0
  212. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_stall_report_survives_a_caller_giving_up.py +0 -0
  213. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_stall_reports_reach_the_artifact.py +0 -0
  214. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_stream_cap.py +0 -0
  215. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_stream_drain_ownership.py +0 -0
  216. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_stream_idle.py +0 -0
  217. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_stream_slot_cap.py +0 -0
  218. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_stream_teardown.py +0 -0
  219. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_talk.py +0 -0
  220. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_talk_reports_a_speaker_that_never_opened.py +0 -0
  221. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_terminal_ack.py +0 -0
  222. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_token_file_robustness.py +0 -0
  223. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_token_refresh.py +0 -0
  224. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_turn_create_permission.py +0 -0
  225. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_tutk_path_is_out_of_reach.py +0 -0
  226. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_upstream_compat.py +0 -0
  227. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_video_profile_log.py +0 -0
  228. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_viewer_check_throttle.py +0 -0
  229. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_viewer_presence.py +0 -0
  230. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_viewer_present_push_mode.py +0 -0
  231. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_wait_or_event.py +0 -0
  232. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_wake_gate_camera_evidence.py +0 -0
  233. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/tests/test_wake_readiness_retry.py +0 -0
  234. {python_aidot_cameras-1.0.0b7 → python_aidot_cameras-1.0.0b8}/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.0b7
3
+ Version: 1.0.0b8
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
@@ -243,6 +243,124 @@ def _section_of(lines: list) -> str:
243
243
  return ""
244
244
 
245
245
 
246
+ #: How long to wait for ICE when the camera's answer arrived with its mids
247
+ #: shifted. Measured over 63 opens on an A000088: every successful open
248
+ #: completed ICE between 8.7 s and 10.6 s, and every failure ran the full 45 s
249
+ #: deadline. There is no overlap between the two bands, so a cap sitting above
250
+ #: the success band cannot cost a session that was going to work.
251
+ SHIFTED_ANSWER_ICE_TIMEOUT_S = 15.0
252
+
253
+
254
+ def answer_inserted_a_section(*, shifted_sections: int,
255
+ unclaimed_answer_mids: int) -> bool:
256
+ """Did the camera ADD an m-section, rather than drop or reorder one?
257
+
258
+ Both shapes shift our mids, and only one of them is the behaviour the ICE
259
+ cap was measured against. An A000088 that inserts its H265 video section
260
+ leaves that section unclaimed after the walk, because nothing in our offer
261
+ wanted it. An A001064 that drops a rejected section and renumbers the rest -
262
+ quirk 1 of this rebuild - shifts the mids just as much and leaves nothing
263
+ over.
264
+
265
+ The distinction is load-bearing: the cap comes from 63 opens on a mains
266
+ A000088 that connects in 9 s, and an SDES camera reaching this path through
267
+ the SDES->DTLS fallback can take 25-70 s to open cold. Cutting its deadline
268
+ to 15 s on a shape that was never measured would break exactly the streaming
269
+ this must not break.
270
+ """
271
+ return shifted_sections > 0 and unclaimed_answer_mids > 0
272
+
273
+
274
+ def ice_wait_timeout(*, shifted_sections: int, default_timeout: float) -> float:
275
+ """How long to give ICE, given what the camera's answer looked like.
276
+
277
+ A shifted answer - one where the camera added its H265 video section and
278
+ pushed every later mid along - is a strong but NOT certain sign the session
279
+ is not coming up: 6 of 7 measured shifts failed at ICE, and the seventh
280
+ established in 8.8 s. So it must still be given room to succeed, just not
281
+ 45 s of it.
282
+
283
+ 45 s matters because it is past the ~30 s deadline Home Assistant's stream
284
+ worker allows, which is the failure this project already learned the hard
285
+ way. Capping at 15 s keeps the one-in-seven that works, fails the rest
286
+ faster than the old behaviour did, and stays inside that deadline either
287
+ way.
288
+
289
+ Never extends a caller's own timeout - a caller asking for 5 s means 5 s.
290
+ """
291
+ if shifted_sections > 0:
292
+ return min(default_timeout, SHIFTED_ANSWER_ICE_TIMEOUT_S)
293
+ return default_timeout
294
+
295
+
296
+ def select_answer_section(
297
+ offer_mid: str,
298
+ offer_kind: str,
299
+ ans_sections: dict,
300
+ offer_video_pts: set,
301
+ claimed: set,
302
+ ) -> "Optional[tuple]":
303
+ """Which answer section fills this offer m-section, or None to stub it.
304
+
305
+ ``ans_sections`` maps ``a=mid`` -> ``(kind, lines)``. ``claimed`` holds the
306
+ answer mids already given to earlier offer sections, so one section is never
307
+ handed out twice. Returns ``(answer_mid, lines)``.
308
+
309
+ Same mid first: that is what a compliant answerer sends, and the A001064
310
+ quirks this rebuild exists for (dropped sections, swapped kinds) are all
311
+ expressed in mid terms.
312
+
313
+ The fallback is for the case the mid walk cannot express. An A000088 was
314
+ measured answering an SD-card offer with an extra ``m=video`` carrying
315
+ ``a=rtpmap:0 H265/90000`` at mid 0, which shifts every later mid by one.
316
+ Matching by mid then puts H265 in our video slot - and aiortc has no H265
317
+ in its codec registry, so setRemoteDescription fails outright rather than
318
+ degrading. On a live offer the same shift makes every mid mismatch on kind
319
+ and the whole answer gets stubbed, which reads as a camera declining media.
320
+ The camera does not add that section every time, so both failures are
321
+ intermittent.
322
+
323
+ So when the same-mid section is the wrong kind, or is a video section
324
+ sharing no payload type with our offer, the other unclaimed sections of the
325
+ right kind are considered and the video one with the most payload-type
326
+ overlap wins. A video section with no overlap is still preferred over
327
+ stubbing: a section aiortc may reject beats one that cannot possibly work.
328
+ """
329
+ def _pts(entry) -> set:
330
+ for ln in entry[1]:
331
+ if ln.startswith("m="):
332
+ return set(ln.split()[3:])
333
+ return set()
334
+
335
+ def _usable(entry) -> bool:
336
+ if entry[0] != offer_kind:
337
+ return False
338
+ # Payload types only discriminate video, and only when we know ours.
339
+ if offer_kind != "video" or not offer_video_pts:
340
+ return True
341
+ return bool(_pts(entry) & offer_video_pts)
342
+
343
+ same = ans_sections.get(offer_mid)
344
+ if same is not None and offer_mid not in claimed and _usable(same):
345
+ return (offer_mid, same[1])
346
+
347
+ candidates = [
348
+ (mid, entry) for mid, entry in ans_sections.items()
349
+ if mid not in claimed and entry[0] == offer_kind
350
+ ]
351
+ if not candidates:
352
+ return None
353
+ if offer_kind == "video" and offer_video_pts:
354
+ mid, entry = max(
355
+ candidates, key=lambda c: (len(_pts(c[1]) & offer_video_pts), c[0])
356
+ )
357
+ return (mid, entry[1])
358
+ # Non-video, or nothing to score with: the earliest unclaimed one of the
359
+ # right kind, which for a shifted answer is the shifted section.
360
+ mid, entry = min(candidates, key=lambda c: c[0])
361
+ return (mid, entry[1])
362
+
363
+
246
364
  def _compress_sdp_for_camera(sdp: str) -> str:
247
365
  """Selective SDP filter matching official Java client's g.b() behaviour.
248
366
 
@@ -0,0 +1,112 @@
1
+ """Ask a camera to play a recording off its own SD card.
2
+
3
+ The control surface only - the request bytes and the reply decode. Opening a
4
+ session and delivering frames is `_CameraSdPlaybackMixin`, added separately, so
5
+ this half can be tested with no camera in the way.
6
+
7
+ The request shape comes from the vendor app rather than from guesswork - but
8
+ from a specific overload of it. The app calls
9
+ `AVIOCTRLDEFs$SMsgAVIoctrlPlayRecord.parseContent(IIIIJII)`, not the
10
+ `(III[B)` overload this module first assumed, and the two disagree about the
11
+ last four bytes. `parseContent(IIIIJII)` allocates 0x18 bytes and fills:
12
+ command at offset 4 and an 8-byte STimeDay at offset 12, both as first
13
+ assumed, but offset 0 and offset 8 are ALWAYS 0 (not a caller-chosen channel),
14
+ and the trailing two bytes at offsets 20-21 carry the real channel and a
15
+ second caller value. The channel comes from `KVSWebRTCChannel.sdRecordPlay`,
16
+ which allocates it as `sdChannelId += 1` wrapping 26 -> 1 before every play -
17
+ so a real request never carries channel 0.
18
+ """
19
+ import struct
20
+ from typing import Any, NamedTuple, Optional
21
+
22
+ RECORD_PLAYCONTROL_REQ_CMD = 0x31A
23
+ RECORD_PLAYCONTROL_RESP_CMD = 0x31B
24
+
25
+ # Only START and STOP have been sent to a camera and answered (measured
26
+ # 2026-08-13, A000088). PAUSE, STEPFORWARD, STEPBACKWARD, FORWARD, BACKWARD,
27
+ # SEEKTIME, END and RESUME are real values from the same vendor enum, kept
28
+ # here for completeness, but nobody should assume any of them are proven
29
+ # against hardware.
30
+ PLAY_PAUSE = 0x00
31
+ PLAY_STOP = 0x01
32
+ PLAY_STEPFORWARD = 0x02
33
+ PLAY_STEPBACKWARD = 0x03
34
+ PLAY_FORWARD = 0x04
35
+ PLAY_BACKWARD = 0x05
36
+ PLAY_SEEKTIME = 0x06
37
+ PLAY_END = 0x07
38
+ PLAY_START = 0x10
39
+ PLAY_RESUME = 0x11
40
+
41
+ _PAYLOAD_LEN = 0x18
42
+ # Unlike _PAYLOAD_LEN, this is not read off the vendor encoder - it rests on
43
+ # one live capture, on an A000088, 2026-08-13. Treat it as less certain than
44
+ # the request shape above.
45
+ _REPLY_LEN = 20
46
+
47
+ _MIN_SD_CHANNEL = 1
48
+ _MAX_SD_CHANNEL = 26
49
+
50
+
51
+ class PlaycontrolReply(NamedTuple):
52
+ """What the camera said back.
53
+
54
+ ``field1`` and ``field2`` are deliberately not named. Measured 2026-08-13:
55
+ ``field1`` read 0 with the camera free and 1 while another viewer held a
56
+ live session, so it is NOT a success code; ``field2`` read 60 on every run,
57
+ but every run used the same record. Naming either would publish a guess.
58
+ """
59
+
60
+ command: int
61
+ field1: int
62
+ field2: int
63
+ year: int
64
+ month: int
65
+ day: int
66
+ hour: int
67
+ minute: int
68
+ second: int
69
+
70
+
71
+ def playcontrol_payload(command: int, record: Any, *, sd_channel: int = 1,
72
+ param: int = 0) -> bytes:
73
+ """The 24 bytes for one playback command against one record.
74
+
75
+ ``sd_channel`` must be 1..26, the range `sdRecordPlay` actually allocates.
76
+ A value outside it is a caller bug, not a protocol surprise, and is
77
+ rejected rather than clamped: a silently changed channel would send a
78
+ request the camera accepts and answers while returning no media - the
79
+ exact symptom this module exists to avoid reproducing.
80
+ """
81
+ if not _MIN_SD_CHANNEL <= sd_channel <= _MAX_SD_CHANNEL:
82
+ raise ValueError(
83
+ f"sd_channel must be {_MIN_SD_CHANNEL}..{_MAX_SD_CHANNEL}, "
84
+ f"got {sd_channel}")
85
+ # Offsets 0 and 8 are always 0 in the vendor app's own call - neither is a
86
+ # caller-chosen channel or param, whatever the (III[B) overload implies.
87
+ head = struct.pack("<III", 0, command, 0)
88
+ when = struct.pack(
89
+ "<HBBBBBB",
90
+ record.year, record.month, record.day,
91
+ 0, # wday - the camera fills its own
92
+ record.hour, record.minute, record.second,
93
+ )
94
+ tail = struct.pack("<BB", sd_channel, param)
95
+ return (head + when + tail
96
+ + b"\x00" * (_PAYLOAD_LEN - len(head) - len(when) - len(tail)))
97
+
98
+
99
+ def decode_playcontrol_reply(data: Optional[bytes]) -> Optional[PlaycontrolReply]:
100
+ """Read a 0x31b reply, or None if it cannot be read.
101
+
102
+ None rather than an exception, for the same reason the listing decode does
103
+ it: a reply with an unexpected shape is entirely ordinary and must not
104
+ surface as a traceback in a media browser.
105
+ """
106
+ if not data or len(data) < _REPLY_LEN:
107
+ return None
108
+ command, field1, field2 = struct.unpack("<III", data[:12])
109
+ year, month, day, _wday, hour, minute, second = struct.unpack(
110
+ "<HBBBBBB", data[12:20])
111
+ return PlaycontrolReply(command, field1, field2,
112
+ year, month, day, hour, minute, second)
@@ -34,6 +34,9 @@ from .protocol import (
34
34
  _normalize_bundle_ice_credentials,
35
35
  _reorder_m_section_ice_attrs,
36
36
  _sdp_transport,
37
+ answer_inserted_a_section,
38
+ ice_wait_timeout,
39
+ select_answer_section,
37
40
  _terminal_webrtc_ack,
38
41
  _upgrade_sctp,
39
42
  _webrtc_consume_video,
@@ -2558,6 +2561,13 @@ class _WebRTCOpenMixin:
2558
2561
  _rr_webrtc_resp_topic: str = ""
2559
2562
  _rr_webrtc_resp_payload: str = ""
2560
2563
  _rr_ice_payloads: list = [] # list of (topic, json_str) tuples
2564
+ # How many answer sections came from a mid other than the offer's. Set
2565
+ # by the normal path's rebuild below; declared here so the ICE wait can
2566
+ # read it whichever path ran.
2567
+ _shifted_picks = 0
2568
+ # True only when the camera ADDED a section, which is the shape the
2569
+ # ICE cap was measured against.
2570
+ _answer_inserted = False
2561
2571
 
2562
2572
  if camera_offer_fut in _rr_done and answer_fut not in _rr_done:
2563
2573
  # ---- ROLE REVERSAL path (e.g. LK.IPC.A001064) ---------------------- #
@@ -3037,14 +3047,52 @@ class _WebRTCOpenMixin:
3037
3047
  # 4-section BUNDLE is aligned) but we only decode H264 and audio.
3038
3048
  _h265_offer_pts: set = set() # H265 no longer offered; camera won't answer H265 PTs
3039
3049
 
3050
+ # Our offer's video payload types, used both here (to tell the
3051
+ # camera's real video answer from an H265 section it added) and
3052
+ # again by _aiortc_answer below.
3053
+ _offer_video_pts: set = set()
3054
+ for _ol in _offer_sdp.splitlines():
3055
+ if _ol.startswith('m=video'):
3056
+ _offer_video_pts = set(_ol.split()[3:])
3057
+ break
3058
+
3040
3059
  _rebuilt: list = list(_ans_header)
3041
3060
  _kept_count = 0
3042
3061
  _stub_count = 0
3043
3062
  _dropped_mids: list = []
3063
+ _claimed_ans_mids: set = set()
3044
3064
  _rejected_mids: set = set() # mids whose stub has port=0 (excluded from BUNDLE)
3045
3065
  for _m in _offer_mids:
3046
3066
  _expected_kind = _offer_kinds[_m]
3047
- _ans_kind, _ans_block = _ans_sections.get(_m, ("", []))
3067
+ # Same mid first, then kind + payload-type overlap. This camera
3068
+ # sometimes adds an H265 video section at mid 0 and shifts every
3069
+ # later mid by one; matching on mid alone then puts H265 in our
3070
+ # video slot (hard setRemoteDescription failure) or mismatches
3071
+ # every kind at once (reads as declined media). See
3072
+ # select_answer_section.
3073
+ _pick = select_answer_section(
3074
+ _m, _expected_kind, _ans_sections,
3075
+ _offer_video_pts, _claimed_ans_mids,
3076
+ )
3077
+ if _pick is not None:
3078
+ _claimed_ans_mids.add(_pick[0])
3079
+ if _pick[0] != _m:
3080
+ _shifted_picks += 1
3081
+ _status(
3082
+ f"answer mid {_pick[0]} taken for offer mid {_m}"
3083
+ f" ({_expected_kind}): the camera shifted its mids"
3084
+ )
3085
+ _ans_kind = _expected_kind if _pick is not None else ""
3086
+ _ans_block = list(_pick[1]) if _pick is not None else []
3087
+ if _pick is not None and _pick[0] != _m:
3088
+ # Renumber to the slot it now occupies. _aiortc_answer
3089
+ # renumbers again downstream, but leaving a section
3090
+ # carrying another slot's a=mid inside _rebuilt would make
3091
+ # the intermediate SDP disagree with its own BUNDLE line.
3092
+ _ans_block = [
3093
+ f'a=mid:{_m}' if _bl.startswith('a=mid:') else _bl
3094
+ for _bl in _ans_block
3095
+ ]
3048
3096
  # Force-stub H265 sections: detect by camera's answer m-line
3049
3097
  # using only our H265 PTs (103/104).
3050
3098
  _ans_h265 = False
@@ -3070,9 +3118,19 @@ class _WebRTCOpenMixin:
3070
3118
  _stub_count += 1
3071
3119
  if _s and _s[0].split()[1] == '0':
3072
3120
  _rejected_mids.add(_m)
3073
- for _ans_mid in _ans_sections:
3074
- if _ans_mid not in _offer_kinds:
3075
- _dropped_mids.append(_ans_mid)
3121
+ # Sections the walk never took. An INSERTED section (the camera's
3122
+ # H265 one) ends up here; a dropped-and-renumbered answer leaves
3123
+ # nothing. That is what separates the shape the ICE cap was
3124
+ # measured on from the one it was not - see
3125
+ # answer_inserted_a_section.
3126
+ _unclaimed_ans_mids = [
3127
+ _am for _am in _ans_sections if _am not in _claimed_ans_mids
3128
+ ]
3129
+ _answer_inserted = answer_inserted_a_section(
3130
+ shifted_sections=_shifted_picks,
3131
+ unclaimed_answer_mids=len(_unclaimed_ans_mids),
3132
+ )
3133
+ _dropped_mids.extend(_unclaimed_ans_mids)
3076
3134
 
3077
3135
  if _stub_count or _dropped_mids:
3078
3136
  _status(
@@ -3196,13 +3254,8 @@ class _WebRTCOpenMixin:
3196
3254
  # bogus single-PT video stub - all of which break aiortc's positional
3197
3255
  # matching. _aiortc_answer() below selects by content and re-emits.
3198
3256
  import re as _re_ans # module has no top-level `import re`
3199
- # Our offer's video payload types (H264 + RTX), used to pick the
3200
- # camera's REAL video answer section apart from H265/bogus stubs.
3201
- _offer_video_pts: set = set()
3202
- for _ol in _offer_sdp.splitlines():
3203
- if _ol.startswith('m=video'):
3204
- _offer_video_pts = set(_ol.split()[3:])
3205
- break
3257
+ # _offer_video_pts (our H264 + RTX payload types) is computed above,
3258
+ # where the mid walk needs it, and reused here.
3206
3259
 
3207
3260
  # Set by _aiortc_answer when the camera returns a DC-only answer
3208
3261
  # (both audio and video rejected = media declined, encoder cold).
@@ -3618,7 +3671,19 @@ class _WebRTCOpenMixin:
3618
3671
  async def _on_ice_gather() -> None:
3619
3672
  _LOGGER.info("webrtc: ICE gatheringState -> %s", pc.iceGatheringState)
3620
3673
 
3621
- deadline = time.monotonic() + timeout
3674
+ # A shifted answer gets a shorter ICE deadline, not a refusal: 6 of 7
3675
+ # measured shifts never connected, but the seventh came up in 8.8 s.
3676
+ # See ice_wait_timeout for the measurement behind the cap.
3677
+ _ice_timeout = ice_wait_timeout(
3678
+ shifted_sections=1 if _answer_inserted else 0,
3679
+ default_timeout=timeout,
3680
+ )
3681
+ if _ice_timeout != timeout:
3682
+ _status(
3683
+ f"answer shifted mids: ICE deadline capped to {_ice_timeout:.0f}s"
3684
+ f" (from {timeout:.0f}s)"
3685
+ )
3686
+ deadline = time.monotonic() + _ice_timeout
3622
3687
  _last_ice_log = time.monotonic()
3623
3688
  _userconnect_midloop_sent = False # guard: re-send user/connect once at half-timeout
3624
3689
  _second_ans_processed = False # guard: process second_answer_fut candidates once
@@ -3719,11 +3784,15 @@ class _WebRTCOpenMixin:
3719
3784
  # Mid-loop user/connect retry: if no camera IP yet and ~half of the
3720
3785
  # ICE timeout has elapsed, re-announce user presence so the camera
3721
3786
  # pushes setDevAttrNotif again. Replaces the invented getDevAttrReq.
3787
+ # Halve the deadline this loop is actually running to (_ice_timeout),
3788
+ # not the caller's: a capped wait compared against the uncapped
3789
+ # timeout is already past half on its first tick, which fires the
3790
+ # one-shot re-announce at t=0 and leaves nothing for mid-loop.
3722
3791
  _remaining = deadline - time.monotonic()
3723
3792
  if (not _userconnect_midloop_sent
3724
3793
  and cam_ip_q.empty()
3725
3794
  and _remaining > 0
3726
- and _remaining <= timeout / 2.0):
3795
+ and _remaining <= _ice_timeout / 2.0):
3727
3796
  outgoing_q.put_nowait((
3728
3797
  f"iot/v1/cb/{user_id}/user/connect",
3729
3798
  json.dumps({
@@ -3767,10 +3836,18 @@ class _WebRTCOpenMixin:
3767
3836
  outgoing_q.put_nowait(None)
3768
3837
  _cancel_track_tasks()
3769
3838
  await pc.close()
3839
+ if _answer_inserted:
3840
+ # Before the answer sections were matched by content, a shifted
3841
+ # answer on the live path stubbed every section and raised this
3842
+ # same error, which the caller retries in a fast bounded burst.
3843
+ # Keep that: a shifted answer that did not connect inside the
3844
+ # cap is the camera declining, not a transport fault, and the
3845
+ # generic 15s-gated retry is the wrong response to it.
3846
+ raise AidotCameraNotReady(self.device_id)
3770
3847
  raise RuntimeError(
3771
3848
  f"async_open_webrtc_stream: ICE connection not established "
3772
3849
  f"(connState={pc.connectionState}"
3773
- f" iceState={pc.iceConnectionState}) within {timeout}s"
3850
+ f" iceState={pc.iceConnectionState}) within {_ice_timeout}s"
3774
3851
  )
3775
3852
 
3776
3853
  if recorder:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-aidot-cameras"
7
- version = "1.0.0b7"
7
+ version = "1.0.0b8"
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.0b7
3
+ Version: 1.0.0b8
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
@@ -61,6 +61,7 @@ aidot_cameras/camera/playback.py
61
61
  aidot_cameras/camera/protocol.py
62
62
  aidot_cameras/camera/sd_events.py
63
63
  aidot_cameras/camera/sd_listing.py
64
+ aidot_cameras/camera/sd_playback.py
64
65
  aidot_cameras/camera/sdes.py
65
66
  aidot_cameras/camera/sdes_open.py
66
67
  aidot_cameras/camera/tutk.py
@@ -75,6 +76,7 @@ python_aidot_cameras.egg-info/top_level.txt
75
76
  tests/test_aioice_compat.py
76
77
  tests/test_alarm_event.py
77
78
  tests/test_answer_sdp_video_pt.py
79
+ tests/test_answer_section_selection.py
78
80
  tests/test_audio_mux_offline.py
79
81
  tests/test_avio_request.py
80
82
  tests/test_avio_response_parse.py
@@ -164,6 +166,7 @@ tests/test_sd_card_presence.py
164
166
  tests/test_sd_event_decode.py
165
167
  tests/test_sd_event_probe_payloads.py
166
168
  tests/test_sd_listing.py
169
+ tests/test_sd_playback.py
167
170
  tests/test_sdes_adaptive.py
168
171
  tests/test_sdes_avio_dispatch.py
169
172
  tests/test_sdes_bridge_break_gate.py
@@ -0,0 +1,189 @@
1
+ """Matching the camera's answer sections to the offer's m-sections.
2
+
3
+ The rebuild that feeds aiortc used to match purely by ``a=mid``. That holds
4
+ right up until the camera adds a section the offer never had, which this
5
+ firmware family does: an A000088 was measured on 2026-08-13 answering an
6
+ SD-card offer with
7
+
8
+ m=video ... 0 a=mid:0 a=rtpmap:0 H265/90000
9
+ m=video ... 101 102 a=mid:1 (the H264 section we negotiated)
10
+ m=application ... a=mid:2
11
+
12
+ Every mid is shifted by one. Matching by mid then hands our video slot the
13
+ H265 section and drops the H264 one, and aiortc rejects the answer outright
14
+ ("Failed to set remote video description send parameters") because H265 is not
15
+ in its codec registry. On a live offer the same shift makes every mid mismatch
16
+ on kind, all sections get stubbed, and the open fails as a cold camera would.
17
+
18
+ Mid order is still the right first guess - it is what a compliant answerer
19
+ sends, and the A001064 quirks the mid-walk was written for depend on it. So the
20
+ mid match stays, and the fallback only runs when it produces something
21
+ unusable.
22
+ """
23
+
24
+ import os
25
+ import sys
26
+
27
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
28
+
29
+ from aidot_cameras.camera.protocol import select_answer_section
30
+
31
+ _OFFER_VIDEO_PTS = {"97", "98", "99", "100", "101", "102"}
32
+
33
+
34
+ def _sec(kind: str, *pts: str) -> tuple:
35
+ """One answer section: (kind, lines), as the rebuild stores them."""
36
+ line = f"m={kind} 9 UDP/TLS/RTP/SAVPF " + " ".join(pts)
37
+ return (kind, [line, "a=rtcp-mux", "a=sendonly"])
38
+
39
+
40
+ # The shapes below are transcribed from measured answers, not invented.
41
+ _SD_SHIFTED = {
42
+ "0": _sec("video", "0"), # H265 stub the camera added
43
+ "1": _sec("video", "101", "102"), # the H264 answer we want
44
+ "2": _sec("application"),
45
+ }
46
+
47
+ _LIVE_SHIFTED = {
48
+ "0": _sec("video", "0"),
49
+ "1": _sec("audio", "8"),
50
+ "2": _sec("video", "101", "102"),
51
+ "3": _sec("application"),
52
+ }
53
+
54
+ _LIVE_CLEAN = {
55
+ "0": _sec("audio", "8"),
56
+ "1": _sec("video", "101", "102"),
57
+ "2": _sec("application"),
58
+ }
59
+
60
+
61
+ def test_clean_answer_is_matched_by_mid_unchanged():
62
+ """The common case must not move: mid 1 is the video, and stays it."""
63
+ for mid, kind in (("0", "audio"), ("1", "video"), ("2", "application")):
64
+ got = select_answer_section(mid, kind, _LIVE_CLEAN, _OFFER_VIDEO_PTS, set())
65
+ assert got == (mid, _LIVE_CLEAN[mid][1])
66
+
67
+
68
+ def test_sd_offer_skips_the_h265_stub_sharing_its_mid():
69
+ """Our only video mid is 0, and the camera's mid 0 is its H265 section."""
70
+ got = select_answer_section("0", "video", _SD_SHIFTED, _OFFER_VIDEO_PTS, set())
71
+ assert got is not None
72
+ assert got[0] == "1", "should take the H264 section, not the H265 one"
73
+ assert "101 102" in got[1][0]
74
+
75
+
76
+ def test_sd_datachannel_finds_the_application_section_a_mid_away():
77
+ got = select_answer_section("1", "application", _SD_SHIFTED,
78
+ _OFFER_VIDEO_PTS, {"0"})
79
+ assert got is not None and got[0] == "2"
80
+
81
+
82
+ def test_live_offer_survives_a_one_mid_shift():
83
+ """audio/video/application must each find their section despite the shift."""
84
+ claimed: set = set()
85
+ picks = {}
86
+ for mid, kind in (("0", "audio"), ("1", "video"), ("2", "application")):
87
+ got = select_answer_section(mid, kind, _LIVE_SHIFTED,
88
+ _OFFER_VIDEO_PTS, claimed)
89
+ assert got is not None, f"offer mid {mid} ({kind}) found nothing"
90
+ claimed.add(got[0])
91
+ picks[kind] = got[0]
92
+ assert picks == {"audio": "1", "video": "2", "application": "3"}
93
+
94
+
95
+ def test_a_section_is_never_handed_to_two_offer_mids():
96
+ claimed = {"1"}
97
+ got = select_answer_section("0", "video", _SD_SHIFTED,
98
+ _OFFER_VIDEO_PTS, claimed)
99
+ assert got is None or got[0] != "1"
100
+
101
+
102
+ def test_missing_kind_returns_none_so_the_caller_stubs():
103
+ """A001064 drops sections outright; the caller must still get to stub."""
104
+ answer = {"0": _sec("video", "101", "102")}
105
+ assert select_answer_section("1", "audio", answer,
106
+ _OFFER_VIDEO_PTS, set()) is None
107
+
108
+
109
+ def test_video_with_no_overlap_is_still_taken_when_it_is_all_there_is():
110
+ """Better a section aiortc may reject than a stub that guarantees failure.
111
+
112
+ Losing the real section would turn a recoverable answer into a declined one,
113
+ so a no-overlap video section is preferred over nothing at all.
114
+ """
115
+ answer = {"0": _sec("video", "0")}
116
+ got = select_answer_section("0", "video", answer, _OFFER_VIDEO_PTS, set())
117
+ assert got is not None and got[0] == "0"
118
+
119
+
120
+ def test_no_offer_pts_falls_back_to_plain_mid_matching():
121
+ """With nothing to score against, the mid is the only signal left."""
122
+ got = select_answer_section("0", "video", _SD_SHIFTED, set(), set())
123
+ assert got is not None and got[0] == "0"
124
+
125
+
126
+ # --- how long to wait for ICE, given what the answer looked like -------------
127
+
128
+ from aidot_cameras.camera.protocol import ice_wait_timeout
129
+
130
+
131
+ def test_a_clean_answer_gets_the_full_timeout():
132
+ assert ice_wait_timeout(shifted_sections=0, default_timeout=45.0) == 45.0
133
+
134
+
135
+ def test_a_shifted_answer_is_capped_but_not_refused():
136
+ """One shifted answer in seven DID open - at 8.8 s. The cap must clear it.
137
+
138
+ Measured over 63 opens: every successful open finished ICE between 8.7 s and
139
+ 10.6 s and every failure ran the full 45 s. There is no overlap, so a cap
140
+ above the success band and far below the failure band loses nothing and
141
+ saves 30 s on the 86% that were never going to connect.
142
+ """
143
+ capped = ice_wait_timeout(shifted_sections=3, default_timeout=45.0)
144
+ assert capped < 45.0
145
+ assert capped > 10.6, "must still clear the slowest observed success"
146
+
147
+
148
+ def test_the_cap_never_extends_a_shorter_caller_timeout():
149
+ """A caller asking for 5 s means 5 s, shifted answer or not."""
150
+ assert ice_wait_timeout(shifted_sections=3, default_timeout=5.0) == 5.0
151
+
152
+
153
+ # --- telling an ADDED section from a dropped or reordered one ----------------
154
+
155
+ from aidot_cameras.camera.protocol import answer_inserted_a_section
156
+
157
+
158
+ def test_an_added_section_leaves_one_unclaimed():
159
+ """The measured shape: the camera adds H265 and every later mid moves on.
160
+
161
+ The SD offer has two mids (video 0, application 1); the camera answers with
162
+ three, and the walk claims its video and application sections, leaving the
163
+ H265 one spare. That spare is what says a section was INSERTED.
164
+ """
165
+ assert answer_inserted_a_section(shifted_sections=2, unclaimed_answer_mids=1)
166
+
167
+
168
+ def test_a_dropped_section_is_not_an_insert():
169
+ """A001064 drops rejected sections and renumbers - quirk 1 of the rebuild.
170
+
171
+ Its mids shift too, but nothing is left over. Treating that as the H265
172
+ shape would cut the ICE deadline of a camera whose cold open is 25-70 s,
173
+ on the SDES->DTLS fallback path, using a cap measured on a mains A000088
174
+ that opens in 9 s.
175
+ """
176
+ assert not answer_inserted_a_section(shifted_sections=2,
177
+ unclaimed_answer_mids=0)
178
+
179
+
180
+ def test_a_clean_answer_is_not_an_insert():
181
+ assert not answer_inserted_a_section(shifted_sections=0,
182
+ unclaimed_answer_mids=0)
183
+
184
+
185
+ def test_a_spare_section_without_a_shift_is_not_an_insert():
186
+ """An extra section nobody wanted, with our mids still lining up, is the
187
+ camera padding the answer - not the shift the cap was measured against."""
188
+ assert not answer_inserted_a_section(shifted_sections=0,
189
+ unclaimed_answer_mids=1)