python-aidot-cameras 0.11.11__tar.gz → 0.11.12__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.12}/PKG-INFO +1 -1
- python_aidot_cameras-0.11.12/aidot/__init__.py +137 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/camera/client.py +157 -5
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/camera/protocol.py +7 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/camera/sdes.py +18 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/camera/sdes_open.py +63 -2
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/pyproject.toml +1 -1
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/python_aidot_cameras.egg-info/SOURCES.txt +4 -0
- python_aidot_cameras-0.11.12/tests/test_dtls_serve_decode_skip.py +181 -0
- python_aidot_cameras-0.11.12/tests/test_dtls_slow_probe.py +163 -0
- python_aidot_cameras-0.11.12/tests/test_h264_decode_rate_limit.py +141 -0
- python_aidot_cameras-0.11.12/tests/test_logging_caps.py +108 -0
- python_aidot_cameras-0.11.12/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.12}/LICENSE +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/MANIFEST.in +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/README.md +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/__main__.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/__init__.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/__init__.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/clock.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/codecs/__init__.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/codecs/base.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/codecs/g711.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/codecs/g722.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/codecs/h264.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/codecs/opus.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/codecs/vpx.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/contrib/__init__.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/contrib/media.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/contrib/signaling.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/events.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/exceptions.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/jitterbuffer.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/mediastreams.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/rate.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/rtcconfiguration.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/rtcdatachannel.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/rtcdtlstransport.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/rtcicetransport.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/rtcpeerconnection.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/rtcrtpparameters.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/rtcrtpreceiver.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/rtcrtpsender.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/rtcrtptransceiver.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/rtcsctptransport.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/rtcsessiondescription.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/rtp.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/sdp.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/stats.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/_vendor/aiortc/utils.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/aes_utils.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/camera/__init__.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/camera/constants.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/camera/controls.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/camera/go2rtc.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/camera/lan_control.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/camera/models.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/camera/playback.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/camera/tutk.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/camera/webrtc.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/camera/webrtc_open.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/client.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/const.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/credentials.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/device_client.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/discover.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/exceptions.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/g711.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/login_const.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/models/__init__.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/models/device_client_model.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/models/device_model.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/models/discover_model.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/aidot/py.typed +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/python_aidot_cameras.egg-info/requires.txt +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/python_aidot_cameras.egg-info/top_level.txt +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/setup.cfg +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_aioice_compat.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_alarm_event.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_backoff.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_deferred_hardening.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_device_login_guard.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_device_user_info_cache.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_dtls_not_ready_burst.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_dtls_pinning.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_dtls_skip_signaling_wait.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_egress_guard.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_go2rtc.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_go2rtc_cli.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_highport_nomination.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_ice_config_cache.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_keepalive_renew.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_keyframe_prompter.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_lan_control.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_live_stream_param.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_motion_poll.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_mqtt_session_reconnect.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_narrow_pc_ice.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_no_undefined_names.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_offline_keepalive_pause.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_open_fail_logger.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_open_gate_delay.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_persistent_mqtt.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_persistent_mqtt_robustness.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_playback_tls.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_post_merge_hardening.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_retry_policy.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_sdes_adaptive.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_sdes_echo_wait_timeout.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_sdes_fast_liveplay.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_sdes_ffmpeg_stderr_log.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_sdes_idle_release.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_sdes_open_cleanup.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_sdes_serve_audio.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_sdes_serve_cmd.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_sdes_sprop.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_sdes_talk.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_sdes_watchdog.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_security_hardening.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_serializable_login_info.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_serve_relay.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_session_stats.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_speak.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_stream_cap.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_stream_drain_ownership.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_stream_idle.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_stream_teardown.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_talk.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_terminal_ack.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_token_file_robustness.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_token_refresh.py +0 -0
- {python_aidot_cameras-0.11.11 → python_aidot_cameras-0.11.12}/tests/test_wait_or_event.py +0 -0
{python_aidot_cameras-0.11.11/python_aidot_cameras.egg-info → python_aidot_cameras-0.11.12}/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.12
|
|
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,26 @@ _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
|
+
|
|
100
120
|
# Strong refs to fire-and-forget tasks: asyncio only keeps weak refs, so a
|
|
101
121
|
# discarded task can be garbage-collected mid-flight. Discarded on completion.
|
|
102
122
|
_BG_TASKS: set = set()
|
|
@@ -164,6 +184,39 @@ def _open_gate_delay(last_open_at: float, now: float, open_gate: float) -> float
|
|
|
164
184
|
return remaining if remaining > 0 else 0.0
|
|
165
185
|
|
|
166
186
|
|
|
187
|
+
def _in_slow_probe(attempt: int, threshold: int) -> bool:
|
|
188
|
+
"""True once `attempt` consecutive DTLS open failures have reached
|
|
189
|
+
`threshold`: the serve loop should stop hammering an idle-but-cloud-online
|
|
190
|
+
camera at the pacer's normal cadence and fall back to a slow probe."""
|
|
191
|
+
return attempt >= threshold
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _probe_interval(attempt: int, threshold: int, normal_delay: float,
|
|
195
|
+
slow_interval: float) -> float:
|
|
196
|
+
"""Effective DTLS-open retry interval for a failed open.
|
|
197
|
+
|
|
198
|
+
Below `threshold` consecutive failures this is just the pacer's own
|
|
199
|
+
`normal_delay` (unchanged jittered-backoff behaviour). At/after `threshold`
|
|
200
|
+
it widens to (at least) `slow_interval` - well beyond the pacer's own cap -
|
|
201
|
+
so a camera that is cloud-online but never actually opens is probed
|
|
202
|
+
roughly every `slow_interval` seconds instead of retried forever at the
|
|
203
|
+
pacer's max cadence."""
|
|
204
|
+
if _in_slow_probe(attempt, threshold):
|
|
205
|
+
return max(normal_delay, slow_interval)
|
|
206
|
+
return normal_delay
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _should_log_slow_probe(attempt: int, threshold: int, log_every: int) -> bool:
|
|
210
|
+
"""True when a slow-probe open failure should be logged: the moment
|
|
211
|
+
slow-probe is entered, then every `log_every`th attempt after - so a
|
|
212
|
+
camera stuck for hours doesn't drip a log line on every single probe."""
|
|
213
|
+
if not _in_slow_probe(attempt, threshold):
|
|
214
|
+
return False
|
|
215
|
+
if log_every <= 0:
|
|
216
|
+
return True
|
|
217
|
+
return (attempt - threshold) % log_every == 0
|
|
218
|
+
|
|
219
|
+
|
|
167
220
|
_FFMPEG_MISSING_MSG = (
|
|
168
221
|
"ffmpeg not found on PATH; install ffmpeg to enable recording/transcoding.\n"
|
|
169
222
|
" Ubuntu/Debian: sudo apt install ffmpeg\n"
|
|
@@ -845,6 +898,23 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
845
898
|
self.device_id,
|
|
846
899
|
)
|
|
847
900
|
|
|
901
|
+
async def _slow_probe_sleep(self, interval: float) -> None:
|
|
902
|
+
"""Sleep `interval` seconds for the DTLS serve loop's slow-probe wait,
|
|
903
|
+
in `_SLOW_PROBE_SLEEP_CHUNK_S` increments rather than one blocking
|
|
904
|
+
`asyncio.sleep(interval)` call, re-checking `_streaming_active` each
|
|
905
|
+
increment. `interval` can be several minutes; without chunking,
|
|
906
|
+
`stop()` would have to wait out the whole thing before the loop next
|
|
907
|
+
checks whether it should keep running.
|
|
908
|
+
"""
|
|
909
|
+
_t0 = time.monotonic()
|
|
910
|
+
while True:
|
|
911
|
+
if not getattr(self, "_streaming_active", False):
|
|
912
|
+
return
|
|
913
|
+
_remaining = interval - (time.monotonic() - _t0)
|
|
914
|
+
if _remaining <= 0:
|
|
915
|
+
return
|
|
916
|
+
await asyncio.sleep(min(_SLOW_PROBE_SLEEP_CHUNK_S, _remaining))
|
|
917
|
+
|
|
848
918
|
# -- Camera helpers ------------------------------------------------------ #
|
|
849
919
|
|
|
850
920
|
@property
|
|
@@ -2976,11 +3046,27 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
2976
3046
|
_LOGGER.debug("PLI request failed: %s", _pli_exc)
|
|
2977
3047
|
|
|
2978
3048
|
@staticmethod
|
|
2979
|
-
def _install_encoded_tap(receiver, out_q, is_video: bool) -> bool:
|
|
3049
|
+
def _install_encoded_tap(receiver, out_q, is_video: bool, serve: bool = False) -> bool:
|
|
2980
3050
|
"""Tee aiortc's depacketized encoded frames (+ RTP timestamp) into a
|
|
2981
3051
|
thread-safe queue before decode. Video frames go as
|
|
2982
3052
|
``(bytes, timestamp, is_keyframe)``, audio as ``(bytes, timestamp)``.
|
|
2983
|
-
``out_q`` is a ``queue.Queue`` (the mux runs in a worker thread).
|
|
3053
|
+
``out_q`` is a ``queue.Queue`` (the mux runs in a worker thread).
|
|
3054
|
+
|
|
3055
|
+
When ``serve`` is set, the served H.264 is muxed as ``-c copy`` straight
|
|
3056
|
+
from this tap, so the vendored decoder's output is discarded (the serve
|
|
3057
|
+
loop uses a no-op on_frame). In that mode we STOP feeding the decoder
|
|
3058
|
+
queue for video DATA frames: decoding them is pure wasted CPU and the
|
|
3059
|
+
sole source of the per-packet decode-failure warning flood. The None
|
|
3060
|
+
terminator is STILL forwarded so the decoder thread exits cleanly, and
|
|
3061
|
+
audio is left untouched (drained separately). We must not instead pass
|
|
3062
|
+
``on_frame=None`` at the receiver level - the decoder thread would keep
|
|
3063
|
+
running and fill the unbounded track queue with nothing draining it.
|
|
3064
|
+
|
|
3065
|
+
Because decode was the only signal that the served H.264 was corrupt, a
|
|
3066
|
+
cheap keyframe-based canary (no decoding) is kept: it counts frames and
|
|
3067
|
+
keyframes, tracks the longest run with no keyframe, and emits a periodic
|
|
3068
|
+
DEBUG summary. It is O(1) per frame and stays at DEBUG so a corrupt
|
|
3069
|
+
camera can't recreate the very flood this removes."""
|
|
2984
3070
|
_qd = getattr(receiver, "_RTCRtpReceiver__decoder_queue", None)
|
|
2985
3071
|
if _qd is None:
|
|
2986
3072
|
return False
|
|
@@ -2990,6 +3076,12 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
2990
3076
|
if getattr(_qd, "_aidot_tapped", False):
|
|
2991
3077
|
return True
|
|
2992
3078
|
_orig_put = _qd.put
|
|
3079
|
+
_skip_decode = bool(serve and is_video)
|
|
3080
|
+
# Served-stream health canary (video-only): observed without decoding.
|
|
3081
|
+
_canary = {"frames": 0, "keyframes": 0, "gap": 0, "max_gap": 0} if _skip_decode else None
|
|
3082
|
+
if _canary is not None:
|
|
3083
|
+
_qd._aidot_serve_canary = _canary
|
|
3084
|
+
_CANARY_LOG_EVERY = 300 # frames (~10-20s of H.264); DEBUG summary cadence
|
|
2993
3085
|
|
|
2994
3086
|
def _tap_put(task, *a, **k):
|
|
2995
3087
|
try:
|
|
@@ -2998,12 +3090,34 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
2998
3090
|
_d = getattr(_enc, "data", None)
|
|
2999
3091
|
_ts = getattr(_enc, "timestamp", None)
|
|
3000
3092
|
if _d and _ts is not None:
|
|
3001
|
-
|
|
3002
|
-
|
|
3093
|
+
_b = bytes(_d)
|
|
3094
|
+
_kf = _h264_has_keyframe(_b) if is_video else False
|
|
3095
|
+
_item = (_b, int(_ts), _kf) if is_video else (_b, int(_ts))
|
|
3003
3096
|
try:
|
|
3004
3097
|
out_q.put_nowait(_item)
|
|
3005
3098
|
except Exception:
|
|
3006
3099
|
pass # full -> drop (PLI re-arms a GOP)
|
|
3100
|
+
if _canary is not None:
|
|
3101
|
+
_canary["frames"] += 1
|
|
3102
|
+
if _kf:
|
|
3103
|
+
_canary["keyframes"] += 1
|
|
3104
|
+
_canary["gap"] = 0
|
|
3105
|
+
else:
|
|
3106
|
+
_canary["gap"] += 1
|
|
3107
|
+
if _canary["gap"] > _canary["max_gap"]:
|
|
3108
|
+
_canary["max_gap"] = _canary["gap"]
|
|
3109
|
+
if _canary["frames"] % _CANARY_LOG_EVERY == 0:
|
|
3110
|
+
_LOGGER.debug(
|
|
3111
|
+
"serve h264 canary: frames=%d keyframes=%d"
|
|
3112
|
+
" max_keyframe_gap=%d cur_gap=%d",
|
|
3113
|
+
_canary["frames"], _canary["keyframes"],
|
|
3114
|
+
_canary["max_gap"], _canary["gap"],
|
|
3115
|
+
)
|
|
3116
|
+
if _skip_decode:
|
|
3117
|
+
# Discarded decode: don't feed the decoder for served
|
|
3118
|
+
# video data frames (see docstring). Terminator (task
|
|
3119
|
+
# is None) still falls through to _orig_put below.
|
|
3120
|
+
return None
|
|
3007
3121
|
except Exception:
|
|
3008
3122
|
_LOGGER.debug("swallowed exception in %s", '_tap_put', exc_info=True)
|
|
3009
3123
|
return _orig_put(task, *a, **k)
|
|
@@ -3017,6 +3131,12 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
3017
3131
|
|
|
3018
3132
|
Called repeatedly until the video tap lands; _install_encoded_tap is
|
|
3019
3133
|
idempotent per receiver, so audio isn't re-wrapped across retries.
|
|
3134
|
+
|
|
3135
|
+
This runs only from the DTLS serve loop, whose on_frame is a no-op and
|
|
3136
|
+
whose output is muxed as H.264 copy from the video tap - so the video
|
|
3137
|
+
tap is installed with serve=True to skip the discarded decode (see
|
|
3138
|
+
_install_encoded_tap). Audio keeps feeding its decoder (drained
|
|
3139
|
+
separately).
|
|
3020
3140
|
"""
|
|
3021
3141
|
got_v = False
|
|
3022
3142
|
got_a = False
|
|
@@ -3025,7 +3145,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
3025
3145
|
if _tr is None:
|
|
3026
3146
|
continue
|
|
3027
3147
|
if _tr.kind == "video" and not got_v:
|
|
3028
|
-
got_v = self._install_encoded_tap(_r, vq, True)
|
|
3148
|
+
got_v = self._install_encoded_tap(_r, vq, True, serve=True)
|
|
3029
3149
|
elif _tr.kind == "audio" and not got_a:
|
|
3030
3150
|
got_a = self._install_encoded_tap(_r, aq, False)
|
|
3031
3151
|
return got_v
|
|
@@ -3419,6 +3539,35 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
3419
3539
|
continue
|
|
3420
3540
|
except Exception as exc:
|
|
3421
3541
|
_delay = _pacer.fail_delay()
|
|
3542
|
+
_attempt = _pacer.attempt
|
|
3543
|
+
_cloud_offline = (
|
|
3544
|
+
(not self.status.online)
|
|
3545
|
+
and getattr(self, "_cloud_online_explicit", False)
|
|
3546
|
+
)
|
|
3547
|
+
# Slow-probe throttle: an idle-but-cloud-online camera never
|
|
3548
|
+
# sets _cloud_offline (the cloud keeps reporting it reachable),
|
|
3549
|
+
# so without this it would retry forever at the pacer's cap.
|
|
3550
|
+
# Keyed purely on the pacer's consecutive-failure count, not
|
|
3551
|
+
# the offline flag - a genuinely offline camera already gets a
|
|
3552
|
+
# comparable slow cadence (with early-resume) from
|
|
3553
|
+
# _backoff_or_offline_pause below, so this only engages the
|
|
3554
|
+
# part of the space that pause doesn't already cover.
|
|
3555
|
+
if _in_slow_probe(_attempt, _SLOW_PROBE_THRESHOLD) and not _cloud_offline:
|
|
3556
|
+
_delay = _probe_interval(
|
|
3557
|
+
_attempt, _SLOW_PROBE_THRESHOLD, _delay, _SLOW_PROBE_INTERVAL_S)
|
|
3558
|
+
if _should_log_slow_probe(
|
|
3559
|
+
_attempt, _SLOW_PROBE_THRESHOLD, _SLOW_PROBE_LOG_EVERY
|
|
3560
|
+
):
|
|
3561
|
+
_LOGGER.info(
|
|
3562
|
+
"DTLS serve: %s still failing to open after %d"
|
|
3563
|
+
" attempts - slow-probing every %.0fs (last error: %s)",
|
|
3564
|
+
self.device_id, _attempt, _delay, exc,
|
|
3565
|
+
)
|
|
3566
|
+
try:
|
|
3567
|
+
await self._slow_probe_sleep(_delay)
|
|
3568
|
+
except asyncio.CancelledError:
|
|
3569
|
+
return
|
|
3570
|
+
continue
|
|
3422
3571
|
self._open_fail_logger()(
|
|
3423
3572
|
"DTLS serve: open failed for %s (retry %.0fs): %s",
|
|
3424
3573
|
self.device_id, _delay, exc,
|
|
@@ -3429,6 +3578,9 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
3429
3578
|
return
|
|
3430
3579
|
continue
|
|
3431
3580
|
|
|
3581
|
+
# Resetting the pacer's attempt count also clears slow-probe state:
|
|
3582
|
+
# it derives purely from _pacer.attempt, so _in_slow_probe reads
|
|
3583
|
+
# false again on the very next failure sequence.
|
|
3432
3584
|
_pacer.reset()
|
|
3433
3585
|
_not_ready_burst = 0 # clean open: reset the encoder-cold burst
|
|
3434
3586
|
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,23 @@ 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
|
+
|
|
49
66
|
class _SdesOpenMixin:
|
|
50
67
|
async def _open_sdes_stream(self, **kwargs) -> "SdesSession":
|
|
51
68
|
"""Allocate-and-hand-off wrapper around _open_sdes_stream_impl.
|
|
@@ -167,6 +184,16 @@ class _SdesOpenMixin:
|
|
|
167
184
|
_cleanup.callback(_run)
|
|
168
185
|
|
|
169
186
|
def _reap(p):
|
|
187
|
+
# This is a locally-initiated kill (cold open cancelled/raised
|
|
188
|
+
# before hand-off) - flag it so the bridge observe loop treats the
|
|
189
|
+
# resulting signal death as expected, not a WARNING-worthy crash.
|
|
190
|
+
# Set first (before the kill/reap below), matching the other
|
|
191
|
+
# kill sites, so the bridge thread can never observe the exit
|
|
192
|
+
# code with the flag still False.
|
|
193
|
+
try:
|
|
194
|
+
_teardown_holder[0] = True
|
|
195
|
+
except Exception:
|
|
196
|
+
pass
|
|
170
197
|
try:
|
|
171
198
|
p.kill()
|
|
172
199
|
except Exception:
|
|
@@ -2084,6 +2111,14 @@ class _SdesOpenMixin:
|
|
|
2084
2111
|
# Proc holder: set to the ffmpeg Popen object after launch so the
|
|
2085
2112
|
# bridge thread can poll for exit without a NameError race.
|
|
2086
2113
|
_proc_holder: list = [None]
|
|
2114
|
+
# Teardown holder: [0] flips True the moment ANY locally-initiated
|
|
2115
|
+
# ffmpeg-kill path fires (_reap() above on a cancelled cold open, the
|
|
2116
|
+
# key-restart proc replace, or the DTLS-fallback abort below - plus
|
|
2117
|
+
# SdesSession.stop() once the session is handed off, via the
|
|
2118
|
+
# teardown_requested= kwarg at construction). The bridge observe loop
|
|
2119
|
+
# reads it via _classify_ffmpeg_exit() to demote an expected signal
|
|
2120
|
+
# death (our own SIGTERM/SIGKILL) from an unexpected ffmpeg crash.
|
|
2121
|
+
_teardown_holder: list = [False]
|
|
2087
2122
|
# Camera's actual video payload type (96=H.264 / 97=H.265), set by the
|
|
2088
2123
|
# bridge on the first video RTP packet so the ffmpeg SDP can be narrowed
|
|
2089
2124
|
# to the matching single codec before ffmpeg is launched.
|
|
@@ -2127,9 +2162,18 @@ class _SdesOpenMixin:
|
|
|
2127
2162
|
if _br_rc is not None:
|
|
2128
2163
|
if _br_rc != 0:
|
|
2129
2164
|
import logging as _log_br
|
|
2130
|
-
|
|
2165
|
+
_br_level = _classify_ffmpeg_exit(
|
|
2166
|
+
_br_rc, bool(_teardown_holder[0])
|
|
2167
|
+
)
|
|
2168
|
+
_br_msg = (
|
|
2131
2169
|
"SDES bridge: ffmpeg exited with code %d"
|
|
2132
|
-
" -
|
|
2170
|
+
" - stopped by teardown"
|
|
2171
|
+
if _br_level < _log_br.WARNING else
|
|
2172
|
+
"SDES bridge: ffmpeg exited with code %d"
|
|
2173
|
+
" - stream ended"
|
|
2174
|
+
)
|
|
2175
|
+
_log_br.getLogger(__name__).log(
|
|
2176
|
+
_br_level, _br_msg, _br_rc
|
|
2133
2177
|
)
|
|
2134
2178
|
break
|
|
2135
2179
|
|
|
@@ -3634,6 +3678,13 @@ class _SdesOpenMixin:
|
|
|
3634
3678
|
srtp_key_video = _real_key_video
|
|
3635
3679
|
_keys_changed = True
|
|
3636
3680
|
if _keys_changed:
|
|
3681
|
+
# Locally-initiated kill (the offered key was wrong;
|
|
3682
|
+
# restarting ffmpeg with the camera's real key) - flag it
|
|
3683
|
+
# so the bridge observe loop does not warn on the old
|
|
3684
|
+
# proc's signal death. Reset once the NEW proc is live
|
|
3685
|
+
# (below) so a later, genuine crash of the restarted
|
|
3686
|
+
# ffmpeg is not silently swallowed as "expected teardown".
|
|
3687
|
+
_teardown_holder[0] = True
|
|
3637
3688
|
proc.terminate()
|
|
3638
3689
|
try:
|
|
3639
3690
|
proc.wait(timeout=2)
|
|
@@ -3682,6 +3733,10 @@ class _SdesOpenMixin:
|
|
|
3682
3733
|
# breaks, and closes the loopback sockets - starving the
|
|
3683
3734
|
# restarted ffmpeg (0-frame stream).
|
|
3684
3735
|
_proc_holder[0] = proc
|
|
3736
|
+
# New proc is live and owns the holder; clear the
|
|
3737
|
+
# teardown flag so this session's NEXT exit (the
|
|
3738
|
+
# restarted ffmpeg's) is classified fresh.
|
|
3739
|
+
_teardown_holder[0] = False
|
|
3685
3740
|
_status("ffmpeg restarted with camera's SRTP keys")
|
|
3686
3741
|
except TimeoutError:
|
|
3687
3742
|
if _cam_echo_received:
|
|
@@ -3698,6 +3753,11 @@ class _SdesOpenMixin:
|
|
|
3698
3753
|
f"no webrtcResp in {_sdes_answer_timeout:.0f}s"
|
|
3699
3754
|
" - SDES handshake failed; aborting ffmpeg and falling back to DTLS"
|
|
3700
3755
|
)
|
|
3756
|
+
# Locally-initiated abort (no answer; giving up on SDES for this
|
|
3757
|
+
# attempt) - flag it so the bridge observe loop does not warn on
|
|
3758
|
+
# the resulting signal death. Nothing continues to use this
|
|
3759
|
+
# proc/holder afterward, so no reset is needed here.
|
|
3760
|
+
_teardown_holder[0] = True
|
|
3701
3761
|
proc.terminate()
|
|
3702
3762
|
try:
|
|
3703
3763
|
proc.wait(timeout=2)
|
|
@@ -3784,4 +3844,5 @@ class _SdesOpenMixin:
|
|
|
3784
3844
|
cmd_chan=_cmd_chan,
|
|
3785
3845
|
talk_state=_talk_state,
|
|
3786
3846
|
media_progress=_media_progress,
|
|
3847
|
+
teardown_requested=_teardown_holder,
|
|
3787
3848
|
)
|
|
@@ -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.12"
|
|
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.12/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.12
|
|
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
|
|
@@ -79,10 +79,13 @@ tests/test_device_login_guard.py
|
|
|
79
79
|
tests/test_device_user_info_cache.py
|
|
80
80
|
tests/test_dtls_not_ready_burst.py
|
|
81
81
|
tests/test_dtls_pinning.py
|
|
82
|
+
tests/test_dtls_serve_decode_skip.py
|
|
82
83
|
tests/test_dtls_skip_signaling_wait.py
|
|
84
|
+
tests/test_dtls_slow_probe.py
|
|
83
85
|
tests/test_egress_guard.py
|
|
84
86
|
tests/test_go2rtc.py
|
|
85
87
|
tests/test_go2rtc_cli.py
|
|
88
|
+
tests/test_h264_decode_rate_limit.py
|
|
86
89
|
tests/test_highport_nomination.py
|
|
87
90
|
tests/test_ice_config_cache.py
|
|
88
91
|
tests/test_keepalive_renew.py
|
|
@@ -112,6 +115,7 @@ tests/test_sdes_serve_audio.py
|
|
|
112
115
|
tests/test_sdes_serve_cmd.py
|
|
113
116
|
tests/test_sdes_sprop.py
|
|
114
117
|
tests/test_sdes_talk.py
|
|
118
|
+
tests/test_sdes_teardown_exit_log.py
|
|
115
119
|
tests/test_sdes_watchdog.py
|
|
116
120
|
tests/test_security_hardening.py
|
|
117
121
|
tests/test_serializable_login_info.py
|