python-aidot-cameras 0.7.16__tar.gz → 0.7.17__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.16/src/python_aidot_cameras.egg-info → python_aidot_cameras-0.7.17}/PKG-INFO +1 -1
  2. {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/pyproject.toml +1 -1
  3. {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/aidot/camera/client.py +85 -91
  4. {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/aidot/camera/protocol.py +40 -0
  5. {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17/src/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  6. {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/tests/test_backoff.py +41 -1
  7. {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/LICENSE +0 -0
  8. {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/MANIFEST.in +0 -0
  9. {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/README.md +0 -0
  10. {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/setup.cfg +0 -0
  11. {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/aidot/__init__.py +0 -0
  12. {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/aidot/aes_utils.py +0 -0
  13. {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/aidot/camera/__init__.py +0 -0
  14. {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/aidot/camera/constants.py +0 -0
  15. {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/aidot/camera/controls.py +0 -0
  16. {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/aidot/camera/go2rtc.py +0 -0
  17. {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/aidot/camera/lan_control.py +0 -0
  18. {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/aidot/camera/models.py +0 -0
  19. {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/aidot/camera/playback.py +0 -0
  20. {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/aidot/camera/sdes.py +0 -0
  21. {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/aidot/camera/tutk.py +0 -0
  22. {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/aidot/camera/webrtc.py +0 -0
  23. {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/aidot/client.py +0 -0
  24. {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/aidot/const.py +0 -0
  25. {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/aidot/credentials.py +0 -0
  26. {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/aidot/device_client.py +0 -0
  27. {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/aidot/discover.py +0 -0
  28. {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/aidot/exceptions.py +0 -0
  29. {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/aidot/g711.py +0 -0
  30. {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/aidot/login_const.py +0 -0
  31. {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/aidot/models/__init__.py +0 -0
  32. {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/aidot/models/device_client_model.py +0 -0
  33. {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/aidot/models/device_model.py +0 -0
  34. {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/aidot/models/discover_model.py +0 -0
  35. {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/aidot/py.typed +0 -0
  36. {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/python_aidot_cameras.egg-info/SOURCES.txt +0 -0
  37. {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  38. {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/python_aidot_cameras.egg-info/requires.txt +0 -0
  39. {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/python_aidot_cameras.egg-info/top_level.txt +0 -0
  40. {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/tests/test_alarm_event.py +0 -0
  41. {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/tests/test_go2rtc.py +0 -0
  42. {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/tests/test_highport_nomination.py +0 -0
  43. {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/tests/test_lan_control.py +0 -0
  44. {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/tests/test_live_stream_param.py +0 -0
  45. {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/tests/test_motion_poll.py +0 -0
  46. {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/tests/test_no_undefined_names.py +0 -0
  47. {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/tests/test_post_merge_hardening.py +0 -0
  48. {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/tests/test_sdes_idle_release.py +0 -0
  49. {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/tests/test_sdes_sprop.py +0 -0
  50. {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/tests/test_sdes_talk.py +0 -0
  51. {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/tests/test_sdes_watchdog.py +0 -0
  52. {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/tests/test_speak.py +0 -0
  53. {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/tests/test_stream_cap.py +0 -0
  54. {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/tests/test_talk.py +0 -0
  55. {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/tests/test_terminal_ack.py +0 -0
  56. {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/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.16
3
+ Version: 0.7.17
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.16"
7
+ version = "0.7.17"
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,7 +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
+ ReconnectPacer,
43
43
  _build_stun_binding_success_response,
44
44
  _terminal_webrtc_ack,
45
45
  _install_highport_nomination_patch,
@@ -636,48 +636,67 @@ class CameraMixin(_CameraControlsMixin):
636
636
  "Content-Type": "application/json",
637
637
  }
638
638
 
639
- async def _async_http_wake(self) -> bool:
640
- """Wake a battery camera via the cloud HTTP low-power endpoint.
639
+ def _owner_id(self) -> str:
640
+ """The Leedarson ``owner`` (userId) header value some IPC endpoints require.
641
641
 
642
- Mirrors DeviceWakeUpRepos.j() in the official app (n.java): POST
643
- ``{smarthome}/api/ipc/devices/{id}/lowPowerActiveState`` with body
644
- ``{"deviceId": id, "status": "wakeup"}`` and owner+token headers. The
645
- cloud forwards the wake to the camera's always-on low-power channel, so it
646
- reaches a deeply-sleeping camera that has dropped its MQTT session - unlike
647
- the MQTT ``lowPowerActiveStateReq``, which only lands if the camera is
648
- already connected. Returns True if the request was accepted (HTTP 200).
642
+ ``_leedarson_headers`` deliberately omits ``owner`` (it breaks
643
+ recording/playback); the wake and liveStreamParam endpoints need it.
649
644
  """
650
- import aiohttp
651
-
652
- headers = self._leedarson_headers()
653
- # The wake endpoint (unlike recording/playback) DOES require owner - the app
654
- # sends it (n.java:71). owner is the Leedarson userId.
655
- headers["owner"] = (
645
+ return (
656
646
  self._user_info.get("owner")
657
647
  or self._user_info.get("id")
658
648
  or self._user_info.get("userId")
659
649
  or str(self.user_id)
660
650
  )
661
- url = (f"{self._smarthome_base}/api/ipc/devices/"
662
- f"{self.device_id}/lowPowerActiveState")
663
- body = {"deviceId": self.device_id, "status": "wakeup"}
651
+
652
+ async def _async_post_ok(
653
+ self, url: str, headers: dict, body: str, *, timeout: float = 10.0,
654
+ label: str = "ipc post",
655
+ ) -> bool:
656
+ """POST a JSON ``body`` string to a Leedarson IPC endpoint; True on success.
657
+
658
+ Success = HTTP 200 with a ``code`` of 0/200/absent. Best-effort: any
659
+ network/parse error returns False, never raises. Shared by the wake and
660
+ liveStreamParam provisioning calls.
661
+ """
662
+ import aiohttp
663
+
664
664
  try:
665
665
  async with aiohttp.ClientSession() as session:
666
666
  async with session.post(
667
- url, headers=headers, json=body,
668
- timeout=aiohttp.ClientTimeout(total=10),
667
+ url, headers=headers, data=body,
668
+ timeout=aiohttp.ClientTimeout(total=timeout),
669
669
  ) as resp:
670
670
  status = resp.status
671
671
  data = await resp.json(content_type=None)
672
672
  code = data.get("code") if isinstance(data, dict) else None
673
673
  ok = status == 200 and (code in (None, 0, 200, "0", "200"))
674
- _LOGGER.debug("http wake %s: status=%s code=%s ok=%s",
675
- self.device_id, status, code, ok)
674
+ _LOGGER.debug("%s %s: status=%s code=%s ok=%s",
675
+ label, self.device_id, status, code, ok)
676
676
  return bool(ok)
677
677
  except Exception as exc:
678
- _LOGGER.debug("http wake failed for %s: %s", self.device_id, exc)
678
+ _LOGGER.debug("%s failed for %s: %s", label, self.device_id, exc)
679
679
  return False
680
680
 
681
+ async def _async_http_wake(self) -> bool:
682
+ """Wake a battery camera via the cloud HTTP low-power endpoint.
683
+
684
+ Mirrors DeviceWakeUpRepos.j() in the official app (n.java): POST
685
+ ``{smarthome}/api/ipc/devices/{id}/lowPowerActiveState`` with body
686
+ ``{"deviceId": id, "status": "wakeup"}`` and owner+token headers. The
687
+ cloud forwards the wake to the camera's always-on low-power channel, so it
688
+ reaches a deeply-sleeping camera that has dropped its MQTT session - unlike
689
+ the MQTT ``lowPowerActiveStateReq``, which only lands if the camera is
690
+ already connected. Returns True if the request was accepted (HTTP 200).
691
+ """
692
+ # The wake endpoint (unlike recording/playback) DOES require owner (n.java:71).
693
+ headers = self._leedarson_headers()
694
+ headers["owner"] = self._owner_id()
695
+ url = (f"{self._smarthome_base}/api/ipc/devices/"
696
+ f"{self.device_id}/lowPowerActiveState")
697
+ body = json.dumps({"deviceId": self.device_id, "status": "wakeup"})
698
+ return await self._async_post_ok(url, headers, body, label="http wake")
699
+
681
700
  def _live_stream_param_request(self):
682
701
  """Build ``(url, headers, body)`` for the liveStreamParam provision call.
683
702
 
@@ -686,12 +705,7 @@ class CameraMixin(_CameraControlsMixin):
686
705
  ``owner`` header is required (as on the wake endpoint).
687
706
  """
688
707
  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
- )
708
+ headers["owner"] = self._owner_id()
695
709
  url = f"{self._smarthome_base}/api/ipc/liveStream/liveStreamParam"
696
710
  return url, headers, json.dumps([self.device_id])
697
711
 
@@ -706,31 +720,10 @@ class CameraMixin(_CameraControlsMixin):
706
720
  provisions the session (and brings the camera online) and returns AWS KVS
707
721
  credentials. We only need the provisioning side effect - the library
708
722
  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.
723
+ credentials are ignored. Best-effort: returns True on HTTP 200.
714
724
  """
715
- import aiohttp
716
-
717
725
  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
726
+ return await self._async_post_ok(url, headers, body, label="liveStreamParam")
734
727
 
735
728
  async def async_wake_camera(self, retries: int = 2) -> bool:
736
729
  """Wake a battery camera on demand via the cloud HTTP low-power endpoint.
@@ -1945,6 +1938,7 @@ class CameraMixin(_CameraControlsMixin):
1945
1938
  fast_connect: Optional[bool] = None,
1946
1939
  sdes_audio: Optional[bool] = None,
1947
1940
  go2rtc_url: Optional[str] = None,
1941
+ live_stream_param: Optional[bool] = None,
1948
1942
  ) -> None:
1949
1943
  """Start a persistent stream that keeps the camera session alive.
1950
1944
 
@@ -1962,12 +1956,19 @@ class CameraMixin(_CameraControlsMixin):
1962
1956
  to the ``AIDOT_FAST_CONNECT`` env var; ``True``/``False`` override it (e.g.
1963
1957
  from a Home Assistant config-entry option, since HA OS can't set env vars).
1964
1958
 
1959
+ ``live_stream_param`` toggles the battery-camera cloud pre-connect
1960
+ (liveStreamParam); like ``fast_connect`` it overrides the
1961
+ ``AIDOT_LIVESTREAM_PARAM`` env var, so an integration on HA OS (no env vars)
1962
+ can still disable it per camera.
1963
+
1965
1964
  Safe to call multiple times - does nothing if already running.
1966
1965
  """
1967
1966
  if fast_connect is not None:
1968
1967
  self._fast_connect_opt = fast_connect
1969
1968
  if sdes_audio is not None:
1970
1969
  self._sdes_audio_opt = sdes_audio
1970
+ if live_stream_param is not None:
1971
+ self._live_stream_param_opt = live_stream_param
1971
1972
  if self._stream_task is not None and not self._stream_task.done():
1972
1973
  return
1973
1974
  self._keepalive_rtsp_url = rtsp_push_url
@@ -2156,11 +2157,11 @@ class CameraMixin(_CameraControlsMixin):
2156
2157
  """Background task: keep SDES stream alive; push to go2rtc via RTSP."""
2157
2158
  _MIN_DELAY = 10.0
2158
2159
  _MAX_DELAY = 300.0
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
2160
+ # Jittered-backoff pacer: escalates on failed/no-media opens so a degraded
2161
+ # camera (or a fleet reconnecting at once) isn't hammered into further
2162
+ # degradation / cloud rate-limiting; resets after a session that delivered
2163
+ # media (see end of loop).
2164
+ _pacer = ReconnectPacer(_MIN_DELAY, _MAX_DELAY)
2164
2165
 
2165
2166
  while self._streaming_active:
2166
2167
  try:
@@ -2171,7 +2172,7 @@ class CameraMixin(_CameraControlsMixin):
2171
2172
  except asyncio.CancelledError:
2172
2173
  return
2173
2174
  except Exception as exc:
2174
- _delay = next_backoff(_attempt, base=_MIN_DELAY, cap=_MAX_DELAY)
2175
+ _delay = _pacer.fail_delay()
2175
2176
  _LOGGER.warning(
2176
2177
  "SDES keepalive: stream open failed for %s (retry in %.0fs): %s",
2177
2178
  self.device_id, _delay, exc,
@@ -2180,7 +2181,6 @@ class CameraMixin(_CameraControlsMixin):
2180
2181
  await asyncio.sleep(_delay)
2181
2182
  except asyncio.CancelledError:
2182
2183
  return
2183
- _attempt += 1
2184
2184
  continue
2185
2185
 
2186
2186
  self._stream_session = session
@@ -2270,16 +2270,10 @@ class CameraMixin(_CameraControlsMixin):
2270
2270
  # Escalate backoff only when the session never delivered media
2271
2271
  # (camera refused / degraded on a rapid reconnect); a session
2272
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)
2273
+ # gone) is a normal lifecycle event and resets to the base interval.
2274
+ _healthy = session.last_media_monotonic > 0.0
2281
2275
  try:
2282
- await asyncio.sleep(_delay)
2276
+ await asyncio.sleep(_pacer.session_end_delay(healthy=_healthy))
2283
2277
  except asyncio.CancelledError:
2284
2278
  return
2285
2279
 
@@ -2295,9 +2289,9 @@ class CameraMixin(_CameraControlsMixin):
2295
2289
  # to 15 s here (+ the SDES/serve loops) if strict app parity is wanted.
2296
2290
  _MIN_DELAY = 5.0
2297
2291
  _MAX_DELAY = 300.0
2298
- # Consecutive failed / frameless opens; drives jittered backoff (see
2299
- # next_backoff). Reset after a session that produced frames.
2300
- _attempt = 0
2292
+ # Jittered-backoff pacer: escalates on failed/frameless opens, resets after
2293
+ # a session that produced frames.
2294
+ _pacer = ReconnectPacer(_MIN_DELAY, _MAX_DELAY)
2301
2295
 
2302
2296
  def _on_frame(frame) -> None:
2303
2297
  # Accept keyframes always; accept P-frames only after first keyframe.
@@ -2339,7 +2333,7 @@ class CameraMixin(_CameraControlsMixin):
2339
2333
  return
2340
2334
  continue
2341
2335
  except Exception as exc:
2342
- _delay = next_backoff(_attempt, base=_MIN_DELAY, cap=_MAX_DELAY)
2336
+ _delay = _pacer.fail_delay()
2343
2337
  _LOGGER.warning(
2344
2338
  "Stream open failed for %s (retry in %.0fs): %s",
2345
2339
  self.device_id, _delay, exc,
@@ -2348,7 +2342,6 @@ class CameraMixin(_CameraControlsMixin):
2348
2342
  await asyncio.sleep(_delay)
2349
2343
  except asyncio.CancelledError:
2350
2344
  return
2351
- _attempt += 1
2352
2345
  continue
2353
2346
 
2354
2347
  self._stream_session = session
@@ -2378,14 +2371,9 @@ class CameraMixin(_CameraControlsMixin):
2378
2371
  # Reset backoff if this session produced frames (a normal drop
2379
2372
  # after good streaming); escalate with jitter if it never did
2380
2373
  # (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)
2374
+ _produced = self._last_frame_time > _open_time
2387
2375
  try:
2388
- await asyncio.sleep(_delay)
2376
+ await asyncio.sleep(_pacer.session_end_delay(healthy=_produced))
2389
2377
  except asyncio.CancelledError:
2390
2378
  return
2391
2379
 
@@ -2501,7 +2489,11 @@ class CameraMixin(_CameraControlsMixin):
2501
2489
  # attempts so no code path (reset bug, fast PC-death) can pound the camera.
2502
2490
  _MIN_DELAY, _MAX_DELAY = 15.0, 300.0
2503
2491
  _open_gate = float(os.environ.get("AIDOT_DTLS_RETRY_GATE_S", "15"))
2504
- _attempt = 0 # consecutive failed opens; drives jittered backoff
2492
+ # Jittered-backoff pacer for failed opens. Unlike the SDES/JPEG loops this
2493
+ # serve loop resets on any successful open (it has the _open_gate spacing
2494
+ # below) rather than escalating on no-media - hence reset() not
2495
+ # session_end_delay().
2496
+ _pacer = ReconnectPacer(_MIN_DELAY, _MAX_DELAY)
2505
2497
  _last_open_at = 0.0 # monotonic of the previous open attempt (0 = none yet)
2506
2498
  loop = asyncio.get_running_loop()
2507
2499
  while self._streaming_active:
@@ -2537,7 +2529,7 @@ class CameraMixin(_CameraControlsMixin):
2537
2529
  return
2538
2530
  continue
2539
2531
  except Exception as exc:
2540
- _delay = next_backoff(_attempt, base=_MIN_DELAY, cap=_MAX_DELAY)
2532
+ _delay = _pacer.fail_delay()
2541
2533
  _LOGGER.warning(
2542
2534
  "DTLS serve: open failed for %s (retry %.0fs): %s",
2543
2535
  self.device_id, _delay, exc,
@@ -2546,10 +2538,9 @@ class CameraMixin(_CameraControlsMixin):
2546
2538
  await asyncio.sleep(_delay)
2547
2539
  except asyncio.CancelledError:
2548
2540
  return
2549
- _attempt += 1
2550
2541
  continue
2551
2542
 
2552
- _attempt = 0
2543
+ _pacer.reset()
2553
2544
  self._stream_session = session
2554
2545
  pc = session._pc
2555
2546
  # PC-dead test for the serve loop. closed/failed are terminal at
@@ -3167,12 +3158,15 @@ class CameraMixin(_CameraControlsMixin):
3167
3158
  # -50019 ("not ready") and never runs ICE -> no media. App parity:
3168
3159
  # KVSPreConnectStrategy.fetchKvsParams calls liveStreamParam first. We
3169
3160
  # 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"):
3161
+ # AWS KVS). Mains cameras are always stream-ready, so skip them.
3162
+ # Validated live on L2_170 (A001513): with this call livePlay succeeds and
3163
+ # decrypted RTP flows; without it, persistent -50019. Controllable like
3164
+ # fast_connect/sdes_audio: start_keepalive(live_stream_param=...) wins,
3165
+ # else the AIDOT_LIVESTREAM_PARAM env var (default on).
3166
+ _lsp = getattr(self, "_live_stream_param_opt", None)
3167
+ if _lsp is None:
3168
+ _lsp = os.environ.get("AIDOT_LIVESTREAM_PARAM", "1") != "0"
3169
+ if self.is_battery_camera and _lsp:
3176
3170
  _lsp_ok = await self._async_fetch_live_stream_param()
3177
3171
  _LOGGER.debug("camera %s: liveStreamParam provisioned ok=%s",
3178
3172
  self.device_id, _lsp_ok)
@@ -429,6 +429,46 @@ def next_backoff(
429
429
  return base + rand() * (ceiling - base)
430
430
 
431
431
 
432
+ class ReconnectPacer:
433
+ """Reconnect-delay state machine shared by the camera reconnect loops.
434
+
435
+ Centralizes the attempt counter, the jittered-backoff math (:func:`next_backoff`),
436
+ and the escalate-on-failure / reset-on-healthy policy + increment ordering that
437
+ the SDES keepalive, JPEG streaming, and DTLS serve loops would otherwise each
438
+ re-implement. Each loop keeps only its own liveness signal and feeds it in, so
439
+ a loop's policy (does it escalate on no-media, or just reset on open?) is an
440
+ explicit method call rather than hidden in copy-pasted counter bookkeeping.
441
+ Not thread-safe; one instance per loop scope.
442
+ """
443
+
444
+ def __init__(
445
+ self, base: float, cap: float,
446
+ *, rand: Optional[Callable[[], float]] = None,
447
+ ) -> None:
448
+ self._base = base
449
+ self._cap = cap
450
+ self._rand = rand # forwarded to next_backoff; injectable for tests
451
+ self._attempt = 0
452
+
453
+ def fail_delay(self) -> float:
454
+ """Delay after a failed OPEN (no session established): the current attempt's
455
+ backoff, then escalate - so the first failure waits exactly ``base``."""
456
+ delay = next_backoff(self._attempt, base=self._base, cap=self._cap, rand=self._rand)
457
+ self._attempt += 1
458
+ return delay
459
+
460
+ def session_end_delay(self, *, healthy: bool) -> float:
461
+ """Delay after a session ENDED: reset to ``base`` if it delivered media
462
+ (``healthy``), else escalate first, then return the delay."""
463
+ self._attempt = 0 if healthy else self._attempt + 1
464
+ return next_backoff(self._attempt, base=self._base, cap=self._cap, rand=self._rand)
465
+
466
+ def reset(self) -> None:
467
+ """Clear the failure count - e.g. after a successful open in a loop that has
468
+ no end-of-session escalation (the DTLS serve loop, gated by its own spacing)."""
469
+ self._attempt = 0
470
+
471
+
432
472
  def _write_text_file(path: str, text: str) -> None:
433
473
  """Write ``text`` to ``path`` synchronously.
434
474
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-aidot-cameras
3
- Version: 0.7.16
3
+ Version: 0.7.17
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
@@ -10,7 +10,7 @@ import sys
10
10
 
11
11
  sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"))
12
12
 
13
- from aidot.camera.protocol import next_backoff
13
+ from aidot.camera.protocol import ReconnectPacer, next_backoff
14
14
 
15
15
  _BASE = 10.0
16
16
  _CAP = 300.0
@@ -69,6 +69,46 @@ def test_negative_attempt_treated_as_zero():
69
69
  assert next_backoff(-3, base=_BASE, cap=_CAP, rand=_lo) == _BASE
70
70
 
71
71
 
72
+ # --- ReconnectPacer: the escalate/reset policy shared by the reconnect loops --- #
73
+
74
+ def test_pacer_first_fail_is_base_then_escalates():
75
+ # fail_delay uses the current attempt then increments: first failure == base,
76
+ # subsequent failures climb the ceiling (rand=_hi gives the ceiling).
77
+ p = ReconnectPacer(_BASE, _CAP, rand=_hi)
78
+ assert p.fail_delay() == _BASE # attempt 0
79
+ assert p.fail_delay() == 2 * _BASE # attempt 1 ceiling
80
+ assert p.fail_delay() == 4 * _BASE # attempt 2 ceiling
81
+
82
+
83
+ def test_pacer_session_end_healthy_resets_to_base():
84
+ p = ReconnectPacer(_BASE, _CAP, rand=_hi)
85
+ p.fail_delay(); p.fail_delay() # climb to attempt 2
86
+ assert p.session_end_delay(healthy=True) == _BASE # reset
87
+ assert p.session_end_delay(healthy=True) == _BASE # stays at base
88
+
89
+
90
+ def test_pacer_session_end_unhealthy_escalates_then_resets():
91
+ # session_end escalates first, then computes: from attempt 0, no-media -> attempt 1.
92
+ p = ReconnectPacer(_BASE, _CAP, rand=_hi)
93
+ assert p.session_end_delay(healthy=False) == 2 * _BASE # attempt 1 ceiling
94
+ assert p.session_end_delay(healthy=False) == 4 * _BASE # attempt 2 ceiling
95
+ assert p.session_end_delay(healthy=True) == _BASE # healthy resets
96
+
97
+
98
+ def test_pacer_reset_clears_failures():
99
+ p = ReconnectPacer(_BASE, _CAP, rand=_hi)
100
+ p.fail_delay(); p.fail_delay(); p.fail_delay()
101
+ p.reset()
102
+ assert p.fail_delay() == _BASE # back to attempt 0
103
+
104
+
105
+ def test_pacer_delays_within_bounds_real_rng():
106
+ p = ReconnectPacer(_BASE, _CAP)
107
+ for _ in range(20):
108
+ d = p.fail_delay()
109
+ assert _BASE <= d <= _CAP
110
+
111
+
72
112
  if __name__ == "__main__":
73
113
  import traceback
74
114
  _fail = 0