python-aidot-cameras 0.11.12__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 (137) hide show
  1. {python_aidot_cameras-0.11.12/python_aidot_cameras.egg-info → python_aidot_cameras-0.11.13}/PKG-INFO +1 -1
  2. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/camera/client.py +23 -1
  3. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/camera/sdes_open.py +43 -4
  4. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/pyproject.toml +1 -1
  5. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  6. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/python_aidot_cameras.egg-info/SOURCES.txt +2 -0
  7. python_aidot_cameras-0.11.13/tests/test_dtls_serve_open_timeout.py +75 -0
  8. python_aidot_cameras-0.11.13/tests/test_sdes_bridge_break_gate.py +229 -0
  9. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/LICENSE +0 -0
  10. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/MANIFEST.in +0 -0
  11. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/README.md +0 -0
  12. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/__init__.py +0 -0
  13. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/__main__.py +0 -0
  14. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/_vendor/__init__.py +0 -0
  15. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/__init__.py +0 -0
  16. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/clock.py +0 -0
  17. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/codecs/__init__.py +0 -0
  18. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/codecs/base.py +0 -0
  19. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/codecs/g711.py +0 -0
  20. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/codecs/g722.py +0 -0
  21. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/codecs/h264.py +0 -0
  22. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/codecs/opus.py +0 -0
  23. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/codecs/vpx.py +0 -0
  24. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/contrib/__init__.py +0 -0
  25. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/contrib/media.py +0 -0
  26. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/contrib/signaling.py +0 -0
  27. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/events.py +0 -0
  28. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/exceptions.py +0 -0
  29. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/jitterbuffer.py +0 -0
  30. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/mediastreams.py +0 -0
  31. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/rate.py +0 -0
  32. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/rtcconfiguration.py +0 -0
  33. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/rtcdatachannel.py +0 -0
  34. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/rtcdtlstransport.py +0 -0
  35. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/rtcicetransport.py +0 -0
  36. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/rtcpeerconnection.py +0 -0
  37. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/rtcrtpparameters.py +0 -0
  38. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/rtcrtpreceiver.py +0 -0
  39. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/rtcrtpsender.py +0 -0
  40. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/rtcrtptransceiver.py +0 -0
  41. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/rtcsctptransport.py +0 -0
  42. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/rtcsessiondescription.py +0 -0
  43. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/rtp.py +0 -0
  44. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/sdp.py +0 -0
  45. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/stats.py +0 -0
  46. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/_vendor/aiortc/utils.py +0 -0
  47. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/aes_utils.py +0 -0
  48. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/camera/__init__.py +0 -0
  49. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/camera/constants.py +0 -0
  50. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/camera/controls.py +0 -0
  51. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/camera/go2rtc.py +0 -0
  52. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/camera/lan_control.py +0 -0
  53. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/camera/models.py +0 -0
  54. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/camera/playback.py +0 -0
  55. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/camera/protocol.py +0 -0
  56. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/camera/sdes.py +0 -0
  57. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/camera/tutk.py +0 -0
  58. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/camera/webrtc.py +0 -0
  59. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/camera/webrtc_open.py +0 -0
  60. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/client.py +0 -0
  61. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/const.py +0 -0
  62. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/credentials.py +0 -0
  63. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/device_client.py +0 -0
  64. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/discover.py +0 -0
  65. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/exceptions.py +0 -0
  66. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/g711.py +0 -0
  67. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/login_const.py +0 -0
  68. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/models/__init__.py +0 -0
  69. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/models/device_client_model.py +0 -0
  70. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/models/device_model.py +0 -0
  71. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/models/discover_model.py +0 -0
  72. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/aidot/py.typed +0 -0
  73. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  74. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
  75. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/python_aidot_cameras.egg-info/requires.txt +0 -0
  76. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/python_aidot_cameras.egg-info/top_level.txt +0 -0
  77. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/setup.cfg +0 -0
  78. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/tests/test_aioice_compat.py +0 -0
  79. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/tests/test_alarm_event.py +0 -0
  80. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/tests/test_backoff.py +0 -0
  81. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/tests/test_deferred_hardening.py +0 -0
  82. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/tests/test_device_login_guard.py +0 -0
  83. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/tests/test_device_user_info_cache.py +0 -0
  84. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/tests/test_dtls_not_ready_burst.py +0 -0
  85. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/tests/test_dtls_pinning.py +0 -0
  86. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/tests/test_dtls_serve_decode_skip.py +0 -0
  87. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/tests/test_dtls_skip_signaling_wait.py +0 -0
  88. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/tests/test_dtls_slow_probe.py +0 -0
  89. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/tests/test_egress_guard.py +0 -0
  90. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/tests/test_go2rtc.py +0 -0
  91. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/tests/test_go2rtc_cli.py +0 -0
  92. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/tests/test_h264_decode_rate_limit.py +0 -0
  93. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/tests/test_highport_nomination.py +0 -0
  94. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/tests/test_ice_config_cache.py +0 -0
  95. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/tests/test_keepalive_renew.py +0 -0
  96. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/tests/test_keyframe_prompter.py +0 -0
  97. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/tests/test_lan_control.py +0 -0
  98. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/tests/test_live_stream_param.py +0 -0
  99. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/tests/test_logging_caps.py +0 -0
  100. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/tests/test_motion_poll.py +0 -0
  101. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/tests/test_mqtt_session_reconnect.py +0 -0
  102. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/tests/test_narrow_pc_ice.py +0 -0
  103. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/tests/test_no_undefined_names.py +0 -0
  104. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/tests/test_offline_keepalive_pause.py +0 -0
  105. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/tests/test_open_fail_logger.py +0 -0
  106. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/tests/test_open_gate_delay.py +0 -0
  107. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/tests/test_persistent_mqtt.py +0 -0
  108. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/tests/test_persistent_mqtt_robustness.py +0 -0
  109. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/tests/test_playback_tls.py +0 -0
  110. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/tests/test_post_merge_hardening.py +0 -0
  111. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/tests/test_retry_policy.py +0 -0
  112. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/tests/test_sdes_adaptive.py +0 -0
  113. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/tests/test_sdes_echo_wait_timeout.py +0 -0
  114. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/tests/test_sdes_fast_liveplay.py +0 -0
  115. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/tests/test_sdes_ffmpeg_stderr_log.py +0 -0
  116. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/tests/test_sdes_idle_release.py +0 -0
  117. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/tests/test_sdes_open_cleanup.py +0 -0
  118. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/tests/test_sdes_serve_audio.py +0 -0
  119. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/tests/test_sdes_serve_cmd.py +0 -0
  120. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/tests/test_sdes_sprop.py +0 -0
  121. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/tests/test_sdes_talk.py +0 -0
  122. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/tests/test_sdes_teardown_exit_log.py +0 -0
  123. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/tests/test_sdes_watchdog.py +0 -0
  124. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/tests/test_security_hardening.py +0 -0
  125. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/tests/test_serializable_login_info.py +0 -0
  126. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/tests/test_serve_relay.py +0 -0
  127. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/tests/test_session_stats.py +0 -0
  128. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/tests/test_speak.py +0 -0
  129. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/tests/test_stream_cap.py +0 -0
  130. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/tests/test_stream_drain_ownership.py +0 -0
  131. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/tests/test_stream_idle.py +0 -0
  132. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/tests/test_stream_teardown.py +0 -0
  133. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/tests/test_talk.py +0 -0
  134. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/tests/test_terminal_ack.py +0 -0
  135. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/tests/test_token_file_robustness.py +0 -0
  136. {python_aidot_cameras-0.11.12 → python_aidot_cameras-0.11.13}/tests/test_token_refresh.py +0 -0
  137. {python_aidot_cameras-0.11.12 → 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.12
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
@@ -117,6 +117,26 @@ _SLOW_PROBE_LOG_EVERY = int(os.environ.get("AIDOT_DTLS_SLOW_PROBE_LOG_EVERY", "6
117
117
  # asyncio.sleep(interval) call, so stop() is not delayed by up to 10 minutes.
118
118
  _SLOW_PROBE_SLEEP_CHUNK_S = float(os.environ.get("AIDOT_DTLS_SLOW_PROBE_CHUNK_S", "5"))
119
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
+
120
140
  # Strong refs to fire-and-forget tasks: asyncio only keeps weak refs, so a
121
141
  # discarded task can be garbage-collected mid-flight. Discarded on completion.
122
142
  _BG_TASKS: set = set()
@@ -3499,7 +3519,9 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3499
3519
  return
3500
3520
  _last_open_at = loop.time()
3501
3521
  try:
3502
- 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
+ )
3503
3525
  except asyncio.CancelledError:
3504
3526
  return
3505
3527
  except AidotCameraBusy as busy:
@@ -63,6 +63,24 @@ def _classify_ffmpeg_exit(rc: int, teardown_requested: bool) -> int:
63
63
  return logging.WARNING
64
64
 
65
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
+
66
84
  class _SdesOpenMixin:
67
85
  async def _open_sdes_stream(self, **kwargs) -> "SdesSession":
68
86
  """Allocate-and-hand-off wrapper around _open_sdes_stream_impl.
@@ -2145,6 +2163,13 @@ class _SdesOpenMixin:
2145
2163
  _trigger_bsrc = None # camera addr for trigger
2146
2164
  _sdes_probe_received = False # True after first 0xC8 probe from camera
2147
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
2148
2173
  _lo_a = _socket_br.socket(_socket_br.AF_INET, _socket_br.SOCK_DGRAM)
2149
2174
  _lo_v = _socket_br.socket(_socket_br.AF_INET, _socket_br.SOCK_DGRAM)
2150
2175
  try:
@@ -2155,15 +2180,24 @@ class _SdesOpenMixin:
2155
2180
  )
2156
2181
  except Exception:
2157
2182
  break
2158
- # 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.
2159
2191
  _br_proc = _proc_holder[0]
2160
2192
  if _br_proc is not None:
2161
2193
  _br_rc = _br_proc.poll()
2162
2194
  if _br_rc is not None:
2163
- 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
2164
2198
  import logging as _log_br
2165
2199
  _br_level = _classify_ffmpeg_exit(
2166
- _br_rc, bool(_teardown_holder[0])
2200
+ _br_rc, _br_teardown_requested
2167
2201
  )
2168
2202
  _br_msg = (
2169
2203
  "SDES bridge: ffmpeg exited with code %d"
@@ -2175,7 +2209,12 @@ class _SdesOpenMixin:
2175
2209
  _log_br.getLogger(__name__).log(
2176
2210
  _br_level, _br_msg, _br_rc
2177
2211
  )
2178
- break
2212
+ if _bridge_should_break(
2213
+ _br_rc, _br_teardown_requested
2214
+ ):
2215
+ break
2216
+ else:
2217
+ _br_exit_logged = False
2179
2218
 
2180
2219
  # AVIO HEARTBEAT (cmd=5156) every 10s, sent as an ENCRYPTED SCTP
2181
2220
  # DATA chunk (PPID=53) - exactly like LIVING - NOT a raw 0xC8 AVIO
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-aidot-cameras"
7
- version = "0.11.12"
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.12
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
@@ -80,6 +80,7 @@ tests/test_device_user_info_cache.py
80
80
  tests/test_dtls_not_ready_burst.py
81
81
  tests/test_dtls_pinning.py
82
82
  tests/test_dtls_serve_decode_skip.py
83
+ tests/test_dtls_serve_open_timeout.py
83
84
  tests/test_dtls_skip_signaling_wait.py
84
85
  tests/test_dtls_slow_probe.py
85
86
  tests/test_egress_guard.py
@@ -106,6 +107,7 @@ tests/test_playback_tls.py
106
107
  tests/test_post_merge_hardening.py
107
108
  tests/test_retry_policy.py
108
109
  tests/test_sdes_adaptive.py
110
+ tests/test_sdes_bridge_break_gate.py
109
111
  tests/test_sdes_echo_wait_timeout.py
110
112
  tests/test_sdes_fast_liveplay.py
111
113
  tests/test_sdes_ffmpeg_stderr_log.py
@@ -0,0 +1,75 @@
1
+ """AIDOT_DTLS_SERVE_OPEN_TIMEOUT_S: env-tunable timeout for the DTLS serve
2
+ loop's async_open_webrtc_stream call (aidot/camera/client.py, ~line 3502).
3
+
4
+ Without a ``timeout`` kwarg that call is hard-pinned to the 30.0s default in
5
+ _async_open_webrtc_stream_impl (webrtc_open.py ~146-151); a dead/wedged DTLS
6
+ camera then burns a full 30s per open attempt. This makes it tunable while
7
+ keeping the default unchanged (battery cameras can legitimately need the full
8
+ 30s to wake).
9
+
10
+ Repo convention: no pytest-asyncio; drive coroutines with asyncio.run().
11
+ """
12
+ import asyncio
13
+ import types
14
+
15
+ import aidot.camera.client as camera_client
16
+ from aidot.camera.client import CameraMixin, _parse_env_float
17
+
18
+
19
+ # --- _parse_env_float: the try/except-fallback idiom used elsewhere in this
20
+ # module for numeric AIDOT_* env vars (see _get_webrtc_open_gate /
21
+ # _get_stream_slots' AIDOT_MAX_CONCURRENT_OPENS / _STREAMS handling). ------- #
22
+
23
+ def test_parse_env_float_defaults_when_unset(monkeypatch):
24
+ monkeypatch.delenv("AIDOT_DTLS_SERVE_OPEN_TIMEOUT_S", raising=False)
25
+ assert _parse_env_float("AIDOT_DTLS_SERVE_OPEN_TIMEOUT_S", 30.0) == 30.0
26
+
27
+
28
+ def test_parse_env_float_override(monkeypatch):
29
+ monkeypatch.setenv("AIDOT_DTLS_SERVE_OPEN_TIMEOUT_S", "12.5")
30
+ assert _parse_env_float("AIDOT_DTLS_SERVE_OPEN_TIMEOUT_S", 30.0) == 12.5
31
+
32
+
33
+ def test_parse_env_float_malformed_falls_back_to_default(monkeypatch):
34
+ monkeypatch.setenv("AIDOT_DTLS_SERVE_OPEN_TIMEOUT_S", "not-a-number")
35
+ assert _parse_env_float("AIDOT_DTLS_SERVE_OPEN_TIMEOUT_S", 30.0) == 30.0
36
+
37
+
38
+ def test_module_constant_defaults_to_30():
39
+ # Guards the preserved-behavior requirement: unset env -> 30.0, matching
40
+ # _async_open_webrtc_stream_impl's prior hard-pinned default exactly.
41
+ assert camera_client._DTLS_SERVE_OPEN_TIMEOUT_S == 30.0
42
+
43
+
44
+ # --- Nice-to-have: the serve loop passes the configured timeout through ---- #
45
+
46
+ class _Ready:
47
+ def clear(self):
48
+ pass
49
+
50
+ def set(self):
51
+ pass
52
+
53
+
54
+ def test_dtls_serve_loop_passes_configured_timeout(monkeypatch):
55
+ calls = []
56
+
57
+ class _Fake:
58
+ _streaming_active = True
59
+ _keepalive_rtsp_url = "http://127.0.0.1:8999/cam.ts"
60
+ device_id = "TESTCAM"
61
+ _serve_ready = _Ready()
62
+
63
+ async def async_open_webrtc_stream(self, *a, **k):
64
+ calls.append(k)
65
+ raise asyncio.CancelledError
66
+
67
+ fake = _Fake()
68
+
69
+ async def _run():
70
+ await types.MethodType(CameraMixin._dtls_serve_loop_inner, fake)()
71
+
72
+ asyncio.run(_run())
73
+
74
+ assert calls, "async_open_webrtc_stream was never called"
75
+ assert calls[0].get("timeout") == camera_client._DTLS_SERVE_OPEN_TIMEOUT_S
@@ -0,0 +1,229 @@
1
+ """Gate the SDES bridge observe-loop break on the teardown flag.
2
+
3
+ During a key-restart (sdes_open.py's SRTP-key-mismatch branch, ~3680-3739) the
4
+ OLD ffmpeg proc is terminated and the shared teardown flag is set BEFORE the
5
+ NEW proc is spawned and _proc_holder[0] is repointed to it. If the bridge
6
+ thread's observe-loop tick lands in that window, it used to see the OLD
7
+ proc's non-None exit code and break unconditionally - tearing down the bridge
8
+ loop, closing the loopback sockets, and starving the freshly restarted
9
+ ffmpeg (NO_MEDIA until the liveness watchdog forces a 40-60s reconnect).
10
+
11
+ The break decision is extracted into a pure helper, _bridge_should_break(rc,
12
+ teardown_requested), so the policy is unit-testable in isolation; the loop
13
+ itself is embedded in a large closure inside _open_sdes_stream_impl that
14
+ cannot be invoked standalone, so the composed loop-level tests below drive a
15
+ small mirror of the loop's shape (select-gated wait / proc-holder poll / the
16
+ pre-existing except-break on socket close) using the real, imported
17
+ _bridge_should_break to make the decision - the same function the production
18
+ loop calls - plus a source-inspection check that the production loop really
19
+ does call it, so the mirror cannot silently drift from the real code.
20
+ """
21
+ import inspect
22
+ import select
23
+ import socket
24
+
25
+ import aidot.camera.sdes_open as sdes_open
26
+ from aidot.camera.sdes_open import _bridge_should_break
27
+
28
+
29
+ # --------------------------------------------------------------------- #
30
+ # Pure helper truth table
31
+ # --------------------------------------------------------------------- #
32
+
33
+ def test_break_on_clean_exit_without_teardown():
34
+ assert _bridge_should_break(0, False) is True
35
+
36
+
37
+ def test_break_on_signal_death_without_teardown():
38
+ assert _bridge_should_break(-9, False) is True
39
+
40
+
41
+ def test_no_break_on_signal_death_during_teardown():
42
+ assert _bridge_should_break(-9, True) is False
43
+
44
+
45
+ def test_no_break_on_clean_exit_during_teardown():
46
+ assert _bridge_should_break(0, True) is False
47
+
48
+
49
+ def test_no_break_while_still_running():
50
+ assert _bridge_should_break(None, False) is False
51
+ assert _bridge_should_break(None, True) is False
52
+
53
+
54
+ # --------------------------------------------------------------------- #
55
+ # Source-structure guard: the real loop must call the pure helper, not
56
+ # reimplement (or bypass) the decision inline.
57
+ # --------------------------------------------------------------------- #
58
+
59
+ def test_production_loop_calls_the_shared_helper():
60
+ src = inspect.getsource(sdes_open)
61
+ start = src.index("def _bridge_fn():")
62
+ end = src.index("def _bridge_fn():", start + 1) if src.count(
63
+ "def _bridge_fn():"
64
+ ) > 1 else len(src)
65
+ block = src[start:end]
66
+ assert "_bridge_should_break(" in block, (
67
+ "the bridge observe loop must delegate the break decision to "
68
+ "_bridge_should_break so the policy stays unit-testable and in sync"
69
+ )
70
+
71
+
72
+ # --------------------------------------------------------------------- #
73
+ # Loop-level behavior with fakes
74
+ # --------------------------------------------------------------------- #
75
+
76
+ class _FakeProc:
77
+ """Minimal Popen stand-in: poll() returns the fixed rc once "exited"."""
78
+
79
+ def __init__(self, rc):
80
+ self._rc = rc
81
+
82
+ def poll(self):
83
+ return self._rc
84
+
85
+
86
+ def _run_observe_loop(sock_a, sock_b, proc_holder, teardown_holder,
87
+ max_ticks=100):
88
+ """Mirror of the sdes_open.py bridge observe loop's shape.
89
+
90
+ Same structure as the production loop: a select()-gated wait whose
91
+ except clause breaks (the socket-close escape hatch), then a proc poll
92
+ that only breaks when the real _bridge_should_break helper says so, with
93
+ an exit-code log fired at most once per skip window. Returns
94
+ (outcome, ticks, log_count) so tests can assert on all three without any
95
+ real sleeping or threading - select's timeout is kept tiny since the
96
+ real 0.5s constant is not itself under test here.
97
+ """
98
+ exit_logged = False
99
+ log_count = 0
100
+ for tick in range(1, max_ticks + 1):
101
+ try:
102
+ select.select([sock_a, sock_b], [], [], 0.01)
103
+ except Exception:
104
+ return "closed", tick, log_count
105
+ proc = proc_holder[0]
106
+ if proc is not None:
107
+ rc = proc.poll()
108
+ if rc is not None:
109
+ if rc != 0 and not exit_logged:
110
+ exit_logged = True
111
+ log_count += 1
112
+ if _bridge_should_break(rc, bool(teardown_holder[0])):
113
+ return "broke", tick, log_count
114
+ continue
115
+ exit_logged = False
116
+ return "exhausted", max_ticks, log_count
117
+
118
+
119
+ def _udp_pair():
120
+ a = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
121
+ b = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
122
+ return a, b
123
+
124
+
125
+ def test_teardown_window_exit_does_not_break_the_loop():
126
+ # Old proc reports a signal death while teardown is flagged (the
127
+ # key-restart window) - the loop must NOT break on that tick.
128
+ a, b = _udp_pair()
129
+ try:
130
+ proc_holder = [_FakeProc(-9)]
131
+ teardown_holder = [True]
132
+ outcome, ticks, log_count = _run_observe_loop(
133
+ a, b, proc_holder, teardown_holder, max_ticks=3
134
+ )
135
+ assert outcome == "exhausted"
136
+ assert log_count == 1 # logged once, not suppressed entirely
137
+ finally:
138
+ a.close()
139
+ b.close()
140
+
141
+
142
+ def test_repoint_to_live_proc_resumes_normal_operation():
143
+ # Same window, but the key-restart repoints _proc_holder[0] to the new,
144
+ # live proc after the first tick - the loop must keep running (not
145
+ # break) and must not re-log once the new proc is polling clean.
146
+ a, b = _udp_pair()
147
+ try:
148
+ proc_holder = [_FakeProc(-9)]
149
+ teardown_holder = [True]
150
+
151
+ exit_logged = False
152
+ log_count = 0
153
+ outcomes = []
154
+ for i in range(4):
155
+ if i == 1:
156
+ proc_holder[0] = _FakeProc(None) # key-restart repoint
157
+ try:
158
+ select.select([a, b], [], [], 0.01)
159
+ except Exception:
160
+ outcomes.append("closed")
161
+ break
162
+ proc = proc_holder[0]
163
+ rc = proc.poll()
164
+ if rc is not None:
165
+ if rc != 0 and not exit_logged:
166
+ exit_logged = True
167
+ log_count += 1
168
+ if _bridge_should_break(rc, bool(teardown_holder[0])):
169
+ outcomes.append("broke")
170
+ break
171
+ else:
172
+ exit_logged = False
173
+ outcomes.append("continued")
174
+ assert outcomes == ["continued", "continued", "continued", "continued"]
175
+ assert log_count == 1
176
+ finally:
177
+ a.close()
178
+ b.close()
179
+
180
+
181
+ def test_genuine_teardown_closes_sockets_and_exits_via_except_break():
182
+ # No key-restart repoint arrives; instead a real teardown (stop(), the
183
+ # DTLS-fallback abort, or _reap()) closes the loopback sockets - select()
184
+ # must raise and the loop must exit via the pre-existing except-break.
185
+ a, b = _udp_pair()
186
+ proc_holder = [_FakeProc(-9)]
187
+ teardown_holder = [True]
188
+
189
+ a.close()
190
+ b.close()
191
+
192
+ outcome, ticks, log_count = _run_observe_loop(
193
+ a, b, proc_holder, teardown_holder, max_ticks=10
194
+ )
195
+ assert outcome == "closed"
196
+ assert ticks == 1
197
+
198
+
199
+ def test_genuine_crash_without_teardown_still_breaks():
200
+ # No teardown in flight: an unexpected ffmpeg crash must still break the
201
+ # loop immediately, exactly as before this fix.
202
+ a, b = _udp_pair()
203
+ try:
204
+ proc_holder = [_FakeProc(-9)]
205
+ teardown_holder = [False]
206
+ outcome, ticks, log_count = _run_observe_loop(
207
+ a, b, proc_holder, teardown_holder, max_ticks=10
208
+ )
209
+ assert outcome == "broke"
210
+ assert ticks == 1
211
+ assert log_count == 1
212
+ finally:
213
+ a.close()
214
+ b.close()
215
+
216
+
217
+ def test_log_fires_at_most_once_across_a_multi_tick_skip_window():
218
+ a, b = _udp_pair()
219
+ try:
220
+ proc_holder = [_FakeProc(-9)]
221
+ teardown_holder = [True]
222
+ outcome, ticks, log_count = _run_observe_loop(
223
+ a, b, proc_holder, teardown_holder, max_ticks=20
224
+ )
225
+ assert outcome == "exhausted"
226
+ assert log_count == 1
227
+ finally:
228
+ a.close()
229
+ b.close()