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
@@ -0,0 +1,547 @@
1
+ """
2
+ Serial Port Adapter - Read serial data from devices into Plexus
3
+
4
+ This adapter reads data from serial ports (USB, UART, RS-232/485) and
5
+ parses incoming lines into Plexus metrics. Supports multiple parsing
6
+ formats for common embedded device output patterns.
7
+
8
+ Requirements:
9
+ pip install plexus-python[serial]
10
+ # or
11
+ pip install pyserial
12
+
13
+ Usage:
14
+ from plexus.adapters import SerialAdapter
15
+
16
+ # Basic usage - parse "metric:value" lines
17
+ adapter = SerialAdapter(port="/dev/ttyUSB0", baudrate=115200)
18
+ adapter.connect()
19
+ for metric in adapter.poll():
20
+ print(f"{metric.name}: {metric.value}")
21
+
22
+ # JSON mode - parse {"temp": 22.5, "humidity": 45} lines
23
+ adapter = SerialAdapter(
24
+ port="/dev/ttyACM0",
25
+ baudrate=9600,
26
+ parser="json",
27
+ )
28
+
29
+ # CSV mode - first line is headers, subsequent lines are values
30
+ # e.g., "temp,humidity,pressure\\n22.5,45,1013.2"
31
+ adapter = SerialAdapter(
32
+ port="COM3",
33
+ baudrate=115200,
34
+ parser="csv",
35
+ )
36
+
37
+ # Run with callback
38
+ def handle_data(metrics):
39
+ for m in metrics:
40
+ print(f"{m.name}: {m.value}")
41
+
42
+ adapter.run(on_data=handle_data)
43
+
44
+ Emitted metrics:
45
+ - serial.{metric_name} - Parsed metric with configurable prefix
46
+
47
+ Parser formats:
48
+ - "line": Expects "metric_name:value" per line (default)
49
+ - "json": Expects a JSON object per line; each key becomes a metric
50
+ - "csv": First line is comma-separated headers, subsequent lines are values
51
+
52
+ Requires: pip install pyserial
53
+ """
54
+
55
+ import json
56
+ import logging
57
+ import time
58
+ from typing import Any, Dict, List, Optional
59
+
60
+ from plexus.adapters.base import (
61
+ ProtocolAdapter,
62
+ Metric,
63
+ AdapterConfig,
64
+ AdapterState,
65
+ ConnectionError,
66
+ ProtocolError,
67
+ )
68
+ from plexus.adapters.registry import AdapterRegistry
69
+
70
+ logger = logging.getLogger(__name__)
71
+
72
+ # Optional dependency — imported at module level so it can be mocked in tests
73
+ try:
74
+ import serial as pyserial
75
+ except ImportError:
76
+ pyserial = None # type: ignore[assignment]
77
+
78
+
79
+ class SerialAdapter(ProtocolAdapter):
80
+ """
81
+ Serial port protocol adapter.
82
+
83
+ Reads lines from a serial port and parses them into Plexus metrics.
84
+ Three parser modes are supported:
85
+
86
+ **line** (default): Each line contains "metric_name:value". The metric
87
+ name and numeric value are split on the colon. Non-numeric values are
88
+ forwarded as strings.
89
+
90
+ Example input::
91
+
92
+ temperature:22.5
93
+ humidity:45
94
+ status:OK
95
+
96
+ **json**: Each line is a JSON object. Every key-value pair in the object
97
+ becomes a separate metric.
98
+
99
+ Example input::
100
+
101
+ {"temperature": 22.5, "humidity": 45}
102
+
103
+ **csv**: The first line received is treated as a comma-separated header
104
+ row. All subsequent lines are parsed as values matching those headers.
105
+
106
+ Example input::
107
+
108
+ temperature,humidity,pressure
109
+ 22.5,45,1013.2
110
+ 22.6,44,1013.1
111
+
112
+ Args:
113
+ port: Serial port path (e.g., "/dev/ttyUSB0", "COM3")
114
+ baudrate: Baud rate (default: 9600)
115
+ parser: Parsing mode - "line", "json", or "csv" (default: "line")
116
+ line_ending: Line ending character(s) (default: "\\n")
117
+ prefix: Prefix prepended to all metric names (default: "serial.")
118
+ timeout: Serial read timeout in seconds (default: 1.0)
119
+ source_id: Optional source identifier attached to all emitted metrics
120
+
121
+ Example:
122
+ adapter = SerialAdapter(
123
+ port="/dev/ttyUSB0",
124
+ baudrate=115200,
125
+ parser="json",
126
+ )
127
+
128
+ with adapter:
129
+ while True:
130
+ for metric in adapter.poll():
131
+ print(f"{metric.name} = {metric.value}")
132
+ """
133
+
134
+ def __init__(
135
+ self,
136
+ port: str = "/dev/ttyUSB0",
137
+ baudrate: int = 9600,
138
+ parser: str = "line",
139
+ line_ending: str = "\n",
140
+ prefix: str = "serial.",
141
+ timeout: float = 1.0,
142
+ source_id: Optional[str] = None,
143
+ **kwargs,
144
+ ):
145
+ config = AdapterConfig(
146
+ name="serial",
147
+ params={
148
+ "port": port,
149
+ "baudrate": baudrate,
150
+ "parser": parser,
151
+ "line_ending": line_ending,
152
+ "prefix": prefix,
153
+ **kwargs,
154
+ },
155
+ )
156
+ super().__init__(config)
157
+
158
+ self.port = port
159
+ self.baudrate = baudrate
160
+ self.parser = parser
161
+ self.line_ending = line_ending
162
+ self.prefix = prefix
163
+ self.timeout = timeout
164
+ self._source_id = source_id
165
+
166
+ self._serial: Optional[Any] = None # serial.Serial instance
167
+ self._csv_headers: Optional[List[str]] = None
168
+ self._read_buffer: str = ""
169
+
170
+ def validate_config(self) -> bool:
171
+ """Validate adapter configuration."""
172
+ if not self.port:
173
+ raise ValueError("Serial port is required")
174
+
175
+ valid_parsers = ["line", "json", "csv"]
176
+ if self.parser not in valid_parsers:
177
+ raise ValueError(
178
+ f"Invalid parser '{self.parser}'. "
179
+ f"Valid parsers: {', '.join(valid_parsers)}"
180
+ )
181
+
182
+ if self.baudrate <= 0:
183
+ raise ValueError("Baud rate must be positive")
184
+
185
+ return True
186
+
187
+ def connect(self) -> bool:
188
+ """
189
+ Open the serial port.
190
+
191
+ Returns:
192
+ True if the port was opened successfully, False otherwise.
193
+
194
+ Raises:
195
+ ConnectionError: If pyserial is not installed or the port
196
+ cannot be opened.
197
+ """
198
+ if pyserial is None:
199
+ self._set_state(AdapterState.ERROR, "pyserial not installed")
200
+ raise ConnectionError(
201
+ "pyserial is required. Install with: pip install plexus-python[serial] "
202
+ "or pip install pyserial"
203
+ )
204
+
205
+ try:
206
+ self._set_state(AdapterState.CONNECTING)
207
+ logger.info(
208
+ f"Opening serial port: {self.port} at {self.baudrate} baud"
209
+ )
210
+
211
+ self._serial = pyserial.Serial(
212
+ port=self.port,
213
+ baudrate=self.baudrate,
214
+ timeout=self.timeout,
215
+ )
216
+
217
+ # Reset CSV headers for a fresh connection
218
+ self._csv_headers = None
219
+ self._read_buffer = ""
220
+
221
+ self._set_state(AdapterState.CONNECTED)
222
+ logger.info(f"Serial port opened: {self.port}")
223
+ return True
224
+
225
+ except Exception as e:
226
+ self._set_state(AdapterState.ERROR, str(e))
227
+ logger.error(f"Failed to open serial port: {e}")
228
+ raise ConnectionError(f"Serial connection failed: {e}")
229
+
230
+ def disconnect(self) -> None:
231
+ """Close the serial port."""
232
+ if self._serial:
233
+ try:
234
+ self._serial.close()
235
+ logger.info(f"Serial port closed: {self.port}")
236
+ except Exception as e:
237
+ logger.warning(f"Error closing serial port: {e}")
238
+ finally:
239
+ self._serial = None
240
+
241
+ self._csv_headers = None
242
+ self._read_buffer = ""
243
+ self._set_state(AdapterState.DISCONNECTED)
244
+
245
+ def poll(self) -> List[Metric]:
246
+ """
247
+ Read available lines from the serial port and parse them into metrics.
248
+
249
+ Reads all available data from the serial buffer, splits into complete
250
+ lines, and parses each line according to the configured parser mode.
251
+
252
+ Returns:
253
+ List of Metric objects. Empty list if no complete lines available.
254
+
255
+ Raises:
256
+ OSError: On port disconnect (triggers auto-reconnect in run loop).
257
+ ProtocolError: If reading data fails.
258
+ """
259
+ if not self._serial or not self._serial.is_open:
260
+ return []
261
+
262
+ metrics: List[Metric] = []
263
+
264
+ try:
265
+ lines = self._read_lines()
266
+ for line in lines:
267
+ parsed = self._parse_line(line)
268
+ metrics.extend(parsed)
269
+ except OSError:
270
+ raise # Let run loop handle disconnect/reconnect
271
+ except Exception as e:
272
+ logger.error(f"Error reading from serial port: {e}")
273
+ raise ProtocolError(f"Serial read error: {e}")
274
+
275
+ return metrics
276
+
277
+ def _read_lines(self) -> List[str]:
278
+ """
279
+ Read complete lines from the serial port.
280
+
281
+ Reads all bytes currently in the serial buffer, appends them to an
282
+ internal buffer, and splits on the configured line ending. Incomplete
283
+ lines (no trailing line ending) are kept in the buffer for the next
284
+ call.
285
+
286
+ Returns:
287
+ List of complete lines (without line endings).
288
+ """
289
+ if not self._serial:
290
+ return []
291
+
292
+ # Read all available bytes
293
+ waiting = self._serial.in_waiting
294
+ if waiting > 0:
295
+ raw = self._serial.read(waiting)
296
+ else:
297
+ # Do a blocking read up to timeout for one byte, then grab rest
298
+ raw = self._serial.read(1)
299
+ if raw:
300
+ extra = self._serial.in_waiting
301
+ if extra > 0:
302
+ raw += self._serial.read(extra)
303
+
304
+ if not raw:
305
+ return []
306
+
307
+ try:
308
+ self._read_buffer += raw.decode("utf-8", errors="replace")
309
+ except Exception:
310
+ return []
311
+
312
+ # Split on line ending
313
+ parts = self._read_buffer.split(self.line_ending)
314
+
315
+ # Last element is either empty (line ended with separator) or
316
+ # an incomplete line — keep it in the buffer
317
+ self._read_buffer = parts[-1]
318
+ complete_lines = [line.strip() for line in parts[:-1] if line.strip()]
319
+
320
+ return complete_lines
321
+
322
+ def _parse_line(self, line: str) -> List[Metric]:
323
+ """
324
+ Parse a single line using the configured parser.
325
+
326
+ Dispatches to the appropriate parser method based on self.parser.
327
+
328
+ Args:
329
+ line: A complete line of text from the serial port.
330
+
331
+ Returns:
332
+ List of Metric objects parsed from the line.
333
+ """
334
+ if self.parser == "json":
335
+ return self._parse_json(line)
336
+ elif self.parser == "csv":
337
+ return self._parse_csv(line)
338
+ else:
339
+ return self._parse_key_value(line)
340
+
341
+ def _parse_key_value(self, line: str) -> List[Metric]:
342
+ """
343
+ Parse a "metric_name:value" line.
344
+
345
+ If the line contains a colon, splits on the first colon to get the
346
+ metric name and value. Attempts to convert the value to a number;
347
+ falls back to string.
348
+
349
+ Args:
350
+ line: Line in "name:value" format.
351
+
352
+ Returns:
353
+ Single-element list with the parsed Metric, or empty list on
354
+ parse failure.
355
+ """
356
+ if ":" not in line:
357
+ logger.debug(f"Skipping line without colon separator: {line!r}")
358
+ return []
359
+
360
+ name, _, raw_value = line.partition(":")
361
+ name = name.strip()
362
+ raw_value = raw_value.strip()
363
+
364
+ if not name or not raw_value:
365
+ return []
366
+
367
+ value = self._coerce_value(raw_value)
368
+ metric_name = f"{self.prefix}{name}"
369
+
370
+ return [
371
+ Metric(
372
+ name=metric_name,
373
+ value=value,
374
+ timestamp=time.time(),
375
+ source_id=self._source_id,
376
+ )
377
+ ]
378
+
379
+ def _parse_json(self, line: str) -> List[Metric]:
380
+ """
381
+ Parse a JSON object line. Each key becomes a separate metric.
382
+
383
+ Args:
384
+ line: Line containing a JSON object string.
385
+
386
+ Returns:
387
+ List of Metric objects, one per key-value pair.
388
+ """
389
+ try:
390
+ data = json.loads(line)
391
+ except json.JSONDecodeError:
392
+ logger.debug(f"Failed to parse JSON line: {line!r}")
393
+ return []
394
+
395
+ if not isinstance(data, dict):
396
+ logger.debug(f"JSON line is not an object: {line!r}")
397
+ return []
398
+
399
+ metrics: List[Metric] = []
400
+ now = time.time()
401
+
402
+ for key, value in data.items():
403
+ if not self._is_valid_value(value):
404
+ continue
405
+
406
+ metric_name = f"{self.prefix}{key}"
407
+ metrics.append(
408
+ Metric(
409
+ name=metric_name,
410
+ value=value,
411
+ timestamp=now,
412
+ source_id=self._source_id,
413
+ )
414
+ )
415
+
416
+ return metrics
417
+
418
+ def _parse_csv(self, line: str) -> List[Metric]:
419
+ """
420
+ Parse a CSV line using previously received headers.
421
+
422
+ The first line received in CSV mode is treated as the header row.
423
+ Subsequent lines are parsed as comma-separated values matching
424
+ those headers positionally.
425
+
426
+ Args:
427
+ line: Comma-separated line of text.
428
+
429
+ Returns:
430
+ List of Metric objects, one per column. Empty list if this is
431
+ the header line or parsing fails.
432
+ """
433
+ parts = [p.strip() for p in line.split(",")]
434
+
435
+ if self._csv_headers is None:
436
+ # First line is the header
437
+ self._csv_headers = parts
438
+ logger.debug(f"CSV headers set: {self._csv_headers}")
439
+ return []
440
+
441
+ if len(parts) != len(self._csv_headers):
442
+ logger.debug(
443
+ f"CSV column count mismatch: expected {len(self._csv_headers)}, "
444
+ f"got {len(parts)}"
445
+ )
446
+ return []
447
+
448
+ metrics: List[Metric] = []
449
+ now = time.time()
450
+
451
+ for header, raw_value in zip(self._csv_headers, parts):
452
+ if not header or not raw_value:
453
+ continue
454
+
455
+ value = self._coerce_value(raw_value)
456
+ metric_name = f"{self.prefix}{header}"
457
+
458
+ metrics.append(
459
+ Metric(
460
+ name=metric_name,
461
+ value=value,
462
+ timestamp=now,
463
+ source_id=self._source_id,
464
+ )
465
+ )
466
+
467
+ return metrics
468
+
469
+ def _coerce_value(self, raw: str) -> Any:
470
+ """
471
+ Coerce a raw string value to the most appropriate Python type.
472
+
473
+ Tries int, then float, then returns the original string.
474
+ """
475
+ # Try int
476
+ try:
477
+ return int(raw)
478
+ except ValueError:
479
+ pass
480
+
481
+ # Try float
482
+ try:
483
+ return float(raw)
484
+ except ValueError:
485
+ pass
486
+
487
+ # Boolean-ish strings
488
+ lower = raw.lower()
489
+ if lower in ("true", "yes", "on"):
490
+ return True
491
+ if lower in ("false", "no", "off"):
492
+ return False
493
+
494
+ return raw
495
+
496
+ def _is_valid_value(self, value: Any) -> bool:
497
+ """Check if a value is a valid Metric value type."""
498
+ return isinstance(value, (int, float, str, bool, dict, list))
499
+
500
+ def write(self, data: str) -> bool:
501
+ """
502
+ Write a string to the serial port.
503
+
504
+ Args:
505
+ data: String data to send. Line ending is NOT automatically appended.
506
+
507
+ Returns:
508
+ True if written successfully.
509
+
510
+ Raises:
511
+ ProtocolError: If the port is not open or write fails.
512
+ """
513
+ if not self._serial or not self._serial.is_open:
514
+ raise ProtocolError("Serial port is not open")
515
+
516
+ try:
517
+ self._serial.write(data.encode("utf-8"))
518
+ self._serial.flush()
519
+ logger.debug(f"Wrote to serial: {data!r}")
520
+ return True
521
+ except Exception as e:
522
+ logger.error(f"Failed to write to serial port: {e}")
523
+ raise ProtocolError(f"Serial write error: {e}")
524
+
525
+ @property
526
+ def stats(self) -> Dict[str, Any]:
527
+ """Get adapter statistics including serial-specific info."""
528
+ base_stats = super().stats
529
+ base_stats.update({
530
+ "port": self.port,
531
+ "baudrate": self.baudrate,
532
+ "parser": self.parser,
533
+ "csv_headers": self._csv_headers,
534
+ "is_open": self._serial.is_open if self._serial else False,
535
+ })
536
+ return base_stats
537
+
538
+
539
+ # Register the adapter
540
+ AdapterRegistry.register(
541
+ "serial",
542
+ SerialAdapter,
543
+ description="Serial port adapter for USB/UART/RS-232/RS-485 devices",
544
+ author="Plexus",
545
+ version="1.0.0",
546
+ requires=["pyserial"],
547
+ )