python-aidot-cameras 0.11.5__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.5/python_aidot_cameras.egg-info → python_aidot_cameras-0.11.6}/PKG-INFO +1 -1
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/camera/client.py +29 -5
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/camera/protocol.py +32 -4
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/camera/sdes_open.py +61 -1
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/camera/webrtc_open.py +12 -1
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/client.py +23 -4
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/pyproject.toml +1 -1
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/python_aidot_cameras.egg-info/SOURCES.txt +3 -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.5 → 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.5 → python_aidot_cameras-0.11.6}/LICENSE +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/MANIFEST.in +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/README.md +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/__init__.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/__main__.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/__init__.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/__init__.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/clock.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/codecs/__init__.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/codecs/base.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/codecs/g711.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/codecs/g722.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/codecs/h264.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/codecs/opus.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/codecs/vpx.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/contrib/__init__.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/contrib/media.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/contrib/signaling.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/events.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/exceptions.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/jitterbuffer.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/mediastreams.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rate.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rtcconfiguration.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rtcdatachannel.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rtcdtlstransport.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rtcicetransport.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rtcpeerconnection.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rtcrtpparameters.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rtcrtpreceiver.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rtcrtpsender.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rtcrtptransceiver.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rtcsctptransport.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rtcsessiondescription.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rtp.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/sdp.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/stats.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/utils.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/aes_utils.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/camera/__init__.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/camera/constants.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/camera/controls.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/camera/go2rtc.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/camera/lan_control.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/camera/models.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/camera/playback.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/camera/sdes.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/camera/tutk.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/camera/webrtc.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/const.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/credentials.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/device_client.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/discover.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/exceptions.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/g711.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/login_const.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/models/__init__.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/models/device_client_model.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/models/device_model.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/models/discover_model.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/py.typed +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/python_aidot_cameras.egg-info/requires.txt +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/python_aidot_cameras.egg-info/top_level.txt +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/setup.cfg +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_aioice_compat.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_alarm_event.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_backoff.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_deferred_hardening.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_device_login_guard.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_device_user_info_cache.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_dtls_not_ready_burst.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_dtls_pinning.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_dtls_skip_signaling_wait.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_egress_guard.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_go2rtc.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_go2rtc_cli.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_highport_nomination.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_ice_config_cache.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_keepalive_renew.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_keyframe_prompter.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_lan_control.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_live_stream_param.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_motion_poll.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_narrow_pc_ice.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_no_undefined_names.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_offline_keepalive_pause.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_open_gate_delay.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_persistent_mqtt.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_persistent_mqtt_robustness.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_playback_tls.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_post_merge_hardening.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_retry_policy.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_sdes_adaptive.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_sdes_echo_wait_timeout.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_sdes_fast_liveplay.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_sdes_idle_release.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_sdes_serve_audio.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_sdes_serve_cmd.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_sdes_talk.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_sdes_watchdog.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_security_hardening.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_serializable_login_info.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_serve_relay.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_session_stats.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_speak.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_stream_cap.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_stream_idle.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_stream_teardown.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_talk.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_terminal_ack.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_token_file_robustness.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_token_refresh.py +0 -0
- {python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_wait_or_event.py +0 -0
{python_aidot_cameras-0.11.5/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
|
|
@@ -984,7 +984,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
984
984
|
|
|
985
985
|
Mirrors the official app's wake (DeviceWakeUpRepos.k()), which fires the
|
|
986
986
|
HTTP wake so a sleeping camera (no live MQTT session) still gets the signal
|
|
987
|
-
through the cloud, and retries on failure (n.java onError "
|
|
987
|
+
through the cloud, and retries on failure (n.java onError "retrying"). The
|
|
988
988
|
MQTT ``lowPowerActiveStateReq`` is also published by the streaming /
|
|
989
989
|
attribute paths; this adds the reliable cloud-side channel. Non-battery
|
|
990
990
|
cameras are always reachable, so this is a no-op for them.
|
|
@@ -1894,7 +1894,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
1894
1894
|
|
|
1895
1895
|
_MIME = {1: "video/mp4", 2: "application/x-mpegURL"}
|
|
1896
1896
|
|
|
1897
|
-
|
|
1897
|
+
async def _fetch():
|
|
1898
1898
|
async with aiohttp.ClientSession() as session:
|
|
1899
1899
|
async with session.post(
|
|
1900
1900
|
f"{self._aidot_v32_base}/playback/getEventVideoUrl",
|
|
@@ -1905,7 +1905,12 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
1905
1905
|
headers=self._aidot_headers(),
|
|
1906
1906
|
timeout=aiohttp.ClientTimeout(total=15),
|
|
1907
1907
|
) as resp:
|
|
1908
|
-
|
|
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
|
|
1909
1914
|
|
|
1910
1915
|
_LOGGER.debug("getEventVideoUrl raw for %s uuid=%s: %s", self.device_id, event_uuid, body)
|
|
1911
1916
|
|
|
@@ -1966,7 +1971,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
1966
1971
|
end_ts = int(time.time() * 1000)
|
|
1967
1972
|
start_ts = end_ts - 30 * 86_400_000 # look back 30 days
|
|
1968
1973
|
|
|
1969
|
-
|
|
1974
|
+
async def _fetch():
|
|
1970
1975
|
async with aiohttp.ClientSession() as session:
|
|
1971
1976
|
async with session.post(
|
|
1972
1977
|
f"{self._aidot_v32_base}/playback/eventRecordingList",
|
|
@@ -1980,7 +1985,12 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
1980
1985
|
headers=self._aidot_headers(),
|
|
1981
1986
|
timeout=aiohttp.ClientTimeout(total=15),
|
|
1982
1987
|
) as resp:
|
|
1983
|
-
|
|
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
|
|
1984
1994
|
|
|
1985
1995
|
_LOGGER.debug("thumbnail eventRecordingList for %s: %s", self.device_id, body)
|
|
1986
1996
|
if body.get("code") != 200:
|
|
@@ -2244,6 +2254,20 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
2244
2254
|
except (asyncio.CancelledError, Exception):
|
|
2245
2255
|
_LOGGER.debug("camera %s: swallowed exception in %s", getattr(self, "device_id", "?"), '_reap_stream_drain', exc_info=True)
|
|
2246
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
|
+
|
|
2247
2271
|
async def async_start_motion_polling(
|
|
2248
2272
|
self, callback: Callable, interval: float = 30.0, lookback_s: int = 600,
|
|
2249
2273
|
) -> None:
|
|
@@ -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):
|
|
@@ -1228,10 +1243,23 @@ def _mqtt_session_sync(
|
|
|
1228
1243
|
# If _on_connect was never fired (WebSocket upgrade failed, auth refused
|
|
1229
1244
|
# at TCP level, etc.) signal conn_ev now so the caller doesn't time out.
|
|
1230
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.
|
|
1231
1248
|
status["connected"] = False
|
|
1232
1249
|
status["rc_str"] = f"disconnect-before-connect rc={reason_code}"
|
|
1233
1250
|
conn_ev.set()
|
|
1234
|
-
|
|
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
|
+
)
|
|
1235
1263
|
|
|
1236
1264
|
def _on_log(c, ud, level, buf):
|
|
1237
1265
|
if len(status["log"]) < 500:
|
|
@@ -1279,9 +1307,9 @@ def _mqtt_session_sync(
|
|
|
1279
1307
|
|
|
1280
1308
|
_LOGGER.info("_mqtt_session: connected to %s:%d clientId=%s", hostname, port, client_id)
|
|
1281
1309
|
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
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)
|
|
1285
1313
|
|
|
1286
1314
|
for pub_topic, pub_payload in publish_items:
|
|
1287
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):
|
|
@@ -1209,7 +1209,7 @@ class _WebRTCOpenMixin:
|
|
|
1209
1209
|
# ------------------------------------------------------------------ #
|
|
1210
1210
|
if use_sdes:
|
|
1211
1211
|
try:
|
|
1212
|
-
|
|
1212
|
+
_sdes_session = await self._open_sdes_stream(
|
|
1213
1213
|
peer_id=peer_id,
|
|
1214
1214
|
user_id=user_id,
|
|
1215
1215
|
device_id=device_id,
|
|
@@ -1237,6 +1237,13 @@ class _WebRTCOpenMixin:
|
|
|
1237
1237
|
rtsp_push_url=rtsp_push_url,
|
|
1238
1238
|
talk=talk,
|
|
1239
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
|
|
1240
1247
|
except CameraMixin._SdesNoAnswerError:
|
|
1241
1248
|
# Camera reported enableSdes='1' but did not respond to our SDES
|
|
1242
1249
|
# offer. iOS telemetry shows models such as LK.IPC.A001064 can
|
|
@@ -3572,6 +3579,10 @@ class _WebRTCOpenMixin:
|
|
|
3572
3579
|
_LOGGER.info(
|
|
3573
3580
|
"WebRTC stream open for %s (peerid=%s)", device_id, peer_id
|
|
3574
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()
|
|
3575
3586
|
return WebRTCSession(
|
|
3576
3587
|
pc=pc,
|
|
3577
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"
|
{python_aidot_cameras-0.11.5 → 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
|
|
@@ -90,6 +90,7 @@ tests/test_keyframe_prompter.py
|
|
|
90
90
|
tests/test_lan_control.py
|
|
91
91
|
tests/test_live_stream_param.py
|
|
92
92
|
tests/test_motion_poll.py
|
|
93
|
+
tests/test_mqtt_session_reconnect.py
|
|
93
94
|
tests/test_narrow_pc_ice.py
|
|
94
95
|
tests/test_no_undefined_names.py
|
|
95
96
|
tests/test_offline_keepalive_pause.py
|
|
@@ -103,6 +104,7 @@ tests/test_sdes_adaptive.py
|
|
|
103
104
|
tests/test_sdes_echo_wait_timeout.py
|
|
104
105
|
tests/test_sdes_fast_liveplay.py
|
|
105
106
|
tests/test_sdes_idle_release.py
|
|
107
|
+
tests/test_sdes_open_cleanup.py
|
|
106
108
|
tests/test_sdes_serve_audio.py
|
|
107
109
|
tests/test_sdes_serve_cmd.py
|
|
108
110
|
tests/test_sdes_sprop.py
|
|
@@ -114,6 +116,7 @@ tests/test_serve_relay.py
|
|
|
114
116
|
tests/test_session_stats.py
|
|
115
117
|
tests/test_speak.py
|
|
116
118
|
tests/test_stream_cap.py
|
|
119
|
+
tests/test_stream_drain_ownership.py
|
|
117
120
|
tests/test_stream_idle.py
|
|
118
121
|
tests/test_stream_teardown.py
|
|
119
122
|
tests/test_talk.py
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Transient-reconnect resilience of the per-stream MQTT signaling session.
|
|
2
|
+
|
|
3
|
+
_mqtt_session_sync runs one paho session for the (up to 3600s) lifetime of a
|
|
4
|
+
stream on the non-persistent transport. A transient broker drop must NOT end the
|
|
5
|
+
receive loop - paho's loop_start auto-reconnects and _on_connect re-subscribes
|
|
6
|
+
(clean_session retains nothing). Only a disconnect that happens before the first
|
|
7
|
+
successful connect should end the session. Driven with a fake paho client.
|
|
8
|
+
"""
|
|
9
|
+
import queue
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
|
|
13
|
+
import aidot.camera.protocol as proto
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _FakeMsg:
|
|
17
|
+
def __init__(self, topic, payload):
|
|
18
|
+
self.topic = topic
|
|
19
|
+
self.payload = payload
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _install_fake_client(monkeypatch, driver):
|
|
23
|
+
"""Patch paho's Client with a fake whose loop_start() runs ``driver(client)``
|
|
24
|
+
in a daemon thread, so it can fire callbacks while the receive loop runs."""
|
|
25
|
+
class _FakeClient:
|
|
26
|
+
def __init__(self, *a, **k):
|
|
27
|
+
self.subs = []
|
|
28
|
+
self.on_connect = self.on_message = self.on_disconnect = self.on_log = None
|
|
29
|
+
def ws_set_options(self, **k): pass
|
|
30
|
+
def username_pw_set(self, *a, **k): pass
|
|
31
|
+
def tls_set_context(self, *a, **k): pass
|
|
32
|
+
def connect(self, *a, **k): pass
|
|
33
|
+
def subscribe(self, topic): self.subs.append(topic)
|
|
34
|
+
def publish(self, *a, **k): pass
|
|
35
|
+
def loop_start(self):
|
|
36
|
+
threading.Thread(target=driver, args=(self,), daemon=True).start()
|
|
37
|
+
def loop_stop(self): pass
|
|
38
|
+
def disconnect(self): pass
|
|
39
|
+
|
|
40
|
+
monkeypatch.setattr("paho.mqtt.client.Client", _FakeClient)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_transient_disconnect_keeps_receiving_and_resubscribes(monkeypatch):
|
|
44
|
+
outgoing = queue.Queue()
|
|
45
|
+
seen_subs = []
|
|
46
|
+
|
|
47
|
+
def driver(c):
|
|
48
|
+
c.on_connect(c, None, {}, 0) # first successful connect
|
|
49
|
+
seen_subs.append(list(c.subs))
|
|
50
|
+
c.on_disconnect(c, None, 0) # transient drop (already connected)
|
|
51
|
+
time.sleep(0.02)
|
|
52
|
+
c.on_connect(c, None, {}, 0) # paho auto-reconnect -> resubscribe
|
|
53
|
+
seen_subs.append(list(c.subs))
|
|
54
|
+
# Arrives only AFTER the transient drop: collected only if the receive
|
|
55
|
+
# loop survived the disconnect.
|
|
56
|
+
c.on_message(c, None, _FakeMsg("t/after", b"live"))
|
|
57
|
+
time.sleep(0.02)
|
|
58
|
+
outgoing.put_nowait(None) # clean stop
|
|
59
|
+
|
|
60
|
+
_install_fake_client(monkeypatch, driver)
|
|
61
|
+
|
|
62
|
+
messages, status = proto._mqtt_session_sync(
|
|
63
|
+
"wss://broker.example/mqtt", "u", "p", "cid",
|
|
64
|
+
["t/a", "t/b"], [], 5.0,
|
|
65
|
+
outgoing_queue=outgoing,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
assert ("t/after", "live") in messages # survived the transient drop
|
|
69
|
+
assert seen_subs[0] == ["t/a", "t/b"] # subscribed on first connect
|
|
70
|
+
assert seen_subs[1] == ["t/a", "t/b", "t/a", "t/b"] # resubscribed on reconnect
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_disconnect_before_connect_ends_session(monkeypatch):
|
|
74
|
+
def driver(c):
|
|
75
|
+
c.on_disconnect(c, None, 7) # never connected -> end + report failure
|
|
76
|
+
|
|
77
|
+
_install_fake_client(monkeypatch, driver)
|
|
78
|
+
|
|
79
|
+
messages, status = proto._mqtt_session_sync(
|
|
80
|
+
"wss://broker.example/mqtt", "u", "p", "cid",
|
|
81
|
+
["t/a"], [], 5.0,
|
|
82
|
+
)
|
|
83
|
+
assert messages == []
|
|
84
|
+
assert status["connected"] is False
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Resource-cleanup plumbing for the SDES cold-open wrapper.
|
|
2
|
+
|
|
3
|
+
_open_sdes_stream reserves UDP sockets, a bridge thread, ffmpeg and a temp SDP
|
|
4
|
+
before handing them to the returned SdesSession. If the cold open is cancelled
|
|
5
|
+
mid-handshake (or raises) before that hand-off, the wrapper must release them via
|
|
6
|
+
its ExitStack; on a successful return it must NOT (the session now owns them).
|
|
7
|
+
These tests exercise that wrapper mechanism directly with a stub impl.
|
|
8
|
+
"""
|
|
9
|
+
import asyncio
|
|
10
|
+
import types
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
from aidot.camera.client import CameraMixin
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _wrapper_for(impl):
|
|
18
|
+
obj = types.SimpleNamespace()
|
|
19
|
+
obj._open_sdes_stream_impl = impl
|
|
20
|
+
return CameraMixin._open_sdes_stream.__get__(obj)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_open_sdes_wrapper_cleans_up_on_cancel():
|
|
24
|
+
ran = []
|
|
25
|
+
|
|
26
|
+
async def impl(_cleanup=None, **kwargs):
|
|
27
|
+
_cleanup.callback(lambda: ran.append("cleaned"))
|
|
28
|
+
raise asyncio.CancelledError()
|
|
29
|
+
|
|
30
|
+
with pytest.raises(asyncio.CancelledError):
|
|
31
|
+
asyncio.run(_wrapper_for(impl)(peer_id="x"))
|
|
32
|
+
assert ran == ["cleaned"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_open_sdes_wrapper_cleans_up_on_error():
|
|
36
|
+
ran = []
|
|
37
|
+
|
|
38
|
+
async def impl(_cleanup=None, **kwargs):
|
|
39
|
+
_cleanup.callback(lambda: ran.append("cleaned"))
|
|
40
|
+
raise RuntimeError("boom")
|
|
41
|
+
|
|
42
|
+
with pytest.raises(RuntimeError):
|
|
43
|
+
asyncio.run(_wrapper_for(impl)(peer_id="x"))
|
|
44
|
+
assert ran == ["cleaned"]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_open_sdes_wrapper_skips_cleanup_on_success():
|
|
48
|
+
ran = []
|
|
49
|
+
sentinel = object()
|
|
50
|
+
|
|
51
|
+
async def impl(_cleanup=None, **kwargs):
|
|
52
|
+
_cleanup.callback(lambda: ran.append("cleaned"))
|
|
53
|
+
return sentinel
|
|
54
|
+
|
|
55
|
+
result = asyncio.run(_wrapper_for(impl)(peer_id="x"))
|
|
56
|
+
assert result is sentinel
|
|
57
|
+
assert ran == [] # ownership transferred to the session; cleanup must NOT run
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_open_sdes_wrapper_runs_cleanups_lifo():
|
|
61
|
+
# Steps run last-registered-first, so ffmpeg is killed before its sockets
|
|
62
|
+
# close (mirrors the impl's registration order: sockets, then sdp, then proc).
|
|
63
|
+
order = []
|
|
64
|
+
|
|
65
|
+
async def impl(_cleanup=None, **kwargs):
|
|
66
|
+
_cleanup.callback(lambda: order.append("sock"))
|
|
67
|
+
_cleanup.callback(lambda: order.append("proc"))
|
|
68
|
+
raise asyncio.CancelledError()
|
|
69
|
+
|
|
70
|
+
with pytest.raises(asyncio.CancelledError):
|
|
71
|
+
asyncio.run(_wrapper_for(impl)(peer_id="x"))
|
|
72
|
+
assert order == ["proc", "sock"] # LIFO
|
|
@@ -52,6 +52,22 @@ def test_stap_a_carries_both():
|
|
|
52
52
|
assert _extract_param_sets_from_rtp(RTP + stap) == {7: SPS, 8: PPS}
|
|
53
53
|
|
|
54
54
|
|
|
55
|
+
def test_stap_a_truncated_size_is_dropped():
|
|
56
|
+
# Advertised NAL size overruns the packet (frame loss / relay truncation):
|
|
57
|
+
# the parser must NOT slice a short NAL and cache it as a corrupt SPS.
|
|
58
|
+
stap = bytes([0x78])
|
|
59
|
+
stap += (len(SPS) + 50).to_bytes(2, "big") + SPS # claims 50 extra bytes
|
|
60
|
+
assert _extract_param_sets_from_rtp(RTP + stap) == {}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_stap_a_keeps_valid_prefix_before_truncation():
|
|
64
|
+
# A valid SPS followed by a truncated PPS: keep the SPS, drop the garbage.
|
|
65
|
+
stap = bytes([0x78])
|
|
66
|
+
stap += len(SPS).to_bytes(2, "big") + SPS # valid SPS
|
|
67
|
+
stap += (len(PPS) + 20).to_bytes(2, "big") + PPS # truncated PPS
|
|
68
|
+
assert _extract_param_sets_from_rtp(RTP + stap) == {7: SPS}
|
|
69
|
+
|
|
70
|
+
|
|
55
71
|
def test_slice_packet_yields_nothing():
|
|
56
72
|
# NAL type 1 (non-IDR slice) - not a parameter set
|
|
57
73
|
assert _extract_param_sets_from_rtp(RTP + bytes([0x61, 0x00, 0x11])) == {}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Persistent-MQTT signaling-drain ownership on the camera stream path.
|
|
2
|
+
|
|
3
|
+
The DeviceClient keeps a single backstop slot (_stream_mqtt_drain/_stream_mqtt_outq)
|
|
4
|
+
for a drain whose open was cancelled before a WebRTC/SdesSession took ownership.
|
|
5
|
+
A successful open hands ownership to the session (which reaps the drain on stop)
|
|
6
|
+
and clears the slot - so a *concurrent* open on the same camera (e.g. a snapshot
|
|
7
|
+
during a live view) no longer reaps, and kills, the live session's drain.
|
|
8
|
+
"""
|
|
9
|
+
import asyncio
|
|
10
|
+
import types
|
|
11
|
+
|
|
12
|
+
from aidot.camera.client import CameraMixin
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _FakeFut:
|
|
16
|
+
def __init__(self):
|
|
17
|
+
self.cancelled_flag = False
|
|
18
|
+
|
|
19
|
+
def done(self):
|
|
20
|
+
return False
|
|
21
|
+
|
|
22
|
+
def cancel(self):
|
|
23
|
+
self.cancelled_flag = True
|
|
24
|
+
|
|
25
|
+
def __await__(self):
|
|
26
|
+
if False:
|
|
27
|
+
yield
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _RecordingQueue:
|
|
32
|
+
def __init__(self):
|
|
33
|
+
self.puts = []
|
|
34
|
+
|
|
35
|
+
def put_nowait(self, item):
|
|
36
|
+
self.puts.append(item)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _client_with_drain():
|
|
40
|
+
obj = types.SimpleNamespace(
|
|
41
|
+
_stream_mqtt_drain=_FakeFut(),
|
|
42
|
+
_stream_mqtt_outq=_RecordingQueue(),
|
|
43
|
+
device_id="cam",
|
|
44
|
+
)
|
|
45
|
+
obj._reap_stream_drain = CameraMixin._reap_stream_drain.__get__(obj)
|
|
46
|
+
obj._release_stream_drain_to_session = (
|
|
47
|
+
CameraMixin._release_stream_drain_to_session.__get__(obj)
|
|
48
|
+
)
|
|
49
|
+
return obj
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_handoff_makes_a_concurrent_reap_a_noop():
|
|
53
|
+
obj = _client_with_drain()
|
|
54
|
+
fut, q = obj._stream_mqtt_drain, obj._stream_mqtt_outq
|
|
55
|
+
|
|
56
|
+
obj._release_stream_drain_to_session() # session took ownership
|
|
57
|
+
assert obj._stream_mqtt_drain is None
|
|
58
|
+
|
|
59
|
+
asyncio.run(obj._reap_stream_drain()) # a concurrent open reaps
|
|
60
|
+
assert q.puts == [] # live drain NOT sentinel-killed
|
|
61
|
+
assert not fut.cancelled_flag # live drain NOT cancelled
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_backstop_still_reaps_an_orphaned_drain():
|
|
65
|
+
# Without a hand-off (open cancelled mid-handshake) the slot still holds the
|
|
66
|
+
# drain, and the reap must release it: sentinel to the queue + cancel.
|
|
67
|
+
obj = _client_with_drain()
|
|
68
|
+
fut, q = obj._stream_mqtt_drain, obj._stream_mqtt_outq
|
|
69
|
+
|
|
70
|
+
asyncio.run(obj._reap_stream_drain())
|
|
71
|
+
assert q.puts == [None] # orphan drain released
|
|
72
|
+
assert fut.cancelled_flag
|
|
73
|
+
assert obj._stream_mqtt_drain is None # slot cleared
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/codecs/__init__.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/codecs/base.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/codecs/g711.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/codecs/g722.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/codecs/h264.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/codecs/opus.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/codecs/vpx.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/contrib/__init__.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/contrib/media.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/exceptions.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/jitterbuffer.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/mediastreams.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rtcconfiguration.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rtcdatachannel.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rtcdtlstransport.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rtcicetransport.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rtcrtpparameters.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rtcrtpreceiver.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rtcrtpsender.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/_vendor/aiortc/rtcsctptransport.py
RENAMED
|
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
|
{python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/aidot/models/device_client_model.py
RENAMED
|
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
|
{python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_deferred_hardening.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_device_login_guard.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_device_user_info_cache.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_dtls_not_ready_burst.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_dtls_skip_signaling_wait.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_highport_nomination.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_no_undefined_names.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_offline_keepalive_pause.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_persistent_mqtt_robustness.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_post_merge_hardening.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_sdes_echo_wait_timeout.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_sdes_fast_liveplay.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_security_hardening.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_serializable_login_info.py
RENAMED
|
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
|
{python_aidot_cameras-0.11.5 → python_aidot_cameras-0.11.6}/tests/test_token_file_robustness.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|