python-aidot-cameras 0.7.17__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.17/src/python_aidot_cameras.egg-info → python_aidot_cameras-0.7.18}/PKG-INFO +1 -1
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/pyproject.toml +1 -1
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/aidot/camera/client.py +96 -1
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/aidot/camera/protocol.py +183 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18/src/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/python_aidot_cameras.egg-info/SOURCES.txt +1 -0
- python_aidot_cameras-0.7.18/tests/test_serve_relay.py +179 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/LICENSE +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/MANIFEST.in +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/README.md +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/setup.cfg +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/aidot/__init__.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/aidot/aes_utils.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/aidot/camera/__init__.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/aidot/camera/constants.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/aidot/camera/controls.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/aidot/camera/go2rtc.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/aidot/camera/lan_control.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/aidot/camera/models.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/aidot/camera/playback.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/aidot/camera/sdes.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/aidot/camera/tutk.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/aidot/camera/webrtc.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/aidot/client.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/aidot/const.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/aidot/credentials.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/aidot/device_client.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/aidot/discover.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/aidot/exceptions.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/aidot/g711.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/aidot/login_const.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/aidot/models/__init__.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/aidot/models/device_client_model.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/aidot/models/device_model.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/aidot/models/discover_model.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/aidot/py.typed +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/python_aidot_cameras.egg-info/requires.txt +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/python_aidot_cameras.egg-info/top_level.txt +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/tests/test_alarm_event.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/tests/test_backoff.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/tests/test_go2rtc.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/tests/test_highport_nomination.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/tests/test_lan_control.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/tests/test_live_stream_param.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/tests/test_motion_poll.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/tests/test_no_undefined_names.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/tests/test_post_merge_hardening.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/tests/test_sdes_idle_release.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/tests/test_sdes_sprop.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/tests/test_sdes_talk.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/tests/test_sdes_watchdog.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/tests/test_speak.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/tests/test_stream_cap.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/tests/test_talk.py +0 -0
- {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/tests/test_terminal_ack.py +0 -0
- {python_aidot_cameras-0.7.17 → 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"
|
|
@@ -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
|
|
@@ -1939,6 +1945,7 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
1939
1945
|
sdes_audio: Optional[bool] = None,
|
|
1940
1946
|
go2rtc_url: Optional[str] = None,
|
|
1941
1947
|
live_stream_param: Optional[bool] = None,
|
|
1948
|
+
serve_relay: Optional[bool] = None,
|
|
1942
1949
|
) -> None:
|
|
1943
1950
|
"""Start a persistent stream that keeps the camera session alive.
|
|
1944
1951
|
|
|
@@ -1961,6 +1968,12 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
1961
1968
|
``AIDOT_LIVESTREAM_PARAM`` env var, so an integration on HA OS (no env vars)
|
|
1962
1969
|
can still disable it per camera.
|
|
1963
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
|
+
|
|
1964
1977
|
Safe to call multiple times - does nothing if already running.
|
|
1965
1978
|
"""
|
|
1966
1979
|
if fast_connect is not None:
|
|
@@ -1969,6 +1982,8 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
1969
1982
|
self._sdes_audio_opt = sdes_audio
|
|
1970
1983
|
if live_stream_param is not None:
|
|
1971
1984
|
self._live_stream_param_opt = live_stream_param
|
|
1985
|
+
if serve_relay is not None:
|
|
1986
|
+
self._serve_relay_opt = serve_relay
|
|
1972
1987
|
if self._stream_task is not None and not self._stream_task.done():
|
|
1973
1988
|
return
|
|
1974
1989
|
self._keepalive_rtsp_url = rtsp_push_url
|
|
@@ -2154,6 +2169,21 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
2154
2169
|
return found if read_any else None
|
|
2155
2170
|
|
|
2156
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:
|
|
2157
2187
|
"""Background task: keep SDES stream alive; push to go2rtc via RTSP."""
|
|
2158
2188
|
_MIN_DELAY = 10.0
|
|
2159
2189
|
_MAX_DELAY = 300.0
|
|
@@ -2164,6 +2194,10 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
2164
2194
|
_pacer = ReconnectPacer(_MIN_DELAY, _MAX_DELAY)
|
|
2165
2195
|
|
|
2166
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)
|
|
2167
2201
|
try:
|
|
2168
2202
|
session = await self.async_open_webrtc_stream(
|
|
2169
2203
|
rtsp_push_url=self._keepalive_rtsp_url,
|
|
@@ -2446,6 +2480,38 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
2446
2480
|
got_a = self._install_encoded_tap(_r, aq, False)
|
|
2447
2481
|
return got_v
|
|
2448
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
|
+
|
|
2449
2515
|
async def _dtls_serve_loop(self) -> None:
|
|
2450
2516
|
"""Acquire a concurrent-stream slot, then run the serve loop.
|
|
2451
2517
|
|
|
@@ -2460,9 +2526,14 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
2460
2526
|
self.device_id,
|
|
2461
2527
|
)
|
|
2462
2528
|
await slots.acquire()
|
|
2529
|
+
self._serve_relay = self._maybe_start_serve_relay(self._keepalive_rtsp_url)
|
|
2463
2530
|
try:
|
|
2464
2531
|
await self._dtls_serve_loop_inner()
|
|
2465
2532
|
finally:
|
|
2533
|
+
_relay = self._serve_relay
|
|
2534
|
+
self._serve_relay = None
|
|
2535
|
+
if _relay is not None:
|
|
2536
|
+
_relay.close()
|
|
2466
2537
|
slots.release()
|
|
2467
2538
|
|
|
2468
2539
|
async def _dtls_serve_loop_inner(self) -> None:
|
|
@@ -2590,12 +2661,24 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
2590
2661
|
except _queue.Empty:
|
|
2591
2662
|
pass
|
|
2592
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
|
|
2593
2674
|
rfd, wfd = os.pipe()
|
|
2594
|
-
proc = await self._spawn_dtls_serve_ffmpeg(
|
|
2675
|
+
proc = await self._spawn_dtls_serve_ffmpeg(_ff_url, rfd)
|
|
2595
2676
|
os.close(rfd)
|
|
2596
2677
|
if proc is None:
|
|
2597
2678
|
os.close(wfd)
|
|
2598
2679
|
break
|
|
2680
|
+
if _relay is not None:
|
|
2681
|
+
_relay.set_backend(_ff_port)
|
|
2599
2682
|
wfile = os.fdopen(wfd, "wb", buffering=0)
|
|
2600
2683
|
progress = [loop.time()]
|
|
2601
2684
|
stop_flag = _threading.Event()
|
|
@@ -2652,6 +2735,8 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
2652
2735
|
wfile = None
|
|
2653
2736
|
mux_thread.join(timeout=2.0)
|
|
2654
2737
|
mux_thread = stop_flag = None
|
|
2738
|
+
if _relay is not None:
|
|
2739
|
+
_relay.set_backend(None) # no backend until next ffmpeg
|
|
2655
2740
|
_terminate_proc(proc)
|
|
2656
2741
|
proc = None
|
|
2657
2742
|
if idle_release:
|
|
@@ -9422,6 +9507,16 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
9422
9507
|
)
|
|
9423
9508
|
|
|
9424
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)
|
|
9425
9520
|
# PULL model: SERVE the decrypted stream over an HTTP-listen socket so
|
|
9426
9521
|
# go2rtc / HA's stream integration pull it the standard way
|
|
9427
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
|
|
@@ -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.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
|
|
@@ -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.17 → python_aidot_cameras-0.7.18}/src/aidot/models/device_client_model.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/aidot/models/device_model.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.7.17 → 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
|
|
File without changes
|
{python_aidot_cameras-0.7.17 → 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.17 → python_aidot_cameras-0.7.18}/tests/test_no_undefined_names.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.7.17 → 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
|