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,622 @@
1
+ """
2
+ Modbus Adapter - Modbus TCP/RTU protocol support for industrial devices
3
+
4
+ This adapter reads Modbus registers (holding, input, coil, discrete) from
5
+ a Modbus slave device and emits scaled, typed metric values.
6
+
7
+ Requirements:
8
+ pip install plexus-python[modbus]
9
+ # or
10
+ pip install pymodbus
11
+
12
+ Usage:
13
+ from plexus.adapters import ModbusAdapter
14
+
15
+ # TCP connection to a PLC
16
+ adapter = ModbusAdapter(
17
+ host="192.168.1.100",
18
+ port=502,
19
+ unit_id=1,
20
+ registers=[
21
+ {"address": 0, "name": "temperature", "type": "holding",
22
+ "data_type": "float32", "scale": 0.1, "unit": "°C"},
23
+ {"address": 2, "name": "pressure", "type": "input",
24
+ "data_type": "uint16", "scale": 0.01, "unit": "bar"},
25
+ {"address": 10, "name": "pump_running", "type": "coil"},
26
+ ],
27
+ poll_interval=1.0,
28
+ )
29
+ adapter.connect()
30
+ for metric in adapter.poll():
31
+ print(f"{metric.name}: {metric.value}")
32
+
33
+ # RTU connection over serial
34
+ adapter = ModbusAdapter(
35
+ host="/dev/ttyUSB0",
36
+ mode="rtu",
37
+ baudrate=9600,
38
+ unit_id=1,
39
+ registers=[
40
+ {"address": 100, "name": "flow_rate", "type": "holding",
41
+ "data_type": "int32", "scale": 0.001, "unit": "m³/h"},
42
+ ],
43
+ )
44
+
45
+ Supported register types:
46
+ - holding: Holding registers (function code 3)
47
+ - input: Input registers (function code 4)
48
+ - coil: Coils / discrete outputs (function code 1)
49
+ - discrete: Discrete inputs (function code 2)
50
+
51
+ Supported data types (for holding/input registers):
52
+ - uint16: Unsigned 16-bit integer (1 register)
53
+ - int16: Signed 16-bit integer (1 register)
54
+ - uint32: Unsigned 32-bit integer (2 registers)
55
+ - int32: Signed 32-bit integer (2 registers)
56
+ - float32: IEEE 754 32-bit float (2 registers)
57
+
58
+ Emitted metrics:
59
+ - {prefix}{name} - Scaled register value (e.g., "modbus.temperature")
60
+ """
61
+
62
+ from typing import Any, Dict, List, Optional
63
+ import struct
64
+ import time
65
+ import logging
66
+
67
+ from plexus.adapters.base import (
68
+ ProtocolAdapter,
69
+ AdapterConfig,
70
+ AdapterState,
71
+ Metric,
72
+ ConnectionError,
73
+ ProtocolError,
74
+ )
75
+ from plexus.adapters.registry import register_adapter
76
+
77
+ logger = logging.getLogger(__name__)
78
+
79
+ # Optional dependency — imported at module level so it can be
80
+ # mocked in tests with @patch("plexus.adapters.modbus.pymodbus_client")
81
+ try:
82
+ from pymodbus.client import ModbusTcpClient, ModbusSerialClient
83
+ pymodbus_client = True
84
+ except ImportError:
85
+ pymodbus_client = None # type: ignore[assignment]
86
+ ModbusTcpClient = None # type: ignore[assignment,misc]
87
+ ModbusSerialClient = None # type: ignore[assignment,misc]
88
+
89
+ # Data type definitions: (struct format, register count)
90
+ _DATA_TYPES = {
91
+ "uint16": (">H", 1),
92
+ "int16": (">h", 1),
93
+ "uint32": (">I", 2),
94
+ "int32": (">i", 2),
95
+ "float32": (">f", 2),
96
+ }
97
+
98
+ # Valid register types
99
+ _REGISTER_TYPES = {"holding", "input", "coil", "discrete"}
100
+
101
+
102
+ @register_adapter(
103
+ "modbus",
104
+ description="Modbus TCP/RTU adapter for industrial devices",
105
+ author="Plexus",
106
+ version="1.0.0",
107
+ requires=["pymodbus"],
108
+ )
109
+ class ModbusAdapter(ProtocolAdapter):
110
+ """
111
+ Modbus protocol adapter for industrial devices.
112
+
113
+ Reads holding registers, input registers, coils, and discrete inputs
114
+ from a Modbus slave via TCP or RTU (serial). Register values are
115
+ converted according to their configured data type, then scaled and
116
+ offset before being emitted as Plexus metrics.
117
+
118
+ Args:
119
+ host: TCP hostname/IP or serial port path (e.g., "192.168.1.100"
120
+ or "/dev/ttyUSB0")
121
+ port: TCP port number (default: 502, ignored for RTU)
122
+ mode: Connection mode — "tcp" or "rtu" (default: "tcp")
123
+ unit_id: Modbus slave/unit ID (default: 1)
124
+ baudrate: Serial baudrate for RTU mode (default: 9600)
125
+ registers: List of register definitions. Each is a dict with:
126
+ - address (int): Register start address
127
+ - count (int): Number of registers to read (default: 1)
128
+ - name (str): Metric name suffix
129
+ - type (str): "holding", "input", "coil", or "discrete"
130
+ (default: "holding")
131
+ - data_type (str): "uint16", "int16", "uint32", "int32",
132
+ or "float32" (default: "uint16")
133
+ - scale (float): Multiply raw value by this (default: 1.0)
134
+ - offset (float): Add to scaled value (default: 0.0)
135
+ - unit (str): Engineering unit string for tags (optional)
136
+ poll_interval: Seconds between polls (default: 1.0)
137
+ prefix: Metric name prefix (default: "modbus.")
138
+ source_id: Source ID for metrics (optional)
139
+
140
+ Example:
141
+ adapter = ModbusAdapter(
142
+ host="192.168.1.100",
143
+ unit_id=1,
144
+ registers=[
145
+ {"address": 0, "name": "temperature", "data_type": "float32",
146
+ "scale": 0.1, "unit": "°C"},
147
+ {"address": 2, "name": "pressure", "data_type": "uint16",
148
+ "scale": 0.01, "unit": "bar"},
149
+ {"address": 10, "name": "pump_on", "type": "coil"},
150
+ ],
151
+ )
152
+
153
+ with adapter:
154
+ while True:
155
+ for metric in adapter.poll():
156
+ print(f"{metric.name} = {metric.value}")
157
+ time.sleep(adapter.poll_interval)
158
+ """
159
+
160
+ def __init__(
161
+ self,
162
+ host: str = "127.0.0.1",
163
+ port: int = 502,
164
+ mode: str = "tcp",
165
+ unit_id: int = 1,
166
+ baudrate: int = 9600,
167
+ registers: Optional[List[Dict[str, Any]]] = None,
168
+ poll_interval: float = 1.0,
169
+ prefix: str = "modbus.",
170
+ source_id: Optional[str] = None,
171
+ **kwargs,
172
+ ):
173
+ config = AdapterConfig(
174
+ name="modbus",
175
+ params={
176
+ "host": host,
177
+ "port": port,
178
+ "mode": mode,
179
+ "unit_id": unit_id,
180
+ "baudrate": baudrate,
181
+ **kwargs,
182
+ },
183
+ )
184
+ super().__init__(config)
185
+
186
+ self.host = host
187
+ self.port = port
188
+ self.mode = mode.lower()
189
+ self.unit_id = unit_id
190
+ self.baudrate = baudrate
191
+ self.poll_interval = poll_interval
192
+ self.prefix = prefix
193
+ self._source_id = source_id
194
+
195
+ # Parse and validate register definitions
196
+ self._registers = self._parse_registers(registers or [])
197
+
198
+ self._client: Optional[Any] = None # pymodbus client instance
199
+
200
+ @staticmethod
201
+ def _parse_registers(
202
+ raw_registers: List[Dict[str, Any]],
203
+ ) -> List[Dict[str, Any]]:
204
+ """
205
+ Validate and normalise register definitions.
206
+
207
+ Each entry must have at least ``address`` (int) and ``name`` (str).
208
+ Missing optional fields are filled with defaults.
209
+
210
+ Raises:
211
+ ValueError: On invalid register configuration.
212
+ """
213
+ parsed: List[Dict[str, Any]] = []
214
+
215
+ for i, reg in enumerate(raw_registers):
216
+ if "address" not in reg:
217
+ raise ValueError(
218
+ f"Register {i}: 'address' is required"
219
+ )
220
+ if "name" not in reg:
221
+ raise ValueError(
222
+ f"Register {i}: 'name' is required"
223
+ )
224
+
225
+ reg_type = reg.get("type", "holding")
226
+ if reg_type not in _REGISTER_TYPES:
227
+ raise ValueError(
228
+ f"Register {i} ({reg['name']}): invalid type "
229
+ f"'{reg_type}'. Must be one of: "
230
+ f"{', '.join(sorted(_REGISTER_TYPES))}"
231
+ )
232
+
233
+ data_type = reg.get("data_type", "uint16")
234
+ # Coil/discrete are always boolean — ignore data_type for them
235
+ if reg_type in ("coil", "discrete"):
236
+ data_type = "bool"
237
+ elif data_type not in _DATA_TYPES:
238
+ raise ValueError(
239
+ f"Register {i} ({reg['name']}): invalid data_type "
240
+ f"'{data_type}'. Must be one of: "
241
+ f"{', '.join(sorted(_DATA_TYPES))}"
242
+ )
243
+
244
+ # For register types, determine count from data_type if not given
245
+ if reg_type in ("holding", "input"):
246
+ default_count = _DATA_TYPES[data_type][1]
247
+ else:
248
+ default_count = reg.get("count", 1)
249
+
250
+ parsed.append({
251
+ "address": int(reg["address"]),
252
+ "count": int(reg.get("count", default_count)),
253
+ "name": str(reg["name"]),
254
+ "type": reg_type,
255
+ "data_type": data_type,
256
+ "scale": float(reg.get("scale", 1.0)),
257
+ "offset": float(reg.get("offset", 0.0)),
258
+ "unit": reg.get("unit"),
259
+ })
260
+
261
+ return parsed
262
+
263
+ def validate_config(self) -> bool:
264
+ """Validate adapter configuration."""
265
+ if self.mode not in ("tcp", "rtu"):
266
+ raise ValueError(
267
+ f"Invalid mode '{self.mode}'. Must be 'tcp' or 'rtu'"
268
+ )
269
+ if not self.host:
270
+ raise ValueError("Host is required")
271
+ if not self._registers:
272
+ logger.warning("No registers configured — poll() will return empty")
273
+ return True
274
+
275
+ def connect(self) -> bool:
276
+ """
277
+ Connect to the Modbus device.
278
+
279
+ Creates a ``ModbusTcpClient`` (TCP mode) or ``ModbusSerialClient``
280
+ (RTU mode) and opens the connection.
281
+
282
+ Returns:
283
+ True if connection successful.
284
+
285
+ Raises:
286
+ ConnectionError: If pymodbus is not installed or connection fails.
287
+ """
288
+ if pymodbus_client is None:
289
+ self._set_state(AdapterState.ERROR, "pymodbus not installed")
290
+ raise ConnectionError(
291
+ "pymodbus is required. Install with: "
292
+ "pip install plexus-python[modbus]"
293
+ )
294
+
295
+ try:
296
+ self._set_state(AdapterState.CONNECTING)
297
+
298
+ if self.mode == "tcp":
299
+ logger.info(
300
+ f"Connecting to Modbus TCP {self.host}:{self.port} "
301
+ f"(unit {self.unit_id})"
302
+ )
303
+ self._client = ModbusTcpClient(
304
+ host=self.host,
305
+ port=self.port,
306
+ )
307
+ elif self.mode == "rtu":
308
+ logger.info(
309
+ f"Connecting to Modbus RTU {self.host} "
310
+ f"at {self.baudrate} baud (unit {self.unit_id})"
311
+ )
312
+ self._client = ModbusSerialClient(
313
+ port=self.host,
314
+ baudrate=self.baudrate,
315
+ )
316
+ else:
317
+ raise ValueError(f"Invalid mode: {self.mode}")
318
+
319
+ connected = self._client.connect()
320
+ if not connected:
321
+ self._set_state(
322
+ AdapterState.ERROR,
323
+ f"Failed to connect to {self.host}",
324
+ )
325
+ raise ConnectionError(
326
+ f"Modbus connection failed: {self.host}"
327
+ )
328
+
329
+ self._set_state(AdapterState.CONNECTED)
330
+ logger.info(f"Connected to Modbus device at {self.host}")
331
+ return True
332
+
333
+ except ConnectionError:
334
+ raise
335
+ except Exception as e:
336
+ self._set_state(AdapterState.ERROR, str(e))
337
+ logger.error(f"Failed to connect to Modbus device: {e}")
338
+ raise ConnectionError(f"Modbus connection failed: {e}")
339
+
340
+ def disconnect(self) -> None:
341
+ """Close the Modbus connection and release resources."""
342
+ if self._client:
343
+ try:
344
+ self._client.close()
345
+ logger.info("Disconnected from Modbus device")
346
+ except Exception as e:
347
+ logger.warning(f"Error closing Modbus connection: {e}")
348
+ finally:
349
+ self._client = None
350
+
351
+ self._set_state(AdapterState.DISCONNECTED)
352
+
353
+ def poll(self) -> List[Metric]:
354
+ """
355
+ Read all configured registers and return metrics.
356
+
357
+ For each register definition the appropriate Modbus function code
358
+ is used. Raw register values are converted according to
359
+ ``data_type``, then ``scale`` and ``offset`` are applied:
360
+
361
+ value = (raw * scale) + offset
362
+
363
+ Returns:
364
+ List of Metric objects — one per configured register.
365
+
366
+ Raises:
367
+ ConnectionError/OSError: On connection loss (triggers auto-reconnect).
368
+ ProtocolError: If a Modbus read fails.
369
+ """
370
+ if not self._client:
371
+ return []
372
+
373
+ metrics: List[Metric] = []
374
+ timestamp = time.time()
375
+
376
+ for reg in self._registers:
377
+ try:
378
+ value = self._read_register(reg)
379
+ if value is None:
380
+ continue
381
+
382
+ tags: Dict[str, str] = {
383
+ "address": str(reg["address"]),
384
+ "register_type": reg["type"],
385
+ "unit_id": str(self.unit_id),
386
+ }
387
+ if reg["unit"]:
388
+ tags["unit"] = reg["unit"]
389
+ if reg["data_type"] != "bool":
390
+ tags["data_type"] = reg["data_type"]
391
+
392
+ metrics.append(
393
+ Metric(
394
+ name=f"{self.prefix}{reg['name']}",
395
+ value=value,
396
+ timestamp=timestamp,
397
+ tags=tags,
398
+ source_id=self._source_id,
399
+ )
400
+ )
401
+
402
+ except OSError:
403
+ raise # Let run loop handle disconnect/reconnect
404
+ except Exception as e:
405
+ logger.error(
406
+ f"Error reading register '{reg['name']}' "
407
+ f"at address {reg['address']}: {e}"
408
+ )
409
+ raise ProtocolError(
410
+ f"Modbus read error for '{reg['name']}' "
411
+ f"at address {reg['address']}: {e}"
412
+ )
413
+
414
+ return metrics
415
+
416
+ def _read_register(
417
+ self, reg: Dict[str, Any]
418
+ ) -> Optional[Any]:
419
+ """
420
+ Read a single register definition and return the converted value.
421
+
422
+ Returns:
423
+ The converted, scaled value — or None if the read failed
424
+ with a Modbus exception.
425
+
426
+ Raises:
427
+ ProtocolError: On communication errors.
428
+ """
429
+ address = reg["address"]
430
+ count = reg["count"]
431
+ reg_type = reg["type"]
432
+
433
+ # --- Coil / discrete reads (boolean) ---
434
+ if reg_type == "coil":
435
+ result = self._client.read_coils(
436
+ address, count=count, slave=self.unit_id,
437
+ )
438
+ elif reg_type == "discrete":
439
+ result = self._client.read_discrete_inputs(
440
+ address, count=count, slave=self.unit_id,
441
+ )
442
+ elif reg_type == "input":
443
+ result = self._client.read_input_registers(
444
+ address, count=count, slave=self.unit_id,
445
+ )
446
+ elif reg_type == "holding":
447
+ result = self._client.read_holding_registers(
448
+ address, count=count, slave=self.unit_id,
449
+ )
450
+ else:
451
+ raise ProtocolError(f"Unknown register type: {reg_type}")
452
+
453
+ # Check for errors
454
+ if result.isError():
455
+ logger.warning(
456
+ f"Modbus error reading {reg_type} register "
457
+ f"at address {address}: {result}"
458
+ )
459
+ return None
460
+
461
+ # --- Boolean registers ---
462
+ if reg_type in ("coil", "discrete"):
463
+ # Return first bit value as bool
464
+ return bool(result.bits[0])
465
+
466
+ # --- Numeric registers ---
467
+ raw_registers = result.registers
468
+ value = self._convert_registers(
469
+ raw_registers, reg["data_type"]
470
+ )
471
+
472
+ # Apply scale and offset
473
+ return (value * reg["scale"]) + reg["offset"]
474
+
475
+ @staticmethod
476
+ def _convert_registers(
477
+ registers: List[int], data_type: str
478
+ ) -> float:
479
+ """
480
+ Convert raw 16-bit register values to a typed numeric value.
481
+
482
+ For 32-bit types, two consecutive registers are combined
483
+ (big-endian / high word first) and unpacked with ``struct``.
484
+
485
+ Args:
486
+ registers: List of raw 16-bit register values.
487
+ data_type: One of "uint16", "int16", "uint32", "int32",
488
+ "float32".
489
+
490
+ Returns:
491
+ The numeric value as a float.
492
+
493
+ Raises:
494
+ ProtocolError: If there are not enough registers for the
495
+ requested data type.
496
+ """
497
+ fmt, expected_count = _DATA_TYPES[data_type]
498
+
499
+ if len(registers) < expected_count:
500
+ raise ProtocolError(
501
+ f"Expected {expected_count} register(s) for {data_type}, "
502
+ f"got {len(registers)}"
503
+ )
504
+
505
+ if expected_count == 1:
506
+ # Pack single 16-bit register as unsigned, then unpack as target type
507
+ raw_bytes = struct.pack(">H", registers[0])
508
+ (value,) = struct.unpack(fmt, raw_bytes)
509
+ else:
510
+ # Pack two 16-bit registers (high word first) into 4 bytes
511
+ raw_bytes = struct.pack(">HH", registers[0], registers[1])
512
+ (value,) = struct.unpack(fmt, raw_bytes)
513
+
514
+ return float(value)
515
+
516
+ def write_register(
517
+ self,
518
+ address: int,
519
+ value: int,
520
+ register_type: str = "holding",
521
+ ) -> bool:
522
+ """
523
+ Write a value to a Modbus register.
524
+
525
+ Args:
526
+ address: Register address.
527
+ value: Value to write (int for registers, bool-ish for coils).
528
+ register_type: "holding" or "coil".
529
+
530
+ Returns:
531
+ True if the write succeeded.
532
+
533
+ Raises:
534
+ ProtocolError: If not connected or write fails.
535
+ """
536
+ if not self._client:
537
+ raise ProtocolError("Not connected to Modbus device")
538
+
539
+ try:
540
+ if register_type == "coil":
541
+ result = self._client.write_coil(
542
+ address, bool(value), slave=self.unit_id,
543
+ )
544
+ elif register_type == "holding":
545
+ result = self._client.write_register(
546
+ address, value, slave=self.unit_id,
547
+ )
548
+ else:
549
+ raise ProtocolError(
550
+ f"Cannot write to '{register_type}' registers"
551
+ )
552
+
553
+ if result.isError():
554
+ logger.error(
555
+ f"Modbus write error at address {address}: {result}"
556
+ )
557
+ return False
558
+
559
+ logger.debug(
560
+ f"Wrote {value} to {register_type} register {address}"
561
+ )
562
+ return True
563
+
564
+ except ProtocolError:
565
+ raise
566
+ except Exception as e:
567
+ logger.error(f"Failed to write Modbus register: {e}")
568
+ raise ProtocolError(f"Modbus write error: {e}")
569
+
570
+ def write_registers(
571
+ self,
572
+ address: int,
573
+ values: List[int],
574
+ ) -> bool:
575
+ """
576
+ Write multiple holding register values starting at an address.
577
+
578
+ Args:
579
+ address: Starting register address.
580
+ values: List of 16-bit integer values to write.
581
+
582
+ Returns:
583
+ True if the write succeeded.
584
+
585
+ Raises:
586
+ ProtocolError: If not connected or write fails.
587
+ """
588
+ if not self._client:
589
+ raise ProtocolError("Not connected to Modbus device")
590
+
591
+ try:
592
+ result = self._client.write_registers(
593
+ address, values, slave=self.unit_id,
594
+ )
595
+ if result.isError():
596
+ logger.error(
597
+ f"Modbus write error at address {address}: {result}"
598
+ )
599
+ return False
600
+
601
+ logger.debug(
602
+ f"Wrote {len(values)} registers starting at {address}"
603
+ )
604
+ return True
605
+
606
+ except Exception as e:
607
+ logger.error(f"Failed to write Modbus registers: {e}")
608
+ raise ProtocolError(f"Modbus write error: {e}")
609
+
610
+ @property
611
+ def stats(self) -> Dict[str, Any]:
612
+ """Get adapter statistics including Modbus-specific info."""
613
+ base_stats = super().stats
614
+ base_stats.update({
615
+ "host": self.host,
616
+ "port": self.port,
617
+ "mode": self.mode,
618
+ "unit_id": self.unit_id,
619
+ "register_count": len(self._registers),
620
+ "poll_interval": self.poll_interval,
621
+ })
622
+ return base_stats