python-aidot-cameras 0.12.8__tar.gz → 0.12.10__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.
- {python_aidot_cameras-0.12.8/python_aidot_cameras.egg-info → python_aidot_cameras-0.12.10}/PKG-INFO +1 -1
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/camera/client.py +88 -10
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/camera/go2rtc.py +23 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/camera/sdes_open.py +36 -5
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/camera/webrtc_open.py +22 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/pyproject.toml +1 -1
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/python_aidot_cameras.egg-info/SOURCES.txt +3 -0
- python_aidot_cameras-0.12.10/tests/test_idle_release_both_loops.py +48 -0
- python_aidot_cameras-0.12.10/tests/test_key_restart_sdp.py +61 -0
- python_aidot_cameras-0.12.10/tests/test_viewer_presence.py +71 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/LICENSE +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/MANIFEST.in +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/README.md +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/__init__.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/__main__.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/__init__.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/__init__.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/clock.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/codecs/__init__.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/codecs/base.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/codecs/g711.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/codecs/g722.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/codecs/h264.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/codecs/opus.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/codecs/vpx.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/contrib/__init__.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/contrib/media.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/contrib/signaling.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/events.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/exceptions.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/jitterbuffer.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/mediastreams.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/rate.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/rtcconfiguration.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/rtcdatachannel.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/rtcdtlstransport.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/rtcicetransport.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/rtcpeerconnection.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/rtcrtpparameters.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/rtcrtpreceiver.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/rtcrtpsender.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/rtcrtptransceiver.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/rtcsctptransport.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/rtcsessiondescription.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/rtp.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/sdp.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/stats.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/utils.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/camera/__init__.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/camera/constants.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/camera/controls.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/camera/lan_control.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/camera/models.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/camera/playback.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/camera/protocol.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/camera/sdes.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/camera/tutk.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/camera/webrtc.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/client.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/const.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/credentials.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/crypto.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/device_client.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/discover.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/exceptions.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/g711.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/py.typed +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/python_aidot_cameras.egg-info/requires.txt +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/python_aidot_cameras.egg-info/top_level.txt +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/setup.cfg +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_aioice_compat.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_alarm_event.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_backoff.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_deferred_hardening.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_device_login_guard.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_device_user_info_cache.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_dtls_not_ready_burst.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_dtls_pinning.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_dtls_serve_decode_skip.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_dtls_serve_open_timeout.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_dtls_skip_signaling_wait.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_dtls_slow_probe.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_egress_guard.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_go2rtc.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_go2rtc_cli.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_h264_decode_rate_limit.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_highport_nomination.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_ice_config_cache.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_keepalive_renew.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_keyframe_prompter.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_lan_control.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_light_active_color_mode.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_live_stream_param.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_logging_caps.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_motion_poll.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_mqtt_credential_refetch.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_mqtt_credential_self_heal.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_mqtt_password_not_persisted.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_mqtt_session_reconnect.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_narrow_pc_ice.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_no_undefined_names.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_offline_keepalive_pause.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_open_fail_logger.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_open_gate_delay.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_persistent_mqtt.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_persistent_mqtt_robustness.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_playback_tls.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_post_merge_hardening.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_retry_policy.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_sdes_adaptive.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_sdes_bridge_break_gate.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_sdes_echo_wait_timeout.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_sdes_fast_liveplay.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_sdes_ffmpeg_stderr_log.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_sdes_idle_release.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_sdes_open_cleanup.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_sdes_serve_audio.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_sdes_serve_cmd.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_sdes_sprop.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_sdes_talk.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_sdes_teardown_exit_log.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_sdes_watchdog.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_sdp_payload_type_narrowing.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_security_hardening.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_serializable_login_info.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_serve_relay.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_session_stats.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_speak.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_stream_cap.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_stream_drain_ownership.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_stream_idle.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_stream_slot_cap.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_stream_teardown.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_talk.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_terminal_ack.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_token_file_robustness.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_token_refresh.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_upstream_compat.py +0 -0
- {python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_wait_or_event.py +0 -0
{python_aidot_cameras-0.12.8/python_aidot_cameras.egg-info → python_aidot_cameras-0.12.10}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-aidot-cameras
|
|
3
|
-
Version: 0.12.
|
|
3
|
+
Version: 0.12.10
|
|
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
|
|
@@ -2762,7 +2762,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
2762
2762
|
_LOGGER.debug("camera %s: swallowed exception in %s", getattr(self, "device_id", "?"), '_register_with_go2rtc', exc_info=True)
|
|
2763
2763
|
if not (self._streaming_active and self._go2rtc_url and self._keepalive_rtsp_url):
|
|
2764
2764
|
return
|
|
2765
|
-
name =
|
|
2765
|
+
name = self._go2rtc_stream_name()
|
|
2766
2766
|
try:
|
|
2767
2767
|
async with aiohttp.ClientSession() as _s2:
|
|
2768
2768
|
url = await prefer_go2rtc(
|
|
@@ -2788,7 +2788,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
2788
2788
|
return
|
|
2789
2789
|
import aiohttp
|
|
2790
2790
|
from .go2rtc import Go2rtcClient
|
|
2791
|
-
name =
|
|
2791
|
+
name = self._go2rtc_stream_name()
|
|
2792
2792
|
try:
|
|
2793
2793
|
async with aiohttp.ClientSession() as _s2:
|
|
2794
2794
|
await Go2rtcClient(_s2, base).remove_stream(name)
|
|
@@ -2903,6 +2903,56 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
2903
2903
|
found = True
|
|
2904
2904
|
return found if read_any else None
|
|
2905
2905
|
|
|
2906
|
+
def _go2rtc_stream_name(self) -> str:
|
|
2907
|
+
"""The go2rtc stream name for this camera - one definition, so the
|
|
2908
|
+
register, deregister and viewer-count paths cannot drift apart."""
|
|
2909
|
+
return f"aidot_{self.device_id[:12]}"
|
|
2910
|
+
|
|
2911
|
+
async def _viewer_present(self, port: int) -> "Optional[bool]":
|
|
2912
|
+
"""Is anyone ACTUALLY watching? True / False / None (unknown).
|
|
2913
|
+
|
|
2914
|
+
Ask go2rtc first when the stream is registered with it, because the serve
|
|
2915
|
+
socket cannot answer this question: go2rtc attaches to the serve as its
|
|
2916
|
+
producer and stays attached for as long as the stream exists, viewer or
|
|
2917
|
+
no viewer. A TCP peer is therefore always present, idle-release never
|
|
2918
|
+
fires, and every camera keeps streaming indefinitely after a single view -
|
|
2919
|
+
which is what "why are all my cameras streaming?" looks like from outside.
|
|
2920
|
+
Confirmed live: five cameras, no viewers, still producing after 7 minutes
|
|
2921
|
+
against a 5 minute idle window.
|
|
2922
|
+
|
|
2923
|
+
Falls back to the TCP-table check when go2rtc is not in use, and returns
|
|
2924
|
+
None (never release) if neither can answer.
|
|
2925
|
+
"""
|
|
2926
|
+
name = self._go2rtc_stream_name()
|
|
2927
|
+
base = self._go2rtc_url
|
|
2928
|
+
if not base:
|
|
2929
|
+
# No go2rtc to ask. In PUSH mode the fallback below is not merely
|
|
2930
|
+
# unavailable, it is WRONG: the "serve port" is go2rtc's shared RTSP
|
|
2931
|
+
# port (8554), where every camera's own publisher is connected, so the
|
|
2932
|
+
# check reports a viewer for every camera forever and nothing ever
|
|
2933
|
+
# releases. Answer "unknown" instead of a confident lie - the caller
|
|
2934
|
+
# then holds the stream rather than tearing down a live view, and the
|
|
2935
|
+
# honest way out is for the consumer to pass go2rtc_url.
|
|
2936
|
+
if self._keepalive_rtsp_url and not str(
|
|
2937
|
+
self._keepalive_rtsp_url).startswith("http"):
|
|
2938
|
+
_LOGGER.debug(
|
|
2939
|
+
"no go2rtc url and the serve is an RTSP push to a shared "
|
|
2940
|
+
"port; cannot tell whether anyone is watching",
|
|
2941
|
+
)
|
|
2942
|
+
return None
|
|
2943
|
+
if base and name:
|
|
2944
|
+
try:
|
|
2945
|
+
import aiohttp
|
|
2946
|
+
|
|
2947
|
+
from .go2rtc import Go2rtcClient
|
|
2948
|
+
async with aiohttp.ClientSession() as _s:
|
|
2949
|
+
viewers = await Go2rtcClient(_s, base).viewer_count(name)
|
|
2950
|
+
if viewers is not None:
|
|
2951
|
+
return viewers > 0
|
|
2952
|
+
except Exception as exc:
|
|
2953
|
+
_LOGGER.debug("go2rtc viewer check failed: %s", exc)
|
|
2954
|
+
return self._sdes_serve_consumer_present(port)
|
|
2955
|
+
|
|
2906
2956
|
async def _sdes_keepalive_loop(self) -> None:
|
|
2907
2957
|
"""Keep the cold-start serve relay alive around the SDES keepalive loop.
|
|
2908
2958
|
|
|
@@ -3019,7 +3069,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
3019
3069
|
_stalled = True
|
|
3020
3070
|
break
|
|
3021
3071
|
if _idle_on and _serve_port is not None:
|
|
3022
|
-
_present = self.
|
|
3072
|
+
_present = await self._viewer_present(_serve_port)
|
|
3023
3073
|
if _present: # True -> a viewer is pulling; stay alive
|
|
3024
3074
|
_last_consumer = time.monotonic()
|
|
3025
3075
|
elif _idle_release_due(_present, _last_consumer,
|
|
@@ -3657,8 +3707,14 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
3657
3707
|
self.device_id,
|
|
3658
3708
|
)
|
|
3659
3709
|
await slots.acquire()
|
|
3660
|
-
self._serve_relay = self._maybe_start_serve_relay(self._keepalive_rtsp_url)
|
|
3661
3710
|
try:
|
|
3711
|
+
# Inside the try on purpose: _maybe_start_serve_relay catches OSError
|
|
3712
|
+
# but Thread.start() raises RuntimeError when the host is out of
|
|
3713
|
+
# threads, and escaping here would skip slots.release() and burn a
|
|
3714
|
+
# permit for the life of the process - the cap would silently shrink
|
|
3715
|
+
# until nothing could stream at all.
|
|
3716
|
+
self._serve_relay = self._maybe_start_serve_relay(
|
|
3717
|
+
self._keepalive_rtsp_url)
|
|
3662
3718
|
await self._dtls_serve_loop_inner()
|
|
3663
3719
|
finally:
|
|
3664
3720
|
_relay = self._serve_relay
|
|
@@ -3916,6 +3972,8 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
3916
3972
|
_stall_pli_s = float(os.environ.get("AIDOT_STALL_PLI_S", "1.0"))
|
|
3917
3973
|
_last_gop_pli = loop.time()
|
|
3918
3974
|
_stall_pli_armed = True
|
|
3975
|
+
_last_viewer_dtls = loop.time()
|
|
3976
|
+
_serve_port_dtls = _sdes_serve_port(serve_url) or 0
|
|
3919
3977
|
# Wait for ffmpeg to exit (go2rtc disconnect) or idle release.
|
|
3920
3978
|
while self._streaming_active and proc.returncode is None:
|
|
3921
3979
|
await asyncio.sleep(0.5)
|
|
@@ -3933,11 +3991,26 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
3933
3991
|
await self._send_video_pli(pc)
|
|
3934
3992
|
_last_gop_pli = _now # also satisfies cadence
|
|
3935
3993
|
_stall_pli_armed = False # one shot per stall
|
|
3936
|
-
#
|
|
3937
|
-
#
|
|
3938
|
-
#
|
|
3939
|
-
|
|
3940
|
-
|
|
3994
|
+
# Whether anyone is WATCHING. Pipe-progress staleness
|
|
3995
|
+
# cannot answer that: the pipe only backs up when nothing
|
|
3996
|
+
# drains the serve socket, and go2rtc drains it forever as
|
|
3997
|
+
# the stream's producer, viewer or not. So progress never
|
|
3998
|
+
# goes stale, the release never fires, and every DTLS
|
|
3999
|
+
# camera streams for the life of the process - the same
|
|
4000
|
+
# defect fixed for the SDES loop, in the other loop.
|
|
4001
|
+
# Ask go2rtc, and only fall back to staleness when it
|
|
4002
|
+
# cannot answer.
|
|
4003
|
+
if idle_secs > 0:
|
|
4004
|
+
_watching = await self._viewer_present(_serve_port_dtls)
|
|
4005
|
+
if _watching:
|
|
4006
|
+
_last_viewer_dtls = _now
|
|
4007
|
+
elif _watching is False:
|
|
4008
|
+
if _now - _last_viewer_dtls > idle_secs:
|
|
4009
|
+
idle_release = True
|
|
4010
|
+
elif _now - progress[0] > idle_secs:
|
|
4011
|
+
# Unknown: keep the old staleness heuristic rather
|
|
4012
|
+
# than holding the stream open forever.
|
|
4013
|
+
idle_release = True
|
|
3941
4014
|
break
|
|
3942
4015
|
# Tear down this ffmpeg+mux cycle before the next.
|
|
3943
4016
|
stop_flag.set()
|
|
@@ -3964,7 +4037,12 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
3964
4037
|
wfile.close()
|
|
3965
4038
|
except Exception:
|
|
3966
4039
|
_LOGGER.debug("camera %s: swallowed exception in %s", getattr(self, "device_id", "?"), '_pc_dead', exc_info=True)
|
|
3967
|
-
if mux_thread is not None:
|
|
4040
|
+
if mux_thread is not None and mux_thread.is_alive():
|
|
4041
|
+
# is_alive() guards the never-started case: if Thread.start()
|
|
4042
|
+
# itself raised, join() raises "cannot join thread before it
|
|
4043
|
+
# is started", which would replace the real exception AND skip
|
|
4044
|
+
# the ffmpeg terminate plus session stop below - orphaning the
|
|
4045
|
+
# process on its serve port and leaking the camera's session.
|
|
3968
4046
|
mux_thread.join(timeout=2.0)
|
|
3969
4047
|
_terminate_proc(proc) # never orphan ffmpeg on teardown
|
|
3970
4048
|
self._stream_session = None
|
|
@@ -72,6 +72,29 @@ class Go2rtcClient:
|
|
|
72
72
|
async def has_stream(self, name: str) -> bool:
|
|
73
73
|
return name in (await self.list_streams())
|
|
74
74
|
|
|
75
|
+
async def viewer_count(self, name: str) -> "Optional[int]":
|
|
76
|
+
"""How many CONSUMERS go2rtc has for ``name``; None if it cannot be read.
|
|
77
|
+
|
|
78
|
+
This is the only trustworthy answer to "is anyone actually watching?".
|
|
79
|
+
The serve socket cannot answer it: go2rtc attaches to the serve as its
|
|
80
|
+
producer and stays attached, so a TCP peer is always present whether or
|
|
81
|
+
not a viewer exists. Idle-release keyed on the socket therefore never
|
|
82
|
+
fires and every camera streams forever after a single view.
|
|
83
|
+
|
|
84
|
+
None means unknown (go2rtc unreachable or the stream absent), and callers
|
|
85
|
+
must fail safe by not releasing on it.
|
|
86
|
+
"""
|
|
87
|
+
streams = await self.list_streams()
|
|
88
|
+
entry = streams.get(name)
|
|
89
|
+
if not isinstance(entry, dict):
|
|
90
|
+
return None
|
|
91
|
+
consumers = entry.get("consumers")
|
|
92
|
+
if consumers is None:
|
|
93
|
+
return 0 # go2rtc reports null rather than [] when there are none
|
|
94
|
+
if isinstance(consumers, list):
|
|
95
|
+
return len(consumers)
|
|
96
|
+
return None
|
|
97
|
+
|
|
75
98
|
async def ensure_stream(self, name: str, source: str) -> bool:
|
|
76
99
|
"""Register (or replace) ``name`` -> ``source``. Returns True on 200."""
|
|
77
100
|
try:
|
{python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/camera/sdes_open.py
RENAMED
|
@@ -45,7 +45,7 @@ _SDP_ALTERNATE_PT = {96: 97, 97: 96, 0: 8, 8: 0}
|
|
|
45
45
|
# session started media at ~21s, which the previous 15s deadline missed entirely -
|
|
46
46
|
# it launched blind with BOTH payload types still unknown, which is what left the
|
|
47
47
|
# audio line advertising PCMU on a PCMA camera.
|
|
48
|
-
_FIRST_MEDIA_WAIT_S =
|
|
48
|
+
_FIRST_MEDIA_WAIT_S = 75.0
|
|
49
49
|
|
|
50
50
|
# Extra time for the audio payload type once video is known. Measured on an
|
|
51
51
|
# A001513: audio follows video by 40-70ms, because the camera answers BUNDLE and
|
|
@@ -3843,20 +3843,44 @@ class _SdesOpenMixin:
|
|
|
3843
3843
|
except Exception:
|
|
3844
3844
|
proc.kill()
|
|
3845
3845
|
_ts2 = int(time.time())
|
|
3846
|
+
# Match the PRIMARY SDP on both counts, or this restart
|
|
3847
|
+
# undoes two fixes at once.
|
|
3848
|
+
#
|
|
3849
|
+
# Transport: for _use_plain_rtp models - which is every
|
|
3850
|
+
# SDES camera this library supports - the bridge decrypts
|
|
3851
|
+
# and forwards PLAIN RTP, so the primary SDP is RTP/AVP
|
|
3852
|
+
# with no a=crypto. Writing RTP/SAVP here makes ffmpeg
|
|
3853
|
+
# try to authenticate already-decrypted packets: every one
|
|
3854
|
+
# fails its HMAC check and a working stream drops to zero
|
|
3855
|
+
# bytes mid-session.
|
|
3856
|
+
#
|
|
3857
|
+
# Payload types: hard-coding "0 8" and "96 97" throws away
|
|
3858
|
+
# the narrowing, and ffmpeg binds the FIRST type on each
|
|
3859
|
+
# line - so an H.265 camera silently loses all video (and
|
|
3860
|
+
# with it the PAT/PMT, hence the whole output), and a PCMA
|
|
3861
|
+
# camera loses its audio. Both types are known by now.
|
|
3862
|
+
_proto = "RTP/AVP" if _use_plain_rtp else "RTP/SAVP"
|
|
3863
|
+
|
|
3864
|
+
def _crypto(key: str) -> str:
|
|
3865
|
+
if _use_plain_rtp:
|
|
3866
|
+
return ""
|
|
3867
|
+
return (f"a=crypto:1 AES_CM_128_HMAC_SHA1_80 "
|
|
3868
|
+
f"inline:{key}\r\n")
|
|
3869
|
+
|
|
3846
3870
|
_new_sdp = (
|
|
3847
3871
|
"v=0\r\n"
|
|
3848
3872
|
f"o=- {_ts2} {_ts2} IN IP4 0.0.0.0\r\n"
|
|
3849
3873
|
"s=aidot-sdes-rx\r\n"
|
|
3850
3874
|
"t=0 0\r\n"
|
|
3851
|
-
f"m=audio {_lo_audio_port}
|
|
3875
|
+
f"m=audio {_lo_audio_port} {_proto} 0 8\r\n"
|
|
3852
3876
|
"c=IN IP4 127.0.0.1\r\n"
|
|
3853
|
-
f"
|
|
3877
|
+
f"{_crypto(srtp_key_audio)}"
|
|
3854
3878
|
"a=rtpmap:0 PCMU/8000\r\n"
|
|
3855
3879
|
"a=rtpmap:8 PCMA/8000\r\n"
|
|
3856
3880
|
"a=rtcp-mux\r\n"
|
|
3857
|
-
f"m=video {_lo_video_port}
|
|
3881
|
+
f"m=video {_lo_video_port} {_proto} 96 97\r\n"
|
|
3858
3882
|
"c=IN IP4 127.0.0.1\r\n"
|
|
3859
|
-
f"
|
|
3883
|
+
f"{_crypto(srtp_key_video)}"
|
|
3860
3884
|
"a=rtpmap:96 H264/90000\r\n"
|
|
3861
3885
|
"a=fmtp:96 level-asymmetry-allowed=1;packetization-mode=1;"
|
|
3862
3886
|
"profile-level-id=42e01f\r\n"
|
|
@@ -3864,6 +3888,13 @@ class _SdesOpenMixin:
|
|
|
3864
3888
|
"a=fmtp:97 level-id=93\r\n"
|
|
3865
3889
|
"a=rtcp-mux\r\n"
|
|
3866
3890
|
)
|
|
3891
|
+
_new_sdp = narrow_sdp_payload_types(
|
|
3892
|
+
_new_sdp,
|
|
3893
|
+
keep_video=(_first_video_pt[0]
|
|
3894
|
+
if _first_video_pt[0] in (96, 97) else None),
|
|
3895
|
+
keep_audio=(_first_audio_pt[0]
|
|
3896
|
+
if _first_audio_pt[0] in (0, 8) else None),
|
|
3897
|
+
)
|
|
3867
3898
|
try:
|
|
3868
3899
|
# Re-apply the cached sprop-parameter-sets here too:
|
|
3869
3900
|
# this serve-restart rewrite is the SDP the keepalive
|
{python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/camera/webrtc_open.py
RENAMED
|
@@ -1660,6 +1660,22 @@ class _WebRTCOpenMixin:
|
|
|
1660
1660
|
_LOGGER.debug("camera %s: swallowed exception in %s", getattr(self, "device_id", "?"), '_on_remote_dc_message', exc_info=True)
|
|
1661
1661
|
|
|
1662
1662
|
track_tasks: list = []
|
|
1663
|
+
|
|
1664
|
+
def _cancel_track_tasks() -> None:
|
|
1665
|
+
"""Cancel the per-session background tasks.
|
|
1666
|
+
|
|
1667
|
+
Only ``WebRTCSession.stop()`` cancels these, and it is reached only
|
|
1668
|
+
when a session is actually returned. Every failure exit below closes
|
|
1669
|
+
the peer connection and re-raises, which leaves them running - and the
|
|
1670
|
+
AVIO heartbeat loop has no terminal condition (its senders swallow
|
|
1671
|
+
every error), so a camera that fails to open repeatedly accumulates
|
|
1672
|
+
one immortal 10s-tick task per attempt, each pinning the closed
|
|
1673
|
+
RTCPeerConnection graph in memory.
|
|
1674
|
+
"""
|
|
1675
|
+
for _t in track_tasks:
|
|
1676
|
+
if not _t.done():
|
|
1677
|
+
_t.cancel()
|
|
1678
|
+
track_tasks.clear()
|
|
1663
1679
|
_kvs_dc = None
|
|
1664
1680
|
if self._offer_should_include_datachannel:
|
|
1665
1681
|
# Match the official client's label exactly: f0.java:2923 uses
|
|
@@ -2346,10 +2362,12 @@ class _WebRTCOpenMixin:
|
|
|
2346
2362
|
_code, _desc = terminal_error_fut.result()
|
|
2347
2363
|
_status(f"camera refused: ack {_code} {_desc} - terminal, not retrying")
|
|
2348
2364
|
outgoing_q.put_nowait(None)
|
|
2365
|
+
_cancel_track_tasks()
|
|
2349
2366
|
await pc.close()
|
|
2350
2367
|
raise AidotCameraBusy(_code, _desc)
|
|
2351
2368
|
_status(f"no webrtcResp in {timeout}s")
|
|
2352
2369
|
outgoing_q.put_nowait(None)
|
|
2370
|
+
_cancel_track_tasks()
|
|
2353
2371
|
await pc.close()
|
|
2354
2372
|
raise RuntimeError(
|
|
2355
2373
|
f"async_open_webrtc_stream: no webrtcResp received within {timeout}s"
|
|
@@ -2556,6 +2574,7 @@ class _WebRTCOpenMixin:
|
|
|
2556
2574
|
f" {_rr_srd_exc}"
|
|
2557
2575
|
)
|
|
2558
2576
|
outgoing_q.put_nowait(None)
|
|
2577
|
+
_cancel_track_tasks()
|
|
2559
2578
|
await pc.close()
|
|
2560
2579
|
raise RuntimeError(
|
|
2561
2580
|
f"async_open_webrtc_stream: setRemoteDescription failed:"
|
|
@@ -3205,6 +3224,7 @@ class _WebRTCOpenMixin:
|
|
|
3205
3224
|
# co-fail into the full 15s-gated generic retry.
|
|
3206
3225
|
outgoing_q.put_nowait(None) # stop MQTT thread (avoid orphan)
|
|
3207
3226
|
try:
|
|
3227
|
+
_cancel_track_tasks()
|
|
3208
3228
|
await pc.close()
|
|
3209
3229
|
except Exception:
|
|
3210
3230
|
pass
|
|
@@ -3347,6 +3367,7 @@ class _WebRTCOpenMixin:
|
|
|
3347
3367
|
)
|
|
3348
3368
|
_status(f"setRemoteDescription failed: {exc}")
|
|
3349
3369
|
outgoing_q.put_nowait(None)
|
|
3370
|
+
_cancel_track_tasks()
|
|
3350
3371
|
await pc.close()
|
|
3351
3372
|
raise RuntimeError(
|
|
3352
3373
|
f"async_open_webrtc_stream: setRemoteDescription failed: {exc}"
|
|
@@ -3569,6 +3590,7 @@ class _WebRTCOpenMixin:
|
|
|
3569
3590
|
|
|
3570
3591
|
if pc.connectionState not in ("connected", "completed"):
|
|
3571
3592
|
outgoing_q.put_nowait(None)
|
|
3593
|
+
_cancel_track_tasks()
|
|
3572
3594
|
await pc.close()
|
|
3573
3595
|
raise RuntimeError(
|
|
3574
3596
|
f"async_open_webrtc_stream: ICE connection not established "
|
|
@@ -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
|
+
version = "0.12.10"
|
|
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"
|
{python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10/python_aidot_cameras.egg-info}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-aidot-cameras
|
|
3
|
-
Version: 0.12.
|
|
3
|
+
Version: 0.12.10
|
|
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
|
|
@@ -84,7 +84,9 @@ tests/test_go2rtc_cli.py
|
|
|
84
84
|
tests/test_h264_decode_rate_limit.py
|
|
85
85
|
tests/test_highport_nomination.py
|
|
86
86
|
tests/test_ice_config_cache.py
|
|
87
|
+
tests/test_idle_release_both_loops.py
|
|
87
88
|
tests/test_keepalive_renew.py
|
|
89
|
+
tests/test_key_restart_sdp.py
|
|
88
90
|
tests/test_keyframe_prompter.py
|
|
89
91
|
tests/test_lan_control.py
|
|
90
92
|
tests/test_light_active_color_mode.py
|
|
@@ -134,4 +136,5 @@ tests/test_terminal_ack.py
|
|
|
134
136
|
tests/test_token_file_robustness.py
|
|
135
137
|
tests/test_token_refresh.py
|
|
136
138
|
tests/test_upstream_compat.py
|
|
139
|
+
tests/test_viewer_presence.py
|
|
137
140
|
tests/test_wait_or_event.py
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Both serve loops must decide idleness by asking who is WATCHING.
|
|
2
|
+
|
|
3
|
+
0.12.9 fixed this for the SDES loop and left the DTLS loop on its old signal -
|
|
4
|
+
pipe-progress staleness - which is the same unanswerable question in disguise: the
|
|
5
|
+
pipe only backs up when nothing drains the serve socket, and go2rtc drains it
|
|
6
|
+
forever as the stream's producer. On a fleet that is mostly DTLS cameras (this one
|
|
7
|
+
is 4 of 5) the fix therefore did nothing.
|
|
8
|
+
"""
|
|
9
|
+
import inspect
|
|
10
|
+
|
|
11
|
+
import aidot_cameras.camera.client as cc
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _src(name):
|
|
15
|
+
return inspect.getsource(getattr(cc.CameraMixin, name))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_sdes_loop_asks_who_is_watching():
|
|
19
|
+
assert "_viewer_present" in _src("_sdes_keepalive_loop_inner")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_dtls_loop_asks_who_is_watching():
|
|
23
|
+
# The regression: this loop used only `_now - progress[0] > idle_secs`.
|
|
24
|
+
assert "_viewer_present" in _src("_dtls_serve_loop_inner")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_dtls_loop_still_has_a_fallback_when_nobody_can_answer():
|
|
28
|
+
# If go2rtc cannot be reached the old staleness heuristic must remain, so an
|
|
29
|
+
# unreachable go2rtc does not mean "hold every stream open forever".
|
|
30
|
+
src = _src("_dtls_serve_loop_inner")
|
|
31
|
+
assert "progress[0] > idle_secs" in src
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_the_stream_slot_is_released_even_if_the_relay_fails_to_start():
|
|
35
|
+
# _maybe_start_serve_relay catches OSError, but Thread.start() raises
|
|
36
|
+
# RuntimeError under thread exhaustion. Starting it outside the try meant the
|
|
37
|
+
# permit was lost for the life of the process and the cap silently shrank.
|
|
38
|
+
src = _src("_dtls_serve_loop")
|
|
39
|
+
acquire = src.index("slots.acquire()")
|
|
40
|
+
try_at = src.index("try:", acquire)
|
|
41
|
+
relay_at = src.index("_maybe_start_serve_relay", acquire)
|
|
42
|
+
assert try_at < relay_at, "relay start must be inside the try that releases"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_teardown_does_not_join_a_thread_that_never_started():
|
|
46
|
+
# join() on an unstarted thread raises and would skip the ffmpeg terminate
|
|
47
|
+
# and the session stop that follow it.
|
|
48
|
+
assert "mux_thread.is_alive()" in _src("_dtls_serve_loop_inner")
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""The SRTP key-restart must rewrite the SDP the same way the primary path does.
|
|
2
|
+
|
|
3
|
+
It rebuilds the ffmpeg SDP from scratch and relaunches. Two things it got wrong,
|
|
4
|
+
each undoing a shipped fix:
|
|
5
|
+
|
|
6
|
+
- Transport. Every SDES model this library supports is in `_use_plain_rtp`: the
|
|
7
|
+
bridge decrypts and forwards PLAIN RTP, so the primary SDP is RTP/AVP with no
|
|
8
|
+
a=crypto. Writing RTP/SAVP makes ffmpeg authenticate already-decrypted packets,
|
|
9
|
+
every HMAC check fails, and a working stream drops to zero bytes mid-session.
|
|
10
|
+
- Payload types. Hard-coding "0 8" and "96 97" discards the narrowing, and ffmpeg
|
|
11
|
+
binds the FIRST type per line - so an H.265 camera loses all video, and with it
|
|
12
|
+
the PAT/PMT and therefore the entire output.
|
|
13
|
+
"""
|
|
14
|
+
import re
|
|
15
|
+
|
|
16
|
+
import aidot_cameras.camera.sdes_open as so
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _restart_block() -> str:
|
|
20
|
+
src = so.__loader__.get_source("aidot_cameras.camera.sdes_open")
|
|
21
|
+
i = src.index("_new_sdp = (")
|
|
22
|
+
return src[i:i + 3000]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_restart_respects_plain_rtp_transport():
|
|
26
|
+
block = _restart_block()
|
|
27
|
+
assert "_proto" in block, "transport must follow _use_plain_rtp, not be fixed"
|
|
28
|
+
assert 'f"m=audio {_lo_audio_port} {_proto}' in block
|
|
29
|
+
assert 'f"m=video {_lo_video_port} {_proto}' in block
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_restart_omits_crypto_for_plain_rtp():
|
|
33
|
+
block = _restart_block()
|
|
34
|
+
assert "_crypto(" in block, "a=crypto must be conditional on the transport"
|
|
35
|
+
assert "a=crypto:1 AES_CM_128_HMAC_SHA1_80 inline:{srtp_key_audio}" not in block
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_restart_narrows_the_payload_types():
|
|
39
|
+
block = _restart_block()
|
|
40
|
+
assert "narrow_sdp_payload_types(" in block
|
|
41
|
+
assert "keep_video=" in block and "keep_audio=" in block
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_first_media_wait_covers_the_documented_cold_start():
|
|
45
|
+
# The repo documents a 25-70s cold start for battery cameras in several
|
|
46
|
+
# places. A 45s deadline sat inside that range, so a camera at the slow end
|
|
47
|
+
# launched with no payload types known - the exact failure the wait exists to
|
|
48
|
+
# prevent.
|
|
49
|
+
assert so._FIRST_MEDIA_WAIT_S >= 70.0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_narrowing_still_produces_a_single_payload_type_per_line():
|
|
53
|
+
sdp = (
|
|
54
|
+
"m=audio 1000 RTP/AVP 0 8\r\n"
|
|
55
|
+
"a=rtpmap:0 PCMU/8000\r\na=rtpmap:8 PCMA/8000\r\n"
|
|
56
|
+
"m=video 1002 RTP/AVP 96 97\r\n"
|
|
57
|
+
"a=rtpmap:96 H264/90000\r\na=rtpmap:97 H265/90000\r\n"
|
|
58
|
+
)
|
|
59
|
+
out = so.narrow_sdp_payload_types(sdp, keep_video=97, keep_audio=8)
|
|
60
|
+
assert re.search(r"m=audio 1000 RTP/AVP 8\r\n", out)
|
|
61
|
+
assert re.search(r"m=video 1002 RTP/AVP 97\r\n", out)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Idle-release must key on real viewers, not on go2rtc being attached.
|
|
2
|
+
|
|
3
|
+
go2rtc attaches to the camera serve as its PRODUCER and stays attached for as long
|
|
4
|
+
as the stream is registered - viewer or no viewer. So the serve socket always has
|
|
5
|
+
an ESTABLISHED peer, the idle window never elapses, and every camera keeps
|
|
6
|
+
streaming forever after a single view. Confirmed live: five cameras, nobody
|
|
7
|
+
watching, still producing after 7 minutes against a 5 minute idle window.
|
|
8
|
+
"""
|
|
9
|
+
import asyncio
|
|
10
|
+
|
|
11
|
+
from aidot_cameras.camera.go2rtc import Go2rtcClient
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class _Resp:
|
|
15
|
+
def __init__(self, payload, status=200):
|
|
16
|
+
self._p, self.status = payload, status
|
|
17
|
+
|
|
18
|
+
async def __aenter__(self):
|
|
19
|
+
return self
|
|
20
|
+
|
|
21
|
+
async def __aexit__(self, *a):
|
|
22
|
+
return False
|
|
23
|
+
|
|
24
|
+
async def json(self):
|
|
25
|
+
return self._p
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _Session:
|
|
29
|
+
def __init__(self, payload, status=200):
|
|
30
|
+
self._p, self._status = payload, status
|
|
31
|
+
|
|
32
|
+
def get(self, *a, **kw):
|
|
33
|
+
return _Resp(self._p, self._status)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _count(payload, name="aidot_abc", status=200):
|
|
37
|
+
client = Go2rtcClient(_Session(payload, status), "http://go2rtc:1984")
|
|
38
|
+
return asyncio.run(client.viewer_count(name))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_no_viewers_when_consumers_is_empty():
|
|
42
|
+
# A producer attached with no consumers is exactly the idle case that was
|
|
43
|
+
# being misread as "someone is watching".
|
|
44
|
+
assert _count({"aidot_abc": {"producers": [{"url": "x"}], "consumers": []}}) == 0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_no_viewers_when_go2rtc_reports_null():
|
|
48
|
+
# go2rtc returns null rather than [] for some streams; that is still nobody.
|
|
49
|
+
assert _count({"aidot_abc": {"producers": [{"url": "x"}], "consumers": None}}) == 0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_counts_real_viewers():
|
|
53
|
+
assert _count({"aidot_abc": {"consumers": [{"id": 1}, {"id": 2}]}}) == 2
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_unknown_when_the_stream_is_absent():
|
|
57
|
+
# Unknown must stay unknown so the caller fails safe and does NOT release.
|
|
58
|
+
assert _count({"something_else": {"consumers": []}}) is None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_unknown_when_go2rtc_is_unreachable():
|
|
62
|
+
assert _count({}, status=500) is None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_release_policy_treats_unknown_as_do_not_release():
|
|
66
|
+
from aidot_cameras.camera.protocol import _idle_release_due
|
|
67
|
+
now = 1000.0
|
|
68
|
+
assert _idle_release_due(None, now - 9999, now, 300) is False # unknown
|
|
69
|
+
assert _idle_release_due(True, now - 9999, now, 300) is False # watching
|
|
70
|
+
assert _idle_release_due(False, now - 9999, now, 300) is True # idle, elapsed
|
|
71
|
+
assert _idle_release_due(False, now - 10, now, 300) is False # idle, too soon
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/clock.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/events.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/rate.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/rtp.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/sdp.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/stats.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/_vendor/aiortc/utils.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/camera/__init__.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/camera/constants.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/camera/controls.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/camera/lan_control.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/camera/playback.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/aidot_cameras/camera/protocol.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_deferred_hardening.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_device_login_guard.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_device_user_info_cache.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_dtls_not_ready_burst.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_dtls_serve_decode_skip.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_dtls_serve_open_timeout.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_dtls_skip_signaling_wait.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_h264_decode_rate_limit.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_highport_nomination.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_keyframe_prompter.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_light_active_color_mode.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_live_stream_param.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_mqtt_credential_refetch.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_mqtt_credential_self_heal.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_mqtt_session_reconnect.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_no_undefined_names.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_offline_keepalive_pause.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_post_merge_hardening.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_sdes_bridge_break_gate.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_sdes_echo_wait_timeout.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_sdes_fast_liveplay.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_sdes_ffmpeg_stderr_log.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_sdes_idle_release.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_sdes_open_cleanup.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_sdes_teardown_exit_log.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_security_hardening.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_serializable_login_info.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_stream_drain_ownership.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.12.8 → python_aidot_cameras-0.12.10}/tests/test_token_file_robustness.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|