plexus-python 0.5.1__py3-none-any.whl → 0.6.1__py3-none-any.whl

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/__init__.py CHANGED
@@ -10,5 +10,5 @@ Plexus — thin Python SDK for sending telemetry to the Plexus gateway.
10
10
  from plexus.client import Plexus, read_mjpeg_frames
11
11
  from plexus.ws import WebSocketTransport
12
12
 
13
- __version__ = "0.5.1"
13
+ __version__ = "0.5.2"
14
14
  __all__ = ["Plexus", "WebSocketTransport", "read_mjpeg_frames"]
plexus/_log.py ADDED
@@ -0,0 +1,15 @@
1
+ import os
2
+ import sys
3
+
4
+ _QUIET = os.environ.get("PLEXUS_QUIET", "").lower() in ("1", "true", "yes")
5
+
6
+
7
+ def _say(line: str) -> None:
8
+ """Single-line status message to stderr. Skipped if PLEXUS_QUIET=1."""
9
+ if _QUIET:
10
+ return
11
+ try:
12
+ sys.stderr.write(f"[plexus] {line}\n")
13
+ sys.stderr.flush()
14
+ except Exception:
15
+ pass
@@ -0,0 +1,25 @@
1
+ from plexus.cameras.thermal import (
2
+ NoCameraFound,
3
+ MLX90640Camera,
4
+ MLX90641Camera,
5
+ SimulatedThermalCamera,
6
+ ThermalCamera,
7
+ ThermalFrame,
8
+ ThermalSource,
9
+ USBThermalCamera,
10
+ build_thermal_frame,
11
+ encode_frame,
12
+ )
13
+
14
+ __all__ = [
15
+ "NoCameraFound",
16
+ "MLX90640Camera",
17
+ "MLX90641Camera",
18
+ "SimulatedThermalCamera",
19
+ "ThermalCamera",
20
+ "ThermalFrame",
21
+ "ThermalSource",
22
+ "USBThermalCamera",
23
+ "build_thermal_frame",
24
+ "encode_frame",
25
+ ]
@@ -0,0 +1,388 @@
1
+ """
2
+ Thermal camera drivers for Plexus.
3
+
4
+ Provides a hardware-agnostic interface over I2C sensors (MLX90640, MLX90641)
5
+ and USB thermal cameras (Y16 pixel format). All drivers return a unified
6
+ ThermalFrame containing a colorized JPEG image plus temperature metadata.
7
+
8
+ Usage:
9
+ from plexus.cameras.thermal import ThermalSource
10
+
11
+ cam = ThermalSource.open() # auto-detect
12
+ cam = ThermalSource.open("sim") # simulated, no hardware
13
+
14
+ while True:
15
+ px.send_thermal_frame(cam.read_frame(), camera_id="thermal")
16
+ time.sleep(1 / 5)
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import base64
22
+ import time
23
+ from abc import ABC, abstractmethod
24
+ from dataclasses import dataclass
25
+ from typing import Any, Dict, Optional
26
+
27
+ import cv2
28
+ import numpy as np
29
+
30
+ # Inferno is perceptually uniform and well-suited for thermal imaging.
31
+ # Swap for cv2.COLORMAP_HOT or cv2.COLORMAP_JET if preferred.
32
+ _COLORMAP = cv2.COLORMAP_INFERNO
33
+
34
+ # Sensors at or below this pixel count include the full temps array in the
35
+ # wire message. I2C sensors (32×24 = 768, 16×12 = 192) always qualify.
36
+ # USB thermal cameras (256×192 = 49 152) do not.
37
+ _TEMPS_INLINE_THRESHOLD = 4096
38
+
39
+ # Upscale sensors whose shortest side is below this threshold.
40
+ _MIN_DISPLAY_SIDE = 160
41
+
42
+
43
+ class NoCameraFound(RuntimeError):
44
+ pass
45
+
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # Abstract interface
49
+ # ---------------------------------------------------------------------------
50
+
51
+
52
+ class ThermalCamera(ABC):
53
+ """Hardware-agnostic thermal camera interface.
54
+
55
+ Subclass and implement read_frame() to support any sensor.
56
+ read_frame() must return a 2-D float32 array of temperatures in Celsius,
57
+ shaped (height, width).
58
+ """
59
+
60
+ @property
61
+ @abstractmethod
62
+ def width(self) -> int: ...
63
+
64
+ @property
65
+ @abstractmethod
66
+ def height(self) -> int: ...
67
+
68
+ @abstractmethod
69
+ def read_frame(self) -> np.ndarray: ...
70
+
71
+ def close(self) -> None:
72
+ pass
73
+
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # Unified output
77
+ # ---------------------------------------------------------------------------
78
+
79
+
80
+ @dataclass
81
+ class ThermalFrame:
82
+ """Encoded output from a ThermalCamera, ready to send to the gateway."""
83
+
84
+ image: np.ndarray # uint8 BGR colorized image (display size)
85
+ width: int # display width (may differ from sensor if upscaled)
86
+ height: int # display height
87
+ sensor_width: int # native sensor width
88
+ sensor_height: int # native sensor height
89
+ temp_min: float
90
+ temp_max: float
91
+ temps: Optional[np.ndarray] # native res; None when sensor > threshold
92
+ timestamp_ms: int
93
+
94
+ def to_message(
95
+ self, camera_id: str, source_id: Optional[str] = None, quality: int = 85
96
+ ) -> Dict[str, Any]:
97
+ """Build the gateway `video_frame` wire message for this frame."""
98
+ _, buf = cv2.imencode(".jpg", self.image, [cv2.IMWRITE_JPEG_QUALITY, quality])
99
+ b64 = base64.b64encode(buf.tobytes()).decode("ascii")
100
+
101
+ msg: Dict[str, Any] = {
102
+ "type": "video_frame",
103
+ "camera_id": camera_id,
104
+ "frame": b64,
105
+ "width": self.width,
106
+ "height": self.height,
107
+ "timestamp": self.timestamp_ms,
108
+ "video_type": "thermal",
109
+ "sensor_width": self.sensor_width,
110
+ "sensor_height": self.sensor_height,
111
+ "temp_min": self.temp_min,
112
+ "temp_max": self.temp_max,
113
+ }
114
+ if source_id is not None:
115
+ msg["source_id"] = source_id
116
+ if self.temps is not None:
117
+ msg["temps"] = self.temps.flatten().tolist()
118
+ return msg
119
+
120
+
121
+ # ---------------------------------------------------------------------------
122
+ # Encoding — single shared path used by both encode_frame() and the SDK's
123
+ # Plexus.send_thermal_frame().
124
+ # ---------------------------------------------------------------------------
125
+
126
+
127
+ def _upscale_size(sw: int, sh: int) -> tuple[int, int]:
128
+ if sw >= _MIN_DISPLAY_SIDE and sh >= _MIN_DISPLAY_SIDE:
129
+ return sw, sh
130
+ scale = max(_MIN_DISPLAY_SIDE / sw, _MIN_DISPLAY_SIDE / sh)
131
+ return round(sw * scale), round(sh * scale)
132
+
133
+
134
+ def build_thermal_frame(
135
+ temps: np.ndarray, timestamp_ms: Optional[int] = None
136
+ ) -> ThermalFrame:
137
+ """Colorize a temperature array into a ThermalFrame.
138
+
139
+ temps: 2-D float32 Celsius array, shape (height, width).
140
+ timestamp_ms: wire timestamp; defaults to current time.
141
+ """
142
+ sh, sw = temps.shape[:2]
143
+
144
+ temp_min = float(np.min(temps))
145
+ temp_max = float(np.max(temps))
146
+ span = temp_max - temp_min
147
+
148
+ normalized = (
149
+ np.zeros((sh, sw), dtype=np.uint8)
150
+ if span < 1e-6
151
+ else ((temps - temp_min) / span * 255).astype(np.uint8)
152
+ )
153
+
154
+ colored = cv2.applyColorMap(normalized, _COLORMAP)
155
+
156
+ dw, dh = _upscale_size(sw, sh)
157
+ if (dw, dh) != (sw, sh):
158
+ colored = cv2.resize(colored, (dw, dh), interpolation=cv2.INTER_CUBIC)
159
+
160
+ return ThermalFrame(
161
+ image=colored,
162
+ width=dw,
163
+ height=dh,
164
+ sensor_width=sw,
165
+ sensor_height=sh,
166
+ temp_min=round(temp_min, 2),
167
+ temp_max=round(temp_max, 2),
168
+ temps=temps if sw * sh <= _TEMPS_INLINE_THRESHOLD else None,
169
+ timestamp_ms=timestamp_ms if timestamp_ms is not None else int(time.time() * 1000),
170
+ )
171
+
172
+
173
+ def encode_frame(cam: ThermalCamera) -> ThermalFrame:
174
+ """Read one frame from a camera and colorize it."""
175
+ return build_thermal_frame(cam.read_frame())
176
+
177
+
178
+ # ---------------------------------------------------------------------------
179
+ # Drivers
180
+ # ---------------------------------------------------------------------------
181
+
182
+
183
+ class SimulatedThermalCamera(ThermalCamera):
184
+ """Simulated thermal camera for testing without hardware.
185
+
186
+ Produces a moving warm blob over a noisy background at 32×24.
187
+ """
188
+
189
+ def __init__(self, width: int = 32, height: int = 24) -> None:
190
+ self._width = width
191
+ self._height = height
192
+ self._t = 0.0
193
+
194
+ @property
195
+ def width(self) -> int:
196
+ return self._width
197
+
198
+ @property
199
+ def height(self) -> int:
200
+ return self._height
201
+
202
+ def read_frame(self) -> np.ndarray:
203
+ self._t += 0.1
204
+ x = np.linspace(0, 2 * np.pi, self._width)
205
+ y = np.linspace(0, 2 * np.pi, self._height)
206
+ xx, yy = np.meshgrid(x, y)
207
+ base = 22.0 + 3.0 * np.sin(xx + self._t) * np.cos(yy + self._t * 0.7)
208
+ cx = int((np.sin(self._t * 0.5) * 0.4 + 0.5) * self._width)
209
+ cy = int((np.cos(self._t * 0.3) * 0.4 + 0.5) * self._height)
210
+ yg, xg = np.ogrid[: self._height, : self._width]
211
+ hotspot = 15.0 * np.exp(-((xg - cx) ** 2 + (yg - cy) ** 2) / 20.0)
212
+ return (base + hotspot).astype(np.float32)
213
+
214
+
215
+ class USBThermalCamera(ThermalCamera):
216
+ """USB thermal camera via V4L2/UVC in Y16 pixel format.
217
+
218
+ Most USB thermal cameras (InfiRay, Topdon, Seek, some FLIR) present as
219
+ standard UVC video devices outputting Y16 frames where each uint16 pixel
220
+ encodes temperature as: celsius = (value / 100.0) - 273.15
221
+
222
+ Requires: pip install opencv-python
223
+ """
224
+
225
+ _KELVIN_SCALE = 100.0
226
+ _KELVIN_OFFSET = 273.15
227
+
228
+ def __init__(self, device_index: int = 0) -> None:
229
+ self._cap = cv2.VideoCapture(device_index)
230
+ if not self._cap.isOpened():
231
+ raise NoCameraFound(f"Cannot open video device {device_index}")
232
+ self._cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*"Y16 "))
233
+ ret, frame = self._cap.read()
234
+ if not ret or frame is None:
235
+ self._cap.release()
236
+ raise NoCameraFound(
237
+ f"Device {device_index} opened but could not read a frame. "
238
+ "Check that it supports Y16 format."
239
+ )
240
+ self._width = int(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH))
241
+ self._height = int(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
242
+
243
+ @property
244
+ def width(self) -> int:
245
+ return self._width
246
+
247
+ @property
248
+ def height(self) -> int:
249
+ return self._height
250
+
251
+ def read_frame(self) -> np.ndarray:
252
+ ret, raw = self._cap.read()
253
+ if not ret or raw is None:
254
+ raise RuntimeError("Failed to read frame from USB thermal camera")
255
+ u16 = raw.view(np.uint16).reshape(self._height, self._width)
256
+ return (u16.astype(np.float32) / self._KELVIN_SCALE) - self._KELVIN_OFFSET
257
+
258
+ def close(self) -> None:
259
+ self._cap.release()
260
+
261
+
262
+ class MLX90640Camera(ThermalCamera):
263
+ """MLX90640 32×24 thermal array via I2C.
264
+
265
+ Requires: pip install adafruit-circuitpython-mlx90640
266
+ Wiring: SCL → GPIO 3, SDA → GPIO 2, 3.3V power (Raspberry Pi).
267
+ """
268
+
269
+ def __init__(self) -> None:
270
+ import adafruit_mlx90640
271
+ import board
272
+ import busio
273
+
274
+ i2c = busio.I2C(board.SCL, board.SDA, frequency=800_000)
275
+ self._mlx = adafruit_mlx90640.MLX90640(i2c)
276
+ self._mlx.refresh_rate = adafruit_mlx90640.RefreshRate.REFRESH_4_HZ
277
+ self._buf = [0.0] * 768
278
+
279
+ @property
280
+ def width(self) -> int:
281
+ return 32
282
+
283
+ @property
284
+ def height(self) -> int:
285
+ return 24
286
+
287
+ def read_frame(self) -> np.ndarray:
288
+ self._mlx.getFrame(self._buf)
289
+ return np.array(self._buf, dtype=np.float32).reshape(24, 32)
290
+
291
+
292
+ class MLX90641Camera(ThermalCamera):
293
+ """MLX90641 16×12 thermal array via I2C.
294
+
295
+ Requires: pip install adafruit-circuitpython-mlx90641
296
+ Wiring: SCL → GPIO 3, SDA → GPIO 2, 3.3V power (Raspberry Pi).
297
+ """
298
+
299
+ def __init__(self) -> None:
300
+ import adafruit_mlx90641
301
+ import board
302
+ import busio
303
+
304
+ i2c = busio.I2C(board.SCL, board.SDA, frequency=400_000)
305
+ self._mlx = adafruit_mlx90641.MLX90641(i2c)
306
+ self._mlx.refresh_rate = adafruit_mlx90641.RefreshRate.REFRESH_4_HZ
307
+ self._buf = [0.0] * 192
308
+
309
+ @property
310
+ def width(self) -> int:
311
+ return 16
312
+
313
+ @property
314
+ def height(self) -> int:
315
+ return 12
316
+
317
+ def read_frame(self) -> np.ndarray:
318
+ self._mlx.getFrame(self._buf)
319
+ return np.array(self._buf, dtype=np.float32).reshape(12, 16)
320
+
321
+
322
+ # ---------------------------------------------------------------------------
323
+ # Auto-detection factory
324
+ # ---------------------------------------------------------------------------
325
+
326
+
327
+ class ThermalSource:
328
+ """Factory that opens the first available thermal camera.
329
+
330
+ Examples:
331
+ ThermalSource.open() # auto-detect
332
+ ThermalSource.open("sim") # simulated
333
+ ThermalSource.open("mlx90640") # force I2C MLX90640
334
+ ThermalSource.open("mlx90641") # force I2C MLX90641
335
+ ThermalSource.open("usb") # USB thermal at index 0
336
+ ThermalSource.open(2) # USB thermal at index 2
337
+ """
338
+
339
+ @staticmethod
340
+ def open(hint: str | int | None = None) -> ThermalCamera:
341
+ if hint == "sim":
342
+ return SimulatedThermalCamera()
343
+ if hint == "mlx90640":
344
+ return MLX90640Camera()
345
+ if hint == "mlx90641":
346
+ return MLX90641Camera()
347
+ if hint == "usb" or isinstance(hint, int):
348
+ return USBThermalCamera(0 if hint == "usb" else hint)
349
+ return ThermalSource._autodetect()
350
+
351
+ @staticmethod
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."
361
+ )
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
plexus/client.py CHANGED
@@ -30,17 +30,16 @@ Usage:
30
30
  px.send("temperature", read_temp())
31
31
  time.sleep(0.01)
32
32
 
33
- Note: Requires authentication. Run 'plexus start' or set PLEXUS_API_KEY.
33
+ Note: Requires authentication. Run 'plexus init' or set PLEXUS_API_KEY.
34
34
  """
35
35
 
36
36
  import gzip
37
37
  import json
38
38
  import logging
39
- import os
39
+ import re
40
40
  import shutil
41
41
  import socket
42
42
  import subprocess
43
- import sys
44
43
  import threading
45
44
  import time
46
45
  import urllib.error
@@ -48,6 +47,7 @@ import urllib.request
48
47
  from contextlib import contextmanager
49
48
  from typing import Any, Dict, Generator, List, Optional, Tuple, Union
50
49
 
50
+ from plexus._log import _say
51
51
  from plexus.buffer import BufferBackend, MemoryBuffer, SqliteBuffer
52
52
  from plexus.config import (
53
53
  RetryConfig,
@@ -59,6 +59,7 @@ from plexus.config import (
59
59
  get_source_id,
60
60
  set_source_id,
61
61
  )
62
+
62
63
  logger = logging.getLogger(__name__)
63
64
 
64
65
 
@@ -101,20 +102,6 @@ class _ConnError(OSError):
101
102
  pass
102
103
 
103
104
 
104
- # Status messages to stderr so users running `python my_script.py` see what's
105
- # happening without having to configure logging. Set PLEXUS_QUIET=1 to disable.
106
- _QUIET = os.environ.get("PLEXUS_QUIET", "").lower() in ("1", "true", "yes")
107
-
108
-
109
- def _say(line: str) -> None:
110
- if _QUIET:
111
- return
112
- try:
113
- sys.stderr.write(f"[plexus] {line}\n")
114
- sys.stderr.flush()
115
- except Exception:
116
- pass
117
-
118
105
  # Flexible value type - supports any JSON-serializable value
119
106
  FlexValue = Union[int, float, str, bool, Dict[str, Any], List[Any]]
120
107
 
@@ -161,6 +148,18 @@ class AuthenticationError(PlexusError):
161
148
  pass
162
149
 
163
150
 
151
+ _SOURCE_ID_RE = re.compile(r'^[a-z0-9][a-z0-9_-]{1,62}$')
152
+
153
+
154
+ def _validate_source_id(source_id: str) -> None:
155
+ if not _SOURCE_ID_RE.match(source_id):
156
+ raise ValueError(
157
+ f"Invalid source_id {source_id!r}. "
158
+ "Must match ^[a-z0-9][a-z0-9_-]{1,62}$ "
159
+ "(lowercase letters, digits, hyphens, underscores; start with letter or digit)."
160
+ )
161
+
162
+
164
163
  class Plexus:
165
164
  """
166
165
  Client for sending sensor data to Plexus.
@@ -186,7 +185,7 @@ class Plexus:
186
185
  timeout: float = 10.0,
187
186
  retry_config: Optional[RetryConfig] = None,
188
187
  max_buffer_size: int = 10000,
189
- persistent_buffer: bool = False,
188
+ persistent_buffer: bool = True,
190
189
  buffer_path: Optional[str] = None,
191
190
  transport: str = "ws",
192
191
  ws_url: Optional[str] = None,
@@ -201,6 +200,7 @@ class Plexus:
201
200
  self.endpoint = (endpoint or get_endpoint()).rstrip("/")
202
201
  self.gateway_url = get_gateway_url()
203
202
  self.source_id = source_id or get_source_id()
203
+ _validate_source_id(self.source_id)
204
204
  self.timeout = timeout
205
205
  self.retry_config = retry_config or RetryConfig()
206
206
  self._max_buffer_size = max_buffer_size
@@ -360,7 +360,7 @@ class Plexus:
360
360
 
361
361
  def send_batch(
362
362
  self,
363
- points: List[Tuple[str, FlexValue]],
363
+ points: List[Union[Tuple[str, FlexValue], Tuple[str, FlexValue, float]]],
364
364
  timestamp: Optional[float] = None,
365
365
  tags: Optional[Dict[str, str]] = None,
366
366
  ) -> bool:
@@ -368,8 +368,11 @@ class Plexus:
368
368
  Send multiple metrics at once.
369
369
 
370
370
  Args:
371
- points: List of (metric, value) tuples. Values can be any FlexValue type.
372
- timestamp: Shared timestamp for all points. If not provided, uses current time.
371
+ points: List of (metric, value) or (metric, value, timestamp) tuples.
372
+ Values can be any FlexValue type. Per-point timestamps override
373
+ the shared timestamp argument.
374
+ timestamp: Shared timestamp for points that don't supply their own.
375
+ If not provided, uses current time.
373
376
  tags: Shared tags for all points
374
377
 
375
378
  Returns:
@@ -382,9 +385,23 @@ class Plexus:
382
385
  ("robot.state", "RUNNING"),
383
386
  ("position", {"x": 1.0, "y": 2.0}),
384
387
  ])
388
+
389
+ # Per-point timestamps (e.g. sensors on different interrupt timers):
390
+ px.send_batch([
391
+ ("imu.accel_x", 0.12, t_imu),
392
+ ("pressure", 1013.2, t_baro),
393
+ ("temperature", 22.4), # uses shared timestamp
394
+ ])
385
395
  """
386
- ts_ms = self._normalize_ts_ms(timestamp)
387
- data_points = [self._make_point(m, v, ts_ms, tags) for m, v in points]
396
+ default_ts_ms = self._normalize_ts_ms(timestamp)
397
+ data_points = []
398
+ for p in points:
399
+ if len(p) == 3:
400
+ m, v, t = p
401
+ data_points.append(self._make_point(m, v, self._normalize_ts_ms(t), tags))
402
+ else:
403
+ m, v = p
404
+ data_points.append(self._make_point(m, v, default_ts_ms, tags))
388
405
  return self._send_points(data_points)
389
406
 
390
407
  def _ensure_ws(self):
@@ -573,6 +590,51 @@ class Plexus:
573
590
  self._normalize_ts_ms(timestamp),
574
591
  )
575
592
 
593
+ def send_thermal_frame(
594
+ self,
595
+ temps,
596
+ camera_id: str = "thermal:0",
597
+ quality: int = 85,
598
+ timestamp: Optional[float] = None,
599
+ ) -> bool:
600
+ """Send a thermal camera frame to Plexus (WebSocket transport only).
601
+
602
+ Args:
603
+ temps: 2-D float32 numpy array of temperatures in Celsius,
604
+ shape (height, width). Obtained from ThermalCamera.read_frame().
605
+ camera_id: Logical camera identifier.
606
+ quality: JPEG quality for the colorized image, 1-100.
607
+ timestamp: Unix timestamp in seconds. Defaults to current time.
608
+
609
+ Returns:
610
+ True if the frame was sent successfully.
611
+
612
+ Raises:
613
+ PlexusError: If transport is not 'ws'.
614
+ ImportError: If opencv-python is not installed.
615
+ """
616
+ if self.transport != "ws":
617
+ raise PlexusError("send_thermal_frame requires transport='ws'")
618
+
619
+ try:
620
+ from plexus.cameras.thermal import build_thermal_frame
621
+ except ImportError as e:
622
+ raise ImportError(
623
+ "send_thermal_frame requires opencv-python. "
624
+ "Install with: pip install opencv-python"
625
+ ) from e
626
+
627
+ frame = build_thermal_frame(temps, timestamp_ms=self._normalize_ts_ms(timestamp))
628
+ msg = frame.to_message(
629
+ camera_id=camera_id, source_id=self.source_id, quality=quality
630
+ )
631
+
632
+ ws = self._ensure_ws()
633
+ if not ws.is_authenticated:
634
+ ws.wait_authenticated(timeout=min(self.timeout, 5.0))
635
+
636
+ return ws.send_json_video_frame(msg)
637
+
576
638
  def stream_camera(
577
639
  self,
578
640
  url: str,
@@ -656,6 +718,12 @@ class Plexus:
656
718
  if self.transport != "ws":
657
719
  raise PlexusError("on_command requires transport='ws'")
658
720
  ws = self._ensure_ws()
721
+ if ws.is_authenticated:
722
+ _say(
723
+ f"⚠ on_command('{name}') called after connection is already authenticated — "
724
+ "command will not be advertised to the dashboard until next reconnect. "
725
+ "Call on_command() before the first send()."
726
+ )
659
727
  ws.register_command(name, handler, description=description, params=params)
660
728
 
661
729
  def _send_points(self, points: List[Dict[str, Any]]) -> bool:
@@ -673,7 +741,7 @@ class Plexus:
673
741
  """
674
742
  if not self.api_key:
675
743
  raise AuthenticationError(
676
- "No API key configured. Run 'plexus start' or set PLEXUS_API_KEY"
744
+ "No API key configured. Run 'plexus init' or set PLEXUS_API_KEY"
677
745
  )
678
746
 
679
747
  # Include any previously buffered points
@@ -905,7 +973,12 @@ class Plexus:
905
973
  self._store_frames = False
906
974
 
907
975
  def close(self):
908
- """Close the client and release resources."""
976
+ """Close the client, flush any buffered points, and release resources."""
977
+ if self.buffer_size() > 0:
978
+ try:
979
+ self.flush_buffer()
980
+ except Exception as e:
981
+ logger.debug("flush on close failed: %s", e)
909
982
  if self._ws is not None:
910
983
  self._ws.stop()
911
984
  self._ws = None
plexus/ws.py CHANGED
@@ -28,11 +28,9 @@ from __future__ import annotations
28
28
  import atexit
29
29
  import json
30
30
  import logging
31
- import os
32
31
  import queue
33
32
  import random
34
33
  import struct
35
- import sys
36
34
  import threading
37
35
  import time
38
36
  from dataclasses import dataclass, field
@@ -46,24 +44,9 @@ except ImportError as e: # pragma: no cover - import-time failure is obvious
46
44
  "Install with: pip install websocket-client"
47
45
  ) from e
48
46
 
49
- logger = logging.getLogger(__name__)
50
-
51
- # By default, print connection status to stderr so users running
52
- # `python my_script.py` can see what's happening without having to
53
- # configure the logging module. Set PLEXUS_QUIET=1 to disable.
54
- _QUIET = os.environ.get("PLEXUS_QUIET", "").lower() in ("1", "true", "yes")
47
+ from plexus._log import _say
55
48
 
56
-
57
- def _say(line: str) -> None:
58
- """Single-line status message to stderr. Skipped if PLEXUS_QUIET=1."""
59
- if _QUIET:
60
- return
61
- try:
62
- sys.stderr.write(f"[plexus] {line}\n")
63
- sys.stderr.flush()
64
- except Exception:
65
- # Stderr blew up — don't take the whole client down with it.
66
- pass
49
+ logger = logging.getLogger(__name__)
67
50
 
68
51
  AUTH_TIMEOUT_S = 10.0
69
52
  HEARTBEAT_INTERVAL_S = 30.0
@@ -226,6 +209,13 @@ class WebSocketTransport:
226
209
  except queue.Full:
227
210
  return False
228
211
 
212
+ def send_json_video_frame(self, msg: Dict[str, Any]) -> bool:
213
+ """Send a JSON video_frame message. Used for frames that carry extra
214
+ metadata (e.g. thermal cameras) that the binary format cannot express."""
215
+ if not self._authenticated.is_set():
216
+ return False
217
+ return self._send_frame(msg)
218
+
229
219
  # ------------------------------------------------------------------ thread
230
220
 
231
221
  def _run(self) -> None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plexus-python
3
- Version: 0.5.1
3
+ Version: 0.6.1
4
4
  Summary: Thin Python SDK for Plexus — send telemetry in one line
5
5
  Project-URL: Homepage, https://plexus.dev
6
6
  Project-URL: Documentation, https://docs.plexus.dev
@@ -23,10 +23,15 @@ Classifier: Topic :: System :: Hardware
23
23
  Requires-Python: >=3.10
24
24
  Requires-Dist: websocket-client>=1.7
25
25
  Provides-Extra: dev
26
+ Requires-Dist: numpy>=1.24; extra == 'dev'
27
+ Requires-Dist: opencv-python>=4.8; extra == 'dev'
26
28
  Requires-Dist: pytest-cov; extra == 'dev'
27
29
  Requires-Dist: pytest>=9.0.3; extra == 'dev'
28
30
  Requires-Dist: ruff; extra == 'dev'
29
31
  Requires-Dist: websockets>=12; extra == 'dev'
32
+ Provides-Extra: thermal
33
+ Requires-Dist: numpy>=1.24; extra == 'thermal'
34
+ Requires-Dist: opencv-python>=4.8; extra == 'thermal'
30
35
  Provides-Extra: video
31
36
  Requires-Dist: pillow>=12.2.0; extra == 'video'
32
37
  Description-Content-Type: text/markdown
@@ -0,0 +1,14 @@
1
+ plexus/__init__.py,sha256=2Q3D7gMSFmMpFzcc_ywnCMH_1zgysy-PH6jW21bONgc,385
2
+ plexus/_log.py,sha256=y1A9ahcPuCefTQ8xsZtOJTb6iT0pP9LAlkeP7U-yvGE,352
3
+ plexus/buffer.py,sha256=3ykybqLs7yMXxQWFajAT8nGe3cs_lW8_6Xvn0vQ69dE,9262
4
+ plexus/cli.py,sha256=-2wvHXQzobx3_tDGTXpaE2PlHv884y93Mu29kZE8qZE,14214
5
+ plexus/client.py,sha256=tB7bl3B06k9UA8TcRtHyu95IK18uXBNVqNEiDqI8MYk,36954
6
+ plexus/config.py,sha256=wsG6lhNLmKe3JRlVycyRUKQeywnPUPPfrWkXFxYwELE,6179
7
+ plexus/ws.py,sha256=_c--U-yySZbMXZsaj0fUaYKF_WesolHsrMDx4oWYfFQ,17428
8
+ plexus/cameras/__init__.py,sha256=OvnU9KGKxkVtFLlk56H9x-ATa6UvpLI7PANa0HQO2cc,490
9
+ plexus/cameras/thermal.py,sha256=-Ov8pgHGKtu5W-zyHRffVACDbsb50lpTmTGEbs_j4lg,12267
10
+ plexus_python-0.6.1.dist-info/METADATA,sha256=J6uSpstAvCNUrfKslAaY-galEMgSODIL7cnleKyGL_4,11749
11
+ plexus_python-0.6.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
12
+ plexus_python-0.6.1.dist-info/entry_points.txt,sha256=YlkOtTn_7Q_IGuJaKdvpU-90dCeBSPx2p_UTGMAz5Zs,43
13
+ plexus_python-0.6.1.dist-info/licenses/LICENSE,sha256=nm3qP1F-JAGcfLpRVtIX24L20LMnRpxmZ2oKZzFpLVo,10755
14
+ plexus_python-0.6.1.dist-info/RECORD,,
@@ -1,11 +0,0 @@
1
- plexus/__init__.py,sha256=mNRP4PCXuMoMvaKh05M-1u3GrJL4TAhqA8nEY1xdiSY,385
2
- plexus/buffer.py,sha256=3ykybqLs7yMXxQWFajAT8nGe3cs_lW8_6Xvn0vQ69dE,9262
3
- plexus/cli.py,sha256=-2wvHXQzobx3_tDGTXpaE2PlHv884y93Mu29kZE8qZE,14214
4
- plexus/client.py,sha256=z8CNy0EFA0Ol3If1lIKgyndYYaqD67lvB0iuGV62Nsc,34034
5
- plexus/config.py,sha256=wsG6lhNLmKe3JRlVycyRUKQeywnPUPPfrWkXFxYwELE,6179
6
- plexus/ws.py,sha256=lSVv-Yf4ODZ0TaziKEF9pEmgVOLAJHMlluULqVydePs,17683
7
- plexus_python-0.5.1.dist-info/METADATA,sha256=HHSsxh19qh99fkDFErlFzdCqxtT3zj0q4VCj8jre72Y,11531
8
- plexus_python-0.5.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
9
- plexus_python-0.5.1.dist-info/entry_points.txt,sha256=YlkOtTn_7Q_IGuJaKdvpU-90dCeBSPx2p_UTGMAz5Zs,43
10
- plexus_python-0.5.1.dist-info/licenses/LICENSE,sha256=nm3qP1F-JAGcfLpRVtIX24L20LMnRpxmZ2oKZzFpLVo,10755
11
- plexus_python-0.5.1.dist-info/RECORD,,