#!/usr/bin/env bash set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "${ROOT_DIR}" PLATFORM_KEY="$(uname -s | tr '[:upper:]' '[:lower:]')" HOST_PLATFORM_ROOT="${ROOT_DIR}/.host_runtime/${PLATFORM_KEY}" HOST_ROOT="" WORK_DIR="" HOST_VENV="" HOST_LOCK_DIR="" HOST_LOCK_PID_FILE="" HOST_LOCK_CMD_FILE="" HOST_LOCK_HELD=0 HOST_BUCKETS=(shared cabana) usage() { cat <<'EOF' Usage: ./dev [args...] ./tool [args...] ./tools/host [args...] Commands: c3 Launch the desktop Qt UI from the isolated host cache. c4 Launch the small raylib UI from the isolated host cache. raybig Launch the large raylib UI from the isolated host cache. onroad Launch replay plus desktop UI(s) from the isolated host cache. replay Build and run replay from the isolated host cache. cabana Build and run cabana from the isolated host cache. plotjuggler Run PlotJuggler helper from the isolated host cache. juggle Alias for plotjuggler. sync Refresh the isolated host cache only. shell Open a shell inside the isolated host cache. help Show this help text. Notes: - Host-tool builds happen under ./.host_runtime/ and do not touch the main tree. - `cabana` uses its own host-runtime bucket, so it can run together with `plotjuggler`. - Other commands that share a bucket still wait on that bucket's lock. - `./build` remains the device-target flow. - For c3/c4/raybig, pass the jobs count first to preserve existing shorthand: ./dev c3 8 ./dev raybig 12 - `./onroad --c3 f08912a233c1584f/2022-08-11--18-02-41/1` launches replay plus the selected desktop UI. - `./dev sync` refreshes all host buckets. Use `./dev sync cabana` to sync one. EOF } default_jobs() { if command -v nproc >/dev/null 2>&1; then nproc elif command -v sysctl >/dev/null 2>&1; then sysctl -n hw.ncpu else echo 8 fi } resolve_host_bucket() { local name="${1:-shared}" case "${name}" in shared|default|ui|c3|c4|raybig|onroad|replay|shell) echo "shared" ;; cabana) echo "cabana" ;; plotjuggler|juggle) echo "shared" ;; *) return 1 ;; esac } set_host_bucket() { local bucket="$1" if [[ "${bucket}" == "shared" ]]; then HOST_ROOT="${HOST_PLATFORM_ROOT}" else HOST_ROOT="${HOST_PLATFORM_ROOT}/${bucket}" fi WORK_DIR="${HOST_ROOT}/worktree" HOST_VENV="${HOST_ROOT}/venv" HOST_LOCK_DIR="${HOST_ROOT}/lock" HOST_LOCK_PID_FILE="${HOST_LOCK_DIR}/pid" HOST_LOCK_CMD_FILE="${HOST_LOCK_DIR}/command" } lock_owner_summary() { local lock_pid="" local lock_cmd="unknown" if [[ -f "${HOST_LOCK_PID_FILE}" ]]; then lock_pid="$(<"${HOST_LOCK_PID_FILE}")" fi if [[ -f "${HOST_LOCK_CMD_FILE}" ]]; then lock_cmd="$(<"${HOST_LOCK_CMD_FILE}")" fi if [[ -n "${lock_pid}" ]]; then echo "pid ${lock_pid} (${lock_cmd})" else echo "${lock_cmd}" fi } lock_is_stale() { local lock_pid="" if [[ ! -f "${HOST_LOCK_PID_FILE}" ]]; then return 1 fi lock_pid="$(<"${HOST_LOCK_PID_FILE}")" if [[ ! "${lock_pid}" =~ ^[0-9]+$ ]]; then return 0 fi if kill -0 "${lock_pid}" 2>/dev/null; then return 1 fi return 0 } release_host_lock() { if [[ "${HOST_LOCK_HELD}" != "1" ]]; then return fi rm -f "${HOST_LOCK_PID_FILE}" "${HOST_LOCK_CMD_FILE}" rmdir "${HOST_LOCK_DIR}" 2>/dev/null || rm -rf "${HOST_LOCK_DIR}" HOST_LOCK_HELD=0 } acquire_host_lock() { local lock_label="$1" local notified=0 mkdir -p "${HOST_ROOT}" while true; do if mkdir "${HOST_LOCK_DIR}" 2>/dev/null; then printf '%s\n' "$$" > "${HOST_LOCK_PID_FILE}" printf '%s\n' "${lock_label}" > "${HOST_LOCK_CMD_FILE}" HOST_LOCK_HELD=1 trap release_host_lock EXIT return fi if lock_is_stale; then echo "Removing stale host runtime lock: $(lock_owner_summary)" >&2 rm -rf "${HOST_LOCK_DIR}" continue fi if [[ "${notified}" == "0" ]]; then echo "Waiting for host runtime lock in ${HOST_ROOT}. Another shorthand command is using it: $(lock_owner_summary)." >&2 notified=1 fi sleep 1 done } ensure_venv() { if [[ ! -x "${ROOT_DIR}/.venv/bin/python3" ]]; then echo "Missing .venv. Run tools/install_python_dependencies.sh first." exit 1 fi if ! command -v uv >/dev/null 2>&1; then echo "Missing uv. Run tools/install_python_dependencies.sh first." exit 1 fi } sync_selected_bucket() { local bucket="$1" set_host_bucket "${bucket}" acquire_host_lock "sync ${bucket}" sync_worktree echo "Host tool cache synced (${bucket}): ${WORK_DIR}" release_host_lock } sync_all_buckets() { local bucket="" for bucket in "${HOST_BUCKETS[@]}"; do sync_selected_bucket "${bucket}" done } purge_host_python_artifacts() { rm -f \ "${WORK_DIR}/common/params_pyx.so" \ "${WORK_DIR}/common/params_pyx.o" \ "${WORK_DIR}/common/params_pyx.cpp" \ "${WORK_DIR}/common/transformations/transformations.so" \ "${WORK_DIR}/common/transformations/transformations.o" \ "${WORK_DIR}/common/transformations/transformations.cpp" \ "${WORK_DIR}/msgq_repo/msgq/ipc_pyx.so" \ "${WORK_DIR}/msgq_repo/msgq/ipc_pyx.o" \ "${WORK_DIR}/msgq_repo/msgq/ipc_pyx.cpp" \ "${WORK_DIR}/msgq_repo/msgq/visionipc/visionipc_pyx.so" \ "${WORK_DIR}/msgq_repo/msgq/visionipc/visionipc_pyx.o" \ "${WORK_DIR}/msgq_repo/msgq/visionipc/visionipc_pyx.cpp" } purge_host_desktop_ui_artifacts() { rm -f \ "${WORK_DIR}/selfdrive/ui/libqt_widgets.a" \ "${WORK_DIR}/selfdrive/ui/libqt_util.a" \ "${WORK_DIR}/selfdrive/ui/assets.o" \ "${WORK_DIR}/selfdrive/ui/main.o" \ "${WORK_DIR}/selfdrive/ui/moc_ui.o" \ "${WORK_DIR}/selfdrive/ui/ui.o" \ "${WORK_DIR}/selfdrive/ui/ui" \ "${WORK_DIR}/cereal/gen/cpp/"*.capnp.o } ensure_host_python_tools() { ensure_venv local root_py_minor root_py_minor="$("${ROOT_DIR}/.venv/bin/python3" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')" if [[ -x "${HOST_VENV}/bin/python3" ]]; then local host_py_minor host_py_minor="$("${HOST_VENV}/bin/python3" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')" if [[ "${host_py_minor}" != "${root_py_minor}" ]]; then rm -rf "${HOST_VENV}" purge_host_python_artifacts fi fi if [[ -x "${HOST_VENV}/bin/scons" && -x "${HOST_VENV}/bin/cythonize" && -x "${HOST_VENV}/bin/python3" ]]; then return fi ( cd "${WORK_DIR}" UV_PROJECT_ENVIRONMENT="${HOST_VENV}" UV_PYTHON="${ROOT_DIR}/.venv/bin/python3" uv sync --frozen --all-extras ) } sync_host_generated_headers() { if ! command -v capnpc >/dev/null 2>&1; then return fi ( cd "${WORK_DIR}" mkdir -p cereal/gen/cpp capnpc --src-prefix=cereal \ cereal/log.capnp \ cereal/car.capnp \ cereal/legacy.capnp \ cereal/custom.capnp \ -o c++:cereal/gen/cpp/ ) } sync_worktree() { ensure_venv mkdir -p "${HOST_ROOT}" local excludes=( ".git/" ".venv/" ".venv-linux-arm64/" ".cache/" ".comma_sysroot/" ".host_runtime/" "__pycache__/" "*.pyc" "*.pyo" "*.o" "*.os" ".sconsign.dblite" "compile_commands.json" "tools/plotjuggler/bin/" "tools/replay/replay" "tools/replay/tests/test_replay" "tools/cabana/cabana" "tools/cabana/tests/test_cabana" "selfdrive/ui/ui" "selfdrive/ui/ui.macos" "selfdrive/ui/ui.larch64" "selfdrive/ui/libqt_widgets.a" "selfdrive/ui/libqt_util.a" "cereal/libcereal.a" "cereal/libsocketmaster.a" "cereal/messaging/bridge" "common/libcommon.a" "common/params_pyx.so" "common/params_pyx.cpp" "common/transformations/libtransformations.a" "common/transformations/transformations.so" "msgq_repo/libmsgq.a" "msgq_repo/libvisionipc.a" "msgq_repo/msgq/ipc_pyx.so" "msgq_repo/msgq/visionipc/visionipc_pyx.so" "rednose_repo/rednose/helpers/ekf_sym_pyx.so" "selfdrive/modeld/models/commonmodel_pyx.so" "selfdrive/pandad/libcan_list_to_can_capnp.a" "selfdrive/pandad/pandad_api_impl.so" "selfdrive/controls/lib/lateral_mpc_lib/c_generated_code/acados_ocp_solver_pyx.so" "selfdrive/controls/lib/lateral_mpc_lib/c_generated_code/libacados_ocp_solver_lat.so" "selfdrive/controls/lib/lateral_mpc_lib/c_generated_code/libacados_ocp_solver_lat.dylib" "selfdrive/controls/lib/longitudinal_mpc_lib/c_generated_code/acados_ocp_solver_pyx.so" "selfdrive/controls/lib/longitudinal_mpc_lib/c_generated_code/libacados_ocp_solver_long.so" "selfdrive/controls/lib/longitudinal_mpc_lib/c_generated_code/libacados_ocp_solver_long.dylib" "third_party/libjson11.a" "third_party/libkaitai.a" ) local rsync_args=(-a --delete) local pattern="" for pattern in "${excludes[@]}"; do rsync_args+=(--exclude "${pattern}") done local _capnp_before _capnp_before="$(stat -c '%Y' "${WORK_DIR}/cereal/custom.capnp" 2>/dev/null || echo 0)" rsync "${rsync_args[@]}" "${ROOT_DIR}/" "${WORK_DIR}/" local _capnp_after _capnp_after="$(stat -c '%Y' "${WORK_DIR}/cereal/custom.capnp" 2>/dev/null || echo 0)" if [[ "${_capnp_before}" != "${_capnp_after}" ]]; then rm -rf "${SP_SCONS_CACHE_DIR:-${HOST_ROOT}/scons_cache}" fi purge_host_desktop_ui_artifacts rm -f "${WORK_DIR}/third_party/libjson11.a" "${WORK_DIR}/third_party/libkaitai.a" sync_host_generated_headers ensure_host_python_tools rm -rf "${WORK_DIR}/.venv" ln -s "${HOST_VENV}" "${WORK_DIR}/.venv" } setup_build_env() { if [[ -d /opt/homebrew/bin ]]; then export PATH="/opt/homebrew/bin:${PATH}" fi mkdir -p "${HOST_ROOT}/scons_cache" export SP_SCONS_CACHE_DIR="${HOST_ROOT}/scons_cache" if [[ "$(uname -s)" == "Darwin" ]]; then export CC="/usr/bin/clang" export CXX="/usr/bin/clang++" export AR="/usr/bin/ar" export RANLIB="/usr/bin/ranlib" fi unset CPATH C_INCLUDE_PATH CPLUS_INCLUDE_PATH CPPFLAGS CFLAGS CXXFLAGS LDFLAGS } export_workdir_pythonpath() { export PYTHONPATH="${WORK_DIR}:${WORK_DIR}/starpilot/third_party" local repo_dir="" for repo_dir in "${WORK_DIR}"/*_repo; do [[ -d "${repo_dir}" ]] && export PYTHONPATH="${PYTHONPATH}:${repo_dir}" done [[ -d "${WORK_DIR}/third_party/acados" ]] && export PYTHONPATH="${PYTHONPATH}:${WORK_DIR}/third_party/acados" } run_host_scons() { local jobs="$1" shift || true ( cd "${WORK_DIR}" setup_build_env source .venv/bin/activate SP_DISABLE_AUTO_DEVICE_SCONS=1 "${WORK_DIR}/.venv/bin/scons" --extras -j"${jobs}" "$@" ) } run_in_worktree() { ( cd "${WORK_DIR}" setup_build_env "$@" ) } run_python_tool() { local script_path="$1" shift || true ( cd "${WORK_DIR}" setup_build_env export_workdir_pythonpath source .venv/bin/activate exec "${WORK_DIR}/.venv/bin/python3" "${WORK_DIR}/${script_path}" "$@" ) } launch_c3() { local jobs jobs="$(default_jobs)" if [[ "${1:-}" =~ ^[0-9]+$ ]]; then jobs="$1" shift || true fi sync_worktree run_in_worktree "${WORK_DIR}/scripts/launch_ui_desktop.sh" "${jobs}" "$@" } launch_c4() { local jobs jobs="$(default_jobs)" if [[ "${1:-}" =~ ^[0-9]+$ ]]; then jobs="$1" shift || true fi sync_worktree run_in_worktree "${WORK_DIR}/scripts/launch_ui_c4_desktop.sh" "${jobs}" "$@" } launch_raybig() { local jobs jobs="$(default_jobs)" if [[ "${1:-}" =~ ^[0-9]+$ ]]; then jobs="$1" shift || true fi sync_worktree run_in_worktree "${WORK_DIR}/scripts/launch_ui_raybig_desktop.sh" "${jobs}" "$@" } launch_onroad() { local jobs jobs="$(default_jobs)" if [[ "${1:-}" =~ ^[0-9]+$ ]]; then jobs="$1" shift || true fi sync_worktree run_in_worktree "${WORK_DIR}/scripts/launch_onroad_desktop.sh" "${jobs}" "$@" } launch_replay() { local jobs jobs="$(default_jobs)" if [[ "${1:-}" =~ ^[0-9]+$ ]]; then jobs="$1" shift || true fi sync_worktree run_host_scons "${jobs}" tools/replay/replay ( cd "${WORK_DIR}" setup_build_env export BASEDIR="${WORK_DIR}" exec "${WORK_DIR}/tools/replay/replay" "$@" ) } launch_cabana() { local jobs jobs="$(default_jobs)" if [[ "${1:-}" =~ ^[0-9]+$ ]]; then jobs="$1" shift || true fi sync_worktree run_host_scons "${jobs}" tools/cabana/cabana ( cd "${WORK_DIR}" setup_build_env export BASEDIR="${WORK_DIR}" exec "${WORK_DIR}/tools/cabana/cabana" "$@" ) } launch_plotjuggler() { local jobs jobs="$(default_jobs)" if [[ "${1:-}" =~ ^[0-9]+$ ]]; then jobs="$1" shift || true fi sync_worktree run_host_scons "${jobs}" \ common/params_pyx.so \ common/transformations/transformations.so \ msgq_repo/msgq/ipc_pyx.so \ msgq_repo/msgq/visionipc/visionipc_pyx.so run_python_tool "tools/plotjuggler/juggle.py" "$@" } open_shell() { sync_worktree ( cd "${WORK_DIR}" setup_build_env export_workdir_pythonpath source .venv/bin/activate exec "${SHELL:-/bin/bash}" ) } main() { local command="${1:-help}" local bucket="" if [[ $# -gt 0 ]]; then shift || true fi case "${command}" in help|-h|--help) usage ;; c3|c4|raybig|onroad|replay|shell) set_host_bucket "shared" acquire_host_lock "${command} $*" ;; cabana) set_host_bucket "cabana" acquire_host_lock "${command} $*" ;; plotjuggler|juggle) set_host_bucket "shared" acquire_host_lock "${command} $*" ;; sync) if [[ $# -gt 0 ]]; then bucket="$(resolve_host_bucket "${1}")" || { echo "Unknown host bucket for sync: ${1}" >&2 echo "Valid sync buckets: shared, cabana" >&2 exit 1 } shift || true sync_selected_bucket "${bucket}" else sync_all_buckets fi exit 0 ;; *) echo "Unknown command: ${command}" >&2 echo >&2 usage >&2 exit 1 ;; esac case "${command}" in c3) launch_c3 "$@" ;; c4) launch_c4 "$@" ;; raybig) launch_raybig "$@" ;; onroad) launch_onroad "$@" ;; replay) launch_replay "$@" ;; cabana) launch_cabana "$@" ;; plotjuggler|juggle) launch_plotjuggler "$@" ;; shell) open_shell ;; esac } set_host_bucket "shared" main "$@"