Manages a GPS source (gpsd socket or direct NMEA serial), providing real-time position data.
| 206 | |
| 207 | # Stage 0: gpsd socket. If gpsd is running it already owns the serial port, |
| 208 | # so trying to open that port directly would fail. Defer to gpsd instead. |
| 209 | sock = _try_gpsd() |
| 210 | if sock: |
| 211 | sock.close() |
| 212 | logger.debug("gpsd detected — using gpsd socket") |
| 213 | return 'gpsd' |
| 214 | |
| 215 | # Build the exclude set first so by-id keyword matches don't accidentally |
| 216 | # return an Espressif device that just happens to have "gnss" in its |
| 217 | # product string (rare, but bounded by being explicit). |
| 218 | if os.path.isdir(by_id): |
| 219 | for entry in os.listdir(by_id): |
| 220 | path = _resolve(entry) |
| 221 | if _port_is_espressif(path): |
| 222 | exclude.add(path) |
| 223 | |
| 224 | # Stage 1: by-id keyword match (most reliable — udev says it's a GPS). |
| 225 | if os.path.isdir(by_id): |
| 226 | for entry in os.listdir(by_id): |
| 227 | lower = entry.lower() |
| 228 | if any(kw in lower for kw in ('gps', 'u-blox', 'ublox', 'nmea', 'gnss', 'bn-', 'vk-')): |
| 229 | path = _resolve(entry) |
| 230 | if os.path.exists(path) and path not in exclude: |
| 231 | return path |
| 232 | |
| 233 | # Stage 2: by-id symlinks that aren't ESP32 — try the NMEA probe on each. |
| 234 | # Catches modules whose product string doesn't include a GPS keyword |
| 235 | # (generic CP210x / CH340 / FTDI bridges with no GPS-specific marking). |
| 236 | if os.path.isdir(by_id): |
| 237 | for entry in sorted(os.listdir(by_id)): |
| 238 | path = _resolve(entry) |
| 239 | if path in exclude or not os.path.exists(path): |
| 240 | continue |
| 241 | if _probe_nmea(path): |
| 242 | return path |
| 243 | |
| 244 | # Stage 3: raw device nodes — covers USB serial, native UART, and Raspberry |
| 245 | # Pi serial ports (/dev/ttyAMA0, /dev/serial0) that have no by-id symlink. |
| 246 | candidates = [] |
| 247 | for pattern in ('/dev/ttyACM*', '/dev/ttyUSB*', '/dev/ttyS[0-9]*', '/dev/ttyAMA*'): |
| 248 | candidates.extend(glob.glob(pattern)) |
| 249 | for fixed in ('/dev/serial0', '/dev/serial1'): |
| 250 | if os.path.exists(fixed): |
| 251 | candidates.append(fixed) |
| 252 | seen = set() |
| 253 | for dev in sorted(candidates): |
| 254 | real = os.path.realpath(dev) |
| 255 | if dev in exclude or real in exclude or real in seen: |
| 256 | continue |
| 257 | seen.add(real) |
| 258 | if _probe_nmea(dev): |
| 259 | return dev |
| 260 | |
| 261 | return None |
| 262 | |
| 263 | |
| 264 | class GPSManager: |
| 265 | """Manages a GPS source (gpsd socket or direct NMEA serial), providing real-time position data.""" |