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