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/bh1750.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""
|
|
2
|
+
BH1750 ambient light sensor driver.
|
|
3
|
+
|
|
4
|
+
The BH1750 measures ambient light intensity in lux.
|
|
5
|
+
Communicates via I2C at address 0x23 (ADDR pin low) or 0x5C (ADDR pin high).
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
from plexus.sensors import BH1750
|
|
9
|
+
|
|
10
|
+
sensor = BH1750()
|
|
11
|
+
for reading in sensor.read():
|
|
12
|
+
print(f"{reading.metric}: {reading.value}")
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import time
|
|
16
|
+
from typing import List, Optional
|
|
17
|
+
from .base import BaseSensor, SensorReading
|
|
18
|
+
|
|
19
|
+
BH1750_ADDR = 0x23
|
|
20
|
+
BH1750_ADDR_ALT = 0x5C
|
|
21
|
+
|
|
22
|
+
# Commands
|
|
23
|
+
CMD_POWER_ON = 0x01
|
|
24
|
+
CMD_RESET = 0x07
|
|
25
|
+
CMD_CONT_HRES = 0x10 # Continuous high-resolution mode (1 lux, 120ms)
|
|
26
|
+
CMD_CONT_HRES2 = 0x11 # Continuous high-resolution mode 2 (0.5 lux, 120ms)
|
|
27
|
+
CMD_ONCE_HRES = 0x20 # One-time high-resolution mode (1 lux, 120ms)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class BH1750(BaseSensor):
|
|
31
|
+
"""
|
|
32
|
+
BH1750 ambient light sensor driver.
|
|
33
|
+
|
|
34
|
+
Provides:
|
|
35
|
+
- light_lux: Ambient light intensity in lux (1-65535 lux range)
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
name = "BH1750"
|
|
39
|
+
description = "Ambient light sensor (1-65535 lux)"
|
|
40
|
+
metrics = ["light_lux"]
|
|
41
|
+
i2c_addresses = [BH1750_ADDR, BH1750_ADDR_ALT]
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
address: int = BH1750_ADDR,
|
|
46
|
+
bus: int = 1,
|
|
47
|
+
sample_rate: float = 1.0,
|
|
48
|
+
prefix: str = "",
|
|
49
|
+
tags: Optional[dict] = None,
|
|
50
|
+
):
|
|
51
|
+
super().__init__(sample_rate=sample_rate, prefix=prefix, tags=tags)
|
|
52
|
+
self.address = address
|
|
53
|
+
self.bus_num = bus
|
|
54
|
+
self._bus = None
|
|
55
|
+
|
|
56
|
+
def setup(self) -> None:
|
|
57
|
+
try:
|
|
58
|
+
from smbus2 import SMBus
|
|
59
|
+
except ImportError:
|
|
60
|
+
raise ImportError(
|
|
61
|
+
"smbus2 is required for BH1750. Install with: pip install smbus2"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
self._bus = SMBus(self.bus_num)
|
|
65
|
+
|
|
66
|
+
# Power on and set continuous high-res mode
|
|
67
|
+
self._bus.write_byte(self.address, CMD_POWER_ON)
|
|
68
|
+
time.sleep(0.01)
|
|
69
|
+
self._bus.write_byte(self.address, CMD_CONT_HRES)
|
|
70
|
+
time.sleep(0.180) # First measurement takes up to 180ms
|
|
71
|
+
|
|
72
|
+
def cleanup(self) -> None:
|
|
73
|
+
if self._bus:
|
|
74
|
+
self._bus.close()
|
|
75
|
+
self._bus = None
|
|
76
|
+
|
|
77
|
+
def read(self) -> List[SensorReading]:
|
|
78
|
+
if self._bus is None:
|
|
79
|
+
self.setup()
|
|
80
|
+
|
|
81
|
+
# Read 2 bytes of light data
|
|
82
|
+
data = self._bus.read_i2c_block_data(self.address, CMD_CONT_HRES, 2)
|
|
83
|
+
raw = (data[0] << 8) | data[1]
|
|
84
|
+
|
|
85
|
+
# Convert to lux (divide by 1.2 per datasheet)
|
|
86
|
+
lux = raw / 1.2
|
|
87
|
+
|
|
88
|
+
return [
|
|
89
|
+
SensorReading("light_lux", round(lux, 1)),
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
def is_available(self) -> bool:
|
|
93
|
+
try:
|
|
94
|
+
from smbus2 import SMBus
|
|
95
|
+
|
|
96
|
+
bus = SMBus(self.bus_num)
|
|
97
|
+
# Power on command — if device ACKs, it's there
|
|
98
|
+
bus.write_byte(self.address, CMD_POWER_ON)
|
|
99
|
+
bus.close()
|
|
100
|
+
return True
|
|
101
|
+
except Exception:
|
|
102
|
+
return False
|
plexus/sensors/bme280.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""
|
|
2
|
+
BME280 environmental sensor driver.
|
|
3
|
+
|
|
4
|
+
The BME280 provides temperature, humidity, and pressure readings.
|
|
5
|
+
Communicates via I2C at address 0x76 or 0x77.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
from plexus import Plexus
|
|
9
|
+
from plexus.sensors import BME280
|
|
10
|
+
|
|
11
|
+
px = Plexus()
|
|
12
|
+
env = BME280()
|
|
13
|
+
|
|
14
|
+
while True:
|
|
15
|
+
for reading in env.read():
|
|
16
|
+
px.send(reading.metric, reading.value)
|
|
17
|
+
time.sleep(1) # 1 Hz is typical for environmental
|
|
18
|
+
|
|
19
|
+
Or with SensorHub:
|
|
20
|
+
from plexus.sensors import SensorHub, BME280
|
|
21
|
+
|
|
22
|
+
hub = SensorHub()
|
|
23
|
+
hub.add(BME280(sample_rate=1))
|
|
24
|
+
hub.run(Plexus())
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from typing import List, Optional
|
|
28
|
+
from .base import BaseSensor, SensorReading
|
|
29
|
+
|
|
30
|
+
# I2C addresses
|
|
31
|
+
BME280_ADDR = 0x76
|
|
32
|
+
BME280_ADDR_ALT = 0x77
|
|
33
|
+
|
|
34
|
+
# Register addresses
|
|
35
|
+
BME280_CHIP_ID_REG = 0xD0
|
|
36
|
+
BME280_CTRL_HUM = 0xF2
|
|
37
|
+
BME280_CTRL_MEAS = 0xF4
|
|
38
|
+
BME280_CONFIG = 0xF5
|
|
39
|
+
BME280_DATA_START = 0xF7
|
|
40
|
+
BME280_CALIB_START = 0x88
|
|
41
|
+
BME280_CALIB_HUM_START = 0xE1
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class BME280(BaseSensor):
|
|
45
|
+
"""
|
|
46
|
+
BME280 environmental sensor driver.
|
|
47
|
+
|
|
48
|
+
Provides:
|
|
49
|
+
- temperature: Temperature in Celsius
|
|
50
|
+
- humidity: Relative humidity in %
|
|
51
|
+
- pressure: Atmospheric pressure in hPa
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
name = "BME280"
|
|
55
|
+
description = "Environmental sensor (temperature, humidity, pressure)"
|
|
56
|
+
metrics = ["temperature", "humidity", "pressure"]
|
|
57
|
+
i2c_addresses = [BME280_ADDR, BME280_ADDR_ALT]
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
address: int = BME280_ADDR,
|
|
62
|
+
bus: int = 1,
|
|
63
|
+
sample_rate: float = 1.0,
|
|
64
|
+
prefix: str = "",
|
|
65
|
+
tags: Optional[dict] = None,
|
|
66
|
+
):
|
|
67
|
+
"""
|
|
68
|
+
Initialize BME280 driver.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
address: I2C address (0x76 or 0x77)
|
|
72
|
+
bus: I2C bus number (usually 1 on Raspberry Pi)
|
|
73
|
+
sample_rate: Readings per second (1 Hz typical)
|
|
74
|
+
prefix: Prefix for metric names
|
|
75
|
+
tags: Tags to add to all readings
|
|
76
|
+
"""
|
|
77
|
+
super().__init__(sample_rate=sample_rate, prefix=prefix, tags=tags)
|
|
78
|
+
self.address = address
|
|
79
|
+
self.bus_num = bus
|
|
80
|
+
self._bus = None
|
|
81
|
+
self._calib = None
|
|
82
|
+
|
|
83
|
+
def setup(self) -> None:
|
|
84
|
+
"""Initialize the BME280 and read calibration data."""
|
|
85
|
+
try:
|
|
86
|
+
from smbus2 import SMBus
|
|
87
|
+
except ImportError:
|
|
88
|
+
raise ImportError(
|
|
89
|
+
"smbus2 is required for BME280. Install with: pip install smbus2"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
self._bus = SMBus(self.bus_num)
|
|
93
|
+
|
|
94
|
+
# Read calibration data
|
|
95
|
+
self._read_calibration()
|
|
96
|
+
|
|
97
|
+
# Configure sensor
|
|
98
|
+
# Humidity oversampling x1
|
|
99
|
+
self._bus.write_byte_data(self.address, BME280_CTRL_HUM, 0x01)
|
|
100
|
+
# Temperature and pressure oversampling x1, normal mode
|
|
101
|
+
self._bus.write_byte_data(self.address, BME280_CTRL_MEAS, 0x27)
|
|
102
|
+
# Standby 1000ms, filter off
|
|
103
|
+
self._bus.write_byte_data(self.address, BME280_CONFIG, 0xA0)
|
|
104
|
+
|
|
105
|
+
def cleanup(self) -> None:
|
|
106
|
+
"""Close I2C bus."""
|
|
107
|
+
if self._bus:
|
|
108
|
+
self._bus.close()
|
|
109
|
+
self._bus = None
|
|
110
|
+
|
|
111
|
+
def _read_calibration(self) -> None:
|
|
112
|
+
"""Read factory calibration data."""
|
|
113
|
+
# Read temperature and pressure calibration (26 bytes at 0x88)
|
|
114
|
+
calib1 = self._bus.read_i2c_block_data(self.address, BME280_CALIB_START, 26)
|
|
115
|
+
|
|
116
|
+
# Read humidity calibration (7 bytes at 0xE1)
|
|
117
|
+
calib2 = self._bus.read_i2c_block_data(self.address, BME280_CALIB_HUM_START, 7)
|
|
118
|
+
|
|
119
|
+
# Parse calibration data
|
|
120
|
+
self._calib = {
|
|
121
|
+
# Temperature
|
|
122
|
+
"T1": calib1[0] | (calib1[1] << 8),
|
|
123
|
+
"T2": self._signed16(calib1[2] | (calib1[3] << 8)),
|
|
124
|
+
"T3": self._signed16(calib1[4] | (calib1[5] << 8)),
|
|
125
|
+
# Pressure
|
|
126
|
+
"P1": calib1[6] | (calib1[7] << 8),
|
|
127
|
+
"P2": self._signed16(calib1[8] | (calib1[9] << 8)),
|
|
128
|
+
"P3": self._signed16(calib1[10] | (calib1[11] << 8)),
|
|
129
|
+
"P4": self._signed16(calib1[12] | (calib1[13] << 8)),
|
|
130
|
+
"P5": self._signed16(calib1[14] | (calib1[15] << 8)),
|
|
131
|
+
"P6": self._signed16(calib1[16] | (calib1[17] << 8)),
|
|
132
|
+
"P7": self._signed16(calib1[18] | (calib1[19] << 8)),
|
|
133
|
+
"P8": self._signed16(calib1[20] | (calib1[21] << 8)),
|
|
134
|
+
"P9": self._signed16(calib1[22] | (calib1[23] << 8)),
|
|
135
|
+
# Humidity
|
|
136
|
+
"H1": calib1[25],
|
|
137
|
+
"H2": self._signed16(calib2[0] | (calib2[1] << 8)),
|
|
138
|
+
"H3": calib2[2],
|
|
139
|
+
"H4": (calib2[3] << 4) | (calib2[4] & 0x0F),
|
|
140
|
+
"H5": (calib2[5] << 4) | ((calib2[4] >> 4) & 0x0F),
|
|
141
|
+
"H6": self._signed8(calib2[6]),
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
def _signed16(self, value: int) -> int:
|
|
145
|
+
"""Convert unsigned 16-bit to signed."""
|
|
146
|
+
if value > 32767:
|
|
147
|
+
return value - 65536
|
|
148
|
+
return value
|
|
149
|
+
|
|
150
|
+
def _signed8(self, value: int) -> int:
|
|
151
|
+
"""Convert unsigned 8-bit to signed."""
|
|
152
|
+
if value > 127:
|
|
153
|
+
return value - 256
|
|
154
|
+
return value
|
|
155
|
+
|
|
156
|
+
def read(self) -> List[SensorReading]:
|
|
157
|
+
"""Read temperature, humidity, and pressure."""
|
|
158
|
+
if self._bus is None:
|
|
159
|
+
self.setup()
|
|
160
|
+
|
|
161
|
+
# Read raw data (8 bytes starting at 0xF7)
|
|
162
|
+
data = self._bus.read_i2c_block_data(self.address, BME280_DATA_START, 8)
|
|
163
|
+
|
|
164
|
+
# Parse raw values (20-bit for pressure/temp, 16-bit for humidity)
|
|
165
|
+
raw_press = (data[0] << 12) | (data[1] << 4) | (data[2] >> 4)
|
|
166
|
+
raw_temp = (data[3] << 12) | (data[4] << 4) | (data[5] >> 4)
|
|
167
|
+
raw_hum = (data[6] << 8) | data[7]
|
|
168
|
+
|
|
169
|
+
# Compensate temperature
|
|
170
|
+
temperature, t_fine = self._compensate_temperature(raw_temp)
|
|
171
|
+
|
|
172
|
+
# Compensate pressure
|
|
173
|
+
pressure = self._compensate_pressure(raw_press, t_fine)
|
|
174
|
+
|
|
175
|
+
# Compensate humidity
|
|
176
|
+
humidity = self._compensate_humidity(raw_hum, t_fine)
|
|
177
|
+
|
|
178
|
+
return [
|
|
179
|
+
SensorReading("temperature", round(temperature, 2)),
|
|
180
|
+
SensorReading("humidity", round(humidity, 1)),
|
|
181
|
+
SensorReading("pressure", round(pressure, 2)),
|
|
182
|
+
]
|
|
183
|
+
|
|
184
|
+
def _compensate_temperature(self, raw: int) -> tuple:
|
|
185
|
+
"""Compensate raw temperature value. Returns (temp_C, t_fine)."""
|
|
186
|
+
c = self._calib
|
|
187
|
+
var1 = ((raw / 16384.0) - (c["T1"] / 1024.0)) * c["T2"]
|
|
188
|
+
var2 = (((raw / 131072.0) - (c["T1"] / 8192.0)) ** 2) * c["T3"]
|
|
189
|
+
t_fine = var1 + var2
|
|
190
|
+
temperature = t_fine / 5120.0
|
|
191
|
+
return temperature, t_fine
|
|
192
|
+
|
|
193
|
+
def _compensate_pressure(self, raw: int, t_fine: float) -> float:
|
|
194
|
+
"""Compensate raw pressure value. Returns pressure in hPa."""
|
|
195
|
+
c = self._calib
|
|
196
|
+
var1 = t_fine / 2.0 - 64000.0
|
|
197
|
+
var2 = var1 * var1 * c["P6"] / 32768.0
|
|
198
|
+
var2 = var2 + var1 * c["P5"] * 2.0
|
|
199
|
+
var2 = var2 / 4.0 + c["P4"] * 65536.0
|
|
200
|
+
var1 = (c["P3"] * var1 * var1 / 524288.0 + c["P2"] * var1) / 524288.0
|
|
201
|
+
var1 = (1.0 + var1 / 32768.0) * c["P1"]
|
|
202
|
+
|
|
203
|
+
if var1 == 0:
|
|
204
|
+
return 0
|
|
205
|
+
|
|
206
|
+
pressure = 1048576.0 - raw
|
|
207
|
+
pressure = ((pressure - var2 / 4096.0) * 6250.0) / var1
|
|
208
|
+
var1 = c["P9"] * pressure * pressure / 2147483648.0
|
|
209
|
+
var2 = pressure * c["P8"] / 32768.0
|
|
210
|
+
pressure = pressure + (var1 + var2 + c["P7"]) / 16.0
|
|
211
|
+
|
|
212
|
+
return pressure / 100.0 # Convert Pa to hPa
|
|
213
|
+
|
|
214
|
+
def _compensate_humidity(self, raw: int, t_fine: float) -> float:
|
|
215
|
+
"""Compensate raw humidity value. Returns relative humidity in %."""
|
|
216
|
+
c = self._calib
|
|
217
|
+
humidity = t_fine - 76800.0
|
|
218
|
+
humidity = (raw - (c["H4"] * 64.0 + c["H5"] / 16384.0 * humidity)) * (
|
|
219
|
+
c["H2"] / 65536.0 * (1.0 + c["H6"] / 67108864.0 * humidity *
|
|
220
|
+
(1.0 + c["H3"] / 67108864.0 * humidity))
|
|
221
|
+
)
|
|
222
|
+
humidity = humidity * (1.0 - c["H1"] * humidity / 524288.0)
|
|
223
|
+
|
|
224
|
+
if humidity < 0:
|
|
225
|
+
humidity = 0
|
|
226
|
+
elif humidity > 100:
|
|
227
|
+
humidity = 100
|
|
228
|
+
|
|
229
|
+
return humidity
|
|
230
|
+
|
|
231
|
+
def is_available(self) -> bool:
|
|
232
|
+
"""Check if BME280 is connected."""
|
|
233
|
+
try:
|
|
234
|
+
from smbus2 import SMBus
|
|
235
|
+
|
|
236
|
+
bus = SMBus(self.bus_num)
|
|
237
|
+
chip_id = bus.read_byte_data(self.address, BME280_CHIP_ID_REG)
|
|
238
|
+
bus.close()
|
|
239
|
+
return chip_id == 0x60 # BME280 chip ID
|
|
240
|
+
except Exception:
|
|
241
|
+
return False
|
plexus/sensors/gps.py
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""
|
|
2
|
+
GPS NMEA sensor driver.
|
|
3
|
+
|
|
4
|
+
Reads NMEA sentences from a serial GPS module (e.g., NEO-6M, NEO-7M, u-blox)
|
|
5
|
+
and provides latitude, longitude, altitude, speed, and satellite count.
|
|
6
|
+
|
|
7
|
+
Default: /dev/ttyAMA0 at 9600 baud (Raspberry Pi UART)
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
from plexus.sensors import GPSSensor
|
|
11
|
+
|
|
12
|
+
gps = GPSSensor()
|
|
13
|
+
for reading in gps.read():
|
|
14
|
+
print(f"{reading.metric}: {reading.value}")
|
|
15
|
+
|
|
16
|
+
Or with auto-detection:
|
|
17
|
+
from plexus.sensors import GPSSensor
|
|
18
|
+
|
|
19
|
+
gps = GPSSensor.auto_detect()
|
|
20
|
+
if gps:
|
|
21
|
+
for reading in gps.read():
|
|
22
|
+
print(f"{reading.metric}: {reading.value}")
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import logging
|
|
26
|
+
from typing import List, Optional
|
|
27
|
+
from .base import BaseSensor, SensorReading
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
# Common GPS serial ports on Linux/Raspberry Pi
|
|
32
|
+
GPS_SERIAL_PORTS = [
|
|
33
|
+
"/dev/ttyAMA0", # Raspberry Pi built-in UART
|
|
34
|
+
"/dev/ttyACM0", # USB GPS (u-blox)
|
|
35
|
+
"/dev/ttyUSB0", # USB-serial GPS
|
|
36
|
+
"/dev/ttyS0", # Standard serial
|
|
37
|
+
"/dev/serial0", # RPi serial symlink
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
GPS_DEFAULT_BAUD = 9600
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _nmea_to_decimal(raw: str, direction: str) -> Optional[float]:
|
|
44
|
+
"""Convert NMEA coordinate (DDMM.MMMMM) to decimal degrees."""
|
|
45
|
+
if not raw or not direction:
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
raw_f = float(raw)
|
|
50
|
+
except ValueError:
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
degrees = int(raw_f / 100)
|
|
54
|
+
minutes = raw_f - (degrees * 100)
|
|
55
|
+
decimal = degrees + (minutes / 60.0)
|
|
56
|
+
|
|
57
|
+
if direction in ("S", "W"):
|
|
58
|
+
decimal = -decimal
|
|
59
|
+
|
|
60
|
+
return decimal
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _validate_coordinate(lat: float, lon: float) -> bool:
|
|
64
|
+
"""Check that lat/lon are within valid ranges."""
|
|
65
|
+
return -90.0 <= lat <= 90.0 and -180.0 <= lon <= 180.0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _nmea_checksum(sentence: str, require_checksum: bool = True) -> bool:
|
|
69
|
+
"""Verify NMEA checksum. Rejects sentences without checksum by default."""
|
|
70
|
+
if "*" not in sentence:
|
|
71
|
+
return not require_checksum
|
|
72
|
+
|
|
73
|
+
body, checksum_str = sentence.rsplit("*", 1)
|
|
74
|
+
# Remove leading $
|
|
75
|
+
body = body.lstrip("$")
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
expected = int(checksum_str[:2], 16)
|
|
79
|
+
except ValueError:
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
computed = 0
|
|
83
|
+
for c in body:
|
|
84
|
+
computed ^= ord(c)
|
|
85
|
+
|
|
86
|
+
return computed == expected
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class GPSSensor(BaseSensor):
|
|
90
|
+
"""
|
|
91
|
+
GPS NMEA sensor driver.
|
|
92
|
+
|
|
93
|
+
Reads NMEA sentences from a serial GPS module.
|
|
94
|
+
|
|
95
|
+
Provides:
|
|
96
|
+
- gps_latitude: Latitude in decimal degrees
|
|
97
|
+
- gps_longitude: Longitude in decimal degrees
|
|
98
|
+
- gps_altitude: Altitude in meters (from GGA)
|
|
99
|
+
- gps_speed_knots: Speed over ground in knots (from RMC)
|
|
100
|
+
- gps_satellites: Number of satellites in use
|
|
101
|
+
- gps_hdop: Horizontal dilution of precision
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
name = "GPS"
|
|
105
|
+
description = "GPS receiver (position, altitude, speed)"
|
|
106
|
+
metrics = [
|
|
107
|
+
"gps_latitude", "gps_longitude", "gps_altitude",
|
|
108
|
+
"gps_speed_knots", "gps_satellites", "gps_hdop",
|
|
109
|
+
]
|
|
110
|
+
i2c_addresses = [] # Not an I2C sensor
|
|
111
|
+
|
|
112
|
+
def __init__(
|
|
113
|
+
self,
|
|
114
|
+
port: str = "/dev/ttyAMA0",
|
|
115
|
+
baudrate: int = GPS_DEFAULT_BAUD,
|
|
116
|
+
sample_rate: float = 1.0,
|
|
117
|
+
prefix: str = "",
|
|
118
|
+
tags: Optional[dict] = None,
|
|
119
|
+
):
|
|
120
|
+
super().__init__(sample_rate=sample_rate, prefix=prefix, tags=tags)
|
|
121
|
+
self.port = port
|
|
122
|
+
self.baudrate = baudrate
|
|
123
|
+
self._serial = None
|
|
124
|
+
self._latitude = None
|
|
125
|
+
self._longitude = None
|
|
126
|
+
self._altitude = None
|
|
127
|
+
self._speed_knots = None
|
|
128
|
+
self._satellites = None
|
|
129
|
+
self._hdop = None
|
|
130
|
+
self._valid = False
|
|
131
|
+
self._buffer = ""
|
|
132
|
+
|
|
133
|
+
def setup(self) -> None:
|
|
134
|
+
try:
|
|
135
|
+
import serial
|
|
136
|
+
except ImportError:
|
|
137
|
+
raise ImportError(
|
|
138
|
+
"pyserial is required for GPS. Install with: pip install pyserial"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
self._serial = serial.Serial(
|
|
142
|
+
self.port,
|
|
143
|
+
self.baudrate,
|
|
144
|
+
timeout=1.0,
|
|
145
|
+
)
|
|
146
|
+
# Flush input buffer
|
|
147
|
+
self._serial.reset_input_buffer()
|
|
148
|
+
|
|
149
|
+
def cleanup(self) -> None:
|
|
150
|
+
if self._serial:
|
|
151
|
+
self._serial.close()
|
|
152
|
+
self._serial = None
|
|
153
|
+
|
|
154
|
+
def _parse_gga(self, fields: List[str]) -> None:
|
|
155
|
+
"""Parse $GPGGA or $GNGGA sentence."""
|
|
156
|
+
if len(fields) < 10:
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
quality = int(fields[6]) if fields[6] else 0
|
|
161
|
+
except ValueError:
|
|
162
|
+
return
|
|
163
|
+
if quality == 0:
|
|
164
|
+
self._valid = False
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
lat = _nmea_to_decimal(fields[2], fields[3])
|
|
168
|
+
lon = _nmea_to_decimal(fields[4], fields[5])
|
|
169
|
+
|
|
170
|
+
if lat is not None and lon is not None:
|
|
171
|
+
if not _validate_coordinate(lat, lon):
|
|
172
|
+
return
|
|
173
|
+
self._latitude = lat
|
|
174
|
+
self._longitude = lon
|
|
175
|
+
elif lat is not None:
|
|
176
|
+
self._latitude = lat
|
|
177
|
+
elif lon is not None:
|
|
178
|
+
self._longitude = lon
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
self._satellites = int(fields[7]) if fields[7] else None
|
|
182
|
+
except ValueError:
|
|
183
|
+
self._satellites = None
|
|
184
|
+
try:
|
|
185
|
+
self._hdop = float(fields[8]) if fields[8] else None
|
|
186
|
+
except ValueError:
|
|
187
|
+
self._hdop = None
|
|
188
|
+
try:
|
|
189
|
+
self._altitude = float(fields[9]) if fields[9] else None
|
|
190
|
+
except ValueError:
|
|
191
|
+
self._altitude = None
|
|
192
|
+
self._valid = True
|
|
193
|
+
|
|
194
|
+
def _parse_rmc(self, fields: List[str]) -> None:
|
|
195
|
+
"""Parse $GPRMC or $GNRMC sentence."""
|
|
196
|
+
if len(fields) < 8:
|
|
197
|
+
return
|
|
198
|
+
|
|
199
|
+
if fields[2] != "A": # V = void (no fix)
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
lat = _nmea_to_decimal(fields[3], fields[4])
|
|
203
|
+
lon = _nmea_to_decimal(fields[5], fields[6])
|
|
204
|
+
|
|
205
|
+
if lat is not None and lon is not None:
|
|
206
|
+
if not _validate_coordinate(lat, lon):
|
|
207
|
+
return
|
|
208
|
+
self._latitude = lat
|
|
209
|
+
self._longitude = lon
|
|
210
|
+
elif lat is not None:
|
|
211
|
+
self._latitude = lat
|
|
212
|
+
elif lon is not None:
|
|
213
|
+
self._longitude = lon
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
self._speed_knots = float(fields[7]) if fields[7] else None
|
|
217
|
+
except ValueError:
|
|
218
|
+
self._speed_knots = None
|
|
219
|
+
self._valid = True
|
|
220
|
+
|
|
221
|
+
def _process_line(self, line: str) -> None:
|
|
222
|
+
"""Process a single NMEA sentence."""
|
|
223
|
+
line = line.strip()
|
|
224
|
+
if not line.startswith("$"):
|
|
225
|
+
return
|
|
226
|
+
|
|
227
|
+
if not _nmea_checksum(line):
|
|
228
|
+
return
|
|
229
|
+
|
|
230
|
+
# Remove checksum for parsing
|
|
231
|
+
if "*" in line:
|
|
232
|
+
line = line[:line.index("*")]
|
|
233
|
+
|
|
234
|
+
fields = line.split(",")
|
|
235
|
+
sentence_type = fields[0]
|
|
236
|
+
|
|
237
|
+
if sentence_type in ("$GPGGA", "$GNGGA"):
|
|
238
|
+
self._parse_gga(fields)
|
|
239
|
+
elif sentence_type in ("$GPRMC", "$GNRMC"):
|
|
240
|
+
self._parse_rmc(fields)
|
|
241
|
+
|
|
242
|
+
def read(self) -> List[SensorReading]:
|
|
243
|
+
if self._serial is None:
|
|
244
|
+
self.setup()
|
|
245
|
+
|
|
246
|
+
# Read available data (non-blocking batch)
|
|
247
|
+
if self._serial.in_waiting > 0:
|
|
248
|
+
raw = self._serial.read(self._serial.in_waiting)
|
|
249
|
+
self._buffer += raw.decode("ascii", errors="ignore")
|
|
250
|
+
|
|
251
|
+
# Process complete lines
|
|
252
|
+
while "\n" in self._buffer:
|
|
253
|
+
line, self._buffer = self._buffer.split("\n", 1)
|
|
254
|
+
self._process_line(line)
|
|
255
|
+
|
|
256
|
+
if not self._valid:
|
|
257
|
+
return []
|
|
258
|
+
|
|
259
|
+
readings = []
|
|
260
|
+
if self._latitude is not None:
|
|
261
|
+
readings.append(SensorReading("gps_latitude", round(self._latitude, 6)))
|
|
262
|
+
if self._longitude is not None:
|
|
263
|
+
readings.append(SensorReading("gps_longitude", round(self._longitude, 6)))
|
|
264
|
+
if self._altitude is not None:
|
|
265
|
+
readings.append(SensorReading("gps_altitude", round(self._altitude, 1)))
|
|
266
|
+
if self._speed_knots is not None:
|
|
267
|
+
readings.append(SensorReading("gps_speed_knots", round(self._speed_knots, 1)))
|
|
268
|
+
if self._satellites is not None:
|
|
269
|
+
readings.append(SensorReading("gps_satellites", self._satellites))
|
|
270
|
+
if self._hdop is not None:
|
|
271
|
+
readings.append(SensorReading("gps_hdop", round(self._hdop, 1)))
|
|
272
|
+
|
|
273
|
+
return readings
|
|
274
|
+
|
|
275
|
+
def validate_reading(self, reading: "SensorReading") -> bool:
|
|
276
|
+
"""Reject readings with out-of-range values."""
|
|
277
|
+
if reading.metric == "gps_latitude" and not (-90.0 <= reading.value <= 90.0):
|
|
278
|
+
return False
|
|
279
|
+
if reading.metric == "gps_longitude" and not (-180.0 <= reading.value <= 180.0):
|
|
280
|
+
return False
|
|
281
|
+
if reading.metric == "gps_altitude" and not (-1000.0 <= reading.value <= 100000.0):
|
|
282
|
+
return False
|
|
283
|
+
if reading.metric == "gps_satellites" and not (0 <= reading.value <= 100):
|
|
284
|
+
return False
|
|
285
|
+
return True
|
|
286
|
+
|
|
287
|
+
def is_available(self) -> bool:
|
|
288
|
+
"""Check if a GPS module is connected and sending data."""
|
|
289
|
+
try:
|
|
290
|
+
import serial
|
|
291
|
+
|
|
292
|
+
ser = serial.Serial(self.port, self.baudrate, timeout=3.0)
|
|
293
|
+
data = ser.read(256)
|
|
294
|
+
ser.close()
|
|
295
|
+
# Check if we got any NMEA data
|
|
296
|
+
return b"$GP" in data or b"$GN" in data
|
|
297
|
+
except Exception:
|
|
298
|
+
return False
|
|
299
|
+
|
|
300
|
+
@classmethod
|
|
301
|
+
def auto_detect(cls) -> Optional["GPSSensor"]:
|
|
302
|
+
"""
|
|
303
|
+
Try to find a connected GPS module on common serial ports.
|
|
304
|
+
|
|
305
|
+
Returns:
|
|
306
|
+
GPSSensor instance if found, None otherwise.
|
|
307
|
+
"""
|
|
308
|
+
for port in GPS_SERIAL_PORTS:
|
|
309
|
+
try:
|
|
310
|
+
sensor = cls(port=port)
|
|
311
|
+
if sensor.is_available():
|
|
312
|
+
logger.info(f"GPS detected on {port}")
|
|
313
|
+
return cls(port=port)
|
|
314
|
+
except Exception:
|
|
315
|
+
continue
|
|
316
|
+
|
|
317
|
+
return None
|