mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-10 06:23:43 +08:00
321 lines
13 KiB
Python
321 lines
13 KiB
Python
import math
|
|
import tempfile
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from openpilot.cereal import log, messaging
|
|
from openpilot.starpilot.gps.phone import PhoneGpsPublisher, phone_gps, read_phone_gps, phone_gps_status
|
|
from openpilot.starpilot.gps import source
|
|
from openpilot.starpilot.gps.source import GPS_SERVICES, PHONE_MAX_AGE_NS, gps_mode, select_location, select_source
|
|
from openpilot.starpilot.navigation.runtime import publish_status
|
|
from openpilot.starpilot.navigation.status import NavigationStatusSource
|
|
|
|
NOW = 101_000_000_000
|
|
PATH = Path(tempfile.gettempdir()) / "phone_gps.json"
|
|
FIX = {"latitude": 40., "longitude": -110., "horizontalAccuracy": 5., "ageMs": 300, "altitude": 100., "speed": 20., "bearing": 90.,
|
|
"bearingAccuracy": 10., "speedAccuracy": 1., "timestamp": 1_790_000_000_000}
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def isolated_handoff(tmp_path, monkeypatch):
|
|
monkeypatch.setitem(globals(), "PATH", tmp_path / "phone_gps.json")
|
|
|
|
|
|
class Messages:
|
|
def __init__(self):
|
|
self.data = {}
|
|
self.valid = dict.fromkeys(GPS_SERVICES, False)
|
|
self.logMonoTime = dict.fromkeys(GPS_SERVICES, 0)
|
|
self.alive = dict.fromkeys(GPS_SERVICES, True)
|
|
self.seen = dict.fromkeys(GPS_SERVICES, True)
|
|
self.recv_time = dict.fromkeys(GPS_SERVICES, 0.)
|
|
|
|
def __getitem__(self, key):
|
|
return self.data[key]
|
|
|
|
def update(self, _timeout):
|
|
pass
|
|
|
|
def send(self, service, message):
|
|
with log.Event.from_bytes(message.to_bytes()) as event:
|
|
value = getattr(event, service).to_dict()
|
|
if service == "starpilotCarState":
|
|
value["gps"] = SimpleNamespace(**getattr(event, service).gps.to_dict())
|
|
if service == "starpilotNavigation":
|
|
value["phoneGps"] = SimpleNamespace(**getattr(event, service).phoneGps.to_dict())
|
|
self.data[service] = SimpleNamespace(**value)
|
|
self.valid[service] = event.valid
|
|
self.logMonoTime[service] = event.logMonoTime
|
|
self.recv_time[service] = event.logMonoTime / 1e9
|
|
|
|
|
|
class Bus:
|
|
def __init__(self, sm, now):
|
|
self.sm, self.now = sm, now
|
|
|
|
def send(self, service, message):
|
|
message.logMonoTime = self.now
|
|
self.sm.send(service, message)
|
|
|
|
|
|
def publish(sm, payload=FIX, now=NOW, tmp_path=None):
|
|
"""Galaxy hands the fix over; navigationd publishes it inside starpilotNavigation."""
|
|
path = (tmp_path or PATH.parent) / "phone_gps.json"
|
|
result = PhoneGpsPublisher(path=path, mono_clock=lambda: now).publish(dict(payload))
|
|
publish_status(Bus(sm, now), {"status": "idle"}, read_phone_gps(now, path))
|
|
return result
|
|
|
|
|
|
def test_phone_is_used_only_without_another_fix():
|
|
sm = Messages()
|
|
assert publish(sm) == {"ok": True}
|
|
stamp, gps = select_location(sm, NOW)
|
|
assert stamp == NOW - 300_000_000 and gps.longitude == -110. and gps.bearingDeg == 90.
|
|
|
|
car = messaging.new_message("starpilotCarState", valid=True, logMonoTime=NOW)
|
|
car.starpilotCarState.gps = {"sourceMonoTime": NOW - 1, "latitude": 41., "longitude": -111., "horizontalAccuracy": 5., "hasFix": True}
|
|
sm.send("starpilotCarState", car)
|
|
assert select_location(sm, NOW)[1].longitude == -111.
|
|
|
|
native = messaging.new_message("gpsLocation", valid=True, logMonoTime=NOW - 10_000_000)
|
|
native.gpsLocation = {"source": "qcomdiag", "hasFix": True, "horizontalAccuracy": 5., "longitude": -90., "latitude": 30.}
|
|
sm.send("gpsLocation", native)
|
|
assert select_location(sm, NOW)[1].longitude == -90.
|
|
|
|
|
|
def test_phone_fix_is_held_through_sparse_updates_then_expires():
|
|
sm = Messages()
|
|
publish(sm)
|
|
stamp = NOW - 300_000_000
|
|
assert select_location(sm, NOW + 2_300_000_000) is not None
|
|
assert select_location(sm, stamp + PHONE_MAX_AGE_NS) is not None
|
|
assert select_location(sm, stamp + PHONE_MAX_AGE_NS + 1) is None
|
|
|
|
|
|
def test_navigation_leaves_phone_empty_without_a_fresh_fix(tmp_path):
|
|
sm = Messages()
|
|
publish(sm, tmp_path=tmp_path)
|
|
later = NOW + PHONE_MAX_AGE_NS + 1_000_000_000
|
|
assert read_phone_gps(later, tmp_path / "phone_gps.json") is None
|
|
publish_status(Bus(sm, later), {"status": "idle"}, None)
|
|
assert select_location(sm, later) is None
|
|
|
|
|
|
def test_phone_handoff_ignores_missing_and_damaged_files(tmp_path):
|
|
assert read_phone_gps(NOW, tmp_path / "missing.json") is None
|
|
(tmp_path / "bad.json").write_text("{")
|
|
assert read_phone_gps(NOW, tmp_path / "bad.json") is None
|
|
|
|
|
|
def test_unknown_vertical_accuracy_is_not_used():
|
|
gps = phone_gps({**FIX, "verticalAccuracy": -1}, NOW)
|
|
assert math.isnan(gps["verticalAccuracy"])
|
|
|
|
|
|
@pytest.mark.parametrize("field", ("altitude", "speed", "bearing", "bearingAccuracy", "speedAccuracy"))
|
|
def test_partial_fix_preserves_position_with_unknown_measurement(field):
|
|
payload = dict(FIX)
|
|
payload.pop(field)
|
|
gps = phone_gps(payload, NOW)
|
|
assert gps['hasFix'] and gps['latitude'] == FIX['latitude']
|
|
key = {'bearing': 'bearingDeg', 'bearingAccuracy': 'bearingAccuracyDeg'}.get(field, field)
|
|
assert math.isnan(gps[key])
|
|
if field == 'bearingAccuracy':
|
|
assert math.isnan(gps['bearingDeg'])
|
|
|
|
|
|
@pytest.mark.parametrize("change", [{"ageMs": 2500}, {"ageMs": -1}, {"latitude": 91.}, {"latitude": 0., "longitude": 0.},
|
|
{"horizontalAccuracy": 0}, {"latitude": True}, {"extra": 1}, {"timestamp": 1.5}])
|
|
def test_invalid_fixes_are_rejected(change):
|
|
with pytest.raises(ValueError):
|
|
phone_gps({**FIX, **change}, NOW)
|
|
|
|
|
|
def test_inaccurate_phone_fix_is_published_but_not_selected():
|
|
sm = Messages()
|
|
publish(sm, {**FIX, "horizontalAccuracy": 60.})
|
|
assert sm.valid["starpilotNavigation"] and select_location(sm, NOW) is None
|
|
|
|
|
|
def test_navigation_status_ranks_phone_below_native():
|
|
sm = Messages()
|
|
with patch('openpilot.cereal.messaging.SubMaster', return_value=sm):
|
|
status = NavigationStatusSource(mono_clock=lambda: NOW - 1_000_000_000, boot_clock=lambda: NOW)
|
|
status.mono_clock = lambda: NOW
|
|
publish(sm)
|
|
assert (status.map_position() or {})['longitude'] == -110.
|
|
native = messaging.new_message("gpsLocation", valid=True, logMonoTime=NOW - 500_000_000)
|
|
native.gpsLocation = {"source": "qcomdiag", "hasFix": True, "horizontalAccuracy": 5., "longitude": -90., "latitude": 30.}
|
|
sm.send("gpsLocation", native)
|
|
assert (status.map_position() or {})['longitude'] == -90.
|
|
|
|
|
|
def native(sm):
|
|
message = messaging.new_message("gpsLocation", valid=True, logMonoTime=NOW - 10_000_000)
|
|
message.gpsLocation = {"source": "qcomdiag", "hasFix": True, "horizontalAccuracy": 5., "longitude": -90., "latitude": 30.}
|
|
sm.send("gpsLocation", message)
|
|
|
|
|
|
def test_gps_modes_choose_phone_first_or_never():
|
|
sm = Messages()
|
|
publish(sm)
|
|
assert select_location(sm, NOW, "comma") is None
|
|
native(sm)
|
|
assert select_location(sm, NOW, "backup")[1].longitude == -90.
|
|
assert select_location(sm, NOW, "comma")[1].longitude == -90.
|
|
assert select_location(sm, NOW, "phone")[1].longitude == -110.
|
|
assert select_location(sm, NOW + 2_300_000_000, "phone")[1].longitude == -110.
|
|
|
|
|
|
def test_gps_mode_follows_navigation_settings(tmp_path, monkeypatch):
|
|
monkeypatch.setattr("openpilot.starpilot.storage.starpilot_storage_root", lambda: tmp_path)
|
|
monkeypatch.setattr(source, "_mode", [-math.inf, "backup"])
|
|
assert gps_mode(10.) == "backup"
|
|
(tmp_path / "navigation").mkdir()
|
|
(tmp_path / "navigation" / "settings.json").write_text('{"gpsSource": "phone"}')
|
|
assert gps_mode(10.5) == "backup"
|
|
assert gps_mode(11.) == "phone"
|
|
(tmp_path / "navigation" / "settings.json").write_text('{"gpsSource": "tunnel"}')
|
|
assert gps_mode(12.) == "backup"
|
|
|
|
|
|
@pytest.mark.parametrize("mode,longitude,name", [("backup", -90., "comma"), ("phone", -110., "phone"), ("comma", -90., "comma")])
|
|
def test_navigation_status_reports_the_source_it_follows(mode, longitude, name):
|
|
sm = Messages()
|
|
with patch('openpilot.cereal.messaging.SubMaster', return_value=sm):
|
|
status = NavigationStatusSource(mono_clock=lambda: NOW - 1_000_000_000, boot_clock=lambda: NOW)
|
|
status.mono_clock = lambda: NOW
|
|
publish(sm)
|
|
native(sm)
|
|
with patch('openpilot.starpilot.navigation.status.gps_mode', return_value=mode):
|
|
position = status.map_position()
|
|
assert position is not None and position['longitude'] == longitude and position['source'] == name
|
|
assert 0 < position['validForMs'] <= 2500
|
|
|
|
|
|
def test_phone_map_lease_is_capped_without_shortening_source_timeout():
|
|
sm = Messages()
|
|
with patch('openpilot.cereal.messaging.SubMaster', return_value=sm):
|
|
status = NavigationStatusSource(mono_clock=lambda: NOW - 1_000_000_000, boot_clock=lambda: NOW)
|
|
clock = [NOW]
|
|
status.mono_clock = lambda: clock[0]
|
|
status.boot_clock = lambda: clock[0] + 1_000_000_000
|
|
publish(sm)
|
|
with patch('openpilot.starpilot.navigation.status.gps_mode', return_value='backup'):
|
|
position = status.map_position()
|
|
assert position is not None and position['validForMs'] == 2500
|
|
clock[0] += 8_000_000_000
|
|
position = status.map_position()
|
|
assert position is not None and position['validForMs'] == 1700
|
|
clock[0] += 2_000_000_000
|
|
assert status.map_position() is None
|
|
|
|
|
|
def test_car_gps_ranks_against_the_phone_in_every_mode():
|
|
sm = Messages()
|
|
publish(sm)
|
|
car = messaging.new_message("starpilotCarState", valid=True, logMonoTime=NOW)
|
|
car.starpilotCarState.gps = {"sourceMonoTime": NOW - 1, "latitude": 41., "longitude": -111., "horizontalAccuracy": 5., "hasFix": True}
|
|
sm.send("starpilotCarState", car)
|
|
assert select_location(sm, NOW, "backup")[1].longitude == -111.
|
|
assert select_location(sm, NOW, "comma")[1].longitude == -111.
|
|
assert select_location(sm, NOW, "phone")[1].longitude == -110.
|
|
with patch('openpilot.cereal.messaging.SubMaster', return_value=sm):
|
|
status = NavigationStatusSource(mono_clock=lambda: NOW - 1_000_000_000, boot_clock=lambda: NOW)
|
|
status.mono_clock = lambda: NOW
|
|
for mode, name in (("backup", "car"), ("comma", "car"), ("phone", "phone")):
|
|
with patch('openpilot.starpilot.navigation.status.gps_mode', return_value=mode):
|
|
assert (status.map_position() or {})['source'] == name
|
|
|
|
|
|
def test_selected_source_is_named():
|
|
sm = Messages()
|
|
assert select_source(sm, NOW) is None
|
|
publish(sm)
|
|
assert select_source(sm, NOW)[0] == "phone"
|
|
car = messaging.new_message("starpilotCarState", valid=True, logMonoTime=NOW)
|
|
car.starpilotCarState.gps = {"sourceMonoTime": NOW - 1, "latitude": 41., "longitude": -111., "horizontalAccuracy": 5., "hasFix": True}
|
|
sm.send("starpilotCarState", car)
|
|
assert select_source(sm, NOW)[0] == "car"
|
|
native(sm)
|
|
assert select_source(sm, NOW)[0] == "comma"
|
|
assert select_source(sm, NOW, "phone")[0] == "phone"
|
|
|
|
|
|
def test_zero_bearing_uncertainty_is_known():
|
|
from openpilot.starpilot.gps.source import bearing
|
|
gps = SimpleNamespace(**phone_gps({**FIX, "bearingAccuracy": 0}, NOW))
|
|
assert bearing(gps) == 90.
|
|
|
|
|
|
def test_unknown_speed_keeps_position_without_heading_prediction(tmp_path):
|
|
from openpilot.starpilot.navigation.runtime import location
|
|
sm = Messages()
|
|
publish(sm, {key: value for key, value in FIX.items() if key not in ('speed', 'bearing', 'bearingAccuracy')}, tmp_path=tmp_path)
|
|
position = location(sm, NOW)
|
|
assert position[1:] == ((-110., 40.), None, None)
|
|
|
|
|
|
def test_accuracy_drop_preserves_good_fix_until_original_expiry(tmp_path):
|
|
path = tmp_path / 'gps.json'
|
|
now = [NOW]
|
|
publisher = PhoneGpsPublisher(path, lambda: now[0])
|
|
publisher.publish(FIX)
|
|
original = read_phone_gps(now[0], path)[0]
|
|
now[0] += 1_000_000_000
|
|
assert publisher.publish({**FIX, 'horizontalAccuracy': 26.})['reason'] == 'poorAccuracy'
|
|
assert read_phone_gps(now[0], path)[0] == original
|
|
assert phone_gps_status(now[0], path)['usable']
|
|
now[0] = original + PHONE_MAX_AGE_NS + 1
|
|
publisher.publish({**FIX, 'horizontalAccuracy': 26.})
|
|
assert read_phone_gps(now[0], path) is None
|
|
assert not phone_gps_status(now[0], path)['usable']
|
|
publisher.publish({**FIX, 'timestamp': FIX['timestamp'] + 11000})
|
|
assert read_phone_gps(now[0], path) is not None
|
|
|
|
|
|
def test_duplicate_and_reordered_fixes_do_not_renew_age(tmp_path):
|
|
path = tmp_path / 'gps.json'
|
|
now = [NOW]
|
|
publisher = PhoneGpsPublisher(path, lambda: now[0])
|
|
publisher.publish(FIX)
|
|
stamp = read_phone_gps(now[0], path)[0]
|
|
now[0] += 1_000_000_000
|
|
for timestamp in (FIX['timestamp'], FIX['timestamp'] - 1000):
|
|
assert publisher.publish({**FIX, 'timestamp': timestamp})['reason'] == 'outOfOrder'
|
|
assert read_phone_gps(now[0], path)[0] == stamp
|
|
|
|
|
|
def test_timestamp_deltas_detect_added_queue_delay(tmp_path):
|
|
path = tmp_path / 'gps.json'
|
|
now = [NOW]
|
|
publisher = PhoneGpsPublisher(path, lambda: now[0])
|
|
publisher.publish(FIX)
|
|
now[0] += 5_000_000_000
|
|
assert publisher.publish({**FIX, 'timestamp': FIX['timestamp'] + 1000})['reason'] == 'delayed'
|
|
# A current measurement recovers; rejected requests did not move the anchor.
|
|
assert publisher.publish({**FIX, 'timestamp': FIX['timestamp'] + 5000}) == {'ok': True}
|
|
assert read_phone_gps(now[0], path)[0] == now[0] - 300_000_000
|
|
|
|
|
|
def test_clock_change_can_recover_after_original_fix_expires(tmp_path):
|
|
path = tmp_path / 'gps.json'
|
|
now = [NOW]
|
|
publisher = PhoneGpsPublisher(path, lambda: now[0])
|
|
publisher.publish(FIX)
|
|
now[0] += PHONE_MAX_AGE_NS + 1
|
|
assert publisher.publish({**FIX, 'timestamp': FIX['timestamp'] - 60000}) == {'ok': True}
|
|
|
|
|
|
def test_invalid_upload_reports_reason_without_erasing_last_fix(tmp_path):
|
|
path = tmp_path / 'gps.json'
|
|
publisher = PhoneGpsPublisher(path, lambda: NOW)
|
|
publisher.publish(FIX)
|
|
with pytest.raises(ValueError):
|
|
publisher.publish({**FIX, 'ageMs': 5000})
|
|
status = phone_gps_status(NOW, path)
|
|
assert status['reason'] == 'Location is too old' and status['usable']
|