#!/usr/bin/env bash set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "${ROOT_DIR}" # Make Docker Desktop binaries discoverable (docker + credential helpers) even # when the caller shell PATH is minimal. if [[ -d /Applications/Docker.app/Contents/Resources/bin ]]; then export PATH="/Applications/Docker.app/Contents/Resources/bin:${PATH}" fi IMAGE_NAME="${COMMA_BUILD_IMAGE:-starpilot-larch64-builder:latest}" SYSROOT_DIR_DEFAULT="${ROOT_DIR}/.comma_sysroot" SYSROOT_DIR="${COMMA_SYSROOT_DIR:-${SYSROOT_DIR_DEFAULT}}" usage() { cat <<'EOF' Usage: scripts/laptop_device_build.sh doctor scripts/laptop_device_build.sh setup [device-host] [device-user] [ssh-port] scripts/laptop_device_build.sh setup-sysroot [device-user] [ssh-port] scripts/laptop_device_build.sh setup-sysroot-agnos [manifest-path] scripts/laptop_device_build.sh build-image scripts/laptop_device_build.sh build [jobs] [scons args...] scripts/laptop_device_build.sh scons [scons args...] scripts/laptop_device_build.sh manager [jobs] [--no-build] [-- manager args...] scripts/laptop_device_build.sh shell Modes: doctor Check host/container prerequisites for laptop-side device builds. setup One-time setup for laptop builds (venv + image + sysroot). setup-sysroot Sync required runtime/linker libs from a comma device over SSH. setup-sysroot-agnos Download AGNOS system image and extract sysroot locally. build-image Build the Linux/aarch64 container image used for device-target builds. build Run larch64 scons build in container and set prebuilt flag. scons Run raw scons in the larch64 container (no prebuilt touch). manager Run system/manager/manager.py in the larch64 container. shell Open an interactive shell in the build container with all mounts. Environment overrides: COMMA_BUILD_IMAGE Container image tag (default: starpilot-larch64-builder:latest) COMMA_SYSROOT_DIR Sysroot location (default: ./.comma_sysroot) COMMA_AUTOSTART_DOCKER Auto-launch Docker Desktop on macOS when needed (default: 1) EOF } err() { echo "ERROR: $*" >&2 exit 1 } require_cmd() { command -v "$1" >/dev/null 2>&1 || err "Missing required command: $1" } detect_engine() { local docker_cmd="" if command -v docker >/dev/null 2>&1; then docker_cmd="$(command -v docker)" elif [[ -x /Applications/Docker.app/Contents/Resources/bin/docker ]]; then docker_cmd="/Applications/Docker.app/Contents/Resources/bin/docker" fi if [[ -n "${docker_cmd}" ]]; then # Needed for docker credential helpers (e.g. docker-credential-desktop). export PATH="$(dirname "${docker_cmd}"):${PATH}" if ! "${docker_cmd}" info >/dev/null 2>&1; then if [[ "$(uname -s)" == "Darwin" ]] && [[ "${COMMA_AUTOSTART_DOCKER:-1}" =~ ^(1|true|yes|on)$ ]]; then if [[ -d /Applications/Docker.app ]] && command -v open >/dev/null 2>&1; then echo "Docker daemon is not running. Launching Docker Desktop..." >&2 open -g -a Docker >/dev/null 2>&1 || open -a Docker >/dev/null 2>&1 || true for ((i=0; i<300; i++)); do if "${docker_cmd}" info >/dev/null 2>&1; then break fi sleep 1 done fi fi if ! "${docker_cmd}" info >/dev/null 2>&1; then err "Docker CLI found, but Docker daemon is not running. Start Docker Desktop (open -a Docker) and retry." fi fi echo "${docker_cmd}" return fi if command -v podman >/dev/null 2>&1; then local podman_cmd podman_cmd="$(command -v podman)" if ! "${podman_cmd}" info >/dev/null 2>&1; then err "Podman CLI found, but Podman service is not running." fi echo "${podman_cmd}" return fi err "No container runtime found (install docker or podman)." } ensure_image_exists() { local engine="$1" if ! "${engine}" image inspect "${IMAGE_NAME}" >/dev/null 2>&1; then echo "Container image ${IMAGE_NAME} not found. Building it now..." "${engine}" build --pull --platform linux/arm64 -f tools/laptop_device_build/Dockerfile -t "${IMAGE_NAME}" . fi assert_image_arch "${engine}" } assert_image_arch() { local engine="$1" local arch arch="$("${engine}" image inspect "${IMAGE_NAME}" --format '{{.Architecture}}' 2>/dev/null || true)" if [[ "${arch}" != "arm64" ]]; then err "Container image ${IMAGE_NAME} is '${arch:-unknown}', expected 'arm64'. Rebuild with: ${engine} build --pull --platform linux/arm64 -f tools/laptop_device_build/Dockerfile -t ${IMAGE_NAME} ." fi } assert_runtime_machine() { local engine="$1" local machine machine="$("${engine}" run --rm --platform linux/arm64 "${IMAGE_NAME}" bash -lc "readelf -h \$(command -v python3) | awk -F: '/Machine/ {gsub(/^ +/, \"\", \$2); print \$2}'" 2>/dev/null || true)" if [[ "${machine}" != "AArch64" ]]; then err "Container Python runtime is '${machine:-unknown}', expected 'AArch64'. This image can still build device artifacts, but cannot run manager with larch64 Python extensions. Rebuild from an arm64-capable base image before using 'manager' mode." fi } 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 } build_container_image() { local engine engine="$(detect_engine)" "${engine}" build --pull --platform linux/arm64 \ -f tools/laptop_device_build/Dockerfile \ -t "${IMAGE_NAME}" . assert_image_arch "${engine}" } sanitize_sysroot_headers() { # Device /usr/local/include can include older capnp/kj headers that conflict # with generated cereal headers in this tree. Keep ffmpeg headers, drop capnp. local removed=0 local path="" for path in \ "${SYSROOT_DIR}/usr/local/include/capnp" \ "${SYSROOT_DIR}/usr/local/include/kj"; do if [[ -e "${path}" ]]; then rm -rf "${path}" removed=1 fi done if [[ "${removed}" -eq 1 ]]; then echo "Sanitized sysroot headers: removed capnp/kj from usr/local/include" fi } ensure_sysroot_layout() { [[ -d "${SYSROOT_DIR}/usr/local/lib" ]] || err "Missing ${SYSROOT_DIR}/usr/local/lib. Run setup-sysroot first." [[ -d "${SYSROOT_DIR}/usr/local/include" ]] || err "Missing ${SYSROOT_DIR}/usr/local/include. Run setup-sysroot first." [[ -d "${SYSROOT_DIR}/lib/aarch64-linux-gnu" ]] || err "Missing ${SYSROOT_DIR}/lib/aarch64-linux-gnu. Run setup-sysroot first." [[ -d "${SYSROOT_DIR}/usr/lib/aarch64-linux-gnu" ]] || err "Missing ${SYSROOT_DIR}/usr/lib/aarch64-linux-gnu. Run setup-sysroot first." [[ -d "${SYSROOT_DIR}/usr/include" ]] || err "Missing ${SYSROOT_DIR}/usr/include. Run setup-sysroot first." [[ -d "${SYSROOT_DIR}/system/vendor/lib64" ]] || err "Missing ${SYSROOT_DIR}/system/vendor/lib64. Run setup-sysroot first." sanitize_sysroot_headers repair_sysroot_linker } is_aarch64_elf() { local path="${1:-}" [[ -f "${path}" ]] || return 1 local desc desc="$(file -b "${path}" 2>/dev/null || true)" [[ "${desc}" == *"ELF 64-bit LSB"* && "${desc}" == *"ARM aarch64"* ]] } repair_sysroot_linker() { local lib_ld="${SYSROOT_DIR}/lib/aarch64-linux-gnu/ld-linux-aarch64.so.1" local usr_ld="${SYSROOT_DIR}/usr/lib/aarch64-linux-gnu/ld-linux-aarch64.so.1" # Some sysroot extraction paths can create this as an empty directory. if [[ -d "${lib_ld}" ]]; then rm -rf "${lib_ld}" fi # Ensure lib-path loader exists (symlink to usr/lib loader is fine). if [[ ! -e "${lib_ld}" && -f "${usr_ld}" ]]; then mkdir -p "$(dirname "${lib_ld}")" ln -s "../../usr/lib/aarch64-linux-gnu/ld-linux-aarch64.so.1" "${lib_ld}" fi } resolve_ldso_source() { local lib_ld="${SYSROOT_DIR}/lib/aarch64-linux-gnu/ld-linux-aarch64.so.1" local usr_ld="${SYSROOT_DIR}/usr/lib/aarch64-linux-gnu/ld-linux-aarch64.so.1" repair_sysroot_linker if is_aarch64_elf "${lib_ld}"; then echo "${lib_ld}" return 0 fi if is_aarch64_elf "${usr_ld}"; then echo "${usr_ld}" return 0 fi err "Missing valid aarch64 dynamic loader in sysroot. Expected one of: ${lib_ld} or ${usr_ld}. Re-run setup-sysroot." } scrub_mixed_arch_artifacts() { # Desktop launchers can leave macOS objects with the same paths as larch64 outputs. # Remove known cross-target extension intermediates so device builds always relink. rm -f \ "${ROOT_DIR}/cereal/messaging/"*.o \ "${ROOT_DIR}/cereal/messaging/bridge" \ "${ROOT_DIR}/msgq_repo/msgq/"*.os \ "${ROOT_DIR}/msgq_repo/msgq/"*.o \ "${ROOT_DIR}/msgq_repo/libmsgq.a" \ "${ROOT_DIR}/msgq_repo/libvisionipc.a" \ "${ROOT_DIR}/msgq_repo/msgq/ipc_pyx.so" \ "${ROOT_DIR}/msgq_repo/msgq/visionipc/"*.os \ "${ROOT_DIR}/msgq_repo/msgq/visionipc/"*.o \ "${ROOT_DIR}/msgq_repo/msgq/visionipc/visionipc_pyx.so" \ "${ROOT_DIR}/msgq/ipc_pyx.so" \ "${ROOT_DIR}/msgq/visionipc/visionipc_pyx.so" \ "${ROOT_DIR}/common/params_pyx.o" \ "${ROOT_DIR}/common/params_pyx.so" \ "${ROOT_DIR}/common/transformations/transformations.o" \ "${ROOT_DIR}/common/transformations/transformations.so" \ "${ROOT_DIR}/selfdrive/modeld/models/commonmodel_pyx.o" \ "${ROOT_DIR}/selfdrive/modeld/models/commonmodel_pyx.so" \ "${ROOT_DIR}/frogpilot/tinygrad_modeld/models/commonmodel_pyx.o" \ "${ROOT_DIR}/frogpilot/tinygrad_modeld/models/commonmodel_pyx.so" } sync_sysroot_from_device() { local host="${1:-}" local user="${2:-comma}" local port="${3:-22}" [[ -n "${host}" ]] || err "setup-sysroot requires ." require_cmd rsync mkdir -p "${SYSROOT_DIR}" local ssh_cmd="ssh -p ${port} -o StrictHostKeyChecking=accept-new" rsync -a --delete -e "${ssh_cmd}" "${user}@${host}:/usr/local/lib/" "${SYSROOT_DIR}/usr/local/lib/" rsync -a --delete -e "${ssh_cmd}" "${user}@${host}:/usr/local/include/" "${SYSROOT_DIR}/usr/local/include/" rsync -a --delete -e "${ssh_cmd}" "${user}@${host}:/lib/aarch64-linux-gnu/" "${SYSROOT_DIR}/lib/aarch64-linux-gnu/" rsync -a --delete -e "${ssh_cmd}" "${user}@${host}:/usr/lib/aarch64-linux-gnu/" "${SYSROOT_DIR}/usr/lib/aarch64-linux-gnu/" rsync -a --delete -e "${ssh_cmd}" "${user}@${host}:/usr/include/" "${SYSROOT_DIR}/usr/include/" local vendor_src="" for candidate in /system/vendor/lib64 /vendor/lib64; do if ssh -p "${port}" -o StrictHostKeyChecking=accept-new "${user}@${host}" "test -d '${candidate}'"; then vendor_src="${candidate}" break fi done if [[ -n "${vendor_src}" ]]; then rsync -a --delete -e "${ssh_cmd}" "${user}@${host}:${vendor_src}/" "${SYSROOT_DIR}/system/vendor/lib64/" else local vendor_dst="${SYSROOT_DIR}/system/vendor/lib64" echo "Warning: no vendor lib64 dir found on device; falling back to usr/lib/aarch64-linux-gnu" if [[ -L "${vendor_dst}" || -f "${vendor_dst}" ]]; then rm -f "${vendor_dst}" elif [[ -d "${vendor_dst}" ]]; then rm -rf "${vendor_dst}" fi mkdir -p "${SYSROOT_DIR}/system/vendor" ln -s ../../usr/lib/aarch64-linux-gnu "${vendor_dst}" fi sanitize_sysroot_headers echo "Sysroot synced to: ${SYSROOT_DIR}" } setup_sysroot_from_agnos() { local manifest="${1:-system/hardware/tici/agnos.json}" local engine engine="$(detect_engine)" ensure_image_exists "${engine}" mkdir -p "${SYSROOT_DIR}" "${ROOT_DIR}/.cache/agnos" "${engine}" run --rm --platform linux/arm64 \ -v "${ROOT_DIR}:/work" \ -v "${SYSROOT_DIR}:/opt/tici-sysroot" \ -v "${ROOT_DIR}/.cache:/work/.cache" \ -w /work \ "${IMAGE_NAME}" \ python3 tools/laptop_device_build/extract_sysroot_from_agnos.py \ --manifest "${manifest}" \ --output-dir /opt/tici-sysroot \ --cache-dir /work/.cache/agnos } run_larch64_scons() { local jobs="$1" shift || true local engine local started_at engine="$(detect_engine)" ensure_image_exists "${engine}" ensure_sysroot_layout scrub_mixed_arch_artifacts started_at="$(date +%s)" echo "==> Starting larch64 scons build" echo " jobs: ${jobs}" echo " note: warp artifact precompile can take several minutes on first run" mkdir -p "${ROOT_DIR}/.cache/scons" local extra_args="" local jobs_prefix="-j${jobs}" if [[ "$#" -gt 0 ]]; then for arg in "$@"; do case "${arg}" in -j|--jobs|-j*|--jobs=*) jobs_prefix="" ;; esac extra_args+=" $(printf '%q' "${arg}")" done fi local cmd cmd="$(cat < larch64 scons completed in $(( $(date +%s) - started_at ))s" } setup_host_venv() { if [[ ! -f "${ROOT_DIR}/.venv/bin/activate" ]]; then if [[ -x "${ROOT_DIR}/tools/install_python_dependencies.sh" ]]; then (cd "${ROOT_DIR}" && tools/install_python_dependencies.sh) else err "Missing host .venv and installer script." fi fi } setup_all() { local host="${1:-}" local user="${2:-comma}" local port="${3:-22}" setup_host_venv build_container_image if [[ -n "${host}" ]]; then sync_sysroot_from_device "${host}" "${user}" "${port}" else setup_sysroot_from_agnos "system/hardware/tici/agnos.json" fi } run_larch64_build() { local jobs="${1:-$(default_jobs)}" shift || true echo "==> Build pass 1/2: full project build" run_larch64_scons "${jobs}" "$@" # Ensure prebuilt runtime compatibility probes can import these modules. # scrub_mixed_arch_artifacts clears them at the start of each run, so # targeted builds must always regenerate this core set. # Do NOT regenerate dmonitoring_model_tinygrad.pkl on laptop builds: # it is backend-captured and should come from device/QCOM-compatible artifacts. echo "==> Build pass 2/2: required runtime artifacts" run_larch64_scons "${jobs}" \ selfdrive/modeld/models/dmonitoring_model_metadata.pkl \ selfdrive/modeld/models/driving_vision_metadata.pkl \ selfdrive/modeld/models/driving_policy_metadata.pkl \ selfdrive/modeld/models/driving_vision_tinygrad.pkl \ selfdrive/modeld/models/driving_policy_tinygrad.pkl \ rednose/helpers/ekf_sym_pyx.so \ common/params_pyx.so \ common/transformations/transformations.so \ 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/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/modeld/models/commonmodel_pyx.so \ cereal/messaging/bridge \ msgq_repo/msgq/ipc_pyx.so \ msgq_repo/msgq/visionipc/visionipc_pyx.so touch "${ROOT_DIR}/prebuilt" } manager_artifacts_ready() { [[ -f "${ROOT_DIR}/msgq/ipc_pyx.so" ]] && [[ -f "${ROOT_DIR}/msgq/visionipc/visionipc_pyx.so" ]] && [[ -f "${ROOT_DIR}/common/params_pyx.so" ]] && [[ -f "${ROOT_DIR}/selfdrive/pandad/pandad_api_impl.so" ]] && [[ -f "${ROOT_DIR}/selfdrive/controls/lib/lateral_mpc_lib/c_generated_code/acados_ocp_solver_pyx.so" ]] && [[ -f "${ROOT_DIR}/selfdrive/controls/lib/lateral_mpc_lib/c_generated_code/libacados_ocp_solver_lat.so" ]] && [[ -f "${ROOT_DIR}/selfdrive/controls/lib/longitudinal_mpc_lib/c_generated_code/acados_ocp_solver_pyx.so" ]] && [[ -f "${ROOT_DIR}/selfdrive/controls/lib/longitudinal_mpc_lib/c_generated_code/libacados_ocp_solver_long.so" ]] && [[ -f "${ROOT_DIR}/selfdrive/ui/ui" ]] } run_manager() { local jobs="$(default_jobs)" local auto_build=1 local manager_args=() if [[ "${1:-}" =~ ^[0-9]+$ ]]; then jobs="$1" shift || true fi while [[ $# -gt 0 ]]; do case "${1}" in --no-build) auto_build=0 shift ;; --) shift || true manager_args=("$@") break ;; *) manager_args+=("$1") shift ;; esac done if [[ "${auto_build}" -eq 1 ]] && ! manager_artifacts_ready; then echo "Missing manager runtime artifacts. Running device-target build first..." run_larch64_build "${jobs}" fi local engine engine="$(detect_engine)" ensure_image_exists "${engine}" assert_runtime_machine "${engine}" ensure_sysroot_layout mkdir -p "${ROOT_DIR}/.cache/scons" local manager_args_q="" if [[ "${#manager_args[@]}" -gt 0 ]]; then for arg in "${manager_args[@]}"; do manager_args_q+=" $(printf '%q' "${arg}")" done fi local cmd cmd="$(cat </dev/null 2>&1; then echo "OK: docker found" elif command -v podman >/dev/null 2>&1; then echo "OK: podman found" else echo "FAIL: install docker or podman" failed=1 fi if command -v rsync >/dev/null 2>&1; then echo "OK: rsync found" else echo "WARN: rsync missing (needed only for setup-sysroot from a physical device)" fi if [[ -d "${SYSROOT_DIR}" ]]; then if [[ -d "${SYSROOT_DIR}/usr/local/lib" && -d "${SYSROOT_DIR}/usr/local/include" && -d "${SYSROOT_DIR}/lib/aarch64-linux-gnu" && -d "${SYSROOT_DIR}/usr/lib/aarch64-linux-gnu" && -d "${SYSROOT_DIR}/usr/include" && -d "${SYSROOT_DIR}/system/vendor/lib64" ]]; then echo "OK: sysroot present at ${SYSROOT_DIR}" else echo "WARN: sysroot dir exists but is incomplete (${SYSROOT_DIR})" failed=1 fi else echo "WARN: sysroot missing (${SYSROOT_DIR})" failed=1 fi if [[ -f "${ROOT_DIR}/.venv/bin/activate" ]]; then echo "OK: host .venv present" else echo "WARN: host .venv missing (run scripts/laptop_device_build.sh setup)" failed=1 fi if [[ "${failed}" -ne 0 ]]; then exit 1 fi } main() { local mode="${1:-}" case "${mode}" in doctor) doctor ;; setup) shift || true setup_all "${1:-}" "${2:-comma}" "${3:-22}" ;; setup-sysroot) shift sync_sysroot_from_device "${1:-}" "${2:-comma}" "${3:-22}" ;; setup-sysroot-agnos) shift || true setup_sysroot_from_agnos "${1:-system/hardware/tici/agnos.json}" ;; build-image) build_container_image ;; build) shift || true local jobs_arg="${1:-$(default_jobs)}" if [[ "${1:-}" =~ ^[0-9]+$ ]]; then shift || true else jobs_arg="$(default_jobs)" fi run_larch64_build "${jobs_arg}" "$@" ;; scons) shift || true run_larch64_scons "$(default_jobs)" "$@" ;; manager) shift || true run_manager "$@" ;; shell) run_shell ;; ""|-h|--help|help) usage ;; *) usage exit 1 ;; esac } main "$@"