Files
StarPilot/openpilot/starpilot/gps/phone.py
T

144 lines
6.0 KiB
Python

import json
import math
import threading
import time
FIELDS = {"latitude", "longitude", "altitude", "speed", "bearing", "horizontalAccuracy", "verticalAccuracy",
"speedAccuracy", "bearingAccuracy", "timestamp", "ageMs"}
MAX_AGE_MS = 2000
def _number(payload, key, required=False):
value = payload.get(key)
if value is None and not required:
return math.nan
if type(value) not in (int, float) or not math.isfinite(value):
raise ValueError(f"Invalid {key}")
return float(value)
def _optional(payload, key, low, high):
# Phones report an unknown value as null or a negative number.
value = _number(payload, key)
return value if low <= value < high else math.nan
def phone_gps(payload, now_ns):
if not isinstance(payload, dict) or set(payload) - FIELDS:
raise ValueError("Unexpected location fields")
latitude, longitude = _number(payload, "latitude", True), _number(payload, "longitude", True)
accuracy, age_ms = _number(payload, "horizontalAccuracy", True), _number(payload, "ageMs", True)
if not (-90 <= latitude <= 90 and -180 <= longitude <= 180) or (latitude == 0 and longitude == 0):
raise ValueError("Invalid position")
if not 0 < accuracy <= 10_000:
raise ValueError("Invalid accuracy")
if not 0 <= age_ms <= MAX_AGE_MS:
raise ValueError("Location is too old")
timestamp = payload.get("timestamp", 0)
if type(timestamp) is not int or not 0 <= timestamp < 2 ** 53:
raise ValueError("Invalid timestamp")
bearing_accuracy = _optional(payload, "bearingAccuracy", 0, 180)
return {
"sourceMonoTime": now_ns - int(age_ms * 1e6),
"latitude": latitude,
"longitude": longitude,
"altitude": _optional(payload, "altitude", -1000, 10_000),
"speed": _optional(payload, "speed", 0, 100),
"bearingDeg": _optional(payload, "bearing", 0, 360) if math.isfinite(bearing_accuracy) else math.nan,
"horizontalAccuracy": accuracy,
"verticalAccuracy": _optional(payload, "verticalAccuracy", 0, 10_000),
"bearingAccuracyDeg": bearing_accuracy,
"speedAccuracy": _optional(payload, "speedAccuracy", 0, 100),
"unixTimestampMillis": timestamp,
"hasFix": True,
}
def handoff_path():
import tempfile
from pathlib import Path
shm = Path("/dev/shm")
return (shm if shm.is_dir() else Path(tempfile.gettempdir())) / "starpilot_phone_gps.json"
class PhoneGpsPublisher:
"""Hands the newest validated phone fix to navigationd, which publishes it inside starpilotNavigation."""
def __init__(self, path=None, mono_clock=time.monotonic_ns):
self._path, self._mono_clock = path, mono_clock
self._lock = threading.Lock()
def publish(self, payload):
from openpilot.starpilot.gps.source import PHONE_MAX_AGE_NS, MAX_HORIZONTAL_ACCURACY
path = self._path or handoff_path()
with self._lock:
now = self._mono_clock()
try:
gps = phone_gps(payload, now)
except ValueError as error:
self._status(path, now, False, str(error))
raise
# Keep the last usable measurement through a brief accuracy dropout, without
# changing its timestamp. It will still expire at the original deadline.
if gps["horizontalAccuracy"] > MAX_HORIZONTAL_ACCURACY:
return self._status(path, now, False, "poorAccuracy")
previous = read_phone_gps(now, path)
if previous is not None:
stamp, old = previous
timestamp, old_timestamp = gps["unixTimestampMillis"], old.unixTimestampMillis
if gps["sourceMonoTime"] <= stamp or (timestamp and old_timestamp and timestamp <= old_timestamp):
return self._status(path, now, False, "outOfOrder")
# Within a live stream, differences between phone timestamps reveal added
# queue delay without assuming the phone's wall clock matches comma's.
if timestamp and old_timestamp:
elapsed = (timestamp - old_timestamp) * 1_000_000
if elapsed <= PHONE_MAX_AGE_NS:
gps["sourceMonoTime"] = min(gps["sourceMonoTime"], stamp + elapsed)
if now - gps["sourceMonoTime"] > MAX_AGE_MS * 1_000_000:
return self._status(path, now, False, "delayed")
temporary = path.with_name(path.name + ".tmp")
temporary.write_text(json.dumps(gps))
temporary.replace(path)
self._status(path, now, True, "accepted")
return {"ok": True}
@staticmethod
def _status(path, now, accepted, reason):
status = path.with_name(path.name + ".status")
temporary = status.with_name(status.name + ".tmp")
temporary.write_text(json.dumps({"receivedMonoTime": now, "accepted": accepted, "reason": reason}))
temporary.replace(status)
return {"ok": True, "accepted": accepted, "reason": reason}
def phone_gps_status(now_ns=None, path=None):
"""Coordinate-free diagnostics; a successful upload need not be a usable fix."""
now_ns = time.monotonic_ns() if now_ns is None else now_ns
path = path or handoff_path()
try:
value = json.loads(path.with_name(path.name + ".status").read_text())
received = value["receivedMonoTime"]
if type(received) is not int or not 0 < received <= now_ns:
return None
fix = read_phone_gps(now_ns, path)
return {"reason": value["reason"], "accepted": value["accepted"], "usable": fix is not None,
"receivedAgeMs": (now_ns - received) / 1e6,
"fixAgeMs": (now_ns - fix[0]) / 1e6 if fix is not None else None}
except (OSError, ValueError, KeyError, TypeError, AttributeError, OverflowError):
return None
def read_phone_gps(now_ns, path=None):
"""The handed-off fix as a Gps-like object, or None when absent, malformed or older than the phone hold."""
from types import SimpleNamespace
from openpilot.starpilot.gps.source import PHONE_MAX_AGE_NS, valid_fix
try:
with (path or handoff_path()).open("rb") as source:
gps = json.loads(source.read(1 << 16))
stamp = gps["sourceMonoTime"]
if type(stamp) is not int:
return None
return valid_fix(SimpleNamespace(**gps), stamp, stamp, now_ns, PHONE_MAX_AGE_NS)
except (OSError, ValueError, KeyError, TypeError, AttributeError, OverflowError):
return None