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.
- {python_aidot_cameras-0.5.9/src/python_aidot_cameras.egg-info → python_aidot_cameras-0.5.10}/PKG-INFO +1 -1
- {python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10}/pyproject.toml +1 -1
- {python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10}/src/aidot/device_client.py +107 -0
- {python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10/src/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
- {python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10}/src/python_aidot_cameras.egg-info/SOURCES.txt +1 -0
- python_aidot_cameras-0.5.10/tests/test_sdes_idle_release.py +143 -0
- {python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10}/LICENSE +0 -0
- {python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10}/README.md +0 -0
- {python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10}/setup.cfg +0 -0
- {python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10}/src/aidot/__init__.py +0 -0
- {python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10}/src/aidot/aes_utils.py +0 -0
- {python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10}/src/aidot/client.py +0 -0
- {python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10}/src/aidot/const.py +0 -0
- {python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10}/src/aidot/credentials.py +0 -0
- {python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10}/src/aidot/discover.py +0 -0
- {python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10}/src/aidot/exceptions.py +0 -0
- {python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10}/src/aidot/g711.py +0 -0
- {python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10}/src/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
- {python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10}/src/python_aidot_cameras.egg-info/requires.txt +0 -0
- {python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10}/src/python_aidot_cameras.egg-info/top_level.txt +0 -0
- {python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10}/tests/test_alarm_event.py +0 -0
- {python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10}/tests/test_highport_nomination.py +0 -0
- {python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10}/tests/test_motion_poll.py +0 -0
- {python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10}/tests/test_no_undefined_names.py +0 -0
- {python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10}/tests/test_sdes_sprop.py +0 -0
- {python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10}/tests/test_sdes_talk.py +0 -0
- {python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10}/tests/test_sdes_watchdog.py +0 -0
- {python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10}/tests/test_speak.py +0 -0
- {python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10}/tests/test_stream_cap.py +0 -0
- {python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10}/tests/test_talk.py +0 -0
- {python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10}/tests/test_terminal_ack.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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
|
|
@@ -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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.5.9 → python_aidot_cameras-0.5.10}/tests/test_highport_nomination.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|