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.
Files changed (58) hide show
  1. {python_aidot_cameras-0.7.17/src/python_aidot_cameras.egg-info → python_aidot_cameras-0.7.19}/PKG-INFO +1 -1
  2. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/pyproject.toml +1 -1
  3. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/aidot/camera/client.py +179 -9
  4. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/aidot/camera/protocol.py +183 -0
  5. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19/src/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  6. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/python_aidot_cameras.egg-info/SOURCES.txt +2 -0
  7. python_aidot_cameras-0.7.19/tests/test_serve_relay.py +179 -0
  8. python_aidot_cameras-0.7.19/tests/test_stream_idle.py +69 -0
  9. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/LICENSE +0 -0
  10. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/MANIFEST.in +0 -0
  11. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/README.md +0 -0
  12. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/setup.cfg +0 -0
  13. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/aidot/__init__.py +0 -0
  14. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/aidot/aes_utils.py +0 -0
  15. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/aidot/camera/__init__.py +0 -0
  16. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/aidot/camera/constants.py +0 -0
  17. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/aidot/camera/controls.py +0 -0
  18. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/aidot/camera/go2rtc.py +0 -0
  19. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/aidot/camera/lan_control.py +0 -0
  20. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/aidot/camera/models.py +0 -0
  21. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/aidot/camera/playback.py +0 -0
  22. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/aidot/camera/sdes.py +0 -0
  23. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/aidot/camera/tutk.py +0 -0
  24. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/aidot/camera/webrtc.py +0 -0
  25. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/aidot/client.py +0 -0
  26. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/aidot/const.py +0 -0
  27. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/aidot/credentials.py +0 -0
  28. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/aidot/device_client.py +0 -0
  29. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/aidot/discover.py +0 -0
  30. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/aidot/exceptions.py +0 -0
  31. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/aidot/g711.py +0 -0
  32. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/aidot/login_const.py +0 -0
  33. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/aidot/models/__init__.py +0 -0
  34. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/aidot/models/device_client_model.py +0 -0
  35. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/aidot/models/device_model.py +0 -0
  36. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/aidot/models/discover_model.py +0 -0
  37. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/aidot/py.typed +0 -0
  38. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  39. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/python_aidot_cameras.egg-info/requires.txt +0 -0
  40. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/src/python_aidot_cameras.egg-info/top_level.txt +0 -0
  41. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/tests/test_alarm_event.py +0 -0
  42. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/tests/test_backoff.py +0 -0
  43. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/tests/test_go2rtc.py +0 -0
  44. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/tests/test_highport_nomination.py +0 -0
  45. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/tests/test_lan_control.py +0 -0
  46. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/tests/test_live_stream_param.py +0 -0
  47. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/tests/test_motion_poll.py +0 -0
  48. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/tests/test_no_undefined_names.py +0 -0
  49. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/tests/test_post_merge_hardening.py +0 -0
  50. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/tests/test_sdes_idle_release.py +0 -0
  51. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/tests/test_sdes_sprop.py +0 -0
  52. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/tests/test_sdes_talk.py +0 -0
  53. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/tests/test_sdes_watchdog.py +0 -0
  54. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/tests/test_speak.py +0 -0
  55. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/tests/test_stream_cap.py +0 -0
  56. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/tests/test_talk.py +0 -0
  57. {python_aidot_cameras-0.7.17 → python_aidot_cameras-0.7.19}/tests/test_terminal_ack.py +0 -0
  58. {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.17
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.17"
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
- _idle_on = os.environ.get("AIDOT_SDES_IDLE_RELEASE", "1") != "0"
2206
- _idle_secs = float(os.environ.get("AIDOT_STREAM_IDLE_S", "120"))
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
- idle_secs = float(os.environ.get("AIDOT_STREAM_IDLE_S", "120"))
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(serve_url, rfd)
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
- if loop.time() - progress[0] > idle_secs:
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
- _PLI_BURST_N = 3 # 5 s-spaced startup PLIs (t≈0,5,10)
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 = 5.0 if _pli_done < _PLI_BURST_N else 30.0
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 <= 3:
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.17
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)