python-aidot-cameras 0.17.1__tar.gz → 0.17.2__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 (197) hide show
  1. {python_aidot_cameras-0.17.1/python_aidot_cameras.egg-info → python_aidot_cameras-0.17.2}/PKG-INFO +4 -1
  2. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/README.md +3 -0
  3. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/camera/client.py +12 -0
  4. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/client.py +26 -6
  5. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/device_client.py +61 -11
  6. python_aidot_cameras-0.17.2/aidot_cameras/lan_retry.py +198 -0
  7. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/pyproject.toml +1 -1
  8. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2/python_aidot_cameras.egg-info}/PKG-INFO +4 -1
  9. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/python_aidot_cameras.egg-info/SOURCES.txt +4 -0
  10. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_dormant_camera_cleanup.py +52 -0
  11. python_aidot_cameras-0.17.2/tests/test_lan_retry_reaches_the_storming_devices.py +252 -0
  12. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_light_active_color_mode.py +15 -6
  13. python_aidot_cameras-0.17.2/tests/test_login_connect_deadline.py +113 -0
  14. python_aidot_cameras-0.17.2/tests/test_login_reconnect_backoff.py +83 -0
  15. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/LICENSE +0 -0
  16. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/MANIFEST.in +0 -0
  17. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/__init__.py +0 -0
  18. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/__main__.py +0 -0
  19. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_upstream.py +0 -0
  20. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/__init__.py +0 -0
  21. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/__init__.py +0 -0
  22. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/clock.py +0 -0
  23. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/codecs/__init__.py +0 -0
  24. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/codecs/base.py +0 -0
  25. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/codecs/g711.py +0 -0
  26. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/codecs/g722.py +0 -0
  27. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/codecs/h264.py +0 -0
  28. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/codecs/opus.py +0 -0
  29. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/codecs/vpx.py +0 -0
  30. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/contrib/__init__.py +0 -0
  31. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/contrib/media.py +0 -0
  32. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/contrib/signaling.py +0 -0
  33. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/events.py +0 -0
  34. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/exceptions.py +0 -0
  35. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/jitterbuffer.py +0 -0
  36. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/mediastreams.py +0 -0
  37. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/rate.py +0 -0
  38. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/rtcconfiguration.py +0 -0
  39. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/rtcdatachannel.py +0 -0
  40. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/rtcdtlstransport.py +0 -0
  41. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/rtcicetransport.py +0 -0
  42. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/rtcpeerconnection.py +0 -0
  43. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/rtcrtpparameters.py +0 -0
  44. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/rtcrtpreceiver.py +0 -0
  45. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/rtcrtpsender.py +0 -0
  46. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/rtcrtptransceiver.py +0 -0
  47. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/rtcsctptransport.py +0 -0
  48. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/rtcsessiondescription.py +0 -0
  49. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/rtp.py +0 -0
  50. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/sdp.py +0 -0
  51. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/stats.py +0 -0
  52. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/utils.py +0 -0
  53. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/ble_gateway.py +0 -0
  54. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/camera/__init__.py +0 -0
  55. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/camera/constants.py +0 -0
  56. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/camera/controls.py +0 -0
  57. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/camera/go2rtc.py +0 -0
  58. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/camera/hwaccel.py +0 -0
  59. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/camera/lan_control.py +0 -0
  60. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/camera/models.py +0 -0
  61. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/camera/playback.py +0 -0
  62. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/camera/protocol.py +0 -0
  63. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/camera/sdes.py +0 -0
  64. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/camera/sdes_open.py +0 -0
  65. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/camera/tutk.py +0 -0
  66. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/camera/webrtc.py +0 -0
  67. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/camera/webrtc_open.py +0 -0
  68. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/const.py +0 -0
  69. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/credentials.py +0 -0
  70. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/crypto.py +0 -0
  71. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/discover.py +0 -0
  72. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/exceptions.py +0 -0
  73. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/g711.py +0 -0
  74. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/py.typed +0 -0
  75. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  76. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
  77. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/python_aidot_cameras.egg-info/requires.txt +0 -0
  78. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/python_aidot_cameras.egg-info/top_level.txt +0 -0
  79. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/setup.cfg +0 -0
  80. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_aioice_compat.py +0 -0
  81. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_alarm_event.py +0 -0
  82. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_answer_sdp_video_pt.py +0 -0
  83. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_audio_mux_offline.py +0 -0
  84. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_avio_request.py +0 -0
  85. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_avio_response_parse.py +0 -0
  86. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_avio_response_router.py +0 -0
  87. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_backoff.py +0 -0
  88. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_battery_detection.py +0 -0
  89. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_battery_evidence_corroboration.py +0 -0
  90. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_battery_skip_turn.py +0 -0
  91. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_ble_gateway.py +0 -0
  92. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_bridge_state_sentinels.py +0 -0
  93. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_busy_backoff.py +0 -0
  94. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_constants_imports_resolve.py +0 -0
  95. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_control_verdicts.py +0 -0
  96. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_deferred_hardening.py +0 -0
  97. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_device_cmd_seq_match.py +0 -0
  98. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_device_login_guard.py +0 -0
  99. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_device_user_info_cache.py +0 -0
  100. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_dtls_mux_terminal_write.py +0 -0
  101. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_dtls_not_ready_burst.py +0 -0
  102. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_dtls_pinning.py +0 -0
  103. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_dtls_serve_decode_skip.py +0 -0
  104. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_dtls_serve_open_timeout.py +0 -0
  105. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_dtls_skip_signaling_wait.py +0 -0
  106. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_dtls_slow_probe.py +0 -0
  107. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_dtls_stdout_producer.py +0 -0
  108. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_egress_guard.py +0 -0
  109. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_env_seams.py +0 -0
  110. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_futile_keepalive.py +0 -0
  111. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_go2rtc.py +0 -0
  112. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_go2rtc_cli.py +0 -0
  113. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_h264_decode_rate_limit.py +0 -0
  114. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_highport_nomination.py +0 -0
  115. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_hwaccel_probe.py +0 -0
  116. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_ice_config_cache.py +0 -0
  117. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_ice_peer_reflexive.py +0 -0
  118. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_idle_release_both_loops.py +0 -0
  119. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_keepalive_renew.py +0 -0
  120. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_key_restart_sdp.py +0 -0
  121. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_keyframe_prompter.py +0 -0
  122. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_lan_control.py +0 -0
  123. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_live_stream_param.py +0 -0
  124. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_live_validate_policy.py +0 -0
  125. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_logging_caps.py +0 -0
  126. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_media_socket_rcvbuf.py +0 -0
  127. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_media_wait_floor.py +0 -0
  128. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_motion_poll.py +0 -0
  129. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_mqtt_credential_refetch.py +0 -0
  130. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_mqtt_credential_self_heal.py +0 -0
  131. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_mqtt_password_not_persisted.py +0 -0
  132. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_mqtt_session_reconnect.py +0 -0
  133. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_narrow_pc_ice.py +0 -0
  134. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_no_self_referential_go2rtc.py +0 -0
  135. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_no_undefined_names.py +0 -0
  136. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_offer_keeps_rtcp_feedback.py +0 -0
  137. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_offline_keepalive_pause.py +0 -0
  138. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_open_fail_logger.py +0 -0
  139. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_open_gate_delay.py +0 -0
  140. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_peerid_reuse.py +0 -0
  141. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_persistent_mqtt.py +0 -0
  142. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_persistent_mqtt_robustness.py +0 -0
  143. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_playback_tls.py +0 -0
  144. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_post_merge_hardening.py +0 -0
  145. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_remb.py +0 -0
  146. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_resolution_persists.py +0 -0
  147. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_restart_cycle.py +0 -0
  148. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_retry_policy.py +0 -0
  149. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_scripts_importable.py +0 -0
  150. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_sdes_adaptive.py +0 -0
  151. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_sdes_avio_dispatch.py +0 -0
  152. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_sdes_bridge_break_gate.py +0 -0
  153. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_sdes_echo_wait_timeout.py +0 -0
  154. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_sdes_fast_liveplay.py +0 -0
  155. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_sdes_ffmpeg_stderr_log.py +0 -0
  156. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_sdes_idle_release.py +0 -0
  157. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_sdes_offer_video_pin.py +0 -0
  158. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_sdes_open_cleanup.py +0 -0
  159. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_sdes_sctp_abort.py +0 -0
  160. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_sdes_serve_audio.py +0 -0
  161. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_sdes_serve_cmd.py +0 -0
  162. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_sdes_serve_stderr_drain.py +0 -0
  163. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_sdes_sprop.py +0 -0
  164. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_sdes_talk.py +0 -0
  165. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_sdes_teardown_exit_log.py +0 -0
  166. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_sdes_terminal_ack.py +0 -0
  167. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_sdes_terminal_ack_latency.py +0 -0
  168. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_sdes_watchdog.py +0 -0
  169. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_sdp_narrowing_properties.py +0 -0
  170. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_sdp_payload_type_narrowing.py +0 -0
  171. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_security_hardening.py +0 -0
  172. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_serializable_login_info.py +0 -0
  173. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_serve_loss_tolerance.py +0 -0
  174. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_serve_relay.py +0 -0
  175. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_session_stats.py +0 -0
  176. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_shared_house_seam.py +0 -0
  177. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_speak.py +0 -0
  178. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_sprop_capture_gate.py +0 -0
  179. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_sprop_staleness.py +0 -0
  180. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_stream_cap.py +0 -0
  181. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_stream_drain_ownership.py +0 -0
  182. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_stream_idle.py +0 -0
  183. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_stream_slot_cap.py +0 -0
  184. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_stream_teardown.py +0 -0
  185. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_talk.py +0 -0
  186. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_terminal_ack.py +0 -0
  187. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_token_file_robustness.py +0 -0
  188. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_token_refresh.py +0 -0
  189. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_turn_create_permission.py +0 -0
  190. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_upstream_compat.py +0 -0
  191. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_video_profile_log.py +0 -0
  192. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_viewer_check_throttle.py +0 -0
  193. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_viewer_presence.py +0 -0
  194. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_viewer_present_push_mode.py +0 -0
  195. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_wait_or_event.py +0 -0
  196. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_wake_gate_camera_evidence.py +0 -0
  197. {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/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.1
3
+ Version: 0.17.2
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
@@ -178,6 +178,9 @@ audio, idle release, the sprop cache path) are documented in
178
178
  | `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
179
  | `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
180
  | `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) |
181
+ | `AIDOT_LOGIN_RETRY_LIMIT` | Consecutive failed LAN logins after which a device is left alone (it is retried again the next time something asks for it). Applies to every device, camera or not - the devices that hit this are lights. | `6` |
182
+ | `AIDOT_LOGIN_RETRY_CAP_S` | Ceiling on the exponential delay between those retries. | `60` |
183
+ | `AIDOT_LOGIN_CONNECT_TIMEOUT_S` | Ceiling on one LAN connect+login attempt. A device that completes the TCP handshake and then stops answering is abandoned and its socket closed rather than parking the attempt forever. | `20` |
181
184
  | `AIDOT_LIVESTREAM_PARAM` | **No-op (ignored).** Formerly requested the cloud `liveStreamParam` pre-connect for battery cameras. That call provisions the camera toward AWS KVS, so it sends its media there instead of to this library and the live view negotiates and then shows nothing - and battery cameras were the only ones it applied to. Setting it (or `start_keepalive(live_stream_param=True)`) logs one warning and changes nothing. | ignored |
182
185
 
183
186
  ### Security hardening
@@ -147,6 +147,9 @@ audio, idle release, the sprop cache path) are documented in
147
147
  | `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
148
  | `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
149
  | `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) |
150
+ | `AIDOT_LOGIN_RETRY_LIMIT` | Consecutive failed LAN logins after which a device is left alone (it is retried again the next time something asks for it). Applies to every device, camera or not - the devices that hit this are lights. | `6` |
151
+ | `AIDOT_LOGIN_RETRY_CAP_S` | Ceiling on the exponential delay between those retries. | `60` |
152
+ | `AIDOT_LOGIN_CONNECT_TIMEOUT_S` | Ceiling on one LAN connect+login attempt. A device that completes the TCP handshake and then stops answering is abandoned and its socket closed rather than parking the attempt forever. | `20` |
150
153
  | `AIDOT_LIVESTREAM_PARAM` | **No-op (ignored).** Formerly requested the cloud `liveStreamParam` pre-connect for battery cameras. That call provisions the camera toward AWS KVS, so it sends its media there instead of to this library and the live view negotiates and then shows nothing - and battery cameras were the only ones it applied to. Setting it (or `start_keepalive(live_stream_param=True)`) logs one warning and changes nothing. | ignored |
151
154
 
152
155
  ### Security hardening
@@ -3622,6 +3622,18 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3622
3622
  self.device_id, _no_media_streak,
3623
3623
  )
3624
3624
  self._streaming_active = False
3625
+ # Same teardown the idle-release exit does. Leaving the
3626
+ # go2rtc stream registered points it at a serve port with
3627
+ # nothing listening, so a viewer attaching to a dormant
3628
+ # camera gets "connection refused" instead of a clean miss -
3629
+ # exactly what the idle-release path exists to avoid.
3630
+ self._cancel_keepalive_renew()
3631
+ try:
3632
+ await self._deregister_go2rtc()
3633
+ except Exception:
3634
+ _LOGGER.debug("camera %s: go2rtc deregister after "
3635
+ "abandon failed", self.device_id,
3636
+ exc_info=True)
3625
3637
  return
3626
3638
  if _use_fast and not _healthy and not _fast_failed:
3627
3639
  _LOGGER.info(
@@ -72,7 +72,7 @@ from .const import (
72
72
  LOGIN_INFO_PERSISTENT_MQTT_LOCK_KEY,
73
73
  RUNTIME_ONLY_LOGIN_INFO_KEYS,
74
74
  )
75
- from .device_client import CameraDeviceClient
75
+ from .device_client import CameraDeviceClient, LightDeviceClient
76
76
  # CARRIED: drop when python-aidot#6 merges - this is upstream's DeviceStatusData
77
77
  # plus the active_color_mode tracker (see aidot_cameras/device_client.py).
78
78
  from .device_client import DeviceStatusData as _CarriedStatusData
@@ -791,13 +791,33 @@ class CameraClient(_UpstreamAidotClient):
791
791
 
792
792
  This is the only place device clients are constructed, upstream or
793
793
  here, and therefore the only place that decides which class to use.
794
- Non-cameras take the ``super()`` path and get a plain upstream
795
- ``DeviceClient``: no camera code RUNS in their path and no camera
796
- behavior is attached to them. (Importing this module still pulls the
797
- camera package in - the guarantee is about execution, not imports.)
794
+ Non-cameras get a ``LightDeviceClient``: upstream's client plus the
795
+ camera-free ``LanRetryMixin``, and nothing else. No camera code RUNS in
796
+ their path and no camera behavior is attached to them. (Importing this
797
+ module still pulls the camera package in - the guarantee is about
798
+ execution, not imports.)
799
+
800
+ The light branch is the one place an upstream method body is
801
+ reproduced. Upstream's ``get_device_client`` names ``DeviceClient``
802
+ directly at construction, so there is no seam that lets a subclass be
803
+ substituted; the alternative is patching upstream's module global, which
804
+ would reach every consumer in the process rather than only ours. The
805
+ one behaviour that copy owes upstream - pushing the discovered LAN IP
806
+ into the client, without which a light never logs in - is covered by
807
+ ``test_a_discovered_address_still_reaches_a_light``. It reads the map
808
+ through ``CameraDiscover.discovered_device``, which resolves correctly
809
+ on both upstream shapes (upstream's own line does not).
798
810
  """
799
811
  if not _is_camera_device(device):
800
- device_client = super().get_device_client(device)
812
+ device_id = device.get(CONF_ID)
813
+ device_client = self._device_clients.get(device_id)
814
+ if device_client is None:
815
+ device_client = LightDeviceClient(device, self.login_info)
816
+ self._device_clients[device_id] = device_client
817
+ if self._discover is not None:
818
+ device_client.update_ip_address(
819
+ self._discover.discovered_device.get(device_id)
820
+ )
801
821
  # CARRIED: drop when python-aidot#6 merges
802
822
  self._carry_active_color_mode(device_client)
803
823
  return device_client
@@ -8,16 +8,22 @@ edited. This module layers the camera surface on top:
8
8
  `CameraDeviceInformation` on them and because the pre-inversion module exposed
9
9
  them under these names. `DeviceInformation` additionally accepts the RAW cloud
10
10
  device dict, which upstream's typed constructor does not.
11
- * `CameraDeviceClient` - `CameraMixin` + upstream `DeviceClient`. Only the five
12
- seams the camera layer actually needs are overridden; no upstream method body
13
- is copied.
11
+ * `LightDeviceClient` - upstream `DeviceClient` + `LanRetryMixin` and nothing
12
+ else. What every non-camera device gets. It carries no camera code, by
13
+ construction: it is declared above the camera import, and the mixin lives in
14
+ its own camera-free module.
15
+ * `CameraDeviceClient` - `CameraMixin` + `LanRetryMixin` + upstream
16
+ `DeviceClient`. Only the seams the camera layer actually needs are
17
+ overridden; no upstream method body is copied.
14
18
 
15
19
  Import order matters: the two data classes must be defined BEFORE
16
20
  `.camera.client` is imported, because `aidot_cameras.camera.models` imports them
17
21
  back from this module at import time.
18
22
  """
19
23
 
24
+ import asyncio
20
25
  import logging
26
+ import os
21
27
  from typing import Any, Optional
22
28
 
23
29
  from aidot.device_client import DeviceClient as _UpstreamDeviceClient
@@ -35,6 +41,20 @@ from ._upstream import (
35
41
  )
36
42
  from ._upstream import DeviceState as _UpstreamDeviceState
37
43
 
44
+ # The LAN retry policy. It lives in its own module because it is mixed into the
45
+ # plain light client as well as the camera one, and that module must never grow
46
+ # a camera import - see its docstring. The names are re-exported below, where
47
+ # the policy used to be defined.
48
+ from .lan_retry import (
49
+ _LOGIN_CONNECT_TIMEOUT_S,
50
+ _LOGIN_RETRY_BASE_S,
51
+ _LOGIN_RETRY_CAP_S,
52
+ _LOGIN_RETRY_LIMIT,
53
+ LanRetryMixin,
54
+ _await_connect_with_deadline,
55
+ _next_login_retry_delay,
56
+ )
57
+
38
58
  from .const import (
39
59
  CONF_ATTR,
40
60
  CONF_HARDWARE_VERSION,
@@ -55,11 +75,13 @@ from .const import (
55
75
 
56
76
  _LOGGER = logging.getLogger(__name__)
57
77
 
78
+
58
79
  # Upstream's base client, re-exported under its plain name. `get_device_client`
59
- # hands one of these back for every non-camera device, so consumers (the Home
60
- # Assistant integration) need the type to annotate against - and should get it
61
- # from here rather than importing `aidot` directly, which for them is an
62
- # undeclared transitive dependency. This is upstream's class, not a subclass.
80
+ # hands back a `LightDeviceClient` for every non-camera device, which IS one of
81
+ # these, so consumers (the Home Assistant integration) can keep annotating
82
+ # against this name - and should get it from here rather than importing `aidot`
83
+ # directly, which for them is an undeclared transitive dependency. This name is
84
+ # upstream's class itself, not a subclass.
63
85
  DeviceClient = _UpstreamDeviceClient
64
86
 
65
87
  # Same reasoning for the LAN session state enum: a consumer that wants to know
@@ -146,6 +168,27 @@ class DeviceInformation(_UpstreamDeviceInformation):
146
168
  self.enable_cct = True
147
169
 
148
170
 
171
+ class LightDeviceClient(LanRetryMixin, _UpstreamDeviceClient):
172
+ """Upstream's client for every non-camera device, with retries bounded.
173
+
174
+ Deliberately declared ABOVE the camera import below, and deliberately not
175
+ built on anything from `.camera`: this is the class a light gets, and the
176
+ dispatch seam guarantees no camera code runs in a light's path. A reader
177
+ checking that guarantee should be able to see it without leaving this page.
178
+
179
+ It exists because the LAN-login storm is a LIGHT problem. Upstream's
180
+ unbounded retry and its missing read timeout are defects of the TCP:10000
181
+ light protocol, and the six devices that produced 15,376 failed logins in
182
+ one run were lights. A policy carried only by `CameraDeviceClient` cannot
183
+ reach them - it is not in their MRO at all - which is exactly how a release
184
+ shipped claiming the storm was fixed while the log showed 26,229 failures
185
+ and zero occurrences of either new line.
186
+
187
+ Everything else stays upstream's: no method body is copied, and the mixin
188
+ overrides only the two seams that are defective.
189
+ """
190
+
191
+
149
192
  # --------------------------------------------------------------------------- #
150
193
  # Camera surface (additive layer)
151
194
  # --------------------------------------------------------------------------- #
@@ -177,12 +220,17 @@ from .camera.client import (
177
220
  )
178
221
 
179
222
 
180
- class CameraDeviceClient(CameraMixin, _UpstreamDeviceClient):
223
+ class CameraDeviceClient(CameraMixin, LanRetryMixin, _UpstreamDeviceClient):
181
224
  """Upstream device client with the camera surface mixed in.
182
225
 
183
- `CameraMixin` comes first in the MRO so camera behavior wins, but the mixin
184
- defines none of the upstream connection methods, so every `super()` call
185
- below lands on `aidot.device_client.DeviceClient`.
226
+ `CameraMixin` comes first in the MRO so camera behavior wins; it defines
227
+ none of the upstream connection methods, so those `super()` calls fall
228
+ through to `LanRetryMixin` and then to `aidot.device_client.DeviceClient`.
229
+
230
+ `LanRetryMixin` is the same policy a light gets - a camera reaching the LAN
231
+ login path at all is already wrong (see `async_login` below), but if one
232
+ ever does, it should be bounded by the same rules rather than by a second
233
+ copy of them.
186
234
  """
187
235
 
188
236
  # Raw JSON of the frame most recently read off the wire, consumed once by
@@ -270,6 +318,8 @@ class CameraDeviceClient(CameraMixin, _UpstreamDeviceClient):
270
318
  model = getattr(getattr(self, "info", None), "model_id", "") or ""
271
319
  if "IPC" in model:
272
320
  return
321
+ # LanRetryMixin is next in the MRO; it forwards to upstream and clears
322
+ # the consecutive-failure count on a login that got through.
273
323
  await super().async_login()
274
324
 
275
325
  async def close(self) -> None:
@@ -0,0 +1,198 @@
1
+ """Bounded LAN login retries for every device client, camera or not.
2
+
3
+ Upstream (`aidot.device_client`) retries a failed TCP:10000 login with no delay
4
+ and no ceiling, and reads the login response with no timeout at all. Both
5
+ defects belong to the LIGHT protocol; the devices that hit them in the field are
6
+ lights. So the policy lives here, in a module that imports nothing from
7
+ `aidot_cameras.camera` and never will - `LanRetryMixin` is mixed into the plain
8
+ `LightDeviceClient` as well as into `CameraDeviceClient`, and the dispatch seam's
9
+ guarantee that no camera code RUNS in a light's path has to survive that.
10
+
11
+ Keeping it in its own module is the enforcement: `device_client` imports the
12
+ camera package at module scope, so anything defined there is one edit away from
13
+ reaching for a camera symbol. Here, a camera import would be visible on sight.
14
+ """
15
+
16
+ import asyncio
17
+ import logging
18
+ import os
19
+ from typing import Optional
20
+
21
+ _LOGGER = logging.getLogger(__name__)
22
+
23
+ #: How many consecutive failed LAN logins before this device is left alone, and
24
+ #: the ceiling on the delay between them. Both overridable per install.
25
+ _LOGIN_RETRY_LIMIT = int(os.environ.get("AIDOT_LOGIN_RETRY_LIMIT", "6"))
26
+ _LOGIN_RETRY_CAP_S = float(os.environ.get("AIDOT_LOGIN_RETRY_CAP_S", "60"))
27
+ _LOGIN_RETRY_BASE_S = 1.0
28
+
29
+ #: Ceiling on one LAN connect+login attempt. Upstream has no read timeout at
30
+ #: all - `grep -cE "wait_for|timeout"` over its device_client returns 0 - so a
31
+ #: device that accepts the TCP connection and then stops answering parks
32
+ #: `readexactly(8)` forever, inside `connect()`.
33
+ _LOGIN_CONNECT_TIMEOUT_S = float(
34
+ os.environ.get("AIDOT_LOGIN_CONNECT_TIMEOUT_S", "20"))
35
+
36
+
37
+ async def _await_connect_with_deadline(
38
+ connect_coro, timeout: float, device_id: str, on_timeout
39
+ ) -> bool:
40
+ """Await a connect attempt, bounded. True if it finished in time.
41
+
42
+ A parked attempt is worse than a failed one. `connect()`'s
43
+ `finally: self._connecting = False` never runs, so the client still believes
44
+ an attempt is in flight - which wedges both re-entry doors, because the
45
+ retry never spawns and a timer that did fire would hit the in-flight guard
46
+ and return. The device stops being managed, silently, and its socket stays
47
+ open. Observed: four of six devices ended that way, and all six emitted
48
+ their single teardown error within 3 ms of each other, one socket having
49
+ been held for 21 minutes.
50
+
51
+ ``on_timeout`` closes the connection. Its failure is logged and swallowed:
52
+ this runs on the failure path already, and letting a close error replace the
53
+ timeout would turn a handled outcome into an unhandled one. Cancellation of
54
+ the caller is never swallowed - shutdown has to be able to stop this.
55
+ """
56
+ try:
57
+ await asyncio.wait_for(connect_coro, timeout)
58
+ return True
59
+ except asyncio.CancelledError:
60
+ raise
61
+ except TimeoutError: # asyncio.TimeoutError is an alias since 3.11
62
+ _LOGGER.warning(
63
+ "%s: LAN connect/login did not answer within %.0fs - abandoning "
64
+ "this attempt and closing the socket. Override with "
65
+ "AIDOT_LOGIN_CONNECT_TIMEOUT_S.", device_id, timeout,
66
+ )
67
+ try:
68
+ await on_timeout()
69
+ except asyncio.CancelledError:
70
+ raise
71
+ except Exception:
72
+ _LOGGER.debug("%s: cleanup after connect timeout failed",
73
+ device_id, exc_info=True)
74
+ return False
75
+
76
+
77
+ def _next_login_retry_delay(attempt: int) -> Optional[float]:
78
+ """Seconds to wait before login attempt ``attempt``, or None to give up.
79
+
80
+ Upstream retries a failed login with no delay and no ceiling: ``login()``
81
+ logs the error, calls ``reset()``, and ``reset()`` ends in
82
+ ``_schedule_reconnect()``, whose last line is
83
+ ``asyncio.create_task(self.async_login())`` - straight back into
84
+ ``connect()``. The ``loop.call_later(60, ...)`` on the line above never
85
+ fires, because the next ``reset()`` cancels ``_reconnect_handle`` at its own
86
+ top. So the period is the device's login round-trip, not a minute.
87
+
88
+ Measured on a live run: 8,434 of 8,434 failures were followed by that
89
+ device's next connect within a median of 0.295 ms - about 7.6 attempts per
90
+ second for one light, 15,376 across six devices, sustained until the process
91
+ ended. The loop never stops on its own.
92
+
93
+ Exponential from 1 s, capped, and then it stops. The first retry stays
94
+ prompt because a single dropped connection is ordinary and recovering from
95
+ it quickly is the behaviour worth keeping; what is not worth keeping is the
96
+ twelve-thousandth attempt.
97
+ """
98
+ if attempt < 0 or attempt >= _LOGIN_RETRY_LIMIT:
99
+ return None
100
+ return min(_LOGIN_RETRY_BASE_S * (2 ** attempt), _LOGIN_RETRY_CAP_S)
101
+
102
+
103
+ class LanRetryMixin:
104
+ """Bound the LAN login loop for whatever client this is mixed into.
105
+
106
+ Mix it in ahead of `aidot.device_client.DeviceClient`, so every `super()`
107
+ call below lands on upstream. Upstream calls `_schedule_reconnect` and
108
+ `connect` through `self`, so an override here intercepts every path into
109
+ them, including the `loop.call_later(60, self._schedule_reconnect)` re-arm,
110
+ whose bound method is resolved through this MRO too.
111
+
112
+ Nothing here knows what kind of device it is holding. That is the point:
113
+ the six devices that produced the storm were lights, and a policy that only
114
+ a camera class carries cannot reach them.
115
+ """
116
+
117
+ #: Consecutive failed logins so far. Class-level so it reads correctly
118
+ #: before the first failure, and reset on any login that gets through.
119
+ _login_attempt: int = 0
120
+
121
+ #: Ceiling on one connect+login attempt. A class attribute rather than a
122
+ #: bare module read so a subclass (or a test) can shorten it in place.
123
+ _login_connect_timeout_s: float = _LOGIN_CONNECT_TIMEOUT_S
124
+
125
+ async def async_login(self) -> None:
126
+ """Log in, and let a login that got through clear the failure count.
127
+
128
+ The ceiling counts CONSECUTIVE failures, so a device that drops
129
+ occasionally and recovers must never accumulate its way to being
130
+ abandoned. Upstream sets `_connect_and_login` only on the success path,
131
+ which makes it the honest signal to reset against.
132
+ """
133
+ await super().async_login()
134
+ if getattr(self, "_connect_and_login", False):
135
+ self._login_attempt = 0
136
+
137
+ async def connect(self, ip_address) -> None:
138
+ """Bound the attempt, so a silent device cannot park it forever.
139
+
140
+ Upstream reads the login response with no timeout, so a device that
141
+ completes the TCP handshake and then says nothing leaves this coroutine
142
+ parked inside `readexactly`, with `_connecting` still True and the
143
+ socket still open. See _await_connect_with_deadline.
144
+
145
+ On timeout we close through `reset()`, which is also what puts the
146
+ device back on the retry path - now a bounded one.
147
+ """
148
+ ok = await _await_connect_with_deadline(
149
+ super().connect(ip_address),
150
+ self._login_connect_timeout_s,
151
+ getattr(self, "device_id", "?"),
152
+ self.reset,
153
+ )
154
+ if not ok:
155
+ # reset() cleared it, but the parked attempt never ran connect()'s
156
+ # own finally, so make the in-flight flag honest either way.
157
+ self._connecting = False
158
+
159
+ def _schedule_reconnect(self) -> None:
160
+ """Back off between failed logins, and eventually stop.
161
+
162
+ Upstream's version re-arms a 60 s timer that never fires and then
163
+ immediately spawns the next login, so a device that cannot log in is
164
+ retried at its own round-trip rate forever - measured at ~7.6/s per
165
+ device, 15,376 attempts in one 25-minute run across six lights.
166
+
167
+ This replaces the immediate respawn with an exponential delay and a
168
+ ceiling. It deliberately does NOT call super(): the whole of upstream's
169
+ body is the defect - the timer that never fires and the task that fires
170
+ at once.
171
+ """
172
+ if getattr(self, "_is_close", False):
173
+ return
174
+ attempt = getattr(self, "_login_attempt", 0)
175
+ delay = _next_login_retry_delay(attempt)
176
+ if delay is None:
177
+ _LOGGER.warning(
178
+ "%s: giving up LAN login after %d consecutive failures; it will "
179
+ "be retried when something asks for this device again. Set "
180
+ "AIDOT_LOGIN_RETRY_LIMIT to change the ceiling.",
181
+ getattr(self, "device_id", "?"), attempt,
182
+ )
183
+ return
184
+ self._login_attempt = attempt + 1
185
+
186
+ async def _retry() -> None:
187
+ try:
188
+ await asyncio.sleep(delay)
189
+ await self.async_login()
190
+ except asyncio.CancelledError:
191
+ raise
192
+ except Exception:
193
+ _LOGGER.debug(
194
+ "%s: delayed LAN login retry failed",
195
+ getattr(self, "device_id", "?"), exc_info=True,
196
+ )
197
+
198
+ self._login_task = asyncio.create_task(_retry())
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-aidot-cameras"
7
- version = "0.17.1"
7
+ version = "0.17.2"
8
8
  description = "Control AiDot/Leedarson WiFi lights and cameras (WebRTC streaming, two-way audio, PTZ, controls)"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-aidot-cameras
3
- Version: 0.17.1
3
+ Version: 0.17.2
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
@@ -178,6 +178,9 @@ audio, idle release, the sprop cache path) are documented in
178
178
  | `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
179
  | `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
180
  | `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) |
181
+ | `AIDOT_LOGIN_RETRY_LIMIT` | Consecutive failed LAN logins after which a device is left alone (it is retried again the next time something asks for it). Applies to every device, camera or not - the devices that hit this are lights. | `6` |
182
+ | `AIDOT_LOGIN_RETRY_CAP_S` | Ceiling on the exponential delay between those retries. | `60` |
183
+ | `AIDOT_LOGIN_CONNECT_TIMEOUT_S` | Ceiling on one LAN connect+login attempt. A device that completes the TCP handshake and then stops answering is abandoned and its socket closed rather than parking the attempt forever. | `20` |
181
184
  | `AIDOT_LIVESTREAM_PARAM` | **No-op (ignored).** Formerly requested the cloud `liveStreamParam` pre-connect for battery cameras. That call provisions the camera toward AWS KVS, so it sends its media there instead of to this library and the live view negotiates and then shows nothing - and battery cameras were the only ones it applied to. Setting it (or `start_keepalive(live_stream_param=True)`) logs one warning and changes nothing. | ignored |
182
185
 
183
186
  ### Security hardening
@@ -14,6 +14,7 @@ aidot_cameras/device_client.py
14
14
  aidot_cameras/discover.py
15
15
  aidot_cameras/exceptions.py
16
16
  aidot_cameras/g711.py
17
+ aidot_cameras/lan_retry.py
17
18
  aidot_cameras/py.typed
18
19
  aidot_cameras/_vendor/__init__.py
19
20
  aidot_cameras/_vendor/aiortc/__init__.py
@@ -113,10 +114,13 @@ tests/test_keepalive_renew.py
113
114
  tests/test_key_restart_sdp.py
114
115
  tests/test_keyframe_prompter.py
115
116
  tests/test_lan_control.py
117
+ tests/test_lan_retry_reaches_the_storming_devices.py
116
118
  tests/test_light_active_color_mode.py
117
119
  tests/test_live_stream_param.py
118
120
  tests/test_live_validate_policy.py
119
121
  tests/test_logging_caps.py
122
+ tests/test_login_connect_deadline.py
123
+ tests/test_login_reconnect_backoff.py
120
124
  tests/test_media_socket_rcvbuf.py
121
125
  tests/test_media_wait_floor.py
122
126
  tests/test_motion_poll.py
@@ -63,3 +63,55 @@ def test_real_failures_still_warn():
63
63
  assert _classify_ffmpeg_exit(-9, teardown_requested=False) == logging.WARNING
64
64
  # ...but an expected teardown is quiet.
65
65
  assert _classify_ffmpeg_exit(-9, teardown_requested=True) == logging.DEBUG
66
+
67
+
68
+ def test_every_exit_that_stops_streaming_also_deregisters():
69
+ """Enumerate the exits instead of slicing to one of them.
70
+
71
+ The tests above slice from `if _idle_release:` to the end of the source, so
72
+ they can only ever see that one exit. A second exit was added later - the
73
+ futile-keepalive abandon - and it returned without deregistering, leaving a
74
+ dormant camera's stream pointed at a dead serve port. No test could see it,
75
+ because no test looked anywhere else.
76
+
77
+ This walks the function instead: every `return` that follows
78
+ `self._streaming_active = False` must have a `_deregister_go2rtc` call
79
+ between the two. It fails on the next exit added carelessly, which the
80
+ slicing form cannot.
81
+ """
82
+ import ast
83
+ import textwrap
84
+
85
+ for name in ("_sdes_keepalive_loop_inner", "_dtls_serve_loop_inner"):
86
+ tree = ast.parse(textwrap.dedent(_src(name)))
87
+ fn = tree.body[0]
88
+
89
+ stops = [] # (lineno of `_streaming_active = False`)
90
+ for node in ast.walk(fn):
91
+ if not isinstance(node, ast.Assign):
92
+ continue
93
+ for t in node.targets:
94
+ if (isinstance(t, ast.Attribute) and t.attr == "_streaming_active"
95
+ and isinstance(node.value, ast.Constant)
96
+ and node.value.value is False):
97
+ stops.append(node.lineno)
98
+
99
+ assert stops, f"{name}: no `_streaming_active = False` found at all"
100
+
101
+ deregs = [n.lineno for n in ast.walk(fn)
102
+ if isinstance(n, ast.Attribute) and n.attr == "_deregister_go2rtc"]
103
+ returns = [n.lineno for n in ast.walk(fn) if isinstance(n, ast.Return)]
104
+
105
+ for stop in stops:
106
+ # The return that this stop falls through to.
107
+ after = [r for r in returns if r > stop]
108
+ if not after:
109
+ continue # falls through to the loop, not an exit
110
+ exit_line = min(after)
111
+ between = [d for d in deregs if stop < d < exit_line]
112
+ assert between, (
113
+ f"{name}: the exit at line {exit_line} sets _streaming_active="
114
+ f"False (line {stop}) and returns without calling "
115
+ f"_deregister_go2rtc. A dormant camera's go2rtc stream is left "
116
+ f"pointing at a serve port with nothing listening."
117
+ )