python-aidot-cameras 0.7.13__tar.gz → 0.7.15__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.13/src/python_aidot_cameras.egg-info → python_aidot_cameras-0.7.15}/PKG-INFO +1 -1
- {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/pyproject.toml +1 -1
- {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/aidot/camera/client.py +116 -18
- {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/aidot/camera/protocol.py +33 -0
- {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15/src/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
- {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/python_aidot_cameras.egg-info/SOURCES.txt +2 -0
- python_aidot_cameras-0.7.15/tests/test_backoff.py +84 -0
- python_aidot_cameras-0.7.15/tests/test_live_stream_param.py +75 -0
- {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/LICENSE +0 -0
- {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/MANIFEST.in +0 -0
- {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/README.md +0 -0
- {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/setup.cfg +0 -0
- {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/aidot/__init__.py +0 -0
- {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/aidot/aes_utils.py +0 -0
- {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/aidot/camera/__init__.py +0 -0
- {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/aidot/camera/constants.py +0 -0
- {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/aidot/camera/controls.py +0 -0
- {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/aidot/camera/go2rtc.py +0 -0
- {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/aidot/camera/lan_control.py +0 -0
- {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/aidot/camera/models.py +0 -0
- {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/aidot/camera/playback.py +0 -0
- {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/aidot/camera/sdes.py +0 -0
- {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/aidot/camera/tutk.py +0 -0
- {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/aidot/camera/webrtc.py +0 -0
- {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/aidot/client.py +0 -0
- {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/aidot/const.py +0 -0
- {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/aidot/credentials.py +0 -0
- {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/aidot/device_client.py +0 -0
- {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/aidot/discover.py +0 -0
- {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/aidot/exceptions.py +0 -0
- {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/aidot/g711.py +0 -0
- {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/aidot/login_const.py +0 -0
- {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/aidot/models/__init__.py +0 -0
- {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/aidot/models/device_client_model.py +0 -0
- {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/aidot/models/device_model.py +0 -0
- {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/aidot/models/discover_model.py +0 -0
- {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/aidot/py.typed +0 -0
- {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
- {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/python_aidot_cameras.egg-info/requires.txt +0 -0
- {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/python_aidot_cameras.egg-info/top_level.txt +0 -0
- {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/tests/test_alarm_event.py +0 -0
- {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/tests/test_go2rtc.py +0 -0
- {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/tests/test_highport_nomination.py +0 -0
- {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/tests/test_lan_control.py +0 -0
- {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/tests/test_motion_poll.py +0 -0
- {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/tests/test_no_undefined_names.py +0 -0
- {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/tests/test_post_merge_hardening.py +0 -0
- {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/tests/test_sdes_idle_release.py +0 -0
- {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/tests/test_sdes_sprop.py +0 -0
- {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/tests/test_sdes_talk.py +0 -0
- {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/tests/test_sdes_watchdog.py +0 -0
- {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/tests/test_speak.py +0 -0
- {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/tests/test_stream_cap.py +0 -0
- {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/tests/test_talk.py +0 -0
- {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/tests/test_terminal_ack.py +0 -0
- {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/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.15
|
|
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
|
|
@@ -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.15"
|
|
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"
|
|
@@ -39,6 +39,7 @@ from .sdes import SdesSession # re-exported (split into sdes.py)
|
|
|
39
39
|
from .controls import _CameraControlsMixin
|
|
40
40
|
from .protocol import ( # split into protocol.py; re-imported for use + back-compat
|
|
41
41
|
_mqtt_timestamp,
|
|
42
|
+
next_backoff,
|
|
42
43
|
_build_stun_binding_success_response,
|
|
43
44
|
_terminal_webrtc_ack,
|
|
44
45
|
_install_highport_nomination_patch,
|
|
@@ -677,6 +678,60 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
677
678
|
_LOGGER.debug("http wake failed for %s: %s", self.device_id, exc)
|
|
678
679
|
return False
|
|
679
680
|
|
|
681
|
+
def _live_stream_param_request(self):
|
|
682
|
+
"""Build ``(url, headers, body)`` for the liveStreamParam provision call.
|
|
683
|
+
|
|
684
|
+
Pure/deterministic - unit-tested in tests/test_live_stream_param.py. The
|
|
685
|
+
body MUST be a JSON array of device ids (object body -> HTTP 500) and the
|
|
686
|
+
``owner`` header is required (as on the wake endpoint).
|
|
687
|
+
"""
|
|
688
|
+
headers = self._leedarson_headers()
|
|
689
|
+
headers["owner"] = (
|
|
690
|
+
self._user_info.get("owner")
|
|
691
|
+
or self._user_info.get("id")
|
|
692
|
+
or self._user_info.get("userId")
|
|
693
|
+
or str(self.user_id)
|
|
694
|
+
)
|
|
695
|
+
url = f"{self._smarthome_base}/api/ipc/liveStream/liveStreamParam"
|
|
696
|
+
return url, headers, json.dumps([self.device_id])
|
|
697
|
+
|
|
698
|
+
async def _async_fetch_live_stream_param(self) -> bool:
|
|
699
|
+
"""Provision the camera's live-stream session via the cloud (app parity).
|
|
700
|
+
|
|
701
|
+
Battery cameras waking from deep sleep reject the MQTT ``livePlayReq``
|
|
702
|
+
with code ``-50019`` ("not ready") and then never run ICE - so no media
|
|
703
|
+
ever flows - unless the app's pre-connect HTTP call has armed the session
|
|
704
|
+
first. The official app (``KVSPreConnectStrategy.fetchKvsParams``) POSTs
|
|
705
|
+
the device id to ``/api/ipc/liveStream/liveStreamParam``; the cloud
|
|
706
|
+
provisions the session (and brings the camera online) and returns AWS KVS
|
|
707
|
+
credentials. We only need the provisioning side effect - the library
|
|
708
|
+
streams over the proprietary MQTT/SDES path, not AWS KVS - so the returned
|
|
709
|
+
credentials are ignored.
|
|
710
|
+
|
|
711
|
+
The request body is a JSON **array** of device ids (an object body returns
|
|
712
|
+
HTTP 500) and needs the ``owner`` header (like the wake endpoint).
|
|
713
|
+
Best-effort: returns True on HTTP 200, never raises.
|
|
714
|
+
"""
|
|
715
|
+
import aiohttp
|
|
716
|
+
|
|
717
|
+
url, headers, body = self._live_stream_param_request()
|
|
718
|
+
try:
|
|
719
|
+
async with aiohttp.ClientSession() as session:
|
|
720
|
+
async with session.post(
|
|
721
|
+
url, headers=headers, data=body,
|
|
722
|
+
timeout=aiohttp.ClientTimeout(total=10),
|
|
723
|
+
) as resp:
|
|
724
|
+
status = resp.status
|
|
725
|
+
data = await resp.json(content_type=None)
|
|
726
|
+
code = data.get("code") if isinstance(data, dict) else None
|
|
727
|
+
ok = status == 200 and (code in (None, 0, 200, "0", "200"))
|
|
728
|
+
_LOGGER.debug("liveStreamParam %s: status=%s code=%s ok=%s",
|
|
729
|
+
self.device_id, status, code, ok)
|
|
730
|
+
return bool(ok)
|
|
731
|
+
except Exception as exc:
|
|
732
|
+
_LOGGER.debug("liveStreamParam failed for %s: %s", self.device_id, exc)
|
|
733
|
+
return False
|
|
734
|
+
|
|
680
735
|
async def async_wake_camera(self, retries: int = 2) -> bool:
|
|
681
736
|
"""Wake a battery camera on demand via the cloud HTTP low-power endpoint.
|
|
682
737
|
|
|
@@ -2101,7 +2156,11 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
2101
2156
|
"""Background task: keep SDES stream alive; push to go2rtc via RTSP."""
|
|
2102
2157
|
_MIN_DELAY = 10.0
|
|
2103
2158
|
_MAX_DELAY = 300.0
|
|
2104
|
-
|
|
2159
|
+
# Consecutive failed / no-media opens; drives jittered exponential
|
|
2160
|
+
# backoff so a degraded camera (or a fleet reconnecting at once) isn't
|
|
2161
|
+
# hammered into further degradation / cloud rate-limiting. Reset only
|
|
2162
|
+
# after a session that actually delivered media (see end of loop).
|
|
2163
|
+
_attempt = 0
|
|
2105
2164
|
|
|
2106
2165
|
while self._streaming_active:
|
|
2107
2166
|
try:
|
|
@@ -2112,18 +2171,18 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
2112
2171
|
except asyncio.CancelledError:
|
|
2113
2172
|
return
|
|
2114
2173
|
except Exception as exc:
|
|
2174
|
+
_delay = next_backoff(_attempt, base=_MIN_DELAY, cap=_MAX_DELAY)
|
|
2115
2175
|
_LOGGER.warning(
|
|
2116
2176
|
"SDES keepalive: stream open failed for %s (retry in %.0fs): %s",
|
|
2117
|
-
self.device_id,
|
|
2177
|
+
self.device_id, _delay, exc,
|
|
2118
2178
|
)
|
|
2119
2179
|
try:
|
|
2120
|
-
await asyncio.sleep(
|
|
2180
|
+
await asyncio.sleep(_delay)
|
|
2121
2181
|
except asyncio.CancelledError:
|
|
2122
2182
|
return
|
|
2123
|
-
|
|
2183
|
+
_attempt += 1
|
|
2124
2184
|
continue
|
|
2125
2185
|
|
|
2126
|
-
_retry_delay = _MIN_DELAY # reset on success
|
|
2127
2186
|
self._stream_session = session
|
|
2128
2187
|
# Don't block solely on wait_done(): the SDES ffmpeg reads RTP over a
|
|
2129
2188
|
# UDP socket with no input timeout, so when a battery camera tears the
|
|
@@ -2208,8 +2267,19 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
2208
2267
|
_LOGGER.debug("camera %s: swallowed exception", '_sdes_keepalive_loop', exc_info=True)
|
|
2209
2268
|
|
|
2210
2269
|
if self._streaming_active:
|
|
2270
|
+
# Escalate backoff only when the session never delivered media
|
|
2271
|
+
# (camera refused / degraded on a rapid reconnect); a session
|
|
2272
|
+
# that streamed fine and then ended (battery teardown, consumer
|
|
2273
|
+
# gone) is a normal lifecycle event and resets to the base
|
|
2274
|
+
# interval so the next view reconnects promptly.
|
|
2275
|
+
if session.last_media_monotonic > 0.0:
|
|
2276
|
+
_attempt = 0
|
|
2277
|
+
_delay = _MIN_DELAY
|
|
2278
|
+
else:
|
|
2279
|
+
_attempt += 1
|
|
2280
|
+
_delay = next_backoff(_attempt, base=_MIN_DELAY, cap=_MAX_DELAY)
|
|
2211
2281
|
try:
|
|
2212
|
-
await asyncio.sleep(
|
|
2282
|
+
await asyncio.sleep(_delay)
|
|
2213
2283
|
except asyncio.CancelledError:
|
|
2214
2284
|
return
|
|
2215
2285
|
|
|
@@ -2225,7 +2295,9 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
2225
2295
|
# to 15 s here (+ the SDES/serve loops) if strict app parity is wanted.
|
|
2226
2296
|
_MIN_DELAY = 5.0
|
|
2227
2297
|
_MAX_DELAY = 300.0
|
|
2228
|
-
|
|
2298
|
+
# Consecutive failed / frameless opens; drives jittered backoff (see
|
|
2299
|
+
# next_backoff). Reset after a session that produced frames.
|
|
2300
|
+
_attempt = 0
|
|
2229
2301
|
|
|
2230
2302
|
def _on_frame(frame) -> None:
|
|
2231
2303
|
# Accept keyframes always; accept P-frames only after first keyframe.
|
|
@@ -2248,6 +2320,7 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
2248
2320
|
_LOGGER.debug("Streaming encode failed for %s: %s", self.device_id, enc_exc)
|
|
2249
2321
|
|
|
2250
2322
|
while self._streaming_active:
|
|
2323
|
+
_open_time = asyncio.get_running_loop().time()
|
|
2251
2324
|
try:
|
|
2252
2325
|
session = await self.async_open_webrtc_stream(on_frame=_on_frame)
|
|
2253
2326
|
except asyncio.CancelledError:
|
|
@@ -2266,18 +2339,18 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
2266
2339
|
return
|
|
2267
2340
|
continue
|
|
2268
2341
|
except Exception as exc:
|
|
2342
|
+
_delay = next_backoff(_attempt, base=_MIN_DELAY, cap=_MAX_DELAY)
|
|
2269
2343
|
_LOGGER.warning(
|
|
2270
2344
|
"Stream open failed for %s (retry in %.0fs): %s",
|
|
2271
|
-
self.device_id,
|
|
2345
|
+
self.device_id, _delay, exc,
|
|
2272
2346
|
)
|
|
2273
2347
|
try:
|
|
2274
|
-
await asyncio.sleep(
|
|
2348
|
+
await asyncio.sleep(_delay)
|
|
2275
2349
|
except asyncio.CancelledError:
|
|
2276
2350
|
return
|
|
2277
|
-
|
|
2351
|
+
_attempt += 1
|
|
2278
2352
|
continue
|
|
2279
2353
|
|
|
2280
|
-
_retry_delay = _MIN_DELAY # reset on success
|
|
2281
2354
|
self._stream_session = session
|
|
2282
2355
|
try:
|
|
2283
2356
|
while self._streaming_active:
|
|
@@ -2302,8 +2375,17 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
2302
2375
|
_LOGGER.debug("camera %s: swallowed exception", '_on_frame', exc_info=True)
|
|
2303
2376
|
|
|
2304
2377
|
if self._streaming_active:
|
|
2378
|
+
# Reset backoff if this session produced frames (a normal drop
|
|
2379
|
+
# after good streaming); escalate with jitter if it never did
|
|
2380
|
+
# (the camera-degradation case).
|
|
2381
|
+
if self._last_frame_time > _open_time:
|
|
2382
|
+
_attempt = 0
|
|
2383
|
+
_delay = _MIN_DELAY
|
|
2384
|
+
else:
|
|
2385
|
+
_attempt += 1
|
|
2386
|
+
_delay = next_backoff(_attempt, base=_MIN_DELAY, cap=_MAX_DELAY)
|
|
2305
2387
|
try:
|
|
2306
|
-
await asyncio.sleep(
|
|
2388
|
+
await asyncio.sleep(_delay)
|
|
2307
2389
|
except asyncio.CancelledError:
|
|
2308
2390
|
return
|
|
2309
2391
|
|
|
@@ -2419,7 +2501,7 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
2419
2501
|
# attempts so no code path (reset bug, fast PC-death) can pound the camera.
|
|
2420
2502
|
_MIN_DELAY, _MAX_DELAY = 15.0, 300.0
|
|
2421
2503
|
_open_gate = float(os.environ.get("AIDOT_DTLS_RETRY_GATE_S", "15"))
|
|
2422
|
-
|
|
2504
|
+
_attempt = 0 # consecutive failed opens; drives jittered backoff
|
|
2423
2505
|
_last_open_at = 0.0 # monotonic of the previous open attempt (0 = none yet)
|
|
2424
2506
|
loop = asyncio.get_running_loop()
|
|
2425
2507
|
while self._streaming_active:
|
|
@@ -2428,7 +2510,7 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
2428
2510
|
aq: "_queue.Queue" = _queue.Queue(maxsize=600)
|
|
2429
2511
|
self._serve_ready.clear() # fresh (cold) session: not ready until bound
|
|
2430
2512
|
# Hard inter-attempt gate (APK parity): never start an open within
|
|
2431
|
-
# _open_gate seconds of the previous one, regardless of
|
|
2513
|
+
# _open_gate seconds of the previous one, regardless of the backoff.
|
|
2432
2514
|
_since_open = loop.time() - _last_open_at
|
|
2433
2515
|
if _last_open_at and _since_open < _open_gate:
|
|
2434
2516
|
try:
|
|
@@ -2455,18 +2537,19 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
2455
2537
|
return
|
|
2456
2538
|
continue
|
|
2457
2539
|
except Exception as exc:
|
|
2540
|
+
_delay = next_backoff(_attempt, base=_MIN_DELAY, cap=_MAX_DELAY)
|
|
2458
2541
|
_LOGGER.warning(
|
|
2459
2542
|
"DTLS serve: open failed for %s (retry %.0fs): %s",
|
|
2460
|
-
self.device_id,
|
|
2543
|
+
self.device_id, _delay, exc,
|
|
2461
2544
|
)
|
|
2462
2545
|
try:
|
|
2463
|
-
await asyncio.sleep(
|
|
2546
|
+
await asyncio.sleep(_delay)
|
|
2464
2547
|
except asyncio.CancelledError:
|
|
2465
2548
|
return
|
|
2466
|
-
|
|
2549
|
+
_attempt += 1
|
|
2467
2550
|
continue
|
|
2468
2551
|
|
|
2469
|
-
|
|
2552
|
+
_attempt = 0
|
|
2470
2553
|
self._stream_session = session
|
|
2471
2554
|
pc = session._pc
|
|
2472
2555
|
# PC-dead test for the serve loop. closed/failed are terminal at
|
|
@@ -3079,6 +3162,21 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
3079
3162
|
|
|
3080
3163
|
use_sdes = force_sdes if force_sdes is not None else self.is_sdes_camera
|
|
3081
3164
|
|
|
3165
|
+
# Battery cameras must have their live-stream session provisioned by the
|
|
3166
|
+
# cloud BEFORE MQTT signaling, or the camera rejects livePlayReq with
|
|
3167
|
+
# -50019 ("not ready") and never runs ICE -> no media. App parity:
|
|
3168
|
+
# KVSPreConnectStrategy.fetchKvsParams calls liveStreamParam first. We
|
|
3169
|
+
# only need the provisioning side effect (we stream over MQTT/SDES, not
|
|
3170
|
+
# AWS KVS). Mains cameras are always stream-ready, so skip them. Disable
|
|
3171
|
+
# via AIDOT_LIVESTREAM_PARAM=0. Validated live on L2_170 (A001513): with
|
|
3172
|
+
# this call livePlay succeeds and decrypted RTP flows; without it,
|
|
3173
|
+
# persistent -50019.
|
|
3174
|
+
if (self.is_battery_camera
|
|
3175
|
+
and os.environ.get("AIDOT_LIVESTREAM_PARAM", "1") != "0"):
|
|
3176
|
+
_lsp_ok = await self._async_fetch_live_stream_param()
|
|
3177
|
+
_LOGGER.debug("camera %s: liveStreamParam provisioned ok=%s",
|
|
3178
|
+
self.device_id, _lsp_ok)
|
|
3179
|
+
|
|
3082
3180
|
# AIDOT_FAST_CONNECT (default off): LAN-direct mode. Both transports stall
|
|
3083
3181
|
# the offer on a TURN relay allocation to the cloud TURN server before ICE
|
|
3084
3182
|
# can even start - for DTLS, aiortc's setLocalDescription blocks until ICE
|
|
@@ -396,6 +396,39 @@ async def _webrtc_consume_video(track: Any, on_frame: Callable) -> None:
|
|
|
396
396
|
break
|
|
397
397
|
|
|
398
398
|
|
|
399
|
+
def next_backoff(
|
|
400
|
+
attempt: int,
|
|
401
|
+
*,
|
|
402
|
+
base: float,
|
|
403
|
+
cap: float,
|
|
404
|
+
rand: Optional[Callable[[], float]] = None,
|
|
405
|
+
) -> float:
|
|
406
|
+
"""Jittered exponential backoff (AWS-style) with a hard floor at ``base``.
|
|
407
|
+
|
|
408
|
+
Returns the retry delay for 0-based ``attempt``: an exponential ceiling
|
|
409
|
+
``base * 2**attempt`` (capped at ``cap``), with the actual delay drawn
|
|
410
|
+
uniformly in ``[base, ceiling]``. The random spread de-synchronizes
|
|
411
|
+
concurrent reconnects so a fleet of cameras (or repeated failures of one)
|
|
412
|
+
recovering at the same moment doesn't stampede the cloud / MQTT broker into
|
|
413
|
+
a reconnect storm or rate-limit -- plain ``delay *= 2`` keeps every retrier
|
|
414
|
+
in lockstep, jitter breaks the lockstep while preserving the same average
|
|
415
|
+
growth.
|
|
416
|
+
|
|
417
|
+
``attempt`` 0 always returns exactly ``base`` (preserving the loops'
|
|
418
|
+
existing minimum spacing). ``rand`` defaults to ``random.random`` and is
|
|
419
|
+
injectable so the policy is unit-testable without randomness.
|
|
420
|
+
"""
|
|
421
|
+
if rand is None:
|
|
422
|
+
rand = random.random
|
|
423
|
+
# Clamp the exponent: a long-lived failing camera can drive ``attempt`` high,
|
|
424
|
+
# and 2**attempt overflows float64 around attempt~1024. The ceiling saturates
|
|
425
|
+
# at ``cap`` long before that, so 30 doublings is always enough headroom.
|
|
426
|
+
ceiling = min(cap, base * (2 ** min(max(attempt, 0), 30)))
|
|
427
|
+
if ceiling <= base:
|
|
428
|
+
return base
|
|
429
|
+
return base + rand() * (ceiling - base)
|
|
430
|
+
|
|
431
|
+
|
|
399
432
|
def _write_text_file(path: str, text: str) -> None:
|
|
400
433
|
"""Write ``text`` to ``path`` synchronously.
|
|
401
434
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-aidot-cameras
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.15
|
|
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
|
|
@@ -35,9 +35,11 @@ src/python_aidot_cameras.egg-info/dependency_links.txt
|
|
|
35
35
|
src/python_aidot_cameras.egg-info/requires.txt
|
|
36
36
|
src/python_aidot_cameras.egg-info/top_level.txt
|
|
37
37
|
tests/test_alarm_event.py
|
|
38
|
+
tests/test_backoff.py
|
|
38
39
|
tests/test_go2rtc.py
|
|
39
40
|
tests/test_highport_nomination.py
|
|
40
41
|
tests/test_lan_control.py
|
|
42
|
+
tests/test_live_stream_param.py
|
|
41
43
|
tests/test_motion_poll.py
|
|
42
44
|
tests/test_no_undefined_names.py
|
|
43
45
|
tests/test_post_merge_hardening.py
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Unit tests for the jittered exponential-backoff helper.
|
|
2
|
+
|
|
3
|
+
next_backoff() is the pure reconnect-delay policy the camera keepalive/serve
|
|
4
|
+
loops use so a degraded camera (or a fleet reconnecting at once) is met with
|
|
5
|
+
spread-out, escalating retries instead of a synchronized storm. Randomness is
|
|
6
|
+
injected so the bounds are deterministic. No camera/network needed.
|
|
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
|
+
from aidot.camera.protocol import next_backoff
|
|
14
|
+
|
|
15
|
+
_BASE = 10.0
|
|
16
|
+
_CAP = 300.0
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _lo(): # rand() -> 0.0 : the low end of the jitter window
|
|
20
|
+
return 0.0
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _hi(): # rand() -> ~1.0 : the high end of the jitter window
|
|
24
|
+
return 1.0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_attempt_zero_is_exactly_base():
|
|
28
|
+
# First retry must preserve the loops' existing minimum spacing.
|
|
29
|
+
assert next_backoff(0, base=_BASE, cap=_CAP, rand=_lo) == _BASE
|
|
30
|
+
assert next_backoff(0, base=_BASE, cap=_CAP, rand=_hi) == _BASE
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_floor_is_always_base():
|
|
34
|
+
# Low end of the window never dips below base, at any attempt.
|
|
35
|
+
for a in range(0, 12):
|
|
36
|
+
assert next_backoff(a, base=_BASE, cap=_CAP, rand=_lo) >= _BASE
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_ceiling_grows_exponentially_until_cap():
|
|
40
|
+
# rand()->1 gives the ceiling: base*2**attempt, capped at cap.
|
|
41
|
+
assert next_backoff(1, base=_BASE, cap=_CAP, rand=_hi) == 20.0
|
|
42
|
+
assert next_backoff(2, base=_BASE, cap=_CAP, rand=_hi) == 40.0
|
|
43
|
+
assert next_backoff(3, base=_BASE, cap=_CAP, rand=_hi) == 80.0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_ceiling_saturates_at_cap():
|
|
47
|
+
# 10*2**5 = 320 > cap(300) -> saturates at cap.
|
|
48
|
+
assert next_backoff(5, base=_BASE, cap=_CAP, rand=_hi) == _CAP
|
|
49
|
+
assert next_backoff(50, base=_BASE, cap=_CAP, rand=_hi) == _CAP
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_delay_within_bounds_for_real_rng():
|
|
53
|
+
# With the real RNG every draw stays in [base, min(cap, base*2**attempt)].
|
|
54
|
+
for a in range(0, 10):
|
|
55
|
+
ceiling = min(_CAP, _BASE * (2 ** a))
|
|
56
|
+
for _ in range(200):
|
|
57
|
+
d = next_backoff(a, base=_BASE, cap=_CAP)
|
|
58
|
+
assert _BASE <= d <= ceiling
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_large_attempt_does_not_overflow():
|
|
62
|
+
# A long-lived failing camera can drive attempt very high; the exponent is
|
|
63
|
+
# clamped so base*2**attempt never overflows float64.
|
|
64
|
+
assert next_backoff(5000, base=_BASE, cap=_CAP, rand=_hi) == _CAP
|
|
65
|
+
assert next_backoff(5000, base=_BASE, cap=_CAP, rand=_lo) == _BASE
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_negative_attempt_treated_as_zero():
|
|
69
|
+
assert next_backoff(-3, base=_BASE, cap=_CAP, rand=_lo) == _BASE
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
if __name__ == "__main__":
|
|
73
|
+
import traceback
|
|
74
|
+
_fail = 0
|
|
75
|
+
for _k, _v in sorted(globals().items()):
|
|
76
|
+
if _k.startswith("test_"):
|
|
77
|
+
try:
|
|
78
|
+
_v()
|
|
79
|
+
print(f"PASS {_k}")
|
|
80
|
+
except Exception:
|
|
81
|
+
_fail += 1
|
|
82
|
+
print(f"FAIL {_k}")
|
|
83
|
+
traceback.print_exc()
|
|
84
|
+
raise SystemExit(1 if _fail else 0)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Unit tests for the liveStreamParam provision-request builder.
|
|
2
|
+
|
|
3
|
+
Battery cameras (e.g. L2_170, A001513) must have their live-stream session
|
|
4
|
+
provisioned by the cloud before MQTT signaling, or they reject livePlayReq with
|
|
5
|
+
-50019 and never stream. The request shape is exact: a JSON ARRAY body of device
|
|
6
|
+
ids (an object body returns HTTP 500) plus an ``owner`` header. This locks that
|
|
7
|
+
format in. No network: builds the request from a bare instance.
|
|
8
|
+
"""
|
|
9
|
+
import json
|
|
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
|
+
# The camera mixin class that defines the builder (avoid hard-coding the name).
|
|
18
|
+
_CAM = next(v for v in vars(cc).values()
|
|
19
|
+
if isinstance(v, type) and "_live_stream_param_request" in v.__dict__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _stub(**over):
|
|
23
|
+
cam = _CAM.__new__(_CAM)
|
|
24
|
+
cam._user_info = over.get("user_info", {"accessToken": "TKN", "id": "owner-123"})
|
|
25
|
+
cam._region = "us" # _smarthome_base is a property derived from _region
|
|
26
|
+
cam.device_id = over.get("device_id", "DEV1234")
|
|
27
|
+
cam.user_id = "U1"
|
|
28
|
+
return cam
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_url_is_livestreamparam_endpoint():
|
|
32
|
+
url, _, _ = _stub()._live_stream_param_request()
|
|
33
|
+
assert url.endswith("/api/ipc/liveStream/liveStreamParam")
|
|
34
|
+
assert url.startswith("https://")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_body_is_json_array_of_device_id():
|
|
38
|
+
# MUST be a JSON array - an object body ({"deviceId": id}) returns HTTP 500.
|
|
39
|
+
_, _, body = _stub()._live_stream_param_request()
|
|
40
|
+
parsed = json.loads(body)
|
|
41
|
+
assert isinstance(parsed, list)
|
|
42
|
+
assert parsed == ["DEV1234"]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_owner_header_required():
|
|
46
|
+
# The endpoint needs owner (like the wake endpoint); falls back across keys.
|
|
47
|
+
_, headers, _ = _stub()._live_stream_param_request()
|
|
48
|
+
assert headers.get("owner") == "owner-123"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_owner_falls_back_to_user_id():
|
|
52
|
+
cam = _stub(user_info={"accessToken": "T"}) # no id/owner/userId
|
|
53
|
+
_, headers, _ = cam._live_stream_param_request()
|
|
54
|
+
assert headers.get("owner") == "U1"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_auth_headers_carried():
|
|
58
|
+
_, headers, _ = _stub()._live_stream_param_request()
|
|
59
|
+
assert headers.get("token") == "TKN"
|
|
60
|
+
assert headers.get("terminal") == "app"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
if __name__ == "__main__":
|
|
64
|
+
import traceback
|
|
65
|
+
_fail = 0
|
|
66
|
+
for _k, _v in sorted(globals().items()):
|
|
67
|
+
if _k.startswith("test_"):
|
|
68
|
+
try:
|
|
69
|
+
_v()
|
|
70
|
+
print(f"PASS {_k}")
|
|
71
|
+
except Exception:
|
|
72
|
+
_fail += 1
|
|
73
|
+
print(f"FAIL {_k}")
|
|
74
|
+
traceback.print_exc()
|
|
75
|
+
raise SystemExit(1 if _fail else 0)
|
|
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.13 → python_aidot_cameras-0.7.15}/src/aidot/models/device_client_model.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/aidot/models/device_model.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/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
|
{python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/tests/test_highport_nomination.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/tests/test_no_undefined_names.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/tests/test_post_merge_hardening.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
|