mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-08 05:23:41 +08:00
a82935f326
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>
336 lines
13 KiB
Python
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())
|