python-aidot-cameras 0.7.21__tar.gz → 0.7.23__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.23}/PKG-INFO +1 -1
  2. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.23}/pyproject.toml +1 -1
  3. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.23}/src/aidot/camera/client.py +77 -28
  4. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.23/src/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  5. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.23}/src/python_aidot_cameras.egg-info/SOURCES.txt +1 -0
  6. python_aidot_cameras-0.7.23/tests/test_sdes_fast_liveplay.py +70 -0
  7. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.23}/LICENSE +0 -0
  8. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.23}/MANIFEST.in +0 -0
  9. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.23}/README.md +0 -0
  10. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.23}/setup.cfg +0 -0
  11. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.23}/src/aidot/__init__.py +0 -0
  12. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.23}/src/aidot/aes_utils.py +0 -0
  13. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.23}/src/aidot/camera/__init__.py +0 -0
  14. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.23}/src/aidot/camera/constants.py +0 -0
  15. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.23}/src/aidot/camera/controls.py +0 -0
  16. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.23}/src/aidot/camera/go2rtc.py +0 -0
  17. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.23}/src/aidot/camera/lan_control.py +0 -0
  18. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.23}/src/aidot/camera/models.py +0 -0
  19. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.23}/src/aidot/camera/playback.py +0 -0
  20. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.23}/src/aidot/camera/protocol.py +0 -0
  21. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.23}/src/aidot/camera/sdes.py +0 -0
  22. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.23}/src/aidot/camera/tutk.py +0 -0
  23. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.23}/src/aidot/camera/webrtc.py +0 -0
  24. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.23}/src/aidot/client.py +0 -0
  25. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.23}/src/aidot/const.py +0 -0
  26. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.23}/src/aidot/credentials.py +0 -0
  27. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.23}/src/aidot/device_client.py +0 -0
  28. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.23}/src/aidot/discover.py +0 -0
  29. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.23}/src/aidot/exceptions.py +0 -0
  30. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.23}/src/aidot/g711.py +0 -0
  31. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.23}/src/aidot/login_const.py +0 -0
  32. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.23}/src/aidot/models/__init__.py +0 -0
  33. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.23}/src/aidot/models/device_client_model.py +0 -0
  34. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.23}/src/aidot/models/device_model.py +0 -0
  35. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.23}/src/aidot/models/discover_model.py +0 -0
  36. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.23}/src/aidot/py.typed +0 -0
  37. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.23}/src/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  38. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.23}/src/python_aidot_cameras.egg-info/requires.txt +0 -0
  39. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.23}/src/python_aidot_cameras.egg-info/top_level.txt +0 -0
  40. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.23}/tests/test_alarm_event.py +0 -0
  41. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.23}/tests/test_backoff.py +0 -0
  42. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.23}/tests/test_go2rtc.py +0 -0
  43. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.23}/tests/test_highport_nomination.py +0 -0
  44. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.23}/tests/test_lan_control.py +0 -0
  45. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.23}/tests/test_live_stream_param.py +0 -0
  46. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.23}/tests/test_motion_poll.py +0 -0
  47. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.23}/tests/test_no_undefined_names.py +0 -0
  48. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.23}/tests/test_post_merge_hardening.py +0 -0
  49. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.23}/tests/test_sdes_idle_release.py +0 -0
  50. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.23}/tests/test_sdes_sprop.py +0 -0
  51. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.23}/tests/test_sdes_talk.py +0 -0
  52. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.23}/tests/test_sdes_watchdog.py +0 -0
  53. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.23}/tests/test_serve_relay.py +0 -0
  54. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.23}/tests/test_speak.py +0 -0
  55. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.23}/tests/test_stream_cap.py +0 -0
  56. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.23}/tests/test_stream_idle.py +0 -0
  57. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.23}/tests/test_talk.py +0 -0
  58. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.23}/tests/test_terminal_ack.py +0 -0
  59. {python_aidot_cameras-0.7.21 → python_aidot_cameras-0.7.23}/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.23
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.23"
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,19 @@ 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 use SDES fast-liveplay (shorten the always-timing-out
2536
+ echo/livePlayResp signaling) - validated in soak, opt-in, default off.
2537
+
2538
+ Per-camera ``sdes_fast_liveplay`` (set via start_keepalive) wins; else the
2539
+ ``AIDOT_SDES_FAST_LIVEPLAY`` env (truthy = 1/true/yes/on), default off.
2540
+ Only consulted for SDES cameras (the caller gates on is_sdes_camera)."""
2541
+ opt = getattr(self, "_sdes_fast_liveplay_opt", None)
2542
+ if opt is not None:
2543
+ return bool(opt)
2544
+ return os.environ.get("AIDOT_SDES_FAST_LIVEPLAY", "").strip().lower() in (
2545
+ "1", "true", "yes", "on")
2546
+
2534
2547
  def _maybe_start_serve_relay(self, serve_url: Optional[str]) -> "Optional[_ServeRelay]":
2535
2548
  """Hold the public serve port via a _ServeRelay so an eager go2rtc pull
2536
2549
  connects-and-waits instead of hitting ECONNREFUSED during the ~16-25s
@@ -3347,16 +3360,9 @@ class CameraMixin(_CameraControlsMixin):
3347
3360
  # the livePlayResp ack, which is purely a camera accept/reject we don't
3348
3361
  # need to block on (the official app never waits for it). So this targeted
3349
3362
  # 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(
3363
+ # Off by default, pending live validation. Applied in _open_sdes_stream
3364
+ # (the SDES path has its own livePlayResp wait); start_keepalive(
3351
3365
  # 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
3366
 
3361
3367
  # Wake battery cameras via the cloud HTTP low-power endpoint before the
3362
3368
  # handshake (matches the app, which fires the HTTP wake so a sleeping camera
@@ -4145,20 +4151,24 @@ class CameraMixin(_CameraControlsMixin):
4145
4151
  # SDP/ICE immediately - losing fast-fail on rejection (rare; ICE then
4146
4152
  # fails instead) in exchange for ~2 s off every LAN connect.
4147
4153
  if _fast_connect:
4154
+ _LOGGER.info(
4155
+ "signaling-wait[%s] livePlayResp skipped (fast_connect)",
4156
+ self.device_id)
4148
4157
  _status(
4149
4158
  "AIDOT_FAST_CONNECT: skipping livePlayResp wait (~2s) -"
4150
4159
  " proceeding to SDP/ICE (app-parity, no fast-fail on reject)"
4151
4160
  )
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
4161
  else:
4162
+ # Instrumented: how long this wait actually costs (it returns as
4163
+ # soon as livePlayResp arrives; the 2s is only paid on timeout).
4164
+ # This is exactly the cost the sdes_fast_liveplay experiment skips.
4165
+ _lp_t0 = time.monotonic()
4166
+ _lp_arrived = False
4158
4167
  try:
4159
4168
  _lp_resp = await asyncio.wait_for(
4160
4169
  asyncio.shield(liveplay_resp_fut), timeout=2.0
4161
4170
  )
4171
+ _lp_arrived = True
4162
4172
  _lp_code = int(_lp_resp.get("code", 200))
4163
4173
  _lp_on = int(_lp_resp.get("livePlay", 1))
4164
4174
  if _lp_code not in (0, 200) or _lp_on == 0:
@@ -4167,6 +4177,10 @@ class CameraMixin(_CameraControlsMixin):
4167
4177
  )
4168
4178
  except TimeoutError:
4169
4179
  pass
4180
+ _LOGGER.info(
4181
+ "signaling-wait[%s] livePlayResp elapsed=%dms arrived=%s",
4182
+ self.device_id,
4183
+ int((time.monotonic() - _lp_t0) * 1000), _lp_arrived)
4170
4184
  # Short extra wait for getIceConfigResp - the server may only respond
4171
4185
  # once a live camera session is active (i.e. after livePlayReq).
4172
4186
  # Waiting here ensures TURN credentials arrive before RTCPeerConnection
@@ -4178,11 +4192,18 @@ class CameraMixin(_CameraControlsMixin):
4178
4192
  # per-session TURN relay credentials, which LAN-direct doesn't need;
4179
4193
  # remote/strict-NAT cameras do, hence this is opt-in (see flag above).
4180
4194
  if not ice_config_fut.done() and not _fast_connect:
4195
+ _ic_t0 = time.monotonic()
4196
+ _ic_arrived = False
4181
4197
  try:
4182
4198
  await asyncio.wait_for(asyncio.shield(ice_config_fut), timeout=3.0)
4199
+ _ic_arrived = True
4183
4200
  _status("getIceConfigResp received (post-livePlayReq)")
4184
4201
  except TimeoutError:
4185
4202
  pass # proceed without TURN; synthetic candidates are fallback
4203
+ _LOGGER.info(
4204
+ "signaling-wait[%s] getIceConfigResp elapsed=%dms arrived=%s",
4205
+ self.device_id,
4206
+ int((time.monotonic() - _ic_t0) * 1000), _ic_arrived)
4186
4207
  elif _fast_connect:
4187
4208
  _status(
4188
4209
  "AIDOT_FAST_CONNECT: skipping getIceConfigResp wait"
@@ -7027,24 +7048,52 @@ class CameraMixin(_CameraControlsMixin):
7027
7048
  # webrtcReq. The echo confirms the MQTT pipeline to this device is live
7028
7049
  # and the broker session is registered. Fall through after 5 s if it
7029
7050
  # never arrives (same safety as the old fixed 0.5 s sleep, but adaptive).
7051
+ # SDES fast-liveplay (opt-in via sdes_fast_liveplay): instrumentation showed
7052
+ # the echo and livePlayResp waits BOTH always time out for the SDES cameras
7053
+ # measured (echo/resp never arrive) yet streaming succeeds - i.e. ~6 s of
7054
+ # dead padding. When the flag is on, cap the echo wait short and skip the
7055
+ # livePlayResp wait; the full ICE/TURN/SCTP handshake is untouched. This
7056
+ # is the SDES path's OWN livePlay waits (the DTLS gate above never runs for
7057
+ # SDES: use_sdes is True there). VALIDATED in a 3 h live soak (15 SDES
7058
+ # opens across battery cameras, flag engaged on all, 0 churn / 0 fail,
7059
+ # ~4.5 s signaling saved); kept off by default pending broader multi-day
7060
+ # use (per-open + near-term stability proven, long-haul not yet).
7061
+ _skip_lp = self._resolve_sdes_fast_liveplay()
7062
+ _echo_timeout = 1.5 if _skip_lp else 5.0
7063
+ _echo_t0 = time.monotonic()
7030
7064
  try:
7031
- await _asyncio.wait_for(liveplay_echo_ev.wait(), timeout=5.0)
7065
+ await _asyncio.wait_for(liveplay_echo_ev.wait(), timeout=_echo_timeout)
7032
7066
  _status("livePlayReq echo received - sending webrtcReq, ICE, then launching ffmpeg")
7033
7067
  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})"
7068
+ _status(f"no livePlayReq echo in {_echo_timeout:.1f}s - sending webrtcReq,"
7069
+ " ICE, then launching ffmpeg anyway")
7070
+ _LOGGER.info(
7071
+ "signaling-wait[%s] livePlayReq-echo elapsed=%dms (timeout=%.1fs)",
7072
+ self.device_id, int((time.monotonic() - _echo_t0) * 1000), _echo_timeout)
7073
+ # livePlayResp: explicit camera accept/reject before SDP/ICE.
7074
+ if _skip_lp:
7075
+ _LOGGER.info(
7076
+ "signaling-wait[%s] livePlayResp skipped (sdes_fast_liveplay)",
7077
+ self.device_id)
7078
+ else:
7079
+ _lp_t0 = time.monotonic()
7080
+ _lp_arrived = False
7081
+ try:
7082
+ _lp_resp_sdes = await _asyncio.wait_for(
7083
+ _asyncio.shield(liveplay_resp_fut), timeout=1.0
7045
7084
  )
7046
- except TimeoutError:
7047
- pass
7085
+ _lp_arrived = True
7086
+ _lp_code_sdes = int(_lp_resp_sdes.get("code", 200))
7087
+ _lp_on_sdes = int(_lp_resp_sdes.get("livePlay", 1))
7088
+ if _lp_code_sdes not in (0, 200) or _lp_on_sdes == 0:
7089
+ raise RuntimeError(
7090
+ f"livePlay rejected by camera (code={_lp_code_sdes}, livePlay={_lp_on_sdes})"
7091
+ )
7092
+ except TimeoutError:
7093
+ pass
7094
+ _LOGGER.info(
7095
+ "signaling-wait[%s] livePlayResp elapsed=%dms arrived=%s",
7096
+ self.device_id, int((time.monotonic() - _lp_t0) * 1000), _lp_arrived)
7048
7097
 
7049
7098
  # --- Build local-receiver SDP for ffmpeg ----------------------------- #
7050
7099
  # 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.23
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)