diff --git a/scripts/launch_onroad_desktop.sh b/scripts/launch_onroad_desktop.sh index d72086e9b..f9ee4bb77 100755 --- a/scripts/launch_onroad_desktop.sh +++ b/scripts/launch_onroad_desktop.sh @@ -27,9 +27,10 @@ env_var_truthy() { usage() { cat <<'EOF' Usage: - ./onroad [jobs] (--c3 | --c4 | --raybig | --all | --replay-only) [-nav] [-alert] [--cem] [--prefix name] + ./onroad [jobs] [--c3 | --c4 | --raybig | --all | --replay-only] [--galaxy] [-nav] [-alert] [--cem] [--prefix name] Examples: + ./onroad ./onroad --c3 ./onroad --c4 --start 30 ./onroad --c4 -nav @@ -42,7 +43,9 @@ Examples: Notes: - This is host/dev only. It uses the isolated host worktree and does not touch the device path. - A private comma connect route still requires tools/lib/auth.py before replay can download it. + - If no UI flag is provided, the route's logged device type selects the UI: mici/c4 -> c4, tici/tizi -> raybig unless UseOldUI was enabled. - Use multiple UI flags together if you want more than one desktop UI at once. + - --galaxy starts a local Galaxy web session with the same preview params and prints the localhost URL. It blocks replay's logged customReserved9 stream so Galaxy can own the live Testing Grounds publisher. - -nav injects a fake navigation demo stream and blocks replay from publishing navInstruction/navRoute. - --cem publishes fake CEM statuses for desktop visual review in the raylib UIs. - -alert blocks replay from publishing selfdriveState and fires a fake critical full-screen red alert (alertSize=full, alertStatus=critical) 20 seconds after the demo publisher starts (10s for replay route + UI to come up, plus 10s for the user to open Settings). Default alert text mimics a real controlsMismatch event; run tools/replay/fake_alert_demo.py directly to override --text1/--text2/--delay. @@ -58,15 +61,21 @@ fi PREFIX_ARG="" REPLAY_ARGS=() UI_TARGETS=() +UI_SELECTION_EXPLICIT=0 LEGACY_UI_SELECTION="" REPLAY_ONLY=0 NAV_DEMO=0 CEM_DEMO=0 ALERT_DEMO=0 +GALAXY=0 REPLAY_PID="" NAV_PID="" CEM_PID="" ALERT_PID="" +GALAXY_PID="" +GALAXY_PORT="" +GALAXY_URL="" +ONROAD_TEMP_PREFIX="" UI_PIDS=() parse_args() { @@ -78,18 +87,22 @@ parse_args() { ;; --c3) UI_TARGETS+=(c3) + UI_SELECTION_EXPLICIT=1 shift ;; --c4) UI_TARGETS+=(c4) + UI_SELECTION_EXPLICIT=1 shift ;; --raybig) UI_TARGETS+=(raybig) + UI_SELECTION_EXPLICIT=1 shift ;; --all) UI_TARGETS+=(c3 c4 raybig) + UI_SELECTION_EXPLICIT=1 shift ;; --replay-only) @@ -104,6 +117,10 @@ parse_args() { CEM_DEMO=1 shift ;; + --galaxy) + GALAXY=1 + shift + ;; -alert|--alert|--alert-demo) ALERT_DEMO=1 shift @@ -114,10 +131,12 @@ parse_args() { exit 1 fi LEGACY_UI_SELECTION="$2" + UI_SELECTION_EXPLICIT=1 shift 2 ;; --ui=*) LEGACY_UI_SELECTION="${1#*=}" + UI_SELECTION_EXPLICIT=1 shift ;; -p|--prefix) @@ -228,6 +247,9 @@ cleanup() { if [[ -n "${ALERT_PID}" ]]; then kill "${ALERT_PID}" >/dev/null 2>&1 || true fi + if [[ -n "${GALAXY_PID}" ]]; then + kill "${GALAXY_PID}" >/dev/null 2>&1 || true + fi for pid in "${UI_PIDS[@]-}"; do if [[ -n "${pid}" ]]; then @@ -246,12 +268,15 @@ cleanup() { if [[ -n "${ALERT_PID}" ]]; then wait "${ALERT_PID}" >/dev/null 2>&1 || true fi + if [[ -n "${GALAXY_PID}" ]]; then + wait "${GALAXY_PID}" >/dev/null 2>&1 || true + fi - if [[ -n "${OPENPILOT_PREFIX:-}" && "${OPENPILOT_PREFIX}" == desktop-onroad-* ]]; then - echo "Cleaning up temporary prefix environment (${OPENPILOT_PREFIX})..." - rm -rf "/dev/shm/msgq_${OPENPILOT_PREFIX}" - rm -rf "/tmp/comma_download_cache${OPENPILOT_PREFIX}" - rm -rf "${HOME}/.comma${OPENPILOT_PREFIX}" + if [[ -n "${ONROAD_TEMP_PREFIX:-}" && "${ONROAD_TEMP_PREFIX}" == desktop-onroad-* ]]; then + echo "Cleaning up temporary prefix environment (${ONROAD_TEMP_PREFIX})..." + rm -rf "/dev/shm/msgq_${ONROAD_TEMP_PREFIX}" + rm -rf "/tmp/comma_download_cache${ONROAD_TEMP_PREFIX}" + rm -rf "${HOME}/.comma${ONROAD_TEMP_PREFIX}" fi exit "${exit_code}" @@ -295,8 +320,8 @@ append_blocked_service_names() { printf '%s' "${joined}" } -ensure_nav_demo_replay_blocklist() { - local nav_services="navInstruction,navRoute" +ensure_replay_blocklist() { + local services="$1" local idx=0 for ((idx=0; idx<${#REPLAY_ARGS[@]}; idx++)); do @@ -306,41 +331,29 @@ ensure_nav_demo_replay_blocklist() { echo "Missing value for ${REPLAY_ARGS[$idx]}" >&2 exit 1 fi - REPLAY_ARGS[$((idx + 1))]="$(append_blocked_service_names "${REPLAY_ARGS[$((idx + 1))]}" "${nav_services}")" + REPLAY_ARGS[$((idx + 1))]="$(append_blocked_service_names "${REPLAY_ARGS[$((idx + 1))]}" "${services}")" return ;; --block=*) - REPLAY_ARGS[$idx]="--block=$(append_blocked_service_names "${REPLAY_ARGS[$idx]#*=}" "${nav_services}")" + REPLAY_ARGS[$idx]="--block=$(append_blocked_service_names "${REPLAY_ARGS[$idx]#*=}" "${services}")" return ;; esac done - REPLAY_ARGS=(-b "${nav_services}" "${REPLAY_ARGS[@]}") + REPLAY_ARGS=(-b "${services}" "${REPLAY_ARGS[@]}") +} + +ensure_nav_demo_replay_blocklist() { + ensure_replay_blocklist "navInstruction,navRoute" } ensure_alert_demo_replay_blocklist() { - local alert_services="selfdriveState" - local idx=0 + ensure_replay_blocklist "selfdriveState" +} - for ((idx=0; idx<${#REPLAY_ARGS[@]}; idx++)); do - case "${REPLAY_ARGS[$idx]}" in - -b|--block) - if (( idx + 1 >= ${#REPLAY_ARGS[@]} )); then - echo "Missing value for ${REPLAY_ARGS[$idx]}" >&2 - exit 1 - fi - REPLAY_ARGS[$((idx + 1))]="$(append_blocked_service_names "${REPLAY_ARGS[$((idx + 1))]}" "${alert_services}")" - return - ;; - --block=*) - REPLAY_ARGS[$idx]="--block=$(append_blocked_service_names "${REPLAY_ARGS[$idx]#*=}" "${alert_services}")" - return - ;; - esac - done - - REPLAY_ARGS=(-b "${alert_services}" "${REPLAY_ARGS[@]}") +ensure_galaxy_replay_blocklist() { + ensure_replay_blocklist "customReserved9" } prepare_env() { @@ -371,32 +384,29 @@ prepare_env() { export SP_CEM_DEMO="${CEM_DEMO}" export SP_ONROAD_ALERT_DEMO="${ALERT_DEMO}" + local generated_prefix="${PREFIX_ARG:-${OPENPILOT_PREFIX:-desktop-onroad-$$}}" + ONROAD_TEMP_PREFIX="${generated_prefix}" + if [[ "$(uname -s)" == "Darwin" ]] || env_var_truthy "${ZMQ:-0}"; then - export OPENPILOT_ZMQ_NAMESPACE="${PREFIX_ARG:-${OPENPILOT_ZMQ_NAMESPACE:-desktop-onroad-$$}}" + export OPENPILOT_ZMQ_NAMESPACE="${PREFIX_ARG:-${OPENPILOT_ZMQ_NAMESPACE:-${generated_prefix}}}" unset OPENPILOT_PREFIX + export PARAMS_ROOT="${PARAMS_ROOT:-${HOME}/.comma${generated_prefix}/params}" else - export OPENPILOT_PREFIX="${PREFIX_ARG:-${OPENPILOT_PREFIX:-desktop-onroad-$$}}" + export OPENPILOT_PREFIX="${generated_prefix}" mkdir -p "/dev/shm/msgq_${OPENPILOT_PREFIX}" fi } seed_params() { - "${ROOT_DIR}/.venv/bin/python3" - <<'PY' -import os + "${ROOT_DIR}/.venv/bin/python3" "${ROOT_DIR}/tools/replay/onroad_config.py" seed "${REPLAY_ARGS[@]}" +} -from openpilot.common.params import Params -from openpilot.system.version import terms_version, training_version - -params = Params() -params.put("HasAcceptedTerms", terms_version) -params.put("CompletedTrainingVersion", training_version) -params.put_bool("OpenpilotEnabledToggle", True) -params.put_bool("IsDriverViewEnabled", False) -params.put_bool("ForceOnroad", False) -params.put_bool("ForceOffroad", False) -if os.getenv("SP_ONROAD_NAV_DEMO", "").lower() in {"1", "true", "yes", "on"}: - params.put_bool("NavigationUI", True) -PY +auto_select_ui_targets() { + local selection="" + selection="$("${ROOT_DIR}/.venv/bin/python3" "${ROOT_DIR}/tools/replay/onroad_config.py" select-ui "${REPLAY_ARGS[@]}")" + IFS=' ' read -r -a UI_TARGETS <<< "${selection}" + dedupe_ui_targets + echo "Auto-selected UI: ${UI_TARGETS[*]}" } seed_starpilot_theme() { @@ -474,6 +484,73 @@ launch_alert_demo() { fi } +pick_free_local_port() { + "${ROOT_DIR}/.venv/bin/python3" - <<'PY' +import socket + +# The desktop ZMQ transport hashes replay service names into ports 8023-65535. +# Keep Galaxy below that range so the HTTP server cannot steal a replay service port. +for port in range(4600, 8023): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + try: + sock.bind(("127.0.0.1", port)) + except OSError: + continue + print(port) + raise SystemExit(0) + +raise SystemExit("Unable to find a free local Galaxy port below the ZMQ replay range.") +PY +} + +wait_for_galaxy() { + local url="${GALAXY_URL}/api/galaxy/status" + local idx=0 + + for ((idx=0; idx<50; idx++)); do + if ! kill -0 "${GALAXY_PID}" >/dev/null 2>&1; then + wait "${GALAXY_PID}" + return 1 + fi + + if "${ROOT_DIR}/.venv/bin/python3" - "${url}" <<'PY' >/dev/null 2>&1; then +import sys +import urllib.request + +with urllib.request.urlopen(sys.argv[1], timeout=0.5) as response: + raise SystemExit(0 if response.status == 200 else 1) +PY + return 0 + fi + + sleep 0.1 + done + + echo "Timed out waiting for local Galaxy session at ${GALAXY_URL}" >&2 + return 1 +} + +launch_galaxy() { + GALAXY_PORT="$(pick_free_local_port)" + GALAXY_URL="http://127.0.0.1:${GALAXY_PORT}" + local galaxy_dir="${HOME}/.comma${ONROAD_TEMP_PREFIX}/starpilot/data/galaxy" + + echo "Starting local Galaxy session..." + ( + export SP_GALAXY_DIR="${galaxy_dir}" + export SP_GALAXY_HOST="127.0.0.1" + export SP_GALAXY_PORT="${GALAXY_PORT}" + export SP_GALAXY_DEBUG=0 + export SP_GALAXY_RELOAD=0 + exec "${ROOT_DIR}/.venv/bin/python3" -m openpilot.starpilot.system.the_galaxy.the_galaxy + ) & + GALAXY_PID=$! + + wait_for_galaxy + + echo "Access Galaxy with ${GALAXY_URL}" +} + launch_c3_ui() { local os_ext="linux" if [[ "$(uname -s)" == "Darwin" ]]; then @@ -528,13 +605,12 @@ if [[ "${ALERT_DEMO}" == "1" ]]; then ensure_alert_demo_replay_blocklist fi -if [[ ${#REPLAY_ARGS[@]} -eq 0 ]]; then - usage >&2 - exit 1 +if [[ "${GALAXY}" == "1" ]]; then + ensure_galaxy_replay_blocklist fi -if [[ "${REPLAY_ONLY}" != "1" && ${#UI_TARGETS[@]} -eq 0 ]]; then - echo "Select at least one UI with --c3, --c4, --raybig, or use --replay-only." >&2 +if [[ ${#REPLAY_ARGS[@]} -eq 0 ]]; then + usage >&2 exit 1 fi @@ -545,6 +621,19 @@ echo "Using OPENPILOT_PREFIX=${OPENPILOT_PREFIX:-}" if [[ -n "${OPENPILOT_ZMQ_NAMESPACE:-}" ]]; then echo "Using OPENPILOT_ZMQ_NAMESPACE=${OPENPILOT_ZMQ_NAMESPACE}" fi +if [[ -n "${PARAMS_ROOT:-}" ]]; then + echo "Using PARAMS_ROOT=${PARAMS_ROOT}" +fi + +if [[ "${REPLAY_ONLY}" != "1" && "${UI_SELECTION_EXPLICIT}" == "0" && ${#UI_TARGETS[@]} -eq 0 ]]; then + auto_select_ui_targets +fi + +if [[ "${REPLAY_ONLY}" != "1" && ${#UI_TARGETS[@]} -eq 0 ]]; then + echo "Select at least one UI with --c3, --c4, --raybig, or use --replay-only." >&2 + exit 1 +fi + echo "Preparing replay and desktop UI runtime..." build_replay @@ -564,6 +653,10 @@ esac seed_params seed_starpilot_theme +if [[ "${GALAXY}" == "1" ]]; then + launch_galaxy +fi + echo "Starting replay: ${REPLAY_ARGS[*]}" launch_replay diff --git a/selfdrive/modeld/dmonitoringmodeld.py b/selfdrive/modeld/dmonitoringmodeld.py index 413c3b6f4..1b63009b5 100644 --- a/selfdrive/modeld/dmonitoringmodeld.py +++ b/selfdrive/modeld/dmonitoringmodeld.py @@ -142,7 +142,12 @@ def main(): assert vipc_client.is_connected() cloudlog.warning(f"connected with buffer size: {vipc_client.buffer_len}") - model = ModelState(vipc_client.width, vipc_client.height) + # connect() finishes before stream is populated. The first buffer always has known dimensions. + first_buf = vipc_client.recv() + while first_buf is None: + first_buf = vipc_client.recv() + + model = ModelState(first_buf.width, first_buf.height) cloudlog.warning("models loaded, dmonitoringmodeld starting") sm = SubMaster(["liveCalibration"]) diff --git a/starpilot/common/safe_mode.py b/starpilot/common/safe_mode.py index 9cbbb6df7..0ab8b8912 100644 --- a/starpilot/common/safe_mode.py +++ b/starpilot/common/safe_mode.py @@ -3,7 +3,7 @@ from __future__ import annotations from cereal import log -from openpilot.common.params import Params +from openpilot.common.params import Params, UnknownKeyName SAFE_MODE_PARAM = "SafeMode" SAFE_MODE_BACKUP_PARAM = "SafeModeBackup" @@ -249,19 +249,22 @@ def _safe_value(params: Params, key: str): def _apply_value(params_raw: Params, key: str, value) -> bool: - current = params_raw.get(key) - if value is None: - if current is None: + try: + current = params_raw.get(key) + if value is None: + if current is None: + return False + params_raw.remove(key) + return True + + if current == value: return False - params_raw.remove(key) + + params_raw.put(key, value) return True - - if current == value: + except UnknownKeyName: return False - params_raw.put(key, value) - return True - def _mark_toggle_update(params_memory: Params | None) -> None: if params_memory is None: diff --git a/starpilot/common/tests/test_safe_mode.py b/starpilot/common/tests/test_safe_mode.py new file mode 100644 index 000000000..7e5543d2a --- /dev/null +++ b/starpilot/common/tests/test_safe_mode.py @@ -0,0 +1,11 @@ +from openpilot.common.params import UnknownKeyName +from openpilot.starpilot.common.safe_mode import _apply_value + + +class RemovedParamStore: + def get(self, key): + raise UnknownKeyName(key) + + +def test_apply_value_ignores_removed_param(): + assert not _apply_value(RemovedParamStore(), "RemovedParam", "stale value") diff --git a/starpilot/navigation/mapd_wrapper.py b/starpilot/navigation/mapd_wrapper.py index a6365b150..2a8ab6f51 100644 --- a/starpilot/navigation/mapd_wrapper.py +++ b/starpilot/navigation/mapd_wrapper.py @@ -107,7 +107,10 @@ def terminate_child(proc: subprocess.Popen[str]) -> None: proc.wait(timeout=2) except subprocess.TimeoutExpired: proc.kill() - proc.wait(timeout=2) + try: + proc.wait(timeout=2) + except subprocess.TimeoutExpired: + cloudlog.error(f"mapd_wrapper child did not exit after kill: pid={proc.pid}") def run_mapd_once() -> int: diff --git a/starpilot/navigation/test_mapd_wrapper.py b/starpilot/navigation/test_mapd_wrapper.py index 7f2133c51..e0a148d8e 100644 --- a/starpilot/navigation/test_mapd_wrapper.py +++ b/starpilot/navigation/test_mapd_wrapper.py @@ -1,9 +1,10 @@ #!/usr/bin/env python3 import json +import subprocess from pathlib import Path -from openpilot.starpilot.navigation.mapd_wrapper import CorruptTileMonitor, quarantine_offline_tile +from openpilot.starpilot.navigation.mapd_wrapper import CorruptTileMonitor, quarantine_offline_tile, terminate_child def _loading_line(filename: str) -> str: @@ -57,3 +58,30 @@ def test_quarantine_offline_tile_renames_backing_archive(tmp_path, monkeypatch): assert not archive.exists() assert Path(quarantined).exists() assert Path(quarantined).name.startswith(f"{archive.name}.corrupt.") + + +def test_terminate_child_tolerates_wedged_process(): + class WedgedProcess: + pid = 123 + + def __init__(self): + self.terminated = False + self.killed = False + + def poll(self): + return None + + def terminate(self): + self.terminated = True + + def kill(self): + self.killed = True + + def wait(self, timeout): + raise subprocess.TimeoutExpired("mapd", timeout) + + proc = WedgedProcess() + terminate_child(proc) + + assert proc.terminated + assert proc.killed diff --git a/starpilot/system/the_galaxy/the_galaxy.py b/starpilot/system/the_galaxy/the_galaxy.py index cb75d9af6..6f4f62bbb 100644 --- a/starpilot/system/the_galaxy/the_galaxy.py +++ b/starpilot/system/the_galaxy/the_galaxy.py @@ -536,6 +536,8 @@ MAPS_CANCEL_DOWNLOAD_PARAM = "CancelDownloadMaps" def _get_galaxy_dir(): + if override := os.getenv("SP_GALAXY_DIR"): + return Path(override) return Path(Paths.comma_home()) / "starpilot" / "data" / "galaxy" if PC else Path("/data/galaxy") @@ -7237,14 +7239,17 @@ def main(): threading.Thread(target=_testing_ground_custom_reserved_worker, daemon=True).start() # Desktop-only debug mode. On-device must stay on 8082 to match Galaxy FRP routing. - debug = not _is_comma_device_runtime() - port = 8083 if debug else 8082 + on_device = _is_comma_device_runtime() + debug = False if on_device else os.getenv("SP_GALAXY_DEBUG", "1").lower() in {"1", "true", "yes", "on"} + port = 8082 if on_device else int(os.getenv("SP_GALAXY_PORT", "8083")) + host = "0.0.0.0" if on_device else os.getenv("SP_GALAXY_HOST", "0.0.0.0") + use_reloader = False if on_device else os.getenv("SP_GALAXY_RELOAD", "0" if not debug else "1").lower() in {"1", "true", "yes", "on"} if debug: print("\"The Galaxy\" is not running on a comma device, enabling debug mode") app.secret_key = secrets.token_hex(32) - app.run(host="0.0.0.0", port=port, debug=debug) + app.run(host=host, port=port, debug=debug, use_reloader=use_reloader) if __name__ == "__main__": main() diff --git a/system/logmessaged.py b/system/logmessaged.py index c095c2619..51bc4e680 100755 --- a/system/logmessaged.py +++ b/system/logmessaged.py @@ -8,6 +8,10 @@ from openpilot.system.hardware.hw import Paths from openpilot.common.swaglog import get_file_handler +def decode_record(data: bytes) -> str: + return data.decode("utf-8", errors="replace") + + def main() -> NoReturn: log_handler = get_file_handler() log_handler.setFormatter(SwagLogFileFormatter(None)) @@ -25,7 +29,7 @@ def main() -> NoReturn: while True: dat = b''.join(sock.recv_multipart()) level = dat[0] - record = dat[1:].decode("utf-8") + record = decode_record(dat[1:]) if level >= log_level: log_handler.emit(record) diff --git a/system/micd.py b/system/micd.py index 69f2d5560..580ba39d6 100755 --- a/system/micd.py +++ b/system/micd.py @@ -125,7 +125,13 @@ class Mic: def micd_thread(self): # sounddevice must be imported after forking processes - import sounddevice as sd + while True: + try: + import sounddevice as sd + break + except Exception: + cloudlog.exception("micd: sounddevice/PortAudio unavailable, retrying") + time.sleep(5) while True: stream = None diff --git a/system/tests/test_logmessaged_decode.py b/system/tests/test_logmessaged_decode.py new file mode 100644 index 000000000..edc7c2e7e --- /dev/null +++ b/system/tests/test_logmessaged_decode.py @@ -0,0 +1,5 @@ +from openpilot.system.logmessaged import decode_record + + +def test_decode_record_replaces_malformed_utf8(): + assert decode_record(b'before\xffafter') == "before\ufffdafter" diff --git a/tools/replay/onroad_config.py b/tools/replay/onroad_config.py new file mode 100644 index 000000000..cc250198c --- /dev/null +++ b/tools/replay/onroad_config.py @@ -0,0 +1,296 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import os +import re +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Sequence + +from openpilot.common.params import Params, UnknownKeyName +from openpilot.system.version import terms_version, training_version +from openpilot.tools.lib.logreader import LogReader, ReadMode, parse_direct, parse_indirect +from openpilot.tools.lib.route import SegmentRange + + +DEMO_ROUTE = "a2a0ccea32023010|2023-07-27--13-01-19" + +_VALUE_OPTIONS = { + "-a", "--allow", + "-b", "--block", + "-c", "--cache", + "-s", "--start", + "-x", "--playback", + "-d", "--data_dir", + "-p", "--prefix", +} +_NO_VALUE_OPTIONS = { + "--dcam", + "--ecam", + "--no-loop", + "--no-cache", + "--qcam", + "--no-hw-decoder", + "--no-vipc", + "--all", + "--headless", +} +_SEGMENT_SLICE_RE = re.compile(r"(?P-?[0-9]+)?:?(?P-?[0-9]+)?:?(?P-?[0-9]+)?$") + + +@dataclass(frozen=True) +class ReplayArgs: + route: str | None + data_dir: str | None + auto_source: bool = False + + +def _truthy_env(name: str) -> bool: + return os.getenv(name, "").lower() in {"1", "true", "yes", "on"} + + +def parse_replay_args(args: Sequence[str]) -> ReplayArgs: + route: str | None = None + data_dir: str | None = None + auto_source = False + + idx = 0 + while idx < len(args): + arg = args[idx] + if arg == "--": + idx += 1 + continue + if arg == "--demo": + route = DEMO_ROUTE + idx += 1 + continue + if arg == "--auto": + auto_source = True + idx += 1 + continue + if arg.startswith("--data_dir="): + data_dir = arg.split("=", 1)[1] + idx += 1 + continue + if arg in ("-d", "--data_dir"): + if idx + 1 < len(args): + data_dir = args[idx + 1] + idx += 2 + continue + if any(arg.startswith(f"{option}=") for option in _VALUE_OPTIONS if option.startswith("--")): + idx += 1 + continue + if arg in _VALUE_OPTIONS: + idx += 2 + continue + if arg in _NO_VALUE_OPTIONS: + idx += 1 + continue + if arg.startswith("-"): + # Unknown replay option. Treat it as a flag so we do not mistake its value + # for a route; replay itself will validate it later. + idx += 1 + continue + if route is None: + route = arg + idx += 1 + + return ReplayArgs(route=route, data_dir=data_dir, auto_source=auto_source) + + +def _first_selected_segment(sr: SegmentRange) -> int: + if not sr.slice: + return 0 + + if ":" not in sr.slice: + segment = int(sr.slice) + return sr.seg_idxs[0] if segment < 0 else segment + + match = _SEGMENT_SLICE_RE.fullmatch(sr.slice) + if match is None: + return sr.seg_idxs[0] + + start_raw = match.group("start") + start = int(start_raw) if start_raw else 0 + return sr.seg_idxs[0] if start < 0 else start + + +def first_segment_identifier(route: str) -> str: + parsed = parse_indirect(route) + direct = parse_direct(parsed) + if direct is not None: + return direct + + sr = SegmentRange(parsed) + selector = sr.selector or "a" + segment = _first_selected_segment(sr) + return f"{sr.dongle_id}/{sr.log_id}/{segment}/{selector}" + + +def _local_route_identifiers(route: str, data_dir: str) -> list[str]: + direct = parse_direct(route) + if direct is not None: + return [direct] + + try: + parsed = parse_indirect(route) + sr = SegmentRange(parsed) + segment = _first_selected_segment(sr) + except Exception: + return [] + + data_root = Path(data_dir) + route_name = sr.route_name.replace("/", "|") + route_name_slash = route_name.replace("|", "/") + segment_names = (f"{route_name}--{segment}", f"{route_name_slash}/{segment}") + filenames = ("rlog.zst", "rlog.bz2", "qlog.zst", "qlog.bz2") + + identifiers: list[str] = [] + for segment_name in segment_names: + for filename in filenames: + candidate = data_root / segment_name / filename + if candidate.exists(): + identifiers.append(str(candidate)) + + for filename in filenames: + explorer_candidate = data_root / f"{route_name}--{segment}--{filename}" + if explorer_candidate.exists(): + identifiers.append(str(explorer_candidate)) + + return identifiers + + +def replay_log_identifiers(replay_args: ReplayArgs) -> list[str]: + if replay_args.route is None or replay_args.auto_source: + return [] + + if replay_args.data_dir: + identifiers = _local_route_identifiers(replay_args.route, replay_args.data_dir) + if identifiers: + return identifiers + + try: + return [first_segment_identifier(replay_args.route)] + except Exception as exc: + print(f"Unable to resolve route metadata for onroad preview: {exc}", file=sys.stderr) + return [] + + +def load_init_data(replay_args: ReplayArgs) -> Any | None: + for identifier in replay_log_identifiers(replay_args): + try: + init_data = LogReader(identifier, default_mode=ReadMode.AUTO).first("initData") + except Exception as exc: + print(f"Unable to read route initData from {identifier}: {exc}", file=sys.stderr) + continue + + if init_data is not None: + return init_data + + return None + + +def logged_params(init_data: Any | None) -> dict[str, bytes]: + if init_data is None: + return {} + + try: + entries = init_data.params.entries + except Exception: + return {} + + params: dict[str, bytes] = {} + for entry in entries: + params[str(entry.key)] = bytes(entry.value) + return params + + +def select_ui_target(init_data: Any | None) -> str: + if init_data is None: + return "raybig" + + device_type = str(getattr(init_data, "deviceType", "")).lower() + if device_type in {"mici", "c4"}: + return "c4" + + if device_type in {"tici", "tizi"} and logged_params(init_data).get("UseOldUI") == b"1": + return "c3" + + return "raybig" + + +def seed_logged_params(init_data: Any | None, params: Params) -> int: + seeded = 0 + for key, raw_value in logged_params(init_data).items(): + if raw_value == b"": + continue + + try: + value = params.cpp2python(key, raw_value) + if value is None: + continue + params.put(key, value) + seeded += 1 + except (UnknownKeyName, TypeError, ValueError): + continue + + return seeded + + +def seed_desktop_overrides(params: Params) -> None: + params.put("HasAcceptedTerms", terms_version) + params.put("CompletedTrainingVersion", training_version) + params.put_bool("OpenpilotEnabledToggle", True) + params.put_bool("IsDriverViewEnabled", False) + params.put_bool("ForceOnroad", False) + params.put_bool("ForceOffroad", False) + if _truthy_env("SP_ONROAD_NAV_DEMO"): + params.put_bool("NavigationUI", True) + + +def seed_onroad_params(init_data: Any | None, params: Params | None = None) -> int: + params = params or Params() + seeded = seed_logged_params(init_data, params) + seed_desktop_overrides(params) + return seeded + + +def _cmd_select_ui(args: Sequence[str]) -> int: + init_data = load_init_data(parse_replay_args(args)) + print(select_ui_target(init_data)) + return 0 + + +def _cmd_seed(args: Sequence[str]) -> int: + init_data = load_init_data(parse_replay_args(args)) + seeded = seed_onroad_params(init_data) + if init_data is not None: + print(f"Seeded {seeded} logged route params for onroad preview.", file=sys.stderr) + else: + print("No route initData found; using desktop onroad defaults.", file=sys.stderr) + return 0 + + +def main(argv: Sequence[str] | None = None) -> int: + argv = list(sys.argv[1:] if argv is None else argv) + if not argv or argv[0] in {"-h", "--help"}: + print("Usage: onroad_config.py (select-ui|seed) ", file=sys.stderr) + return 2 + + command = argv[0] + args = argv[1:] + if args[:1] == ["--"]: + args = args[1:] + + if command == "select-ui": + return _cmd_select_ui(args) + if command == "seed": + return _cmd_seed(args) + + print(f"Unknown command: {command}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/replay/tests/test_onroad_config.py b/tools/replay/tests/test_onroad_config.py new file mode 100644 index 000000000..60f0b15e7 --- /dev/null +++ b/tools/replay/tests/test_onroad_config.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from types import SimpleNamespace + +from openpilot.tools.replay import onroad_config + + +TEST_ROUTE = "344c5c15b34f2d8a/2024-01-03--09-37-12" + + +class FakeParams: + def __init__(self): + self.values = {} + + def cpp2python(self, key, value): + if key == "ShowSLCOffset": + return value == b"1" + if key == "UnknownOldParam": + raise onroad_config.UnknownKeyName(key) + return value.decode("utf-8") + + def put(self, key, value): + self.values[key] = value + + def put_bool(self, key, value): + self.values[key] = bool(value) + + +def _init_data(device_type="tici", params=None): + params = params or {} + entries = [SimpleNamespace(key=key, value=value) for key, value in params.items()] + return SimpleNamespace(deviceType=device_type, params=SimpleNamespace(entries=entries)) + + +def test_parse_replay_args_handles_demo_data_dir_and_flags(): + replay_args = onroad_config.parse_replay_args([ + "--start", "30", + "--data_dir=/tmp/routes", + "--demo", + "--no-loop", + ]) + + assert replay_args.route == onroad_config.DEMO_ROUTE + assert replay_args.data_dir == "/tmp/routes" + assert not replay_args.auto_source + + +def test_first_segment_identifier_limits_full_route_to_segment_zero(): + assert onroad_config.first_segment_identifier(TEST_ROUTE) == f"{TEST_ROUTE}/0/a" + assert onroad_config.first_segment_identifier(f"{TEST_ROUTE}/5:6/r") == f"{TEST_ROUTE}/5/r" + + +def test_replay_log_identifiers_prefers_local_data_dir(tmp_path): + segment_dir = tmp_path / f"{TEST_ROUTE.replace('/', '|')}--5" + segment_dir.mkdir() + qlog = segment_dir / "qlog.zst" + qlog.touch() + + identifiers = onroad_config.replay_log_identifiers( + onroad_config.ReplayArgs(route=f"{TEST_ROUTE}/5", data_dir=str(tmp_path)) + ) + + assert identifiers == [str(qlog)] + + +def test_select_ui_uses_c4_for_mici_routes(): + assert onroad_config.select_ui_target(_init_data("mici")) == "c4" + + +def test_select_ui_uses_old_qt_only_when_big_route_logged_use_old_ui(): + assert onroad_config.select_ui_target(_init_data("tici", {"UseOldUI": b"1"})) == "c3" + assert onroad_config.select_ui_target(_init_data("tici", {"TryRaylibUI": b"0"})) == "raybig" + assert onroad_config.select_ui_target(_init_data("tizi")) == "raybig" + + +def test_seed_onroad_params_uses_logged_disabled_bool_and_desktop_overrides(monkeypatch): + monkeypatch.setenv("SP_ONROAD_NAV_DEMO", "1") + params = FakeParams() + init_data = _init_data("tici", { + "ShowSLCOffset": b"0", + "LanguageSetting": b"main_en", + "AccessToken": b"", + "UnknownOldParam": b"1", + }) + + seeded = onroad_config.seed_onroad_params(init_data, params) + + assert seeded == 2 + assert params.values["ShowSLCOffset"] is False + assert params.values["LanguageSetting"] == "main_en" + assert "AccessToken" not in params.values + assert params.values["OpenpilotEnabledToggle"] is True + assert params.values["NavigationUI"] is True