python-aidot-cameras 0.5.8__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.8/src/python_aidot_cameras.egg-info → python_aidot_cameras-0.5.10}/PKG-INFO +1 -1
  2. {python_aidot_cameras-0.5.8 → python_aidot_cameras-0.5.10}/pyproject.toml +1 -1
  3. {python_aidot_cameras-0.5.8 → python_aidot_cameras-0.5.10}/src/aidot/device_client.py +118 -3
  4. {python_aidot_cameras-0.5.8 → python_aidot_cameras-0.5.10/src/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  5. {python_aidot_cameras-0.5.8 → 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.8 → python_aidot_cameras-0.5.10}/LICENSE +0 -0
  8. {python_aidot_cameras-0.5.8 → python_aidot_cameras-0.5.10}/README.md +0 -0
  9. {python_aidot_cameras-0.5.8 → python_aidot_cameras-0.5.10}/setup.cfg +0 -0
  10. {python_aidot_cameras-0.5.8 → python_aidot_cameras-0.5.10}/src/aidot/__init__.py +0 -0
  11. {python_aidot_cameras-0.5.8 → python_aidot_cameras-0.5.10}/src/aidot/aes_utils.py +0 -0
  12. {python_aidot_cameras-0.5.8 → python_aidot_cameras-0.5.10}/src/aidot/client.py +0 -0
  13. {python_aidot_cameras-0.5.8 → python_aidot_cameras-0.5.10}/src/aidot/const.py +0 -0
  14. {python_aidot_cameras-0.5.8 → python_aidot_cameras-0.5.10}/src/aidot/credentials.py +0 -0
  15. {python_aidot_cameras-0.5.8 → python_aidot_cameras-0.5.10}/src/aidot/discover.py +0 -0
  16. {python_aidot_cameras-0.5.8 → python_aidot_cameras-0.5.10}/src/aidot/exceptions.py +0 -0
  17. {python_aidot_cameras-0.5.8 → python_aidot_cameras-0.5.10}/src/aidot/g711.py +0 -0
  18. {python_aidot_cameras-0.5.8 → python_aidot_cameras-0.5.10}/src/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  19. {python_aidot_cameras-0.5.8 → python_aidot_cameras-0.5.10}/src/python_aidot_cameras.egg-info/requires.txt +0 -0
  20. {python_aidot_cameras-0.5.8 → python_aidot_cameras-0.5.10}/src/python_aidot_cameras.egg-info/top_level.txt +0 -0
  21. {python_aidot_cameras-0.5.8 → python_aidot_cameras-0.5.10}/tests/test_alarm_event.py +0 -0
  22. {python_aidot_cameras-0.5.8 → python_aidot_cameras-0.5.10}/tests/test_highport_nomination.py +0 -0
  23. {python_aidot_cameras-0.5.8 → python_aidot_cameras-0.5.10}/tests/test_motion_poll.py +0 -0
  24. {python_aidot_cameras-0.5.8 → python_aidot_cameras-0.5.10}/tests/test_no_undefined_names.py +0 -0
  25. {python_aidot_cameras-0.5.8 → python_aidot_cameras-0.5.10}/tests/test_sdes_sprop.py +0 -0
  26. {python_aidot_cameras-0.5.8 → python_aidot_cameras-0.5.10}/tests/test_sdes_talk.py +0 -0
  27. {python_aidot_cameras-0.5.8 → python_aidot_cameras-0.5.10}/tests/test_sdes_watchdog.py +0 -0
  28. {python_aidot_cameras-0.5.8 → python_aidot_cameras-0.5.10}/tests/test_speak.py +0 -0
  29. {python_aidot_cameras-0.5.8 → python_aidot_cameras-0.5.10}/tests/test_stream_cap.py +0 -0
  30. {python_aidot_cameras-0.5.8 → python_aidot_cameras-0.5.10}/tests/test_talk.py +0 -0
  31. {python_aidot_cameras-0.5.8 → python_aidot_cameras-0.5.10}/tests/test_terminal_ack.py +0 -0
  32. {python_aidot_cameras-0.5.8 → 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.8
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.8"
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"
@@ -2059,8 +2059,11 @@ def _save_sprop(devid: str, sprop: str) -> None:
2059
2059
  with open(tmp, "w") as fh:
2060
2060
  fh.write(sprop)
2061
2061
  os.replace(tmp, _sprop_cache_path(devid))
2062
- except OSError:
2063
- pass
2062
+ except OSError as exc:
2063
+ # Surface (don't swallow): if the cache dir isn't writable the whole
2064
+ # sprop feature is silently inert. AIDOT_SPROP_DIR can redirect it.
2065
+ _LOGGER.warning("sprop cache write failed (%s): %s — set AIDOT_SPROP_DIR "
2066
+ "to a writable path", _SPROP_DIR, exc)
2064
2067
 
2065
2068
 
2066
2069
  def _inject_sprop(sdp: str, devid: str) -> str:
@@ -2080,6 +2083,53 @@ def _inject_sprop(sdp: str, devid: str) -> str:
2080
2083
  return "\r\n".join(out)
2081
2084
 
2082
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
+
2083
2133
  def _dtls_av_mux_run(vq, aq, out_fileobj, progress, stop_flag) -> None:
2084
2134
  """Mux tapped video (H.264 copy) + audio (PCMA->AAC) to ``out_fileobj`` as
2085
2135
  RTP-timestamped MPEG-TS. Runs in a worker thread; the serve's ffmpeg reads
@@ -4276,6 +4326,24 @@ class DeviceClient(object):
4276
4326
  await session.stop()
4277
4327
  return True
4278
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
+
4279
4347
  async def _sdes_keepalive_loop(self) -> None:
4280
4348
  """Background task: keep SDES stream alive; push to go2rtc via RTSP."""
4281
4349
  _MIN_DELAY = 10.0
@@ -4312,6 +4380,20 @@ class DeviceClient(object):
4312
4380
  _started_at = time.monotonic()
4313
4381
  _done = asyncio.ensure_future(session.wait_done())
4314
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
4315
4397
  try:
4316
4398
  while True:
4317
4399
  _fin, _ = await asyncio.wait({_done}, timeout=5.0)
@@ -4324,6 +4406,15 @@ class DeviceClient(object):
4324
4406
  ):
4325
4407
  _stalled = True
4326
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
4327
4418
  except asyncio.CancelledError:
4328
4419
  _done.cancel()
4329
4420
  self._stream_session = None
@@ -4332,6 +4423,25 @@ class DeviceClient(object):
4332
4423
  finally:
4333
4424
  self._stream_session = None
4334
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
+
4335
4445
  if _stalled:
4336
4446
  _LOGGER.info(
4337
4447
  "SDES %s: no media in watchdog window - restarting stream",
@@ -12098,8 +12208,13 @@ class DeviceClient(object):
12098
12208
  "a=rtcp-mux\r\n"
12099
12209
  )
12100
12210
  try:
12211
+ # Re-apply the cached sprop-parameter-sets here too:
12212
+ # this serve-restart rewrite is the SDP the keepalive
12213
+ # ffmpeg actually reads on every watchdog cycle, so
12214
+ # without this the out-of-band SPS is lost on restart
12215
+ # (the failure persisted live even for cached cameras).
12101
12216
  with open(sdp_path, "w") as _f2:
12102
- _f2.write(_new_sdp)
12217
+ _f2.write(_inject_sprop(_new_sdp, self.device_id))
12103
12218
  except Exception as _sdp_exc2:
12104
12219
  _LOGGER.warning("could not rewrite SDP for restart: %s", _sdp_exc2)
12105
12220
  proc = subprocess.Popen(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-aidot-cameras
3
- Version: 0.5.8
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)