python-aidot-cameras 0.12.2__tar.gz → 0.12.6__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {python_aidot_cameras-0.12.2/python_aidot_cameras.egg-info → python_aidot_cameras-0.12.6}/PKG-INFO +1 -1
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/camera/client.py +143 -16
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/camera/protocol.py +52 -2
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/camera/sdes_open.py +119 -18
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/client.py +94 -9
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/const.py +13 -1
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/device_client.py +6 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/pyproject.toml +1 -1
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/python_aidot_cameras.egg-info/SOURCES.txt +3 -0
- python_aidot_cameras-0.12.6/tests/test_mqtt_credential_refetch.py +288 -0
- python_aidot_cameras-0.12.6/tests/test_mqtt_password_not_persisted.py +36 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_sdes_serve_audio.py +9 -3
- python_aidot_cameras-0.12.6/tests/test_sdp_payload_type_narrowing.py +83 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_serializable_login_info.py +17 -10
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_upstream_compat.py +13 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/LICENSE +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/MANIFEST.in +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/README.md +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/__init__.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/__main__.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/__init__.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/__init__.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/clock.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/codecs/__init__.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/codecs/base.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/codecs/g711.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/codecs/g722.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/codecs/h264.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/codecs/opus.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/codecs/vpx.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/contrib/__init__.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/contrib/media.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/contrib/signaling.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/events.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/exceptions.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/jitterbuffer.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/mediastreams.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/rate.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/rtcconfiguration.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/rtcdatachannel.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/rtcdtlstransport.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/rtcicetransport.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/rtcpeerconnection.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/rtcrtpparameters.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/rtcrtpreceiver.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/rtcrtpsender.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/rtcrtptransceiver.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/rtcsctptransport.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/rtcsessiondescription.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/rtp.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/sdp.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/stats.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/utils.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/camera/__init__.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/camera/constants.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/camera/controls.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/camera/go2rtc.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/camera/lan_control.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/camera/models.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/camera/playback.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/camera/sdes.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/camera/tutk.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/camera/webrtc.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/camera/webrtc_open.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/credentials.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/crypto.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/discover.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/exceptions.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/g711.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/py.typed +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/python_aidot_cameras.egg-info/requires.txt +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/python_aidot_cameras.egg-info/top_level.txt +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/setup.cfg +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_aioice_compat.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_alarm_event.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_backoff.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_deferred_hardening.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_device_login_guard.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_device_user_info_cache.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_dtls_not_ready_burst.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_dtls_pinning.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_dtls_serve_decode_skip.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_dtls_serve_open_timeout.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_dtls_skip_signaling_wait.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_dtls_slow_probe.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_egress_guard.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_go2rtc.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_go2rtc_cli.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_h264_decode_rate_limit.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_highport_nomination.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_ice_config_cache.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_keepalive_renew.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_keyframe_prompter.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_lan_control.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_light_active_color_mode.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_live_stream_param.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_logging_caps.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_motion_poll.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_mqtt_credential_self_heal.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_mqtt_session_reconnect.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_narrow_pc_ice.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_no_undefined_names.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_offline_keepalive_pause.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_open_fail_logger.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_open_gate_delay.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_persistent_mqtt.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_persistent_mqtt_robustness.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_playback_tls.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_post_merge_hardening.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_retry_policy.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_sdes_adaptive.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_sdes_bridge_break_gate.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_sdes_echo_wait_timeout.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_sdes_fast_liveplay.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_sdes_ffmpeg_stderr_log.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_sdes_idle_release.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_sdes_open_cleanup.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_sdes_serve_cmd.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_sdes_sprop.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_sdes_talk.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_sdes_teardown_exit_log.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_sdes_watchdog.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_security_hardening.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_serve_relay.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_session_stats.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_speak.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_stream_cap.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_stream_drain_ownership.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_stream_idle.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_stream_teardown.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_talk.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_terminal_ack.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_token_file_robustness.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_token_refresh.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_wait_or_event.py +0 -0
{python_aidot_cameras-0.12.2/python_aidot_cameras.egg-info → python_aidot_cameras-0.12.6}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-aidot-cameras
|
|
3
|
-
Version: 0.12.
|
|
3
|
+
Version: 0.12.6
|
|
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
|
|
@@ -11,6 +11,7 @@ from typing import Any, Callable, List, Optional
|
|
|
11
11
|
from ..exceptions import AidotCameraBusy, AidotCameraNotReady
|
|
12
12
|
from ..const import APP_ID as _AIDOT_APP_ID
|
|
13
13
|
from ..const import (
|
|
14
|
+
LOGIN_INFO_MQTT_PASSWORD_KEYS,
|
|
14
15
|
LOGIN_INFO_PERSISTENT_MQTT_KEY,
|
|
15
16
|
LOGIN_INFO_PERSISTENT_MQTT_LOCK_KEY,
|
|
16
17
|
)
|
|
@@ -141,6 +142,11 @@ _DTLS_SERVE_OPEN_TIMEOUT_S = _parse_env_float("AIDOT_DTLS_SERVE_OPEN_TIMEOUT_S",
|
|
|
141
142
|
# discarded task can be garbage-collected mid-flight. Discarded on completion.
|
|
142
143
|
_BG_TASKS: set = set()
|
|
143
144
|
|
|
145
|
+
# Minimum gap between MQTT credential refetches after the broker refused one.
|
|
146
|
+
# One rotation is normal (another login happened); a second refusal that fast
|
|
147
|
+
# means refetching is not helping, so back off rather than loop.
|
|
148
|
+
_MQTT_CREDENTIAL_REFETCH_FLOOR = 30.0
|
|
149
|
+
|
|
144
150
|
|
|
145
151
|
def _spawn_bg(coro):
|
|
146
152
|
_t = asyncio.ensure_future(coro)
|
|
@@ -656,6 +662,11 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
656
662
|
# Optional LAN control client; when attached, attribute writes go local-first.
|
|
657
663
|
_lan_client = None
|
|
658
664
|
|
|
665
|
+
# MQTT credential state, defaulted at class level so the credential path is
|
|
666
|
+
# safe on any client whose __init__ did not run to completion.
|
|
667
|
+
_mqtt_credential_refresh_cb = None
|
|
668
|
+
_mqtt_refused_at = 0.0
|
|
669
|
+
|
|
659
670
|
def attach_lan_client(self, lan_client) -> None:
|
|
660
671
|
"""Route camera attribute writes through ``lan_client`` (local-first,
|
|
661
672
|
cloud-fallback). Pass an ``aidot.camera.lan_control.CameraLanClient``."""
|
|
@@ -709,6 +720,15 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
709
720
|
# updates the shared login_info in place, so camera HTTP calls can recover
|
|
710
721
|
# from a 21026 "Please login again" instead of failing silently.
|
|
711
722
|
self._token_refresh_cb: "Optional[Callable]" = None
|
|
723
|
+
# Async callback (set by AidotClient) that fetches a fresh MQTT password
|
|
724
|
+
# into the shared login_info. The password is deliberately never
|
|
725
|
+
# persisted (it rotates on every account login), so on a restart from a
|
|
726
|
+
# stored token there is none until this runs - see
|
|
727
|
+
# _async_get_smarthome_auth, which calls it before consulting login_info.
|
|
728
|
+
self._mqtt_credential_refresh_cb: "Optional[Callable]" = None
|
|
729
|
+
# Loop timestamp of the last broker credential refusal, for the refetch
|
|
730
|
+
# floor in _async_refresh_mqtt_credential.
|
|
731
|
+
self._mqtt_refused_at: float = 0.0
|
|
712
732
|
# Raw device dict retained for transport-type detection (isDTLS field)
|
|
713
733
|
self._raw_device: dict = device
|
|
714
734
|
|
|
@@ -945,6 +965,49 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
945
965
|
"""Register the AidotClient coroutine that refreshes the access token."""
|
|
946
966
|
self._token_refresh_cb = cb
|
|
947
967
|
|
|
968
|
+
def set_mqtt_credential_refresh_cb(self, cb: "Callable") -> None:
|
|
969
|
+
"""Register the AidotClient coroutine that re-fetches the MQTT password."""
|
|
970
|
+
self._mqtt_credential_refresh_cb = cb
|
|
971
|
+
|
|
972
|
+
def _shared_mqtt_password(self) -> Optional[str]:
|
|
973
|
+
"""The account's current MQTT password, or None if it holds none.
|
|
974
|
+
|
|
975
|
+
Read from the shared ``login_info`` dict, which is the single source of
|
|
976
|
+
truth: it is what the fetch writes and what a broker refusal clears.
|
|
977
|
+
"""
|
|
978
|
+
li = self._user_info if isinstance(self._user_info, dict) else {}
|
|
979
|
+
for key in LOGIN_INFO_MQTT_PASSWORD_KEYS:
|
|
980
|
+
val = li.get(key)
|
|
981
|
+
if val:
|
|
982
|
+
return val
|
|
983
|
+
return None
|
|
984
|
+
|
|
985
|
+
async def _async_refresh_mqtt_credential(self) -> None:
|
|
986
|
+
"""Ask the account client for a fresh MQTT password, if it can supply one.
|
|
987
|
+
|
|
988
|
+
Never raises: every caller has fallback strategies, so a failed fetch
|
|
989
|
+
should degrade rather than break a stream open.
|
|
990
|
+
"""
|
|
991
|
+
cb = self._mqtt_credential_refresh_cb
|
|
992
|
+
if cb is None:
|
|
993
|
+
return
|
|
994
|
+
# Floor between attempts. If the freshly fetched password is refused too
|
|
995
|
+
# - another login rotated again, or the endpoint hands back the same value
|
|
996
|
+
# - every stream open would otherwise cost another credential request and
|
|
997
|
+
# another refused connection. Better to fail this open and let the next
|
|
998
|
+
# one try than to hammer the vendor's endpoint.
|
|
999
|
+
since = asyncio.get_running_loop().time() - self._mqtt_refused_at
|
|
1000
|
+
if self._mqtt_refused_at and since < _MQTT_CREDENTIAL_REFETCH_FLOOR:
|
|
1001
|
+
_LOGGER.debug(
|
|
1002
|
+
"skipping MQTT credential refetch: only %.1fs since the last "
|
|
1003
|
+
"refusal (floor %ss)", since, _MQTT_CREDENTIAL_REFETCH_FLOOR,
|
|
1004
|
+
)
|
|
1005
|
+
return
|
|
1006
|
+
try:
|
|
1007
|
+
await cb()
|
|
1008
|
+
except Exception as exc:
|
|
1009
|
+
_LOGGER.debug("MQTT credential refresh failed: %s", exc)
|
|
1010
|
+
|
|
948
1011
|
@staticmethod
|
|
949
1012
|
def _is_auth_error(data: Any) -> bool:
|
|
950
1013
|
"""True if a smarthome response means the token is stale (re-login)."""
|
|
@@ -1186,15 +1249,35 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
1186
1249
|
"""Fetch MQTT credentials (mqttUser + mqttPassword) for the Arnoo broker.
|
|
1187
1250
|
|
|
1188
1251
|
Strategy order - stops at first success:
|
|
1189
|
-
0. Already cached
|
|
1190
|
-
1. mqttPassword
|
|
1252
|
+
0. Already cached, and still agreeing with the account-shared login_info
|
|
1253
|
+
1. mqttPassword in AiDot login_info, fetching it first if absent
|
|
1191
1254
|
2. GET /user/getUser <- documented SDK step (reqUserAuthInfoWithCallback)
|
|
1192
1255
|
Returns LDSAuthInfo {userId, mqttUser, mqttPassword, ...}
|
|
1193
1256
|
3. getServerUrlConfig response side-effect (if MQTT URL not yet fetched)
|
|
1194
1257
|
4. accessToken as MQTT password (Arnoo broker fallback)
|
|
1195
1258
|
"""
|
|
1196
|
-
|
|
1259
|
+
# Strategy 0. This cache is per device client but the credential is per
|
|
1260
|
+
# account, so the shared login_info wins any disagreement: when a broker
|
|
1261
|
+
# refusal cleared or replaced the account's password, a sibling camera's
|
|
1262
|
+
# cache still holds the dead one, and trusting it would refuse again on
|
|
1263
|
+
# every camera in turn. Treating the cache as cold whenever it disagrees
|
|
1264
|
+
# makes login_info the single source of truth.
|
|
1265
|
+
_cached_pwd = (self._smarthome_auth or {}).get("mqttPassword")
|
|
1266
|
+
if _cached_pwd and _cached_pwd == self._shared_mqtt_password():
|
|
1267
|
+
return self._smarthome_auth
|
|
1268
|
+
# An empty login_info is not automatically "nothing to disagree with": it
|
|
1269
|
+
# is exactly the post-invalidation state, since a refusal pops the shared
|
|
1270
|
+
# password but can only null the cache on the ONE client whose connection
|
|
1271
|
+
# was refused. So a cache that CAME from login_info is stale here, while
|
|
1272
|
+
# one from /user/getUser or the accessToken fallback is per client and has
|
|
1273
|
+
# no shared counterpart to compare against. Strategy 1 stamps its source
|
|
1274
|
+
# precisely so the two can be told apart.
|
|
1275
|
+
_cached_src = ((self._smarthome_auth or {}).get("raw") or {}).get("source") or ""
|
|
1276
|
+
_from_shared = _cached_src.startswith("login_info.")
|
|
1277
|
+
if _cached_pwd and self._shared_mqtt_password() is None and not _from_shared:
|
|
1197
1278
|
return self._smarthome_auth
|
|
1279
|
+
if _cached_pwd:
|
|
1280
|
+
self._smarthome_auth = None
|
|
1198
1281
|
|
|
1199
1282
|
import aiohttp
|
|
1200
1283
|
|
|
@@ -1205,9 +1288,15 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
1205
1288
|
or str(self.user_id)
|
|
1206
1289
|
)
|
|
1207
1290
|
|
|
1208
|
-
# --- Strategy 1: mqttPassword
|
|
1209
|
-
#
|
|
1210
|
-
|
|
1291
|
+
# --- Strategy 1: mqttPassword in AiDot login_info, fetched if absent ---
|
|
1292
|
+
# The password is never persisted (it rotates on every account login), so
|
|
1293
|
+
# login_info has none after a restart from a stored token, and a broker
|
|
1294
|
+
# refusal clears it deliberately. Fetch before reading, or the strategies
|
|
1295
|
+
# below shadow the absence: strategy 4 always "succeeds" with the access
|
|
1296
|
+
# token, which the broker then refuses, and the retry loops forever.
|
|
1297
|
+
if self._shared_mqtt_password() is None:
|
|
1298
|
+
await self._async_refresh_mqtt_credential()
|
|
1299
|
+
for key in LOGIN_INFO_MQTT_PASSWORD_KEYS:
|
|
1211
1300
|
val = self._user_info.get(key)
|
|
1212
1301
|
if val:
|
|
1213
1302
|
self._smarthome_auth = {
|
|
@@ -3271,10 +3360,19 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
3271
3360
|
def _resolve_sdes_serve_audio(self) -> bool:
|
|
3272
3361
|
"""Whether to include audio in the SDES camera serve.
|
|
3273
3362
|
|
|
3274
|
-
**Default ON** for app-parity (the official app plays camera audio).
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3363
|
+
**Default ON** for app-parity (the official app plays camera audio).
|
|
3364
|
+
|
|
3365
|
+
This was briefly defaulted off, because mapping audio used to serve no
|
|
3366
|
+
video at all: the SDP advertises two payload types per media line and
|
|
3367
|
+
ffmpeg binds the first, so the synthesised PCMA (pt 8) was discarded in
|
|
3368
|
+
favour of the advertised PCMU (pt 0) - and since the mpegts mux withholds
|
|
3369
|
+
its PAT/PMT until every mapped stream has produced a packet, the consumer
|
|
3370
|
+
got zero bytes and lost the picture too. The audio line is now narrowed to
|
|
3371
|
+
the payload type actually in use, the same way the video line already was,
|
|
3372
|
+
and the serve falls back to video-only if the audio payload type cannot be
|
|
3373
|
+
characterised in time. So audio can no longer cost the video.
|
|
3374
|
+
|
|
3375
|
+
Per-camera ``sdes_audio`` (via ``start_keepalive``) wins; else the
|
|
3278
3376
|
``AIDOT_SDES_SERVE_AUDIO`` env (falsy = 0/false/no/off disables)."""
|
|
3279
3377
|
opt = getattr(self, "_sdes_audio_opt", None)
|
|
3280
3378
|
if opt is not None:
|
|
@@ -3403,26 +3501,55 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
3403
3501
|
return None
|
|
3404
3502
|
from .protocol import _PersistentMqtt
|
|
3405
3503
|
|
|
3504
|
+
# paho fires on_connect on its own network thread; every mutation
|
|
3505
|
+
# below has to be ordered against the get-or-create above, so it is
|
|
3506
|
+
# hopped onto this loop and taken under the same account lock.
|
|
3507
|
+
_loop = asyncio.get_running_loop()
|
|
3508
|
+
|
|
3406
3509
|
def _invalidate_mqtt_credentials(rc) -> None:
|
|
3407
|
-
"""
|
|
3510
|
+
"""Retire the refused client and drop the credential it used.
|
|
3408
3511
|
|
|
3409
3512
|
The broker rotates this password on every account login and
|
|
3410
3513
|
allows one connection at a time, so any other login (the phone
|
|
3411
3514
|
app, a second Home Assistant, a script) invalidates ours. The
|
|
3412
3515
|
cached copy lives in login_info, where it short-circuits the
|
|
3413
3516
|
credential fetch, so it has to be cleared or every retry reuses
|
|
3414
|
-
the dead password.
|
|
3415
|
-
|
|
3517
|
+
the dead password.
|
|
3518
|
+
|
|
3519
|
+
Runs on paho's network thread, so it only schedules the work: the
|
|
3520
|
+
mutations must be ordered against a concurrent get-or-create, and
|
|
3521
|
+
``retire()`` calls ``loop_stop()``, which raises if called from
|
|
3522
|
+
the very thread it would join.
|
|
3416
3523
|
"""
|
|
3417
|
-
|
|
3524
|
+
_loop.call_soon_threadsafe(_spawn_bg, _retire_and_invalidate(rc))
|
|
3525
|
+
|
|
3526
|
+
async def _retire_and_invalidate(rc) -> None:
|
|
3527
|
+
# Deliberately outside the lock. These are plain dict writes with
|
|
3528
|
+
# no ordering requirement, and a sibling can be holding the lock
|
|
3529
|
+
# across an HTTP credential fetch - taking it here would stall the
|
|
3530
|
+
# invalidation behind that request's timeout for no benefit.
|
|
3531
|
+
for key in LOGIN_INFO_MQTT_PASSWORD_KEYS:
|
|
3418
3532
|
if li.pop(key, None) is not None:
|
|
3419
3533
|
_LOGGER.warning(
|
|
3420
3534
|
"cleared the cached MQTT password after the broker "
|
|
3421
3535
|
"rejected it (rc=%s); it will be re-fetched", rc,
|
|
3422
3536
|
)
|
|
3423
3537
|
self._smarthome_auth = None
|
|
3424
|
-
|
|
3425
|
-
|
|
3538
|
+
# The URL response is the one that can carry a server-issued
|
|
3539
|
+
# password (strategy 3); leaving it cached would skip that
|
|
3540
|
+
# fallback entirely on the recovery path.
|
|
3541
|
+
self._mqtt_url = None
|
|
3542
|
+
# Note the refusal so a second failure in quick succession backs
|
|
3543
|
+
# off instead of hammering the credential endpoint.
|
|
3544
|
+
self._mqtt_refused_at = _loop.time()
|
|
3545
|
+
# This one DOES need the lock: it is a compare-and-pop against the
|
|
3546
|
+
# get-or-create above, and without it a rebuild that already read
|
|
3547
|
+
# the slot could have its replacement removed instead of ours.
|
|
3548
|
+
async with lock:
|
|
3549
|
+
if li.get(LOGIN_INFO_PERSISTENT_MQTT_KEY) is pm:
|
|
3550
|
+
li.pop(LOGIN_INFO_PERSISTENT_MQTT_KEY, None)
|
|
3551
|
+
# Outside the lock: retire() blocks briefly joining paho's thread.
|
|
3552
|
+
await _loop.run_in_executor(None, pm.retire)
|
|
3426
3553
|
|
|
3427
3554
|
pm = _PersistentMqtt(mqtt_url, mqtt_user, mqtt_pwd, client_id,
|
|
3428
3555
|
on_auth_failure=_invalidate_mqtt_credentials)
|
{python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/camera/protocol.py
RENAMED
|
@@ -1434,6 +1434,17 @@ class _PersistentMqtt:
|
|
|
1434
1434
|
# the owner can invalidate its cache and re-authenticate.
|
|
1435
1435
|
self._on_auth_failure = on_auth_failure
|
|
1436
1436
|
self._auth_failed_reported = False
|
|
1437
|
+
# Latched by the owner when it retires this instance after a refusal. A
|
|
1438
|
+
# retired client must stop reconnecting: paho's own retry loop would keep
|
|
1439
|
+
# offering the dead password to the vendor's broker for the life of the
|
|
1440
|
+
# process, and callers still holding it would block on a _connected event
|
|
1441
|
+
# that can never set.
|
|
1442
|
+
self._retired = False
|
|
1443
|
+
# Set when the broker refuses our credentials. Waiters check it so they
|
|
1444
|
+
# fail fast instead of sitting out a 15s timeout on a connection that is
|
|
1445
|
+
# never coming, and _connected stays clear so nothing publishes into a
|
|
1446
|
+
# socket the broker rejected.
|
|
1447
|
+
self._auth_refused = threading.Event()
|
|
1437
1448
|
self._client = None
|
|
1438
1449
|
self._host = None
|
|
1439
1450
|
self._port = None
|
|
@@ -1492,7 +1503,11 @@ class _PersistentMqtt:
|
|
|
1492
1503
|
self._connected.set()
|
|
1493
1504
|
elif rc in self._AUTH_REFUSAL_RCS:
|
|
1494
1505
|
# Stale credential: paho would retry this password forever. Tell the
|
|
1495
|
-
# owner once so it can invalidate its cache and
|
|
1506
|
+
# owner once so it can invalidate its cache, retire this client and
|
|
1507
|
+
# re-authenticate. Unblock anyone waiting on _connected first - they
|
|
1508
|
+
# would otherwise sit out their full timeout for a connection that is
|
|
1509
|
+
# never coming.
|
|
1510
|
+
self._auth_refused.set()
|
|
1496
1511
|
_LOGGER.warning(
|
|
1497
1512
|
"persistent mqtt: broker rejected our credentials (rc=%s) - the "
|
|
1498
1513
|
"MQTT password has almost certainly been rotated by another "
|
|
@@ -1527,6 +1542,12 @@ class _PersistentMqtt:
|
|
|
1527
1542
|
_LOGGER.debug("persistent mqtt: handler raised", exc_info=True)
|
|
1528
1543
|
|
|
1529
1544
|
def _ensure_started_sync(self, timeout=15.0):
|
|
1545
|
+
# A retired instance must never rebuild: retire() nulls _client, which on
|
|
1546
|
+
# its own would look exactly like "not started yet" and reconnect with the
|
|
1547
|
+
# credential the broker just refused. Callers still holding a retired
|
|
1548
|
+
# client get a clean False and fall back to a per-op session.
|
|
1549
|
+
if self._retired:
|
|
1550
|
+
return False
|
|
1530
1551
|
with self._lock:
|
|
1531
1552
|
if self._client is None:
|
|
1532
1553
|
self._client = self._build()
|
|
@@ -1538,7 +1559,18 @@ class _PersistentMqtt:
|
|
|
1538
1559
|
return False
|
|
1539
1560
|
self._client.loop_start() # drives keepalive + auto-reconnect
|
|
1540
1561
|
self._started = True
|
|
1541
|
-
|
|
1562
|
+
# Wait for either outcome. paho retries a refused CONNACK on its own
|
|
1563
|
+
# schedule, so without watching _auth_refused every caller would burn the
|
|
1564
|
+
# full timeout before the owner gets a chance to re-authenticate.
|
|
1565
|
+
import time as _time
|
|
1566
|
+
deadline = _time.monotonic() + timeout
|
|
1567
|
+
while True:
|
|
1568
|
+
if self._connected.wait(0.1):
|
|
1569
|
+
return True
|
|
1570
|
+
if self._auth_refused.is_set() or self._retired:
|
|
1571
|
+
return False
|
|
1572
|
+
if _time.monotonic() >= deadline:
|
|
1573
|
+
return False
|
|
1542
1574
|
|
|
1543
1575
|
def _subscribe_sync(self, topics):
|
|
1544
1576
|
new = []
|
|
@@ -1646,6 +1678,24 @@ class _PersistentMqtt:
|
|
|
1646
1678
|
loop = asyncio.get_running_loop()
|
|
1647
1679
|
return await loop.run_in_executor(None, functools.partial(_pub))
|
|
1648
1680
|
|
|
1681
|
+
def retire(self):
|
|
1682
|
+
"""Permanently shut this client down after a credential refusal.
|
|
1683
|
+
|
|
1684
|
+
``close()`` alone is not enough from the owner's side: paho's retry loop
|
|
1685
|
+
was configured with ``reconnect_delay_set(max_delay=30)``, so a refused
|
|
1686
|
+
client keeps re-offering the dead password to the vendor's broker for the
|
|
1687
|
+
life of the process - and ``_auth_failed_reported`` latches, so it does so
|
|
1688
|
+
silently. Latching ``_retired`` also releases anyone still waiting on
|
|
1689
|
+
this instance instead of leaving them blocked on a timeout.
|
|
1690
|
+
|
|
1691
|
+
Must not be called from paho's network thread: ``loop_stop()`` there
|
|
1692
|
+
raises ``cannot join current thread``. The owner schedules it onto the
|
|
1693
|
+
event loop with ``call_soon_threadsafe``.
|
|
1694
|
+
"""
|
|
1695
|
+
self._retired = True
|
|
1696
|
+
self._on_auth_failure = None
|
|
1697
|
+
self.close()
|
|
1698
|
+
|
|
1649
1699
|
def close(self):
|
|
1650
1700
|
c = self._client
|
|
1651
1701
|
self._client = None
|
{python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/camera/sdes_open.py
RENAMED
|
@@ -34,6 +34,54 @@ from .protocol import (
|
|
|
34
34
|
_LOGGER = logging.getLogger(__name__)
|
|
35
35
|
|
|
36
36
|
|
|
37
|
+
# Payload types the ffmpeg SDP advertises per media line, and their alternates.
|
|
38
|
+
# The SDP offers both because the camera picks one per session.
|
|
39
|
+
_SDP_ALTERNATE_PT = {96: 97, 97: 96, 0: 8, 8: 0}
|
|
40
|
+
|
|
41
|
+
# Extra time, on top of the video payload-type wait, to characterise the audio
|
|
42
|
+
# payload type before the serve launches. Short by design: audio that has not
|
|
43
|
+
# appeared by then is served on a later open (every ffmpeg restart re-runs this
|
|
44
|
+
# wait, and by then the camera has been streaming for a while), which is cheaper
|
|
45
|
+
# than delaying the picture for every silent camera.
|
|
46
|
+
_AUDIO_PT_GRACE_S = 4.0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def narrow_sdp_payload_types(sdp_text: str, keep_video=None, keep_audio=None) -> str:
|
|
50
|
+
"""Rewrite an SDP to advertise a single payload type per media line.
|
|
51
|
+
|
|
52
|
+
ffmpeg binds each RTP depacketizer to the FIRST payload type on the m-line and
|
|
53
|
+
silently discards packets carrying any other one. The SDP written for the
|
|
54
|
+
bridge lists both candidates per line (``m=video ... 96 97`` for H.264/H.265,
|
|
55
|
+
``m=audio ... 0 8`` for PCMU/PCMA) because which one the camera uses varies per
|
|
56
|
+
session, so whichever it actually sends has to be promoted before launch.
|
|
57
|
+
|
|
58
|
+
Getting this wrong on the VIDEO line costs the picture. Getting it wrong on the
|
|
59
|
+
AUDIO line costs the picture too, and less obviously: the mpegts mux withholds
|
|
60
|
+
its PAT/PMT until every mapped stream has produced a packet, so an audio stream
|
|
61
|
+
whose packets are all discarded means the consumer receives zero bytes - video
|
|
62
|
+
included - while every other signal looks healthy.
|
|
63
|
+
|
|
64
|
+
``keep_video`` / ``keep_audio`` of None leaves that line alone.
|
|
65
|
+
"""
|
|
66
|
+
drop = {_SDP_ALTERNATE_PT[k] for k in (keep_video, keep_audio)
|
|
67
|
+
if k in _SDP_ALTERNATE_PT}
|
|
68
|
+
kept = []
|
|
69
|
+
for line in sdp_text.splitlines(keepends=True):
|
|
70
|
+
stripped = line.lstrip()
|
|
71
|
+
if stripped.startswith("m=video") and keep_video is not None:
|
|
72
|
+
line = line.replace(" 96 97", f" {keep_video}")
|
|
73
|
+
elif stripped.startswith("m=audio") and keep_audio is not None:
|
|
74
|
+
line = line.replace(" 0 8", f" {keep_audio}")
|
|
75
|
+
elif any(
|
|
76
|
+
stripped.startswith(f"a={attr}:{pt}{sep}")
|
|
77
|
+
for pt in drop
|
|
78
|
+
for attr, sep in (("rtpmap", " "), ("fmtp", " "), ("fmtp", ";"))
|
|
79
|
+
):
|
|
80
|
+
continue
|
|
81
|
+
kept.append(line)
|
|
82
|
+
return "".join(kept)
|
|
83
|
+
|
|
84
|
+
|
|
37
85
|
def _sdes_echo_wait_timeout(skip_liveplay: bool) -> float:
|
|
38
86
|
"""Seconds to block on the camera's webrtcReq echo before proceeding.
|
|
39
87
|
|
|
@@ -2141,6 +2189,11 @@ class _SdesOpenMixin:
|
|
|
2141
2189
|
# bridge on the first video RTP packet so the ffmpeg SDP can be narrowed
|
|
2142
2190
|
# to the matching single codec before ffmpeg is launched.
|
|
2143
2191
|
_first_video_pt: list = [None]
|
|
2192
|
+
# Same for audio: the ffmpeg SDP advertises BOTH PCMU (0) and PCMA (8),
|
|
2193
|
+
# and ffmpeg binds the depacketizer to the first one listed - so the line
|
|
2194
|
+
# has to be narrowed to the payload type the camera actually sends, the
|
|
2195
|
+
# same way the video line already is.
|
|
2196
|
+
_first_audio_pt: list = [None]
|
|
2144
2197
|
# Media-liveness: bridge sets [0] = time.monotonic() on every forwarded
|
|
2145
2198
|
# media packet; the keepalive watchdog reads it via SdesSession to
|
|
2146
2199
|
# restart a session the camera silently stopped feeding.
|
|
@@ -3095,10 +3148,21 @@ class _SdesOpenMixin:
|
|
|
3095
3148
|
)
|
|
3096
3149
|
if not _br_first_audio_logged:
|
|
3097
3150
|
_br_first_audio_logged = True
|
|
3151
|
+
# Record the payload type this path
|
|
3152
|
+
# SYNTHESISES (_tk_pt, PCMA) so the
|
|
3153
|
+
# SDP can be narrowed to it. Without
|
|
3154
|
+
# this the m=audio line keeps
|
|
3155
|
+
# advertising "0 8", ffmpeg binds
|
|
3156
|
+
# PCMU and discards every packet, and
|
|
3157
|
+
# the mpegts PMT is never written - so
|
|
3158
|
+
# the consumer gets zero bytes and the
|
|
3159
|
+
# VIDEO is lost with the audio.
|
|
3160
|
+
_first_audio_pt[0] = _tk_pt
|
|
3098
3161
|
_status(
|
|
3099
3162
|
f"bridge: first SDES audio -> ffmpeg"
|
|
3100
3163
|
f" loopback:{_lo_audio_port}"
|
|
3101
3164
|
f" ({len(_pd_plain)}B PCMA)"
|
|
3165
|
+
f" pt={_tk_pt}"
|
|
3102
3166
|
)
|
|
3103
3167
|
except Exception:
|
|
3104
3168
|
_LOGGER.debug("camera %s: swallowed exception in %s", getattr(self, "device_id", "?"), '_enc_c8_sctp', exc_info=True)
|
|
@@ -3190,6 +3254,7 @@ class _SdesOpenMixin:
|
|
|
3190
3254
|
)
|
|
3191
3255
|
if _kind == "audio" and not _br_first_audio_logged:
|
|
3192
3256
|
_br_first_audio_logged = True
|
|
3257
|
+
_first_audio_pt[0] = _pt
|
|
3193
3258
|
_status(f"bridge: first audio RTP pt={_pt}")
|
|
3194
3259
|
# Capture the camera's audio return address so the talk
|
|
3195
3260
|
# pump (SdesSession.async_start_talk) can send outbound
|
|
@@ -3454,12 +3519,13 @@ class _SdesOpenMixin:
|
|
|
3454
3519
|
if out_dir:
|
|
3455
3520
|
os.makedirs(out_dir, exist_ok=True)
|
|
3456
3521
|
# Build the ffmpeg command (single source of truth: _build_sdes_serve_cmd).
|
|
3522
|
+
_serve_audio = self._resolve_sdes_serve_audio()
|
|
3457
3523
|
cmd = _build_sdes_serve_cmd(
|
|
3458
3524
|
sdp_path=sdp_path,
|
|
3459
3525
|
rtsp_push_url=rtsp_push_url,
|
|
3460
3526
|
output_path=output_path,
|
|
3461
3527
|
max_seconds=max_seconds,
|
|
3462
|
-
sdes_audio=
|
|
3528
|
+
sdes_audio=_serve_audio,
|
|
3463
3529
|
audio_gain_db=self._resolve_sdes_audio_gain_db(),
|
|
3464
3530
|
)
|
|
3465
3531
|
# --- H.265 fix: narrow the ffmpeg SDP to the camera's actual codec ----
|
|
@@ -3470,36 +3536,71 @@ class _SdesOpenMixin:
|
|
|
3470
3536
|
# video pt, then rewrite the SDP to that single codec before launch.
|
|
3471
3537
|
# ffmpeg recovers on the next periodic keyframe, so the small spawn delay
|
|
3472
3538
|
# is harmless. Falls back to the dual-codec SDP if no video is seen.
|
|
3539
|
+
# When audio is being served the same wait has to cover the AUDIO payload
|
|
3540
|
+
# type, and for a stricter reason: an unnarrowed video line merely risks the
|
|
3541
|
+
# picture, while an unnarrowed audio line guarantees the mux never writes its
|
|
3542
|
+
# PAT/PMT, which loses the picture AND the audio. Wait for both, then take
|
|
3543
|
+
# whatever has been observed by the deadline.
|
|
3544
|
+
# Two separate budgets on purpose. Video keeps its original 15s, because
|
|
3545
|
+
# the picture is what the viewer is waiting for. Audio gets only a short
|
|
3546
|
+
# extra grace on top: sharing one deadline would make a camera that never
|
|
3547
|
+
# sends audio delay the picture from a couple of seconds to the full 15,
|
|
3548
|
+
# which trades the bug for a worse one.
|
|
3473
3549
|
_vpt_deadline = time.monotonic() + 15.0
|
|
3474
3550
|
while _first_video_pt[0] is None and time.monotonic() < _vpt_deadline:
|
|
3475
3551
|
await asyncio.sleep(0.1)
|
|
3552
|
+
if _serve_audio and _first_audio_pt[0] is None:
|
|
3553
|
+
_apt_deadline = time.monotonic() + _AUDIO_PT_GRACE_S
|
|
3554
|
+
while _first_audio_pt[0] is None and time.monotonic() < _apt_deadline:
|
|
3555
|
+
await asyncio.sleep(0.1)
|
|
3476
3556
|
_vpt = _first_video_pt[0]
|
|
3477
|
-
|
|
3478
|
-
|
|
3479
|
-
|
|
3480
|
-
|
|
3481
|
-
|
|
3482
|
-
|
|
3483
|
-
|
|
3484
|
-
|
|
3485
|
-
|
|
3486
|
-
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
3557
|
+
_apt = _first_audio_pt[0]
|
|
3558
|
+
if _serve_audio and _apt is None:
|
|
3559
|
+
# No audio observed in the window, so the line cannot be narrowed and
|
|
3560
|
+
# mapping audio would stall the mux. Serve video only rather than
|
|
3561
|
+
# serving nothing, and say so.
|
|
3562
|
+
_LOGGER.warning(
|
|
3563
|
+
"camera %s: no audio observed before the serve launched, so the "
|
|
3564
|
+
"audio payload type is unknown; mapping it would stall the mpegts "
|
|
3565
|
+
"mux and serve no video. Continuing without audio.",
|
|
3566
|
+
getattr(self, "device_id", "?"),
|
|
3567
|
+
)
|
|
3568
|
+
_serve_audio = False
|
|
3569
|
+
cmd = _build_sdes_serve_cmd(
|
|
3570
|
+
sdp_path=sdp_path,
|
|
3571
|
+
rtsp_push_url=rtsp_push_url,
|
|
3572
|
+
output_path=output_path,
|
|
3573
|
+
max_seconds=max_seconds,
|
|
3574
|
+
sdes_audio=False,
|
|
3575
|
+
audio_gain_db=self._resolve_sdes_audio_gain_db(),
|
|
3576
|
+
)
|
|
3577
|
+
# Audio matters even more than video here. A multi-PT m-line makes ffmpeg
|
|
3578
|
+
# bind the depacketizer to the FIRST payload type and silently discard the
|
|
3579
|
+
# rest, and the mpegts mux withholds its PAT/PMT until EVERY mapped stream
|
|
3580
|
+
# has produced a packet - so an audio line advertising "0 8" (PCMU first)
|
|
3581
|
+
# on a camera that sends PCMA discards every audio packet and the consumer
|
|
3582
|
+
# receives zero bytes. That takes the video down with it, which is why
|
|
3583
|
+
# enabling serve audio appeared to break streaming outright.
|
|
3584
|
+
if _vpt in (96, 97) or (_serve_audio and _apt in (0, 8)):
|
|
3491
3585
|
def _read_sdp_file() -> str:
|
|
3492
3586
|
with open(sdp_path, encoding="utf-8") as _f_sdp:
|
|
3493
3587
|
return _f_sdp.read()
|
|
3494
3588
|
try:
|
|
3589
|
+
_keep_v = int(_vpt) if _vpt in (96, 97) else None
|
|
3590
|
+
_keep_a = int(_apt) if (_serve_audio and _apt in (0, 8)) else None
|
|
3495
3591
|
_cur_sdp = await asyncio.get_running_loop().run_in_executor(
|
|
3496
3592
|
None, _read_sdp_file)
|
|
3497
3593
|
await asyncio.get_running_loop().run_in_executor(
|
|
3498
3594
|
None, _write_text_file, sdp_path,
|
|
3499
|
-
|
|
3595
|
+
narrow_sdp_payload_types(_cur_sdp, _keep_v, _keep_a))
|
|
3500
3596
|
_status(
|
|
3501
|
-
|
|
3502
|
-
f"
|
|
3597
|
+
"SDES: narrowed ffmpeg SDP to"
|
|
3598
|
+
+ (f" video pt={_keep_v}"
|
|
3599
|
+
f" ({'H265' if _keep_v == 97 else 'H264'})"
|
|
3600
|
+
if _keep_v is not None else "")
|
|
3601
|
+
+ (f" audio pt={_keep_a}"
|
|
3602
|
+
f" ({'PCMA' if _keep_a == 8 else 'PCMU'})"
|
|
3603
|
+
if _keep_a is not None else ""))
|
|
3503
3604
|
except Exception:
|
|
3504
3605
|
_LOGGER.debug("camera %s: swallowed exception in %s", getattr(self, "device_id", "?"), '_open_sdes_stream', exc_info=True)
|
|
3505
3606
|
_LOGGER.info("SDES ffmpeg cmd: %s", " ".join(cmd))
|