Files
StarPilot/openpilot/tools/replay/onroad.py
T
firestar5683 a82935f326 User interfaces and Android Auto
Integrate onroad views, configurable widgets, local settings, steering-profile contracts, Bluetooth and Android Auto with their device and runtime services.

Co-authored-by: Adeeb Shihadeh <adeebshihadeh@gmail.com>
Co-authored-by: Daniel Koepping <elkoled@gmail.com>
Co-authored-by: Danny <dannyirwin31@yahoo.com>
Co-authored-by: DimitriChrysafis <Dimitri.chrysafis@gmail.com>
Co-authored-by: Dom <168790843+firestar5683@users.noreply.github.com>
Co-authored-by: Harald Schäfer <harald.the.engineer@gmail.com>
Co-authored-by: Prabhaav Pillai <prabhaav@msu.edu>
Co-authored-by: Shane Smiskol <shane@smiskol.com>
Co-authored-by: dirwin31 <83434411+dirwin31@users.noreply.github.com>
Co-authored-by: firestarsdog <229254897+firestarsdog@users.noreply.github.com>
Co-authored-by: stef <19478336+stefpi@users.noreply.github.com>
Co-authored-by: whoisdomi <dcantu83@gmail.com>

Co-authored-by: Intelli <contact@intelli.software>
Co-authored-by: pharmacomaniac <blittle65@gmail.com>

Co-authored-by: prabhaavp <prabhaav@msu.edu>
2026-10-14 20:02:39 -05:00

336 lines
13 KiB
Python

"""Run replay and the selected native desktop UI(s) in one private host session."""
from __future__ import annotations
from dataclasses import dataclass
from contextlib import contextmanager
import os
from pathlib import Path
import re
import secrets
import signal
import shutil
import subprocess
import sys
import tempfile
import time
USAGE = """Usage: ./onroad [jobs] [--c3|--c4|--all|--replay-only] [--params /saved/params] [--alert] [--cem] [--csc] [--prefix replay-NAME] <replay args>
Replay arguments include a route or --demo, --start, --cache, --playback, --data_dir and --no-loop.
The native replay terminal provides playback controls. Route playback may read/download the route you request.
--alert previews a synthetic visual critical alert; it does not publish car control.
--cem and --csc preview synthetic onroad visuals in the selected native UI only.
Old --nav, --offroad and --galaxy demos are not available in this port.
--params supplements recorded settings with a read-only snapshot, including unlogged layout preferences.
"""
UNAVAILABLE = frozenset(("--nav", "-nav", "--offroad", "--galaxy"))
PREFIX_RE = re.compile(r"replay-[A-Za-z0-9_-]{1,48}\Z")
@dataclass(frozen=True)
class OnroadPlan:
targets: tuple[str, ...]
explicit: bool
replay_only: bool
prefix: str | None
replay_args: tuple[str, ...]
alert: bool = False
visual_preview: frozenset[str] = frozenset()
params_snapshot: str | None = None
def _block_alert_service(args: tuple[str, ...]) -> list[str]:
"""Merge the one owned publisher exclusion with a user's existing replay block list."""
result: list[str] = []
blocked: list[str] = []
index = 0
while index < len(args):
arg = args[index]
if arg in ("-b", "--block"):
blocked.extend(args[index + 1].split(","))
index += 2
continue
if arg.startswith("--block="):
blocked.extend(arg.partition("=")[2].split(","))
else:
result.append(arg)
index += 1
services = [service for service in (*blocked, "selfdriveState") if service]
return ["-b", ",".join(dict.fromkeys(services)), *result]
def parse_onroad_args(args: list[str]) -> OnroadPlan:
from openpilot.tools.replay.onroad_config import parse_replay_args
targets: list[str] = []
explicit = False
replay_only = False
alert = False
visual_preview: set[str] = set()
prefix = None
params_snapshot = None
replay_args: list[str] = []
index = 0
while index < len(args):
arg = args[index]
if arg == "--":
replay_args.extend(args[index + 1:])
break
if arg in UNAVAILABLE:
raise ValueError(f"{arg} has no current producer; this developer preview does not start a substitute")
if arg in ("-alert", "--alert", "--alert-demo"):
alert = True
index += 1
continue
if arg in ("--cem", "--mici-widget-demo", "--widget-demo"):
visual_preview.add("cem")
index += 1
continue
if arg in ("--csc", "--csc-demo"):
visual_preview.add("csc")
index += 1
continue
if arg in ("--c3", "--c4", "--all", "--replay-only"):
if arg == "--all":
targets.extend(("c3", "c4"))
explicit = True
elif arg == "--replay-only":
replay_only = True
else:
targets.append(arg.removeprefix("--"))
explicit = True
elif arg == "--params" or arg.startswith("--params="):
if arg == "--params":
if index + 1 >= len(args):
raise ValueError("missing --params directory")
params_snapshot = args[index + 1]
index += 1
else:
params_snapshot = arg.partition("=")[2]
if not params_snapshot or not Path(params_snapshot).is_absolute():
raise ValueError("--params must name an absolute directory")
elif arg in ("--prefix", "-p"):
if index + 1 >= len(args):
raise ValueError("missing --prefix value")
prefix = args[index + 1]
index += 1
elif arg.startswith("--prefix="):
prefix = arg.partition("=")[2]
elif arg.startswith("--ui=") or arg == "--ui":
if arg == "--ui":
if index + 1 >= len(args):
raise ValueError("missing --ui value")
selection = args[index + 1]
index += 1
else:
selection = arg.partition("=")[2]
raw = selection.replace(" ", "").lower()
if raw == "all":
targets.extend(("c3", "c4"))
elif raw != "none":
choices = raw.split(",")
if not choices or any(value not in ("c3", "c4") for value in choices):
raise ValueError("--ui accepts c3, c4, all, or none")
targets.extend(choices)
explicit = True
else:
replay_args.append(arg)
index += 1
if prefix is not None and PREFIX_RE.fullmatch(prefix) is None:
raise ValueError("--prefix must be replay-NAME with letters, digits, _ or -")
if any(arg in ("-p", "--prefix") or arg.startswith("--prefix=") for arg in replay_args):
raise ValueError("Replay prefix must be set before --, so the UI and replay share one isolated namespace")
if not replay_args:
raise ValueError("a route or --demo is required")
parsed = parse_replay_args(replay_args)
if parsed.route is None:
raise ValueError("a route or --demo is required")
if replay_only:
targets = []
if (alert or visual_preview) and (replay_only or (explicit and not targets)):
raise ValueError("Visual previews require a native UI")
return OnroadPlan(tuple(name for name in ("c3", "c4") if name in targets), explicit, replay_only,
prefix, tuple(replay_args), alert, frozenset(visual_preview), params_snapshot)
def _private_prefix(requested: str | None) -> str:
return requested if requested is not None else "replay-" + secrets.token_hex(8)
def _session_marker(private_root: Path, prefix: str) -> Path:
return private_root / f"replay-session-{prefix}"
def _ipc_root(prefix: str) -> Path:
return Path("/tmp" if sys.platform == "darwin" else "/dev/shm") / f"msgq_{prefix}"
@contextmanager
def owned_ipc_namespace(prefix: str):
"""Create only this session's native msgq parent, then remove it after children exit."""
root = _ipc_root(prefix)
try:
root.mkdir(mode=0o700)
except FileExistsError as error:
raise RuntimeError(f"Replay prefix already has an IPC namespace: {prefix}") from error
try:
yield root
finally:
shutil.rmtree(root)
@contextmanager
def _parent_prefix(prefix: str):
previous = os.environ.get("OPENPILOT_PREFIX")
os.environ["OPENPILOT_PREFIX"] = prefix
try:
yield
finally:
if previous is None:
os.environ.pop("OPENPILOT_PREFIX", None)
else:
os.environ["OPENPILOT_PREFIX"] = previous
def seed_replay_params(init_data, params_root: str, prefix: str, snapshot: str | None = None) -> int:
from openpilot.common.params import Params
from openpilot.tools.replay.onroad_config import seed_preview
with _parent_prefix(prefix):
return seed_preview(init_data, Params(params_root), snapshot)
def _stop(children: list[subprocess.Popen]) -> None:
for child in reversed(children):
if child.poll() is None:
child.terminate()
deadline = time.monotonic() + 3.0
for child in reversed(children):
if child.poll() is None:
try:
child.wait(timeout=max(0.01, deadline - time.monotonic()))
except subprocess.TimeoutExpired:
child.kill()
child.wait(timeout=1.0)
def _exit_status(code: int) -> int:
return code if code >= 0 else 128 - code
def supervise(replay_command: list[str], ui_commands: list[list[str]], env: dict[str, str], *,
demo_commands: list[list[str]] | None = None,
spawn=subprocess.Popen) -> int:
children: list[subprocess.Popen] = []
shutdown_signal: int | None = None
def interrupted(signum: int, _frame) -> None:
nonlocal shutdown_signal
shutdown_signal = signum
old_handlers = {sig: signal.signal(sig, interrupted) for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP)}
try:
children.append(spawn(replay_command, env=env))
if shutdown_signal is not None:
return 128 + shutdown_signal
first_status = children[0].poll()
if first_status is not None:
return _exit_status(first_status)
for command in [*ui_commands, *(demo_commands or ())]:
children.append(spawn(command, env=env))
if shutdown_signal is not None:
return 128 + shutdown_signal
while True:
if shutdown_signal is not None:
return 128 + shutdown_signal
for index, child in enumerate(children):
status = child.poll()
if status is not None:
if index >= 1 + len(ui_commands) and status == 0:
return 1 # A clean exit from a demo publisher still leaves the preview unsupported.
return _exit_status(status)
time.sleep(0.1)
finally:
_stop(children)
for sig, handler in old_handlers.items():
signal.signal(sig, handler)
def run(plan: OnroadPlan) -> int:
from openpilot.tools.replay.onroad_config import parse_replay_args, route_init_data, select_ui_target, replay_device_type
from openpilot.starpilot.ui.host_launch import launch_environment
from openpilot.starpilot.ui.developer_preview import encode_flags
from openpilot.starpilot.ui.presentation import Profile
source = dict(os.environ)
if source.get("SP_HOST_RUNTIME") != "1":
raise RuntimeError("Start replay through ./onroad in the isolated host runtime")
raw_host_params = source.get("SP_HOST_PARAMS_ROOT", "")
if not raw_host_params or not Path(raw_host_params).is_absolute():
raise RuntimeError("Host replay requires an isolated absolute SP_HOST_PARAMS_ROOT")
host_params = Path(raw_host_params).resolve()
if host_params != Path(source.get("PARAMS_ROOT", "")).resolve():
raise RuntimeError("Host replay Params root differs from the isolated runner root")
prefix = _private_prefix(plan.prefix)
marker = _session_marker(host_params.parent, prefix)
if marker.exists():
raise RuntimeError(f"Replay prefix already has a host session: {prefix}")
if _ipc_root(prefix).exists() or _ipc_root(prefix).is_symlink():
raise RuntimeError(f"Replay prefix already has an IPC namespace: {prefix}")
repo = Path(__file__).resolve().parents[3]
replay_binary = repo / "openpilot/tools/replay/replay"
if not replay_binary.is_file() or not os.access(replay_binary, os.X_OK):
raise RuntimeError("Host replay binary is missing; rebuild the isolated host runtime")
no_ui = plan.replay_only or (plan.explicit and not plan.targets)
replay_args = parse_replay_args(plan.replay_args)
init_data = None if no_ui else route_init_data(replay_args)
if not no_ui and not plan.explicit and replay_args.data_dir and init_data is None:
raise RuntimeError("Local replay has no readable initData; choose --c3 or --c4 explicitly")
targets = plan.targets if plan.explicit or plan.replay_only else (select_ui_target(init_data),)
preview = encode_flags(plan.visual_preview)
if targets:
for profile in (Profile.LARGE if name == "c3" else Profile.COMPACT for name in targets):
launch_environment(profile, {**source, "OPENPILOT_PREFIX": prefix,
"PARAMS_ROOT": str(host_params),
"SP_ONROAD_VISUAL_PREVIEW": preview}) # font/asset contract before children
host_params.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="replay-params-", dir=host_params.parent) as params_root:
env = {**source, "OPENPILOT_PREFIX": prefix, "PARAMS_ROOT": params_root,
"SP_ONROAD_VISUAL_PREVIEW": preview, "SP_REPLAY_DEVICE_TYPE": replay_device_type(init_data)}
env["COMMA_CACHE"] = str(host_params.parent / "download-cache")
restored = seed_replay_params(init_data, params_root, prefix, plan.params_snapshot)
marker.mkdir(mode=0o700)
try:
ui_commands = [[sys.executable, "-m", "openpilot.starpilot.ui.host_launch",
"large" if target == "c3" else "compact"] for target in targets]
print(f"Replay prefix {prefix}; native UI: {', '.join(targets) if targets else 'none'}", flush=True)
if targets:
print(f"Recorded device: {env['SP_REPLAY_DEVICE_TYPE']}; restored {restored} saved settings", flush=True)
if plan.params_snapshot is None:
print("Unlogged settings use defaults; --params /saved/params supplies an exact saved layout and preferences.", flush=True)
with owned_ipc_namespace(prefix) as ipc_root:
from openpilot.tools.replay.display_clock import RECORD
clock_path = ipc_root / "display-clock"
fd = os.open(clock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
try:
os.ftruncate(fd, RECORD.size)
finally:
os.close(fd)
env["SP_REPLAY_CLOCK_PATH"] = str(clock_path)
replay_command = [str(replay_binary), *(_block_alert_service(plan.replay_args) if plan.alert else plan.replay_args)]
demos = [[sys.executable, "-m", "openpilot.tools.replay.alert_demo"]] if plan.alert else []
return supervise(replay_command, ui_commands, env, demo_commands=demos)
finally:
marker.rmdir()
def main(argv: list[str] | None = None) -> int:
args = list(sys.argv[1:] if argv is None else argv)
if any(arg in ("--help", "-h") for arg in args):
print(USAGE)
return 0
try:
return run(parse_onroad_args(args))
except (KeyError, OSError, RuntimeError, ValueError) as error:
print(f"onroad: {error}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())