python-aidot-cameras 0.7.12__tar.gz → 0.7.14__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.12/src/python_aidot_cameras.egg-info → python_aidot_cameras-0.7.14}/PKG-INFO +1 -1
- {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/pyproject.toml +1 -1
- {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/aidot/camera/client.py +92 -18
- {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/aidot/camera/protocol.py +33 -0
- {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14/src/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
- {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/python_aidot_cameras.egg-info/SOURCES.txt +1 -0
- python_aidot_cameras-0.7.14/tests/test_backoff.py +84 -0
- {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/LICENSE +0 -0
- {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/MANIFEST.in +0 -0
- {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/README.md +0 -0
- {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/setup.cfg +0 -0
- {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/aidot/__init__.py +0 -0
- {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/aidot/aes_utils.py +0 -0
- {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/aidot/camera/__init__.py +0 -0
- {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/aidot/camera/constants.py +0 -0
- {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/aidot/camera/controls.py +0 -0
- {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/aidot/camera/go2rtc.py +0 -0
- {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/aidot/camera/lan_control.py +0 -0
- {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/aidot/camera/models.py +0 -0
- {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/aidot/camera/playback.py +0 -0
- {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/aidot/camera/sdes.py +0 -0
- {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/aidot/camera/tutk.py +0 -0
- {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/aidot/camera/webrtc.py +0 -0
- {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/aidot/client.py +0 -0
- {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/aidot/const.py +0 -0
- {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/aidot/credentials.py +0 -0
- {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/aidot/device_client.py +0 -0
- {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/aidot/discover.py +0 -0
- {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/aidot/exceptions.py +0 -0
- {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/aidot/g711.py +0 -0
- {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/aidot/login_const.py +0 -0
- {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/aidot/models/__init__.py +0 -0
- {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/aidot/models/device_client_model.py +0 -0
- {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/aidot/models/device_model.py +0 -0
- {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/aidot/models/discover_model.py +0 -0
- {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/aidot/py.typed +0 -0
- {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
- {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/python_aidot_cameras.egg-info/requires.txt +0 -0
- {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/python_aidot_cameras.egg-info/top_level.txt +0 -0
- {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/tests/test_alarm_event.py +0 -0
- {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/tests/test_go2rtc.py +0 -0
- {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/tests/test_highport_nomination.py +0 -0
- {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/tests/test_lan_control.py +0 -0
- {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/tests/test_motion_poll.py +0 -0
- {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/tests/test_no_undefined_names.py +0 -0
- {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/tests/test_post_merge_hardening.py +0 -0
- {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/tests/test_sdes_idle_release.py +0 -0
- {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/tests/test_sdes_sprop.py +0 -0
- {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/tests/test_sdes_talk.py +0 -0
- {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/tests/test_sdes_watchdog.py +0 -0
- {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/tests/test_speak.py +0 -0
- {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/tests/test_stream_cap.py +0 -0
- {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/tests/test_talk.py +0 -0
- {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/tests/test_terminal_ack.py +0 -0
- {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/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.14
|
|
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.14"
|
|
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,
|
|
@@ -2101,7 +2102,11 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
2101
2102
|
"""Background task: keep SDES stream alive; push to go2rtc via RTSP."""
|
|
2102
2103
|
_MIN_DELAY = 10.0
|
|
2103
2104
|
_MAX_DELAY = 300.0
|
|
2104
|
-
|
|
2105
|
+
# Consecutive failed / no-media opens; drives jittered exponential
|
|
2106
|
+
# backoff so a degraded camera (or a fleet reconnecting at once) isn't
|
|
2107
|
+
# hammered into further degradation / cloud rate-limiting. Reset only
|
|
2108
|
+
# after a session that actually delivered media (see end of loop).
|
|
2109
|
+
_attempt = 0
|
|
2105
2110
|
|
|
2106
2111
|
while self._streaming_active:
|
|
2107
2112
|
try:
|
|
@@ -2112,18 +2117,18 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
2112
2117
|
except asyncio.CancelledError:
|
|
2113
2118
|
return
|
|
2114
2119
|
except Exception as exc:
|
|
2120
|
+
_delay = next_backoff(_attempt, base=_MIN_DELAY, cap=_MAX_DELAY)
|
|
2115
2121
|
_LOGGER.warning(
|
|
2116
2122
|
"SDES keepalive: stream open failed for %s (retry in %.0fs): %s",
|
|
2117
|
-
self.device_id,
|
|
2123
|
+
self.device_id, _delay, exc,
|
|
2118
2124
|
)
|
|
2119
2125
|
try:
|
|
2120
|
-
await asyncio.sleep(
|
|
2126
|
+
await asyncio.sleep(_delay)
|
|
2121
2127
|
except asyncio.CancelledError:
|
|
2122
2128
|
return
|
|
2123
|
-
|
|
2129
|
+
_attempt += 1
|
|
2124
2130
|
continue
|
|
2125
2131
|
|
|
2126
|
-
_retry_delay = _MIN_DELAY # reset on success
|
|
2127
2132
|
self._stream_session = session
|
|
2128
2133
|
# Don't block solely on wait_done(): the SDES ffmpeg reads RTP over a
|
|
2129
2134
|
# UDP socket with no input timeout, so when a battery camera tears the
|
|
@@ -2208,8 +2213,19 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
2208
2213
|
_LOGGER.debug("camera %s: swallowed exception", '_sdes_keepalive_loop', exc_info=True)
|
|
2209
2214
|
|
|
2210
2215
|
if self._streaming_active:
|
|
2216
|
+
# Escalate backoff only when the session never delivered media
|
|
2217
|
+
# (camera refused / degraded on a rapid reconnect); a session
|
|
2218
|
+
# that streamed fine and then ended (battery teardown, consumer
|
|
2219
|
+
# gone) is a normal lifecycle event and resets to the base
|
|
2220
|
+
# interval so the next view reconnects promptly.
|
|
2221
|
+
if session.last_media_monotonic > 0.0:
|
|
2222
|
+
_attempt = 0
|
|
2223
|
+
_delay = _MIN_DELAY
|
|
2224
|
+
else:
|
|
2225
|
+
_attempt += 1
|
|
2226
|
+
_delay = next_backoff(_attempt, base=_MIN_DELAY, cap=_MAX_DELAY)
|
|
2211
2227
|
try:
|
|
2212
|
-
await asyncio.sleep(
|
|
2228
|
+
await asyncio.sleep(_delay)
|
|
2213
2229
|
except asyncio.CancelledError:
|
|
2214
2230
|
return
|
|
2215
2231
|
|
|
@@ -2225,7 +2241,9 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
2225
2241
|
# to 15 s here (+ the SDES/serve loops) if strict app parity is wanted.
|
|
2226
2242
|
_MIN_DELAY = 5.0
|
|
2227
2243
|
_MAX_DELAY = 300.0
|
|
2228
|
-
|
|
2244
|
+
# Consecutive failed / frameless opens; drives jittered backoff (see
|
|
2245
|
+
# next_backoff). Reset after a session that produced frames.
|
|
2246
|
+
_attempt = 0
|
|
2229
2247
|
|
|
2230
2248
|
def _on_frame(frame) -> None:
|
|
2231
2249
|
# Accept keyframes always; accept P-frames only after first keyframe.
|
|
@@ -2248,6 +2266,7 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
2248
2266
|
_LOGGER.debug("Streaming encode failed for %s: %s", self.device_id, enc_exc)
|
|
2249
2267
|
|
|
2250
2268
|
while self._streaming_active:
|
|
2269
|
+
_open_time = asyncio.get_running_loop().time()
|
|
2251
2270
|
try:
|
|
2252
2271
|
session = await self.async_open_webrtc_stream(on_frame=_on_frame)
|
|
2253
2272
|
except asyncio.CancelledError:
|
|
@@ -2266,18 +2285,18 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
2266
2285
|
return
|
|
2267
2286
|
continue
|
|
2268
2287
|
except Exception as exc:
|
|
2288
|
+
_delay = next_backoff(_attempt, base=_MIN_DELAY, cap=_MAX_DELAY)
|
|
2269
2289
|
_LOGGER.warning(
|
|
2270
2290
|
"Stream open failed for %s (retry in %.0fs): %s",
|
|
2271
|
-
self.device_id,
|
|
2291
|
+
self.device_id, _delay, exc,
|
|
2272
2292
|
)
|
|
2273
2293
|
try:
|
|
2274
|
-
await asyncio.sleep(
|
|
2294
|
+
await asyncio.sleep(_delay)
|
|
2275
2295
|
except asyncio.CancelledError:
|
|
2276
2296
|
return
|
|
2277
|
-
|
|
2297
|
+
_attempt += 1
|
|
2278
2298
|
continue
|
|
2279
2299
|
|
|
2280
|
-
_retry_delay = _MIN_DELAY # reset on success
|
|
2281
2300
|
self._stream_session = session
|
|
2282
2301
|
try:
|
|
2283
2302
|
while self._streaming_active:
|
|
@@ -2302,8 +2321,17 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
2302
2321
|
_LOGGER.debug("camera %s: swallowed exception", '_on_frame', exc_info=True)
|
|
2303
2322
|
|
|
2304
2323
|
if self._streaming_active:
|
|
2324
|
+
# Reset backoff if this session produced frames (a normal drop
|
|
2325
|
+
# after good streaming); escalate with jitter if it never did
|
|
2326
|
+
# (the camera-degradation case).
|
|
2327
|
+
if self._last_frame_time > _open_time:
|
|
2328
|
+
_attempt = 0
|
|
2329
|
+
_delay = _MIN_DELAY
|
|
2330
|
+
else:
|
|
2331
|
+
_attempt += 1
|
|
2332
|
+
_delay = next_backoff(_attempt, base=_MIN_DELAY, cap=_MAX_DELAY)
|
|
2305
2333
|
try:
|
|
2306
|
-
await asyncio.sleep(
|
|
2334
|
+
await asyncio.sleep(_delay)
|
|
2307
2335
|
except asyncio.CancelledError:
|
|
2308
2336
|
return
|
|
2309
2337
|
|
|
@@ -2419,7 +2447,7 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
2419
2447
|
# attempts so no code path (reset bug, fast PC-death) can pound the camera.
|
|
2420
2448
|
_MIN_DELAY, _MAX_DELAY = 15.0, 300.0
|
|
2421
2449
|
_open_gate = float(os.environ.get("AIDOT_DTLS_RETRY_GATE_S", "15"))
|
|
2422
|
-
|
|
2450
|
+
_attempt = 0 # consecutive failed opens; drives jittered backoff
|
|
2423
2451
|
_last_open_at = 0.0 # monotonic of the previous open attempt (0 = none yet)
|
|
2424
2452
|
loop = asyncio.get_running_loop()
|
|
2425
2453
|
while self._streaming_active:
|
|
@@ -2428,7 +2456,7 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
2428
2456
|
aq: "_queue.Queue" = _queue.Queue(maxsize=600)
|
|
2429
2457
|
self._serve_ready.clear() # fresh (cold) session: not ready until bound
|
|
2430
2458
|
# Hard inter-attempt gate (APK parity): never start an open within
|
|
2431
|
-
# _open_gate seconds of the previous one, regardless of
|
|
2459
|
+
# _open_gate seconds of the previous one, regardless of the backoff.
|
|
2432
2460
|
_since_open = loop.time() - _last_open_at
|
|
2433
2461
|
if _last_open_at and _since_open < _open_gate:
|
|
2434
2462
|
try:
|
|
@@ -2455,18 +2483,19 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
2455
2483
|
return
|
|
2456
2484
|
continue
|
|
2457
2485
|
except Exception as exc:
|
|
2486
|
+
_delay = next_backoff(_attempt, base=_MIN_DELAY, cap=_MAX_DELAY)
|
|
2458
2487
|
_LOGGER.warning(
|
|
2459
2488
|
"DTLS serve: open failed for %s (retry %.0fs): %s",
|
|
2460
|
-
self.device_id,
|
|
2489
|
+
self.device_id, _delay, exc,
|
|
2461
2490
|
)
|
|
2462
2491
|
try:
|
|
2463
|
-
await asyncio.sleep(
|
|
2492
|
+
await asyncio.sleep(_delay)
|
|
2464
2493
|
except asyncio.CancelledError:
|
|
2465
2494
|
return
|
|
2466
|
-
|
|
2495
|
+
_attempt += 1
|
|
2467
2496
|
continue
|
|
2468
2497
|
|
|
2469
|
-
|
|
2498
|
+
_attempt = 0
|
|
2470
2499
|
self._stream_session = session
|
|
2471
2500
|
pc = session._pc
|
|
2472
2501
|
# PC-dead test for the serve loop. closed/failed are terminal at
|
|
@@ -8091,6 +8120,10 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
8091
8120
|
# Proc holder: set to the ffmpeg Popen object after launch so the
|
|
8092
8121
|
# bridge thread can poll for exit without a NameError race.
|
|
8093
8122
|
_proc_holder: list = [None]
|
|
8123
|
+
# Camera's actual video payload type (96=H.264 / 97=H.265), set by the
|
|
8124
|
+
# bridge on the first video RTP packet so the ffmpeg SDP can be narrowed
|
|
8125
|
+
# to the matching single codec before ffmpeg is launched.
|
|
8126
|
+
_first_video_pt: list = [None]
|
|
8094
8127
|
# Media-liveness: bridge sets [0] = time.monotonic() on every forwarded
|
|
8095
8128
|
# media packet; the keepalive watchdog reads it via SdesSession to
|
|
8096
8129
|
# restart a session the camera silently stopped feeding.
|
|
@@ -9108,6 +9141,7 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
9108
9141
|
)
|
|
9109
9142
|
elif _kind == "video" and not _br_first_video_logged:
|
|
9110
9143
|
_br_first_video_logged = True
|
|
9144
|
+
_first_video_pt[0] = _pt
|
|
9111
9145
|
_status(f"bridge: first video RTP pt={_pt}")
|
|
9112
9146
|
# Camera answers BUNDLE (all media on one 5-tuple),
|
|
9113
9147
|
# so the talk destination is the same address as
|
|
@@ -9426,6 +9460,46 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
9426
9460
|
*_time_args,
|
|
9427
9461
|
*dest_args,
|
|
9428
9462
|
]
|
|
9463
|
+
# --- H.265 fix: narrow the ffmpeg SDP to the camera's actual codec ----
|
|
9464
|
+
# The camera streams H.264 (pt=96) OR H.265 (pt=97), varying per session.
|
|
9465
|
+
# An m=video line listing both ("96 97") makes ffmpeg bind the RTP
|
|
9466
|
+
# depacketizer to the FIRST pt (96/H.264) and silently drop the camera's
|
|
9467
|
+
# H.265 packets -> 0 frames. Wait for the bridge to observe the real
|
|
9468
|
+
# video pt, then rewrite the SDP to that single codec before launch.
|
|
9469
|
+
# ffmpeg recovers on the next periodic keyframe, so the small spawn delay
|
|
9470
|
+
# is harmless. Falls back to the dual-codec SDP if no video is seen.
|
|
9471
|
+
_vpt_deadline = time.monotonic() + 15.0
|
|
9472
|
+
while _first_video_pt[0] is None and time.monotonic() < _vpt_deadline:
|
|
9473
|
+
await asyncio.sleep(0.1)
|
|
9474
|
+
_vpt = _first_video_pt[0]
|
|
9475
|
+
if _vpt in (96, 97):
|
|
9476
|
+
def _narrow_sdp_to_pt(_sdp_text: str, _keep: int) -> str:
|
|
9477
|
+
_drop = 96 if _keep == 97 else 97
|
|
9478
|
+
_kept = []
|
|
9479
|
+
for _ln in _sdp_text.splitlines(keepends=True):
|
|
9480
|
+
_s = _ln.lstrip()
|
|
9481
|
+
if _s.startswith("m=video"):
|
|
9482
|
+
_ln = _ln.replace(" 96 97", f" {_keep}")
|
|
9483
|
+
elif (_s.startswith(f"a=rtpmap:{_drop} ")
|
|
9484
|
+
or _s.startswith(f"a=fmtp:{_drop} ")
|
|
9485
|
+
or _s.startswith(f"a=fmtp:{_drop};")):
|
|
9486
|
+
continue
|
|
9487
|
+
_kept.append(_ln)
|
|
9488
|
+
return "".join(_kept)
|
|
9489
|
+
def _read_sdp_file() -> str:
|
|
9490
|
+
with open(sdp_path, encoding="utf-8") as _f_sdp:
|
|
9491
|
+
return _f_sdp.read()
|
|
9492
|
+
try:
|
|
9493
|
+
_cur_sdp = await asyncio.get_running_loop().run_in_executor(
|
|
9494
|
+
None, _read_sdp_file)
|
|
9495
|
+
await asyncio.get_running_loop().run_in_executor(
|
|
9496
|
+
None, _write_text_file, sdp_path,
|
|
9497
|
+
_narrow_sdp_to_pt(_cur_sdp, int(_vpt)))
|
|
9498
|
+
_status(
|
|
9499
|
+
f"SDES: narrowed ffmpeg SDP to video pt={_vpt}"
|
|
9500
|
+
f" ({'H265' if int(_vpt) == 97 else 'H264'})")
|
|
9501
|
+
except Exception:
|
|
9502
|
+
_LOGGER.debug("camera %s: swallowed exception", '_open_sdes_stream', exc_info=True)
|
|
9429
9503
|
_LOGGER.info("SDES ffmpeg cmd: %s", " ".join(cmd))
|
|
9430
9504
|
if _ffmpeg_path() is None:
|
|
9431
9505
|
# ffmpeg is not installed - clean up and surface a clear error
|
|
@@ -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.14
|
|
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,6 +35,7 @@ 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
|
|
@@ -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)
|
|
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.12 → python_aidot_cameras-0.7.14}/src/aidot/models/device_client_model.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/aidot/models/device_model.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/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.12 → python_aidot_cameras-0.7.14}/tests/test_highport_nomination.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/tests/test_no_undefined_names.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/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
|