python-aidot-cameras 0.7.35__tar.gz → 0.8.0__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.7.35/src/python_aidot_cameras.egg-info → python_aidot_cameras-0.8.0}/PKG-INFO +1 -1
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/pyproject.toml +1 -1
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/aidot/camera/client.py +91 -11
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/aidot/camera/protocol.py +8 -2
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/aidot/camera/sdes.py +14 -0
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/aidot/const.py +0 -1
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0/src/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/python_aidot_cameras.egg-info/SOURCES.txt +2 -0
- python_aidot_cameras-0.8.0/tests/test_persistent_mqtt_robustness.py +127 -0
- python_aidot_cameras-0.8.0/tests/test_stream_teardown.py +75 -0
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/LICENSE +0 -0
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/MANIFEST.in +0 -0
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/README.md +0 -0
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/setup.cfg +0 -0
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/aidot/__init__.py +0 -0
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/aidot/aes_utils.py +0 -0
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/aidot/camera/__init__.py +0 -0
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/aidot/camera/constants.py +0 -0
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/aidot/camera/controls.py +0 -0
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/aidot/camera/go2rtc.py +0 -0
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/aidot/camera/lan_control.py +0 -0
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/aidot/camera/models.py +0 -0
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/aidot/camera/playback.py +0 -0
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/aidot/camera/tutk.py +0 -0
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/aidot/camera/webrtc.py +0 -0
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/aidot/client.py +0 -0
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/aidot/credentials.py +0 -0
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/aidot/device_client.py +0 -0
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/aidot/discover.py +0 -0
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/aidot/exceptions.py +0 -0
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/aidot/g711.py +0 -0
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/aidot/login_const.py +0 -0
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/aidot/models/__init__.py +0 -0
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/aidot/models/device_client_model.py +0 -0
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/aidot/models/device_model.py +0 -0
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/aidot/models/discover_model.py +0 -0
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/aidot/py.typed +0 -0
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/python_aidot_cameras.egg-info/requires.txt +0 -0
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/python_aidot_cameras.egg-info/top_level.txt +0 -0
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/tests/test_alarm_event.py +0 -0
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/tests/test_backoff.py +0 -0
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/tests/test_device_login_guard.py +0 -0
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/tests/test_go2rtc.py +0 -0
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/tests/test_highport_nomination.py +0 -0
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/tests/test_lan_control.py +0 -0
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/tests/test_live_stream_param.py +0 -0
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/tests/test_motion_poll.py +0 -0
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/tests/test_no_undefined_names.py +0 -0
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/tests/test_persistent_mqtt.py +0 -0
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/tests/test_post_merge_hardening.py +0 -0
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/tests/test_sdes_adaptive.py +0 -0
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/tests/test_sdes_fast_liveplay.py +0 -0
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/tests/test_sdes_idle_release.py +0 -0
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/tests/test_sdes_serve_audio.py +0 -0
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/tests/test_sdes_serve_cmd.py +0 -0
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/tests/test_sdes_sprop.py +0 -0
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/tests/test_sdes_talk.py +0 -0
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/tests/test_sdes_watchdog.py +0 -0
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/tests/test_serve_relay.py +0 -0
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/tests/test_speak.py +0 -0
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/tests/test_stream_cap.py +0 -0
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/tests/test_stream_idle.py +0 -0
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/tests/test_talk.py +0 -0
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/tests/test_terminal_ack.py +0 -0
- {python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/tests/test_token_refresh.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-aidot-cameras
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: Control AiDot/Leedarson WiFi lights and cameras (WebRTC streaming, two-way audio, PTZ, controls)
|
|
5
5
|
Author-email: cbrightly <chris.brightly@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-aidot-cameras"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.8.0"
|
|
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"
|
|
@@ -584,6 +584,7 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
584
584
|
self._streaming_active: bool = False
|
|
585
585
|
self._stream_session: Optional[Any] = None
|
|
586
586
|
self._stream_task: Optional["asyncio.Task[None]"] = None
|
|
587
|
+
self._stream_mqtt_drain: Optional["asyncio.Future"] = None
|
|
587
588
|
self._last_frame_time: float = 0.0
|
|
588
589
|
self._keepalive_rtsp_url: Optional[str] = None # local serve URL (go2rtc pulls)
|
|
589
590
|
self._go2rtc_url: Optional[str] = None # go2rtc API base (prefer-go2rtc)
|
|
@@ -1339,6 +1340,20 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
1339
1340
|
subscribe_topics=sub_topics,
|
|
1340
1341
|
timeout=timeout,
|
|
1341
1342
|
)
|
|
1343
|
+
if _st and _st.get("error"):
|
|
1344
|
+
# Persistent connection unavailable - fall back to a fresh per-op
|
|
1345
|
+
# connect so the command isn't silently dropped (and isn't falsely
|
|
1346
|
+
# reported as sent on the empty result the persistent path returns).
|
|
1347
|
+
_LOGGER.debug(
|
|
1348
|
+
"_mqtt_device_cmd: persistent MQTT failed (%s); falling back "
|
|
1349
|
+
"to a per-op session for %s", _st.get("error"), device_id,
|
|
1350
|
+
)
|
|
1351
|
+
messages = await _mqtt_session(
|
|
1352
|
+
mqtt_url, mqtt_user, mqtt_pwd, client_id,
|
|
1353
|
+
subscribe_topics=sub_topics,
|
|
1354
|
+
publish_items=publish_items,
|
|
1355
|
+
duration=timeout,
|
|
1356
|
+
)
|
|
1342
1357
|
else:
|
|
1343
1358
|
messages = await _mqtt_session(
|
|
1344
1359
|
mqtt_url, mqtt_user, mqtt_pwd, client_id,
|
|
@@ -1582,6 +1597,20 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
1582
1597
|
subscribe_topics=sub_topics,
|
|
1583
1598
|
timeout=timeout,
|
|
1584
1599
|
)
|
|
1600
|
+
if _st and _st.get("error"):
|
|
1601
|
+
# Persistent connection unavailable - fall back to a fresh per-op
|
|
1602
|
+
# connect so a transient outage doesn't masquerade as "no attrs".
|
|
1603
|
+
_LOGGER.debug(
|
|
1604
|
+
"async_get_camera_attributes: persistent MQTT failed (%s); "
|
|
1605
|
+
"falling back to a per-op session for %s",
|
|
1606
|
+
_st.get("error"), device_id,
|
|
1607
|
+
)
|
|
1608
|
+
messages = await _mqtt_session(
|
|
1609
|
+
mqtt_url, mqtt_user, mqtt_pwd, client_id,
|
|
1610
|
+
subscribe_topics=sub_topics,
|
|
1611
|
+
publish_items=publish_items,
|
|
1612
|
+
duration=timeout,
|
|
1613
|
+
)
|
|
1585
1614
|
else:
|
|
1586
1615
|
messages = await _mqtt_session(
|
|
1587
1616
|
mqtt_url, mqtt_user, mqtt_pwd, client_id,
|
|
@@ -1981,6 +2010,37 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
1981
2010
|
await task
|
|
1982
2011
|
except (asyncio.CancelledError, Exception):
|
|
1983
2012
|
_LOGGER.debug("camera %s: swallowed exception", 'async_stop_streaming', exc_info=True)
|
|
2013
|
+
# Reap a persistent-MQTT stream drain that no session stopped (e.g. an
|
|
2014
|
+
# open cancelled mid-handshake) so its handler is removed from the shared
|
|
2015
|
+
# connection and its blocked executor thread is released.
|
|
2016
|
+
await self._reap_stream_drain()
|
|
2017
|
+
|
|
2018
|
+
async def _reap_stream_drain(self):
|
|
2019
|
+
"""Stop and reap the persistent-MQTT stream drain task, if any.
|
|
2020
|
+
|
|
2021
|
+
The drain blocks an executor thread on ``outgoing_q.get`` until a ``None``
|
|
2022
|
+
sentinel arrives. A normal stop pushes that sentinel from the
|
|
2023
|
+
WebRTCSession/SdesSession; but if no session took ownership (open
|
|
2024
|
+
cancelled mid-handshake) or a new open replaced this one, we must push it
|
|
2025
|
+
ourselves - cancelling the future alone cannot interrupt the blocked
|
|
2026
|
+
thread, leaking it (and its handler on the shared connection) forever."""
|
|
2027
|
+
drain = getattr(self, "_stream_mqtt_drain", None)
|
|
2028
|
+
outq = getattr(self, "_stream_mqtt_outq", None)
|
|
2029
|
+
self._stream_mqtt_drain = None
|
|
2030
|
+
self._stream_mqtt_outq = None
|
|
2031
|
+
if drain is None:
|
|
2032
|
+
return
|
|
2033
|
+
if outq is not None:
|
|
2034
|
+
try:
|
|
2035
|
+
outq.put_nowait(None) # release the executor thread in outgoing_q.get
|
|
2036
|
+
except Exception:
|
|
2037
|
+
_LOGGER.debug("camera %s: swallowed exception", '_reap_stream_drain', exc_info=True)
|
|
2038
|
+
if not drain.done():
|
|
2039
|
+
drain.cancel()
|
|
2040
|
+
try:
|
|
2041
|
+
await drain
|
|
2042
|
+
except (asyncio.CancelledError, Exception):
|
|
2043
|
+
_LOGGER.debug("camera %s: swallowed exception", '_reap_stream_drain', exc_info=True)
|
|
1984
2044
|
|
|
1985
2045
|
async def async_start_motion_polling(
|
|
1986
2046
|
self, callback: Callable, interval: float = 30.0, lookback_s: int = 600,
|
|
@@ -2833,19 +2893,28 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
2833
2893
|
pm = li.get("_persistent_mqtt")
|
|
2834
2894
|
if pm is not None:
|
|
2835
2895
|
return pm
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2896
|
+
# Serialize get-or-create. Without this, two concurrent first-callers
|
|
2897
|
+
# (e.g. a command publish racing a stream open) both pass the None-check
|
|
2898
|
+
# above, both await below, and both build a _PersistentMqtt - the second
|
|
2899
|
+
# clobbering the first and orphaning a connection on the single-client_id
|
|
2900
|
+
# broker. dict.setdefault is atomic (no await between create and insert),
|
|
2901
|
+
# so every caller for this account shares the one lock.
|
|
2902
|
+
lock = li.setdefault("_persistent_mqtt_lock", asyncio.Lock())
|
|
2903
|
+
async with lock:
|
|
2904
|
+
pm = li.get("_persistent_mqtt") # re-check under the lock
|
|
2905
|
+
if pm is not None:
|
|
2906
|
+
return pm
|
|
2907
|
+
smarthome_auth = await self._async_get_smarthome_auth()
|
|
2908
|
+
mqtt_user = (smarthome_auth or {}).get("mqttUser") or str(self.user_id)
|
|
2909
|
+
mqtt_pwd = (smarthome_auth or {}).get("mqttPassword") or ""
|
|
2910
|
+
client_id = (self._user_info.get("mqttClientId") or f"app-{mqtt_user}")
|
|
2911
|
+
mqtt_url = await self._async_get_mqtt_url()
|
|
2912
|
+
if not mqtt_url:
|
|
2913
|
+
return None
|
|
2914
|
+
from .protocol import _PersistentMqtt
|
|
2846
2915
|
pm = _PersistentMqtt(mqtt_url, mqtt_user, mqtt_pwd, client_id)
|
|
2847
2916
|
li["_persistent_mqtt"] = pm
|
|
2848
|
-
|
|
2917
|
+
return pm
|
|
2849
2918
|
|
|
2850
2919
|
@staticmethod
|
|
2851
2920
|
def _adaptive_next_fast(adaptive: bool, fast_failed: bool) -> bool:
|
|
@@ -4226,6 +4295,10 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
4226
4295
|
# (the stream's mqtt_cid IS the authorized mqttClientId, so it's the same
|
|
4227
4296
|
# connection) - subscribe + register a handler + drain outgoing_q through
|
|
4228
4297
|
# it, and DON'T tear the connection down on stop (matching the app).
|
|
4298
|
+
# A prior open may have left a drain running (e.g. a reopen with no
|
|
4299
|
+
# async_stop_streaming between them); reap it before starting a new one
|
|
4300
|
+
# so we don't orphan its executor thread + handler on the shared conn.
|
|
4301
|
+
await self._reap_stream_drain()
|
|
4229
4302
|
_pm_stream = (await self._get_persistent_mqtt()
|
|
4230
4303
|
if self._resolve_persistent_mqtt() else None)
|
|
4231
4304
|
if _pm_stream is not None:
|
|
@@ -4243,6 +4316,13 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
4243
4316
|
_pm_stream.remove_handler(_on_mqtt_message)
|
|
4244
4317
|
|
|
4245
4318
|
mqtt_fut = asyncio.ensure_future(_pm_stream_drain())
|
|
4319
|
+
# Track the drain AND its queue so teardown (or a replacing open) can
|
|
4320
|
+
# reap it even if this open is cancelled before a WebRTCSession takes
|
|
4321
|
+
# ownership. _reap_stream_drain pushes the outgoing_q sentinel to
|
|
4322
|
+
# release the thread blocked on outgoing_q.get and removes the handler
|
|
4323
|
+
# from the shared persistent connection.
|
|
4324
|
+
self._stream_mqtt_drain = mqtt_fut
|
|
4325
|
+
self._stream_mqtt_outq = outgoing_q
|
|
4246
4326
|
_on_mqtt_ready({"connected": True, "rc": 0, "rc_str": "persistent"})
|
|
4247
4327
|
else:
|
|
4248
4328
|
mqtt_fut = loop.run_in_executor(
|
|
@@ -1378,10 +1378,16 @@ class _PersistentMqtt:
|
|
|
1378
1378
|
q = _queue.Queue()
|
|
1379
1379
|
with self._lock:
|
|
1380
1380
|
self._collectors.append(q)
|
|
1381
|
+
c = self._client # snapshot: a concurrent close() may null self._client
|
|
1381
1382
|
collected = []
|
|
1382
1383
|
try:
|
|
1383
|
-
|
|
1384
|
-
|
|
1384
|
+
if c is None:
|
|
1385
|
+
return [], {"error": "persistent mqtt closed"}
|
|
1386
|
+
try:
|
|
1387
|
+
for pt, pp in (publish_items or []):
|
|
1388
|
+
c.publish(pt, pp)
|
|
1389
|
+
except Exception as exc:
|
|
1390
|
+
return [], {"error": f"persistent mqtt publish failed: {exc}"}
|
|
1385
1391
|
deadline = _time.monotonic() + timeout
|
|
1386
1392
|
while True:
|
|
1387
1393
|
remaining = deadline - _time.monotonic()
|
|
@@ -186,6 +186,13 @@ class SdesSession:
|
|
|
186
186
|
await _stop_loop.run_in_executor(None, lambda: self._proc.wait(5))
|
|
187
187
|
except Exception:
|
|
188
188
|
self._proc.kill()
|
|
189
|
+
# Reap the SIGKILL'd child: this is a raw subprocess.Popen with no
|
|
190
|
+
# asyncio child-watcher to auto-reap it, so without a follow-up wait()
|
|
191
|
+
# it lingers as a zombie under rapid keepalive reconnect churn.
|
|
192
|
+
try:
|
|
193
|
+
await _stop_loop.run_in_executor(None, lambda: self._proc.wait(5))
|
|
194
|
+
except Exception:
|
|
195
|
+
_LOGGER.debug("camera %s: swallowed exception", 'stop', exc_info=True)
|
|
189
196
|
# Read drained stderr in the executor with a hard timeout: proc.stderr.read()
|
|
190
197
|
# blocks until EOF, which never arrives if the killed process is still a
|
|
191
198
|
# zombie / stuck in uninterruptible I/O - doing it inline would hang the
|
|
@@ -199,6 +206,13 @@ class SdesSession:
|
|
|
199
206
|
)
|
|
200
207
|
except Exception: # incl. asyncio.TimeoutError - never let teardown hang here
|
|
201
208
|
_LOGGER.debug("camera %s: swallowed exception", 'stop', exc_info=True)
|
|
209
|
+
# On timeout the executor thread is still blocked in stderr.read() on
|
|
210
|
+
# a wedged ffmpeg; close the pipe so that read returns instead of
|
|
211
|
+
# pinning a default-pool thread for the life of the process.
|
|
212
|
+
try:
|
|
213
|
+
self._proc.stderr.close()
|
|
214
|
+
except Exception:
|
|
215
|
+
_LOGGER.debug("camera %s: swallowed exception", 'stop', exc_info=True)
|
|
202
216
|
if stderr_bytes:
|
|
203
217
|
_LOGGER.warning("ffmpeg SDES stderr:\n%s", stderr_bytes.decode(errors="replace"))
|
|
204
218
|
import os
|
|
@@ -169,7 +169,6 @@ DEFAULT_COUNTRY_CODE = "US"
|
|
|
169
169
|
CONF_APP_ID = "Appid"
|
|
170
170
|
CONF_TERMINAL = "Terminal"
|
|
171
171
|
CONF_LOGIN_RESPONSE = "login_response"
|
|
172
|
-
CONF_LOGIN_INFO = "login_info"
|
|
173
172
|
CONF_SELECTED_HOUSE = "selected_house"
|
|
174
173
|
CONF_DEVICE_LIST = "device_list"
|
|
175
174
|
CONF_PRODUCT_LIST = "product_list"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-aidot-cameras
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
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
|
|
@@ -44,6 +44,7 @@ tests/test_live_stream_param.py
|
|
|
44
44
|
tests/test_motion_poll.py
|
|
45
45
|
tests/test_no_undefined_names.py
|
|
46
46
|
tests/test_persistent_mqtt.py
|
|
47
|
+
tests/test_persistent_mqtt_robustness.py
|
|
47
48
|
tests/test_post_merge_hardening.py
|
|
48
49
|
tests/test_sdes_adaptive.py
|
|
49
50
|
tests/test_sdes_fast_liveplay.py
|
|
@@ -57,6 +58,7 @@ tests/test_serve_relay.py
|
|
|
57
58
|
tests/test_speak.py
|
|
58
59
|
tests/test_stream_cap.py
|
|
59
60
|
tests/test_stream_idle.py
|
|
61
|
+
tests/test_stream_teardown.py
|
|
60
62
|
tests/test_talk.py
|
|
61
63
|
tests/test_terminal_ack.py
|
|
62
64
|
tests/test_token_refresh.py
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Regression tests for persistent-MQTT robustness fixes (pre-0.8.0 review).
|
|
2
|
+
|
|
3
|
+
Covers three confirmed defects in the default-on persistent-MQTT path:
|
|
4
|
+
* _request_sync must not raise when a concurrent close() nulls self._client,
|
|
5
|
+
nor when publish() raises - it returns an error tuple so callers fall back.
|
|
6
|
+
* _get_persistent_mqtt must create exactly ONE connection per account even
|
|
7
|
+
under concurrent first-callers (double-checked-locking race).
|
|
8
|
+
* _reap_stream_drain must release the executor thread blocked on outgoing_q
|
|
9
|
+
(cancellation alone cannot) and run the drain's finally (handler removal).
|
|
10
|
+
"""
|
|
11
|
+
import asyncio
|
|
12
|
+
import os
|
|
13
|
+
import queue
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"))
|
|
17
|
+
|
|
18
|
+
import aidot.camera.protocol as proto
|
|
19
|
+
import aidot.camera.client as cc
|
|
20
|
+
from aidot.camera.protocol import _PersistentMqtt
|
|
21
|
+
|
|
22
|
+
_CAM = next(v for v in vars(cc).values()
|
|
23
|
+
if isinstance(v, type) and "_get_persistent_mqtt" in v.__dict__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _cam():
|
|
27
|
+
return _CAM.__new__(_CAM)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# --- finding 3: _request_sync hardened against a concurrent close()/publish --- #
|
|
31
|
+
|
|
32
|
+
def test_request_sync_returns_error_when_client_nulled(monkeypatch):
|
|
33
|
+
pm = _PersistentMqtt("wss://h:8443/mqtt", "u", "p", "cid")
|
|
34
|
+
monkeypatch.setattr(pm, "_ensure_started_sync", lambda timeout=15.0: True)
|
|
35
|
+
pm._client = None # simulate close() nulling the client after the started-check
|
|
36
|
+
messages, st = pm._request_sync([("t", "pp")], ["a/#"], None, 0.05)
|
|
37
|
+
assert messages == []
|
|
38
|
+
assert st.get("error") # truthy error tuple, NOT an AttributeError
|
|
39
|
+
assert pm._collectors == [] # transient collector still cleaned up
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_request_sync_returns_error_when_publish_raises(monkeypatch):
|
|
43
|
+
pm = _PersistentMqtt("wss://h:8443/mqtt", "u", "p", "cid")
|
|
44
|
+
monkeypatch.setattr(pm, "_ensure_started_sync", lambda timeout=15.0: True)
|
|
45
|
+
|
|
46
|
+
class _BadClient:
|
|
47
|
+
def publish(self, *a):
|
|
48
|
+
raise RuntimeError("boom")
|
|
49
|
+
|
|
50
|
+
pm._client = _BadClient()
|
|
51
|
+
messages, st = pm._request_sync([("t", "pp")], [], None, 0.05)
|
|
52
|
+
assert messages == []
|
|
53
|
+
assert "boom" in (st.get("error") or "")
|
|
54
|
+
assert pm._collectors == []
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# --- finding 4: get-or-create is single-flight under concurrency ------------- #
|
|
58
|
+
|
|
59
|
+
def test_get_persistent_mqtt_one_instance_under_concurrency(monkeypatch):
|
|
60
|
+
cam = _cam()
|
|
61
|
+
cam._user_info = {"mqttClientId": "cid"}
|
|
62
|
+
created = []
|
|
63
|
+
|
|
64
|
+
async def _auth():
|
|
65
|
+
return {"mqttUser": "u", "mqttPassword": "p"}
|
|
66
|
+
|
|
67
|
+
async def _url():
|
|
68
|
+
await asyncio.sleep(0) # yield so both callers interleave past the first check
|
|
69
|
+
return "wss://h:8443/mqtt"
|
|
70
|
+
|
|
71
|
+
cam._async_get_smarthome_auth = _auth
|
|
72
|
+
cam._async_get_mqtt_url = _url
|
|
73
|
+
|
|
74
|
+
orig = proto._PersistentMqtt
|
|
75
|
+
|
|
76
|
+
def _counting(*a, **k):
|
|
77
|
+
obj = orig(*a, **k)
|
|
78
|
+
created.append(obj)
|
|
79
|
+
return obj
|
|
80
|
+
|
|
81
|
+
monkeypatch.setattr(proto, "_PersistentMqtt", _counting)
|
|
82
|
+
|
|
83
|
+
async def _run():
|
|
84
|
+
return await asyncio.gather(cam._get_persistent_mqtt(),
|
|
85
|
+
cam._get_persistent_mqtt())
|
|
86
|
+
|
|
87
|
+
a, b = asyncio.run(_run())
|
|
88
|
+
assert a is b is not None
|
|
89
|
+
assert len(created) == 1 # the race previously built two
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# --- finding 1: reaping releases the executor-blocked drain ------------------ #
|
|
93
|
+
|
|
94
|
+
def test_reap_stream_drain_releases_blocked_drain():
|
|
95
|
+
cam = _cam()
|
|
96
|
+
outq = queue.Queue()
|
|
97
|
+
saw = []
|
|
98
|
+
|
|
99
|
+
async def _drain():
|
|
100
|
+
loop = asyncio.get_running_loop()
|
|
101
|
+
try:
|
|
102
|
+
while True:
|
|
103
|
+
out = await loop.run_in_executor(None, outq.get)
|
|
104
|
+
if out is None: # sentinel from _reap_stream_drain
|
|
105
|
+
return
|
|
106
|
+
finally:
|
|
107
|
+
saw.append("finally") # in prod this removes the handler
|
|
108
|
+
|
|
109
|
+
async def _run():
|
|
110
|
+
fut = asyncio.ensure_future(_drain())
|
|
111
|
+
await asyncio.sleep(0.05) # let it block on outgoing_q.get
|
|
112
|
+
cam._stream_mqtt_drain = fut
|
|
113
|
+
cam._stream_mqtt_outq = outq
|
|
114
|
+
await cam._reap_stream_drain()
|
|
115
|
+
return fut
|
|
116
|
+
|
|
117
|
+
fut = asyncio.run(_run())
|
|
118
|
+
assert fut.done()
|
|
119
|
+
assert "finally" in saw # drain's cleanup ran (not leaked)
|
|
120
|
+
assert cam._stream_mqtt_drain is None and cam._stream_mqtt_outq is None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_reap_stream_drain_noop_when_nothing_tracked():
|
|
124
|
+
cam = _cam()
|
|
125
|
+
# No drain tracked: must be a clean no-op, not raise.
|
|
126
|
+
asyncio.run(cam._reap_stream_drain())
|
|
127
|
+
assert getattr(cam, "_stream_mqtt_drain", None) is None
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Unit test: async_stop_streaming reaps an orphaned persistent-MQTT drain.
|
|
2
|
+
|
|
3
|
+
If a stream open is cancelled before a WebRTCSession takes ownership of the
|
|
4
|
+
drain task, the drain would otherwise block forever on outgoing_q.get with its
|
|
5
|
+
handler still registered. async_stop_streaming must cancel it (its finally then
|
|
6
|
+
removes the handler). No camera needed.
|
|
7
|
+
"""
|
|
8
|
+
import asyncio
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"))
|
|
13
|
+
|
|
14
|
+
import aidot.camera.client as cc
|
|
15
|
+
|
|
16
|
+
_CAM = next(v for v in vars(cc).values()
|
|
17
|
+
if isinstance(v, type) and "async_stop_streaming" in v.__dict__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _bare_client():
|
|
21
|
+
c = _CAM.__new__(_CAM)
|
|
22
|
+
c._streaming_active = True
|
|
23
|
+
c._serve_ready = asyncio.Event()
|
|
24
|
+
c._go2rtc_task = None
|
|
25
|
+
c._stream_session = None
|
|
26
|
+
c._stream_task = None
|
|
27
|
+
c._stream_mqtt_drain = None
|
|
28
|
+
|
|
29
|
+
async def _noop_deregister():
|
|
30
|
+
return None
|
|
31
|
+
c._deregister_go2rtc = _noop_deregister
|
|
32
|
+
return c
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_orphaned_drain_is_cancelled():
|
|
36
|
+
async def _run():
|
|
37
|
+
c = _bare_client()
|
|
38
|
+
handler_removed = {"v": False}
|
|
39
|
+
|
|
40
|
+
async def _drain():
|
|
41
|
+
try:
|
|
42
|
+
await asyncio.Event().wait() # blocks forever, like outgoing_q.get
|
|
43
|
+
finally:
|
|
44
|
+
handler_removed["v"] = True # the real finally calls remove_handler
|
|
45
|
+
c._stream_mqtt_drain = asyncio.ensure_future(_drain())
|
|
46
|
+
await asyncio.sleep(0) # let the drain start blocking
|
|
47
|
+
|
|
48
|
+
await c.async_stop_streaming()
|
|
49
|
+
|
|
50
|
+
assert c._stream_mqtt_drain is None # cleared
|
|
51
|
+
assert handler_removed["v"] is True # drain's finally ran (handler removed)
|
|
52
|
+
asyncio.run(_run())
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_no_drain_is_safe():
|
|
56
|
+
async def _run():
|
|
57
|
+
c = _bare_client() # _stream_mqtt_drain is None
|
|
58
|
+
await c.async_stop_streaming() # must not raise
|
|
59
|
+
assert c._streaming_active is False
|
|
60
|
+
asyncio.run(_run())
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
if __name__ == "__main__":
|
|
64
|
+
import traceback
|
|
65
|
+
_fail = 0
|
|
66
|
+
for _k, _v in sorted(globals().items()):
|
|
67
|
+
if _k.startswith("test_"):
|
|
68
|
+
try:
|
|
69
|
+
_v()
|
|
70
|
+
print(f"PASS {_k}")
|
|
71
|
+
except Exception:
|
|
72
|
+
_fail += 1
|
|
73
|
+
print(f"FAIL {_k}")
|
|
74
|
+
traceback.print_exc()
|
|
75
|
+
raise SystemExit(1 if _fail else 0)
|
|
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.7.35 → python_aidot_cameras-0.8.0}/src/aidot/models/device_client_model.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/src/aidot/models/discover_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
|
{python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/tests/test_highport_nomination.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.7.35 → python_aidot_cameras-0.8.0}/tests/test_post_merge_hardening.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
|