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