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.
Files changed (50) hide show
  1. plexus/__init__.py +31 -0
  2. plexus/__main__.py +4 -0
  3. plexus/adapters/__init__.py +122 -0
  4. plexus/adapters/base.py +409 -0
  5. plexus/adapters/ble.py +257 -0
  6. plexus/adapters/can.py +439 -0
  7. plexus/adapters/can_detect.py +174 -0
  8. plexus/adapters/mavlink.py +642 -0
  9. plexus/adapters/mavlink_detect.py +192 -0
  10. plexus/adapters/modbus.py +622 -0
  11. plexus/adapters/mqtt.py +350 -0
  12. plexus/adapters/opcua.py +607 -0
  13. plexus/adapters/registry.py +206 -0
  14. plexus/adapters/serial_adapter.py +547 -0
  15. plexus/buffer.py +257 -0
  16. plexus/cameras/__init__.py +57 -0
  17. plexus/cameras/auto.py +239 -0
  18. plexus/cameras/base.py +189 -0
  19. plexus/cameras/picamera.py +171 -0
  20. plexus/cameras/usb.py +143 -0
  21. plexus/cli.py +783 -0
  22. plexus/client.py +465 -0
  23. plexus/config.py +169 -0
  24. plexus/connector.py +666 -0
  25. plexus/deps.py +246 -0
  26. plexus/detect.py +1238 -0
  27. plexus/importers/__init__.py +25 -0
  28. plexus/importers/rosbag.py +778 -0
  29. plexus/sensors/__init__.py +118 -0
  30. plexus/sensors/ads1115.py +164 -0
  31. plexus/sensors/adxl345.py +179 -0
  32. plexus/sensors/auto.py +290 -0
  33. plexus/sensors/base.py +412 -0
  34. plexus/sensors/bh1750.py +102 -0
  35. plexus/sensors/bme280.py +241 -0
  36. plexus/sensors/gps.py +317 -0
  37. plexus/sensors/ina219.py +149 -0
  38. plexus/sensors/magnetometer.py +239 -0
  39. plexus/sensors/mpu6050.py +162 -0
  40. plexus/sensors/sht3x.py +139 -0
  41. plexus/sensors/spi_scan.py +164 -0
  42. plexus/sensors/system.py +261 -0
  43. plexus/sensors/vl53l0x.py +109 -0
  44. plexus/streaming.py +743 -0
  45. plexus/tui.py +642 -0
  46. plexus_python-0.1.0.dist-info/METADATA +470 -0
  47. plexus_python-0.1.0.dist-info/RECORD +50 -0
  48. plexus_python-0.1.0.dist-info/WHEEL +4 -0
  49. plexus_python-0.1.0.dist-info/entry_points.txt +2 -0
  50. 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)