python-aidot-cameras 0.12.15__tar.gz → 0.12.16__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 (149) hide show
  1. {python_aidot_cameras-0.12.15/python_aidot_cameras.egg-info → python_aidot_cameras-0.12.16}/PKG-INFO +1 -1
  2. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/aidot_cameras/camera/client.py +47 -0
  3. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/aidot_cameras/camera/webrtc_open.py +34 -1
  4. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/pyproject.toml +1 -1
  5. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  6. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/python_aidot_cameras.egg-info/SOURCES.txt +2 -0
  7. python_aidot_cameras-0.12.16/tests/test_peerid_reuse.py +69 -0
  8. python_aidot_cameras-0.12.16/tests/test_sdes_terminal_ack.py +73 -0
  9. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/LICENSE +0 -0
  10. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/MANIFEST.in +0 -0
  11. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/README.md +0 -0
  12. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/aidot_cameras/__init__.py +0 -0
  13. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/aidot_cameras/__main__.py +0 -0
  14. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/__init__.py +0 -0
  15. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/__init__.py +0 -0
  16. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/clock.py +0 -0
  17. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/codecs/__init__.py +0 -0
  18. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/codecs/base.py +0 -0
  19. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/codecs/g711.py +0 -0
  20. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/codecs/g722.py +0 -0
  21. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/codecs/h264.py +0 -0
  22. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/codecs/opus.py +0 -0
  23. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/codecs/vpx.py +0 -0
  24. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/contrib/__init__.py +0 -0
  25. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/contrib/media.py +0 -0
  26. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/contrib/signaling.py +0 -0
  27. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/events.py +0 -0
  28. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/exceptions.py +0 -0
  29. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/jitterbuffer.py +0 -0
  30. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/mediastreams.py +0 -0
  31. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/rate.py +0 -0
  32. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/rtcconfiguration.py +0 -0
  33. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/rtcdatachannel.py +0 -0
  34. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/rtcdtlstransport.py +0 -0
  35. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/rtcicetransport.py +0 -0
  36. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/rtcpeerconnection.py +0 -0
  37. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/rtcrtpparameters.py +0 -0
  38. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/rtcrtpreceiver.py +0 -0
  39. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/rtcrtpsender.py +0 -0
  40. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/rtcrtptransceiver.py +0 -0
  41. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/rtcsctptransport.py +0 -0
  42. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/rtcsessiondescription.py +0 -0
  43. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/rtp.py +0 -0
  44. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/sdp.py +0 -0
  45. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/stats.py +0 -0
  46. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/utils.py +0 -0
  47. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/aidot_cameras/camera/__init__.py +0 -0
  48. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/aidot_cameras/camera/constants.py +0 -0
  49. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/aidot_cameras/camera/controls.py +0 -0
  50. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/aidot_cameras/camera/go2rtc.py +0 -0
  51. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/aidot_cameras/camera/lan_control.py +0 -0
  52. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/aidot_cameras/camera/models.py +0 -0
  53. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/aidot_cameras/camera/playback.py +0 -0
  54. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/aidot_cameras/camera/protocol.py +0 -0
  55. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/aidot_cameras/camera/sdes.py +0 -0
  56. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/aidot_cameras/camera/sdes_open.py +0 -0
  57. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/aidot_cameras/camera/tutk.py +0 -0
  58. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/aidot_cameras/camera/webrtc.py +0 -0
  59. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/aidot_cameras/client.py +0 -0
  60. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/aidot_cameras/const.py +0 -0
  61. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/aidot_cameras/credentials.py +0 -0
  62. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/aidot_cameras/crypto.py +0 -0
  63. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/aidot_cameras/device_client.py +0 -0
  64. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/aidot_cameras/discover.py +0 -0
  65. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/aidot_cameras/exceptions.py +0 -0
  66. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/aidot_cameras/g711.py +0 -0
  67. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/aidot_cameras/py.typed +0 -0
  68. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  69. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
  70. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/python_aidot_cameras.egg-info/requires.txt +0 -0
  71. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/python_aidot_cameras.egg-info/top_level.txt +0 -0
  72. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/setup.cfg +0 -0
  73. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_aioice_compat.py +0 -0
  74. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_alarm_event.py +0 -0
  75. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_backoff.py +0 -0
  76. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_battery_skip_turn.py +0 -0
  77. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_deferred_hardening.py +0 -0
  78. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_device_login_guard.py +0 -0
  79. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_device_user_info_cache.py +0 -0
  80. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_dormant_camera_cleanup.py +0 -0
  81. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_dtls_mux_terminal_write.py +0 -0
  82. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_dtls_not_ready_burst.py +0 -0
  83. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_dtls_pinning.py +0 -0
  84. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_dtls_serve_decode_skip.py +0 -0
  85. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_dtls_serve_open_timeout.py +0 -0
  86. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_dtls_skip_signaling_wait.py +0 -0
  87. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_dtls_slow_probe.py +0 -0
  88. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_egress_guard.py +0 -0
  89. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_go2rtc.py +0 -0
  90. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_go2rtc_cli.py +0 -0
  91. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_h264_decode_rate_limit.py +0 -0
  92. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_highport_nomination.py +0 -0
  93. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_ice_config_cache.py +0 -0
  94. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_idle_release_both_loops.py +0 -0
  95. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_keepalive_renew.py +0 -0
  96. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_key_restart_sdp.py +0 -0
  97. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_keyframe_prompter.py +0 -0
  98. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_lan_control.py +0 -0
  99. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_light_active_color_mode.py +0 -0
  100. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_live_stream_param.py +0 -0
  101. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_logging_caps.py +0 -0
  102. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_motion_poll.py +0 -0
  103. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_mqtt_credential_refetch.py +0 -0
  104. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_mqtt_credential_self_heal.py +0 -0
  105. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_mqtt_password_not_persisted.py +0 -0
  106. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_mqtt_session_reconnect.py +0 -0
  107. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_narrow_pc_ice.py +0 -0
  108. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_no_self_referential_go2rtc.py +0 -0
  109. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_no_undefined_names.py +0 -0
  110. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_offline_keepalive_pause.py +0 -0
  111. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_open_fail_logger.py +0 -0
  112. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_open_gate_delay.py +0 -0
  113. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_persistent_mqtt.py +0 -0
  114. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_persistent_mqtt_robustness.py +0 -0
  115. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_playback_tls.py +0 -0
  116. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_post_merge_hardening.py +0 -0
  117. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_retry_policy.py +0 -0
  118. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_sdes_adaptive.py +0 -0
  119. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_sdes_bridge_break_gate.py +0 -0
  120. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_sdes_echo_wait_timeout.py +0 -0
  121. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_sdes_fast_liveplay.py +0 -0
  122. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_sdes_ffmpeg_stderr_log.py +0 -0
  123. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_sdes_idle_release.py +0 -0
  124. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_sdes_open_cleanup.py +0 -0
  125. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_sdes_serve_audio.py +0 -0
  126. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_sdes_serve_cmd.py +0 -0
  127. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_sdes_sprop.py +0 -0
  128. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_sdes_talk.py +0 -0
  129. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_sdes_teardown_exit_log.py +0 -0
  130. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_sdes_watchdog.py +0 -0
  131. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_sdp_payload_type_narrowing.py +0 -0
  132. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_security_hardening.py +0 -0
  133. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_serializable_login_info.py +0 -0
  134. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_serve_relay.py +0 -0
  135. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_session_stats.py +0 -0
  136. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_speak.py +0 -0
  137. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_stream_cap.py +0 -0
  138. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_stream_drain_ownership.py +0 -0
  139. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_stream_idle.py +0 -0
  140. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_stream_slot_cap.py +0 -0
  141. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_stream_teardown.py +0 -0
  142. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_talk.py +0 -0
  143. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_terminal_ack.py +0 -0
  144. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_token_file_robustness.py +0 -0
  145. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_token_refresh.py +0 -0
  146. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_upstream_compat.py +0 -0
  147. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_viewer_check_throttle.py +0 -0
  148. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_viewer_presence.py +0 -0
  149. {python_aidot_cameras-0.12.15 → python_aidot_cameras-0.12.16}/tests/test_wait_or_event.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-aidot-cameras
3
- Version: 0.12.15
3
+ Version: 0.12.16
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
@@ -3057,11 +3057,30 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3057
3057
  # lifetime; an integration reload / restart re-probes the fast path.
3058
3058
  _fast_failed = bool(getattr(self, "_fast_path_unavailable", False))
3059
3059
 
3060
+ # Reuse ONE peerid across the retries of this loop instead of minting a
3061
+ # fresh one per attempt. A fresh peerid = a new camera-side session, and
3062
+ # the camera frees old ones only slowly, so mint-per-retry stacks up
3063
+ # sessions on a failing battery camera faster than they drain (measured:
3064
+ # this is how the L2 wedged into a wake-then-sleep loop). Mirrors the
3065
+ # official app, which resends within one session. Rotate to a fresh
3066
+ # peerid after a session that actually delivered media (so the next view
3067
+ # is a clean session) or after _PEERID_MAX_REUSE consecutive dead
3068
+ # attempts (so a poisoned peerid can't wedge us permanently).
3069
+ _PEERID_MAX_REUSE = 3
3070
+ _loop_peer_id = self.generate_webrtc_peer_id(live_type=2, stream_id=0,
3071
+ sdes=True)
3072
+ _peer_reuses = 0
3073
+
3060
3074
  while self._streaming_active:
3061
3075
  if self._serve_relay is not None:
3062
3076
  # Clear any stale backend from a prior session; the open below
3063
3077
  # points the relay at this session's fresh internal ffmpeg port.
3064
3078
  self._serve_relay.set_backend(None)
3079
+ if _peer_reuses >= _PEERID_MAX_REUSE:
3080
+ _loop_peer_id = self.generate_webrtc_peer_id(
3081
+ live_type=2, stream_id=0, sdes=True)
3082
+ _peer_reuses = 0
3083
+ _peer_reuses += 1
3065
3084
  _use_fast = self._adaptive_next_fast(_adaptive, _fast_failed)
3066
3085
  if _adaptive:
3067
3086
  self._fast_attempt_override = _use_fast
@@ -3069,10 +3088,31 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3069
3088
  session = await self.async_open_webrtc_stream(
3070
3089
  rtsp_push_url=self._keepalive_rtsp_url,
3071
3090
  timeout=(_FAST_OPEN_TIMEOUT if _use_fast else 120.0),
3091
+ reuse_peer_id=_loop_peer_id,
3072
3092
  )
3073
3093
  except asyncio.CancelledError:
3074
3094
  self._fast_attempt_override = None
3075
3095
  return
3096
+ except AidotCameraBusy as busy:
3097
+ # The camera itself said it has no free session (-50002 max
3098
+ # concurrent streams / -50015). Each retry mints a NEW peerid and
3099
+ # so registers ANOTHER camera-side session, which the camera only
3100
+ # releases slowly - so retrying on the short backoff makes the
3101
+ # shortage worse and can wedge a battery camera into a
3102
+ # wake-then-sleep loop that serves nothing. Wait out the release
3103
+ # window instead. (The DTLS loops have always done this; the SDES
3104
+ # loop silently did not.)
3105
+ self._fast_attempt_override = None
3106
+ _LOGGER.warning(
3107
+ "SDES keepalive: camera %s refused the stream (%s) - "
3108
+ "backing off %.0fs so its sessions can be released",
3109
+ self.device_id, busy, _MAX_DELAY,
3110
+ )
3111
+ try:
3112
+ await asyncio.sleep(_MAX_DELAY)
3113
+ except asyncio.CancelledError:
3114
+ return
3115
+ continue
3076
3116
  except Exception as exc:
3077
3117
  self._fast_attempt_override = None
3078
3118
  if _use_fast:
@@ -3197,6 +3237,13 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3197
3237
  # Adaptive bookkeeping: a fast attempt that never delivered media
3198
3238
  # latches the loop onto the full relay path for its remaining opens.
3199
3239
  _healthy = session.last_media_monotonic > 0.0
3240
+ if _healthy:
3241
+ # A session that delivered media is a real, cleanly-used session;
3242
+ # the next open should be a fresh one rather than re-offering on a
3243
+ # peerid the camera has already finished with.
3244
+ _loop_peer_id = self.generate_webrtc_peer_id(
3245
+ live_type=2, stream_id=0, sdes=True)
3246
+ _peer_reuses = 0
3200
3247
  if _use_fast and not _healthy and not _fast_failed:
3201
3248
  _LOGGER.info(
3202
3249
  "SDES adaptive[%s]: fast attempt delivered no media - "
@@ -159,6 +159,7 @@ class _WebRTCOpenMixin:
159
159
  rtsp_push_url: Optional[str] = None,
160
160
  talk_pcm_provider: "Optional[Callable[[], Optional[bytes]]]" = None,
161
161
  talk: bool = False,
162
+ reuse_peer_id: Optional[str] = None,
162
163
  ) -> "WebRTCSession":
163
164
  """Open a liveType=2 WebRTC stream via MQTT signaling.
164
165
 
@@ -497,7 +498,14 @@ class _WebRTCOpenMixin:
497
498
  _dtls_fallback_ok = str(_cam_props.get("isDTLS", "1")) != "0"
498
499
 
499
500
  device_id = self.device_id
500
- peer_id = self.generate_webrtc_peer_id(
501
+ # A fresh peerid mints a NEW camera-side session, and the camera releases
502
+ # old ones only slowly (~3-4 min measured), so minting one on every retry
503
+ # of a failing loop stacks up sessions faster than they drain and can wedge
504
+ # a battery camera. The official app instead reuses one peerid and resends
505
+ # the same offer within a single session. ``reuse_peer_id`` lets a caller
506
+ # (the SDES keepalive loop) hold one peerid across retries; None keeps the
507
+ # historical mint-per-attempt behaviour for every other caller.
508
+ peer_id = reuse_peer_id or self.generate_webrtc_peer_id(
501
509
  live_type=2, stream_id=stream_id, sdes=use_sdes
502
510
  )
503
511
  loop = asyncio.get_running_loop()
@@ -1212,7 +1220,26 @@ class _WebRTCOpenMixin:
1212
1220
  # Branch: SDES-SRTP cameras use ffmpeg; DTLS cameras use aiortc
1213
1221
  # ------------------------------------------------------------------ #
1214
1222
  if use_sdes:
1223
+ def _raise_if_camera_refused():
1224
+ """Surface a terminal webrtcResp ack on the SDES path too.
1225
+
1226
+ GAP D was only wired into the DTLS branch: the shared message
1227
+ handler records -50002 (max concurrent streams) / -50015 into
1228
+ ``terminal_error_fut`` for BOTH transports, but only the DTLS
1229
+ connect ever read it. So when a battery camera answered "no
1230
+ free session", the SDES keepalive loop treated it as a generic
1231
+ failure and retried on the short backoff - hammering the very
1232
+ camera that had just said it was full, and registering yet
1233
+ another peerid each time. Raising AidotCameraBusy lets the
1234
+ loop back off for the camera's release window instead.
1235
+ """
1236
+ if terminal_error_fut.done():
1237
+ _code, _desc = terminal_error_fut.result()
1238
+ _status(f"camera refused the stream (code={_code}) - not retrying")
1239
+ raise AidotCameraBusy(_code, _desc)
1240
+
1215
1241
  try:
1242
+ _raise_if_camera_refused()
1216
1243
  _sdes_session = await self._open_sdes_stream(
1217
1244
  peer_id=peer_id,
1218
1245
  user_id=user_id,
@@ -1248,7 +1275,13 @@ class _WebRTCOpenMixin:
1248
1275
  # DTLS-fallback path below keeps the slot for its own hand-off.)
1249
1276
  self._release_stream_drain_to_session()
1250
1277
  return _sdes_session
1278
+ except AidotCameraBusy:
1279
+ raise
1251
1280
  except CameraMixin._SdesNoAnswerError:
1281
+ # A terminal refusal that lands while we were waiting surfaces
1282
+ # as a no-answer; check before spending a DTLS fallback on a
1283
+ # camera that has already told us it has no free session.
1284
+ _raise_if_camera_refused()
1252
1285
  # Camera reported enableSdes='1' but did not respond to our SDES
1253
1286
  # offer. iOS telemetry shows models such as LK.IPC.A001064 can
1254
1287
  # have an incorrectly set enableSdes property while actually
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-aidot-cameras"
7
- version = "0.12.15"
7
+ version = "0.12.16"
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.12.15
3
+ Version: 0.12.16
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
@@ -106,6 +106,7 @@ tests/test_no_undefined_names.py
106
106
  tests/test_offline_keepalive_pause.py
107
107
  tests/test_open_fail_logger.py
108
108
  tests/test_open_gate_delay.py
109
+ tests/test_peerid_reuse.py
109
110
  tests/test_persistent_mqtt.py
110
111
  tests/test_persistent_mqtt_robustness.py
111
112
  tests/test_playback_tls.py
@@ -123,6 +124,7 @@ tests/test_sdes_serve_cmd.py
123
124
  tests/test_sdes_sprop.py
124
125
  tests/test_sdes_talk.py
125
126
  tests/test_sdes_teardown_exit_log.py
127
+ tests/test_sdes_terminal_ack.py
126
128
  tests/test_sdes_watchdog.py
127
129
  tests/test_sdp_payload_type_narrowing.py
128
130
  tests/test_security_hardening.py
@@ -0,0 +1,69 @@
1
+ """The SDES keepalive loop reuses one peerid across retries instead of minting
2
+ a fresh one every attempt.
3
+
4
+ A fresh peerid registers a NEW camera-side session, and the camera releases old
5
+ ones only slowly (~3-4 min measured), so minting one per retry on a failing loop
6
+ stacks up sessions faster than they drain - which is how a battery A001513 gets
7
+ wedged into a wake-then-sleep loop that serves nothing. The official app reuses a
8
+ peerid and resends within one session. This locks:
9
+ - the impl accepts reuse_peer_id and uses it verbatim when given;
10
+ - it still mints a fresh one when not given (unchanged for every other caller);
11
+ - the SDES keepalive loop holds one across retries and rotates it after a
12
+ media-delivering session or a reuse cap.
13
+ """
14
+ import inspect
15
+ import os
16
+ import sys
17
+
18
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
19
+
20
+ import aidot_cameras.camera.client as cc
21
+
22
+
23
+ def _impl_signature():
24
+ for cls in vars(cc).values():
25
+ if isinstance(cls, type) and "_async_open_webrtc_stream_impl" in cls.__dict__:
26
+ return inspect.signature(cls.__dict__["_async_open_webrtc_stream_impl"])
27
+ raise AssertionError("impl not found")
28
+
29
+
30
+ def _fn_source(qualname):
31
+ for cls in vars(cc).values():
32
+ if isinstance(cls, type) and qualname in cls.__dict__:
33
+ return inspect.getsource(cls.__dict__[qualname])
34
+ raise AssertionError(f"{qualname} not found")
35
+
36
+
37
+ def test_impl_accepts_reuse_peer_id_defaulting_to_none():
38
+ p = _impl_signature().parameters.get("reuse_peer_id")
39
+ assert p is not None, "impl must accept reuse_peer_id"
40
+ assert p.default is None, "default None preserves mint-per-call for other callers"
41
+
42
+
43
+ def test_impl_uses_reuse_peer_id_verbatim_when_given():
44
+ src = _fn_source("_async_open_webrtc_stream_impl")
45
+ # peer_id = reuse_peer_id or generate_webrtc_peer_id(...)
46
+ assert "reuse_peer_id or" in src, (
47
+ "when a peerid is supplied it must be used as-is, not regenerated")
48
+
49
+
50
+ def test_sdes_keepalive_loop_holds_and_rotates_one_peerid():
51
+ src = _fn_source("_sdes_keepalive_loop_inner")
52
+ assert "reuse_peer_id=" in src, "the SDES loop must pass a reused peerid"
53
+ assert "_loop_peer_id" in src
54
+ # rotate after a media-delivering session and after a reuse cap
55
+ assert "_PEERID_MAX_REUSE" in src, "must cap consecutive reuses"
56
+ assert src.count("generate_webrtc_peer_id") >= 2, (
57
+ "must be able to rotate to a fresh peerid (initial + rotation)")
58
+
59
+
60
+ def test_generate_peer_id_is_unique_per_call():
61
+ gen = None
62
+ for cls in vars(cc).values():
63
+ if isinstance(cls, type) and "generate_webrtc_peer_id" in cls.__dict__:
64
+ gen = cls.generate_webrtc_peer_id
65
+ break
66
+ assert gen is not None
67
+ a = gen(live_type=2, stream_id=0, sdes=True)
68
+ b = gen(live_type=2, stream_id=0, sdes=True)
69
+ assert a != b and a.endswith("_2_0_1") and b.endswith("_2_0_1")
@@ -0,0 +1,73 @@
1
+ """A terminal webrtcResp ack must be honoured on the SDES path, not just DTLS.
2
+
3
+ The shared message handler records -50002 ("max concurrent streams") and -50015
4
+ into ``terminal_error_fut`` for BOTH transports, but only the DTLS connect ever
5
+ read it. So when a battery camera answered "no free session", the SDES keepalive
6
+ loop saw a generic failure and retried on the short backoff - hammering the very
7
+ camera that had just said it was full, and minting a NEW peerid (= another
8
+ camera-side session, released only slowly) on every attempt. That is how a
9
+ battery A001513 gets wedged into a wake-then-sleep loop that serves nothing.
10
+
11
+ These tests lock the two halves of the fix: the codes are classified as
12
+ terminal, and the SDES keepalive loop treats AidotCameraBusy as "back off for
13
+ the release window" rather than "retry immediately".
14
+ """
15
+ import inspect
16
+ import os
17
+ import sys
18
+
19
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
20
+
21
+ from aidot_cameras.camera.protocol import (
22
+ _WEBRTC_TERMINAL_ACK_CODES,
23
+ _terminal_webrtc_ack,
24
+ )
25
+ import aidot_cameras.camera.client as cc
26
+ import aidot_cameras.camera.webrtc_open as wo
27
+
28
+
29
+ def test_session_exceed_codes_are_terminal():
30
+ assert -50002 in _WEBRTC_TERMINAL_ACK_CODES
31
+ assert -50015 in _WEBRTC_TERMINAL_ACK_CODES
32
+
33
+
34
+ def test_terminal_ack_parses_session_exceed():
35
+ msg = {"method": "webrtcResp", "ack": {"code": -50002, "desc": "SESSION_EXCEED"}}
36
+ got = _terminal_webrtc_ack(msg)
37
+ assert got is not None
38
+ assert got[0] == -50002
39
+
40
+
41
+ def test_transient_ack_is_not_terminal():
42
+ # -50019 ("not ready") is benign: mains cameras emit it and recover via ICE.
43
+ msg = {"method": "webrtcResp", "ack": {"code": -50019, "desc": "not ready"}}
44
+ assert _terminal_webrtc_ack(msg) is None
45
+
46
+
47
+ def _fn_source(mod, qualname):
48
+ """Source of a method by name, without relying on file offsets."""
49
+ for cls in vars(mod).values():
50
+ if isinstance(cls, type):
51
+ fn = cls.__dict__.get(qualname)
52
+ if fn is not None:
53
+ return inspect.getsource(fn)
54
+ raise AssertionError(f"{qualname} not found in {mod.__name__}")
55
+
56
+
57
+ def test_sdes_open_path_checks_the_terminal_future():
58
+ # The SDES branch must consult terminal_error_fut; before the fix it never did.
59
+ src = _fn_source(wo, "_async_open_webrtc_stream_impl")
60
+ sdes_branch = src[src.rindex("if use_sdes:"):]
61
+ assert "terminal_error_fut" in sdes_branch, (
62
+ "SDES branch must read terminal_error_fut (GAP D was DTLS-only)")
63
+ assert "AidotCameraBusy" in sdes_branch
64
+
65
+
66
+ def test_sdes_keepalive_loop_backs_off_on_camera_busy():
67
+ loop_src = _fn_source(cc, "_sdes_keepalive_loop_inner")
68
+ assert "except AidotCameraBusy" in loop_src, (
69
+ "the SDES keepalive loop must honour a camera refusal instead of "
70
+ "retrying on the short backoff")
71
+ busy_at = loop_src.index("except AidotCameraBusy")
72
+ assert "_MAX_DELAY" in loop_src[busy_at:busy_at + 1600], (
73
+ "a camera refusal must wait out the long release window")