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.
Files changed (55) hide show
  1. {python_aidot_cameras-0.7.12/src/python_aidot_cameras.egg-info → python_aidot_cameras-0.7.14}/PKG-INFO +1 -1
  2. {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/pyproject.toml +1 -1
  3. {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/aidot/camera/client.py +92 -18
  4. {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/aidot/camera/protocol.py +33 -0
  5. {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14/src/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  6. {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/python_aidot_cameras.egg-info/SOURCES.txt +1 -0
  7. python_aidot_cameras-0.7.14/tests/test_backoff.py +84 -0
  8. {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/LICENSE +0 -0
  9. {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/MANIFEST.in +0 -0
  10. {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/README.md +0 -0
  11. {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/setup.cfg +0 -0
  12. {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/aidot/__init__.py +0 -0
  13. {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/aidot/aes_utils.py +0 -0
  14. {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/aidot/camera/__init__.py +0 -0
  15. {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/aidot/camera/constants.py +0 -0
  16. {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/aidot/camera/controls.py +0 -0
  17. {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/aidot/camera/go2rtc.py +0 -0
  18. {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/aidot/camera/lan_control.py +0 -0
  19. {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/aidot/camera/models.py +0 -0
  20. {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/aidot/camera/playback.py +0 -0
  21. {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/aidot/camera/sdes.py +0 -0
  22. {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/aidot/camera/tutk.py +0 -0
  23. {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/aidot/camera/webrtc.py +0 -0
  24. {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/aidot/client.py +0 -0
  25. {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/aidot/const.py +0 -0
  26. {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/aidot/credentials.py +0 -0
  27. {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/aidot/device_client.py +0 -0
  28. {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/aidot/discover.py +0 -0
  29. {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/aidot/exceptions.py +0 -0
  30. {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/aidot/g711.py +0 -0
  31. {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/aidot/login_const.py +0 -0
  32. {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/aidot/models/__init__.py +0 -0
  33. {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/aidot/models/device_client_model.py +0 -0
  34. {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/aidot/models/device_model.py +0 -0
  35. {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/aidot/models/discover_model.py +0 -0
  36. {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/aidot/py.typed +0 -0
  37. {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  38. {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/python_aidot_cameras.egg-info/requires.txt +0 -0
  39. {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/src/python_aidot_cameras.egg-info/top_level.txt +0 -0
  40. {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/tests/test_alarm_event.py +0 -0
  41. {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/tests/test_go2rtc.py +0 -0
  42. {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/tests/test_highport_nomination.py +0 -0
  43. {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/tests/test_lan_control.py +0 -0
  44. {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/tests/test_motion_poll.py +0 -0
  45. {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/tests/test_no_undefined_names.py +0 -0
  46. {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/tests/test_post_merge_hardening.py +0 -0
  47. {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/tests/test_sdes_idle_release.py +0 -0
  48. {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/tests/test_sdes_sprop.py +0 -0
  49. {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/tests/test_sdes_talk.py +0 -0
  50. {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/tests/test_sdes_watchdog.py +0 -0
  51. {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/tests/test_speak.py +0 -0
  52. {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/tests/test_stream_cap.py +0 -0
  53. {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/tests/test_talk.py +0 -0
  54. {python_aidot_cameras-0.7.12 → python_aidot_cameras-0.7.14}/tests/test_terminal_ack.py +0 -0
  55. {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.12
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.12"
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
- _retry_delay = _MIN_DELAY
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, _retry_delay, exc,
2123
+ self.device_id, _delay, exc,
2118
2124
  )
2119
2125
  try:
2120
- await asyncio.sleep(_retry_delay)
2126
+ await asyncio.sleep(_delay)
2121
2127
  except asyncio.CancelledError:
2122
2128
  return
2123
- _retry_delay = min(_retry_delay * 2, _MAX_DELAY)
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(_MIN_DELAY)
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
- _retry_delay = _MIN_DELAY
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, _retry_delay, exc,
2291
+ self.device_id, _delay, exc,
2272
2292
  )
2273
2293
  try:
2274
- await asyncio.sleep(_retry_delay)
2294
+ await asyncio.sleep(_delay)
2275
2295
  except asyncio.CancelledError:
2276
2296
  return
2277
- _retry_delay = min(_retry_delay * 2, _MAX_DELAY)
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(_MIN_DELAY)
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
- retry_delay = _MIN_DELAY
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 retry_delay.
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, retry_delay, exc,
2489
+ self.device_id, _delay, exc,
2461
2490
  )
2462
2491
  try:
2463
- await asyncio.sleep(retry_delay)
2492
+ await asyncio.sleep(_delay)
2464
2493
  except asyncio.CancelledError:
2465
2494
  return
2466
- retry_delay = min(retry_delay * 2, _MAX_DELAY)
2495
+ _attempt += 1
2467
2496
  continue
2468
2497
 
2469
- retry_delay = _MIN_DELAY
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.12
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)