python-aidot-cameras 0.12.13__tar.gz → 0.12.15__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 (147) hide show
  1. {python_aidot_cameras-0.12.13/python_aidot_cameras.egg-info → python_aidot_cameras-0.12.15}/PKG-INFO +1 -1
  2. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/camera/client.py +33 -6
  3. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/camera/protocol.py +75 -7
  4. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/camera/sdes_open.py +57 -32
  5. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/camera/webrtc_open.py +12 -8
  6. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/pyproject.toml +1 -1
  7. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  8. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/python_aidot_cameras.egg-info/SOURCES.txt +2 -0
  9. python_aidot_cameras-0.12.15/tests/test_battery_skip_turn.py +59 -0
  10. python_aidot_cameras-0.12.15/tests/test_dtls_mux_terminal_write.py +126 -0
  11. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_live_stream_param.py +48 -0
  12. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_sdp_payload_type_narrowing.py +68 -0
  13. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/LICENSE +0 -0
  14. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/MANIFEST.in +0 -0
  15. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/README.md +0 -0
  16. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/__init__.py +0 -0
  17. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/__main__.py +0 -0
  18. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/__init__.py +0 -0
  19. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/__init__.py +0 -0
  20. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/clock.py +0 -0
  21. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/codecs/__init__.py +0 -0
  22. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/codecs/base.py +0 -0
  23. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/codecs/g711.py +0 -0
  24. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/codecs/g722.py +0 -0
  25. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/codecs/h264.py +0 -0
  26. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/codecs/opus.py +0 -0
  27. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/codecs/vpx.py +0 -0
  28. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/contrib/__init__.py +0 -0
  29. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/contrib/media.py +0 -0
  30. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/contrib/signaling.py +0 -0
  31. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/events.py +0 -0
  32. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/exceptions.py +0 -0
  33. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/jitterbuffer.py +0 -0
  34. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/mediastreams.py +0 -0
  35. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/rate.py +0 -0
  36. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/rtcconfiguration.py +0 -0
  37. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/rtcdatachannel.py +0 -0
  38. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/rtcdtlstransport.py +0 -0
  39. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/rtcicetransport.py +0 -0
  40. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/rtcpeerconnection.py +0 -0
  41. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/rtcrtpparameters.py +0 -0
  42. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/rtcrtpreceiver.py +0 -0
  43. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/rtcrtpsender.py +0 -0
  44. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/rtcrtptransceiver.py +0 -0
  45. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/rtcsctptransport.py +0 -0
  46. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/rtcsessiondescription.py +0 -0
  47. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/rtp.py +0 -0
  48. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/sdp.py +0 -0
  49. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/stats.py +0 -0
  50. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/utils.py +0 -0
  51. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/camera/__init__.py +0 -0
  52. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/camera/constants.py +0 -0
  53. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/camera/controls.py +0 -0
  54. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/camera/go2rtc.py +0 -0
  55. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/camera/lan_control.py +0 -0
  56. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/camera/models.py +0 -0
  57. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/camera/playback.py +0 -0
  58. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/camera/sdes.py +0 -0
  59. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/camera/tutk.py +0 -0
  60. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/camera/webrtc.py +0 -0
  61. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/client.py +0 -0
  62. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/const.py +0 -0
  63. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/credentials.py +0 -0
  64. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/crypto.py +0 -0
  65. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/device_client.py +0 -0
  66. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/discover.py +0 -0
  67. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/exceptions.py +0 -0
  68. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/g711.py +0 -0
  69. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/py.typed +0 -0
  70. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  71. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
  72. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/python_aidot_cameras.egg-info/requires.txt +0 -0
  73. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/python_aidot_cameras.egg-info/top_level.txt +0 -0
  74. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/setup.cfg +0 -0
  75. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_aioice_compat.py +0 -0
  76. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_alarm_event.py +0 -0
  77. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_backoff.py +0 -0
  78. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_deferred_hardening.py +0 -0
  79. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_device_login_guard.py +0 -0
  80. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_device_user_info_cache.py +0 -0
  81. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_dormant_camera_cleanup.py +0 -0
  82. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_dtls_not_ready_burst.py +0 -0
  83. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_dtls_pinning.py +0 -0
  84. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_dtls_serve_decode_skip.py +0 -0
  85. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_dtls_serve_open_timeout.py +0 -0
  86. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_dtls_skip_signaling_wait.py +0 -0
  87. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_dtls_slow_probe.py +0 -0
  88. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_egress_guard.py +0 -0
  89. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_go2rtc.py +0 -0
  90. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_go2rtc_cli.py +0 -0
  91. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_h264_decode_rate_limit.py +0 -0
  92. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_highport_nomination.py +0 -0
  93. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_ice_config_cache.py +0 -0
  94. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_idle_release_both_loops.py +0 -0
  95. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_keepalive_renew.py +0 -0
  96. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_key_restart_sdp.py +0 -0
  97. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_keyframe_prompter.py +0 -0
  98. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_lan_control.py +0 -0
  99. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_light_active_color_mode.py +0 -0
  100. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_logging_caps.py +0 -0
  101. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_motion_poll.py +0 -0
  102. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_mqtt_credential_refetch.py +0 -0
  103. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_mqtt_credential_self_heal.py +0 -0
  104. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_mqtt_password_not_persisted.py +0 -0
  105. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_mqtt_session_reconnect.py +0 -0
  106. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_narrow_pc_ice.py +0 -0
  107. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_no_self_referential_go2rtc.py +0 -0
  108. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_no_undefined_names.py +0 -0
  109. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_offline_keepalive_pause.py +0 -0
  110. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_open_fail_logger.py +0 -0
  111. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_open_gate_delay.py +0 -0
  112. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_persistent_mqtt.py +0 -0
  113. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_persistent_mqtt_robustness.py +0 -0
  114. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_playback_tls.py +0 -0
  115. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_post_merge_hardening.py +0 -0
  116. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_retry_policy.py +0 -0
  117. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_sdes_adaptive.py +0 -0
  118. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_sdes_bridge_break_gate.py +0 -0
  119. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_sdes_echo_wait_timeout.py +0 -0
  120. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_sdes_fast_liveplay.py +0 -0
  121. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_sdes_ffmpeg_stderr_log.py +0 -0
  122. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_sdes_idle_release.py +0 -0
  123. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_sdes_open_cleanup.py +0 -0
  124. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_sdes_serve_audio.py +0 -0
  125. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_sdes_serve_cmd.py +0 -0
  126. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_sdes_sprop.py +0 -0
  127. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_sdes_talk.py +0 -0
  128. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_sdes_teardown_exit_log.py +0 -0
  129. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_sdes_watchdog.py +0 -0
  130. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_security_hardening.py +0 -0
  131. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_serializable_login_info.py +0 -0
  132. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_serve_relay.py +0 -0
  133. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_session_stats.py +0 -0
  134. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_speak.py +0 -0
  135. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_stream_cap.py +0 -0
  136. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_stream_drain_ownership.py +0 -0
  137. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_stream_idle.py +0 -0
  138. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_stream_slot_cap.py +0 -0
  139. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_stream_teardown.py +0 -0
  140. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_talk.py +0 -0
  141. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_terminal_ack.py +0 -0
  142. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_token_file_robustness.py +0 -0
  143. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_token_refresh.py +0 -0
  144. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_upstream_compat.py +0 -0
  145. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_viewer_check_throttle.py +0 -0
  146. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_viewer_presence.py +0 -0
  147. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/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.13
3
+ Version: 0.12.15
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
@@ -3571,7 +3579,18 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3571
3579
 
3572
3580
  Per-camera ``sdes_skip_turn`` (set via start_keepalive) wins; else the
3573
3581
  ``AIDOT_SDES_SKIP_TURN_PREALLOC`` env (truthy = 1/true/yes/on), default
3574
- off. Only consulted for SDES cameras."""
3582
+ off. Only consulted for SDES cameras.
3583
+
3584
+ NEVER skipped for a battery camera, whatever the opt/env say: a battery
3585
+ camera sleeps and is woken through the cloud, so it is reached over the
3586
+ TURN relay rather than a host-direct LAN path. Skipping the relay
3587
+ pre-allocation there means the camera has no reachable path back and
3588
+ sends no media at all (validated live on an A001513: with the relay it
3589
+ streams h264 1280x960, with skip_turn it serves nothing). The LAN-direct
3590
+ latency win only applies to a mains camera that actually has a host
3591
+ candidate on the HA segment."""
3592
+ if getattr(self, "is_battery_camera", False):
3593
+ return False
3575
3594
  opt = getattr(self, "_sdes_skip_turn_opt", None)
3576
3595
  if opt is not None:
3577
3596
  return bool(opt)
@@ -4080,7 +4099,15 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
4080
4099
  # Unknown: keep the old staleness heuristic rather
4081
4100
  # than holding the stream open forever.
4082
4101
  idle_release = True
4083
- break
4102
+ # Only an actual idle release ends this serve cycle.
4103
+ # (This break was unconditional - any idle_secs > 0
4104
+ # tore down a HEALTHY ffmpeg after one 0.5s tick,
4105
+ # respawning it forever: ffmpeg died at output-open
4106
+ # with "Immediate exit requested" and every DTLS
4107
+ # camera served nothing. stream_idle_s=0 masked it,
4108
+ # which is why never-release fleets still worked.)
4109
+ if idle_release:
4110
+ break
4084
4111
  # Tear down this ffmpeg+mux cycle before the next.
4085
4112
  stop_flag.set()
4086
4113
  try:
@@ -925,8 +925,52 @@ def _dtls_av_mux_run(vq, aq, out_fileobj, progress, stop_flag) -> None:
925
925
  except Exception as exc: # pragma: no cover
926
926
  _LOGGER.warning("DTLS A/V mux: PyAV unavailable: %s", exc)
927
927
  return
928
+
929
+ class _FailFlagWriter:
930
+ """Write-only stand-in for the pipe that RECORDS write failures.
931
+
932
+ FFmpeg's mpegts muxer checks avio write errors lazily: a dead pipe
933
+ (EPIPE after its ffmpeg exits) makes ``out.mux()`` appear to succeed
934
+ forever, and the error only surfaces - or hangs - at trailer time.
935
+ PyAV's pyio callback DOES see each failed ``write``; recording it here
936
+ lets the mux loop notice within one iteration and exit, instead of an
937
+ abandoned thread draining the SHARED vq/aq into a dead pipe and
938
+ starving the next cycle's ffmpeg (the 2026-07 dead-DTLS-video field
939
+ failure). Deliberately exposes no seek/tell: the pipe is streaming.
940
+ """
941
+
942
+ def __init__(self, f):
943
+ self._f = f
944
+ self.failed = False
945
+
946
+ def write(self, b):
947
+ try:
948
+ return self._f.write(b)
949
+ except Exception:
950
+ self.failed = True
951
+ raise
952
+
953
+ def flush(self):
954
+ try:
955
+ self._f.flush()
956
+ except Exception:
957
+ self.failed = True
958
+ raise
959
+
960
+ def close(self):
961
+ self._f.close()
962
+
963
+ wsink = _FailFlagWriter(out_fileobj)
928
964
  try:
929
- out = av.open(out_fileobj, "w", format="mpegts")
965
+ # max_interleave_delta (default 10s of stream time): with an AAC stream
966
+ # declared but no audio packets arriving, libavformat's interleave
967
+ # queue holds EVERY video packet waiting to order them against audio -
968
+ # zero bytes reach the pipe, the serve ffmpeg starves at its input
969
+ # probe and never binds its -listen port (field failure 2026-07: all
970
+ # DTLS cameras dark while the taps were full of frames). 100ms keeps
971
+ # video flowing whether or not audio ever shows up.
972
+ out = av.open(wsink, "w", format="mpegts",
973
+ options={"max_interleave_delta": "100000"})
930
974
  except Exception as exc:
931
975
  _LOGGER.warning("DTLS A/V mux: av.open failed: %s", exc)
932
976
  return
@@ -975,6 +1019,14 @@ def _dtls_av_mux_run(vq, aq, out_fileobj, progress, stop_flag) -> None:
975
1019
  a_rtp0 = [None] # first audio RTP timestamp (8 kHz units), for gap detection
976
1020
  a_in = [0] # 8 kHz samples emitted to the resampler so far (incl. concealed)
977
1021
  vstarted = [False]
1022
+ # A failed container write is TERMINAL for this mux: the pipe's ffmpeg is
1023
+ # gone (EPIPE) and can never come back for this cycle. Swallowing it and
1024
+ # looping used to leave abandoned mux threads (the teardown join(2.0) gives
1025
+ # up on a thread blocked in write) racing the NEXT cycle's thread for the
1026
+ # SHARED vq/aq - stealing its frames into a dead pipe, starving the new
1027
+ # ffmpeg at its input probe so it never bound its -listen port. One dead
1028
+ # write -> exit the thread and leave the queues alone.
1029
+ mux_dead = [False]
978
1030
 
979
1031
  def _flush_video():
980
1032
  while True:
@@ -995,7 +1047,9 @@ def _dtls_av_mux_run(vq, aq, out_fileobj, progress, stop_flag) -> None:
995
1047
  out.mux(pkt)
996
1048
  progress[0] = _t.monotonic()
997
1049
  except Exception:
998
- _LOGGER.debug("swallowed exception in %s", '_flush_video', exc_info=True)
1050
+ mux_dead[0] = True
1051
+ _LOGGER.debug("DTLS A/V mux: video write failed - ending mux thread", exc_info=True)
1052
+ return
999
1053
 
1000
1054
  def _flush_audio(drain=False):
1001
1055
  if not have_audio:
@@ -1115,20 +1169,34 @@ def _dtls_av_mux_run(vq, aq, out_fileobj, progress, stop_flag) -> None:
1115
1169
  out.mux(opkt)
1116
1170
  progress[0] = _t.monotonic()
1117
1171
  except Exception:
1118
- _LOGGER.debug("swallowed exception in %s", '_flush_audio', exc_info=True)
1172
+ mux_dead[0] = True
1173
+ _LOGGER.debug("DTLS A/V mux: audio write failed - ending mux thread", exc_info=True)
1174
+ return
1175
+
1176
+ def _dead() -> bool:
1177
+ return mux_dead[0] or wsink.failed
1119
1178
 
1120
- while not stop_flag.is_set():
1179
+ while not stop_flag.is_set() and not _dead():
1121
1180
  _flush_video()
1122
1181
  _flush_audio()
1123
1182
  _t.sleep(0.005) # 5 ms - processes audio within 5 ms of arrival vs 20 ms
1124
- _flush_video()
1125
- _flush_audio(drain=True) # final: pad the last partial frame so it flushes
1183
+ if not _dead():
1184
+ _flush_video()
1185
+ _flush_audio(drain=True) # final: pad the last partial frame so it flushes
1126
1186
  try:
1127
- if have_audio:
1187
+ if have_audio and not _dead():
1128
1188
  for opkt in aenc.encode(None): # flush
1129
1189
  out.mux(opkt)
1130
1190
  except Exception:
1131
1191
  _LOGGER.debug("swallowed exception in %s", '_dtls_av_mux_run', exc_info=True)
1192
+ if _dead():
1193
+ # Close the raw pipe FIRST: the container's trailer flush then fails
1194
+ # fast ("closed file") instead of hanging on the dead pipe.
1195
+ _LOGGER.debug("DTLS A/V mux: sink write failed - ending mux thread")
1196
+ try:
1197
+ wsink.close()
1198
+ except Exception:
1199
+ pass
1132
1200
  try:
1133
1201
  out.close()
1134
1202
  except Exception:
@@ -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
  )
@@ -237,16 +237,20 @@ class _WebRTCOpenMixin:
237
237
  # Battery cameras must have their live-stream session provisioned by the
238
238
  # cloud BEFORE MQTT signaling, or the camera rejects livePlayReq with
239
239
  # -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).
240
+ # KVSPreConnectStrategy.fetchKvsParams calls liveStreamParam first, which
241
+ # provisions the camera's session toward AWS KVS. DEFAULT OFF: we stream
242
+ # over MQTT/SDES, not KVS, and on A001513 "L2" cameras this pre-connect
243
+ # DIVERTS the camera's media to KVS - the SDES bridge then receives no
244
+ # video RTP at all (validated live: with the call the L2 serves nothing;
245
+ # with AIDOT_LIVESTREAM_PARAM=0 it streams h264 1280x960 + PCMA, matching
246
+ # the pre-0.7 behaviour that always worked). It was added under #43 to
247
+ # cure a -50019 ("not ready") livePlayResp, but -50019 is benign - mains
248
+ # cameras emit it too and recover via ICE - so the pre-connect fixed a
249
+ # non-bug at the cost of the battery live view. Kept as an opt-in escape
250
+ # hatch: start_keepalive(live_stream_param=True) or AIDOT_LIVESTREAM_PARAM=1.
247
251
  _lsp = getattr(self, "_live_stream_param_opt", None)
248
252
  if _lsp is None:
249
- _lsp = os.environ.get("AIDOT_LIVESTREAM_PARAM", "1") != "0"
253
+ _lsp = os.environ.get("AIDOT_LIVESTREAM_PARAM", "0") != "0"
250
254
  if self.is_battery_camera and _lsp:
251
255
  _lsp_ok = await self._async_fetch_live_stream_param()
252
256
  _LOGGER.debug("camera %s: liveStreamParam provisioned ok=%s",
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-aidot-cameras"
7
- version = "0.12.13"
7
+ version = "0.12.15"
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.13
3
+ Version: 0.12.15
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,10 +69,12 @@ 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
75
76
  tests/test_dormant_camera_cleanup.py
77
+ tests/test_dtls_mux_terminal_write.py
76
78
  tests/test_dtls_not_ready_burst.py
77
79
  tests/test_dtls_pinning.py
78
80
  tests/test_dtls_serve_decode_skip.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
@@ -0,0 +1,126 @@
1
+ """A failed container write must END the DTLS mux thread, not be swallowed.
2
+
3
+ Regression for the abandoned-mux-thread leak: teardown's join(2.0) gives up on
4
+ a thread blocked in a pipe write; once that pipe's ffmpeg dies the write raises
5
+ EPIPE forever. The old per-packet swallow kept the abandoned thread looping,
6
+ stealing frames from the SHARED vq/aq that the next cycle's thread needed - the
7
+ new ffmpeg starved at its input probe and never bound its -listen port (the
8
+ field "dead DTLS video" of 2026-07). A dead write now exits the thread and
9
+ leaves the queues alone.
10
+ """
11
+
12
+ import os
13
+ import queue
14
+ import threading
15
+ import time
16
+
17
+ import pytest
18
+
19
+ av = pytest.importorskip("av")
20
+
21
+ from aidot_cameras.camera.protocol import _dtls_av_mux_run
22
+
23
+
24
+ def _h264_payloads(n=60):
25
+ """Real encoded H.264 access units (the mux needs valid-ish annex-b).
26
+
27
+ Noise frames compress terribly on purpose: the total must exceed PyAV's
28
+ ~32KB avio buffer so the container performs REAL pipe writes (a smaller
29
+ payload sits in the buffer forever and never observes the dead pipe).
30
+ """
31
+ import numpy as np
32
+
33
+ rng = np.random.default_rng(7)
34
+ enc = av.CodecContext.create("libx264", "w")
35
+ enc.width, enc.height, enc.pix_fmt = 320, 240, "yuv420p"
36
+ from fractions import Fraction
37
+
38
+ enc.time_base = Fraction(1, 15)
39
+ enc.options = {"tune": "zerolatency"}
40
+ out = []
41
+ for i in range(n):
42
+ img = rng.integers(0, 255, (240, 320, 3), dtype=np.uint8)
43
+ fr = av.VideoFrame.from_ndarray(img, format="rgb24").reformat(format="yuv420p")
44
+ fr.pts = i
45
+ for p in enc.encode(fr):
46
+ out.append((bytes(p), bool(p.is_keyframe)))
47
+ for p in enc.encode(None):
48
+ out.append((bytes(p), bool(p.is_keyframe)))
49
+ assert sum(len(d) for d, _ in out) > 100_000
50
+ return out
51
+
52
+
53
+ def test_dead_pipe_ends_mux_thread_and_spares_queues():
54
+ payloads = _h264_payloads()
55
+ assert payloads and payloads[0][1], "first packet should be a keyframe"
56
+
57
+ vq: queue.Queue = queue.Queue()
58
+ aq: queue.Queue = queue.Queue()
59
+ ts = 0
60
+ for data, kf in payloads:
61
+ vq.put((data, ts, kf))
62
+ ts += 6000
63
+
64
+ r, w = os.pipe()
65
+ os.close(r) # reader already gone -> every flushed write is EPIPE
66
+ wfile = os.fdopen(w, "wb", buffering=0)
67
+ progress = [time.monotonic()]
68
+ stop_flag = threading.Event()
69
+
70
+ t = threading.Thread(
71
+ target=_dtls_av_mux_run, args=(vq, aq, wfile, progress, stop_flag), daemon=True
72
+ )
73
+ t.start()
74
+ t.join(timeout=10.0)
75
+
76
+ # The thread must have exited on its own (stop_flag was never set).
77
+ assert not t.is_alive(), "mux thread must end on a dead pipe, not loop forever"
78
+ assert not stop_flag.is_set()
79
+ # And it must not have drained the whole queue into the dead pipe: once the
80
+ # avio buffer first flushes and EPIPEs, remaining frames stay for the next
81
+ # cycle's consumer. (The avio buffer absorbs the first ~32KB, so a few
82
+ # packets are legitimately consumed before the first real write.)
83
+ assert not vq.empty(), "a dead mux must leave queued frames for the next cycle"
84
+
85
+
86
+ def test_healthy_pipe_still_muxes():
87
+ payloads = _h264_payloads()
88
+ vq: queue.Queue = queue.Queue()
89
+ aq: queue.Queue = queue.Queue()
90
+ ts = 0
91
+ for data, kf in payloads:
92
+ vq.put((data, ts, kf))
93
+ ts += 6000
94
+
95
+ r, w = os.pipe()
96
+ got = [0]
97
+
98
+ def drain():
99
+ while True:
100
+ try:
101
+ b = os.read(r, 65536)
102
+ except OSError:
103
+ return
104
+ if not b:
105
+ return
106
+ got[0] += len(b)
107
+
108
+ threading.Thread(target=drain, daemon=True).start()
109
+ wfile = os.fdopen(w, "wb", buffering=0)
110
+ progress = [time.monotonic()]
111
+ stop_flag = threading.Event()
112
+ t = threading.Thread(
113
+ target=_dtls_av_mux_run, args=(vq, aq, wfile, progress, stop_flag), daemon=True
114
+ )
115
+ t.start()
116
+ time.sleep(1.5)
117
+ # REGRESSION (interleave stall): with an AAC stream declared but zero audio
118
+ # packets, libavformat used to buffer ALL video in its interleave queue -
119
+ # bytes only appeared at close(). They must flow DURING muxing.
120
+ assert got[0] > 10_000, (
121
+ "video must reach the pipe while muxing, without any audio packets"
122
+ )
123
+ stop_flag.set()
124
+ t.join(timeout=10.0)
125
+ assert not t.is_alive()
126
+ assert vq.empty(), "healthy mux consumes the queue"
@@ -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"