python-aidot-cameras 0.7.16__tar.gz → 0.7.18__tar.gz

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