Files
AR-GPS/backend/nmea_reader.py
T

211 lines
8.3 KiB
Python

"""NMEA 0183 serial reader — parses GGA, RMC, VTG, GSV, GSA, GLL.
Runs in a background thread; calls broadcast_fn(msg) directly.
Thread safety is handled by the caller (Qt signal emit or similar)."""
import threading, serial, serial.tools.list_ports
# Known USB-serial VIDs: u-blox, CH340, FTDI, Prolific
KNOWN_VIDS = {0x1546, 0x1A86, 0x0403, 0x067B}
SYSTEM_MAP = {"GP": "GPS", "GL": "GLONASS", "GA": "Galileo",
"GB": "BeiDou", "GN": "GNSS", "GQ": "QZSS"}
class NMEAReader(threading.Thread):
def __init__(self, port: str, baud: int, broadcast_fn):
super().__init__(daemon=True, name="nmea-reader")
self.port = port
self.baud = baud
self._bcast = broadcast_fn
self._stop = threading.Event()
self._fix = {}
self._sats = {} # key="{sys}_{prn}" → dict
self._active = set() # PRN strings from GSA
# ── Auto-detect ───────────────────────────────────────────────────────────
@staticmethod
def autodetect() -> str | None:
"""Try to find a GPS serial port.
1. Match by USB vendor ID (u-blox, CH340, FTDI, Prolific) — fast and reliable.
2. If no VID match, return the first available COM port from the OS list
(works for USB-CDC devices that don't expose a VID, e.g. some clone adapters).
"""
ports = serial.tools.list_ports.comports()
# Priority: well-known GPS USB chip vendor IDs
for p in ports:
if (p.vid or 0) in KNOWN_VIDS:
return p.device
# Fallback: first port in the system list (avoid brute-force which can hang on BT ports)
if ports:
return ports[0].device
return None
@staticmethod
def list_ports():
return [{"port": p.device, "desc": p.description,
"vid": p.vid, "pid": p.pid}
for p in serial.tools.list_ports.comports()]
# ── Thread control ────────────────────────────────────────────────────────
def stop(self):
self._stop.set()
def _emit(self, msg: dict):
self._bcast(msg)
# ── Main loop ─────────────────────────────────────────────────────────────
def run(self):
try:
ser = serial.Serial(self.port, self.baud, timeout=0.3)
except Exception as e:
self._emit({"type": "error", "msg": str(e)})
return
self._emit({"type": "connected", "port": self.port, "baud": self.baud})
while not self._stop.is_set():
try:
raw = ser.readline()
if not raw:
continue
line = raw.decode("ascii", errors="replace").strip()
if not line.startswith("$"):
continue
self._emit({"type": "raw", "sentence": line})
self._parse(line)
except serial.SerialException as e:
self._emit({"type": "error", "msg": str(e)})
break
except Exception:
pass
ser.close()
self._emit({"type": "disconnected"})
# ── NMEA dispatch ─────────────────────────────────────────────────────────
def _parse(self, line: str):
body = line[1:line.index("*")] if "*" in line else line[1:]
parts = body.split(",")
if not parts:
return
talker = parts[0][:2]
sentence = parts[0][2:]
dispatch = {
"GGA": self._gga, "RMC": self._rmc, "VTG": self._vtg,
"GSV": self._gsv, "GSA": self._gsa, "GLL": self._gll,
}
fn = dispatch.get(sentence)
if fn:
try:
fn(parts, talker)
except Exception:
pass
# ── Helpers ───────────────────────────────────────────────────────────────
@staticmethod
def _lat(v, h):
if not v: return None
d = int(v[:2]); m = float(v[2:])
return -(d + m/60) if h == "S" else (d + m/60)
@staticmethod
def _lon(v, h):
if not v: return None
d = int(v[:3]); m = float(v[3:])
return -(d + m/60) if h == "W" else (d + m/60)
@staticmethod
def _f(v):
s = v.split("*")[0].strip() if v else ""
return float(s) if s else None
@staticmethod
def _i(v):
s = v.split("*")[0].strip() if v else ""
return int(s) if s else None
def _sat_list(self):
return list(self._sats.values())
# ── Sentence handlers ─────────────────────────────────────────────────────
def _gga(self, p, talker):
if len(p) < 10: return
lat = self._lat(p[2], p[3])
lon = self._lon(p[4], p[5])
fq = self._i(p[6]) or 0
sats = self._i(p[7]) or 0
hdop = self._f(p[8])
alt = self._f(p[9])
self._fix.update({"lat": lat, "lon": lon, "fix_quality": fq,
"satellites": sats, "hdop": hdop, "altitude": alt,
"utc": p[1]})
self._emit({"type": "position", **self._fix, "sats": self._sat_list()})
def _rmc(self, p, talker):
if len(p) < 9: return
if p[2] != "A": return # void
lat = self._lat(p[3], p[4])
lon = self._lon(p[5], p[6])
sog = self._f(p[7])
cog = self._f(p[8])
magvar = None
if len(p) > 11 and p[10]:
mv = self._f(p[10])
if mv is not None:
magvar = -mv if (len(p) > 11 and p[11].startswith("W")) else mv
self._fix.update({"sog": sog, "cog": cog, "magvar": magvar,
"date": p[9]})
self._emit({"type": "rmc", "lat": lat, "lon": lon,
"sog": sog, "cog": cog, "magvar": magvar, "date": p[9]})
def _vtg(self, p, talker):
if len(p) < 8: return
mode = p[9].split("*")[0] if len(p) > 9 else ""
if mode == "N": return
cog_t = self._f(p[1])
cog_m = self._f(p[3])
sog = self._f(p[5])
self._fix.update({"cog": cog_t, "cog_m": cog_m, "sog": sog})
def _gsv(self, p, talker):
sys = SYSTEM_MAP.get(talker, talker)
i = 4
while i + 2 < len(p):
prn = p[i].strip()
el = self._i(p[i+1]) if i+1 < len(p) else None
az = self._i(p[i+2]) if i+2 < len(p) else None
snr_raw = p[i+3] if i+3 < len(p) else ""
snr = self._i(snr_raw)
if prn:
key = f"{sys}_{prn}"
self._sats[key] = {
"key": key, "prn": prn, "system": sys,
"el": el, "az": az, "snr": snr,
"used": prn in self._active,
}
i += 4
self._emit({"type": "satellites", "sats": self._sat_list()})
def _gsa(self, p, talker):
if len(p) < 15: return
self._active = {p[i].strip() for i in range(3, 15) if p[i].strip()}
fix_mode = self._i(p[2]) or 1
pdop = self._f(p[15]) if len(p) > 15 else None
hdop = self._f(p[16]) if len(p) > 16 else None
vdop = self._f(p[17]) if len(p) > 17 else None
self._fix.update({"fix_mode": fix_mode, "pdop": pdop,
"hdop": hdop, "vdop": vdop})
self._emit({"type": "dop", "fix_mode": fix_mode,
"pdop": pdop, "hdop": hdop, "vdop": vdop})
# refresh used flag
for k, s in self._sats.items():
s["used"] = s["prn"] in self._active
def _gll(self, p, talker):
if len(p) < 6: return
status = p[6] if len(p) > 6 else p[5]
if "A" not in status: return
lat = self._lat(p[1], p[2])
lon = self._lon(p[3], p[4])
if lat and lon:
self._fix.update({"lat": lat, "lon": lon})