python-aidot-cameras 0.12.12__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.12/python_aidot_cameras.egg-info → python_aidot_cameras-0.12.14}/PKG-INFO +1 -1
  2. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/aidot_cameras/camera/client.py +30 -1
  3. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/aidot_cameras/camera/protocol.py +75 -7
  4. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/aidot_cameras/camera/sdes_open.py +11 -0
  5. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/pyproject.toml +1 -1
  6. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  7. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/python_aidot_cameras.egg-info/SOURCES.txt +2 -0
  8. python_aidot_cameras-0.12.14/tests/test_dormant_camera_cleanup.py +65 -0
  9. python_aidot_cameras-0.12.14/tests/test_dtls_mux_terminal_write.py +126 -0
  10. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_viewer_check_throttle.py +0 -1
  11. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/LICENSE +0 -0
  12. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/MANIFEST.in +0 -0
  13. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/README.md +0 -0
  14. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/aidot_cameras/__init__.py +0 -0
  15. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/aidot_cameras/__main__.py +0 -0
  16. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/__init__.py +0 -0
  17. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/__init__.py +0 -0
  18. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/clock.py +0 -0
  19. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/codecs/__init__.py +0 -0
  20. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/codecs/base.py +0 -0
  21. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/codecs/g711.py +0 -0
  22. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/codecs/g722.py +0 -0
  23. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/codecs/h264.py +0 -0
  24. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/codecs/opus.py +0 -0
  25. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/codecs/vpx.py +0 -0
  26. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/contrib/__init__.py +0 -0
  27. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/contrib/media.py +0 -0
  28. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/contrib/signaling.py +0 -0
  29. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/events.py +0 -0
  30. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/exceptions.py +0 -0
  31. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/jitterbuffer.py +0 -0
  32. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/mediastreams.py +0 -0
  33. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/rate.py +0 -0
  34. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/rtcconfiguration.py +0 -0
  35. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/rtcdatachannel.py +0 -0
  36. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/rtcdtlstransport.py +0 -0
  37. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/rtcicetransport.py +0 -0
  38. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/rtcpeerconnection.py +0 -0
  39. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/rtcrtpparameters.py +0 -0
  40. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/rtcrtpreceiver.py +0 -0
  41. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/rtcrtpsender.py +0 -0
  42. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/rtcrtptransceiver.py +0 -0
  43. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/rtcsctptransport.py +0 -0
  44. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/rtcsessiondescription.py +0 -0
  45. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/rtp.py +0 -0
  46. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/sdp.py +0 -0
  47. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/stats.py +0 -0
  48. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/aidot_cameras/_vendor/aiortc/utils.py +0 -0
  49. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/aidot_cameras/camera/__init__.py +0 -0
  50. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/aidot_cameras/camera/constants.py +0 -0
  51. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/aidot_cameras/camera/controls.py +0 -0
  52. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/aidot_cameras/camera/go2rtc.py +0 -0
  53. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/aidot_cameras/camera/lan_control.py +0 -0
  54. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/aidot_cameras/camera/models.py +0 -0
  55. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/aidot_cameras/camera/playback.py +0 -0
  56. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/aidot_cameras/camera/sdes.py +0 -0
  57. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/aidot_cameras/camera/tutk.py +0 -0
  58. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/aidot_cameras/camera/webrtc.py +0 -0
  59. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/aidot_cameras/camera/webrtc_open.py +0 -0
  60. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/aidot_cameras/client.py +0 -0
  61. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/aidot_cameras/const.py +0 -0
  62. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/aidot_cameras/credentials.py +0 -0
  63. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/aidot_cameras/crypto.py +0 -0
  64. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/aidot_cameras/device_client.py +0 -0
  65. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/aidot_cameras/discover.py +0 -0
  66. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/aidot_cameras/exceptions.py +0 -0
  67. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/aidot_cameras/g711.py +0 -0
  68. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/aidot_cameras/py.typed +0 -0
  69. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  70. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
  71. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/python_aidot_cameras.egg-info/requires.txt +0 -0
  72. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/python_aidot_cameras.egg-info/top_level.txt +0 -0
  73. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/setup.cfg +0 -0
  74. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_aioice_compat.py +0 -0
  75. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_alarm_event.py +0 -0
  76. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_backoff.py +0 -0
  77. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_deferred_hardening.py +0 -0
  78. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_device_login_guard.py +0 -0
  79. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_device_user_info_cache.py +0 -0
  80. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_dtls_not_ready_burst.py +0 -0
  81. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_dtls_pinning.py +0 -0
  82. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_dtls_serve_decode_skip.py +0 -0
  83. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_dtls_serve_open_timeout.py +0 -0
  84. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_dtls_skip_signaling_wait.py +0 -0
  85. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_dtls_slow_probe.py +0 -0
  86. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_egress_guard.py +0 -0
  87. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_go2rtc.py +0 -0
  88. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_go2rtc_cli.py +0 -0
  89. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_h264_decode_rate_limit.py +0 -0
  90. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_highport_nomination.py +0 -0
  91. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_ice_config_cache.py +0 -0
  92. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_idle_release_both_loops.py +0 -0
  93. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_keepalive_renew.py +0 -0
  94. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_key_restart_sdp.py +0 -0
  95. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_keyframe_prompter.py +0 -0
  96. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_lan_control.py +0 -0
  97. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_light_active_color_mode.py +0 -0
  98. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_live_stream_param.py +0 -0
  99. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_logging_caps.py +0 -0
  100. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_motion_poll.py +0 -0
  101. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_mqtt_credential_refetch.py +0 -0
  102. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_mqtt_credential_self_heal.py +0 -0
  103. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_mqtt_password_not_persisted.py +0 -0
  104. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_mqtt_session_reconnect.py +0 -0
  105. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_narrow_pc_ice.py +0 -0
  106. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_no_self_referential_go2rtc.py +0 -0
  107. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_no_undefined_names.py +0 -0
  108. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_offline_keepalive_pause.py +0 -0
  109. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_open_fail_logger.py +0 -0
  110. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_open_gate_delay.py +0 -0
  111. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_persistent_mqtt.py +0 -0
  112. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_persistent_mqtt_robustness.py +0 -0
  113. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_playback_tls.py +0 -0
  114. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_post_merge_hardening.py +0 -0
  115. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_retry_policy.py +0 -0
  116. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_sdes_adaptive.py +0 -0
  117. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_sdes_bridge_break_gate.py +0 -0
  118. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_sdes_echo_wait_timeout.py +0 -0
  119. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_sdes_fast_liveplay.py +0 -0
  120. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_sdes_ffmpeg_stderr_log.py +0 -0
  121. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_sdes_idle_release.py +0 -0
  122. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_sdes_open_cleanup.py +0 -0
  123. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_sdes_serve_audio.py +0 -0
  124. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_sdes_serve_cmd.py +0 -0
  125. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_sdes_sprop.py +0 -0
  126. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_sdes_talk.py +0 -0
  127. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_sdes_teardown_exit_log.py +0 -0
  128. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_sdes_watchdog.py +0 -0
  129. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_sdp_payload_type_narrowing.py +0 -0
  130. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_security_hardening.py +0 -0
  131. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_serializable_login_info.py +0 -0
  132. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_serve_relay.py +0 -0
  133. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_session_stats.py +0 -0
  134. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_speak.py +0 -0
  135. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_stream_cap.py +0 -0
  136. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_stream_drain_ownership.py +0 -0
  137. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_stream_idle.py +0 -0
  138. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_stream_slot_cap.py +0 -0
  139. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_stream_teardown.py +0 -0
  140. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_talk.py +0 -0
  141. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_terminal_ack.py +0 -0
  142. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_token_file_robustness.py +0 -0
  143. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_token_refresh.py +0 -0
  144. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_upstream_compat.py +0 -0
  145. {python_aidot_cameras-0.12.12 → python_aidot_cameras-0.12.14}/tests/test_viewer_presence.py +0 -0
  146. {python_aidot_cameras-0.12.12 → 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.12
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
@@ -3155,6 +3155,19 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3155
3155
  self._cancel_keepalive_renew()
3156
3156
  self._keepalive_rtsp_url = None
3157
3157
  self._serve_ready.clear()
3158
+ # Take the stream out of go2rtc too. Leaving it registered
3159
+ # points go2rtc at a source that no longer exists, so a viewer
3160
+ # attaching while the camera is dormant gets a hard refusal
3161
+ # ("connection refused" on the serve port) instead of a clean
3162
+ # "no such stream" - and in push mode that source was never
3163
+ # dialable in the first place. stream_source() re-registers on
3164
+ # the next view.
3165
+ try:
3166
+ await self._deregister_go2rtc()
3167
+ except Exception:
3168
+ _LOGGER.debug("camera %s: swallowed exception in %s",
3169
+ getattr(self, "device_id", "?"),
3170
+ "_deregister_go2rtc", exc_info=True)
3158
3171
  _LOGGER.debug(
3159
3172
  "SDES serve: %s idle (no viewer) - released until next view",
3160
3173
  self.device_id,
@@ -4067,7 +4080,15 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
4067
4080
  # Unknown: keep the old staleness heuristic rather
4068
4081
  # than holding the stream open forever.
4069
4082
  idle_release = True
4070
- 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
4071
4092
  # Tear down this ffmpeg+mux cycle before the next.
4072
4093
  stop_flag.set()
4073
4094
  try:
@@ -4116,6 +4137,14 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
4116
4137
  self._cancel_keepalive_renew()
4117
4138
  self._keepalive_rtsp_url = None
4118
4139
  self._serve_ready.clear()
4140
+ # Same as the SDES path: do not leave go2rtc holding a stream
4141
+ # whose source no longer exists.
4142
+ try:
4143
+ await self._deregister_go2rtc()
4144
+ except Exception:
4145
+ _LOGGER.debug("camera %s: swallowed exception in %s",
4146
+ getattr(self, "device_id", "?"),
4147
+ "_deregister_go2rtc", exc_info=True)
4119
4148
  _LOGGER.debug(
4120
4149
  "DTLS serve: %s idle (no viewer) - released until next view",
4121
4150
  self.device_id,
@@ -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:
@@ -102,6 +102,11 @@ def _sdes_echo_wait_timeout(skip_liveplay: bool) -> float:
102
102
  return 0.0 if skip_liveplay else 2.0
103
103
 
104
104
 
105
+ # ffmpeg returns AVERROR(EPIPE) = -32 when its output consumer disappears, and a
106
+ # process exit status is truncated to an unsigned byte: -32 & 0xFF = 224.
107
+ _FFMPEG_EXIT_EPIPE = 224
108
+
109
+
105
110
  def _classify_ffmpeg_exit(rc: int, teardown_requested: bool) -> int:
106
111
  """Log level for the bridge observe loop's "ffmpeg exited" line.
107
112
 
@@ -116,6 +121,12 @@ def _classify_ffmpeg_exit(rc: int, teardown_requested: bool) -> int:
116
121
  """
117
122
  if rc < 0 and teardown_requested:
118
123
  return logging.DEBUG
124
+ if rc == _FFMPEG_EXIT_EPIPE:
125
+ # Broken pipe: the consumer went away. That is the NORMAL end of a
126
+ # `-listen 1` serve - go2rtc disconnects and ffmpeg exits - so warning
127
+ # about it trains the reader to ignore real failures. A process exit
128
+ # status is an unsigned byte, so AVERROR(EPIPE) = -32 surfaces as 224.
129
+ return logging.DEBUG
119
130
  return logging.WARNING
120
131
 
121
132
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-aidot-cameras"
7
- version = "0.12.12"
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.12
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
@@ -72,6 +72,8 @@ tests/test_backoff.py
72
72
  tests/test_deferred_hardening.py
73
73
  tests/test_device_login_guard.py
74
74
  tests/test_device_user_info_cache.py
75
+ tests/test_dormant_camera_cleanup.py
76
+ tests/test_dtls_mux_terminal_write.py
75
77
  tests/test_dtls_not_ready_burst.py
76
78
  tests/test_dtls_pinning.py
77
79
  tests/test_dtls_serve_decode_skip.py
@@ -0,0 +1,65 @@
1
+ """A dormant camera must not leave a dead stream registered in go2rtc.
2
+
3
+ Idle-release stops the serve but used to leave the go2rtc stream in place,
4
+ pointing at a source that no longer exists. A viewer attaching then gets a hard
5
+ "connection refused" on the serve port rather than a clean miss - and in PUSH mode
6
+ that source was never dialable at all, because the keepalive publishes INTO go2rtc
7
+ rather than serving over HTTP.
8
+
9
+ Observed live: four go2rtc streams registered against serve ports with nothing
10
+ listening, and go2rtc reporting
11
+ `dial tcp 127.0.0.1:18981: connect: connection refused`.
12
+ """
13
+ import inspect
14
+
15
+ import aidot_cameras.camera.client as cc
16
+ from aidot_cameras.camera.sdes_open import (
17
+ _FFMPEG_EXIT_EPIPE,
18
+ _classify_ffmpeg_exit,
19
+ )
20
+ import logging
21
+
22
+
23
+ def _src(name):
24
+ return inspect.getsource(getattr(cc.CameraMixin, name))
25
+
26
+
27
+ def test_sdes_idle_release_deregisters_from_go2rtc():
28
+ src = _src("_sdes_keepalive_loop_inner")
29
+ idle = src[src.index("if _idle_release:"):]
30
+ assert "_deregister_go2rtc" in idle
31
+
32
+
33
+ def test_dtls_idle_release_deregisters_from_go2rtc():
34
+ src = _src("_dtls_serve_loop_inner")
35
+ idle = src[src.index("if idle_release:"):]
36
+ assert "_deregister_go2rtc" in idle
37
+
38
+
39
+ def test_deregistration_failure_cannot_break_the_release():
40
+ # Going dormant must still complete if go2rtc is unreachable.
41
+ for name, marker in (("_sdes_keepalive_loop_inner", "if _idle_release:"),
42
+ ("_dtls_serve_loop_inner", "if idle_release:")):
43
+ idle = _src(name)
44
+ idle = idle[idle.index(marker):]
45
+ block = idle[idle.index("_deregister_go2rtc") - 200:
46
+ idle.index("_deregister_go2rtc") + 300]
47
+ assert "try:" in block and "except Exception" in block
48
+
49
+
50
+ def test_consumer_disconnect_is_not_logged_as_a_failure():
51
+ # ffmpeg returns AVERROR(EPIPE) = -32 when its consumer goes away, and an exit
52
+ # status is an unsigned byte, so it surfaces as 224. That is the normal end of
53
+ # a `-listen 1` serve, not a fault.
54
+ assert _FFMPEG_EXIT_EPIPE == 224
55
+ assert _classify_ffmpeg_exit(224, teardown_requested=False) == logging.DEBUG
56
+ assert _classify_ffmpeg_exit(224, teardown_requested=True) == logging.DEBUG
57
+
58
+
59
+ def test_real_failures_still_warn():
60
+ assert _classify_ffmpeg_exit(1, teardown_requested=False) == logging.WARNING
61
+ assert _classify_ffmpeg_exit(255, teardown_requested=False) == logging.WARNING
62
+ # A signal death with no teardown in flight is still unexpected.
63
+ assert _classify_ffmpeg_exit(-9, teardown_requested=False) == logging.WARNING
64
+ # ...but an expected teardown is quiet.
65
+ assert _classify_ffmpeg_exit(-9, teardown_requested=True) == logging.DEBUG
@@ -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"
@@ -8,7 +8,6 @@ anywhere. Idle release is measured in minutes, so a few seconds of staleness in
8
8
  the answer costs nothing.
9
9
  """
10
10
  import asyncio
11
- import time
12
11
 
13
12
  import aidot_cameras.camera.client as cc
14
13
  from aidot_cameras.device_client import CameraDeviceClient