python-aidot-cameras 0.11.11__tar.gz → 0.11.12__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 (137) hide show
  1. {python_aidot_cameras-0.11.11/python_aidot_cameras.egg-info → python_aidot_cameras-0.11.12}/PKG-INFO +1 -1
  2. python_aidot_cameras-0.11.12/aidot/__init__.py +137 -0
  3. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/camera/client.py +157 -5
  4. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/camera/protocol.py +7 -0
  5. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/camera/sdes.py +18 -0
  6. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/camera/sdes_open.py +63 -2
  7. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/pyproject.toml +1 -1
  8. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  9. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/python_aidot_cameras.egg-info/SOURCES.txt +4 -0
  10. python_aidot_cameras-0.11.12/tests/test_dtls_serve_decode_skip.py +181 -0
  11. python_aidot_cameras-0.11.12/tests/test_dtls_slow_probe.py +163 -0
  12. python_aidot_cameras-0.11.12/tests/test_h264_decode_rate_limit.py +141 -0
  13. python_aidot_cameras-0.11.12/tests/test_logging_caps.py +108 -0
  14. python_aidot_cameras-0.11.12/tests/test_sdes_teardown_exit_log.py +128 -0
  15. python_aidot_cameras-0.11.11/aidot/__init__.py +0 -54
  16. python_aidot_cameras-0.11.11/tests/test_logging_caps.py +0 -31
  17. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/LICENSE +0 -0
  18. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/MANIFEST.in +0 -0
  19. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/README.md +0 -0
  20. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/__main__.py +0 -0
  21. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/__init__.py +0 -0
  22. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/__init__.py +0 -0
  23. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/clock.py +0 -0
  24. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/codecs/__init__.py +0 -0
  25. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/codecs/base.py +0 -0
  26. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/codecs/g711.py +0 -0
  27. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/codecs/g722.py +0 -0
  28. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/codecs/h264.py +0 -0
  29. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/codecs/opus.py +0 -0
  30. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/codecs/vpx.py +0 -0
  31. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/contrib/__init__.py +0 -0
  32. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/contrib/media.py +0 -0
  33. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/contrib/signaling.py +0 -0
  34. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/events.py +0 -0
  35. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/exceptions.py +0 -0
  36. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/jitterbuffer.py +0 -0
  37. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/mediastreams.py +0 -0
  38. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/rate.py +0 -0
  39. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/rtcconfiguration.py +0 -0
  40. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/rtcdatachannel.py +0 -0
  41. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/rtcdtlstransport.py +0 -0
  42. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/rtcicetransport.py +0 -0
  43. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/rtcpeerconnection.py +0 -0
  44. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/rtcrtpparameters.py +0 -0
  45. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/rtcrtpreceiver.py +0 -0
  46. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/rtcrtpsender.py +0 -0
  47. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/rtcrtptransceiver.py +0 -0
  48. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/rtcsctptransport.py +0 -0
  49. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/rtcsessiondescription.py +0 -0
  50. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/rtp.py +0 -0
  51. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/sdp.py +0 -0
  52. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/stats.py +0 -0
  53. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/utils.py +0 -0
  54. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/aes_utils.py +0 -0
  55. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/camera/__init__.py +0 -0
  56. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/camera/constants.py +0 -0
  57. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/camera/controls.py +0 -0
  58. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/camera/go2rtc.py +0 -0
  59. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/camera/lan_control.py +0 -0
  60. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/camera/models.py +0 -0
  61. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/camera/playback.py +0 -0
  62. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/camera/tutk.py +0 -0
  63. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/camera/webrtc.py +0 -0
  64. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/camera/webrtc_open.py +0 -0
  65. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/client.py +0 -0
  66. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/const.py +0 -0
  67. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/credentials.py +0 -0
  68. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/device_client.py +0 -0
  69. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/discover.py +0 -0
  70. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/exceptions.py +0 -0
  71. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/g711.py +0 -0
  72. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/login_const.py +0 -0
  73. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/models/__init__.py +0 -0
  74. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/models/device_client_model.py +0 -0
  75. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/models/device_model.py +0 -0
  76. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/models/discover_model.py +0 -0
  77. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/py.typed +0 -0
  78. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  79. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
  80. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/python_aidot_cameras.egg-info/requires.txt +0 -0
  81. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/python_aidot_cameras.egg-info/top_level.txt +0 -0
  82. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/setup.cfg +0 -0
  83. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_aioice_compat.py +0 -0
  84. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_alarm_event.py +0 -0
  85. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_backoff.py +0 -0
  86. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_deferred_hardening.py +0 -0
  87. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_device_login_guard.py +0 -0
  88. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_device_user_info_cache.py +0 -0
  89. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_dtls_not_ready_burst.py +0 -0
  90. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_dtls_pinning.py +0 -0
  91. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_dtls_skip_signaling_wait.py +0 -0
  92. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_egress_guard.py +0 -0
  93. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_go2rtc.py +0 -0
  94. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_go2rtc_cli.py +0 -0
  95. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_highport_nomination.py +0 -0
  96. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_ice_config_cache.py +0 -0
  97. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_keepalive_renew.py +0 -0
  98. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_keyframe_prompter.py +0 -0
  99. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_lan_control.py +0 -0
  100. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_live_stream_param.py +0 -0
  101. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_motion_poll.py +0 -0
  102. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_mqtt_session_reconnect.py +0 -0
  103. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_narrow_pc_ice.py +0 -0
  104. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_no_undefined_names.py +0 -0
  105. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_offline_keepalive_pause.py +0 -0
  106. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_open_fail_logger.py +0 -0
  107. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_open_gate_delay.py +0 -0
  108. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_persistent_mqtt.py +0 -0
  109. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_persistent_mqtt_robustness.py +0 -0
  110. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_playback_tls.py +0 -0
  111. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_post_merge_hardening.py +0 -0
  112. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_retry_policy.py +0 -0
  113. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_sdes_adaptive.py +0 -0
  114. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_sdes_echo_wait_timeout.py +0 -0
  115. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_sdes_fast_liveplay.py +0 -0
  116. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_sdes_ffmpeg_stderr_log.py +0 -0
  117. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_sdes_idle_release.py +0 -0
  118. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_sdes_open_cleanup.py +0 -0
  119. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_sdes_serve_audio.py +0 -0
  120. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_sdes_serve_cmd.py +0 -0
  121. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_sdes_sprop.py +0 -0
  122. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_sdes_talk.py +0 -0
  123. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_sdes_watchdog.py +0 -0
  124. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_security_hardening.py +0 -0
  125. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_serializable_login_info.py +0 -0
  126. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_serve_relay.py +0 -0
  127. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_session_stats.py +0 -0
  128. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_speak.py +0 -0
  129. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_stream_cap.py +0 -0
  130. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_stream_drain_ownership.py +0 -0
  131. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_stream_idle.py +0 -0
  132. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_stream_teardown.py +0 -0
  133. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_talk.py +0 -0
  134. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_terminal_ack.py +0 -0
  135. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_token_file_robustness.py +0 -0
  136. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_token_refresh.py +0 -0
  137. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/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.11.11
3
+ Version: 0.11.12
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
@@ -0,0 +1,137 @@
1
+ """AiDot camera and device library."""
2
+
3
+ import logging as _logging
4
+ import time as _time
5
+
6
+ from .client import AidotClient
7
+ from .device_client import DeviceClient
8
+ from .discover import Discover
9
+ from .exceptions import (
10
+ AidotAuthFailed,
11
+ AidotAuthTokenExpired,
12
+ AidotCameraBusy,
13
+ AidotError,
14
+ AidotNotLogin,
15
+ AidotOSError,
16
+ AidotUserOrPassIncorrect,
17
+ HTTPError,
18
+ InvalidHost,
19
+ InvalidURL,
20
+ )
21
+
22
+ # Cap the noisy external/vendored loggers so enabling DEBUG on the parent
23
+ # ``aidot`` logger to diagnose the integration does not unleash a flood that,
24
+ # on a microSD Home Assistant host, can starve the recorder with log I/O.
25
+ #
26
+ # The vendored aiortc RTP receiver/sender log every media packet at DEBUG, so
27
+ # capping them at INFO removes that flood while leaving the diagnostically
28
+ # valuable DEBUG (DTLS, SCTP/DCEP, ICE connection state) flowing.
29
+ #
30
+ # The external ``aioice`` package's flood is different: it logs ICE
31
+ # connectivity-check state transitions at INFO, not DEBUG (aioice is a real
32
+ # dependency, not vendored, so it is not under ``aidot._vendor``). An INFO cap
33
+ # cannot suppress an INFO-level flood, so ``aioice.ice`` and ``aioice.turn``
34
+ # are capped at WARNING instead; aioice emits nothing at WARNING or above, so
35
+ # no real warning is lost. A user who wants the aioice ICE INFO logging back
36
+ # (e.g. to debug connectivity issues) can set that logger's level explicitly -
37
+ # the NOTSET guard below respects it.
38
+ _EXTERNAL_LOGGER_CAPS = {
39
+ "aidot._vendor.aiortc.rtcrtpreceiver": _logging.INFO,
40
+ "aidot._vendor.aiortc.rtcrtpsender": _logging.INFO,
41
+ "aioice.ice": _logging.WARNING,
42
+ "aioice.turn": _logging.WARNING,
43
+ }
44
+
45
+
46
+ def _cap_external_loggers() -> None:
47
+ """Idempotently apply the caps in ``_EXTERNAL_LOGGER_CAPS``.
48
+
49
+ Only touches a logger whose level is still NOTSET, so an explicit level
50
+ set by the user (before or after this runs) is never overridden.
51
+ """
52
+ for _name, _level in _EXTERNAL_LOGGER_CAPS.items():
53
+ _lg = _logging.getLogger(_name)
54
+ if _lg.level == _logging.NOTSET:
55
+ _lg.setLevel(_level)
56
+
57
+
58
+ _cap_external_loggers()
59
+
60
+ # The vendored H.264 decoder logs a WARNING per corrupt/undecodable frame
61
+ # (aidot/_vendor/aiortc/codecs/h264.py:118). A degrading link can therefore
62
+ # log hundreds of identical lines in a few minutes; a live evaluation saw 172
63
+ # in 11 minutes. A level cap cannot help here since the message is already at
64
+ # WARNING, and the message is a corruption canary for a degrading link, so it
65
+ # must not be silenced outright. Instead a rate-limiting filter lets the
66
+ # first WARNING through immediately, suppresses subsequent same-logger
67
+ # WARNINGs within a window, and lets the first WARNING after the window
68
+ # elapses through carrying the suppressed count, so the canary stays visible
69
+ # at a low rate instead of flooding.
70
+ _H264_DECODE_LOGGER_NAME = "aidot._vendor.aiortc.codecs.h264"
71
+ _H264_DECODE_RATE_LIMIT_WINDOW_SECONDS = 30.0
72
+
73
+
74
+ class _RateLimitingWarningFilter(_logging.Filter):
75
+ """Rate-limit WARNING records on the logger it is attached to.
76
+
77
+ The first WARNING is let through immediately. Subsequent WARNINGs within
78
+ ``window_seconds`` of it are suppressed (dropped) but counted. The next
79
+ WARNING to arrive once the window has elapsed is let through, with its
80
+ message annotated with how many were suppressed during the window, and a
81
+ new window begins from that record's time. Records at other levels, and
82
+ records on other loggers, are never touched.
83
+
84
+ ``time_func`` defaults to ``time.monotonic`` but is injectable so tests
85
+ can drive the window deterministically without real sleeps.
86
+ """
87
+
88
+ def __init__(self, window_seconds: float = _H264_DECODE_RATE_LIMIT_WINDOW_SECONDS, time_func=_time.monotonic) -> None:
89
+ super().__init__()
90
+ self._window_seconds = window_seconds
91
+ self._time_func = time_func
92
+ self._window_start = None
93
+ self._suppressed = 0
94
+
95
+ def filter(self, record: _logging.LogRecord) -> bool:
96
+ if record.levelno != _logging.WARNING:
97
+ return True
98
+ _now = self._time_func()
99
+ if self._window_start is None:
100
+ self._window_start = _now
101
+ return True
102
+ if _now - self._window_start < self._window_seconds:
103
+ self._suppressed += 1
104
+ return False
105
+ _suppressed = self._suppressed
106
+ self._window_start = _now
107
+ self._suppressed = 0
108
+ if _suppressed:
109
+ record.msg = f"{record.getMessage()} (suppressed {_suppressed} similar warning(s) in the last {self._window_seconds:.0f}s)"
110
+ record.args = ()
111
+ return True
112
+
113
+
114
+ def _install_h264_decode_rate_limit_filter() -> None:
115
+ """Idempotently attach the rate-limiting filter to the h264 decode logger."""
116
+ _lg = _logging.getLogger(_H264_DECODE_LOGGER_NAME)
117
+ if not any(isinstance(_f, _RateLimitingWarningFilter) for _f in _lg.filters):
118
+ _lg.addFilter(_RateLimitingWarningFilter())
119
+
120
+
121
+ _install_h264_decode_rate_limit_filter()
122
+
123
+ __all__ = [
124
+ "AidotAuthFailed",
125
+ "AidotAuthTokenExpired",
126
+ "AidotCameraBusy",
127
+ "AidotClient",
128
+ "AidotError",
129
+ "AidotNotLogin",
130
+ "AidotOSError",
131
+ "AidotUserOrPassIncorrect",
132
+ "DeviceClient",
133
+ "Discover",
134
+ "HTTPError",
135
+ "InvalidHost",
136
+ "InvalidURL",
137
+ ]
@@ -97,6 +97,26 @@ _LOGGER = logging.getLogger(__name__)
97
97
  _OFFLINE_RECHECK_S = float(os.environ.get("AIDOT_OFFLINE_RECHECK_S", "30"))
98
98
  _OFFLINE_PROBE_S = float(os.environ.get("AIDOT_OFFLINE_PROBE_S", "600"))
99
99
 
100
+ # DTLS serve loop slow-probe: an idle-but-cloud-online camera (asleep, wedged
101
+ # firmware, ...) never trips the offline pause above (the cloud keeps reporting
102
+ # it reachable), so its failed opens would otherwise retry FOREVER at the
103
+ # pacer's 300s cap - burning a 30s open timeout every attempt and dripping a
104
+ # WARNING each time (see _open_fail_logger). After
105
+ # AIDOT_DTLS_SLOW_PROBE_THRESHOLD consecutive open failures (keyed on the
106
+ # ReconnectPacer's attempt count - see _in_slow_probe / _probe_interval below -
107
+ # NOT the cloud-offline flag, which this case never sets), the loop widens its
108
+ # retry interval to AIDOT_DTLS_SLOW_PROBE_INTERVAL_S and downgrades the
109
+ # per-attempt WARNING to a periodic INFO summary (_should_log_slow_probe).
110
+ # Resets the instant an open succeeds, via the same ReconnectPacer.reset() the
111
+ # loop already calls on success (attempt -> 0, so _in_slow_probe is false again
112
+ # with no separate state to clear).
113
+ _SLOW_PROBE_THRESHOLD = int(os.environ.get("AIDOT_DTLS_SLOW_PROBE_THRESHOLD", "5"))
114
+ _SLOW_PROBE_INTERVAL_S = float(os.environ.get("AIDOT_DTLS_SLOW_PROBE_INTERVAL_S", "600"))
115
+ _SLOW_PROBE_LOG_EVERY = int(os.environ.get("AIDOT_DTLS_SLOW_PROBE_LOG_EVERY", "6"))
116
+ # Sleep increment for the slow-probe wait: never one blocking
117
+ # asyncio.sleep(interval) call, so stop() is not delayed by up to 10 minutes.
118
+ _SLOW_PROBE_SLEEP_CHUNK_S = float(os.environ.get("AIDOT_DTLS_SLOW_PROBE_CHUNK_S", "5"))
119
+
100
120
  # Strong refs to fire-and-forget tasks: asyncio only keeps weak refs, so a
101
121
  # discarded task can be garbage-collected mid-flight. Discarded on completion.
102
122
  _BG_TASKS: set = set()
@@ -164,6 +184,39 @@ def _open_gate_delay(last_open_at: float, now: float, open_gate: float) -> float
164
184
  return remaining if remaining > 0 else 0.0
165
185
 
166
186
 
187
+ def _in_slow_probe(attempt: int, threshold: int) -> bool:
188
+ """True once `attempt` consecutive DTLS open failures have reached
189
+ `threshold`: the serve loop should stop hammering an idle-but-cloud-online
190
+ camera at the pacer's normal cadence and fall back to a slow probe."""
191
+ return attempt >= threshold
192
+
193
+
194
+ def _probe_interval(attempt: int, threshold: int, normal_delay: float,
195
+ slow_interval: float) -> float:
196
+ """Effective DTLS-open retry interval for a failed open.
197
+
198
+ Below `threshold` consecutive failures this is just the pacer's own
199
+ `normal_delay` (unchanged jittered-backoff behaviour). At/after `threshold`
200
+ it widens to (at least) `slow_interval` - well beyond the pacer's own cap -
201
+ so a camera that is cloud-online but never actually opens is probed
202
+ roughly every `slow_interval` seconds instead of retried forever at the
203
+ pacer's max cadence."""
204
+ if _in_slow_probe(attempt, threshold):
205
+ return max(normal_delay, slow_interval)
206
+ return normal_delay
207
+
208
+
209
+ def _should_log_slow_probe(attempt: int, threshold: int, log_every: int) -> bool:
210
+ """True when a slow-probe open failure should be logged: the moment
211
+ slow-probe is entered, then every `log_every`th attempt after - so a
212
+ camera stuck for hours doesn't drip a log line on every single probe."""
213
+ if not _in_slow_probe(attempt, threshold):
214
+ return False
215
+ if log_every <= 0:
216
+ return True
217
+ return (attempt - threshold) % log_every == 0
218
+
219
+
167
220
  _FFMPEG_MISSING_MSG = (
168
221
  "ffmpeg not found on PATH; install ffmpeg to enable recording/transcoding.\n"
169
222
  " Ubuntu/Debian: sudo apt install ffmpeg\n"
@@ -845,6 +898,23 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
845
898
  self.device_id,
846
899
  )
847
900
 
901
+ async def _slow_probe_sleep(self, interval: float) -> None:
902
+ """Sleep `interval` seconds for the DTLS serve loop's slow-probe wait,
903
+ in `_SLOW_PROBE_SLEEP_CHUNK_S` increments rather than one blocking
904
+ `asyncio.sleep(interval)` call, re-checking `_streaming_active` each
905
+ increment. `interval` can be several minutes; without chunking,
906
+ `stop()` would have to wait out the whole thing before the loop next
907
+ checks whether it should keep running.
908
+ """
909
+ _t0 = time.monotonic()
910
+ while True:
911
+ if not getattr(self, "_streaming_active", False):
912
+ return
913
+ _remaining = interval - (time.monotonic() - _t0)
914
+ if _remaining <= 0:
915
+ return
916
+ await asyncio.sleep(min(_SLOW_PROBE_SLEEP_CHUNK_S, _remaining))
917
+
848
918
  # -- Camera helpers ------------------------------------------------------ #
849
919
 
850
920
  @property
@@ -2976,11 +3046,27 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2976
3046
  _LOGGER.debug("PLI request failed: %s", _pli_exc)
2977
3047
 
2978
3048
  @staticmethod
2979
- def _install_encoded_tap(receiver, out_q, is_video: bool) -> bool:
3049
+ def _install_encoded_tap(receiver, out_q, is_video: bool, serve: bool = False) -> bool:
2980
3050
  """Tee aiortc's depacketized encoded frames (+ RTP timestamp) into a
2981
3051
  thread-safe queue before decode. Video frames go as
2982
3052
  ``(bytes, timestamp, is_keyframe)``, audio as ``(bytes, timestamp)``.
2983
- ``out_q`` is a ``queue.Queue`` (the mux runs in a worker thread)."""
3053
+ ``out_q`` is a ``queue.Queue`` (the mux runs in a worker thread).
3054
+
3055
+ When ``serve`` is set, the served H.264 is muxed as ``-c copy`` straight
3056
+ from this tap, so the vendored decoder's output is discarded (the serve
3057
+ loop uses a no-op on_frame). In that mode we STOP feeding the decoder
3058
+ queue for video DATA frames: decoding them is pure wasted CPU and the
3059
+ sole source of the per-packet decode-failure warning flood. The None
3060
+ terminator is STILL forwarded so the decoder thread exits cleanly, and
3061
+ audio is left untouched (drained separately). We must not instead pass
3062
+ ``on_frame=None`` at the receiver level - the decoder thread would keep
3063
+ running and fill the unbounded track queue with nothing draining it.
3064
+
3065
+ Because decode was the only signal that the served H.264 was corrupt, a
3066
+ cheap keyframe-based canary (no decoding) is kept: it counts frames and
3067
+ keyframes, tracks the longest run with no keyframe, and emits a periodic
3068
+ DEBUG summary. It is O(1) per frame and stays at DEBUG so a corrupt
3069
+ camera can't recreate the very flood this removes."""
2984
3070
  _qd = getattr(receiver, "_RTCRtpReceiver__decoder_queue", None)
2985
3071
  if _qd is None:
2986
3072
  return False
@@ -2990,6 +3076,12 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2990
3076
  if getattr(_qd, "_aidot_tapped", False):
2991
3077
  return True
2992
3078
  _orig_put = _qd.put
3079
+ _skip_decode = bool(serve and is_video)
3080
+ # Served-stream health canary (video-only): observed without decoding.
3081
+ _canary = {"frames": 0, "keyframes": 0, "gap": 0, "max_gap": 0} if _skip_decode else None
3082
+ if _canary is not None:
3083
+ _qd._aidot_serve_canary = _canary
3084
+ _CANARY_LOG_EVERY = 300 # frames (~10-20s of H.264); DEBUG summary cadence
2993
3085
 
2994
3086
  def _tap_put(task, *a, **k):
2995
3087
  try:
@@ -2998,12 +3090,34 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2998
3090
  _d = getattr(_enc, "data", None)
2999
3091
  _ts = getattr(_enc, "timestamp", None)
3000
3092
  if _d and _ts is not None:
3001
- _item = ((bytes(_d), int(_ts), _h264_has_keyframe(bytes(_d)))
3002
- if is_video else (bytes(_d), int(_ts)))
3093
+ _b = bytes(_d)
3094
+ _kf = _h264_has_keyframe(_b) if is_video else False
3095
+ _item = (_b, int(_ts), _kf) if is_video else (_b, int(_ts))
3003
3096
  try:
3004
3097
  out_q.put_nowait(_item)
3005
3098
  except Exception:
3006
3099
  pass # full -> drop (PLI re-arms a GOP)
3100
+ if _canary is not None:
3101
+ _canary["frames"] += 1
3102
+ if _kf:
3103
+ _canary["keyframes"] += 1
3104
+ _canary["gap"] = 0
3105
+ else:
3106
+ _canary["gap"] += 1
3107
+ if _canary["gap"] > _canary["max_gap"]:
3108
+ _canary["max_gap"] = _canary["gap"]
3109
+ if _canary["frames"] % _CANARY_LOG_EVERY == 0:
3110
+ _LOGGER.debug(
3111
+ "serve h264 canary: frames=%d keyframes=%d"
3112
+ " max_keyframe_gap=%d cur_gap=%d",
3113
+ _canary["frames"], _canary["keyframes"],
3114
+ _canary["max_gap"], _canary["gap"],
3115
+ )
3116
+ if _skip_decode:
3117
+ # Discarded decode: don't feed the decoder for served
3118
+ # video data frames (see docstring). Terminator (task
3119
+ # is None) still falls through to _orig_put below.
3120
+ return None
3007
3121
  except Exception:
3008
3122
  _LOGGER.debug("swallowed exception in %s", '_tap_put', exc_info=True)
3009
3123
  return _orig_put(task, *a, **k)
@@ -3017,6 +3131,12 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3017
3131
 
3018
3132
  Called repeatedly until the video tap lands; _install_encoded_tap is
3019
3133
  idempotent per receiver, so audio isn't re-wrapped across retries.
3134
+
3135
+ This runs only from the DTLS serve loop, whose on_frame is a no-op and
3136
+ whose output is muxed as H.264 copy from the video tap - so the video
3137
+ tap is installed with serve=True to skip the discarded decode (see
3138
+ _install_encoded_tap). Audio keeps feeding its decoder (drained
3139
+ separately).
3020
3140
  """
3021
3141
  got_v = False
3022
3142
  got_a = False
@@ -3025,7 +3145,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3025
3145
  if _tr is None:
3026
3146
  continue
3027
3147
  if _tr.kind == "video" and not got_v:
3028
- got_v = self._install_encoded_tap(_r, vq, True)
3148
+ got_v = self._install_encoded_tap(_r, vq, True, serve=True)
3029
3149
  elif _tr.kind == "audio" and not got_a:
3030
3150
  got_a = self._install_encoded_tap(_r, aq, False)
3031
3151
  return got_v
@@ -3419,6 +3539,35 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3419
3539
  continue
3420
3540
  except Exception as exc:
3421
3541
  _delay = _pacer.fail_delay()
3542
+ _attempt = _pacer.attempt
3543
+ _cloud_offline = (
3544
+ (not self.status.online)
3545
+ and getattr(self, "_cloud_online_explicit", False)
3546
+ )
3547
+ # Slow-probe throttle: an idle-but-cloud-online camera never
3548
+ # sets _cloud_offline (the cloud keeps reporting it reachable),
3549
+ # so without this it would retry forever at the pacer's cap.
3550
+ # Keyed purely on the pacer's consecutive-failure count, not
3551
+ # the offline flag - a genuinely offline camera already gets a
3552
+ # comparable slow cadence (with early-resume) from
3553
+ # _backoff_or_offline_pause below, so this only engages the
3554
+ # part of the space that pause doesn't already cover.
3555
+ if _in_slow_probe(_attempt, _SLOW_PROBE_THRESHOLD) and not _cloud_offline:
3556
+ _delay = _probe_interval(
3557
+ _attempt, _SLOW_PROBE_THRESHOLD, _delay, _SLOW_PROBE_INTERVAL_S)
3558
+ if _should_log_slow_probe(
3559
+ _attempt, _SLOW_PROBE_THRESHOLD, _SLOW_PROBE_LOG_EVERY
3560
+ ):
3561
+ _LOGGER.info(
3562
+ "DTLS serve: %s still failing to open after %d"
3563
+ " attempts - slow-probing every %.0fs (last error: %s)",
3564
+ self.device_id, _attempt, _delay, exc,
3565
+ )
3566
+ try:
3567
+ await self._slow_probe_sleep(_delay)
3568
+ except asyncio.CancelledError:
3569
+ return
3570
+ continue
3422
3571
  self._open_fail_logger()(
3423
3572
  "DTLS serve: open failed for %s (retry %.0fs): %s",
3424
3573
  self.device_id, _delay, exc,
@@ -3429,6 +3578,9 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3429
3578
  return
3430
3579
  continue
3431
3580
 
3581
+ # Resetting the pacer's attempt count also clears slow-probe state:
3582
+ # it derives purely from _pacer.attempt, so _in_slow_probe reads
3583
+ # false again on the very next failure sequence.
3432
3584
  _pacer.reset()
3433
3585
  _not_ready_burst = 0 # clean open: reset the encoder-cold burst
3434
3586
  self._stream_session = session
@@ -458,6 +458,13 @@ class ReconnectPacer:
458
458
  self._rand = rand # forwarded to next_backoff; injectable for tests
459
459
  self._attempt = 0
460
460
 
461
+ @property
462
+ def attempt(self) -> int:
463
+ """Consecutive-failure count (read-only). Callers that need to escalate
464
+ beyond this pacer's own cap (see the DTLS serve loop's slow-probe
465
+ throttle) key off this instead of duplicating the counter."""
466
+ return self._attempt
467
+
461
468
  def fail_delay(self) -> float:
462
469
  """Delay after a failed OPEN (no session established): the current attempt's
463
470
  backoff, then escalate - so the first failure waits exactly ``base``."""
@@ -34,6 +34,7 @@ class SdesSession:
34
34
  cmd_chan=None,
35
35
  talk_state=None,
36
36
  media_progress=None,
37
+ teardown_requested=None,
37
38
  ) -> None:
38
39
  self._proc = proc
39
40
  self._sdp_path = sdp_path
@@ -47,6 +48,19 @@ class SdesSession:
47
48
  # that stopped sending (battery teardown ~49-72s) and reconnect - ffmpeg
48
49
  # itself never exits on a dead UDP input, so wait_done() alone would hang.
49
50
  self._media_progress = media_progress if media_progress is not None else [0.0]
51
+ # Mutable one-element list shared with the bridge thread: [0] flips True
52
+ # the moment ANY locally-initiated ffmpeg-kill path fires (this stop(),
53
+ # the cold-open _reap(), the key-restart proc replace, or the DTLS-
54
+ # fallback abort - all in sdes_open.py). The bridge observe loop reads
55
+ # it to tell an expected signal death (SIGTERM/SIGKILL from our own
56
+ # teardown - a battery cam's ffmpeg cannot exit on a dead UDP input) from
57
+ # an unexpected ffmpeg crash, so only the latter logs at WARNING. A
58
+ # plain list (not threading.Event) matches the _proc_holder /
59
+ # _media_progress sharing pattern already used with the bridge thread;
60
+ # list item assignment is atomic under the GIL.
61
+ self._teardown_requested = (
62
+ teardown_requested if teardown_requested is not None else [False]
63
+ )
50
64
  # Mutable one-element list shared with the bridge thread. Bridge sets
51
65
  # [0] to a callable(cmd, payload) once the SCTP channel is up.
52
66
  self._cmd_chan = cmd_chan if cmd_chan is not None else [None]
@@ -203,6 +217,10 @@ class SdesSession:
203
217
  await asyncio.sleep(0.3)
204
218
  self._talk_state["stop"] = True
205
219
  self._talk_state["provider"] = None
220
+ # Flag this as a locally-initiated teardown BEFORE signalling ffmpeg, so
221
+ # the bridge thread's observe loop never races a look at a stale False
222
+ # if it polls the exit code immediately after terminate()/kill().
223
+ self._teardown_requested[0] = True
206
224
  self._proc.terminate()
207
225
  _stop_loop = asyncio.get_running_loop()
208
226
  try:
@@ -46,6 +46,23 @@ def _sdes_echo_wait_timeout(skip_liveplay: bool) -> float:
46
46
  return 0.0 if skip_liveplay else 2.0
47
47
 
48
48
 
49
+ def _classify_ffmpeg_exit(rc: int, teardown_requested: bool) -> int:
50
+ """Log level for the bridge observe loop's "ffmpeg exited" line.
51
+
52
+ Teardown here is SIGTERM-first with a SIGKILL fallback (SdesSession.stop(),
53
+ the cold-open _reap(), the key-restart proc replace, and the DTLS-fallback
54
+ abort). A battery camera's ffmpeg cannot exit promptly on a dead UDP input,
55
+ so a normal locally-initiated stop routinely ends in a signal death (rc < 0,
56
+ e.g. -9 SIGKILL / -15 SIGTERM) - expected, and should not warn. Any other
57
+ non-zero exit - a signal death with no teardown in flight, or a positive
58
+ ffmpeg error code even during teardown - is unexpected and stays loud.
59
+ Pure function so the policy is unit-testable without a live bridge thread.
60
+ """
61
+ if rc < 0 and teardown_requested:
62
+ return logging.DEBUG
63
+ return logging.WARNING
64
+
65
+
49
66
  class _SdesOpenMixin:
50
67
  async def _open_sdes_stream(self, **kwargs) -> "SdesSession":
51
68
  """Allocate-and-hand-off wrapper around _open_sdes_stream_impl.
@@ -167,6 +184,16 @@ class _SdesOpenMixin:
167
184
  _cleanup.callback(_run)
168
185
 
169
186
  def _reap(p):
187
+ # This is a locally-initiated kill (cold open cancelled/raised
188
+ # before hand-off) - flag it so the bridge observe loop treats the
189
+ # resulting signal death as expected, not a WARNING-worthy crash.
190
+ # Set first (before the kill/reap below), matching the other
191
+ # kill sites, so the bridge thread can never observe the exit
192
+ # code with the flag still False.
193
+ try:
194
+ _teardown_holder[0] = True
195
+ except Exception:
196
+ pass
170
197
  try:
171
198
  p.kill()
172
199
  except Exception:
@@ -2084,6 +2111,14 @@ class _SdesOpenMixin:
2084
2111
  # Proc holder: set to the ffmpeg Popen object after launch so the
2085
2112
  # bridge thread can poll for exit without a NameError race.
2086
2113
  _proc_holder: list = [None]
2114
+ # Teardown holder: [0] flips True the moment ANY locally-initiated
2115
+ # ffmpeg-kill path fires (_reap() above on a cancelled cold open, the
2116
+ # key-restart proc replace, or the DTLS-fallback abort below - plus
2117
+ # SdesSession.stop() once the session is handed off, via the
2118
+ # teardown_requested= kwarg at construction). The bridge observe loop
2119
+ # reads it via _classify_ffmpeg_exit() to demote an expected signal
2120
+ # death (our own SIGTERM/SIGKILL) from an unexpected ffmpeg crash.
2121
+ _teardown_holder: list = [False]
2087
2122
  # Camera's actual video payload type (96=H.264 / 97=H.265), set by the
2088
2123
  # bridge on the first video RTP packet so the ffmpeg SDP can be narrowed
2089
2124
  # to the matching single codec before ffmpeg is launched.
@@ -2127,9 +2162,18 @@ class _SdesOpenMixin:
2127
2162
  if _br_rc is not None:
2128
2163
  if _br_rc != 0:
2129
2164
  import logging as _log_br
2130
- _log_br.getLogger(__name__).warning(
2165
+ _br_level = _classify_ffmpeg_exit(
2166
+ _br_rc, bool(_teardown_holder[0])
2167
+ )
2168
+ _br_msg = (
2131
2169
  "SDES bridge: ffmpeg exited with code %d"
2132
- " - stream ended", _br_rc
2170
+ " - stopped by teardown"
2171
+ if _br_level < _log_br.WARNING else
2172
+ "SDES bridge: ffmpeg exited with code %d"
2173
+ " - stream ended"
2174
+ )
2175
+ _log_br.getLogger(__name__).log(
2176
+ _br_level, _br_msg, _br_rc
2133
2177
  )
2134
2178
  break
2135
2179
 
@@ -3634,6 +3678,13 @@ class _SdesOpenMixin:
3634
3678
  srtp_key_video = _real_key_video
3635
3679
  _keys_changed = True
3636
3680
  if _keys_changed:
3681
+ # Locally-initiated kill (the offered key was wrong;
3682
+ # restarting ffmpeg with the camera's real key) - flag it
3683
+ # so the bridge observe loop does not warn on the old
3684
+ # proc's signal death. Reset once the NEW proc is live
3685
+ # (below) so a later, genuine crash of the restarted
3686
+ # ffmpeg is not silently swallowed as "expected teardown".
3687
+ _teardown_holder[0] = True
3637
3688
  proc.terminate()
3638
3689
  try:
3639
3690
  proc.wait(timeout=2)
@@ -3682,6 +3733,10 @@ class _SdesOpenMixin:
3682
3733
  # breaks, and closes the loopback sockets - starving the
3683
3734
  # restarted ffmpeg (0-frame stream).
3684
3735
  _proc_holder[0] = proc
3736
+ # New proc is live and owns the holder; clear the
3737
+ # teardown flag so this session's NEXT exit (the
3738
+ # restarted ffmpeg's) is classified fresh.
3739
+ _teardown_holder[0] = False
3685
3740
  _status("ffmpeg restarted with camera's SRTP keys")
3686
3741
  except TimeoutError:
3687
3742
  if _cam_echo_received:
@@ -3698,6 +3753,11 @@ class _SdesOpenMixin:
3698
3753
  f"no webrtcResp in {_sdes_answer_timeout:.0f}s"
3699
3754
  " - SDES handshake failed; aborting ffmpeg and falling back to DTLS"
3700
3755
  )
3756
+ # Locally-initiated abort (no answer; giving up on SDES for this
3757
+ # attempt) - flag it so the bridge observe loop does not warn on
3758
+ # the resulting signal death. Nothing continues to use this
3759
+ # proc/holder afterward, so no reset is needed here.
3760
+ _teardown_holder[0] = True
3701
3761
  proc.terminate()
3702
3762
  try:
3703
3763
  proc.wait(timeout=2)
@@ -3784,4 +3844,5 @@ class _SdesOpenMixin:
3784
3844
  cmd_chan=_cmd_chan,
3785
3845
  talk_state=_talk_state,
3786
3846
  media_progress=_media_progress,
3847
+ teardown_requested=_teardown_holder,
3787
3848
  )
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-aidot-cameras"
7
- version = "0.11.11"
7
+ version = "0.11.12"
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.11.11
3
+ Version: 0.11.12
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
@@ -79,10 +79,13 @@ tests/test_device_login_guard.py
79
79
  tests/test_device_user_info_cache.py
80
80
  tests/test_dtls_not_ready_burst.py
81
81
  tests/test_dtls_pinning.py
82
+ tests/test_dtls_serve_decode_skip.py
82
83
  tests/test_dtls_skip_signaling_wait.py
84
+ tests/test_dtls_slow_probe.py
83
85
  tests/test_egress_guard.py
84
86
  tests/test_go2rtc.py
85
87
  tests/test_go2rtc_cli.py
88
+ tests/test_h264_decode_rate_limit.py
86
89
  tests/test_highport_nomination.py
87
90
  tests/test_ice_config_cache.py
88
91
  tests/test_keepalive_renew.py
@@ -112,6 +115,7 @@ tests/test_sdes_serve_audio.py
112
115
  tests/test_sdes_serve_cmd.py
113
116
  tests/test_sdes_sprop.py
114
117
  tests/test_sdes_talk.py
118
+ tests/test_sdes_teardown_exit_log.py
115
119
  tests/test_sdes_watchdog.py
116
120
  tests/test_security_hardening.py
117
121
  tests/test_serializable_login_info.py