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.
- plexus/__init__.py +31 -0
- plexus/__main__.py +4 -0
- plexus/adapters/__init__.py +122 -0
- plexus/adapters/base.py +409 -0
- plexus/adapters/ble.py +257 -0
- plexus/adapters/can.py +439 -0
- plexus/adapters/can_detect.py +174 -0
- plexus/adapters/mavlink.py +642 -0
- plexus/adapters/mavlink_detect.py +192 -0
- plexus/adapters/modbus.py +622 -0
- plexus/adapters/mqtt.py +350 -0
- plexus/adapters/opcua.py +607 -0
- plexus/adapters/registry.py +206 -0
- plexus/adapters/serial_adapter.py +547 -0
- plexus/buffer.py +257 -0
- plexus/cameras/__init__.py +57 -0
- plexus/cameras/auto.py +239 -0
- plexus/cameras/base.py +189 -0
- plexus/cameras/picamera.py +171 -0
- plexus/cameras/usb.py +143 -0
- plexus/cli.py +783 -0
- plexus/client.py +465 -0
- plexus/config.py +169 -0
- plexus/connector.py +666 -0
- plexus/deps.py +246 -0
- plexus/detect.py +1238 -0
- plexus/importers/__init__.py +25 -0
- plexus/importers/rosbag.py +778 -0
- plexus/sensors/__init__.py +118 -0
- plexus/sensors/ads1115.py +164 -0
- plexus/sensors/adxl345.py +179 -0
- plexus/sensors/auto.py +290 -0
- plexus/sensors/base.py +412 -0
- plexus/sensors/bh1750.py +102 -0
- plexus/sensors/bme280.py +241 -0
- plexus/sensors/gps.py +317 -0
- plexus/sensors/ina219.py +149 -0
- plexus/sensors/magnetometer.py +239 -0
- plexus/sensors/mpu6050.py +162 -0
- plexus/sensors/sht3x.py +139 -0
- plexus/sensors/spi_scan.py +164 -0
- plexus/sensors/system.py +261 -0
- plexus/sensors/vl53l0x.py +109 -0
- plexus/streaming.py +743 -0
- plexus/tui.py +642 -0
- plexus_python-0.1.0.dist-info/METADATA +470 -0
- plexus_python-0.1.0.dist-info/RECORD +50 -0
- plexus_python-0.1.0.dist-info/WHEEL +4 -0
- plexus_python-0.1.0.dist-info/entry_points.txt +2 -0
- plexus_python-0.1.0.dist-info/licenses/LICENSE +190 -0
plexus/sensors/auto.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Auto-detection of connected sensors.
|
|
3
|
+
|
|
4
|
+
Scans I2C buses to find known sensors and automatically creates driver instances.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
from plexus.sensors import scan_sensors, auto_sensors
|
|
8
|
+
|
|
9
|
+
# Scan and list available sensors
|
|
10
|
+
sensors = scan_sensors()
|
|
11
|
+
for s in sensors:
|
|
12
|
+
print(f"{s.name} at 0x{s.address:02X}")
|
|
13
|
+
|
|
14
|
+
# Auto-create sensor instances
|
|
15
|
+
hub = auto_sensors()
|
|
16
|
+
hub.run(Plexus())
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
from typing import List, Dict, Type, Optional, Tuple
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
|
|
23
|
+
from .base import BaseSensor, SensorHub
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
from .spi_scan import scan_spi
|
|
27
|
+
_HAS_SPI = True
|
|
28
|
+
except ImportError:
|
|
29
|
+
_HAS_SPI = False
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class DetectedSensor:
|
|
36
|
+
"""Information about a detected sensor."""
|
|
37
|
+
name: str
|
|
38
|
+
address: int
|
|
39
|
+
bus: int
|
|
40
|
+
driver: Type[BaseSensor]
|
|
41
|
+
description: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# Registry of known sensors and their I2C addresses
|
|
45
|
+
KNOWN_SENSORS: List[Tuple[Type[BaseSensor], int, str]] = []
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def register_sensor(driver: Type[BaseSensor], address: int, chip_id_check=None):
|
|
49
|
+
"""Register a sensor for auto-detection."""
|
|
50
|
+
KNOWN_SENSORS.append((driver, address, chip_id_check))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _init_known_sensors():
|
|
54
|
+
"""Initialize the registry with known sensors."""
|
|
55
|
+
global KNOWN_SENSORS
|
|
56
|
+
|
|
57
|
+
if KNOWN_SENSORS:
|
|
58
|
+
return # Already initialized
|
|
59
|
+
|
|
60
|
+
# Import drivers
|
|
61
|
+
from .mpu6050 import MPU6050, MPU9250
|
|
62
|
+
from .bme280 import BME280
|
|
63
|
+
from .ina219 import INA219
|
|
64
|
+
from .sht3x import SHT3x
|
|
65
|
+
from .bh1750 import BH1750
|
|
66
|
+
from .vl53l0x import VL53L0X
|
|
67
|
+
from .ads1115 import ADS1115
|
|
68
|
+
from .magnetometer import QMC5883L, HMC5883L
|
|
69
|
+
from .adxl345 import ADXL345
|
|
70
|
+
|
|
71
|
+
# Register known sensors: (driver_class, i2c_address, chip_id_check)
|
|
72
|
+
KNOWN_SENSORS = [
|
|
73
|
+
# IMU sensors
|
|
74
|
+
(MPU6050, 0x68, None),
|
|
75
|
+
(MPU6050, 0x69, None),
|
|
76
|
+
(MPU9250, 0x68, None), # Same address as MPU6050
|
|
77
|
+
# Environmental sensors
|
|
78
|
+
(BME280, 0x76, None),
|
|
79
|
+
(BME280, 0x77, None),
|
|
80
|
+
# Current/power monitoring
|
|
81
|
+
(INA219, 0x40, None),
|
|
82
|
+
(INA219, 0x41, None),
|
|
83
|
+
(INA219, 0x44, None),
|
|
84
|
+
(INA219, 0x45, None),
|
|
85
|
+
# Precision temperature/humidity
|
|
86
|
+
(SHT3x, 0x44, None),
|
|
87
|
+
(SHT3x, 0x45, None),
|
|
88
|
+
# Ambient light
|
|
89
|
+
(BH1750, 0x23, None),
|
|
90
|
+
(BH1750, 0x5C, None),
|
|
91
|
+
# Time-of-flight distance
|
|
92
|
+
(VL53L0X, 0x29, None),
|
|
93
|
+
# ADC
|
|
94
|
+
(ADS1115, 0x48, None),
|
|
95
|
+
(ADS1115, 0x49, None),
|
|
96
|
+
(ADS1115, 0x4A, None),
|
|
97
|
+
(ADS1115, 0x4B, None),
|
|
98
|
+
# Magnetometers
|
|
99
|
+
(QMC5883L, 0x0D, None),
|
|
100
|
+
(HMC5883L, 0x1E, None),
|
|
101
|
+
# Accelerometer (I2C mode)
|
|
102
|
+
(ADXL345, 0x53, None),
|
|
103
|
+
(ADXL345, 0x1D, None),
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _discover_i2c_buses() -> List[int]:
|
|
108
|
+
"""Find all available I2C bus numbers on the system."""
|
|
109
|
+
import glob as _glob
|
|
110
|
+
buses = []
|
|
111
|
+
for path in sorted(_glob.glob("/dev/i2c-*")):
|
|
112
|
+
try:
|
|
113
|
+
buses.append(int(path.split("-")[-1]))
|
|
114
|
+
except ValueError:
|
|
115
|
+
pass
|
|
116
|
+
return buses
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def scan_i2c(bus: int = 1) -> List[int]:
|
|
120
|
+
"""
|
|
121
|
+
Scan I2C bus for connected devices.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
bus: I2C bus number (usually 1 on Raspberry Pi)
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
List of detected I2C addresses
|
|
128
|
+
"""
|
|
129
|
+
try:
|
|
130
|
+
from smbus2 import SMBus
|
|
131
|
+
except ImportError:
|
|
132
|
+
raise ImportError(
|
|
133
|
+
"smbus2 is required for I2C scanning. Install with: pip install smbus2"
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
addresses = []
|
|
137
|
+
try:
|
|
138
|
+
i2c = SMBus(bus)
|
|
139
|
+
except PermissionError:
|
|
140
|
+
raise # Let CLI handle with user-friendly message
|
|
141
|
+
except OSError:
|
|
142
|
+
raise # Let CLI handle with user-friendly message
|
|
143
|
+
|
|
144
|
+
for addr in range(0x03, 0x78): # Valid I2C address range
|
|
145
|
+
try:
|
|
146
|
+
i2c.write_quick(addr)
|
|
147
|
+
addresses.append(addr)
|
|
148
|
+
except OSError:
|
|
149
|
+
pass # No device at this address
|
|
150
|
+
|
|
151
|
+
i2c.close()
|
|
152
|
+
return addresses
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def scan_sensors(bus: Optional[int] = None) -> List[DetectedSensor]:
|
|
156
|
+
"""
|
|
157
|
+
Scan for known sensors on I2C buses.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
bus: I2C bus number, or None to scan all available buses.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
List of detected sensors with their drivers
|
|
164
|
+
"""
|
|
165
|
+
_init_known_sensors()
|
|
166
|
+
|
|
167
|
+
buses = [bus] if bus is not None else _discover_i2c_buses()
|
|
168
|
+
|
|
169
|
+
detected = []
|
|
170
|
+
|
|
171
|
+
for scan_bus in buses:
|
|
172
|
+
try:
|
|
173
|
+
addresses = scan_i2c(scan_bus)
|
|
174
|
+
except OSError:
|
|
175
|
+
logger.debug("Could not open I2C bus %d, skipping", scan_bus)
|
|
176
|
+
continue
|
|
177
|
+
|
|
178
|
+
if not addresses:
|
|
179
|
+
continue
|
|
180
|
+
|
|
181
|
+
logger.debug("I2C bus %d: found addresses %s", scan_bus,
|
|
182
|
+
[f"0x{a:02X}" for a in addresses])
|
|
183
|
+
|
|
184
|
+
for address in addresses:
|
|
185
|
+
for driver, known_addr, _ in KNOWN_SENSORS:
|
|
186
|
+
if address == known_addr:
|
|
187
|
+
already_found = any(
|
|
188
|
+
d.address == address and d.bus == scan_bus and d.driver == driver
|
|
189
|
+
for d in detected
|
|
190
|
+
)
|
|
191
|
+
if not already_found:
|
|
192
|
+
try:
|
|
193
|
+
sensor = driver(address=address, bus=scan_bus)
|
|
194
|
+
if sensor.is_available():
|
|
195
|
+
detected.append(DetectedSensor(
|
|
196
|
+
name=driver.name,
|
|
197
|
+
address=address,
|
|
198
|
+
bus=scan_bus,
|
|
199
|
+
driver=driver,
|
|
200
|
+
description=driver.description,
|
|
201
|
+
))
|
|
202
|
+
break
|
|
203
|
+
except Exception as e:
|
|
204
|
+
logger.debug(f"Sensor probe failed at 0x{address:02X} on bus {scan_bus}: {e}")
|
|
205
|
+
|
|
206
|
+
# Also scan SPI buses if spidev is available
|
|
207
|
+
if _HAS_SPI:
|
|
208
|
+
try:
|
|
209
|
+
spi_matches = scan_spi()
|
|
210
|
+
for match in spi_matches:
|
|
211
|
+
detected.append(DetectedSensor(
|
|
212
|
+
name=match.name,
|
|
213
|
+
address=0, # SPI doesn't use addresses
|
|
214
|
+
bus=match.bus,
|
|
215
|
+
driver=match.driver,
|
|
216
|
+
description=match.description,
|
|
217
|
+
))
|
|
218
|
+
except Exception as e:
|
|
219
|
+
logger.debug("SPI scan failed: %s", e)
|
|
220
|
+
|
|
221
|
+
return detected
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def auto_sensors(
|
|
225
|
+
bus: Optional[int] = None,
|
|
226
|
+
sample_rate: Optional[float] = None,
|
|
227
|
+
prefix: str = "",
|
|
228
|
+
detected: Optional[List[DetectedSensor]] = None,
|
|
229
|
+
) -> SensorHub:
|
|
230
|
+
"""
|
|
231
|
+
Auto-detect sensors and create a SensorHub.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
bus: I2C bus number, or None to scan all available buses.
|
|
235
|
+
sample_rate: Override sample rate for all sensors (None = use defaults)
|
|
236
|
+
prefix: Prefix for all metric names
|
|
237
|
+
detected: Pre-scanned sensors to use (skips re-scanning if provided)
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
SensorHub with all detected sensors added
|
|
241
|
+
"""
|
|
242
|
+
hub = SensorHub()
|
|
243
|
+
|
|
244
|
+
if detected is None:
|
|
245
|
+
detected = scan_sensors(bus)
|
|
246
|
+
|
|
247
|
+
for info in detected:
|
|
248
|
+
if info.address == 0:
|
|
249
|
+
# SPI sensor
|
|
250
|
+
kwargs = {"bus_type": "spi", "spi_bus": info.bus}
|
|
251
|
+
else:
|
|
252
|
+
kwargs = {"address": info.address, "bus": info.bus}
|
|
253
|
+
|
|
254
|
+
if sample_rate is not None:
|
|
255
|
+
kwargs["sample_rate"] = sample_rate
|
|
256
|
+
|
|
257
|
+
if prefix:
|
|
258
|
+
kwargs["prefix"] = prefix
|
|
259
|
+
|
|
260
|
+
sensor = info.driver(**kwargs)
|
|
261
|
+
hub.add(sensor)
|
|
262
|
+
|
|
263
|
+
return hub
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def get_sensor_info() -> Dict[str, dict]:
|
|
267
|
+
"""
|
|
268
|
+
Get information about all supported sensors.
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
Dict mapping sensor names to their info
|
|
272
|
+
"""
|
|
273
|
+
_init_known_sensors()
|
|
274
|
+
|
|
275
|
+
info = {}
|
|
276
|
+
seen_drivers = set()
|
|
277
|
+
|
|
278
|
+
for driver, addr, _ in KNOWN_SENSORS:
|
|
279
|
+
if driver not in seen_drivers:
|
|
280
|
+
info[driver.name] = {
|
|
281
|
+
"name": driver.name,
|
|
282
|
+
"description": driver.description,
|
|
283
|
+
"metrics": driver.metrics,
|
|
284
|
+
"i2c_addresses": [
|
|
285
|
+
f"0x{a:02X}" for d, a, _ in KNOWN_SENSORS if d == driver
|
|
286
|
+
],
|
|
287
|
+
}
|
|
288
|
+
seen_drivers.add(driver)
|
|
289
|
+
|
|
290
|
+
return info
|
plexus/sensors/base.py
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base sensor class and utilities for Plexus sensor drivers.
|
|
3
|
+
|
|
4
|
+
All sensor drivers inherit from BaseSensor and implement the read() method.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
from abc import ABC, abstractmethod
|
|
11
|
+
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeoutError
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Dict, List, Optional, Any
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class SensorReading:
|
|
20
|
+
"""A single sensor reading with metric name and value."""
|
|
21
|
+
metric: str
|
|
22
|
+
value: Any
|
|
23
|
+
timestamp: float = field(default_factory=time.time)
|
|
24
|
+
tags: Dict[str, str] = field(default_factory=dict)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class BaseSensor(ABC):
|
|
28
|
+
"""
|
|
29
|
+
Base class for all sensor drivers.
|
|
30
|
+
|
|
31
|
+
Subclasses must implement:
|
|
32
|
+
- read() -> List[SensorReading]: Read current sensor values
|
|
33
|
+
- name: Human-readable sensor name
|
|
34
|
+
- metrics: List of metric names this sensor provides
|
|
35
|
+
|
|
36
|
+
Optional overrides:
|
|
37
|
+
- setup(): Initialize the sensor (called once)
|
|
38
|
+
- cleanup(): Clean up resources (called on stop)
|
|
39
|
+
- is_available(): Check if sensor is connected
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
# Sensor metadata (override in subclass)
|
|
43
|
+
name: str = "Unknown Sensor"
|
|
44
|
+
description: str = ""
|
|
45
|
+
metrics: List[str] = []
|
|
46
|
+
|
|
47
|
+
# I2C address(es) for auto-detection
|
|
48
|
+
i2c_addresses: List[int] = []
|
|
49
|
+
|
|
50
|
+
# SPI bus/chip-select pairs for auto-detection
|
|
51
|
+
spi_devices: List[tuple] = []
|
|
52
|
+
|
|
53
|
+
# Per-sensor read timeout (seconds). None = use SensorHub default.
|
|
54
|
+
read_timeout: Optional[float] = None
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
sample_rate: float = 10.0,
|
|
59
|
+
prefix: str = "",
|
|
60
|
+
tags: Optional[Dict[str, str]] = None,
|
|
61
|
+
):
|
|
62
|
+
"""
|
|
63
|
+
Initialize the sensor driver.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
sample_rate: Readings per second (Hz). Default 10 Hz.
|
|
67
|
+
prefix: Prefix for metric names (e.g., "robot1." -> "robot1.accel_x")
|
|
68
|
+
tags: Tags to add to all readings from this sensor
|
|
69
|
+
"""
|
|
70
|
+
self.sample_rate = sample_rate
|
|
71
|
+
self.prefix = prefix
|
|
72
|
+
self.tags = tags or {}
|
|
73
|
+
self._running = False
|
|
74
|
+
self._error: Optional[str] = None
|
|
75
|
+
self._consecutive_failures = 0
|
|
76
|
+
self._disabled = False
|
|
77
|
+
self._original_sample_rate = sample_rate
|
|
78
|
+
|
|
79
|
+
def validate_reading(self, reading: "SensorReading") -> bool:
|
|
80
|
+
"""
|
|
81
|
+
Validate a sensor reading. Override in subclasses for domain checks.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
True if the reading is valid
|
|
85
|
+
"""
|
|
86
|
+
return True
|
|
87
|
+
|
|
88
|
+
@abstractmethod
|
|
89
|
+
def read(self) -> List[SensorReading]:
|
|
90
|
+
"""
|
|
91
|
+
Read current sensor values.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
List of SensorReading objects with current values
|
|
95
|
+
"""
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
def setup(self) -> None:
|
|
99
|
+
"""
|
|
100
|
+
Initialize the sensor hardware.
|
|
101
|
+
Called once before reading starts.
|
|
102
|
+
Override in subclass if needed.
|
|
103
|
+
"""
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
def cleanup(self) -> None:
|
|
107
|
+
"""
|
|
108
|
+
Clean up sensor resources.
|
|
109
|
+
Called when sensor is stopped.
|
|
110
|
+
Override in subclass if needed.
|
|
111
|
+
"""
|
|
112
|
+
pass
|
|
113
|
+
|
|
114
|
+
def is_available(self) -> bool:
|
|
115
|
+
"""
|
|
116
|
+
Check if the sensor is connected and responding.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
True if sensor is available
|
|
120
|
+
"""
|
|
121
|
+
try:
|
|
122
|
+
self.read()
|
|
123
|
+
return True
|
|
124
|
+
except Exception:
|
|
125
|
+
return False
|
|
126
|
+
|
|
127
|
+
def get_prefixed_metric(self, metric: str) -> str:
|
|
128
|
+
"""Get metric name with prefix applied."""
|
|
129
|
+
if self.prefix:
|
|
130
|
+
return f"{self.prefix}{metric}"
|
|
131
|
+
return metric
|
|
132
|
+
|
|
133
|
+
def get_info(self) -> Dict[str, Any]:
|
|
134
|
+
"""Get sensor information for display."""
|
|
135
|
+
return {
|
|
136
|
+
"name": self.name,
|
|
137
|
+
"description": self.description,
|
|
138
|
+
"metrics": self.metrics,
|
|
139
|
+
"sample_rate": self.sample_rate,
|
|
140
|
+
"prefix": self.prefix,
|
|
141
|
+
"available": self.is_available(),
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class SensorHub:
|
|
146
|
+
"""
|
|
147
|
+
Manages multiple sensors and streams their data to Plexus.
|
|
148
|
+
|
|
149
|
+
Usage:
|
|
150
|
+
from plexus import Plexus
|
|
151
|
+
from plexus.sensors import SensorHub, MPU6050, BME280
|
|
152
|
+
|
|
153
|
+
hub = SensorHub()
|
|
154
|
+
hub.add(MPU6050())
|
|
155
|
+
hub.add(BME280())
|
|
156
|
+
hub.run(Plexus()) # Streams forever
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
def __init__(
|
|
160
|
+
self,
|
|
161
|
+
default_timeout: float = 5.0,
|
|
162
|
+
max_workers: Optional[int] = None,
|
|
163
|
+
):
|
|
164
|
+
"""
|
|
165
|
+
Args:
|
|
166
|
+
default_timeout: Default per-sensor read timeout in seconds.
|
|
167
|
+
max_workers: Max threads for concurrent reads. None = number of sensors.
|
|
168
|
+
"""
|
|
169
|
+
self.sensors: List[BaseSensor] = []
|
|
170
|
+
self._running = False
|
|
171
|
+
self.default_timeout = default_timeout
|
|
172
|
+
self.max_workers = max_workers
|
|
173
|
+
self.error_report_fn: Optional[Any] = None # async fn(source, error, severity)
|
|
174
|
+
|
|
175
|
+
def add(self, sensor: BaseSensor) -> "SensorHub":
|
|
176
|
+
"""Add a sensor to the hub."""
|
|
177
|
+
self.sensors.append(sensor)
|
|
178
|
+
return self
|
|
179
|
+
|
|
180
|
+
def remove(self, sensor: BaseSensor) -> "SensorHub":
|
|
181
|
+
"""Remove a sensor from the hub."""
|
|
182
|
+
self.sensors.remove(sensor)
|
|
183
|
+
return self
|
|
184
|
+
|
|
185
|
+
def setup(self) -> None:
|
|
186
|
+
"""Initialize all sensors."""
|
|
187
|
+
for sensor in self.sensors:
|
|
188
|
+
try:
|
|
189
|
+
sensor.setup()
|
|
190
|
+
except Exception as e:
|
|
191
|
+
logger.warning(f"Failed to setup {sensor.name}: {e}")
|
|
192
|
+
sensor._error = str(e)
|
|
193
|
+
|
|
194
|
+
def cleanup(self) -> None:
|
|
195
|
+
"""Clean up all sensors."""
|
|
196
|
+
for sensor in self.sensors:
|
|
197
|
+
try:
|
|
198
|
+
sensor.cleanup()
|
|
199
|
+
except Exception:
|
|
200
|
+
pass
|
|
201
|
+
|
|
202
|
+
def _get_timeout(self, sensor: BaseSensor) -> float:
|
|
203
|
+
"""Get the effective timeout for a sensor."""
|
|
204
|
+
return sensor.read_timeout if sensor.read_timeout is not None else self.default_timeout
|
|
205
|
+
|
|
206
|
+
def _handle_sensor_failure(self, sensor: BaseSensor) -> None:
|
|
207
|
+
"""Track consecutive failures and degrade gracefully."""
|
|
208
|
+
sensor._consecutive_failures += 1
|
|
209
|
+
if sensor._consecutive_failures >= 5 and not sensor._disabled:
|
|
210
|
+
new_rate = sensor.sample_rate / 2.0
|
|
211
|
+
if new_rate < 0.1:
|
|
212
|
+
sensor._disabled = True
|
|
213
|
+
logger.warning(
|
|
214
|
+
"%s disabled after %d consecutive failures",
|
|
215
|
+
sensor.name, sensor._consecutive_failures,
|
|
216
|
+
)
|
|
217
|
+
self._report_sensor_error(
|
|
218
|
+
sensor,
|
|
219
|
+
f"Disabled after {sensor._consecutive_failures} consecutive failures",
|
|
220
|
+
"error",
|
|
221
|
+
)
|
|
222
|
+
else:
|
|
223
|
+
sensor.sample_rate = new_rate
|
|
224
|
+
logger.warning(
|
|
225
|
+
"%s: %d consecutive failures, reducing poll rate to %.2f Hz",
|
|
226
|
+
sensor.name, sensor._consecutive_failures, new_rate,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
def _report_sensor_error(self, sensor: BaseSensor, error: str, severity: str) -> None:
|
|
230
|
+
"""Report sensor error to dashboard if error_report_fn is set."""
|
|
231
|
+
if self.error_report_fn:
|
|
232
|
+
import asyncio
|
|
233
|
+
try:
|
|
234
|
+
coro = self.error_report_fn(f"sensor.{sensor.name}", error, severity)
|
|
235
|
+
loop = asyncio.get_event_loop()
|
|
236
|
+
if loop.is_running():
|
|
237
|
+
asyncio.ensure_future(coro)
|
|
238
|
+
else:
|
|
239
|
+
loop.run_until_complete(coro)
|
|
240
|
+
except Exception:
|
|
241
|
+
pass
|
|
242
|
+
|
|
243
|
+
def _handle_sensor_success(self, sensor: BaseSensor) -> None:
|
|
244
|
+
"""Reset failure tracking on successful read."""
|
|
245
|
+
if sensor._consecutive_failures > 0:
|
|
246
|
+
sensor._consecutive_failures = 0
|
|
247
|
+
if sensor.sample_rate != sensor._original_sample_rate:
|
|
248
|
+
sensor.sample_rate = sensor._original_sample_rate
|
|
249
|
+
logger.info(
|
|
250
|
+
"%s recovered, restoring poll rate to %.2f Hz",
|
|
251
|
+
sensor.name, sensor._original_sample_rate,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
def read_all(self) -> List[SensorReading]:
|
|
255
|
+
"""Read from all sensors concurrently with per-sensor timeouts."""
|
|
256
|
+
active = [s for s in self.sensors if not s._disabled]
|
|
257
|
+
if not active:
|
|
258
|
+
return []
|
|
259
|
+
|
|
260
|
+
readings = []
|
|
261
|
+
workers = self.max_workers or len(active)
|
|
262
|
+
pool = ThreadPoolExecutor(max_workers=workers)
|
|
263
|
+
|
|
264
|
+
try:
|
|
265
|
+
futures = {pool.submit(s.read): s for s in active}
|
|
266
|
+
for future in futures:
|
|
267
|
+
sensor = futures[future]
|
|
268
|
+
timeout = self._get_timeout(sensor)
|
|
269
|
+
try:
|
|
270
|
+
sensor_readings = future.result(timeout=timeout)
|
|
271
|
+
validated = [r for r in sensor_readings if sensor.validate_reading(r)]
|
|
272
|
+
readings.extend(validated)
|
|
273
|
+
self._handle_sensor_success(sensor)
|
|
274
|
+
except FuturesTimeoutError:
|
|
275
|
+
logger.warning("Timeout reading %s (%.1fs)", sensor.name, timeout)
|
|
276
|
+
sensor._error = f"timeout ({timeout}s)"
|
|
277
|
+
future.cancel()
|
|
278
|
+
self._handle_sensor_failure(sensor)
|
|
279
|
+
except Exception as e:
|
|
280
|
+
logger.debug(f"Read error from {sensor.name}: {e}")
|
|
281
|
+
sensor._error = str(e)
|
|
282
|
+
self._handle_sensor_failure(sensor)
|
|
283
|
+
finally:
|
|
284
|
+
if sys.version_info >= (3, 9):
|
|
285
|
+
pool.shutdown(wait=False, cancel_futures=True)
|
|
286
|
+
else:
|
|
287
|
+
pool.shutdown(wait=False)
|
|
288
|
+
|
|
289
|
+
return readings
|
|
290
|
+
|
|
291
|
+
def run(
|
|
292
|
+
self,
|
|
293
|
+
client, # Plexus client
|
|
294
|
+
run_id: Optional[str] = None,
|
|
295
|
+
) -> None:
|
|
296
|
+
"""
|
|
297
|
+
Run the sensor hub, streaming data to Plexus.
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
client: Plexus client instance
|
|
301
|
+
run_id: Optional run ID for grouping data
|
|
302
|
+
"""
|
|
303
|
+
self.setup()
|
|
304
|
+
self._running = True
|
|
305
|
+
|
|
306
|
+
# Find the fastest sensor to determine loop timing
|
|
307
|
+
max_rate = max(s.sample_rate for s in self.sensors) if self.sensors else 10.0
|
|
308
|
+
min_interval = 1.0 / max_rate
|
|
309
|
+
|
|
310
|
+
# Track last read time per sensor
|
|
311
|
+
last_read = {id(s): 0.0 for s in self.sensors}
|
|
312
|
+
|
|
313
|
+
try:
|
|
314
|
+
context = client.run(run_id) if run_id else _nullcontext()
|
|
315
|
+
|
|
316
|
+
with context:
|
|
317
|
+
while self._running:
|
|
318
|
+
loop_start = time.time()
|
|
319
|
+
now = loop_start
|
|
320
|
+
|
|
321
|
+
# Collect sensors that are due for a read
|
|
322
|
+
due_sensors = []
|
|
323
|
+
for sensor in self.sensors:
|
|
324
|
+
if sensor._disabled:
|
|
325
|
+
continue
|
|
326
|
+
sensor_id = id(sensor)
|
|
327
|
+
interval = 1.0 / sensor.sample_rate
|
|
328
|
+
if now - last_read[sensor_id] >= interval:
|
|
329
|
+
due_sensors.append(sensor)
|
|
330
|
+
|
|
331
|
+
if due_sensors:
|
|
332
|
+
# Read all due sensors concurrently
|
|
333
|
+
workers = self.max_workers or len(due_sensors)
|
|
334
|
+
pool = ThreadPoolExecutor(max_workers=workers)
|
|
335
|
+
try:
|
|
336
|
+
futures = {pool.submit(s.read): s for s in due_sensors}
|
|
337
|
+
for future in futures:
|
|
338
|
+
sensor = futures[future]
|
|
339
|
+
timeout = self._get_timeout(sensor)
|
|
340
|
+
try:
|
|
341
|
+
readings = future.result(timeout=timeout)
|
|
342
|
+
validated = [r for r in readings if sensor.validate_reading(r)]
|
|
343
|
+
self._handle_sensor_success(sensor)
|
|
344
|
+
|
|
345
|
+
batch_points = []
|
|
346
|
+
batch_timestamp = None
|
|
347
|
+
batch_tags = None
|
|
348
|
+
|
|
349
|
+
for reading in validated:
|
|
350
|
+
metric = sensor.get_prefixed_metric(reading.metric)
|
|
351
|
+
tags = {**sensor.tags, **reading.tags}
|
|
352
|
+
batch_points.append((metric, reading.value))
|
|
353
|
+
|
|
354
|
+
if batch_timestamp is None:
|
|
355
|
+
batch_timestamp = reading.timestamp
|
|
356
|
+
batch_tags = tags if tags else None
|
|
357
|
+
|
|
358
|
+
if batch_points:
|
|
359
|
+
client.send_batch(
|
|
360
|
+
batch_points,
|
|
361
|
+
timestamp=batch_timestamp,
|
|
362
|
+
tags=batch_tags,
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
last_read[id(sensor)] = now
|
|
366
|
+
|
|
367
|
+
except FuturesTimeoutError:
|
|
368
|
+
logger.warning("Timeout reading %s (%.1fs)", sensor.name, timeout)
|
|
369
|
+
sensor._error = f"timeout ({timeout}s)"
|
|
370
|
+
future.cancel()
|
|
371
|
+
self._handle_sensor_failure(sensor)
|
|
372
|
+
last_read[id(sensor)] = now
|
|
373
|
+
except Exception as e:
|
|
374
|
+
sensor._error = str(e)
|
|
375
|
+
self._handle_sensor_failure(sensor)
|
|
376
|
+
last_read[id(sensor)] = now
|
|
377
|
+
finally:
|
|
378
|
+
if sys.version_info >= (3, 9):
|
|
379
|
+
pool.shutdown(wait=False, cancel_futures=True)
|
|
380
|
+
else:
|
|
381
|
+
pool.shutdown(wait=False)
|
|
382
|
+
|
|
383
|
+
# Sleep to maintain timing
|
|
384
|
+
elapsed = time.time() - loop_start
|
|
385
|
+
if elapsed < min_interval:
|
|
386
|
+
time.sleep(min_interval - elapsed)
|
|
387
|
+
|
|
388
|
+
finally:
|
|
389
|
+
self.cleanup()
|
|
390
|
+
|
|
391
|
+
def stop(self) -> None:
|
|
392
|
+
"""Stop the sensor hub."""
|
|
393
|
+
self._running = False
|
|
394
|
+
|
|
395
|
+
def get_info(self) -> List[Dict[str, Any]]:
|
|
396
|
+
"""Get info about all sensors."""
|
|
397
|
+
return [s.get_info() for s in self.sensors]
|
|
398
|
+
|
|
399
|
+
def get_sensor(self, name: str) -> Optional[BaseSensor]:
|
|
400
|
+
"""Get a sensor by name."""
|
|
401
|
+
for sensor in self.sensors:
|
|
402
|
+
if sensor.name == name:
|
|
403
|
+
return sensor
|
|
404
|
+
return None
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
class _nullcontext:
|
|
408
|
+
"""Null context manager for Python 3.8 compatibility."""
|
|
409
|
+
def __enter__(self):
|
|
410
|
+
return None
|
|
411
|
+
def __exit__(self, *args):
|
|
412
|
+
return False
|