python-aidot-cameras 0.12.14__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.14/python_aidot_cameras.egg-info → python_aidot_cameras-0.12.16}/PKG-INFO +1 -1
  2. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/camera/client.py +71 -5
  3. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/camera/sdes_open.py +57 -32
  4. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/camera/webrtc_open.py +46 -9
  5. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/pyproject.toml +1 -1
  6. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  7. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/python_aidot_cameras.egg-info/SOURCES.txt +3 -0
  8. python_aidot_cameras-0.12.16/tests/test_battery_skip_turn.py +59 -0
  9. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_live_stream_param.py +48 -0
  10. python_aidot_cameras-0.12.16/tests/test_peerid_reuse.py +69 -0
  11. python_aidot_cameras-0.12.16/tests/test_sdes_terminal_ack.py +73 -0
  12. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_sdp_payload_type_narrowing.py +68 -0
  13. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/LICENSE +0 -0
  14. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/MANIFEST.in +0 -0
  15. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/README.md +0 -0
  16. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/__init__.py +0 -0
  17. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/__main__.py +0 -0
  18. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/__init__.py +0 -0
  19. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/__init__.py +0 -0
  20. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/clock.py +0 -0
  21. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/codecs/__init__.py +0 -0
  22. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/codecs/base.py +0 -0
  23. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/codecs/g711.py +0 -0
  24. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/codecs/g722.py +0 -0
  25. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/codecs/h264.py +0 -0
  26. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/codecs/opus.py +0 -0
  27. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/codecs/vpx.py +0 -0
  28. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/contrib/__init__.py +0 -0
  29. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/contrib/media.py +0 -0
  30. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/contrib/signaling.py +0 -0
  31. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/events.py +0 -0
  32. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/exceptions.py +0 -0
  33. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/jitterbuffer.py +0 -0
  34. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/mediastreams.py +0 -0
  35. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/rate.py +0 -0
  36. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/rtcconfiguration.py +0 -0
  37. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/rtcdatachannel.py +0 -0
  38. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/rtcdtlstransport.py +0 -0
  39. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/rtcicetransport.py +0 -0
  40. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/rtcpeerconnection.py +0 -0
  41. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/rtcrtpparameters.py +0 -0
  42. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/rtcrtpreceiver.py +0 -0
  43. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/rtcrtpsender.py +0 -0
  44. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/rtcrtptransceiver.py +0 -0
  45. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/rtcsctptransport.py +0 -0
  46. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/rtcsessiondescription.py +0 -0
  47. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/rtp.py +0 -0
  48. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/sdp.py +0 -0
  49. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/stats.py +0 -0
  50. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/utils.py +0 -0
  51. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/camera/__init__.py +0 -0
  52. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/camera/constants.py +0 -0
  53. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/camera/controls.py +0 -0
  54. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/camera/go2rtc.py +0 -0
  55. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/camera/lan_control.py +0 -0
  56. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/camera/models.py +0 -0
  57. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/camera/playback.py +0 -0
  58. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/camera/protocol.py +0 -0
  59. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/camera/sdes.py +0 -0
  60. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/camera/tutk.py +0 -0
  61. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/camera/webrtc.py +0 -0
  62. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/client.py +0 -0
  63. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/const.py +0 -0
  64. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/credentials.py +0 -0
  65. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/crypto.py +0 -0
  66. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/device_client.py +0 -0
  67. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/discover.py +0 -0
  68. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/exceptions.py +0 -0
  69. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/g711.py +0 -0
  70. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/py.typed +0 -0
  71. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  72. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
  73. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/python_aidot_cameras.egg-info/requires.txt +0 -0
  74. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/python_aidot_cameras.egg-info/top_level.txt +0 -0
  75. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/setup.cfg +0 -0
  76. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_aioice_compat.py +0 -0
  77. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_alarm_event.py +0 -0
  78. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_backoff.py +0 -0
  79. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_deferred_hardening.py +0 -0
  80. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_device_login_guard.py +0 -0
  81. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_device_user_info_cache.py +0 -0
  82. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_dormant_camera_cleanup.py +0 -0
  83. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_dtls_mux_terminal_write.py +0 -0
  84. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_dtls_not_ready_burst.py +0 -0
  85. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_dtls_pinning.py +0 -0
  86. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_dtls_serve_decode_skip.py +0 -0
  87. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_dtls_serve_open_timeout.py +0 -0
  88. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_dtls_skip_signaling_wait.py +0 -0
  89. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_dtls_slow_probe.py +0 -0
  90. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_egress_guard.py +0 -0
  91. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_go2rtc.py +0 -0
  92. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_go2rtc_cli.py +0 -0
  93. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_h264_decode_rate_limit.py +0 -0
  94. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_highport_nomination.py +0 -0
  95. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_ice_config_cache.py +0 -0
  96. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_idle_release_both_loops.py +0 -0
  97. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_keepalive_renew.py +0 -0
  98. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_key_restart_sdp.py +0 -0
  99. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_keyframe_prompter.py +0 -0
  100. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_lan_control.py +0 -0
  101. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_light_active_color_mode.py +0 -0
  102. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_logging_caps.py +0 -0
  103. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_motion_poll.py +0 -0
  104. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_mqtt_credential_refetch.py +0 -0
  105. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_mqtt_credential_self_heal.py +0 -0
  106. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_mqtt_password_not_persisted.py +0 -0
  107. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_mqtt_session_reconnect.py +0 -0
  108. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_narrow_pc_ice.py +0 -0
  109. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_no_self_referential_go2rtc.py +0 -0
  110. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_no_undefined_names.py +0 -0
  111. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_offline_keepalive_pause.py +0 -0
  112. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_open_fail_logger.py +0 -0
  113. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_open_gate_delay.py +0 -0
  114. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_persistent_mqtt.py +0 -0
  115. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_persistent_mqtt_robustness.py +0 -0
  116. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_playback_tls.py +0 -0
  117. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_post_merge_hardening.py +0 -0
  118. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_retry_policy.py +0 -0
  119. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_sdes_adaptive.py +0 -0
  120. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_sdes_bridge_break_gate.py +0 -0
  121. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_sdes_echo_wait_timeout.py +0 -0
  122. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_sdes_fast_liveplay.py +0 -0
  123. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_sdes_ffmpeg_stderr_log.py +0 -0
  124. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_sdes_idle_release.py +0 -0
  125. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_sdes_open_cleanup.py +0 -0
  126. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_sdes_serve_audio.py +0 -0
  127. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_sdes_serve_cmd.py +0 -0
  128. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_sdes_sprop.py +0 -0
  129. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_sdes_talk.py +0 -0
  130. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_sdes_teardown_exit_log.py +0 -0
  131. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_sdes_watchdog.py +0 -0
  132. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_security_hardening.py +0 -0
  133. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_serializable_login_info.py +0 -0
  134. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_serve_relay.py +0 -0
  135. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_session_stats.py +0 -0
  136. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_speak.py +0 -0
  137. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_stream_cap.py +0 -0
  138. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_stream_drain_ownership.py +0 -0
  139. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_stream_idle.py +0 -0
  140. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_stream_slot_cap.py +0 -0
  141. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_stream_teardown.py +0 -0
  142. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_talk.py +0 -0
  143. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_terminal_ack.py +0 -0
  144. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_token_file_robustness.py +0 -0
  145. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_token_refresh.py +0 -0
  146. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_upstream_compat.py +0 -0
  147. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_viewer_check_throttle.py +0 -0
  148. {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_viewer_presence.py +0 -0
  149. {python_aidot_cameras-0.12.14 → 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.14
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
@@ -369,6 +369,7 @@ def _build_sdes_serve_cmd(
369
369
  max_seconds: Optional[float] = None,
370
370
  sdes_audio: bool = False,
371
371
  audio_gain_db: float = -8.0,
372
+ push_video_only: bool = False,
372
373
  ) -> list:
373
374
  """Build the ffmpeg argv for the SDES bridge serve.
374
375
 
@@ -425,8 +426,13 @@ def _build_sdes_serve_cmd(
425
426
  rtsp_push_url,
426
427
  ]
427
428
  elif rtsp_push_url:
429
+ # PUSH is G.711 passthrough (-c copy) as always - EXCEPT when the audio
430
+ # payload type was never observed (push_video_only): announcing an
431
+ # un-narrowed audio line the server cannot accept kills the whole
432
+ # publish with 400 Bad Request, so map video only and announce clean.
428
433
  dest_args = [
429
- "-c", "copy",
434
+ *(["-map", "0:v:0", "-c:v", "copy"] if push_video_only
435
+ else ["-c", "copy"]),
430
436
  *time_args,
431
437
  "-f", "rtsp", "-rtsp_transport", "tcp",
432
438
  rtsp_push_url,
@@ -2695,9 +2701,11 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2695
2701
  from a Home Assistant config-entry option, since HA OS can't set env vars).
2696
2702
 
2697
2703
  ``live_stream_param`` toggles the battery-camera cloud pre-connect
2698
- (liveStreamParam); like ``fast_connect`` it overrides the
2699
- ``AIDOT_LIVESTREAM_PARAM`` env var, so an integration on HA OS (no env vars)
2700
- can still disable it per camera.
2704
+ (liveStreamParam), which is **off by default**: it provisions the camera
2705
+ toward AWS KVS and diverts an A001513's media away from the SDES bridge
2706
+ (no video RTP arrives). Like ``fast_connect`` it overrides the
2707
+ ``AIDOT_LIVESTREAM_PARAM`` env var; set True only to re-enable the
2708
+ pre-connect for a camera that needs it.
2701
2709
 
2702
2710
  ``serve_relay`` toggles the cold-start serve-port relay (holds the public
2703
2711
  serve port connectable through the WebRTC handshake so an eager go2rtc
@@ -3049,11 +3057,30 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3049
3057
  # lifetime; an integration reload / restart re-probes the fast path.
3050
3058
  _fast_failed = bool(getattr(self, "_fast_path_unavailable", False))
3051
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
+
3052
3074
  while self._streaming_active:
3053
3075
  if self._serve_relay is not None:
3054
3076
  # Clear any stale backend from a prior session; the open below
3055
3077
  # points the relay at this session's fresh internal ffmpeg port.
3056
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
3057
3084
  _use_fast = self._adaptive_next_fast(_adaptive, _fast_failed)
3058
3085
  if _adaptive:
3059
3086
  self._fast_attempt_override = _use_fast
@@ -3061,10 +3088,31 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3061
3088
  session = await self.async_open_webrtc_stream(
3062
3089
  rtsp_push_url=self._keepalive_rtsp_url,
3063
3090
  timeout=(_FAST_OPEN_TIMEOUT if _use_fast else 120.0),
3091
+ reuse_peer_id=_loop_peer_id,
3064
3092
  )
3065
3093
  except asyncio.CancelledError:
3066
3094
  self._fast_attempt_override = None
3067
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
3068
3116
  except Exception as exc:
3069
3117
  self._fast_attempt_override = None
3070
3118
  if _use_fast:
@@ -3189,6 +3237,13 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3189
3237
  # Adaptive bookkeeping: a fast attempt that never delivered media
3190
3238
  # latches the loop onto the full relay path for its remaining opens.
3191
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
3192
3247
  if _use_fast and not _healthy and not _fast_failed:
3193
3248
  _LOGGER.info(
3194
3249
  "SDES adaptive[%s]: fast attempt delivered no media - "
@@ -3571,7 +3626,18 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3571
3626
 
3572
3627
  Per-camera ``sdes_skip_turn`` (set via start_keepalive) wins; else the
3573
3628
  ``AIDOT_SDES_SKIP_TURN_PREALLOC`` env (truthy = 1/true/yes/on), default
3574
- off. Only consulted for SDES cameras."""
3629
+ off. Only consulted for SDES cameras.
3630
+
3631
+ NEVER skipped for a battery camera, whatever the opt/env say: a battery
3632
+ camera sleeps and is woken through the cloud, so it is reached over the
3633
+ TURN relay rather than a host-direct LAN path. Skipping the relay
3634
+ pre-allocation there means the camera has no reachable path back and
3635
+ sends no media at all (validated live on an A001513: with the relay it
3636
+ streams h264 1280x960, with skip_turn it serves nothing). The LAN-direct
3637
+ latency win only applies to a mains camera that actually has a host
3638
+ candidate on the HA segment."""
3639
+ if getattr(self, "is_battery_camera", False):
3640
+ return False
3575
3641
  opt = getattr(self, "_sdes_skip_turn_opt", None)
3576
3642
  if opt is not None:
3577
3643
  return bool(opt)
@@ -36,7 +36,9 @@ _LOGGER = logging.getLogger(__name__)
36
36
 
37
37
  # Payload types the ffmpeg SDP advertises per media line, and their alternates.
38
38
  # The SDP offers both because the camera picks one per session.
39
- _SDP_ALTERNATE_PT = {96: 97, 97: 96, 0: 8, 8: 0}
39
+ # Video payloads the cameras negotiate: 96=H.264, 97/98=H.265 (two variants).
40
+ _SDP_VIDEO_PTS = (96, 97, 98)
41
+ _SDP_AUDIO_PTS = (0, 8)
40
42
 
41
43
  # How long to wait for the FIRST media of a session before launching the serve.
42
44
  # Nothing useful can happen before then: the payload types are unknown, so the SDP
@@ -70,16 +72,33 @@ def narrow_sdp_payload_types(sdp_text: str, keep_video=None, keep_audio=None) ->
70
72
  included - while every other signal looks healthy.
71
73
 
72
74
  ``keep_video`` / ``keep_audio`` of None leaves that line alone.
75
+
76
+ Handles any payload list on the m-line (``96 97``, ``96 97 98``, ...): the
77
+ kept line advertises ONLY the kept payload and the rtpmap/fmtp lines of
78
+ every dropped payload are removed. The old implementation replaced the
79
+ literal ``" 96 97"``, so the day the SDP template grew a third video
80
+ payload (98, the second H.265 variant) narrowing silently stopped working
81
+ for it - and an un-narrowed multi-codec SDP makes the RTSP-push ANNOUNCE
82
+ carry a parameterless H.265 stream that go2rtc rejects with 400.
73
83
  """
74
- drop = {_SDP_ALTERNATE_PT[k] for k in (keep_video, keep_audio)
75
- if k in _SDP_ALTERNATE_PT}
84
+ drop: set = set()
76
85
  kept = []
77
86
  for line in sdp_text.splitlines(keepends=True):
78
87
  stripped = line.lstrip()
79
- if stripped.startswith("m=video") and keep_video is not None:
80
- line = line.replace(" 96 97", f" {keep_video}")
81
- elif stripped.startswith("m=audio") and keep_audio is not None:
82
- line = line.replace(" 0 8", f" {keep_audio}")
88
+ m_kind = keep = None
89
+ if stripped.startswith("m=video"):
90
+ m_kind, keep = "video", keep_video
91
+ elif stripped.startswith("m=audio"):
92
+ m_kind, keep = "audio", keep_audio
93
+ if m_kind is not None and keep is not None:
94
+ head, _, pts = stripped.partition(" RTP/")
95
+ proto, _, pt_list = pts.partition(" ")
96
+ listed = pt_list.split()
97
+ if str(keep) in listed:
98
+ drop.update(p for p in listed if p != str(keep))
99
+ line = (line[: len(line) - len(stripped)]
100
+ + f"{head} RTP/{proto} {keep}"
101
+ + ("\r\n" if line.endswith("\r\n") else "\n"))
83
102
  elif any(
84
103
  stripped.startswith(f"a={attr}:{pt}{sep}")
85
104
  for pt in drop
@@ -3537,16 +3556,11 @@ class _SdesOpenMixin:
3537
3556
  out_dir = os.path.dirname(output_path)
3538
3557
  if out_dir:
3539
3558
  os.makedirs(out_dir, exist_ok=True)
3540
- # Build the ffmpeg command (single source of truth: _build_sdes_serve_cmd).
3559
+ # The ffmpeg command is built once below, AFTER the first-media wait
3560
+ # resolves the real payload types (single source of truth:
3561
+ # _build_sdes_serve_cmd) - so both the SDP narrowing and the push
3562
+ # video-only decision see the observed codecs.
3541
3563
  _serve_audio = self._resolve_sdes_serve_audio()
3542
- cmd = _build_sdes_serve_cmd(
3543
- sdp_path=sdp_path,
3544
- rtsp_push_url=rtsp_push_url,
3545
- output_path=output_path,
3546
- max_seconds=max_seconds,
3547
- sdes_audio=_serve_audio,
3548
- audio_gain_db=self._resolve_sdes_audio_gain_db(),
3549
- )
3550
3564
  # --- H.265 fix: narrow the ffmpeg SDP to the camera's actual codec ----
3551
3565
  # The camera streams H.264 (pt=96) OR H.265 (pt=97), varying per session.
3552
3566
  # An m=video line listing both ("96 97") makes ffmpeg bind the RTP
@@ -3578,10 +3592,15 @@ class _SdesOpenMixin:
3578
3592
  await asyncio.sleep(0.1)
3579
3593
  _vpt = _first_video_pt[0]
3580
3594
  _apt = _first_audio_pt[0]
3581
- if _serve_audio and _apt is None:
3595
+ # Which single payload type (if any) each line can be narrowed to. Audio
3596
+ # is usable only when its type was actually observed; otherwise the "0 8"
3597
+ # line cannot be narrowed.
3598
+ _keep_v = int(_vpt) if _vpt in _SDP_VIDEO_PTS else None
3599
+ _keep_a = int(_apt) if _apt in _SDP_AUDIO_PTS else None
3600
+ if _serve_audio and _keep_a is None:
3582
3601
  # No audio observed in the window, so the line cannot be narrowed and
3583
- # mapping audio would stall the mux. Serve video only rather than
3584
- # serving nothing, and say so.
3602
+ # mapping audio would stall the mux (pull) or break the ANNOUNCE
3603
+ # (push). Serve video only rather than serving nothing, and say so.
3585
3604
  _LOGGER.warning(
3586
3605
  "camera %s: no audio observed before the serve launched, so the "
3587
3606
  "audio payload type is unknown; mapping it would stall the mpegts "
@@ -3589,14 +3608,22 @@ class _SdesOpenMixin:
3589
3608
  getattr(self, "device_id", "?"),
3590
3609
  )
3591
3610
  _serve_audio = False
3592
- cmd = _build_sdes_serve_cmd(
3593
- sdp_path=sdp_path,
3594
- rtsp_push_url=rtsp_push_url,
3595
- output_path=output_path,
3596
- max_seconds=max_seconds,
3597
- sdes_audio=False,
3598
- audio_gain_db=self._resolve_sdes_audio_gain_db(),
3599
- )
3611
+ # RTSP-PUSH copies EVERY input stream (-c copy). If the audio line was
3612
+ # never narrowed to a single type, its multi-PT ANNOUNCE is rejected by
3613
+ # the RTSP server (400 Bad Request) and takes the whole publish down - so
3614
+ # push must map video only whenever audio is not usable. (Pull already
3615
+ # gates audio via sdes_audio in _build_sdes_serve_cmd.)
3616
+ _is_push = bool(rtsp_push_url) and not rtsp_push_url.startswith("http")
3617
+ _push_video_only = _is_push and _keep_a is None
3618
+ cmd = _build_sdes_serve_cmd(
3619
+ sdp_path=sdp_path,
3620
+ rtsp_push_url=rtsp_push_url,
3621
+ output_path=output_path,
3622
+ max_seconds=max_seconds,
3623
+ sdes_audio=_serve_audio,
3624
+ audio_gain_db=self._resolve_sdes_audio_gain_db(),
3625
+ push_video_only=_push_video_only,
3626
+ )
3600
3627
  # Audio matters even more than video here. A multi-PT m-line makes ffmpeg
3601
3628
  # bind the depacketizer to the FIRST payload type and silently discard the
3602
3629
  # rest, and the mpegts mux withholds its PAT/PMT until EVERY mapped stream
@@ -3604,13 +3631,11 @@ class _SdesOpenMixin:
3604
3631
  # on a camera that sends PCMA discards every audio packet and the consumer
3605
3632
  # receives zero bytes. That takes the video down with it, which is why
3606
3633
  # enabling serve audio appeared to break streaming outright.
3607
- if _vpt in (96, 97) or (_serve_audio and _apt in (0, 8)):
3634
+ if _keep_v is not None or _keep_a is not None:
3608
3635
  def _read_sdp_file() -> str:
3609
3636
  with open(sdp_path, encoding="utf-8") as _f_sdp:
3610
3637
  return _f_sdp.read()
3611
3638
  try:
3612
- _keep_v = int(_vpt) if _vpt in (96, 97) else None
3613
- _keep_a = int(_apt) if (_serve_audio and _apt in (0, 8)) else None
3614
3639
  _cur_sdp = await asyncio.get_running_loop().run_in_executor(
3615
3640
  None, _read_sdp_file)
3616
3641
  await asyncio.get_running_loop().run_in_executor(
@@ -3619,7 +3644,7 @@ class _SdesOpenMixin:
3619
3644
  _status(
3620
3645
  "SDES: narrowed ffmpeg SDP to"
3621
3646
  + (f" video pt={_keep_v}"
3622
- f" ({'H265' if _keep_v == 97 else 'H264'})"
3647
+ f" ({'H264' if _keep_v == 96 else 'H265'})"
3623
3648
  if _keep_v is not None else "")
3624
3649
  + (f" audio pt={_keep_a}"
3625
3650
  f" ({'PCMA' if _keep_a == 8 else 'PCMU'})"
@@ -3902,7 +3927,7 @@ class _SdesOpenMixin:
3902
3927
  _new_sdp = narrow_sdp_payload_types(
3903
3928
  _new_sdp,
3904
3929
  keep_video=(_first_video_pt[0]
3905
- if _first_video_pt[0] in (96, 97) else None),
3930
+ if _first_video_pt[0] in _SDP_VIDEO_PTS else None),
3906
3931
  keep_audio=(_first_audio_pt[0]
3907
3932
  if _first_audio_pt[0] in (0, 8) else None),
3908
3933
  )
@@ -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
 
@@ -237,16 +238,20 @@ class _WebRTCOpenMixin:
237
238
  # Battery cameras must have their live-stream session provisioned by the
238
239
  # cloud BEFORE MQTT signaling, or the camera rejects livePlayReq with
239
240
  # -50019 ("not ready") and never runs ICE -> no media. App parity:
240
- # KVSPreConnectStrategy.fetchKvsParams calls liveStreamParam first. We
241
- # only need the provisioning side effect (we stream over MQTT/SDES, not
242
- # AWS KVS). Mains cameras are always stream-ready, so skip them.
243
- # Validated live on L2_170 (A001513): with this call livePlay succeeds and
244
- # decrypted RTP flows; without it, persistent -50019. Controllable like
245
- # fast_connect/sdes_audio: start_keepalive(live_stream_param=...) wins,
246
- # else the AIDOT_LIVESTREAM_PARAM env var (default on).
241
+ # KVSPreConnectStrategy.fetchKvsParams calls liveStreamParam first, which
242
+ # provisions the camera's session toward AWS KVS. DEFAULT OFF: we stream
243
+ # over MQTT/SDES, not KVS, and on A001513 "L2" cameras this pre-connect
244
+ # DIVERTS the camera's media to KVS - the SDES bridge then receives no
245
+ # video RTP at all (validated live: with the call the L2 serves nothing;
246
+ # with AIDOT_LIVESTREAM_PARAM=0 it streams h264 1280x960 + PCMA, matching
247
+ # the pre-0.7 behaviour that always worked). It was added under #43 to
248
+ # cure a -50019 ("not ready") livePlayResp, but -50019 is benign - mains
249
+ # cameras emit it too and recover via ICE - so the pre-connect fixed a
250
+ # non-bug at the cost of the battery live view. Kept as an opt-in escape
251
+ # hatch: start_keepalive(live_stream_param=True) or AIDOT_LIVESTREAM_PARAM=1.
247
252
  _lsp = getattr(self, "_live_stream_param_opt", None)
248
253
  if _lsp is None:
249
- _lsp = os.environ.get("AIDOT_LIVESTREAM_PARAM", "1") != "0"
254
+ _lsp = os.environ.get("AIDOT_LIVESTREAM_PARAM", "0") != "0"
250
255
  if self.is_battery_camera and _lsp:
251
256
  _lsp_ok = await self._async_fetch_live_stream_param()
252
257
  _LOGGER.debug("camera %s: liveStreamParam provisioned ok=%s",
@@ -493,7 +498,14 @@ class _WebRTCOpenMixin:
493
498
  _dtls_fallback_ok = str(_cam_props.get("isDTLS", "1")) != "0"
494
499
 
495
500
  device_id = self.device_id
496
- 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(
497
509
  live_type=2, stream_id=stream_id, sdes=use_sdes
498
510
  )
499
511
  loop = asyncio.get_running_loop()
@@ -1208,7 +1220,26 @@ class _WebRTCOpenMixin:
1208
1220
  # Branch: SDES-SRTP cameras use ffmpeg; DTLS cameras use aiortc
1209
1221
  # ------------------------------------------------------------------ #
1210
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
+
1211
1241
  try:
1242
+ _raise_if_camera_refused()
1212
1243
  _sdes_session = await self._open_sdes_stream(
1213
1244
  peer_id=peer_id,
1214
1245
  user_id=user_id,
@@ -1244,7 +1275,13 @@ class _WebRTCOpenMixin:
1244
1275
  # DTLS-fallback path below keeps the slot for its own hand-off.)
1245
1276
  self._release_stream_drain_to_session()
1246
1277
  return _sdes_session
1278
+ except AidotCameraBusy:
1279
+ raise
1247
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()
1248
1285
  # Camera reported enableSdes='1' but did not respond to our SDES
1249
1286
  # offer. iOS telemetry shows models such as LK.IPC.A001064 can
1250
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.14"
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.14
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
@@ -69,6 +69,7 @@ python_aidot_cameras.egg-info/top_level.txt
69
69
  tests/test_aioice_compat.py
70
70
  tests/test_alarm_event.py
71
71
  tests/test_backoff.py
72
+ tests/test_battery_skip_turn.py
72
73
  tests/test_deferred_hardening.py
73
74
  tests/test_device_login_guard.py
74
75
  tests/test_device_user_info_cache.py
@@ -105,6 +106,7 @@ tests/test_no_undefined_names.py
105
106
  tests/test_offline_keepalive_pause.py
106
107
  tests/test_open_fail_logger.py
107
108
  tests/test_open_gate_delay.py
109
+ tests/test_peerid_reuse.py
108
110
  tests/test_persistent_mqtt.py
109
111
  tests/test_persistent_mqtt_robustness.py
110
112
  tests/test_playback_tls.py
@@ -122,6 +124,7 @@ tests/test_sdes_serve_cmd.py
122
124
  tests/test_sdes_sprop.py
123
125
  tests/test_sdes_talk.py
124
126
  tests/test_sdes_teardown_exit_log.py
127
+ tests/test_sdes_terminal_ack.py
125
128
  tests/test_sdes_watchdog.py
126
129
  tests/test_sdp_payload_type_narrowing.py
127
130
  tests/test_security_hardening.py
@@ -0,0 +1,59 @@
1
+ """A battery SDES camera must NEVER skip the TURN relay pre-allocation.
2
+
3
+ A battery camera sleeps and is woken through the cloud, so its media returns
4
+ over the TURN relay, not a host-direct LAN path. Skipping the relay (the
5
+ LAN-direct latency optimization) leaves the camera with no reachable path back
6
+ and it sends no media at all. Validated live on an A001513: with the relay it
7
+ streams h264 1280x960; with skip_turn it serves nothing. The opt/env must not be
8
+ able to turn the relay off for a battery camera; the optimization applies only to
9
+ mains SDES cameras that actually have a host candidate on the HA segment.
10
+ """
11
+ import os
12
+ import sys
13
+
14
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
15
+
16
+ import aidot_cameras.camera.client as cc
17
+
18
+ _CAM = next(v for v in vars(cc).values()
19
+ if isinstance(v, type) and "_resolve_sdes_skip_turn" in v.__dict__)
20
+
21
+
22
+ class _Info:
23
+ def __init__(self, model_id):
24
+ self.model_id = model_id
25
+
26
+
27
+ def _cam(battery, **attrs):
28
+ c = _CAM.__new__(_CAM)
29
+ # is_battery_camera is a property derived from info.model_id.
30
+ c.info = _Info("LK.IPC.A001513" if battery else "LK.IPC.A001064")
31
+ for k, v in attrs.items():
32
+ setattr(c, k, v)
33
+ return c
34
+
35
+
36
+ def test_battery_never_skips_turn_even_with_opt():
37
+ c = _cam(True, _sdes_skip_turn_opt=True)
38
+ assert c._resolve_sdes_skip_turn() is False
39
+
40
+
41
+ def test_battery_never_skips_turn_even_with_env(monkeypatch):
42
+ monkeypatch.setenv("AIDOT_SDES_SKIP_TURN_PREALLOC", "1")
43
+ c = _cam(True)
44
+ assert c._resolve_sdes_skip_turn() is False
45
+
46
+
47
+ def test_battery_never_skips_turn_with_fast_attempt_override():
48
+ c = _cam(True, _fast_attempt_override=True)
49
+ assert c._resolve_sdes_skip_turn() is False
50
+
51
+
52
+ def test_mains_still_honors_the_opt():
53
+ assert _cam(False, _sdes_skip_turn_opt=True)._resolve_sdes_skip_turn() is True
54
+ assert _cam(False, _sdes_skip_turn_opt=False)._resolve_sdes_skip_turn() is False
55
+
56
+
57
+ def test_mains_default_off(monkeypatch):
58
+ monkeypatch.delenv("AIDOT_SDES_SKIP_TURN_PREALLOC", raising=False)
59
+ assert _cam(False)._resolve_sdes_skip_turn() is False
@@ -73,3 +73,51 @@ if __name__ == "__main__":
73
73
  print(f"FAIL {_k}")
74
74
  traceback.print_exc()
75
75
  raise SystemExit(1 if _fail else 0)
76
+
77
+
78
+ # --- default OFF regression --------------------------------------------------
79
+ # The liveStreamParam KVS pre-connect is OFF by default. It was added under #43
80
+ # to cure a -50019 livePlayResp, but -50019 is benign (mains cameras emit it and
81
+ # recover via ICE), and on A001513 "L2" cameras the pre-connect diverts the
82
+ # camera's media to AWS KVS so the SDES bridge receives no video RTP. Validated
83
+ # live: with the pre-connect the L2 serves nothing; with it off the L2 streams
84
+ # h264 1280x960 + PCMA. Lock the default so it cannot silently flip back on.
85
+ import asyncio
86
+
87
+
88
+ def _resolve_lsp(opt, env):
89
+ # Mirror the gate in webrtc_open._async_open_webrtc_stream_impl.
90
+ _lsp = opt
91
+ if _lsp is None:
92
+ _lsp = env.get("AIDOT_LIVESTREAM_PARAM", "0") != "0"
93
+ return _lsp
94
+
95
+
96
+ def test_liveStreamParam_defaults_off():
97
+ assert _resolve_lsp(None, {}) is False
98
+
99
+
100
+ def test_liveStreamParam_env_opt_in():
101
+ assert _resolve_lsp(None, {"AIDOT_LIVESTREAM_PARAM": "1"}) is True
102
+
103
+
104
+ def test_liveStreamParam_explicit_opt_wins_over_env():
105
+ assert _resolve_lsp(True, {"AIDOT_LIVESTREAM_PARAM": "0"}) is True
106
+ assert _resolve_lsp(False, {"AIDOT_LIVESTREAM_PARAM": "1"}) is False
107
+
108
+
109
+ def test_open_path_skips_arm_by_default(monkeypatch):
110
+ # The real gate: a battery camera with no opt/env must NOT call the arm.
111
+ called = {"n": 0}
112
+
113
+ async def _fake_arm(self):
114
+ called["n"] += 1
115
+ return True
116
+
117
+ # Exercise the exact resolution + guard the open path uses.
118
+ monkeypatch.delenv("AIDOT_LIVESTREAM_PARAM", raising=False)
119
+ is_battery = True
120
+ _lsp = _resolve_lsp(None, os.environ)
121
+ if is_battery and _lsp:
122
+ asyncio.get_event_loop().run_until_complete(_fake_arm(object()))
123
+ assert called["n"] == 0, "arm must not run by default on a battery camera"
@@ -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")