python-aidot-cameras 0.5.2__tar.gz → 0.5.5__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {python_aidot_cameras-0.5.2/src/python_aidot_cameras.egg-info → python_aidot_cameras-0.5.5}/PKG-INFO +1 -1
- {python_aidot_cameras-0.5.2 → python_aidot_cameras-0.5.5}/pyproject.toml +1 -1
- {python_aidot_cameras-0.5.2 → python_aidot_cameras-0.5.5}/src/aidot/device_client.py +103 -8
- {python_aidot_cameras-0.5.2 → python_aidot_cameras-0.5.5/src/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
- {python_aidot_cameras-0.5.2 → python_aidot_cameras-0.5.5}/LICENSE +0 -0
- {python_aidot_cameras-0.5.2 → python_aidot_cameras-0.5.5}/README.md +0 -0
- {python_aidot_cameras-0.5.2 → python_aidot_cameras-0.5.5}/setup.cfg +0 -0
- {python_aidot_cameras-0.5.2 → python_aidot_cameras-0.5.5}/src/aidot/__init__.py +0 -0
- {python_aidot_cameras-0.5.2 → python_aidot_cameras-0.5.5}/src/aidot/aes_utils.py +0 -0
- {python_aidot_cameras-0.5.2 → python_aidot_cameras-0.5.5}/src/aidot/client.py +0 -0
- {python_aidot_cameras-0.5.2 → python_aidot_cameras-0.5.5}/src/aidot/const.py +0 -0
- {python_aidot_cameras-0.5.2 → python_aidot_cameras-0.5.5}/src/aidot/credentials.py +0 -0
- {python_aidot_cameras-0.5.2 → python_aidot_cameras-0.5.5}/src/aidot/discover.py +0 -0
- {python_aidot_cameras-0.5.2 → python_aidot_cameras-0.5.5}/src/aidot/exceptions.py +0 -0
- {python_aidot_cameras-0.5.2 → python_aidot_cameras-0.5.5}/src/aidot/g711.py +0 -0
- {python_aidot_cameras-0.5.2 → python_aidot_cameras-0.5.5}/src/python_aidot_cameras.egg-info/SOURCES.txt +0 -0
- {python_aidot_cameras-0.5.2 → python_aidot_cameras-0.5.5}/src/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
- {python_aidot_cameras-0.5.2 → python_aidot_cameras-0.5.5}/src/python_aidot_cameras.egg-info/requires.txt +0 -0
- {python_aidot_cameras-0.5.2 → python_aidot_cameras-0.5.5}/src/python_aidot_cameras.egg-info/top_level.txt +0 -0
- {python_aidot_cameras-0.5.2 → python_aidot_cameras-0.5.5}/tests/test_alarm_event.py +0 -0
- {python_aidot_cameras-0.5.2 → python_aidot_cameras-0.5.5}/tests/test_highport_nomination.py +0 -0
- {python_aidot_cameras-0.5.2 → python_aidot_cameras-0.5.5}/tests/test_motion_poll.py +0 -0
- {python_aidot_cameras-0.5.2 → python_aidot_cameras-0.5.5}/tests/test_sdes_talk.py +0 -0
- {python_aidot_cameras-0.5.2 → python_aidot_cameras-0.5.5}/tests/test_sdes_watchdog.py +0 -0
- {python_aidot_cameras-0.5.2 → python_aidot_cameras-0.5.5}/tests/test_speak.py +0 -0
- {python_aidot_cameras-0.5.2 → python_aidot_cameras-0.5.5}/tests/test_stream_cap.py +0 -0
- {python_aidot_cameras-0.5.2 → python_aidot_cameras-0.5.5}/tests/test_talk.py +0 -0
- {python_aidot_cameras-0.5.2 → python_aidot_cameras-0.5.5}/tests/test_terminal_ack.py +0 -0
- {python_aidot_cameras-0.5.2 → python_aidot_cameras-0.5.5}/tests/test_token_refresh.py +0 -0
{python_aidot_cameras-0.5.2/src/python_aidot_cameras.egg-info → python_aidot_cameras-0.5.5}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-aidot-cameras
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.5
|
|
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.
|
|
7
|
+
version = "0.5.5"
|
|
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"
|
|
@@ -2110,7 +2110,32 @@ def _dtls_av_mux_run(vq, aq, out_fileobj, progress, stop_flag) -> None:
|
|
|
2110
2110
|
while True:
|
|
2111
2111
|
fr = fifo.read(1024) # AAC wants 1024-sample frames
|
|
2112
2112
|
if fr is None:
|
|
2113
|
-
|
|
2113
|
+
# 160 PCMA samples → 960 resampled @ 48 kHz, but AAC needs 1024.
|
|
2114
|
+
# Every ~17 packets (~340 ms) the FIFO falls 64 samples short
|
|
2115
|
+
# and would skip an entire 21 ms frame — audible choppiness.
|
|
2116
|
+
# Pad the leftover with silence so the frame is complete.
|
|
2117
|
+
s = getattr(fifo, "samples", 0)
|
|
2118
|
+
if s == 0:
|
|
2119
|
+
break
|
|
2120
|
+
fr = fifo.read(s)
|
|
2121
|
+
if fr is None or fr.samples == 0:
|
|
2122
|
+
break
|
|
2123
|
+
if _np is not None:
|
|
2124
|
+
try:
|
|
2125
|
+
arr = fr.to_ndarray()
|
|
2126
|
+
pad_n = 1024 - arr.shape[1]
|
|
2127
|
+
if pad_n > 0:
|
|
2128
|
+
padded = _np.concatenate(
|
|
2129
|
+
[arr, _np.zeros((arr.shape[0], pad_n), dtype=arr.dtype)],
|
|
2130
|
+
axis=1,
|
|
2131
|
+
)
|
|
2132
|
+
pfr = av.AudioFrame.from_ndarray(
|
|
2133
|
+
padded, format="fltp", layout="mono"
|
|
2134
|
+
)
|
|
2135
|
+
pfr.sample_rate = 48000
|
|
2136
|
+
fr = pfr
|
|
2137
|
+
except Exception:
|
|
2138
|
+
break
|
|
2114
2139
|
fr.pts = a_pts[0]
|
|
2115
2140
|
fr.time_base = Fraction(1, 48000)
|
|
2116
2141
|
a_pts[0] += fr.samples
|
|
@@ -2124,7 +2149,7 @@ def _dtls_av_mux_run(vq, aq, out_fileobj, progress, stop_flag) -> None:
|
|
|
2124
2149
|
while not stop_flag.is_set():
|
|
2125
2150
|
_flush_video()
|
|
2126
2151
|
_flush_audio()
|
|
2127
|
-
_t.sleep(0.
|
|
2152
|
+
_t.sleep(0.005) # 5 ms — processes audio within 5 ms of arrival vs 20 ms
|
|
2128
2153
|
_flush_video()
|
|
2129
2154
|
_flush_audio()
|
|
2130
2155
|
try:
|
|
@@ -4415,14 +4440,32 @@ class DeviceClient(object):
|
|
|
4415
4440
|
import threading as _threading
|
|
4416
4441
|
import queue as _queue
|
|
4417
4442
|
serve_url = self._keepalive_rtsp_url
|
|
4418
|
-
|
|
4443
|
+
# APK parity: the official app gates re-connects to ~15 s (f0.java I1=15000)
|
|
4444
|
+
# and never hammers. We previously floored at 5 s and a partial-success →
|
|
4445
|
+
# reset path could drop the effective spacing even lower, pounding a flaky
|
|
4446
|
+
# A000088 camera fast enough to wedge its DTLS stack (ICE completes but DTLS
|
|
4447
|
+
# never fires until a power-cycle). Floor the backoff at the 15 s gate and,
|
|
4448
|
+
# independently, enforce a hard minimum wall-clock spacing between OPEN
|
|
4449
|
+
# attempts so no code path (reset bug, fast PC-death) can pound the camera.
|
|
4450
|
+
_MIN_DELAY, _MAX_DELAY = 15.0, 300.0
|
|
4451
|
+
_open_gate = float(os.environ.get("AIDOT_DTLS_RETRY_GATE_S", "15"))
|
|
4419
4452
|
retry_delay = _MIN_DELAY
|
|
4453
|
+
_last_open_at = 0.0 # monotonic of the previous open attempt (0 = none yet)
|
|
4420
4454
|
loop = asyncio.get_running_loop()
|
|
4421
4455
|
while self._streaming_active:
|
|
4422
4456
|
# Thread-safe queues: taps run on the loop, the A/V mux in a thread.
|
|
4423
4457
|
vq: "_queue.Queue" = _queue.Queue(maxsize=600)
|
|
4424
4458
|
aq: "_queue.Queue" = _queue.Queue(maxsize=600)
|
|
4425
4459
|
self._serve_ready.clear() # fresh (cold) session: not ready until bound
|
|
4460
|
+
# Hard inter-attempt gate (APK parity): never start an open within
|
|
4461
|
+
# _open_gate seconds of the previous one, regardless of retry_delay.
|
|
4462
|
+
_since_open = loop.time() - _last_open_at
|
|
4463
|
+
if _last_open_at and _since_open < _open_gate:
|
|
4464
|
+
try:
|
|
4465
|
+
await asyncio.sleep(_open_gate - _since_open)
|
|
4466
|
+
except asyncio.CancelledError:
|
|
4467
|
+
return
|
|
4468
|
+
_last_open_at = loop.time()
|
|
4426
4469
|
try:
|
|
4427
4470
|
session = await self.async_open_webrtc_stream(on_frame=lambda _f: None)
|
|
4428
4471
|
except asyncio.CancelledError:
|
|
@@ -5111,6 +5154,20 @@ class DeviceClient(object):
|
|
|
5111
5154
|
|
|
5112
5155
|
use_sdes = force_sdes if force_sdes is not None else self.is_sdes_camera
|
|
5113
5156
|
|
|
5157
|
+
# AIDOT_FAST_CONNECT (default off): LAN-direct mode. Both transports stall
|
|
5158
|
+
# the offer on a TURN relay allocation to the cloud TURN server before ICE
|
|
5159
|
+
# can even start — for DTLS, aiortc's setLocalDescription blocks until ICE
|
|
5160
|
+
# gathering (incl. TURN Allocate) completes; for SDES we synchronously
|
|
5161
|
+
# pre-allocate relay before building the offer. On a LAN the camera's host
|
|
5162
|
+
# candidate wins anyway, so this is pure cold-start latency (~2-3 s). When
|
|
5163
|
+
# set, we skip relay allocation so the offer goes out immediately and the
|
|
5164
|
+
# LAN host candidate connects in ~1 s. TRADE-OFF: no relay means cameras
|
|
5165
|
+
# on a different network segment / behind strict NAT cannot connect, so
|
|
5166
|
+
# this is opt-in and off by default.
|
|
5167
|
+
_fast_connect = os.environ.get("AIDOT_FAST_CONNECT", "").strip().lower() in (
|
|
5168
|
+
"1", "true", "yes", "on",
|
|
5169
|
+
)
|
|
5170
|
+
|
|
5114
5171
|
# Wake battery cameras via the cloud HTTP low-power endpoint before the
|
|
5115
5172
|
# handshake (matches the app, which fires the HTTP wake so a sleeping camera
|
|
5116
5173
|
# gets the signal even with no live MQTT session; the MQTT
|
|
@@ -5903,14 +5960,24 @@ class DeviceClient(object):
|
|
|
5903
5960
|
# Short extra wait for getIceConfigResp - the server may only respond
|
|
5904
5961
|
# once a live camera session is active (i.e. after livePlayReq).
|
|
5905
5962
|
# Waiting here ensures TURN credentials arrive before RTCPeerConnection
|
|
5906
|
-
# is created.
|
|
5907
|
-
#
|
|
5908
|
-
|
|
5963
|
+
# is created. MEASURED (2026-06-07, live): this wait is the DOMINANT
|
|
5964
|
+
# cold-start cost — ~2.5 s for getIceConfigResp to arrive after camera
|
|
5965
|
+
# wake (NOT the TURN gather, which is ~70 ms). AIDOT_FAST_CONNECT
|
|
5966
|
+
# skips it: we proceed immediately on STUN/host candidates (no relay),
|
|
5967
|
+
# so a LAN camera connects in ~1 s. getIceConfigResp only supplies the
|
|
5968
|
+
# per-session TURN relay credentials, which LAN-direct doesn't need;
|
|
5969
|
+
# remote/strict-NAT cameras do, hence this is opt-in (see flag above).
|
|
5970
|
+
if not ice_config_fut.done() and not _fast_connect:
|
|
5909
5971
|
try:
|
|
5910
5972
|
await asyncio.wait_for(asyncio.shield(ice_config_fut), timeout=3.0)
|
|
5911
5973
|
_status("getIceConfigResp received (post-livePlayReq)")
|
|
5912
5974
|
except asyncio.TimeoutError:
|
|
5913
5975
|
pass # proceed without TURN; synthetic candidates are fallback
|
|
5976
|
+
elif _fast_connect:
|
|
5977
|
+
_status(
|
|
5978
|
+
"AIDOT_FAST_CONNECT: skipping getIceConfigResp wait"
|
|
5979
|
+
" (~2.5s) - STUN/host candidates only, LAN-direct"
|
|
5980
|
+
)
|
|
5914
5981
|
|
|
5915
5982
|
# ------------------------------------------------------------------ #
|
|
5916
5983
|
# Branch: SDES-SRTP cameras use ffmpeg; DTLS cameras use aiortc
|
|
@@ -6109,6 +6176,28 @@ class DeviceClient(object):
|
|
|
6109
6176
|
f"ICE servers: STUN={_stun_url}"
|
|
6110
6177
|
f" relay×{len(_turn_entries)}: {_turn_entries}"
|
|
6111
6178
|
)
|
|
6179
|
+
if _fast_connect:
|
|
6180
|
+
# Strip TURN URIs so aiortc's setLocalDescription doesn't block on a
|
|
6181
|
+
# TURN Allocate round-trip during ICE gathering — the LAN host
|
|
6182
|
+
# candidate then connects in ~1 s. Keep STUN (cheap, no allocate).
|
|
6183
|
+
_stun_only = []
|
|
6184
|
+
for _srv in _ice_servers:
|
|
6185
|
+
_su = [
|
|
6186
|
+
u for u in (_srv.urls if isinstance(_srv.urls, list) else [_srv.urls])
|
|
6187
|
+
if not str(u).startswith(("turn:", "turns:"))
|
|
6188
|
+
]
|
|
6189
|
+
if _su:
|
|
6190
|
+
_stun_only.append(
|
|
6191
|
+
RTCIceServer(urls=_su, username=_srv.username,
|
|
6192
|
+
credential=_srv.credential)
|
|
6193
|
+
)
|
|
6194
|
+
_ice_servers = _stun_only or [
|
|
6195
|
+
RTCIceServer(urls=["stun:stun.l.google.com:19302"])
|
|
6196
|
+
]
|
|
6197
|
+
_status(
|
|
6198
|
+
"AIDOT_FAST_CONNECT: stripped TURN (STUN-only, LAN-direct) -"
|
|
6199
|
+
f" {len(_ice_servers)} ICE server(s), no relay gather stall"
|
|
6200
|
+
)
|
|
6112
6201
|
pc = RTCPeerConnection(
|
|
6113
6202
|
configuration=RTCConfiguration(iceServers=_ice_servers)
|
|
6114
6203
|
)
|
|
@@ -8767,7 +8856,10 @@ class DeviceClient(object):
|
|
|
8767
8856
|
# Allocate relay now so offer c= and m= carry relay IP/port.
|
|
8768
8857
|
# Camera reads offer's c= to know where to send SRTP - relay address
|
|
8769
8858
|
# here means SRTP reaches us even through port-restricted / hairpin NAT.
|
|
8770
|
-
|
|
8859
|
+
# AIDOT_FAST_CONNECT skips this blocking pre-allocation (LAN-direct mode):
|
|
8860
|
+
# the offer goes out immediately with host/srflx candidates and the LAN
|
|
8861
|
+
# path connects without waiting on a cloud TURN Allocate round-trip.
|
|
8862
|
+
if _sdes_turn_entries and not _fast_connect:
|
|
8771
8863
|
try:
|
|
8772
8864
|
import re as _re_pre, hashlib as _hlk_pre
|
|
8773
8865
|
_our_te_pre = next(
|
|
@@ -9457,7 +9549,10 @@ class DeviceClient(object):
|
|
|
9457
9549
|
# Early allocation was done inside the echo handler above. This block
|
|
9458
9550
|
# handles cameras that did not produce an echo (non-echo-reversal path)
|
|
9459
9551
|
# so _relay_addrs is still empty at this point.
|
|
9460
|
-
|
|
9552
|
+
# Skipped in AIDOT_FAST_CONNECT (LAN-direct): no relay alloc, host/srflx
|
|
9553
|
+
# candidates only (the synchronous allocate would block the post-offer
|
|
9554
|
+
# candidate trickle on a cloud round-trip).
|
|
9555
|
+
if not _relay_addrs and not _fast_connect:
|
|
9461
9556
|
try:
|
|
9462
9557
|
import re as _re_relay, hashlib as _hlk
|
|
9463
9558
|
_our_turn_entry = None
|
{python_aidot_cameras-0.5.2 → python_aidot_cameras-0.5.5/src/python_aidot_cameras.egg-info}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-aidot-cameras
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.5
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|