python-aidot-cameras 1.0.0b6__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.0b6/python_aidot_cameras.egg-info → python_aidot_cameras-1.0.0b8}/PKG-INFO +1 -1
  2. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/camera/models.py +42 -0
  3. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/camera/protocol.py +118 -0
  4. python_aidot_cameras-1.0.0b8/aidot_cameras/camera/sd_playback.py +112 -0
  5. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/camera/webrtc_open.py +91 -14
  6. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/pyproject.toml +1 -1
  7. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  8. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/python_aidot_cameras.egg-info/SOURCES.txt +4 -0
  9. python_aidot_cameras-1.0.0b8/tests/test_answer_section_selection.py +189 -0
  10. python_aidot_cameras-1.0.0b8/tests/test_sd_card_presence.py +94 -0
  11. python_aidot_cameras-1.0.0b8/tests/test_sd_playback.py +107 -0
  12. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/LICENSE +0 -0
  13. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/MANIFEST.in +0 -0
  14. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/README.md +0 -0
  15. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/__init__.py +0 -0
  16. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/__main__.py +0 -0
  17. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_upstream.py +0 -0
  18. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/__init__.py +0 -0
  19. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/__init__.py +0 -0
  20. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/clock.py +0 -0
  21. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/codecs/__init__.py +0 -0
  22. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/codecs/base.py +0 -0
  23. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/codecs/g711.py +0 -0
  24. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/codecs/g722.py +0 -0
  25. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/codecs/h264.py +0 -0
  26. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/codecs/opus.py +0 -0
  27. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/codecs/vpx.py +0 -0
  28. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/contrib/__init__.py +0 -0
  29. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/contrib/media.py +0 -0
  30. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/contrib/signaling.py +0 -0
  31. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/events.py +0 -0
  32. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/exceptions.py +0 -0
  33. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/jitterbuffer.py +0 -0
  34. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/mediastreams.py +0 -0
  35. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/rate.py +0 -0
  36. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/rtcconfiguration.py +0 -0
  37. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/rtcdatachannel.py +0 -0
  38. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/rtcdtlstransport.py +0 -0
  39. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/rtcicetransport.py +0 -0
  40. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/rtcpeerconnection.py +0 -0
  41. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/rtcrtpparameters.py +0 -0
  42. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/rtcrtpreceiver.py +0 -0
  43. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/rtcrtpsender.py +0 -0
  44. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/rtcrtptransceiver.py +0 -0
  45. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/rtcsctptransport.py +0 -0
  46. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/rtcsessiondescription.py +0 -0
  47. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/rtp.py +0 -0
  48. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/sdp.py +0 -0
  49. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/stats.py +0 -0
  50. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/_vendor/aiortc/utils.py +0 -0
  51. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/ble_gateway.py +0 -0
  52. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/camera/__init__.py +0 -0
  53. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/camera/client.py +0 -0
  54. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/camera/constants.py +0 -0
  55. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/camera/controls.py +0 -0
  56. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/camera/go2rtc.py +0 -0
  57. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/camera/hwaccel.py +0 -0
  58. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/camera/lan_control.py +0 -0
  59. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/camera/playback.py +0 -0
  60. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/camera/sd_events.py +0 -0
  61. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/camera/sd_listing.py +0 -0
  62. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/camera/sdes.py +0 -0
  63. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/camera/sdes_open.py +0 -0
  64. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/camera/tutk.py +0 -0
  65. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/camera/webrtc.py +0 -0
  66. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/client.py +0 -0
  67. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/const.py +0 -0
  68. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/credentials.py +0 -0
  69. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/crypto.py +0 -0
  70. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/device_client.py +0 -0
  71. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/discover.py +0 -0
  72. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/exceptions.py +0 -0
  73. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/g711.py +0 -0
  74. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/lan_retry.py +0 -0
  75. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/aidot_cameras/py.typed +0 -0
  76. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  77. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
  78. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/python_aidot_cameras.egg-info/requires.txt +0 -0
  79. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/python_aidot_cameras.egg-info/top_level.txt +0 -0
  80. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/setup.cfg +0 -0
  81. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_aioice_compat.py +0 -0
  82. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_alarm_event.py +0 -0
  83. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_answer_sdp_video_pt.py +0 -0
  84. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_audio_mux_offline.py +0 -0
  85. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_avio_request.py +0 -0
  86. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_avio_response_parse.py +0 -0
  87. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_avio_response_router.py +0 -0
  88. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_backoff.py +0 -0
  89. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_battery_detection.py +0 -0
  90. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_battery_evidence_corroboration.py +0 -0
  91. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_battery_skip_turn.py +0 -0
  92. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_bitrate_campaign_arms.py +0 -0
  93. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_ble_gateway.py +0 -0
  94. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_bridge_state_sentinels.py +0 -0
  95. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_busy_backoff.py +0 -0
  96. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_cloud_count.py +0 -0
  97. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_cloud_plan.py +0 -0
  98. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_constants_imports_resolve.py +0 -0
  99. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_control_verdicts.py +0 -0
  100. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_deferred_hardening.py +0 -0
  101. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_device_cmd_seq_match.py +0 -0
  102. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_device_login_guard.py +0 -0
  103. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_device_user_info_cache.py +0 -0
  104. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_dormant_camera_cleanup.py +0 -0
  105. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_dtls_mux_terminal_write.py +0 -0
  106. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_dtls_not_ready_burst.py +0 -0
  107. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_dtls_pinning.py +0 -0
  108. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_dtls_resolution_ack.py +0 -0
  109. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_dtls_serve_decode_skip.py +0 -0
  110. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_dtls_serve_open_timeout.py +0 -0
  111. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_dtls_skip_signaling_wait.py +0 -0
  112. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_dtls_slow_probe.py +0 -0
  113. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_dtls_stdout_producer.py +0 -0
  114. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_egress_guard.py +0 -0
  115. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_env_seams.py +0 -0
  116. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_feature_probe_needs_a_live_session.py +0 -0
  117. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_futile_keepalive.py +0 -0
  118. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_gate_requires_a_decoded_frame.py +0 -0
  119. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_go2rtc.py +0 -0
  120. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_go2rtc_cli.py +0 -0
  121. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_h264_decode_rate_limit.py +0 -0
  122. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_highport_nomination.py +0 -0
  123. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_hwaccel_probe.py +0 -0
  124. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_ice_config_cache.py +0 -0
  125. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_ice_peer_reflexive.py +0 -0
  126. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_idle_release_both_loops.py +0 -0
  127. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_keepalive_renew.py +0 -0
  128. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_key_restart_sdp.py +0 -0
  129. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_keyframe_prompter.py +0 -0
  130. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_lan_control.py +0 -0
  131. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_lan_retry_reaches_the_storming_devices.py +0 -0
  132. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_light_active_color_mode.py +0 -0
  133. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_live_session_carries_talk.py +0 -0
  134. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_live_stream_param.py +0 -0
  135. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_live_validate_policy.py +0 -0
  136. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_logging_caps.py +0 -0
  137. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_login_connect_deadline.py +0 -0
  138. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_login_reconnect_backoff.py +0 -0
  139. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_media_counted_only_when_plaintext.py +0 -0
  140. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_media_socket_rcvbuf.py +0 -0
  141. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_media_wait_floor.py +0 -0
  142. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_motion_poll.py +0 -0
  143. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_mqtt_credential_refetch.py +0 -0
  144. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_mqtt_credential_self_heal.py +0 -0
  145. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_mqtt_password_not_persisted.py +0 -0
  146. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_mqtt_session_reconnect.py +0 -0
  147. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_narrow_pc_ice.py +0 -0
  148. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_no_key_material_in_logs.py +0 -0
  149. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_no_self_referential_go2rtc.py +0 -0
  150. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_no_undefined_names.py +0 -0
  151. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_offer_keeps_rtcp_feedback.py +0 -0
  152. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_offline_keepalive_pause.py +0 -0
  153. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_open_fail_logger.py +0 -0
  154. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_open_gate_delay.py +0 -0
  155. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_peerid_reuse.py +0 -0
  156. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_persistent_mqtt.py +0 -0
  157. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_persistent_mqtt_robustness.py +0 -0
  158. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_playback_tls.py +0 -0
  159. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_post_merge_hardening.py +0 -0
  160. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_ptz_probe_reads_what_it_is_told.py +0 -0
  161. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_quality_campaign_in_session.py +0 -0
  162. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_recent_recordings.py +0 -0
  163. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_remb.py +0 -0
  164. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_resolution_persists.py +0 -0
  165. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_restart_cycle.py +0 -0
  166. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_retry_policy.py +0 -0
  167. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_scripts_importable.py +0 -0
  168. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_sctp_init_ack_tsn.py +0 -0
  169. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_sd_event_decode.py +0 -0
  170. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_sd_event_probe_payloads.py +0 -0
  171. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_sd_listing.py +0 -0
  172. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_sdes_adaptive.py +0 -0
  173. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_sdes_avio_dispatch.py +0 -0
  174. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_sdes_bridge_break_gate.py +0 -0
  175. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_sdes_echo_wait_timeout.py +0 -0
  176. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_sdes_fast_liveplay.py +0 -0
  177. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_sdes_ffmpeg_stderr_log.py +0 -0
  178. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_sdes_first_media_stall_report.py +0 -0
  179. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_sdes_idle_release.py +0 -0
  180. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_sdes_offer_video_order.py +0 -0
  181. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_sdes_offer_video_pin.py +0 -0
  182. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_sdes_open_cleanup.py +0 -0
  183. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_sdes_sctp_abort.py +0 -0
  184. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_sdes_serve_audio.py +0 -0
  185. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_sdes_serve_cmd.py +0 -0
  186. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_sdes_serve_stderr_drain.py +0 -0
  187. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_sdes_sprop.py +0 -0
  188. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_sdes_talk.py +0 -0
  189. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_sdes_teardown_exit_log.py +0 -0
  190. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_sdes_terminal_ack.py +0 -0
  191. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_sdes_terminal_ack_latency.py +0 -0
  192. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_sdes_watchdog.py +0 -0
  193. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_sdp_narrowing_properties.py +0 -0
  194. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_sdp_payload_type_narrowing.py +0 -0
  195. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_security_hardening.py +0 -0
  196. {python_aidot_cameras-1.0.0b6 → 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.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_serializable_login_info.py +0 -0
  198. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_serve_loss_tolerance.py +0 -0
  199. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_serve_relay.py +0 -0
  200. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_session_stats.py +0 -0
  201. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_shared_house_seam.py +0 -0
  202. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_snapshot_cancellation.py +0 -0
  203. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_speak.py +0 -0
  204. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_sprop_capture_gate.py +0 -0
  205. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_sprop_staleness.py +0 -0
  206. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_sprop_write_is_reported_truthfully.py +0 -0
  207. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_srtp_rx_rekey.py +0 -0
  208. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_srtp_tx_key_note.py +0 -0
  209. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_stall_report_says_what_the_answer_carried.py +0 -0
  210. {python_aidot_cameras-1.0.0b6 → 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.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_stall_report_separates_silence_from_undecryptable.py +0 -0
  212. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_stall_report_survives_a_caller_giving_up.py +0 -0
  213. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_stall_reports_reach_the_artifact.py +0 -0
  214. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_stream_cap.py +0 -0
  215. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_stream_drain_ownership.py +0 -0
  216. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_stream_idle.py +0 -0
  217. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_stream_slot_cap.py +0 -0
  218. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_stream_teardown.py +0 -0
  219. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_talk.py +0 -0
  220. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_talk_reports_a_speaker_that_never_opened.py +0 -0
  221. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_terminal_ack.py +0 -0
  222. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_token_file_robustness.py +0 -0
  223. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_token_refresh.py +0 -0
  224. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_turn_create_permission.py +0 -0
  225. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_tutk_path_is_out_of_reach.py +0 -0
  226. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_upstream_compat.py +0 -0
  227. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_video_profile_log.py +0 -0
  228. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_viewer_check_throttle.py +0 -0
  229. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_viewer_presence.py +0 -0
  230. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_viewer_present_push_mode.py +0 -0
  231. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_wait_or_event.py +0 -0
  232. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_wake_gate_camera_evidence.py +0 -0
  233. {python_aidot_cameras-1.0.0b6 → python_aidot_cameras-1.0.0b8}/tests/test_wake_readiness_retry.py +0 -0
  234. {python_aidot_cameras-1.0.0b6 → 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.0b6
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
@@ -48,6 +48,38 @@ def _as_bool(v: Any) -> Optional[bool]:
48
48
  return None if i is None else bool(i)
49
49
 
50
50
 
51
+ def _card_present(attr: dict) -> Optional[bool]:
52
+ """Is an SD card in the slot? None means nobody said.
53
+
54
+ Three states, and the third is the point. Of seven cameras measured
55
+ 2026-08-12, one reported ``SDcardExistFlag: true``, two reported ``false``,
56
+ and four carried neither key - including an A000088, the same model as all
57
+ three that did report. So a missing key cannot mean "this model cannot say";
58
+ it means we do not know, and anything that reads it as "no card" would tell
59
+ someone with a working camera that their slot is empty.
60
+
61
+ ``SDcardExistFlag`` wins where it exists. ``SDcardBaseInfo`` is the fallback
62
+ and arrives as a STRING holding a JSON array - element 0 agreed with the
63
+ flag on every camera carrying both. The other four positions are not
64
+ decoded: ``30432`` is plausibly a 32 GB card's total MB and plausibly
65
+ something else, and a guess does not belong in a public data model.
66
+ """
67
+ if "SDcardExistFlag" in attr:
68
+ flag = _as_bool(attr.get("SDcardExistFlag"))
69
+ if flag is not None:
70
+ return flag
71
+
72
+ raw = attr.get("SDcardBaseInfo")
73
+ if isinstance(raw, str):
74
+ try:
75
+ raw = json.loads(raw)
76
+ except ValueError:
77
+ return None
78
+ if isinstance(raw, (list, tuple)) and raw:
79
+ return _as_bool(raw[0])
80
+ return None
81
+
82
+
51
83
  class CameraStatusData(DeviceStatusData):
52
84
  """Core status plus camera fields; accepts both typed-model and dict updates."""
53
85
 
@@ -74,6 +106,11 @@ class CameraStatusData(DeviceStatusData):
74
106
  battery_remaining: Optional[int] = None # Battery_remaining 0-100 (%)
75
107
  occupancy: Optional[bool] = None # Occupancy live presence
76
108
  sd_card_status: Optional[str] = None # SDcardStatus
109
+ # Tri-state: True in the slot, False slot empty, None nobody said. Reads
110
+ # INVERTED on A000088 relative to sd_card_status ("0" with a card, "1"
111
+ # without, 3/3) - which is why sd_card_status is not used for this and the
112
+ # two are kept apart. That inversion has no corroboration off A000088.
113
+ sd_card_present: Optional[bool] = None # SDcardExistFlag/SDcardBaseInfo[0]
77
114
  wifi_rssi: Optional[int] = None # networkRssi (dBm), cloud property
78
115
 
79
116
  def update(self, attr) -> None:
@@ -125,6 +162,11 @@ class CameraStatusData(DeviceStatusData):
125
162
  self.occupancy = b
126
163
  if (v := attr.get("SDcardStatus")) is not None:
127
164
  self.sd_card_status = str(v)
165
+ # Only overwrite on an actual reading. Attribute pushes are partial - a
166
+ # motion notif carries no card keys - so a silent update must leave what
167
+ # the last full poll established alone.
168
+ if (b := _card_present(attr)) is not None:
169
+ self.sd_card_present = b
128
170
  if (i := _as_int(attr.get("networkRssi"))) is not None:
129
171
  self.wifi_rssi = i
130
172
 
@@ -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.0b6"
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.0b6
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
@@ -160,9 +162,11 @@ tests/test_restart_cycle.py
160
162
  tests/test_retry_policy.py
161
163
  tests/test_scripts_importable.py
162
164
  tests/test_sctp_init_ack_tsn.py
165
+ tests/test_sd_card_presence.py
163
166
  tests/test_sd_event_decode.py
164
167
  tests/test_sd_event_probe_payloads.py
165
168
  tests/test_sd_listing.py
169
+ tests/test_sd_playback.py
166
170
  tests/test_sdes_adaptive.py
167
171
  tests/test_sdes_avio_dispatch.py
168
172
  tests/test_sdes_bridge_break_gate.py