python-aidot-cameras 0.5.7__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.7/src/python_aidot_cameras.egg-info → python_aidot_cameras-0.5.8}/PKG-INFO +1 -1
  2. {python_aidot_cameras-0.5.7 → python_aidot_cameras-0.5.8}/pyproject.toml +1 -1
  3. {python_aidot_cameras-0.5.7 → python_aidot_cameras-0.5.8}/src/aidot/device_client.py +120 -3
  4. {python_aidot_cameras-0.5.7 → python_aidot_cameras-0.5.8/src/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  5. {python_aidot_cameras-0.5.7 → 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.7 → python_aidot_cameras-0.5.8}/LICENSE +0 -0
  9. {python_aidot_cameras-0.5.7 → python_aidot_cameras-0.5.8}/README.md +0 -0
  10. {python_aidot_cameras-0.5.7 → python_aidot_cameras-0.5.8}/setup.cfg +0 -0
  11. {python_aidot_cameras-0.5.7 → python_aidot_cameras-0.5.8}/src/aidot/__init__.py +0 -0
  12. {python_aidot_cameras-0.5.7 → python_aidot_cameras-0.5.8}/src/aidot/aes_utils.py +0 -0
  13. {python_aidot_cameras-0.5.7 → python_aidot_cameras-0.5.8}/src/aidot/client.py +0 -0
  14. {python_aidot_cameras-0.5.7 → python_aidot_cameras-0.5.8}/src/aidot/const.py +0 -0
  15. {python_aidot_cameras-0.5.7 → python_aidot_cameras-0.5.8}/src/aidot/credentials.py +0 -0
  16. {python_aidot_cameras-0.5.7 → python_aidot_cameras-0.5.8}/src/aidot/discover.py +0 -0
  17. {python_aidot_cameras-0.5.7 → python_aidot_cameras-0.5.8}/src/aidot/exceptions.py +0 -0
  18. {python_aidot_cameras-0.5.7 → python_aidot_cameras-0.5.8}/src/aidot/g711.py +0 -0
  19. {python_aidot_cameras-0.5.7 → python_aidot_cameras-0.5.8}/src/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  20. {python_aidot_cameras-0.5.7 → python_aidot_cameras-0.5.8}/src/python_aidot_cameras.egg-info/requires.txt +0 -0
  21. {python_aidot_cameras-0.5.7 → python_aidot_cameras-0.5.8}/src/python_aidot_cameras.egg-info/top_level.txt +0 -0
  22. {python_aidot_cameras-0.5.7 → python_aidot_cameras-0.5.8}/tests/test_alarm_event.py +0 -0
  23. {python_aidot_cameras-0.5.7 → python_aidot_cameras-0.5.8}/tests/test_highport_nomination.py +0 -0
  24. {python_aidot_cameras-0.5.7 → python_aidot_cameras-0.5.8}/tests/test_motion_poll.py +0 -0
  25. {python_aidot_cameras-0.5.7 → python_aidot_cameras-0.5.8}/tests/test_sdes_talk.py +0 -0
  26. {python_aidot_cameras-0.5.7 → python_aidot_cameras-0.5.8}/tests/test_sdes_watchdog.py +0 -0
  27. {python_aidot_cameras-0.5.7 → python_aidot_cameras-0.5.8}/tests/test_speak.py +0 -0
  28. {python_aidot_cameras-0.5.7 → python_aidot_cameras-0.5.8}/tests/test_stream_cap.py +0 -0
  29. {python_aidot_cameras-0.5.7 → python_aidot_cameras-0.5.8}/tests/test_talk.py +0 -0
  30. {python_aidot_cameras-0.5.7 → python_aidot_cameras-0.5.8}/tests/test_terminal_ack.py +0 -0
  31. {python_aidot_cameras-0.5.7 → 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.7
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"
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
@@ -9224,7 +9320,7 @@ class DeviceClient(object):
9224
9320
  )
9225
9321
 
9226
9322
  sdp_path = await asyncio.get_running_loop().run_in_executor(
9227
- None, _make_sdp_tempfile, ffmpeg_sdp)
9323
+ None, _make_sdp_tempfile, _inject_sprop(ffmpeg_sdp, self.device_id))
9228
9324
 
9229
9325
  # --- Send webrtcReq BEFORE releasing reservation sockets ------------- #
9230
9326
  # ICE cameras (e.g. LK.IPC.A001064) send STUN binding requests to our
@@ -10178,7 +10274,7 @@ class DeviceClient(object):
10178
10274
  )
10179
10275
  try:
10180
10276
  await asyncio.get_running_loop().run_in_executor(
10181
- None, _write_text_file, sdp_path, _updated_sdp)
10277
+ None, _write_text_file, sdp_path, _inject_sprop(_updated_sdp, self.device_id))
10182
10278
  except Exception as _sdp_exc:
10183
10279
  _LOGGER.warning("_open_sdes_stream: could not rewrite SDP: %s", _sdp_exc)
10184
10280
 
@@ -10423,6 +10519,7 @@ class DeviceClient(object):
10423
10519
  # TUTK cameras use plain RTP/AVP (no SRTP): the bridge synthesizes
10424
10520
  # standard RTP packets from TUTK SFrames and forwards without crypto.
10425
10521
  _ts_br = int(time.time())
10522
+ # (Out-of-band SPS/PPS is injected at the write below via _inject_sprop.)
10426
10523
  if _use_plain_rtp:
10427
10524
  _br_sdp = (
10428
10525
  "v=0\r\n"
@@ -10467,7 +10564,7 @@ class DeviceClient(object):
10467
10564
  )
10468
10565
  try:
10469
10566
  await asyncio.get_running_loop().run_in_executor(
10470
- None, _write_text_file, sdp_path, _br_sdp)
10567
+ None, _write_text_file, sdp_path, _inject_sprop(_br_sdp, self.device_id))
10471
10568
  except Exception as _br_sdp_exc:
10472
10569
  _LOGGER.warning("bridge: could not rewrite SDP: %s", _br_sdp_exc)
10473
10570
 
@@ -11548,6 +11645,26 @@ class DeviceClient(object):
11548
11645
  f" ssrc=0x{_ssrc_d:08x}"
11549
11646
  f" pt={_pt}"
11550
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
11551
11668
  # Rebase RTP timestamps to start near 0. Camera picks a
11552
11669
  # random starting timestamp (RFC 3550 §5.1); the 90 kHz
11553
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.7
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)