python-aidot-cameras 0.5.7__tar.gz → 0.5.9__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.9}/PKG-INFO +1 -1
  2. {python_aidot_cameras-0.5.7 → python_aidot_cameras-0.5.9}/pyproject.toml +1 -1
  3. {python_aidot_cameras-0.5.7 → python_aidot_cameras-0.5.9}/src/aidot/device_client.py +129 -4
  4. {python_aidot_cameras-0.5.7 → python_aidot_cameras-0.5.9/src/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  5. {python_aidot_cameras-0.5.7 → python_aidot_cameras-0.5.9}/src/python_aidot_cameras.egg-info/SOURCES.txt +2 -0
  6. python_aidot_cameras-0.5.9/tests/test_no_undefined_names.py +44 -0
  7. python_aidot_cameras-0.5.9/tests/test_sdes_sprop.py +111 -0
  8. {python_aidot_cameras-0.5.7 → python_aidot_cameras-0.5.9}/LICENSE +0 -0
  9. {python_aidot_cameras-0.5.7 → python_aidot_cameras-0.5.9}/README.md +0 -0
  10. {python_aidot_cameras-0.5.7 → python_aidot_cameras-0.5.9}/setup.cfg +0 -0
  11. {python_aidot_cameras-0.5.7 → python_aidot_cameras-0.5.9}/src/aidot/__init__.py +0 -0
  12. {python_aidot_cameras-0.5.7 → python_aidot_cameras-0.5.9}/src/aidot/aes_utils.py +0 -0
  13. {python_aidot_cameras-0.5.7 → python_aidot_cameras-0.5.9}/src/aidot/client.py +0 -0
  14. {python_aidot_cameras-0.5.7 → python_aidot_cameras-0.5.9}/src/aidot/const.py +0 -0
  15. {python_aidot_cameras-0.5.7 → python_aidot_cameras-0.5.9}/src/aidot/credentials.py +0 -0
  16. {python_aidot_cameras-0.5.7 → python_aidot_cameras-0.5.9}/src/aidot/discover.py +0 -0
  17. {python_aidot_cameras-0.5.7 → python_aidot_cameras-0.5.9}/src/aidot/exceptions.py +0 -0
  18. {python_aidot_cameras-0.5.7 → python_aidot_cameras-0.5.9}/src/aidot/g711.py +0 -0
  19. {python_aidot_cameras-0.5.7 → python_aidot_cameras-0.5.9}/src/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  20. {python_aidot_cameras-0.5.7 → python_aidot_cameras-0.5.9}/src/python_aidot_cameras.egg-info/requires.txt +0 -0
  21. {python_aidot_cameras-0.5.7 → python_aidot_cameras-0.5.9}/src/python_aidot_cameras.egg-info/top_level.txt +0 -0
  22. {python_aidot_cameras-0.5.7 → python_aidot_cameras-0.5.9}/tests/test_alarm_event.py +0 -0
  23. {python_aidot_cameras-0.5.7 → python_aidot_cameras-0.5.9}/tests/test_highport_nomination.py +0 -0
  24. {python_aidot_cameras-0.5.7 → python_aidot_cameras-0.5.9}/tests/test_motion_poll.py +0 -0
  25. {python_aidot_cameras-0.5.7 → python_aidot_cameras-0.5.9}/tests/test_sdes_talk.py +0 -0
  26. {python_aidot_cameras-0.5.7 → python_aidot_cameras-0.5.9}/tests/test_sdes_watchdog.py +0 -0
  27. {python_aidot_cameras-0.5.7 → python_aidot_cameras-0.5.9}/tests/test_speak.py +0 -0
  28. {python_aidot_cameras-0.5.7 → python_aidot_cameras-0.5.9}/tests/test_stream_cap.py +0 -0
  29. {python_aidot_cameras-0.5.7 → python_aidot_cameras-0.5.9}/tests/test_talk.py +0 -0
  30. {python_aidot_cameras-0.5.7 → python_aidot_cameras-0.5.9}/tests/test_terminal_ack.py +0 -0
  31. {python_aidot_cameras-0.5.7 → python_aidot_cameras-0.5.9}/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.9
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.9"
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,105 @@ 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 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)
2067
+
2068
+
2069
+ def _inject_sprop(sdp: str, devid: str) -> str:
2070
+ """Append the cached ``sprop-parameter-sets`` to the ffmpeg-input SDP's
2071
+ ``a=fmtp:96`` line so ffmpeg inits the H.264 decoder out-of-band (robust to
2072
+ in-band SPS loss). No-op if nothing is cached yet or sprop is already
2073
+ present. Applied at every ffmpeg-input SDP write so both the serve path and
2074
+ the snapshot/file path benefit."""
2075
+ sprop = _load_sprop(devid)
2076
+ if not sprop or "sprop-parameter-sets=" in sdp:
2077
+ return sdp
2078
+ out = []
2079
+ for ln in sdp.split("\r\n"):
2080
+ if ln.startswith("a=fmtp:96") and "sprop-parameter-sets" not in ln:
2081
+ ln = ln + ";sprop-parameter-sets=" + sprop
2082
+ out.append(ln)
2083
+ return "\r\n".join(out)
2084
+
2085
+
1987
2086
  def _dtls_av_mux_run(vq, aq, out_fileobj, progress, stop_flag) -> None:
1988
2087
  """Mux tapped video (H.264 copy) + audio (PCMA->AAC) to ``out_fileobj`` as
1989
2088
  RTP-timestamped MPEG-TS. Runs in a worker thread; the serve's ffmpeg reads
@@ -9224,7 +9323,7 @@ class DeviceClient(object):
9224
9323
  )
9225
9324
 
9226
9325
  sdp_path = await asyncio.get_running_loop().run_in_executor(
9227
- None, _make_sdp_tempfile, ffmpeg_sdp)
9326
+ None, _make_sdp_tempfile, _inject_sprop(ffmpeg_sdp, self.device_id))
9228
9327
 
9229
9328
  # --- Send webrtcReq BEFORE releasing reservation sockets ------------- #
9230
9329
  # ICE cameras (e.g. LK.IPC.A001064) send STUN binding requests to our
@@ -10178,7 +10277,7 @@ class DeviceClient(object):
10178
10277
  )
10179
10278
  try:
10180
10279
  await asyncio.get_running_loop().run_in_executor(
10181
- None, _write_text_file, sdp_path, _updated_sdp)
10280
+ None, _write_text_file, sdp_path, _inject_sprop(_updated_sdp, self.device_id))
10182
10281
  except Exception as _sdp_exc:
10183
10282
  _LOGGER.warning("_open_sdes_stream: could not rewrite SDP: %s", _sdp_exc)
10184
10283
 
@@ -10423,6 +10522,7 @@ class DeviceClient(object):
10423
10522
  # TUTK cameras use plain RTP/AVP (no SRTP): the bridge synthesizes
10424
10523
  # standard RTP packets from TUTK SFrames and forwards without crypto.
10425
10524
  _ts_br = int(time.time())
10525
+ # (Out-of-band SPS/PPS is injected at the write below via _inject_sprop.)
10426
10526
  if _use_plain_rtp:
10427
10527
  _br_sdp = (
10428
10528
  "v=0\r\n"
@@ -10467,7 +10567,7 @@ class DeviceClient(object):
10467
10567
  )
10468
10568
  try:
10469
10569
  await asyncio.get_running_loop().run_in_executor(
10470
- None, _write_text_file, sdp_path, _br_sdp)
10570
+ None, _write_text_file, sdp_path, _inject_sprop(_br_sdp, self.device_id))
10471
10571
  except Exception as _br_sdp_exc:
10472
10572
  _LOGGER.warning("bridge: could not rewrite SDP: %s", _br_sdp_exc)
10473
10573
 
@@ -11548,6 +11648,26 @@ class DeviceClient(object):
11548
11648
  f" ssrc=0x{_ssrc_d:08x}"
11549
11649
  f" pt={_pt}"
11550
11650
  )
11651
+ # Capture this camera's SPS/PPS once so future streams
11652
+ # can inject sprop-parameter-sets (out-of-band decoder
11653
+ # init, robust to in-band SPS loss). Parses only until
11654
+ # both are seen; then _sprop_done short-circuits.
11655
+ if (_kind == "video"
11656
+ and not getattr(_bridge_fn, "_sprop_done", False)):
11657
+ _ps = _extract_param_sets_from_rtp(_fwd_pkt)
11658
+ if _ps:
11659
+ _psc = getattr(_bridge_fn, "_ps_cache", None)
11660
+ if _psc is None:
11661
+ _psc = {}
11662
+ _bridge_fn._ps_cache = _psc
11663
+ _psc.update(_ps)
11664
+ if 7 in _psc and 8 in _psc:
11665
+ _sprop_new = _build_sprop(_psc[7], _psc[8])
11666
+ if _sprop_new != _load_sprop(self.device_id):
11667
+ _save_sprop(self.device_id, _sprop_new)
11668
+ _status("bridge: cached sprop-parameter"
11669
+ f"-sets for {self.device_id}")
11670
+ _bridge_fn._sprop_done = True
11551
11671
  # Rebase RTP timestamps to start near 0. Camera picks a
11552
11672
  # random starting timestamp (RFC 3550 §5.1); the 90 kHz
11553
11673
  # video clock can be near 2^32 and wraps, producing huge
@@ -11981,8 +12101,13 @@ class DeviceClient(object):
11981
12101
  "a=rtcp-mux\r\n"
11982
12102
  )
11983
12103
  try:
12104
+ # Re-apply the cached sprop-parameter-sets here too:
12105
+ # this serve-restart rewrite is the SDP the keepalive
12106
+ # ffmpeg actually reads on every watchdog cycle, so
12107
+ # without this the out-of-band SPS is lost on restart
12108
+ # (the failure persisted live even for cached cameras).
11984
12109
  with open(sdp_path, "w") as _f2:
11985
- _f2.write(_new_sdp)
12110
+ _f2.write(_inject_sprop(_new_sdp, self.device_id))
11986
12111
  except Exception as _sdp_exc2:
11987
12112
  _LOGGER.warning("could not rewrite SDP for restart: %s", _sdp_exc2)
11988
12113
  proc = subprocess.Popen(
@@ -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.9
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)