#!/usr/bin/env bash set -euo pipefail SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="${SCRIPT_DIR}" VENV_PYTHON="${PROJECT_ROOT}/.venv/bin/python" VENV_PIP="${PROJECT_ROOT}/.venv/bin/pip" GUI_ENTRY="${PROJECT_ROOT}/python_app/gui/main.py" REQUIREMENTS_FILE="${PROJECT_ROOT}/requirements.txt" PYTHON_CMD="" PROFILE_PATH="" # Single-instance coordination: the headless daemon and the interactive GUI # must never run at once (they share the radar, SHM rings and locator port). SERVICE_NAME="radar.service" LOCK_FILE="/tmp/radar_system.lock" SKIP_BUILD=0 BUILD_ONLY=0 CLEAN_SHM=0 AUTO_START=0 PRODUCER_ONLY=0 HEADLESS=0 # Acquisition device, detected from the active run config's radar.model. Drives # device-specific provisioning, dependencies, and which collector binary to build # — so every device launches the same way (no per-device flags). RADAR_MODEL="" print_usage() { cat <<'EOF' Usage: ./start.sh [options] Options: --profile PATH Use a specific GUI/run config profile (device is auto-detected from its radar.model) --auto-start Start the GUI pipeline automatically after launch --headless Run without a display (Qt offscreen platform) and apply the active radar config, then start the pipeline. Suitable for unattended Raspberry Pi deployments. Implies --auto-start. --producer-only Run only the raw producer selected by the profile --skip-build Skip C++ build step --build-only Build C++ binaries and exit --clean-shm Remove known radar shared-memory segments before start -h, --help Show this help EOF } parse_args() { while (($# > 0)); do case "$1" in --skip-build) SKIP_BUILD=1 ;; --build-only) BUILD_ONLY=1 ;; --clean-shm) CLEAN_SHM=1 ;; --profile) if (($# < 2)); then echo "--profile requires a path argument." >&2 exit 1 fi PROFILE_PATH="$2" shift ;; --auto-start) AUTO_START=1 ;; --headless) HEADLESS=1 AUTO_START=1 ;; --producer-only) PRODUCER_ONLY=1 ;; -h|--help) print_usage exit 0 ;; *) echo "Unknown argument: $1" >&2 print_usage exit 1 ;; esac shift done } absolute_path() { local path="$1" if [[ "${path}" = /* ]]; then printf '%s\n' "${path}" return fi printf '%s\n' "${PROJECT_ROOT}/${path}" } resolve_profile_path() { if [[ -n "${PROFILE_PATH}" ]]; then PROFILE_PATH="$(absolute_path "${PROFILE_PATH}")" if [[ ! -f "${PROFILE_PATH}" ]]; then echo "Config profile not found: ${PROFILE_PATH}" >&2 exit 1 fi fi } # Resolve the config that will actually be used (explicit --profile, else the # active run_config.json) and read its radar.model. Best-effort: any failure # falls back to 'librevna' (the full-provisioning superset), so detection can # never make a launch less safe. Uses system python3 (the venv may not exist yet). detect_radar_model() { local config_path="${PROFILE_PATH:-${PROJECT_ROOT}/run_config.json}" RADAR_MODEL="librevna" [[ -f "${config_path}" ]] || return local detected detected="$(python3 -c ' import json, sys try: with open(sys.argv[1]) as handle: data = json.load(handle) radar = data.get("radar") if isinstance(data, dict) else {} model = radar.get("model") if isinstance(radar, dict) else None print(model or "librevna") except Exception: print("librevna") ' "${config_path}" 2>/dev/null)" || detected="" [[ -n "${detected}" ]] && RADAR_MODEL="${detected}" echo "[start.sh] Detected radar model: ${RADAR_MODEL} (config: ${config_path})" } # Acquisition producer for the active model, mirroring the process supervisor's # selection so --producer-only behaves identically to a full pipeline launch. producer_command() { local config_path="$1" case "${RADAR_MODEL}" in kamil_adc) printf '%s\0' "${PYTHON_CMD}" -m python_app.scripts.kamil_adc_raw_producer --config "${config_path}" ;; librevna_multi|sn9000) printf '%s\0' "${PYTHON_CMD}" -m python_app.scripts.matrix_raw_producer --config "${config_path}" ;; *) printf '%s\0' "${PROJECT_ROOT}/build/bin/sweep_orchestrator" --config "${config_path}" ;; esac } check_environment() { if ! command -v python3 >/dev/null 2>&1; then echo "python3 is not installed or not found in PATH." >&2 exit 1 fi if [[ ! -f "${REQUIREMENTS_FILE}" ]]; then echo "Requirements file not found: ${REQUIREMENTS_FILE}" >&2 exit 1 fi if [[ ! -f "${GUI_ENTRY}" ]]; then echo "GUI entry not found: ${GUI_ENTRY}" >&2 exit 1 fi } ensure_python_dependencies() { local dependency_check if [[ "${RADAR_MODEL}" == "kamil_adc" ]]; then # Kamil ADC has no VISA dependency (it talks to its own L-Card collector). dependency_check='import numpy, serial, PyQt6, pyqtgraph, usb1' else dependency_check='import numpy, serial, PyQt6, pyqtgraph, usb1, pyvisa' fi if [[ ! -x "${VENV_PYTHON}" ]]; then echo "[start.sh] Creating virtual environment..." python3 -m venv "${PROJECT_ROOT}/.venv" fi if [[ ! -x "${VENV_PIP}" ]]; then echo "pip is missing in virtual environment: ${VENV_PIP}" >&2 exit 1 fi if "${VENV_PYTHON}" -c "${dependency_check}" >/dev/null 2>&1; then PYTHON_CMD="${VENV_PYTHON}" return fi if ((HEADLESS == 1)); then # Headless = unattended (often offline) appliance: never attempt a network # pip install that could hang or crash-loop the service. Provisioning is a # one-time interactive step. Fail fast with a clear, actionable message. echo "Python dependencies are missing and headless mode does not provision them." >&2 echo "Run an interactive './start.sh' once (online) to create the venv, then retry." >&2 exit 1 fi echo "[start.sh] Installing Python dependencies into virtual environment..." "${VENV_PIP}" install --upgrade pip "${VENV_PIP}" install -r "${REQUIREMENTS_FILE}" if ! "${VENV_PYTHON}" -c "${dependency_check}" >/dev/null 2>&1; then echo "Required Python dependencies are still unavailable in virtual environment: ${PROJECT_ROOT}/.venv" >&2 exit 1 fi PYTHON_CMD="${VENV_PYTHON}" echo "[start.sh] Using virtual environment: ${PYTHON_CMD}" } run_privileged() { if [[ "${EUID}" -eq 0 ]]; then "$@" return fi if command -v sudo >/dev/null 2>&1; then sudo "$@" return fi echo "Need elevated privileges to run: $*" >&2 echo "Run as root or install 'sudo'." >&2 exit 1 } ensure_system_dependencies() { if command -v pkg-config >/dev/null 2>&1 && pkg-config --exists libusb-1.0; then return fi echo "[start.sh] Installing system dependency: libusb-1.0 dev headers..." if command -v apt-get >/dev/null 2>&1; then run_privileged apt-get update run_privileged apt-get install -y pkg-config libusb-1.0-0-dev elif command -v dnf >/dev/null 2>&1; then run_privileged dnf install -y pkgconf-pkg-config libusb1-devel elif command -v yum >/dev/null 2>&1; then run_privileged yum install -y pkgconfig libusb1-devel elif command -v pacman >/dev/null 2>&1; then run_privileged pacman -Sy --needed pkgconf libusb elif command -v zypper >/dev/null 2>&1; then run_privileged zypper --non-interactive install pkg-config libusb-1_0-devel elif command -v brew >/dev/null 2>&1; then brew install pkg-config libusb else echo "Could not detect supported package manager." >&2 echo "Install manually: pkg-config and libusb development package." >&2 exit 1 fi if ! command -v pkg-config >/dev/null 2>&1 || ! pkg-config --exists libusb-1.0; then echo "libusb-1.0 development package is still unavailable after install attempt." >&2 exit 1 fi } ensure_usb_access_rules() { if [[ "$(uname -s)" != "Linux" ]]; then return fi if ! command -v udevadm >/dev/null 2>&1; then return fi local rule_file="/etc/udev/rules.d/99-radar-librevna.rules" local tmp_rule tmp_rule="$(mktemp)" cat > "${tmp_rule}" <<'EOF' # LibreVNA USB access for non-root users SUBSYSTEM=="usb", ATTR{idVendor}=="0483", ATTR{idProduct}=="564e", GROUP="plugdev", MODE="0660", TAG+="uaccess" SUBSYSTEM=="usb", ATTR{idVendor}=="0483", ATTR{idProduct}=="4121", GROUP="plugdev", MODE="0660", TAG+="uaccess" SUBSYSTEM=="usb", ATTR{idVendor}=="1209", ATTR{idProduct}=="4121", GROUP="plugdev", MODE="0660", TAG+="uaccess" EOF if [[ -f "${rule_file}" ]] && cmp -s "${tmp_rule}" "${rule_file}"; then rm -f "${tmp_rule}" return fi echo "[start.sh] Installing udev rule for LibreVNA USB access..." run_privileged install -m 0644 "${tmp_rule}" "${rule_file}" rm -f "${tmp_rule}" run_privileged udevadm control --reload-rules run_privileged udevadm trigger echo "[start.sh] udev rules updated. Reconnect USB device if it is already plugged in." } build_cpp_binaries() { local jobs jobs="${BUILD_JOBS:-$(nproc)}" # The Kamil ADC collector is an extra, device-specific binary built only for # that model; all models share the core pipeline binaries (`all`). local targets="all" if [[ "${RADAR_MODEL}" == "kamil_adc" ]]; then targets="all kamil_adc_collector" fi if make -C "${PROJECT_ROOT}" -q ${targets} >/dev/null 2>&1; then echo "[start.sh] C++ binaries are up to date; skipping build." return fi echo "[start.sh] Building C++ binaries (jobs=${jobs}, targets: ${targets})..." make -C "${PROJECT_ROOT}" -j"${jobs}" ${targets} } cleanup_known_shm() { echo "[start.sh] Cleaning known shared-memory segments..." rm -f \ /dev/shm/radar_raw \ /dev/shm/radar_raw_tap \ /dev/shm/radar_preprocessed \ /dev/shm/radar_preprocessed_tap \ /dev/shm/radar_results \ /dev/shm/radar_raw_kamil_adc \ /dev/shm/radar_raw_tap_kamil_adc \ /dev/shm/radar_preprocessed_kamil_adc \ /dev/shm/radar_preprocessed_tap_kamil_adc \ /dev/shm/radar_results_kamil_adc \ || true } kill_stale_adc_collector() { # The L-Card ADC collector runs in its own session and can outlive a crashed # or force-killed run, holding the E-502 device and hanging the next start. # Kill any leftover hard before launching so acquisition always opens cleanly. if command -v pkill >/dev/null 2>&1; then if pkill -9 -f kamil_adc_collector 2>/dev/null; then echo "[start.sh] Killed leftover ADC collector process(es)." fi fi } run_gui() { if [[ -n "${PROFILE_PATH}" ]]; then export RADAR_SYSTEM_PROFILE="${PROFILE_PATH}" echo "[start.sh] Using config profile: ${PROFILE_PATH}" fi if ((AUTO_START == 1)); then export RADAR_SYSTEM_AUTO_START=1 echo "[start.sh] GUI auto-start is enabled." fi if ((HEADLESS == 1)); then # Qt offscreen platform lets the GUI controller and its event loop run # on a machine with no display (typical Raspberry Pi deployment). All # backend services — supervisor, Python device drivers, SHM readers, # locator client (vlc) handling — continue to work unchanged. export QT_QPA_PLATFORM=offscreen export RADAR_SYSTEM_HEADLESS=1 export RADAR_SYSTEM_AUTO_APPLY_RADAR=1 echo "[start.sh] Headless mode: Qt offscreen + auto apply-radar + auto-start." fi echo "[start.sh] Launching GUI..." exec "${PYTHON_CMD}" "${GUI_ENTRY}" } run_producer_only() { local config_path="${PROFILE_PATH:-${PROJECT_ROOT}/run_config.json}" if [[ ! -f "${config_path}" ]]; then echo "--producer-only needs a config (pass --profile, or create run_config.json)." >&2 exit 1 fi export PYTHONPATH="${PROJECT_ROOT}${PYTHONPATH:+:${PYTHONPATH}}" # Run the acquisition producer the supervisor would pick for this model. local -a command=() while IFS= read -r -d '' token; do command+=("${token}") done < <(producer_command "${config_path}") echo "[start.sh] Starting ${RADAR_MODEL} producer: ${command[*]}" exec "${command[@]}" } stop_headless_service() { # Stop the headless daemon (if running) so it releases the radar, SHM rings # and locator port before the interactive GUI/producer takes over. Relies on # the passwordless sudoers rule installed by deploy/install-daemon.sh. Safe # no-op when systemd or the unit is absent (is-active is false -> skip). command -v systemctl >/dev/null 2>&1 || return 0 if systemctl is-active --quiet "${SERVICE_NAME}"; then echo "[start.sh] Stopping ${SERVICE_NAME} so the GUI can take over the hardware..." # Non-fatal: a sudo/systemctl failure must not abort the GUI launch under # `set -e`. The single-instance lock below still prevents a real conflict. if ! sudo systemctl stop "${SERVICE_NAME}"; then echo "[start.sh] WARNING: failed to stop ${SERVICE_NAME}; relying on the instance lock." >&2 fi fi } verify_cpp_binaries() { # Guard the daemon's --skip-build path: refuse to run against a tree whose C++ # binaries were never built, instead of failing obscurely at spawn time. local missing=0 bin local required_bins="data_processor data_preprocessor" # Kamil ADC also needs its acquisition collector; other models use the C++ # sweep_orchestrator, which `all` always builds. if [[ "${RADAR_MODEL}" == "kamil_adc" ]]; then required_bins="${required_bins} kamil_adc_collector" fi for bin in ${required_bins}; do if [[ ! -x "${PROJECT_ROOT}/build/bin/${bin}" ]]; then echo "Required binary missing or not executable: build/bin/${bin}" >&2 missing=1 fi done if ((missing == 1)); then echo "Build the C++ binaries first: run ./start.sh without --skip-build, or 'make all'." >&2 exit 1 fi } acquire_single_instance_lock() { # Fail fast if another instance already owns the hardware, instead of # surfacing a cryptic 'shm ring busy' / 'port in use' later. fd 9 survives # the exec into Python, so the lock is held for the whole app lifetime. exec 9>"${LOCK_FILE}" if ! flock -n 9; then echo "[start.sh] Another radar instance already holds ${LOCK_FILE}; aborting." >&2 echo "[start.sh] Stop it first: sudo systemctl stop ${SERVICE_NAME} (or close the other run)." >&2 exit 1 fi } main() { parse_args "$@" resolve_profile_path check_environment detect_radar_model # Skip first-time provisioning (build headers, USB udev rule) in headless # mode: the daemon runs unattended at boot as a non-root user and must not # block on sudo. A fresh machine is provisioned by one interactive launch. # Also skip it for non-LibreVNA devices (e.g. Kamil ADC), which neither use # libusb directly nor need the LibreVNA USB access rule. if [[ "${RADAR_MODEL}" != "kamil_adc" ]] && ((HEADLESS == 0)); then ensure_system_dependencies ensure_usb_access_rules fi ensure_python_dependencies if ((CLEAN_SHM == 1)); then cleanup_known_shm fi if ((SKIP_BUILD == 0)); then build_cpp_binaries fi verify_cpp_binaries if ((BUILD_ONLY == 1)); then echo "[start.sh] Build completed." exit 0 fi # Interactive GUI/producer launch: stop the headless daemon first so it # frees the radar, SHM and locator port. The daemon itself runs --headless # and skips this, so it never stops itself. if ((HEADLESS == 0)); then stop_headless_service fi acquire_single_instance_lock # Clear any ADC collector wedged by a previous run before we launch a new one. kill_stale_adc_collector if ((PRODUCER_ONLY == 1)); then run_producer_only fi run_gui } main "$@"