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.
Files changed (56) hide show
  1. {python_aidot_cameras-0.7.13/src/python_aidot_cameras.egg-info → python_aidot_cameras-0.7.15}/PKG-INFO +1 -1
  2. {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/pyproject.toml +1 -1
  3. {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/aidot/camera/client.py +116 -18
  4. {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/aidot/camera/protocol.py +33 -0
  5. {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15/src/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  6. {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/python_aidot_cameras.egg-info/SOURCES.txt +2 -0
  7. python_aidot_cameras-0.7.15/tests/test_backoff.py +84 -0
  8. python_aidot_cameras-0.7.15/tests/test_live_stream_param.py +75 -0
  9. {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/LICENSE +0 -0
  10. {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/MANIFEST.in +0 -0
  11. {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/README.md +0 -0
  12. {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/setup.cfg +0 -0
  13. {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/aidot/__init__.py +0 -0
  14. {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/aidot/aes_utils.py +0 -0
  15. {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/aidot/camera/__init__.py +0 -0
  16. {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/aidot/camera/constants.py +0 -0
  17. {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/aidot/camera/controls.py +0 -0
  18. {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/aidot/camera/go2rtc.py +0 -0
  19. {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/aidot/camera/lan_control.py +0 -0
  20. {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/aidot/camera/models.py +0 -0
  21. {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/aidot/camera/playback.py +0 -0
  22. {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/aidot/camera/sdes.py +0 -0
  23. {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/aidot/camera/tutk.py +0 -0
  24. {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/aidot/camera/webrtc.py +0 -0
  25. {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/aidot/client.py +0 -0
  26. {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/aidot/const.py +0 -0
  27. {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/aidot/credentials.py +0 -0
  28. {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/aidot/device_client.py +0 -0
  29. {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/aidot/discover.py +0 -0
  30. {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/aidot/exceptions.py +0 -0
  31. {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/aidot/g711.py +0 -0
  32. {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/aidot/login_const.py +0 -0
  33. {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/aidot/models/__init__.py +0 -0
  34. {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/aidot/models/device_client_model.py +0 -0
  35. {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/aidot/models/device_model.py +0 -0
  36. {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/aidot/models/discover_model.py +0 -0
  37. {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/aidot/py.typed +0 -0
  38. {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  39. {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/python_aidot_cameras.egg-info/requires.txt +0 -0
  40. {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/src/python_aidot_cameras.egg-info/top_level.txt +0 -0
  41. {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/tests/test_alarm_event.py +0 -0
  42. {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/tests/test_go2rtc.py +0 -0
  43. {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/tests/test_highport_nomination.py +0 -0
  44. {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/tests/test_lan_control.py +0 -0
  45. {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/tests/test_motion_poll.py +0 -0
  46. {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/tests/test_no_undefined_names.py +0 -0
  47. {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/tests/test_post_merge_hardening.py +0 -0
  48. {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/tests/test_sdes_idle_release.py +0 -0
  49. {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/tests/test_sdes_sprop.py +0 -0
  50. {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/tests/test_sdes_talk.py +0 -0
  51. {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/tests/test_sdes_watchdog.py +0 -0
  52. {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/tests/test_speak.py +0 -0
  53. {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/tests/test_stream_cap.py +0 -0
  54. {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/tests/test_talk.py +0 -0
  55. {python_aidot_cameras-0.7.13 → python_aidot_cameras-0.7.15}/tests/test_terminal_ack.py +0 -0
  56. {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.13
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.13"
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
- _retry_delay = _MIN_DELAY
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, _retry_delay, exc,
2177
+ self.device_id, _delay, exc,
2118
2178
  )
2119
2179
  try:
2120
- await asyncio.sleep(_retry_delay)
2180
+ await asyncio.sleep(_delay)
2121
2181
  except asyncio.CancelledError:
2122
2182
  return
2123
- _retry_delay = min(_retry_delay * 2, _MAX_DELAY)
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(_MIN_DELAY)
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
- _retry_delay = _MIN_DELAY
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, _retry_delay, exc,
2345
+ self.device_id, _delay, exc,
2272
2346
  )
2273
2347
  try:
2274
- await asyncio.sleep(_retry_delay)
2348
+ await asyncio.sleep(_delay)
2275
2349
  except asyncio.CancelledError:
2276
2350
  return
2277
- _retry_delay = min(_retry_delay * 2, _MAX_DELAY)
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(_MIN_DELAY)
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
- retry_delay = _MIN_DELAY
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 retry_delay.
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, retry_delay, exc,
2543
+ self.device_id, _delay, exc,
2461
2544
  )
2462
2545
  try:
2463
- await asyncio.sleep(retry_delay)
2546
+ await asyncio.sleep(_delay)
2464
2547
  except asyncio.CancelledError:
2465
2548
  return
2466
- retry_delay = min(retry_delay * 2, _MAX_DELAY)
2549
+ _attempt += 1
2467
2550
  continue
2468
2551
 
2469
- retry_delay = _MIN_DELAY
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.13
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)