python-aidot-cameras 1.0.0b3__tar.gz → 1.0.0b5__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 (230) hide show
  1. {python_aidot_cameras-1.0.0b3/python_aidot_cameras.egg-info → python_aidot_cameras-1.0.0b5}/PKG-INFO +28 -1
  2. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/README.md +27 -0
  3. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/camera/client.py +200 -1
  4. python_aidot_cameras-1.0.0b5/aidot_cameras/camera/sd_events.py +305 -0
  5. python_aidot_cameras-1.0.0b5/aidot_cameras/camera/sd_listing.py +142 -0
  6. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/camera/sdes_open.py +204 -44
  7. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/pyproject.toml +1 -1
  8. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5/python_aidot_cameras.egg-info}/PKG-INFO +28 -1
  9. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/python_aidot_cameras.egg-info/SOURCES.txt +15 -0
  10. python_aidot_cameras-1.0.0b5/tests/test_bitrate_campaign_arms.py +113 -0
  11. python_aidot_cameras-1.0.0b5/tests/test_cloud_count.py +152 -0
  12. python_aidot_cameras-1.0.0b5/tests/test_cloud_plan.py +164 -0
  13. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_live_validate_policy.py +4 -1
  14. python_aidot_cameras-1.0.0b5/tests/test_quality_campaign_in_session.py +237 -0
  15. python_aidot_cameras-1.0.0b5/tests/test_recent_recordings.py +158 -0
  16. python_aidot_cameras-1.0.0b5/tests/test_sd_event_decode.py +191 -0
  17. python_aidot_cameras-1.0.0b5/tests/test_sd_event_probe_payloads.py +129 -0
  18. python_aidot_cameras-1.0.0b5/tests/test_sd_listing.py +192 -0
  19. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_sdes_first_media_stall_report.py +17 -9
  20. python_aidot_cameras-1.0.0b5/tests/test_stall_report_says_what_the_answer_carried.py +161 -0
  21. python_aidot_cameras-1.0.0b5/tests/test_stall_report_says_whether_the_trigger_was_answered.py +79 -0
  22. python_aidot_cameras-1.0.0b5/tests/test_stall_report_separates_silence_from_undecryptable.py +93 -0
  23. python_aidot_cameras-1.0.0b5/tests/test_stall_report_survives_a_caller_giving_up.py +115 -0
  24. python_aidot_cameras-1.0.0b5/tests/test_stall_reports_reach_the_artifact.py +95 -0
  25. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/LICENSE +0 -0
  26. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/MANIFEST.in +0 -0
  27. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/__init__.py +0 -0
  28. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/__main__.py +0 -0
  29. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_upstream.py +0 -0
  30. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/__init__.py +0 -0
  31. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/__init__.py +0 -0
  32. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/clock.py +0 -0
  33. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/codecs/__init__.py +0 -0
  34. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/codecs/base.py +0 -0
  35. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/codecs/g711.py +0 -0
  36. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/codecs/g722.py +0 -0
  37. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/codecs/h264.py +0 -0
  38. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/codecs/opus.py +0 -0
  39. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/codecs/vpx.py +0 -0
  40. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/contrib/__init__.py +0 -0
  41. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/contrib/media.py +0 -0
  42. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/contrib/signaling.py +0 -0
  43. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/events.py +0 -0
  44. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/exceptions.py +0 -0
  45. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/jitterbuffer.py +0 -0
  46. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/mediastreams.py +0 -0
  47. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/rate.py +0 -0
  48. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/rtcconfiguration.py +0 -0
  49. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/rtcdatachannel.py +0 -0
  50. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/rtcdtlstransport.py +0 -0
  51. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/rtcicetransport.py +0 -0
  52. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/rtcpeerconnection.py +0 -0
  53. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/rtcrtpparameters.py +0 -0
  54. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/rtcrtpreceiver.py +0 -0
  55. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/rtcrtpsender.py +0 -0
  56. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/rtcrtptransceiver.py +0 -0
  57. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/rtcsctptransport.py +0 -0
  58. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/rtcsessiondescription.py +0 -0
  59. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/rtp.py +0 -0
  60. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/sdp.py +0 -0
  61. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/stats.py +0 -0
  62. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/utils.py +0 -0
  63. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/ble_gateway.py +0 -0
  64. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/camera/__init__.py +0 -0
  65. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/camera/constants.py +0 -0
  66. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/camera/controls.py +0 -0
  67. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/camera/go2rtc.py +0 -0
  68. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/camera/hwaccel.py +0 -0
  69. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/camera/lan_control.py +0 -0
  70. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/camera/models.py +0 -0
  71. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/camera/playback.py +0 -0
  72. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/camera/protocol.py +0 -0
  73. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/camera/sdes.py +0 -0
  74. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/camera/tutk.py +0 -0
  75. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/camera/webrtc.py +0 -0
  76. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/camera/webrtc_open.py +0 -0
  77. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/client.py +0 -0
  78. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/const.py +0 -0
  79. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/credentials.py +0 -0
  80. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/crypto.py +0 -0
  81. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/device_client.py +0 -0
  82. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/discover.py +0 -0
  83. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/exceptions.py +0 -0
  84. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/g711.py +0 -0
  85. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/lan_retry.py +0 -0
  86. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/aidot_cameras/py.typed +0 -0
  87. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  88. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
  89. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/python_aidot_cameras.egg-info/requires.txt +0 -0
  90. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/python_aidot_cameras.egg-info/top_level.txt +0 -0
  91. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/setup.cfg +0 -0
  92. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_aioice_compat.py +0 -0
  93. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_alarm_event.py +0 -0
  94. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_answer_sdp_video_pt.py +0 -0
  95. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_audio_mux_offline.py +0 -0
  96. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_avio_request.py +0 -0
  97. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_avio_response_parse.py +0 -0
  98. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_avio_response_router.py +0 -0
  99. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_backoff.py +0 -0
  100. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_battery_detection.py +0 -0
  101. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_battery_evidence_corroboration.py +0 -0
  102. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_battery_skip_turn.py +0 -0
  103. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_ble_gateway.py +0 -0
  104. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_bridge_state_sentinels.py +0 -0
  105. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_busy_backoff.py +0 -0
  106. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_constants_imports_resolve.py +0 -0
  107. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_control_verdicts.py +0 -0
  108. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_deferred_hardening.py +0 -0
  109. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_device_cmd_seq_match.py +0 -0
  110. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_device_login_guard.py +0 -0
  111. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_device_user_info_cache.py +0 -0
  112. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_dormant_camera_cleanup.py +0 -0
  113. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_dtls_mux_terminal_write.py +0 -0
  114. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_dtls_not_ready_burst.py +0 -0
  115. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_dtls_pinning.py +0 -0
  116. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_dtls_resolution_ack.py +0 -0
  117. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_dtls_serve_decode_skip.py +0 -0
  118. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_dtls_serve_open_timeout.py +0 -0
  119. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_dtls_skip_signaling_wait.py +0 -0
  120. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_dtls_slow_probe.py +0 -0
  121. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_dtls_stdout_producer.py +0 -0
  122. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_egress_guard.py +0 -0
  123. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_env_seams.py +0 -0
  124. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_feature_probe_needs_a_live_session.py +0 -0
  125. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_futile_keepalive.py +0 -0
  126. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_gate_requires_a_decoded_frame.py +0 -0
  127. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_go2rtc.py +0 -0
  128. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_go2rtc_cli.py +0 -0
  129. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_h264_decode_rate_limit.py +0 -0
  130. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_highport_nomination.py +0 -0
  131. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_hwaccel_probe.py +0 -0
  132. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_ice_config_cache.py +0 -0
  133. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_ice_peer_reflexive.py +0 -0
  134. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_idle_release_both_loops.py +0 -0
  135. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_keepalive_renew.py +0 -0
  136. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_key_restart_sdp.py +0 -0
  137. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_keyframe_prompter.py +0 -0
  138. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_lan_control.py +0 -0
  139. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_lan_retry_reaches_the_storming_devices.py +0 -0
  140. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_light_active_color_mode.py +0 -0
  141. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_live_session_carries_talk.py +0 -0
  142. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_live_stream_param.py +0 -0
  143. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_logging_caps.py +0 -0
  144. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_login_connect_deadline.py +0 -0
  145. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_login_reconnect_backoff.py +0 -0
  146. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_media_counted_only_when_plaintext.py +0 -0
  147. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_media_socket_rcvbuf.py +0 -0
  148. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_media_wait_floor.py +0 -0
  149. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_motion_poll.py +0 -0
  150. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_mqtt_credential_refetch.py +0 -0
  151. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_mqtt_credential_self_heal.py +0 -0
  152. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_mqtt_password_not_persisted.py +0 -0
  153. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_mqtt_session_reconnect.py +0 -0
  154. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_narrow_pc_ice.py +0 -0
  155. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_no_key_material_in_logs.py +0 -0
  156. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_no_self_referential_go2rtc.py +0 -0
  157. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_no_undefined_names.py +0 -0
  158. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_offer_keeps_rtcp_feedback.py +0 -0
  159. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_offline_keepalive_pause.py +0 -0
  160. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_open_fail_logger.py +0 -0
  161. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_open_gate_delay.py +0 -0
  162. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_peerid_reuse.py +0 -0
  163. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_persistent_mqtt.py +0 -0
  164. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_persistent_mqtt_robustness.py +0 -0
  165. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_playback_tls.py +0 -0
  166. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_post_merge_hardening.py +0 -0
  167. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_ptz_probe_reads_what_it_is_told.py +0 -0
  168. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_remb.py +0 -0
  169. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_resolution_persists.py +0 -0
  170. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_restart_cycle.py +0 -0
  171. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_retry_policy.py +0 -0
  172. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_scripts_importable.py +0 -0
  173. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_sctp_init_ack_tsn.py +0 -0
  174. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_sdes_adaptive.py +0 -0
  175. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_sdes_avio_dispatch.py +0 -0
  176. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_sdes_bridge_break_gate.py +0 -0
  177. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_sdes_echo_wait_timeout.py +0 -0
  178. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_sdes_fast_liveplay.py +0 -0
  179. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_sdes_ffmpeg_stderr_log.py +0 -0
  180. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_sdes_idle_release.py +0 -0
  181. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_sdes_offer_video_order.py +0 -0
  182. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_sdes_offer_video_pin.py +0 -0
  183. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_sdes_open_cleanup.py +0 -0
  184. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_sdes_sctp_abort.py +0 -0
  185. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_sdes_serve_audio.py +0 -0
  186. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_sdes_serve_cmd.py +0 -0
  187. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_sdes_serve_stderr_drain.py +0 -0
  188. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_sdes_sprop.py +0 -0
  189. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_sdes_talk.py +0 -0
  190. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_sdes_teardown_exit_log.py +0 -0
  191. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_sdes_terminal_ack.py +0 -0
  192. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_sdes_terminal_ack_latency.py +0 -0
  193. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_sdes_watchdog.py +0 -0
  194. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_sdp_narrowing_properties.py +0 -0
  195. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_sdp_payload_type_narrowing.py +0 -0
  196. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_security_hardening.py +0 -0
  197. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_self_peer_is_an_address_not_an_ip.py +0 -0
  198. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_serializable_login_info.py +0 -0
  199. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_serve_loss_tolerance.py +0 -0
  200. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_serve_relay.py +0 -0
  201. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_session_stats.py +0 -0
  202. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_shared_house_seam.py +0 -0
  203. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_snapshot_cancellation.py +0 -0
  204. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_speak.py +0 -0
  205. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_sprop_capture_gate.py +0 -0
  206. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_sprop_staleness.py +0 -0
  207. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_sprop_write_is_reported_truthfully.py +0 -0
  208. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_srtp_rx_rekey.py +0 -0
  209. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_srtp_tx_key_note.py +0 -0
  210. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_stream_cap.py +0 -0
  211. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_stream_drain_ownership.py +0 -0
  212. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_stream_idle.py +0 -0
  213. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_stream_slot_cap.py +0 -0
  214. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_stream_teardown.py +0 -0
  215. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_talk.py +0 -0
  216. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_talk_reports_a_speaker_that_never_opened.py +0 -0
  217. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_terminal_ack.py +0 -0
  218. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_token_file_robustness.py +0 -0
  219. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_token_refresh.py +0 -0
  220. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_turn_create_permission.py +0 -0
  221. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_tutk_path_is_out_of_reach.py +0 -0
  222. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_upstream_compat.py +0 -0
  223. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_video_profile_log.py +0 -0
  224. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_viewer_check_throttle.py +0 -0
  225. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_viewer_presence.py +0 -0
  226. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_viewer_present_push_mode.py +0 -0
  227. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_wait_or_event.py +0 -0
  228. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_wake_gate_camera_evidence.py +0 -0
  229. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/tests/test_wake_readiness_retry.py +0 -0
  230. {python_aidot_cameras-1.0.0b3 → python_aidot_cameras-1.0.0b5}/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.0b3
3
+ Version: 1.0.0b5
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
@@ -66,6 +66,33 @@ Other battery models (A001108, A001360) are recognized in code with the same
66
66
  battery handling. See [`docs/CAMERAS.md`](docs/CAMERAS.md#supported-cameras) for
67
67
  the authoritative table and per-model notes.
68
68
 
69
+ ## Known characteristics
70
+
71
+ Two behaviours that are measured, understood as far as the evidence allows, and
72
+ not going to change in this release. Neither is a fault to report.
73
+
74
+ **Recorded video is retrievable only from cloud storage.** Whether a camera
75
+ records to the cloud or to its own SD card is per-camera, not per-model, and the
76
+ device reports it as `IsSupportPlayback`. This library retrieves cloud
77
+ recordings (`async_get_cloud_recordings`, `async_open_cloud_playback`) and has
78
+ no SD-card equivalent, so a camera storing to a card records normally and
79
+ nothing here can play it back. On the reference fleet that is four of seven
80
+ cameras. The vendor's own commands for it are identified but their responses
81
+ have never been decoded; see `docs/ROAD-TO-1.0.md` item 6.
82
+
83
+ **The A001064 (PTZ) streams at roughly 2.2 Mbps, about 24% above the vendor
84
+ app.** Measured from a capture of the app on the same camera: it takes about
85
+ 1796 Kbps on HD and about 866 Kbps on SD. An earlier version of this note
86
+ repeated a figure of 225-500 Kbps for the app - that was never measured here,
87
+ and the capture contradicts it at both quality settings.
88
+
89
+ So the practical difference is a quarter more bandwidth at equivalent quality,
90
+ plus a roughly 2:1 saving that the app's SD control achieves and this library's
91
+ does not. The command sent here is byte-for-byte the app's, in-band and
92
+ mid-session on the same transport, and it does not change the rate; the app's
93
+ does. Streaming is correct either way and the cost is bandwidth, which matters
94
+ on a metered or congested link.
95
+
69
96
  ## Local (LAN) control and account ownership
70
97
 
71
98
  Local control over the devices' TCP:10000 channel - both the light/plug protocol
@@ -35,6 +35,33 @@ Other battery models (A001108, A001360) are recognized in code with the same
35
35
  battery handling. See [`docs/CAMERAS.md`](docs/CAMERAS.md#supported-cameras) for
36
36
  the authoritative table and per-model notes.
37
37
 
38
+ ## Known characteristics
39
+
40
+ Two behaviours that are measured, understood as far as the evidence allows, and
41
+ not going to change in this release. Neither is a fault to report.
42
+
43
+ **Recorded video is retrievable only from cloud storage.** Whether a camera
44
+ records to the cloud or to its own SD card is per-camera, not per-model, and the
45
+ device reports it as `IsSupportPlayback`. This library retrieves cloud
46
+ recordings (`async_get_cloud_recordings`, `async_open_cloud_playback`) and has
47
+ no SD-card equivalent, so a camera storing to a card records normally and
48
+ nothing here can play it back. On the reference fleet that is four of seven
49
+ cameras. The vendor's own commands for it are identified but their responses
50
+ have never been decoded; see `docs/ROAD-TO-1.0.md` item 6.
51
+
52
+ **The A001064 (PTZ) streams at roughly 2.2 Mbps, about 24% above the vendor
53
+ app.** Measured from a capture of the app on the same camera: it takes about
54
+ 1796 Kbps on HD and about 866 Kbps on SD. An earlier version of this note
55
+ repeated a figure of 225-500 Kbps for the app - that was never measured here,
56
+ and the capture contradicts it at both quality settings.
57
+
58
+ So the practical difference is a quarter more bandwidth at equivalent quality,
59
+ plus a roughly 2:1 saving that the app's SD control achieves and this library's
60
+ does not. The command sent here is byte-for-byte the app's, in-band and
61
+ mid-session on the same transport, and it does not change the rate; the app's
62
+ does. Streaming is correct either way and the cost is bandwidth, which matters
63
+ on a metered or congested link.
64
+
38
65
  ## Local (LAN) control and account ownership
39
66
 
40
67
  Local control over the devices' TCP:10000 channel - both the light/plug protocol
@@ -46,6 +46,7 @@ from .playback import ( # re-exported (split into playback.py)
46
46
  from .webrtc import WebRTCSession # re-exported (split into webrtc.py)
47
47
  from .sdes import SdesSession # noqa: F401 - re-exported (split into sdes.py); used by sdes_open mixin
48
48
  from .controls import _CameraControlsMixin
49
+ from .sd_listing import _CameraSdMixin
49
50
  from .webrtc_open import _WebRTCOpenMixin
50
51
  from .sdes_open import _SdesOpenMixin
51
52
  from .protocol import ( # noqa: F401 - used here and/or by the webrtc_open mixin; kept re-exported for back-compat
@@ -859,7 +860,7 @@ def _ack_matches_seq(msg, seq):
859
860
  return code if isinstance(code, int) else False
860
861
 
861
862
 
862
- class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
863
+ class CameraMixin(_CameraControlsMixin, _CameraSdMixin, _WebRTCOpenMixin, _SdesOpenMixin):
863
864
  """All camera/streaming methods, mixed into DeviceClient via inheritance."""
864
865
 
865
866
  # Devices without an aesKey never get this set by the core constructor;
@@ -2391,6 +2392,88 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2391
2392
  )
2392
2393
  return None
2393
2394
 
2395
+ async def async_get_recent_recordings(self, total: int = 10) -> "List[dict]":
2396
+ """The camera's most recent cloud event recordings.
2397
+
2398
+ A second listing API, and the one the vendor app actually uses on its
2399
+ event page. Captured 2026-04-05:
2400
+
2401
+ POST /api/ipc/playback/getRecentEventRecordingList
2402
+ {"deviceIds": [...], "total": 3}
2403
+
2404
+ **Both listings work; the zeros were the account** (measured
2405
+ 2026-08-11). Every fleet run had reported zero events from every camera
2406
+ through this call AND through `async_get_cloud_recordings`, which read
2407
+ as two broken listings. On the OWNER account, on the same fleet, ninety
2408
+ minutes after a run that reported zero: this call returns the full count
2409
+ it asks for on six of seven cameras, and the range query returns a full
2410
+ first page against server totals of 121-1517 over thirty days. The
2411
+ seventh camera has no events by any method.
2412
+
2413
+ The fleet runs use a shared-home member. The same runs report no cloud
2414
+ thumbnail either, and the owner/member thumbnail split was measured
2415
+ separately on 2026-08-09 - so an account seeing neither is behaving as
2416
+ the member, not meeting a defect. Read a zero from this method as a
2417
+ question about the account before reading it as a fault.
2418
+
2419
+ Two shape differences from every other call in this class, and both
2420
+ would break the usual handling:
2421
+
2422
+ * the response is a BARE JSON ARRAY, not the ``{"code": ..., "data":
2423
+ ...}`` envelope, so there is no code to check and no ``data`` to
2424
+ unwrap;
2425
+ * there is no pagination - ``total`` is "how many do you want", not a
2426
+ page size.
2427
+
2428
+ Each item carries ``eventUuid``, ``eventTime`` (epoch ms), ``deviceId``,
2429
+ ``deviceName``, ``eventCodeList``, ``eventDescList`` and ``picUrl``.
2430
+ Filtered to THIS camera, because the app asks for a whole house at once
2431
+ and the reply is not per-device.
2432
+ """
2433
+ import aiohttp
2434
+
2435
+ body = {"deviceIds": [self.device_id], "total": max(1, int(total))}
2436
+
2437
+ async def _fetch():
2438
+ async with aiohttp.ClientSession() as session:
2439
+ async with session.post(
2440
+ f"{self._aidot_v32_base}/playback/getRecentEventRecordingList",
2441
+ json=body,
2442
+ headers=self._aidot_headers(),
2443
+ timeout=aiohttp.ClientTimeout(total=30),
2444
+ ) as resp:
2445
+ return await resp.json(content_type=None)
2446
+
2447
+ try:
2448
+ data = await _fetch()
2449
+ # The auth check reads a dict; a list is already a success here.
2450
+ if isinstance(data, dict):
2451
+ if self._is_auth_error(data) and await self._async_refresh_auth_token():
2452
+ data = await _fetch()
2453
+ if isinstance(data, dict):
2454
+ # An envelope means an error, since success is a bare array.
2455
+ _LOGGER.warning(
2456
+ "getRecentEventRecordingList code=%s msg=%s for %s",
2457
+ data.get("code"), data.get("desc") or data.get("msg"),
2458
+ self.device_id,
2459
+ )
2460
+ return []
2461
+ if not isinstance(data, list):
2462
+ return []
2463
+ mine = [e for e in data
2464
+ if isinstance(e, dict)
2465
+ and e.get("deviceId") in (None, self.device_id)]
2466
+ _LOGGER.info(
2467
+ "getRecentEventRecordingList for %s: %d of %d event(s)",
2468
+ self.device_id, len(mine), len(data),
2469
+ )
2470
+ return mine
2471
+ except Exception as exc:
2472
+ _LOGGER.warning(
2473
+ "getRecentEventRecordingList failed for %s: %s",
2474
+ self.device_id, exc)
2475
+ return []
2476
+
2394
2477
  async def async_get_cloud_recordings(
2395
2478
  self,
2396
2479
  start_ts: int,
@@ -2403,6 +2486,14 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2403
2486
 
2404
2487
  start_ts / end_ts: Unix timestamps in milliseconds.
2405
2488
  page: 1-indexed page number (the server uses 1-based pagination).
2489
+ The server caps a page at 10 items whatever ``page_size`` says -
2490
+ measured 2026-08-11 across seven cameras, and the vendor's own client
2491
+ hard-overwrites the field to 10 (5 for a multi-device request), so it
2492
+ is the server's rule rather than a quirk of one account. A caller that
2493
+ asks for 30 gets 10 and no indication that it was truncated, which is
2494
+ how the Home Assistant browser came to show 10 events out of 1517.
2495
+ Page; do not raise ``page_size``. ``data.total`` in the reply is the
2496
+ true count for the window, and is correct even on a one-item page.
2406
2497
  Returns list of dicts from the server (each has eventUuid, eventDesc, picUrl, etc.).
2407
2498
 
2408
2499
  Uses /api/ipc/playback/eventRecordingList (confirmed from EventListRepos.java).
@@ -2456,6 +2547,106 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2456
2547
  )
2457
2548
  return []
2458
2549
 
2550
+ async def async_count_cloud_recordings(
2551
+ self, start_ts: int, end_ts: int
2552
+ ) -> Optional[int]:
2553
+ """How many cloud events a window holds, in one request.
2554
+
2555
+ The listing endpoint reports the window's true ``total`` alongside
2556
+ whatever page it serves, and that total is correct even on a one-item
2557
+ page - measured 2026-08-11 across seven cameras. So a count costs one
2558
+ request, where counting by paging costs one per ten events: a day
2559
+ holding 1517 events is 152 requests, and a seven-day view that counts
2560
+ by paging fires hundreds every time it is opened.
2561
+
2562
+ Returns None when the call fails. A caller must not read that as zero -
2563
+ "the server did not answer" and "the window is empty" lead to opposite
2564
+ conclusions in a UI, and collapsing them is a defect this project has
2565
+ had to fix before.
2566
+ """
2567
+ import aiohttp
2568
+
2569
+ _body = {
2570
+ "deviceIds": [self.device_id],
2571
+ "pageNum": 1,
2572
+ "pageSize": 1,
2573
+ "recordSta": start_ts,
2574
+ "recordEnd": end_ts,
2575
+ }
2576
+
2577
+ async def _fetch():
2578
+ async with aiohttp.ClientSession() as session:
2579
+ async with session.post(
2580
+ f"{self._aidot_v32_base}/playback/eventRecordingList",
2581
+ json=_body,
2582
+ headers=self._aidot_headers(),
2583
+ timeout=aiohttp.ClientTimeout(total=30),
2584
+ ) as resp:
2585
+ return await resp.json(content_type=None)
2586
+
2587
+ try:
2588
+ body = await _fetch()
2589
+ if self._is_auth_error(body) and await self._async_refresh_auth_token():
2590
+ body = await _fetch()
2591
+ if not isinstance(body, dict) or body.get("code") != 200:
2592
+ _LOGGER.debug(
2593
+ "eventRecordingList count for %s: %s", self.device_id, body)
2594
+ return None
2595
+ total = (body.get("data") or {}).get("total")
2596
+ return int(total) if isinstance(total, int) else None
2597
+ except Exception as exc:
2598
+ _LOGGER.debug(
2599
+ "eventRecordingList count failed for %s: %s", self.device_id, exc)
2600
+ return None
2601
+
2602
+ async def async_get_cloud_plan(self) -> Optional[dict]:
2603
+ """The camera's cloud recording subscription, or None.
2604
+
2605
+ Answers the question an empty event list cannot: whether there are no
2606
+ events, or no longer a plan under which events would be kept. Measured
2607
+ 2026-08-11 on seven cameras - an active plan reads
2608
+
2609
+ {"packageName": "AI Protection ", "packageType": 1,
2610
+ "subscribeStatus": 1, "startTime": <ms>, "endTime": <ms>,
2611
+ "expiredDays": 15, "videoLength": 60}
2612
+
2613
+ Times are epoch milliseconds. ``videoLength`` is the clip length in
2614
+ seconds.
2615
+
2616
+ Returns None on any failure rather than an empty dict: the caller's
2617
+ question is "is there a plan", and {} answers it wrong.
2618
+
2619
+ Source: the vendor app's own JS bundle -
2620
+ GET /api/ipc/recordPlanController/getPackageInfoByDevId?deviceId=
2621
+ """
2622
+ import aiohttp
2623
+
2624
+ async def _fetch():
2625
+ async with aiohttp.ClientSession() as session:
2626
+ async with session.get(
2627
+ f"{self._aidot_v32_base}/recordPlanController"
2628
+ "/getPackageInfoByDevId",
2629
+ params={"deviceId": self.device_id},
2630
+ headers=self._aidot_headers(),
2631
+ timeout=aiohttp.ClientTimeout(total=15),
2632
+ ) as resp:
2633
+ return await resp.json(content_type=None)
2634
+
2635
+ try:
2636
+ body = await _fetch()
2637
+ if self._is_auth_error(body) and await self._async_refresh_auth_token():
2638
+ body = await _fetch()
2639
+ if not isinstance(body, dict) or body.get("code") != 200:
2640
+ _LOGGER.debug(
2641
+ "getPackageInfoByDevId for %s: %s", self.device_id, body)
2642
+ return None
2643
+ data = body.get("data")
2644
+ return data if isinstance(data, dict) and data else None
2645
+ except Exception as exc:
2646
+ _LOGGER.debug(
2647
+ "getPackageInfoByDevId failed for %s: %s", self.device_id, exc)
2648
+ return None
2649
+
2459
2650
  async def async_get_event_video_media(
2460
2651
  self, event_uuid: str
2461
2652
  ) -> Optional[tuple[str, str]]:
@@ -2465,6 +2656,14 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2465
2656
  most browsers handle direct MP4 more reliably than HLS with signed CDN
2466
2657
  segments. Falls back to whatever is available.
2467
2658
 
2659
+ **That preference has never once been exercised.** Measured 2026-08-11
2660
+ on the owner account, three cameras across three model families
2661
+ (A001064, A001513, A000088): every reply carried exactly ONE entry, and
2662
+ it was type 2. So playback here is HLS in practice, and anything built
2663
+ on it should be designed for HLS rather than for the MP4 this docstring
2664
+ would lead a reader to expect. The branch is kept because it costs
2665
+ nothing and the field exists in the app's own decoder.
2666
+
2468
2667
  From CloudPlaybackFragment.java / EventListRepos.java:
2469
2668
  POST /api/ipc/playback/getEventVideoUrl
2470
2669
  body: {deviceId, eventList: [{eventUuid}]}
@@ -0,0 +1,305 @@
1
+ """Build the camera's recording-list requests, and decode its replies.
2
+
3
+ Both halves live here because they are one wire format, and because the
4
+ selector lesson below belongs next to the code that sends it: the requests used
5
+ to exist only in ``scripts/sd_event_probe.py``, which the package cannot import,
6
+ so every caller either duplicated them or could not ask at all.
7
+
8
+ The decoder was written from the vendor client's own decoder rather than from a
9
+ capture. What is CONFIRMED from ``KVSWebRTCChannel``'s 0x319 handler:
10
+
11
+ * the reply is matched by the SEQUENCE NUMBER of the request, not by the
12
+ command alone - the handler's first act is ``if getListSeq != seq: return``;
13
+ * a four-byte little-endian value is assembled and named ``listEventTotalTime``
14
+ (its name says time, not count, and this module does not pretend otherwise);
15
+ * a single byte is read as ``listEventEndFlag`` - the reply is PAGED, and a
16
+ caller that stops at the first packet gets a truncated list;
17
+ * a single byte is read as ``listEventCount`` - how many records this packet
18
+ carries;
19
+ * records are **twelve bytes each**, copied out with an explicit
20
+ ``arraycopy(..., 12)``, and each begins with a two-byte year read via
21
+ ``byteToShort`` - an ``STimeDay``.
22
+
23
+ **The header is now measured, not inferred (2026-08-11, run 31497241870).** The
24
+ first version of this module used the published TUTK layout - two STimeDays then
25
+ the counters, 24 bytes - and refused to decode the real replies because they
26
+ were shorter than that. Refusing was correct, and it is how the true layout came
27
+ back readable instead of being forced into the wrong one.
28
+
29
+ Two live replies, and a 12-byte header fits both exactly:
30
+
31
+ HASLISTEVENT_RESP 00000000 01000000 0001a800 + 168 bytes
32
+ LISTEVENT_RESP 00000000 01000000 00010000 + 0 bytes
33
+
34
+ channel uint32 LE total uint32 LE index end_flag count reserved
35
+
36
+ `count` equals the body length in both - 168 and 0. And 168 is exactly the
37
+ 7-day range that was requested, one byte per hour, which is what HASLISTEVENT
38
+ means: a per-hour occupancy map rather than a list. LISTEVENT carries 12-byte
39
+ records in the same body.
40
+ """
41
+ import struct
42
+ import time
43
+ from typing import List, NamedTuple, Optional
44
+
45
+ #: One record. The size is confirmed from the vendor's own arraycopy.
46
+ EVENT_RECORD_LEN = 12
47
+
48
+ #: Measured from two live replies, not taken from the published struct - the
49
+ #: published one is 24 bytes and neither reply is that long.
50
+ _HEADER_LEN = 12
51
+
52
+
53
+ class SdEvent(NamedTuple):
54
+ """One recording the camera says it holds."""
55
+
56
+ #: UTC, from the record's STimeDay.
57
+ year: int
58
+ month: int
59
+ day: int
60
+ hour: int
61
+ minute: int
62
+ second: int
63
+ channel: int
64
+ event: int
65
+ status: int
66
+
67
+ def isoformat(self) -> str:
68
+ return (f"{self.year:04d}-{self.month:02d}-{self.day:02d}"
69
+ f"T{self.hour:02d}:{self.minute:02d}:{self.second:02d}Z")
70
+
71
+
72
+ class SdEventPage(NamedTuple):
73
+ """One packet of the paged reply.
74
+
75
+ ``end_flag`` is why this is a page and not a list: the camera answers a
76
+ range in several packets and a caller that reads only the first silently
77
+ truncates the recording list. Nothing here hides that.
78
+ """
79
+
80
+ events: List[SdEvent]
81
+ channel: int
82
+ total: int
83
+ #: Named page_index / record_count, not index / count: a NamedTuple field
84
+ #: called `index` or `count` shadows tuple.index() and tuple.count(), so
85
+ #: `page.count` would stop being callable and read as a number instead.
86
+ page_index: int
87
+ end_flag: int
88
+ record_count: int
89
+ #: True when the payload length matches the count the header declared.
90
+ consistent: bool
91
+ #: Bytes left over after the declared records - non-zero means the layout
92
+ #: read here is wrong, and says so rather than dropping them.
93
+ trailing: int
94
+
95
+
96
+ class SdRecordingList(NamedTuple):
97
+ """What one camera says its card holds over one window.
98
+
99
+ ``records`` is the list and is always the primary answer. ``hours`` is the
100
+ HASLISTEVENT occupancy map when the camera gave one - optional, never
101
+ load-bearing. Measured 2026-08-11: the camera that returned four real
102
+ records returned an ALL-ZERO map for the same window, so a consumer that
103
+ trusted the map over the records would have shown an empty card.
104
+
105
+ ``start_ts``/``end_ts`` are the window that was asked for. The map is one
106
+ byte per hour counting from ``start_ts``, so without them a byte cannot be
107
+ placed on a clock.
108
+ """
109
+
110
+ records: List[SdEvent]
111
+ hours: Optional[bytes]
112
+ #: True when the camera replied to at least one of the two requests.
113
+ #:
114
+ #: Without this, a camera that says NOTHING and a camera that says "my card
115
+ #: is empty" both arrive as an empty list, and a browser has no way to tell
116
+ #: "we asked and got silence" from "there is nothing there" - so it prints
117
+ #: the second when the first is true. That collapse is the exact failure
118
+ #: this subsystem exists to prevent, and it is worth a field rather than a
119
+ #: convention: an empty ``records`` is only a statement about the card when
120
+ #: ``answered`` is True.
121
+ answered: bool
122
+ #: Whether the list that came back is the whole list. False when the
123
+ #: reply's end flag never arrived, and False when a reply arrived that could
124
+ #: not be decoded - in both cases there may be more than is shown.
125
+ #:
126
+ #: Only meaningful when ``answered`` is True. Silence carries no end flag,
127
+ #: so it says nothing about completeness either way.
128
+ complete: bool
129
+ start_ts: float
130
+ end_ts: float
131
+
132
+
133
+ #: Reply command ids, so a caller that knows which question it asked can say so.
134
+ LISTEVENT_RESP_CMD = 0x319
135
+ HASLISTEVENT_RESP_CMD = 0x4B6
136
+
137
+ #: Request command ids, from AVIOCTRLDEFs in the vendor's own client.
138
+ HASLISTEVENT_REQ_CMD = 0x4B5
139
+ LISTEVENT_REQ_CMD = 0x318
140
+
141
+ #: The event selector the vendor's app sends, read off
142
+ #: KVSWebRTCChannel.getSDRecordList: `const/16 v1, 0x12`.
143
+ #:
144
+ #: It is NOT the default here, because on a camera with a card it returns
145
+ #: nothing. Measured 2026-08-11: the same session, the same window, 0x12
146
+ #: answered an EMPTY page while 0 answered with four real records, the first
147
+ #: stamped 2026-08-11T20:41:42Z. The earlier note claiming the opposite was
148
+ #: measured on three cameras with no card, where every selector answered empty
149
+ #: and the comparison was between two empty answers.
150
+ SD_EVENT_APP = 0x12
151
+
152
+ #: The selector that returns records. The default, so a caller that does not
153
+ #: know about the above cannot accidentally ask the question that gets silence.
154
+ SD_EVENT_ANY = 0
155
+
156
+
157
+ def stimeday(when: float) -> bytes:
158
+ """The vendor's 8-byte STimeDay, from ``AVIOCTRLDEFs$STimeDay``.
159
+
160
+ ``unsigned short year`` then six single bytes - month, day, wday, hour,
161
+ minute, second. UTC, because the field is named ``startutctime``.
162
+ """
163
+ t = time.gmtime(when)
164
+ return struct.pack(
165
+ "<HBBBBBB",
166
+ t.tm_year,
167
+ t.tm_mon,
168
+ t.tm_mday,
169
+ (t.tm_wday + 1) % 7, # tm_wday is Mon=0; the vendor's wday is Sun=0
170
+ t.tm_hour,
171
+ t.tm_min,
172
+ t.tm_sec,
173
+ )
174
+
175
+
176
+ def haslistevent_payload(start: float, end: float, channel: int = 0,
177
+ event: int = SD_EVENT_ANY) -> bytes:
178
+ """22 bytes: channel, start, end, and a two-byte selector tail.
179
+
180
+ The length is not a guess - ``parseConent`` allocates ``const/16 v1, 0x16``,
181
+ which is 22, and copies the channel at offset 0 and the first STimeDay at
182
+ offset 4.
183
+ """
184
+ return (struct.pack("<I", channel)
185
+ + stimeday(start) + stimeday(end)
186
+ + struct.pack("<H", event & 0xFFFF))
187
+
188
+
189
+ def listevent_payload(start: float, end: float, channel: int = 0,
190
+ event: int = SD_EVENT_ANY, status: int = 0) -> bytes:
191
+ """24 bytes, not 22 - and the difference is the whole point.
192
+
193
+ ``SMsgAVIoctrlListEventReq`` has three ``parseConent`` overloads. The one
194
+ whose field list this was first written from is not the one the WebRTC path
195
+ uses: ``KVSWebRTCChannel.getSDRecordList`` calls the epoch-long overload,
196
+ which allocates ``const/16 v4, 0x18`` - 24 bytes - where the other
197
+ allocates 22. Four bytes of channel, two eight-byte STimeDays, the event and
198
+ status selectors, then two bytes of tail.
199
+ """
200
+ return (struct.pack("<I", channel)
201
+ + stimeday(start) + stimeday(end)
202
+ + bytes((event & 0xFF, status & 0xFF))
203
+ + b"\x00\x00")
204
+
205
+
206
+ def _consistent(count: int, body_len: int, command: Optional[int]) -> bool:
207
+ """Does the declared entry count match the body, for THIS reply's entry size?
208
+
209
+ Records are 12 bytes each; occupancy-map hours are one byte each. When the
210
+ command is known the right rule is used. When it is not, either reading is
211
+ accepted - reporting False for a well-formed page just because the decoder
212
+ was not told what it was reading would be the check lying about the data.
213
+ """
214
+ if command == HASLISTEVENT_RESP_CMD:
215
+ return count == body_len
216
+ if command == LISTEVENT_RESP_CMD:
217
+ return count * EVENT_RECORD_LEN == body_len
218
+ return count == body_len or count * EVENT_RECORD_LEN == body_len
219
+
220
+
221
+ def _stimeday(buf: bytes, off: int) -> tuple:
222
+ year, month, day, _wday, hour, minute, second = struct.unpack_from(
223
+ "<HBBBBBB", buf, off)
224
+ return year, month, day, hour, minute, second
225
+
226
+
227
+ def decode_event_record(buf: bytes, off: int = 0) -> SdEvent:
228
+ """One 12-byte record: an STimeDay then channel, event, status, reserved."""
229
+ year, month, day, hour, minute, second = _stimeday(buf, off)
230
+ channel, event, status = struct.unpack_from("<BBB", buf, off + 8)
231
+ return SdEvent(year, month, day, hour, minute, second,
232
+ channel, event, status)
233
+
234
+
235
+ def decode_list_event_response(payload: bytes, *, command: Optional[int] = None) -> Optional[SdEventPage]:
236
+ """Decode a 0x319 payload, or None if it cannot be one.
237
+
238
+ None rather than an exception or a half-filled result: this runs against a
239
+ reply nobody has seen yet, and "this does not fit" is the answer that keeps
240
+ the next person honest.
241
+ """
242
+ # A HASLISTEVENT body is a per-hour occupancy map, not records. Reading it
243
+ # as records is not a near miss: the real 168-byte map is exactly fourteen
244
+ # 12-byte records, so the decode SUCCEEDS and invents fourteen recordings
245
+ # dated 0000-00-00. Measured 2026-08-11, when a formatted-but-empty card's
246
+ # 24-byte map read as "records: 2". A caller that knows which reply it has
247
+ # gets nothing rather than fiction; a caller that passes no command still
248
+ # gets the old best-effort behaviour, because that is what the existing
249
+ # tests and probe runs read.
250
+ if command == HASLISTEVENT_RESP_CMD:
251
+ return None
252
+ if payload is None or len(payload) < _HEADER_LEN:
253
+ return None
254
+ channel, total = struct.unpack_from("<II", payload, 0)
255
+ index, end_flag, count, _reserved = struct.unpack_from("<BBBB", payload, 8)
256
+ body = payload[_HEADER_LEN:]
257
+ usable = len(body) // EVENT_RECORD_LEN
258
+ # `count` is a BYTE count on HASLISTEVENT (one byte per hour) and a RECORD
259
+ # count on LISTEVENT. Only the record reading is meaningful here, so the
260
+ # number of records decoded is bounded by what the body can actually hold
261
+ # rather than by what the header claims.
262
+ take = usable
263
+ events = [decode_event_record(body, i * EVENT_RECORD_LEN)
264
+ for i in range(take)]
265
+ return SdEventPage(
266
+ events=events,
267
+ channel=channel,
268
+ total=total,
269
+ page_index=index,
270
+ end_flag=end_flag,
271
+ record_count=count,
272
+ # `count` counts ENTRIES in the body, and an entry is not always a
273
+ # byte. Measured 2026-08-11 against the first live replies carrying real
274
+ # data: a LISTEVENT_RESP with four records is 12 header + 48 body and
275
+ # declares count=4, while a HASLISTEVENT_RESP occupancy map declares
276
+ # count=168 for 168 bytes because there an entry IS one hour-byte.
277
+ #
278
+ # So the entry size depends on WHICH reply this is, and the payload
279
+ # alone cannot always say - a 24-byte body with count=24 is a valid
280
+ # 24-hour map, and a 24-byte body with count=2 is a valid two-record
281
+ # page. Pass `command` when it is known. Without it, accept either
282
+ # reading rather than calling a good page inconsistent, and say in
283
+ # `consistent` only what can honestly be said.
284
+ consistent=_consistent(count, len(body), command),
285
+ trailing=len(body) - usable * EVENT_RECORD_LEN,
286
+ )
287
+
288
+
289
+ def decode_hour_map(payload: Optional[bytes], *,
290
+ command: Optional[int] = None) -> Optional[bytes]:
291
+ """The per-hour occupancy bytes from a HASLISTEVENT reply, or None.
292
+
293
+ One byte per hour of the window that was requested, starting at the request
294
+ start: 168 bytes for seven days, 24 for one. A non-zero byte means the card
295
+ holds something recorded in that hour. It says nothing about how many
296
+ recordings, so nothing here counts them.
297
+
298
+ None means "this is not a map", which is a different answer from an empty
299
+ map and is why the return is not simply ``b""``.
300
+ """
301
+ if command == LISTEVENT_RESP_CMD:
302
+ return None
303
+ if payload is None or len(payload) <= _HEADER_LEN:
304
+ return None
305
+ return bytes(payload[_HEADER_LEN:])