python-aidot-cameras 0.11.3__tar.gz → 0.11.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.
- {python_aidot_cameras-0.11.3/python_aidot_cameras.egg-info → python_aidot_cameras-0.11.6}/PKG-INFO +2 -2
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/camera/client.py +110 -5
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/camera/protocol.py +38 -5
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/camera/sdes_open.py +61 -1
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/camera/webrtc_open.py +18 -26
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/client.py +23 -4
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/pyproject.toml +2 -2
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6/python_aidot_cameras.egg-info}/PKG-INFO +2 -2
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/python_aidot_cameras.egg-info/SOURCES.txt +4 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/python_aidot_cameras.egg-info/requires.txt +1 -1
- python_aidot_cameras-0.11.6/tests/test_keepalive_renew.py +119 -0
- python_aidot_cameras-0.11.6/tests/test_mqtt_session_reconnect.py +84 -0
- python_aidot_cameras-0.11.6/tests/test_sdes_open_cleanup.py +72 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_sdes_sprop.py +16 -0
- python_aidot_cameras-0.11.6/tests/test_stream_drain_ownership.py +73 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/LICENSE +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/MANIFEST.in +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/README.md +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/__init__.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/__main__.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/__init__.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/__init__.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/clock.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/codecs/__init__.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/codecs/base.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/codecs/g711.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/codecs/g722.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/codecs/h264.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/codecs/opus.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/codecs/vpx.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/contrib/__init__.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/contrib/media.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/contrib/signaling.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/events.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/exceptions.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/jitterbuffer.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/mediastreams.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rate.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rtcconfiguration.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rtcdatachannel.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rtcdtlstransport.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rtcicetransport.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rtcpeerconnection.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rtcrtpparameters.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rtcrtpreceiver.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rtcrtpsender.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rtcrtptransceiver.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rtcsctptransport.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rtcsessiondescription.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rtp.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/sdp.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/stats.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/utils.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/aes_utils.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/camera/__init__.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/camera/constants.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/camera/controls.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/camera/go2rtc.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/camera/lan_control.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/camera/models.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/camera/playback.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/camera/sdes.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/camera/tutk.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/camera/webrtc.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/const.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/credentials.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/device_client.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/discover.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/exceptions.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/g711.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/login_const.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/models/__init__.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/models/device_client_model.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/models/device_model.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/models/discover_model.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/aidot/py.typed +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/python_aidot_cameras.egg-info/top_level.txt +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/setup.cfg +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_aioice_compat.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_alarm_event.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_backoff.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_deferred_hardening.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_device_login_guard.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_device_user_info_cache.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_dtls_not_ready_burst.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_dtls_pinning.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_dtls_skip_signaling_wait.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_egress_guard.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_go2rtc.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_go2rtc_cli.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_highport_nomination.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_ice_config_cache.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_keyframe_prompter.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_lan_control.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_live_stream_param.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_motion_poll.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_narrow_pc_ice.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_no_undefined_names.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_offline_keepalive_pause.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_open_gate_delay.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_persistent_mqtt.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_persistent_mqtt_robustness.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_playback_tls.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_post_merge_hardening.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_retry_policy.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_sdes_adaptive.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_sdes_echo_wait_timeout.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_sdes_fast_liveplay.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_sdes_idle_release.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_sdes_serve_audio.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_sdes_serve_cmd.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_sdes_talk.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_sdes_watchdog.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_security_hardening.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_serializable_login_info.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_serve_relay.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_session_stats.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_speak.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_stream_cap.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_stream_idle.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_stream_teardown.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_talk.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_terminal_ack.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_token_file_robustness.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_token_refresh.py +0 -0
- {python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6}/tests/test_wait_or_event.py +0 -0
{python_aidot_cameras-0.11.3/python_aidot_cameras.egg-info → python_aidot_cameras-0.11.6}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-aidot-cameras
|
|
3
|
-
Version: 0.11.
|
|
3
|
+
Version: 0.11.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
|
|
@@ -12,7 +12,7 @@ Requires-Python: >=3.11
|
|
|
12
12
|
Description-Content-Type: text/markdown
|
|
13
13
|
License-File: LICENSE
|
|
14
14
|
Requires-Dist: aiohttp>=3.9
|
|
15
|
-
Requires-Dist: paho-mqtt>=
|
|
15
|
+
Requires-Dist: paho-mqtt>=1.6.1
|
|
16
16
|
Requires-Dist: cryptography>=42.0
|
|
17
17
|
Requires-Dist: pycryptodome>=3.20
|
|
18
18
|
Requires-Dist: dacite>=1.8
|
|
@@ -565,6 +565,14 @@ def _get_stream_slots() -> "asyncio.Semaphore":
|
|
|
565
565
|
|
|
566
566
|
|
|
567
567
|
|
|
568
|
+
# Battery cameras re-sleep on their own low-power timer ~25 s after the last
|
|
569
|
+
# keep-alive, even mid-view. Renew setKeepAliveTime INSIDE that window so there
|
|
570
|
+
# is no sleep gap (the official app renews every 30 s, which leaves a ~5 s gap
|
|
571
|
+
# against the 25 s window). See CameraMixin._keepalive_renew_loop.
|
|
572
|
+
_KEEPALIVE_WINDOW_S = 25
|
|
573
|
+
_KEEPALIVE_RENEW_S = 20
|
|
574
|
+
|
|
575
|
+
|
|
568
576
|
class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
569
577
|
"""All camera/streaming methods, mixed into DeviceClient via inheritance."""
|
|
570
578
|
|
|
@@ -976,7 +984,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
976
984
|
|
|
977
985
|
Mirrors the official app's wake (DeviceWakeUpRepos.k()), which fires the
|
|
978
986
|
HTTP wake so a sleeping camera (no live MQTT session) still gets the signal
|
|
979
|
-
through the cloud, and retries on failure (n.java onError "
|
|
987
|
+
through the cloud, and retries on failure (n.java onError "retrying"). The
|
|
980
988
|
MQTT ``lowPowerActiveStateReq`` is also published by the streaming /
|
|
981
989
|
attribute paths; this adds the reliable cloud-side channel. Non-battery
|
|
982
990
|
cameras are always reachable, so this is a no-op for them.
|
|
@@ -1886,7 +1894,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
1886
1894
|
|
|
1887
1895
|
_MIME = {1: "video/mp4", 2: "application/x-mpegURL"}
|
|
1888
1896
|
|
|
1889
|
-
|
|
1897
|
+
async def _fetch():
|
|
1890
1898
|
async with aiohttp.ClientSession() as session:
|
|
1891
1899
|
async with session.post(
|
|
1892
1900
|
f"{self._aidot_v32_base}/playback/getEventVideoUrl",
|
|
@@ -1897,7 +1905,12 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
1897
1905
|
headers=self._aidot_headers(),
|
|
1898
1906
|
timeout=aiohttp.ClientTimeout(total=15),
|
|
1899
1907
|
) as resp:
|
|
1900
|
-
|
|
1908
|
+
return await resp.json(content_type=None)
|
|
1909
|
+
|
|
1910
|
+
try:
|
|
1911
|
+
body = await _fetch()
|
|
1912
|
+
if self._is_auth_error(body) and await self._async_refresh_auth_token():
|
|
1913
|
+
body = await _fetch() # retry once with the refreshed token
|
|
1901
1914
|
|
|
1902
1915
|
_LOGGER.debug("getEventVideoUrl raw for %s uuid=%s: %s", self.device_id, event_uuid, body)
|
|
1903
1916
|
|
|
@@ -1958,7 +1971,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
1958
1971
|
end_ts = int(time.time() * 1000)
|
|
1959
1972
|
start_ts = end_ts - 30 * 86_400_000 # look back 30 days
|
|
1960
1973
|
|
|
1961
|
-
|
|
1974
|
+
async def _fetch():
|
|
1962
1975
|
async with aiohttp.ClientSession() as session:
|
|
1963
1976
|
async with session.post(
|
|
1964
1977
|
f"{self._aidot_v32_base}/playback/eventRecordingList",
|
|
@@ -1972,7 +1985,12 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
1972
1985
|
headers=self._aidot_headers(),
|
|
1973
1986
|
timeout=aiohttp.ClientTimeout(total=15),
|
|
1974
1987
|
) as resp:
|
|
1975
|
-
|
|
1988
|
+
return await resp.json(content_type=None)
|
|
1989
|
+
|
|
1990
|
+
try:
|
|
1991
|
+
body = await _fetch()
|
|
1992
|
+
if self._is_auth_error(body) and await self._async_refresh_auth_token():
|
|
1993
|
+
body = await _fetch() # retry once with the refreshed token
|
|
1976
1994
|
|
|
1977
1995
|
_LOGGER.debug("thumbnail eventRecordingList for %s: %s", self.device_id, body)
|
|
1978
1996
|
if body.get("code") != 200:
|
|
@@ -2169,6 +2187,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
2169
2187
|
if self._stream_task is not None and not self._stream_task.done():
|
|
2170
2188
|
return
|
|
2171
2189
|
self._streaming_active = True
|
|
2190
|
+
self._start_keepalive_renew() # battery: renew keep-alive on this path too
|
|
2172
2191
|
self._stream_task = asyncio.ensure_future(self._streaming_loop())
|
|
2173
2192
|
|
|
2174
2193
|
async def async_stop_streaming(self) -> None:
|
|
@@ -2196,6 +2215,13 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
2196
2215
|
await task
|
|
2197
2216
|
except (asyncio.CancelledError, Exception):
|
|
2198
2217
|
_LOGGER.debug("camera %s: swallowed exception in %s", getattr(self, "device_id", "?"), 'async_stop_streaming', exc_info=True)
|
|
2218
|
+
ka_task, self._keepalive_task = getattr(self, "_keepalive_task", None), None
|
|
2219
|
+
if ka_task is not None and not ka_task.done():
|
|
2220
|
+
ka_task.cancel()
|
|
2221
|
+
try:
|
|
2222
|
+
await ka_task
|
|
2223
|
+
except (asyncio.CancelledError, Exception):
|
|
2224
|
+
_LOGGER.debug("camera %s: swallowed exception in %s", getattr(self, "device_id", "?"), 'async_stop_streaming', exc_info=True)
|
|
2199
2225
|
# Reap a persistent-MQTT stream drain that no session stopped (e.g. an
|
|
2200
2226
|
# open cancelled mid-handshake) so its handler is removed from the shared
|
|
2201
2227
|
# connection and its blocked executor thread is released.
|
|
@@ -2228,6 +2254,20 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
2228
2254
|
except (asyncio.CancelledError, Exception):
|
|
2229
2255
|
_LOGGER.debug("camera %s: swallowed exception in %s", getattr(self, "device_id", "?"), '_reap_stream_drain', exc_info=True)
|
|
2230
2256
|
|
|
2257
|
+
def _release_stream_drain_to_session(self):
|
|
2258
|
+
"""Hand ownership of the persistent-MQTT signaling drain to the session
|
|
2259
|
+
that just opened successfully.
|
|
2260
|
+
|
|
2261
|
+
The returned WebRTCSession/SdesSession reaps its own outgoing_q + mqtt_fut
|
|
2262
|
+
on stop(); ``_stream_mqtt_drain``/``_stream_mqtt_outq`` are only a backstop
|
|
2263
|
+
for a drain whose open was cancelled *before* a session took ownership.
|
|
2264
|
+
Clearing the slot on hand-off means a concurrent open on this same camera
|
|
2265
|
+
(e.g. a snapshot taken during a live view) no longer reaps - and so no
|
|
2266
|
+
longer kills - the live session's drain; the slot from then on only ever
|
|
2267
|
+
holds a genuinely orphaned (cancelled mid-handshake) drain."""
|
|
2268
|
+
self._stream_mqtt_drain = None
|
|
2269
|
+
self._stream_mqtt_outq = None
|
|
2270
|
+
|
|
2231
2271
|
async def async_start_motion_polling(
|
|
2232
2272
|
self, callback: Callable, interval: float = 30.0, lookback_s: int = 600,
|
|
2233
2273
|
) -> None:
|
|
@@ -2307,6 +2347,65 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
2307
2347
|
except asyncio.CancelledError:
|
|
2308
2348
|
return
|
|
2309
2349
|
|
|
2350
|
+
async def _async_set_keep_alive(self, keep_alive_s: int = _KEEPALIVE_WINDOW_S) -> None:
|
|
2351
|
+
"""POST setKeepAliveTime so a battery camera stays awake for keep_alive_s
|
|
2352
|
+
seconds. Best-effort (errors swallowed). Wire format matches the app
|
|
2353
|
+
(n.java: keepAliveTime=25)."""
|
|
2354
|
+
try:
|
|
2355
|
+
import aiohttp as _aiohttp
|
|
2356
|
+
async with _aiohttp.ClientSession() as _s:
|
|
2357
|
+
async with _s.post(
|
|
2358
|
+
f"{self._aidot_v32_base}/devices/{self.device_id}/setKeepAliveTime",
|
|
2359
|
+
json={"keepAliveTime": keep_alive_s},
|
|
2360
|
+
headers=self._aidot_headers(),
|
|
2361
|
+
timeout=_aiohttp.ClientTimeout(total=8),
|
|
2362
|
+
) as _r:
|
|
2363
|
+
_LOGGER.debug(
|
|
2364
|
+
"setKeepAliveTime HTTP %d for %s", _r.status, self.device_id
|
|
2365
|
+
)
|
|
2366
|
+
except Exception as _exc:
|
|
2367
|
+
_LOGGER.debug(
|
|
2368
|
+
"setKeepAliveTime HTTP failed for %s: %s", self.device_id, _exc
|
|
2369
|
+
)
|
|
2370
|
+
|
|
2371
|
+
def _start_keepalive_renew(self) -> None:
|
|
2372
|
+
"""Start the battery keep-alive renew loop - battery-only and
|
|
2373
|
+
single-instance: cancel any prior loop first so a re-view within the
|
|
2374
|
+
renew sleep window can't orphan one (an orphan keeps POSTing
|
|
2375
|
+
setKeepAliveTime until _streaming_active next clears). No-op for mains
|
|
2376
|
+
cameras, which never sleep."""
|
|
2377
|
+
if not self.is_battery_camera:
|
|
2378
|
+
return
|
|
2379
|
+
prev = getattr(self, "_keepalive_task", None)
|
|
2380
|
+
if prev is not None and not prev.done():
|
|
2381
|
+
prev.cancel()
|
|
2382
|
+
self._keepalive_task = asyncio.ensure_future(self._keepalive_renew_loop())
|
|
2383
|
+
|
|
2384
|
+
def _cancel_keepalive_renew(self) -> None:
|
|
2385
|
+
"""Cancel the battery keep-alive renew loop if running (fire-and-forget)."""
|
|
2386
|
+
task, self._keepalive_task = getattr(self, "_keepalive_task", None), None
|
|
2387
|
+
if task is not None and not task.done():
|
|
2388
|
+
task.cancel()
|
|
2389
|
+
|
|
2390
|
+
async def _keepalive_renew_loop(self) -> None:
|
|
2391
|
+
"""Renew setKeepAliveTime while a BATTERY camera is streaming (app parity).
|
|
2392
|
+
|
|
2393
|
+
The camera's own low-power timer returns it to sleep ~25 s after the last
|
|
2394
|
+
keep-alive even mid-view, so a one-shot keep-alive at open lets the stream
|
|
2395
|
+
drop partway through. The official app renews it throughout a live view;
|
|
2396
|
+
we renew every _KEEPALIVE_RENEW_S seconds (inside the 25 s window, so no
|
|
2397
|
+
sleep gap). Mains cameras never sleep, so this is battery-only."""
|
|
2398
|
+
if not self.is_battery_camera:
|
|
2399
|
+
return
|
|
2400
|
+
try:
|
|
2401
|
+
while self._streaming_active:
|
|
2402
|
+
await asyncio.sleep(_KEEPALIVE_RENEW_S)
|
|
2403
|
+
if not self._streaming_active:
|
|
2404
|
+
break
|
|
2405
|
+
await self._async_set_keep_alive()
|
|
2406
|
+
except asyncio.CancelledError:
|
|
2407
|
+
pass
|
|
2408
|
+
|
|
2310
2409
|
async def start_keepalive(
|
|
2311
2410
|
self,
|
|
2312
2411
|
rtsp_push_url: Optional[str] = None,
|
|
@@ -2381,6 +2480,10 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
2381
2480
|
self._keepalive_rtsp_url = rtsp_push_url
|
|
2382
2481
|
self._go2rtc_url = go2rtc_url
|
|
2383
2482
|
self._streaming_active = True
|
|
2483
|
+
# App parity: keep a battery camera awake for the whole view (mains cams
|
|
2484
|
+
# never sleep, so this is a no-op there). Single-instance; cancelled in
|
|
2485
|
+
# stop / idle-release.
|
|
2486
|
+
self._start_keepalive_renew()
|
|
2384
2487
|
if self.is_sdes_camera:
|
|
2385
2488
|
self._stream_task = asyncio.ensure_future(self._sdes_keepalive_loop())
|
|
2386
2489
|
elif rtsp_push_url and rtsp_push_url.startswith("http"):
|
|
@@ -2703,6 +2806,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
2703
2806
|
except Exception:
|
|
2704
2807
|
_LOGGER.debug("camera %s: swallowed exception in %s", getattr(self, "device_id", "?"), '_sdes_keepalive_loop', exc_info=True)
|
|
2705
2808
|
self._streaming_active = False
|
|
2809
|
+
self._cancel_keepalive_renew()
|
|
2706
2810
|
self._keepalive_rtsp_url = None
|
|
2707
2811
|
self._serve_ready.clear()
|
|
2708
2812
|
_LOGGER.debug(
|
|
@@ -3490,6 +3594,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
3490
3594
|
# No viewer - go dormant; camera.stream_source() restarts us when
|
|
3491
3595
|
# someone opens the live view again.
|
|
3492
3596
|
self._streaming_active = False
|
|
3597
|
+
self._cancel_keepalive_renew()
|
|
3493
3598
|
self._keepalive_rtsp_url = None
|
|
3494
3599
|
self._serve_ready.clear()
|
|
3495
3600
|
_LOGGER.debug(
|
|
@@ -536,6 +536,11 @@ def _extract_param_sets_from_rtp(pkt: bytes) -> dict:
|
|
|
536
536
|
while i + 2 <= len(payload):
|
|
537
537
|
size = int.from_bytes(payload[i:i + 2], "big")
|
|
538
538
|
i += 2
|
|
539
|
+
if i + size > len(payload):
|
|
540
|
+
# Truncated STAP-A: the advertised NAL size runs past the end of
|
|
541
|
+
# the packet, so the remaining bytes are garbage. Stop rather
|
|
542
|
+
# than slice a short NAL and cache a corrupt SPS/PPS.
|
|
543
|
+
break
|
|
539
544
|
nal = payload[i:i + size]
|
|
540
545
|
i += size
|
|
541
546
|
if nal:
|
|
@@ -1211,6 +1216,16 @@ def _mqtt_session_sync(
|
|
|
1211
1216
|
status["connected"] = (rc == 0)
|
|
1212
1217
|
status["rc"] = rc
|
|
1213
1218
|
status["rc_str"] = str(reason_code)
|
|
1219
|
+
if rc == 0:
|
|
1220
|
+
# (Re)subscribe on EVERY successful connect. paho's loop_start
|
|
1221
|
+
# auto-reconnects after a transient drop, but with clean_session the
|
|
1222
|
+
# broker retains no subscriptions, so a reconnected client would be
|
|
1223
|
+
# deaf unless they are re-established here rather than only once below.
|
|
1224
|
+
for _sub in subscribe_topics:
|
|
1225
|
+
try:
|
|
1226
|
+
c.subscribe(_sub)
|
|
1227
|
+
except Exception:
|
|
1228
|
+
_LOGGER.debug("_mqtt_session: resubscribe failed for %s", _sub, exc_info=True)
|
|
1214
1229
|
conn_ev.set()
|
|
1215
1230
|
|
|
1216
1231
|
def _on_message(c, ud, msg):
|
|
@@ -1219,14 +1234,32 @@ def _mqtt_session_sync(
|
|
|
1219
1234
|
else str(msg.payload))
|
|
1220
1235
|
msg_q.put((msg.topic, payload))
|
|
1221
1236
|
|
|
1222
|
-
def _on_disconnect(c, ud,
|
|
1237
|
+
def _on_disconnect(c, ud, *args):
|
|
1238
|
+
# paho >=2 calls (disconnect_flags, reason_code, properties); paho <2
|
|
1239
|
+
# calls (rc,). Pull the reason code from whichever slot it lands in so
|
|
1240
|
+
# the log is truthful on both (a fixed 5-arg signature put paho 1.x's rc
|
|
1241
|
+
# in disconnect_flags and logged reason_code as None).
|
|
1242
|
+
reason_code = args[1] if len(args) >= 2 else (args[0] if args else None)
|
|
1223
1243
|
# If _on_connect was never fired (WebSocket upgrade failed, auth refused
|
|
1224
1244
|
# at TCP level, etc.) signal conn_ev now so the caller doesn't time out.
|
|
1225
1245
|
if not conn_ev.is_set():
|
|
1246
|
+
# Never connected (WS upgrade failed, auth refused at TCP level, ...):
|
|
1247
|
+
# unblock the caller and end the receive loop - the connect failed.
|
|
1226
1248
|
status["connected"] = False
|
|
1227
1249
|
status["rc_str"] = f"disconnect-before-connect rc={reason_code}"
|
|
1228
1250
|
conn_ev.set()
|
|
1229
|
-
|
|
1251
|
+
msg_q.put(None) # sentinel: end the receive loop
|
|
1252
|
+
return
|
|
1253
|
+
# Already connected once: this is a transient drop. paho's loop_start
|
|
1254
|
+
# auto-reconnects and _on_connect re-subscribes, so DON'T end the receive
|
|
1255
|
+
# loop here - a broker blip must not tear down a long-lived stream's
|
|
1256
|
+
# signaling (the non-persistent transport runs one session for the whole
|
|
1257
|
+
# stream). A real teardown ends the loop via the outgoing-queue None
|
|
1258
|
+
# sentinel or the duration deadline instead.
|
|
1259
|
+
_LOGGER.debug(
|
|
1260
|
+
"_mqtt_session: transient disconnect rc=%s (paho will reconnect)",
|
|
1261
|
+
reason_code,
|
|
1262
|
+
)
|
|
1230
1263
|
|
|
1231
1264
|
def _on_log(c, ud, level, buf):
|
|
1232
1265
|
if len(status["log"]) < 500:
|
|
@@ -1274,9 +1307,9 @@ def _mqtt_session_sync(
|
|
|
1274
1307
|
|
|
1275
1308
|
_LOGGER.info("_mqtt_session: connected to %s:%d clientId=%s", hostname, port, client_id)
|
|
1276
1309
|
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1310
|
+
# Subscriptions are (re)established in _on_connect so they survive a paho
|
|
1311
|
+
# auto-reconnect; nothing to subscribe here.
|
|
1312
|
+
_LOGGER.debug("_mqtt_session: subscribed (in on_connect) %s", subscribe_topics)
|
|
1280
1313
|
|
|
1281
1314
|
for pub_topic, pub_payload in publish_items:
|
|
1282
1315
|
client.publish(pub_topic, pub_payload)
|
|
@@ -47,9 +47,38 @@ def _sdes_echo_wait_timeout(skip_liveplay: bool) -> float:
|
|
|
47
47
|
|
|
48
48
|
|
|
49
49
|
class _SdesOpenMixin:
|
|
50
|
-
async def _open_sdes_stream(
|
|
50
|
+
async def _open_sdes_stream(self, **kwargs) -> "SdesSession":
|
|
51
|
+
"""Allocate-and-hand-off wrapper around _open_sdes_stream_impl.
|
|
52
|
+
|
|
53
|
+
The impl reserves two UDP sockets, starts a bridge thread, launches
|
|
54
|
+
ffmpeg and writes a temp SDP, then hands them to the returned
|
|
55
|
+
SdesSession (whose stop() releases them). If the cold open is cancelled
|
|
56
|
+
mid-handshake (25-70s) - or raises before that hand-off - none of that
|
|
57
|
+
has an owner yet, so a plain call leaks the fds, the thread and a /tmp
|
|
58
|
+
file on every cancelled attempt (eventually "Too many open files").
|
|
59
|
+
The impl registers each resource on an ExitStack as it is allocated;
|
|
60
|
+
close the stack unless the impl actually returned a session.
|
|
61
|
+
"""
|
|
62
|
+
from contextlib import ExitStack
|
|
63
|
+
|
|
64
|
+
_cleanup = ExitStack()
|
|
65
|
+
_ok = False
|
|
66
|
+
try:
|
|
67
|
+
_session = await self._open_sdes_stream_impl(_cleanup=_cleanup, **kwargs)
|
|
68
|
+
_ok = True
|
|
69
|
+
return _session
|
|
70
|
+
finally:
|
|
71
|
+
if not _ok:
|
|
72
|
+
# Closing the reservation sockets also unblocks the bridge
|
|
73
|
+
# thread's recv so it exits; ffmpeg is killed and the SDP file
|
|
74
|
+
# unlinked. LIFO, and every callback swallows so this never
|
|
75
|
+
# masks the original CancelledError/exception.
|
|
76
|
+
_cleanup.close()
|
|
77
|
+
|
|
78
|
+
async def _open_sdes_stream_impl(
|
|
51
79
|
self,
|
|
52
80
|
*,
|
|
81
|
+
_cleanup=None,
|
|
53
82
|
peer_id: str,
|
|
54
83
|
user_id: str,
|
|
55
84
|
device_id: str,
|
|
@@ -120,16 +149,45 @@ class _SdesOpenMixin:
|
|
|
120
149
|
def _seq() -> str:
|
|
121
150
|
return f"ap{random.randint(1000000, 9999999)}"
|
|
122
151
|
|
|
152
|
+
# Register a resource with the wrapper's cleanup stack so it is released
|
|
153
|
+
# if the cold open is cancelled/raises before the SdesSession takes
|
|
154
|
+
# ownership. Each step swallows so ExitStack.close() can never mask the
|
|
155
|
+
# original CancelledError/exception. No-op if called without a stack.
|
|
156
|
+
def _cl(fn, *a):
|
|
157
|
+
if _cleanup is None:
|
|
158
|
+
return
|
|
159
|
+
def _run():
|
|
160
|
+
try:
|
|
161
|
+
fn(*a)
|
|
162
|
+
except Exception:
|
|
163
|
+
_LOGGER.debug(
|
|
164
|
+
"camera %s: swallowed sdes-open cleanup step",
|
|
165
|
+
getattr(self, "device_id", "?"), exc_info=True,
|
|
166
|
+
)
|
|
167
|
+
_cleanup.callback(_run)
|
|
168
|
+
|
|
169
|
+
def _reap(p):
|
|
170
|
+
try:
|
|
171
|
+
p.kill()
|
|
172
|
+
except Exception:
|
|
173
|
+
pass
|
|
174
|
+
try:
|
|
175
|
+
p.poll() # reap the killed child so it does not linger as a zombie
|
|
176
|
+
except Exception:
|
|
177
|
+
pass
|
|
178
|
+
|
|
123
179
|
# --- Allocate UDP ports and determine local IP ---------------------- #
|
|
124
180
|
import socket as _socket
|
|
125
181
|
|
|
126
182
|
_audio_sock = _socket.socket(_socket.AF_INET, _socket.SOCK_DGRAM)
|
|
127
183
|
_audio_sock.bind(("0.0.0.0", 0))
|
|
128
184
|
audio_port = _audio_sock.getsockname()[1]
|
|
185
|
+
_cl(_audio_sock.close) # also unblocks the bridge thread's recv on cleanup
|
|
129
186
|
|
|
130
187
|
_video_sock = _socket.socket(_socket.AF_INET, _socket.SOCK_DGRAM)
|
|
131
188
|
_video_sock.bind(("0.0.0.0", 0))
|
|
132
189
|
video_port = _video_sock.getsockname()[1]
|
|
190
|
+
_cl(_video_sock.close)
|
|
133
191
|
|
|
134
192
|
# Use the outbound interface toward 8.8.8.8 to find our local IP.
|
|
135
193
|
# connect() on a UDP socket does not send any packet.
|
|
@@ -733,6 +791,7 @@ class _SdesOpenMixin:
|
|
|
733
791
|
|
|
734
792
|
sdp_path = await asyncio.get_running_loop().run_in_executor(
|
|
735
793
|
None, _make_sdp_tempfile, _inject_sprop(ffmpeg_sdp, self.device_id))
|
|
794
|
+
_cl(os.unlink, sdp_path) # released with the sockets on a cancelled open
|
|
736
795
|
|
|
737
796
|
# --- Send webrtcReq BEFORE releasing reservation sockets ------------- #
|
|
738
797
|
# ICE cameras (e.g. LK.IPC.A001064) send STUN binding requests to our
|
|
@@ -3384,6 +3443,7 @@ class _SdesOpenMixin:
|
|
|
3384
3443
|
stderr=subprocess.PIPE,
|
|
3385
3444
|
)
|
|
3386
3445
|
_proc_holder[0] = proc
|
|
3446
|
+
_cl(_reap, proc) # kill ffmpeg if the open is cancelled before hand-off
|
|
3387
3447
|
except FileNotFoundError:
|
|
3388
3448
|
# ffmpeg is not installed - clean up and surface a clear error.
|
|
3389
3449
|
for _rsock in (_audio_sock, _video_sock):
|
|
@@ -1000,32 +1000,13 @@ class _WebRTCOpenMixin:
|
|
|
1000
1000
|
"lowPowerActiveState HTTP failed for %s: %s", device_id, _we
|
|
1001
1001
|
)
|
|
1002
1002
|
|
|
1003
|
-
async def _http_keepalive() -> None:
|
|
1004
|
-
# setKeepAliveTime keeps the camera in active state for 25 s
|
|
1005
|
-
# after wake (n.java:224 - keepAliveTime=25, not 20).
|
|
1006
|
-
# Without this call the camera's built-in timer may return it
|
|
1007
|
-
# to sleep before SCTP + LIVING completes (~10-15 s).
|
|
1008
|
-
try:
|
|
1009
|
-
import aiohttp as _aiohttp_k
|
|
1010
|
-
async with _aiohttp_k.ClientSession() as _ks:
|
|
1011
|
-
async with _ks.post(
|
|
1012
|
-
f"{self._aidot_v32_base}/devices/{device_id}"
|
|
1013
|
-
"/setKeepAliveTime",
|
|
1014
|
-
json={"keepAliveTime": 25},
|
|
1015
|
-
headers=self._aidot_headers(),
|
|
1016
|
-
timeout=_aiohttp_k.ClientTimeout(total=8),
|
|
1017
|
-
) as _kr:
|
|
1018
|
-
_LOGGER.debug(
|
|
1019
|
-
"setKeepAliveTime HTTP %d for %s",
|
|
1020
|
-
_kr.status, device_id,
|
|
1021
|
-
)
|
|
1022
|
-
except Exception as _ke:
|
|
1023
|
-
_LOGGER.debug(
|
|
1024
|
-
"setKeepAliveTime HTTP failed for %s: %s", device_id, _ke
|
|
1025
|
-
)
|
|
1026
|
-
|
|
1027
1003
|
_spawn_bg(_http_wake())
|
|
1028
|
-
|
|
1004
|
+
# setKeepAliveTime keeps the camera active for ~25 s after wake so the
|
|
1005
|
+
# SCTP + LIVING handshake can complete. For BATTERY cameras a renewal
|
|
1006
|
+
# loop (CameraMixin._keepalive_renew_loop, started by start_keepalive)
|
|
1007
|
+
# then re-issues it every 20 s for the whole view - app parity so the
|
|
1008
|
+
# camera doesn't return to sleep mid-stream.
|
|
1009
|
+
_spawn_bg(self._async_set_keep_alive())
|
|
1029
1010
|
|
|
1030
1011
|
outgoing_q.put_nowait(
|
|
1031
1012
|
(f"iot/v1/s/{user_id}/IPC/getIceConfigReq", _ice_req_payload)
|
|
@@ -1228,7 +1209,7 @@ class _WebRTCOpenMixin:
|
|
|
1228
1209
|
# ------------------------------------------------------------------ #
|
|
1229
1210
|
if use_sdes:
|
|
1230
1211
|
try:
|
|
1231
|
-
|
|
1212
|
+
_sdes_session = await self._open_sdes_stream(
|
|
1232
1213
|
peer_id=peer_id,
|
|
1233
1214
|
user_id=user_id,
|
|
1234
1215
|
device_id=device_id,
|
|
@@ -1256,6 +1237,13 @@ class _WebRTCOpenMixin:
|
|
|
1256
1237
|
rtsp_push_url=rtsp_push_url,
|
|
1257
1238
|
talk=talk,
|
|
1258
1239
|
)
|
|
1240
|
+
# Success: the SdesSession now owns outgoing_q + mqtt_fut and
|
|
1241
|
+
# reaps them on stop(); clear the backstop slot so a concurrent
|
|
1242
|
+
# open on this camera cannot reap this live session's drain.
|
|
1243
|
+
# (On _SdesNoAnswerError the await raises before this runs, so the
|
|
1244
|
+
# DTLS-fallback path below keeps the slot for its own hand-off.)
|
|
1245
|
+
self._release_stream_drain_to_session()
|
|
1246
|
+
return _sdes_session
|
|
1259
1247
|
except CameraMixin._SdesNoAnswerError:
|
|
1260
1248
|
# Camera reported enableSdes='1' but did not respond to our SDES
|
|
1261
1249
|
# offer. iOS telemetry shows models such as LK.IPC.A001064 can
|
|
@@ -3591,6 +3579,10 @@ class _WebRTCOpenMixin:
|
|
|
3591
3579
|
_LOGGER.info(
|
|
3592
3580
|
"WebRTC stream open for %s (peerid=%s)", device_id, peer_id
|
|
3593
3581
|
)
|
|
3582
|
+
# Ownership of the signaling drain passes to this session (its stop()
|
|
3583
|
+
# reaps outgoing_q + mqtt_fut); clear the backstop slot so a concurrent
|
|
3584
|
+
# open on this camera cannot reap this live session's drain.
|
|
3585
|
+
self._release_stream_drain_to_session()
|
|
3594
3586
|
return WebRTCSession(
|
|
3595
3587
|
pc=pc,
|
|
3596
3588
|
outgoing_q=outgoing_q,
|
|
@@ -50,6 +50,27 @@ _LOGGER = logging.getLogger(__name__)
|
|
|
50
50
|
# other cloud API calls that use owner+token headers.
|
|
51
51
|
_CLOUD_APP_ID = "68"
|
|
52
52
|
|
|
53
|
+
# Strong refs to fire-and-forget tasks: asyncio only keeps weak refs, so a
|
|
54
|
+
# discarded task can be garbage-collected mid-flight (see camera/client.py).
|
|
55
|
+
_BG_TASKS: set = set()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _spawn_bg(coro):
|
|
59
|
+
_t = asyncio.ensure_future(coro)
|
|
60
|
+
_BG_TASKS.add(_t)
|
|
61
|
+
|
|
62
|
+
def _done(task):
|
|
63
|
+
_BG_TASKS.discard(task)
|
|
64
|
+
# Retrieve the exception so a failing background task doesn't surface as
|
|
65
|
+
# an unhandled "Task exception was never retrieved" at GC time; log it.
|
|
66
|
+
if not task.cancelled():
|
|
67
|
+
exc = task.exception()
|
|
68
|
+
if exc is not None:
|
|
69
|
+
_LOGGER.debug("aidot background task failed: %r", exc, exc_info=exc)
|
|
70
|
+
|
|
71
|
+
_t.add_done_callback(_done)
|
|
72
|
+
return _t
|
|
73
|
+
|
|
53
74
|
|
|
54
75
|
def rsa_password_encrypt(message: str) -> str:
|
|
55
76
|
"""RSA-encrypt the password using the AiDot public key (for loginWithFreeVerification)."""
|
|
@@ -513,9 +534,7 @@ class AidotClient:
|
|
|
513
534
|
device_client.set_token_refresh_cb(self.async_ensure_token)
|
|
514
535
|
self._device_clients[device_id] = device_client
|
|
515
536
|
# Pre-warm ICE config cache so stream open does not block on this fetch.
|
|
516
|
-
|
|
517
|
-
_prefetch_ice_config(device_client)
|
|
518
|
-
)
|
|
537
|
+
_spawn_bg(_prefetch_ice_config(device_client))
|
|
519
538
|
# Started lazily here as well: __init__ cannot start discovery when
|
|
520
539
|
# constructed outside a running event loop (stored-token sync path).
|
|
521
540
|
if self._discover is None:
|
|
@@ -583,7 +602,7 @@ class AidotClient:
|
|
|
583
602
|
|
|
584
603
|
def cleanup(self) -> None:
|
|
585
604
|
"""Sync entry point: fire-and-forget async_close()."""
|
|
586
|
-
|
|
605
|
+
_spawn_bg(self.async_close())
|
|
587
606
|
|
|
588
607
|
async def async_cleanup(self) -> None:
|
|
589
608
|
"""Async entry point: close the client and release resources."""
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-aidot-cameras"
|
|
7
|
-
version = "0.11.
|
|
7
|
+
version = "0.11.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"
|
|
@@ -22,7 +22,7 @@ classifiers = [
|
|
|
22
22
|
# after re-validating against it.
|
|
23
23
|
dependencies = [
|
|
24
24
|
"aiohttp>=3.9",
|
|
25
|
-
"paho-mqtt>=
|
|
25
|
+
"paho-mqtt>=1.6.1",
|
|
26
26
|
"cryptography>=42.0",
|
|
27
27
|
"pycryptodome>=3.20",
|
|
28
28
|
"dacite>=1.8",
|
{python_aidot_cameras-0.11.3 → python_aidot_cameras-0.11.6/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.11.
|
|
3
|
+
Version: 0.11.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
|
|
@@ -12,7 +12,7 @@ Requires-Python: >=3.11
|
|
|
12
12
|
Description-Content-Type: text/markdown
|
|
13
13
|
License-File: LICENSE
|
|
14
14
|
Requires-Dist: aiohttp>=3.9
|
|
15
|
-
Requires-Dist: paho-mqtt>=
|
|
15
|
+
Requires-Dist: paho-mqtt>=1.6.1
|
|
16
16
|
Requires-Dist: cryptography>=42.0
|
|
17
17
|
Requires-Dist: pycryptodome>=3.20
|
|
18
18
|
Requires-Dist: dacite>=1.8
|
|
@@ -85,10 +85,12 @@ tests/test_go2rtc.py
|
|
|
85
85
|
tests/test_go2rtc_cli.py
|
|
86
86
|
tests/test_highport_nomination.py
|
|
87
87
|
tests/test_ice_config_cache.py
|
|
88
|
+
tests/test_keepalive_renew.py
|
|
88
89
|
tests/test_keyframe_prompter.py
|
|
89
90
|
tests/test_lan_control.py
|
|
90
91
|
tests/test_live_stream_param.py
|
|
91
92
|
tests/test_motion_poll.py
|
|
93
|
+
tests/test_mqtt_session_reconnect.py
|
|
92
94
|
tests/test_narrow_pc_ice.py
|
|
93
95
|
tests/test_no_undefined_names.py
|
|
94
96
|
tests/test_offline_keepalive_pause.py
|
|
@@ -102,6 +104,7 @@ tests/test_sdes_adaptive.py
|
|
|
102
104
|
tests/test_sdes_echo_wait_timeout.py
|
|
103
105
|
tests/test_sdes_fast_liveplay.py
|
|
104
106
|
tests/test_sdes_idle_release.py
|
|
107
|
+
tests/test_sdes_open_cleanup.py
|
|
105
108
|
tests/test_sdes_serve_audio.py
|
|
106
109
|
tests/test_sdes_serve_cmd.py
|
|
107
110
|
tests/test_sdes_sprop.py
|
|
@@ -113,6 +116,7 @@ tests/test_serve_relay.py
|
|
|
113
116
|
tests/test_session_stats.py
|
|
114
117
|
tests/test_speak.py
|
|
115
118
|
tests/test_stream_cap.py
|
|
119
|
+
tests/test_stream_drain_ownership.py
|
|
116
120
|
tests/test_stream_idle.py
|
|
117
121
|
tests/test_stream_teardown.py
|
|
118
122
|
tests/test_talk.py
|