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/__init__.py ADDED
@@ -0,0 +1,31 @@
1
+ """
2
+ Plexus Agent - Send sensor data to Plexus in one line of code.
3
+
4
+ Basic Usage:
5
+ from plexus import Plexus
6
+
7
+ px = Plexus()
8
+ px.send("temperature", 72.5)
9
+
10
+ With Sensors (pip install plexus-python[sensors]):
11
+ from plexus import Plexus
12
+ from plexus.sensors import SensorHub, MPU6050, BME280
13
+
14
+ hub = SensorHub()
15
+ hub.add(MPU6050(sample_rate=100))
16
+ hub.add(BME280(sample_rate=1))
17
+ hub.run(Plexus())
18
+
19
+ Auto-Detection:
20
+ from plexus import Plexus
21
+ from plexus.sensors import auto_sensors
22
+
23
+ hub = auto_sensors() # Finds all connected sensors
24
+ hub.run(Plexus())
25
+ """
26
+
27
+ from plexus.client import Plexus
28
+ from plexus.config import load_config, save_config
29
+
30
+ __version__ = "0.1.0"
31
+ __all__ = ["Plexus", "load_config", "save_config"]
plexus/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ """Allow running plexus as: python3 -m plexus"""
2
+ from plexus.cli import main
3
+
4
+ main()
@@ -0,0 +1,122 @@
1
+ """
2
+ Protocol Adapters - Extensible protocol support for Plexus
3
+
4
+ This module provides a plugin system for protocol adapters, enabling
5
+ Plexus to ingest data from any protocol without modifying core code.
6
+
7
+ Built-in adapters:
8
+ - MQTTAdapter: Bridge MQTT brokers to Plexus
9
+ - CANAdapter: CAN bus with DBC signal decoding
10
+ - MAVLinkAdapter: MAVLink for drones and autonomous vehicles
11
+ - OPCUAAdapter: OPC-UA client for industrial automation servers
12
+ - SerialAdapter: Serial port (USB/UART/RS-232/RS-485) reader
13
+
14
+ Usage:
15
+ from plexus.adapters import MQTTAdapter, CANAdapter, MAVLinkAdapter, AdapterRegistry
16
+
17
+ # Use built-in adapter
18
+ adapter = MQTTAdapter(broker="localhost", topic="sensors/#")
19
+ adapter.connect()
20
+ adapter.run(on_data=my_callback)
21
+
22
+ # CAN bus adapter
23
+ adapter = CANAdapter(interface="socketcan", channel="can0", dbc_path="vehicle.dbc")
24
+ with adapter:
25
+ for metric in adapter.poll():
26
+ print(f"{metric.name}: {metric.value}")
27
+
28
+ # MAVLink adapter
29
+ adapter = MAVLinkAdapter(connection_string="udpin:0.0.0.0:14550")
30
+ with adapter:
31
+ for metric in adapter.poll():
32
+ print(f"{metric.name}: {metric.value}")
33
+
34
+ # Create custom adapter
35
+ from plexus.adapters import ProtocolAdapter, Metric
36
+
37
+ class MyProtocolAdapter(ProtocolAdapter):
38
+ def connect(self) -> bool:
39
+ # Connect to your protocol
40
+ return True
41
+
42
+ def poll(self) -> "List[Metric]":
43
+ # Read data and return metrics
44
+ return [Metric("sensor.temp", 72.5)]
45
+
46
+ # Register custom adapter
47
+ AdapterRegistry.register("my-protocol", MyProtocolAdapter)
48
+ """
49
+
50
+ from plexus.adapters.base import (
51
+ ProtocolAdapter,
52
+ Metric,
53
+ AdapterConfig,
54
+ AdapterState,
55
+ AdapterError,
56
+ )
57
+ from plexus.adapters.registry import AdapterRegistry
58
+ from plexus.adapters.mqtt import MQTTAdapter
59
+
60
+ # Import CANAdapter (requires optional [can] extra)
61
+ try:
62
+ from plexus.adapters.can import CANAdapter
63
+ _HAS_CAN = True
64
+ except ImportError:
65
+ CANAdapter = None # type: ignore
66
+ _HAS_CAN = False
67
+
68
+ # Import ModbusAdapter (requires optional [modbus] extra)
69
+ try:
70
+ from plexus.adapters.modbus import ModbusAdapter
71
+ _HAS_MODBUS = True
72
+ except ImportError:
73
+ ModbusAdapter = None # type: ignore
74
+ _HAS_MODBUS = False
75
+
76
+ # Import MAVLinkAdapter (requires optional [mavlink] extra)
77
+ try:
78
+ from plexus.adapters.mavlink import MAVLinkAdapter
79
+ _HAS_MAVLINK = True
80
+ except ImportError:
81
+ MAVLinkAdapter = None # type: ignore
82
+ _HAS_MAVLINK = False
83
+
84
+ # Import OPCUAAdapter (requires optional [opcua] extra)
85
+ try:
86
+ from plexus.adapters.opcua import OPCUAAdapter
87
+ _HAS_OPCUA = True
88
+ except ImportError:
89
+ OPCUAAdapter = None # type: ignore
90
+ _HAS_OPCUA = False
91
+
92
+ # Import SerialAdapter (requires optional [serial] extra)
93
+ try:
94
+ from plexus.adapters.serial_adapter import SerialAdapter
95
+ _HAS_SERIAL = True
96
+ except ImportError:
97
+ SerialAdapter = None # type: ignore
98
+ _HAS_SERIAL = False
99
+
100
+ # Import BLERelayAdapter (requires optional [ble] extra)
101
+ try:
102
+ from plexus.adapters.ble import BLERelayAdapter
103
+ _HAS_BLE = True
104
+ except ImportError:
105
+ BLERelayAdapter = None # type: ignore
106
+ _HAS_BLE = False
107
+
108
+ __all__ = [
109
+ "ProtocolAdapter",
110
+ "Metric",
111
+ "AdapterConfig",
112
+ "AdapterState",
113
+ "AdapterError",
114
+ "AdapterRegistry",
115
+ "MQTTAdapter",
116
+ "CANAdapter",
117
+ "ModbusAdapter",
118
+ "MAVLinkAdapter",
119
+ "OPCUAAdapter",
120
+ "SerialAdapter",
121
+ "BLERelayAdapter",
122
+ ]
@@ -0,0 +1,409 @@
1
+ """
2
+ Base Protocol Adapter - Abstract interface for all protocol adapters
3
+
4
+ This module defines the interface that all protocol adapters must implement.
5
+ By following this interface, new protocols can be added without modifying
6
+ the core Plexus codebase.
7
+
8
+ Example custom adapter:
9
+
10
+ from plexus.adapters import ProtocolAdapter, Metric, AdapterConfig
11
+
12
+ class SerialAdapter(ProtocolAdapter):
13
+ '''Adapter for serial port communication'''
14
+
15
+ def __init__(self, port: str, baudrate: int = 9600, **kwargs):
16
+ config = AdapterConfig(
17
+ name="serial",
18
+ params={"port": port, "baudrate": baudrate, **kwargs}
19
+ )
20
+ super().__init__(config)
21
+ self.port = port
22
+ self.baudrate = baudrate
23
+ self._serial = None
24
+
25
+ def connect(self) -> bool:
26
+ import serial
27
+ self._serial = serial.Serial(self.port, self.baudrate)
28
+ return self._serial.is_open
29
+
30
+ def disconnect(self) -> None:
31
+ if self._serial:
32
+ self._serial.close()
33
+
34
+ def poll(self) -> "List[Metric]":
35
+ line = self._serial.readline().decode().strip()
36
+ # Parse your protocol format
37
+ metric, value = line.split(":")
38
+ return [Metric(metric, float(value))]
39
+ """
40
+
41
+ import logging
42
+ import random
43
+ from abc import ABC, abstractmethod
44
+ from dataclasses import dataclass, field
45
+ from enum import Enum
46
+ from typing import Any, Callable, Dict, List, Optional, Union
47
+ import time
48
+
49
+ logger = logging.getLogger(__name__)
50
+
51
+
52
+ class AdapterState(Enum):
53
+ """Adapter connection state"""
54
+ DISCONNECTED = "disconnected"
55
+ CONNECTING = "connecting"
56
+ CONNECTED = "connected"
57
+ ERROR = "error"
58
+ RECONNECTING = "reconnecting"
59
+
60
+
61
+ class AdapterError(Exception):
62
+ """Base exception for adapter errors"""
63
+ pass
64
+
65
+
66
+ class ConnectionError(AdapterError):
67
+ """Raised when connection fails"""
68
+ pass
69
+
70
+
71
+ class ProtocolError(AdapterError):
72
+ """Raised when protocol-specific error occurs"""
73
+ pass
74
+
75
+
76
+ @dataclass
77
+ class AdapterConfig:
78
+ """Configuration for a protocol adapter"""
79
+ name: str
80
+ params: Dict[str, Any] = field(default_factory=dict)
81
+
82
+ # Connection settings
83
+ auto_reconnect: bool = True
84
+ reconnect_interval: float = 5.0
85
+ max_reconnect_attempts: int = 10
86
+
87
+ # Data settings
88
+ batch_size: int = 100
89
+ flush_interval: float = 1.0
90
+
91
+
92
+ @dataclass
93
+ class Metric:
94
+ """
95
+ A single metric data point.
96
+
97
+ This is the universal format that all adapters produce.
98
+ The Plexus client will convert these to the ingest API format.
99
+
100
+ Args:
101
+ name: Metric name (e.g., "temperature", "motor.rpm", "robot.state")
102
+ value: The value - can be number, string, bool, dict, or list
103
+ timestamp: Unix timestamp (seconds). If None, current time is used.
104
+ tags: Optional key-value metadata
105
+ source_id: Optional source identifier
106
+ data_class: Pipeline data class - "metric" (default), "event", or "blob"
107
+
108
+ Examples:
109
+ Metric("temperature", 72.5)
110
+ Metric("robot.state", "MOVING")
111
+ Metric("position", {"x": 1.5, "y": 2.3, "z": 0.0})
112
+ Metric("joint_angles", [0.5, 1.2, -0.3, 0.0])
113
+ """
114
+ name: str
115
+ value: Union[int, float, str, bool, Dict[str, Any], List[Any]]
116
+ timestamp: Optional[float] = None
117
+ tags: Optional[Dict[str, str]] = None
118
+ source_id: Optional[str] = None
119
+ data_class: str = "metric"
120
+
121
+ def __post_init__(self):
122
+ if self.timestamp is None:
123
+ self.timestamp = time.time()
124
+
125
+ def to_dict(self) -> Dict[str, Any]:
126
+ """Convert to dictionary for API submission"""
127
+ result = {
128
+ "class": self.data_class,
129
+ "metric": self.name,
130
+ "value": self.value,
131
+ "timestamp": self.timestamp,
132
+ }
133
+ if self.tags:
134
+ result["tags"] = self.tags
135
+ if self.source_id:
136
+ result["source_id"] = self.source_id
137
+ return result
138
+
139
+
140
+ # Type alias for data callback
141
+ DataCallback = Callable[[List[Metric]], None]
142
+
143
+
144
+ class ProtocolAdapter(ABC):
145
+ """
146
+ Abstract base class for protocol adapters.
147
+
148
+ All protocol adapters must inherit from this class and implement
149
+ the required abstract methods.
150
+
151
+ Lifecycle:
152
+ 1. __init__() - Configure the adapter
153
+ 2. connect() - Establish connection
154
+ 3. run() or poll() - Receive data
155
+ 4. disconnect() - Clean up
156
+
157
+ Two modes of operation:
158
+ - Push mode: Override on_data() or pass callback to run()
159
+ - Pull mode: Call poll() periodically to get data
160
+ """
161
+
162
+ def __init__(self, config: AdapterConfig):
163
+ self.config = config
164
+ self._state = AdapterState.DISCONNECTED
165
+ self._error: Optional[str] = None
166
+ self._metrics_received = 0
167
+ self._last_data_time: Optional[float] = None
168
+ self._on_data_callback: Optional[DataCallback] = None
169
+ self._on_state_change: Optional[Callable[[AdapterState], None]] = None
170
+ self._on_error_report: Optional[Any] = None # async fn(source, error, severity)
171
+
172
+ @property
173
+ def state(self) -> AdapterState:
174
+ """Current adapter state"""
175
+ return self._state
176
+
177
+ @property
178
+ def is_connected(self) -> bool:
179
+ """Whether adapter is connected"""
180
+ return self._state == AdapterState.CONNECTED
181
+
182
+ @property
183
+ def error(self) -> Optional[str]:
184
+ """Last error message, if any"""
185
+ return self._error
186
+
187
+ @property
188
+ def stats(self) -> Dict[str, Any]:
189
+ """Adapter statistics"""
190
+ return {
191
+ "state": self._state.value,
192
+ "metrics_received": self._metrics_received,
193
+ "last_data_time": self._last_data_time,
194
+ "error": self._error,
195
+ }
196
+
197
+ def _set_state(self, state: AdapterState, error: Optional[str] = None):
198
+ """Update adapter state and notify listeners"""
199
+ old_state = self._state
200
+ self._state = state
201
+ self._error = error
202
+ if self._on_state_change:
203
+ self._on_state_change(state)
204
+ # Report transitions to ERROR or RECONNECTING to dashboard
205
+ if state in (AdapterState.ERROR, AdapterState.RECONNECTING) and old_state != state:
206
+ if self._on_error_report:
207
+ import asyncio
208
+ severity = "error" if state == AdapterState.ERROR else "warning"
209
+ msg = error or f"Adapter {self.config.name} entered {state.value}"
210
+ try:
211
+ coro = self._on_error_report(
212
+ f"adapter.{self.config.name}", msg, severity
213
+ )
214
+ # Fire-and-forget if there's a running loop
215
+ loop = asyncio.get_event_loop()
216
+ if loop.is_running():
217
+ asyncio.ensure_future(coro)
218
+ else:
219
+ loop.run_until_complete(coro)
220
+ except Exception:
221
+ pass
222
+
223
+ def _emit_data(self, metrics: List[Metric]):
224
+ """Emit data to callback"""
225
+ if metrics:
226
+ self._metrics_received += len(metrics)
227
+ self._last_data_time = time.time()
228
+ if self._on_data_callback:
229
+ self._on_data_callback(metrics)
230
+
231
+ # =========================================================================
232
+ # Auto-Reconnection
233
+ # =========================================================================
234
+
235
+ def _reconnect_loop(self, max_attempts: Optional[int] = None):
236
+ """Exponential backoff reconnection.
237
+
238
+ Uses config values if max_attempts is not specified.
239
+ """
240
+ if max_attempts is None:
241
+ max_attempts = self.config.max_reconnect_attempts
242
+
243
+ attempt = 0
244
+ delay = self.config.reconnect_interval
245
+
246
+ while max_attempts is None or attempt < max_attempts:
247
+ attempt += 1
248
+ jitter = random.uniform(0.75, 1.25)
249
+ time.sleep(delay * jitter)
250
+
251
+ try:
252
+ self.disconnect()
253
+ result = self.connect()
254
+ if result:
255
+ logger.info(
256
+ "%s: reconnected after %d attempt(s)",
257
+ self.config.name, attempt,
258
+ )
259
+ return True
260
+ except Exception as e:
261
+ logger.warning(
262
+ "%s: reconnect attempt %d failed: %s",
263
+ self.config.name, attempt, e,
264
+ )
265
+
266
+ delay = min(delay * 2, 60.0)
267
+
268
+ self._set_state(AdapterState.ERROR, "Max reconnect attempts reached")
269
+ return False
270
+
271
+ # =========================================================================
272
+ # Abstract methods - MUST be implemented by subclasses
273
+ # =========================================================================
274
+
275
+ @abstractmethod
276
+ def connect(self) -> bool:
277
+ """
278
+ Establish connection to the data source.
279
+
280
+ Returns:
281
+ True if connection successful, False otherwise.
282
+
283
+ Raises:
284
+ ConnectionError: If connection fails with an error.
285
+ """
286
+ pass
287
+
288
+ @abstractmethod
289
+ def disconnect(self) -> None:
290
+ """
291
+ Close connection and clean up resources.
292
+
293
+ This should be idempotent - calling it multiple times should be safe.
294
+ """
295
+ pass
296
+
297
+ @abstractmethod
298
+ def poll(self) -> List[Metric]:
299
+ """
300
+ Poll for new data (pull mode).
301
+
302
+ Returns:
303
+ List of Metric objects. Empty list if no new data.
304
+
305
+ Raises:
306
+ ProtocolError: If reading data fails.
307
+
308
+ Note:
309
+ For push-based protocols (like MQTT), this may return an empty
310
+ list and data will arrive via the callback instead.
311
+ """
312
+ pass
313
+
314
+ # =========================================================================
315
+ # Optional methods - MAY be overridden by subclasses
316
+ # =========================================================================
317
+
318
+ def validate_config(self) -> bool:
319
+ """
320
+ Validate adapter configuration.
321
+
322
+ Override this to add protocol-specific validation.
323
+
324
+ Returns:
325
+ True if config is valid.
326
+
327
+ Raises:
328
+ ValueError: If config is invalid.
329
+ """
330
+ return True
331
+
332
+ def on_data(self, metrics: List[Metric]) -> None:
333
+ """
334
+ Handle incoming data (push mode).
335
+
336
+ Override this for custom data handling, or pass a callback to run().
337
+
338
+ Args:
339
+ metrics: List of received metrics.
340
+ """
341
+ pass
342
+
343
+ def run(
344
+ self,
345
+ on_data: Optional[DataCallback] = None,
346
+ on_state_change: Optional[Callable[[AdapterState], None]] = None,
347
+ blocking: bool = True,
348
+ ) -> None:
349
+ """
350
+ Run the adapter (main loop for push-based protocols).
351
+
352
+ Args:
353
+ on_data: Callback for received data. If None, on_data() method is used.
354
+ on_state_change: Callback for state changes.
355
+ blocking: If True, blocks until stopped. If False, returns immediately.
356
+
357
+ For pull-based protocols, this starts a polling loop.
358
+ For push-based protocols, this starts listening for events.
359
+ """
360
+ self._on_data_callback = on_data
361
+ self._on_state_change = on_state_change
362
+
363
+ if not self.is_connected:
364
+ if not self.connect():
365
+ raise ConnectionError(f"Failed to connect: {self._error}")
366
+
367
+ if blocking:
368
+ self._run_loop()
369
+
370
+ def _run_loop(self) -> None:
371
+ """
372
+ Default run loop implementation (polling mode).
373
+
374
+ Override this for push-based protocols like MQTT.
375
+ Handles disconnect detection and auto-reconnection.
376
+ """
377
+ try:
378
+ while True:
379
+ try:
380
+ while self.is_connected:
381
+ metrics = self.poll()
382
+ if metrics:
383
+ self._emit_data(metrics)
384
+ self.on_data(metrics)
385
+ time.sleep(0.01) # 100 Hz max
386
+ except (ConnectionError, OSError) as e:
387
+ logger.warning("%s: connection lost: %s", self.config.name, e)
388
+ self._set_state(AdapterState.RECONNECTING, str(e))
389
+ if self.config.auto_reconnect:
390
+ if self._reconnect_loop():
391
+ continue
392
+ break
393
+
394
+ # Normal exit (is_connected went False)
395
+ break
396
+ except KeyboardInterrupt:
397
+ pass
398
+ finally:
399
+ self.disconnect()
400
+
401
+ def __enter__(self):
402
+ """Context manager entry"""
403
+ self.connect()
404
+ return self
405
+
406
+ def __exit__(self, exc_type, exc_val, exc_tb):
407
+ """Context manager exit"""
408
+ self.disconnect()
409
+ return False