plexus-python 0.6.2__tar.gz → 0.6.3__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.
- {plexus_python-0.6.2 → plexus_python-0.6.3}/CHANGELOG.md +36 -0
- {plexus_python-0.6.2 → plexus_python-0.6.3}/PKG-INFO +1 -1
- plexus_python-0.6.3/TODO.md +1 -0
- plexus_python-0.6.3/plexus/__init__.py +14 -0
- {plexus_python-0.6.2 → plexus_python-0.6.3}/plexus/_log.py +1 -3
- {plexus_python-0.6.2 → plexus_python-0.6.3}/plexus/buffer.py +12 -0
- {plexus_python-0.6.2 → plexus_python-0.6.3}/plexus/cameras/thermal.py +27 -48
- {plexus_python-0.6.2 → plexus_python-0.6.3}/plexus/client.py +31 -54
- {plexus_python-0.6.2 → plexus_python-0.6.3}/plexus/config.py +0 -48
- {plexus_python-0.6.2 → plexus_python-0.6.3}/plexus/ws.py +1 -24
- {plexus_python-0.6.2 → plexus_python-0.6.3}/pyproject.toml +2 -2
- {plexus_python-0.6.2 → plexus_python-0.6.3}/tests/test_ws.py +1 -74
- {plexus_python-0.6.2 → plexus_python-0.6.3}/uv.lock +17 -19
- plexus_python-0.6.2/plexus/__init__.py +0 -14
- {plexus_python-0.6.2 → plexus_python-0.6.3}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {plexus_python-0.6.2 → plexus_python-0.6.3}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {plexus_python-0.6.2 → plexus_python-0.6.3}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {plexus_python-0.6.2 → plexus_python-0.6.3}/.github/workflows/ci.yml +0 -0
- {plexus_python-0.6.2 → plexus_python-0.6.3}/.github/workflows/publish.yml +0 -0
- {plexus_python-0.6.2 → plexus_python-0.6.3}/.gitignore +0 -0
- {plexus_python-0.6.2 → plexus_python-0.6.3}/AGENTS.md +0 -0
- {plexus_python-0.6.2 → plexus_python-0.6.3}/API.md +0 -0
- {plexus_python-0.6.2 → plexus_python-0.6.3}/CODE_OF_CONDUCT.md +0 -0
- {plexus_python-0.6.2 → plexus_python-0.6.3}/CONTRIBUTING.md +0 -0
- {plexus_python-0.6.2 → plexus_python-0.6.3}/LICENSE +0 -0
- {plexus_python-0.6.2 → plexus_python-0.6.3}/README.md +0 -0
- {plexus_python-0.6.2 → plexus_python-0.6.3}/SECURITY.md +0 -0
- {plexus_python-0.6.2 → plexus_python-0.6.3}/examples/.python-version +0 -0
- {plexus_python-0.6.2 → plexus_python-0.6.3}/examples/README.md +0 -0
- {plexus_python-0.6.2 → plexus_python-0.6.3}/examples/basic.py +0 -0
- {plexus_python-0.6.2 → plexus_python-0.6.3}/examples/can.py +0 -0
- {plexus_python-0.6.2 → plexus_python-0.6.3}/examples/i2c_bme280.py +0 -0
- {plexus_python-0.6.2 → plexus_python-0.6.3}/examples/mac_metrics.py +0 -0
- {plexus_python-0.6.2 → plexus_python-0.6.3}/examples/mavlink.py +0 -0
- {plexus_python-0.6.2 → plexus_python-0.6.3}/examples/mqtt.py +0 -0
- {plexus_python-0.6.2 → plexus_python-0.6.3}/examples/pyproject.toml +0 -0
- {plexus_python-0.6.2 → plexus_python-0.6.3}/examples/thermal_camera.py +0 -0
- {plexus_python-0.6.2 → plexus_python-0.6.3}/examples/uv.lock +0 -0
- {plexus_python-0.6.2 → plexus_python-0.6.3}/plexus/cameras/__init__.py +0 -0
- {plexus_python-0.6.2 → plexus_python-0.6.3}/plexus/cli.py +0 -0
- {plexus_python-0.6.2 → plexus_python-0.6.3}/scripts/plexus.service +0 -0
- {plexus_python-0.6.2 → plexus_python-0.6.3}/scripts/release.sh +0 -0
- {plexus_python-0.6.2 → plexus_python-0.6.3}/scripts/scan_buses.py +0 -0
- {plexus_python-0.6.2 → plexus_python-0.6.3}/scripts/setup.sh +0 -0
- {plexus_python-0.6.2 → plexus_python-0.6.3}/skills/plexus/SKILL.md +0 -0
- {plexus_python-0.6.2 → plexus_python-0.6.3}/skills/plexus/references/api.md +0 -0
- {plexus_python-0.6.2 → plexus_python-0.6.3}/skills/plexus/references/sdk.md +0 -0
- {plexus_python-0.6.2 → plexus_python-0.6.3}/tests/test_basic.py +0 -0
- {plexus_python-0.6.2 → plexus_python-0.6.3}/tests/test_buffer.py +0 -0
- {plexus_python-0.6.2 → plexus_python-0.6.3}/tests/test_config.py +0 -0
- {plexus_python-0.6.2 → plexus_python-0.6.3}/tests/test_retry.py +0 -0
- {plexus_python-0.6.2 → plexus_python-0.6.3}/tests/test_thermal.py +0 -0
- {plexus_python-0.6.2 → plexus_python-0.6.3}/tests/test_video.py +0 -0
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.6.3] - 2026-06-02 - Remove install_id / source_id auto-suffix
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- Removed `install_id` from the device auth frame and `WebSocketTransport`.
|
|
8
|
+
- Removed server-side source_id auto-suffix handling (`on_source_id_assigned` callback, `set_source_id` persistence).
|
|
9
|
+
- Removed `get_install_id` and `set_source_id` from `plexus.config`.
|
|
10
|
+
|
|
11
|
+
## [0.7.0] - 2026-05-29 - SDK hardening
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- Buffer overflow now surfaces on stderr (`⚠ buffer full, dropped N oldest points`) instead
|
|
16
|
+
of going silently to `logging.warning`. Both `MemoryBuffer` and `SqliteBuffer` are covered.
|
|
17
|
+
- `on_command()` late-registration warning now goes through `logging.warning()` so it is not
|
|
18
|
+
suppressed by `PLEXUS_QUIET=1`.
|
|
19
|
+
- `PLEXUS_QUIET` env var is now read at call time rather than import time — setting it after
|
|
20
|
+
module load now takes effect.
|
|
21
|
+
- `max_buffer_size` setter no longer directly mutates `_buffer._max_size`; routes through a
|
|
22
|
+
new `resize()` method on `BufferBackend`.
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- `PlexusError`, `AuthenticationError`, and `RetryConfig` are now exported from the top-level
|
|
27
|
+
`plexus` package. `from plexus import PlexusError` now works as expected.
|
|
28
|
+
- `WebSocketTransport` and `read_mjpeg_frames` removed from `__all__` — both remain importable
|
|
29
|
+
via their source modules but are no longer part of the top-level public API.
|
|
30
|
+
|
|
31
|
+
### Breaking
|
|
32
|
+
|
|
33
|
+
- `Plexus.__init__` no longer accepts a `transport` parameter. WebSocket with automatic HTTP
|
|
34
|
+
fallback is the only send path.
|
|
35
|
+
- `ThermalSource.open()` now requires an explicit hint (`"sim"`, `"mlx90640"`, `"mlx90641"`,
|
|
36
|
+
`"usb"`, or a device index). Auto-detection has been removed — it could not reliably
|
|
37
|
+
distinguish USB thermal cameras from regular webcams.
|
|
38
|
+
|
|
3
39
|
## [0.6.2] - 2026-05-28 - Dependency fix
|
|
4
40
|
|
|
5
41
|
### Fixed
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# TODO
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Plexus — thin Python SDK for sending telemetry to the Plexus gateway.
|
|
3
|
+
|
|
4
|
+
from plexus import Plexus
|
|
5
|
+
|
|
6
|
+
px = Plexus(api_key="plx_xxx", source_id="device-001")
|
|
7
|
+
px.send("temperature", 72.5)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from plexus.client import Plexus, PlexusError, AuthenticationError, read_mjpeg_frames
|
|
11
|
+
from plexus.config import RetryConfig
|
|
12
|
+
|
|
13
|
+
__version__ = "0.6.3"
|
|
14
|
+
__all__ = ["Plexus", "PlexusError", "AuthenticationError", "RetryConfig", "read_mjpeg_frames"]
|
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
import os
|
|
2
2
|
import sys
|
|
3
3
|
|
|
4
|
-
_QUIET = os.environ.get("PLEXUS_QUIET", "").lower() in ("1", "true", "yes")
|
|
5
|
-
|
|
6
4
|
|
|
7
5
|
def _say(line: str) -> None:
|
|
8
6
|
"""Single-line status message to stderr. Skipped if PLEXUS_QUIET=1."""
|
|
9
|
-
if
|
|
7
|
+
if os.environ.get("PLEXUS_QUIET", "").lower() in ("1", "true", "yes"):
|
|
10
8
|
return
|
|
11
9
|
try:
|
|
12
10
|
sys.stderr.write(f"[plexus] {line}\n")
|
|
@@ -41,6 +41,10 @@ class BufferBackend(ABC):
|
|
|
41
41
|
def size(self) -> int:
|
|
42
42
|
"""Return current number of buffered points."""
|
|
43
43
|
|
|
44
|
+
@abstractmethod
|
|
45
|
+
def resize(self, max_size: int) -> None:
|
|
46
|
+
"""Update the maximum buffer capacity."""
|
|
47
|
+
|
|
44
48
|
def drain(self, batch_size: int = 5000) -> Tuple[List[Dict[str, Any]], int]:
|
|
45
49
|
"""Remove and return the oldest batch_size points atomically.
|
|
46
50
|
|
|
@@ -97,6 +101,10 @@ class MemoryBuffer(BufferBackend):
|
|
|
97
101
|
with self._lock:
|
|
98
102
|
return len(self._buffer)
|
|
99
103
|
|
|
104
|
+
def resize(self, max_size: int) -> None:
|
|
105
|
+
with self._lock:
|
|
106
|
+
self._max_size = max_size
|
|
107
|
+
|
|
100
108
|
def drain(self, batch_size: int = 5000) -> Tuple[List[Dict[str, Any]], int]:
|
|
101
109
|
with self._lock:
|
|
102
110
|
batch = self._buffer[:batch_size]
|
|
@@ -185,6 +193,10 @@ class SqliteBuffer(BufferBackend):
|
|
|
185
193
|
cursor = self._conn.execute("SELECT COUNT(*) FROM points")
|
|
186
194
|
return cursor.fetchone()[0]
|
|
187
195
|
|
|
196
|
+
def resize(self, max_size: int) -> None:
|
|
197
|
+
with self._lock:
|
|
198
|
+
self._max_size = max_size
|
|
199
|
+
|
|
188
200
|
def drain(self, batch_size: int = 5000) -> Tuple[List[Dict[str, Any]], int]:
|
|
189
201
|
"""Remove and return the oldest batch_size points atomically.
|
|
190
202
|
|
|
@@ -8,8 +8,9 @@ ThermalFrame containing a colorized JPEG image plus temperature metadata.
|
|
|
8
8
|
Usage:
|
|
9
9
|
from plexus.cameras.thermal import ThermalSource
|
|
10
10
|
|
|
11
|
-
cam = ThermalSource.open() # auto-detect
|
|
12
11
|
cam = ThermalSource.open("sim") # simulated, no hardware
|
|
12
|
+
cam = ThermalSource.open("mlx90640") # I2C MLX90640
|
|
13
|
+
cam = ThermalSource.open("usb") # USB thermal at index 0
|
|
13
14
|
|
|
14
15
|
while True:
|
|
15
16
|
px.send_thermal_frame(cam.read_frame(), camera_id="thermal")
|
|
@@ -325,19 +326,33 @@ class MLX90641Camera(ThermalCamera):
|
|
|
325
326
|
|
|
326
327
|
|
|
327
328
|
class ThermalSource:
|
|
328
|
-
"""Factory
|
|
329
|
+
"""Factory for opening a thermal camera by type.
|
|
330
|
+
|
|
331
|
+
A camera type must be specified explicitly — auto-detection is not supported
|
|
332
|
+
because USB thermal cameras and regular webcams are indistinguishable at the
|
|
333
|
+
OS level without knowing what you're looking for.
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
hint: Which camera to open. One of:
|
|
337
|
+
"sim" — simulated 32×24 camera, no hardware required
|
|
338
|
+
"mlx90640" — MLX90640 32×24 I2C sensor (Raspberry Pi, 3.3V)
|
|
339
|
+
"mlx90641" — MLX90641 16×12 I2C sensor (Raspberry Pi, 3.3V)
|
|
340
|
+
"usb" — USB thermal camera at device index 0
|
|
341
|
+
<int> — USB thermal camera at a specific device index
|
|
342
|
+
|
|
343
|
+
Raises:
|
|
344
|
+
ValueError: If hint is None or unrecognised.
|
|
345
|
+
NoCameraFound: If the specified device cannot be opened.
|
|
329
346
|
|
|
330
347
|
Examples:
|
|
331
|
-
ThermalSource.open()
|
|
332
|
-
ThermalSource.open("
|
|
333
|
-
ThermalSource.open("
|
|
334
|
-
ThermalSource.open(
|
|
335
|
-
ThermalSource.open("usb") # USB thermal at index 0
|
|
336
|
-
ThermalSource.open(2) # USB thermal at index 2
|
|
348
|
+
cam = ThermalSource.open("sim") # no hardware
|
|
349
|
+
cam = ThermalSource.open("mlx90640") # I2C MLX90640
|
|
350
|
+
cam = ThermalSource.open("usb") # USB at index 0
|
|
351
|
+
cam = ThermalSource.open(2) # USB at index 2
|
|
337
352
|
"""
|
|
338
353
|
|
|
339
354
|
@staticmethod
|
|
340
|
-
def open(hint: str | int
|
|
355
|
+
def open(hint: str | int) -> ThermalCamera:
|
|
341
356
|
if hint == "sim":
|
|
342
357
|
return SimulatedThermalCamera()
|
|
343
358
|
if hint == "mlx90640":
|
|
@@ -346,43 +361,7 @@ class ThermalSource:
|
|
|
346
361
|
return MLX90641Camera()
|
|
347
362
|
if hint == "usb" or isinstance(hint, int):
|
|
348
363
|
return USBThermalCamera(0 if hint == "usb" else hint)
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
def _autodetect() -> ThermalCamera:
|
|
353
|
-
cam = ThermalSource._try_i2c()
|
|
354
|
-
if cam:
|
|
355
|
-
return cam
|
|
356
|
-
cam = ThermalSource._try_usb()
|
|
357
|
-
if cam:
|
|
358
|
-
return cam
|
|
359
|
-
raise NoCameraFound(
|
|
360
|
-
"No thermal camera detected. Use ThermalSource.open('sim') to run without hardware."
|
|
364
|
+
raise ValueError(
|
|
365
|
+
f"Unknown camera hint {hint!r}. "
|
|
366
|
+
"Valid options: 'sim', 'mlx90640', 'mlx90641', 'usb', or a device index (int)."
|
|
361
367
|
)
|
|
362
|
-
|
|
363
|
-
@staticmethod
|
|
364
|
-
def _try_i2c() -> ThermalCamera | None:
|
|
365
|
-
try:
|
|
366
|
-
import board
|
|
367
|
-
import busio
|
|
368
|
-
|
|
369
|
-
i2c = busio.I2C(board.SCL, board.SDA)
|
|
370
|
-
i2c.try_lock()
|
|
371
|
-
addrs = i2c.scan()
|
|
372
|
-
i2c.unlock()
|
|
373
|
-
if 0x33 in addrs:
|
|
374
|
-
return MLX90640Camera()
|
|
375
|
-
if 0x60 in addrs:
|
|
376
|
-
return MLX90641Camera()
|
|
377
|
-
except Exception:
|
|
378
|
-
pass
|
|
379
|
-
return None
|
|
380
|
-
|
|
381
|
-
@staticmethod
|
|
382
|
-
def _try_usb() -> ThermalCamera | None:
|
|
383
|
-
for idx in range(10):
|
|
384
|
-
try:
|
|
385
|
-
return USBThermalCamera(idx)
|
|
386
|
-
except NoCameraFound:
|
|
387
|
-
continue
|
|
388
|
-
return None
|
|
@@ -55,9 +55,7 @@ from plexus.config import (
|
|
|
55
55
|
get_endpoint,
|
|
56
56
|
get_gateway_url,
|
|
57
57
|
get_gateway_ws_url,
|
|
58
|
-
get_install_id,
|
|
59
58
|
get_source_id,
|
|
60
|
-
set_source_id,
|
|
61
59
|
)
|
|
62
60
|
|
|
63
61
|
logger = logging.getLogger(__name__)
|
|
@@ -187,7 +185,6 @@ class Plexus:
|
|
|
187
185
|
max_buffer_size: int = 10000,
|
|
188
186
|
persistent_buffer: bool = True,
|
|
189
187
|
buffer_path: Optional[str] = None,
|
|
190
|
-
transport: str = "ws",
|
|
191
188
|
ws_url: Optional[str] = None,
|
|
192
189
|
):
|
|
193
190
|
self.api_key = api_key or get_api_key()
|
|
@@ -212,9 +209,6 @@ class Plexus:
|
|
|
212
209
|
self._pil_image = None # lazy PIL.Image import
|
|
213
210
|
self._fit_warned: bool = False
|
|
214
211
|
|
|
215
|
-
if transport not in ("ws", "http"):
|
|
216
|
-
raise ValueError(f"transport must be 'ws' or 'http', got {transport!r}")
|
|
217
|
-
self.transport = transport
|
|
218
212
|
self._ws_url = (ws_url or get_gateway_ws_url())
|
|
219
213
|
self._ws = None # lazily constructed in _ensure_ws()
|
|
220
214
|
self._clock_offset_ms: int = 0
|
|
@@ -222,10 +216,14 @@ class Plexus:
|
|
|
222
216
|
# Pluggable buffer backend for failed sends
|
|
223
217
|
if persistent_buffer:
|
|
224
218
|
self._buffer: BufferBackend = SqliteBuffer(
|
|
225
|
-
path=buffer_path, max_size=max_buffer_size
|
|
219
|
+
path=buffer_path, max_size=max_buffer_size,
|
|
220
|
+
on_overflow=self._on_buffer_overflow,
|
|
226
221
|
)
|
|
227
222
|
else:
|
|
228
|
-
self._buffer: BufferBackend = MemoryBuffer(
|
|
223
|
+
self._buffer: BufferBackend = MemoryBuffer(
|
|
224
|
+
max_size=max_buffer_size,
|
|
225
|
+
on_overflow=self._on_buffer_overflow,
|
|
226
|
+
)
|
|
229
227
|
|
|
230
228
|
# State that drives the [plexus] stderr status line.
|
|
231
229
|
self._announced_first_send = False
|
|
@@ -240,7 +238,7 @@ class Plexus:
|
|
|
240
238
|
@max_buffer_size.setter
|
|
241
239
|
def max_buffer_size(self, value):
|
|
242
240
|
self._max_buffer_size = value
|
|
243
|
-
self._buffer.
|
|
241
|
+
self._buffer.resize(value)
|
|
244
242
|
|
|
245
243
|
def _get_session(self) -> _Session:
|
|
246
244
|
if self._session is None:
|
|
@@ -414,27 +412,18 @@ class Plexus:
|
|
|
414
412
|
api_key=self.api_key,
|
|
415
413
|
source_id=self.source_id,
|
|
416
414
|
ws_url=self._ws_url,
|
|
417
|
-
install_id=get_install_id(),
|
|
418
415
|
agent_version=__version__,
|
|
419
|
-
on_source_id_assigned=self._on_source_id_assigned,
|
|
420
416
|
on_clock_synced=self._on_clock_synced,
|
|
421
417
|
)
|
|
422
418
|
self._ws.start()
|
|
423
419
|
return self._ws
|
|
424
420
|
|
|
421
|
+
def _on_buffer_overflow(self, dropped: int) -> None:
|
|
422
|
+
_say(f"⚠ buffer full, dropped {dropped} oldest points (gateway unreachable?)")
|
|
423
|
+
|
|
425
424
|
def _on_clock_synced(self, offset_ms: int) -> None:
|
|
426
425
|
self._clock_offset_ms = offset_ms
|
|
427
426
|
|
|
428
|
-
def _on_source_id_assigned(self, assigned: str) -> None:
|
|
429
|
-
"""Callback from WebSocketTransport when the gateway returns an
|
|
430
|
-
auto-suffixed source_id. Persists it so subsequent runs (and the HTTP
|
|
431
|
-
fallback path in this process) use the assigned name directly."""
|
|
432
|
-
self.source_id = assigned
|
|
433
|
-
try:
|
|
434
|
-
set_source_id(assigned)
|
|
435
|
-
except Exception as e: # pragma: no cover - persistence failure is non-fatal
|
|
436
|
-
logger.debug("failed to persist assigned source_id: %s", e)
|
|
437
|
-
|
|
438
427
|
def _encode_frame(self, frame, quality: int) -> Tuple[bytes, int, int]:
|
|
439
428
|
"""Normalize any supported frame type to (jpeg_bytes, width, height).
|
|
440
429
|
|
|
@@ -575,9 +564,6 @@ class Plexus:
|
|
|
575
564
|
ValueError: If frame type is not supported.
|
|
576
565
|
ImportError: If a required optional dependency is missing.
|
|
577
566
|
"""
|
|
578
|
-
if self.transport != "ws":
|
|
579
|
-
raise PlexusError("send_video_frame requires transport='ws'")
|
|
580
|
-
|
|
581
567
|
jpeg_bytes, width, height = self._encode_frame(frame, quality)
|
|
582
568
|
jpeg_bytes = self._fit_to_wire(jpeg_bytes, quality)
|
|
583
569
|
|
|
@@ -613,9 +599,6 @@ class Plexus:
|
|
|
613
599
|
PlexusError: If transport is not 'ws'.
|
|
614
600
|
ImportError: If opencv-python-headless is not installed.
|
|
615
601
|
"""
|
|
616
|
-
if self.transport != "ws":
|
|
617
|
-
raise PlexusError("send_thermal_frame requires transport='ws'")
|
|
618
|
-
|
|
619
602
|
try:
|
|
620
603
|
from plexus.cameras.thermal import build_thermal_frame
|
|
621
604
|
except ImportError as e:
|
|
@@ -663,8 +646,6 @@ class Plexus:
|
|
|
663
646
|
time.sleep(60)
|
|
664
647
|
stop.set()
|
|
665
648
|
"""
|
|
666
|
-
if self.transport != "ws":
|
|
667
|
-
raise PlexusError("stream_camera requires transport='ws'")
|
|
668
649
|
if shutil.which("ffmpeg") is None:
|
|
669
650
|
raise PlexusError(
|
|
670
651
|
"FFmpeg not found. Install it: https://ffmpeg.org/download.html"
|
|
@@ -715,24 +696,21 @@ class Plexus:
|
|
|
715
696
|
Must be called before the first send() so the command is advertised
|
|
716
697
|
in the auth frame.
|
|
717
698
|
"""
|
|
718
|
-
if self.transport != "ws":
|
|
719
|
-
raise PlexusError("on_command requires transport='ws'")
|
|
720
699
|
ws = self._ensure_ws()
|
|
721
700
|
if ws.is_authenticated:
|
|
722
|
-
|
|
723
|
-
|
|
701
|
+
logger.warning(
|
|
702
|
+
"on_command('%s') called after connection is already authenticated — "
|
|
724
703
|
"command will not be advertised to the dashboard until next reconnect. "
|
|
725
|
-
"Call on_command() before the first send()."
|
|
704
|
+
"Call on_command() before the first send().",
|
|
705
|
+
name,
|
|
726
706
|
)
|
|
727
707
|
ws.register_command(name, handler, description=description, params=params)
|
|
728
708
|
|
|
729
709
|
def _send_points(self, points: List[Dict[str, Any]]) -> bool:
|
|
730
710
|
"""Send data points to the gateway with retry and buffering.
|
|
731
711
|
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
the socket fails, fall through to the HTTP path so points still land.
|
|
735
|
-
- transport='http': always POST /ingest with retries.
|
|
712
|
+
Tries WebSocket first; if not yet authenticated or the socket fails,
|
|
713
|
+
falls through to HTTP POST so points still land.
|
|
736
714
|
|
|
737
715
|
Retry behavior (HTTP path):
|
|
738
716
|
- Retries on: Timeout, ConnectionError, HTTP 429, HTTP 5xx
|
|
@@ -748,22 +726,21 @@ class Plexus:
|
|
|
748
726
|
all_points = self._get_buffered_points() + points
|
|
749
727
|
|
|
750
728
|
# Preferred path: WebSocket.
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
self._announced_http_fallback = True
|
|
729
|
+
ws = self._ensure_ws()
|
|
730
|
+
# Brief wait on first call so startup races don't dump every point
|
|
731
|
+
# into the HTTP fallback path.
|
|
732
|
+
if not ws.is_authenticated:
|
|
733
|
+
ws.wait_authenticated(timeout=min(self.timeout, 5.0))
|
|
734
|
+
if ws.send_points(all_points):
|
|
735
|
+
self._clear_buffer()
|
|
736
|
+
self._note_send(len(all_points), via="ws")
|
|
737
|
+
return True
|
|
738
|
+
# Socket unavailable → fall through to HTTP.
|
|
739
|
+
if not self._announced_http_fallback:
|
|
740
|
+
_say(
|
|
741
|
+
f"⚠ WebSocket unavailable, falling back to POST {self.gateway_url}/ingest"
|
|
742
|
+
)
|
|
743
|
+
self._announced_http_fallback = True
|
|
767
744
|
|
|
768
745
|
url = f"{self.gateway_url}/ingest"
|
|
769
746
|
last_error: Optional[Exception] = None
|
|
@@ -145,54 +145,6 @@ def get_source_id() -> Optional[str]:
|
|
|
145
145
|
return source_id
|
|
146
146
|
|
|
147
147
|
|
|
148
|
-
def get_install_id() -> str:
|
|
149
|
-
"""Get the device install ID, generating one if not set.
|
|
150
|
-
|
|
151
|
-
The install_id is a stable per-installation UUID. It is generated lazily
|
|
152
|
-
on first run (NOT at image-build time) so that cloned SD-card images
|
|
153
|
-
naturally get distinct install_ids on their first boot. The gateway uses
|
|
154
|
-
it to tell "same device reconnecting" from "different device claiming the
|
|
155
|
-
same name" when resolving source_id collisions.
|
|
156
|
-
|
|
157
|
-
Resolution order:
|
|
158
|
-
1. ``PLEXUS_INSTALL_ID`` env var — lets ephemeral containers (Fly
|
|
159
|
-
machines, CI runners, Kubernetes pods) pin a stable identity
|
|
160
|
-
across restarts when the config filesystem is ephemeral. Without
|
|
161
|
-
this, every redeploy generates a new install_id and the gateway
|
|
162
|
-
auto-suffixes the source_id to avoid a collision with the prior
|
|
163
|
-
install ("gw-001" → "gw-001_2" → "gw-001_3"…).
|
|
164
|
-
2. ``install_id`` in the on-disk config.
|
|
165
|
-
3. Newly-generated UUID, persisted to config.
|
|
166
|
-
"""
|
|
167
|
-
env_id = os.environ.get("PLEXUS_INSTALL_ID", "").strip()
|
|
168
|
-
if env_id:
|
|
169
|
-
return env_id
|
|
170
|
-
|
|
171
|
-
config = load_config()
|
|
172
|
-
install_id = config.get("install_id")
|
|
173
|
-
|
|
174
|
-
if not install_id:
|
|
175
|
-
import uuid
|
|
176
|
-
install_id = uuid.uuid4().hex
|
|
177
|
-
config["install_id"] = install_id
|
|
178
|
-
save_config(config)
|
|
179
|
-
|
|
180
|
-
return install_id
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
def set_source_id(source_id: str) -> None:
|
|
184
|
-
"""Persist an updated source_id to the config file.
|
|
185
|
-
|
|
186
|
-
Called by the SDK when the gateway returns an auto-suffixed name so the
|
|
187
|
-
assigned name is stable across reconnects.
|
|
188
|
-
"""
|
|
189
|
-
config = load_config()
|
|
190
|
-
if config.get("source_id") == source_id:
|
|
191
|
-
return
|
|
192
|
-
config["source_id"] = source_id
|
|
193
|
-
save_config(config)
|
|
194
|
-
|
|
195
|
-
|
|
196
148
|
def get_persistent_buffer() -> bool:
|
|
197
149
|
"""Get persistent buffer setting. Default True (store-and-forward enabled)."""
|
|
198
150
|
config = load_config()
|
|
@@ -89,11 +89,9 @@ class WebSocketTransport:
|
|
|
89
89
|
source_id: str,
|
|
90
90
|
ws_url: str,
|
|
91
91
|
*,
|
|
92
|
-
install_id: str = "",
|
|
93
92
|
agent_version: str = "0.0.0",
|
|
94
93
|
platform: str = "python-sdk",
|
|
95
94
|
auto_reconnect: bool = True,
|
|
96
|
-
on_source_id_assigned: Optional[Callable[[str], None]] = None,
|
|
97
95
|
on_clock_synced: Optional[Callable[[int], None]] = None,
|
|
98
96
|
):
|
|
99
97
|
if not api_key:
|
|
@@ -103,12 +101,10 @@ class WebSocketTransport:
|
|
|
103
101
|
|
|
104
102
|
self.api_key = api_key
|
|
105
103
|
self.source_id = source_id
|
|
106
|
-
self.install_id = install_id
|
|
107
104
|
self.ws_url = _ensure_device_path(ws_url)
|
|
108
105
|
self.agent_version = agent_version
|
|
109
106
|
self.platform = platform
|
|
110
107
|
self.auto_reconnect = auto_reconnect
|
|
111
|
-
self._on_source_id_assigned = on_source_id_assigned
|
|
112
108
|
self._on_clock_synced = on_clock_synced
|
|
113
109
|
|
|
114
110
|
self._commands: Dict[str, _RegisteredCommand] = {}
|
|
@@ -278,16 +274,13 @@ class WebSocketTransport:
|
|
|
278
274
|
self._ws = ws
|
|
279
275
|
|
|
280
276
|
# 1. Send device_auth
|
|
281
|
-
desired_source_id = self.source_id
|
|
282
277
|
auth = {
|
|
283
278
|
"type": "device_auth",
|
|
284
279
|
"api_key": self.api_key,
|
|
285
|
-
"source_id":
|
|
280
|
+
"source_id": self.source_id,
|
|
286
281
|
"platform": self.platform,
|
|
287
282
|
"agent_version": self.agent_version,
|
|
288
283
|
}
|
|
289
|
-
if self.install_id:
|
|
290
|
-
auth["install_id"] = self.install_id
|
|
291
284
|
if self._commands:
|
|
292
285
|
auth["commands"] = [c.to_manifest() for c in self._commands.values()]
|
|
293
286
|
ws.send(json.dumps(auth))
|
|
@@ -312,22 +305,6 @@ class WebSocketTransport:
|
|
|
312
305
|
except Exception as e:
|
|
313
306
|
logger.debug("on_clock_synced callback raised: %s", e)
|
|
314
307
|
|
|
315
|
-
# The gateway may return a different source_id if the desired name
|
|
316
|
-
# was already claimed by another install — adopt the assigned value
|
|
317
|
-
# so all subsequent frames (heartbeats, future reconnects) use it.
|
|
318
|
-
assigned = msg.get("source_id")
|
|
319
|
-
if isinstance(assigned, str) and assigned and assigned != self.source_id:
|
|
320
|
-
logger.info(
|
|
321
|
-
"plexus ws source_id auto-suffixed: requested=%s assigned=%s",
|
|
322
|
-
desired_source_id, assigned,
|
|
323
|
-
)
|
|
324
|
-
self.source_id = assigned
|
|
325
|
-
if self._on_source_id_assigned is not None:
|
|
326
|
-
try:
|
|
327
|
-
self._on_source_id_assigned(assigned)
|
|
328
|
-
except Exception as e: # pragma: no cover - callback errors must not break auth
|
|
329
|
-
logger.debug("on_source_id_assigned callback raised: %s", e)
|
|
330
|
-
|
|
331
308
|
was_reconnect = self._backoff_attempt > 0
|
|
332
309
|
self._authenticated.set()
|
|
333
310
|
self._backoff_attempt = 0
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "plexus-python"
|
|
7
|
-
version = "0.6.
|
|
7
|
+
version = "0.6.3"
|
|
8
8
|
description = "Thin Python SDK for Plexus — send telemetry in one line"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "Apache-2.0"
|
|
@@ -51,4 +51,4 @@ packages = ["plexus"]
|
|
|
51
51
|
|
|
52
52
|
[tool.ruff]
|
|
53
53
|
line-length = 100
|
|
54
|
-
target-version = "
|
|
54
|
+
target-version = "py310"
|
|
@@ -126,7 +126,6 @@ def test_auth_handshake_and_telemetry(gateway):
|
|
|
126
126
|
api_key="plx_test_abc",
|
|
127
127
|
source_id="drone-001",
|
|
128
128
|
ws_url=_url(gateway.port),
|
|
129
|
-
install_id="install-A",
|
|
130
129
|
agent_version="9.9.9",
|
|
131
130
|
)
|
|
132
131
|
t.start()
|
|
@@ -137,7 +136,7 @@ def test_auth_handshake_and_telemetry(gateway):
|
|
|
137
136
|
assert gateway.auth_frame["type"] == "device_auth"
|
|
138
137
|
assert gateway.auth_frame["api_key"] == "plx_test_abc"
|
|
139
138
|
assert gateway.auth_frame["source_id"] == "drone-001"
|
|
140
|
-
assert
|
|
139
|
+
assert "install_id" not in gateway.auth_frame
|
|
141
140
|
assert gateway.auth_frame["platform"] == "python-sdk"
|
|
142
141
|
assert gateway.auth_frame["agent_version"] == "9.9.9"
|
|
143
142
|
# commands is omitted when none registered
|
|
@@ -256,78 +255,6 @@ def test_handler_exception_returns_error(gateway):
|
|
|
256
255
|
t.stop()
|
|
257
256
|
|
|
258
257
|
|
|
259
|
-
def test_install_id_omitted_when_empty():
|
|
260
|
-
# Default install_id="" should not leak an empty install_id field into
|
|
261
|
-
# the auth frame — that keeps the wire shape identical for legacy SDK
|
|
262
|
-
# builds that don't set one.
|
|
263
|
-
g = _StubGateway()
|
|
264
|
-
g.start()
|
|
265
|
-
try:
|
|
266
|
-
t = WebSocketTransport(
|
|
267
|
-
api_key="plx_test_abc",
|
|
268
|
-
source_id="drone-001",
|
|
269
|
-
ws_url=_url(g.port),
|
|
270
|
-
)
|
|
271
|
-
t.start()
|
|
272
|
-
try:
|
|
273
|
-
assert t.wait_authenticated(timeout=3)
|
|
274
|
-
assert "install_id" not in g.auth_frame
|
|
275
|
-
finally:
|
|
276
|
-
t.stop()
|
|
277
|
-
finally:
|
|
278
|
-
g.stop()
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
def test_server_assigned_source_id_is_adopted():
|
|
282
|
-
# Simulate the auto-suffix path: SDK asks for "drone-01", the gateway
|
|
283
|
-
# returns "drone-01_2" in the authenticated frame. The transport must
|
|
284
|
-
# adopt the assigned name and fire the on_source_id_assigned callback.
|
|
285
|
-
g = _StubGateway(assigned_source_id="drone-01_2")
|
|
286
|
-
g.start()
|
|
287
|
-
try:
|
|
288
|
-
seen: List[str] = []
|
|
289
|
-
t = WebSocketTransport(
|
|
290
|
-
api_key="plx_test_abc",
|
|
291
|
-
source_id="drone-01",
|
|
292
|
-
ws_url=_url(g.port),
|
|
293
|
-
install_id="install-B",
|
|
294
|
-
on_source_id_assigned=lambda s: seen.append(s),
|
|
295
|
-
)
|
|
296
|
-
t.start()
|
|
297
|
-
try:
|
|
298
|
-
assert t.wait_authenticated(timeout=3)
|
|
299
|
-
assert t.source_id == "drone-01_2"
|
|
300
|
-
assert seen == ["drone-01_2"]
|
|
301
|
-
finally:
|
|
302
|
-
t.stop()
|
|
303
|
-
finally:
|
|
304
|
-
g.stop()
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
def test_same_assigned_source_id_does_not_fire_callback():
|
|
308
|
-
# Happy path — gateway returns the same name. No callback, source_id unchanged.
|
|
309
|
-
g = _StubGateway() # echoes whatever was sent
|
|
310
|
-
g.start()
|
|
311
|
-
try:
|
|
312
|
-
seen: List[str] = []
|
|
313
|
-
t = WebSocketTransport(
|
|
314
|
-
api_key="plx_test_abc",
|
|
315
|
-
source_id="drone-01",
|
|
316
|
-
ws_url=_url(g.port),
|
|
317
|
-
install_id="install-A",
|
|
318
|
-
on_source_id_assigned=lambda s: seen.append(s),
|
|
319
|
-
)
|
|
320
|
-
t.start()
|
|
321
|
-
try:
|
|
322
|
-
assert t.wait_authenticated(timeout=3)
|
|
323
|
-
assert t.source_id == "drone-01"
|
|
324
|
-
assert seen == []
|
|
325
|
-
finally:
|
|
326
|
-
t.stop()
|
|
327
|
-
finally:
|
|
328
|
-
g.stop()
|
|
329
|
-
|
|
330
|
-
|
|
331
258
|
def test_ensure_device_path():
|
|
332
259
|
from plexus.ws import _ensure_device_path
|
|
333
260
|
assert _ensure_device_path("wss://foo") == "wss://foo/ws/device"
|
|
@@ -302,7 +302,7 @@ wheels = [
|
|
|
302
302
|
]
|
|
303
303
|
|
|
304
304
|
[[package]]
|
|
305
|
-
name = "opencv-python"
|
|
305
|
+
name = "opencv-python-headless"
|
|
306
306
|
version = "4.13.0.92"
|
|
307
307
|
source = { registry = "https://pypi.org/simple" }
|
|
308
308
|
dependencies = [
|
|
@@ -310,14 +310,14 @@ dependencies = [
|
|
|
310
310
|
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
|
311
311
|
]
|
|
312
312
|
wheels = [
|
|
313
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
314
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
315
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
316
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
317
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
318
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
319
|
-
{ url = "https://files.pythonhosted.org/packages/fb/
|
|
320
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
313
|
+
{ url = "https://files.pythonhosted.org/packages/79/42/2310883be3b8826ac58c3f2787b9358a2d46923d61f88fedf930bc59c60c/opencv_python_headless-4.13.0.92-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:1a7d040ac656c11b8c38677cc8cccdc149f98535089dbe5b081e80a4e5903209", size = 46247192, upload-time = "2026-02-05T07:01:35.187Z" },
|
|
314
|
+
{ url = "https://files.pythonhosted.org/packages/2d/1e/6f9e38005a6f7f22af785df42a43139d0e20f169eb5787ce8be37ee7fcc9/opencv_python_headless-4.13.0.92-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:3e0a6f0a37994ec6ce5f59e936be21d5d6384a4556f2d2da9c2f9c5dc948394c", size = 32568914, upload-time = "2026-02-05T07:01:51.989Z" },
|
|
315
|
+
{ url = "https://files.pythonhosted.org/packages/21/76/9417a6aef9def70e467a5bf560579f816148a4c658b7d525581b356eda9e/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c8cfc8e87ed452b5cecb9419473ee5560a989859fe1d10d1ce11ae87b09a2cb", size = 33703709, upload-time = "2026-02-05T10:24:46.469Z" },
|
|
316
|
+
{ url = "https://files.pythonhosted.org/packages/92/ce/bd17ff5772938267fd49716e94ca24f616ff4cb1ff4c6be13085108037be/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0525a3d2c0b46c611e2130b5fdebc94cf404845d8fa64d2f3a3b679572a5bd22", size = 56016764, upload-time = "2026-02-05T10:26:48.904Z" },
|
|
317
|
+
{ url = "https://files.pythonhosted.org/packages/8f/b4/b7bcbf7c874665825a8c8e1097e93ea25d1f1d210a3e20d4451d01da30aa/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb60e36b237b1ebd40a912da5384b348df8ed534f6f644d8e0b4f103e272ba7d", size = 35010236, upload-time = "2026-02-05T10:28:11.031Z" },
|
|
318
|
+
{ url = "https://files.pythonhosted.org/packages/4b/33/b5db29a6c00eb8f50708110d8d453747ca125c8b805bc437b289dbdcc057/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0bd48544f77c68b2941392fcdf9bcd2b9cdf00e98cb8c29b2455d194763cf99e", size = 60391106, upload-time = "2026-02-05T10:30:14.236Z" },
|
|
319
|
+
{ url = "https://files.pythonhosted.org/packages/fb/c3/52cfea47cd33e53e8c0fbd6e7c800b457245c1fda7d61660b4ffe9596a7f/opencv_python_headless-4.13.0.92-cp37-abi3-win32.whl", hash = "sha256:a7cf08e5b191f4ebb530791acc0825a7986e0d0dee2a3c491184bd8599848a4b", size = 30812232, upload-time = "2026-02-05T07:02:29.594Z" },
|
|
320
|
+
{ url = "https://files.pythonhosted.org/packages/4a/90/b338326131ccb2aaa3c2c85d00f41822c0050139a4bfe723cfd95455bd2d/opencv_python_headless-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:77a82fe35ddcec0f62c15f2ba8a12ecc2ed4207c17b0902c7a3151ae29f37fb6", size = 40070414, upload-time = "2026-02-05T07:02:26.448Z" },
|
|
321
321
|
]
|
|
322
322
|
|
|
323
323
|
[[package]]
|
|
@@ -429,7 +429,7 @@ wheels = [
|
|
|
429
429
|
|
|
430
430
|
[[package]]
|
|
431
431
|
name = "plexus-python"
|
|
432
|
-
version = "0.
|
|
432
|
+
version = "0.6.3"
|
|
433
433
|
source = { editable = "." }
|
|
434
434
|
dependencies = [
|
|
435
435
|
{ name = "websocket-client" },
|
|
@@ -439,27 +439,25 @@ dependencies = [
|
|
|
439
439
|
dev = [
|
|
440
440
|
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
|
441
441
|
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
|
442
|
-
{ name = "opencv-python" },
|
|
442
|
+
{ name = "opencv-python-headless" },
|
|
443
443
|
{ name = "pytest" },
|
|
444
444
|
{ name = "pytest-cov" },
|
|
445
445
|
{ name = "ruff" },
|
|
446
446
|
{ name = "websockets" },
|
|
447
447
|
]
|
|
448
|
-
|
|
448
|
+
video = [
|
|
449
449
|
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
|
450
450
|
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
|
451
|
-
{ name = "opencv-python" },
|
|
452
|
-
]
|
|
453
|
-
video = [
|
|
451
|
+
{ name = "opencv-python-headless" },
|
|
454
452
|
{ name = "pillow" },
|
|
455
453
|
]
|
|
456
454
|
|
|
457
455
|
[package.metadata]
|
|
458
456
|
requires-dist = [
|
|
459
457
|
{ name = "numpy", marker = "extra == 'dev'", specifier = ">=1.24" },
|
|
460
|
-
{ name = "numpy", marker = "extra == '
|
|
461
|
-
{ name = "opencv-python", marker = "extra == 'dev'", specifier = ">=4.8" },
|
|
462
|
-
{ name = "opencv-python", marker = "extra == '
|
|
458
|
+
{ name = "numpy", marker = "extra == 'video'", specifier = ">=1.24" },
|
|
459
|
+
{ name = "opencv-python-headless", marker = "extra == 'dev'", specifier = ">=4.8" },
|
|
460
|
+
{ name = "opencv-python-headless", marker = "extra == 'video'", specifier = ">=4.8" },
|
|
463
461
|
{ name = "pillow", marker = "extra == 'video'", specifier = ">=12.2.0" },
|
|
464
462
|
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.3" },
|
|
465
463
|
{ name = "pytest-cov", marker = "extra == 'dev'" },
|
|
@@ -467,7 +465,7 @@ requires-dist = [
|
|
|
467
465
|
{ name = "websocket-client", specifier = ">=1.7" },
|
|
468
466
|
{ name = "websockets", marker = "extra == 'dev'", specifier = ">=12" },
|
|
469
467
|
]
|
|
470
|
-
provides-extras = ["video", "
|
|
468
|
+
provides-extras = ["video", "dev"]
|
|
471
469
|
|
|
472
470
|
[[package]]
|
|
473
471
|
name = "pluggy"
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Plexus — thin Python SDK for sending telemetry to the Plexus gateway.
|
|
3
|
-
|
|
4
|
-
from plexus import Plexus
|
|
5
|
-
|
|
6
|
-
px = Plexus(api_key="plx_xxx", source_id="device-001")
|
|
7
|
-
px.send("temperature", 72.5)
|
|
8
|
-
"""
|
|
9
|
-
|
|
10
|
-
from plexus.client import Plexus, read_mjpeg_frames
|
|
11
|
-
from plexus.ws import WebSocketTransport
|
|
12
|
-
|
|
13
|
-
__version__ = "0.6.2"
|
|
14
|
-
__all__ = ["Plexus", "WebSocketTransport", "read_mjpeg_frames"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
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
|