python-aidot-cameras 0.7.35__tar.gz → 0.7.36__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 (65) hide show
  1. {python_aidot_cameras-0.7.35/src/python_aidot_cameras.egg-info → python_aidot_cameras-0.7.36}/PKG-INFO +1 -1
  2. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/pyproject.toml +1 -1
  3. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/src/aidot/camera/client.py +18 -0
  4. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36/src/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  5. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/src/python_aidot_cameras.egg-info/SOURCES.txt +1 -0
  6. python_aidot_cameras-0.7.36/tests/test_stream_teardown.py +75 -0
  7. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/LICENSE +0 -0
  8. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/MANIFEST.in +0 -0
  9. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/README.md +0 -0
  10. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/setup.cfg +0 -0
  11. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/src/aidot/__init__.py +0 -0
  12. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/src/aidot/aes_utils.py +0 -0
  13. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/src/aidot/camera/__init__.py +0 -0
  14. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/src/aidot/camera/constants.py +0 -0
  15. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/src/aidot/camera/controls.py +0 -0
  16. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/src/aidot/camera/go2rtc.py +0 -0
  17. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/src/aidot/camera/lan_control.py +0 -0
  18. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/src/aidot/camera/models.py +0 -0
  19. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/src/aidot/camera/playback.py +0 -0
  20. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/src/aidot/camera/protocol.py +0 -0
  21. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/src/aidot/camera/sdes.py +0 -0
  22. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/src/aidot/camera/tutk.py +0 -0
  23. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/src/aidot/camera/webrtc.py +0 -0
  24. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/src/aidot/client.py +0 -0
  25. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/src/aidot/const.py +0 -0
  26. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/src/aidot/credentials.py +0 -0
  27. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/src/aidot/device_client.py +0 -0
  28. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/src/aidot/discover.py +0 -0
  29. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/src/aidot/exceptions.py +0 -0
  30. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/src/aidot/g711.py +0 -0
  31. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/src/aidot/login_const.py +0 -0
  32. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/src/aidot/models/__init__.py +0 -0
  33. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/src/aidot/models/device_client_model.py +0 -0
  34. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/src/aidot/models/device_model.py +0 -0
  35. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/src/aidot/models/discover_model.py +0 -0
  36. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/src/aidot/py.typed +0 -0
  37. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/src/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  38. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/src/python_aidot_cameras.egg-info/requires.txt +0 -0
  39. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/src/python_aidot_cameras.egg-info/top_level.txt +0 -0
  40. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/tests/test_alarm_event.py +0 -0
  41. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/tests/test_backoff.py +0 -0
  42. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/tests/test_device_login_guard.py +0 -0
  43. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/tests/test_go2rtc.py +0 -0
  44. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/tests/test_highport_nomination.py +0 -0
  45. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/tests/test_lan_control.py +0 -0
  46. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/tests/test_live_stream_param.py +0 -0
  47. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/tests/test_motion_poll.py +0 -0
  48. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/tests/test_no_undefined_names.py +0 -0
  49. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/tests/test_persistent_mqtt.py +0 -0
  50. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/tests/test_post_merge_hardening.py +0 -0
  51. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/tests/test_sdes_adaptive.py +0 -0
  52. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/tests/test_sdes_fast_liveplay.py +0 -0
  53. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/tests/test_sdes_idle_release.py +0 -0
  54. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/tests/test_sdes_serve_audio.py +0 -0
  55. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/tests/test_sdes_serve_cmd.py +0 -0
  56. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/tests/test_sdes_sprop.py +0 -0
  57. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/tests/test_sdes_talk.py +0 -0
  58. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/tests/test_sdes_watchdog.py +0 -0
  59. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/tests/test_serve_relay.py +0 -0
  60. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/tests/test_speak.py +0 -0
  61. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/tests/test_stream_cap.py +0 -0
  62. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/tests/test_stream_idle.py +0 -0
  63. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/tests/test_talk.py +0 -0
  64. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/tests/test_terminal_ack.py +0 -0
  65. {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.7.36}/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.7.36
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.7.36"
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)
@@ -1981,6 +1982,16 @@ class CameraMixin(_CameraControlsMixin):
1981
1982
  await task
1982
1983
  except (asyncio.CancelledError, Exception):
1983
1984
  _LOGGER.debug("camera %s: swallowed exception", 'async_stop_streaming', exc_info=True)
1985
+ # Reap a persistent-MQTT stream drain that no session stopped (e.g. an
1986
+ # open cancelled mid-handshake): cancelling it runs its finally, which
1987
+ # removes the handler from the shared persistent connection.
1988
+ drain, self._stream_mqtt_drain = getattr(self, "_stream_mqtt_drain", None), None
1989
+ if drain is not None and not drain.done():
1990
+ drain.cancel()
1991
+ try:
1992
+ await drain
1993
+ except (asyncio.CancelledError, Exception):
1994
+ _LOGGER.debug("camera %s: swallowed exception", 'async_stop_streaming', exc_info=True)
1984
1995
 
1985
1996
  async def async_start_motion_polling(
1986
1997
  self, callback: Callable, interval: float = 30.0, lookback_s: int = 600,
@@ -4243,6 +4254,13 @@ class CameraMixin(_CameraControlsMixin):
4243
4254
  _pm_stream.remove_handler(_on_mqtt_message)
4244
4255
 
4245
4256
  mqtt_fut = asyncio.ensure_future(_pm_stream_drain())
4257
+ # Track the drain so teardown can reap it even if this open is
4258
+ # cancelled before a WebRTCSession takes ownership (the session
4259
+ # normally stops it via the outgoing_q sentinel). Without this an
4260
+ # open cancelled mid-handshake leaves the drain blocked on
4261
+ # outgoing_q.get forever and its handler registered on the shared
4262
+ # persistent connection.
4263
+ self._stream_mqtt_drain = mqtt_fut
4246
4264
  _on_mqtt_ready({"connected": True, "rc": 0, "rc_str": "persistent"})
4247
4265
  else:
4248
4266
  mqtt_fut = loop.run_in_executor(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-aidot-cameras
3
- Version: 0.7.35
3
+ Version: 0.7.36
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
@@ -57,6 +57,7 @@ tests/test_serve_relay.py
57
57
  tests/test_speak.py
58
58
  tests/test_stream_cap.py
59
59
  tests/test_stream_idle.py
60
+ tests/test_stream_teardown.py
60
61
  tests/test_talk.py
61
62
  tests/test_terminal_ack.py
62
63
  tests/test_token_refresh.py
@@ -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)