python-aidot-cameras 0.5.9__tar.gz → 0.5.10__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 (32) hide show
  1. {python_aidot_cameras-0.5.9/src/python_aidot_cameras.egg-info → python_aidot_cameras-0.5.10}/PKG-INFO +1 -1
  2. {python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10}/pyproject.toml +1 -1
  3. {python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10}/src/aidot/device_client.py +107 -0
  4. {python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10/src/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  5. {python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10}/src/python_aidot_cameras.egg-info/SOURCES.txt +1 -0
  6. python_aidot_cameras-0.5.10/tests/test_sdes_idle_release.py +143 -0
  7. {python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10}/LICENSE +0 -0
  8. {python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10}/README.md +0 -0
  9. {python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10}/setup.cfg +0 -0
  10. {python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10}/src/aidot/__init__.py +0 -0
  11. {python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10}/src/aidot/aes_utils.py +0 -0
  12. {python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10}/src/aidot/client.py +0 -0
  13. {python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10}/src/aidot/const.py +0 -0
  14. {python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10}/src/aidot/credentials.py +0 -0
  15. {python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10}/src/aidot/discover.py +0 -0
  16. {python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10}/src/aidot/exceptions.py +0 -0
  17. {python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10}/src/aidot/g711.py +0 -0
  18. {python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10}/src/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  19. {python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10}/src/python_aidot_cameras.egg-info/requires.txt +0 -0
  20. {python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10}/src/python_aidot_cameras.egg-info/top_level.txt +0 -0
  21. {python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10}/tests/test_alarm_event.py +0 -0
  22. {python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10}/tests/test_highport_nomination.py +0 -0
  23. {python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10}/tests/test_motion_poll.py +0 -0
  24. {python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10}/tests/test_no_undefined_names.py +0 -0
  25. {python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10}/tests/test_sdes_sprop.py +0 -0
  26. {python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10}/tests/test_sdes_talk.py +0 -0
  27. {python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10}/tests/test_sdes_watchdog.py +0 -0
  28. {python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10}/tests/test_speak.py +0 -0
  29. {python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10}/tests/test_stream_cap.py +0 -0
  30. {python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10}/tests/test_talk.py +0 -0
  31. {python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10}/tests/test_terminal_ack.py +0 -0
  32. {python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10}/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.5.9
3
+ Version: 0.5.10
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.5.9"
7
+ version = "0.5.10"
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"
@@ -2083,6 +2083,53 @@ def _inject_sprop(sdp: str, devid: str) -> str:
2083
2083
  return "\r\n".join(out)
2084
2084
 
2085
2085
 
2086
+ def _sdes_serve_port(url: "Optional[str]") -> "Optional[int]":
2087
+ """Extract the TCP port from an SDES serve URL (``http://127.0.0.1:PORT/x.ts``).
2088
+
2089
+ Returns None if the URL is missing/malformed. Pure (unit-testable)."""
2090
+ if not url:
2091
+ return None
2092
+ try:
2093
+ return int(url.rsplit(":", 1)[1].split("/", 1)[0])
2094
+ except (ValueError, IndexError):
2095
+ return None
2096
+
2097
+
2098
+ def _tcp_table_has_established_on_port(table_text: str, port: int) -> bool:
2099
+ """True if any row in a /proc/net/tcp[6] dump is ESTABLISHED on local ``port``.
2100
+
2101
+ /proc/net/tcp columns: ``sl local_address rem_address st ...`` where
2102
+ local_address is ``HEXIP:HEXPORT`` (port is upper-hex, %04X) and ``st`` is the
2103
+ connection state (``01`` == ESTABLISHED, ``0A`` == LISTEN). A LISTEN socket
2104
+ (ffmpeg ``-listen 1`` waiting) is NOT a consumer; only an ESTABLISHED peer is.
2105
+ Pure function so the no-viewer policy is unit-testable without a live host."""
2106
+ target = f"{port:04X}"
2107
+ for line in table_text.splitlines()[1:]: # skip header row
2108
+ cols = line.split()
2109
+ if len(cols) < 4:
2110
+ continue
2111
+ local, st = cols[1], cols[3]
2112
+ if ":" not in local:
2113
+ continue
2114
+ if local.rsplit(":", 1)[1].upper() == target and st == "01":
2115
+ return True
2116
+ return False
2117
+
2118
+
2119
+ def _idle_release_due(present, last_consumer: float, now: float,
2120
+ idle_secs: float) -> bool:
2121
+ """Whether a viewerless SDES keepalive should release.
2122
+
2123
+ ``present``: True (a TCP consumer is connected to the serve port), False
2124
+ (none), or None (the table was unreadable - unknown). Release ONLY when
2125
+ we're certain there's no consumer (False) AND the idle window has elapsed;
2126
+ True keeps it alive and None never releases (fail-safe on non-Linux). Pure
2127
+ so the policy is unit-testable without a live loop."""
2128
+ if present is not False:
2129
+ return False
2130
+ return (now - last_consumer) > idle_secs
2131
+
2132
+
2086
2133
  def _dtls_av_mux_run(vq, aq, out_fileobj, progress, stop_flag) -> None:
2087
2134
  """Mux tapped video (H.264 copy) + audio (PCMA->AAC) to ``out_fileobj`` as
2088
2135
  RTP-timestamped MPEG-TS. Runs in a worker thread; the serve's ffmpeg reads
@@ -4279,6 +4326,24 @@ class DeviceClient(object):
4279
4326
  await session.stop()
4280
4327
  return True
4281
4328
 
4329
+ def _sdes_serve_consumer_present(self, port: int) -> "Optional[bool]":
4330
+ """True if a TCP client is connected to the SDES serve ``port`` (a viewer
4331
+ is pulling), False if none, None if the TCP table can't be read (non-Linux
4332
+ / sandboxed host). The caller MUST treat None as 'unknown' and NOT release
4333
+ — failing safe to the current always-reconnect behaviour."""
4334
+ read_any = False
4335
+ found = False
4336
+ for _path in ("/proc/net/tcp", "/proc/net/tcp6"):
4337
+ try:
4338
+ with open(_path, "r") as _fh:
4339
+ _txt = _fh.read()
4340
+ except OSError:
4341
+ continue
4342
+ read_any = True
4343
+ if _tcp_table_has_established_on_port(_txt, port):
4344
+ found = True
4345
+ return found if read_any else None
4346
+
4282
4347
  async def _sdes_keepalive_loop(self) -> None:
4283
4348
  """Background task: keep SDES stream alive; push to go2rtc via RTSP."""
4284
4349
  _MIN_DELAY = 10.0
@@ -4315,6 +4380,20 @@ class DeviceClient(object):
4315
4380
  _started_at = time.monotonic()
4316
4381
  _done = asyncio.ensure_future(session.wait_done())
4317
4382
  _stalled = False
4383
+ _idle_release = False
4384
+ # No-viewer release. Unlike the DTLS serve (which idle-releases when
4385
+ # its mux pipe goes stale), an SDES keepalive otherwise reconnects
4386
+ # FOREVER even with zero HA consumers — a battery-draining orphan that
4387
+ # also holds a stream slot and TURN-relay bandwidth. SDES has no mux
4388
+ # pipe to watch, so detect "nobody is pulling" via an ESTABLISHED TCP
4389
+ # connection on the -listen serve port and release after the same idle
4390
+ # window as DTLS; the next view re-runs camera.stream_source(). Fail
4391
+ # safe: unknown (non-Linux /proc) never releases. Escape hatch:
4392
+ # AIDOT_SDES_IDLE_RELEASE=0.
4393
+ _idle_on = os.environ.get("AIDOT_SDES_IDLE_RELEASE", "1") != "0"
4394
+ _idle_secs = float(os.environ.get("AIDOT_STREAM_IDLE_S", "120"))
4395
+ _serve_port = _sdes_serve_port(self._keepalive_rtsp_url)
4396
+ _last_consumer = _started_at # grace: count idle from session open
4318
4397
  try:
4319
4398
  while True:
4320
4399
  _fin, _ = await asyncio.wait({_done}, timeout=5.0)
@@ -4327,6 +4406,15 @@ class DeviceClient(object):
4327
4406
  ):
4328
4407
  _stalled = True
4329
4408
  break
4409
+ if _idle_on and _serve_port is not None:
4410
+ _present = self._sdes_serve_consumer_present(_serve_port)
4411
+ if _present: # True → a viewer is pulling; stay alive
4412
+ _last_consumer = time.monotonic()
4413
+ elif _idle_release_due(_present, _last_consumer,
4414
+ time.monotonic(), _idle_secs):
4415
+ _idle_release = True
4416
+ break
4417
+ # _present is None (unreadable table) → don't release
4330
4418
  except asyncio.CancelledError:
4331
4419
  _done.cancel()
4332
4420
  self._stream_session = None
@@ -4335,6 +4423,25 @@ class DeviceClient(object):
4335
4423
  finally:
4336
4424
  self._stream_session = None
4337
4425
 
4426
+ if _idle_release:
4427
+ # No HA consumer for the idle window: go dormant instead of
4428
+ # reconnecting forever. camera.stream_source() restarts us (and
4429
+ # the camera.py stale-stream watchdog evicts HA's cached stream
4430
+ # once stream_rtsp_url is None) when someone opens the view again.
4431
+ _done.cancel()
4432
+ try:
4433
+ await session.stop()
4434
+ except Exception:
4435
+ pass
4436
+ self._streaming_active = False
4437
+ self._keepalive_rtsp_url = None
4438
+ self._serve_ready.clear()
4439
+ _LOGGER.debug(
4440
+ "SDES serve: %s idle (no viewer) - released until next view",
4441
+ self.device_id,
4442
+ )
4443
+ return
4444
+
4338
4445
  if _stalled:
4339
4446
  _LOGGER.info(
4340
4447
  "SDES %s: no media in watchdog window - restarting stream",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-aidot-cameras
3
- Version: 0.5.9
3
+ Version: 0.5.10
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
@@ -19,6 +19,7 @@ tests/test_alarm_event.py
19
19
  tests/test_highport_nomination.py
20
20
  tests/test_motion_poll.py
21
21
  tests/test_no_undefined_names.py
22
+ tests/test_sdes_idle_release.py
22
23
  tests/test_sdes_sprop.py
23
24
  tests/test_sdes_talk.py
24
25
  tests/test_sdes_watchdog.py
@@ -0,0 +1,143 @@
1
+ """Unit tests for the SDES no-viewer idle-release policy helpers.
2
+
3
+ An SDES keepalive otherwise reconnects forever even with zero HA consumers
4
+ (a battery-draining orphan). The loop releases when no TCP client is connected
5
+ to the -listen serve port for the idle window. These pure helpers decide that
6
+ policy; no camera/network/host needed.
7
+
8
+ - _sdes_serve_port: pull the TCP port out of the serve URL.
9
+ - _tcp_table_has_established_on_port: is a consumer ESTABLISHED on that port?
10
+ (A LISTEN socket - ffmpeg waiting - must NOT count as a consumer.)
11
+ """
12
+ import os
13
+ import sys
14
+
15
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
16
+
17
+ from aidot.device_client import (
18
+ _sdes_serve_port,
19
+ _tcp_table_has_established_on_port,
20
+ _idle_release_due,
21
+ )
22
+
23
+ _PORT = 18981 # 0x4A25
24
+ _HEX = f"{_PORT:04X}" # "4A25"
25
+
26
+ _HEADER = (
27
+ " sl local_address rem_address st tx_queue rx_queue tr tm->when "
28
+ "retrnsmt uid timeout inode"
29
+ )
30
+
31
+
32
+ def _row(local_port_hex, st, remote="0100007F:9C40"):
33
+ return (
34
+ f" 0: 0100007F:{local_port_hex} {remote} {st} "
35
+ "00000000:00000000 00:00000000 00000000 0 0 99999 1"
36
+ )
37
+
38
+
39
+ # ---- _sdes_serve_port ------------------------------------------------------
40
+
41
+ def test_serve_port_parsed():
42
+ assert _sdes_serve_port("http://127.0.0.1:18981/abc.ts") == 18981
43
+
44
+
45
+ def test_serve_port_none_url():
46
+ assert _sdes_serve_port(None) is None
47
+
48
+
49
+ def test_serve_port_malformed():
50
+ assert _sdes_serve_port("not-a-url") is None
51
+ assert _sdes_serve_port("http://127.0.0.1:notaport/x.ts") is None
52
+
53
+
54
+ # ---- _tcp_table_has_established_on_port ------------------------------------
55
+
56
+ def test_established_consumer_present():
57
+ table = _HEADER + "\n" + _row(_HEX, "01") # 01 == ESTABLISHED
58
+ assert _tcp_table_has_established_on_port(table, _PORT) is True
59
+
60
+
61
+ def test_listen_only_is_not_a_consumer():
62
+ # ffmpeg -listen 1 waiting (0A == LISTEN) is not a viewer pulling.
63
+ table = _HEADER + "\n" + _row(_HEX, "0A")
64
+ assert _tcp_table_has_established_on_port(table, _PORT) is False
65
+
66
+
67
+ def test_established_on_other_port_ignored():
68
+ table = _HEADER + "\n" + _row("0050", "01") # ESTABLISHED on :80, not ours
69
+ assert _tcp_table_has_established_on_port(table, _PORT) is False
70
+
71
+
72
+ def test_empty_or_header_only_table():
73
+ assert _tcp_table_has_established_on_port("", _PORT) is False
74
+ assert _tcp_table_has_established_on_port(_HEADER, _PORT) is False
75
+
76
+
77
+ def test_tcp6_long_local_address_parses():
78
+ # tcp6 local_address is a 32-hex-char IPv6 + :PORT; port parse must still work.
79
+ table = (
80
+ _HEADER + "\n"
81
+ f" 0: 00000000000000000000000001000000:{_HEX} "
82
+ "00000000000000000000000000000000:0000 01 "
83
+ "00000000:00000000 00:00000000 00000000 0 0 12345 1"
84
+ )
85
+ assert _tcp_table_has_established_on_port(table, _PORT) is True
86
+
87
+
88
+ def test_mixed_rows_finds_the_established_one():
89
+ table = "\n".join([
90
+ _HEADER,
91
+ _row(_HEX, "0A"), # our port, LISTEN (not a consumer)
92
+ _row("0050", "01"), # other port, ESTABLISHED
93
+ _row(_HEX, "01"), # our port, ESTABLISHED <-- the viewer
94
+ ])
95
+ assert _tcp_table_has_established_on_port(table, _PORT) is True
96
+
97
+
98
+ # ---- _idle_release_due (the release decision) ------------------------------
99
+
100
+ _IDLE = 120.0
101
+
102
+
103
+ def test_consumer_present_never_releases():
104
+ # present=True → a viewer is pulling, even long past the idle window
105
+ assert _idle_release_due(True, last_consumer=0.0, now=10_000.0,
106
+ idle_secs=_IDLE) is False
107
+
108
+
109
+ def test_unknown_table_never_releases():
110
+ # present=None → /proc unreadable (non-Linux) → fail-safe, never release
111
+ assert _idle_release_due(None, last_consumer=0.0, now=10_000.0,
112
+ idle_secs=_IDLE) is False
113
+
114
+
115
+ def test_no_consumer_within_window_holds():
116
+ # no consumer but still inside the idle window → keep trying
117
+ assert _idle_release_due(False, last_consumer=1000.0, now=1090.0,
118
+ idle_secs=_IDLE) is False
119
+
120
+
121
+ def test_no_consumer_past_window_releases():
122
+ # no consumer for longer than the idle window → release the orphan
123
+ assert _idle_release_due(False, last_consumer=1000.0, now=1121.0,
124
+ idle_secs=_IDLE) is True
125
+
126
+
127
+ def test_release_boundary_is_strictly_greater():
128
+ assert _idle_release_due(False, 1000.0, 1120.0, _IDLE) is False # == window
129
+
130
+
131
+ if __name__ == "__main__":
132
+ import traceback
133
+ _fail = 0
134
+ for _k, _v in sorted(globals().items()):
135
+ if _k.startswith("test_"):
136
+ try:
137
+ _v()
138
+ print(f"PASS {_k}")
139
+ except Exception:
140
+ _fail += 1
141
+ print(f"FAIL {_k}")
142
+ traceback.print_exc()
143
+ raise SystemExit(1 if _fail else 0)