plexus-python 0.1.0__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 +31 -0
- plexus/__main__.py +4 -0
- plexus/adapters/__init__.py +122 -0
- plexus/adapters/base.py +409 -0
- plexus/adapters/ble.py +257 -0
- plexus/adapters/can.py +439 -0
- plexus/adapters/can_detect.py +174 -0
- plexus/adapters/mavlink.py +642 -0
- plexus/adapters/mavlink_detect.py +192 -0
- plexus/adapters/modbus.py +622 -0
- plexus/adapters/mqtt.py +350 -0
- plexus/adapters/opcua.py +607 -0
- plexus/adapters/registry.py +206 -0
- plexus/adapters/serial_adapter.py +547 -0
- plexus/buffer.py +257 -0
- plexus/cameras/__init__.py +57 -0
- plexus/cameras/auto.py +239 -0
- plexus/cameras/base.py +189 -0
- plexus/cameras/picamera.py +171 -0
- plexus/cameras/usb.py +143 -0
- plexus/cli.py +783 -0
- plexus/client.py +465 -0
- plexus/config.py +169 -0
- plexus/connector.py +666 -0
- plexus/deps.py +246 -0
- plexus/detect.py +1238 -0
- plexus/importers/__init__.py +25 -0
- plexus/importers/rosbag.py +778 -0
- plexus/sensors/__init__.py +118 -0
- plexus/sensors/ads1115.py +164 -0
- plexus/sensors/adxl345.py +179 -0
- plexus/sensors/auto.py +290 -0
- plexus/sensors/base.py +412 -0
- plexus/sensors/bh1750.py +102 -0
- plexus/sensors/bme280.py +241 -0
- plexus/sensors/gps.py +317 -0
- plexus/sensors/ina219.py +149 -0
- plexus/sensors/magnetometer.py +239 -0
- plexus/sensors/mpu6050.py +162 -0
- plexus/sensors/sht3x.py +139 -0
- plexus/sensors/spi_scan.py +164 -0
- plexus/sensors/system.py +261 -0
- plexus/sensors/vl53l0x.py +109 -0
- plexus/streaming.py +743 -0
- plexus/tui.py +642 -0
- plexus_python-0.1.0.dist-info/METADATA +470 -0
- plexus_python-0.1.0.dist-info/RECORD +50 -0
- plexus_python-0.1.0.dist-info/WHEEL +4 -0
- plexus_python-0.1.0.dist-info/entry_points.txt +2 -0
- plexus_python-0.1.0.dist-info/licenses/LICENSE +190 -0
plexus/client.py
ADDED
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Plexus client for sending sensor data.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
from plexus import Plexus
|
|
6
|
+
|
|
7
|
+
px = Plexus()
|
|
8
|
+
px.send("temperature", 72.5)
|
|
9
|
+
|
|
10
|
+
# With tags
|
|
11
|
+
px.send("motor.rpm", 3450, tags={"motor_id": "A1"})
|
|
12
|
+
|
|
13
|
+
# Flexible value types (not just numbers!)
|
|
14
|
+
px.send("robot.state", "MOVING") # String states
|
|
15
|
+
px.send("error.code", "E_MOTOR_STALL") # Error codes
|
|
16
|
+
px.send("position", {"x": 1.5, "y": 2.3, "z": 0.8}) # Complex objects
|
|
17
|
+
px.send("joint_angles", [0.5, 1.2, -0.3, 0.0]) # Arrays
|
|
18
|
+
px.send("motor.enabled", True) # Booleans
|
|
19
|
+
|
|
20
|
+
# Batch send
|
|
21
|
+
px.send_batch([
|
|
22
|
+
("temperature", 72.5),
|
|
23
|
+
("humidity", 45.2),
|
|
24
|
+
("pressure", 1013.25),
|
|
25
|
+
])
|
|
26
|
+
|
|
27
|
+
# Run recording
|
|
28
|
+
with px.run("motor-test-001"):
|
|
29
|
+
while True:
|
|
30
|
+
px.send("temperature", read_temp())
|
|
31
|
+
time.sleep(0.01)
|
|
32
|
+
|
|
33
|
+
Note: Requires authentication. Run 'plexus start' or set PLEXUS_API_KEY.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
import gzip
|
|
37
|
+
import json
|
|
38
|
+
import logging
|
|
39
|
+
import time
|
|
40
|
+
from contextlib import contextmanager
|
|
41
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
42
|
+
|
|
43
|
+
import requests
|
|
44
|
+
|
|
45
|
+
from plexus.buffer import BufferBackend, MemoryBuffer, SqliteBuffer
|
|
46
|
+
from plexus.config import (
|
|
47
|
+
RetryConfig,
|
|
48
|
+
get_api_key,
|
|
49
|
+
get_endpoint,
|
|
50
|
+
get_gateway_url,
|
|
51
|
+
get_source_id,
|
|
52
|
+
require_login,
|
|
53
|
+
)
|
|
54
|
+
logger = logging.getLogger(__name__)
|
|
55
|
+
|
|
56
|
+
# Flexible value type - supports any JSON-serializable value
|
|
57
|
+
FlexValue = Union[int, float, str, bool, Dict[str, Any], List[Any]]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class PlexusError(Exception):
|
|
61
|
+
"""Base exception for Plexus errors."""
|
|
62
|
+
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class AuthenticationError(PlexusError):
|
|
67
|
+
"""Raised when API key is missing or invalid."""
|
|
68
|
+
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Plexus:
|
|
73
|
+
"""
|
|
74
|
+
Client for sending sensor data to Plexus.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
api_key: Your Plexus API key. If not provided, reads from
|
|
78
|
+
PLEXUS_API_KEY env var or ~/.plexus/config.json
|
|
79
|
+
endpoint: API endpoint URL. Defaults to https://app.plexus.company
|
|
80
|
+
source_id: Unique identifier for this source. Auto-generated if not provided.
|
|
81
|
+
timeout: Request timeout in seconds. Default 10s.
|
|
82
|
+
retry_config: Configuration for retry behavior. If None, uses defaults.
|
|
83
|
+
max_buffer_size: Maximum number of points to buffer locally on failures. Default 10000.
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
RuntimeError: If not logged in (no API key configured)
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
def __init__(
|
|
90
|
+
self,
|
|
91
|
+
api_key: Optional[str] = None,
|
|
92
|
+
endpoint: Optional[str] = None,
|
|
93
|
+
source_id: Optional[str] = None,
|
|
94
|
+
timeout: float = 10.0,
|
|
95
|
+
retry_config: Optional[RetryConfig] = None,
|
|
96
|
+
max_buffer_size: int = 10000,
|
|
97
|
+
persistent_buffer: bool = False,
|
|
98
|
+
buffer_path: Optional[str] = None,
|
|
99
|
+
):
|
|
100
|
+
self.api_key = api_key or get_api_key()
|
|
101
|
+
|
|
102
|
+
# Require login if no API key provided
|
|
103
|
+
if not self.api_key:
|
|
104
|
+
require_login()
|
|
105
|
+
|
|
106
|
+
self.endpoint = (endpoint or get_endpoint()).rstrip("/")
|
|
107
|
+
self.gateway_url = get_gateway_url()
|
|
108
|
+
self.source_id = source_id or get_source_id()
|
|
109
|
+
self.timeout = timeout
|
|
110
|
+
self.retry_config = retry_config or RetryConfig()
|
|
111
|
+
self._max_buffer_size = max_buffer_size
|
|
112
|
+
|
|
113
|
+
self._run_id: Optional[str] = None
|
|
114
|
+
self._session: Optional[requests.Session] = None
|
|
115
|
+
self._store_frames: bool = False
|
|
116
|
+
|
|
117
|
+
# Pluggable buffer backend for failed sends
|
|
118
|
+
if persistent_buffer:
|
|
119
|
+
self._buffer: BufferBackend = SqliteBuffer(
|
|
120
|
+
path=buffer_path, max_size=max_buffer_size
|
|
121
|
+
)
|
|
122
|
+
else:
|
|
123
|
+
self._buffer: BufferBackend = MemoryBuffer(max_size=max_buffer_size)
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def max_buffer_size(self):
|
|
127
|
+
return self._max_buffer_size
|
|
128
|
+
|
|
129
|
+
@max_buffer_size.setter
|
|
130
|
+
def max_buffer_size(self, value):
|
|
131
|
+
self._max_buffer_size = value
|
|
132
|
+
self._buffer._max_size = value
|
|
133
|
+
|
|
134
|
+
def _get_session(self) -> requests.Session:
|
|
135
|
+
"""Get or create a requests session for connection pooling."""
|
|
136
|
+
if self._session is None:
|
|
137
|
+
self._session = requests.Session()
|
|
138
|
+
if self.api_key:
|
|
139
|
+
self._session.headers["x-api-key"] = self.api_key
|
|
140
|
+
self._session.headers["Content-Type"] = "application/json"
|
|
141
|
+
from plexus import __version__
|
|
142
|
+
self._session.headers["User-Agent"] = f"plexus-python/{__version__}"
|
|
143
|
+
return self._session
|
|
144
|
+
|
|
145
|
+
@staticmethod
|
|
146
|
+
def _normalize_ts_ms(timestamp: Optional[float] = None) -> int:
|
|
147
|
+
"""Normalize a timestamp to milliseconds.
|
|
148
|
+
|
|
149
|
+
Accepts:
|
|
150
|
+
- None: returns current time in ms
|
|
151
|
+
- float seconds (e.g. time.time()): converts to ms
|
|
152
|
+
- int/float ms: returned as-is
|
|
153
|
+
"""
|
|
154
|
+
if timestamp is None:
|
|
155
|
+
return int(time.time() * 1000)
|
|
156
|
+
# Heuristic: values < 1e12 are seconds
|
|
157
|
+
if timestamp > 0 and timestamp < 1e12:
|
|
158
|
+
return int(timestamp * 1000)
|
|
159
|
+
return int(timestamp)
|
|
160
|
+
|
|
161
|
+
def _make_point(
|
|
162
|
+
self,
|
|
163
|
+
metric: str,
|
|
164
|
+
value: FlexValue,
|
|
165
|
+
timestamp: Optional[float] = None,
|
|
166
|
+
tags: Optional[Dict[str, str]] = None,
|
|
167
|
+
data_class: str = "metric",
|
|
168
|
+
) -> Dict[str, Any]:
|
|
169
|
+
"""Create a data point dictionary.
|
|
170
|
+
|
|
171
|
+
Value can be:
|
|
172
|
+
- number (int/float): Traditional sensor readings
|
|
173
|
+
- string: State machines, error codes, status
|
|
174
|
+
- bool: Binary flags, enabled/disabled states
|
|
175
|
+
- dict: Complex objects, vectors, nested data
|
|
176
|
+
- list: Arrays, coordinates, multi-value readings
|
|
177
|
+
"""
|
|
178
|
+
point = {
|
|
179
|
+
"class": data_class,
|
|
180
|
+
"metric": metric,
|
|
181
|
+
"value": value,
|
|
182
|
+
"timestamp": self._normalize_ts_ms(timestamp),
|
|
183
|
+
}
|
|
184
|
+
if tags:
|
|
185
|
+
point["tags"] = tags
|
|
186
|
+
if self._run_id:
|
|
187
|
+
point["run_id"] = self._run_id
|
|
188
|
+
return point
|
|
189
|
+
|
|
190
|
+
def send(
|
|
191
|
+
self,
|
|
192
|
+
metric: str,
|
|
193
|
+
value: FlexValue,
|
|
194
|
+
timestamp: Optional[float] = None,
|
|
195
|
+
tags: Optional[Dict[str, str]] = None,
|
|
196
|
+
data_class: str = "metric",
|
|
197
|
+
) -> bool:
|
|
198
|
+
"""
|
|
199
|
+
Send a single metric value to Plexus.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
metric: Name of the metric (e.g., "temperature", "motor.rpm")
|
|
203
|
+
value: Value to send. Can be:
|
|
204
|
+
- number (int/float): px.send("temp", 72.5)
|
|
205
|
+
- string: px.send("state", "RUNNING")
|
|
206
|
+
- bool: px.send("enabled", True)
|
|
207
|
+
- dict: px.send("pos", {"x": 1, "y": 2})
|
|
208
|
+
- list: px.send("angles", [0.5, 1.2, -0.3])
|
|
209
|
+
timestamp: Unix timestamp. If not provided, uses current time.
|
|
210
|
+
tags: Optional key-value tags for the metric
|
|
211
|
+
data_class: Pipeline data class - "metric" (default) or "event"
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
True if successful
|
|
215
|
+
|
|
216
|
+
Raises:
|
|
217
|
+
AuthenticationError: If API key is missing or invalid (cloud mode only)
|
|
218
|
+
PlexusError: If the request fails
|
|
219
|
+
|
|
220
|
+
Example:
|
|
221
|
+
px.send("temperature", 72.5)
|
|
222
|
+
px.send("motor.rpm", 3450, tags={"motor_id": "A1"})
|
|
223
|
+
px.send("gps.status", {"fix": "lost"}, data_class="event")
|
|
224
|
+
"""
|
|
225
|
+
point = self._make_point(metric, value, timestamp, tags, data_class)
|
|
226
|
+
return self._send_points([point])
|
|
227
|
+
|
|
228
|
+
def send_batch(
|
|
229
|
+
self,
|
|
230
|
+
points: List[Tuple[str, FlexValue]],
|
|
231
|
+
timestamp: Optional[float] = None,
|
|
232
|
+
tags: Optional[Dict[str, str]] = None,
|
|
233
|
+
) -> bool:
|
|
234
|
+
"""
|
|
235
|
+
Send multiple metrics at once.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
points: List of (metric, value) tuples. Values can be any FlexValue type.
|
|
239
|
+
timestamp: Shared timestamp for all points. If not provided, uses current time.
|
|
240
|
+
tags: Shared tags for all points
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
True if successful
|
|
244
|
+
|
|
245
|
+
Example:
|
|
246
|
+
px.send_batch([
|
|
247
|
+
("temperature", 72.5),
|
|
248
|
+
("humidity", 45.2),
|
|
249
|
+
("robot.state", "RUNNING"),
|
|
250
|
+
("position", {"x": 1.0, "y": 2.0}),
|
|
251
|
+
])
|
|
252
|
+
"""
|
|
253
|
+
ts = timestamp or time.time()
|
|
254
|
+
data_points = [self._make_point(m, v, ts, tags) for m, v in points]
|
|
255
|
+
return self._send_points(data_points)
|
|
256
|
+
|
|
257
|
+
def _send_points(self, points: List[Dict[str, Any]]) -> bool:
|
|
258
|
+
"""Send data points to the API with retry and buffering.
|
|
259
|
+
|
|
260
|
+
Retry behavior:
|
|
261
|
+
- Retries on: Timeout, ConnectionError, HTTP 429 (rate limit), HTTP 5xx
|
|
262
|
+
- No retry on: HTTP 401/403 (auth errors), HTTP 400/422 (bad request)
|
|
263
|
+
- After max retries: buffers points locally for next send attempt
|
|
264
|
+
"""
|
|
265
|
+
if not self.api_key:
|
|
266
|
+
raise AuthenticationError(
|
|
267
|
+
"No API key configured. Run 'plexus start' or set PLEXUS_API_KEY"
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
# Include any previously buffered points
|
|
271
|
+
all_points = self._get_buffered_points() + points
|
|
272
|
+
|
|
273
|
+
url = f"{self.gateway_url}/ingest"
|
|
274
|
+
last_error: Optional[Exception] = None
|
|
275
|
+
|
|
276
|
+
for attempt in range(self.retry_config.max_retries + 1):
|
|
277
|
+
try:
|
|
278
|
+
payload = json.dumps({"source_id": self.source_id, "points": all_points})
|
|
279
|
+
payload_bytes = payload.encode("utf-8")
|
|
280
|
+
|
|
281
|
+
# Gzip compress payloads > 1KB for bandwidth efficiency
|
|
282
|
+
if len(payload_bytes) > 1024:
|
|
283
|
+
body = gzip.compress(payload_bytes, compresslevel=6)
|
|
284
|
+
headers = {"Content-Type": "application/json", "Content-Encoding": "gzip"}
|
|
285
|
+
else:
|
|
286
|
+
body = payload_bytes
|
|
287
|
+
headers = {"Content-Type": "application/json"}
|
|
288
|
+
|
|
289
|
+
response = self._get_session().post(
|
|
290
|
+
url,
|
|
291
|
+
data=body,
|
|
292
|
+
headers=headers,
|
|
293
|
+
timeout=self.timeout,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
# Auth errors - don't retry, raise immediately
|
|
297
|
+
if response.status_code == 401:
|
|
298
|
+
raise AuthenticationError("Invalid API key")
|
|
299
|
+
elif response.status_code == 403:
|
|
300
|
+
raise AuthenticationError("API key doesn't have write permissions")
|
|
301
|
+
|
|
302
|
+
# Bad request errors - don't retry (client error)
|
|
303
|
+
elif response.status_code in (400, 422):
|
|
304
|
+
raise PlexusError(
|
|
305
|
+
f"Bad request: {response.status_code} - {response.text}"
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
# Rate limit - retry with backoff
|
|
309
|
+
elif response.status_code == 429:
|
|
310
|
+
last_error = PlexusError("Rate limited (429)")
|
|
311
|
+
if attempt < self.retry_config.max_retries:
|
|
312
|
+
time.sleep(self.retry_config.get_delay(attempt))
|
|
313
|
+
continue
|
|
314
|
+
break
|
|
315
|
+
|
|
316
|
+
# Server errors - retry with backoff
|
|
317
|
+
elif response.status_code >= 500:
|
|
318
|
+
last_error = PlexusError(
|
|
319
|
+
f"Server error: {response.status_code} - {response.text}"
|
|
320
|
+
)
|
|
321
|
+
if attempt < self.retry_config.max_retries:
|
|
322
|
+
time.sleep(self.retry_config.get_delay(attempt))
|
|
323
|
+
continue
|
|
324
|
+
break
|
|
325
|
+
|
|
326
|
+
# Success - clear the buffer and return
|
|
327
|
+
elif response.status_code < 400:
|
|
328
|
+
self._clear_buffer()
|
|
329
|
+
return True
|
|
330
|
+
|
|
331
|
+
# Other 4xx errors - don't retry
|
|
332
|
+
else:
|
|
333
|
+
raise PlexusError(
|
|
334
|
+
f"API error: {response.status_code} - {response.text}"
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
except requests.exceptions.Timeout:
|
|
338
|
+
last_error = PlexusError(f"Request timed out after {self.timeout}s")
|
|
339
|
+
if attempt < self.retry_config.max_retries:
|
|
340
|
+
time.sleep(self.retry_config.get_delay(attempt))
|
|
341
|
+
continue
|
|
342
|
+
break
|
|
343
|
+
|
|
344
|
+
except requests.exceptions.ConnectionError as e:
|
|
345
|
+
last_error = PlexusError(f"Connection failed: {e}")
|
|
346
|
+
if attempt < self.retry_config.max_retries:
|
|
347
|
+
time.sleep(self.retry_config.get_delay(attempt))
|
|
348
|
+
continue
|
|
349
|
+
break
|
|
350
|
+
|
|
351
|
+
# All retries failed - buffer the points for later
|
|
352
|
+
self._add_to_buffer(points)
|
|
353
|
+
|
|
354
|
+
if last_error:
|
|
355
|
+
raise last_error
|
|
356
|
+
raise PlexusError("Send failed after all retries")
|
|
357
|
+
|
|
358
|
+
def _add_to_buffer(self, points: List[Dict[str, Any]]) -> None:
|
|
359
|
+
"""Add points to the local buffer for later retry."""
|
|
360
|
+
self._buffer.add(points)
|
|
361
|
+
|
|
362
|
+
def _get_buffered_points(self) -> List[Dict[str, Any]]:
|
|
363
|
+
"""Get a copy of buffered points without clearing."""
|
|
364
|
+
return self._buffer.get_all()
|
|
365
|
+
|
|
366
|
+
def _clear_buffer(self) -> None:
|
|
367
|
+
"""Clear the failed points buffer."""
|
|
368
|
+
self._buffer.clear()
|
|
369
|
+
|
|
370
|
+
def buffer_size(self) -> int:
|
|
371
|
+
"""Return the number of points currently buffered locally.
|
|
372
|
+
|
|
373
|
+
Points are buffered when sends fail after all retries.
|
|
374
|
+
They will be included in the next send attempt.
|
|
375
|
+
"""
|
|
376
|
+
return self._buffer.size()
|
|
377
|
+
|
|
378
|
+
def flush_buffer(self) -> bool:
|
|
379
|
+
"""Attempt to send all buffered points.
|
|
380
|
+
|
|
381
|
+
Returns:
|
|
382
|
+
True if buffer is empty (either was empty or successfully flushed)
|
|
383
|
+
|
|
384
|
+
Raises:
|
|
385
|
+
PlexusError: If flush fails (points remain in buffer)
|
|
386
|
+
"""
|
|
387
|
+
if self.buffer_size() == 0:
|
|
388
|
+
return True
|
|
389
|
+
|
|
390
|
+
# Send with empty new points list - will include buffered points
|
|
391
|
+
return self._send_points([])
|
|
392
|
+
|
|
393
|
+
@contextmanager
|
|
394
|
+
def run(self, run_id: str, tags: Optional[Dict[str, str]] = None, store_frames: bool = False):
|
|
395
|
+
"""
|
|
396
|
+
Context manager for recording a run.
|
|
397
|
+
|
|
398
|
+
All sends within this context will be tagged with the run ID,
|
|
399
|
+
making it easy to replay and analyze later.
|
|
400
|
+
|
|
401
|
+
Args:
|
|
402
|
+
run_id: Unique identifier for this run (e.g., "motor-test-001")
|
|
403
|
+
tags: Optional tags to apply to all points in this run
|
|
404
|
+
store_frames: If True, camera frames are uploaded to the Plexus API
|
|
405
|
+
for persistent storage alongside the live WebSocket stream.
|
|
406
|
+
|
|
407
|
+
Example:
|
|
408
|
+
with px.run("motor-test-001", store_frames=True):
|
|
409
|
+
while True:
|
|
410
|
+
px.send("temperature", read_temp())
|
|
411
|
+
time.sleep(0.01)
|
|
412
|
+
"""
|
|
413
|
+
self._run_id = run_id
|
|
414
|
+
self._store_frames = store_frames
|
|
415
|
+
|
|
416
|
+
# Notify API that run started
|
|
417
|
+
try:
|
|
418
|
+
self._get_session().post(
|
|
419
|
+
f"{self.endpoint}/api/runs",
|
|
420
|
+
json={
|
|
421
|
+
"run_id": run_id,
|
|
422
|
+
"source_id": self.source_id,
|
|
423
|
+
"status": "started",
|
|
424
|
+
"tags": tags,
|
|
425
|
+
"timestamp": time.time(),
|
|
426
|
+
},
|
|
427
|
+
timeout=self.timeout,
|
|
428
|
+
)
|
|
429
|
+
except Exception as e:
|
|
430
|
+
logger.debug(f"Run start notification failed: {e}")
|
|
431
|
+
|
|
432
|
+
try:
|
|
433
|
+
yield
|
|
434
|
+
finally:
|
|
435
|
+
# Notify API that run ended
|
|
436
|
+
try:
|
|
437
|
+
self._get_session().post(
|
|
438
|
+
f"{self.endpoint}/api/runs",
|
|
439
|
+
json={
|
|
440
|
+
"run_id": run_id,
|
|
441
|
+
"source_id": self.source_id,
|
|
442
|
+
"status": "ended",
|
|
443
|
+
"timestamp": time.time(),
|
|
444
|
+
},
|
|
445
|
+
timeout=self.timeout,
|
|
446
|
+
)
|
|
447
|
+
except Exception as e:
|
|
448
|
+
logger.debug(f"Run end notification failed: {e}")
|
|
449
|
+
self._run_id = None
|
|
450
|
+
self._store_frames = False
|
|
451
|
+
|
|
452
|
+
def close(self):
|
|
453
|
+
"""Close the client and release resources."""
|
|
454
|
+
if self._session:
|
|
455
|
+
self._session.close()
|
|
456
|
+
self._session = None
|
|
457
|
+
if hasattr(self._buffer, "close"):
|
|
458
|
+
self._buffer.close()
|
|
459
|
+
|
|
460
|
+
def __enter__(self):
|
|
461
|
+
return self
|
|
462
|
+
|
|
463
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
464
|
+
self.close()
|
|
465
|
+
return False
|
plexus/config.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration management for Plexus Agent.
|
|
3
|
+
|
|
4
|
+
Config is stored in ~/.plexus/config.json
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import random
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class RetryConfig:
|
|
17
|
+
"""Configuration for retry behavior with exponential backoff.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
max_retries: Maximum number of retry attempts. Default 3.
|
|
21
|
+
base_delay: Initial delay in seconds before first retry. Default 1.0.
|
|
22
|
+
max_delay: Maximum delay between retries in seconds. Default 30.0.
|
|
23
|
+
exponential_base: Base for exponential backoff calculation. Default 2.
|
|
24
|
+
jitter: Whether to add random jitter to delays. Default True.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
max_retries: int = 3
|
|
28
|
+
base_delay: float = 1.0
|
|
29
|
+
max_delay: float = 30.0
|
|
30
|
+
exponential_base: float = 2.0
|
|
31
|
+
jitter: bool = True
|
|
32
|
+
|
|
33
|
+
def get_delay(self, attempt: int) -> float:
|
|
34
|
+
"""Calculate delay for a given retry attempt (0-indexed).
|
|
35
|
+
|
|
36
|
+
Uses exponential backoff: delay = base_delay * (exponential_base ** attempt)
|
|
37
|
+
With optional jitter to prevent thundering herd.
|
|
38
|
+
"""
|
|
39
|
+
delay = self.base_delay * (self.exponential_base ** attempt)
|
|
40
|
+
delay = min(delay, self.max_delay)
|
|
41
|
+
|
|
42
|
+
if self.jitter:
|
|
43
|
+
# Add jitter: random value between 0 and delay
|
|
44
|
+
delay = delay * (0.5 + random.random() * 0.5)
|
|
45
|
+
|
|
46
|
+
return delay
|
|
47
|
+
|
|
48
|
+
CONFIG_DIR = Path.home() / ".plexus"
|
|
49
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
50
|
+
|
|
51
|
+
PLEXUS_ENDPOINT = "https://app.plexus.company"
|
|
52
|
+
PLEXUS_GATEWAY_URL = "https://plexus-gateway.fly.dev"
|
|
53
|
+
|
|
54
|
+
DEFAULT_CONFIG = {
|
|
55
|
+
"api_key": None,
|
|
56
|
+
"source_id": None,
|
|
57
|
+
"org_id": None,
|
|
58
|
+
"source_name": None,
|
|
59
|
+
"endpoint": None,
|
|
60
|
+
"persistent_buffer": True,
|
|
61
|
+
"sensors": None,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
def get_config_path() -> Path:
|
|
65
|
+
"""Get the path to the config file."""
|
|
66
|
+
return CONFIG_FILE
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def load_config() -> dict:
|
|
70
|
+
"""Load config from file, creating defaults if needed."""
|
|
71
|
+
if not CONFIG_FILE.exists():
|
|
72
|
+
return DEFAULT_CONFIG.copy()
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
with open(CONFIG_FILE, "r") as f:
|
|
76
|
+
config = json.load(f)
|
|
77
|
+
# Merge with defaults to handle missing keys
|
|
78
|
+
return {**DEFAULT_CONFIG, **config}
|
|
79
|
+
except (json.JSONDecodeError, IOError):
|
|
80
|
+
return DEFAULT_CONFIG.copy()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def save_config(config: dict) -> None:
|
|
84
|
+
"""Save config to file."""
|
|
85
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
try:
|
|
87
|
+
os.chmod(CONFIG_DIR, 0o700)
|
|
88
|
+
except OSError:
|
|
89
|
+
pass # Windows or restricted filesystem
|
|
90
|
+
with open(CONFIG_FILE, "w") as f:
|
|
91
|
+
json.dump(config, f, indent=2)
|
|
92
|
+
# Set restrictive permissions (API key is sensitive)
|
|
93
|
+
os.chmod(CONFIG_FILE, 0o600)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def get_api_key() -> Optional[str]:
|
|
97
|
+
"""Get API key from config or environment variable."""
|
|
98
|
+
# Environment variable takes precedence
|
|
99
|
+
env_key = os.environ.get("PLEXUS_API_KEY")
|
|
100
|
+
if env_key:
|
|
101
|
+
return env_key
|
|
102
|
+
|
|
103
|
+
config = load_config()
|
|
104
|
+
return config.get("api_key")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def get_endpoint() -> str:
|
|
108
|
+
"""Get the API endpoint URL."""
|
|
109
|
+
# Environment variable takes precedence
|
|
110
|
+
env_endpoint = os.environ.get("PLEXUS_ENDPOINT")
|
|
111
|
+
if env_endpoint:
|
|
112
|
+
return env_endpoint
|
|
113
|
+
|
|
114
|
+
# Check config file (use default if value is None/empty)
|
|
115
|
+
config = load_config()
|
|
116
|
+
return config.get("endpoint") or PLEXUS_ENDPOINT
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def get_gateway_url() -> str:
|
|
120
|
+
"""Get the ingest gateway base URL (POST /ingest, GET /ws/device)."""
|
|
121
|
+
env_gateway = os.environ.get("PLEXUS_GATEWAY_URL")
|
|
122
|
+
if env_gateway:
|
|
123
|
+
return env_gateway.rstrip("/")
|
|
124
|
+
config = load_config()
|
|
125
|
+
return (config.get("gateway_url") or PLEXUS_GATEWAY_URL).rstrip("/")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def get_source_id() -> Optional[str]:
|
|
129
|
+
"""Get the source ID, generating one if not set."""
|
|
130
|
+
config = load_config()
|
|
131
|
+
source_id = config.get("source_id")
|
|
132
|
+
|
|
133
|
+
if not source_id:
|
|
134
|
+
import uuid
|
|
135
|
+
source_id = f"source-{uuid.uuid4().hex[:8]}"
|
|
136
|
+
config["source_id"] = source_id
|
|
137
|
+
save_config(config)
|
|
138
|
+
|
|
139
|
+
return source_id
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def get_org_id() -> Optional[str]:
|
|
143
|
+
"""Get the organization ID from config or environment variable."""
|
|
144
|
+
# Environment variable takes precedence
|
|
145
|
+
env_org = os.environ.get("PLEXUS_ORG_ID")
|
|
146
|
+
if env_org:
|
|
147
|
+
return env_org
|
|
148
|
+
|
|
149
|
+
config = load_config()
|
|
150
|
+
return config.get("org_id")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def is_logged_in() -> bool:
|
|
154
|
+
"""Check if device is authenticated (has API key)."""
|
|
155
|
+
return get_api_key() is not None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def require_login() -> None:
|
|
159
|
+
"""Raise an error if not logged in."""
|
|
160
|
+
if not is_logged_in():
|
|
161
|
+
raise RuntimeError(
|
|
162
|
+
"Not logged in. Run 'plexus start' to connect your account."
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def get_persistent_buffer() -> bool:
|
|
167
|
+
"""Get persistent buffer setting. Default True (store-and-forward enabled)."""
|
|
168
|
+
config = load_config()
|
|
169
|
+
return config.get("persistent_buffer", True)
|