python-aidot-cameras 0.7.17__tar.gz → 0.7.19__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.17/src/python_aidot_cameras.egg-info → python_aidot_cameras-0.7.19}/PKG-INFO +1 -1
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/pyproject.toml +1 -1
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/aidot/camera/client.py +179 -9
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/aidot/camera/protocol.py +183 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19/src/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/python_aidot_cameras.egg-info/SOURCES.txt +2 -0
- python_aidot_cameras-0.7.19/tests/test_serve_relay.py +179 -0
- python_aidot_cameras-0.7.19/tests/test_stream_idle.py +69 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/LICENSE +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/MANIFEST.in +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/README.md +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/setup.cfg +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/aidot/__init__.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/aidot/aes_utils.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/aidot/camera/__init__.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/aidot/camera/constants.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/aidot/camera/controls.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/aidot/camera/go2rtc.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/aidot/camera/lan_control.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/aidot/camera/models.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/aidot/camera/playback.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/aidot/camera/sdes.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/aidot/camera/tutk.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/aidot/camera/webrtc.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/aidot/client.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/aidot/const.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/aidot/credentials.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/aidot/device_client.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/aidot/discover.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/aidot/exceptions.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/aidot/g711.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/aidot/login_const.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/aidot/models/__init__.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/aidot/models/device_client_model.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/aidot/models/device_model.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/aidot/models/discover_model.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/aidot/py.typed +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/python_aidot_cameras.egg-info/requires.txt +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/python_aidot_cameras.egg-info/top_level.txt +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/tests/test_alarm_event.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/tests/test_backoff.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/tests/test_go2rtc.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/tests/test_highport_nomination.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/tests/test_lan_control.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/tests/test_live_stream_param.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/tests/test_motion_poll.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/tests/test_no_undefined_names.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/tests/test_post_merge_hardening.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/tests/test_sdes_idle_release.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/tests/test_sdes_sprop.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/tests/test_sdes_talk.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/tests/test_sdes_watchdog.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/tests/test_speak.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/tests/test_stream_cap.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/tests/test_talk.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/tests/test_terminal_ack.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/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.19
|
|
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.19"
|
|
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"
|
|
@@ -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,12 @@ 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
|
|
511
|
+
# Cold-start instrumentation: monotonic at the start of an open, so any
|
|
512
|
+
# thread (signaling, bridge, serve) can log elapsed-ms phase markers.
|
|
513
|
+
self._cold_open_t0: "Optional[float]" = None
|
|
505
514
|
# Motion/event polling (cloud event list). NOTE: the camera does NOT push motion
|
|
506
515
|
# to a passive MQTT subscriber - alarmType is only emitted during an active live
|
|
507
516
|
# view (decompiled NewLiveFragment) - so real-time-ish motion for HA comes from
|
|
@@ -1939,6 +1948,8 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
1939
1948
|
sdes_audio: Optional[bool] = None,
|
|
1940
1949
|
go2rtc_url: Optional[str] = None,
|
|
1941
1950
|
live_stream_param: Optional[bool] = None,
|
|
1951
|
+
serve_relay: Optional[bool] = None,
|
|
1952
|
+
stream_idle_s: Optional[float] = None,
|
|
1942
1953
|
) -> None:
|
|
1943
1954
|
"""Start a persistent stream that keeps the camera session alive.
|
|
1944
1955
|
|
|
@@ -1961,6 +1972,20 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
1961
1972
|
``AIDOT_LIVESTREAM_PARAM`` env var, so an integration on HA OS (no env vars)
|
|
1962
1973
|
can still disable it per camera.
|
|
1963
1974
|
|
|
1975
|
+
``serve_relay`` toggles the cold-start serve-port relay (holds the public
|
|
1976
|
+
serve port connectable through the WebRTC handshake so an eager go2rtc
|
|
1977
|
+
pull waits instead of getting connection-refused). Overrides the
|
|
1978
|
+
``AIDOT_SERVE_RELAY`` env var (default on); set False to serve ffmpeg
|
|
1979
|
+
directly on the public port (the pre-0.7.18 behaviour).
|
|
1980
|
+
|
|
1981
|
+
``stream_idle_s`` sets the no-viewer idle-release window in seconds,
|
|
1982
|
+
overriding the ``AIDOT_STREAM_IDLE_S`` env default (120). ``0`` (or
|
|
1983
|
+
negative) keeps the warm WebRTC session forever so re-views are instant
|
|
1984
|
+
(app-like) - sensible for mains cameras (no battery cost), but it holds a
|
|
1985
|
+
concurrent-stream slot for the camera's lifetime, so don't pin more
|
|
1986
|
+
cameras than ``AIDOT_MAX_CONCURRENT_STREAMS`` allows. Leave None for
|
|
1987
|
+
battery cameras (motion-prewarm + idle-release preserves battery).
|
|
1988
|
+
|
|
1964
1989
|
Safe to call multiple times - does nothing if already running.
|
|
1965
1990
|
"""
|
|
1966
1991
|
if fast_connect is not None:
|
|
@@ -1969,6 +1994,10 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
1969
1994
|
self._sdes_audio_opt = sdes_audio
|
|
1970
1995
|
if live_stream_param is not None:
|
|
1971
1996
|
self._live_stream_param_opt = live_stream_param
|
|
1997
|
+
if serve_relay is not None:
|
|
1998
|
+
self._serve_relay_opt = serve_relay
|
|
1999
|
+
if stream_idle_s is not None:
|
|
2000
|
+
self._stream_idle_opt = stream_idle_s
|
|
1972
2001
|
if self._stream_task is not None and not self._stream_task.done():
|
|
1973
2002
|
return
|
|
1974
2003
|
self._keepalive_rtsp_url = rtsp_push_url
|
|
@@ -2154,6 +2183,21 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
2154
2183
|
return found if read_any else None
|
|
2155
2184
|
|
|
2156
2185
|
async def _sdes_keepalive_loop(self) -> None:
|
|
2186
|
+
"""Keep the cold-start serve relay alive around the SDES keepalive loop.
|
|
2187
|
+
|
|
2188
|
+
The relay holds the public serve port connectable through every (re)open
|
|
2189
|
+
so an eager go2rtc pull waits instead of getting connection-refused while
|
|
2190
|
+
the SDES handshake runs (the same race fixed for the DTLS serve)."""
|
|
2191
|
+
self._serve_relay = self._maybe_start_serve_relay(self._keepalive_rtsp_url)
|
|
2192
|
+
try:
|
|
2193
|
+
await self._sdes_keepalive_loop_inner()
|
|
2194
|
+
finally:
|
|
2195
|
+
_relay = self._serve_relay
|
|
2196
|
+
self._serve_relay = None
|
|
2197
|
+
if _relay is not None:
|
|
2198
|
+
_relay.close()
|
|
2199
|
+
|
|
2200
|
+
async def _sdes_keepalive_loop_inner(self) -> None:
|
|
2157
2201
|
"""Background task: keep SDES stream alive; push to go2rtc via RTSP."""
|
|
2158
2202
|
_MIN_DELAY = 10.0
|
|
2159
2203
|
_MAX_DELAY = 300.0
|
|
@@ -2164,6 +2208,10 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
2164
2208
|
_pacer = ReconnectPacer(_MIN_DELAY, _MAX_DELAY)
|
|
2165
2209
|
|
|
2166
2210
|
while self._streaming_active:
|
|
2211
|
+
if self._serve_relay is not None:
|
|
2212
|
+
# Clear any stale backend from a prior session; the open below
|
|
2213
|
+
# points the relay at this session's fresh internal ffmpeg port.
|
|
2214
|
+
self._serve_relay.set_backend(None)
|
|
2167
2215
|
try:
|
|
2168
2216
|
session = await self.async_open_webrtc_stream(
|
|
2169
2217
|
rtsp_push_url=self._keepalive_rtsp_url,
|
|
@@ -2202,8 +2250,10 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
2202
2250
|
# window as DTLS; the next view re-runs camera.stream_source(). Fail
|
|
2203
2251
|
# safe: unknown (non-Linux /proc) never releases. Escape hatch:
|
|
2204
2252
|
# AIDOT_SDES_IDLE_RELEASE=0.
|
|
2205
|
-
|
|
2206
|
-
_idle_secs =
|
|
2253
|
+
# stream_idle_s / AIDOT_STREAM_IDLE_S override; <= 0 = never release.
|
|
2254
|
+
_idle_secs = self._resolve_idle_secs()
|
|
2255
|
+
_idle_on = (os.environ.get("AIDOT_SDES_IDLE_RELEASE", "1") != "0"
|
|
2256
|
+
and _idle_secs > 0)
|
|
2207
2257
|
_serve_port = _sdes_serve_port(self._keepalive_rtsp_url)
|
|
2208
2258
|
_last_consumer = _started_at # grace: count idle from session open
|
|
2209
2259
|
try:
|
|
@@ -2446,6 +2496,70 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
2446
2496
|
got_a = self._install_encoded_tap(_r, aq, False)
|
|
2447
2497
|
return got_v
|
|
2448
2498
|
|
|
2499
|
+
def _cold_phase(self, label: str) -> None:
|
|
2500
|
+
"""Log elapsed-ms since the open began (cold-start instrumentation).
|
|
2501
|
+
|
|
2502
|
+
Defensive + best-effort: never raises, no-op if no open is in flight.
|
|
2503
|
+
Emits a single greppable INFO line per phase so a cold connect's timeline
|
|
2504
|
+
(open -> webrtcReq -> first-media -> serving) can be measured without a
|
|
2505
|
+
debugger or ad-hoc log scraping. Grep ``cold-start[``."""
|
|
2506
|
+
try:
|
|
2507
|
+
t0 = self._cold_open_t0
|
|
2508
|
+
if t0 is None:
|
|
2509
|
+
return
|
|
2510
|
+
_LOGGER.info(
|
|
2511
|
+
"cold-start[%s] %s +%dms",
|
|
2512
|
+
self.device_id, label, int((time.monotonic() - t0) * 1000),
|
|
2513
|
+
)
|
|
2514
|
+
except Exception:
|
|
2515
|
+
pass
|
|
2516
|
+
|
|
2517
|
+
def _resolve_idle_secs(self) -> float:
|
|
2518
|
+
"""Serve idle-release window (seconds). A per-camera ``stream_idle_s``
|
|
2519
|
+
(set via start_keepalive) overrides the ``AIDOT_STREAM_IDLE_S`` env
|
|
2520
|
+
default of 120 s. <= 0 means *never* idle-release (keep the warm session
|
|
2521
|
+
for instant re-views — sensible for mains cameras, which have no battery
|
|
2522
|
+
cost; note it holds a concurrent-stream slot for the camera's lifetime)."""
|
|
2523
|
+
opt = getattr(self, "_stream_idle_opt", None)
|
|
2524
|
+
if opt is not None:
|
|
2525
|
+
return float(opt)
|
|
2526
|
+
try:
|
|
2527
|
+
return float(os.environ.get("AIDOT_STREAM_IDLE_S", "120"))
|
|
2528
|
+
except (TypeError, ValueError):
|
|
2529
|
+
return 120.0
|
|
2530
|
+
|
|
2531
|
+
def _maybe_start_serve_relay(self, serve_url: Optional[str]) -> "Optional[_ServeRelay]":
|
|
2532
|
+
"""Hold the public serve port via a _ServeRelay so an eager go2rtc pull
|
|
2533
|
+
connects-and-waits instead of hitting ECONNREFUSED during the ~16-25s
|
|
2534
|
+
cold handshake (ffmpeg only binds its -listen socket after input frames).
|
|
2535
|
+
|
|
2536
|
+
Returns the started relay, or None when disabled / not an http serve /
|
|
2537
|
+
the bind fails - in which case the caller serves ffmpeg directly on the
|
|
2538
|
+
public port (the pre-relay behaviour, so a bind clash never breaks
|
|
2539
|
+
streaming)."""
|
|
2540
|
+
if not (serve_url and serve_url.startswith("http")):
|
|
2541
|
+
return None
|
|
2542
|
+
opt = getattr(self, "_serve_relay_opt", None)
|
|
2543
|
+
if opt is None:
|
|
2544
|
+
opt = os.environ.get("AIDOT_SERVE_RELAY", "1") != "0"
|
|
2545
|
+
if not opt:
|
|
2546
|
+
return None
|
|
2547
|
+
port = _sdes_serve_port(serve_url)
|
|
2548
|
+
if port is None:
|
|
2549
|
+
return None
|
|
2550
|
+
relay = _ServeRelay(port)
|
|
2551
|
+
try:
|
|
2552
|
+
relay.start()
|
|
2553
|
+
except OSError as exc:
|
|
2554
|
+
_LOGGER.warning(
|
|
2555
|
+
"serve relay: bind :%s failed (%s) - serving ffmpeg directly",
|
|
2556
|
+
port, exc,
|
|
2557
|
+
)
|
|
2558
|
+
return None
|
|
2559
|
+
_LOGGER.debug(
|
|
2560
|
+
"serve relay: holding public port :%s for %s", port, self.device_id)
|
|
2561
|
+
return relay
|
|
2562
|
+
|
|
2449
2563
|
async def _dtls_serve_loop(self) -> None:
|
|
2450
2564
|
"""Acquire a concurrent-stream slot, then run the serve loop.
|
|
2451
2565
|
|
|
@@ -2460,9 +2574,14 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
2460
2574
|
self.device_id,
|
|
2461
2575
|
)
|
|
2462
2576
|
await slots.acquire()
|
|
2577
|
+
self._serve_relay = self._maybe_start_serve_relay(self._keepalive_rtsp_url)
|
|
2463
2578
|
try:
|
|
2464
2579
|
await self._dtls_serve_loop_inner()
|
|
2465
2580
|
finally:
|
|
2581
|
+
_relay = self._serve_relay
|
|
2582
|
+
self._serve_relay = None
|
|
2583
|
+
if _relay is not None:
|
|
2584
|
+
_relay.close()
|
|
2466
2585
|
slots.release()
|
|
2467
2586
|
|
|
2468
2587
|
async def _dtls_serve_loop_inner(self) -> None:
|
|
@@ -2575,7 +2694,9 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
2575
2694
|
# Must exceed HA's stream-worker reconnect interval (~40s) or the serve
|
|
2576
2695
|
# is torn down between a viewer's retries -> "Connection refused". 120s
|
|
2577
2696
|
# survives several retries while still releasing an unviewed camera.
|
|
2578
|
-
|
|
2697
|
+
# stream_idle_s / AIDOT_STREAM_IDLE_S override; <= 0 = never release
|
|
2698
|
+
# (keep warm for instant re-views; mains cameras only - see P1).
|
|
2699
|
+
idle_secs = self._resolve_idle_secs()
|
|
2579
2700
|
proc = wfile = stop_flag = mux_thread = None
|
|
2580
2701
|
cancelled = idle_release = False
|
|
2581
2702
|
try:
|
|
@@ -2590,12 +2711,24 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
2590
2711
|
except _queue.Empty:
|
|
2591
2712
|
pass
|
|
2592
2713
|
await self._send_video_pli(pc) # request a clean keyframe
|
|
2714
|
+
# With the relay holding the public port, ffmpeg listens on a
|
|
2715
|
+
# fresh internal port each cycle and the relay splices to it;
|
|
2716
|
+
# without it, ffmpeg owns the public port directly (old path).
|
|
2717
|
+
_relay = self._serve_relay
|
|
2718
|
+
if _relay is not None:
|
|
2719
|
+
_ff_port = _grab_free_port()
|
|
2720
|
+
_ff_url = _rewrite_serve_port(serve_url, _ff_port)
|
|
2721
|
+
else:
|
|
2722
|
+
_ff_port = None
|
|
2723
|
+
_ff_url = serve_url
|
|
2593
2724
|
rfd, wfd = os.pipe()
|
|
2594
|
-
proc = await self._spawn_dtls_serve_ffmpeg(
|
|
2725
|
+
proc = await self._spawn_dtls_serve_ffmpeg(_ff_url, rfd)
|
|
2595
2726
|
os.close(rfd)
|
|
2596
2727
|
if proc is None:
|
|
2597
2728
|
os.close(wfd)
|
|
2598
2729
|
break
|
|
2730
|
+
if _relay is not None:
|
|
2731
|
+
_relay.set_backend(_ff_port)
|
|
2599
2732
|
wfile = os.fdopen(wfd, "wb", buffering=0)
|
|
2600
2733
|
progress = [loop.time()]
|
|
2601
2734
|
stop_flag = _threading.Event()
|
|
@@ -2622,6 +2755,7 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
2622
2755
|
# client and tear down before go2rtc connects.)
|
|
2623
2756
|
await asyncio.sleep(0.3)
|
|
2624
2757
|
self._serve_ready.set()
|
|
2758
|
+
self._cold_phase("serving (dtls)")
|
|
2625
2759
|
# Periodic keyframe request shortens the camera's GOP so HA's
|
|
2626
2760
|
# HLS segmenter (which cuts on keyframes) produces short
|
|
2627
2761
|
# segments -> far less player buffering (the ~20s HLS lag is
|
|
@@ -2639,8 +2773,9 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
2639
2773
|
await self._send_video_pli(pc)
|
|
2640
2774
|
_last_gop_pli = loop.time()
|
|
2641
2775
|
# No consumer -> the pipe fills, the mux blocks, progress
|
|
2642
|
-
# goes stale. A real viewer keeps it fresh.
|
|
2643
|
-
|
|
2776
|
+
# goes stale. A real viewer keeps it fresh. idle_secs<=0
|
|
2777
|
+
# disables release entirely (keep warm for instant views).
|
|
2778
|
+
if idle_secs > 0 and loop.time() - progress[0] > idle_secs:
|
|
2644
2779
|
idle_release = True
|
|
2645
2780
|
break
|
|
2646
2781
|
# Tear down this ffmpeg+mux cycle before the next.
|
|
@@ -2652,6 +2787,8 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
2652
2787
|
wfile = None
|
|
2653
2788
|
mux_thread.join(timeout=2.0)
|
|
2654
2789
|
mux_thread = stop_flag = None
|
|
2790
|
+
if _relay is not None:
|
|
2791
|
+
_relay.set_backend(None) # no backend until next ffmpeg
|
|
2655
2792
|
_terminate_proc(proc)
|
|
2656
2793
|
proc = None
|
|
2657
2794
|
if idle_release:
|
|
@@ -3151,6 +3288,10 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
3151
3288
|
"""
|
|
3152
3289
|
import queue as _q_mod
|
|
3153
3290
|
|
|
3291
|
+
# Cold-start instrumentation: stamp t0 so phase markers below (and in the
|
|
3292
|
+
# bridge/serve threads) log elapsed-ms. Grep ``cold-start[``.
|
|
3293
|
+
self._cold_open_t0 = time.monotonic()
|
|
3294
|
+
|
|
3154
3295
|
use_sdes = force_sdes if force_sdes is not None else self.is_sdes_camera
|
|
3155
3296
|
|
|
3156
3297
|
# Battery cameras must have their live-stream session provisioned by the
|
|
@@ -4827,6 +4968,7 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
4827
4968
|
},
|
|
4828
4969
|
})
|
|
4829
4970
|
outgoing_q.put_nowait((webrtc_req_topic, webrtc_req_payload))
|
|
4971
|
+
self._cold_phase("webrtcReq")
|
|
4830
4972
|
_status(f"webrtcReq sent peerid={peer_id}"
|
|
4831
4973
|
f" IceServerList×{len(_ice_server_list)}"
|
|
4832
4974
|
f" payload={len(webrtc_req_payload)}B")
|
|
@@ -8319,9 +8461,25 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
8319
8461
|
# on the camera's own GOP). We can't decode here to detect the
|
|
8320
8462
|
# first keyframe, so we time-box the burst and keep a slow
|
|
8321
8463
|
# safety PLI in case the camera's GOP is long.
|
|
8322
|
-
|
|
8464
|
+
# P2: front-load the burst so the first decodable IDR arrives
|
|
8465
|
+
# sooner on a cold open. Was 3 PLIs at a flat 5 s (first IDR
|
|
8466
|
+
# up to ~10 s); now a denser early ramp, then the same 30 s
|
|
8467
|
+
# safety PLI. AIDOT_SDES_PLI_GAPS overrides the early gaps
|
|
8468
|
+
# (comma-sep seconds; e.g. "5,5,5" restores the old cadence).
|
|
8469
|
+
_pli_gaps = getattr(_bridge_fn, '_pli_gaps', None)
|
|
8470
|
+
if _pli_gaps is None:
|
|
8471
|
+
try:
|
|
8472
|
+
_pli_gaps = tuple(
|
|
8473
|
+
float(_x) for _x in os.environ.get(
|
|
8474
|
+
"AIDOT_SDES_PLI_GAPS", "0,1.5,2,3").split(",")
|
|
8475
|
+
if _x.strip()
|
|
8476
|
+
) or (0.0, 1.5, 2.0, 3.0)
|
|
8477
|
+
except ValueError:
|
|
8478
|
+
_pli_gaps = (0.0, 1.5, 2.0, 3.0)
|
|
8479
|
+
_bridge_fn._pli_gaps = _pli_gaps
|
|
8323
8480
|
_pli_done = getattr(_bridge_fn, '_pli_count', 0)
|
|
8324
|
-
_pli_interval =
|
|
8481
|
+
_pli_interval = (_pli_gaps[_pli_done]
|
|
8482
|
+
if _pli_done < len(_pli_gaps) else 30.0)
|
|
8325
8483
|
if (hasattr(_bridge_fn, '_cam_video_ssrc')
|
|
8326
8484
|
and hasattr(_bridge_fn, '_cam_srtp_sock')
|
|
8327
8485
|
and _time_br.time() - getattr(
|
|
@@ -8370,7 +8528,7 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
8370
8528
|
_bridge_fn._last_pli_ts = _time_br.time()
|
|
8371
8529
|
_pli_n = getattr(_bridge_fn, '_pli_count', 0) + 1
|
|
8372
8530
|
_bridge_fn._pli_count = _pli_n
|
|
8373
|
-
if _pli_n <=
|
|
8531
|
+
if _pli_n <= len(_pli_gaps):
|
|
8374
8532
|
_status(
|
|
8375
8533
|
f"SDES: sent RTCP PLI #{_pli_n}"
|
|
8376
8534
|
f" → SSRC=0x{_pli_media_ssrc:08x}"
|
|
@@ -9249,6 +9407,7 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
9249
9407
|
_bridge_fn._srtp_rx_sess = _plsrtp_rx.Session(
|
|
9250
9408
|
policy=_rx_pol
|
|
9251
9409
|
)
|
|
9410
|
+
self._cold_phase("first-media")
|
|
9252
9411
|
_status("bridge: SRTP RX session ready (cam→us)")
|
|
9253
9412
|
except Exception as _srx_e:
|
|
9254
9413
|
_status(f"bridge: SRTP RX init failed: {_srx_e}")
|
|
@@ -9422,6 +9581,16 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
9422
9581
|
)
|
|
9423
9582
|
|
|
9424
9583
|
if rtsp_push_url and rtsp_push_url.startswith("http"):
|
|
9584
|
+
# Cold-start relay: when the keepalive loop is holding the public
|
|
9585
|
+
# serve port, point ffmpeg at a fresh internal port and let the relay
|
|
9586
|
+
# splice the public port to it - so an early go2rtc pull waits through
|
|
9587
|
+
# this handshake instead of being refused. set_backend retries the
|
|
9588
|
+
# dial until ffmpeg's -listen socket binds (only after input frames).
|
|
9589
|
+
_relay = getattr(self, "_serve_relay", None)
|
|
9590
|
+
if _relay is not None:
|
|
9591
|
+
_ff_port = _grab_free_port()
|
|
9592
|
+
rtsp_push_url = _rewrite_serve_port(rtsp_push_url, _ff_port)
|
|
9593
|
+
_relay.set_backend(_ff_port)
|
|
9425
9594
|
# PULL model: SERVE the decrypted stream over an HTTP-listen socket so
|
|
9426
9595
|
# go2rtc / HA's stream integration pull it the standard way
|
|
9427
9596
|
# (streams.add) - no go2rtc pre-registration needed. ffmpeg -listen 1
|
|
@@ -9645,6 +9814,7 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
9645
9814
|
await asyncio.sleep(0.05)
|
|
9646
9815
|
|
|
9647
9816
|
_bind_ms = int((time.monotonic() - _t0) * 1000)
|
|
9817
|
+
self._cold_phase("serving (sdes ffmpeg bound)")
|
|
9648
9818
|
if _bound:
|
|
9649
9819
|
_status(
|
|
9650
9820
|
f"SDES ffmpeg ready - loopback audio={_lo_audio_port}"
|
|
@@ -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
|
|
@@ -612,6 +614,187 @@ def _sdes_serve_port(url: "Optional[str]") -> "Optional[int]":
|
|
|
612
614
|
return None
|
|
613
615
|
|
|
614
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
|
+
|
|
615
798
|
def _tcp_table_has_established_on_port(table_text: str, port: int) -> bool:
|
|
616
799
|
"""True if any row in a /proc/net/tcp[6] dump is ESTABLISHED on local ``port``.
|
|
617
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.19
|
|
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
|
|
@@ -47,8 +47,10 @@ tests/test_sdes_idle_release.py
|
|
|
47
47
|
tests/test_sdes_sprop.py
|
|
48
48
|
tests/test_sdes_talk.py
|
|
49
49
|
tests/test_sdes_watchdog.py
|
|
50
|
+
tests/test_serve_relay.py
|
|
50
51
|
tests/test_speak.py
|
|
51
52
|
tests/test_stream_cap.py
|
|
53
|
+
tests/test_stream_idle.py
|
|
52
54
|
tests/test_talk.py
|
|
53
55
|
tests/test_terminal_ack.py
|
|
54
56
|
tests/test_token_refresh.py
|
|
@@ -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)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Unit tests for _resolve_idle_secs - the serve idle-release policy (P1).
|
|
2
|
+
|
|
3
|
+
The no-viewer idle window decides when a warm WebRTC session is torn down. A
|
|
4
|
+
per-camera ``stream_idle_s`` (from start_keepalive) overrides AIDOT_STREAM_IDLE_S
|
|
5
|
+
(default 120 s); <= 0 means never release (keep mains cameras warm for instant
|
|
6
|
+
re-views). No network/camera: drives a bare instance.
|
|
7
|
+
"""
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"))
|
|
12
|
+
|
|
13
|
+
import aidot.camera.client as cc
|
|
14
|
+
|
|
15
|
+
_CAM = next(v for v in vars(cc).values()
|
|
16
|
+
if isinstance(v, type) and "_resolve_idle_secs" in v.__dict__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _cam():
|
|
20
|
+
return _CAM.__new__(_CAM)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_default_is_120_when_unset(monkeypatch):
|
|
24
|
+
monkeypatch.delenv("AIDOT_STREAM_IDLE_S", raising=False)
|
|
25
|
+
assert _cam()._resolve_idle_secs() == 120.0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_env_override(monkeypatch):
|
|
29
|
+
monkeypatch.setenv("AIDOT_STREAM_IDLE_S", "300")
|
|
30
|
+
assert _cam()._resolve_idle_secs() == 300.0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_kwarg_option_wins_over_env(monkeypatch):
|
|
34
|
+
monkeypatch.setenv("AIDOT_STREAM_IDLE_S", "300")
|
|
35
|
+
cam = _cam()
|
|
36
|
+
cam._stream_idle_opt = 45.0 # what start_keepalive(stream_idle_s=45) sets
|
|
37
|
+
assert cam._resolve_idle_secs() == 45.0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_zero_means_never_release():
|
|
41
|
+
cam = _cam()
|
|
42
|
+
cam._stream_idle_opt = 0.0
|
|
43
|
+
# The serve loops treat <= 0 as "never release"; the resolver just returns it.
|
|
44
|
+
assert cam._resolve_idle_secs() == 0.0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_bad_env_falls_back_to_120(monkeypatch):
|
|
48
|
+
monkeypatch.setenv("AIDOT_STREAM_IDLE_S", "not-a-number")
|
|
49
|
+
assert _cam()._resolve_idle_secs() == 120.0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
if __name__ == "__main__":
|
|
53
|
+
import traceback
|
|
54
|
+
|
|
55
|
+
class _MP: # minimal monkeypatch stand-in for direct `python` runs
|
|
56
|
+
def setenv(self, k, v): os.environ[k] = v
|
|
57
|
+
def delenv(self, k, raising=False): os.environ.pop(k, None)
|
|
58
|
+
|
|
59
|
+
_fail = 0
|
|
60
|
+
for _k, _v in sorted(globals().items()):
|
|
61
|
+
if _k.startswith("test_"):
|
|
62
|
+
try:
|
|
63
|
+
_v(_MP()) if _v.__code__.co_argcount else _v()
|
|
64
|
+
print(f"PASS {_k}")
|
|
65
|
+
except Exception:
|
|
66
|
+
_fail += 1
|
|
67
|
+
print(f"FAIL {_k}")
|
|
68
|
+
traceback.print_exc()
|
|
69
|
+
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.17 → python_aidot_cameras-0.7.19}/src/aidot/models/device_client_model.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/aidot/models/device_model.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/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.17 → python_aidot_cameras-0.7.19}/tests/test_highport_nomination.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/tests/test_no_undefined_names.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/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
|