python-aidot-cameras 0.7.21__tar.gz → 0.7.22__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 (59) hide show
  1. {python_aidot_cameras-0.7.21/src/python_aidot_cameras.egg-info → python_aidot_cameras-0.7.22}/PKG-INFO +1 -1
  2. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.22}/pyproject.toml +1 -1
  3. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.22}/src/aidot/camera/client.py +73 -28
  4. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.22/src/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  5. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.22}/src/python_aidot_cameras.egg-info/SOURCES.txt +1 -0
  6. python_aidot_cameras-0.7.22/tests/test_sdes_fast_liveplay.py +70 -0
  7. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.22}/LICENSE +0 -0
  8. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.22}/MANIFEST.in +0 -0
  9. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.22}/README.md +0 -0
  10. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.22}/setup.cfg +0 -0
  11. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.22}/src/aidot/__init__.py +0 -0
  12. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.22}/src/aidot/aes_utils.py +0 -0
  13. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.22}/src/aidot/camera/__init__.py +0 -0
  14. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.22}/src/aidot/camera/constants.py +0 -0
  15. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.22}/src/aidot/camera/controls.py +0 -0
  16. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.22}/src/aidot/camera/go2rtc.py +0 -0
  17. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.22}/src/aidot/camera/lan_control.py +0 -0
  18. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.22}/src/aidot/camera/models.py +0 -0
  19. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.22}/src/aidot/camera/playback.py +0 -0
  20. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.22}/src/aidot/camera/protocol.py +0 -0
  21. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.22}/src/aidot/camera/sdes.py +0 -0
  22. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.22}/src/aidot/camera/tutk.py +0 -0
  23. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.22}/src/aidot/camera/webrtc.py +0 -0
  24. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.22}/src/aidot/client.py +0 -0
  25. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.22}/src/aidot/const.py +0 -0
  26. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.22}/src/aidot/credentials.py +0 -0
  27. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.22}/src/aidot/device_client.py +0 -0
  28. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.22}/src/aidot/discover.py +0 -0
  29. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.22}/src/aidot/exceptions.py +0 -0
  30. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.22}/src/aidot/g711.py +0 -0
  31. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.22}/src/aidot/login_const.py +0 -0
  32. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.22}/src/aidot/models/__init__.py +0 -0
  33. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.22}/src/aidot/models/device_client_model.py +0 -0
  34. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.22}/src/aidot/models/device_model.py +0 -0
  35. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.22}/src/aidot/models/discover_model.py +0 -0
  36. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.22}/src/aidot/py.typed +0 -0
  37. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.22}/src/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  38. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.22}/src/python_aidot_cameras.egg-info/requires.txt +0 -0
  39. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.22}/src/python_aidot_cameras.egg-info/top_level.txt +0 -0
  40. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.22}/tests/test_alarm_event.py +0 -0
  41. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.22}/tests/test_backoff.py +0 -0
  42. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.22}/tests/test_go2rtc.py +0 -0
  43. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.22}/tests/test_highport_nomination.py +0 -0
  44. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.22}/tests/test_lan_control.py +0 -0
  45. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.22}/tests/test_live_stream_param.py +0 -0
  46. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.22}/tests/test_motion_poll.py +0 -0
  47. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.22}/tests/test_no_undefined_names.py +0 -0
  48. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.22}/tests/test_post_merge_hardening.py +0 -0
  49. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.22}/tests/test_sdes_idle_release.py +0 -0
  50. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.22}/tests/test_sdes_sprop.py +0 -0
  51. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.22}/tests/test_sdes_talk.py +0 -0
  52. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.22}/tests/test_sdes_watchdog.py +0 -0
  53. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.22}/tests/test_serve_relay.py +0 -0
  54. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.22}/tests/test_speak.py +0 -0
  55. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.22}/tests/test_stream_cap.py +0 -0
  56. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.22}/tests/test_stream_idle.py +0 -0
  57. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.22}/tests/test_talk.py +0 -0
  58. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.22}/tests/test_terminal_ack.py +0 -0
  59. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.22}/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.7.21
3
+ Version: 0.7.22
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.7.21"
7
+ version = "0.7.22"
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"
@@ -2531,6 +2531,18 @@ class CameraMixin(_CameraControlsMixin):
2531
2531
  except (TypeError, ValueError):
2532
2532
  return 120.0
2533
2533
 
2534
+ def _resolve_sdes_fast_liveplay(self) -> bool:
2535
+ """Whether to skip the ~2 s livePlayResp wait for SDES (P5, EXPERIMENTAL).
2536
+
2537
+ Per-camera ``sdes_fast_liveplay`` (set via start_keepalive) wins; else the
2538
+ ``AIDOT_SDES_FAST_LIVEPLAY`` env (truthy = 1/true/yes/on), default off.
2539
+ Only consulted for SDES cameras (the caller gates on is_sdes_camera)."""
2540
+ opt = getattr(self, "_sdes_fast_liveplay_opt", None)
2541
+ if opt is not None:
2542
+ return bool(opt)
2543
+ return os.environ.get("AIDOT_SDES_FAST_LIVEPLAY", "").strip().lower() in (
2544
+ "1", "true", "yes", "on")
2545
+
2534
2546
  def _maybe_start_serve_relay(self, serve_url: Optional[str]) -> "Optional[_ServeRelay]":
2535
2547
  """Hold the public serve port via a _ServeRelay so an eager go2rtc pull
2536
2548
  connects-and-waits instead of hitting ECONNREFUSED during the ~16-25s
@@ -3347,16 +3359,9 @@ class CameraMixin(_CameraControlsMixin):
3347
3359
  # the livePlayResp ack, which is purely a camera accept/reject we don't
3348
3360
  # need to block on (the official app never waits for it). So this targeted
3349
3361
  # skip is expected to be SCTP-safe and shave ~2 s off the SDES cold start.
3350
- # Off by default, pending live A/B validation. start_keepalive(
3362
+ # Off by default, pending live validation. Applied in _open_sdes_stream
3363
+ # (the SDES path has its own livePlayResp wait); start_keepalive(
3351
3364
  # sdes_fast_liveplay=...) wins, else AIDOT_SDES_FAST_LIVEPLAY env.
3352
- _sdes_skip_lp = False
3353
- if self.is_sdes_camera:
3354
- _sl = getattr(self, "_sdes_fast_liveplay_opt", None)
3355
- if _sl is None:
3356
- _sl = os.environ.get(
3357
- "AIDOT_SDES_FAST_LIVEPLAY", "").strip().lower() in (
3358
- "1", "true", "yes", "on")
3359
- _sdes_skip_lp = bool(_sl)
3360
3365
 
3361
3366
  # Wake battery cameras via the cloud HTTP low-power endpoint before the
3362
3367
  # handshake (matches the app, which fires the HTTP wake so a sleeping camera
@@ -4145,20 +4150,24 @@ class CameraMixin(_CameraControlsMixin):
4145
4150
  # SDP/ICE immediately - losing fast-fail on rejection (rare; ICE then
4146
4151
  # fails instead) in exchange for ~2 s off every LAN connect.
4147
4152
  if _fast_connect:
4153
+ _LOGGER.info(
4154
+ "signaling-wait[%s] livePlayResp skipped (fast_connect)",
4155
+ self.device_id)
4148
4156
  _status(
4149
4157
  "AIDOT_FAST_CONNECT: skipping livePlayResp wait (~2s) -"
4150
4158
  " proceeding to SDP/ICE (app-parity, no fast-fail on reject)"
4151
4159
  )
4152
- elif _sdes_skip_lp:
4153
- _status(
4154
- "AIDOT_SDES_FAST_LIVEPLAY: skipping livePlayResp wait (~2s)"
4155
- " for SDES - full ICE/TURN/SCTP handshake retained (experiment)"
4156
- )
4157
4160
  else:
4161
+ # Instrumented: how long this wait actually costs (it returns as
4162
+ # soon as livePlayResp arrives; the 2s is only paid on timeout).
4163
+ # This is exactly the cost the sdes_fast_liveplay experiment skips.
4164
+ _lp_t0 = time.monotonic()
4165
+ _lp_arrived = False
4158
4166
  try:
4159
4167
  _lp_resp = await asyncio.wait_for(
4160
4168
  asyncio.shield(liveplay_resp_fut), timeout=2.0
4161
4169
  )
4170
+ _lp_arrived = True
4162
4171
  _lp_code = int(_lp_resp.get("code", 200))
4163
4172
  _lp_on = int(_lp_resp.get("livePlay", 1))
4164
4173
  if _lp_code not in (0, 200) or _lp_on == 0:
@@ -4167,6 +4176,10 @@ class CameraMixin(_CameraControlsMixin):
4167
4176
  )
4168
4177
  except TimeoutError:
4169
4178
  pass
4179
+ _LOGGER.info(
4180
+ "signaling-wait[%s] livePlayResp elapsed=%dms arrived=%s",
4181
+ self.device_id,
4182
+ int((time.monotonic() - _lp_t0) * 1000), _lp_arrived)
4170
4183
  # Short extra wait for getIceConfigResp - the server may only respond
4171
4184
  # once a live camera session is active (i.e. after livePlayReq).
4172
4185
  # Waiting here ensures TURN credentials arrive before RTCPeerConnection
@@ -4178,11 +4191,18 @@ class CameraMixin(_CameraControlsMixin):
4178
4191
  # per-session TURN relay credentials, which LAN-direct doesn't need;
4179
4192
  # remote/strict-NAT cameras do, hence this is opt-in (see flag above).
4180
4193
  if not ice_config_fut.done() and not _fast_connect:
4194
+ _ic_t0 = time.monotonic()
4195
+ _ic_arrived = False
4181
4196
  try:
4182
4197
  await asyncio.wait_for(asyncio.shield(ice_config_fut), timeout=3.0)
4198
+ _ic_arrived = True
4183
4199
  _status("getIceConfigResp received (post-livePlayReq)")
4184
4200
  except TimeoutError:
4185
4201
  pass # proceed without TURN; synthetic candidates are fallback
4202
+ _LOGGER.info(
4203
+ "signaling-wait[%s] getIceConfigResp elapsed=%dms arrived=%s",
4204
+ self.device_id,
4205
+ int((time.monotonic() - _ic_t0) * 1000), _ic_arrived)
4186
4206
  elif _fast_connect:
4187
4207
  _status(
4188
4208
  "AIDOT_FAST_CONNECT: skipping getIceConfigResp wait"
@@ -7027,24 +7047,49 @@ class CameraMixin(_CameraControlsMixin):
7027
7047
  # webrtcReq. The echo confirms the MQTT pipeline to this device is live
7028
7048
  # and the broker session is registered. Fall through after 5 s if it
7029
7049
  # never arrives (same safety as the old fixed 0.5 s sleep, but adaptive).
7050
+ # P5 (experimental, opt-in via sdes_fast_liveplay): instrumentation showed
7051
+ # the echo and livePlayResp waits BOTH always time out for the SDES cameras
7052
+ # measured (echo/resp never arrive) yet streaming succeeds - i.e. ~6 s of
7053
+ # dead padding. When the flag is on, cap the echo wait short and skip the
7054
+ # livePlayResp wait; the full ICE/TURN/SCTP handshake is untouched. This
7055
+ # is the SDES path's OWN livePlay waits (the DTLS gate above never runs for
7056
+ # SDES: use_sdes is True there).
7057
+ _skip_lp = self._resolve_sdes_fast_liveplay()
7058
+ _echo_timeout = 1.5 if _skip_lp else 5.0
7059
+ _echo_t0 = time.monotonic()
7030
7060
  try:
7031
- await _asyncio.wait_for(liveplay_echo_ev.wait(), timeout=5.0)
7061
+ await _asyncio.wait_for(liveplay_echo_ev.wait(), timeout=_echo_timeout)
7032
7062
  _status("livePlayReq echo received - sending webrtcReq, ICE, then launching ffmpeg")
7033
7063
  except TimeoutError:
7034
- _status("no livePlayReq echo in 5s - sending webrtcReq, ICE, then launching ffmpeg anyway")
7035
- # If camera provided explicit livePlayResp failure, abort before SDP/ICE.
7036
- try:
7037
- _lp_resp_sdes = await _asyncio.wait_for(
7038
- _asyncio.shield(liveplay_resp_fut), timeout=1.0
7039
- )
7040
- _lp_code_sdes = int(_lp_resp_sdes.get("code", 200))
7041
- _lp_on_sdes = int(_lp_resp_sdes.get("livePlay", 1))
7042
- if _lp_code_sdes not in (0, 200) or _lp_on_sdes == 0:
7043
- raise RuntimeError(
7044
- f"livePlay rejected by camera (code={_lp_code_sdes}, livePlay={_lp_on_sdes})"
7064
+ _status(f"no livePlayReq echo in {_echo_timeout:.1f}s - sending webrtcReq,"
7065
+ " ICE, then launching ffmpeg anyway")
7066
+ _LOGGER.info(
7067
+ "signaling-wait[%s] livePlayReq-echo elapsed=%dms (timeout=%.1fs)",
7068
+ self.device_id, int((time.monotonic() - _echo_t0) * 1000), _echo_timeout)
7069
+ # livePlayResp: explicit camera accept/reject before SDP/ICE.
7070
+ if _skip_lp:
7071
+ _LOGGER.info(
7072
+ "signaling-wait[%s] livePlayResp skipped (sdes_fast_liveplay)",
7073
+ self.device_id)
7074
+ else:
7075
+ _lp_t0 = time.monotonic()
7076
+ _lp_arrived = False
7077
+ try:
7078
+ _lp_resp_sdes = await _asyncio.wait_for(
7079
+ _asyncio.shield(liveplay_resp_fut), timeout=1.0
7045
7080
  )
7046
- except TimeoutError:
7047
- pass
7081
+ _lp_arrived = True
7082
+ _lp_code_sdes = int(_lp_resp_sdes.get("code", 200))
7083
+ _lp_on_sdes = int(_lp_resp_sdes.get("livePlay", 1))
7084
+ if _lp_code_sdes not in (0, 200) or _lp_on_sdes == 0:
7085
+ raise RuntimeError(
7086
+ f"livePlay rejected by camera (code={_lp_code_sdes}, livePlay={_lp_on_sdes})"
7087
+ )
7088
+ except TimeoutError:
7089
+ pass
7090
+ _LOGGER.info(
7091
+ "signaling-wait[%s] livePlayResp elapsed=%dms arrived=%s",
7092
+ self.device_id, int((time.monotonic() - _lp_t0) * 1000), _lp_arrived)
7048
7093
 
7049
7094
  # --- Build local-receiver SDP for ffmpeg ----------------------------- #
7050
7095
  # Built BEFORE sending webrtcReq so ffmpeg is already listening on the
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-aidot-cameras
3
- Version: 0.7.21
3
+ Version: 0.7.22
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
@@ -43,6 +43,7 @@ tests/test_live_stream_param.py
43
43
  tests/test_motion_poll.py
44
44
  tests/test_no_undefined_names.py
45
45
  tests/test_post_merge_hardening.py
46
+ tests/test_sdes_fast_liveplay.py
46
47
  tests/test_sdes_idle_release.py
47
48
  tests/test_sdes_sprop.py
48
49
  tests/test_sdes_talk.py
@@ -0,0 +1,70 @@
1
+ """Unit tests for _resolve_sdes_fast_liveplay - the P5 flag (EXPERIMENTAL).
2
+
3
+ P5 skips only the ~2s livePlayResp wait for SDES cameras. This locks the
4
+ opt/env precedence (per-camera kwarg wins over AIDOT_SDES_FAST_LIVEPLAY env,
5
+ default off) so the wiring is verifiable without a camera. The actual stability
6
+ effect is validated by real-world soak (see scripts/sdes_soak_monitor.py), not
7
+ here.
8
+ """
9
+ import os
10
+ import sys
11
+
12
+ sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"))
13
+
14
+ import aidot.camera.client as cc
15
+
16
+ _CAM = next(v for v in vars(cc).values()
17
+ if isinstance(v, type) and "_resolve_sdes_fast_liveplay" in v.__dict__)
18
+
19
+
20
+ def _cam():
21
+ return _CAM.__new__(_CAM)
22
+
23
+
24
+ def test_default_off(monkeypatch):
25
+ monkeypatch.delenv("AIDOT_SDES_FAST_LIVEPLAY", raising=False)
26
+ assert _cam()._resolve_sdes_fast_liveplay() is False
27
+
28
+
29
+ def test_env_truthy_values(monkeypatch):
30
+ for val in ("1", "true", "TRUE", "yes", "on", " On "):
31
+ monkeypatch.setenv("AIDOT_SDES_FAST_LIVEPLAY", val)
32
+ assert _cam()._resolve_sdes_fast_liveplay() is True, val
33
+
34
+
35
+ def test_env_falsey_values(monkeypatch):
36
+ for val in ("0", "false", "no", "off", ""):
37
+ monkeypatch.setenv("AIDOT_SDES_FAST_LIVEPLAY", val)
38
+ assert _cam()._resolve_sdes_fast_liveplay() is False, val
39
+
40
+
41
+ def test_kwarg_option_wins_over_env(monkeypatch):
42
+ monkeypatch.setenv("AIDOT_SDES_FAST_LIVEPLAY", "1")
43
+ cam = _cam()
44
+ cam._sdes_fast_liveplay_opt = False # start_keepalive(sdes_fast_liveplay=False)
45
+ assert cam._resolve_sdes_fast_liveplay() is False
46
+
47
+ cam2 = _cam()
48
+ monkeypatch.delenv("AIDOT_SDES_FAST_LIVEPLAY", raising=False)
49
+ cam2._sdes_fast_liveplay_opt = True
50
+ assert cam2._resolve_sdes_fast_liveplay() is True
51
+
52
+
53
+ if __name__ == "__main__":
54
+ import traceback
55
+
56
+ class _MP:
57
+ def setenv(self, k, v): os.environ[k] = v
58
+ def delenv(self, k, raising=False): os.environ.pop(k, None)
59
+
60
+ _fail = 0
61
+ for _k, _v in sorted(globals().items()):
62
+ if _k.startswith("test_"):
63
+ try:
64
+ _v(_MP()) if _v.__code__.co_argcount else _v()
65
+ print(f"PASS {_k}")
66
+ except Exception:
67
+ _fail += 1
68
+ print(f"FAIL {_k}")
69
+ traceback.print_exc()
70
+ raise SystemExit(1 if _fail else 0)