plexus-python 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. plexus/__init__.py +31 -0
  2. plexus/__main__.py +4 -0
  3. plexus/adapters/__init__.py +122 -0
  4. plexus/adapters/base.py +409 -0
  5. plexus/adapters/ble.py +257 -0
  6. plexus/adapters/can.py +439 -0
  7. plexus/adapters/can_detect.py +174 -0
  8. plexus/adapters/mavlink.py +642 -0
  9. plexus/adapters/mavlink_detect.py +192 -0
  10. plexus/adapters/modbus.py +622 -0
  11. plexus/adapters/mqtt.py +350 -0
  12. plexus/adapters/opcua.py +607 -0
  13. plexus/adapters/registry.py +206 -0
  14. plexus/adapters/serial_adapter.py +547 -0
  15. plexus/buffer.py +257 -0
  16. plexus/cameras/__init__.py +57 -0
  17. plexus/cameras/auto.py +239 -0
  18. plexus/cameras/base.py +189 -0
  19. plexus/cameras/picamera.py +171 -0
  20. plexus/cameras/usb.py +143 -0
  21. plexus/cli.py +783 -0
  22. plexus/client.py +465 -0
  23. plexus/config.py +169 -0
  24. plexus/connector.py +666 -0
  25. plexus/deps.py +246 -0
  26. plexus/detect.py +1238 -0
  27. plexus/importers/__init__.py +25 -0
  28. plexus/importers/rosbag.py +778 -0
  29. plexus/sensors/__init__.py +118 -0
  30. plexus/sensors/ads1115.py +164 -0
  31. plexus/sensors/adxl345.py +179 -0
  32. plexus/sensors/auto.py +290 -0
  33. plexus/sensors/base.py +412 -0
  34. plexus/sensors/bh1750.py +102 -0
  35. plexus/sensors/bme280.py +241 -0
  36. plexus/sensors/gps.py +317 -0
  37. plexus/sensors/ina219.py +149 -0
  38. plexus/sensors/magnetometer.py +239 -0
  39. plexus/sensors/mpu6050.py +162 -0
  40. plexus/sensors/sht3x.py +139 -0
  41. plexus/sensors/spi_scan.py +164 -0
  42. plexus/sensors/system.py +261 -0
  43. plexus/sensors/vl53l0x.py +109 -0
  44. plexus/streaming.py +743 -0
  45. plexus/tui.py +642 -0
  46. plexus_python-0.1.0.dist-info/METADATA +470 -0
  47. plexus_python-0.1.0.dist-info/RECORD +50 -0
  48. plexus_python-0.1.0.dist-info/WHEEL +4 -0
  49. plexus_python-0.1.0.dist-info/entry_points.txt +2 -0
  50. plexus_python-0.1.0.dist-info/licenses/LICENSE +190 -0
@@ -0,0 +1,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
@@ -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