python-aidot-cameras 0.7.25__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.25/src/python_aidot_cameras.egg-info → python_aidot_cameras-0.7.27}/PKG-INFO +5 -1
  2. {python_aidot_cameras-0.7.25 → python_aidot_cameras-0.7.27}/README.md +4 -0
  3. {python_aidot_cameras-0.7.25 → python_aidot_cameras-0.7.27}/pyproject.toml +1 -1
  4. {python_aidot_cameras-0.7.25 → python_aidot_cameras-0.7.27}/src/aidot/camera/client.py +134 -3
  5. {python_aidot_cameras-0.7.25 → python_aidot_cameras-0.7.27/src/python_aidot_cameras.egg-info}/PKG-INFO +5 -1
  6. {python_aidot_cameras-0.7.25 → 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.25 → python_aidot_cameras-0.7.27}/LICENSE +0 -0
  9. {python_aidot_cameras-0.7.25 → python_aidot_cameras-0.7.27}/MANIFEST.in +0 -0
  10. {python_aidot_cameras-0.7.25 → python_aidot_cameras-0.7.27}/setup.cfg +0 -0
  11. {python_aidot_cameras-0.7.25 → python_aidot_cameras-0.7.27}/src/aidot/__init__.py +0 -0
  12. {python_aidot_cameras-0.7.25 → python_aidot_cameras-0.7.27}/src/aidot/aes_utils.py +0 -0
  13. {python_aidot_cameras-0.7.25 → python_aidot_cameras-0.7.27}/src/aidot/camera/__init__.py +0 -0
  14. {python_aidot_cameras-0.7.25 → python_aidot_cameras-0.7.27}/src/aidot/camera/constants.py +0 -0
  15. {python_aidot_cameras-0.7.25 → python_aidot_cameras-0.7.27}/src/aidot/camera/controls.py +0 -0
  16. {python_aidot_cameras-0.7.25 → python_aidot_cameras-0.7.27}/src/aidot/camera/go2rtc.py +0 -0
  17. {python_aidot_cameras-0.7.25 → python_aidot_cameras-0.7.27}/src/aidot/camera/lan_control.py +0 -0
  18. {python_aidot_cameras-0.7.25 → python_aidot_cameras-0.7.27}/src/aidot/camera/models.py +0 -0
  19. {python_aidot_cameras-0.7.25 → python_aidot_cameras-0.7.27}/src/aidot/camera/playback.py +0 -0
  20. {python_aidot_cameras-0.7.25 → python_aidot_cameras-0.7.27}/src/aidot/camera/protocol.py +0 -0
  21. {python_aidot_cameras-0.7.25 → python_aidot_cameras-0.7.27}/src/aidot/camera/sdes.py +0 -0
  22. {python_aidot_cameras-0.7.25 → python_aidot_cameras-0.7.27}/src/aidot/camera/tutk.py +0 -0
  23. {python_aidot_cameras-0.7.25 → python_aidot_cameras-0.7.27}/src/aidot/camera/webrtc.py +0 -0
  24. {python_aidot_cameras-0.7.25 → python_aidot_cameras-0.7.27}/src/aidot/client.py +0 -0
  25. {python_aidot_cameras-0.7.25 → python_aidot_cameras-0.7.27}/src/aidot/const.py +0 -0
  26. {python_aidot_cameras-0.7.25 → python_aidot_cameras-0.7.27}/src/aidot/credentials.py +0 -0
  27. {python_aidot_cameras-0.7.25 → python_aidot_cameras-0.7.27}/src/aidot/device_client.py +0 -0
  28. {python_aidot_cameras-0.7.25 → python_aidot_cameras-0.7.27}/src/aidot/discover.py +0 -0
  29. {python_aidot_cameras-0.7.25 → python_aidot_cameras-0.7.27}/src/aidot/exceptions.py +0 -0
  30. {python_aidot_cameras-0.7.25 → python_aidot_cameras-0.7.27}/src/aidot/g711.py +0 -0
  31. {python_aidot_cameras-0.7.25 → python_aidot_cameras-0.7.27}/src/aidot/login_const.py +0 -0
  32. {python_aidot_cameras-0.7.25 → python_aidot_cameras-0.7.27}/src/aidot/models/__init__.py +0 -0
  33. {python_aidot_cameras-0.7.25 → python_aidot_cameras-0.7.27}/src/aidot/models/device_client_model.py +0 -0
  34. {python_aidot_cameras-0.7.25 → python_aidot_cameras-0.7.27}/src/aidot/models/device_model.py +0 -0
  35. {python_aidot_cameras-0.7.25 → python_aidot_cameras-0.7.27}/src/aidot/models/discover_model.py +0 -0
  36. {python_aidot_cameras-0.7.25 → python_aidot_cameras-0.7.27}/src/aidot/py.typed +0 -0
  37. {python_aidot_cameras-0.7.25 → python_aidot_cameras-0.7.27}/src/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  38. {python_aidot_cameras-0.7.25 → python_aidot_cameras-0.7.27}/src/python_aidot_cameras.egg-info/requires.txt +0 -0
  39. {python_aidot_cameras-0.7.25 → python_aidot_cameras-0.7.27}/src/python_aidot_cameras.egg-info/top_level.txt +0 -0
  40. {python_aidot_cameras-0.7.25 → python_aidot_cameras-0.7.27}/tests/test_alarm_event.py +0 -0
  41. {python_aidot_cameras-0.7.25 → python_aidot_cameras-0.7.27}/tests/test_backoff.py +0 -0
  42. {python_aidot_cameras-0.7.25 → python_aidot_cameras-0.7.27}/tests/test_go2rtc.py +0 -0
  43. {python_aidot_cameras-0.7.25 → python_aidot_cameras-0.7.27}/tests/test_highport_nomination.py +0 -0
  44. {python_aidot_cameras-0.7.25 → python_aidot_cameras-0.7.27}/tests/test_lan_control.py +0 -0
  45. {python_aidot_cameras-0.7.25 → python_aidot_cameras-0.7.27}/tests/test_live_stream_param.py +0 -0
  46. {python_aidot_cameras-0.7.25 → python_aidot_cameras-0.7.27}/tests/test_motion_poll.py +0 -0
  47. {python_aidot_cameras-0.7.25 → python_aidot_cameras-0.7.27}/tests/test_no_undefined_names.py +0 -0
  48. {python_aidot_cameras-0.7.25 → python_aidot_cameras-0.7.27}/tests/test_post_merge_hardening.py +0 -0
  49. {python_aidot_cameras-0.7.25 → python_aidot_cameras-0.7.27}/tests/test_sdes_fast_liveplay.py +0 -0
  50. {python_aidot_cameras-0.7.25 → python_aidot_cameras-0.7.27}/tests/test_sdes_idle_release.py +0 -0
  51. {python_aidot_cameras-0.7.25 → python_aidot_cameras-0.7.27}/tests/test_sdes_sprop.py +0 -0
  52. {python_aidot_cameras-0.7.25 → python_aidot_cameras-0.7.27}/tests/test_sdes_talk.py +0 -0
  53. {python_aidot_cameras-0.7.25 → python_aidot_cameras-0.7.27}/tests/test_sdes_watchdog.py +0 -0
  54. {python_aidot_cameras-0.7.25 → python_aidot_cameras-0.7.27}/tests/test_serve_relay.py +0 -0
  55. {python_aidot_cameras-0.7.25 → python_aidot_cameras-0.7.27}/tests/test_speak.py +0 -0
  56. {python_aidot_cameras-0.7.25 → python_aidot_cameras-0.7.27}/tests/test_stream_cap.py +0 -0
  57. {python_aidot_cameras-0.7.25 → python_aidot_cameras-0.7.27}/tests/test_stream_idle.py +0 -0
  58. {python_aidot_cameras-0.7.25 → python_aidot_cameras-0.7.27}/tests/test_talk.py +0 -0
  59. {python_aidot_cameras-0.7.25 → python_aidot_cameras-0.7.27}/tests/test_terminal_ack.py +0 -0
  60. {python_aidot_cameras-0.7.25 → 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.25
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,9 @@ 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) |
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) |
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) |
125
128
  | `AIDOT_MAX_CONCURRENT_OPENS` | Caps how many stream opens run concurrently. | `2` |
126
129
  | `AIDOT_MAX_CONCURRENT_STREAMS` | Caps how many cameras stream at once. | `3` |
127
130
  | `AIDOT_STREAM_IDLE_S` | Seconds of stream idle before an idle release. | `120` |
@@ -131,6 +134,7 @@ chosen to work out of the box; override only when tuning.
131
134
  | `AIDOT_BUSY_RETRY_S` | Delay, in seconds, before retrying when a camera reports busy. | `45` |
132
135
  | `AIDOT_LIVESTREAM_PARAM` | Set to `0` to skip the cloud `liveStreamParam` pre-connect that provisions battery cameras' live-stream sessions before signaling (without it, battery cameras like the L2 models reject streaming with `-50019`). | `1` (enabled) |
133
136
  | `AIDOT_GOP_PLI_S` | Interval, in seconds, between PLI (keyframe) requests. | `2.0` |
137
+ | `AIDOT_SDES_PLI_GAPS` | Comma-separated second offsets for the early PLI (keyframe-request) burst on SDES cameras, to pull the first keyframe in faster on cold start. | `0,1.5,2,3` |
134
138
  | `AIDOT_AUDIO_TARGET_DBFS` | Target loudness (dBFS) for two-way audio normalization. | `-15` |
135
139
  | `AIDOT_AUDIO_MAXGAIN_DB` | Maximum gain (dB) applied by the audio normalizer. | `30` |
136
140
  | `AIDOT_AUDIO_MINGAIN_DB` | Minimum gain (dB) applied by the audio normalizer. | `-12` |
@@ -95,6 +95,9 @@ 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) |
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) |
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) |
98
101
  | `AIDOT_MAX_CONCURRENT_OPENS` | Caps how many stream opens run concurrently. | `2` |
99
102
  | `AIDOT_MAX_CONCURRENT_STREAMS` | Caps how many cameras stream at once. | `3` |
100
103
  | `AIDOT_STREAM_IDLE_S` | Seconds of stream idle before an idle release. | `120` |
@@ -104,6 +107,7 @@ chosen to work out of the box; override only when tuning.
104
107
  | `AIDOT_BUSY_RETRY_S` | Delay, in seconds, before retrying when a camera reports busy. | `45` |
105
108
  | `AIDOT_LIVESTREAM_PARAM` | Set to `0` to skip the cloud `liveStreamParam` pre-connect that provisions battery cameras' live-stream sessions before signaling (without it, battery cameras like the L2 models reject streaming with `-50019`). | `1` (enabled) |
106
109
  | `AIDOT_GOP_PLI_S` | Interval, in seconds, between PLI (keyframe) requests. | `2.0` |
110
+ | `AIDOT_SDES_PLI_GAPS` | Comma-separated second offsets for the early PLI (keyframe-request) burst on SDES cameras, to pull the first keyframe in faster on cold start. | `0,1.5,2,3` |
107
111
  | `AIDOT_AUDIO_TARGET_DBFS` | Target loudness (dBFS) for two-way audio normalization. | `-15` |
108
112
  | `AIDOT_AUDIO_MAXGAIN_DB` | Maximum gain (dB) applied by the audio normalizer. | `30` |
109
113
  | `AIDOT_AUDIO_MINGAIN_DB` | Minimum gain (dB) applied by the audio normalizer. | `-12` |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-aidot-cameras"
7
- version = "0.7.25"
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,9 +2601,77 @@ 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
 
2610
+ def _resolve_sdes_skip_turn(self) -> bool:
2611
+ """EXPERIMENTAL (opt-in, default off): skip the blocking SDES TURN relay
2612
+ pre-allocation, for cameras reachable LAN-direct.
2613
+
2614
+ Before building the offer the SDES path does two synchronous RFC-5766
2615
+ Allocate round-trips (audio + video) to the cloud TURN server so the
2616
+ offer's c=/m= can carry a relay address - ~2-3 s of pure cold-start
2617
+ latency. On a LAN the camera's host candidate wins and that relay is
2618
+ never used, so skipping it shaves the latency - at the cost of no relay
2619
+ fallback for a camera on a different segment / behind strict NAT (the
2620
+ same trade-off ``AIDOT_FAST_CONNECT`` already makes for DTLS, and which
2621
+ is force-disabled for SDES because it *also* skips the SCTP-arming
2622
+ waits; this flag skips ONLY the relay pre-allocation, leaving the rest
2623
+ of the SDES handshake intact).
2624
+
2625
+ Per-camera ``sdes_skip_turn`` (set via start_keepalive) wins; else the
2626
+ ``AIDOT_SDES_SKIP_TURN_PREALLOC`` env (truthy = 1/true/yes/on), default
2627
+ off. Only consulted for SDES cameras."""
2628
+ opt = getattr(self, "_sdes_skip_turn_opt", None)
2629
+ if opt is not None:
2630
+ return bool(opt)
2631
+ ov = getattr(self, "_fast_attempt_override", None)
2632
+ if ov is not None:
2633
+ return bool(ov)
2634
+ return os.environ.get("AIDOT_SDES_SKIP_TURN_PREALLOC", "").strip().lower() in (
2635
+ "1", "true", "yes", "on")
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
+
2562
2675
  def _maybe_start_serve_relay(self, serve_url: Optional[str]) -> "Optional[_ServeRelay]":
2563
2676
  """Hold the public serve port via a _ServeRelay so an eager go2rtc pull
2564
2677
  connects-and-waits instead of hitting ECONNREFUSED during the ~16-25s
@@ -6818,7 +6931,15 @@ class CameraMixin(_CameraControlsMixin):
6818
6931
  # AIDOT_FAST_CONNECT skips this blocking pre-allocation (LAN-direct mode):
6819
6932
  # the offer goes out immediately with host/srflx candidates and the LAN
6820
6933
  # path connects without waiting on a cloud TURN Allocate round-trip.
6821
- if _sdes_turn_entries and not _fast_connect:
6934
+ # AIDOT_SDES_SKIP_TURN_PREALLOC (experimental, opt-in) does the same skip
6935
+ # for SDES specifically, where _fast_connect is force-off (see
6936
+ # _resolve_sdes_skip_turn). Either way the cost is instrumented below so
6937
+ # the saving is measurable: grep ``signaling-wait[`` for sdes-turn-prealloc.
6938
+ _skip_turn_prealloc = self._resolve_sdes_skip_turn()
6939
+ _turn_t0 = time.monotonic()
6940
+ _turn_did = False
6941
+ if _sdes_turn_entries and not _fast_connect and not _skip_turn_prealloc:
6942
+ _turn_did = True
6822
6943
  try:
6823
6944
  import re as _re_pre
6824
6945
  import hashlib as _hlk_pre
@@ -6855,6 +6976,16 @@ class CameraMixin(_CameraControlsMixin):
6855
6976
  )
6856
6977
  except Exception as _pre_exc:
6857
6978
  _LOGGER.warning("TURN pre-allocation error: %s", _pre_exc)
6979
+ if _skip_turn_prealloc and _sdes_turn_entries:
6980
+ _status(
6981
+ "AIDOT_SDES_SKIP_TURN_PREALLOC: skipping TURN relay"
6982
+ " pre-allocation (~2-3s) - host/srflx candidates only, LAN-direct"
6983
+ )
6984
+ _LOGGER.info(
6985
+ "signaling-wait[%s] sdes-turn-prealloc elapsed=%dms allocated=%d skipped=%s",
6986
+ self.device_id,
6987
+ int((time.monotonic() - _turn_t0) * 1000),
6988
+ len(_relay_addrs), bool(_skip_turn_prealloc))
6858
6989
 
6859
6990
  # --- DTLS certificate for m=application probe ----------------------- #
6860
6991
  # PreCon cameras (sptPreconn=1) need SESSION_MODE_REQ via SCTP datachannel.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-aidot-cameras
3
- Version: 0.7.25
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,9 @@ 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) |
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) |
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) |
125
128
  | `AIDOT_MAX_CONCURRENT_OPENS` | Caps how many stream opens run concurrently. | `2` |
126
129
  | `AIDOT_MAX_CONCURRENT_STREAMS` | Caps how many cameras stream at once. | `3` |
127
130
  | `AIDOT_STREAM_IDLE_S` | Seconds of stream idle before an idle release. | `120` |
@@ -131,6 +134,7 @@ chosen to work out of the box; override only when tuning.
131
134
  | `AIDOT_BUSY_RETRY_S` | Delay, in seconds, before retrying when a camera reports busy. | `45` |
132
135
  | `AIDOT_LIVESTREAM_PARAM` | Set to `0` to skip the cloud `liveStreamParam` pre-connect that provisions battery cameras' live-stream sessions before signaling (without it, battery cameras like the L2 models reject streaming with `-50019`). | `1` (enabled) |
133
136
  | `AIDOT_GOP_PLI_S` | Interval, in seconds, between PLI (keyframe) requests. | `2.0` |
137
+ | `AIDOT_SDES_PLI_GAPS` | Comma-separated second offsets for the early PLI (keyframe-request) burst on SDES cameras, to pull the first keyframe in faster on cold start. | `0,1.5,2,3` |
134
138
  | `AIDOT_AUDIO_TARGET_DBFS` | Target loudness (dBFS) for two-way audio normalization. | `-15` |
135
139
  | `AIDOT_AUDIO_MAXGAIN_DB` | Maximum gain (dB) applied by the audio normalizer. | `30` |
136
140
  | `AIDOT_AUDIO_MINGAIN_DB` | Minimum gain (dB) applied by the audio normalizer. | `-12` |
@@ -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