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.
Files changed (61) hide show
  1. {python_aidot_cameras-0.7.27/src/python_aidot_cameras.egg-info → python_aidot_cameras-0.7.28}/PKG-INFO +2 -1
  2. {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/README.md +1 -0
  3. {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/pyproject.toml +1 -1
  4. {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/aidot/camera/client.py +74 -12
  5. {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/aidot/camera/protocol.py +180 -0
  6. {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/aidot/client.py +9 -0
  7. {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28/src/python_aidot_cameras.egg-info}/PKG-INFO +2 -1
  8. {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/python_aidot_cameras.egg-info/SOURCES.txt +1 -0
  9. python_aidot_cameras-0.7.28/tests/test_persistent_mqtt.py +68 -0
  10. {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/LICENSE +0 -0
  11. {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/MANIFEST.in +0 -0
  12. {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/setup.cfg +0 -0
  13. {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/aidot/__init__.py +0 -0
  14. {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/aidot/aes_utils.py +0 -0
  15. {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/aidot/camera/__init__.py +0 -0
  16. {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/aidot/camera/constants.py +0 -0
  17. {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/aidot/camera/controls.py +0 -0
  18. {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/aidot/camera/go2rtc.py +0 -0
  19. {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/aidot/camera/lan_control.py +0 -0
  20. {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/aidot/camera/models.py +0 -0
  21. {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/aidot/camera/playback.py +0 -0
  22. {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/aidot/camera/sdes.py +0 -0
  23. {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/aidot/camera/tutk.py +0 -0
  24. {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/aidot/camera/webrtc.py +0 -0
  25. {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/aidot/const.py +0 -0
  26. {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/aidot/credentials.py +0 -0
  27. {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/aidot/device_client.py +0 -0
  28. {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/aidot/discover.py +0 -0
  29. {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/aidot/exceptions.py +0 -0
  30. {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/aidot/g711.py +0 -0
  31. {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/aidot/login_const.py +0 -0
  32. {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/aidot/models/__init__.py +0 -0
  33. {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/aidot/models/device_client_model.py +0 -0
  34. {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/aidot/models/device_model.py +0 -0
  35. {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/aidot/models/discover_model.py +0 -0
  36. {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/aidot/py.typed +0 -0
  37. {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  38. {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/python_aidot_cameras.egg-info/requires.txt +0 -0
  39. {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/src/python_aidot_cameras.egg-info/top_level.txt +0 -0
  40. {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/tests/test_alarm_event.py +0 -0
  41. {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/tests/test_backoff.py +0 -0
  42. {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/tests/test_go2rtc.py +0 -0
  43. {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/tests/test_highport_nomination.py +0 -0
  44. {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/tests/test_lan_control.py +0 -0
  45. {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/tests/test_live_stream_param.py +0 -0
  46. {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/tests/test_motion_poll.py +0 -0
  47. {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/tests/test_no_undefined_names.py +0 -0
  48. {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/tests/test_post_merge_hardening.py +0 -0
  49. {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/tests/test_sdes_adaptive.py +0 -0
  50. {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/tests/test_sdes_fast_liveplay.py +0 -0
  51. {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/tests/test_sdes_idle_release.py +0 -0
  52. {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/tests/test_sdes_sprop.py +0 -0
  53. {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/tests/test_sdes_talk.py +0 -0
  54. {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/tests/test_sdes_watchdog.py +0 -0
  55. {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/tests/test_serve_relay.py +0 -0
  56. {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/tests/test_speak.py +0 -0
  57. {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/tests/test_stream_cap.py +0 -0
  58. {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/tests/test_stream_idle.py +0 -0
  59. {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/tests/test_talk.py +0 -0
  60. {python_aidot_cameras-0.7.27 → python_aidot_cameras-0.7.28}/tests/test_terminal_ack.py +0 -0
  61. {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.27
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.27"
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
- messages = await _mqtt_session(
1245
- mqtt_url, mqtt_user, mqtt_pwd, client_id,
1246
- subscribe_topics=sub_topics,
1247
- publish_items=publish_items,
1248
- duration=timeout,
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
- messages = await _mqtt_session(
1477
- mqtt_url, mqtt_user, mqtt_pwd, client_id,
1478
- subscribe_topics=sub_topics,
1479
- publish_items=publish_items,
1480
- duration=timeout,
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.27
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/#"}