python-aidot-cameras 0.11.11__tar.gz → 0.11.13__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 (139) hide show
  1. {python_aidot_cameras-0.11.11/python_aidot_cameras.egg-info → python_aidot_cameras-0.11.13}/PKG-INFO +1 -1
  2. python_aidot_cameras-0.11.13/aidot/__init__.py +137 -0
  3. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/camera/client.py +180 -6
  4. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/camera/protocol.py +7 -0
  5. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/camera/sdes.py +18 -0
  6. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/camera/sdes_open.py +105 -5
  7. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/pyproject.toml +1 -1
  8. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  9. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/python_aidot_cameras.egg-info/SOURCES.txt +6 -0
  10. python_aidot_cameras-0.11.13/tests/test_dtls_serve_decode_skip.py +181 -0
  11. python_aidot_cameras-0.11.13/tests/test_dtls_serve_open_timeout.py +75 -0
  12. python_aidot_cameras-0.11.13/tests/test_dtls_slow_probe.py +163 -0
  13. python_aidot_cameras-0.11.13/tests/test_h264_decode_rate_limit.py +141 -0
  14. python_aidot_cameras-0.11.13/tests/test_logging_caps.py +108 -0
  15. python_aidot_cameras-0.11.13/tests/test_sdes_bridge_break_gate.py +229 -0
  16. python_aidot_cameras-0.11.13/tests/test_sdes_teardown_exit_log.py +128 -0
  17. python_aidot_cameras-0.11.11/aidot/__init__.py +0 -54
  18. python_aidot_cameras-0.11.11/tests/test_logging_caps.py +0 -31
  19. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/LICENSE +0 -0
  20. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/MANIFEST.in +0 -0
  21. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/README.md +0 -0
  22. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/__main__.py +0 -0
  23. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/_vendor/__init__.py +0 -0
  24. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/__init__.py +0 -0
  25. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/clock.py +0 -0
  26. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/codecs/__init__.py +0 -0
  27. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/codecs/base.py +0 -0
  28. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/codecs/g711.py +0 -0
  29. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/codecs/g722.py +0 -0
  30. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/codecs/h264.py +0 -0
  31. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/codecs/opus.py +0 -0
  32. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/codecs/vpx.py +0 -0
  33. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/contrib/__init__.py +0 -0
  34. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/contrib/media.py +0 -0
  35. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/contrib/signaling.py +0 -0
  36. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/events.py +0 -0
  37. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/exceptions.py +0 -0
  38. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/jitterbuffer.py +0 -0
  39. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/mediastreams.py +0 -0
  40. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/rate.py +0 -0
  41. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/rtcconfiguration.py +0 -0
  42. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/rtcdatachannel.py +0 -0
  43. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/rtcdtlstransport.py +0 -0
  44. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/rtcicetransport.py +0 -0
  45. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/rtcpeerconnection.py +0 -0
  46. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/rtcrtpparameters.py +0 -0
  47. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/rtcrtpreceiver.py +0 -0
  48. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/rtcrtpsender.py +0 -0
  49. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/rtcrtptransceiver.py +0 -0
  50. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/rtcsctptransport.py +0 -0
  51. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/rtcsessiondescription.py +0 -0
  52. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/rtp.py +0 -0
  53. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/sdp.py +0 -0
  54. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/stats.py +0 -0
  55. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/utils.py +0 -0
  56. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/aes_utils.py +0 -0
  57. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/camera/__init__.py +0 -0
  58. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/camera/constants.py +0 -0
  59. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/camera/controls.py +0 -0
  60. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/camera/go2rtc.py +0 -0
  61. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/camera/lan_control.py +0 -0
  62. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/camera/models.py +0 -0
  63. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/camera/playback.py +0 -0
  64. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/camera/tutk.py +0 -0
  65. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/camera/webrtc.py +0 -0
  66. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/camera/webrtc_open.py +0 -0
  67. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/client.py +0 -0
  68. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/const.py +0 -0
  69. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/credentials.py +0 -0
  70. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/device_client.py +0 -0
  71. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/discover.py +0 -0
  72. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/exceptions.py +0 -0
  73. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/g711.py +0 -0
  74. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/login_const.py +0 -0
  75. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/models/__init__.py +0 -0
  76. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/models/device_client_model.py +0 -0
  77. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/models/device_model.py +0 -0
  78. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/models/discover_model.py +0 -0
  79. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/aidot/py.typed +0 -0
  80. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  81. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
  82. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/python_aidot_cameras.egg-info/requires.txt +0 -0
  83. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/python_aidot_cameras.egg-info/top_level.txt +0 -0
  84. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/setup.cfg +0 -0
  85. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/tests/test_aioice_compat.py +0 -0
  86. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/tests/test_alarm_event.py +0 -0
  87. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/tests/test_backoff.py +0 -0
  88. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/tests/test_deferred_hardening.py +0 -0
  89. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/tests/test_device_login_guard.py +0 -0
  90. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/tests/test_device_user_info_cache.py +0 -0
  91. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/tests/test_dtls_not_ready_burst.py +0 -0
  92. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/tests/test_dtls_pinning.py +0 -0
  93. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/tests/test_dtls_skip_signaling_wait.py +0 -0
  94. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/tests/test_egress_guard.py +0 -0
  95. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/tests/test_go2rtc.py +0 -0
  96. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/tests/test_go2rtc_cli.py +0 -0
  97. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/tests/test_highport_nomination.py +0 -0
  98. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/tests/test_ice_config_cache.py +0 -0
  99. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/tests/test_keepalive_renew.py +0 -0
  100. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/tests/test_keyframe_prompter.py +0 -0
  101. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/tests/test_lan_control.py +0 -0
  102. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/tests/test_live_stream_param.py +0 -0
  103. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/tests/test_motion_poll.py +0 -0
  104. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/tests/test_mqtt_session_reconnect.py +0 -0
  105. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/tests/test_narrow_pc_ice.py +0 -0
  106. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/tests/test_no_undefined_names.py +0 -0
  107. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/tests/test_offline_keepalive_pause.py +0 -0
  108. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/tests/test_open_fail_logger.py +0 -0
  109. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/tests/test_open_gate_delay.py +0 -0
  110. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/tests/test_persistent_mqtt.py +0 -0
  111. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/tests/test_persistent_mqtt_robustness.py +0 -0
  112. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/tests/test_playback_tls.py +0 -0
  113. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/tests/test_post_merge_hardening.py +0 -0
  114. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/tests/test_retry_policy.py +0 -0
  115. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/tests/test_sdes_adaptive.py +0 -0
  116. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/tests/test_sdes_echo_wait_timeout.py +0 -0
  117. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/tests/test_sdes_fast_liveplay.py +0 -0
  118. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/tests/test_sdes_ffmpeg_stderr_log.py +0 -0
  119. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/tests/test_sdes_idle_release.py +0 -0
  120. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/tests/test_sdes_open_cleanup.py +0 -0
  121. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/tests/test_sdes_serve_audio.py +0 -0
  122. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/tests/test_sdes_serve_cmd.py +0 -0
  123. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/tests/test_sdes_sprop.py +0 -0
  124. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/tests/test_sdes_talk.py +0 -0
  125. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/tests/test_sdes_watchdog.py +0 -0
  126. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/tests/test_security_hardening.py +0 -0
  127. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/tests/test_serializable_login_info.py +0 -0
  128. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/tests/test_serve_relay.py +0 -0
  129. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/tests/test_session_stats.py +0 -0
  130. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/tests/test_speak.py +0 -0
  131. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/tests/test_stream_cap.py +0 -0
  132. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/tests/test_stream_drain_ownership.py +0 -0
  133. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/tests/test_stream_idle.py +0 -0
  134. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/tests/test_stream_teardown.py +0 -0
  135. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/tests/test_talk.py +0 -0
  136. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/tests/test_terminal_ack.py +0 -0
  137. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/tests/test_token_file_robustness.py +0 -0
  138. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/tests/test_token_refresh.py +0 -0
  139. {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.13}/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.13
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,46 @@ _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
+
120
+
121
+ def _parse_env_float(name: str, default: float) -> float:
122
+ """Parse a float env var, falling back to ``default`` on any missing or
123
+ malformed value (matches the try/except idiom used by
124
+ _get_webrtc_open_gate / _get_stream_slots below for AIDOT_MAX_CONCURRENT_*
125
+ rather than raising at import time)."""
126
+ try:
127
+ return float(os.environ.get(name, str(default)))
128
+ except (ValueError, TypeError):
129
+ return default
130
+
131
+
132
+ # DTLS serve-open timeout: how long a single async_open_webrtc_stream() call
133
+ # in the serve loop is allowed to take before giving up. Left at the
134
+ # _async_open_webrtc_stream_impl default (30.0s) so battery cameras that need
135
+ # the full window to wake still work; tunable via
136
+ # AIDOT_DTLS_SERVE_OPEN_TIMEOUT_S for operators who want faster-failing
137
+ # retries against a known-dead camera.
138
+ _DTLS_SERVE_OPEN_TIMEOUT_S = _parse_env_float("AIDOT_DTLS_SERVE_OPEN_TIMEOUT_S", 30.0)
139
+
100
140
  # Strong refs to fire-and-forget tasks: asyncio only keeps weak refs, so a
101
141
  # discarded task can be garbage-collected mid-flight. Discarded on completion.
102
142
  _BG_TASKS: set = set()
@@ -164,6 +204,39 @@ def _open_gate_delay(last_open_at: float, now: float, open_gate: float) -> float
164
204
  return remaining if remaining > 0 else 0.0
165
205
 
166
206
 
207
+ def _in_slow_probe(attempt: int, threshold: int) -> bool:
208
+ """True once `attempt` consecutive DTLS open failures have reached
209
+ `threshold`: the serve loop should stop hammering an idle-but-cloud-online
210
+ camera at the pacer's normal cadence and fall back to a slow probe."""
211
+ return attempt >= threshold
212
+
213
+
214
+ def _probe_interval(attempt: int, threshold: int, normal_delay: float,
215
+ slow_interval: float) -> float:
216
+ """Effective DTLS-open retry interval for a failed open.
217
+
218
+ Below `threshold` consecutive failures this is just the pacer's own
219
+ `normal_delay` (unchanged jittered-backoff behaviour). At/after `threshold`
220
+ it widens to (at least) `slow_interval` - well beyond the pacer's own cap -
221
+ so a camera that is cloud-online but never actually opens is probed
222
+ roughly every `slow_interval` seconds instead of retried forever at the
223
+ pacer's max cadence."""
224
+ if _in_slow_probe(attempt, threshold):
225
+ return max(normal_delay, slow_interval)
226
+ return normal_delay
227
+
228
+
229
+ def _should_log_slow_probe(attempt: int, threshold: int, log_every: int) -> bool:
230
+ """True when a slow-probe open failure should be logged: the moment
231
+ slow-probe is entered, then every `log_every`th attempt after - so a
232
+ camera stuck for hours doesn't drip a log line on every single probe."""
233
+ if not _in_slow_probe(attempt, threshold):
234
+ return False
235
+ if log_every <= 0:
236
+ return True
237
+ return (attempt - threshold) % log_every == 0
238
+
239
+
167
240
  _FFMPEG_MISSING_MSG = (
168
241
  "ffmpeg not found on PATH; install ffmpeg to enable recording/transcoding.\n"
169
242
  " Ubuntu/Debian: sudo apt install ffmpeg\n"
@@ -845,6 +918,23 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
845
918
  self.device_id,
846
919
  )
847
920
 
921
+ async def _slow_probe_sleep(self, interval: float) -> None:
922
+ """Sleep `interval` seconds for the DTLS serve loop's slow-probe wait,
923
+ in `_SLOW_PROBE_SLEEP_CHUNK_S` increments rather than one blocking
924
+ `asyncio.sleep(interval)` call, re-checking `_streaming_active` each
925
+ increment. `interval` can be several minutes; without chunking,
926
+ `stop()` would have to wait out the whole thing before the loop next
927
+ checks whether it should keep running.
928
+ """
929
+ _t0 = time.monotonic()
930
+ while True:
931
+ if not getattr(self, "_streaming_active", False):
932
+ return
933
+ _remaining = interval - (time.monotonic() - _t0)
934
+ if _remaining <= 0:
935
+ return
936
+ await asyncio.sleep(min(_SLOW_PROBE_SLEEP_CHUNK_S, _remaining))
937
+
848
938
  # -- Camera helpers ------------------------------------------------------ #
849
939
 
850
940
  @property
@@ -2976,11 +3066,27 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2976
3066
  _LOGGER.debug("PLI request failed: %s", _pli_exc)
2977
3067
 
2978
3068
  @staticmethod
2979
- def _install_encoded_tap(receiver, out_q, is_video: bool) -> bool:
3069
+ def _install_encoded_tap(receiver, out_q, is_video: bool, serve: bool = False) -> bool:
2980
3070
  """Tee aiortc's depacketized encoded frames (+ RTP timestamp) into a
2981
3071
  thread-safe queue before decode. Video frames go as
2982
3072
  ``(bytes, timestamp, is_keyframe)``, audio as ``(bytes, timestamp)``.
2983
- ``out_q`` is a ``queue.Queue`` (the mux runs in a worker thread)."""
3073
+ ``out_q`` is a ``queue.Queue`` (the mux runs in a worker thread).
3074
+
3075
+ When ``serve`` is set, the served H.264 is muxed as ``-c copy`` straight
3076
+ from this tap, so the vendored decoder's output is discarded (the serve
3077
+ loop uses a no-op on_frame). In that mode we STOP feeding the decoder
3078
+ queue for video DATA frames: decoding them is pure wasted CPU and the
3079
+ sole source of the per-packet decode-failure warning flood. The None
3080
+ terminator is STILL forwarded so the decoder thread exits cleanly, and
3081
+ audio is left untouched (drained separately). We must not instead pass
3082
+ ``on_frame=None`` at the receiver level - the decoder thread would keep
3083
+ running and fill the unbounded track queue with nothing draining it.
3084
+
3085
+ Because decode was the only signal that the served H.264 was corrupt, a
3086
+ cheap keyframe-based canary (no decoding) is kept: it counts frames and
3087
+ keyframes, tracks the longest run with no keyframe, and emits a periodic
3088
+ DEBUG summary. It is O(1) per frame and stays at DEBUG so a corrupt
3089
+ camera can't recreate the very flood this removes."""
2984
3090
  _qd = getattr(receiver, "_RTCRtpReceiver__decoder_queue", None)
2985
3091
  if _qd is None:
2986
3092
  return False
@@ -2990,6 +3096,12 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2990
3096
  if getattr(_qd, "_aidot_tapped", False):
2991
3097
  return True
2992
3098
  _orig_put = _qd.put
3099
+ _skip_decode = bool(serve and is_video)
3100
+ # Served-stream health canary (video-only): observed without decoding.
3101
+ _canary = {"frames": 0, "keyframes": 0, "gap": 0, "max_gap": 0} if _skip_decode else None
3102
+ if _canary is not None:
3103
+ _qd._aidot_serve_canary = _canary
3104
+ _CANARY_LOG_EVERY = 300 # frames (~10-20s of H.264); DEBUG summary cadence
2993
3105
 
2994
3106
  def _tap_put(task, *a, **k):
2995
3107
  try:
@@ -2998,12 +3110,34 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2998
3110
  _d = getattr(_enc, "data", None)
2999
3111
  _ts = getattr(_enc, "timestamp", None)
3000
3112
  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)))
3113
+ _b = bytes(_d)
3114
+ _kf = _h264_has_keyframe(_b) if is_video else False
3115
+ _item = (_b, int(_ts), _kf) if is_video else (_b, int(_ts))
3003
3116
  try:
3004
3117
  out_q.put_nowait(_item)
3005
3118
  except Exception:
3006
3119
  pass # full -> drop (PLI re-arms a GOP)
3120
+ if _canary is not None:
3121
+ _canary["frames"] += 1
3122
+ if _kf:
3123
+ _canary["keyframes"] += 1
3124
+ _canary["gap"] = 0
3125
+ else:
3126
+ _canary["gap"] += 1
3127
+ if _canary["gap"] > _canary["max_gap"]:
3128
+ _canary["max_gap"] = _canary["gap"]
3129
+ if _canary["frames"] % _CANARY_LOG_EVERY == 0:
3130
+ _LOGGER.debug(
3131
+ "serve h264 canary: frames=%d keyframes=%d"
3132
+ " max_keyframe_gap=%d cur_gap=%d",
3133
+ _canary["frames"], _canary["keyframes"],
3134
+ _canary["max_gap"], _canary["gap"],
3135
+ )
3136
+ if _skip_decode:
3137
+ # Discarded decode: don't feed the decoder for served
3138
+ # video data frames (see docstring). Terminator (task
3139
+ # is None) still falls through to _orig_put below.
3140
+ return None
3007
3141
  except Exception:
3008
3142
  _LOGGER.debug("swallowed exception in %s", '_tap_put', exc_info=True)
3009
3143
  return _orig_put(task, *a, **k)
@@ -3017,6 +3151,12 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3017
3151
 
3018
3152
  Called repeatedly until the video tap lands; _install_encoded_tap is
3019
3153
  idempotent per receiver, so audio isn't re-wrapped across retries.
3154
+
3155
+ This runs only from the DTLS serve loop, whose on_frame is a no-op and
3156
+ whose output is muxed as H.264 copy from the video tap - so the video
3157
+ tap is installed with serve=True to skip the discarded decode (see
3158
+ _install_encoded_tap). Audio keeps feeding its decoder (drained
3159
+ separately).
3020
3160
  """
3021
3161
  got_v = False
3022
3162
  got_a = False
@@ -3025,7 +3165,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3025
3165
  if _tr is None:
3026
3166
  continue
3027
3167
  if _tr.kind == "video" and not got_v:
3028
- got_v = self._install_encoded_tap(_r, vq, True)
3168
+ got_v = self._install_encoded_tap(_r, vq, True, serve=True)
3029
3169
  elif _tr.kind == "audio" and not got_a:
3030
3170
  got_a = self._install_encoded_tap(_r, aq, False)
3031
3171
  return got_v
@@ -3379,7 +3519,9 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3379
3519
  return
3380
3520
  _last_open_at = loop.time()
3381
3521
  try:
3382
- session = await self.async_open_webrtc_stream(on_frame=lambda _f: None)
3522
+ session = await self.async_open_webrtc_stream(
3523
+ on_frame=lambda _f: None, timeout=_DTLS_SERVE_OPEN_TIMEOUT_S,
3524
+ )
3383
3525
  except asyncio.CancelledError:
3384
3526
  return
3385
3527
  except AidotCameraBusy as busy:
@@ -3419,6 +3561,35 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3419
3561
  continue
3420
3562
  except Exception as exc:
3421
3563
  _delay = _pacer.fail_delay()
3564
+ _attempt = _pacer.attempt
3565
+ _cloud_offline = (
3566
+ (not self.status.online)
3567
+ and getattr(self, "_cloud_online_explicit", False)
3568
+ )
3569
+ # Slow-probe throttle: an idle-but-cloud-online camera never
3570
+ # sets _cloud_offline (the cloud keeps reporting it reachable),
3571
+ # so without this it would retry forever at the pacer's cap.
3572
+ # Keyed purely on the pacer's consecutive-failure count, not
3573
+ # the offline flag - a genuinely offline camera already gets a
3574
+ # comparable slow cadence (with early-resume) from
3575
+ # _backoff_or_offline_pause below, so this only engages the
3576
+ # part of the space that pause doesn't already cover.
3577
+ if _in_slow_probe(_attempt, _SLOW_PROBE_THRESHOLD) and not _cloud_offline:
3578
+ _delay = _probe_interval(
3579
+ _attempt, _SLOW_PROBE_THRESHOLD, _delay, _SLOW_PROBE_INTERVAL_S)
3580
+ if _should_log_slow_probe(
3581
+ _attempt, _SLOW_PROBE_THRESHOLD, _SLOW_PROBE_LOG_EVERY
3582
+ ):
3583
+ _LOGGER.info(
3584
+ "DTLS serve: %s still failing to open after %d"
3585
+ " attempts - slow-probing every %.0fs (last error: %s)",
3586
+ self.device_id, _attempt, _delay, exc,
3587
+ )
3588
+ try:
3589
+ await self._slow_probe_sleep(_delay)
3590
+ except asyncio.CancelledError:
3591
+ return
3592
+ continue
3422
3593
  self._open_fail_logger()(
3423
3594
  "DTLS serve: open failed for %s (retry %.0fs): %s",
3424
3595
  self.device_id, _delay, exc,
@@ -3429,6 +3600,9 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3429
3600
  return
3430
3601
  continue
3431
3602
 
3603
+ # Resetting the pacer's attempt count also clears slow-probe state:
3604
+ # it derives purely from _pacer.attempt, so _in_slow_probe reads
3605
+ # false again on the very next failure sequence.
3432
3606
  _pacer.reset()
3433
3607
  _not_ready_burst = 0 # clean open: reset the encoder-cold burst
3434
3608
  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,41 @@ 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
+
66
+ def _bridge_should_break(rc, teardown_requested: bool) -> bool:
67
+ """Whether the bridge observe loop should end on this poll() result.
68
+
69
+ rc is the held proc's poll() result (None while it is still running).
70
+ Ending the loop is only correct when ffmpeg has actually exited AND no
71
+ locally-initiated teardown is in flight for it: a non-None rc seen while
72
+ teardown_requested is True is the key-restart window (or any other
73
+ flagged local kill) - the OLD proc's exit, not a reason to stop. That
74
+ window always resolves promptly one of two ways, so skipping the break
75
+ here cannot hang the bridge thread: either the key-restart repoints
76
+ _proc_holder[0] at the live new proc (next poll() is None again), or a
77
+ genuine teardown closes the loopback sockets, which makes the loop's
78
+ select() raise and exit via its own except-break. Pure function so the
79
+ policy is unit-testable without a live bridge thread.
80
+ """
81
+ return rc is not None and not teardown_requested
82
+
83
+
49
84
  class _SdesOpenMixin:
50
85
  async def _open_sdes_stream(self, **kwargs) -> "SdesSession":
51
86
  """Allocate-and-hand-off wrapper around _open_sdes_stream_impl.
@@ -167,6 +202,16 @@ class _SdesOpenMixin:
167
202
  _cleanup.callback(_run)
168
203
 
169
204
  def _reap(p):
205
+ # This is a locally-initiated kill (cold open cancelled/raised
206
+ # before hand-off) - flag it so the bridge observe loop treats the
207
+ # resulting signal death as expected, not a WARNING-worthy crash.
208
+ # Set first (before the kill/reap below), matching the other
209
+ # kill sites, so the bridge thread can never observe the exit
210
+ # code with the flag still False.
211
+ try:
212
+ _teardown_holder[0] = True
213
+ except Exception:
214
+ pass
170
215
  try:
171
216
  p.kill()
172
217
  except Exception:
@@ -2084,6 +2129,14 @@ class _SdesOpenMixin:
2084
2129
  # Proc holder: set to the ffmpeg Popen object after launch so the
2085
2130
  # bridge thread can poll for exit without a NameError race.
2086
2131
  _proc_holder: list = [None]
2132
+ # Teardown holder: [0] flips True the moment ANY locally-initiated
2133
+ # ffmpeg-kill path fires (_reap() above on a cancelled cold open, the
2134
+ # key-restart proc replace, or the DTLS-fallback abort below - plus
2135
+ # SdesSession.stop() once the session is handed off, via the
2136
+ # teardown_requested= kwarg at construction). The bridge observe loop
2137
+ # reads it via _classify_ffmpeg_exit() to demote an expected signal
2138
+ # death (our own SIGTERM/SIGKILL) from an unexpected ffmpeg crash.
2139
+ _teardown_holder: list = [False]
2087
2140
  # Camera's actual video payload type (96=H.264 / 97=H.265), set by the
2088
2141
  # bridge on the first video RTP packet so the ffmpeg SDP can be narrowed
2089
2142
  # to the matching single codec before ffmpeg is launched.
@@ -2110,6 +2163,13 @@ class _SdesOpenMixin:
2110
2163
  _trigger_bsrc = None # camera addr for trigger
2111
2164
  _sdes_probe_received = False # True after first 0xC8 probe from camera
2112
2165
  _last_hb_ts = 0.0 # time of last AVIO HEARTBEAT send
2166
+ # One-shot guard for the "ffmpeg exited" log below: while the held
2167
+ # proc keeps reporting the same stale exit code across a
2168
+ # teardown-window skip (see _bridge_should_break), only the first
2169
+ # tick logs it. Reset to False whenever the held proc is next
2170
+ # seen alive (poll() None), so a later, genuine exit of THAT proc
2171
+ # still gets its own line.
2172
+ _br_exit_logged = False
2113
2173
  _lo_a = _socket_br.socket(_socket_br.AF_INET, _socket_br.SOCK_DGRAM)
2114
2174
  _lo_v = _socket_br.socket(_socket_br.AF_INET, _socket_br.SOCK_DGRAM)
2115
2175
  try:
@@ -2120,18 +2180,41 @@ class _SdesOpenMixin:
2120
2180
  )
2121
2181
  except Exception:
2122
2182
  break
2123
- # Stop the bridge when ffmpeg exits (normal end or crash).
2183
+ # Stop the bridge when ffmpeg exits (normal end or crash) -
2184
+ # but NOT on a stale exit seen during a flagged teardown
2185
+ # window (key-restart proc replace, stop(), _reap(), the
2186
+ # DTLS-fallback abort): _bridge_should_break() skips the
2187
+ # break there, and the loop resolves it either via the
2188
+ # key-restart's _proc_holder[0] repoint (next poll() None,
2189
+ # below) or via a genuine teardown closing the loopback
2190
+ # sockets, which raises out of the select() above.
2124
2191
  _br_proc = _proc_holder[0]
2125
2192
  if _br_proc is not None:
2126
2193
  _br_rc = _br_proc.poll()
2127
2194
  if _br_rc is not None:
2128
- if _br_rc != 0:
2195
+ _br_teardown_requested = bool(_teardown_holder[0])
2196
+ if _br_rc != 0 and not _br_exit_logged:
2197
+ _br_exit_logged = True
2129
2198
  import logging as _log_br
2130
- _log_br.getLogger(__name__).warning(
2199
+ _br_level = _classify_ffmpeg_exit(
2200
+ _br_rc, _br_teardown_requested
2201
+ )
2202
+ _br_msg = (
2131
2203
  "SDES bridge: ffmpeg exited with code %d"
2132
- " - stream ended", _br_rc
2204
+ " - stopped by teardown"
2205
+ if _br_level < _log_br.WARNING else
2206
+ "SDES bridge: ffmpeg exited with code %d"
2207
+ " - stream ended"
2133
2208
  )
2134
- break
2209
+ _log_br.getLogger(__name__).log(
2210
+ _br_level, _br_msg, _br_rc
2211
+ )
2212
+ if _bridge_should_break(
2213
+ _br_rc, _br_teardown_requested
2214
+ ):
2215
+ break
2216
+ else:
2217
+ _br_exit_logged = False
2135
2218
 
2136
2219
  # AVIO HEARTBEAT (cmd=5156) every 10s, sent as an ENCRYPTED SCTP
2137
2220
  # DATA chunk (PPID=53) - exactly like LIVING - NOT a raw 0xC8 AVIO
@@ -3634,6 +3717,13 @@ class _SdesOpenMixin:
3634
3717
  srtp_key_video = _real_key_video
3635
3718
  _keys_changed = True
3636
3719
  if _keys_changed:
3720
+ # Locally-initiated kill (the offered key was wrong;
3721
+ # restarting ffmpeg with the camera's real key) - flag it
3722
+ # so the bridge observe loop does not warn on the old
3723
+ # proc's signal death. Reset once the NEW proc is live
3724
+ # (below) so a later, genuine crash of the restarted
3725
+ # ffmpeg is not silently swallowed as "expected teardown".
3726
+ _teardown_holder[0] = True
3637
3727
  proc.terminate()
3638
3728
  try:
3639
3729
  proc.wait(timeout=2)
@@ -3682,6 +3772,10 @@ class _SdesOpenMixin:
3682
3772
  # breaks, and closes the loopback sockets - starving the
3683
3773
  # restarted ffmpeg (0-frame stream).
3684
3774
  _proc_holder[0] = proc
3775
+ # New proc is live and owns the holder; clear the
3776
+ # teardown flag so this session's NEXT exit (the
3777
+ # restarted ffmpeg's) is classified fresh.
3778
+ _teardown_holder[0] = False
3685
3779
  _status("ffmpeg restarted with camera's SRTP keys")
3686
3780
  except TimeoutError:
3687
3781
  if _cam_echo_received:
@@ -3698,6 +3792,11 @@ class _SdesOpenMixin:
3698
3792
  f"no webrtcResp in {_sdes_answer_timeout:.0f}s"
3699
3793
  " - SDES handshake failed; aborting ffmpeg and falling back to DTLS"
3700
3794
  )
3795
+ # Locally-initiated abort (no answer; giving up on SDES for this
3796
+ # attempt) - flag it so the bridge observe loop does not warn on
3797
+ # the resulting signal death. Nothing continues to use this
3798
+ # proc/holder afterward, so no reset is needed here.
3799
+ _teardown_holder[0] = True
3701
3800
  proc.terminate()
3702
3801
  try:
3703
3802
  proc.wait(timeout=2)
@@ -3784,4 +3883,5 @@ class _SdesOpenMixin:
3784
3883
  cmd_chan=_cmd_chan,
3785
3884
  talk_state=_talk_state,
3786
3885
  media_progress=_media_progress,
3886
+ teardown_requested=_teardown_holder,
3787
3887
  )
@@ -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.13"
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.13
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