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.
- {python_aidot_cameras-0.7.26/src/python_aidot_cameras.egg-info → python_aidot_cameras-0.7.27}/PKG-INFO +2 -1
- {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/README.md +1 -0
- {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/pyproject.toml +1 -1
- {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/aidot/camera/client.py +91 -2
- {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27/src/python_aidot_cameras.egg-info}/PKG-INFO +2 -1
- {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/python_aidot_cameras.egg-info/SOURCES.txt +1 -0
- python_aidot_cameras-0.7.27/tests/test_sdes_adaptive.py +126 -0
- {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/LICENSE +0 -0
- {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/MANIFEST.in +0 -0
- {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/setup.cfg +0 -0
- {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/aidot/__init__.py +0 -0
- {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/aidot/aes_utils.py +0 -0
- {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/aidot/camera/__init__.py +0 -0
- {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/aidot/camera/constants.py +0 -0
- {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/aidot/camera/controls.py +0 -0
- {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/aidot/camera/go2rtc.py +0 -0
- {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/aidot/camera/lan_control.py +0 -0
- {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/aidot/camera/models.py +0 -0
- {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/aidot/camera/playback.py +0 -0
- {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/aidot/camera/protocol.py +0 -0
- {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/aidot/camera/sdes.py +0 -0
- {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/aidot/camera/tutk.py +0 -0
- {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/aidot/camera/webrtc.py +0 -0
- {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/aidot/client.py +0 -0
- {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/aidot/const.py +0 -0
- {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/aidot/credentials.py +0 -0
- {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/aidot/device_client.py +0 -0
- {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/aidot/discover.py +0 -0
- {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/aidot/exceptions.py +0 -0
- {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/aidot/g711.py +0 -0
- {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/aidot/login_const.py +0 -0
- {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/aidot/models/__init__.py +0 -0
- {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/aidot/models/device_client_model.py +0 -0
- {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/aidot/models/device_model.py +0 -0
- {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/aidot/models/discover_model.py +0 -0
- {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/aidot/py.typed +0 -0
- {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
- {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/python_aidot_cameras.egg-info/requires.txt +0 -0
- {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/python_aidot_cameras.egg-info/top_level.txt +0 -0
- {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/tests/test_alarm_event.py +0 -0
- {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/tests/test_backoff.py +0 -0
- {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/tests/test_go2rtc.py +0 -0
- {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/tests/test_highport_nomination.py +0 -0
- {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/tests/test_lan_control.py +0 -0
- {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/tests/test_live_stream_param.py +0 -0
- {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/tests/test_motion_poll.py +0 -0
- {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/tests/test_no_undefined_names.py +0 -0
- {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/tests/test_post_merge_hardening.py +0 -0
- {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/tests/test_sdes_fast_liveplay.py +0 -0
- {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/tests/test_sdes_idle_release.py +0 -0
- {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/tests/test_sdes_sprop.py +0 -0
- {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/tests/test_sdes_talk.py +0 -0
- {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/tests/test_sdes_watchdog.py +0 -0
- {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/tests/test_serve_relay.py +0 -0
- {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/tests/test_speak.py +0 -0
- {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/tests/test_stream_cap.py +0 -0
- {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/tests/test_stream_idle.py +0 -0
- {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/tests/test_talk.py +0 -0
- {python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/tests/test_terminal_ack.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/aidot/models/device_client_model.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/aidot/models/device_model.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/src/aidot/models/discover_model.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/tests/test_highport_nomination.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/tests/test_no_undefined_names.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/tests/test_post_merge_hardening.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.7.26 → python_aidot_cameras-0.7.27}/tests/test_sdes_fast_liveplay.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|