python-aidot-cameras 0.12.7__tar.gz → 0.12.8__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.12.7/python_aidot_cameras.egg-info → python_aidot_cameras-0.12.8}/PKG-INFO +1 -1
  2. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/aidot_cameras/__init__.py +2 -0
  3. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/aidot_cameras/camera/client.py +48 -6
  4. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/pyproject.toml +1 -1
  5. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  6. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/python_aidot_cameras.egg-info/SOURCES.txt +1 -0
  7. python_aidot_cameras-0.12.8/tests/test_stream_slot_cap.py +80 -0
  8. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_upstream_compat.py +7 -0
  9. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/LICENSE +0 -0
  10. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/MANIFEST.in +0 -0
  11. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/README.md +0 -0
  12. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/aidot_cameras/__main__.py +0 -0
  13. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/aidot_cameras/_vendor/__init__.py +0 -0
  14. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/aidot_cameras/_vendor/aiortc/__init__.py +0 -0
  15. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/aidot_cameras/_vendor/aiortc/clock.py +0 -0
  16. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/aidot_cameras/_vendor/aiortc/codecs/__init__.py +0 -0
  17. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/aidot_cameras/_vendor/aiortc/codecs/base.py +0 -0
  18. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/aidot_cameras/_vendor/aiortc/codecs/g711.py +0 -0
  19. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/aidot_cameras/_vendor/aiortc/codecs/g722.py +0 -0
  20. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/aidot_cameras/_vendor/aiortc/codecs/h264.py +0 -0
  21. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/aidot_cameras/_vendor/aiortc/codecs/opus.py +0 -0
  22. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/aidot_cameras/_vendor/aiortc/codecs/vpx.py +0 -0
  23. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/aidot_cameras/_vendor/aiortc/contrib/__init__.py +0 -0
  24. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/aidot_cameras/_vendor/aiortc/contrib/media.py +0 -0
  25. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/aidot_cameras/_vendor/aiortc/contrib/signaling.py +0 -0
  26. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/aidot_cameras/_vendor/aiortc/events.py +0 -0
  27. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/aidot_cameras/_vendor/aiortc/exceptions.py +0 -0
  28. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/aidot_cameras/_vendor/aiortc/jitterbuffer.py +0 -0
  29. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/aidot_cameras/_vendor/aiortc/mediastreams.py +0 -0
  30. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/aidot_cameras/_vendor/aiortc/rate.py +0 -0
  31. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/aidot_cameras/_vendor/aiortc/rtcconfiguration.py +0 -0
  32. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/aidot_cameras/_vendor/aiortc/rtcdatachannel.py +0 -0
  33. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/aidot_cameras/_vendor/aiortc/rtcdtlstransport.py +0 -0
  34. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/aidot_cameras/_vendor/aiortc/rtcicetransport.py +0 -0
  35. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/aidot_cameras/_vendor/aiortc/rtcpeerconnection.py +0 -0
  36. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/aidot_cameras/_vendor/aiortc/rtcrtpparameters.py +0 -0
  37. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/aidot_cameras/_vendor/aiortc/rtcrtpreceiver.py +0 -0
  38. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/aidot_cameras/_vendor/aiortc/rtcrtpsender.py +0 -0
  39. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/aidot_cameras/_vendor/aiortc/rtcrtptransceiver.py +0 -0
  40. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/aidot_cameras/_vendor/aiortc/rtcsctptransport.py +0 -0
  41. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/aidot_cameras/_vendor/aiortc/rtcsessiondescription.py +0 -0
  42. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/aidot_cameras/_vendor/aiortc/rtp.py +0 -0
  43. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/aidot_cameras/_vendor/aiortc/sdp.py +0 -0
  44. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/aidot_cameras/_vendor/aiortc/stats.py +0 -0
  45. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/aidot_cameras/_vendor/aiortc/utils.py +0 -0
  46. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/aidot_cameras/camera/__init__.py +0 -0
  47. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/aidot_cameras/camera/constants.py +0 -0
  48. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/aidot_cameras/camera/controls.py +0 -0
  49. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/aidot_cameras/camera/go2rtc.py +0 -0
  50. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/aidot_cameras/camera/lan_control.py +0 -0
  51. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/aidot_cameras/camera/models.py +0 -0
  52. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/aidot_cameras/camera/playback.py +0 -0
  53. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/aidot_cameras/camera/protocol.py +0 -0
  54. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/aidot_cameras/camera/sdes.py +0 -0
  55. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/aidot_cameras/camera/sdes_open.py +0 -0
  56. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/aidot_cameras/camera/tutk.py +0 -0
  57. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/aidot_cameras/camera/webrtc.py +0 -0
  58. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/aidot_cameras/camera/webrtc_open.py +0 -0
  59. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/aidot_cameras/client.py +0 -0
  60. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/aidot_cameras/const.py +0 -0
  61. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/aidot_cameras/credentials.py +0 -0
  62. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/aidot_cameras/crypto.py +0 -0
  63. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/aidot_cameras/device_client.py +0 -0
  64. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/aidot_cameras/discover.py +0 -0
  65. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/aidot_cameras/exceptions.py +0 -0
  66. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/aidot_cameras/g711.py +0 -0
  67. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/aidot_cameras/py.typed +0 -0
  68. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  69. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
  70. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/python_aidot_cameras.egg-info/requires.txt +0 -0
  71. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/python_aidot_cameras.egg-info/top_level.txt +0 -0
  72. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/setup.cfg +0 -0
  73. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_aioice_compat.py +0 -0
  74. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_alarm_event.py +0 -0
  75. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_backoff.py +0 -0
  76. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_deferred_hardening.py +0 -0
  77. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_device_login_guard.py +0 -0
  78. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_device_user_info_cache.py +0 -0
  79. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_dtls_not_ready_burst.py +0 -0
  80. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_dtls_pinning.py +0 -0
  81. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_dtls_serve_decode_skip.py +0 -0
  82. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_dtls_serve_open_timeout.py +0 -0
  83. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_dtls_skip_signaling_wait.py +0 -0
  84. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_dtls_slow_probe.py +0 -0
  85. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_egress_guard.py +0 -0
  86. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_go2rtc.py +0 -0
  87. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_go2rtc_cli.py +0 -0
  88. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_h264_decode_rate_limit.py +0 -0
  89. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_highport_nomination.py +0 -0
  90. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_ice_config_cache.py +0 -0
  91. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_keepalive_renew.py +0 -0
  92. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_keyframe_prompter.py +0 -0
  93. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_lan_control.py +0 -0
  94. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_light_active_color_mode.py +0 -0
  95. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_live_stream_param.py +0 -0
  96. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_logging_caps.py +0 -0
  97. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_motion_poll.py +0 -0
  98. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_mqtt_credential_refetch.py +0 -0
  99. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_mqtt_credential_self_heal.py +0 -0
  100. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_mqtt_password_not_persisted.py +0 -0
  101. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_mqtt_session_reconnect.py +0 -0
  102. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_narrow_pc_ice.py +0 -0
  103. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_no_undefined_names.py +0 -0
  104. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_offline_keepalive_pause.py +0 -0
  105. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_open_fail_logger.py +0 -0
  106. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_open_gate_delay.py +0 -0
  107. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_persistent_mqtt.py +0 -0
  108. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_persistent_mqtt_robustness.py +0 -0
  109. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_playback_tls.py +0 -0
  110. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_post_merge_hardening.py +0 -0
  111. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_retry_policy.py +0 -0
  112. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_sdes_adaptive.py +0 -0
  113. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_sdes_bridge_break_gate.py +0 -0
  114. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_sdes_echo_wait_timeout.py +0 -0
  115. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_sdes_fast_liveplay.py +0 -0
  116. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_sdes_ffmpeg_stderr_log.py +0 -0
  117. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_sdes_idle_release.py +0 -0
  118. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_sdes_open_cleanup.py +0 -0
  119. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_sdes_serve_audio.py +0 -0
  120. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_sdes_serve_cmd.py +0 -0
  121. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_sdes_sprop.py +0 -0
  122. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_sdes_talk.py +0 -0
  123. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_sdes_teardown_exit_log.py +0 -0
  124. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_sdes_watchdog.py +0 -0
  125. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_sdp_payload_type_narrowing.py +0 -0
  126. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_security_hardening.py +0 -0
  127. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_serializable_login_info.py +0 -0
  128. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_serve_relay.py +0 -0
  129. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_session_stats.py +0 -0
  130. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_speak.py +0 -0
  131. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_stream_cap.py +0 -0
  132. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_stream_drain_ownership.py +0 -0
  133. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_stream_idle.py +0 -0
  134. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_stream_teardown.py +0 -0
  135. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_talk.py +0 -0
  136. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_terminal_ack.py +0 -0
  137. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_token_file_robustness.py +0 -0
  138. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_token_refresh.py +0 -0
  139. {python_aidot_cameras-0.12.7 → python_aidot_cameras-0.12.8}/tests/test_wait_or_event.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-aidot-cameras
3
- Version: 0.12.7
3
+ Version: 0.12.8
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
@@ -12,6 +12,7 @@ import time as _time
12
12
 
13
13
  from .client import AidotClient, CameraClient
14
14
  from .device_client import CameraDeviceClient, DeviceInformation, DeviceStatusData
15
+ from .camera.client import configure_stream_limits
15
16
  from .discover import CameraDiscover, Discover
16
17
  from .exceptions import (
17
18
  AidotAuthFailed,
@@ -147,4 +148,5 @@ __all__ = [
147
148
  "HTTPError",
148
149
  "InvalidHost",
149
150
  "InvalidURL",
151
+ "configure_stream_limits",
150
152
  ]
@@ -571,6 +571,51 @@ def _get_webrtc_open_gate() -> "asyncio.Semaphore":
571
571
 
572
572
 
573
573
  _STREAM_SLOTS: "Optional[asyncio.Semaphore]" = None
574
+ _STREAM_SLOTS_CAP: int = 0
575
+
576
+
577
+ def _stream_slots_default() -> int:
578
+ """The configured cap: an explicit env override, else the built-in default."""
579
+ try:
580
+ return max(1, int(os.environ.get("AIDOT_MAX_CONCURRENT_STREAMS", "3")))
581
+ except (ValueError, TypeError):
582
+ return 3
583
+
584
+
585
+ def configure_stream_limits(max_streams: int) -> int:
586
+ """Raise the concurrent-serve cap to fit the fleet; return the cap in force.
587
+
588
+ The default cap of 3 is a host-protection guard, but it silently STARVES an
589
+ account with more cameras than that: a camera holds its slot for the life of
590
+ its serve, so on a fleet of 4 DTLS cameras the fourth waits forever while the
591
+ other three are kept alive - it simply never streams, with no error anywhere.
592
+ Confirmed live on a 4-camera fleet, where the library logged "waiting for a
593
+ stream slot (cap reached)" for the odd one out on every attempt.
594
+
595
+ A consumer that knows how many cameras it is about to drive (the Home
596
+ Assistant integration knows its camera count) should call this so the cap is
597
+ never below the fleet size.
598
+
599
+ ``AIDOT_MAX_CONCURRENT_STREAMS`` still wins when set: an operator who
600
+ deliberately capped a small host means it, and would rather have cameras take
601
+ turns than have the host fall over. The cap only ever grows - shrinking it
602
+ would strand a camera that already holds a slot.
603
+ """
604
+ global _STREAM_SLOTS, _STREAM_SLOTS_CAP
605
+ if os.environ.get("AIDOT_MAX_CONCURRENT_STREAMS"):
606
+ _get_stream_slots() # ensure it exists so the cap is populated
607
+ return _STREAM_SLOTS_CAP
608
+ target = max(1, int(max_streams))
609
+ slots = _get_stream_slots()
610
+ if target > _STREAM_SLOTS_CAP:
611
+ for _ in range(target - _STREAM_SLOTS_CAP):
612
+ slots.release() # grows the semaphore's permit count
613
+ _LOGGER.info(
614
+ "concurrent-serve cap raised from %d to %d to fit the camera fleet",
615
+ _STREAM_SLOTS_CAP, target,
616
+ )
617
+ _STREAM_SLOTS_CAP = target
618
+ return _STREAM_SLOTS_CAP
574
619
 
575
620
 
576
621
  def _get_stream_slots() -> "asyncio.Semaphore":
@@ -586,13 +631,10 @@ def _get_stream_slots() -> "asyncio.Semaphore":
586
631
  -50002 SESSION_EXCEED terminal error, which we mirror separately). This cap
587
632
  is an HA-specific resource guard: HA dashboards can request N cameras at once,
588
633
  which a phone never does. Deliberate divergence, kept for host protection."""
589
- global _STREAM_SLOTS
634
+ global _STREAM_SLOTS, _STREAM_SLOTS_CAP
590
635
  if _STREAM_SLOTS is None:
591
- try:
592
- _n = max(1, int(os.environ.get("AIDOT_MAX_CONCURRENT_STREAMS", "3")))
593
- except (ValueError, TypeError):
594
- _n = 3
595
- _STREAM_SLOTS = asyncio.Semaphore(_n)
636
+ _STREAM_SLOTS_CAP = _stream_slots_default()
637
+ _STREAM_SLOTS = asyncio.Semaphore(_STREAM_SLOTS_CAP)
596
638
  return _STREAM_SLOTS
597
639
 
598
640
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-aidot-cameras"
7
- version = "0.12.7"
7
+ version = "0.12.8"
8
8
  description = "Control AiDot/Leedarson WiFi lights and cameras (WebRTC streaming, two-way audio, PTZ, controls)"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-aidot-cameras
3
- Version: 0.12.7
3
+ Version: 0.12.8
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
@@ -127,6 +127,7 @@ tests/test_speak.py
127
127
  tests/test_stream_cap.py
128
128
  tests/test_stream_drain_ownership.py
129
129
  tests/test_stream_idle.py
130
+ tests/test_stream_slot_cap.py
130
131
  tests/test_stream_teardown.py
131
132
  tests/test_talk.py
132
133
  tests/test_terminal_ack.py
@@ -0,0 +1,80 @@
1
+ """The concurrent-serve cap must never sit below the camera fleet.
2
+
3
+ A camera holds its slot for the life of its serve, so a cap lower than the number
4
+ of cameras does not merely slow things down - the odd one out waits forever and
5
+ simply never streams, with no error surfaced anywhere. Confirmed live on a fleet
6
+ of 4 DTLS cameras against the default cap of 3: the library logged "waiting for a
7
+ stream slot (cap reached)" for the fourth on every attempt, and that camera was
8
+ exactly the one that would not play in Home Assistant.
9
+ """
10
+ import asyncio
11
+
12
+ import pytest
13
+
14
+ import aidot_cameras.camera.client as cc
15
+
16
+
17
+ @pytest.fixture(autouse=True)
18
+ def _reset(monkeypatch):
19
+ monkeypatch.delenv("AIDOT_MAX_CONCURRENT_STREAMS", raising=False)
20
+ cc._STREAM_SLOTS = None
21
+ cc._STREAM_SLOTS_CAP = 0
22
+ yield
23
+ cc._STREAM_SLOTS = None
24
+ cc._STREAM_SLOTS_CAP = 0
25
+
26
+
27
+ def test_default_is_the_host_protection_cap():
28
+ async def go():
29
+ assert cc._stream_slots_default() == 3
30
+ cc._get_stream_slots()
31
+ return cc._STREAM_SLOTS_CAP
32
+ assert asyncio.run(go()) == 3
33
+
34
+
35
+ def test_a_bigger_fleet_raises_the_cap():
36
+ async def go():
37
+ return cc.configure_stream_limits(5)
38
+ assert asyncio.run(go()) == 5
39
+
40
+
41
+ def test_every_camera_in_the_fleet_can_hold_a_slot_at_once():
42
+ # The actual regression: with a cap of 3 and 4 cameras, the fourth blocks.
43
+ async def go():
44
+ cc.configure_stream_limits(4)
45
+ slots = cc._get_stream_slots()
46
+ for _ in range(4):
47
+ await asyncio.wait_for(slots.acquire(), timeout=0.5)
48
+ return True
49
+ assert asyncio.run(go()) is True
50
+
51
+
52
+ def test_the_cap_never_shrinks():
53
+ # Shrinking would strand a camera that already holds a slot.
54
+ async def go():
55
+ cc.configure_stream_limits(6)
56
+ return cc.configure_stream_limits(2)
57
+ assert asyncio.run(go()) == 6
58
+
59
+
60
+ def test_an_explicit_operator_cap_wins(monkeypatch):
61
+ # Someone who capped a small host means it, and would rather cameras took
62
+ # turns than have the host fall over.
63
+ monkeypatch.setenv("AIDOT_MAX_CONCURRENT_STREAMS", "1")
64
+ async def go():
65
+ return cc.configure_stream_limits(8)
66
+ assert asyncio.run(go()) == 1
67
+
68
+
69
+ def test_raising_is_idempotent():
70
+ async def go():
71
+ cc.configure_stream_limits(5)
72
+ cc.configure_stream_limits(5)
73
+ slots = cc._get_stream_slots()
74
+ for _ in range(5):
75
+ await asyncio.wait_for(slots.acquire(), timeout=0.5)
76
+ # A sixth must block: the cap is 5, not 10.
77
+ with pytest.raises(asyncio.TimeoutError):
78
+ await asyncio.wait_for(slots.acquire(), timeout=0.2)
79
+ return True
80
+ assert asyncio.run(go()) is True
@@ -259,3 +259,10 @@ def test_device_state_is_upstreams_own_enum_reexported():
259
259
  assert DeviceState is _upstream
260
260
  for name in ("IDLE", "AUTHENTICATED"):
261
261
  assert hasattr(DeviceState, name), name
262
+
263
+
264
+ def test_configure_stream_limits_is_public():
265
+ # The Home Assistant integration calls this to size the concurrent-serve cap
266
+ # to its camera count; a cap below the fleet silently starves a camera.
267
+ import aidot_cameras
268
+ assert callable(getattr(aidot_cameras, "configure_stream_limits", None))