python-aidot-cameras 0.5.6__tar.gz → 0.5.8__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.6/src/python_aidot_cameras.egg-info → python_aidot_cameras-0.5.8}/PKG-INFO +1 -1
- {python_aidot_cameras-0.5.6 → python_aidot_cameras-0.5.8}/pyproject.toml +1 -1
- {python_aidot_cameras-0.5.6 → python_aidot_cameras-0.5.8}/src/aidot/device_client.py +131 -3
- {python_aidot_cameras-0.5.6 → python_aidot_cameras-0.5.8/src/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
- {python_aidot_cameras-0.5.6 → python_aidot_cameras-0.5.8}/src/python_aidot_cameras.egg-info/SOURCES.txt +2 -0
- python_aidot_cameras-0.5.8/tests/test_no_undefined_names.py +44 -0
- python_aidot_cameras-0.5.8/tests/test_sdes_sprop.py +111 -0
- {python_aidot_cameras-0.5.6 → python_aidot_cameras-0.5.8}/LICENSE +0 -0
- {python_aidot_cameras-0.5.6 → python_aidot_cameras-0.5.8}/README.md +0 -0
- {python_aidot_cameras-0.5.6 → python_aidot_cameras-0.5.8}/setup.cfg +0 -0
- {python_aidot_cameras-0.5.6 → python_aidot_cameras-0.5.8}/src/aidot/__init__.py +0 -0
- {python_aidot_cameras-0.5.6 → python_aidot_cameras-0.5.8}/src/aidot/aes_utils.py +0 -0
- {python_aidot_cameras-0.5.6 → python_aidot_cameras-0.5.8}/src/aidot/client.py +0 -0
- {python_aidot_cameras-0.5.6 → python_aidot_cameras-0.5.8}/src/aidot/const.py +0 -0
- {python_aidot_cameras-0.5.6 → python_aidot_cameras-0.5.8}/src/aidot/credentials.py +0 -0
- {python_aidot_cameras-0.5.6 → python_aidot_cameras-0.5.8}/src/aidot/discover.py +0 -0
- {python_aidot_cameras-0.5.6 → python_aidot_cameras-0.5.8}/src/aidot/exceptions.py +0 -0
- {python_aidot_cameras-0.5.6 → python_aidot_cameras-0.5.8}/src/aidot/g711.py +0 -0
- {python_aidot_cameras-0.5.6 → python_aidot_cameras-0.5.8}/src/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
- {python_aidot_cameras-0.5.6 → python_aidot_cameras-0.5.8}/src/python_aidot_cameras.egg-info/requires.txt +0 -0
- {python_aidot_cameras-0.5.6 → python_aidot_cameras-0.5.8}/src/python_aidot_cameras.egg-info/top_level.txt +0 -0
- {python_aidot_cameras-0.5.6 → python_aidot_cameras-0.5.8}/tests/test_alarm_event.py +0 -0
- {python_aidot_cameras-0.5.6 → python_aidot_cameras-0.5.8}/tests/test_highport_nomination.py +0 -0
- {python_aidot_cameras-0.5.6 → python_aidot_cameras-0.5.8}/tests/test_motion_poll.py +0 -0
- {python_aidot_cameras-0.5.6 → python_aidot_cameras-0.5.8}/tests/test_sdes_talk.py +0 -0
- {python_aidot_cameras-0.5.6 → python_aidot_cameras-0.5.8}/tests/test_sdes_watchdog.py +0 -0
- {python_aidot_cameras-0.5.6 → python_aidot_cameras-0.5.8}/tests/test_speak.py +0 -0
- {python_aidot_cameras-0.5.6 → python_aidot_cameras-0.5.8}/tests/test_stream_cap.py +0 -0
- {python_aidot_cameras-0.5.6 → python_aidot_cameras-0.5.8}/tests/test_talk.py +0 -0
- {python_aidot_cameras-0.5.6 → python_aidot_cameras-0.5.8}/tests/test_terminal_ack.py +0 -0
- {python_aidot_cameras-0.5.6 → python_aidot_cameras-0.5.8}/tests/test_token_refresh.py +0 -0
{python_aidot_cameras-0.5.6/src/python_aidot_cameras.egg-info → python_aidot_cameras-0.5.8}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-aidot-cameras
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.8
|
|
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.8"
|
|
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"
|
|
@@ -1984,6 +1984,102 @@ def _get_stream_slots() -> "asyncio.Semaphore":
|
|
|
1984
1984
|
return _STREAM_SLOTS
|
|
1985
1985
|
|
|
1986
1986
|
|
|
1987
|
+
# Where captured SPS/PPS are cached. Override with AIDOT_SPROP_DIR so HA can
|
|
1988
|
+
# point it at persistent storage (the core container's ~ is ephemeral; without a
|
|
1989
|
+
# persistent dir the cache simply re-bootstraps from the first stream each boot).
|
|
1990
|
+
_SPROP_DIR = os.environ.get("AIDOT_SPROP_DIR") or os.path.join(
|
|
1991
|
+
os.path.expanduser("~"), ".config", "aidot", "sprop")
|
|
1992
|
+
|
|
1993
|
+
|
|
1994
|
+
def _extract_param_sets_from_rtp(pkt: bytes) -> dict:
|
|
1995
|
+
"""Pull H.264 SPS (NAL type 7) / PPS (type 8) out of one RTP packet.
|
|
1996
|
+
|
|
1997
|
+
Handles single-NAL-unit packets and STAP-A (type 24) aggregation - SPS/PPS
|
|
1998
|
+
are small and the camera sends them either as their own packets or in a
|
|
1999
|
+
STAP-A at the head of an IDR. FU-A fragments are ignored (param sets are not
|
|
2000
|
+
fragmented in practice). Returns ``{7: sps_bytes, 8: pps_bytes}`` for those
|
|
2001
|
+
found (each value is the NAL unit *including* its header byte). Pure /
|
|
2002
|
+
unit-testable: parses the RTP header length defensively."""
|
|
2003
|
+
if len(pkt) < 13:
|
|
2004
|
+
return {}
|
|
2005
|
+
cc = pkt[0] & 0x0F
|
|
2006
|
+
ext = (pkt[0] >> 4) & 0x01
|
|
2007
|
+
off = 12 + 4 * cc
|
|
2008
|
+
if ext and len(pkt) >= off + 4:
|
|
2009
|
+
ext_words = int.from_bytes(pkt[off + 2:off + 4], "big")
|
|
2010
|
+
off += 4 + 4 * ext_words
|
|
2011
|
+
payload = pkt[off:]
|
|
2012
|
+
if not payload:
|
|
2013
|
+
return {}
|
|
2014
|
+
out: dict = {}
|
|
2015
|
+
ntype = payload[0] & 0x1F
|
|
2016
|
+
if ntype == 24: # STAP-A: [hdr] then [ (size16)(nal) ]...
|
|
2017
|
+
i = 1
|
|
2018
|
+
while i + 2 <= len(payload):
|
|
2019
|
+
size = int.from_bytes(payload[i:i + 2], "big")
|
|
2020
|
+
i += 2
|
|
2021
|
+
nal = payload[i:i + size]
|
|
2022
|
+
i += size
|
|
2023
|
+
if nal:
|
|
2024
|
+
t = nal[0] & 0x1F
|
|
2025
|
+
if t in (7, 8):
|
|
2026
|
+
out[t] = nal
|
|
2027
|
+
elif ntype in (7, 8): # single NAL unit packet
|
|
2028
|
+
out[ntype] = payload
|
|
2029
|
+
return out
|
|
2030
|
+
|
|
2031
|
+
|
|
2032
|
+
def _build_sprop(sps: bytes, pps: bytes) -> str:
|
|
2033
|
+
"""``sprop-parameter-sets`` value: base64(SPS NAL),base64(PPS NAL)."""
|
|
2034
|
+
import base64 as _b64
|
|
2035
|
+
return _b64.b64encode(sps).decode() + "," + _b64.b64encode(pps).decode()
|
|
2036
|
+
|
|
2037
|
+
|
|
2038
|
+
def _sprop_cache_path(devid: str) -> str:
|
|
2039
|
+
return os.path.join(_SPROP_DIR, f"{devid}.sprop")
|
|
2040
|
+
|
|
2041
|
+
|
|
2042
|
+
def _load_sprop(devid: str) -> "Optional[str]":
|
|
2043
|
+
"""Cached ``sprop-parameter-sets`` for ``devid`` (captured from a prior
|
|
2044
|
+
stream), or None. Fail-safe: any error -> None (SDP omits sprop = today's
|
|
2045
|
+
behaviour)."""
|
|
2046
|
+
try:
|
|
2047
|
+
with open(_sprop_cache_path(devid), "r") as fh:
|
|
2048
|
+
s = fh.read().strip()
|
|
2049
|
+
return s or None
|
|
2050
|
+
except OSError:
|
|
2051
|
+
return None
|
|
2052
|
+
|
|
2053
|
+
|
|
2054
|
+
def _save_sprop(devid: str, sprop: str) -> None:
|
|
2055
|
+
"""Persist the ``sprop-parameter-sets`` string for ``devid`` (best-effort)."""
|
|
2056
|
+
try:
|
|
2057
|
+
os.makedirs(_SPROP_DIR, exist_ok=True)
|
|
2058
|
+
tmp = _sprop_cache_path(devid) + ".tmp"
|
|
2059
|
+
with open(tmp, "w") as fh:
|
|
2060
|
+
fh.write(sprop)
|
|
2061
|
+
os.replace(tmp, _sprop_cache_path(devid))
|
|
2062
|
+
except OSError:
|
|
2063
|
+
pass
|
|
2064
|
+
|
|
2065
|
+
|
|
2066
|
+
def _inject_sprop(sdp: str, devid: str) -> str:
|
|
2067
|
+
"""Append the cached ``sprop-parameter-sets`` to the ffmpeg-input SDP's
|
|
2068
|
+
``a=fmtp:96`` line so ffmpeg inits the H.264 decoder out-of-band (robust to
|
|
2069
|
+
in-band SPS loss). No-op if nothing is cached yet or sprop is already
|
|
2070
|
+
present. Applied at every ffmpeg-input SDP write so both the serve path and
|
|
2071
|
+
the snapshot/file path benefit."""
|
|
2072
|
+
sprop = _load_sprop(devid)
|
|
2073
|
+
if not sprop or "sprop-parameter-sets=" in sdp:
|
|
2074
|
+
return sdp
|
|
2075
|
+
out = []
|
|
2076
|
+
for ln in sdp.split("\r\n"):
|
|
2077
|
+
if ln.startswith("a=fmtp:96") and "sprop-parameter-sets" not in ln:
|
|
2078
|
+
ln = ln + ";sprop-parameter-sets=" + sprop
|
|
2079
|
+
out.append(ln)
|
|
2080
|
+
return "\r\n".join(out)
|
|
2081
|
+
|
|
2082
|
+
|
|
1987
2083
|
def _dtls_av_mux_run(vq, aq, out_fileobj, progress, stop_flag) -> None:
|
|
1988
2084
|
"""Mux tapped video (H.264 copy) + audio (PCMA->AAC) to ``out_fileobj`` as
|
|
1989
2085
|
RTP-timestamped MPEG-TS. Runs in a worker thread; the serve's ffmpeg reads
|
|
@@ -8644,6 +8740,17 @@ class DeviceClient(object):
|
|
|
8644
8740
|
import tempfile
|
|
8645
8741
|
import json
|
|
8646
8742
|
|
|
8743
|
+
# _open_sdes_stream runs in its own scope, so recompute the fast-connect
|
|
8744
|
+
# flag here (same precedence as the parent open path: explicit per-camera
|
|
8745
|
+
# option set via start_keepalive, else the AIDOT_FAST_CONNECT env var).
|
|
8746
|
+
# Without this the SDES path NameErrors on every open (regression fixed
|
|
8747
|
+
# 2026-06-07: the SDES TURN-skip gates reference _fast_connect).
|
|
8748
|
+
_fast_connect = getattr(self, "_fast_connect_opt", None)
|
|
8749
|
+
if _fast_connect is None:
|
|
8750
|
+
_fast_connect = os.environ.get("AIDOT_FAST_CONNECT", "").strip().lower() in (
|
|
8751
|
+
"1", "true", "yes", "on",
|
|
8752
|
+
)
|
|
8753
|
+
|
|
8647
8754
|
user_id = user_id or str(self.user_id)
|
|
8648
8755
|
|
|
8649
8756
|
# Models confirmed (2026-05-02) to send TUTK-framed data instead of
|
|
@@ -9213,7 +9320,7 @@ class DeviceClient(object):
|
|
|
9213
9320
|
)
|
|
9214
9321
|
|
|
9215
9322
|
sdp_path = await asyncio.get_running_loop().run_in_executor(
|
|
9216
|
-
None, _make_sdp_tempfile, ffmpeg_sdp)
|
|
9323
|
+
None, _make_sdp_tempfile, _inject_sprop(ffmpeg_sdp, self.device_id))
|
|
9217
9324
|
|
|
9218
9325
|
# --- Send webrtcReq BEFORE releasing reservation sockets ------------- #
|
|
9219
9326
|
# ICE cameras (e.g. LK.IPC.A001064) send STUN binding requests to our
|
|
@@ -10167,7 +10274,7 @@ class DeviceClient(object):
|
|
|
10167
10274
|
)
|
|
10168
10275
|
try:
|
|
10169
10276
|
await asyncio.get_running_loop().run_in_executor(
|
|
10170
|
-
None, _write_text_file, sdp_path, _updated_sdp)
|
|
10277
|
+
None, _write_text_file, sdp_path, _inject_sprop(_updated_sdp, self.device_id))
|
|
10171
10278
|
except Exception as _sdp_exc:
|
|
10172
10279
|
_LOGGER.warning("_open_sdes_stream: could not rewrite SDP: %s", _sdp_exc)
|
|
10173
10280
|
|
|
@@ -10412,6 +10519,7 @@ class DeviceClient(object):
|
|
|
10412
10519
|
# TUTK cameras use plain RTP/AVP (no SRTP): the bridge synthesizes
|
|
10413
10520
|
# standard RTP packets from TUTK SFrames and forwards without crypto.
|
|
10414
10521
|
_ts_br = int(time.time())
|
|
10522
|
+
# (Out-of-band SPS/PPS is injected at the write below via _inject_sprop.)
|
|
10415
10523
|
if _use_plain_rtp:
|
|
10416
10524
|
_br_sdp = (
|
|
10417
10525
|
"v=0\r\n"
|
|
@@ -10456,7 +10564,7 @@ class DeviceClient(object):
|
|
|
10456
10564
|
)
|
|
10457
10565
|
try:
|
|
10458
10566
|
await asyncio.get_running_loop().run_in_executor(
|
|
10459
|
-
None, _write_text_file, sdp_path, _br_sdp)
|
|
10567
|
+
None, _write_text_file, sdp_path, _inject_sprop(_br_sdp, self.device_id))
|
|
10460
10568
|
except Exception as _br_sdp_exc:
|
|
10461
10569
|
_LOGGER.warning("bridge: could not rewrite SDP: %s", _br_sdp_exc)
|
|
10462
10570
|
|
|
@@ -11537,6 +11645,26 @@ class DeviceClient(object):
|
|
|
11537
11645
|
f" ssrc=0x{_ssrc_d:08x}"
|
|
11538
11646
|
f" pt={_pt}"
|
|
11539
11647
|
)
|
|
11648
|
+
# Capture this camera's SPS/PPS once so future streams
|
|
11649
|
+
# can inject sprop-parameter-sets (out-of-band decoder
|
|
11650
|
+
# init, robust to in-band SPS loss). Parses only until
|
|
11651
|
+
# both are seen; then _sprop_done short-circuits.
|
|
11652
|
+
if (_kind == "video"
|
|
11653
|
+
and not getattr(_bridge_fn, "_sprop_done", False)):
|
|
11654
|
+
_ps = _extract_param_sets_from_rtp(_fwd_pkt)
|
|
11655
|
+
if _ps:
|
|
11656
|
+
_psc = getattr(_bridge_fn, "_ps_cache", None)
|
|
11657
|
+
if _psc is None:
|
|
11658
|
+
_psc = {}
|
|
11659
|
+
_bridge_fn._ps_cache = _psc
|
|
11660
|
+
_psc.update(_ps)
|
|
11661
|
+
if 7 in _psc and 8 in _psc:
|
|
11662
|
+
_sprop_new = _build_sprop(_psc[7], _psc[8])
|
|
11663
|
+
if _sprop_new != _load_sprop(self.device_id):
|
|
11664
|
+
_save_sprop(self.device_id, _sprop_new)
|
|
11665
|
+
_status("bridge: cached sprop-parameter"
|
|
11666
|
+
f"-sets for {self.device_id}")
|
|
11667
|
+
_bridge_fn._sprop_done = True
|
|
11540
11668
|
# Rebase RTP timestamps to start near 0. Camera picks a
|
|
11541
11669
|
# random starting timestamp (RFC 3550 §5.1); the 90 kHz
|
|
11542
11670
|
# video clock can be near 2^32 and wraps, producing huge
|
{python_aidot_cameras-0.5.6 → python_aidot_cameras-0.5.8/src/python_aidot_cameras.egg-info}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-aidot-cameras
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.8
|
|
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
|
|
@@ -18,6 +18,8 @@ src/python_aidot_cameras.egg-info/top_level.txt
|
|
|
18
18
|
tests/test_alarm_event.py
|
|
19
19
|
tests/test_highport_nomination.py
|
|
20
20
|
tests/test_motion_poll.py
|
|
21
|
+
tests/test_no_undefined_names.py
|
|
22
|
+
tests/test_sdes_sprop.py
|
|
21
23
|
tests/test_sdes_talk.py
|
|
22
24
|
tests/test_sdes_watchdog.py
|
|
23
25
|
tests/test_speak.py
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Guard against NameError-class regressions in device_client.py.
|
|
2
|
+
|
|
3
|
+
`ast.parse`/`py_compile` only catch syntax errors, not undefined names used at
|
|
4
|
+
runtime (e.g. a variable referenced in one method but defined only in another).
|
|
5
|
+
That class of bug shipped once (0.5.5/0.5.6: `_fast_connect` referenced in
|
|
6
|
+
`_open_sdes_stream` but defined only in `_async_open_webrtc_stream_impl`,
|
|
7
|
+
breaking every SDES camera open). This test runs pyflakes and fails on any
|
|
8
|
+
undefined name except a small allowlist of intentional closure patterns.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import pathlib
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
_SRC = (
|
|
18
|
+
pathlib.Path(__file__).resolve().parent.parent
|
|
19
|
+
/ "src" / "aidot" / "device_client.py"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# Names pyflakes can't resolve because they're bound in an inner runtime scope
|
|
23
|
+
# (closure) and referenced from a sibling scope; verified working in production
|
|
24
|
+
# and already marked `# noqa: F821` in the source.
|
|
25
|
+
_ALLOWLIST = {"_enc_c8_sctp"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_device_client_has_no_undefined_names():
|
|
29
|
+
try:
|
|
30
|
+
proc = subprocess.run(
|
|
31
|
+
[sys.executable, "-m", "pyflakes", str(_SRC)],
|
|
32
|
+
capture_output=True, text=True,
|
|
33
|
+
)
|
|
34
|
+
except FileNotFoundError: # pragma: no cover
|
|
35
|
+
pytest.skip("pyflakes not available")
|
|
36
|
+
if "No module named pyflakes" in proc.stderr: # pragma: no cover
|
|
37
|
+
pytest.skip("pyflakes not installed")
|
|
38
|
+
|
|
39
|
+
undefined = [
|
|
40
|
+
line for line in proc.stdout.splitlines()
|
|
41
|
+
if "undefined name" in line
|
|
42
|
+
and not any(name in line for name in _ALLOWLIST)
|
|
43
|
+
]
|
|
44
|
+
assert not undefined, "Undefined names (NameError risk):\n" + "\n".join(undefined)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Unit tests for the SDES out-of-band SPS/PPS (sprop-parameter-sets) helpers.
|
|
2
|
+
|
|
3
|
+
A lost in-band SPS wedges ffmpeg ("could not find codec parameters"); injecting
|
|
4
|
+
the camera's SPS/PPS into the ffmpeg SDP as sprop-parameter-sets initializes the
|
|
5
|
+
decoder out-of-band so the loss no longer matters. These pure helpers capture
|
|
6
|
+
the param sets from RTP, format the sprop value, and cache it per camera.
|
|
7
|
+
"""
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
|
|
12
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
13
|
+
|
|
14
|
+
import aidot.device_client as dc
|
|
15
|
+
from aidot.device_client import (
|
|
16
|
+
_extract_param_sets_from_rtp,
|
|
17
|
+
_build_sprop,
|
|
18
|
+
_load_sprop,
|
|
19
|
+
_save_sprop,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# Real Driveway (A001513) parameter sets captured live 2026-06-08.
|
|
23
|
+
SPS = bytes.fromhex("27640033ad00ce8050079a6a020203e0000003002000000303c6f207d00bbffff814")
|
|
24
|
+
PPS = bytes.fromhex("28ee3cb0")
|
|
25
|
+
SPROP = "J2QAM60AzoBQB5pqAgID4AAAAwAgAAADA8byB9ALv//4FA==,KO48sA=="
|
|
26
|
+
|
|
27
|
+
RTP = b"\x80\x60\x00\x01\x00\x00\x00\x00\x12\x34\x56\x78" # V2, no CC/ext, 12B
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ---- _build_sprop ----------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
def test_build_sprop_matches_live_vector():
|
|
33
|
+
assert _build_sprop(SPS, PPS) == SPROP
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ---- _extract_param_sets_from_rtp -----------------------------------------
|
|
37
|
+
|
|
38
|
+
def test_single_nal_sps():
|
|
39
|
+
assert _extract_param_sets_from_rtp(RTP + SPS) == {7: SPS}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_single_nal_pps():
|
|
43
|
+
assert _extract_param_sets_from_rtp(RTP + PPS) == {8: PPS}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_stap_a_carries_both():
|
|
47
|
+
stap = bytes([0x78]) # 0x78 & 0x1f == 24 (STAP-A)
|
|
48
|
+
stap += len(SPS).to_bytes(2, "big") + SPS
|
|
49
|
+
stap += len(PPS).to_bytes(2, "big") + PPS
|
|
50
|
+
assert _extract_param_sets_from_rtp(RTP + stap) == {7: SPS, 8: PPS}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_slice_packet_yields_nothing():
|
|
54
|
+
# NAL type 1 (non-IDR slice) - not a parameter set
|
|
55
|
+
assert _extract_param_sets_from_rtp(RTP + bytes([0x61, 0x00, 0x11])) == {}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_short_or_empty_packet_safe():
|
|
59
|
+
assert _extract_param_sets_from_rtp(b"") == {}
|
|
60
|
+
assert _extract_param_sets_from_rtp(b"\x80\x60\x00") == {}
|
|
61
|
+
assert _extract_param_sets_from_rtp(RTP) == {} # header only, no payload
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_csrc_header_offset_handled():
|
|
65
|
+
# CC=2 -> 12 + 8 bytes of CSRC before the payload
|
|
66
|
+
hdr = b"\x82\x60\x00\x01\x00\x00\x00\x00\x12\x34\x56\x78" + b"\xaa\xaa\xaa\xaa\xbb\xbb\xbb\xbb"
|
|
67
|
+
assert _extract_param_sets_from_rtp(hdr + SPS) == {7: SPS}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ---- cache roundtrip -------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
def test_cache_roundtrip(monkeypatch):
|
|
73
|
+
d = tempfile.mkdtemp()
|
|
74
|
+
monkeypatch.setattr(dc, "_SPROP_DIR", d)
|
|
75
|
+
devid = "abc123"
|
|
76
|
+
assert _load_sprop(devid) is None # nothing cached yet
|
|
77
|
+
_save_sprop(devid, SPROP)
|
|
78
|
+
assert _load_sprop(devid) == SPROP # persists + reads back
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_load_missing_is_none(monkeypatch):
|
|
82
|
+
monkeypatch.setattr(dc, "_SPROP_DIR", "/nonexistent/path/xyz")
|
|
83
|
+
assert _load_sprop("whatever") is None # fail-safe, no raise
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
if __name__ == "__main__":
|
|
87
|
+
import traceback
|
|
88
|
+
|
|
89
|
+
class _MP: # tiny monkeypatch shim so this runs without pytest
|
|
90
|
+
def __init__(self): self._undo = []
|
|
91
|
+
def setattr(self, obj, name, val):
|
|
92
|
+
self._undo.append((obj, name, getattr(obj, name)))
|
|
93
|
+
setattr(obj, name, val)
|
|
94
|
+
def undo(self):
|
|
95
|
+
for obj, name, val in reversed(self._undo):
|
|
96
|
+
setattr(obj, name, val)
|
|
97
|
+
|
|
98
|
+
_fail = 0
|
|
99
|
+
for _k, _v in sorted(globals().items()):
|
|
100
|
+
if _k.startswith("test_"):
|
|
101
|
+
mp = _MP()
|
|
102
|
+
try:
|
|
103
|
+
_v(mp) if "monkeypatch" in _v.__code__.co_varnames else _v()
|
|
104
|
+
print(f"PASS {_k}")
|
|
105
|
+
except Exception:
|
|
106
|
+
_fail += 1
|
|
107
|
+
print(f"FAIL {_k}")
|
|
108
|
+
traceback.print_exc()
|
|
109
|
+
finally:
|
|
110
|
+
mp.undo()
|
|
111
|
+
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
|
|
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
|