python-aidot-cameras 0.7.35__tar.gz → 0.8.0__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 (66) hide show
  1. {python_aidot_cameras-0.7.35/src/python_aidot_cameras.egg-info → python_aidot_cameras-0.8.0}/PKG-INFO +1 -1
  2. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/pyproject.toml +1 -1
  3. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/aidot/camera/client.py +91 -11
  4. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/aidot/camera/protocol.py +8 -2
  5. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/aidot/camera/sdes.py +14 -0
  6. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/aidot/const.py +0 -1
  7. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0/src/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  8. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/python_aidot_cameras.egg-info/SOURCES.txt +2 -0
  9. python_aidot_cameras-0.8.0/tests/test_persistent_mqtt_robustness.py +127 -0
  10. python_aidot_cameras-0.8.0/tests/test_stream_teardown.py +75 -0
  11. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/LICENSE +0 -0
  12. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/MANIFEST.in +0 -0
  13. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/README.md +0 -0
  14. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/setup.cfg +0 -0
  15. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/aidot/__init__.py +0 -0
  16. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/aidot/aes_utils.py +0 -0
  17. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/aidot/camera/__init__.py +0 -0
  18. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/aidot/camera/constants.py +0 -0
  19. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/aidot/camera/controls.py +0 -0
  20. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/aidot/camera/go2rtc.py +0 -0
  21. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/aidot/camera/lan_control.py +0 -0
  22. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/aidot/camera/models.py +0 -0
  23. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/aidot/camera/playback.py +0 -0
  24. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/aidot/camera/tutk.py +0 -0
  25. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/aidot/camera/webrtc.py +0 -0
  26. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/aidot/client.py +0 -0
  27. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/aidot/credentials.py +0 -0
  28. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/aidot/device_client.py +0 -0
  29. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/aidot/discover.py +0 -0
  30. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/aidot/exceptions.py +0 -0
  31. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/aidot/g711.py +0 -0
  32. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/aidot/login_const.py +0 -0
  33. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/aidot/models/__init__.py +0 -0
  34. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/aidot/models/device_client_model.py +0 -0
  35. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/aidot/models/device_model.py +0 -0
  36. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/aidot/models/discover_model.py +0 -0
  37. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/aidot/py.typed +0 -0
  38. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  39. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/python_aidot_cameras.egg-info/requires.txt +0 -0
  40. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/python_aidot_cameras.egg-info/top_level.txt +0 -0
  41. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/tests/test_alarm_event.py +0 -0
  42. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/tests/test_backoff.py +0 -0
  43. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/tests/test_device_login_guard.py +0 -0
  44. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/tests/test_go2rtc.py +0 -0
  45. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/tests/test_highport_nomination.py +0 -0
  46. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/tests/test_lan_control.py +0 -0
  47. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/tests/test_live_stream_param.py +0 -0
  48. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/tests/test_motion_poll.py +0 -0
  49. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/tests/test_no_undefined_names.py +0 -0
  50. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/tests/test_persistent_mqtt.py +0 -0
  51. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/tests/test_post_merge_hardening.py +0 -0
  52. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/tests/test_sdes_adaptive.py +0 -0
  53. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/tests/test_sdes_fast_liveplay.py +0 -0
  54. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/tests/test_sdes_idle_release.py +0 -0
  55. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/tests/test_sdes_serve_audio.py +0 -0
  56. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/tests/test_sdes_serve_cmd.py +0 -0
  57. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/tests/test_sdes_sprop.py +0 -0
  58. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/tests/test_sdes_talk.py +0 -0
  59. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/tests/test_sdes_watchdog.py +0 -0
  60. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/tests/test_serve_relay.py +0 -0
  61. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/tests/test_speak.py +0 -0
  62. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/tests/test_stream_cap.py +0 -0
  63. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/tests/test_stream_idle.py +0 -0
  64. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/tests/test_talk.py +0 -0
  65. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/tests/test_terminal_ack.py +0 -0
  66. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/tests/test_token_refresh.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-aidot-cameras
3
- Version: 0.7.35
3
+ Version: 0.8.0
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-aidot-cameras"
7
- version = "0.7.35"
7
+ version = "0.8.0"
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"
@@ -584,6 +584,7 @@ class CameraMixin(_CameraControlsMixin):
584
584
  self._streaming_active: bool = False
585
585
  self._stream_session: Optional[Any] = None
586
586
  self._stream_task: Optional["asyncio.Task[None]"] = None
587
+ self._stream_mqtt_drain: Optional["asyncio.Future"] = None
587
588
  self._last_frame_time: float = 0.0
588
589
  self._keepalive_rtsp_url: Optional[str] = None # local serve URL (go2rtc pulls)
589
590
  self._go2rtc_url: Optional[str] = None # go2rtc API base (prefer-go2rtc)
@@ -1339,6 +1340,20 @@ class CameraMixin(_CameraControlsMixin):
1339
1340
  subscribe_topics=sub_topics,
1340
1341
  timeout=timeout,
1341
1342
  )
1343
+ if _st and _st.get("error"):
1344
+ # Persistent connection unavailable - fall back to a fresh per-op
1345
+ # connect so the command isn't silently dropped (and isn't falsely
1346
+ # reported as sent on the empty result the persistent path returns).
1347
+ _LOGGER.debug(
1348
+ "_mqtt_device_cmd: persistent MQTT failed (%s); falling back "
1349
+ "to a per-op session for %s", _st.get("error"), device_id,
1350
+ )
1351
+ messages = await _mqtt_session(
1352
+ mqtt_url, mqtt_user, mqtt_pwd, client_id,
1353
+ subscribe_topics=sub_topics,
1354
+ publish_items=publish_items,
1355
+ duration=timeout,
1356
+ )
1342
1357
  else:
1343
1358
  messages = await _mqtt_session(
1344
1359
  mqtt_url, mqtt_user, mqtt_pwd, client_id,
@@ -1582,6 +1597,20 @@ class CameraMixin(_CameraControlsMixin):
1582
1597
  subscribe_topics=sub_topics,
1583
1598
  timeout=timeout,
1584
1599
  )
1600
+ if _st and _st.get("error"):
1601
+ # Persistent connection unavailable - fall back to a fresh per-op
1602
+ # connect so a transient outage doesn't masquerade as "no attrs".
1603
+ _LOGGER.debug(
1604
+ "async_get_camera_attributes: persistent MQTT failed (%s); "
1605
+ "falling back to a per-op session for %s",
1606
+ _st.get("error"), device_id,
1607
+ )
1608
+ messages = await _mqtt_session(
1609
+ mqtt_url, mqtt_user, mqtt_pwd, client_id,
1610
+ subscribe_topics=sub_topics,
1611
+ publish_items=publish_items,
1612
+ duration=timeout,
1613
+ )
1585
1614
  else:
1586
1615
  messages = await _mqtt_session(
1587
1616
  mqtt_url, mqtt_user, mqtt_pwd, client_id,
@@ -1981,6 +2010,37 @@ class CameraMixin(_CameraControlsMixin):
1981
2010
  await task
1982
2011
  except (asyncio.CancelledError, Exception):
1983
2012
  _LOGGER.debug("camera %s: swallowed exception", 'async_stop_streaming', exc_info=True)
2013
+ # Reap a persistent-MQTT stream drain that no session stopped (e.g. an
2014
+ # open cancelled mid-handshake) so its handler is removed from the shared
2015
+ # connection and its blocked executor thread is released.
2016
+ await self._reap_stream_drain()
2017
+
2018
+ async def _reap_stream_drain(self):
2019
+ """Stop and reap the persistent-MQTT stream drain task, if any.
2020
+
2021
+ The drain blocks an executor thread on ``outgoing_q.get`` until a ``None``
2022
+ sentinel arrives. A normal stop pushes that sentinel from the
2023
+ WebRTCSession/SdesSession; but if no session took ownership (open
2024
+ cancelled mid-handshake) or a new open replaced this one, we must push it
2025
+ ourselves - cancelling the future alone cannot interrupt the blocked
2026
+ thread, leaking it (and its handler on the shared connection) forever."""
2027
+ drain = getattr(self, "_stream_mqtt_drain", None)
2028
+ outq = getattr(self, "_stream_mqtt_outq", None)
2029
+ self._stream_mqtt_drain = None
2030
+ self._stream_mqtt_outq = None
2031
+ if drain is None:
2032
+ return
2033
+ if outq is not None:
2034
+ try:
2035
+ outq.put_nowait(None) # release the executor thread in outgoing_q.get
2036
+ except Exception:
2037
+ _LOGGER.debug("camera %s: swallowed exception", '_reap_stream_drain', exc_info=True)
2038
+ if not drain.done():
2039
+ drain.cancel()
2040
+ try:
2041
+ await drain
2042
+ except (asyncio.CancelledError, Exception):
2043
+ _LOGGER.debug("camera %s: swallowed exception", '_reap_stream_drain', exc_info=True)
1984
2044
 
1985
2045
  async def async_start_motion_polling(
1986
2046
  self, callback: Callable, interval: float = 30.0, lookback_s: int = 600,
@@ -2833,19 +2893,28 @@ class CameraMixin(_CameraControlsMixin):
2833
2893
  pm = li.get("_persistent_mqtt")
2834
2894
  if pm is not None:
2835
2895
  return pm
2836
- smarthome_auth = await self._async_get_smarthome_auth()
2837
- mqtt_user = (smarthome_auth or {}).get("mqttUser") or str(self.user_id)
2838
- mqtt_pwd = (smarthome_auth or {}).get("mqttPassword") or ""
2839
- client_id = (self._user_info.get("mqttClientId") or f"app-{mqtt_user}")
2840
- mqtt_url = await self._async_get_mqtt_url()
2841
- if not mqtt_url:
2842
- return None
2843
- from .protocol import _PersistentMqtt
2844
- pm = li.get("_persistent_mqtt") # re-check under the brief await gap
2845
- if pm is None:
2896
+ # Serialize get-or-create. Without this, two concurrent first-callers
2897
+ # (e.g. a command publish racing a stream open) both pass the None-check
2898
+ # above, both await below, and both build a _PersistentMqtt - the second
2899
+ # clobbering the first and orphaning a connection on the single-client_id
2900
+ # broker. dict.setdefault is atomic (no await between create and insert),
2901
+ # so every caller for this account shares the one lock.
2902
+ lock = li.setdefault("_persistent_mqtt_lock", asyncio.Lock())
2903
+ async with lock:
2904
+ pm = li.get("_persistent_mqtt") # re-check under the lock
2905
+ if pm is not None:
2906
+ return pm
2907
+ smarthome_auth = await self._async_get_smarthome_auth()
2908
+ mqtt_user = (smarthome_auth or {}).get("mqttUser") or str(self.user_id)
2909
+ mqtt_pwd = (smarthome_auth or {}).get("mqttPassword") or ""
2910
+ client_id = (self._user_info.get("mqttClientId") or f"app-{mqtt_user}")
2911
+ mqtt_url = await self._async_get_mqtt_url()
2912
+ if not mqtt_url:
2913
+ return None
2914
+ from .protocol import _PersistentMqtt
2846
2915
  pm = _PersistentMqtt(mqtt_url, mqtt_user, mqtt_pwd, client_id)
2847
2916
  li["_persistent_mqtt"] = pm
2848
- return pm
2917
+ return pm
2849
2918
 
2850
2919
  @staticmethod
2851
2920
  def _adaptive_next_fast(adaptive: bool, fast_failed: bool) -> bool:
@@ -4226,6 +4295,10 @@ class CameraMixin(_CameraControlsMixin):
4226
4295
  # (the stream's mqtt_cid IS the authorized mqttClientId, so it's the same
4227
4296
  # connection) - subscribe + register a handler + drain outgoing_q through
4228
4297
  # it, and DON'T tear the connection down on stop (matching the app).
4298
+ # A prior open may have left a drain running (e.g. a reopen with no
4299
+ # async_stop_streaming between them); reap it before starting a new one
4300
+ # so we don't orphan its executor thread + handler on the shared conn.
4301
+ await self._reap_stream_drain()
4229
4302
  _pm_stream = (await self._get_persistent_mqtt()
4230
4303
  if self._resolve_persistent_mqtt() else None)
4231
4304
  if _pm_stream is not None:
@@ -4243,6 +4316,13 @@ class CameraMixin(_CameraControlsMixin):
4243
4316
  _pm_stream.remove_handler(_on_mqtt_message)
4244
4317
 
4245
4318
  mqtt_fut = asyncio.ensure_future(_pm_stream_drain())
4319
+ # Track the drain AND its queue so teardown (or a replacing open) can
4320
+ # reap it even if this open is cancelled before a WebRTCSession takes
4321
+ # ownership. _reap_stream_drain pushes the outgoing_q sentinel to
4322
+ # release the thread blocked on outgoing_q.get and removes the handler
4323
+ # from the shared persistent connection.
4324
+ self._stream_mqtt_drain = mqtt_fut
4325
+ self._stream_mqtt_outq = outgoing_q
4246
4326
  _on_mqtt_ready({"connected": True, "rc": 0, "rc_str": "persistent"})
4247
4327
  else:
4248
4328
  mqtt_fut = loop.run_in_executor(
@@ -1378,10 +1378,16 @@ class _PersistentMqtt:
1378
1378
  q = _queue.Queue()
1379
1379
  with self._lock:
1380
1380
  self._collectors.append(q)
1381
+ c = self._client # snapshot: a concurrent close() may null self._client
1381
1382
  collected = []
1382
1383
  try:
1383
- for pt, pp in (publish_items or []):
1384
- self._client.publish(pt, pp)
1384
+ if c is None:
1385
+ return [], {"error": "persistent mqtt closed"}
1386
+ try:
1387
+ for pt, pp in (publish_items or []):
1388
+ c.publish(pt, pp)
1389
+ except Exception as exc:
1390
+ return [], {"error": f"persistent mqtt publish failed: {exc}"}
1385
1391
  deadline = _time.monotonic() + timeout
1386
1392
  while True:
1387
1393
  remaining = deadline - _time.monotonic()
@@ -186,6 +186,13 @@ class SdesSession:
186
186
  await _stop_loop.run_in_executor(None, lambda: self._proc.wait(5))
187
187
  except Exception:
188
188
  self._proc.kill()
189
+ # Reap the SIGKILL'd child: this is a raw subprocess.Popen with no
190
+ # asyncio child-watcher to auto-reap it, so without a follow-up wait()
191
+ # it lingers as a zombie under rapid keepalive reconnect churn.
192
+ try:
193
+ await _stop_loop.run_in_executor(None, lambda: self._proc.wait(5))
194
+ except Exception:
195
+ _LOGGER.debug("camera %s: swallowed exception", 'stop', exc_info=True)
189
196
  # Read drained stderr in the executor with a hard timeout: proc.stderr.read()
190
197
  # blocks until EOF, which never arrives if the killed process is still a
191
198
  # zombie / stuck in uninterruptible I/O - doing it inline would hang the
@@ -199,6 +206,13 @@ class SdesSession:
199
206
  )
200
207
  except Exception: # incl. asyncio.TimeoutError - never let teardown hang here
201
208
  _LOGGER.debug("camera %s: swallowed exception", 'stop', exc_info=True)
209
+ # On timeout the executor thread is still blocked in stderr.read() on
210
+ # a wedged ffmpeg; close the pipe so that read returns instead of
211
+ # pinning a default-pool thread for the life of the process.
212
+ try:
213
+ self._proc.stderr.close()
214
+ except Exception:
215
+ _LOGGER.debug("camera %s: swallowed exception", 'stop', exc_info=True)
202
216
  if stderr_bytes:
203
217
  _LOGGER.warning("ffmpeg SDES stderr:\n%s", stderr_bytes.decode(errors="replace"))
204
218
  import os
@@ -169,7 +169,6 @@ DEFAULT_COUNTRY_CODE = "US"
169
169
  CONF_APP_ID = "Appid"
170
170
  CONF_TERMINAL = "Terminal"
171
171
  CONF_LOGIN_RESPONSE = "login_response"
172
- CONF_LOGIN_INFO = "login_info"
173
172
  CONF_SELECTED_HOUSE = "selected_house"
174
173
  CONF_DEVICE_LIST = "device_list"
175
174
  CONF_PRODUCT_LIST = "product_list"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-aidot-cameras
3
- Version: 0.7.35
3
+ Version: 0.8.0
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
@@ -44,6 +44,7 @@ tests/test_live_stream_param.py
44
44
  tests/test_motion_poll.py
45
45
  tests/test_no_undefined_names.py
46
46
  tests/test_persistent_mqtt.py
47
+ tests/test_persistent_mqtt_robustness.py
47
48
  tests/test_post_merge_hardening.py
48
49
  tests/test_sdes_adaptive.py
49
50
  tests/test_sdes_fast_liveplay.py
@@ -57,6 +58,7 @@ tests/test_serve_relay.py
57
58
  tests/test_speak.py
58
59
  tests/test_stream_cap.py
59
60
  tests/test_stream_idle.py
61
+ tests/test_stream_teardown.py
60
62
  tests/test_talk.py
61
63
  tests/test_terminal_ack.py
62
64
  tests/test_token_refresh.py
@@ -0,0 +1,127 @@
1
+ """Regression tests for persistent-MQTT robustness fixes (pre-0.8.0 review).
2
+
3
+ Covers three confirmed defects in the default-on persistent-MQTT path:
4
+ * _request_sync must not raise when a concurrent close() nulls self._client,
5
+ nor when publish() raises - it returns an error tuple so callers fall back.
6
+ * _get_persistent_mqtt must create exactly ONE connection per account even
7
+ under concurrent first-callers (double-checked-locking race).
8
+ * _reap_stream_drain must release the executor thread blocked on outgoing_q
9
+ (cancellation alone cannot) and run the drain's finally (handler removal).
10
+ """
11
+ import asyncio
12
+ import os
13
+ import queue
14
+ import sys
15
+
16
+ sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"))
17
+
18
+ import aidot.camera.protocol as proto
19
+ import aidot.camera.client as cc
20
+ from aidot.camera.protocol import _PersistentMqtt
21
+
22
+ _CAM = next(v for v in vars(cc).values()
23
+ if isinstance(v, type) and "_get_persistent_mqtt" in v.__dict__)
24
+
25
+
26
+ def _cam():
27
+ return _CAM.__new__(_CAM)
28
+
29
+
30
+ # --- finding 3: _request_sync hardened against a concurrent close()/publish --- #
31
+
32
+ def test_request_sync_returns_error_when_client_nulled(monkeypatch):
33
+ pm = _PersistentMqtt("wss://h:8443/mqtt", "u", "p", "cid")
34
+ monkeypatch.setattr(pm, "_ensure_started_sync", lambda timeout=15.0: True)
35
+ pm._client = None # simulate close() nulling the client after the started-check
36
+ messages, st = pm._request_sync([("t", "pp")], ["a/#"], None, 0.05)
37
+ assert messages == []
38
+ assert st.get("error") # truthy error tuple, NOT an AttributeError
39
+ assert pm._collectors == [] # transient collector still cleaned up
40
+
41
+
42
+ def test_request_sync_returns_error_when_publish_raises(monkeypatch):
43
+ pm = _PersistentMqtt("wss://h:8443/mqtt", "u", "p", "cid")
44
+ monkeypatch.setattr(pm, "_ensure_started_sync", lambda timeout=15.0: True)
45
+
46
+ class _BadClient:
47
+ def publish(self, *a):
48
+ raise RuntimeError("boom")
49
+
50
+ pm._client = _BadClient()
51
+ messages, st = pm._request_sync([("t", "pp")], [], None, 0.05)
52
+ assert messages == []
53
+ assert "boom" in (st.get("error") or "")
54
+ assert pm._collectors == []
55
+
56
+
57
+ # --- finding 4: get-or-create is single-flight under concurrency ------------- #
58
+
59
+ def test_get_persistent_mqtt_one_instance_under_concurrency(monkeypatch):
60
+ cam = _cam()
61
+ cam._user_info = {"mqttClientId": "cid"}
62
+ created = []
63
+
64
+ async def _auth():
65
+ return {"mqttUser": "u", "mqttPassword": "p"}
66
+
67
+ async def _url():
68
+ await asyncio.sleep(0) # yield so both callers interleave past the first check
69
+ return "wss://h:8443/mqtt"
70
+
71
+ cam._async_get_smarthome_auth = _auth
72
+ cam._async_get_mqtt_url = _url
73
+
74
+ orig = proto._PersistentMqtt
75
+
76
+ def _counting(*a, **k):
77
+ obj = orig(*a, **k)
78
+ created.append(obj)
79
+ return obj
80
+
81
+ monkeypatch.setattr(proto, "_PersistentMqtt", _counting)
82
+
83
+ async def _run():
84
+ return await asyncio.gather(cam._get_persistent_mqtt(),
85
+ cam._get_persistent_mqtt())
86
+
87
+ a, b = asyncio.run(_run())
88
+ assert a is b is not None
89
+ assert len(created) == 1 # the race previously built two
90
+
91
+
92
+ # --- finding 1: reaping releases the executor-blocked drain ------------------ #
93
+
94
+ def test_reap_stream_drain_releases_blocked_drain():
95
+ cam = _cam()
96
+ outq = queue.Queue()
97
+ saw = []
98
+
99
+ async def _drain():
100
+ loop = asyncio.get_running_loop()
101
+ try:
102
+ while True:
103
+ out = await loop.run_in_executor(None, outq.get)
104
+ if out is None: # sentinel from _reap_stream_drain
105
+ return
106
+ finally:
107
+ saw.append("finally") # in prod this removes the handler
108
+
109
+ async def _run():
110
+ fut = asyncio.ensure_future(_drain())
111
+ await asyncio.sleep(0.05) # let it block on outgoing_q.get
112
+ cam._stream_mqtt_drain = fut
113
+ cam._stream_mqtt_outq = outq
114
+ await cam._reap_stream_drain()
115
+ return fut
116
+
117
+ fut = asyncio.run(_run())
118
+ assert fut.done()
119
+ assert "finally" in saw # drain's cleanup ran (not leaked)
120
+ assert cam._stream_mqtt_drain is None and cam._stream_mqtt_outq is None
121
+
122
+
123
+ def test_reap_stream_drain_noop_when_nothing_tracked():
124
+ cam = _cam()
125
+ # No drain tracked: must be a clean no-op, not raise.
126
+ asyncio.run(cam._reap_stream_drain())
127
+ assert getattr(cam, "_stream_mqtt_drain", None) is None
@@ -0,0 +1,75 @@
1
+ """Unit test: async_stop_streaming reaps an orphaned persistent-MQTT drain.
2
+
3
+ If a stream open is cancelled before a WebRTCSession takes ownership of the
4
+ drain task, the drain would otherwise block forever on outgoing_q.get with its
5
+ handler still registered. async_stop_streaming must cancel it (its finally then
6
+ removes the handler). No camera needed.
7
+ """
8
+ import asyncio
9
+ import os
10
+ import sys
11
+
12
+ sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"))
13
+
14
+ import aidot.camera.client as cc
15
+
16
+ _CAM = next(v for v in vars(cc).values()
17
+ if isinstance(v, type) and "async_stop_streaming" in v.__dict__)
18
+
19
+
20
+ def _bare_client():
21
+ c = _CAM.__new__(_CAM)
22
+ c._streaming_active = True
23
+ c._serve_ready = asyncio.Event()
24
+ c._go2rtc_task = None
25
+ c._stream_session = None
26
+ c._stream_task = None
27
+ c._stream_mqtt_drain = None
28
+
29
+ async def _noop_deregister():
30
+ return None
31
+ c._deregister_go2rtc = _noop_deregister
32
+ return c
33
+
34
+
35
+ def test_orphaned_drain_is_cancelled():
36
+ async def _run():
37
+ c = _bare_client()
38
+ handler_removed = {"v": False}
39
+
40
+ async def _drain():
41
+ try:
42
+ await asyncio.Event().wait() # blocks forever, like outgoing_q.get
43
+ finally:
44
+ handler_removed["v"] = True # the real finally calls remove_handler
45
+ c._stream_mqtt_drain = asyncio.ensure_future(_drain())
46
+ await asyncio.sleep(0) # let the drain start blocking
47
+
48
+ await c.async_stop_streaming()
49
+
50
+ assert c._stream_mqtt_drain is None # cleared
51
+ assert handler_removed["v"] is True # drain's finally ran (handler removed)
52
+ asyncio.run(_run())
53
+
54
+
55
+ def test_no_drain_is_safe():
56
+ async def _run():
57
+ c = _bare_client() # _stream_mqtt_drain is None
58
+ await c.async_stop_streaming() # must not raise
59
+ assert c._streaming_active is False
60
+ asyncio.run(_run())
61
+
62
+
63
+ if __name__ == "__main__":
64
+ import traceback
65
+ _fail = 0
66
+ for _k, _v in sorted(globals().items()):
67
+ if _k.startswith("test_"):
68
+ try:
69
+ _v()
70
+ print(f"PASS {_k}")
71
+ except Exception:
72
+ _fail += 1
73
+ print(f"FAIL {_k}")
74
+ traceback.print_exc()
75
+ raise SystemExit(1 if _fail else 0)