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.
Files changed (57) hide show
  1. {python_aidot_cameras-0.7.17/src/python_aidot_cameras.egg-info → python_aidot_cameras-0.7.18}/PKG-INFO +1 -1
  2. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/pyproject.toml +1 -1
  3. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/aidot/camera/client.py +96 -1
  4. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/aidot/camera/protocol.py +183 -0
  5. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18/src/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  6. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/python_aidot_cameras.egg-info/SOURCES.txt +1 -0
  7. python_aidot_cameras-0.7.18/tests/test_serve_relay.py +179 -0
  8. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/LICENSE +0 -0
  9. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/MANIFEST.in +0 -0
  10. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/README.md +0 -0
  11. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/setup.cfg +0 -0
  12. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/aidot/__init__.py +0 -0
  13. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/aidot/aes_utils.py +0 -0
  14. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/aidot/camera/__init__.py +0 -0
  15. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/aidot/camera/constants.py +0 -0
  16. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/aidot/camera/controls.py +0 -0
  17. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/aidot/camera/go2rtc.py +0 -0
  18. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/aidot/camera/lan_control.py +0 -0
  19. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/aidot/camera/models.py +0 -0
  20. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/aidot/camera/playback.py +0 -0
  21. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/aidot/camera/sdes.py +0 -0
  22. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/aidot/camera/tutk.py +0 -0
  23. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/aidot/camera/webrtc.py +0 -0
  24. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/aidot/client.py +0 -0
  25. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/aidot/const.py +0 -0
  26. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/aidot/credentials.py +0 -0
  27. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/aidot/device_client.py +0 -0
  28. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/aidot/discover.py +0 -0
  29. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/aidot/exceptions.py +0 -0
  30. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/aidot/g711.py +0 -0
  31. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/aidot/login_const.py +0 -0
  32. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/aidot/models/__init__.py +0 -0
  33. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/aidot/models/device_client_model.py +0 -0
  34. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/aidot/models/device_model.py +0 -0
  35. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/aidot/models/discover_model.py +0 -0
  36. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/aidot/py.typed +0 -0
  37. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  38. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/python_aidot_cameras.egg-info/requires.txt +0 -0
  39. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/src/python_aidot_cameras.egg-info/top_level.txt +0 -0
  40. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/tests/test_alarm_event.py +0 -0
  41. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/tests/test_backoff.py +0 -0
  42. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/tests/test_go2rtc.py +0 -0
  43. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/tests/test_highport_nomination.py +0 -0
  44. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/tests/test_lan_control.py +0 -0
  45. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/tests/test_live_stream_param.py +0 -0
  46. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/tests/test_motion_poll.py +0 -0
  47. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/tests/test_no_undefined_names.py +0 -0
  48. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/tests/test_post_merge_hardening.py +0 -0
  49. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/tests/test_sdes_idle_release.py +0 -0
  50. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/tests/test_sdes_sprop.py +0 -0
  51. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/tests/test_sdes_talk.py +0 -0
  52. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/tests/test_sdes_watchdog.py +0 -0
  53. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/tests/test_speak.py +0 -0
  54. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/tests/test_stream_cap.py +0 -0
  55. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/tests/test_talk.py +0 -0
  56. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.18}/tests/test_terminal_ack.py +0 -0
  57. {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.17
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.17"
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(serve_url, rfd)
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.17
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
@@ -47,6 +47,7 @@ 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
52
53
  tests/test_talk.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)