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.
- {python_aidot_cameras-0.7.16/src/python_aidot_cameras.egg-info → python_aidot_cameras-0.7.17}/PKG-INFO +1 -1
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/pyproject.toml +1 -1
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/aidot/camera/client.py +85 -91
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/aidot/camera/protocol.py +40 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17/src/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/tests/test_backoff.py +41 -1
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/LICENSE +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/MANIFEST.in +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/README.md +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/setup.cfg +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/aidot/__init__.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/aidot/aes_utils.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/aidot/camera/__init__.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/aidot/camera/constants.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/aidot/camera/controls.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/aidot/camera/go2rtc.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/aidot/camera/lan_control.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/aidot/camera/models.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/aidot/camera/playback.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/aidot/camera/sdes.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/aidot/camera/tutk.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/aidot/camera/webrtc.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/aidot/client.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/aidot/const.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/aidot/credentials.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/aidot/device_client.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/aidot/discover.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/aidot/exceptions.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/aidot/g711.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/aidot/login_const.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/aidot/models/__init__.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/aidot/models/device_client_model.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/aidot/models/device_model.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/aidot/models/discover_model.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/aidot/py.typed +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/python_aidot_cameras.egg-info/SOURCES.txt +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/python_aidot_cameras.egg-info/requires.txt +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/python_aidot_cameras.egg-info/top_level.txt +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/tests/test_alarm_event.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/tests/test_go2rtc.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/tests/test_highport_nomination.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/tests/test_lan_control.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/tests/test_live_stream_param.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/tests/test_motion_poll.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/tests/test_no_undefined_names.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/tests/test_post_merge_hardening.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/tests/test_sdes_idle_release.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/tests/test_sdes_sprop.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/tests/test_sdes_talk.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/tests/test_sdes_watchdog.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/tests/test_speak.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/tests/test_stream_cap.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/tests/test_talk.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/tests/test_terminal_ack.py +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
640
|
-
"""
|
|
639
|
+
def _owner_id(self) -> str:
|
|
640
|
+
"""The Leedarson ``owner`` (userId) header value some IPC endpoints require.
|
|
641
641
|
|
|
642
|
-
|
|
643
|
-
|
|
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
|
-
|
|
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
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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,
|
|
668
|
-
timeout=aiohttp.ClientTimeout(total=
|
|
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("
|
|
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("
|
|
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
|
-
|
|
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
|
-
#
|
|
2160
|
-
#
|
|
2161
|
-
#
|
|
2162
|
-
#
|
|
2163
|
-
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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
|
-
#
|
|
2299
|
-
#
|
|
2300
|
-
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
3171
|
-
#
|
|
3172
|
-
#
|
|
3173
|
-
#
|
|
3174
|
-
|
|
3175
|
-
|
|
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.
|
|
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
|
|
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.16 → python_aidot_cameras-0.7.17}/src/aidot/models/device_client_model.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/src/aidot/models/device_model.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/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
|
|
File without changes
|
{python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/tests/test_highport_nomination.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/tests/test_no_undefined_names.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.17}/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
|