python-aidot-cameras 0.11.5__tar.gz → 0.11.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 (128) hide show
  1. {python_aidot_cameras-0.11.5/python_aidot_cameras.egg-info → python_aidot_cameras-0.11.6}/PKG-INFO +1 -1
  2. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/camera/client.py +29 -5
  3. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/camera/protocol.py +32 -4
  4. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/camera/sdes_open.py +61 -1
  5. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/camera/webrtc_open.py +12 -1
  6. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/client.py +23 -4
  7. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/pyproject.toml +1 -1
  8. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  9. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/python_aidot_cameras.egg-info/SOURCES.txt +3 -0
  10. python_aidot_cameras-0.11.6/tests/test_mqtt_session_reconnect.py +84 -0
  11. python_aidot_cameras-0.11.6/tests/test_sdes_open_cleanup.py +72 -0
  12. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_sdes_sprop.py +16 -0
  13. python_aidot_cameras-0.11.6/tests/test_stream_drain_ownership.py +73 -0
  14. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/LICENSE +0 -0
  15. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/MANIFEST.in +0 -0
  16. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/README.md +0 -0
  17. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/__init__.py +0 -0
  18. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/__main__.py +0 -0
  19. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/__init__.py +0 -0
  20. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/__init__.py +0 -0
  21. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/clock.py +0 -0
  22. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/codecs/__init__.py +0 -0
  23. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/codecs/base.py +0 -0
  24. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/codecs/g711.py +0 -0
  25. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/codecs/g722.py +0 -0
  26. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/codecs/h264.py +0 -0
  27. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/codecs/opus.py +0 -0
  28. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/codecs/vpx.py +0 -0
  29. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/contrib/__init__.py +0 -0
  30. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/contrib/media.py +0 -0
  31. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/contrib/signaling.py +0 -0
  32. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/events.py +0 -0
  33. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/exceptions.py +0 -0
  34. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/jitterbuffer.py +0 -0
  35. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/mediastreams.py +0 -0
  36. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rate.py +0 -0
  37. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rtcconfiguration.py +0 -0
  38. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rtcdatachannel.py +0 -0
  39. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rtcdtlstransport.py +0 -0
  40. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rtcicetransport.py +0 -0
  41. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rtcpeerconnection.py +0 -0
  42. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rtcrtpparameters.py +0 -0
  43. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rtcrtpreceiver.py +0 -0
  44. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rtcrtpsender.py +0 -0
  45. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rtcrtptransceiver.py +0 -0
  46. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rtcsctptransport.py +0 -0
  47. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rtcsessiondescription.py +0 -0
  48. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rtp.py +0 -0
  49. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/sdp.py +0 -0
  50. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/stats.py +0 -0
  51. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/utils.py +0 -0
  52. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/aes_utils.py +0 -0
  53. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/camera/__init__.py +0 -0
  54. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/camera/constants.py +0 -0
  55. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/camera/controls.py +0 -0
  56. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/camera/go2rtc.py +0 -0
  57. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/camera/lan_control.py +0 -0
  58. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/camera/models.py +0 -0
  59. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/camera/playback.py +0 -0
  60. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/camera/sdes.py +0 -0
  61. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/camera/tutk.py +0 -0
  62. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/camera/webrtc.py +0 -0
  63. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/const.py +0 -0
  64. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/credentials.py +0 -0
  65. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/device_client.py +0 -0
  66. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/discover.py +0 -0
  67. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/exceptions.py +0 -0
  68. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/g711.py +0 -0
  69. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/login_const.py +0 -0
  70. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/models/__init__.py +0 -0
  71. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/models/device_client_model.py +0 -0
  72. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/models/device_model.py +0 -0
  73. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/models/discover_model.py +0 -0
  74. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/py.typed +0 -0
  75. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  76. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
  77. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/python_aidot_cameras.egg-info/requires.txt +0 -0
  78. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/python_aidot_cameras.egg-info/top_level.txt +0 -0
  79. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/setup.cfg +0 -0
  80. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_aioice_compat.py +0 -0
  81. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_alarm_event.py +0 -0
  82. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_backoff.py +0 -0
  83. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_deferred_hardening.py +0 -0
  84. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_device_login_guard.py +0 -0
  85. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_device_user_info_cache.py +0 -0
  86. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_dtls_not_ready_burst.py +0 -0
  87. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_dtls_pinning.py +0 -0
  88. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_dtls_skip_signaling_wait.py +0 -0
  89. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_egress_guard.py +0 -0
  90. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_go2rtc.py +0 -0
  91. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_go2rtc_cli.py +0 -0
  92. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_highport_nomination.py +0 -0
  93. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_ice_config_cache.py +0 -0
  94. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_keepalive_renew.py +0 -0
  95. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_keyframe_prompter.py +0 -0
  96. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_lan_control.py +0 -0
  97. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_live_stream_param.py +0 -0
  98. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_motion_poll.py +0 -0
  99. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_narrow_pc_ice.py +0 -0
  100. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_no_undefined_names.py +0 -0
  101. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_offline_keepalive_pause.py +0 -0
  102. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_open_gate_delay.py +0 -0
  103. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_persistent_mqtt.py +0 -0
  104. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_persistent_mqtt_robustness.py +0 -0
  105. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_playback_tls.py +0 -0
  106. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_post_merge_hardening.py +0 -0
  107. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_retry_policy.py +0 -0
  108. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_sdes_adaptive.py +0 -0
  109. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_sdes_echo_wait_timeout.py +0 -0
  110. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_sdes_fast_liveplay.py +0 -0
  111. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_sdes_idle_release.py +0 -0
  112. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_sdes_serve_audio.py +0 -0
  113. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_sdes_serve_cmd.py +0 -0
  114. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_sdes_talk.py +0 -0
  115. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_sdes_watchdog.py +0 -0
  116. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_security_hardening.py +0 -0
  117. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_serializable_login_info.py +0 -0
  118. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_serve_relay.py +0 -0
  119. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_session_stats.py +0 -0
  120. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_speak.py +0 -0
  121. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_stream_cap.py +0 -0
  122. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_stream_idle.py +0 -0
  123. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_stream_teardown.py +0 -0
  124. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_talk.py +0 -0
  125. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_terminal_ack.py +0 -0
  126. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_token_file_robustness.py +0 -0
  127. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_token_refresh.py +0 -0
  128. {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.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.11.5
3
+ Version: 0.11.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
@@ -984,7 +984,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
984
984
 
985
985
  Mirrors the official app's wake (DeviceWakeUpRepos.k()), which fires the
986
986
  HTTP wake so a sleeping camera (no live MQTT session) still gets the signal
987
- through the cloud, and retries on failure (n.java onError "正在重试"). The
987
+ through the cloud, and retries on failure (n.java onError "retrying"). The
988
988
  MQTT ``lowPowerActiveStateReq`` is also published by the streaming /
989
989
  attribute paths; this adds the reliable cloud-side channel. Non-battery
990
990
  cameras are always reachable, so this is a no-op for them.
@@ -1894,7 +1894,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
1894
1894
 
1895
1895
  _MIME = {1: "video/mp4", 2: "application/x-mpegURL"}
1896
1896
 
1897
- try:
1897
+ async def _fetch():
1898
1898
  async with aiohttp.ClientSession() as session:
1899
1899
  async with session.post(
1900
1900
  f"{self._aidot_v32_base}/playback/getEventVideoUrl",
@@ -1905,7 +1905,12 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
1905
1905
  headers=self._aidot_headers(),
1906
1906
  timeout=aiohttp.ClientTimeout(total=15),
1907
1907
  ) as resp:
1908
- body = await resp.json(content_type=None)
1908
+ return await resp.json(content_type=None)
1909
+
1910
+ try:
1911
+ body = await _fetch()
1912
+ if self._is_auth_error(body) and await self._async_refresh_auth_token():
1913
+ body = await _fetch() # retry once with the refreshed token
1909
1914
 
1910
1915
  _LOGGER.debug("getEventVideoUrl raw for %s uuid=%s: %s", self.device_id, event_uuid, body)
1911
1916
 
@@ -1966,7 +1971,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
1966
1971
  end_ts = int(time.time() * 1000)
1967
1972
  start_ts = end_ts - 30 * 86_400_000 # look back 30 days
1968
1973
 
1969
- try:
1974
+ async def _fetch():
1970
1975
  async with aiohttp.ClientSession() as session:
1971
1976
  async with session.post(
1972
1977
  f"{self._aidot_v32_base}/playback/eventRecordingList",
@@ -1980,7 +1985,12 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
1980
1985
  headers=self._aidot_headers(),
1981
1986
  timeout=aiohttp.ClientTimeout(total=15),
1982
1987
  ) as resp:
1983
- body = await resp.json(content_type=None)
1988
+ return await resp.json(content_type=None)
1989
+
1990
+ try:
1991
+ body = await _fetch()
1992
+ if self._is_auth_error(body) and await self._async_refresh_auth_token():
1993
+ body = await _fetch() # retry once with the refreshed token
1984
1994
 
1985
1995
  _LOGGER.debug("thumbnail eventRecordingList for %s: %s", self.device_id, body)
1986
1996
  if body.get("code") != 200:
@@ -2244,6 +2254,20 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2244
2254
  except (asyncio.CancelledError, Exception):
2245
2255
  _LOGGER.debug("camera %s: swallowed exception in %s", getattr(self, "device_id", "?"), '_reap_stream_drain', exc_info=True)
2246
2256
 
2257
+ def _release_stream_drain_to_session(self):
2258
+ """Hand ownership of the persistent-MQTT signaling drain to the session
2259
+ that just opened successfully.
2260
+
2261
+ The returned WebRTCSession/SdesSession reaps its own outgoing_q + mqtt_fut
2262
+ on stop(); ``_stream_mqtt_drain``/``_stream_mqtt_outq`` are only a backstop
2263
+ for a drain whose open was cancelled *before* a session took ownership.
2264
+ Clearing the slot on hand-off means a concurrent open on this same camera
2265
+ (e.g. a snapshot taken during a live view) no longer reaps - and so no
2266
+ longer kills - the live session's drain; the slot from then on only ever
2267
+ holds a genuinely orphaned (cancelled mid-handshake) drain."""
2268
+ self._stream_mqtt_drain = None
2269
+ self._stream_mqtt_outq = None
2270
+
2247
2271
  async def async_start_motion_polling(
2248
2272
  self, callback: Callable, interval: float = 30.0, lookback_s: int = 600,
2249
2273
  ) -> None:
@@ -536,6 +536,11 @@ def _extract_param_sets_from_rtp(pkt: bytes) -> dict:
536
536
  while i + 2 <= len(payload):
537
537
  size = int.from_bytes(payload[i:i + 2], "big")
538
538
  i += 2
539
+ if i + size > len(payload):
540
+ # Truncated STAP-A: the advertised NAL size runs past the end of
541
+ # the packet, so the remaining bytes are garbage. Stop rather
542
+ # than slice a short NAL and cache a corrupt SPS/PPS.
543
+ break
539
544
  nal = payload[i:i + size]
540
545
  i += size
541
546
  if nal:
@@ -1211,6 +1216,16 @@ def _mqtt_session_sync(
1211
1216
  status["connected"] = (rc == 0)
1212
1217
  status["rc"] = rc
1213
1218
  status["rc_str"] = str(reason_code)
1219
+ if rc == 0:
1220
+ # (Re)subscribe on EVERY successful connect. paho's loop_start
1221
+ # auto-reconnects after a transient drop, but with clean_session the
1222
+ # broker retains no subscriptions, so a reconnected client would be
1223
+ # deaf unless they are re-established here rather than only once below.
1224
+ for _sub in subscribe_topics:
1225
+ try:
1226
+ c.subscribe(_sub)
1227
+ except Exception:
1228
+ _LOGGER.debug("_mqtt_session: resubscribe failed for %s", _sub, exc_info=True)
1214
1229
  conn_ev.set()
1215
1230
 
1216
1231
  def _on_message(c, ud, msg):
@@ -1228,10 +1243,23 @@ def _mqtt_session_sync(
1228
1243
  # If _on_connect was never fired (WebSocket upgrade failed, auth refused
1229
1244
  # at TCP level, etc.) signal conn_ev now so the caller doesn't time out.
1230
1245
  if not conn_ev.is_set():
1246
+ # Never connected (WS upgrade failed, auth refused at TCP level, ...):
1247
+ # unblock the caller and end the receive loop - the connect failed.
1231
1248
  status["connected"] = False
1232
1249
  status["rc_str"] = f"disconnect-before-connect rc={reason_code}"
1233
1250
  conn_ev.set()
1234
- msg_q.put(None) # sentinel to unblock the receive loop
1251
+ msg_q.put(None) # sentinel: end the receive loop
1252
+ return
1253
+ # Already connected once: this is a transient drop. paho's loop_start
1254
+ # auto-reconnects and _on_connect re-subscribes, so DON'T end the receive
1255
+ # loop here - a broker blip must not tear down a long-lived stream's
1256
+ # signaling (the non-persistent transport runs one session for the whole
1257
+ # stream). A real teardown ends the loop via the outgoing-queue None
1258
+ # sentinel or the duration deadline instead.
1259
+ _LOGGER.debug(
1260
+ "_mqtt_session: transient disconnect rc=%s (paho will reconnect)",
1261
+ reason_code,
1262
+ )
1235
1263
 
1236
1264
  def _on_log(c, ud, level, buf):
1237
1265
  if len(status["log"]) < 500:
@@ -1279,9 +1307,9 @@ def _mqtt_session_sync(
1279
1307
 
1280
1308
  _LOGGER.info("_mqtt_session: connected to %s:%d clientId=%s", hostname, port, client_id)
1281
1309
 
1282
- for topic in subscribe_topics:
1283
- client.subscribe(topic)
1284
- _LOGGER.debug("_mqtt_session: subscribed %s", topic)
1310
+ # Subscriptions are (re)established in _on_connect so they survive a paho
1311
+ # auto-reconnect; nothing to subscribe here.
1312
+ _LOGGER.debug("_mqtt_session: subscribed (in on_connect) %s", subscribe_topics)
1285
1313
 
1286
1314
  for pub_topic, pub_payload in publish_items:
1287
1315
  client.publish(pub_topic, pub_payload)
@@ -47,9 +47,38 @@ def _sdes_echo_wait_timeout(skip_liveplay: bool) -> float:
47
47
 
48
48
 
49
49
  class _SdesOpenMixin:
50
- async def _open_sdes_stream(
50
+ async def _open_sdes_stream(self, **kwargs) -> "SdesSession":
51
+ """Allocate-and-hand-off wrapper around _open_sdes_stream_impl.
52
+
53
+ The impl reserves two UDP sockets, starts a bridge thread, launches
54
+ ffmpeg and writes a temp SDP, then hands them to the returned
55
+ SdesSession (whose stop() releases them). If the cold open is cancelled
56
+ mid-handshake (25-70s) - or raises before that hand-off - none of that
57
+ has an owner yet, so a plain call leaks the fds, the thread and a /tmp
58
+ file on every cancelled attempt (eventually "Too many open files").
59
+ The impl registers each resource on an ExitStack as it is allocated;
60
+ close the stack unless the impl actually returned a session.
61
+ """
62
+ from contextlib import ExitStack
63
+
64
+ _cleanup = ExitStack()
65
+ _ok = False
66
+ try:
67
+ _session = await self._open_sdes_stream_impl(_cleanup=_cleanup, **kwargs)
68
+ _ok = True
69
+ return _session
70
+ finally:
71
+ if not _ok:
72
+ # Closing the reservation sockets also unblocks the bridge
73
+ # thread's recv so it exits; ffmpeg is killed and the SDP file
74
+ # unlinked. LIFO, and every callback swallows so this never
75
+ # masks the original CancelledError/exception.
76
+ _cleanup.close()
77
+
78
+ async def _open_sdes_stream_impl(
51
79
  self,
52
80
  *,
81
+ _cleanup=None,
53
82
  peer_id: str,
54
83
  user_id: str,
55
84
  device_id: str,
@@ -120,16 +149,45 @@ class _SdesOpenMixin:
120
149
  def _seq() -> str:
121
150
  return f"ap{random.randint(1000000, 9999999)}"
122
151
 
152
+ # Register a resource with the wrapper's cleanup stack so it is released
153
+ # if the cold open is cancelled/raises before the SdesSession takes
154
+ # ownership. Each step swallows so ExitStack.close() can never mask the
155
+ # original CancelledError/exception. No-op if called without a stack.
156
+ def _cl(fn, *a):
157
+ if _cleanup is None:
158
+ return
159
+ def _run():
160
+ try:
161
+ fn(*a)
162
+ except Exception:
163
+ _LOGGER.debug(
164
+ "camera %s: swallowed sdes-open cleanup step",
165
+ getattr(self, "device_id", "?"), exc_info=True,
166
+ )
167
+ _cleanup.callback(_run)
168
+
169
+ def _reap(p):
170
+ try:
171
+ p.kill()
172
+ except Exception:
173
+ pass
174
+ try:
175
+ p.poll() # reap the killed child so it does not linger as a zombie
176
+ except Exception:
177
+ pass
178
+
123
179
  # --- Allocate UDP ports and determine local IP ---------------------- #
124
180
  import socket as _socket
125
181
 
126
182
  _audio_sock = _socket.socket(_socket.AF_INET, _socket.SOCK_DGRAM)
127
183
  _audio_sock.bind(("0.0.0.0", 0))
128
184
  audio_port = _audio_sock.getsockname()[1]
185
+ _cl(_audio_sock.close) # also unblocks the bridge thread's recv on cleanup
129
186
 
130
187
  _video_sock = _socket.socket(_socket.AF_INET, _socket.SOCK_DGRAM)
131
188
  _video_sock.bind(("0.0.0.0", 0))
132
189
  video_port = _video_sock.getsockname()[1]
190
+ _cl(_video_sock.close)
133
191
 
134
192
  # Use the outbound interface toward 8.8.8.8 to find our local IP.
135
193
  # connect() on a UDP socket does not send any packet.
@@ -733,6 +791,7 @@ class _SdesOpenMixin:
733
791
 
734
792
  sdp_path = await asyncio.get_running_loop().run_in_executor(
735
793
  None, _make_sdp_tempfile, _inject_sprop(ffmpeg_sdp, self.device_id))
794
+ _cl(os.unlink, sdp_path) # released with the sockets on a cancelled open
736
795
 
737
796
  # --- Send webrtcReq BEFORE releasing reservation sockets ------------- #
738
797
  # ICE cameras (e.g. LK.IPC.A001064) send STUN binding requests to our
@@ -3384,6 +3443,7 @@ class _SdesOpenMixin:
3384
3443
  stderr=subprocess.PIPE,
3385
3444
  )
3386
3445
  _proc_holder[0] = proc
3446
+ _cl(_reap, proc) # kill ffmpeg if the open is cancelled before hand-off
3387
3447
  except FileNotFoundError:
3388
3448
  # ffmpeg is not installed - clean up and surface a clear error.
3389
3449
  for _rsock in (_audio_sock, _video_sock):
@@ -1209,7 +1209,7 @@ class _WebRTCOpenMixin:
1209
1209
  # ------------------------------------------------------------------ #
1210
1210
  if use_sdes:
1211
1211
  try:
1212
- return await self._open_sdes_stream(
1212
+ _sdes_session = await self._open_sdes_stream(
1213
1213
  peer_id=peer_id,
1214
1214
  user_id=user_id,
1215
1215
  device_id=device_id,
@@ -1237,6 +1237,13 @@ class _WebRTCOpenMixin:
1237
1237
  rtsp_push_url=rtsp_push_url,
1238
1238
  talk=talk,
1239
1239
  )
1240
+ # Success: the SdesSession now owns outgoing_q + mqtt_fut and
1241
+ # reaps them on stop(); clear the backstop slot so a concurrent
1242
+ # open on this camera cannot reap this live session's drain.
1243
+ # (On _SdesNoAnswerError the await raises before this runs, so the
1244
+ # DTLS-fallback path below keeps the slot for its own hand-off.)
1245
+ self._release_stream_drain_to_session()
1246
+ return _sdes_session
1240
1247
  except CameraMixin._SdesNoAnswerError:
1241
1248
  # Camera reported enableSdes='1' but did not respond to our SDES
1242
1249
  # offer. iOS telemetry shows models such as LK.IPC.A001064 can
@@ -3572,6 +3579,10 @@ class _WebRTCOpenMixin:
3572
3579
  _LOGGER.info(
3573
3580
  "WebRTC stream open for %s (peerid=%s)", device_id, peer_id
3574
3581
  )
3582
+ # Ownership of the signaling drain passes to this session (its stop()
3583
+ # reaps outgoing_q + mqtt_fut); clear the backstop slot so a concurrent
3584
+ # open on this camera cannot reap this live session's drain.
3585
+ self._release_stream_drain_to_session()
3575
3586
  return WebRTCSession(
3576
3587
  pc=pc,
3577
3588
  outgoing_q=outgoing_q,
@@ -50,6 +50,27 @@ _LOGGER = logging.getLogger(__name__)
50
50
  # other cloud API calls that use owner+token headers.
51
51
  _CLOUD_APP_ID = "68"
52
52
 
53
+ # Strong refs to fire-and-forget tasks: asyncio only keeps weak refs, so a
54
+ # discarded task can be garbage-collected mid-flight (see camera/client.py).
55
+ _BG_TASKS: set = set()
56
+
57
+
58
+ def _spawn_bg(coro):
59
+ _t = asyncio.ensure_future(coro)
60
+ _BG_TASKS.add(_t)
61
+
62
+ def _done(task):
63
+ _BG_TASKS.discard(task)
64
+ # Retrieve the exception so a failing background task doesn't surface as
65
+ # an unhandled "Task exception was never retrieved" at GC time; log it.
66
+ if not task.cancelled():
67
+ exc = task.exception()
68
+ if exc is not None:
69
+ _LOGGER.debug("aidot background task failed: %r", exc, exc_info=exc)
70
+
71
+ _t.add_done_callback(_done)
72
+ return _t
73
+
53
74
 
54
75
  def rsa_password_encrypt(message: str) -> str:
55
76
  """RSA-encrypt the password using the AiDot public key (for loginWithFreeVerification)."""
@@ -513,9 +534,7 @@ class AidotClient:
513
534
  device_client.set_token_refresh_cb(self.async_ensure_token)
514
535
  self._device_clients[device_id] = device_client
515
536
  # Pre-warm ICE config cache so stream open does not block on this fetch.
516
- asyncio.get_running_loop().create_task(
517
- _prefetch_ice_config(device_client)
518
- )
537
+ _spawn_bg(_prefetch_ice_config(device_client))
519
538
  # Started lazily here as well: __init__ cannot start discovery when
520
539
  # constructed outside a running event loop (stored-token sync path).
521
540
  if self._discover is None:
@@ -583,7 +602,7 @@ class AidotClient:
583
602
 
584
603
  def cleanup(self) -> None:
585
604
  """Sync entry point: fire-and-forget async_close()."""
586
- asyncio.get_running_loop().create_task(self.async_close())
605
+ _spawn_bg(self.async_close())
587
606
 
588
607
  async def async_cleanup(self) -> None:
589
608
  """Async entry point: close the client and release resources."""
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-aidot-cameras"
7
- version = "0.11.5"
7
+ version = "0.11.6"
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.11.5
3
+ Version: 0.11.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
@@ -90,6 +90,7 @@ tests/test_keyframe_prompter.py
90
90
  tests/test_lan_control.py
91
91
  tests/test_live_stream_param.py
92
92
  tests/test_motion_poll.py
93
+ tests/test_mqtt_session_reconnect.py
93
94
  tests/test_narrow_pc_ice.py
94
95
  tests/test_no_undefined_names.py
95
96
  tests/test_offline_keepalive_pause.py
@@ -103,6 +104,7 @@ tests/test_sdes_adaptive.py
103
104
  tests/test_sdes_echo_wait_timeout.py
104
105
  tests/test_sdes_fast_liveplay.py
105
106
  tests/test_sdes_idle_release.py
107
+ tests/test_sdes_open_cleanup.py
106
108
  tests/test_sdes_serve_audio.py
107
109
  tests/test_sdes_serve_cmd.py
108
110
  tests/test_sdes_sprop.py
@@ -114,6 +116,7 @@ tests/test_serve_relay.py
114
116
  tests/test_session_stats.py
115
117
  tests/test_speak.py
116
118
  tests/test_stream_cap.py
119
+ tests/test_stream_drain_ownership.py
117
120
  tests/test_stream_idle.py
118
121
  tests/test_stream_teardown.py
119
122
  tests/test_talk.py
@@ -0,0 +1,84 @@
1
+ """Transient-reconnect resilience of the per-stream MQTT signaling session.
2
+
3
+ _mqtt_session_sync runs one paho session for the (up to 3600s) lifetime of a
4
+ stream on the non-persistent transport. A transient broker drop must NOT end the
5
+ receive loop - paho's loop_start auto-reconnects and _on_connect re-subscribes
6
+ (clean_session retains nothing). Only a disconnect that happens before the first
7
+ successful connect should end the session. Driven with a fake paho client.
8
+ """
9
+ import queue
10
+ import threading
11
+ import time
12
+
13
+ import aidot.camera.protocol as proto
14
+
15
+
16
+ class _FakeMsg:
17
+ def __init__(self, topic, payload):
18
+ self.topic = topic
19
+ self.payload = payload
20
+
21
+
22
+ def _install_fake_client(monkeypatch, driver):
23
+ """Patch paho's Client with a fake whose loop_start() runs ``driver(client)``
24
+ in a daemon thread, so it can fire callbacks while the receive loop runs."""
25
+ class _FakeClient:
26
+ def __init__(self, *a, **k):
27
+ self.subs = []
28
+ self.on_connect = self.on_message = self.on_disconnect = self.on_log = None
29
+ def ws_set_options(self, **k): pass
30
+ def username_pw_set(self, *a, **k): pass
31
+ def tls_set_context(self, *a, **k): pass
32
+ def connect(self, *a, **k): pass
33
+ def subscribe(self, topic): self.subs.append(topic)
34
+ def publish(self, *a, **k): pass
35
+ def loop_start(self):
36
+ threading.Thread(target=driver, args=(self,), daemon=True).start()
37
+ def loop_stop(self): pass
38
+ def disconnect(self): pass
39
+
40
+ monkeypatch.setattr("paho.mqtt.client.Client", _FakeClient)
41
+
42
+
43
+ def test_transient_disconnect_keeps_receiving_and_resubscribes(monkeypatch):
44
+ outgoing = queue.Queue()
45
+ seen_subs = []
46
+
47
+ def driver(c):
48
+ c.on_connect(c, None, {}, 0) # first successful connect
49
+ seen_subs.append(list(c.subs))
50
+ c.on_disconnect(c, None, 0) # transient drop (already connected)
51
+ time.sleep(0.02)
52
+ c.on_connect(c, None, {}, 0) # paho auto-reconnect -> resubscribe
53
+ seen_subs.append(list(c.subs))
54
+ # Arrives only AFTER the transient drop: collected only if the receive
55
+ # loop survived the disconnect.
56
+ c.on_message(c, None, _FakeMsg("t/after", b"live"))
57
+ time.sleep(0.02)
58
+ outgoing.put_nowait(None) # clean stop
59
+
60
+ _install_fake_client(monkeypatch, driver)
61
+
62
+ messages, status = proto._mqtt_session_sync(
63
+ "wss://broker.example/mqtt", "u", "p", "cid",
64
+ ["t/a", "t/b"], [], 5.0,
65
+ outgoing_queue=outgoing,
66
+ )
67
+
68
+ assert ("t/after", "live") in messages # survived the transient drop
69
+ assert seen_subs[0] == ["t/a", "t/b"] # subscribed on first connect
70
+ assert seen_subs[1] == ["t/a", "t/b", "t/a", "t/b"] # resubscribed on reconnect
71
+
72
+
73
+ def test_disconnect_before_connect_ends_session(monkeypatch):
74
+ def driver(c):
75
+ c.on_disconnect(c, None, 7) # never connected -> end + report failure
76
+
77
+ _install_fake_client(monkeypatch, driver)
78
+
79
+ messages, status = proto._mqtt_session_sync(
80
+ "wss://broker.example/mqtt", "u", "p", "cid",
81
+ ["t/a"], [], 5.0,
82
+ )
83
+ assert messages == []
84
+ assert status["connected"] is False
@@ -0,0 +1,72 @@
1
+ """Resource-cleanup plumbing for the SDES cold-open wrapper.
2
+
3
+ _open_sdes_stream reserves UDP sockets, a bridge thread, ffmpeg and a temp SDP
4
+ before handing them to the returned SdesSession. If the cold open is cancelled
5
+ mid-handshake (or raises) before that hand-off, the wrapper must release them via
6
+ its ExitStack; on a successful return it must NOT (the session now owns them).
7
+ These tests exercise that wrapper mechanism directly with a stub impl.
8
+ """
9
+ import asyncio
10
+ import types
11
+
12
+ import pytest
13
+
14
+ from aidot.camera.client import CameraMixin
15
+
16
+
17
+ def _wrapper_for(impl):
18
+ obj = types.SimpleNamespace()
19
+ obj._open_sdes_stream_impl = impl
20
+ return CameraMixin._open_sdes_stream.__get__(obj)
21
+
22
+
23
+ def test_open_sdes_wrapper_cleans_up_on_cancel():
24
+ ran = []
25
+
26
+ async def impl(_cleanup=None, **kwargs):
27
+ _cleanup.callback(lambda: ran.append("cleaned"))
28
+ raise asyncio.CancelledError()
29
+
30
+ with pytest.raises(asyncio.CancelledError):
31
+ asyncio.run(_wrapper_for(impl)(peer_id="x"))
32
+ assert ran == ["cleaned"]
33
+
34
+
35
+ def test_open_sdes_wrapper_cleans_up_on_error():
36
+ ran = []
37
+
38
+ async def impl(_cleanup=None, **kwargs):
39
+ _cleanup.callback(lambda: ran.append("cleaned"))
40
+ raise RuntimeError("boom")
41
+
42
+ with pytest.raises(RuntimeError):
43
+ asyncio.run(_wrapper_for(impl)(peer_id="x"))
44
+ assert ran == ["cleaned"]
45
+
46
+
47
+ def test_open_sdes_wrapper_skips_cleanup_on_success():
48
+ ran = []
49
+ sentinel = object()
50
+
51
+ async def impl(_cleanup=None, **kwargs):
52
+ _cleanup.callback(lambda: ran.append("cleaned"))
53
+ return sentinel
54
+
55
+ result = asyncio.run(_wrapper_for(impl)(peer_id="x"))
56
+ assert result is sentinel
57
+ assert ran == [] # ownership transferred to the session; cleanup must NOT run
58
+
59
+
60
+ def test_open_sdes_wrapper_runs_cleanups_lifo():
61
+ # Steps run last-registered-first, so ffmpeg is killed before its sockets
62
+ # close (mirrors the impl's registration order: sockets, then sdp, then proc).
63
+ order = []
64
+
65
+ async def impl(_cleanup=None, **kwargs):
66
+ _cleanup.callback(lambda: order.append("sock"))
67
+ _cleanup.callback(lambda: order.append("proc"))
68
+ raise asyncio.CancelledError()
69
+
70
+ with pytest.raises(asyncio.CancelledError):
71
+ asyncio.run(_wrapper_for(impl)(peer_id="x"))
72
+ assert order == ["proc", "sock"] # LIFO
@@ -52,6 +52,22 @@ def test_stap_a_carries_both():
52
52
  assert _extract_param_sets_from_rtp(RTP + stap) == {7: SPS, 8: PPS}
53
53
 
54
54
 
55
+ def test_stap_a_truncated_size_is_dropped():
56
+ # Advertised NAL size overruns the packet (frame loss / relay truncation):
57
+ # the parser must NOT slice a short NAL and cache it as a corrupt SPS.
58
+ stap = bytes([0x78])
59
+ stap += (len(SPS) + 50).to_bytes(2, "big") + SPS # claims 50 extra bytes
60
+ assert _extract_param_sets_from_rtp(RTP + stap) == {}
61
+
62
+
63
+ def test_stap_a_keeps_valid_prefix_before_truncation():
64
+ # A valid SPS followed by a truncated PPS: keep the SPS, drop the garbage.
65
+ stap = bytes([0x78])
66
+ stap += len(SPS).to_bytes(2, "big") + SPS # valid SPS
67
+ stap += (len(PPS) + 20).to_bytes(2, "big") + PPS # truncated PPS
68
+ assert _extract_param_sets_from_rtp(RTP + stap) == {7: SPS}
69
+
70
+
55
71
  def test_slice_packet_yields_nothing():
56
72
  # NAL type 1 (non-IDR slice) - not a parameter set
57
73
  assert _extract_param_sets_from_rtp(RTP + bytes([0x61, 0x00, 0x11])) == {}
@@ -0,0 +1,73 @@
1
+ """Persistent-MQTT signaling-drain ownership on the camera stream path.
2
+
3
+ The DeviceClient keeps a single backstop slot (_stream_mqtt_drain/_stream_mqtt_outq)
4
+ for a drain whose open was cancelled before a WebRTC/SdesSession took ownership.
5
+ A successful open hands ownership to the session (which reaps the drain on stop)
6
+ and clears the slot - so a *concurrent* open on the same camera (e.g. a snapshot
7
+ during a live view) no longer reaps, and kills, the live session's drain.
8
+ """
9
+ import asyncio
10
+ import types
11
+
12
+ from aidot.camera.client import CameraMixin
13
+
14
+
15
+ class _FakeFut:
16
+ def __init__(self):
17
+ self.cancelled_flag = False
18
+
19
+ def done(self):
20
+ return False
21
+
22
+ def cancel(self):
23
+ self.cancelled_flag = True
24
+
25
+ def __await__(self):
26
+ if False:
27
+ yield
28
+ return None
29
+
30
+
31
+ class _RecordingQueue:
32
+ def __init__(self):
33
+ self.puts = []
34
+
35
+ def put_nowait(self, item):
36
+ self.puts.append(item)
37
+
38
+
39
+ def _client_with_drain():
40
+ obj = types.SimpleNamespace(
41
+ _stream_mqtt_drain=_FakeFut(),
42
+ _stream_mqtt_outq=_RecordingQueue(),
43
+ device_id="cam",
44
+ )
45
+ obj._reap_stream_drain = CameraMixin._reap_stream_drain.__get__(obj)
46
+ obj._release_stream_drain_to_session = (
47
+ CameraMixin._release_stream_drain_to_session.__get__(obj)
48
+ )
49
+ return obj
50
+
51
+
52
+ def test_handoff_makes_a_concurrent_reap_a_noop():
53
+ obj = _client_with_drain()
54
+ fut, q = obj._stream_mqtt_drain, obj._stream_mqtt_outq
55
+
56
+ obj._release_stream_drain_to_session() # session took ownership
57
+ assert obj._stream_mqtt_drain is None
58
+
59
+ asyncio.run(obj._reap_stream_drain()) # a concurrent open reaps
60
+ assert q.puts == [] # live drain NOT sentinel-killed
61
+ assert not fut.cancelled_flag # live drain NOT cancelled
62
+
63
+
64
+ def test_backstop_still_reaps_an_orphaned_drain():
65
+ # Without a hand-off (open cancelled mid-handshake) the slot still holds the
66
+ # drain, and the reap must release it: sentinel to the queue + cancel.
67
+ obj = _client_with_drain()
68
+ fut, q = obj._stream_mqtt_drain, obj._stream_mqtt_outq
69
+
70
+ asyncio.run(obj._reap_stream_drain())
71
+ assert q.puts == [None] # orphan drain released
72
+ assert fut.cancelled_flag
73
+ assert obj._stream_mqtt_drain is None # slot cleared