python-aidot-cameras 0.7.34__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.34/src/python_aidot_cameras.egg-info → python_aidot_cameras-0.7.36}/PKG-INFO +1 -1
  2. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/pyproject.toml +1 -1
  3. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/src/aidot/camera/client.py +36 -7
  4. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36/src/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  5. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/src/python_aidot_cameras.egg-info/SOURCES.txt +1 -0
  6. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/tests/test_sdes_serve_audio.py +22 -0
  7. python_aidot_cameras-0.7.36/tests/test_stream_teardown.py +75 -0
  8. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/LICENSE +0 -0
  9. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/MANIFEST.in +0 -0
  10. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/README.md +0 -0
  11. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/setup.cfg +0 -0
  12. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/src/aidot/__init__.py +0 -0
  13. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/src/aidot/aes_utils.py +0 -0
  14. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/src/aidot/camera/__init__.py +0 -0
  15. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/src/aidot/camera/constants.py +0 -0
  16. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/src/aidot/camera/controls.py +0 -0
  17. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/src/aidot/camera/go2rtc.py +0 -0
  18. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/src/aidot/camera/lan_control.py +0 -0
  19. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/src/aidot/camera/models.py +0 -0
  20. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/src/aidot/camera/playback.py +0 -0
  21. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/src/aidot/camera/protocol.py +0 -0
  22. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/src/aidot/camera/sdes.py +0 -0
  23. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/src/aidot/camera/tutk.py +0 -0
  24. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/src/aidot/camera/webrtc.py +0 -0
  25. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/src/aidot/client.py +0 -0
  26. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/src/aidot/const.py +0 -0
  27. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/src/aidot/credentials.py +0 -0
  28. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/src/aidot/device_client.py +0 -0
  29. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/src/aidot/discover.py +0 -0
  30. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/src/aidot/exceptions.py +0 -0
  31. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/src/aidot/g711.py +0 -0
  32. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/src/aidot/login_const.py +0 -0
  33. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/src/aidot/models/__init__.py +0 -0
  34. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/src/aidot/models/device_client_model.py +0 -0
  35. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/src/aidot/models/device_model.py +0 -0
  36. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/src/aidot/models/discover_model.py +0 -0
  37. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/src/aidot/py.typed +0 -0
  38. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/src/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  39. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/src/python_aidot_cameras.egg-info/requires.txt +0 -0
  40. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/src/python_aidot_cameras.egg-info/top_level.txt +0 -0
  41. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/tests/test_alarm_event.py +0 -0
  42. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/tests/test_backoff.py +0 -0
  43. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/tests/test_device_login_guard.py +0 -0
  44. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/tests/test_go2rtc.py +0 -0
  45. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/tests/test_highport_nomination.py +0 -0
  46. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/tests/test_lan_control.py +0 -0
  47. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/tests/test_live_stream_param.py +0 -0
  48. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/tests/test_motion_poll.py +0 -0
  49. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/tests/test_no_undefined_names.py +0 -0
  50. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/tests/test_persistent_mqtt.py +0 -0
  51. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/tests/test_post_merge_hardening.py +0 -0
  52. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/tests/test_sdes_adaptive.py +0 -0
  53. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/tests/test_sdes_fast_liveplay.py +0 -0
  54. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/tests/test_sdes_idle_release.py +0 -0
  55. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/tests/test_sdes_serve_cmd.py +0 -0
  56. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/tests/test_sdes_sprop.py +0 -0
  57. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/tests/test_sdes_talk.py +0 -0
  58. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/tests/test_sdes_watchdog.py +0 -0
  59. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/tests/test_serve_relay.py +0 -0
  60. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/tests/test_speak.py +0 -0
  61. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/tests/test_stream_cap.py +0 -0
  62. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/tests/test_stream_idle.py +0 -0
  63. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/tests/test_talk.py +0 -0
  64. {python_aidot_cameras-0.7.34 → python_aidot_cameras-0.7.36}/tests/test_terminal_ack.py +0 -0
  65. {python_aidot_cameras-0.7.34 → 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.34
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.34"
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,
@@ -2071,6 +2082,7 @@ class CameraMixin(_CameraControlsMixin):
2071
2082
  sdes_fast_liveplay: Optional[bool] = None,
2072
2083
  sdes_skip_turn: Optional[bool] = None,
2073
2084
  sdes_adaptive: Optional[bool] = None,
2085
+ sdes_audio_gain_db: Optional[float] = None,
2074
2086
  ) -> None:
2075
2087
  """Start a persistent stream that keeps the camera session alive.
2076
2088
 
@@ -2113,6 +2125,8 @@ class CameraMixin(_CameraControlsMixin):
2113
2125
  self._fast_connect_opt = fast_connect
2114
2126
  if sdes_audio is not None:
2115
2127
  self._sdes_audio_opt = sdes_audio
2128
+ if sdes_audio_gain_db is not None:
2129
+ self._sdes_audio_gain_opt = sdes_audio_gain_db
2116
2130
  if live_stream_param is not None:
2117
2131
  self._live_stream_param_opt = live_stream_param
2118
2132
  if serve_relay is not None:
@@ -2734,6 +2748,19 @@ class CameraMixin(_CameraControlsMixin):
2734
2748
  return os.environ.get("AIDOT_SDES_SERVE_AUDIO", "").strip().lower() not in (
2735
2749
  "0", "false", "no", "off")
2736
2750
 
2751
+ def _resolve_sdes_audio_gain_db(self) -> float:
2752
+ """Gain (dB) applied to the served SDES audio (the camera mic runs hot).
2753
+
2754
+ Per-camera ``sdes_audio_gain_db`` (via ``start_keepalive``) wins; else the
2755
+ ``AIDOT_SDES_AUDIO_GAIN_DB`` env; else ``-8``. A bad value falls back to
2756
+ the default rather than raising."""
2757
+ opt = getattr(self, "_sdes_audio_gain_opt", None)
2758
+ src = opt if opt is not None else os.environ.get("AIDOT_SDES_AUDIO_GAIN_DB", "-8")
2759
+ try:
2760
+ return float(src)
2761
+ except (ValueError, TypeError):
2762
+ return -8.0
2763
+
2737
2764
  def _resolve_sdes_skip_turn(self) -> bool:
2738
2765
  """EXPERIMENTAL (opt-in, default off): skip the blocking SDES TURN relay
2739
2766
  pre-allocation, for cameras reachable LAN-direct.
@@ -4227,6 +4254,13 @@ class CameraMixin(_CameraControlsMixin):
4227
4254
  _pm_stream.remove_handler(_on_mqtt_message)
4228
4255
 
4229
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
4230
4264
  _on_mqtt_ready({"connected": True, "rc": 0, "rc_str": "persistent"})
4231
4265
  else:
4232
4266
  mqtt_fut = loop.run_in_executor(
@@ -10052,18 +10086,13 @@ class CameraMixin(_CameraControlsMixin):
10052
10086
  if out_dir:
10053
10087
  os.makedirs(out_dir, exist_ok=True)
10054
10088
  # Build the ffmpeg command (single source of truth: _build_sdes_serve_cmd).
10055
- _sdes_audio = self._resolve_sdes_serve_audio()
10056
- try:
10057
- _sdes_gain_db = float(os.environ.get("AIDOT_SDES_AUDIO_GAIN_DB", "-8"))
10058
- except (ValueError, TypeError):
10059
- _sdes_gain_db = -8.0
10060
10089
  cmd = _build_sdes_serve_cmd(
10061
10090
  sdp_path=sdp_path,
10062
10091
  rtsp_push_url=rtsp_push_url,
10063
10092
  output_path=output_path,
10064
10093
  max_seconds=max_seconds,
10065
- sdes_audio=bool(_sdes_audio),
10066
- audio_gain_db=_sdes_gain_db,
10094
+ sdes_audio=self._resolve_sdes_serve_audio(),
10095
+ audio_gain_db=self._resolve_sdes_audio_gain_db(),
10067
10096
  )
10068
10097
  # --- H.265 fix: narrow the ffmpeg SDP to the camera's actual codec ----
10069
10098
  # The camera streams H.264 (pt=96) OR H.265 (pt=97), varying per session.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-aidot-cameras
3
- Version: 0.7.34
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
@@ -48,6 +48,28 @@ def test_kwarg_option_wins_over_env(monkeypatch):
48
48
  assert cam2._resolve_sdes_serve_audio() is True
49
49
 
50
50
 
51
+ def test_gain_default(monkeypatch):
52
+ monkeypatch.delenv("AIDOT_SDES_AUDIO_GAIN_DB", raising=False)
53
+ assert _cam()._resolve_sdes_audio_gain_db() == -8.0
54
+
55
+
56
+ def test_gain_env(monkeypatch):
57
+ monkeypatch.setenv("AIDOT_SDES_AUDIO_GAIN_DB", "-3.5")
58
+ assert _cam()._resolve_sdes_audio_gain_db() == -3.5
59
+
60
+
61
+ def test_gain_opt_wins(monkeypatch):
62
+ monkeypatch.setenv("AIDOT_SDES_AUDIO_GAIN_DB", "-3")
63
+ cam = _cam()
64
+ cam._sdes_audio_gain_opt = 2.0
65
+ assert cam._resolve_sdes_audio_gain_db() == 2.0
66
+
67
+
68
+ def test_gain_bad_value_falls_back(monkeypatch):
69
+ monkeypatch.setenv("AIDOT_SDES_AUDIO_GAIN_DB", "loud")
70
+ assert _cam()._resolve_sdes_audio_gain_db() == -8.0
71
+
72
+
51
73
  if __name__ == "__main__":
52
74
  import traceback
53
75
 
@@ -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)