python-aidot-cameras 0.7.27__tar.gz → 0.7.28__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.27/src/python_aidot_cameras.egg-info → python_aidot_cameras-0.7.28}/PKG-INFO +2 -1
- {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/README.md +1 -0
- {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/pyproject.toml +1 -1
- {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/aidot/camera/client.py +74 -12
- {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/aidot/camera/protocol.py +180 -0
- {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/aidot/client.py +9 -0
- {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28/src/python_aidot_cameras.egg-info}/PKG-INFO +2 -1
- {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/python_aidot_cameras.egg-info/SOURCES.txt +1 -0
- python_aidot_cameras-0.7.28/tests/test_persistent_mqtt.py +68 -0
- {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/LICENSE +0 -0
- {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/MANIFEST.in +0 -0
- {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/setup.cfg +0 -0
- {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/aidot/__init__.py +0 -0
- {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/aidot/aes_utils.py +0 -0
- {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/aidot/camera/__init__.py +0 -0
- {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/aidot/camera/constants.py +0 -0
- {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/aidot/camera/controls.py +0 -0
- {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/aidot/camera/go2rtc.py +0 -0
- {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/aidot/camera/lan_control.py +0 -0
- {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/aidot/camera/models.py +0 -0
- {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/aidot/camera/playback.py +0 -0
- {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/aidot/camera/sdes.py +0 -0
- {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/aidot/camera/tutk.py +0 -0
- {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/aidot/camera/webrtc.py +0 -0
- {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/aidot/const.py +0 -0
- {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/aidot/credentials.py +0 -0
- {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/aidot/device_client.py +0 -0
- {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/aidot/discover.py +0 -0
- {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/aidot/exceptions.py +0 -0
- {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/aidot/g711.py +0 -0
- {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/aidot/login_const.py +0 -0
- {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/aidot/models/__init__.py +0 -0
- {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/aidot/models/device_client_model.py +0 -0
- {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/aidot/models/device_model.py +0 -0
- {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/aidot/models/discover_model.py +0 -0
- {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/aidot/py.typed +0 -0
- {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
- {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/python_aidot_cameras.egg-info/requires.txt +0 -0
- {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/python_aidot_cameras.egg-info/top_level.txt +0 -0
- {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/tests/test_alarm_event.py +0 -0
- {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/tests/test_backoff.py +0 -0
- {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/tests/test_go2rtc.py +0 -0
- {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/tests/test_highport_nomination.py +0 -0
- {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/tests/test_lan_control.py +0 -0
- {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/tests/test_live_stream_param.py +0 -0
- {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/tests/test_motion_poll.py +0 -0
- {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/tests/test_no_undefined_names.py +0 -0
- {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/tests/test_post_merge_hardening.py +0 -0
- {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/tests/test_sdes_adaptive.py +0 -0
- {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/tests/test_sdes_fast_liveplay.py +0 -0
- {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/tests/test_sdes_idle_release.py +0 -0
- {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/tests/test_sdes_sprop.py +0 -0
- {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/tests/test_sdes_talk.py +0 -0
- {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/tests/test_sdes_watchdog.py +0 -0
- {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/tests/test_serve_relay.py +0 -0
- {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/tests/test_speak.py +0 -0
- {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/tests/test_stream_cap.py +0 -0
- {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/tests/test_stream_idle.py +0 -0
- {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/tests/test_talk.py +0 -0
- {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/tests/test_terminal_ack.py +0 -0
- {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/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.7.
|
|
3
|
+
Version: 0.7.28
|
|
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
|
|
@@ -122,6 +122,7 @@ chosen to work out of the box; override only when tuning.
|
|
|
122
122
|
| `AIDOT_SPROP_DIR` | Directory where captured SPS/PPS (sprop) parameter sets are cached. Set this to a writable path (e.g. for Home Assistant) if the default location is read-only. | `<package dir>` |
|
|
123
123
|
| `AIDOT_DISABLE_HIGHPORT_FIX` | If set (any value), disables the DTLS high-port `USE-CANDIDATE` nomination fix and falls back to upstream aioice behavior (used to measure the baseline connect rate). | unset (fix enabled) |
|
|
124
124
|
| `AIDOT_FAST_CONNECT` | Enables LAN-direct "fast connect" mode (STUN-only, skips several cloud signaling waits) when set to a truthy value. | unset (off) |
|
|
125
|
+
| `AIDOT_PERSISTENT_MQTT` | Reuse ONE account-level persistent MQTT connection for device commands and attribute fetches (matching the official app) instead of connecting per operation, cutting cloud connect churn. Truthy value enables. The stream-open path is unaffected. | unset (off) |
|
|
125
126
|
| `AIDOT_SDES_ADAPTIVE` | Adaptive fast-with-fallback for the SDES keepalive loop: try the fast path first (skip livePlay waits + TURN relay pre-alloc) and fall back to the full relay path if a fast attempt delivers no media. A per-device cache skips the fast attempt on later views once it has failed for a camera. Truthy value enables. | unset (off) |
|
|
126
127
|
| `AIDOT_SDES_FAST_LIVEPLAY` | Truthy value skips the `livePlayResp` blocking wait for eligible SDES cameras (~4.5 s faster cold start), while keeping the full ICE/SCTP handshake. Role-reversal models (A001064 PTZ) are always excluded. Soak-validated; opt-in. | unset (off) |
|
|
127
128
|
| `AIDOT_SERVE_RELAY` | Holds the public stream port via an internal relay that proxies to ffmpeg, so the first (cold) view connects instead of failing while ffmpeg can't pre-bind the port. Set to `0` to serve ffmpeg directly. | `1` (enabled) |
|
|
@@ -95,6 +95,7 @@ chosen to work out of the box; override only when tuning.
|
|
|
95
95
|
| `AIDOT_SPROP_DIR` | Directory where captured SPS/PPS (sprop) parameter sets are cached. Set this to a writable path (e.g. for Home Assistant) if the default location is read-only. | `<package dir>` |
|
|
96
96
|
| `AIDOT_DISABLE_HIGHPORT_FIX` | If set (any value), disables the DTLS high-port `USE-CANDIDATE` nomination fix and falls back to upstream aioice behavior (used to measure the baseline connect rate). | unset (fix enabled) |
|
|
97
97
|
| `AIDOT_FAST_CONNECT` | Enables LAN-direct "fast connect" mode (STUN-only, skips several cloud signaling waits) when set to a truthy value. | unset (off) |
|
|
98
|
+
| `AIDOT_PERSISTENT_MQTT` | Reuse ONE account-level persistent MQTT connection for device commands and attribute fetches (matching the official app) instead of connecting per operation, cutting cloud connect churn. Truthy value enables. The stream-open path is unaffected. | unset (off) |
|
|
98
99
|
| `AIDOT_SDES_ADAPTIVE` | Adaptive fast-with-fallback for the SDES keepalive loop: try the fast path first (skip livePlay waits + TURN relay pre-alloc) and fall back to the full relay path if a fast attempt delivers no media. A per-device cache skips the fast attempt on later views once it has failed for a camera. Truthy value enables. | unset (off) |
|
|
99
100
|
| `AIDOT_SDES_FAST_LIVEPLAY` | Truthy value skips the `livePlayResp` blocking wait for eligible SDES cameras (~4.5 s faster cold start), while keeping the full ICE/SCTP handshake. Role-reversal models (A001064 PTZ) are always excluded. Soak-validated; opt-in. | unset (off) |
|
|
100
101
|
| `AIDOT_SERVE_RELAY` | Holds the public stream port via an internal relay that proxies to ffmpeg, so the first (cold) view connects instead of failing while ffmpeg can't pre-bind the port. Set to `0` to serve ffmpeg directly. | `1` (enabled) |
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-aidot-cameras"
|
|
7
|
-
version = "0.7.
|
|
7
|
+
version = "0.7.28"
|
|
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"
|
|
@@ -1241,12 +1241,23 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
1241
1241
|
publish_items.append((_wake_topic, _wake_payload))
|
|
1242
1242
|
publish_items.append((pub_topic, payload_str))
|
|
1243
1243
|
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1244
|
+
if self._resolve_persistent_mqtt():
|
|
1245
|
+
pm = await self._get_persistent_mqtt()
|
|
1246
|
+
else:
|
|
1247
|
+
pm = None
|
|
1248
|
+
if pm is not None:
|
|
1249
|
+
messages, _st = await pm.request(
|
|
1250
|
+
publish_items=publish_items,
|
|
1251
|
+
subscribe_topics=sub_topics,
|
|
1252
|
+
timeout=timeout,
|
|
1253
|
+
)
|
|
1254
|
+
else:
|
|
1255
|
+
messages = await _mqtt_session(
|
|
1256
|
+
mqtt_url, mqtt_user, mqtt_pwd, client_id,
|
|
1257
|
+
subscribe_topics=sub_topics,
|
|
1258
|
+
publish_items=publish_items,
|
|
1259
|
+
duration=timeout,
|
|
1260
|
+
)
|
|
1250
1261
|
|
|
1251
1262
|
for topic, raw in messages:
|
|
1252
1263
|
if ack_keyword and ack_keyword not in topic:
|
|
@@ -1473,12 +1484,23 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
1473
1484
|
})
|
|
1474
1485
|
publish_items.append((_wake_topic, _wake_payload))
|
|
1475
1486
|
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1487
|
+
if self._resolve_persistent_mqtt():
|
|
1488
|
+
pm = await self._get_persistent_mqtt()
|
|
1489
|
+
else:
|
|
1490
|
+
pm = None
|
|
1491
|
+
if pm is not None:
|
|
1492
|
+
messages, _st = await pm.request(
|
|
1493
|
+
publish_items=publish_items,
|
|
1494
|
+
subscribe_topics=sub_topics,
|
|
1495
|
+
timeout=timeout,
|
|
1496
|
+
)
|
|
1497
|
+
else:
|
|
1498
|
+
messages = await _mqtt_session(
|
|
1499
|
+
mqtt_url, mqtt_user, mqtt_pwd, client_id,
|
|
1500
|
+
subscribe_topics=sub_topics,
|
|
1501
|
+
publish_items=publish_items,
|
|
1502
|
+
duration=timeout,
|
|
1503
|
+
)
|
|
1482
1504
|
|
|
1483
1505
|
for topic, raw in messages:
|
|
1484
1506
|
if "setDevAttrNotif" not in topic:
|
|
@@ -2659,6 +2681,46 @@ class CameraMixin(_CameraControlsMixin):
|
|
|
2659
2681
|
return os.environ.get("AIDOT_SDES_ADAPTIVE", "").strip().lower() in (
|
|
2660
2682
|
"1", "true", "yes", "on")
|
|
2661
2683
|
|
|
2684
|
+
def _resolve_persistent_mqtt(self) -> bool:
|
|
2685
|
+
"""Whether commands + attribute fetches reuse ONE account-level persistent
|
|
2686
|
+
MQTT connection (matching the app's LDSBaseMqttServiceImpl) instead of
|
|
2687
|
+
connecting per op. Opt-in (``AIDOT_PERSISTENT_MQTT`` env / per-camera
|
|
2688
|
+
``_persistent_mqtt_opt``), default off while it's validated. Phase 1: the
|
|
2689
|
+
stream-open path is unchanged (it uses session client_ids)."""
|
|
2690
|
+
opt = getattr(self, "_persistent_mqtt_opt", None)
|
|
2691
|
+
if opt is not None:
|
|
2692
|
+
return bool(opt)
|
|
2693
|
+
return os.environ.get("AIDOT_PERSISTENT_MQTT", "").strip().lower() in (
|
|
2694
|
+
"1", "true", "yes", "on")
|
|
2695
|
+
|
|
2696
|
+
async def _get_persistent_mqtt(self):
|
|
2697
|
+
"""Get-or-create the account-shared ``_PersistentMqtt`` (one per account,
|
|
2698
|
+
keyed on the shared ``login_info``). The broker binds auth to the single
|
|
2699
|
+
authorized client_id, so there must be exactly one. Returns None if it
|
|
2700
|
+
can't be built (caller falls back to per-op ``_mqtt_session``)."""
|
|
2701
|
+
# _user_info is the shared account dict (same object across the account's
|
|
2702
|
+
# DeviceClients, and the same object as AidotClient.login_info) - the right
|
|
2703
|
+
# place to cache one connection per account.
|
|
2704
|
+
li = self._user_info if isinstance(getattr(self, "_user_info", None), dict) else None
|
|
2705
|
+
if li is None:
|
|
2706
|
+
return None
|
|
2707
|
+
pm = li.get("_persistent_mqtt")
|
|
2708
|
+
if pm is not None:
|
|
2709
|
+
return pm
|
|
2710
|
+
smarthome_auth = await self._async_get_smarthome_auth()
|
|
2711
|
+
mqtt_user = (smarthome_auth or {}).get("mqttUser") or str(self.user_id)
|
|
2712
|
+
mqtt_pwd = (smarthome_auth or {}).get("mqttPassword") or ""
|
|
2713
|
+
client_id = (self._user_info.get("mqttClientId") or f"app-{mqtt_user}")
|
|
2714
|
+
mqtt_url = await self._async_get_mqtt_url()
|
|
2715
|
+
if not mqtt_url:
|
|
2716
|
+
return None
|
|
2717
|
+
from .protocol import _PersistentMqtt
|
|
2718
|
+
pm = li.get("_persistent_mqtt") # re-check under the brief await gap
|
|
2719
|
+
if pm is None:
|
|
2720
|
+
pm = _PersistentMqtt(mqtt_url, mqtt_user, mqtt_pwd, client_id)
|
|
2721
|
+
li["_persistent_mqtt"] = pm
|
|
2722
|
+
return pm
|
|
2723
|
+
|
|
2662
2724
|
@staticmethod
|
|
2663
2725
|
def _adaptive_next_fast(adaptive: bool, fast_failed: bool) -> bool:
|
|
2664
2726
|
"""Whether the next SDES open attempt should use the fast path: only when
|
|
@@ -1245,6 +1245,186 @@ def _mqtt_session_sync(
|
|
|
1245
1245
|
return collected, status
|
|
1246
1246
|
|
|
1247
1247
|
|
|
1248
|
+
class _PersistentMqtt:
|
|
1249
|
+
"""One long-lived paho MQTT connection reused across many publish/collect
|
|
1250
|
+
round-trips - matching the official app's single persistent connection
|
|
1251
|
+
(LDSBaseMqttServiceImpl) instead of our historical connect-per-op.
|
|
1252
|
+
|
|
1253
|
+
Thread-safe: the paho network loop runs in its own background thread and
|
|
1254
|
+
auto-reconnects; tracked subscriptions are replayed on every (re)connect.
|
|
1255
|
+
``request()`` registers a transient collector, publishes, and gathers
|
|
1256
|
+
matching messages for ``timeout`` WITHOUT tearing the connection down, so
|
|
1257
|
+
N operations cost ONE connect instead of N. The client_id is account-level,
|
|
1258
|
+
so exactly one of these should exist per account (per AidotClient)."""
|
|
1259
|
+
|
|
1260
|
+
def __init__(self, mqtt_url, mqtt_user, mqtt_pwd, client_id, ws_path="/mqtt"):
|
|
1261
|
+
self._url = mqtt_url
|
|
1262
|
+
self._user = mqtt_user
|
|
1263
|
+
self._pwd = mqtt_pwd
|
|
1264
|
+
self._cid = client_id
|
|
1265
|
+
self._ws_path = ws_path
|
|
1266
|
+
self._client = None
|
|
1267
|
+
self._host = None
|
|
1268
|
+
self._port = None
|
|
1269
|
+
self._connected = threading.Event()
|
|
1270
|
+
self._lock = threading.Lock()
|
|
1271
|
+
self._subs = set() # topics to (re)subscribe on connect
|
|
1272
|
+
self._collectors = [] # transient queues, each receives every msg
|
|
1273
|
+
self._started = False
|
|
1274
|
+
self.connects = 0 # observability: how many times we connected
|
|
1275
|
+
|
|
1276
|
+
def _build(self):
|
|
1277
|
+
import paho.mqtt.client as _paho
|
|
1278
|
+
import ssl as _ssl
|
|
1279
|
+
from urllib.parse import urlparse
|
|
1280
|
+
parsed = urlparse(self._url)
|
|
1281
|
+
self._host = parsed.hostname or self._url
|
|
1282
|
+
self._port = parsed.port or (8443 if parsed.scheme in ("wss", "https") else 1883)
|
|
1283
|
+
tls = parsed.scheme in ("wss", "https", "mqtts")
|
|
1284
|
+
path = self._ws_path if self._ws_path is not None else (parsed.path or "/mqtt")
|
|
1285
|
+
if path == "":
|
|
1286
|
+
path = "/"
|
|
1287
|
+
try:
|
|
1288
|
+
c = _paho.Client(callback_api_version=_paho.CallbackAPIVersion.VERSION2,
|
|
1289
|
+
client_id=self._cid, transport="websockets")
|
|
1290
|
+
except AttributeError:
|
|
1291
|
+
c = _paho.Client(client_id=self._cid, transport="websockets")
|
|
1292
|
+
c.ws_set_options(path=path)
|
|
1293
|
+
if self._user:
|
|
1294
|
+
c.username_pw_set(self._user, self._pwd or "")
|
|
1295
|
+
if tls:
|
|
1296
|
+
c.tls_set_context(_ssl.create_default_context())
|
|
1297
|
+
try:
|
|
1298
|
+
c.reconnect_delay_set(min_delay=1, max_delay=30)
|
|
1299
|
+
except Exception:
|
|
1300
|
+
pass
|
|
1301
|
+
c.on_connect = self._on_connect
|
|
1302
|
+
c.on_disconnect = self._on_disconnect
|
|
1303
|
+
c.on_message = self._on_message
|
|
1304
|
+
return c
|
|
1305
|
+
|
|
1306
|
+
def _on_connect(self, c, ud, flags, reason_code, props=None):
|
|
1307
|
+
try:
|
|
1308
|
+
rc = int(reason_code)
|
|
1309
|
+
except (TypeError, ValueError):
|
|
1310
|
+
rc = getattr(reason_code, "value", -1)
|
|
1311
|
+
if rc == 0:
|
|
1312
|
+
self.connects += 1
|
|
1313
|
+
with self._lock:
|
|
1314
|
+
subs = list(self._subs)
|
|
1315
|
+
for t in subs: # replay subscriptions after (re)connect
|
|
1316
|
+
try:
|
|
1317
|
+
c.subscribe(t)
|
|
1318
|
+
except Exception:
|
|
1319
|
+
_LOGGER.debug("persistent mqtt: resubscribe %s failed", t)
|
|
1320
|
+
self._connected.set()
|
|
1321
|
+
else:
|
|
1322
|
+
_LOGGER.warning("persistent mqtt: broker refused rc=%s", rc)
|
|
1323
|
+
|
|
1324
|
+
def _on_disconnect(self, c, ud, *args, **kwargs):
|
|
1325
|
+
self._connected.clear() # paho loop auto-reconnects; subs replay on_connect
|
|
1326
|
+
|
|
1327
|
+
def _on_message(self, c, ud, msg):
|
|
1328
|
+
payload = (msg.payload.decode("utf-8", errors="replace")
|
|
1329
|
+
if isinstance(msg.payload, (bytes, bytearray)) else str(msg.payload))
|
|
1330
|
+
item = (msg.topic, payload)
|
|
1331
|
+
with self._lock:
|
|
1332
|
+
cols = list(self._collectors)
|
|
1333
|
+
for q in cols:
|
|
1334
|
+
q.put(item)
|
|
1335
|
+
|
|
1336
|
+
def _ensure_started_sync(self, timeout=15.0):
|
|
1337
|
+
with self._lock:
|
|
1338
|
+
if self._client is None:
|
|
1339
|
+
self._client = self._build()
|
|
1340
|
+
try:
|
|
1341
|
+
self._client.connect(self._host, self._port, keepalive=60)
|
|
1342
|
+
except Exception as exc:
|
|
1343
|
+
_LOGGER.warning("persistent mqtt: connect() raised: %s", exc)
|
|
1344
|
+
self._client = None
|
|
1345
|
+
return False
|
|
1346
|
+
self._client.loop_start() # drives keepalive + auto-reconnect
|
|
1347
|
+
self._started = True
|
|
1348
|
+
return self._connected.wait(timeout)
|
|
1349
|
+
|
|
1350
|
+
def _subscribe_sync(self, topics):
|
|
1351
|
+
new = []
|
|
1352
|
+
with self._lock:
|
|
1353
|
+
for t in topics:
|
|
1354
|
+
if t not in self._subs:
|
|
1355
|
+
self._subs.add(t)
|
|
1356
|
+
new.append(t)
|
|
1357
|
+
c = self._client
|
|
1358
|
+
if c is not None and self._connected.is_set():
|
|
1359
|
+
for t in new:
|
|
1360
|
+
try:
|
|
1361
|
+
c.subscribe(t)
|
|
1362
|
+
except Exception:
|
|
1363
|
+
_LOGGER.debug("persistent mqtt: subscribe %s failed", t)
|
|
1364
|
+
|
|
1365
|
+
def _request_sync(self, publish_items, subscribe_topics, match, timeout):
|
|
1366
|
+
import queue as _queue
|
|
1367
|
+
import time as _time
|
|
1368
|
+
if not self._ensure_started_sync():
|
|
1369
|
+
return [], {"error": "persistent mqtt connect timeout"}
|
|
1370
|
+
self._subscribe_sync(subscribe_topics or [])
|
|
1371
|
+
q = _queue.Queue()
|
|
1372
|
+
with self._lock:
|
|
1373
|
+
self._collectors.append(q)
|
|
1374
|
+
collected = []
|
|
1375
|
+
try:
|
|
1376
|
+
for pt, pp in (publish_items or []):
|
|
1377
|
+
self._client.publish(pt, pp)
|
|
1378
|
+
deadline = _time.monotonic() + timeout
|
|
1379
|
+
while True:
|
|
1380
|
+
remaining = deadline - _time.monotonic()
|
|
1381
|
+
if remaining <= 0:
|
|
1382
|
+
break
|
|
1383
|
+
try:
|
|
1384
|
+
item = q.get(timeout=min(remaining, 0.1))
|
|
1385
|
+
except _queue.Empty:
|
|
1386
|
+
continue
|
|
1387
|
+
if match is None or match(item[0], item[1]):
|
|
1388
|
+
collected.append(item)
|
|
1389
|
+
finally:
|
|
1390
|
+
with self._lock:
|
|
1391
|
+
try:
|
|
1392
|
+
self._collectors.remove(q)
|
|
1393
|
+
except ValueError:
|
|
1394
|
+
pass
|
|
1395
|
+
return collected, {"error": None}
|
|
1396
|
+
|
|
1397
|
+
async def request(self, publish_items, subscribe_topics=None, match=None, timeout=5.0):
|
|
1398
|
+
"""Publish ``publish_items`` and collect matching messages for ``timeout``
|
|
1399
|
+
on the shared persistent connection (one connect for the account, reused).
|
|
1400
|
+
Returns (messages, status)."""
|
|
1401
|
+
import functools
|
|
1402
|
+
loop = asyncio.get_running_loop()
|
|
1403
|
+
return await loop.run_in_executor(None, functools.partial(
|
|
1404
|
+
self._request_sync, publish_items, subscribe_topics, match, timeout))
|
|
1405
|
+
|
|
1406
|
+
async def ensure_connected(self, timeout=15.0):
|
|
1407
|
+
import functools
|
|
1408
|
+
loop = asyncio.get_running_loop()
|
|
1409
|
+
return await loop.run_in_executor(
|
|
1410
|
+
None, functools.partial(self._ensure_started_sync, timeout))
|
|
1411
|
+
|
|
1412
|
+
def close(self):
|
|
1413
|
+
c = self._client
|
|
1414
|
+
self._client = None
|
|
1415
|
+
self._started = False
|
|
1416
|
+
self._connected.clear()
|
|
1417
|
+
if c is not None:
|
|
1418
|
+
try:
|
|
1419
|
+
c.loop_stop()
|
|
1420
|
+
except Exception:
|
|
1421
|
+
pass
|
|
1422
|
+
try:
|
|
1423
|
+
c.disconnect()
|
|
1424
|
+
except Exception:
|
|
1425
|
+
pass
|
|
1426
|
+
|
|
1427
|
+
|
|
1248
1428
|
async def _mqtt_session(
|
|
1249
1429
|
mqtt_url: str,
|
|
1250
1430
|
mqtt_user: str,
|
|
@@ -529,6 +529,15 @@ class AidotClient:
|
|
|
529
529
|
for client in self._device_clients.values():
|
|
530
530
|
await client.close()
|
|
531
531
|
self._device_clients.clear()
|
|
532
|
+
# Close the account-shared persistent MQTT connection, if one was opened
|
|
533
|
+
# (AIDOT_PERSISTENT_MQTT). Stored on the shared login_info by the camera
|
|
534
|
+
# command/attr paths; closing here stops its background paho loop.
|
|
535
|
+
pm = self.login_info.pop("_persistent_mqtt", None) if isinstance(self.login_info, dict) else None
|
|
536
|
+
if pm is not None:
|
|
537
|
+
try:
|
|
538
|
+
pm.close()
|
|
539
|
+
except Exception:
|
|
540
|
+
_LOGGER.debug("aidot: persistent mqtt close failed", exc_info=True)
|
|
532
541
|
|
|
533
542
|
def cleanup(self) -> None:
|
|
534
543
|
"""Sync entry point: fire-and-forget async_close()."""
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-aidot-cameras
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.28
|
|
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
|
|
@@ -122,6 +122,7 @@ chosen to work out of the box; override only when tuning.
|
|
|
122
122
|
| `AIDOT_SPROP_DIR` | Directory where captured SPS/PPS (sprop) parameter sets are cached. Set this to a writable path (e.g. for Home Assistant) if the default location is read-only. | `<package dir>` |
|
|
123
123
|
| `AIDOT_DISABLE_HIGHPORT_FIX` | If set (any value), disables the DTLS high-port `USE-CANDIDATE` nomination fix and falls back to upstream aioice behavior (used to measure the baseline connect rate). | unset (fix enabled) |
|
|
124
124
|
| `AIDOT_FAST_CONNECT` | Enables LAN-direct "fast connect" mode (STUN-only, skips several cloud signaling waits) when set to a truthy value. | unset (off) |
|
|
125
|
+
| `AIDOT_PERSISTENT_MQTT` | Reuse ONE account-level persistent MQTT connection for device commands and attribute fetches (matching the official app) instead of connecting per operation, cutting cloud connect churn. Truthy value enables. The stream-open path is unaffected. | unset (off) |
|
|
125
126
|
| `AIDOT_SDES_ADAPTIVE` | Adaptive fast-with-fallback for the SDES keepalive loop: try the fast path first (skip livePlay waits + TURN relay pre-alloc) and fall back to the full relay path if a fast attempt delivers no media. A per-device cache skips the fast attempt on later views once it has failed for a camera. Truthy value enables. | unset (off) |
|
|
126
127
|
| `AIDOT_SDES_FAST_LIVEPLAY` | Truthy value skips the `livePlayResp` blocking wait for eligible SDES cameras (~4.5 s faster cold start), while keeping the full ICE/SCTP handshake. Role-reversal models (A001064 PTZ) are always excluded. Soak-validated; opt-in. | unset (off) |
|
|
127
128
|
| `AIDOT_SERVE_RELAY` | Holds the public stream port via an internal relay that proxies to ffmpeg, so the first (cold) view connects instead of failing while ffmpeg can't pre-bind the port. Set to `0` to serve ffmpeg directly. | `1` (enabled) |
|
|
@@ -42,6 +42,7 @@ tests/test_lan_control.py
|
|
|
42
42
|
tests/test_live_stream_param.py
|
|
43
43
|
tests/test_motion_poll.py
|
|
44
44
|
tests/test_no_undefined_names.py
|
|
45
|
+
tests/test_persistent_mqtt.py
|
|
45
46
|
tests/test_post_merge_hardening.py
|
|
46
47
|
tests/test_sdes_adaptive.py
|
|
47
48
|
tests/test_sdes_fast_liveplay.py
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Unit tests for the persistent-MQTT flag wiring (Phase 1).
|
|
2
|
+
|
|
3
|
+
Locks the opt/env precedence of ``_resolve_persistent_mqtt`` (per-camera kwarg
|
|
4
|
+
wins over AIDOT_PERSISTENT_MQTT env, default off) and the basic reuse semantics
|
|
5
|
+
of the ``_PersistentMqtt`` collector routing. The live connection behaviour is
|
|
6
|
+
validated on hardware, not here.
|
|
7
|
+
"""
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"))
|
|
12
|
+
|
|
13
|
+
import aidot.camera.client as cc
|
|
14
|
+
from aidot.camera.protocol import _PersistentMqtt
|
|
15
|
+
|
|
16
|
+
_CAM = next(v for v in vars(cc).values()
|
|
17
|
+
if isinstance(v, type) and "_resolve_persistent_mqtt" in v.__dict__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _cam():
|
|
21
|
+
return _CAM.__new__(_CAM)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_default_off(monkeypatch):
|
|
25
|
+
monkeypatch.delenv("AIDOT_PERSISTENT_MQTT", raising=False)
|
|
26
|
+
assert _cam()._resolve_persistent_mqtt() is False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_env_enables(monkeypatch):
|
|
30
|
+
for val in ("1", "true", "TRUE", "yes", "on", " On "):
|
|
31
|
+
monkeypatch.setenv("AIDOT_PERSISTENT_MQTT", val)
|
|
32
|
+
assert _cam()._resolve_persistent_mqtt() is True, val
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_env_falsey_stays_off(monkeypatch):
|
|
36
|
+
for val in ("0", "false", "no", "off", "", "anything"):
|
|
37
|
+
monkeypatch.setenv("AIDOT_PERSISTENT_MQTT", val)
|
|
38
|
+
assert _cam()._resolve_persistent_mqtt() is False, val
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_kwarg_wins_over_env(monkeypatch):
|
|
42
|
+
monkeypatch.setenv("AIDOT_PERSISTENT_MQTT", "1")
|
|
43
|
+
cam = _cam()
|
|
44
|
+
cam._persistent_mqtt_opt = False
|
|
45
|
+
assert cam._resolve_persistent_mqtt() is False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_collector_routing_fans_out_messages():
|
|
49
|
+
# _on_message must fan every message out to every registered collector queue,
|
|
50
|
+
# so concurrent request()s each see the traffic (matching is per-collector).
|
|
51
|
+
import queue
|
|
52
|
+
pm = _PersistentMqtt("wss://h:8443/mqtt", "u", "p", "cid")
|
|
53
|
+
q1, q2 = queue.Queue(), queue.Queue()
|
|
54
|
+
pm._collectors = [q1, q2]
|
|
55
|
+
|
|
56
|
+
class _Msg:
|
|
57
|
+
topic = "iot/v1/cb/dev/x"
|
|
58
|
+
payload = b'{"k":1}'
|
|
59
|
+
pm._on_message(None, None, _Msg())
|
|
60
|
+
assert q1.get_nowait() == ("iot/v1/cb/dev/x", '{"k":1}')
|
|
61
|
+
assert q2.get_nowait() == ("iot/v1/cb/dev/x", '{"k":1}')
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_subscriptions_tracked_for_replay():
|
|
65
|
+
# subscribe topics must be remembered so they can be replayed on reconnect
|
|
66
|
+
pm = _PersistentMqtt("wss://h:8443/mqtt", "u", "p", "cid")
|
|
67
|
+
pm._subscribe_sync(["a/#", "b/#", "a/#"]) # no client yet -> just tracked
|
|
68
|
+
assert pm._subs == {"a/#", "b/#"}
|
|
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.27 → python_aidot_cameras-0.7.28}/src/aidot/models/device_client_model.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/aidot/models/device_model.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/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
|
{python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/tests/test_highport_nomination.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/tests/test_no_undefined_names.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/tests/test_post_merge_hardening.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|