python-aidot-cameras 0.12.13__tar.gz → 0.12.14__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 (146) hide show
  1. {python_aidot_cameras-0.12.13/python_aidot_cameras.egg-info → python_aidot_cameras-0.12.14}/PKG-INFO +1 -1
  2. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/aidot_cameras/camera/client.py +9 -1
  3. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/aidot_cameras/camera/protocol.py +75 -7
  4. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/pyproject.toml +1 -1
  5. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  6. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/python_aidot_cameras.egg-info/SOURCES.txt +1 -0
  7. python_aidot_cameras-0.12.14/tests/test_dtls_mux_terminal_write.py +126 -0
  8. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/LICENSE +0 -0
  9. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/MANIFEST.in +0 -0
  10. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/README.md +0 -0
  11. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/aidot_cameras/__init__.py +0 -0
  12. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/aidot_cameras/__main__.py +0 -0
  13. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/__init__.py +0 -0
  14. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/__init__.py +0 -0
  15. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/clock.py +0 -0
  16. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/codecs/__init__.py +0 -0
  17. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/codecs/base.py +0 -0
  18. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/codecs/g711.py +0 -0
  19. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/codecs/g722.py +0 -0
  20. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/codecs/h264.py +0 -0
  21. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/codecs/opus.py +0 -0
  22. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/codecs/vpx.py +0 -0
  23. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/contrib/__init__.py +0 -0
  24. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/contrib/media.py +0 -0
  25. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/contrib/signaling.py +0 -0
  26. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/events.py +0 -0
  27. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/exceptions.py +0 -0
  28. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/jitterbuffer.py +0 -0
  29. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/mediastreams.py +0 -0
  30. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/rate.py +0 -0
  31. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/rtcconfiguration.py +0 -0
  32. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/rtcdatachannel.py +0 -0
  33. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/rtcdtlstransport.py +0 -0
  34. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/rtcicetransport.py +0 -0
  35. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/rtcpeerconnection.py +0 -0
  36. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/rtcrtpparameters.py +0 -0
  37. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/rtcrtpreceiver.py +0 -0
  38. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/rtcrtpsender.py +0 -0
  39. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/rtcrtptransceiver.py +0 -0
  40. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/rtcsctptransport.py +0 -0
  41. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/rtcsessiondescription.py +0 -0
  42. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/rtp.py +0 -0
  43. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/sdp.py +0 -0
  44. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/stats.py +0 -0
  45. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/utils.py +0 -0
  46. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/aidot_cameras/camera/__init__.py +0 -0
  47. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/aidot_cameras/camera/constants.py +0 -0
  48. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/aidot_cameras/camera/controls.py +0 -0
  49. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/aidot_cameras/camera/go2rtc.py +0 -0
  50. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/aidot_cameras/camera/lan_control.py +0 -0
  51. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/aidot_cameras/camera/models.py +0 -0
  52. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/aidot_cameras/camera/playback.py +0 -0
  53. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/aidot_cameras/camera/sdes.py +0 -0
  54. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/aidot_cameras/camera/sdes_open.py +0 -0
  55. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/aidot_cameras/camera/tutk.py +0 -0
  56. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/aidot_cameras/camera/webrtc.py +0 -0
  57. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/aidot_cameras/camera/webrtc_open.py +0 -0
  58. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/aidot_cameras/client.py +0 -0
  59. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/aidot_cameras/const.py +0 -0
  60. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/aidot_cameras/credentials.py +0 -0
  61. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/aidot_cameras/crypto.py +0 -0
  62. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/aidot_cameras/device_client.py +0 -0
  63. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/aidot_cameras/discover.py +0 -0
  64. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/aidot_cameras/exceptions.py +0 -0
  65. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/aidot_cameras/g711.py +0 -0
  66. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/aidot_cameras/py.typed +0 -0
  67. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  68. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
  69. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/python_aidot_cameras.egg-info/requires.txt +0 -0
  70. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/python_aidot_cameras.egg-info/top_level.txt +0 -0
  71. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/setup.cfg +0 -0
  72. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_aioice_compat.py +0 -0
  73. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_alarm_event.py +0 -0
  74. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_backoff.py +0 -0
  75. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_deferred_hardening.py +0 -0
  76. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_device_login_guard.py +0 -0
  77. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_device_user_info_cache.py +0 -0
  78. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_dormant_camera_cleanup.py +0 -0
  79. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_dtls_not_ready_burst.py +0 -0
  80. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_dtls_pinning.py +0 -0
  81. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_dtls_serve_decode_skip.py +0 -0
  82. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_dtls_serve_open_timeout.py +0 -0
  83. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_dtls_skip_signaling_wait.py +0 -0
  84. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_dtls_slow_probe.py +0 -0
  85. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_egress_guard.py +0 -0
  86. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_go2rtc.py +0 -0
  87. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_go2rtc_cli.py +0 -0
  88. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_h264_decode_rate_limit.py +0 -0
  89. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_highport_nomination.py +0 -0
  90. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_ice_config_cache.py +0 -0
  91. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_idle_release_both_loops.py +0 -0
  92. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_keepalive_renew.py +0 -0
  93. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_key_restart_sdp.py +0 -0
  94. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_keyframe_prompter.py +0 -0
  95. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_lan_control.py +0 -0
  96. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_light_active_color_mode.py +0 -0
  97. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_live_stream_param.py +0 -0
  98. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_logging_caps.py +0 -0
  99. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_motion_poll.py +0 -0
  100. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_mqtt_credential_refetch.py +0 -0
  101. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_mqtt_credential_self_heal.py +0 -0
  102. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_mqtt_password_not_persisted.py +0 -0
  103. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_mqtt_session_reconnect.py +0 -0
  104. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_narrow_pc_ice.py +0 -0
  105. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_no_self_referential_go2rtc.py +0 -0
  106. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_no_undefined_names.py +0 -0
  107. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_offline_keepalive_pause.py +0 -0
  108. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_open_fail_logger.py +0 -0
  109. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_open_gate_delay.py +0 -0
  110. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_persistent_mqtt.py +0 -0
  111. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_persistent_mqtt_robustness.py +0 -0
  112. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_playback_tls.py +0 -0
  113. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_post_merge_hardening.py +0 -0
  114. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_retry_policy.py +0 -0
  115. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_sdes_adaptive.py +0 -0
  116. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_sdes_bridge_break_gate.py +0 -0
  117. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_sdes_echo_wait_timeout.py +0 -0
  118. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_sdes_fast_liveplay.py +0 -0
  119. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_sdes_ffmpeg_stderr_log.py +0 -0
  120. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_sdes_idle_release.py +0 -0
  121. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_sdes_open_cleanup.py +0 -0
  122. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_sdes_serve_audio.py +0 -0
  123. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_sdes_serve_cmd.py +0 -0
  124. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_sdes_sprop.py +0 -0
  125. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_sdes_talk.py +0 -0
  126. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_sdes_teardown_exit_log.py +0 -0
  127. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_sdes_watchdog.py +0 -0
  128. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_sdp_payload_type_narrowing.py +0 -0
  129. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_security_hardening.py +0 -0
  130. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_serializable_login_info.py +0 -0
  131. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_serve_relay.py +0 -0
  132. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_session_stats.py +0 -0
  133. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_speak.py +0 -0
  134. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_stream_cap.py +0 -0
  135. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_stream_drain_ownership.py +0 -0
  136. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_stream_idle.py +0 -0
  137. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_stream_slot_cap.py +0 -0
  138. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_stream_teardown.py +0 -0
  139. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_talk.py +0 -0
  140. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_terminal_ack.py +0 -0
  141. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_token_file_robustness.py +0 -0
  142. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_token_refresh.py +0 -0
  143. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_upstream_compat.py +0 -0
  144. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_viewer_check_throttle.py +0 -0
  145. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/tests/test_viewer_presence.py +0 -0
  146. {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.14}/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.14
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
@@ -4080,7 +4080,15 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
4080
4080
  # Unknown: keep the old staleness heuristic rather
4081
4081
  # than holding the stream open forever.
4082
4082
  idle_release = True
4083
- break
4083
+ # Only an actual idle release ends this serve cycle.
4084
+ # (This break was unconditional - any idle_secs > 0
4085
+ # tore down a HEALTHY ffmpeg after one 0.5s tick,
4086
+ # respawning it forever: ffmpeg died at output-open
4087
+ # with "Immediate exit requested" and every DTLS
4088
+ # camera served nothing. stream_idle_s=0 masked it,
4089
+ # which is why never-release fleets still worked.)
4090
+ if idle_release:
4091
+ break
4084
4092
  # Tear down this ffmpeg+mux cycle before the next.
4085
4093
  stop_flag.set()
4086
4094
  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:
@@ -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.14"
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.14
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
@@ -73,6 +73,7 @@ tests/test_deferred_hardening.py
73
73
  tests/test_device_login_guard.py
74
74
  tests/test_device_user_info_cache.py
75
75
  tests/test_dormant_camera_cleanup.py
76
+ tests/test_dtls_mux_terminal_write.py
76
77
  tests/test_dtls_not_ready_burst.py
77
78
  tests/test_dtls_pinning.py
78
79
  tests/test_dtls_serve_decode_skip.py
@@ -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"