python-aidot-cameras 0.17.2__tar.gz → 1.0.0b2__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 (214) hide show
  1. {python_aidot_cameras-0.17.2/python_aidot_cameras.egg-info → python_aidot_cameras-1.0.0b2}/PKG-INFO +37 -1
  2. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/README.md +36 -0
  3. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/camera/client.py +37 -7
  4. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/camera/lan_control.py +26 -2
  5. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/camera/protocol.py +12 -4
  6. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/camera/sdes.py +28 -8
  7. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/camera/sdes_open.py +795 -138
  8. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/camera/webrtc_open.py +51 -16
  9. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/pyproject.toml +1 -1
  10. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2/python_aidot_cameras.egg-info}/PKG-INFO +37 -1
  11. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/python_aidot_cameras.egg-info/SOURCES.txt +17 -1
  12. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_control_verdicts.py +12 -2
  13. python_aidot_cameras-1.0.0b2/tests/test_dtls_resolution_ack.py +238 -0
  14. python_aidot_cameras-1.0.0b2/tests/test_feature_probe_needs_a_live_session.py +104 -0
  15. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_futile_keepalive.py +35 -4
  16. python_aidot_cameras-1.0.0b2/tests/test_gate_requires_a_decoded_frame.py +101 -0
  17. python_aidot_cameras-1.0.0b2/tests/test_key_restart_sdp.py +195 -0
  18. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_lan_control.py +61 -0
  19. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_live_validate_policy.py +182 -0
  20. python_aidot_cameras-1.0.0b2/tests/test_media_counted_only_when_plaintext.py +237 -0
  21. python_aidot_cameras-1.0.0b2/tests/test_no_key_material_in_logs.py +269 -0
  22. python_aidot_cameras-1.0.0b2/tests/test_ptz_probe_reads_what_it_is_told.py +88 -0
  23. python_aidot_cameras-1.0.0b2/tests/test_sctp_init_ack_tsn.py +94 -0
  24. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_sdes_bridge_break_gate.py +74 -10
  25. python_aidot_cameras-1.0.0b2/tests/test_sdes_first_media_stall_report.py +344 -0
  26. python_aidot_cameras-1.0.0b2/tests/test_sdes_offer_video_order.py +263 -0
  27. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_sdes_talk.py +16 -0
  28. python_aidot_cameras-1.0.0b2/tests/test_snapshot_cancellation.py +105 -0
  29. python_aidot_cameras-1.0.0b2/tests/test_sprop_write_is_reported_truthfully.py +102 -0
  30. python_aidot_cameras-1.0.0b2/tests/test_srtp_rx_rekey.py +183 -0
  31. python_aidot_cameras-1.0.0b2/tests/test_srtp_tx_key_note.py +101 -0
  32. python_aidot_cameras-1.0.0b2/tests/test_talk_reports_a_speaker_that_never_opened.py +170 -0
  33. python_aidot_cameras-1.0.0b2/tests/test_tutk_path_is_out_of_reach.py +78 -0
  34. python_aidot_cameras-1.0.0b2/tests/test_webrtc_answer_race.py +101 -0
  35. python_aidot_cameras-0.17.2/tests/test_key_restart_sdp.py +0 -61
  36. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/LICENSE +0 -0
  37. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/MANIFEST.in +0 -0
  38. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/__init__.py +0 -0
  39. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/__main__.py +0 -0
  40. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/_upstream.py +0 -0
  41. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/_vendor/__init__.py +0 -0
  42. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/_vendor/aiortc/__init__.py +0 -0
  43. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/_vendor/aiortc/clock.py +0 -0
  44. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/_vendor/aiortc/codecs/__init__.py +0 -0
  45. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/_vendor/aiortc/codecs/base.py +0 -0
  46. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/_vendor/aiortc/codecs/g711.py +0 -0
  47. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/_vendor/aiortc/codecs/g722.py +0 -0
  48. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/_vendor/aiortc/codecs/h264.py +0 -0
  49. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/_vendor/aiortc/codecs/opus.py +0 -0
  50. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/_vendor/aiortc/codecs/vpx.py +0 -0
  51. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/_vendor/aiortc/contrib/__init__.py +0 -0
  52. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/_vendor/aiortc/contrib/media.py +0 -0
  53. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/_vendor/aiortc/contrib/signaling.py +0 -0
  54. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/_vendor/aiortc/events.py +0 -0
  55. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/_vendor/aiortc/exceptions.py +0 -0
  56. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/_vendor/aiortc/jitterbuffer.py +0 -0
  57. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/_vendor/aiortc/mediastreams.py +0 -0
  58. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/_vendor/aiortc/rate.py +0 -0
  59. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/_vendor/aiortc/rtcconfiguration.py +0 -0
  60. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/_vendor/aiortc/rtcdatachannel.py +0 -0
  61. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/_vendor/aiortc/rtcdtlstransport.py +0 -0
  62. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/_vendor/aiortc/rtcicetransport.py +0 -0
  63. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/_vendor/aiortc/rtcpeerconnection.py +0 -0
  64. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/_vendor/aiortc/rtcrtpparameters.py +0 -0
  65. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/_vendor/aiortc/rtcrtpreceiver.py +0 -0
  66. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/_vendor/aiortc/rtcrtpsender.py +0 -0
  67. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/_vendor/aiortc/rtcrtptransceiver.py +0 -0
  68. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/_vendor/aiortc/rtcsctptransport.py +0 -0
  69. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/_vendor/aiortc/rtcsessiondescription.py +0 -0
  70. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/_vendor/aiortc/rtp.py +0 -0
  71. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/_vendor/aiortc/sdp.py +0 -0
  72. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/_vendor/aiortc/stats.py +0 -0
  73. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/_vendor/aiortc/utils.py +0 -0
  74. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/ble_gateway.py +0 -0
  75. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/camera/__init__.py +0 -0
  76. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/camera/constants.py +0 -0
  77. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/camera/controls.py +0 -0
  78. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/camera/go2rtc.py +0 -0
  79. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/camera/hwaccel.py +0 -0
  80. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/camera/models.py +0 -0
  81. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/camera/playback.py +0 -0
  82. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/camera/tutk.py +0 -0
  83. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/camera/webrtc.py +0 -0
  84. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/client.py +0 -0
  85. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/const.py +0 -0
  86. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/credentials.py +0 -0
  87. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/crypto.py +0 -0
  88. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/device_client.py +0 -0
  89. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/discover.py +0 -0
  90. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/exceptions.py +0 -0
  91. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/g711.py +0 -0
  92. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/lan_retry.py +0 -0
  93. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/aidot_cameras/py.typed +0 -0
  94. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  95. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
  96. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/python_aidot_cameras.egg-info/requires.txt +0 -0
  97. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/python_aidot_cameras.egg-info/top_level.txt +0 -0
  98. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/setup.cfg +0 -0
  99. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_aioice_compat.py +0 -0
  100. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_alarm_event.py +0 -0
  101. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_answer_sdp_video_pt.py +0 -0
  102. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_audio_mux_offline.py +0 -0
  103. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_avio_request.py +0 -0
  104. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_avio_response_parse.py +0 -0
  105. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_avio_response_router.py +0 -0
  106. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_backoff.py +0 -0
  107. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_battery_detection.py +0 -0
  108. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_battery_evidence_corroboration.py +0 -0
  109. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_battery_skip_turn.py +0 -0
  110. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_ble_gateway.py +0 -0
  111. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_bridge_state_sentinels.py +0 -0
  112. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_busy_backoff.py +0 -0
  113. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_constants_imports_resolve.py +0 -0
  114. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_deferred_hardening.py +0 -0
  115. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_device_cmd_seq_match.py +0 -0
  116. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_device_login_guard.py +0 -0
  117. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_device_user_info_cache.py +0 -0
  118. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_dormant_camera_cleanup.py +0 -0
  119. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_dtls_mux_terminal_write.py +0 -0
  120. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_dtls_not_ready_burst.py +0 -0
  121. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_dtls_pinning.py +0 -0
  122. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_dtls_serve_decode_skip.py +0 -0
  123. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_dtls_serve_open_timeout.py +0 -0
  124. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_dtls_skip_signaling_wait.py +0 -0
  125. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_dtls_slow_probe.py +0 -0
  126. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_dtls_stdout_producer.py +0 -0
  127. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_egress_guard.py +0 -0
  128. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_env_seams.py +0 -0
  129. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_go2rtc.py +0 -0
  130. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_go2rtc_cli.py +0 -0
  131. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_h264_decode_rate_limit.py +0 -0
  132. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_highport_nomination.py +0 -0
  133. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_hwaccel_probe.py +0 -0
  134. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_ice_config_cache.py +0 -0
  135. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_ice_peer_reflexive.py +0 -0
  136. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_idle_release_both_loops.py +0 -0
  137. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_keepalive_renew.py +0 -0
  138. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_keyframe_prompter.py +0 -0
  139. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_lan_retry_reaches_the_storming_devices.py +0 -0
  140. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_light_active_color_mode.py +0 -0
  141. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_live_stream_param.py +0 -0
  142. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_logging_caps.py +0 -0
  143. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_login_connect_deadline.py +0 -0
  144. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_login_reconnect_backoff.py +0 -0
  145. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_media_socket_rcvbuf.py +0 -0
  146. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_media_wait_floor.py +0 -0
  147. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_motion_poll.py +0 -0
  148. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_mqtt_credential_refetch.py +0 -0
  149. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_mqtt_credential_self_heal.py +0 -0
  150. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_mqtt_password_not_persisted.py +0 -0
  151. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_mqtt_session_reconnect.py +0 -0
  152. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_narrow_pc_ice.py +0 -0
  153. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_no_self_referential_go2rtc.py +0 -0
  154. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_no_undefined_names.py +0 -0
  155. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_offer_keeps_rtcp_feedback.py +0 -0
  156. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_offline_keepalive_pause.py +0 -0
  157. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_open_fail_logger.py +0 -0
  158. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_open_gate_delay.py +0 -0
  159. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_peerid_reuse.py +0 -0
  160. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_persistent_mqtt.py +0 -0
  161. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_persistent_mqtt_robustness.py +0 -0
  162. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_playback_tls.py +0 -0
  163. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_post_merge_hardening.py +0 -0
  164. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_remb.py +0 -0
  165. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_resolution_persists.py +0 -0
  166. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_restart_cycle.py +0 -0
  167. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_retry_policy.py +0 -0
  168. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_scripts_importable.py +0 -0
  169. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_sdes_adaptive.py +0 -0
  170. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_sdes_avio_dispatch.py +0 -0
  171. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_sdes_echo_wait_timeout.py +0 -0
  172. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_sdes_fast_liveplay.py +0 -0
  173. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_sdes_ffmpeg_stderr_log.py +0 -0
  174. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_sdes_idle_release.py +0 -0
  175. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_sdes_offer_video_pin.py +0 -0
  176. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_sdes_open_cleanup.py +0 -0
  177. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_sdes_sctp_abort.py +0 -0
  178. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_sdes_serve_audio.py +0 -0
  179. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_sdes_serve_cmd.py +0 -0
  180. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_sdes_serve_stderr_drain.py +0 -0
  181. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_sdes_sprop.py +0 -0
  182. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_sdes_teardown_exit_log.py +0 -0
  183. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_sdes_terminal_ack.py +0 -0
  184. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_sdes_terminal_ack_latency.py +0 -0
  185. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_sdes_watchdog.py +0 -0
  186. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_sdp_narrowing_properties.py +0 -0
  187. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_sdp_payload_type_narrowing.py +0 -0
  188. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_security_hardening.py +0 -0
  189. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_serializable_login_info.py +0 -0
  190. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_serve_loss_tolerance.py +0 -0
  191. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_serve_relay.py +0 -0
  192. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_session_stats.py +0 -0
  193. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_shared_house_seam.py +0 -0
  194. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_speak.py +0 -0
  195. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_sprop_capture_gate.py +0 -0
  196. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_sprop_staleness.py +0 -0
  197. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_stream_cap.py +0 -0
  198. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_stream_drain_ownership.py +0 -0
  199. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_stream_idle.py +0 -0
  200. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_stream_slot_cap.py +0 -0
  201. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_stream_teardown.py +0 -0
  202. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_talk.py +0 -0
  203. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_terminal_ack.py +0 -0
  204. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_token_file_robustness.py +0 -0
  205. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_token_refresh.py +0 -0
  206. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_turn_create_permission.py +0 -0
  207. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_upstream_compat.py +0 -0
  208. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_video_profile_log.py +0 -0
  209. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_viewer_check_throttle.py +0 -0
  210. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_viewer_presence.py +0 -0
  211. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_viewer_present_push_mode.py +0 -0
  212. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_wait_or_event.py +0 -0
  213. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_wake_gate_camera_evidence.py +0 -0
  214. {python_aidot_cameras-0.17.2 → python_aidot_cameras-1.0.0b2}/tests/test_wake_readiness_retry.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-aidot-cameras
3
- Version: 0.17.2
3
+ Version: 1.0.0b2
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,40 @@ 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
+ ## Local (LAN) control and account ownership
70
+
71
+ Local control over the devices' TCP:10000 channel - both the light/plug protocol
72
+ and this package's `CameraLanClient` - is accepted **only for the account that
73
+ owns the devices**. A member of a shared home is rejected at the device, even
74
+ though the cloud happily hands that member a full device list, complete with the
75
+ per-device `password` and `aesKey`.
76
+
77
+ The rejection is easy to misread, because the device answers with a code that
78
+ varies by firmware family and a message that blames the wrong thing:
79
+
80
+ | Model | Ack |
81
+ | --- | --- |
82
+ | `LK.light.A001493` | 400 `not equal abort user id or password` |
83
+ | `LK.light.A001497`, `LK.plug.A001535` | 4354 `fail` |
84
+ | `LK.IPC.A000088` | 4352 `fail` |
85
+
86
+ The A001493's message is the honest one: it is the **user id** that does not
87
+ match, not the password. Every credential in the request can be correct and the
88
+ login will still be refused if the `userId` is not the owner's.
89
+
90
+ Verified 2026-08-08 against ten devices across four model families: every one
91
+ returns 200 for the owning account and one of the codes above for a shared-home
92
+ member.
93
+
94
+ **If local control never engages, check which account the integration is signed
95
+ in as before looking anywhere else.** A dedicated or secondary login - the sort
96
+ of thing you might use to avoid contending with a phone app over a rotating
97
+ refresh token - will control everything fine through the cloud and never once
98
+ log in locally.
99
+
100
+ Cameras' live video is unaffected either way: it is WebRTC signaled over cloud
101
+ MQTT and does not use this path.
102
+
69
103
  ## Library install
70
104
 
71
105
  Install from PyPI (the simplest, recommended method):
@@ -175,6 +209,8 @@ audio, idle release, the sprop cache path) are documented in
175
209
  | `AIDOT_SDES_SKIP_TURN_PREALLOC` | Skip the SDES TURN relay pre-allocation (~2-3 s of cold-start latency) so signaling goes straight out with the host candidate. Faster on a LAN, at the cost of no relay fallback for a camera on a different segment / behind strict NAT. Experimental, opt-in (truthy = `1`/`true`/`yes`/`on`). | unset (off) |
176
210
  | `AIDOT_SDES_ADAPTIVE` | Adaptive fast-with-fallback for the SDES keepalive loop: try the fast path first and fall back to the full relay path if a fast attempt delivers no media. A per-device cache skips the fast attempt on later views once it has failed. Truthy value enables. Ignored for battery cameras, where the fast path cannot win and its short grace truncates the cold start. | unset (off) |
177
211
  | `AIDOT_SDES_FAST_LIVEPLAY` | Don't block on the `livePlayResp` wait for eligible SDES cameras (~4.5 s faster cold start). Role-reversal models (A001064 PTZ) always excluded for correctness. **On by default**; set to `0`/`false`/`no`/`off` to disable. | enabled (on) |
212
+ | `AIDOT_SDES_VIDEO_PT` | Pin the SDES offer to ONE video codec by payload type, so the camera cannot choose. The offer advertises 96 (H264) and 97 (H265) and the camera decides which to send; on an A001064 that means the same request comes back h264 1280x720 most sessions and hevc 2560x1440 occasionally, at a third of the bitrate. Set to `96` for H264 only (measured h264 720p in 4 of 4 sessions). **Do not set it to `97`** - an H265-only offer returned no video at all in 3 of 3 rounds; narrowing to H265 removes the option rather than selecting it. | unset (both offered) |
213
+ | `AIDOT_SDES_VIDEO_PT_ORDER` | Reorder the SDES offer's video codec list without narrowing it, as a comma-separated preference list (`97,96`). RFC 3264 makes the `m=video` payload-type list a preference list, most-preferred first, and ours has always read `96 97` - not by decision, it has simply never been varied. Whatever is named leads and the rest is appended, so this can express a preference and can never produce a narrowed or empty video m-line. **Experimental and untested on hardware:** whether the camera acts on the order is the open question, so the default is deliberately unchanged. | unset (`96 97`) |
178
214
  | `AIDOT_DTLS_FAST_LIVEPLAY` | The DTLS (A000088) analogue: skip the `livePlayReq`-echo and `livePlayResp` waits (the dominant LAN cold-start cost) while keeping the full ICE/TURN/DTLS handshake, so remote/relay viewing is unaffected. **On by default**; set to `0`/`false`/`no`/`off` to disable. | enabled (on) |
179
215
  | `AIDOT_PERSISTENT_MQTT` | Reuse ONE account-level persistent MQTT connection for commands, attribute fetches, and stream-open signaling (matching the official app) instead of connecting per operation. **On by default** (live soaks cut SDES NO_MEDIA from ~57% to ~11-19%); set to `0`/`false`/`no`/`off` to disable. | enabled (on) |
180
216
  | `AIDOT_SERVE_RELAY` | Hold the public stream port via an internal relay that proxies to ffmpeg, so the first (cold) view connects instead of failing while ffmpeg can't pre-bind the port. Set to `0` to serve ffmpeg directly. | `1` (enabled) |
@@ -35,6 +35,40 @@ 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
+ ## Local (LAN) control and account ownership
39
+
40
+ Local control over the devices' TCP:10000 channel - both the light/plug protocol
41
+ and this package's `CameraLanClient` - is accepted **only for the account that
42
+ owns the devices**. A member of a shared home is rejected at the device, even
43
+ though the cloud happily hands that member a full device list, complete with the
44
+ per-device `password` and `aesKey`.
45
+
46
+ The rejection is easy to misread, because the device answers with a code that
47
+ varies by firmware family and a message that blames the wrong thing:
48
+
49
+ | Model | Ack |
50
+ | --- | --- |
51
+ | `LK.light.A001493` | 400 `not equal abort user id or password` |
52
+ | `LK.light.A001497`, `LK.plug.A001535` | 4354 `fail` |
53
+ | `LK.IPC.A000088` | 4352 `fail` |
54
+
55
+ The A001493's message is the honest one: it is the **user id** that does not
56
+ match, not the password. Every credential in the request can be correct and the
57
+ login will still be refused if the `userId` is not the owner's.
58
+
59
+ Verified 2026-08-08 against ten devices across four model families: every one
60
+ returns 200 for the owning account and one of the codes above for a shared-home
61
+ member.
62
+
63
+ **If local control never engages, check which account the integration is signed
64
+ in as before looking anywhere else.** A dedicated or secondary login - the sort
65
+ of thing you might use to avoid contending with a phone app over a rotating
66
+ refresh token - will control everything fine through the cloud and never once
67
+ log in locally.
68
+
69
+ Cameras' live video is unaffected either way: it is WebRTC signaled over cloud
70
+ MQTT and does not use this path.
71
+
38
72
  ## Library install
39
73
 
40
74
  Install from PyPI (the simplest, recommended method):
@@ -144,6 +178,8 @@ audio, idle release, the sprop cache path) are documented in
144
178
  | `AIDOT_SDES_SKIP_TURN_PREALLOC` | Skip the SDES TURN relay pre-allocation (~2-3 s of cold-start latency) so signaling goes straight out with the host candidate. Faster on a LAN, at the cost of no relay fallback for a camera on a different segment / behind strict NAT. Experimental, opt-in (truthy = `1`/`true`/`yes`/`on`). | unset (off) |
145
179
  | `AIDOT_SDES_ADAPTIVE` | Adaptive fast-with-fallback for the SDES keepalive loop: try the fast path first and fall back to the full relay path if a fast attempt delivers no media. A per-device cache skips the fast attempt on later views once it has failed. Truthy value enables. Ignored for battery cameras, where the fast path cannot win and its short grace truncates the cold start. | unset (off) |
146
180
  | `AIDOT_SDES_FAST_LIVEPLAY` | Don't block on the `livePlayResp` wait for eligible SDES cameras (~4.5 s faster cold start). Role-reversal models (A001064 PTZ) always excluded for correctness. **On by default**; set to `0`/`false`/`no`/`off` to disable. | enabled (on) |
181
+ | `AIDOT_SDES_VIDEO_PT` | Pin the SDES offer to ONE video codec by payload type, so the camera cannot choose. The offer advertises 96 (H264) and 97 (H265) and the camera decides which to send; on an A001064 that means the same request comes back h264 1280x720 most sessions and hevc 2560x1440 occasionally, at a third of the bitrate. Set to `96` for H264 only (measured h264 720p in 4 of 4 sessions). **Do not set it to `97`** - an H265-only offer returned no video at all in 3 of 3 rounds; narrowing to H265 removes the option rather than selecting it. | unset (both offered) |
182
+ | `AIDOT_SDES_VIDEO_PT_ORDER` | Reorder the SDES offer's video codec list without narrowing it, as a comma-separated preference list (`97,96`). RFC 3264 makes the `m=video` payload-type list a preference list, most-preferred first, and ours has always read `96 97` - not by decision, it has simply never been varied. Whatever is named leads and the rest is appended, so this can express a preference and can never produce a narrowed or empty video m-line. **Experimental and untested on hardware:** whether the camera acts on the order is the open question, so the default is deliberately unchanged. | unset (`96 97`) |
147
183
  | `AIDOT_DTLS_FAST_LIVEPLAY` | The DTLS (A000088) analogue: skip the `livePlayReq`-echo and `livePlayResp` waits (the dominant LAN cold-start cost) while keeping the full ICE/TURN/DTLS handshake, so remote/relay viewing is unaffected. **On by default**; set to `0`/`false`/`no`/`off` to disable. | enabled (on) |
148
184
  | `AIDOT_PERSISTENT_MQTT` | Reuse ONE account-level persistent MQTT connection for commands, attribute fetches, and stream-open signaling (matching the official app) instead of connecting per operation. **On by default** (live soaks cut SDES NO_MEDIA from ~57% to ~11-19%); set to `0`/`false`/`no`/`off` to disable. | enabled (on) |
149
185
  | `AIDOT_SERVE_RELAY` | Hold the public stream port via an internal relay that proxies to ffmpeg, so the first (cold) view connects instead of failing while ffmpeg can't pre-bind the port. Set to `0` to serve ffmpeg directly. | `1` (enabled) |
@@ -398,6 +398,21 @@ _BUSY_BACKOFF_S = float(os.environ.get("AIDOT_BUSY_BACKOFF_S", "20"))
398
398
  _FUTILE_KEEPALIVE_LIMIT = int(os.environ.get("AIDOT_FUTILE_KEEPALIVE_LIMIT", "5"))
399
399
 
400
400
 
401
+ def _next_no_media_streak(no_media_streak: int, healthy: bool) -> int:
402
+ """The streak after a keepalive session that was (or was not) healthy.
403
+
404
+ The ceiling counts CONSECUTIVE failures, so a session that delivered media
405
+ resets it to zero. Without that reset the count is cumulative and a camera
406
+ that merely fails occasionally - five scattered no-media sessions spread
407
+ across a day, each one separated by sessions that worked - eventually trips
408
+ a limit meant for cameras that never deliver anything at all, and has its
409
+ background keepalive stopped for good.
410
+ """
411
+ if healthy:
412
+ return 0
413
+ return no_media_streak + 1
414
+
415
+
401
416
  def _should_abandon_keepalive(no_media_streak: int, *, is_battery: bool,
402
417
  limit: int = _FUTILE_KEEPALIVE_LIMIT) -> bool:
403
418
  """Should the background keepalive stop reopening this camera?
@@ -1808,13 +1823,22 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
1808
1823
  self.device_id, len(data))
1809
1824
  elif isinstance(body, dict):
1810
1825
  data = body.get("data") or {}
1826
+ # Keys, never values: this response can carry the per-device
1827
+ # streaming credentials (tutkAccount / tutkPassword - see
1828
+ # docs/DEFERRED_FEATURES.md), and the warning below is logged
1829
+ # at default level, so a whole-body dump lands in every user's
1830
+ # home-assistant.log. The matched-device log further down
1831
+ # already prints keys only; these two did not.
1811
1832
  if data:
1812
- _LOGGER.debug("batchGetDeviceUserInfo response for %s (status=%d): %s",
1813
- self.device_id, status, body)
1833
+ _LOGGER.debug(
1834
+ "batchGetDeviceUserInfo response for %s (status=%d):"
1835
+ " keys=%s, %d item(s)",
1836
+ self.device_id, status, sorted(body.keys()), len(data),
1837
+ )
1814
1838
  else:
1815
1839
  _LOGGER.warning(
1816
- "batchGetDeviceUserInfo no data for %s (status=%d): %s",
1817
- self.device_id, status, body,
1840
+ "batchGetDeviceUserInfo no data for %s (status=%d): keys=%s",
1841
+ self.device_id, status, sorted(body.keys()),
1818
1842
  )
1819
1843
  else:
1820
1844
  data = {}
@@ -2622,7 +2646,14 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2622
2646
  _session.wait_done(),
2623
2647
  timeout=_sdes_snap_seconds + 80,
2624
2648
  )
2625
- except (TimeoutError, _asyncio.CancelledError):
2649
+ except _asyncio.CancelledError:
2650
+ # Not the same event as a timeout. A timeout means the
2651
+ # camera was slow and whatever landed in the temp file is
2652
+ # worth salvaging; a cancellation means the caller is gone,
2653
+ # so salvaging it (another ffmpeg, up to 15s) is work nobody
2654
+ # is waiting for and it stops shutdown from completing.
2655
+ raise
2656
+ except TimeoutError:
2626
2657
  pass
2627
2658
  finally:
2628
2659
  await _session.stop()
@@ -3599,6 +3630,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3599
3630
  # Adaptive bookkeeping: a fast attempt that never delivered media
3600
3631
  # latches the loop onto the full relay path for its remaining opens.
3601
3632
  _healthy = session.last_media_monotonic > 0.0
3633
+ _no_media_streak = _next_no_media_streak(_no_media_streak, _healthy)
3602
3634
  if _healthy:
3603
3635
  # A session that delivered media is a real, cleanly-used session;
3604
3636
  # the next open should be a fresh one rather than re-offering on a
@@ -3606,9 +3638,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3606
3638
  _loop_peer_id = self.generate_webrtc_peer_id(
3607
3639
  live_type=2, stream_id=0, sdes=True)
3608
3640
  _peer_reuses = 0
3609
- _no_media_streak = 0
3610
3641
  else:
3611
- _no_media_streak += 1
3612
3642
  if _should_abandon_keepalive(
3613
3643
  _no_media_streak, is_battery=self.is_battery_camera):
3614
3644
  # Every open so far has cost the camera a wake and returned
@@ -164,6 +164,26 @@ class CameraLanError(Exception):
164
164
  """A local control operation failed (connect, login, or protocol error)."""
165
165
 
166
166
 
167
+ class CameraLanLoginRejected(CameraLanError):
168
+ """The device advertised local control and then refused our login.
169
+
170
+ Separate from its parent because the two mean opposite things to a caller.
171
+ A device that does not offer local control is ordinary and should be quiet;
172
+ a device that offers it, accepts the connection, decrypts our request with
173
+ its own key, and STILL answers with a non-200 ack is a defect - either in
174
+ what we send or in the device - and the caller should be able to say so out
175
+ loud rather than logging it at the same level as "not supported here".
176
+
177
+ ``ack`` is the device's own code, because the refusals differ by family and
178
+ the number is the only handle on which one happened: A000088 cameras answer
179
+ 4352, A001497 lights and A001535 plugs 4354, A001493 lights 400.
180
+ """
181
+
182
+ def __init__(self, message: str, ack: Any = None) -> None:
183
+ super().__init__(message)
184
+ self.ack = ack
185
+
186
+
167
187
  class CameraLanClient:
168
188
  """Short-lived local device-control client for one camera.
169
189
 
@@ -291,9 +311,13 @@ class CameraLanClient:
291
311
  ) from exc
292
312
  if ack != 200:
293
313
  # The real device rejected our key-authenticated login; stop using
294
- # the LAN path and revert to cloud.
314
+ # the LAN path and revert to cloud. Raised as the specific type so
315
+ # a caller can tell this apart from a device that never offered
316
+ # local control - see CameraLanLoginRejected.
295
317
  self._eligible = False
296
- raise CameraLanError(f"{self.device_id}: login rejected ack={ack}")
318
+ raise CameraLanLoginRejected(
319
+ f"{self.device_id}: login rejected ack={ack}", ack=ack
320
+ )
297
321
  return (resp.get("payload") or {}).get("ascNumber", 1)
298
322
 
299
323
  # -- public control ---------------------------------------------------- #
@@ -662,8 +662,14 @@ def _load_sprop(devid: str) -> "Optional[str]":
662
662
  return None
663
663
 
664
664
 
665
- def _save_sprop(devid: str, sprop: str) -> None:
666
- """Persist the ``sprop-parameter-sets`` string for ``devid`` (best-effort).
665
+ def _save_sprop(devid: str, sprop: str) -> bool:
666
+ """Persist the ``sprop-parameter-sets`` for ``devid``. True if it wrote.
667
+
668
+ Returns whether anything was actually cached, because two of the three
669
+ outcomes here write nothing and the caller announces the write in the only
670
+ log line this feature has. An unconditional announcement claims a cache
671
+ that an unstable camera provably does not have, which is the observable an
672
+ investigation reads first.
667
673
 
668
674
  If a DIFFERENT value was already cached, this camera does not keep its
669
675
  parameter sets stable: mark it and drop the cache, so nothing is injected
@@ -672,7 +678,7 @@ def _save_sprop(devid: str, sprop: str) -> None:
672
678
  loses nothing by the injection being skipped.
673
679
  """
674
680
  if _sprop_is_unstable(devid):
675
- return # already known unstable; nothing to cache for this camera
681
+ return False # already known unstable; nothing to cache for this camera
676
682
  try:
677
683
  prev = _load_sprop(devid)
678
684
  if prev is not None and prev != sprop:
@@ -689,17 +695,19 @@ def _save_sprop(devid: str, sprop: str) -> None:
689
695
  os.remove(_sprop_cache_path(devid))
690
696
  except OSError:
691
697
  pass
692
- return
698
+ return False
693
699
  os.makedirs(_SPROP_DIR, exist_ok=True)
694
700
  tmp = _sprop_cache_path(devid) + ".tmp"
695
701
  with open(tmp, "w") as fh:
696
702
  fh.write(sprop)
697
703
  os.replace(tmp, _sprop_cache_path(devid))
704
+ return True
698
705
  except OSError as exc:
699
706
  # Surface (don't swallow): if the cache dir isn't writable the whole
700
707
  # sprop feature is silently inert. AIDOT_SPROP_DIR can redirect it.
701
708
  _LOGGER.warning("sprop cache write failed (%s): %s - set AIDOT_SPROP_DIR "
702
709
  "to a writable path", _SPROP_DIR, exc)
710
+ return False
703
711
 
704
712
 
705
713
  def _inject_sprop(sdp: str, devid: str) -> str:
@@ -133,12 +133,19 @@ class SdesSession(AvioRequestMixin):
133
133
  def talk_supported(self) -> bool:
134
134
  """True if two-way audio can be used on this SDES session.
135
135
 
136
- Requires a talk-capable open (offer advertised sendrecv + a=ssrc). The
137
- camera audio address is filled in by the bridge on first inbound audio;
138
- async_start_talk tolerates being called before that - the pump waits for
139
- the address before emitting.
136
+ Requires a talk-capable open (offer advertised sendrecv + a=ssrc) AND a
137
+ session that is still running. The camera audio address is filled in by
138
+ the bridge on first inbound audio; async_start_talk tolerates being
139
+ called before that - the pump waits for the address before emitting.
140
+
141
+ Liveness is part of the answer because the talk state is a plain dict
142
+ that outlives ffmpeg, while everything that acts on it - the bridge
143
+ thread that sends SPEAKERSTART, the socket the pump writes to - dies
144
+ with ffmpeg. ``async_speak`` reuses the warm session whenever this is
145
+ true, so answering from the dict alone routed talk into a session that
146
+ could no longer carry it instead of opening a fresh one.
140
147
  """
141
- return bool(self._talk_state)
148
+ return bool(self._talk_state) and self.is_alive
142
149
 
143
150
  async def async_start_talk(
144
151
  self, pcm_provider: "Callable[[], Optional[bytes]]"
@@ -170,10 +177,23 @@ class SdesSession(AvioRequestMixin):
170
177
  target=_run_sdes_talk_pump, args=(self._talk_state,), daemon=True
171
178
  )
172
179
  self._talk_thread.start()
173
- if await _speaker_ack_accepted(waiter, SDES_SPEAKER_ACK_TIMEOUT_S):
180
+ accepted = await _speaker_ack_accepted(waiter, SDES_SPEAKER_ACK_TIMEOUT_S)
181
+ # _speaker_ack_accepted is generous by design: it treats an unfamiliar or
182
+ # absent ack as acceptance, because no genuine refusal has ever been
183
+ # identified and guessing at one would break working cameras. That
184
+ # generosity is about INTERPRETING the camera's answer, and it has
185
+ # nothing to say about the case where the question was never asked.
186
+ # `speaker_on` is set on exactly one line - immediately after the bridge
187
+ # thread dispatches SPEAKERSTART(848) - so a falsy flag here means our
188
+ # own bridge never sent the command, which happens whenever ffmpeg (and
189
+ # with it that thread) has already exited. The ack budget above already
190
+ # covers SDES_SPEAKERSTART_DELAY, so a live bridge has always set it by
191
+ # now and this cannot race a working session.
192
+ if accepted and self._talk_state.get("speaker_on"):
174
193
  return True
175
- # The camera refused. Stop the microphone rather than stream viewer
176
- # audio at a speaker that never opened.
194
+ # Either the camera refused, or the speaker command never left this
195
+ # process. Stop the microphone rather than stream viewer audio at a
196
+ # speaker that never opened.
177
197
  self._talk_state["want_speaker"] = False
178
198
  self._talk_state["provider"] = None
179
199
  return False