Inject a position from an external source (e.g. a USB-connected Piglet/HuginnESP companion that has its own GPS receiver). Lets Ragnar operate as if it had GPS even when no local receiver is attached, so BT/cell scans, the gps_track table, and the status UI all see a
(self, lat, lon, alt=None, speed_kmh=None,
satellites=None, hdop=None, source='companion')
| 403 | Idempotent: if already reading that port, do nothing. Prefers gpsd when |
| 404 | it is running (it owns the serial device). This is what makes GPS |
| 405 | self-healing — a puck that appears minutes after boot, or bounces |
| 406 | (disconnect→reconnect), is attached automatically with no restart. |
| 407 | """ |
| 408 | if not port: |
| 409 | return False |
| 410 | target = port |
| 411 | sock = _try_gpsd() |
| 412 | if sock: |
| 413 | try: |
| 414 | sock.close() |
| 415 | except Exception: |
| 416 | pass |
| 417 | target = 'gpsd' |
| 418 | if self._running and self.port == target: |
| 419 | return True |
| 420 | if self._running: |
| 421 | self.stop() |
| 422 | self.port = target |
| 423 | return self.start() |
| 424 | |
| 425 | def detach(self): |
| 426 | """Release the current GPS device — called when it is unplugged so the |
| 427 | monitor can re-attach a fresh one later.""" |
| 428 | was = self.port |
| 429 | self.stop() |
| 430 | self.port = None |
| 431 | self.error = "No GPS device detected" |
| 432 | if was: |
| 433 | logger.info(f"GPS detached from {was}") |
| 434 | |
| 435 | def has_fix(self): |
| 436 | """Return True if GPS has a valid position fix (not stale).""" |
| 437 | if self.fix_quality <= 0 or self.latitude is None or self.longitude is None: |
| 438 | return False |
| 439 | # Consider fix stale after 10 seconds without update |
| 440 | if self.last_update and (time.time() - self.last_update) > 10: |
| 441 | return False |
| 442 | # Stamp TTFF here rather than in each of the GGA / RMC / gpsd paths: |
| 443 | # this method is the one definition of "we actually have a fix", so |
| 444 | # every producer would otherwise need the same guard duplicated. |
| 445 | if not self.first_fix_time: |
| 446 | self.first_fix_time = time.time() |
| 447 | return True |
| 448 | |
| 449 | @property |
| 450 | def ttff_seconds(self): |
| 451 | """Seconds from reader start to first fix, or None if still searching.""" |
| 452 | if not self.start_time or not self.first_fix_time: |
| 453 | return None |
| 454 | return round(self.first_fix_time - self.start_time, 1) |
| 455 | |
| 456 | def get_position(self): |
| 457 | """Return current position dict (or None if no fix).""" |
| 458 | if not self.has_fix(): |
| 459 | return None |
| 460 | with self._lock: |
| 461 | return { |
| 462 | 'lat': self.latitude, |