python-aidot-cameras 0.12.2__tar.gz → 0.12.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. {python_aidot_cameras-0.12.2/python_aidot_cameras.egg-info → python_aidot_cameras-0.12.6}/PKG-INFO +1 -1
  2. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/camera/client.py +143 -16
  3. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/camera/protocol.py +52 -2
  4. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/camera/sdes_open.py +119 -18
  5. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/client.py +94 -9
  6. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/const.py +13 -1
  7. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/device_client.py +6 -0
  8. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/pyproject.toml +1 -1
  9. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  10. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/python_aidot_cameras.egg-info/SOURCES.txt +3 -0
  11. python_aidot_cameras-0.12.6/tests/test_mqtt_credential_refetch.py +288 -0
  12. python_aidot_cameras-0.12.6/tests/test_mqtt_password_not_persisted.py +36 -0
  13. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_sdes_serve_audio.py +9 -3
  14. python_aidot_cameras-0.12.6/tests/test_sdp_payload_type_narrowing.py +83 -0
  15. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_serializable_login_info.py +17 -10
  16. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_upstream_compat.py +13 -0
  17. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/LICENSE +0 -0
  18. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/MANIFEST.in +0 -0
  19. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/README.md +0 -0
  20. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/__init__.py +0 -0
  21. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/__main__.py +0 -0
  22. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/__init__.py +0 -0
  23. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/__init__.py +0 -0
  24. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/clock.py +0 -0
  25. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/codecs/__init__.py +0 -0
  26. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/codecs/base.py +0 -0
  27. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/codecs/g711.py +0 -0
  28. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/codecs/g722.py +0 -0
  29. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/codecs/h264.py +0 -0
  30. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/codecs/opus.py +0 -0
  31. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/codecs/vpx.py +0 -0
  32. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/contrib/__init__.py +0 -0
  33. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/contrib/media.py +0 -0
  34. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/contrib/signaling.py +0 -0
  35. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/events.py +0 -0
  36. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/exceptions.py +0 -0
  37. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/jitterbuffer.py +0 -0
  38. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/mediastreams.py +0 -0
  39. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/rate.py +0 -0
  40. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/rtcconfiguration.py +0 -0
  41. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/rtcdatachannel.py +0 -0
  42. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/rtcdtlstransport.py +0 -0
  43. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/rtcicetransport.py +0 -0
  44. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/rtcpeerconnection.py +0 -0
  45. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/rtcrtpparameters.py +0 -0
  46. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/rtcrtpreceiver.py +0 -0
  47. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/rtcrtpsender.py +0 -0
  48. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/rtcrtptransceiver.py +0 -0
  49. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/rtcsctptransport.py +0 -0
  50. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/rtcsessiondescription.py +0 -0
  51. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/rtp.py +0 -0
  52. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/sdp.py +0 -0
  53. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/stats.py +0 -0
  54. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/utils.py +0 -0
  55. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/camera/__init__.py +0 -0
  56. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/camera/constants.py +0 -0
  57. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/camera/controls.py +0 -0
  58. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/camera/go2rtc.py +0 -0
  59. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/camera/lan_control.py +0 -0
  60. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/camera/models.py +0 -0
  61. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/camera/playback.py +0 -0
  62. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/camera/sdes.py +0 -0
  63. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/camera/tutk.py +0 -0
  64. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/camera/webrtc.py +0 -0
  65. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/camera/webrtc_open.py +0 -0
  66. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/credentials.py +0 -0
  67. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/crypto.py +0 -0
  68. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/discover.py +0 -0
  69. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/exceptions.py +0 -0
  70. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/g711.py +0 -0
  71. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/aidot_cameras/py.typed +0 -0
  72. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  73. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
  74. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/python_aidot_cameras.egg-info/requires.txt +0 -0
  75. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/python_aidot_cameras.egg-info/top_level.txt +0 -0
  76. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/setup.cfg +0 -0
  77. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_aioice_compat.py +0 -0
  78. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_alarm_event.py +0 -0
  79. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_backoff.py +0 -0
  80. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_deferred_hardening.py +0 -0
  81. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_device_login_guard.py +0 -0
  82. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_device_user_info_cache.py +0 -0
  83. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_dtls_not_ready_burst.py +0 -0
  84. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_dtls_pinning.py +0 -0
  85. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_dtls_serve_decode_skip.py +0 -0
  86. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_dtls_serve_open_timeout.py +0 -0
  87. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_dtls_skip_signaling_wait.py +0 -0
  88. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_dtls_slow_probe.py +0 -0
  89. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_egress_guard.py +0 -0
  90. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_go2rtc.py +0 -0
  91. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_go2rtc_cli.py +0 -0
  92. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_h264_decode_rate_limit.py +0 -0
  93. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_highport_nomination.py +0 -0
  94. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_ice_config_cache.py +0 -0
  95. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_keepalive_renew.py +0 -0
  96. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_keyframe_prompter.py +0 -0
  97. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_lan_control.py +0 -0
  98. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_light_active_color_mode.py +0 -0
  99. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_live_stream_param.py +0 -0
  100. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_logging_caps.py +0 -0
  101. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_motion_poll.py +0 -0
  102. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_mqtt_credential_self_heal.py +0 -0
  103. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_mqtt_session_reconnect.py +0 -0
  104. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_narrow_pc_ice.py +0 -0
  105. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_no_undefined_names.py +0 -0
  106. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_offline_keepalive_pause.py +0 -0
  107. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_open_fail_logger.py +0 -0
  108. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_open_gate_delay.py +0 -0
  109. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_persistent_mqtt.py +0 -0
  110. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_persistent_mqtt_robustness.py +0 -0
  111. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_playback_tls.py +0 -0
  112. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_post_merge_hardening.py +0 -0
  113. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_retry_policy.py +0 -0
  114. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_sdes_adaptive.py +0 -0
  115. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_sdes_bridge_break_gate.py +0 -0
  116. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_sdes_echo_wait_timeout.py +0 -0
  117. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_sdes_fast_liveplay.py +0 -0
  118. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_sdes_ffmpeg_stderr_log.py +0 -0
  119. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_sdes_idle_release.py +0 -0
  120. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_sdes_open_cleanup.py +0 -0
  121. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_sdes_serve_cmd.py +0 -0
  122. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_sdes_sprop.py +0 -0
  123. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_sdes_talk.py +0 -0
  124. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_sdes_teardown_exit_log.py +0 -0
  125. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_sdes_watchdog.py +0 -0
  126. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_security_hardening.py +0 -0
  127. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_serve_relay.py +0 -0
  128. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_session_stats.py +0 -0
  129. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_speak.py +0 -0
  130. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_stream_cap.py +0 -0
  131. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_stream_drain_ownership.py +0 -0
  132. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_stream_idle.py +0 -0
  133. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_stream_teardown.py +0 -0
  134. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_talk.py +0 -0
  135. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_terminal_ack.py +0 -0
  136. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_token_file_robustness.py +0 -0
  137. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/tests/test_token_refresh.py +0 -0
  138. {python_aidot_cameras-0.12.2 → python_aidot_cameras-0.12.6}/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.6
4
4
  Summary: Control AiDot/Leedarson WiFi lights and cameras (WebRTC streaming, two-way audio, PTZ, controls)
5
5
  Author-email: cbrightly <chris.brightly@gmail.com>
6
6
  License-Expression: MIT
@@ -11,6 +11,7 @@ from typing import Any, Callable, List, Optional
11
11
  from ..exceptions import AidotCameraBusy, AidotCameraNotReady
12
12
  from ..const import APP_ID as _AIDOT_APP_ID
13
13
  from ..const import (
14
+ LOGIN_INFO_MQTT_PASSWORD_KEYS,
14
15
  LOGIN_INFO_PERSISTENT_MQTT_KEY,
15
16
  LOGIN_INFO_PERSISTENT_MQTT_LOCK_KEY,
16
17
  )
@@ -141,6 +142,11 @@ _DTLS_SERVE_OPEN_TIMEOUT_S = _parse_env_float("AIDOT_DTLS_SERVE_OPEN_TIMEOUT_S",
141
142
  # discarded task can be garbage-collected mid-flight. Discarded on completion.
142
143
  _BG_TASKS: set = set()
143
144
 
145
+ # Minimum gap between MQTT credential refetches after the broker refused one.
146
+ # One rotation is normal (another login happened); a second refusal that fast
147
+ # means refetching is not helping, so back off rather than loop.
148
+ _MQTT_CREDENTIAL_REFETCH_FLOOR = 30.0
149
+
144
150
 
145
151
  def _spawn_bg(coro):
146
152
  _t = asyncio.ensure_future(coro)
@@ -656,6 +662,11 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
656
662
  # Optional LAN control client; when attached, attribute writes go local-first.
657
663
  _lan_client = None
658
664
 
665
+ # MQTT credential state, defaulted at class level so the credential path is
666
+ # safe on any client whose __init__ did not run to completion.
667
+ _mqtt_credential_refresh_cb = None
668
+ _mqtt_refused_at = 0.0
669
+
659
670
  def attach_lan_client(self, lan_client) -> None:
660
671
  """Route camera attribute writes through ``lan_client`` (local-first,
661
672
  cloud-fallback). Pass an ``aidot.camera.lan_control.CameraLanClient``."""
@@ -709,6 +720,15 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
709
720
  # updates the shared login_info in place, so camera HTTP calls can recover
710
721
  # from a 21026 "Please login again" instead of failing silently.
711
722
  self._token_refresh_cb: "Optional[Callable]" = None
723
+ # Async callback (set by AidotClient) that fetches a fresh MQTT password
724
+ # into the shared login_info. The password is deliberately never
725
+ # persisted (it rotates on every account login), so on a restart from a
726
+ # stored token there is none until this runs - see
727
+ # _async_get_smarthome_auth, which calls it before consulting login_info.
728
+ self._mqtt_credential_refresh_cb: "Optional[Callable]" = None
729
+ # Loop timestamp of the last broker credential refusal, for the refetch
730
+ # floor in _async_refresh_mqtt_credential.
731
+ self._mqtt_refused_at: float = 0.0
712
732
  # Raw device dict retained for transport-type detection (isDTLS field)
713
733
  self._raw_device: dict = device
714
734
 
@@ -945,6 +965,49 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
945
965
  """Register the AidotClient coroutine that refreshes the access token."""
946
966
  self._token_refresh_cb = cb
947
967
 
968
+ def set_mqtt_credential_refresh_cb(self, cb: "Callable") -> None:
969
+ """Register the AidotClient coroutine that re-fetches the MQTT password."""
970
+ self._mqtt_credential_refresh_cb = cb
971
+
972
+ def _shared_mqtt_password(self) -> Optional[str]:
973
+ """The account's current MQTT password, or None if it holds none.
974
+
975
+ Read from the shared ``login_info`` dict, which is the single source of
976
+ truth: it is what the fetch writes and what a broker refusal clears.
977
+ """
978
+ li = self._user_info if isinstance(self._user_info, dict) else {}
979
+ for key in LOGIN_INFO_MQTT_PASSWORD_KEYS:
980
+ val = li.get(key)
981
+ if val:
982
+ return val
983
+ return None
984
+
985
+ async def _async_refresh_mqtt_credential(self) -> None:
986
+ """Ask the account client for a fresh MQTT password, if it can supply one.
987
+
988
+ Never raises: every caller has fallback strategies, so a failed fetch
989
+ should degrade rather than break a stream open.
990
+ """
991
+ cb = self._mqtt_credential_refresh_cb
992
+ if cb is None:
993
+ return
994
+ # Floor between attempts. If the freshly fetched password is refused too
995
+ # - another login rotated again, or the endpoint hands back the same value
996
+ # - every stream open would otherwise cost another credential request and
997
+ # another refused connection. Better to fail this open and let the next
998
+ # one try than to hammer the vendor's endpoint.
999
+ since = asyncio.get_running_loop().time() - self._mqtt_refused_at
1000
+ if self._mqtt_refused_at and since < _MQTT_CREDENTIAL_REFETCH_FLOOR:
1001
+ _LOGGER.debug(
1002
+ "skipping MQTT credential refetch: only %.1fs since the last "
1003
+ "refusal (floor %ss)", since, _MQTT_CREDENTIAL_REFETCH_FLOOR,
1004
+ )
1005
+ return
1006
+ try:
1007
+ await cb()
1008
+ except Exception as exc:
1009
+ _LOGGER.debug("MQTT credential refresh failed: %s", exc)
1010
+
948
1011
  @staticmethod
949
1012
  def _is_auth_error(data: Any) -> bool:
950
1013
  """True if a smarthome response means the token is stale (re-login)."""
@@ -1186,15 +1249,35 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
1186
1249
  """Fetch MQTT credentials (mqttUser + mqttPassword) for the Arnoo broker.
1187
1250
 
1188
1251
  Strategy order - stops at first success:
1189
- 0. Already cached
1190
- 1. mqttPassword 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,10 +3360,19 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3271
3360
  def _resolve_sdes_serve_audio(self) -> bool:
3272
3361
  """Whether to include audio in the SDES camera serve.
3273
3362
 
3274
- **Default ON** for app-parity (the official app plays camera audio). 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
3363
+ **Default ON** for app-parity (the official app plays camera audio).
3364
+
3365
+ This was briefly defaulted off, because mapping audio used to serve no
3366
+ video at all: the SDP advertises two payload types per media line and
3367
+ ffmpeg binds the first, so the synthesised PCMA (pt 8) was discarded in
3368
+ favour of the advertised PCMU (pt 0) - and since the mpegts mux withholds
3369
+ its PAT/PMT until every mapped stream has produced a packet, the consumer
3370
+ got zero bytes and lost the picture too. The audio line is now narrowed to
3371
+ the payload type actually in use, the same way the video line already was,
3372
+ and the serve falls back to video-only if the audio payload type cannot be
3373
+ characterised in time. So audio can no longer cost the video.
3374
+
3375
+ Per-camera ``sdes_audio`` (via ``start_keepalive``) wins; else the
3278
3376
  ``AIDOT_SDES_SERVE_AUDIO`` env (falsy = 0/false/no/off disables)."""
3279
3377
  opt = getattr(self, "_sdes_audio_opt", None)
3280
3378
  if opt is not None:
@@ -3403,26 +3501,55 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3403
3501
  return None
3404
3502
  from .protocol import _PersistentMqtt
3405
3503
 
3504
+ # paho fires on_connect on its own network thread; every mutation
3505
+ # below has to be ordered against the get-or-create above, so it is
3506
+ # hopped onto this loop and taken under the same account lock.
3507
+ _loop = asyncio.get_running_loop()
3508
+
3406
3509
  def _invalidate_mqtt_credentials(rc) -> None:
3407
- """Drop the cached MQTT password so the next use re-fetches it.
3510
+ """Retire the refused client and drop the credential it used.
3408
3511
 
3409
3512
  The broker rotates this password on every account login and
3410
3513
  allows one connection at a time, so any other login (the phone
3411
3514
  app, a second Home Assistant, a script) invalidates ours. The
3412
3515
  cached copy lives in login_info, where it short-circuits the
3413
3516
  credential fetch, so it has to be cleared or every retry reuses
3414
- the dead password. The persistent client itself is dropped too,
3415
- so the next request rebuilds it with fresh credentials.
3517
+ the dead password.
3518
+
3519
+ Runs on paho's network thread, so it only schedules the work: the
3520
+ mutations must be ordered against a concurrent get-or-create, and
3521
+ ``retire()`` calls ``loop_stop()``, which raises if called from
3522
+ the very thread it would join.
3416
3523
  """
3417
- for key in ("mqttPassword", "mqttPwd"):
3524
+ _loop.call_soon_threadsafe(_spawn_bg, _retire_and_invalidate(rc))
3525
+
3526
+ async def _retire_and_invalidate(rc) -> None:
3527
+ # Deliberately outside the lock. These are plain dict writes with
3528
+ # no ordering requirement, and a sibling can be holding the lock
3529
+ # across an HTTP credential fetch - taking it here would stall the
3530
+ # invalidation behind that request's timeout for no benefit.
3531
+ for key in LOGIN_INFO_MQTT_PASSWORD_KEYS:
3418
3532
  if li.pop(key, None) is not None:
3419
3533
  _LOGGER.warning(
3420
3534
  "cleared the cached MQTT password after the broker "
3421
3535
  "rejected it (rc=%s); it will be re-fetched", rc,
3422
3536
  )
3423
3537
  self._smarthome_auth = None
3424
- if li.get(LOGIN_INFO_PERSISTENT_MQTT_KEY) is pm:
3425
- li.pop(LOGIN_INFO_PERSISTENT_MQTT_KEY, None)
3538
+ # The URL response is the one that can carry a server-issued
3539
+ # password (strategy 3); leaving it cached would skip that
3540
+ # fallback entirely on the recovery path.
3541
+ self._mqtt_url = None
3542
+ # Note the refusal so a second failure in quick succession backs
3543
+ # off instead of hammering the credential endpoint.
3544
+ self._mqtt_refused_at = _loop.time()
3545
+ # This one DOES need the lock: it is a compare-and-pop against the
3546
+ # get-or-create above, and without it a rebuild that already read
3547
+ # the slot could have its replacement removed instead of ours.
3548
+ async with lock:
3549
+ if li.get(LOGIN_INFO_PERSISTENT_MQTT_KEY) is pm:
3550
+ li.pop(LOGIN_INFO_PERSISTENT_MQTT_KEY, None)
3551
+ # Outside the lock: retire() blocks briefly joining paho's thread.
3552
+ await _loop.run_in_executor(None, pm.retire)
3426
3553
 
3427
3554
  pm = _PersistentMqtt(mqtt_url, mqtt_user, mqtt_pwd, client_id,
3428
3555
  on_auth_failure=_invalidate_mqtt_credentials)
@@ -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
@@ -34,6 +34,54 @@ from .protocol import (
34
34
  _LOGGER = logging.getLogger(__name__)
35
35
 
36
36
 
37
+ # Payload types the ffmpeg SDP advertises per media line, and their alternates.
38
+ # The SDP offers both because the camera picks one per session.
39
+ _SDP_ALTERNATE_PT = {96: 97, 97: 96, 0: 8, 8: 0}
40
+
41
+ # Extra time, on top of the video payload-type wait, to characterise the audio
42
+ # payload type before the serve launches. Short by design: audio that has not
43
+ # appeared by then is served on a later open (every ffmpeg restart re-runs this
44
+ # wait, and by then the camera has been streaming for a while), which is cheaper
45
+ # than delaying the picture for every silent camera.
46
+ _AUDIO_PT_GRACE_S = 4.0
47
+
48
+
49
+ def narrow_sdp_payload_types(sdp_text: str, keep_video=None, keep_audio=None) -> str:
50
+ """Rewrite an SDP to advertise a single payload type per media line.
51
+
52
+ ffmpeg binds each RTP depacketizer to the FIRST payload type on the m-line and
53
+ silently discards packets carrying any other one. The SDP written for the
54
+ bridge lists both candidates per line (``m=video ... 96 97`` for H.264/H.265,
55
+ ``m=audio ... 0 8`` for PCMU/PCMA) because which one the camera uses varies per
56
+ session, so whichever it actually sends has to be promoted before launch.
57
+
58
+ Getting this wrong on the VIDEO line costs the picture. Getting it wrong on the
59
+ AUDIO line costs the picture too, and less obviously: the mpegts mux withholds
60
+ its PAT/PMT until every mapped stream has produced a packet, so an audio stream
61
+ whose packets are all discarded means the consumer receives zero bytes - video
62
+ included - while every other signal looks healthy.
63
+
64
+ ``keep_video`` / ``keep_audio`` of None leaves that line alone.
65
+ """
66
+ drop = {_SDP_ALTERNATE_PT[k] for k in (keep_video, keep_audio)
67
+ if k in _SDP_ALTERNATE_PT}
68
+ kept = []
69
+ for line in sdp_text.splitlines(keepends=True):
70
+ stripped = line.lstrip()
71
+ if stripped.startswith("m=video") and keep_video is not None:
72
+ line = line.replace(" 96 97", f" {keep_video}")
73
+ elif stripped.startswith("m=audio") and keep_audio is not None:
74
+ line = line.replace(" 0 8", f" {keep_audio}")
75
+ elif any(
76
+ stripped.startswith(f"a={attr}:{pt}{sep}")
77
+ for pt in drop
78
+ for attr, sep in (("rtpmap", " "), ("fmtp", " "), ("fmtp", ";"))
79
+ ):
80
+ continue
81
+ kept.append(line)
82
+ return "".join(kept)
83
+
84
+
37
85
  def _sdes_echo_wait_timeout(skip_liveplay: bool) -> float:
38
86
  """Seconds to block on the camera's webrtcReq echo before proceeding.
39
87
 
@@ -2141,6 +2189,11 @@ class _SdesOpenMixin:
2141
2189
  # bridge on the first video RTP packet so the ffmpeg SDP can be narrowed
2142
2190
  # to the matching single codec before ffmpeg is launched.
2143
2191
  _first_video_pt: list = [None]
2192
+ # Same for audio: the ffmpeg SDP advertises BOTH PCMU (0) and PCMA (8),
2193
+ # and ffmpeg binds the depacketizer to the first one listed - so the line
2194
+ # has to be narrowed to the payload type the camera actually sends, the
2195
+ # same way the video line already is.
2196
+ _first_audio_pt: list = [None]
2144
2197
  # Media-liveness: bridge sets [0] = time.monotonic() on every forwarded
2145
2198
  # media packet; the keepalive watchdog reads it via SdesSession to
2146
2199
  # restart a session the camera silently stopped feeding.
@@ -3095,10 +3148,21 @@ class _SdesOpenMixin:
3095
3148
  )
3096
3149
  if not _br_first_audio_logged:
3097
3150
  _br_first_audio_logged = True
3151
+ # Record the payload type this path
3152
+ # SYNTHESISES (_tk_pt, PCMA) so the
3153
+ # SDP can be narrowed to it. Without
3154
+ # this the m=audio line keeps
3155
+ # advertising "0 8", ffmpeg binds
3156
+ # PCMU and discards every packet, and
3157
+ # the mpegts PMT is never written - so
3158
+ # the consumer gets zero bytes and the
3159
+ # VIDEO is lost with the audio.
3160
+ _first_audio_pt[0] = _tk_pt
3098
3161
  _status(
3099
3162
  f"bridge: first SDES audio -> ffmpeg"
3100
3163
  f" loopback:{_lo_audio_port}"
3101
3164
  f" ({len(_pd_plain)}B PCMA)"
3165
+ f" pt={_tk_pt}"
3102
3166
  )
3103
3167
  except Exception:
3104
3168
  _LOGGER.debug("camera %s: swallowed exception in %s", getattr(self, "device_id", "?"), '_enc_c8_sctp', exc_info=True)
@@ -3190,6 +3254,7 @@ class _SdesOpenMixin:
3190
3254
  )
3191
3255
  if _kind == "audio" and not _br_first_audio_logged:
3192
3256
  _br_first_audio_logged = True
3257
+ _first_audio_pt[0] = _pt
3193
3258
  _status(f"bridge: first audio RTP pt={_pt}")
3194
3259
  # Capture the camera's audio return address so the talk
3195
3260
  # pump (SdesSession.async_start_talk) can send outbound
@@ -3454,12 +3519,13 @@ class _SdesOpenMixin:
3454
3519
  if out_dir:
3455
3520
  os.makedirs(out_dir, exist_ok=True)
3456
3521
  # Build the ffmpeg command (single source of truth: _build_sdes_serve_cmd).
3522
+ _serve_audio = self._resolve_sdes_serve_audio()
3457
3523
  cmd = _build_sdes_serve_cmd(
3458
3524
  sdp_path=sdp_path,
3459
3525
  rtsp_push_url=rtsp_push_url,
3460
3526
  output_path=output_path,
3461
3527
  max_seconds=max_seconds,
3462
- sdes_audio=self._resolve_sdes_serve_audio(),
3528
+ sdes_audio=_serve_audio,
3463
3529
  audio_gain_db=self._resolve_sdes_audio_gain_db(),
3464
3530
  )
3465
3531
  # --- H.265 fix: narrow the ffmpeg SDP to the camera's actual codec ----
@@ -3470,36 +3536,71 @@ class _SdesOpenMixin:
3470
3536
  # video pt, then rewrite the SDP to that single codec before launch.
3471
3537
  # ffmpeg recovers on the next periodic keyframe, so the small spawn delay
3472
3538
  # is harmless. Falls back to the dual-codec SDP if no video is seen.
3539
+ # When audio is being served the same wait has to cover the AUDIO payload
3540
+ # type, and for a stricter reason: an unnarrowed video line merely risks the
3541
+ # picture, while an unnarrowed audio line guarantees the mux never writes its
3542
+ # PAT/PMT, which loses the picture AND the audio. Wait for both, then take
3543
+ # whatever has been observed by the deadline.
3544
+ # Two separate budgets on purpose. Video keeps its original 15s, because
3545
+ # the picture is what the viewer is waiting for. Audio gets only a short
3546
+ # extra grace on top: sharing one deadline would make a camera that never
3547
+ # sends audio delay the picture from a couple of seconds to the full 15,
3548
+ # which trades the bug for a worse one.
3473
3549
  _vpt_deadline = time.monotonic() + 15.0
3474
3550
  while _first_video_pt[0] is None and time.monotonic() < _vpt_deadline:
3475
3551
  await asyncio.sleep(0.1)
3552
+ if _serve_audio and _first_audio_pt[0] is None:
3553
+ _apt_deadline = time.monotonic() + _AUDIO_PT_GRACE_S
3554
+ while _first_audio_pt[0] is None and time.monotonic() < _apt_deadline:
3555
+ await asyncio.sleep(0.1)
3476
3556
  _vpt = _first_video_pt[0]
3477
- if _vpt in (96, 97):
3478
- def _narrow_sdp_to_pt(_sdp_text: str, _keep: int) -> str:
3479
- _drop = 96 if _keep == 97 else 97
3480
- _kept = []
3481
- for _ln in _sdp_text.splitlines(keepends=True):
3482
- _s = _ln.lstrip()
3483
- if _s.startswith("m=video"):
3484
- _ln = _ln.replace(" 96 97", f" {_keep}")
3485
- elif (_s.startswith(f"a=rtpmap:{_drop} ")
3486
- or _s.startswith(f"a=fmtp:{_drop} ")
3487
- or _s.startswith(f"a=fmtp:{_drop};")):
3488
- continue
3489
- _kept.append(_ln)
3490
- return "".join(_kept)
3557
+ _apt = _first_audio_pt[0]
3558
+ if _serve_audio and _apt is None:
3559
+ # No audio observed in the window, so the line cannot be narrowed and
3560
+ # mapping audio would stall the mux. Serve video only rather than
3561
+ # serving nothing, and say so.
3562
+ _LOGGER.warning(
3563
+ "camera %s: no audio observed before the serve launched, so the "
3564
+ "audio payload type is unknown; mapping it would stall the mpegts "
3565
+ "mux and serve no video. Continuing without audio.",
3566
+ getattr(self, "device_id", "?"),
3567
+ )
3568
+ _serve_audio = False
3569
+ cmd = _build_sdes_serve_cmd(
3570
+ sdp_path=sdp_path,
3571
+ rtsp_push_url=rtsp_push_url,
3572
+ output_path=output_path,
3573
+ max_seconds=max_seconds,
3574
+ sdes_audio=False,
3575
+ audio_gain_db=self._resolve_sdes_audio_gain_db(),
3576
+ )
3577
+ # Audio matters even more than video here. A multi-PT m-line makes ffmpeg
3578
+ # bind the depacketizer to the FIRST payload type and silently discard the
3579
+ # rest, and the mpegts mux withholds its PAT/PMT until EVERY mapped stream
3580
+ # has produced a packet - so an audio line advertising "0 8" (PCMU first)
3581
+ # on a camera that sends PCMA discards every audio packet and the consumer
3582
+ # receives zero bytes. That takes the video down with it, which is why
3583
+ # enabling serve audio appeared to break streaming outright.
3584
+ if _vpt in (96, 97) or (_serve_audio and _apt in (0, 8)):
3491
3585
  def _read_sdp_file() -> str:
3492
3586
  with open(sdp_path, encoding="utf-8") as _f_sdp:
3493
3587
  return _f_sdp.read()
3494
3588
  try:
3589
+ _keep_v = int(_vpt) if _vpt in (96, 97) else None
3590
+ _keep_a = int(_apt) if (_serve_audio and _apt in (0, 8)) else None
3495
3591
  _cur_sdp = await asyncio.get_running_loop().run_in_executor(
3496
3592
  None, _read_sdp_file)
3497
3593
  await asyncio.get_running_loop().run_in_executor(
3498
3594
  None, _write_text_file, sdp_path,
3499
- _narrow_sdp_to_pt(_cur_sdp, int(_vpt)))
3595
+ narrow_sdp_payload_types(_cur_sdp, _keep_v, _keep_a))
3500
3596
  _status(
3501
- f"SDES: narrowed ffmpeg SDP to video pt={_vpt}"
3502
- f" ({'H265' if int(_vpt) == 97 else 'H264'})")
3597
+ "SDES: narrowed ffmpeg SDP to"
3598
+ + (f" video pt={_keep_v}"
3599
+ f" ({'H265' if _keep_v == 97 else 'H264'})"
3600
+ if _keep_v is not None else "")
3601
+ + (f" audio pt={_keep_a}"
3602
+ f" ({'PCMA' if _keep_a == 8 else 'PCMU'})"
3603
+ if _keep_a is not None else ""))
3503
3604
  except Exception:
3504
3605
  _LOGGER.debug("camera %s: swallowed exception in %s", getattr(self, "device_id", "?"), '_open_sdes_stream', exc_info=True)
3505
3606
  _LOGGER.info("SDES ffmpeg cmd: %s", " ".join(cmd))