475 lines
16 KiB
Bash
Executable File
475 lines
16 KiB
Bash
Executable File
#!/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 "$@"
|