python-aidot-cameras 0.12.2__tar.gz → 0.12.4__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.4}/PKG-INFO +1 -1
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/camera/client.py +148 -19
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/camera/protocol.py +52 -2
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/client.py +94 -9
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/const.py +13 -1
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/device_client.py +6 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/pyproject.toml +1 -1
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/python_aidot_cameras.egg-info/SOURCES.txt +2 -0
- python_aidot_cameras-0.12.4/tests/test_mqtt_credential_refetch.py +288 -0
- python_aidot_cameras-0.12.4/tests/test_mqtt_password_not_persisted.py +36 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_sdes_serve_audio.py +19 -12
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_serializable_login_info.py +17 -10
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_upstream_compat.py +13 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/LICENSE +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/MANIFEST.in +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/README.md +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/__init__.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/__main__.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/__init__.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/__init__.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/clock.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/codecs/__init__.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/codecs/base.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/codecs/g711.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/codecs/g722.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/codecs/h264.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/codecs/opus.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/codecs/vpx.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/contrib/__init__.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/contrib/media.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/contrib/signaling.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/events.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/exceptions.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/jitterbuffer.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/mediastreams.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/rate.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/rtcconfiguration.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/rtcdatachannel.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/rtcdtlstransport.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/rtcicetransport.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/rtcpeerconnection.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/rtcrtpparameters.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/rtcrtpreceiver.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/rtcrtpsender.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/rtcrtptransceiver.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/rtcsctptransport.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/rtcsessiondescription.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/rtp.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/sdp.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/stats.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/utils.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/camera/__init__.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/camera/constants.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/camera/controls.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/camera/go2rtc.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/camera/lan_control.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/camera/models.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/camera/playback.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/camera/sdes.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/camera/sdes_open.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/camera/tutk.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/camera/webrtc.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/camera/webrtc_open.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/credentials.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/crypto.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/discover.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/exceptions.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/g711.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/py.typed +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/python_aidot_cameras.egg-info/requires.txt +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/python_aidot_cameras.egg-info/top_level.txt +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/setup.cfg +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_aioice_compat.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_alarm_event.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_backoff.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_deferred_hardening.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_device_login_guard.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_device_user_info_cache.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_dtls_not_ready_burst.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_dtls_pinning.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_dtls_serve_decode_skip.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_dtls_serve_open_timeout.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_dtls_skip_signaling_wait.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_dtls_slow_probe.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_egress_guard.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_go2rtc.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_go2rtc_cli.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_h264_decode_rate_limit.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_highport_nomination.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_ice_config_cache.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_keepalive_renew.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_keyframe_prompter.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_lan_control.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_light_active_color_mode.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_live_stream_param.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_logging_caps.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_motion_poll.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_mqtt_credential_self_heal.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_mqtt_session_reconnect.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_narrow_pc_ice.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_no_undefined_names.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_offline_keepalive_pause.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_open_fail_logger.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_open_gate_delay.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_persistent_mqtt.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_persistent_mqtt_robustness.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_playback_tls.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_post_merge_hardening.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_retry_policy.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_sdes_adaptive.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_sdes_bridge_break_gate.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_sdes_echo_wait_timeout.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_sdes_fast_liveplay.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_sdes_ffmpeg_stderr_log.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_sdes_idle_release.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_sdes_open_cleanup.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_sdes_serve_cmd.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_sdes_sprop.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_sdes_talk.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_sdes_teardown_exit_log.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_sdes_watchdog.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_security_hardening.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_serve_relay.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_session_stats.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_speak.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_stream_cap.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_stream_drain_ownership.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_stream_idle.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_stream_teardown.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_talk.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_terminal_ack.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_token_file_robustness.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_token_refresh.py +0 -0
- {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_wait_or_event.py +0 -0
{python_aidot_cameras-0.12.2/python_aidot_cameras.egg-info → python_aidot_cameras-0.12.4}/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.4
|
|
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,16 +3360,27 @@ 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
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3363
|
+
**Default OFF**, because including it can cost the video entirely. The
|
|
3364
|
+
mpegts mux writes its PAT/PMT only once EVERY mapped stream has produced a
|
|
3365
|
+
packet, and ``amix`` does not emit until every one of its inputs has
|
|
3366
|
+
delivered a frame - so on a camera that sends no PCMA, the AAC encoder
|
|
3367
|
+
never produces, the PMT is never written, and a consumer gets an accepted
|
|
3368
|
+
connection and then zero bytes. The continuous ``anullsrc`` silence base
|
|
3369
|
+
was meant to prevent exactly that and does not: verified live on a battery
|
|
3370
|
+
camera, audio on gave 0 bytes across 45 consecutive attach attempts while
|
|
3371
|
+
audio off gave 303 KB of 1280x960 H.264 from the same session, and
|
|
3372
|
+
reordering the mix so the silence drives it did not help.
|
|
3373
|
+
|
|
3374
|
+
Missing audio is a worse experience than the app; NO VIDEO is a broken
|
|
3375
|
+
integration, so video wins the tie. Turn audio back on per camera with
|
|
3376
|
+
``sdes_audio`` (via ``start_keepalive``), or globally with
|
|
3377
|
+
``AIDOT_SDES_SERVE_AUDIO=1`` - on a camera that does send audio steadily
|
|
3378
|
+
it works, which is why this stays an option rather than being removed."""
|
|
3279
3379
|
opt = getattr(self, "_sdes_audio_opt", None)
|
|
3280
3380
|
if opt is not None:
|
|
3281
3381
|
return bool(opt)
|
|
3282
|
-
return os.environ.get("AIDOT_SDES_SERVE_AUDIO", "").strip().lower()
|
|
3283
|
-
"
|
|
3382
|
+
return os.environ.get("AIDOT_SDES_SERVE_AUDIO", "").strip().lower() in (
|
|
3383
|
+
"1", "true", "yes", "on")
|
|
3284
3384
|
|
|
3285
3385
|
def _resolve_sdes_audio_gain_db(self) -> float:
|
|
3286
3386
|
"""Gain (dB) applied to the served SDES audio (the camera mic runs hot).
|
|
@@ -3403,26 +3503,55 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
3403
3503
|
return None
|
|
3404
3504
|
from .protocol import _PersistentMqtt
|
|
3405
3505
|
|
|
3506
|
+
# paho fires on_connect on its own network thread; every mutation
|
|
3507
|
+
# below has to be ordered against the get-or-create above, so it is
|
|
3508
|
+
# hopped onto this loop and taken under the same account lock.
|
|
3509
|
+
_loop = asyncio.get_running_loop()
|
|
3510
|
+
|
|
3406
3511
|
def _invalidate_mqtt_credentials(rc) -> None:
|
|
3407
|
-
"""
|
|
3512
|
+
"""Retire the refused client and drop the credential it used.
|
|
3408
3513
|
|
|
3409
3514
|
The broker rotates this password on every account login and
|
|
3410
3515
|
allows one connection at a time, so any other login (the phone
|
|
3411
3516
|
app, a second Home Assistant, a script) invalidates ours. The
|
|
3412
3517
|
cached copy lives in login_info, where it short-circuits the
|
|
3413
3518
|
credential fetch, so it has to be cleared or every retry reuses
|
|
3414
|
-
the dead password.
|
|
3415
|
-
|
|
3519
|
+
the dead password.
|
|
3520
|
+
|
|
3521
|
+
Runs on paho's network thread, so it only schedules the work: the
|
|
3522
|
+
mutations must be ordered against a concurrent get-or-create, and
|
|
3523
|
+
``retire()`` calls ``loop_stop()``, which raises if called from
|
|
3524
|
+
the very thread it would join.
|
|
3416
3525
|
"""
|
|
3417
|
-
|
|
3526
|
+
_loop.call_soon_threadsafe(_spawn_bg, _retire_and_invalidate(rc))
|
|
3527
|
+
|
|
3528
|
+
async def _retire_and_invalidate(rc) -> None:
|
|
3529
|
+
# Deliberately outside the lock. These are plain dict writes with
|
|
3530
|
+
# no ordering requirement, and a sibling can be holding the lock
|
|
3531
|
+
# across an HTTP credential fetch - taking it here would stall the
|
|
3532
|
+
# invalidation behind that request's timeout for no benefit.
|
|
3533
|
+
for key in LOGIN_INFO_MQTT_PASSWORD_KEYS:
|
|
3418
3534
|
if li.pop(key, None) is not None:
|
|
3419
3535
|
_LOGGER.warning(
|
|
3420
3536
|
"cleared the cached MQTT password after the broker "
|
|
3421
3537
|
"rejected it (rc=%s); it will be re-fetched", rc,
|
|
3422
3538
|
)
|
|
3423
3539
|
self._smarthome_auth = None
|
|
3424
|
-
|
|
3425
|
-
|
|
3540
|
+
# The URL response is the one that can carry a server-issued
|
|
3541
|
+
# password (strategy 3); leaving it cached would skip that
|
|
3542
|
+
# fallback entirely on the recovery path.
|
|
3543
|
+
self._mqtt_url = None
|
|
3544
|
+
# Note the refusal so a second failure in quick succession backs
|
|
3545
|
+
# off instead of hammering the credential endpoint.
|
|
3546
|
+
self._mqtt_refused_at = _loop.time()
|
|
3547
|
+
# This one DOES need the lock: it is a compare-and-pop against the
|
|
3548
|
+
# get-or-create above, and without it a rebuild that already read
|
|
3549
|
+
# the slot could have its replacement removed instead of ours.
|
|
3550
|
+
async with lock:
|
|
3551
|
+
if li.get(LOGIN_INFO_PERSISTENT_MQTT_KEY) is pm:
|
|
3552
|
+
li.pop(LOGIN_INFO_PERSISTENT_MQTT_KEY, None)
|
|
3553
|
+
# Outside the lock: retire() blocks briefly joining paho's thread.
|
|
3554
|
+
await _loop.run_in_executor(None, pm.retire)
|
|
3426
3555
|
|
|
3427
3556
|
pm = _PersistentMqtt(mqtt_url, mqtt_user, mqtt_pwd, client_id,
|
|
3428
3557
|
on_auth_failure=_invalidate_mqtt_credentials)
|
{python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/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
|
|
@@ -14,7 +14,8 @@ camera layer and the CLI depend on and upstream does not provide:
|
|
|
14
14
|
|
|
15
15
|
* a stable, mutable ``login_info`` dict (see the property below);
|
|
16
16
|
* ``serializable_login_info()`` for persisting it;
|
|
17
|
-
* ``
|
|
17
|
+
* ``async_ensure_mqtt_credential()``, which fetches the MQTT password cameras
|
|
18
|
+
need (never persisted - it rotates on every account login);
|
|
18
19
|
* ``async_ensure_token()``, the refresh hook camera HTTP calls invoke on a
|
|
19
20
|
21026 "Please login again";
|
|
20
21
|
* ``async_get_all_device()`` returning cameras (upstream filters them out);
|
|
@@ -62,6 +63,10 @@ _LOGGER = logging.getLogger(__name__)
|
|
|
62
63
|
# other cloud API calls that use owner+token headers.
|
|
63
64
|
_CLOUD_APP_ID = "68"
|
|
64
65
|
|
|
66
|
+
# Every key /commons/userConfig has been seen to return the MQTT password under.
|
|
67
|
+
# The typo'd "mqqtPwd" is the vendor's, not ours - see the read below.
|
|
68
|
+
_MQTT_PASSWORD_RESPONSE_KEYS = ("mqttPassword", "mqqtPwd", "mqttPwd")
|
|
69
|
+
|
|
65
70
|
# Strong refs to fire-and-forget tasks: asyncio only keeps weak refs, so a
|
|
66
71
|
# discarded task can be garbage-collected mid-flight (see camera/client.py).
|
|
67
72
|
_BG_TASKS: set = set()
|
|
@@ -130,6 +135,26 @@ def _upstream_can_build(device: dict[str, Any]) -> bool:
|
|
|
130
135
|
return not _aes_key_is_null(device)
|
|
131
136
|
|
|
132
137
|
|
|
138
|
+
def _without_mqtt_password(data: Any) -> Any:
|
|
139
|
+
"""A copy of a userConfig response with every MQTT password removed.
|
|
140
|
+
|
|
141
|
+
The password is runtime-only by design (it rotates on every account login),
|
|
142
|
+
so it must not survive inside a nested blob that IS persisted.
|
|
143
|
+
"""
|
|
144
|
+
if not isinstance(data, dict):
|
|
145
|
+
return data
|
|
146
|
+
out = {k: v for k, v in data.items() if k not in _MQTT_PASSWORD_RESPONSE_KEYS}
|
|
147
|
+
mqtt_block = out.get("mqtt")
|
|
148
|
+
if isinstance(mqtt_block, dict):
|
|
149
|
+
out["mqtt"] = {
|
|
150
|
+
k: v for k, v in mqtt_block.items() if k not in ("password", "pwd")
|
|
151
|
+
}
|
|
152
|
+
elif isinstance(mqtt_block, str):
|
|
153
|
+
# An opaque JSON string that may embed the password; nothing reads it.
|
|
154
|
+
out.pop("mqtt", None)
|
|
155
|
+
return out
|
|
156
|
+
|
|
157
|
+
|
|
133
158
|
async def _prefetch_ice_config(dc: "CameraDeviceClient") -> None:
|
|
134
159
|
"""Background task: warm the HTTP ICE config cache for a camera."""
|
|
135
160
|
try:
|
|
@@ -159,6 +184,9 @@ class CameraClient(_UpstreamAidotClient):
|
|
|
159
184
|
# Single-flight guard so a burst of camera 21026s (all pollers at once)
|
|
160
185
|
# coalesces into ONE token refresh / re-login instead of many.
|
|
161
186
|
self._ensure_token_inflight: "Optional[asyncio.Future]" = None
|
|
187
|
+
# Same single-flight idiom for the MQTT credential fetch - see
|
|
188
|
+
# async_ensure_mqtt_credential().
|
|
189
|
+
self._user_config_inflight: "Optional[asyncio.Future]" = None
|
|
162
190
|
# Upstream keeps the session inside CloudApi only; the camera-side
|
|
163
191
|
# /commons/userConfig fetch needs it directly.
|
|
164
192
|
self.session = session
|
|
@@ -180,9 +208,10 @@ class CameraClient(_UpstreamAidotClient):
|
|
|
180
208
|
stored = stored.get(CONF_LOGIN_INFO) or {}
|
|
181
209
|
# UserInformation is a strict dataclass: update_from_json() drops
|
|
182
210
|
# any key it has no field for, which is every camera-only key
|
|
183
|
-
# (
|
|
184
|
-
#
|
|
185
|
-
#
|
|
211
|
+
# (mqttClientId, _userConfigRaw). Carry those across a restart on
|
|
212
|
+
# the login_info dict instead. The MQTT password is deliberately
|
|
213
|
+
# NOT among them - it is runtime-only, so it is neither written nor
|
|
214
|
+
# read back, and a camera fetches a fresh one on first use.
|
|
186
215
|
self._login_info.update(
|
|
187
216
|
{
|
|
188
217
|
k: v
|
|
@@ -210,8 +239,10 @@ class CameraClient(_UpstreamAidotClient):
|
|
|
210
239
|
Upstream's ``login_info`` is ``user_info.to_dict()``, i.e. a fresh
|
|
211
240
|
``asdict()`` copy per access, and ``UserInformation`` silently drops any
|
|
212
241
|
key it has no dataclass field for. The camera layer needs the opposite
|
|
213
|
-
on both counts: it stores camera-only keys here (
|
|
214
|
-
|
|
242
|
+
on both counts: it stores camera-only keys here (mqttClientId, and the
|
|
243
|
+
rotating mqttPassword, which never leaves memory - see
|
|
244
|
+
``serializable_login_info``) and it shares ONE dict across every device
|
|
245
|
+
client on the
|
|
215
246
|
account so a single persistent-MQTT connection can be cached on it (see
|
|
216
247
|
``camera/client.py``'s ``_get_persistent_mqtt``).
|
|
217
248
|
|
|
@@ -240,12 +271,23 @@ class CameraClient(_UpstreamAidotClient):
|
|
|
240
271
|
Anything that persists ``login_info`` - this library's own standalone
|
|
241
272
|
CLI, or an integration's config-entry storage - should call this instead
|
|
242
273
|
of serializing ``login_info`` directly.
|
|
274
|
+
|
|
275
|
+
The MQTT password is stripped here rather than only where it is fetched.
|
|
276
|
+
Confirmed live on a real account: an entry written by an earlier version
|
|
277
|
+
still had the credential nested inside ``_userConfigRaw``, and that blob
|
|
278
|
+
is loaded back verbatim from the stored token, so sanitizing only on the
|
|
279
|
+
way in would carry an already-leaked password forward for the life of the
|
|
280
|
+
install. Stripping on the way out is the one choke point every persist
|
|
281
|
+
goes through.
|
|
243
282
|
"""
|
|
244
|
-
|
|
283
|
+
out = {
|
|
245
284
|
k: v
|
|
246
285
|
for k, v in self.login_info.items()
|
|
247
286
|
if k not in RUNTIME_ONLY_LOGIN_INFO_KEYS
|
|
248
287
|
}
|
|
288
|
+
if "_userConfigRaw" in out:
|
|
289
|
+
out["_userConfigRaw"] = _without_mqtt_password(out["_userConfigRaw"])
|
|
290
|
+
return out
|
|
249
291
|
|
|
250
292
|
# ---------------------------------------------------------------- #
|
|
251
293
|
# token lifecycle
|
|
@@ -371,6 +413,39 @@ class CameraClient(_UpstreamAidotClient):
|
|
|
371
413
|
self._schedule_proactive_refresh(self._token_ttl())
|
|
372
414
|
return self.login_info
|
|
373
415
|
|
|
416
|
+
async def async_ensure_mqtt_credential(self) -> None:
|
|
417
|
+
"""Fetch a fresh MQTT password, coalescing concurrent callers into one call.
|
|
418
|
+
|
|
419
|
+
Camera clients call this whenever the shared ``login_info`` holds no MQTT
|
|
420
|
+
password, which is the normal state after a restart from a stored token
|
|
421
|
+
(the password is never persisted) and after the broker refused the one we
|
|
422
|
+
had. Several cameras can hit that at once - a stream open racing a
|
|
423
|
+
command publish - and only the ``_get_persistent_mqtt`` path holds a lock,
|
|
424
|
+
so without coalescing they would each fire their own ``userConfig``
|
|
425
|
+
request and each rotate the credential out from under the others.
|
|
426
|
+
"""
|
|
427
|
+
fut = self._user_config_inflight
|
|
428
|
+
if fut is not None:
|
|
429
|
+
await asyncio.shield(fut)
|
|
430
|
+
return
|
|
431
|
+
loop = asyncio.get_running_loop()
|
|
432
|
+
fut = self._user_config_inflight = loop.create_future()
|
|
433
|
+
try:
|
|
434
|
+
await self._async_fetch_user_config()
|
|
435
|
+
if not fut.done():
|
|
436
|
+
fut.set_result(None)
|
|
437
|
+
except BaseException as exc:
|
|
438
|
+
if not fut.done():
|
|
439
|
+
fut.set_exception(exc)
|
|
440
|
+
raise
|
|
441
|
+
finally:
|
|
442
|
+
if self._user_config_inflight is fut:
|
|
443
|
+
self._user_config_inflight = None
|
|
444
|
+
# Nobody may have joined this flight, in which case the exception is
|
|
445
|
+
# never retrieved and asyncio logs about it at GC time. Consume it.
|
|
446
|
+
if fut.done() and not fut.cancelled():
|
|
447
|
+
fut.exception()
|
|
448
|
+
|
|
374
449
|
async def _async_fetch_user_config(self) -> None:
|
|
375
450
|
"""Fetch /commons/userConfig and store mqttPassword in login_info.
|
|
376
451
|
|
|
@@ -411,8 +486,12 @@ class CameraClient(_UpstreamAidotClient):
|
|
|
411
486
|
data = body if isinstance(body, dict) else {}
|
|
412
487
|
if isinstance(data.get("data"), dict):
|
|
413
488
|
data = data["data"]
|
|
414
|
-
# Always store the raw response for the camera clients to inspect
|
|
415
|
-
|
|
489
|
+
# Always store the raw response for the camera clients to inspect -
|
|
490
|
+
# minus the credential. Only mqttClientId is ever read back out of
|
|
491
|
+
# it, and this dict IS persisted (it is not a runtime-only key), so
|
|
492
|
+
# keeping the password here would write it to storage one level below
|
|
493
|
+
# the key that was deliberately stripped.
|
|
494
|
+
login_info["_userConfigRaw"] = _without_mqtt_password(data)
|
|
416
495
|
# Password may be at top level OR nested under an 'mqtt' subkey.
|
|
417
496
|
mqtt_block = data.get("mqtt") or {}
|
|
418
497
|
if isinstance(mqtt_block, str):
|
|
@@ -568,6 +647,12 @@ class CameraClient(_UpstreamAidotClient):
|
|
|
568
647
|
# Let the camera HTTP calls force a token refresh on 21026
|
|
569
648
|
# ("Please login again") and retry.
|
|
570
649
|
device_client.set_token_refresh_cb(self.async_ensure_token)
|
|
650
|
+
# And let them fetch an MQTT password on demand. It is never
|
|
651
|
+
# persisted (it rotates on every account login), so on a restart from
|
|
652
|
+
# a stored token nothing has fetched one yet.
|
|
653
|
+
device_client.set_mqtt_credential_refresh_cb(
|
|
654
|
+
self.async_ensure_mqtt_credential
|
|
655
|
+
)
|
|
571
656
|
self._device_clients[_device.id] = device_client
|
|
572
657
|
# Pre-warm the ICE config cache so stream open does not block on it.
|
|
573
658
|
_spawn_bg(_prefetch_ice_config(device_client))
|
|
@@ -54,6 +54,18 @@ BASE_URL = API_URL_TEMPLATE.format(region=DEFAULT_REGION)
|
|
|
54
54
|
# exclude them first - see CameraClient.serializable_login_info().
|
|
55
55
|
LOGIN_INFO_PERSISTENT_MQTT_KEY = "_persistent_mqtt"
|
|
56
56
|
LOGIN_INFO_PERSISTENT_MQTT_LOCK_KEY = "_persistent_mqtt_lock"
|
|
57
|
+
|
|
58
|
+
# The MQTT password is a CACHE, not state, and must never be persisted. The
|
|
59
|
+
# broker issues a new one on every account login and allows a single connection,
|
|
60
|
+
# so a stored copy is stale the moment anything else logs in (the phone app is
|
|
61
|
+
# enough). Persisting it made the failure survive restarts: the credential
|
|
62
|
+
# fetch prefers the cached value, so a stale one was reloaded from disk on every
|
|
63
|
+
# start and the broker refused it forever (rc=134), which killed camera
|
|
64
|
+
# signaling while snapshots kept working. Keeping it runtime-only means each
|
|
65
|
+
# start fetches a fresh one - a single extra HTTP call.
|
|
66
|
+
LOGIN_INFO_MQTT_PASSWORD_KEYS = ("mqttPassword", "mqttPwd")
|
|
67
|
+
|
|
57
68
|
RUNTIME_ONLY_LOGIN_INFO_KEYS = frozenset(
|
|
58
|
-
{LOGIN_INFO_PERSISTENT_MQTT_KEY, LOGIN_INFO_PERSISTENT_MQTT_LOCK_KEY
|
|
69
|
+
{LOGIN_INFO_PERSISTENT_MQTT_KEY, LOGIN_INFO_PERSISTENT_MQTT_LOCK_KEY,
|
|
70
|
+
*LOGIN_INFO_MQTT_PASSWORD_KEYS}
|
|
59
71
|
)
|
|
@@ -21,6 +21,7 @@ import logging
|
|
|
21
21
|
from typing import Any, Optional
|
|
22
22
|
|
|
23
23
|
from aidot.device_client import DeviceClient as _UpstreamDeviceClient
|
|
24
|
+
from aidot.device_client import DeviceState as _UpstreamDeviceState
|
|
24
25
|
from aidot.device_client import DeviceInformation as _UpstreamDeviceInformation
|
|
25
26
|
from aidot.device_client import DeviceStatusData as _UpstreamDeviceStatusData
|
|
26
27
|
from aidot.models.auth_model import UserInformation
|
|
@@ -53,6 +54,11 @@ _LOGGER = logging.getLogger(__name__)
|
|
|
53
54
|
# undeclared transitive dependency. This is upstream's class, not a subclass.
|
|
54
55
|
DeviceClient = _UpstreamDeviceClient
|
|
55
56
|
|
|
57
|
+
# Same reasoning for the LAN session state enum: a consumer that wants to know
|
|
58
|
+
# whether a device client is authenticated has to compare against it, and
|
|
59
|
+
# reaching into `aidot` for it means depending on a package it never declared.
|
|
60
|
+
DeviceState = _UpstreamDeviceState
|
|
61
|
+
|
|
56
62
|
|
|
57
63
|
class DeviceStatusData(_UpstreamDeviceStatusData):
|
|
58
64
|
"""Upstream status plus the carried `active_color_mode` tracking."""
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-aidot-cameras"
|
|
7
|
-
version = "0.12.
|
|
7
|
+
version = "0.12.4"
|
|
8
8
|
description = "Control AiDot/Leedarson WiFi lights and cameras (WebRTC streaming, two-way audio, PTZ, controls)"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
{python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4/python_aidot_cameras.egg-info}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-aidot-cameras
|
|
3
|
-
Version: 0.12.
|
|
3
|
+
Version: 0.12.4
|
|
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
|
|
@@ -91,7 +91,9 @@ tests/test_light_active_color_mode.py
|
|
|
91
91
|
tests/test_live_stream_param.py
|
|
92
92
|
tests/test_logging_caps.py
|
|
93
93
|
tests/test_motion_poll.py
|
|
94
|
+
tests/test_mqtt_credential_refetch.py
|
|
94
95
|
tests/test_mqtt_credential_self_heal.py
|
|
96
|
+
tests/test_mqtt_password_not_persisted.py
|
|
95
97
|
tests/test_mqtt_session_reconnect.py
|
|
96
98
|
tests/test_narrow_pc_ice.py
|
|
97
99
|
tests/test_no_undefined_names.py
|