python-aidot-cameras 0.5.4__tar.gz → 0.5.6__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 (29) hide show
  1. {python_aidot_cameras-0.5.4/src/python_aidot_cameras.egg-info → python_aidot_cameras-0.5.6}/PKG-INFO +1 -1
  2. {python_aidot_cameras-0.5.4 → python_aidot_cameras-0.5.6}/pyproject.toml +1 -1
  3. {python_aidot_cameras-0.5.4 → python_aidot_cameras-0.5.6}/src/aidot/device_client.py +103 -21
  4. {python_aidot_cameras-0.5.4 → python_aidot_cameras-0.5.6/src/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  5. {python_aidot_cameras-0.5.4 → python_aidot_cameras-0.5.6}/LICENSE +0 -0
  6. {python_aidot_cameras-0.5.4 → python_aidot_cameras-0.5.6}/README.md +0 -0
  7. {python_aidot_cameras-0.5.4 → python_aidot_cameras-0.5.6}/setup.cfg +0 -0
  8. {python_aidot_cameras-0.5.4 → python_aidot_cameras-0.5.6}/src/aidot/__init__.py +0 -0
  9. {python_aidot_cameras-0.5.4 → python_aidot_cameras-0.5.6}/src/aidot/aes_utils.py +0 -0
  10. {python_aidot_cameras-0.5.4 → python_aidot_cameras-0.5.6}/src/aidot/client.py +0 -0
  11. {python_aidot_cameras-0.5.4 → python_aidot_cameras-0.5.6}/src/aidot/const.py +0 -0
  12. {python_aidot_cameras-0.5.4 → python_aidot_cameras-0.5.6}/src/aidot/credentials.py +0 -0
  13. {python_aidot_cameras-0.5.4 → python_aidot_cameras-0.5.6}/src/aidot/discover.py +0 -0
  14. {python_aidot_cameras-0.5.4 → python_aidot_cameras-0.5.6}/src/aidot/exceptions.py +0 -0
  15. {python_aidot_cameras-0.5.4 → python_aidot_cameras-0.5.6}/src/aidot/g711.py +0 -0
  16. {python_aidot_cameras-0.5.4 → python_aidot_cameras-0.5.6}/src/python_aidot_cameras.egg-info/SOURCES.txt +0 -0
  17. {python_aidot_cameras-0.5.4 → python_aidot_cameras-0.5.6}/src/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  18. {python_aidot_cameras-0.5.4 → python_aidot_cameras-0.5.6}/src/python_aidot_cameras.egg-info/requires.txt +0 -0
  19. {python_aidot_cameras-0.5.4 → python_aidot_cameras-0.5.6}/src/python_aidot_cameras.egg-info/top_level.txt +0 -0
  20. {python_aidot_cameras-0.5.4 → python_aidot_cameras-0.5.6}/tests/test_alarm_event.py +0 -0
  21. {python_aidot_cameras-0.5.4 → python_aidot_cameras-0.5.6}/tests/test_highport_nomination.py +0 -0
  22. {python_aidot_cameras-0.5.4 → python_aidot_cameras-0.5.6}/tests/test_motion_poll.py +0 -0
  23. {python_aidot_cameras-0.5.4 → python_aidot_cameras-0.5.6}/tests/test_sdes_talk.py +0 -0
  24. {python_aidot_cameras-0.5.4 → python_aidot_cameras-0.5.6}/tests/test_sdes_watchdog.py +0 -0
  25. {python_aidot_cameras-0.5.4 → python_aidot_cameras-0.5.6}/tests/test_speak.py +0 -0
  26. {python_aidot_cameras-0.5.4 → python_aidot_cameras-0.5.6}/tests/test_stream_cap.py +0 -0
  27. {python_aidot_cameras-0.5.4 → python_aidot_cameras-0.5.6}/tests/test_talk.py +0 -0
  28. {python_aidot_cameras-0.5.4 → python_aidot_cameras-0.5.6}/tests/test_terminal_ack.py +0 -0
  29. {python_aidot_cameras-0.5.4 → python_aidot_cameras-0.5.6}/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.5.4
3
+ Version: 0.5.6
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.5.4"
7
+ version = "0.5.6"
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"
@@ -4042,7 +4042,11 @@ class DeviceClient(object):
4042
4042
  except asyncio.CancelledError:
4043
4043
  return
4044
4044
 
4045
- async def start_keepalive(self, rtsp_push_url: Optional[str] = None) -> None:
4045
+ async def start_keepalive(
4046
+ self,
4047
+ rtsp_push_url: Optional[str] = None,
4048
+ fast_connect: Optional[bool] = None,
4049
+ ) -> None:
4046
4050
  """Start a persistent stream that keeps the camera session alive.
4047
4051
 
4048
4052
  For SDES cameras (A001064/A001513): opens an indefinite ffmpeg stream and
@@ -4054,8 +4058,15 @@ class DeviceClient(object):
4054
4058
  pulls (real play/pause video, no decode/re-encode). Without a serve URL
4055
4059
  it falls back to the on_frame keepalive loop (latest_jpeg / MJPEG).
4056
4060
 
4061
+ ``fast_connect`` enables LAN-direct mode (skip the ~2.5s livePlay/ICE-config
4062
+ waits + TURN relay; see _async_open_webrtc_stream_impl). ``None`` leaves it
4063
+ to the ``AIDOT_FAST_CONNECT`` env var; ``True``/``False`` override it (e.g.
4064
+ from a Home Assistant config-entry option, since HA OS can't set env vars).
4065
+
4057
4066
  Safe to call multiple times - does nothing if already running.
4058
4067
  """
4068
+ if fast_connect is not None:
4069
+ self._fast_connect_opt = fast_connect
4059
4070
  if self._stream_task is not None and not self._stream_task.done():
4060
4071
  return
4061
4072
  self._keepalive_rtsp_url = rtsp_push_url
@@ -5154,6 +5165,24 @@ class DeviceClient(object):
5154
5165
 
5155
5166
  use_sdes = force_sdes if force_sdes is not None else self.is_sdes_camera
5156
5167
 
5168
+ # AIDOT_FAST_CONNECT (default off): LAN-direct mode. Both transports stall
5169
+ # the offer on a TURN relay allocation to the cloud TURN server before ICE
5170
+ # can even start — for DTLS, aiortc's setLocalDescription blocks until ICE
5171
+ # gathering (incl. TURN Allocate) completes; for SDES we synchronously
5172
+ # pre-allocate relay before building the offer. On a LAN the camera's host
5173
+ # candidate wins anyway, so this is pure cold-start latency (~2-3 s). When
5174
+ # set, we skip relay allocation so the offer goes out immediately and the
5175
+ # LAN host candidate connects in ~1 s. TRADE-OFF: no relay means cameras
5176
+ # on a different network segment / behind strict NAT cannot connect, so
5177
+ # this is opt-in and off by default.
5178
+ # Prefer an explicit per-camera setting (e.g. HA config-entry option via
5179
+ # start_keepalive(fast_connect=...)); fall back to the env var otherwise.
5180
+ _fast_connect = getattr(self, "_fast_connect_opt", None)
5181
+ if _fast_connect is None:
5182
+ _fast_connect = os.environ.get("AIDOT_FAST_CONNECT", "").strip().lower() in (
5183
+ "1", "true", "yes", "on",
5184
+ )
5185
+
5157
5186
  # Wake battery cameras via the cloud HTTP low-power endpoint before the
5158
5187
  # handshake (matches the app, which fires the HTTP wake so a sleeping camera
5159
5188
  # gets the signal even with no live MQTT session; the MQTT
@@ -5924,36 +5953,61 @@ class DeviceClient(object):
5924
5953
  _status(f"livePlayReq sent peerid={peer_id}")
5925
5954
  # Wait for the broker to echo livePlayReq back (confirms delivery).
5926
5955
  # Proceed as soon as the echo arrives; fall through after 0.5 s.
5927
- try:
5928
- await asyncio.wait_for(liveplay_echo_ev.wait(), timeout=0.5)
5929
- except asyncio.TimeoutError:
5930
- pass
5956
+ # AIDOT_FAST_CONNECT skips this too (the echo often just times out,
5957
+ # costing a flat 0.5 s); we proceed straight to SDP/ICE.
5958
+ if not _fast_connect:
5959
+ try:
5960
+ await asyncio.wait_for(liveplay_echo_ev.wait(), timeout=0.5)
5961
+ except asyncio.TimeoutError:
5962
+ pass
5931
5963
  # livePlayResp carries camera-side accept/reject. If the camera
5932
5964
  # rejects start-play, continuing to SDP/ICE causes large STUN churn
5933
5965
  # but no media. Fail fast with the concrete code.
5934
- try:
5935
- _lp_resp = await asyncio.wait_for(
5936
- asyncio.shield(liveplay_resp_fut), timeout=2.0
5937
- )
5938
- _lp_code = int(_lp_resp.get("code", 200))
5939
- _lp_on = int(_lp_resp.get("livePlay", 1))
5940
- if _lp_code not in (0, 200) or _lp_on == 0:
5941
- raise RuntimeError(
5942
- f"livePlay rejected by camera (code={_lp_code}, livePlay={_lp_on})"
5966
+ # MEASURED (2026-06-07, live A/B): this up-to-2 s wait is the DOMINANT
5967
+ # cold-start cost (camera-awake → ICE-servers ≈ 2.5 s = 0.5 s echo +
5968
+ # 2.0 s here). The official app does NOT wait for/parse livePlayResp
5969
+ # (parity-confirmed). AIDOT_FAST_CONNECT skips it and proceeds to
5970
+ # SDP/ICE immediately — losing fast-fail on rejection (rare; ICE then
5971
+ # fails instead) in exchange for ~2 s off every LAN connect.
5972
+ if not _fast_connect:
5973
+ try:
5974
+ _lp_resp = await asyncio.wait_for(
5975
+ asyncio.shield(liveplay_resp_fut), timeout=2.0
5943
5976
  )
5944
- except asyncio.TimeoutError:
5945
- pass
5977
+ _lp_code = int(_lp_resp.get("code", 200))
5978
+ _lp_on = int(_lp_resp.get("livePlay", 1))
5979
+ if _lp_code not in (0, 200) or _lp_on == 0:
5980
+ raise RuntimeError(
5981
+ f"livePlay rejected by camera (code={_lp_code}, livePlay={_lp_on})"
5982
+ )
5983
+ except asyncio.TimeoutError:
5984
+ pass
5985
+ else:
5986
+ _status(
5987
+ "AIDOT_FAST_CONNECT: skipping livePlayResp wait (~2s) -"
5988
+ " proceeding to SDP/ICE (app-parity, no fast-fail on reject)"
5989
+ )
5946
5990
  # Short extra wait for getIceConfigResp - the server may only respond
5947
5991
  # once a live camera session is active (i.e. after livePlayReq).
5948
5992
  # Waiting here ensures TURN credentials arrive before RTCPeerConnection
5949
- # is created. The 3-second window is long enough for typical Arnoo
5950
- # broker round-trips without adding noticeable latency to fast paths.
5951
- if not ice_config_fut.done():
5993
+ # is created. MEASURED (2026-06-07, live): this wait is the DOMINANT
5994
+ # cold-start cost ~2.5 s for getIceConfigResp to arrive after camera
5995
+ # wake (NOT the TURN gather, which is ~70 ms). AIDOT_FAST_CONNECT
5996
+ # skips it: we proceed immediately on STUN/host candidates (no relay),
5997
+ # so a LAN camera connects in ~1 s. getIceConfigResp only supplies the
5998
+ # per-session TURN relay credentials, which LAN-direct doesn't need;
5999
+ # remote/strict-NAT cameras do, hence this is opt-in (see flag above).
6000
+ if not ice_config_fut.done() and not _fast_connect:
5952
6001
  try:
5953
6002
  await asyncio.wait_for(asyncio.shield(ice_config_fut), timeout=3.0)
5954
6003
  _status("getIceConfigResp received (post-livePlayReq)")
5955
6004
  except asyncio.TimeoutError:
5956
6005
  pass # proceed without TURN; synthetic candidates are fallback
6006
+ elif _fast_connect:
6007
+ _status(
6008
+ "AIDOT_FAST_CONNECT: skipping getIceConfigResp wait"
6009
+ " (~2.5s) - STUN/host candidates only, LAN-direct"
6010
+ )
5957
6011
 
5958
6012
  # ------------------------------------------------------------------ #
5959
6013
  # Branch: SDES-SRTP cameras use ffmpeg; DTLS cameras use aiortc
@@ -6152,6 +6206,28 @@ class DeviceClient(object):
6152
6206
  f"ICE servers: STUN={_stun_url}"
6153
6207
  f" relay×{len(_turn_entries)}: {_turn_entries}"
6154
6208
  )
6209
+ if _fast_connect:
6210
+ # Strip TURN URIs so aiortc's setLocalDescription doesn't block on a
6211
+ # TURN Allocate round-trip during ICE gathering — the LAN host
6212
+ # candidate then connects in ~1 s. Keep STUN (cheap, no allocate).
6213
+ _stun_only = []
6214
+ for _srv in _ice_servers:
6215
+ _su = [
6216
+ u for u in (_srv.urls if isinstance(_srv.urls, list) else [_srv.urls])
6217
+ if not str(u).startswith(("turn:", "turns:"))
6218
+ ]
6219
+ if _su:
6220
+ _stun_only.append(
6221
+ RTCIceServer(urls=_su, username=_srv.username,
6222
+ credential=_srv.credential)
6223
+ )
6224
+ _ice_servers = _stun_only or [
6225
+ RTCIceServer(urls=["stun:stun.l.google.com:19302"])
6226
+ ]
6227
+ _status(
6228
+ "AIDOT_FAST_CONNECT: stripped TURN (STUN-only, LAN-direct) -"
6229
+ f" {len(_ice_servers)} ICE server(s), no relay gather stall"
6230
+ )
6155
6231
  pc = RTCPeerConnection(
6156
6232
  configuration=RTCConfiguration(iceServers=_ice_servers)
6157
6233
  )
@@ -8810,7 +8886,10 @@ class DeviceClient(object):
8810
8886
  # Allocate relay now so offer c= and m= carry relay IP/port.
8811
8887
  # Camera reads offer's c= to know where to send SRTP - relay address
8812
8888
  # here means SRTP reaches us even through port-restricted / hairpin NAT.
8813
- if _sdes_turn_entries:
8889
+ # AIDOT_FAST_CONNECT skips this blocking pre-allocation (LAN-direct mode):
8890
+ # the offer goes out immediately with host/srflx candidates and the LAN
8891
+ # path connects without waiting on a cloud TURN Allocate round-trip.
8892
+ if _sdes_turn_entries and not _fast_connect:
8814
8893
  try:
8815
8894
  import re as _re_pre, hashlib as _hlk_pre
8816
8895
  _our_te_pre = next(
@@ -9500,7 +9579,10 @@ class DeviceClient(object):
9500
9579
  # Early allocation was done inside the echo handler above. This block
9501
9580
  # handles cameras that did not produce an echo (non-echo-reversal path)
9502
9581
  # so _relay_addrs is still empty at this point.
9503
- if not _relay_addrs:
9582
+ # Skipped in AIDOT_FAST_CONNECT (LAN-direct): no relay alloc, host/srflx
9583
+ # candidates only (the synchronous allocate would block the post-offer
9584
+ # candidate trickle on a cloud round-trip).
9585
+ if not _relay_addrs and not _fast_connect:
9504
9586
  try:
9505
9587
  import re as _re_relay, hashlib as _hlk
9506
9588
  _our_turn_entry = None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-aidot-cameras
3
- Version: 0.5.4
3
+ Version: 0.5.6
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