python-aidot-cameras 0.7.26__tar.gz → 0.7.28__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 (61) hide show
  1. {python_aidot_cameras-0.7.26/src/python_aidot_cameras.egg-info → python_aidot_cameras-0.7.28}/PKG-INFO +3 -1
  2. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.28}/README.md +2 -0
  3. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.28}/pyproject.toml +1 -1
  4. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.28}/src/aidot/camera/client.py +165 -14
  5. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.28}/src/aidot/camera/protocol.py +180 -0
  6. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.28}/src/aidot/client.py +9 -0
  7. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.28/src/python_aidot_cameras.egg-info}/PKG-INFO +3 -1
  8. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.28}/src/python_aidot_cameras.egg-info/SOURCES.txt +2 -0
  9. python_aidot_cameras-0.7.28/tests/test_persistent_mqtt.py +68 -0
  10. python_aidot_cameras-0.7.28/tests/test_sdes_adaptive.py +126 -0
  11. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.28}/LICENSE +0 -0
  12. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.28}/MANIFEST.in +0 -0
  13. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.28}/setup.cfg +0 -0
  14. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.28}/src/aidot/__init__.py +0 -0
  15. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.28}/src/aidot/aes_utils.py +0 -0
  16. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.28}/src/aidot/camera/__init__.py +0 -0
  17. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.28}/src/aidot/camera/constants.py +0 -0
  18. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.28}/src/aidot/camera/controls.py +0 -0
  19. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.28}/src/aidot/camera/go2rtc.py +0 -0
  20. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.28}/src/aidot/camera/lan_control.py +0 -0
  21. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.28}/src/aidot/camera/models.py +0 -0
  22. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.28}/src/aidot/camera/playback.py +0 -0
  23. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.28}/src/aidot/camera/sdes.py +0 -0
  24. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.28}/src/aidot/camera/tutk.py +0 -0
  25. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.28}/src/aidot/camera/webrtc.py +0 -0
  26. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.28}/src/aidot/const.py +0 -0
  27. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.28}/src/aidot/credentials.py +0 -0
  28. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.28}/src/aidot/device_client.py +0 -0
  29. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.28}/src/aidot/discover.py +0 -0
  30. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.28}/src/aidot/exceptions.py +0 -0
  31. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.28}/src/aidot/g711.py +0 -0
  32. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.28}/src/aidot/login_const.py +0 -0
  33. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.28}/src/aidot/models/__init__.py +0 -0
  34. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.28}/src/aidot/models/device_client_model.py +0 -0
  35. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.28}/src/aidot/models/device_model.py +0 -0
  36. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.28}/src/aidot/models/discover_model.py +0 -0
  37. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.28}/src/aidot/py.typed +0 -0
  38. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.28}/src/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  39. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.28}/src/python_aidot_cameras.egg-info/requires.txt +0 -0
  40. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.28}/src/python_aidot_cameras.egg-info/top_level.txt +0 -0
  41. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.28}/tests/test_alarm_event.py +0 -0
  42. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.28}/tests/test_backoff.py +0 -0
  43. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.28}/tests/test_go2rtc.py +0 -0
  44. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.28}/tests/test_highport_nomination.py +0 -0
  45. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.28}/tests/test_lan_control.py +0 -0
  46. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.28}/tests/test_live_stream_param.py +0 -0
  47. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.28}/tests/test_motion_poll.py +0 -0
  48. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.28}/tests/test_no_undefined_names.py +0 -0
  49. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.28}/tests/test_post_merge_hardening.py +0 -0
  50. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.28}/tests/test_sdes_fast_liveplay.py +0 -0
  51. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.28}/tests/test_sdes_idle_release.py +0 -0
  52. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.28}/tests/test_sdes_sprop.py +0 -0
  53. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.28}/tests/test_sdes_talk.py +0 -0
  54. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.28}/tests/test_sdes_watchdog.py +0 -0
  55. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.28}/tests/test_serve_relay.py +0 -0
  56. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.28}/tests/test_speak.py +0 -0
  57. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.28}/tests/test_stream_cap.py +0 -0
  58. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.28}/tests/test_stream_idle.py +0 -0
  59. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.28}/tests/test_talk.py +0 -0
  60. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.28}/tests/test_terminal_ack.py +0 -0
  61. {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.28}/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.28
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,8 @@ 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_PERSISTENT_MQTT` | Reuse ONE account-level persistent MQTT connection for device commands and attribute fetches (matching the official app) instead of connecting per operation, cutting cloud connect churn. Truthy value enables. The stream-open path is unaffected. | unset (off) |
126
+ | `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
127
  | `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
128
  | `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
129
  | `AIDOT_MAX_CONCURRENT_OPENS` | Caps how many stream opens run concurrently. | `2` |
@@ -95,6 +95,8 @@ 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_PERSISTENT_MQTT` | Reuse ONE account-level persistent MQTT connection for device commands and attribute fetches (matching the official app) instead of connecting per operation, cutting cloud connect churn. Truthy value enables. The stream-open path is unaffected. | unset (off) |
99
+ | `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
100
  | `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
101
  | `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
102
  | `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.28"
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"
@@ -1241,12 +1241,23 @@ class CameraMixin(_CameraControlsMixin):
1241
1241
  publish_items.append((_wake_topic, _wake_payload))
1242
1242
  publish_items.append((pub_topic, payload_str))
1243
1243
 
1244
- messages = await _mqtt_session(
1245
- mqtt_url, mqtt_user, mqtt_pwd, client_id,
1246
- subscribe_topics=sub_topics,
1247
- publish_items=publish_items,
1248
- duration=timeout,
1249
- )
1244
+ if self._resolve_persistent_mqtt():
1245
+ pm = await self._get_persistent_mqtt()
1246
+ else:
1247
+ pm = None
1248
+ if pm is not None:
1249
+ messages, _st = await pm.request(
1250
+ publish_items=publish_items,
1251
+ subscribe_topics=sub_topics,
1252
+ timeout=timeout,
1253
+ )
1254
+ else:
1255
+ messages = await _mqtt_session(
1256
+ mqtt_url, mqtt_user, mqtt_pwd, client_id,
1257
+ subscribe_topics=sub_topics,
1258
+ publish_items=publish_items,
1259
+ duration=timeout,
1260
+ )
1250
1261
 
1251
1262
  for topic, raw in messages:
1252
1263
  if ack_keyword and ack_keyword not in topic:
@@ -1473,12 +1484,23 @@ class CameraMixin(_CameraControlsMixin):
1473
1484
  })
1474
1485
  publish_items.append((_wake_topic, _wake_payload))
1475
1486
 
1476
- messages = await _mqtt_session(
1477
- mqtt_url, mqtt_user, mqtt_pwd, client_id,
1478
- subscribe_topics=sub_topics,
1479
- publish_items=publish_items,
1480
- duration=timeout,
1481
- )
1487
+ if self._resolve_persistent_mqtt():
1488
+ pm = await self._get_persistent_mqtt()
1489
+ else:
1490
+ pm = None
1491
+ if pm is not None:
1492
+ messages, _st = await pm.request(
1493
+ publish_items=publish_items,
1494
+ subscribe_topics=sub_topics,
1495
+ timeout=timeout,
1496
+ )
1497
+ else:
1498
+ messages = await _mqtt_session(
1499
+ mqtt_url, mqtt_user, mqtt_pwd, client_id,
1500
+ subscribe_topics=sub_topics,
1501
+ publish_items=publish_items,
1502
+ duration=timeout,
1503
+ )
1482
1504
 
1483
1505
  for topic, raw in messages:
1484
1506
  if "setDevAttrNotif" not in topic:
@@ -1959,6 +1981,8 @@ class CameraMixin(_CameraControlsMixin):
1959
1981
  serve_relay: Optional[bool] = None,
1960
1982
  stream_idle_s: Optional[float] = None,
1961
1983
  sdes_fast_liveplay: Optional[bool] = None,
1984
+ sdes_skip_turn: Optional[bool] = None,
1985
+ sdes_adaptive: Optional[bool] = None,
1962
1986
  ) -> None:
1963
1987
  """Start a persistent stream that keeps the camera session alive.
1964
1988
 
@@ -2009,6 +2033,10 @@ class CameraMixin(_CameraControlsMixin):
2009
2033
  self._stream_idle_opt = stream_idle_s
2010
2034
  if sdes_fast_liveplay is not None:
2011
2035
  self._sdes_fast_liveplay_opt = sdes_fast_liveplay
2036
+ if sdes_skip_turn is not None:
2037
+ self._sdes_skip_turn_opt = sdes_skip_turn
2038
+ if sdes_adaptive is not None:
2039
+ self._sdes_adaptive_opt = sdes_adaptive
2012
2040
  if self._stream_task is not None and not self._stream_task.done():
2013
2041
  return
2014
2042
  self._keepalive_rtsp_url = rtsp_push_url
@@ -2218,19 +2246,46 @@ class CameraMixin(_CameraControlsMixin):
2218
2246
  # media (see end of loop).
2219
2247
  _pacer = ReconnectPacer(_MIN_DELAY, _MAX_DELAY)
2220
2248
 
2249
+ # Adaptive fast-with-fallback (default on): the first open tries the fast
2250
+ # path (skip livePlay waits + TURN relay pre-allocation) with a SHORT
2251
+ # timeout/grace so a non-LAN camera fails quickly; on no media we latch
2252
+ # _fast_failed and the remaining opens this loop use the full, patient
2253
+ # relay path. Makes fast-by-default safe regardless of camera reachability.
2254
+ _adaptive = self._resolve_sdes_adaptive()
2255
+ _FAST_OPEN_TIMEOUT = 45.0
2256
+ _FAST_GRACE = 40.0
2257
+ # Per-device cache: once a fast attempt has failed for this camera (e.g. a
2258
+ # strict-NAT / non-LAN camera that genuinely needs the relay), remember it
2259
+ # so later views skip the fast attempt entirely instead of re-paying the
2260
+ # ~40s fast timeout on every fresh keepalive loop. Latches for the client's
2261
+ # lifetime; an integration reload / restart re-probes the fast path.
2262
+ _fast_failed = bool(getattr(self, "_fast_path_unavailable", False))
2263
+
2221
2264
  while self._streaming_active:
2222
2265
  if self._serve_relay is not None:
2223
2266
  # Clear any stale backend from a prior session; the open below
2224
2267
  # points the relay at this session's fresh internal ffmpeg port.
2225
2268
  self._serve_relay.set_backend(None)
2269
+ _use_fast = self._adaptive_next_fast(_adaptive, _fast_failed)
2270
+ if _adaptive:
2271
+ self._fast_attempt_override = _use_fast
2226
2272
  try:
2227
2273
  session = await self.async_open_webrtc_stream(
2228
2274
  rtsp_push_url=self._keepalive_rtsp_url,
2229
- timeout=120.0,
2275
+ timeout=(_FAST_OPEN_TIMEOUT if _use_fast else 120.0),
2230
2276
  )
2231
2277
  except asyncio.CancelledError:
2278
+ self._fast_attempt_override = None
2232
2279
  return
2233
2280
  except Exception as exc:
2281
+ self._fast_attempt_override = None
2282
+ if _use_fast:
2283
+ _fast_failed = self._adaptive_after_attempt(True, False, _fast_failed)
2284
+ self._fast_path_unavailable = True # cache across views
2285
+ _LOGGER.info(
2286
+ "SDES adaptive[%s]: fast open failed (%.0fs) - "
2287
+ "falling back to full relay path", self.device_id,
2288
+ _FAST_OPEN_TIMEOUT)
2234
2289
  _delay = _pacer.fail_delay()
2235
2290
  _LOGGER.warning(
2236
2291
  "SDES keepalive: stream open failed for %s (retry in %.0fs): %s",
@@ -2242,6 +2297,7 @@ class CameraMixin(_CameraControlsMixin):
2242
2297
  return
2243
2298
  continue
2244
2299
 
2300
+ self._fast_attempt_override = None
2245
2301
  self._stream_session = session
2246
2302
  # Don't block solely on wait_done(): the SDES ffmpeg reads RTP over a
2247
2303
  # UDP socket with no input timeout, so when a battery camera tears the
@@ -2276,6 +2332,7 @@ class CameraMixin(_CameraControlsMixin):
2276
2332
  session.last_media_monotonic,
2277
2333
  _started_at,
2278
2334
  time.monotonic(),
2335
+ grace=(_FAST_GRACE if _use_fast else 60.0),
2279
2336
  ):
2280
2337
  _stalled = True
2281
2338
  break
@@ -2327,12 +2384,22 @@ class CameraMixin(_CameraControlsMixin):
2327
2384
  except Exception:
2328
2385
  _LOGGER.debug("camera %s: swallowed exception", '_sdes_keepalive_loop', exc_info=True)
2329
2386
 
2387
+ # Adaptive bookkeeping: a fast attempt that never delivered media
2388
+ # latches the loop onto the full relay path for its remaining opens.
2389
+ _healthy = session.last_media_monotonic > 0.0
2390
+ if _use_fast and not _healthy and not _fast_failed:
2391
+ _LOGGER.info(
2392
+ "SDES adaptive[%s]: fast attempt delivered no media - "
2393
+ "falling back to full relay path", self.device_id)
2394
+ _fast_failed = self._adaptive_after_attempt(_use_fast, _healthy, _fast_failed)
2395
+ if _use_fast and not _healthy:
2396
+ self._fast_path_unavailable = True # cache across views
2397
+
2330
2398
  if self._streaming_active:
2331
2399
  # Escalate backoff only when the session never delivered media
2332
2400
  # (camera refused / degraded on a rapid reconnect); a session
2333
2401
  # that streamed fine and then ended (battery teardown, consumer
2334
2402
  # gone) is a normal lifecycle event and resets to the base interval.
2335
- _healthy = session.last_media_monotonic > 0.0
2336
2403
  try:
2337
2404
  await asyncio.sleep(_pacer.session_end_delay(healthy=_healthy))
2338
2405
  except asyncio.CancelledError:
@@ -2556,6 +2623,9 @@ class CameraMixin(_CameraControlsMixin):
2556
2623
  opt = getattr(self, "_sdes_fast_liveplay_opt", None)
2557
2624
  if opt is not None:
2558
2625
  return bool(opt)
2626
+ ov = getattr(self, "_fast_attempt_override", None)
2627
+ if ov is not None:
2628
+ return bool(ov)
2559
2629
  return os.environ.get("AIDOT_SDES_FAST_LIVEPLAY", "").strip().lower() in (
2560
2630
  "1", "true", "yes", "on")
2561
2631
 
@@ -2580,9 +2650,90 @@ class CameraMixin(_CameraControlsMixin):
2580
2650
  opt = getattr(self, "_sdes_skip_turn_opt", None)
2581
2651
  if opt is not None:
2582
2652
  return bool(opt)
2653
+ ov = getattr(self, "_fast_attempt_override", None)
2654
+ if ov is not None:
2655
+ return bool(ov)
2583
2656
  return os.environ.get("AIDOT_SDES_SKIP_TURN_PREALLOC", "").strip().lower() in (
2584
2657
  "1", "true", "yes", "on")
2585
2658
 
2659
+ def _resolve_sdes_adaptive(self) -> bool:
2660
+ """Whether the SDES keepalive loop drives the fast path adaptively
2661
+ (opt-in, default OFF): try fast-first (skip the livePlay waits + TURN
2662
+ relay pre-allocation, with a short timeout), and fall back to the full,
2663
+ patient relay path if the fast attempt delivers no media.
2664
+
2665
+ This makes "fast by default" safe for cameras of unknown reachability:
2666
+ a LAN-direct camera gets the fast connect; a remote / strict-NAT camera
2667
+ loses one short fast attempt, then connects via the full relay path.
2668
+
2669
+ Default OFF pending real-world fast-failure-rate data: a fast *failure*
2670
+ costs ~40 s (the grace) before fallback while success saves only ~7 s, so
2671
+ until the failure rate is known on real fleets this stays opt-in.
2672
+
2673
+ Per-camera ``sdes_adaptive`` (via start_keepalive) wins; else the
2674
+ ``AIDOT_SDES_ADAPTIVE`` env (truthy = 1/true/yes/on), default off. When
2675
+ off, the per-attempt override is never set, so the explicit
2676
+ ``sdes_fast_liveplay`` / ``sdes_skip_turn`` opts (or their envs) decide -
2677
+ exactly the pre-adaptive behaviour."""
2678
+ opt = getattr(self, "_sdes_adaptive_opt", None)
2679
+ if opt is not None:
2680
+ return bool(opt)
2681
+ return os.environ.get("AIDOT_SDES_ADAPTIVE", "").strip().lower() in (
2682
+ "1", "true", "yes", "on")
2683
+
2684
+ def _resolve_persistent_mqtt(self) -> bool:
2685
+ """Whether commands + attribute fetches reuse ONE account-level persistent
2686
+ MQTT connection (matching the app's LDSBaseMqttServiceImpl) instead of
2687
+ connecting per op. Opt-in (``AIDOT_PERSISTENT_MQTT`` env / per-camera
2688
+ ``_persistent_mqtt_opt``), default off while it's validated. Phase 1: the
2689
+ stream-open path is unchanged (it uses session client_ids)."""
2690
+ opt = getattr(self, "_persistent_mqtt_opt", None)
2691
+ if opt is not None:
2692
+ return bool(opt)
2693
+ return os.environ.get("AIDOT_PERSISTENT_MQTT", "").strip().lower() in (
2694
+ "1", "true", "yes", "on")
2695
+
2696
+ async def _get_persistent_mqtt(self):
2697
+ """Get-or-create the account-shared ``_PersistentMqtt`` (one per account,
2698
+ keyed on the shared ``login_info``). The broker binds auth to the single
2699
+ authorized client_id, so there must be exactly one. Returns None if it
2700
+ can't be built (caller falls back to per-op ``_mqtt_session``)."""
2701
+ # _user_info is the shared account dict (same object across the account's
2702
+ # DeviceClients, and the same object as AidotClient.login_info) - the right
2703
+ # place to cache one connection per account.
2704
+ li = self._user_info if isinstance(getattr(self, "_user_info", None), dict) else None
2705
+ if li is None:
2706
+ return None
2707
+ pm = li.get("_persistent_mqtt")
2708
+ if pm is not None:
2709
+ return pm
2710
+ smarthome_auth = await self._async_get_smarthome_auth()
2711
+ mqtt_user = (smarthome_auth or {}).get("mqttUser") or str(self.user_id)
2712
+ mqtt_pwd = (smarthome_auth or {}).get("mqttPassword") or ""
2713
+ client_id = (self._user_info.get("mqttClientId") or f"app-{mqtt_user}")
2714
+ mqtt_url = await self._async_get_mqtt_url()
2715
+ if not mqtt_url:
2716
+ return None
2717
+ from .protocol import _PersistentMqtt
2718
+ pm = li.get("_persistent_mqtt") # re-check under the brief await gap
2719
+ if pm is None:
2720
+ pm = _PersistentMqtt(mqtt_url, mqtt_user, mqtt_pwd, client_id)
2721
+ li["_persistent_mqtt"] = pm
2722
+ return pm
2723
+
2724
+ @staticmethod
2725
+ def _adaptive_next_fast(adaptive: bool, fast_failed: bool) -> bool:
2726
+ """Whether the next SDES open attempt should use the fast path: only when
2727
+ adaptive mode is on and the fast path has not already failed this loop."""
2728
+ return bool(adaptive) and not bool(fast_failed)
2729
+
2730
+ @staticmethod
2731
+ def _adaptive_after_attempt(use_fast: bool, healthy: bool, fast_failed: bool) -> bool:
2732
+ """Updated ``fast_failed`` after an attempt: latch it once a fast attempt
2733
+ delivers no media, so the loop stays on the full relay path (no
2734
+ oscillation) until it restarts fresh on the next view."""
2735
+ return bool(fast_failed) or (bool(use_fast) and not bool(healthy))
2736
+
2586
2737
  def _maybe_start_serve_relay(self, serve_url: Optional[str]) -> "Optional[_ServeRelay]":
2587
2738
  """Hold the public serve port via a _ServeRelay so an eager go2rtc pull
2588
2739
  connects-and-waits instead of hitting ECONNREFUSED during the ~16-25s
@@ -1245,6 +1245,186 @@ def _mqtt_session_sync(
1245
1245
  return collected, status
1246
1246
 
1247
1247
 
1248
+ class _PersistentMqtt:
1249
+ """One long-lived paho MQTT connection reused across many publish/collect
1250
+ round-trips - matching the official app's single persistent connection
1251
+ (LDSBaseMqttServiceImpl) instead of our historical connect-per-op.
1252
+
1253
+ Thread-safe: the paho network loop runs in its own background thread and
1254
+ auto-reconnects; tracked subscriptions are replayed on every (re)connect.
1255
+ ``request()`` registers a transient collector, publishes, and gathers
1256
+ matching messages for ``timeout`` WITHOUT tearing the connection down, so
1257
+ N operations cost ONE connect instead of N. The client_id is account-level,
1258
+ so exactly one of these should exist per account (per AidotClient)."""
1259
+
1260
+ def __init__(self, mqtt_url, mqtt_user, mqtt_pwd, client_id, ws_path="/mqtt"):
1261
+ self._url = mqtt_url
1262
+ self._user = mqtt_user
1263
+ self._pwd = mqtt_pwd
1264
+ self._cid = client_id
1265
+ self._ws_path = ws_path
1266
+ self._client = None
1267
+ self._host = None
1268
+ self._port = None
1269
+ self._connected = threading.Event()
1270
+ self._lock = threading.Lock()
1271
+ self._subs = set() # topics to (re)subscribe on connect
1272
+ self._collectors = [] # transient queues, each receives every msg
1273
+ self._started = False
1274
+ self.connects = 0 # observability: how many times we connected
1275
+
1276
+ def _build(self):
1277
+ import paho.mqtt.client as _paho
1278
+ import ssl as _ssl
1279
+ from urllib.parse import urlparse
1280
+ parsed = urlparse(self._url)
1281
+ self._host = parsed.hostname or self._url
1282
+ self._port = parsed.port or (8443 if parsed.scheme in ("wss", "https") else 1883)
1283
+ tls = parsed.scheme in ("wss", "https", "mqtts")
1284
+ path = self._ws_path if self._ws_path is not None else (parsed.path or "/mqtt")
1285
+ if path == "":
1286
+ path = "/"
1287
+ try:
1288
+ c = _paho.Client(callback_api_version=_paho.CallbackAPIVersion.VERSION2,
1289
+ client_id=self._cid, transport="websockets")
1290
+ except AttributeError:
1291
+ c = _paho.Client(client_id=self._cid, transport="websockets")
1292
+ c.ws_set_options(path=path)
1293
+ if self._user:
1294
+ c.username_pw_set(self._user, self._pwd or "")
1295
+ if tls:
1296
+ c.tls_set_context(_ssl.create_default_context())
1297
+ try:
1298
+ c.reconnect_delay_set(min_delay=1, max_delay=30)
1299
+ except Exception:
1300
+ pass
1301
+ c.on_connect = self._on_connect
1302
+ c.on_disconnect = self._on_disconnect
1303
+ c.on_message = self._on_message
1304
+ return c
1305
+
1306
+ def _on_connect(self, c, ud, flags, reason_code, props=None):
1307
+ try:
1308
+ rc = int(reason_code)
1309
+ except (TypeError, ValueError):
1310
+ rc = getattr(reason_code, "value", -1)
1311
+ if rc == 0:
1312
+ self.connects += 1
1313
+ with self._lock:
1314
+ subs = list(self._subs)
1315
+ for t in subs: # replay subscriptions after (re)connect
1316
+ try:
1317
+ c.subscribe(t)
1318
+ except Exception:
1319
+ _LOGGER.debug("persistent mqtt: resubscribe %s failed", t)
1320
+ self._connected.set()
1321
+ else:
1322
+ _LOGGER.warning("persistent mqtt: broker refused rc=%s", rc)
1323
+
1324
+ def _on_disconnect(self, c, ud, *args, **kwargs):
1325
+ self._connected.clear() # paho loop auto-reconnects; subs replay on_connect
1326
+
1327
+ def _on_message(self, c, ud, msg):
1328
+ payload = (msg.payload.decode("utf-8", errors="replace")
1329
+ if isinstance(msg.payload, (bytes, bytearray)) else str(msg.payload))
1330
+ item = (msg.topic, payload)
1331
+ with self._lock:
1332
+ cols = list(self._collectors)
1333
+ for q in cols:
1334
+ q.put(item)
1335
+
1336
+ def _ensure_started_sync(self, timeout=15.0):
1337
+ with self._lock:
1338
+ if self._client is None:
1339
+ self._client = self._build()
1340
+ try:
1341
+ self._client.connect(self._host, self._port, keepalive=60)
1342
+ except Exception as exc:
1343
+ _LOGGER.warning("persistent mqtt: connect() raised: %s", exc)
1344
+ self._client = None
1345
+ return False
1346
+ self._client.loop_start() # drives keepalive + auto-reconnect
1347
+ self._started = True
1348
+ return self._connected.wait(timeout)
1349
+
1350
+ def _subscribe_sync(self, topics):
1351
+ new = []
1352
+ with self._lock:
1353
+ for t in topics:
1354
+ if t not in self._subs:
1355
+ self._subs.add(t)
1356
+ new.append(t)
1357
+ c = self._client
1358
+ if c is not None and self._connected.is_set():
1359
+ for t in new:
1360
+ try:
1361
+ c.subscribe(t)
1362
+ except Exception:
1363
+ _LOGGER.debug("persistent mqtt: subscribe %s failed", t)
1364
+
1365
+ def _request_sync(self, publish_items, subscribe_topics, match, timeout):
1366
+ import queue as _queue
1367
+ import time as _time
1368
+ if not self._ensure_started_sync():
1369
+ return [], {"error": "persistent mqtt connect timeout"}
1370
+ self._subscribe_sync(subscribe_topics or [])
1371
+ q = _queue.Queue()
1372
+ with self._lock:
1373
+ self._collectors.append(q)
1374
+ collected = []
1375
+ try:
1376
+ for pt, pp in (publish_items or []):
1377
+ self._client.publish(pt, pp)
1378
+ deadline = _time.monotonic() + timeout
1379
+ while True:
1380
+ remaining = deadline - _time.monotonic()
1381
+ if remaining <= 0:
1382
+ break
1383
+ try:
1384
+ item = q.get(timeout=min(remaining, 0.1))
1385
+ except _queue.Empty:
1386
+ continue
1387
+ if match is None or match(item[0], item[1]):
1388
+ collected.append(item)
1389
+ finally:
1390
+ with self._lock:
1391
+ try:
1392
+ self._collectors.remove(q)
1393
+ except ValueError:
1394
+ pass
1395
+ return collected, {"error": None}
1396
+
1397
+ async def request(self, publish_items, subscribe_topics=None, match=None, timeout=5.0):
1398
+ """Publish ``publish_items`` and collect matching messages for ``timeout``
1399
+ on the shared persistent connection (one connect for the account, reused).
1400
+ Returns (messages, status)."""
1401
+ import functools
1402
+ loop = asyncio.get_running_loop()
1403
+ return await loop.run_in_executor(None, functools.partial(
1404
+ self._request_sync, publish_items, subscribe_topics, match, timeout))
1405
+
1406
+ async def ensure_connected(self, timeout=15.0):
1407
+ import functools
1408
+ loop = asyncio.get_running_loop()
1409
+ return await loop.run_in_executor(
1410
+ None, functools.partial(self._ensure_started_sync, timeout))
1411
+
1412
+ def close(self):
1413
+ c = self._client
1414
+ self._client = None
1415
+ self._started = False
1416
+ self._connected.clear()
1417
+ if c is not None:
1418
+ try:
1419
+ c.loop_stop()
1420
+ except Exception:
1421
+ pass
1422
+ try:
1423
+ c.disconnect()
1424
+ except Exception:
1425
+ pass
1426
+
1427
+
1248
1428
  async def _mqtt_session(
1249
1429
  mqtt_url: str,
1250
1430
  mqtt_user: str,
@@ -529,6 +529,15 @@ class AidotClient:
529
529
  for client in self._device_clients.values():
530
530
  await client.close()
531
531
  self._device_clients.clear()
532
+ # Close the account-shared persistent MQTT connection, if one was opened
533
+ # (AIDOT_PERSISTENT_MQTT). Stored on the shared login_info by the camera
534
+ # command/attr paths; closing here stops its background paho loop.
535
+ pm = self.login_info.pop("_persistent_mqtt", None) if isinstance(self.login_info, dict) else None
536
+ if pm is not None:
537
+ try:
538
+ pm.close()
539
+ except Exception:
540
+ _LOGGER.debug("aidot: persistent mqtt close failed", exc_info=True)
532
541
 
533
542
  def cleanup(self) -> None:
534
543
  """Sync entry point: fire-and-forget async_close()."""
@@ -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.28
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,8 @@ 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_PERSISTENT_MQTT` | Reuse ONE account-level persistent MQTT connection for device commands and attribute fetches (matching the official app) instead of connecting per operation, cutting cloud connect churn. Truthy value enables. The stream-open path is unaffected. | unset (off) |
126
+ | `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
127
  | `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
128
  | `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
129
  | `AIDOT_MAX_CONCURRENT_OPENS` | Caps how many stream opens run concurrently. | `2` |
@@ -42,7 +42,9 @@ tests/test_lan_control.py
42
42
  tests/test_live_stream_param.py
43
43
  tests/test_motion_poll.py
44
44
  tests/test_no_undefined_names.py
45
+ tests/test_persistent_mqtt.py
45
46
  tests/test_post_merge_hardening.py
47
+ tests/test_sdes_adaptive.py
46
48
  tests/test_sdes_fast_liveplay.py
47
49
  tests/test_sdes_idle_release.py
48
50
  tests/test_sdes_sprop.py
@@ -0,0 +1,68 @@
1
+ """Unit tests for the persistent-MQTT flag wiring (Phase 1).
2
+
3
+ Locks the opt/env precedence of ``_resolve_persistent_mqtt`` (per-camera kwarg
4
+ wins over AIDOT_PERSISTENT_MQTT env, default off) and the basic reuse semantics
5
+ of the ``_PersistentMqtt`` collector routing. The live connection behaviour is
6
+ validated on hardware, not here.
7
+ """
8
+ import os
9
+ import sys
10
+
11
+ sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"))
12
+
13
+ import aidot.camera.client as cc
14
+ from aidot.camera.protocol import _PersistentMqtt
15
+
16
+ _CAM = next(v for v in vars(cc).values()
17
+ if isinstance(v, type) and "_resolve_persistent_mqtt" 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_PERSISTENT_MQTT", raising=False)
26
+ assert _cam()._resolve_persistent_mqtt() is False
27
+
28
+
29
+ def test_env_enables(monkeypatch):
30
+ for val in ("1", "true", "TRUE", "yes", "on", " On "):
31
+ monkeypatch.setenv("AIDOT_PERSISTENT_MQTT", val)
32
+ assert _cam()._resolve_persistent_mqtt() is True, val
33
+
34
+
35
+ def test_env_falsey_stays_off(monkeypatch):
36
+ for val in ("0", "false", "no", "off", "", "anything"):
37
+ monkeypatch.setenv("AIDOT_PERSISTENT_MQTT", val)
38
+ assert _cam()._resolve_persistent_mqtt() is False, val
39
+
40
+
41
+ def test_kwarg_wins_over_env(monkeypatch):
42
+ monkeypatch.setenv("AIDOT_PERSISTENT_MQTT", "1")
43
+ cam = _cam()
44
+ cam._persistent_mqtt_opt = False
45
+ assert cam._resolve_persistent_mqtt() is False
46
+
47
+
48
+ def test_collector_routing_fans_out_messages():
49
+ # _on_message must fan every message out to every registered collector queue,
50
+ # so concurrent request()s each see the traffic (matching is per-collector).
51
+ import queue
52
+ pm = _PersistentMqtt("wss://h:8443/mqtt", "u", "p", "cid")
53
+ q1, q2 = queue.Queue(), queue.Queue()
54
+ pm._collectors = [q1, q2]
55
+
56
+ class _Msg:
57
+ topic = "iot/v1/cb/dev/x"
58
+ payload = b'{"k":1}'
59
+ pm._on_message(None, None, _Msg())
60
+ assert q1.get_nowait() == ("iot/v1/cb/dev/x", '{"k":1}')
61
+ assert q2.get_nowait() == ("iot/v1/cb/dev/x", '{"k":1}')
62
+
63
+
64
+ def test_subscriptions_tracked_for_replay():
65
+ # subscribe topics must be remembered so they can be replayed on reconnect
66
+ pm = _PersistentMqtt("wss://h:8443/mqtt", "u", "p", "cid")
67
+ pm._subscribe_sync(["a/#", "b/#", "a/#"]) # no client yet -> just tracked
68
+ assert pm._subs == {"a/#", "b/#"}
@@ -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