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.
Files changed (137) hide show
  1. {python_aidot_cameras-0.12.2/python_aidot_cameras.egg-info → python_aidot_cameras-0.12.4}/PKG-INFO +1 -1
  2. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/camera/client.py +148 -19
  3. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/camera/protocol.py +52 -2
  4. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/client.py +94 -9
  5. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/const.py +13 -1
  6. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/device_client.py +6 -0
  7. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/pyproject.toml +1 -1
  8. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  9. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/python_aidot_cameras.egg-info/SOURCES.txt +2 -0
  10. python_aidot_cameras-0.12.4/tests/test_mqtt_credential_refetch.py +288 -0
  11. python_aidot_cameras-0.12.4/tests/test_mqtt_password_not_persisted.py +36 -0
  12. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_sdes_serve_audio.py +19 -12
  13. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_serializable_login_info.py +17 -10
  14. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_upstream_compat.py +13 -0
  15. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/LICENSE +0 -0
  16. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/MANIFEST.in +0 -0
  17. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/README.md +0 -0
  18. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/__init__.py +0 -0
  19. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/__main__.py +0 -0
  20. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/__init__.py +0 -0
  21. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/__init__.py +0 -0
  22. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/clock.py +0 -0
  23. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/codecs/__init__.py +0 -0
  24. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/codecs/base.py +0 -0
  25. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/codecs/g711.py +0 -0
  26. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/codecs/g722.py +0 -0
  27. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/codecs/h264.py +0 -0
  28. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/codecs/opus.py +0 -0
  29. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/codecs/vpx.py +0 -0
  30. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/contrib/__init__.py +0 -0
  31. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/contrib/media.py +0 -0
  32. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/contrib/signaling.py +0 -0
  33. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/events.py +0 -0
  34. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/exceptions.py +0 -0
  35. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/jitterbuffer.py +0 -0
  36. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/mediastreams.py +0 -0
  37. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/rate.py +0 -0
  38. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/rtcconfiguration.py +0 -0
  39. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/rtcdatachannel.py +0 -0
  40. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/rtcdtlstransport.py +0 -0
  41. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/rtcicetransport.py +0 -0
  42. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/rtcpeerconnection.py +0 -0
  43. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/rtcrtpparameters.py +0 -0
  44. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/rtcrtpreceiver.py +0 -0
  45. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/rtcrtpsender.py +0 -0
  46. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/rtcrtptransceiver.py +0 -0
  47. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/rtcsctptransport.py +0 -0
  48. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/rtcsessiondescription.py +0 -0
  49. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/rtp.py +0 -0
  50. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/sdp.py +0 -0
  51. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/stats.py +0 -0
  52. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/_vendor/aiortc/utils.py +0 -0
  53. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/camera/__init__.py +0 -0
  54. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/camera/constants.py +0 -0
  55. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/camera/controls.py +0 -0
  56. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/camera/go2rtc.py +0 -0
  57. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/camera/lan_control.py +0 -0
  58. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/camera/models.py +0 -0
  59. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/camera/playback.py +0 -0
  60. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/camera/sdes.py +0 -0
  61. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/camera/sdes_open.py +0 -0
  62. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/camera/tutk.py +0 -0
  63. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/camera/webrtc.py +0 -0
  64. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/camera/webrtc_open.py +0 -0
  65. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/credentials.py +0 -0
  66. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/crypto.py +0 -0
  67. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/discover.py +0 -0
  68. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/exceptions.py +0 -0
  69. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/g711.py +0 -0
  70. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/aidot_cameras/py.typed +0 -0
  71. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  72. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
  73. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/python_aidot_cameras.egg-info/requires.txt +0 -0
  74. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/python_aidot_cameras.egg-info/top_level.txt +0 -0
  75. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/setup.cfg +0 -0
  76. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_aioice_compat.py +0 -0
  77. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_alarm_event.py +0 -0
  78. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_backoff.py +0 -0
  79. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_deferred_hardening.py +0 -0
  80. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_device_login_guard.py +0 -0
  81. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_device_user_info_cache.py +0 -0
  82. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_dtls_not_ready_burst.py +0 -0
  83. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_dtls_pinning.py +0 -0
  84. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_dtls_serve_decode_skip.py +0 -0
  85. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_dtls_serve_open_timeout.py +0 -0
  86. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_dtls_skip_signaling_wait.py +0 -0
  87. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_dtls_slow_probe.py +0 -0
  88. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_egress_guard.py +0 -0
  89. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_go2rtc.py +0 -0
  90. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_go2rtc_cli.py +0 -0
  91. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_h264_decode_rate_limit.py +0 -0
  92. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_highport_nomination.py +0 -0
  93. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_ice_config_cache.py +0 -0
  94. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_keepalive_renew.py +0 -0
  95. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_keyframe_prompter.py +0 -0
  96. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_lan_control.py +0 -0
  97. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_light_active_color_mode.py +0 -0
  98. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_live_stream_param.py +0 -0
  99. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_logging_caps.py +0 -0
  100. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_motion_poll.py +0 -0
  101. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_mqtt_credential_self_heal.py +0 -0
  102. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_mqtt_session_reconnect.py +0 -0
  103. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_narrow_pc_ice.py +0 -0
  104. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_no_undefined_names.py +0 -0
  105. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_offline_keepalive_pause.py +0 -0
  106. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_open_fail_logger.py +0 -0
  107. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_open_gate_delay.py +0 -0
  108. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_persistent_mqtt.py +0 -0
  109. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_persistent_mqtt_robustness.py +0 -0
  110. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_playback_tls.py +0 -0
  111. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_post_merge_hardening.py +0 -0
  112. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_retry_policy.py +0 -0
  113. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_sdes_adaptive.py +0 -0
  114. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_sdes_bridge_break_gate.py +0 -0
  115. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_sdes_echo_wait_timeout.py +0 -0
  116. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_sdes_fast_liveplay.py +0 -0
  117. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_sdes_ffmpeg_stderr_log.py +0 -0
  118. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_sdes_idle_release.py +0 -0
  119. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_sdes_open_cleanup.py +0 -0
  120. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_sdes_serve_cmd.py +0 -0
  121. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_sdes_sprop.py +0 -0
  122. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_sdes_talk.py +0 -0
  123. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_sdes_teardown_exit_log.py +0 -0
  124. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_sdes_watchdog.py +0 -0
  125. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_security_hardening.py +0 -0
  126. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_serve_relay.py +0 -0
  127. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_session_stats.py +0 -0
  128. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_speak.py +0 -0
  129. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_stream_cap.py +0 -0
  130. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_stream_drain_ownership.py +0 -0
  131. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_stream_idle.py +0 -0
  132. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_stream_teardown.py +0 -0
  133. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_talk.py +0 -0
  134. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_terminal_ack.py +0 -0
  135. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_token_file_robustness.py +0 -0
  136. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_token_refresh.py +0 -0
  137. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.4}/tests/test_wait_or_event.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-aidot-cameras
3
- Version: 0.12.2
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 already in AiDot login_info (from _async_fetch_user_config)
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
- if self._smarthome_auth and self._smarthome_auth.get("mqttPassword"):
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 already in AiDot login_info ---
1209
- # _async_fetch_user_config() stores it under "mqttPassword" or "mqttPwd".
1210
- for key in ("mqttPassword", "mqttPwd"):
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 ON** for app-parity (the official app plays camera audio). The
3275
- serve feeds the AAC encoder a continuous silence base (anullsrc + amix) so
3276
- sparse battery PCMA streams cleanly through the mpegts mux. Per-camera
3277
- ``sdes_audio`` (via ``start_keepalive``) wins; else the
3278
- ``AIDOT_SDES_SERVE_AUDIO`` env (falsy = 0/false/no/off disables)."""
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() not in (
3283
- "0", "false", "no", "off")
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
- """Drop the cached MQTT password so the next use re-fetches it.
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. The persistent client itself is dropped too,
3415
- so the next request rebuilds it with fresh credentials.
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
- for key in ("mqttPassword", "mqttPwd"):
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
- if li.get(LOGIN_INFO_PERSISTENT_MQTT_KEY) is pm:
3425
- li.pop(LOGIN_INFO_PERSISTENT_MQTT_KEY, None)
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)
@@ -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 re-authenticate.
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
- return self._connected.wait(timeout)
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
- * ``_async_fetch_user_config()``, which fetches the MQTT password cameras need;
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
- # (mqttPassword, mqttClientId, _userConfigRaw). Carry them across
184
- # a restart on the login_info dict instead, or every reload would
185
- # re-fetch MQTT credentials it had already persisted.
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 (mqttPassword,
214
- mqttClientId) and it shares ONE dict across every device client on the
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
- return {
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
- login_info["_userConfigRaw"] = data
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.2"
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"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-aidot-cameras
3
- Version: 0.12.2
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