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,164 @@
1
+ """
2
+ SPI bus scanning for sensor auto-detection.
3
+
4
+ Scans available SPI buses for known sensors by reading WHO_AM_I registers.
5
+
6
+ Usage:
7
+ from plexus.sensors.spi_scan import scan_spi, scan_spi_buses
8
+
9
+ # List available SPI buses
10
+ buses = scan_spi_buses()
11
+
12
+ # Scan for known sensors
13
+ sensors = scan_spi()
14
+ for s in sensors:
15
+ print(f"{s.name} on SPI bus {s.bus} CS {s.cs}")
16
+ """
17
+
18
+ import glob
19
+ import logging
20
+ import re
21
+ from dataclasses import dataclass
22
+ from typing import List, Optional, Tuple, Type
23
+
24
+ from .base import BaseSensor
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ @dataclass
30
+ class SPISensorMatch:
31
+ """A sensor detected on an SPI bus."""
32
+ name: str
33
+ bus: int
34
+ cs: int
35
+ driver: Type[BaseSensor]
36
+ description: str
37
+
38
+
39
+ @dataclass
40
+ class SPISensorInfo:
41
+ """Registration info for an SPI-detectable sensor."""
42
+ driver: Type[BaseSensor]
43
+ who_am_i_reg: int
44
+ expected_id: int
45
+ spi_mode: int
46
+ spi_speed: int
47
+ description: str
48
+
49
+
50
+ # Registry of known SPI sensors
51
+ KNOWN_SPI_SENSORS: List[SPISensorInfo] = []
52
+
53
+
54
+ def register_spi_sensor(info: SPISensorInfo):
55
+ """Register a sensor for SPI auto-detection."""
56
+ KNOWN_SPI_SENSORS.append(info)
57
+
58
+
59
+ def _init_known_spi_sensors():
60
+ """Initialize the SPI sensor registry."""
61
+ if KNOWN_SPI_SENSORS:
62
+ return
63
+
64
+ try:
65
+ from .adxl345 import ADXL345
66
+ register_spi_sensor(SPISensorInfo(
67
+ driver=ADXL345,
68
+ who_am_i_reg=0x00,
69
+ expected_id=0xE5,
70
+ spi_mode=3,
71
+ spi_speed=1000000,
72
+ description="3-axis accelerometer",
73
+ ))
74
+ except ImportError:
75
+ pass
76
+
77
+
78
+ def scan_spi_buses() -> List[Tuple[int, int]]:
79
+ """
80
+ Enumerate available SPI bus/CS combinations.
81
+
82
+ Scans /dev/spidev* for available SPI devices.
83
+
84
+ Returns:
85
+ List of (bus, cs) tuples
86
+ """
87
+ devices = sorted(glob.glob("/dev/spidev*"))
88
+ buses = []
89
+ for dev in devices:
90
+ match = re.search(r"spidev(\d+)\.(\d+)", dev)
91
+ if match:
92
+ bus = int(match.group(1))
93
+ cs = int(match.group(2))
94
+ buses.append((bus, cs))
95
+ return buses
96
+
97
+
98
+ def _spi_read_register(bus: int, cs: int, reg: int, mode: int, speed: int) -> Optional[int]:
99
+ """Read a single register over SPI. Returns None on failure."""
100
+ try:
101
+ import spidev
102
+ except ImportError:
103
+ raise ImportError(
104
+ "spidev is required for SPI scanning. Install with: pip install spidev"
105
+ )
106
+
107
+ try:
108
+ spi = spidev.SpiDev()
109
+ spi.open(bus, cs)
110
+ spi.mode = mode
111
+ spi.max_speed_hz = speed
112
+ # SPI read: set bit 7 of register address
113
+ resp = spi.xfer2([reg | 0x80, 0x00])
114
+ spi.close()
115
+ return resp[1]
116
+ except PermissionError:
117
+ logger.warning(
118
+ "Permission denied opening SPI bus %d CS %d. "
119
+ "Try: sudo usermod -aG spi $USER && reboot",
120
+ bus, cs,
121
+ )
122
+ return None
123
+ except Exception as e:
124
+ logger.debug("SPI read failed on bus %d cs %d reg 0x%02X: %s", bus, cs, reg, e)
125
+ return None
126
+
127
+
128
+ def scan_spi(buses: Optional[List[Tuple[int, int]]] = None) -> List[SPISensorMatch]:
129
+ """
130
+ Scan SPI buses for known sensors.
131
+
132
+ Args:
133
+ buses: List of (bus, cs) tuples to scan. None = auto-detect available buses.
134
+
135
+ Returns:
136
+ List of detected sensors
137
+ """
138
+ _init_known_spi_sensors()
139
+
140
+ if buses is None:
141
+ buses = scan_spi_buses()
142
+
143
+ if not buses:
144
+ return []
145
+
146
+ detected = []
147
+
148
+ for bus, cs in buses:
149
+ for info in KNOWN_SPI_SENSORS:
150
+ value = _spi_read_register(bus, cs, info.who_am_i_reg, info.spi_mode, info.spi_speed)
151
+ if value == info.expected_id:
152
+ detected.append(SPISensorMatch(
153
+ name=info.driver.name,
154
+ bus=bus,
155
+ cs=cs,
156
+ driver=info.driver,
157
+ description=info.description,
158
+ ))
159
+ logger.info(
160
+ "Found %s on SPI bus %d CS %d", info.driver.name, bus, cs
161
+ )
162
+ break # One sensor per CS line
163
+
164
+ return detected
@@ -0,0 +1,261 @@
1
+ """
2
+ System health sensor driver.
3
+
4
+ Reports CPU, memory, disk, network, and process metrics.
5
+ No external dependencies — uses only the Python standard library.
6
+ Works on any Linux system; some metrics degrade gracefully on macOS.
7
+
8
+ Usage:
9
+ from plexus.sensors import SystemSensor, SensorHub
10
+ from plexus import Plexus
11
+
12
+ hub = SensorHub()
13
+ hub.add(SystemSensor())
14
+ hub.run(Plexus())
15
+
16
+ Or from the CLI:
17
+ plexus start --sensor system
18
+ """
19
+
20
+ import os
21
+ import time
22
+ import shutil
23
+ import logging
24
+ import subprocess
25
+ from typing import Dict, List, Optional
26
+
27
+ from .base import BaseSensor, SensorReading
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ class SystemSensor(BaseSensor):
33
+ """
34
+ System health sensor for fleet monitoring.
35
+
36
+ Provides:
37
+ - cpu.temperature: CPU temperature in Celsius (Linux)
38
+ - cpu.usage_pct: CPU usage as a percentage (0-100)
39
+ - cpu.load: 1-minute load average
40
+ - memory.used_pct: Memory usage as a percentage (0-100)
41
+ - memory.available_mb: Available memory in MB
42
+ - disk.used_pct: Root disk usage as a percentage (0-100)
43
+ - disk.available_gb: Available disk space in GB
44
+ - net.rx_bytes: Network bytes received (cumulative)
45
+ - net.tx_bytes: Network bytes transmitted (cumulative)
46
+ - system.uptime: System uptime in seconds
47
+ - system.processes: Number of running processes
48
+ """
49
+
50
+ name = "System"
51
+ description = "System health (CPU, memory, disk, network, uptime)"
52
+ metrics = [
53
+ "cpu.temperature",
54
+ "cpu.usage_pct",
55
+ "cpu.load",
56
+ "memory.used_pct",
57
+ "memory.available_mb",
58
+ "disk.used_pct",
59
+ "disk.available_gb",
60
+ "net.rx_bytes",
61
+ "net.tx_bytes",
62
+ "system.uptime",
63
+ "system.processes",
64
+ ]
65
+
66
+ def __init__(
67
+ self,
68
+ sample_rate: float = 1.0,
69
+ prefix: str = "",
70
+ tags: Optional[dict] = None,
71
+ ):
72
+ super().__init__(sample_rate=sample_rate, prefix=prefix, tags=tags)
73
+ # Previous CPU times for usage calculation
74
+ self._prev_cpu_times: Optional[Dict[str, int]] = None
75
+ self._prev_cpu_time: float = 0.0
76
+
77
+ def _read_cpu_temperature(self) -> Optional[float]:
78
+ """Read CPU temperature from thermal zone. Returns Celsius or None."""
79
+ try:
80
+ with open("/sys/class/thermal/thermal_zone0/temp", "r") as f:
81
+ return int(f.read().strip()) / 1000.0
82
+ except (FileNotFoundError, ValueError, PermissionError):
83
+ pass
84
+
85
+ # Fallback: vcgencmd (common on ARM SBCs)
86
+ try:
87
+ result = subprocess.run(
88
+ ["vcgencmd", "measure_temp"],
89
+ capture_output=True,
90
+ text=True,
91
+ timeout=2,
92
+ )
93
+ if result.returncode == 0:
94
+ temp_str = result.stdout.strip().replace("temp=", "").replace("'C", "")
95
+ return float(temp_str)
96
+ except (FileNotFoundError, subprocess.TimeoutExpired, ValueError):
97
+ pass
98
+
99
+ return None
100
+
101
+ def _read_cpu_usage_pct(self) -> Optional[float]:
102
+ """Read CPU usage percentage from /proc/stat delta."""
103
+ try:
104
+ with open("/proc/stat", "r") as f:
105
+ line = f.readline() # first line is aggregate
106
+ parts = line.split()
107
+ # user, nice, system, idle, iowait, irq, softirq, steal
108
+ times = {
109
+ "user": int(parts[1]),
110
+ "nice": int(parts[2]),
111
+ "system": int(parts[3]),
112
+ "idle": int(parts[4]),
113
+ "iowait": int(parts[5]) if len(parts) > 5 else 0,
114
+ }
115
+ now = time.monotonic()
116
+
117
+ if self._prev_cpu_times is not None:
118
+ prev = self._prev_cpu_times
119
+ dt = now - self._prev_cpu_time
120
+
121
+ if dt > 0:
122
+ d_idle = (times["idle"] + times["iowait"]) - (prev["idle"] + prev["iowait"])
123
+ d_total = sum(times.values()) - sum(prev.values())
124
+
125
+ if d_total > 0:
126
+ usage = (1.0 - d_idle / d_total) * 100.0
127
+ self._prev_cpu_times = times
128
+ self._prev_cpu_time = now
129
+ return round(max(0.0, min(100.0, usage)), 1)
130
+
131
+ self._prev_cpu_times = times
132
+ self._prev_cpu_time = now
133
+ return None # Need two samples to calculate delta
134
+ except (FileNotFoundError, ValueError, IndexError):
135
+ return None
136
+
137
+ def _read_cpu_load(self) -> Optional[float]:
138
+ """Read 1-minute load average."""
139
+ try:
140
+ return round(os.getloadavg()[0], 2)
141
+ except OSError:
142
+ return None
143
+
144
+ def _read_memory(self) -> Optional[tuple]:
145
+ """Read memory stats. Returns (used_pct, available_mb) or None."""
146
+ try:
147
+ with open("/proc/meminfo", "r") as f:
148
+ meminfo = {}
149
+ for line in f:
150
+ parts = line.split(":")
151
+ if len(parts) == 2:
152
+ key = parts[0].strip()
153
+ val = parts[1].strip().split()[0]
154
+ meminfo[key] = int(val)
155
+
156
+ total = meminfo.get("MemTotal", 0)
157
+ available = meminfo.get("MemAvailable", 0)
158
+ if total > 0:
159
+ used_pct = round((1.0 - available / total) * 100.0, 1)
160
+ available_mb = round(available / 1024.0, 1)
161
+ return used_pct, available_mb
162
+ except (FileNotFoundError, ValueError, KeyError):
163
+ pass
164
+ return None
165
+
166
+ def _read_disk(self) -> tuple:
167
+ """Read disk stats. Returns (used_pct, available_gb)."""
168
+ usage = shutil.disk_usage("/")
169
+ used_pct = round(usage.used / usage.total * 100.0, 1)
170
+ available_gb = round(usage.free / (1024 ** 3), 2)
171
+ return used_pct, available_gb
172
+
173
+ def _read_network_bytes(self) -> Optional[tuple]:
174
+ """Read total network rx/tx bytes from /proc/net/dev. Returns (rx, tx) or None."""
175
+ try:
176
+ rx_total = 0
177
+ tx_total = 0
178
+ with open("/proc/net/dev", "r") as f:
179
+ for line in f:
180
+ line = line.strip()
181
+ if ":" not in line:
182
+ continue
183
+ iface, data = line.split(":", 1)
184
+ iface = iface.strip()
185
+ # Skip loopback
186
+ if iface == "lo":
187
+ continue
188
+ fields = data.split()
189
+ if len(fields) >= 9:
190
+ rx_total += int(fields[0])
191
+ tx_total += int(fields[8])
192
+ return rx_total, tx_total
193
+ except (FileNotFoundError, ValueError):
194
+ return None
195
+
196
+ def _read_uptime(self) -> Optional[float]:
197
+ """Read system uptime in seconds from /proc/uptime."""
198
+ try:
199
+ with open("/proc/uptime", "r") as f:
200
+ return round(float(f.read().split()[0]), 1)
201
+ except (FileNotFoundError, ValueError):
202
+ return None
203
+
204
+ def _read_process_count(self) -> Optional[int]:
205
+ """Count running processes via /proc."""
206
+ try:
207
+ count = 0
208
+ for entry in os.listdir("/proc"):
209
+ if entry.isdigit():
210
+ count += 1
211
+ return count
212
+ except OSError:
213
+ return None
214
+
215
+ def read(self) -> List[SensorReading]:
216
+ readings = []
217
+
218
+ # CPU
219
+ cpu_temp = self._read_cpu_temperature()
220
+ if cpu_temp is not None:
221
+ readings.append(SensorReading("cpu.temperature", round(cpu_temp, 1)))
222
+
223
+ cpu_usage = self._read_cpu_usage_pct()
224
+ if cpu_usage is not None:
225
+ readings.append(SensorReading("cpu.usage_pct", cpu_usage))
226
+
227
+ cpu_load = self._read_cpu_load()
228
+ if cpu_load is not None:
229
+ readings.append(SensorReading("cpu.load", cpu_load))
230
+
231
+ # Memory
232
+ mem = self._read_memory()
233
+ if mem is not None:
234
+ readings.append(SensorReading("memory.used_pct", mem[0]))
235
+ readings.append(SensorReading("memory.available_mb", mem[1]))
236
+
237
+ # Disk
238
+ disk_used_pct, disk_available_gb = self._read_disk()
239
+ readings.append(SensorReading("disk.used_pct", disk_used_pct))
240
+ readings.append(SensorReading("disk.available_gb", disk_available_gb))
241
+
242
+ # Network
243
+ net = self._read_network_bytes()
244
+ if net is not None:
245
+ readings.append(SensorReading("net.rx_bytes", net[0]))
246
+ readings.append(SensorReading("net.tx_bytes", net[1]))
247
+
248
+ # System
249
+ uptime = self._read_uptime()
250
+ if uptime is not None:
251
+ readings.append(SensorReading("system.uptime", uptime))
252
+
253
+ procs = self._read_process_count()
254
+ if procs is not None:
255
+ readings.append(SensorReading("system.processes", procs))
256
+
257
+ return readings
258
+
259
+ def is_available(self) -> bool:
260
+ """Always available — disk and load work on any POSIX system."""
261
+ return True
@@ -0,0 +1,109 @@
1
+ """
2
+ VL53L0X Time-of-Flight distance sensor driver.
3
+
4
+ The VL53L0X measures distance using a laser (940nm VCSEL).
5
+ Range: 30mm to 2000mm with ±3% accuracy.
6
+ Communicates via I2C at default address 0x29.
7
+
8
+ Usage:
9
+ from plexus.sensors import VL53L0X
10
+
11
+ sensor = VL53L0X()
12
+ for reading in sensor.read():
13
+ print(f"{reading.metric}: {reading.value}")
14
+ """
15
+
16
+ import time
17
+ from typing import List, Optional
18
+ from .base import BaseSensor, SensorReading
19
+
20
+ VL53L0X_ADDR = 0x29
21
+
22
+ # Key register addresses
23
+ REG_IDENTIFICATION_MODEL_ID = 0xC0
24
+ REG_SYSRANGE_START = 0x00
25
+ REG_RESULT_RANGE_STATUS = 0x14
26
+ REG_RESULT_RANGE_VAL = 0x1E # 16-bit range value in mm
27
+
28
+
29
+ class VL53L0X(BaseSensor):
30
+ """
31
+ VL53L0X Time-of-Flight distance sensor driver.
32
+
33
+ Provides:
34
+ - distance_mm: Distance in millimeters (30-2000mm range)
35
+
36
+ Note: This is a simplified driver using single-shot ranging.
37
+ For production use with custom timing budgets, consider the
38
+ official VL53L0X Python library.
39
+ """
40
+
41
+ name = "VL53L0X"
42
+ description = "Time-of-Flight distance sensor (30-2000mm)"
43
+ metrics = ["distance_mm"]
44
+ i2c_addresses = [VL53L0X_ADDR]
45
+
46
+ def __init__(
47
+ self,
48
+ address: int = VL53L0X_ADDR,
49
+ bus: int = 1,
50
+ sample_rate: float = 10.0,
51
+ prefix: str = "",
52
+ tags: Optional[dict] = None,
53
+ ):
54
+ super().__init__(sample_rate=sample_rate, prefix=prefix, tags=tags)
55
+ self.address = address
56
+ self.bus_num = bus
57
+ self._bus = None
58
+
59
+ def setup(self) -> None:
60
+ try:
61
+ from smbus2 import SMBus
62
+ except ImportError:
63
+ raise ImportError(
64
+ "smbus2 is required for VL53L0X. Install with: pip install smbus2"
65
+ )
66
+
67
+ self._bus = SMBus(self.bus_num)
68
+
69
+ def cleanup(self) -> None:
70
+ if self._bus:
71
+ self._bus.close()
72
+ self._bus = None
73
+
74
+ def read(self) -> List[SensorReading]:
75
+ if self._bus is None:
76
+ self.setup()
77
+
78
+ # Start single-shot ranging
79
+ self._bus.write_byte_data(self.address, REG_SYSRANGE_START, 0x01)
80
+
81
+ # Wait for measurement to complete (up to 50ms)
82
+ for _ in range(50):
83
+ time.sleep(0.001)
84
+ status = self._bus.read_byte_data(self.address, REG_RESULT_RANGE_STATUS)
85
+ if status & 0x01: # Device ready
86
+ break
87
+
88
+ # Read range result (2 bytes, big-endian)
89
+ data = self._bus.read_i2c_block_data(self.address, REG_RESULT_RANGE_VAL, 2)
90
+ distance_mm = (data[0] << 8) | data[1]
91
+
92
+ # Filter out invalid readings (0 = no target, 8190+ = out of range)
93
+ if distance_mm == 0 or distance_mm >= 8190:
94
+ return []
95
+
96
+ return [
97
+ SensorReading("distance_mm", distance_mm),
98
+ ]
99
+
100
+ def is_available(self) -> bool:
101
+ try:
102
+ from smbus2 import SMBus
103
+
104
+ bus = SMBus(self.bus_num)
105
+ model_id = bus.read_byte_data(self.address, REG_IDENTIFICATION_MODEL_ID)
106
+ bus.close()
107
+ return model_id == 0xEE # VL53L0X model ID
108
+ except Exception:
109
+ return False