python-aidot-cameras 0.12.0__tar.gz → 0.12.2__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 (135) hide show
  1. {python_aidot_cameras-0.12.0/python_aidot_cameras.egg-info → python_aidot_cameras-0.12.2}/PKG-INFO +1 -1
  2. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/aidot_cameras/camera/client.py +32 -3
  3. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/aidot_cameras/camera/protocol.py +30 -1
  4. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/aidot_cameras/client.py +19 -4
  5. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/pyproject.toml +1 -1
  6. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  7. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/python_aidot_cameras.egg-info/SOURCES.txt +1 -0
  8. python_aidot_cameras-0.12.2/tests/test_mqtt_credential_self_heal.py +52 -0
  9. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/LICENSE +0 -0
  10. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/MANIFEST.in +0 -0
  11. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/README.md +0 -0
  12. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/aidot_cameras/__init__.py +0 -0
  13. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/aidot_cameras/__main__.py +0 -0
  14. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/aidot_cameras/_vendor/__init__.py +0 -0
  15. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/aidot_cameras/_vendor/aiortc/__init__.py +0 -0
  16. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/aidot_cameras/_vendor/aiortc/clock.py +0 -0
  17. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/aidot_cameras/_vendor/aiortc/codecs/__init__.py +0 -0
  18. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/aidot_cameras/_vendor/aiortc/codecs/base.py +0 -0
  19. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/aidot_cameras/_vendor/aiortc/codecs/g711.py +0 -0
  20. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/aidot_cameras/_vendor/aiortc/codecs/g722.py +0 -0
  21. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/aidot_cameras/_vendor/aiortc/codecs/h264.py +0 -0
  22. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/aidot_cameras/_vendor/aiortc/codecs/opus.py +0 -0
  23. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/aidot_cameras/_vendor/aiortc/codecs/vpx.py +0 -0
  24. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/aidot_cameras/_vendor/aiortc/contrib/__init__.py +0 -0
  25. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/aidot_cameras/_vendor/aiortc/contrib/media.py +0 -0
  26. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/aidot_cameras/_vendor/aiortc/contrib/signaling.py +0 -0
  27. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/aidot_cameras/_vendor/aiortc/events.py +0 -0
  28. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/aidot_cameras/_vendor/aiortc/exceptions.py +0 -0
  29. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/aidot_cameras/_vendor/aiortc/jitterbuffer.py +0 -0
  30. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/aidot_cameras/_vendor/aiortc/mediastreams.py +0 -0
  31. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/aidot_cameras/_vendor/aiortc/rate.py +0 -0
  32. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/aidot_cameras/_vendor/aiortc/rtcconfiguration.py +0 -0
  33. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/aidot_cameras/_vendor/aiortc/rtcdatachannel.py +0 -0
  34. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/aidot_cameras/_vendor/aiortc/rtcdtlstransport.py +0 -0
  35. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/aidot_cameras/_vendor/aiortc/rtcicetransport.py +0 -0
  36. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/aidot_cameras/_vendor/aiortc/rtcpeerconnection.py +0 -0
  37. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/aidot_cameras/_vendor/aiortc/rtcrtpparameters.py +0 -0
  38. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/aidot_cameras/_vendor/aiortc/rtcrtpreceiver.py +0 -0
  39. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/aidot_cameras/_vendor/aiortc/rtcrtpsender.py +0 -0
  40. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/aidot_cameras/_vendor/aiortc/rtcrtptransceiver.py +0 -0
  41. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/aidot_cameras/_vendor/aiortc/rtcsctptransport.py +0 -0
  42. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/aidot_cameras/_vendor/aiortc/rtcsessiondescription.py +0 -0
  43. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/aidot_cameras/_vendor/aiortc/rtp.py +0 -0
  44. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/aidot_cameras/_vendor/aiortc/sdp.py +0 -0
  45. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/aidot_cameras/_vendor/aiortc/stats.py +0 -0
  46. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/aidot_cameras/_vendor/aiortc/utils.py +0 -0
  47. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/aidot_cameras/camera/__init__.py +0 -0
  48. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/aidot_cameras/camera/constants.py +0 -0
  49. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/aidot_cameras/camera/controls.py +0 -0
  50. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/aidot_cameras/camera/go2rtc.py +0 -0
  51. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/aidot_cameras/camera/lan_control.py +0 -0
  52. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/aidot_cameras/camera/models.py +0 -0
  53. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/aidot_cameras/camera/playback.py +0 -0
  54. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/aidot_cameras/camera/sdes.py +0 -0
  55. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/aidot_cameras/camera/sdes_open.py +0 -0
  56. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/aidot_cameras/camera/tutk.py +0 -0
  57. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/aidot_cameras/camera/webrtc.py +0 -0
  58. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/aidot_cameras/camera/webrtc_open.py +0 -0
  59. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/aidot_cameras/const.py +0 -0
  60. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/aidot_cameras/credentials.py +0 -0
  61. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/aidot_cameras/crypto.py +0 -0
  62. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/aidot_cameras/device_client.py +0 -0
  63. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/aidot_cameras/discover.py +0 -0
  64. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/aidot_cameras/exceptions.py +0 -0
  65. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/aidot_cameras/g711.py +0 -0
  66. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/aidot_cameras/py.typed +0 -0
  67. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  68. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
  69. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/python_aidot_cameras.egg-info/requires.txt +0 -0
  70. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/python_aidot_cameras.egg-info/top_level.txt +0 -0
  71. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/setup.cfg +0 -0
  72. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_aioice_compat.py +0 -0
  73. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_alarm_event.py +0 -0
  74. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_backoff.py +0 -0
  75. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_deferred_hardening.py +0 -0
  76. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_device_login_guard.py +0 -0
  77. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_device_user_info_cache.py +0 -0
  78. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_dtls_not_ready_burst.py +0 -0
  79. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_dtls_pinning.py +0 -0
  80. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_dtls_serve_decode_skip.py +0 -0
  81. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_dtls_serve_open_timeout.py +0 -0
  82. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_dtls_skip_signaling_wait.py +0 -0
  83. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_dtls_slow_probe.py +0 -0
  84. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_egress_guard.py +0 -0
  85. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_go2rtc.py +0 -0
  86. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_go2rtc_cli.py +0 -0
  87. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_h264_decode_rate_limit.py +0 -0
  88. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_highport_nomination.py +0 -0
  89. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_ice_config_cache.py +0 -0
  90. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_keepalive_renew.py +0 -0
  91. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_keyframe_prompter.py +0 -0
  92. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_lan_control.py +0 -0
  93. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_light_active_color_mode.py +0 -0
  94. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_live_stream_param.py +0 -0
  95. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_logging_caps.py +0 -0
  96. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_motion_poll.py +0 -0
  97. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_mqtt_session_reconnect.py +0 -0
  98. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_narrow_pc_ice.py +0 -0
  99. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_no_undefined_names.py +0 -0
  100. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_offline_keepalive_pause.py +0 -0
  101. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_open_fail_logger.py +0 -0
  102. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_open_gate_delay.py +0 -0
  103. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_persistent_mqtt.py +0 -0
  104. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_persistent_mqtt_robustness.py +0 -0
  105. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_playback_tls.py +0 -0
  106. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_post_merge_hardening.py +0 -0
  107. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_retry_policy.py +0 -0
  108. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_sdes_adaptive.py +0 -0
  109. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_sdes_bridge_break_gate.py +0 -0
  110. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_sdes_echo_wait_timeout.py +0 -0
  111. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_sdes_fast_liveplay.py +0 -0
  112. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_sdes_ffmpeg_stderr_log.py +0 -0
  113. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_sdes_idle_release.py +0 -0
  114. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_sdes_open_cleanup.py +0 -0
  115. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_sdes_serve_audio.py +0 -0
  116. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_sdes_serve_cmd.py +0 -0
  117. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_sdes_sprop.py +0 -0
  118. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_sdes_talk.py +0 -0
  119. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_sdes_teardown_exit_log.py +0 -0
  120. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_sdes_watchdog.py +0 -0
  121. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_security_hardening.py +0 -0
  122. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_serializable_login_info.py +0 -0
  123. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_serve_relay.py +0 -0
  124. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_session_stats.py +0 -0
  125. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_speak.py +0 -0
  126. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_stream_cap.py +0 -0
  127. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_stream_drain_ownership.py +0 -0
  128. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_stream_idle.py +0 -0
  129. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_stream_teardown.py +0 -0
  130. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_talk.py +0 -0
  131. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_terminal_ack.py +0 -0
  132. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_token_file_robustness.py +0 -0
  133. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_token_refresh.py +0 -0
  134. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/tests/test_upstream_compat.py +0 -0
  135. {python_aidot_cameras-0.12.0 → python_aidot_cameras-0.12.2}/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.0
3
+ Version: 0.12.2
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
@@ -2645,9 +2645,15 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2645
2645
 
2646
2646
  async def _deregister_go2rtc(self) -> None:
2647
2647
  """Remove this camera's stream from go2rtc (best-effort)."""
2648
- base, pull = self._go2rtc_url, self._go2rtc_pull_url
2648
+ base = self._go2rtc_url
2649
2649
  self._go2rtc_pull_url = None
2650
- if not (base and pull):
2650
+ # Deliberately NOT gated on `pull`: go2rtc answers the register call with
2651
+ # 400 when it cannot immediately validate the source (a camera that has
2652
+ # not started producing yet), yet it still keeps the stream registered.
2653
+ # ensure_stream reports that as failure, so pull stays None - and gating
2654
+ # on it here leaked one dead stream per attempt, which then sat in go2rtc
2655
+ # with a producer nothing was feeding.
2656
+ if not base:
2651
2657
  return
2652
2658
  import aiohttp
2653
2659
  from .go2rtc import Go2rtcClient
@@ -3396,7 +3402,30 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3396
3402
  if not mqtt_url:
3397
3403
  return None
3398
3404
  from .protocol import _PersistentMqtt
3399
- pm = _PersistentMqtt(mqtt_url, mqtt_user, mqtt_pwd, client_id)
3405
+
3406
+ def _invalidate_mqtt_credentials(rc) -> None:
3407
+ """Drop the cached MQTT password so the next use re-fetches it.
3408
+
3409
+ The broker rotates this password on every account login and
3410
+ allows one connection at a time, so any other login (the phone
3411
+ app, a second Home Assistant, a script) invalidates ours. The
3412
+ cached copy lives in login_info, where it short-circuits the
3413
+ 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.
3416
+ """
3417
+ for key in ("mqttPassword", "mqttPwd"):
3418
+ if li.pop(key, None) is not None:
3419
+ _LOGGER.warning(
3420
+ "cleared the cached MQTT password after the broker "
3421
+ "rejected it (rc=%s); it will be re-fetched", rc,
3422
+ )
3423
+ self._smarthome_auth = None
3424
+ if li.get(LOGIN_INFO_PERSISTENT_MQTT_KEY) is pm:
3425
+ li.pop(LOGIN_INFO_PERSISTENT_MQTT_KEY, None)
3426
+
3427
+ pm = _PersistentMqtt(mqtt_url, mqtt_user, mqtt_pwd, client_id,
3428
+ on_auth_failure=_invalidate_mqtt_credentials)
3400
3429
  li[LOGIN_INFO_PERSISTENT_MQTT_KEY] = pm
3401
3430
  return pm
3402
3431
 
@@ -1414,12 +1414,26 @@ class _PersistentMqtt:
1414
1414
  N operations cost ONE connect instead of N. The client_id is account-level,
1415
1415
  so exactly one of these should exist per account (per AidotClient)."""
1416
1416
 
1417
- def __init__(self, mqtt_url, mqtt_user, mqtt_pwd, client_id, ws_path="/mqtt"):
1417
+ # Connect return codes meaning "these credentials are not acceptable"
1418
+ # rather than "try again later". 4/5 are MQTT 3.1.1 (bad user/pass, not
1419
+ # authorized); 134/135 are the MQTT 5 equivalents (0x86/0x87). The AiDot
1420
+ # broker rotates the MQTT password on every account login and allows one
1421
+ # connection at a time, so a cached password can go stale at any moment -
1422
+ # a login from the phone app is enough. Retrying it forever is useless;
1423
+ # the owner has to fetch a fresh credential.
1424
+ _AUTH_REFUSAL_RCS = frozenset({4, 5, 134, 135})
1425
+
1426
+ def __init__(self, mqtt_url, mqtt_user, mqtt_pwd, client_id, ws_path="/mqtt",
1427
+ on_auth_failure=None):
1418
1428
  self._url = mqtt_url
1419
1429
  self._user = mqtt_user
1420
1430
  self._pwd = mqtt_pwd
1421
1431
  self._cid = client_id
1422
1432
  self._ws_path = ws_path
1433
+ # Invoked (once per instance) when the broker rejects our credentials so
1434
+ # the owner can invalidate its cache and re-authenticate.
1435
+ self._on_auth_failure = on_auth_failure
1436
+ self._auth_failed_reported = False
1423
1437
  self._client = None
1424
1438
  self._host = None
1425
1439
  self._port = None
@@ -1476,6 +1490,21 @@ class _PersistentMqtt:
1476
1490
  except Exception:
1477
1491
  _LOGGER.debug("persistent mqtt: resubscribe %s failed", t)
1478
1492
  self._connected.set()
1493
+ elif rc in self._AUTH_REFUSAL_RCS:
1494
+ # Stale credential: paho would retry this password forever. Tell the
1495
+ # owner once so it can invalidate its cache and re-authenticate.
1496
+ _LOGGER.warning(
1497
+ "persistent mqtt: broker rejected our credentials (rc=%s) - the "
1498
+ "MQTT password has almost certainly been rotated by another "
1499
+ "login; requesting a fresh one", rc,
1500
+ )
1501
+ if self._on_auth_failure and not self._auth_failed_reported:
1502
+ self._auth_failed_reported = True
1503
+ try:
1504
+ self._on_auth_failure(rc)
1505
+ except Exception:
1506
+ _LOGGER.debug("persistent mqtt: on_auth_failure raised",
1507
+ exc_info=True)
1479
1508
  else:
1480
1509
  _LOGGER.warning("persistent mqtt: broker refused rc=%s", rc)
1481
1510
 
@@ -464,6 +464,13 @@ class CameraClient(_UpstreamAidotClient):
464
464
  The HTTP calls themselves are still upstream's ``CloudApi``.
465
465
  """
466
466
  final_device_list: list[dict[str, Any]] = []
467
+ # Devices upstream's client cannot construct (no usable aesKey): Zigbee
468
+ # sub-devices, remotes, and other accessories that were never supported
469
+ # here. They are counted and reported once, not warned about
470
+ # individually - an account with a dozen Zigbee sensors would otherwise
471
+ # log a dozen WARNINGs on every refresh for devices nothing was ever
472
+ # going to use.
473
+ unbuildable: dict[str, int] = {}
467
474
  houses = await self._cloud_api.get_houses() or []
468
475
  for house in houses:
469
476
  if house.get(CONF_IS_OWNER) is False:
@@ -473,11 +480,19 @@ class CameraClient(_UpstreamAidotClient):
473
480
  if _is_camera_device(device) or _upstream_can_build(device):
474
481
  final_device_list.append(device)
475
482
  else:
476
- _LOGGER.warning(
477
- "skipping device %s (%s): no AES key, upstream's device "
478
- "client cannot be built for it",
479
- device.get(CONF_ID), device.get(CONF_MODEL_ID),
483
+ model = device.get(CONF_MODEL_ID) or "unknown"
484
+ unbuildable[model] = unbuildable.get(model, 0) + 1
485
+ _LOGGER.debug(
486
+ "skipping device %s (%s): no usable aesKey, upstream's "
487
+ "device client cannot be built for it",
488
+ device.get(CONF_ID), model,
480
489
  )
490
+ if unbuildable:
491
+ _LOGGER.info(
492
+ "skipped %d device(s) with no usable aesKey (not supported here): %s",
493
+ sum(unbuildable.values()),
494
+ ", ".join(f"{m} x{n}" for m, n in sorted(unbuildable.items())),
495
+ )
481
496
 
482
497
  product_ids = ",".join(
483
498
  sorted(
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-aidot-cameras"
7
- version = "0.12.0"
7
+ version = "0.12.2"
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.0
3
+ Version: 0.12.2
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,6 +91,7 @@ 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_self_heal.py
94
95
  tests/test_mqtt_session_reconnect.py
95
96
  tests/test_narrow_pc_ice.py
96
97
  tests/test_no_undefined_names.py
@@ -0,0 +1,52 @@
1
+ """The broker rotates the MQTT password on every account login and allows one
2
+ connection at a time, so a cached password goes stale whenever anything else
3
+ logs in (the phone app is enough). paho would retry the dead password forever,
4
+ so a credential refusal has to invalidate the cache instead.
5
+ """
6
+ import aidot_cameras.camera.protocol as proto
7
+
8
+
9
+ def _pm(on_auth_failure=None):
10
+ return proto._PersistentMqtt(
11
+ "wss://broker.example:8443/mqtt", "user", "stale-pwd", "app-user",
12
+ on_auth_failure=on_auth_failure,
13
+ )
14
+
15
+
16
+ def test_auth_refusal_codes_cover_mqtt3_and_mqtt5():
17
+ # 4/5 are MQTT 3.1.1 bad-user-pass / not-authorized; 134/135 are the MQTT 5
18
+ # equivalents (0x86/0x87) - the AiDot broker answers with 134.
19
+ for rc in (4, 5, 134, 135):
20
+ assert rc in proto._PersistentMqtt._AUTH_REFUSAL_RCS, rc
21
+
22
+
23
+ def test_credential_refusal_invokes_the_callback_once():
24
+ seen = []
25
+ pm = _pm(on_auth_failure=seen.append)
26
+ pm._on_connect(None, None, {}, 134)
27
+ pm._on_connect(None, None, {}, 134) # repeated refusal must not re-notify
28
+ assert seen == [134]
29
+
30
+
31
+ def test_transient_refusal_does_not_invalidate_credentials():
32
+ # rc=3 is "server unavailable" - retrying the same password is correct.
33
+ seen = []
34
+ pm = _pm(on_auth_failure=seen.append)
35
+ pm._on_connect(None, None, {}, 3)
36
+ assert seen == []
37
+
38
+
39
+ def test_successful_connect_does_not_invalidate_credentials():
40
+ seen = []
41
+ pm = _pm(on_auth_failure=seen.append)
42
+ pm._on_connect(None, None, {}, 0)
43
+ assert seen == []
44
+ assert pm._connected.is_set()
45
+
46
+
47
+ def test_callback_exception_is_contained():
48
+ def boom(rc):
49
+ raise RuntimeError("callback exploded")
50
+
51
+ pm = _pm(on_auth_failure=boom)
52
+ pm._on_connect(None, None, {}, 134) # must not propagate into paho's loop