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/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