python-aidot-cameras 0.7.36__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.36/src/python_aidot_cameras.egg-info → python_aidot_cameras-0.8.0}/PKG-INFO +1 -1
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/pyproject.toml +1 -1
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/src/aidot/camera/client.py +87 -25
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/src/aidot/camera/protocol.py +8 -2
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/src/aidot/camera/sdes.py +14 -0
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/src/aidot/const.py +0 -1
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0/src/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/src/python_aidot_cameras.egg-info/SOURCES.txt +1 -0
- python_aidot_cameras-0.8.0/tests/test_persistent_mqtt_robustness.py +127 -0
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/LICENSE +0 -0
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/MANIFEST.in +0 -0
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/README.md +0 -0
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/setup.cfg +0 -0
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/src/aidot/__init__.py +0 -0
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/src/aidot/aes_utils.py +0 -0
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/src/aidot/camera/__init__.py +0 -0
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/src/aidot/camera/constants.py +0 -0
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/src/aidot/camera/controls.py +0 -0
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/src/aidot/camera/go2rtc.py +0 -0
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/src/aidot/camera/lan_control.py +0 -0
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/src/aidot/camera/models.py +0 -0
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/src/aidot/camera/playback.py +0 -0
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/src/aidot/camera/tutk.py +0 -0
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/src/aidot/camera/webrtc.py +0 -0
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/src/aidot/client.py +0 -0
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/src/aidot/credentials.py +0 -0
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/src/aidot/device_client.py +0 -0
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/src/aidot/discover.py +0 -0
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/src/aidot/exceptions.py +0 -0
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/src/aidot/g711.py +0 -0
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/src/aidot/login_const.py +0 -0
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/src/aidot/models/__init__.py +0 -0
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/src/aidot/models/device_client_model.py +0 -0
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/src/aidot/models/device_model.py +0 -0
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/src/aidot/models/discover_model.py +0 -0
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/src/aidot/py.typed +0 -0
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/src/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/src/python_aidot_cameras.egg-info/requires.txt +0 -0
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/src/python_aidot_cameras.egg-info/top_level.txt +0 -0
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/tests/test_alarm_event.py +0 -0
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/tests/test_backoff.py +0 -0
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/tests/test_device_login_guard.py +0 -0
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/tests/test_go2rtc.py +0 -0
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/tests/test_highport_nomination.py +0 -0
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/tests/test_lan_control.py +0 -0
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/tests/test_live_stream_param.py +0 -0
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/tests/test_motion_poll.py +0 -0
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/tests/test_no_undefined_names.py +0 -0
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/tests/test_persistent_mqtt.py +0 -0
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/tests/test_post_merge_hardening.py +0 -0
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/tests/test_sdes_adaptive.py +0 -0
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/tests/test_sdes_fast_liveplay.py +0 -0
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/tests/test_sdes_idle_release.py +0 -0
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/tests/test_sdes_serve_audio.py +0 -0
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/tests/test_sdes_serve_cmd.py +0 -0
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/tests/test_sdes_sprop.py +0 -0
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/tests/test_sdes_talk.py +0 -0
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/tests/test_sdes_watchdog.py +0 -0
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/tests/test_serve_relay.py +0 -0
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/tests/test_speak.py +0 -0
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/tests/test_stream_cap.py +0 -0
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/tests/test_stream_idle.py +0 -0
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/tests/test_stream_teardown.py +0 -0
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/tests/test_talk.py +0 -0
- {python_aidot_cameras-0.7.36 → python_aidot_cameras-0.8.0}/tests/test_terminal_ack.py +0 -0
- {python_aidot_cameras-0.7.36 → 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"
|
|
@@ -1340,6 +1340,20 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
1340
1340
|
subscribe_topics=sub_topics,
|
|
1341
1341
|
timeout=timeout,
|
|
1342
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
|
+
)
|
|
1343
1357
|
else:
|
|
1344
1358
|
messages = await _mqtt_session(
|
|
1345
1359
|
mqtt_url, mqtt_user, mqtt_pwd, client_id,
|
|
@@ -1583,6 +1597,20 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
1583
1597
|
subscribe_topics=sub_topics,
|
|
1584
1598
|
timeout=timeout,
|
|
1585
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
|
+
)
|
|
1586
1614
|
else:
|
|
1587
1615
|
messages = await _mqtt_session(
|
|
1588
1616
|
mqtt_url, mqtt_user, mqtt_pwd, client_id,
|
|
@@ -1983,15 +2011,36 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
1983
2011
|
except (asyncio.CancelledError, Exception):
|
|
1984
2012
|
_LOGGER.debug("camera %s: swallowed exception", 'async_stop_streaming', exc_info=True)
|
|
1985
2013
|
# Reap a persistent-MQTT stream drain that no session stopped (e.g. an
|
|
1986
|
-
# open cancelled mid-handshake)
|
|
1987
|
-
#
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
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:
|
|
1991
2034
|
try:
|
|
1992
|
-
|
|
1993
|
-
except
|
|
1994
|
-
_LOGGER.debug("camera %s: swallowed exception", '
|
|
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)
|
|
1995
2044
|
|
|
1996
2045
|
async def async_start_motion_polling(
|
|
1997
2046
|
self, callback: Callable, interval: float = 30.0, lookback_s: int = 600,
|
|
@@ -2844,19 +2893,28 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
2844
2893
|
pm = li.get("_persistent_mqtt")
|
|
2845
2894
|
if pm is not None:
|
|
2846
2895
|
return pm
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
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
|
|
2857
2915
|
pm = _PersistentMqtt(mqtt_url, mqtt_user, mqtt_pwd, client_id)
|
|
2858
2916
|
li["_persistent_mqtt"] = pm
|
|
2859
|
-
|
|
2917
|
+
return pm
|
|
2860
2918
|
|
|
2861
2919
|
@staticmethod
|
|
2862
2920
|
def _adaptive_next_fast(adaptive: bool, fast_failed: bool) -> bool:
|
|
@@ -4237,6 +4295,10 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
4237
4295
|
# (the stream's mqtt_cid IS the authorized mqttClientId, so it's the same
|
|
4238
4296
|
# connection) - subscribe + register a handler + drain outgoing_q through
|
|
4239
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()
|
|
4240
4302
|
_pm_stream = (await self._get_persistent_mqtt()
|
|
4241
4303
|
if self._resolve_persistent_mqtt() else None)
|
|
4242
4304
|
if _pm_stream is not None:
|
|
@@ -4254,13 +4316,13 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
4254
4316
|
_pm_stream.remove_handler(_on_mqtt_message)
|
|
4255
4317
|
|
|
4256
4318
|
mqtt_fut = asyncio.ensure_future(_pm_stream_drain())
|
|
4257
|
-
# Track the drain
|
|
4258
|
-
# cancelled before a WebRTCSession takes
|
|
4259
|
-
#
|
|
4260
|
-
#
|
|
4261
|
-
#
|
|
4262
|
-
# persistent connection.
|
|
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.
|
|
4263
4324
|
self._stream_mqtt_drain = mqtt_fut
|
|
4325
|
+
self._stream_mqtt_outq = outgoing_q
|
|
4264
4326
|
_on_mqtt_ready({"connected": True, "rc": 0, "rc_str": "persistent"})
|
|
4265
4327
|
else:
|
|
4266
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
|
|
@@ -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
|
|
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.36 → 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.36 → 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.36 → 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.36 → 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
|
|
File without changes
|