python-aidot-cameras 0.7.26__tar.gz → 0.7.27__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 (60) hide show
  1. {python_aidot_cameras-0.7.26/src/python_aidot_cameras.egg-info → python_aidot_cameras-0.7.27}/PKG-INFO +2 -1
  2. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/README.md +1 -0
  3. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/pyproject.toml +1 -1
  4. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/aidot/camera/client.py +91 -2
  5. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27/src/python_aidot_cameras.egg-info}/PKG-INFO +2 -1
  6. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/python_aidot_cameras.egg-info/SOURCES.txt +1 -0
  7. python_aidot_cameras-0.7.27/tests/test_sdes_adaptive.py +126 -0
  8. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/LICENSE +0 -0
  9. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/MANIFEST.in +0 -0
  10. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/setup.cfg +0 -0
  11. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/aidot/__init__.py +0 -0
  12. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/aidot/aes_utils.py +0 -0
  13. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/aidot/camera/__init__.py +0 -0
  14. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/aidot/camera/constants.py +0 -0
  15. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/aidot/camera/controls.py +0 -0
  16. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/aidot/camera/go2rtc.py +0 -0
  17. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/aidot/camera/lan_control.py +0 -0
  18. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/aidot/camera/models.py +0 -0
  19. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/aidot/camera/playback.py +0 -0
  20. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/aidot/camera/protocol.py +0 -0
  21. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/aidot/camera/sdes.py +0 -0
  22. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/aidot/camera/tutk.py +0 -0
  23. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/aidot/camera/webrtc.py +0 -0
  24. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/aidot/client.py +0 -0
  25. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/aidot/const.py +0 -0
  26. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/aidot/credentials.py +0 -0
  27. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/aidot/device_client.py +0 -0
  28. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/aidot/discover.py +0 -0
  29. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/aidot/exceptions.py +0 -0
  30. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/aidot/g711.py +0 -0
  31. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/aidot/login_const.py +0 -0
  32. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/aidot/models/__init__.py +0 -0
  33. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/aidot/models/device_client_model.py +0 -0
  34. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/aidot/models/device_model.py +0 -0
  35. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/aidot/models/discover_model.py +0 -0
  36. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/aidot/py.typed +0 -0
  37. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  38. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/python_aidot_cameras.egg-info/requires.txt +0 -0
  39. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/python_aidot_cameras.egg-info/top_level.txt +0 -0
  40. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/tests/test_alarm_event.py +0 -0
  41. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/tests/test_backoff.py +0 -0
  42. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/tests/test_go2rtc.py +0 -0
  43. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/tests/test_highport_nomination.py +0 -0
  44. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/tests/test_lan_control.py +0 -0
  45. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/tests/test_live_stream_param.py +0 -0
  46. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/tests/test_motion_poll.py +0 -0
  47. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/tests/test_no_undefined_names.py +0 -0
  48. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/tests/test_post_merge_hardening.py +0 -0
  49. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/tests/test_sdes_fast_liveplay.py +0 -0
  50. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/tests/test_sdes_idle_release.py +0 -0
  51. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/tests/test_sdes_sprop.py +0 -0
  52. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/tests/test_sdes_talk.py +0 -0
  53. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/tests/test_sdes_watchdog.py +0 -0
  54. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/tests/test_serve_relay.py +0 -0
  55. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/tests/test_speak.py +0 -0
  56. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/tests/test_stream_cap.py +0 -0
  57. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/tests/test_stream_idle.py +0 -0
  58. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/tests/test_talk.py +0 -0
  59. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/tests/test_terminal_ack.py +0 -0
  60. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/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.26
3
+ Version: 0.7.27
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
@@ -122,6 +122,7 @@ chosen to work out of the box; override only when tuning.
122
122
  | `AIDOT_SPROP_DIR` | Directory where captured SPS/PPS (sprop) parameter sets are cached. Set this to a writable path (e.g. for Home Assistant) if the default location is read-only. | `<package dir>` |
123
123
  | `AIDOT_DISABLE_HIGHPORT_FIX` | If set (any value), disables the DTLS high-port `USE-CANDIDATE` nomination fix and falls back to upstream aioice behavior (used to measure the baseline connect rate). | unset (fix enabled) |
124
124
  | `AIDOT_FAST_CONNECT` | Enables LAN-direct "fast connect" mode (STUN-only, skips several cloud signaling waits) when set to a truthy value. | unset (off) |
125
+ | `AIDOT_SDES_ADAPTIVE` | Adaptive fast-with-fallback for the SDES keepalive loop: try the fast path first (skip livePlay waits + TURN relay pre-alloc) and fall back to the full relay path if a fast attempt delivers no media. A per-device cache skips the fast attempt on later views once it has failed for a camera. Truthy value enables. | unset (off) |
125
126
  | `AIDOT_SDES_FAST_LIVEPLAY` | Truthy value skips the `livePlayResp` blocking wait for eligible SDES cameras (~4.5 s faster cold start), while keeping the full ICE/SCTP handshake. Role-reversal models (A001064 PTZ) are always excluded. Soak-validated; opt-in. | unset (off) |
126
127
  | `AIDOT_SERVE_RELAY` | Holds the public stream port via an internal relay that proxies to ffmpeg, so the first (cold) view connects instead of failing while ffmpeg can't pre-bind the port. Set to `0` to serve ffmpeg directly. | `1` (enabled) |
127
128
  | `AIDOT_MAX_CONCURRENT_OPENS` | Caps how many stream opens run concurrently. | `2` |
@@ -95,6 +95,7 @@ chosen to work out of the box; override only when tuning.
95
95
  | `AIDOT_SPROP_DIR` | Directory where captured SPS/PPS (sprop) parameter sets are cached. Set this to a writable path (e.g. for Home Assistant) if the default location is read-only. | `<package dir>` |
96
96
  | `AIDOT_DISABLE_HIGHPORT_FIX` | If set (any value), disables the DTLS high-port `USE-CANDIDATE` nomination fix and falls back to upstream aioice behavior (used to measure the baseline connect rate). | unset (fix enabled) |
97
97
  | `AIDOT_FAST_CONNECT` | Enables LAN-direct "fast connect" mode (STUN-only, skips several cloud signaling waits) when set to a truthy value. | unset (off) |
98
+ | `AIDOT_SDES_ADAPTIVE` | Adaptive fast-with-fallback for the SDES keepalive loop: try the fast path first (skip livePlay waits + TURN relay pre-alloc) and fall back to the full relay path if a fast attempt delivers no media. A per-device cache skips the fast attempt on later views once it has failed for a camera. Truthy value enables. | unset (off) |
98
99
  | `AIDOT_SDES_FAST_LIVEPLAY` | Truthy value skips the `livePlayResp` blocking wait for eligible SDES cameras (~4.5 s faster cold start), while keeping the full ICE/SCTP handshake. Role-reversal models (A001064 PTZ) are always excluded. Soak-validated; opt-in. | unset (off) |
99
100
  | `AIDOT_SERVE_RELAY` | Holds the public stream port via an internal relay that proxies to ffmpeg, so the first (cold) view connects instead of failing while ffmpeg can't pre-bind the port. Set to `0` to serve ffmpeg directly. | `1` (enabled) |
100
101
  | `AIDOT_MAX_CONCURRENT_OPENS` | Caps how many stream opens run concurrently. | `2` |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-aidot-cameras"
7
- version = "0.7.26"
7
+ version = "0.7.27"
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"
@@ -1959,6 +1959,8 @@ class CameraMixin(_CameraControlsMixin):
1959
1959
  serve_relay: Optional[bool] = None,
1960
1960
  stream_idle_s: Optional[float] = None,
1961
1961
  sdes_fast_liveplay: Optional[bool] = None,
1962
+ sdes_skip_turn: Optional[bool] = None,
1963
+ sdes_adaptive: Optional[bool] = None,
1962
1964
  ) -> None:
1963
1965
  """Start a persistent stream that keeps the camera session alive.
1964
1966
 
@@ -2009,6 +2011,10 @@ class CameraMixin(_CameraControlsMixin):
2009
2011
  self._stream_idle_opt = stream_idle_s
2010
2012
  if sdes_fast_liveplay is not None:
2011
2013
  self._sdes_fast_liveplay_opt = sdes_fast_liveplay
2014
+ if sdes_skip_turn is not None:
2015
+ self._sdes_skip_turn_opt = sdes_skip_turn
2016
+ if sdes_adaptive is not None:
2017
+ self._sdes_adaptive_opt = sdes_adaptive
2012
2018
  if self._stream_task is not None and not self._stream_task.done():
2013
2019
  return
2014
2020
  self._keepalive_rtsp_url = rtsp_push_url
@@ -2218,19 +2224,46 @@ class CameraMixin(_CameraControlsMixin):
2218
2224
  # media (see end of loop).
2219
2225
  _pacer = ReconnectPacer(_MIN_DELAY, _MAX_DELAY)
2220
2226
 
2227
+ # Adaptive fast-with-fallback (default on): the first open tries the fast
2228
+ # path (skip livePlay waits + TURN relay pre-allocation) with a SHORT
2229
+ # timeout/grace so a non-LAN camera fails quickly; on no media we latch
2230
+ # _fast_failed and the remaining opens this loop use the full, patient
2231
+ # relay path. Makes fast-by-default safe regardless of camera reachability.
2232
+ _adaptive = self._resolve_sdes_adaptive()
2233
+ _FAST_OPEN_TIMEOUT = 45.0
2234
+ _FAST_GRACE = 40.0
2235
+ # Per-device cache: once a fast attempt has failed for this camera (e.g. a
2236
+ # strict-NAT / non-LAN camera that genuinely needs the relay), remember it
2237
+ # so later views skip the fast attempt entirely instead of re-paying the
2238
+ # ~40s fast timeout on every fresh keepalive loop. Latches for the client's
2239
+ # lifetime; an integration reload / restart re-probes the fast path.
2240
+ _fast_failed = bool(getattr(self, "_fast_path_unavailable", False))
2241
+
2221
2242
  while self._streaming_active:
2222
2243
  if self._serve_relay is not None:
2223
2244
  # Clear any stale backend from a prior session; the open below
2224
2245
  # points the relay at this session's fresh internal ffmpeg port.
2225
2246
  self._serve_relay.set_backend(None)
2247
+ _use_fast = self._adaptive_next_fast(_adaptive, _fast_failed)
2248
+ if _adaptive:
2249
+ self._fast_attempt_override = _use_fast
2226
2250
  try:
2227
2251
  session = await self.async_open_webrtc_stream(
2228
2252
  rtsp_push_url=self._keepalive_rtsp_url,
2229
- timeout=120.0,
2253
+ timeout=(_FAST_OPEN_TIMEOUT if _use_fast else 120.0),
2230
2254
  )
2231
2255
  except asyncio.CancelledError:
2256
+ self._fast_attempt_override = None
2232
2257
  return
2233
2258
  except Exception as exc:
2259
+ self._fast_attempt_override = None
2260
+ if _use_fast:
2261
+ _fast_failed = self._adaptive_after_attempt(True, False, _fast_failed)
2262
+ self._fast_path_unavailable = True # cache across views
2263
+ _LOGGER.info(
2264
+ "SDES adaptive[%s]: fast open failed (%.0fs) - "
2265
+ "falling back to full relay path", self.device_id,
2266
+ _FAST_OPEN_TIMEOUT)
2234
2267
  _delay = _pacer.fail_delay()
2235
2268
  _LOGGER.warning(
2236
2269
  "SDES keepalive: stream open failed for %s (retry in %.0fs): %s",
@@ -2242,6 +2275,7 @@ class CameraMixin(_CameraControlsMixin):
2242
2275
  return
2243
2276
  continue
2244
2277
 
2278
+ self._fast_attempt_override = None
2245
2279
  self._stream_session = session
2246
2280
  # Don't block solely on wait_done(): the SDES ffmpeg reads RTP over a
2247
2281
  # UDP socket with no input timeout, so when a battery camera tears the
@@ -2276,6 +2310,7 @@ class CameraMixin(_CameraControlsMixin):
2276
2310
  session.last_media_monotonic,
2277
2311
  _started_at,
2278
2312
  time.monotonic(),
2313
+ grace=(_FAST_GRACE if _use_fast else 60.0),
2279
2314
  ):
2280
2315
  _stalled = True
2281
2316
  break
@@ -2327,12 +2362,22 @@ class CameraMixin(_CameraControlsMixin):
2327
2362
  except Exception:
2328
2363
  _LOGGER.debug("camera %s: swallowed exception", '_sdes_keepalive_loop', exc_info=True)
2329
2364
 
2365
+ # Adaptive bookkeeping: a fast attempt that never delivered media
2366
+ # latches the loop onto the full relay path for its remaining opens.
2367
+ _healthy = session.last_media_monotonic > 0.0
2368
+ if _use_fast and not _healthy and not _fast_failed:
2369
+ _LOGGER.info(
2370
+ "SDES adaptive[%s]: fast attempt delivered no media - "
2371
+ "falling back to full relay path", self.device_id)
2372
+ _fast_failed = self._adaptive_after_attempt(_use_fast, _healthy, _fast_failed)
2373
+ if _use_fast and not _healthy:
2374
+ self._fast_path_unavailable = True # cache across views
2375
+
2330
2376
  if self._streaming_active:
2331
2377
  # Escalate backoff only when the session never delivered media
2332
2378
  # (camera refused / degraded on a rapid reconnect); a session
2333
2379
  # that streamed fine and then ended (battery teardown, consumer
2334
2380
  # gone) is a normal lifecycle event and resets to the base interval.
2335
- _healthy = session.last_media_monotonic > 0.0
2336
2381
  try:
2337
2382
  await asyncio.sleep(_pacer.session_end_delay(healthy=_healthy))
2338
2383
  except asyncio.CancelledError:
@@ -2556,6 +2601,9 @@ class CameraMixin(_CameraControlsMixin):
2556
2601
  opt = getattr(self, "_sdes_fast_liveplay_opt", None)
2557
2602
  if opt is not None:
2558
2603
  return bool(opt)
2604
+ ov = getattr(self, "_fast_attempt_override", None)
2605
+ if ov is not None:
2606
+ return bool(ov)
2559
2607
  return os.environ.get("AIDOT_SDES_FAST_LIVEPLAY", "").strip().lower() in (
2560
2608
  "1", "true", "yes", "on")
2561
2609
 
@@ -2580,9 +2628,50 @@ class CameraMixin(_CameraControlsMixin):
2580
2628
  opt = getattr(self, "_sdes_skip_turn_opt", None)
2581
2629
  if opt is not None:
2582
2630
  return bool(opt)
2631
+ ov = getattr(self, "_fast_attempt_override", None)
2632
+ if ov is not None:
2633
+ return bool(ov)
2583
2634
  return os.environ.get("AIDOT_SDES_SKIP_TURN_PREALLOC", "").strip().lower() in (
2584
2635
  "1", "true", "yes", "on")
2585
2636
 
2637
+ def _resolve_sdes_adaptive(self) -> bool:
2638
+ """Whether the SDES keepalive loop drives the fast path adaptively
2639
+ (opt-in, default OFF): try fast-first (skip the livePlay waits + TURN
2640
+ relay pre-allocation, with a short timeout), and fall back to the full,
2641
+ patient relay path if the fast attempt delivers no media.
2642
+
2643
+ This makes "fast by default" safe for cameras of unknown reachability:
2644
+ a LAN-direct camera gets the fast connect; a remote / strict-NAT camera
2645
+ loses one short fast attempt, then connects via the full relay path.
2646
+
2647
+ Default OFF pending real-world fast-failure-rate data: a fast *failure*
2648
+ costs ~40 s (the grace) before fallback while success saves only ~7 s, so
2649
+ until the failure rate is known on real fleets this stays opt-in.
2650
+
2651
+ Per-camera ``sdes_adaptive`` (via start_keepalive) wins; else the
2652
+ ``AIDOT_SDES_ADAPTIVE`` env (truthy = 1/true/yes/on), default off. When
2653
+ off, the per-attempt override is never set, so the explicit
2654
+ ``sdes_fast_liveplay`` / ``sdes_skip_turn`` opts (or their envs) decide -
2655
+ exactly the pre-adaptive behaviour."""
2656
+ opt = getattr(self, "_sdes_adaptive_opt", None)
2657
+ if opt is not None:
2658
+ return bool(opt)
2659
+ return os.environ.get("AIDOT_SDES_ADAPTIVE", "").strip().lower() in (
2660
+ "1", "true", "yes", "on")
2661
+
2662
+ @staticmethod
2663
+ def _adaptive_next_fast(adaptive: bool, fast_failed: bool) -> bool:
2664
+ """Whether the next SDES open attempt should use the fast path: only when
2665
+ adaptive mode is on and the fast path has not already failed this loop."""
2666
+ return bool(adaptive) and not bool(fast_failed)
2667
+
2668
+ @staticmethod
2669
+ def _adaptive_after_attempt(use_fast: bool, healthy: bool, fast_failed: bool) -> bool:
2670
+ """Updated ``fast_failed`` after an attempt: latch it once a fast attempt
2671
+ delivers no media, so the loop stays on the full relay path (no
2672
+ oscillation) until it restarts fresh on the next view."""
2673
+ return bool(fast_failed) or (bool(use_fast) and not bool(healthy))
2674
+
2586
2675
  def _maybe_start_serve_relay(self, serve_url: Optional[str]) -> "Optional[_ServeRelay]":
2587
2676
  """Hold the public serve port via a _ServeRelay so an eager go2rtc pull
2588
2677
  connects-and-waits instead of hitting ECONNREFUSED during the ~16-25s
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-aidot-cameras
3
- Version: 0.7.26
3
+ Version: 0.7.27
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
@@ -122,6 +122,7 @@ chosen to work out of the box; override only when tuning.
122
122
  | `AIDOT_SPROP_DIR` | Directory where captured SPS/PPS (sprop) parameter sets are cached. Set this to a writable path (e.g. for Home Assistant) if the default location is read-only. | `<package dir>` |
123
123
  | `AIDOT_DISABLE_HIGHPORT_FIX` | If set (any value), disables the DTLS high-port `USE-CANDIDATE` nomination fix and falls back to upstream aioice behavior (used to measure the baseline connect rate). | unset (fix enabled) |
124
124
  | `AIDOT_FAST_CONNECT` | Enables LAN-direct "fast connect" mode (STUN-only, skips several cloud signaling waits) when set to a truthy value. | unset (off) |
125
+ | `AIDOT_SDES_ADAPTIVE` | Adaptive fast-with-fallback for the SDES keepalive loop: try the fast path first (skip livePlay waits + TURN relay pre-alloc) and fall back to the full relay path if a fast attempt delivers no media. A per-device cache skips the fast attempt on later views once it has failed for a camera. Truthy value enables. | unset (off) |
125
126
  | `AIDOT_SDES_FAST_LIVEPLAY` | Truthy value skips the `livePlayResp` blocking wait for eligible SDES cameras (~4.5 s faster cold start), while keeping the full ICE/SCTP handshake. Role-reversal models (A001064 PTZ) are always excluded. Soak-validated; opt-in. | unset (off) |
126
127
  | `AIDOT_SERVE_RELAY` | Holds the public stream port via an internal relay that proxies to ffmpeg, so the first (cold) view connects instead of failing while ffmpeg can't pre-bind the port. Set to `0` to serve ffmpeg directly. | `1` (enabled) |
127
128
  | `AIDOT_MAX_CONCURRENT_OPENS` | Caps how many stream opens run concurrently. | `2` |
@@ -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_adaptive.py
46
47
  tests/test_sdes_fast_liveplay.py
47
48
  tests/test_sdes_idle_release.py
48
49
  tests/test_sdes_sprop.py
@@ -0,0 +1,126 @@
1
+ """Unit tests for the SDES adaptive fast-with-fallback controller.
2
+
3
+ The adaptive controller (default on) makes "fast by default" safe regardless of
4
+ camera reachability: the SDES keepalive loop tries the fast path first (skip the
5
+ livePlay waits + TURN relay pre-allocation, short timeout/grace) and falls back
6
+ to the full relay path if a fast attempt delivers no media. These tests lock the
7
+ decision state-machine and the resolver precedence so the wiring is verifiable
8
+ without a camera; the real media/stability effect is validated by live soak.
9
+ """
10
+ import os
11
+ import sys
12
+
13
+ sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"))
14
+
15
+ import aidot.camera.client as cc
16
+
17
+ _CAM = next(v for v in vars(cc).values()
18
+ if isinstance(v, type) and "_resolve_sdes_adaptive" in v.__dict__)
19
+
20
+
21
+ def _cam():
22
+ return _CAM.__new__(_CAM)
23
+
24
+
25
+ # --- _resolve_sdes_adaptive: opt-in, default OFF --------------------------- #
26
+
27
+ def test_adaptive_default_off(monkeypatch):
28
+ monkeypatch.delenv("AIDOT_SDES_ADAPTIVE", raising=False)
29
+ assert _cam()._resolve_sdes_adaptive() is False
30
+
31
+
32
+ def test_adaptive_env_enables(monkeypatch):
33
+ for val in ("1", "true", "TRUE", "yes", "on", " On "):
34
+ monkeypatch.setenv("AIDOT_SDES_ADAPTIVE", val)
35
+ assert _cam()._resolve_sdes_adaptive() is True, val
36
+
37
+
38
+ def test_adaptive_env_falsey_stays_off(monkeypatch):
39
+ for val in ("0", "false", "no", "off", "", "anything"):
40
+ monkeypatch.setenv("AIDOT_SDES_ADAPTIVE", val)
41
+ assert _cam()._resolve_sdes_adaptive() is False, val
42
+
43
+
44
+ def test_adaptive_kwarg_wins_over_env(monkeypatch):
45
+ monkeypatch.setenv("AIDOT_SDES_ADAPTIVE", "1")
46
+ cam = _cam()
47
+ cam._sdes_adaptive_opt = False # start_keepalive(sdes_adaptive=False)
48
+ assert cam._resolve_sdes_adaptive() is False
49
+
50
+
51
+ # --- decision state machine ------------------------------------------------ #
52
+
53
+ def test_next_fast_only_when_adaptive_and_not_failed():
54
+ assert _CAM._adaptive_next_fast(True, False) is True
55
+ assert _CAM._adaptive_next_fast(True, True) is False # latched off
56
+ assert _CAM._adaptive_next_fast(False, False) is False # adaptive off
57
+
58
+
59
+ def test_after_attempt_latches_on_fast_nomedia():
60
+ # a fast attempt that delivered no media latches fast_failed
61
+ assert _CAM._adaptive_after_attempt(True, False, False) is True
62
+ # a fast attempt that delivered media does NOT latch
63
+ assert _CAM._adaptive_after_attempt(True, True, False) is False
64
+ # a full attempt (use_fast False) never latches, regardless of media
65
+ assert _CAM._adaptive_after_attempt(False, False, False) is False
66
+ # once latched, it stays latched (no oscillation back to fast)
67
+ assert _CAM._adaptive_after_attempt(False, True, True) is True
68
+
69
+
70
+ def test_full_lifecycle_fast_then_fallback_sticks():
71
+ adaptive, failed = True, False
72
+ # attempt 1: fast, no media -> latch
73
+ use_fast = _CAM._adaptive_next_fast(adaptive, failed)
74
+ assert use_fast is True
75
+ failed = _CAM._adaptive_after_attempt(use_fast, False, failed)
76
+ assert failed is True
77
+ # attempt 2: must be full now, and stays full even after a healthy session
78
+ use_fast = _CAM._adaptive_next_fast(adaptive, failed)
79
+ assert use_fast is False
80
+ failed = _CAM._adaptive_after_attempt(use_fast, True, failed)
81
+ assert failed is True
82
+
83
+
84
+ # --- per-attempt override feeds the fast resolvers ------------------------- #
85
+
86
+ def test_override_drives_skip_turn_resolver(monkeypatch):
87
+ monkeypatch.delenv("AIDOT_SDES_SKIP_TURN_PREALLOC", raising=False)
88
+ cam = _cam()
89
+ cam._fast_attempt_override = True
90
+ assert cam._resolve_sdes_skip_turn() is True
91
+ cam._fast_attempt_override = False
92
+ assert cam._resolve_sdes_skip_turn() is False
93
+
94
+
95
+ def test_explicit_opt_beats_override(monkeypatch):
96
+ # an explicit user opt must win over the adaptive per-attempt override
97
+ cam = _cam()
98
+ cam._sdes_skip_turn_opt = False
99
+ cam._fast_attempt_override = True
100
+ assert cam._resolve_sdes_skip_turn() is False
101
+
102
+
103
+ def test_cache_makes_next_view_skip_fast():
104
+ # A camera that previously failed the fast path latches _fast_path_unavailable,
105
+ # so the next keepalive loop starts already-failed and skips the fast attempt
106
+ # (no repeated ~40s fast timeout per fresh view). A fresh camera tries fast.
107
+ cached = _cam()
108
+ cached._fast_path_unavailable = True
109
+ init_failed = bool(getattr(cached, "_fast_path_unavailable", False))
110
+ assert init_failed is True
111
+ assert _CAM._adaptive_next_fast(True, init_failed) is False
112
+
113
+ fresh = _cam()
114
+ init_fresh = bool(getattr(fresh, "_fast_path_unavailable", False))
115
+ assert init_fresh is False
116
+ assert _CAM._adaptive_next_fast(True, init_fresh) is True
117
+
118
+
119
+ def test_override_respects_role_reversal_exclusion(monkeypatch):
120
+ from types import SimpleNamespace
121
+ monkeypatch.delenv("AIDOT_SDES_FAST_LIVEPLAY", raising=False)
122
+ cam = _cam()
123
+ cam.info = SimpleNamespace(model_id="LK.IPC.A001064")
124
+ cam._fast_attempt_override = True # adaptive would try fast...
125
+ # ...but the role-reversal model is excluded from fast-liveplay regardless
126
+ assert cam._resolve_sdes_fast_liveplay() is False