Files
StarPilot/openpilot/starpilot/maps/tests/shadow_executable_host.py
firestar5683 8f132013d2 Navigation UI
Expose map operations and status through Galaxy presentation endpoints.

Co-authored-by: Danny <dannyirwin31@yahoo.com>
Co-authored-by: Prabhaav Pillai <prabhaav@msu.edu>
Co-authored-by: dirwin31 <83434411+dirwin31@users.noreply.github.com>
2026-10-14 20:02:39 -05:00

170 lines
7.5 KiB
Python

"""Required disposable host GPS -> executable shadow -> MapStatus IPC proof."""
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import time
import uuid
from openpilot.cereal import log, messaging
from openpilot.starpilot.galaxy.map_status import MapStatus
def gps(source: str, *, event_valid: bool = True, has_fix: bool = True):
event = messaging.new_message(source, valid=event_valid)
location = getattr(event, source)
location.flags = 1 if has_fix else 0
location.hasFix = has_fix
location.horizontalAccuracy = 1.0
location.bearingDeg = 0.0
location.latitude = 35.15
location.longitude = -97.9
location.speed = 8.0
return event
def until(source: MapStatus, predicate, publish=None, *, seconds=2.0):
deadline = time.monotonic() + seconds
last = None
seen = set()
while time.monotonic() < deadline:
if publish is not None:
publish()
last = source.snapshot()
seen.add((last['state'], last['roadStatus'], last['gpsSource']))
if predicate(last):
return last
time.sleep(0.025)
raise AssertionError(f'expected map state did not arrive: {last}; seen={sorted(map(str, seen))}')
def main():
binary = Path(os.environ['STARPILOT_SHADOW_BINARY'])
root = Path(os.environ['STARPILOT_SHADOW_ROOT'])
assert binary.is_file() and root.is_dir()
prefix = f'mapshadow_exec_{uuid.uuid4().hex}'
with tempfile.TemporaryDirectory(prefix='mapshadow-params-') as params_root:
os.environ['PARAMS_ROOT'] = params_root
os.environ['OPENPILOT_PREFIX'] = prefix
messaging.set_fake_prefix(prefix)
queue_root = Path('/dev/shm' if Path('/dev/shm').is_dir() else '/tmp') / f'msgq_{prefix}'
queue_root.mkdir(mode=0o700)
source = MapStatus()
external = messaging.pub_sock('gpsLocationExternal')
internal = messaging.pub_sock('gpsLocation')
ignored = {name: messaging.pub_sock(name) for name in ('carState', 'modelV2', 'selfdriveState', 'mapdIn')}
processes = []
provider_log = tempfile.TemporaryFile()
def launch():
child = subprocess.Popen([str(binary), '--shadow', '--offline-root', str(root)],
stdin=subprocess.DEVNULL, stdout=provider_log, stderr=provider_log,
env=os.environ.copy())
processes.append(child)
return child
def stop(child):
if child.poll() is not None:
return
child.terminate()
try:
child.wait(timeout=3)
except subprocess.TimeoutExpired:
child.kill()
child.wait(timeout=3)
try:
child = launch()
deadline = time.monotonic() + 2
while not (queue_root / 'mapdOut').exists() and time.monotonic() < deadline:
time.sleep(0.01)
source.snapshot()
raw_output = messaging.sub_sock('mapdOut', conflate=True)
try:
first = until(source, lambda item: item['state'] == 'matched_limit_unqualified' and item['gpsSource'] == 'external',
lambda: external.send(gps('gpsLocationExternal').to_bytes()))
except AssertionError as error:
provider_log.seek(0)
raise AssertionError(f'{error}; child exit={child.poll()}; provider={provider_log.read().decode(errors="replace")}') from error
assert abs(first['candidateSpeedMps'] - 13.4112) < 0.01
old = gps('gpsLocationExternal')
old.logMonoTime = time.monotonic_ns() - 3_000_000_000
external.send(old.to_bytes())
time.sleep(0.12) # two provider ticks; old fix must not replace the recent one
retained = until(source, lambda item: item['state'] == 'matched_limit_unqualified' and
item['gpsSource'] == 'external' and item['sourceSwitches'] == first['sourceSwitches'])
assert abs(retained['candidateSpeedMps'] - 13.4112) < 0.01
invalid = gps('gpsLocationExternal', event_valid=False)
external.send(invalid.to_bytes())
until(source, lambda item: item['roadStatus'] == 'noGps' and item['candidateSpeedMps'] is None, seconds=1.0)
until(source, lambda item: item['state'] == 'matched_limit_unqualified' and item['gpsSource'] == 'external',
lambda: external.send(gps('gpsLocationExternal').to_bytes()))
no_fix = gps('gpsLocationExternal', has_fix=False)
external.send(no_fix.to_bytes())
until(source, lambda item: item['roadStatus'] == 'noGps' and item['candidateSpeedMps'] is None, seconds=1.0)
until(source, lambda item: item['state'] == 'matched_limit_unqualified' and item['gpsSource'] == 'external',
lambda: external.send(gps('gpsLocationExternal').to_bytes()))
for name, publisher in ignored.items():
message = messaging.new_message(name, valid=True)
if name == 'carState':
message.carState.vEgo = 32
elif name == 'modelV2':
message.modelV2.frameId = 12345
elif name == 'selfdriveState':
message.selfdriveState.personality = 'relaxed'
else:
message.mapdIn.type = 'setSpeedLimitControl'
message.mapdIn.bool = True
publisher.send(message.to_bytes())
observed = until(source, lambda item: item['state'] == 'matched_limit_unqualified' and item['gpsSource'] == 'external',
lambda: external.send(gps('gpsLocationExternal').to_bytes()))
assert abs(observed['candidateSpeedMps'] - 13.4112) < 0.01
deadline = time.monotonic() + 1
packet = None
while packet is None and time.monotonic() < deadline:
packet = raw_output.receive(non_blocking=True)
if packet is None:
time.sleep(0.01)
assert packet is not None
with log.Event.from_bytes(packet) as event:
output = event.mapdOut
assert output.suggestedSpeed == 0 and output.nextSpeedLimit == 0
assert not output.speedLimitAccepted and output.visionCurveSpeed == 0 and output.mapCurveSpeed == 0
fallback = until(source, lambda item: item['state'] == 'matched_limit_unqualified' and item['gpsSource'] == 'internal',
lambda: internal.send(gps('gpsLocation').to_bytes()), seconds=2.0)
assert abs(fallback['candidateSpeedMps'] - 13.4112) < 0.01
stale = until(source, lambda item: item['state'] in ('loss', 'stale', 'unknown') and
item['gpsSource'] == 'none' and item['candidateSpeedMps'] is None,
seconds=3.0)
stop(child)
restart_count = stale['producerRestarts']
child = launch()
restarted = until(source, lambda item: item['state'] == 'matched_limit_unqualified' and
item['gpsSource'] == 'external' and item['producerRestarts'] > restart_count,
lambda: external.send(gps('gpsLocationExternal').to_bytes()))
assert abs(restarted['candidateSpeedMps'] - 13.4112) < 0.01
stop(child)
print(json.dumps({'external': True, 'invalidEvent': True, 'noFix': True, 'oldGps': True, 'internalFallback': True,
'stale': True, 'restarted': True, 'ignoredInputs': True}))
finally:
cleanup_errors = []
for child in processes:
try:
stop(child)
except Exception as error:
cleanup_errors.append(error)
for action in (source.close, messaging.delete_fake_prefix, lambda: shutil.rmtree(queue_root), provider_log.close):
try:
action()
except Exception as error:
cleanup_errors.append(error)
if cleanup_errors and sys.exc_info()[0] is None:
raise RuntimeError(f'shadow fixture cleanup failed: {cleanup_errors}')
if __name__ == '__main__':
main()