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
|
@@ -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
|
plexus/sensors/system.py
ADDED
|
@@ -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
|