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.
Files changed (31) hide show
  1. {python_aidot_cameras-0.5.6/src/python_aidot_cameras.egg-info → python_aidot_cameras-0.5.8}/PKG-INFO +1 -1
  2. {python_aidot_cameras-0.5.6 → python_aidot_cameras-0.5.8}/pyproject.toml +1 -1
  3. {python_aidot_cameras-0.5.6 → python_aidot_cameras-0.5.8}/src/aidot/device_client.py +131 -3
  4. {python_aidot_cameras-0.5.6 → python_aidot_cameras-0.5.8/src/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  5. {python_aidot_cameras-0.5.6 → python_aidot_cameras-0.5.8}/src/python_aidot_cameras.egg-info/SOURCES.txt +2 -0
  6. python_aidot_cameras-0.5.8/tests/test_no_undefined_names.py +44 -0
  7. python_aidot_cameras-0.5.8/tests/test_sdes_sprop.py +111 -0
  8. {python_aidot_cameras-0.5.6 → python_aidot_cameras-0.5.8}/LICENSE +0 -0
  9. {python_aidot_cameras-0.5.6 → python_aidot_cameras-0.5.8}/README.md +0 -0
  10. {python_aidot_cameras-0.5.6 → python_aidot_cameras-0.5.8}/setup.cfg +0 -0
  11. {python_aidot_cameras-0.5.6 → python_aidot_cameras-0.5.8}/src/aidot/__init__.py +0 -0
  12. {python_aidot_cameras-0.5.6 → python_aidot_cameras-0.5.8}/src/aidot/aes_utils.py +0 -0
  13. {python_aidot_cameras-0.5.6 → python_aidot_cameras-0.5.8}/src/aidot/client.py +0 -0
  14. {python_aidot_cameras-0.5.6 → python_aidot_cameras-0.5.8}/src/aidot/const.py +0 -0
  15. {python_aidot_cameras-0.5.6 → python_aidot_cameras-0.5.8}/src/aidot/credentials.py +0 -0
  16. {python_aidot_cameras-0.5.6 → python_aidot_cameras-0.5.8}/src/aidot/discover.py +0 -0
  17. {python_aidot_cameras-0.5.6 → python_aidot_cameras-0.5.8}/src/aidot/exceptions.py +0 -0
  18. {python_aidot_cameras-0.5.6 → python_aidot_cameras-0.5.8}/src/aidot/g711.py +0 -0
  19. {python_aidot_cameras-0.5.6 → python_aidot_cameras-0.5.8}/src/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  20. {python_aidot_cameras-0.5.6 → python_aidot_cameras-0.5.8}/src/python_aidot_cameras.egg-info/requires.txt +0 -0
  21. {python_aidot_cameras-0.5.6 → python_aidot_cameras-0.5.8}/src/python_aidot_cameras.egg-info/top_level.txt +0 -0
  22. {python_aidot_cameras-0.5.6 → python_aidot_cameras-0.5.8}/tests/test_alarm_event.py +0 -0
  23. {python_aidot_cameras-0.5.6 → python_aidot_cameras-0.5.8}/tests/test_highport_nomination.py +0 -0
  24. {python_aidot_cameras-0.5.6 → python_aidot_cameras-0.5.8}/tests/test_motion_poll.py +0 -0
  25. {python_aidot_cameras-0.5.6 → python_aidot_cameras-0.5.8}/tests/test_sdes_talk.py +0 -0
  26. {python_aidot_cameras-0.5.6 → python_aidot_cameras-0.5.8}/tests/test_sdes_watchdog.py +0 -0
  27. {python_aidot_cameras-0.5.6 → python_aidot_cameras-0.5.8}/tests/test_speak.py +0 -0
  28. {python_aidot_cameras-0.5.6 → python_aidot_cameras-0.5.8}/tests/test_stream_cap.py +0 -0
  29. {python_aidot_cameras-0.5.6 → python_aidot_cameras-0.5.8}/tests/test_talk.py +0 -0
  30. {python_aidot_cameras-0.5.6 → python_aidot_cameras-0.5.8}/tests/test_terminal_ack.py +0 -0
  31. {python_aidot_cameras-0.5.6 → python_aidot_cameras-0.5.8}/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.6
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.6"
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-aidot-cameras
3
- Version: 0.5.6
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)