python-aidot-cameras 0.7.16__tar.gz → 0.7.18__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.18}/PKG-INFO +1 -1
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18}/pyproject.toml +1 -1
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18}/src/aidot/camera/client.py +181 -92
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18}/src/aidot/camera/protocol.py +223 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18/src/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18}/src/python_aidot_cameras.egg-info/SOURCES.txt +1 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18}/tests/test_backoff.py +41 -1
- python_aidot_cameras-0.7.18/tests/test_serve_relay.py +179 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18}/LICENSE +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18}/MANIFEST.in +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18}/README.md +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18}/setup.cfg +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18}/src/aidot/__init__.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18}/src/aidot/aes_utils.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18}/src/aidot/camera/__init__.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18}/src/aidot/camera/constants.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18}/src/aidot/camera/controls.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18}/src/aidot/camera/go2rtc.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18}/src/aidot/camera/lan_control.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18}/src/aidot/camera/models.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18}/src/aidot/camera/playback.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18}/src/aidot/camera/sdes.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18}/src/aidot/camera/tutk.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18}/src/aidot/camera/webrtc.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18}/src/aidot/client.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18}/src/aidot/const.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18}/src/aidot/credentials.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18}/src/aidot/device_client.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18}/src/aidot/discover.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18}/src/aidot/exceptions.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18}/src/aidot/g711.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18}/src/aidot/login_const.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18}/src/aidot/models/__init__.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18}/src/aidot/models/device_client_model.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18}/src/aidot/models/device_model.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18}/src/aidot/models/discover_model.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18}/src/aidot/py.typed +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18}/src/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18}/src/python_aidot_cameras.egg-info/requires.txt +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18}/src/python_aidot_cameras.egg-info/top_level.txt +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18}/tests/test_alarm_event.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18}/tests/test_go2rtc.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18}/tests/test_highport_nomination.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18}/tests/test_lan_control.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18}/tests/test_live_stream_param.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18}/tests/test_motion_poll.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18}/tests/test_no_undefined_names.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18}/tests/test_post_merge_hardening.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18}/tests/test_sdes_idle_release.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18}/tests/test_sdes_sprop.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18}/tests/test_sdes_talk.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18}/tests/test_sdes_watchdog.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18}/tests/test_speak.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18}/tests/test_stream_cap.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18}/tests/test_talk.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18}/tests/test_terminal_ack.py +0 -0
- {python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18}/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.18
|
|
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.18"
|
|
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,
|
|
@@ -55,6 +55,9 @@ from .protocol import ( # split into protocol.py; re-imported for use + back-co
|
|
|
55
55
|
_save_sprop,
|
|
56
56
|
_inject_sprop,
|
|
57
57
|
_sdes_serve_port,
|
|
58
|
+
_ServeRelay,
|
|
59
|
+
_rewrite_serve_port,
|
|
60
|
+
_grab_free_port,
|
|
58
61
|
_tcp_table_has_established_on_port,
|
|
59
62
|
_idle_release_due,
|
|
60
63
|
_dtls_av_mux_run,
|
|
@@ -502,6 +505,9 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
502
505
|
# camera.stream_source() can wait and hand HA a ready URL (avoids HA's
|
|
503
506
|
# ~40s connection-refused retry gap on cold start).
|
|
504
507
|
self._serve_ready: "asyncio.Event" = asyncio.Event()
|
|
508
|
+
# Cold-start serve-port relay (holds the public port connectable through
|
|
509
|
+
# the handshake so an eager go2rtc pull waits instead of being refused).
|
|
510
|
+
self._serve_relay: "Optional[_ServeRelay]" = None
|
|
505
511
|
# Motion/event polling (cloud event list). NOTE: the camera does NOT push motion
|
|
506
512
|
# to a passive MQTT subscriber - alarmType is only emitted during an active live
|
|
507
513
|
# view (decompiled NewLiveFragment) - so real-time-ish motion for HA comes from
|
|
@@ -636,48 +642,67 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
636
642
|
"Content-Type": "application/json",
|
|
637
643
|
}
|
|
638
644
|
|
|
639
|
-
|
|
640
|
-
"""
|
|
645
|
+
def _owner_id(self) -> str:
|
|
646
|
+
"""The Leedarson ``owner`` (userId) header value some IPC endpoints require.
|
|
641
647
|
|
|
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).
|
|
648
|
+
``_leedarson_headers`` deliberately omits ``owner`` (it breaks
|
|
649
|
+
recording/playback); the wake and liveStreamParam endpoints need it.
|
|
649
650
|
"""
|
|
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"] = (
|
|
651
|
+
return (
|
|
656
652
|
self._user_info.get("owner")
|
|
657
653
|
or self._user_info.get("id")
|
|
658
654
|
or self._user_info.get("userId")
|
|
659
655
|
or str(self.user_id)
|
|
660
656
|
)
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
657
|
+
|
|
658
|
+
async def _async_post_ok(
|
|
659
|
+
self, url: str, headers: dict, body: str, *, timeout: float = 10.0,
|
|
660
|
+
label: str = "ipc post",
|
|
661
|
+
) -> bool:
|
|
662
|
+
"""POST a JSON ``body`` string to a Leedarson IPC endpoint; True on success.
|
|
663
|
+
|
|
664
|
+
Success = HTTP 200 with a ``code`` of 0/200/absent. Best-effort: any
|
|
665
|
+
network/parse error returns False, never raises. Shared by the wake and
|
|
666
|
+
liveStreamParam provisioning calls.
|
|
667
|
+
"""
|
|
668
|
+
import aiohttp
|
|
669
|
+
|
|
664
670
|
try:
|
|
665
671
|
async with aiohttp.ClientSession() as session:
|
|
666
672
|
async with session.post(
|
|
667
|
-
url, headers=headers,
|
|
668
|
-
timeout=aiohttp.ClientTimeout(total=
|
|
673
|
+
url, headers=headers, data=body,
|
|
674
|
+
timeout=aiohttp.ClientTimeout(total=timeout),
|
|
669
675
|
) as resp:
|
|
670
676
|
status = resp.status
|
|
671
677
|
data = await resp.json(content_type=None)
|
|
672
678
|
code = data.get("code") if isinstance(data, dict) else None
|
|
673
679
|
ok = status == 200 and (code in (None, 0, 200, "0", "200"))
|
|
674
|
-
_LOGGER.debug("
|
|
675
|
-
self.device_id, status, code, ok)
|
|
680
|
+
_LOGGER.debug("%s %s: status=%s code=%s ok=%s",
|
|
681
|
+
label, self.device_id, status, code, ok)
|
|
676
682
|
return bool(ok)
|
|
677
683
|
except Exception as exc:
|
|
678
|
-
_LOGGER.debug("
|
|
684
|
+
_LOGGER.debug("%s failed for %s: %s", label, self.device_id, exc)
|
|
679
685
|
return False
|
|
680
686
|
|
|
687
|
+
async def _async_http_wake(self) -> bool:
|
|
688
|
+
"""Wake a battery camera via the cloud HTTP low-power endpoint.
|
|
689
|
+
|
|
690
|
+
Mirrors DeviceWakeUpRepos.j() in the official app (n.java): POST
|
|
691
|
+
``{smarthome}/api/ipc/devices/{id}/lowPowerActiveState`` with body
|
|
692
|
+
``{"deviceId": id, "status": "wakeup"}`` and owner+token headers. The
|
|
693
|
+
cloud forwards the wake to the camera's always-on low-power channel, so it
|
|
694
|
+
reaches a deeply-sleeping camera that has dropped its MQTT session - unlike
|
|
695
|
+
the MQTT ``lowPowerActiveStateReq``, which only lands if the camera is
|
|
696
|
+
already connected. Returns True if the request was accepted (HTTP 200).
|
|
697
|
+
"""
|
|
698
|
+
# The wake endpoint (unlike recording/playback) DOES require owner (n.java:71).
|
|
699
|
+
headers = self._leedarson_headers()
|
|
700
|
+
headers["owner"] = self._owner_id()
|
|
701
|
+
url = (f"{self._smarthome_base}/api/ipc/devices/"
|
|
702
|
+
f"{self.device_id}/lowPowerActiveState")
|
|
703
|
+
body = json.dumps({"deviceId": self.device_id, "status": "wakeup"})
|
|
704
|
+
return await self._async_post_ok(url, headers, body, label="http wake")
|
|
705
|
+
|
|
681
706
|
def _live_stream_param_request(self):
|
|
682
707
|
"""Build ``(url, headers, body)`` for the liveStreamParam provision call.
|
|
683
708
|
|
|
@@ -686,12 +711,7 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
686
711
|
``owner`` header is required (as on the wake endpoint).
|
|
687
712
|
"""
|
|
688
713
|
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
|
-
)
|
|
714
|
+
headers["owner"] = self._owner_id()
|
|
695
715
|
url = f"{self._smarthome_base}/api/ipc/liveStream/liveStreamParam"
|
|
696
716
|
return url, headers, json.dumps([self.device_id])
|
|
697
717
|
|
|
@@ -706,31 +726,10 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
706
726
|
provisions the session (and brings the camera online) and returns AWS KVS
|
|
707
727
|
credentials. We only need the provisioning side effect - the library
|
|
708
728
|
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.
|
|
729
|
+
credentials are ignored. Best-effort: returns True on HTTP 200.
|
|
714
730
|
"""
|
|
715
|
-
import aiohttp
|
|
716
|
-
|
|
717
731
|
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
|
|
732
|
+
return await self._async_post_ok(url, headers, body, label="liveStreamParam")
|
|
734
733
|
|
|
735
734
|
async def async_wake_camera(self, retries: int = 2) -> bool:
|
|
736
735
|
"""Wake a battery camera on demand via the cloud HTTP low-power endpoint.
|
|
@@ -1945,6 +1944,8 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
1945
1944
|
fast_connect: Optional[bool] = None,
|
|
1946
1945
|
sdes_audio: Optional[bool] = None,
|
|
1947
1946
|
go2rtc_url: Optional[str] = None,
|
|
1947
|
+
live_stream_param: Optional[bool] = None,
|
|
1948
|
+
serve_relay: Optional[bool] = None,
|
|
1948
1949
|
) -> None:
|
|
1949
1950
|
"""Start a persistent stream that keeps the camera session alive.
|
|
1950
1951
|
|
|
@@ -1962,12 +1963,27 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
1962
1963
|
to the ``AIDOT_FAST_CONNECT`` env var; ``True``/``False`` override it (e.g.
|
|
1963
1964
|
from a Home Assistant config-entry option, since HA OS can't set env vars).
|
|
1964
1965
|
|
|
1966
|
+
``live_stream_param`` toggles the battery-camera cloud pre-connect
|
|
1967
|
+
(liveStreamParam); like ``fast_connect`` it overrides the
|
|
1968
|
+
``AIDOT_LIVESTREAM_PARAM`` env var, so an integration on HA OS (no env vars)
|
|
1969
|
+
can still disable it per camera.
|
|
1970
|
+
|
|
1971
|
+
``serve_relay`` toggles the cold-start serve-port relay (holds the public
|
|
1972
|
+
serve port connectable through the WebRTC handshake so an eager go2rtc
|
|
1973
|
+
pull waits instead of getting connection-refused). Overrides the
|
|
1974
|
+
``AIDOT_SERVE_RELAY`` env var (default on); set False to serve ffmpeg
|
|
1975
|
+
directly on the public port (the pre-0.7.18 behaviour).
|
|
1976
|
+
|
|
1965
1977
|
Safe to call multiple times - does nothing if already running.
|
|
1966
1978
|
"""
|
|
1967
1979
|
if fast_connect is not None:
|
|
1968
1980
|
self._fast_connect_opt = fast_connect
|
|
1969
1981
|
if sdes_audio is not None:
|
|
1970
1982
|
self._sdes_audio_opt = sdes_audio
|
|
1983
|
+
if live_stream_param is not None:
|
|
1984
|
+
self._live_stream_param_opt = live_stream_param
|
|
1985
|
+
if serve_relay is not None:
|
|
1986
|
+
self._serve_relay_opt = serve_relay
|
|
1971
1987
|
if self._stream_task is not None and not self._stream_task.done():
|
|
1972
1988
|
return
|
|
1973
1989
|
self._keepalive_rtsp_url = rtsp_push_url
|
|
@@ -2153,16 +2169,35 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
2153
2169
|
return found if read_any else None
|
|
2154
2170
|
|
|
2155
2171
|
async def _sdes_keepalive_loop(self) -> None:
|
|
2172
|
+
"""Keep the cold-start serve relay alive around the SDES keepalive loop.
|
|
2173
|
+
|
|
2174
|
+
The relay holds the public serve port connectable through every (re)open
|
|
2175
|
+
so an eager go2rtc pull waits instead of getting connection-refused while
|
|
2176
|
+
the SDES handshake runs (the same race fixed for the DTLS serve)."""
|
|
2177
|
+
self._serve_relay = self._maybe_start_serve_relay(self._keepalive_rtsp_url)
|
|
2178
|
+
try:
|
|
2179
|
+
await self._sdes_keepalive_loop_inner()
|
|
2180
|
+
finally:
|
|
2181
|
+
_relay = self._serve_relay
|
|
2182
|
+
self._serve_relay = None
|
|
2183
|
+
if _relay is not None:
|
|
2184
|
+
_relay.close()
|
|
2185
|
+
|
|
2186
|
+
async def _sdes_keepalive_loop_inner(self) -> None:
|
|
2156
2187
|
"""Background task: keep SDES stream alive; push to go2rtc via RTSP."""
|
|
2157
2188
|
_MIN_DELAY = 10.0
|
|
2158
2189
|
_MAX_DELAY = 300.0
|
|
2159
|
-
#
|
|
2160
|
-
#
|
|
2161
|
-
#
|
|
2162
|
-
#
|
|
2163
|
-
|
|
2190
|
+
# Jittered-backoff pacer: escalates on failed/no-media opens so a degraded
|
|
2191
|
+
# camera (or a fleet reconnecting at once) isn't hammered into further
|
|
2192
|
+
# degradation / cloud rate-limiting; resets after a session that delivered
|
|
2193
|
+
# media (see end of loop).
|
|
2194
|
+
_pacer = ReconnectPacer(_MIN_DELAY, _MAX_DELAY)
|
|
2164
2195
|
|
|
2165
2196
|
while self._streaming_active:
|
|
2197
|
+
if self._serve_relay is not None:
|
|
2198
|
+
# Clear any stale backend from a prior session; the open below
|
|
2199
|
+
# points the relay at this session's fresh internal ffmpeg port.
|
|
2200
|
+
self._serve_relay.set_backend(None)
|
|
2166
2201
|
try:
|
|
2167
2202
|
session = await self.async_open_webrtc_stream(
|
|
2168
2203
|
rtsp_push_url=self._keepalive_rtsp_url,
|
|
@@ -2171,7 +2206,7 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
2171
2206
|
except asyncio.CancelledError:
|
|
2172
2207
|
return
|
|
2173
2208
|
except Exception as exc:
|
|
2174
|
-
_delay =
|
|
2209
|
+
_delay = _pacer.fail_delay()
|
|
2175
2210
|
_LOGGER.warning(
|
|
2176
2211
|
"SDES keepalive: stream open failed for %s (retry in %.0fs): %s",
|
|
2177
2212
|
self.device_id, _delay, exc,
|
|
@@ -2180,7 +2215,6 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
2180
2215
|
await asyncio.sleep(_delay)
|
|
2181
2216
|
except asyncio.CancelledError:
|
|
2182
2217
|
return
|
|
2183
|
-
_attempt += 1
|
|
2184
2218
|
continue
|
|
2185
2219
|
|
|
2186
2220
|
self._stream_session = session
|
|
@@ -2270,16 +2304,10 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
2270
2304
|
# Escalate backoff only when the session never delivered media
|
|
2271
2305
|
# (camera refused / degraded on a rapid reconnect); a session
|
|
2272
2306
|
# 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)
|
|
2307
|
+
# gone) is a normal lifecycle event and resets to the base interval.
|
|
2308
|
+
_healthy = session.last_media_monotonic > 0.0
|
|
2281
2309
|
try:
|
|
2282
|
-
await asyncio.sleep(
|
|
2310
|
+
await asyncio.sleep(_pacer.session_end_delay(healthy=_healthy))
|
|
2283
2311
|
except asyncio.CancelledError:
|
|
2284
2312
|
return
|
|
2285
2313
|
|
|
@@ -2295,9 +2323,9 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
2295
2323
|
# to 15 s here (+ the SDES/serve loops) if strict app parity is wanted.
|
|
2296
2324
|
_MIN_DELAY = 5.0
|
|
2297
2325
|
_MAX_DELAY = 300.0
|
|
2298
|
-
#
|
|
2299
|
-
#
|
|
2300
|
-
|
|
2326
|
+
# Jittered-backoff pacer: escalates on failed/frameless opens, resets after
|
|
2327
|
+
# a session that produced frames.
|
|
2328
|
+
_pacer = ReconnectPacer(_MIN_DELAY, _MAX_DELAY)
|
|
2301
2329
|
|
|
2302
2330
|
def _on_frame(frame) -> None:
|
|
2303
2331
|
# Accept keyframes always; accept P-frames only after first keyframe.
|
|
@@ -2339,7 +2367,7 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
2339
2367
|
return
|
|
2340
2368
|
continue
|
|
2341
2369
|
except Exception as exc:
|
|
2342
|
-
_delay =
|
|
2370
|
+
_delay = _pacer.fail_delay()
|
|
2343
2371
|
_LOGGER.warning(
|
|
2344
2372
|
"Stream open failed for %s (retry in %.0fs): %s",
|
|
2345
2373
|
self.device_id, _delay, exc,
|
|
@@ -2348,7 +2376,6 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
2348
2376
|
await asyncio.sleep(_delay)
|
|
2349
2377
|
except asyncio.CancelledError:
|
|
2350
2378
|
return
|
|
2351
|
-
_attempt += 1
|
|
2352
2379
|
continue
|
|
2353
2380
|
|
|
2354
2381
|
self._stream_session = session
|
|
@@ -2378,14 +2405,9 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
2378
2405
|
# Reset backoff if this session produced frames (a normal drop
|
|
2379
2406
|
# after good streaming); escalate with jitter if it never did
|
|
2380
2407
|
# (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)
|
|
2408
|
+
_produced = self._last_frame_time > _open_time
|
|
2387
2409
|
try:
|
|
2388
|
-
await asyncio.sleep(
|
|
2410
|
+
await asyncio.sleep(_pacer.session_end_delay(healthy=_produced))
|
|
2389
2411
|
except asyncio.CancelledError:
|
|
2390
2412
|
return
|
|
2391
2413
|
|
|
@@ -2458,6 +2480,38 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
2458
2480
|
got_a = self._install_encoded_tap(_r, aq, False)
|
|
2459
2481
|
return got_v
|
|
2460
2482
|
|
|
2483
|
+
def _maybe_start_serve_relay(self, serve_url: Optional[str]) -> "Optional[_ServeRelay]":
|
|
2484
|
+
"""Hold the public serve port via a _ServeRelay so an eager go2rtc pull
|
|
2485
|
+
connects-and-waits instead of hitting ECONNREFUSED during the ~16-25s
|
|
2486
|
+
cold handshake (ffmpeg only binds its -listen socket after input frames).
|
|
2487
|
+
|
|
2488
|
+
Returns the started relay, or None when disabled / not an http serve /
|
|
2489
|
+
the bind fails - in which case the caller serves ffmpeg directly on the
|
|
2490
|
+
public port (the pre-relay behaviour, so a bind clash never breaks
|
|
2491
|
+
streaming)."""
|
|
2492
|
+
if not (serve_url and serve_url.startswith("http")):
|
|
2493
|
+
return None
|
|
2494
|
+
opt = getattr(self, "_serve_relay_opt", None)
|
|
2495
|
+
if opt is None:
|
|
2496
|
+
opt = os.environ.get("AIDOT_SERVE_RELAY", "1") != "0"
|
|
2497
|
+
if not opt:
|
|
2498
|
+
return None
|
|
2499
|
+
port = _sdes_serve_port(serve_url)
|
|
2500
|
+
if port is None:
|
|
2501
|
+
return None
|
|
2502
|
+
relay = _ServeRelay(port)
|
|
2503
|
+
try:
|
|
2504
|
+
relay.start()
|
|
2505
|
+
except OSError as exc:
|
|
2506
|
+
_LOGGER.warning(
|
|
2507
|
+
"serve relay: bind :%s failed (%s) - serving ffmpeg directly",
|
|
2508
|
+
port, exc,
|
|
2509
|
+
)
|
|
2510
|
+
return None
|
|
2511
|
+
_LOGGER.debug(
|
|
2512
|
+
"serve relay: holding public port :%s for %s", port, self.device_id)
|
|
2513
|
+
return relay
|
|
2514
|
+
|
|
2461
2515
|
async def _dtls_serve_loop(self) -> None:
|
|
2462
2516
|
"""Acquire a concurrent-stream slot, then run the serve loop.
|
|
2463
2517
|
|
|
@@ -2472,9 +2526,14 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
2472
2526
|
self.device_id,
|
|
2473
2527
|
)
|
|
2474
2528
|
await slots.acquire()
|
|
2529
|
+
self._serve_relay = self._maybe_start_serve_relay(self._keepalive_rtsp_url)
|
|
2475
2530
|
try:
|
|
2476
2531
|
await self._dtls_serve_loop_inner()
|
|
2477
2532
|
finally:
|
|
2533
|
+
_relay = self._serve_relay
|
|
2534
|
+
self._serve_relay = None
|
|
2535
|
+
if _relay is not None:
|
|
2536
|
+
_relay.close()
|
|
2478
2537
|
slots.release()
|
|
2479
2538
|
|
|
2480
2539
|
async def _dtls_serve_loop_inner(self) -> None:
|
|
@@ -2501,7 +2560,11 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
2501
2560
|
# attempts so no code path (reset bug, fast PC-death) can pound the camera.
|
|
2502
2561
|
_MIN_DELAY, _MAX_DELAY = 15.0, 300.0
|
|
2503
2562
|
_open_gate = float(os.environ.get("AIDOT_DTLS_RETRY_GATE_S", "15"))
|
|
2504
|
-
|
|
2563
|
+
# Jittered-backoff pacer for failed opens. Unlike the SDES/JPEG loops this
|
|
2564
|
+
# serve loop resets on any successful open (it has the _open_gate spacing
|
|
2565
|
+
# below) rather than escalating on no-media - hence reset() not
|
|
2566
|
+
# session_end_delay().
|
|
2567
|
+
_pacer = ReconnectPacer(_MIN_DELAY, _MAX_DELAY)
|
|
2505
2568
|
_last_open_at = 0.0 # monotonic of the previous open attempt (0 = none yet)
|
|
2506
2569
|
loop = asyncio.get_running_loop()
|
|
2507
2570
|
while self._streaming_active:
|
|
@@ -2537,7 +2600,7 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
2537
2600
|
return
|
|
2538
2601
|
continue
|
|
2539
2602
|
except Exception as exc:
|
|
2540
|
-
_delay =
|
|
2603
|
+
_delay = _pacer.fail_delay()
|
|
2541
2604
|
_LOGGER.warning(
|
|
2542
2605
|
"DTLS serve: open failed for %s (retry %.0fs): %s",
|
|
2543
2606
|
self.device_id, _delay, exc,
|
|
@@ -2546,10 +2609,9 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
2546
2609
|
await asyncio.sleep(_delay)
|
|
2547
2610
|
except asyncio.CancelledError:
|
|
2548
2611
|
return
|
|
2549
|
-
_attempt += 1
|
|
2550
2612
|
continue
|
|
2551
2613
|
|
|
2552
|
-
|
|
2614
|
+
_pacer.reset()
|
|
2553
2615
|
self._stream_session = session
|
|
2554
2616
|
pc = session._pc
|
|
2555
2617
|
# PC-dead test for the serve loop. closed/failed are terminal at
|
|
@@ -2599,12 +2661,24 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
2599
2661
|
except _queue.Empty:
|
|
2600
2662
|
pass
|
|
2601
2663
|
await self._send_video_pli(pc) # request a clean keyframe
|
|
2664
|
+
# With the relay holding the public port, ffmpeg listens on a
|
|
2665
|
+
# fresh internal port each cycle and the relay splices to it;
|
|
2666
|
+
# without it, ffmpeg owns the public port directly (old path).
|
|
2667
|
+
_relay = self._serve_relay
|
|
2668
|
+
if _relay is not None:
|
|
2669
|
+
_ff_port = _grab_free_port()
|
|
2670
|
+
_ff_url = _rewrite_serve_port(serve_url, _ff_port)
|
|
2671
|
+
else:
|
|
2672
|
+
_ff_port = None
|
|
2673
|
+
_ff_url = serve_url
|
|
2602
2674
|
rfd, wfd = os.pipe()
|
|
2603
|
-
proc = await self._spawn_dtls_serve_ffmpeg(
|
|
2675
|
+
proc = await self._spawn_dtls_serve_ffmpeg(_ff_url, rfd)
|
|
2604
2676
|
os.close(rfd)
|
|
2605
2677
|
if proc is None:
|
|
2606
2678
|
os.close(wfd)
|
|
2607
2679
|
break
|
|
2680
|
+
if _relay is not None:
|
|
2681
|
+
_relay.set_backend(_ff_port)
|
|
2608
2682
|
wfile = os.fdopen(wfd, "wb", buffering=0)
|
|
2609
2683
|
progress = [loop.time()]
|
|
2610
2684
|
stop_flag = _threading.Event()
|
|
@@ -2661,6 +2735,8 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
2661
2735
|
wfile = None
|
|
2662
2736
|
mux_thread.join(timeout=2.0)
|
|
2663
2737
|
mux_thread = stop_flag = None
|
|
2738
|
+
if _relay is not None:
|
|
2739
|
+
_relay.set_backend(None) # no backend until next ffmpeg
|
|
2664
2740
|
_terminate_proc(proc)
|
|
2665
2741
|
proc = None
|
|
2666
2742
|
if idle_release:
|
|
@@ -3167,12 +3243,15 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
3167
3243
|
# -50019 ("not ready") and never runs ICE -> no media. App parity:
|
|
3168
3244
|
# KVSPreConnectStrategy.fetchKvsParams calls liveStreamParam first. We
|
|
3169
3245
|
# 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
|
-
|
|
3246
|
+
# AWS KVS). Mains cameras are always stream-ready, so skip them.
|
|
3247
|
+
# Validated live on L2_170 (A001513): with this call livePlay succeeds and
|
|
3248
|
+
# decrypted RTP flows; without it, persistent -50019. Controllable like
|
|
3249
|
+
# fast_connect/sdes_audio: start_keepalive(live_stream_param=...) wins,
|
|
3250
|
+
# else the AIDOT_LIVESTREAM_PARAM env var (default on).
|
|
3251
|
+
_lsp = getattr(self, "_live_stream_param_opt", None)
|
|
3252
|
+
if _lsp is None:
|
|
3253
|
+
_lsp = os.environ.get("AIDOT_LIVESTREAM_PARAM", "1") != "0"
|
|
3254
|
+
if self.is_battery_camera and _lsp:
|
|
3176
3255
|
_lsp_ok = await self._async_fetch_live_stream_param()
|
|
3177
3256
|
_LOGGER.debug("camera %s: liveStreamParam provisioned ok=%s",
|
|
3178
3257
|
self.device_id, _lsp_ok)
|
|
@@ -9428,6 +9507,16 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
9428
9507
|
)
|
|
9429
9508
|
|
|
9430
9509
|
if rtsp_push_url and rtsp_push_url.startswith("http"):
|
|
9510
|
+
# Cold-start relay: when the keepalive loop is holding the public
|
|
9511
|
+
# serve port, point ffmpeg at a fresh internal port and let the relay
|
|
9512
|
+
# splice the public port to it - so an early go2rtc pull waits through
|
|
9513
|
+
# this handshake instead of being refused. set_backend retries the
|
|
9514
|
+
# dial until ffmpeg's -listen socket binds (only after input frames).
|
|
9515
|
+
_relay = getattr(self, "_serve_relay", None)
|
|
9516
|
+
if _relay is not None:
|
|
9517
|
+
_ff_port = _grab_free_port()
|
|
9518
|
+
rtsp_push_url = _rewrite_serve_port(rtsp_push_url, _ff_port)
|
|
9519
|
+
_relay.set_backend(_ff_port)
|
|
9431
9520
|
# PULL model: SERVE the decrypted stream over an HTTP-listen socket so
|
|
9432
9521
|
# go2rtc / HA's stream integration pull it the standard way
|
|
9433
9522
|
# (streams.add) - no go2rtc pre-registration needed. ffmpeg -listen 1
|
|
@@ -12,6 +12,8 @@ import json
|
|
|
12
12
|
import logging
|
|
13
13
|
import os
|
|
14
14
|
import random
|
|
15
|
+
import select
|
|
16
|
+
import socket
|
|
15
17
|
import struct
|
|
16
18
|
import tempfile
|
|
17
19
|
import threading
|
|
@@ -429,6 +431,46 @@ def next_backoff(
|
|
|
429
431
|
return base + rand() * (ceiling - base)
|
|
430
432
|
|
|
431
433
|
|
|
434
|
+
class ReconnectPacer:
|
|
435
|
+
"""Reconnect-delay state machine shared by the camera reconnect loops.
|
|
436
|
+
|
|
437
|
+
Centralizes the attempt counter, the jittered-backoff math (:func:`next_backoff`),
|
|
438
|
+
and the escalate-on-failure / reset-on-healthy policy + increment ordering that
|
|
439
|
+
the SDES keepalive, JPEG streaming, and DTLS serve loops would otherwise each
|
|
440
|
+
re-implement. Each loop keeps only its own liveness signal and feeds it in, so
|
|
441
|
+
a loop's policy (does it escalate on no-media, or just reset on open?) is an
|
|
442
|
+
explicit method call rather than hidden in copy-pasted counter bookkeeping.
|
|
443
|
+
Not thread-safe; one instance per loop scope.
|
|
444
|
+
"""
|
|
445
|
+
|
|
446
|
+
def __init__(
|
|
447
|
+
self, base: float, cap: float,
|
|
448
|
+
*, rand: Optional[Callable[[], float]] = None,
|
|
449
|
+
) -> None:
|
|
450
|
+
self._base = base
|
|
451
|
+
self._cap = cap
|
|
452
|
+
self._rand = rand # forwarded to next_backoff; injectable for tests
|
|
453
|
+
self._attempt = 0
|
|
454
|
+
|
|
455
|
+
def fail_delay(self) -> float:
|
|
456
|
+
"""Delay after a failed OPEN (no session established): the current attempt's
|
|
457
|
+
backoff, then escalate - so the first failure waits exactly ``base``."""
|
|
458
|
+
delay = next_backoff(self._attempt, base=self._base, cap=self._cap, rand=self._rand)
|
|
459
|
+
self._attempt += 1
|
|
460
|
+
return delay
|
|
461
|
+
|
|
462
|
+
def session_end_delay(self, *, healthy: bool) -> float:
|
|
463
|
+
"""Delay after a session ENDED: reset to ``base`` if it delivered media
|
|
464
|
+
(``healthy``), else escalate first, then return the delay."""
|
|
465
|
+
self._attempt = 0 if healthy else self._attempt + 1
|
|
466
|
+
return next_backoff(self._attempt, base=self._base, cap=self._cap, rand=self._rand)
|
|
467
|
+
|
|
468
|
+
def reset(self) -> None:
|
|
469
|
+
"""Clear the failure count - e.g. after a successful open in a loop that has
|
|
470
|
+
no end-of-session escalation (the DTLS serve loop, gated by its own spacing)."""
|
|
471
|
+
self._attempt = 0
|
|
472
|
+
|
|
473
|
+
|
|
432
474
|
def _write_text_file(path: str, text: str) -> None:
|
|
433
475
|
"""Write ``text`` to ``path`` synchronously.
|
|
434
476
|
|
|
@@ -572,6 +614,187 @@ def _sdes_serve_port(url: "Optional[str]") -> "Optional[int]":
|
|
|
572
614
|
return None
|
|
573
615
|
|
|
574
616
|
|
|
617
|
+
class _ServeRelay:
|
|
618
|
+
"""Keep a public TCP serve port continuously connectable while the real
|
|
619
|
+
server (ffmpeg ``-listen 1``) comes and goes on an internal port.
|
|
620
|
+
|
|
621
|
+
Why this exists: the library advertises the public serve port to go2rtc / HA
|
|
622
|
+
the moment a view starts, but ffmpeg only binds its ``-listen`` socket AFTER
|
|
623
|
+
the ~16-25 s WebRTC handshake delivers the first frames - ffmpeg probes its
|
|
624
|
+
input before it opens any output (verified empirically: ``ffmpeg -i pipe:0
|
|
625
|
+
... -f mpegts -listen 1`` does not bind the port until input data flows). An
|
|
626
|
+
eager pull therefore hits ``ECONNREFUSED`` and go2rtc gives up within
|
|
627
|
+
~200 ms, so the camera card stays blank on the first (cold) view.
|
|
628
|
+
|
|
629
|
+
This relay binds the public port up front and holds it for the whole
|
|
630
|
+
streaming session. An early pull CONNECTS and waits; the relay keeps
|
|
631
|
+
redialing the current internal ffmpeg port (set via :meth:`set_backend`) and,
|
|
632
|
+
once ffmpeg is listening, splices bytes both ways. The public listener
|
|
633
|
+
survives ffmpeg restarts (go2rtc reconnects), so the consumer never sees a
|
|
634
|
+
refused connection mid-session.
|
|
635
|
+
"""
|
|
636
|
+
|
|
637
|
+
def __init__(self, public_port: int, *, host: str = "127.0.0.1",
|
|
638
|
+
dial_timeout: float = 90.0, dial_interval: float = 0.1) -> None:
|
|
639
|
+
self._public_port = public_port
|
|
640
|
+
self._host = host
|
|
641
|
+
self._dial_timeout = dial_timeout
|
|
642
|
+
self._dial_interval = dial_interval
|
|
643
|
+
self._backend_port: "Optional[int]" = None
|
|
644
|
+
self._listen: "Optional[socket.socket]" = None
|
|
645
|
+
self._accept_thread: "Optional[threading.Thread]" = None
|
|
646
|
+
self._conns: "set[socket.socket]" = set()
|
|
647
|
+
self._lock = threading.Lock()
|
|
648
|
+
self._closed = threading.Event()
|
|
649
|
+
|
|
650
|
+
@property
|
|
651
|
+
def port(self) -> int:
|
|
652
|
+
"""The bound public port (resolved after :meth:`start` if 0 was passed)."""
|
|
653
|
+
return self._public_port
|
|
654
|
+
|
|
655
|
+
def start(self) -> None:
|
|
656
|
+
"""Bind the public port and begin accepting. Raises OSError on bind."""
|
|
657
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
658
|
+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
659
|
+
s.bind((self._host, self._public_port))
|
|
660
|
+
s.listen(8)
|
|
661
|
+
s.settimeout(0.5)
|
|
662
|
+
self._public_port = s.getsockname()[1]
|
|
663
|
+
self._listen = s
|
|
664
|
+
self._accept_thread = threading.Thread(
|
|
665
|
+
target=self._accept_loop,
|
|
666
|
+
name=f"serve-relay-{self._public_port}",
|
|
667
|
+
daemon=True,
|
|
668
|
+
)
|
|
669
|
+
self._accept_thread.start()
|
|
670
|
+
|
|
671
|
+
def set_backend(self, port: "Optional[int]") -> None:
|
|
672
|
+
"""Point the relay at the current internal ffmpeg port (None = no backend
|
|
673
|
+
running yet; connected pulls wait for the next backend)."""
|
|
674
|
+
with self._lock:
|
|
675
|
+
self._backend_port = port
|
|
676
|
+
|
|
677
|
+
def close(self) -> None:
|
|
678
|
+
"""Tear down the listener, the accept thread, and any live connections."""
|
|
679
|
+
self._closed.set()
|
|
680
|
+
try:
|
|
681
|
+
if self._listen is not None:
|
|
682
|
+
self._listen.close()
|
|
683
|
+
except OSError:
|
|
684
|
+
pass
|
|
685
|
+
with self._lock:
|
|
686
|
+
conns = list(self._conns)
|
|
687
|
+
self._conns.clear()
|
|
688
|
+
for s in conns:
|
|
689
|
+
try:
|
|
690
|
+
s.close()
|
|
691
|
+
except OSError:
|
|
692
|
+
pass
|
|
693
|
+
t = self._accept_thread
|
|
694
|
+
if t is not None:
|
|
695
|
+
t.join(timeout=2.0)
|
|
696
|
+
|
|
697
|
+
def _accept_loop(self) -> None:
|
|
698
|
+
while not self._closed.is_set():
|
|
699
|
+
try:
|
|
700
|
+
cli, _ = self._listen.accept() # type: ignore[union-attr]
|
|
701
|
+
except TimeoutError:
|
|
702
|
+
continue
|
|
703
|
+
except OSError:
|
|
704
|
+
break
|
|
705
|
+
threading.Thread(
|
|
706
|
+
target=self._handle, args=(cli,), daemon=True
|
|
707
|
+
).start()
|
|
708
|
+
|
|
709
|
+
def _handle(self, cli: "socket.socket") -> None:
|
|
710
|
+
with self._lock:
|
|
711
|
+
self._conns.add(cli)
|
|
712
|
+
be: "Optional[socket.socket]" = None
|
|
713
|
+
try:
|
|
714
|
+
be = self._dial_backend()
|
|
715
|
+
if be is not None:
|
|
716
|
+
self._splice(cli, be)
|
|
717
|
+
finally:
|
|
718
|
+
for s in (cli, be):
|
|
719
|
+
if s is not None:
|
|
720
|
+
try:
|
|
721
|
+
s.close()
|
|
722
|
+
except OSError:
|
|
723
|
+
pass
|
|
724
|
+
with self._lock:
|
|
725
|
+
self._conns.discard(cli)
|
|
726
|
+
|
|
727
|
+
def _dial_backend(self) -> "Optional[socket.socket]":
|
|
728
|
+
"""Block until the backend is set + connectable (or timeout / close)."""
|
|
729
|
+
deadline = time.monotonic() + self._dial_timeout
|
|
730
|
+
while not self._closed.is_set() and time.monotonic() < deadline:
|
|
731
|
+
with self._lock:
|
|
732
|
+
port = self._backend_port
|
|
733
|
+
if port:
|
|
734
|
+
try:
|
|
735
|
+
return socket.create_connection(
|
|
736
|
+
(self._host, port), timeout=2.0)
|
|
737
|
+
except OSError:
|
|
738
|
+
pass
|
|
739
|
+
time.sleep(self._dial_interval)
|
|
740
|
+
return None
|
|
741
|
+
|
|
742
|
+
def _splice(self, a: "socket.socket", b: "socket.socket") -> None:
|
|
743
|
+
a.setblocking(True)
|
|
744
|
+
b.setblocking(True)
|
|
745
|
+
socks = [a, b]
|
|
746
|
+
while not self._closed.is_set():
|
|
747
|
+
try:
|
|
748
|
+
r, _, x = select.select(socks, [], socks, 1.0)
|
|
749
|
+
except (OSError, ValueError):
|
|
750
|
+
return
|
|
751
|
+
if x:
|
|
752
|
+
return
|
|
753
|
+
for s in r:
|
|
754
|
+
peer = b if s is a else a
|
|
755
|
+
try:
|
|
756
|
+
data = s.recv(65536)
|
|
757
|
+
except OSError:
|
|
758
|
+
return
|
|
759
|
+
if not data:
|
|
760
|
+
return
|
|
761
|
+
try:
|
|
762
|
+
peer.sendall(data)
|
|
763
|
+
except OSError:
|
|
764
|
+
return
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def _rewrite_serve_port(url: "Optional[str]", new_port: int) -> "Optional[str]":
|
|
768
|
+
"""Return ``url`` with its host port replaced by ``new_port``.
|
|
769
|
+
|
|
770
|
+
Used to point ffmpeg at an internal _ServeRelay backend port while the public
|
|
771
|
+
port (in the advertised URL) is held by the relay. ``http://127.0.0.1:18989/
|
|
772
|
+
x.ts`` -> ``http://127.0.0.1:<new_port>/x.ts``. Pure (unit-testable)."""
|
|
773
|
+
if not url:
|
|
774
|
+
return url
|
|
775
|
+
try:
|
|
776
|
+
scheme, rest = url.split("://", 1)
|
|
777
|
+
hostport, slash, path = rest.partition("/")
|
|
778
|
+
host = hostport.rsplit(":", 1)[0]
|
|
779
|
+
return f"{scheme}://{host}:{new_port}{slash}{path}"
|
|
780
|
+
except (ValueError, IndexError):
|
|
781
|
+
return url
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def _grab_free_port(host: str = "127.0.0.1") -> int:
|
|
785
|
+
"""Bind an ephemeral TCP port, release it, and return the number.
|
|
786
|
+
|
|
787
|
+
There's an inherent TOCTOU window before ffmpeg rebinds it, but it's a
|
|
788
|
+
loopback-only internal port and ffmpeg's launch retries the serve cycle on
|
|
789
|
+
failure, so a lost race is self-healing."""
|
|
790
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
791
|
+
try:
|
|
792
|
+
s.bind((host, 0))
|
|
793
|
+
return s.getsockname()[1]
|
|
794
|
+
finally:
|
|
795
|
+
s.close()
|
|
796
|
+
|
|
797
|
+
|
|
575
798
|
def _tcp_table_has_established_on_port(table_text: str, port: int) -> bool:
|
|
576
799
|
"""True if any row in a /proc/net/tcp[6] dump is ESTABLISHED on local ``port``.
|
|
577
800
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-aidot-cameras
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.18
|
|
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
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Unit tests for _ServeRelay - the cold-start port holder.
|
|
2
|
+
|
|
3
|
+
The relay keeps a public serve port connectable while ffmpeg (the real
|
|
4
|
+
-listen backend) is not up yet, so an eager go2rtc pull CONNECTS and waits
|
|
5
|
+
instead of getting ECONNREFUSED and giving up. These tests use real localhost
|
|
6
|
+
sockets with ephemeral ports - no camera, no ffmpeg, fully deterministic.
|
|
7
|
+
"""
|
|
8
|
+
import os
|
|
9
|
+
import socket
|
|
10
|
+
import sys
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
|
|
14
|
+
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"))
|
|
15
|
+
|
|
16
|
+
from aidot.camera.protocol import _ServeRelay, _grab_free_port, _rewrite_serve_port
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_rewrite_serve_port_swaps_only_the_port():
|
|
20
|
+
assert (_rewrite_serve_port("http://127.0.0.1:18989/abc.ts", 40001)
|
|
21
|
+
== "http://127.0.0.1:40001/abc.ts")
|
|
22
|
+
# Path and scheme preserved; host preserved.
|
|
23
|
+
assert (_rewrite_serve_port("http://127.0.0.1:8554/aidot_x", 9)
|
|
24
|
+
== "http://127.0.0.1:9/aidot_x")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_rewrite_serve_port_handles_missing_or_malformed():
|
|
28
|
+
assert _rewrite_serve_port(None, 1) is None
|
|
29
|
+
assert _rewrite_serve_port("not a url", 1) == "not a url"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_grab_free_port_returns_a_bindable_port():
|
|
33
|
+
p = _grab_free_port()
|
|
34
|
+
assert isinstance(p, int) and 1024 < p < 65536
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _free_port():
|
|
38
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
39
|
+
s.bind(("127.0.0.1", 0))
|
|
40
|
+
p = s.getsockname()[1]
|
|
41
|
+
s.close()
|
|
42
|
+
return p
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class _EchoBackend:
|
|
46
|
+
"""A tiny one-shot -listen-style backend: accepts one client and echoes,
|
|
47
|
+
optionally prefixing a tag so tests can tell backends apart."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, tag=b""):
|
|
50
|
+
self._tag = tag
|
|
51
|
+
self._srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
52
|
+
self._srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
53
|
+
self._srv.bind(("127.0.0.1", 0))
|
|
54
|
+
self._srv.listen(1)
|
|
55
|
+
self.port = self._srv.getsockname()[1]
|
|
56
|
+
self._t = threading.Thread(target=self._run, daemon=True)
|
|
57
|
+
self._t.start()
|
|
58
|
+
|
|
59
|
+
def _run(self):
|
|
60
|
+
try:
|
|
61
|
+
cli, _ = self._srv.accept()
|
|
62
|
+
except OSError:
|
|
63
|
+
return
|
|
64
|
+
try:
|
|
65
|
+
cli.sendall(self._tag)
|
|
66
|
+
while True:
|
|
67
|
+
d = cli.recv(4096)
|
|
68
|
+
if not d:
|
|
69
|
+
break
|
|
70
|
+
cli.sendall(d)
|
|
71
|
+
except OSError:
|
|
72
|
+
pass
|
|
73
|
+
finally:
|
|
74
|
+
cli.close()
|
|
75
|
+
|
|
76
|
+
def close(self):
|
|
77
|
+
try:
|
|
78
|
+
self._srv.close()
|
|
79
|
+
except OSError:
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_connect_before_backend_does_not_refuse_then_flows():
|
|
84
|
+
# The core guarantee: a pull that arrives BEFORE the backend exists connects
|
|
85
|
+
# (no ECONNREFUSED) and gets data once the backend appears.
|
|
86
|
+
relay = _ServeRelay(_free_port(), dial_timeout=10.0)
|
|
87
|
+
relay.start()
|
|
88
|
+
try:
|
|
89
|
+
cli = socket.create_connection(("127.0.0.1", relay.port), timeout=2.0)
|
|
90
|
+
# Backend comes up ~now, after the client already connected.
|
|
91
|
+
be = _EchoBackend()
|
|
92
|
+
relay.set_backend(be.port)
|
|
93
|
+
cli.sendall(b"GET /x.ts HTTP/1.0\r\n\r\n")
|
|
94
|
+
cli.settimeout(5.0)
|
|
95
|
+
got = cli.recv(4096)
|
|
96
|
+
assert b"GET /x.ts" in got
|
|
97
|
+
cli.close()
|
|
98
|
+
be.close()
|
|
99
|
+
finally:
|
|
100
|
+
relay.close()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_without_relay_connection_is_refused():
|
|
104
|
+
# Control: this is the bug the relay fixes - an unbound port refuses fast.
|
|
105
|
+
port = _free_port()
|
|
106
|
+
try:
|
|
107
|
+
socket.create_connection(("127.0.0.1", port), timeout=1.0)
|
|
108
|
+
assert False, "expected ConnectionRefusedError on an unbound port"
|
|
109
|
+
except ConnectionRefusedError:
|
|
110
|
+
pass
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_backend_swap_between_connections():
|
|
114
|
+
# After ffmpeg restarts (go2rtc reconnect) the relay redials the new backend.
|
|
115
|
+
relay = _ServeRelay(_free_port(), dial_timeout=10.0)
|
|
116
|
+
relay.start()
|
|
117
|
+
try:
|
|
118
|
+
be1 = _EchoBackend(tag=b"ONE:")
|
|
119
|
+
relay.set_backend(be1.port)
|
|
120
|
+
c1 = socket.create_connection(("127.0.0.1", relay.port), timeout=2.0)
|
|
121
|
+
c1.settimeout(5.0)
|
|
122
|
+
assert c1.recv(16).startswith(b"ONE:")
|
|
123
|
+
c1.close()
|
|
124
|
+
be1.close()
|
|
125
|
+
|
|
126
|
+
be2 = _EchoBackend(tag=b"TWO:")
|
|
127
|
+
relay.set_backend(be2.port)
|
|
128
|
+
c2 = socket.create_connection(("127.0.0.1", relay.port), timeout=2.0)
|
|
129
|
+
c2.settimeout(5.0)
|
|
130
|
+
assert c2.recv(16).startswith(b"TWO:")
|
|
131
|
+
c2.close()
|
|
132
|
+
be2.close()
|
|
133
|
+
finally:
|
|
134
|
+
relay.close()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_close_frees_the_public_port():
|
|
138
|
+
port = _free_port()
|
|
139
|
+
relay = _ServeRelay(port)
|
|
140
|
+
relay.start()
|
|
141
|
+
relay.close()
|
|
142
|
+
# SO_REUSEADDR + a real close means we can rebind immediately (this is what
|
|
143
|
+
# lets ffmpeg/other consumers take the port back after teardown).
|
|
144
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
145
|
+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
146
|
+
s.bind(("127.0.0.1", port))
|
|
147
|
+
s.close()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_dial_timeout_closes_idle_client():
|
|
151
|
+
# A client that connects but whose backend never appears is dropped after
|
|
152
|
+
# the dial timeout (so it can retry) rather than hanging forever.
|
|
153
|
+
relay = _ServeRelay(_free_port(), dial_timeout=0.5, dial_interval=0.05)
|
|
154
|
+
relay.start()
|
|
155
|
+
try:
|
|
156
|
+
cli = socket.create_connection(("127.0.0.1", relay.port), timeout=2.0)
|
|
157
|
+
cli.settimeout(3.0)
|
|
158
|
+
t0 = time.monotonic()
|
|
159
|
+
got = cli.recv(16) # blocks until relay closes the conn (EOF -> b"")
|
|
160
|
+
assert got == b""
|
|
161
|
+
assert time.monotonic() - t0 >= 0.4
|
|
162
|
+
cli.close()
|
|
163
|
+
finally:
|
|
164
|
+
relay.close()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
if __name__ == "__main__":
|
|
168
|
+
import traceback
|
|
169
|
+
_fail = 0
|
|
170
|
+
for _k, _v in sorted(globals().items()):
|
|
171
|
+
if _k.startswith("test_"):
|
|
172
|
+
try:
|
|
173
|
+
_v()
|
|
174
|
+
print(f"PASS {_k}")
|
|
175
|
+
except Exception:
|
|
176
|
+
_fail += 1
|
|
177
|
+
print(f"FAIL {_k}")
|
|
178
|
+
traceback.print_exc()
|
|
179
|
+
raise SystemExit(1 if _fail else 0)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18}/src/aidot/models/device_client_model.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18}/src/aidot/models/device_model.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18}/src/aidot/models/discover_model.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18}/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.18}/tests/test_no_undefined_names.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.7.16 → python_aidot_cameras-0.7.18}/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
|