#!/usr/bin/env bash set -euo pipefail # ============================================================ # photo_sanad.sh # ------------------------------------------------------------ # ONE command to run everything: # ./photo_sanad.sh # # What it does: # 1) Activates the camera env (py3.10) and starts the Core direct camera service in background # 2) Activates gemini (py3.11) and runs Gemini/voice_sanad.py in foreground # 3) Sets PulseAudio default sink/source (optional) # 4) Sets PYTHONPATH for unitree_sdk2_python (so unitree_sdk2py imports work) # 5) On exit, stops the direct camera server # ============================================================ SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" DEFAULT_BASE_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)" BASE_DIR="${BASE_DIR:-$DEFAULT_BASE_DIR}" PY_FILE="$BASE_DIR/Gemini/voice_sanad.py" RUN_USER="${SUDO_USER:-${USER:-$(id -un)}}" RUN_USER_HOME="$(getent passwd "$RUN_USER" 2>/dev/null | cut -d: -f6 || true)" if [[ -z "${RUN_USER_HOME:-}" ]]; then RUN_USER_HOME="$HOME" fi RUN_USER_ID="$(id -u "$RUN_USER" 2>/dev/null || true)" export HOME="$RUN_USER_HOME" if [[ -n "${RUN_USER_ID:-}" ]] && [[ -d "/run/user/$RUN_USER_ID" ]]; then export XDG_RUNTIME_DIR="/run/user/$RUN_USER_ID" if [[ -S "$XDG_RUNTIME_DIR/pulse/native" ]]; then export PULSE_SERVER="unix:$XDG_RUNTIME_DIR/pulse/native" fi fi # conda CONDA_SH="${CONDA_SH:-$RUN_USER_HOME/miniconda3/etc/profile.d/conda.sh}" ENV_TELE="teleimager" # Python 3.10 camera env (pyrealsense2) ENV_GEM="gemini" # Python 3.11 SDK_PY_PATH="${SDK_PY_PATH:-$RUN_USER_HOME/unitree_sdk2_python}" DIRECT_CAMERA_PORT="${DIRECT_CAMERA_PORT:-8091}" DIRECT_CAMERA_HOST="${DIRECT_CAMERA_HOST:-127.0.0.1}" DIRECT_CAMERA_SOURCE="${DIRECT_CAMERA_SOURCE:-realsense}" DIRECT_CAMERA_SCRIPT="${DIRECT_CAMERA_SCRIPT:-$BASE_DIR/Core/direct_camera_service.py}" LEGACY_DIRECT_CAMERA_SCRIPT="$BASE_DIR/Scripts/direct_camera_samples_server.py" # Optional: choose your camera (if needed) # export CAMERA_DEVICE=/dev/video2 # export PHOTO_PREFIX=photo export CAMERA_DEVICE="${CAMERA_DEVICE:-/dev/video0}" export PHOTO_PREFIX="${PHOTO_PREFIX:-photo}" # RealSense fix/sudo behavior SUDO_ENABLE_FIX="${SUDO_ENABLE_FIX:-1}" # 1=cache sudo so /api/fix can run SUDO_PROMPT_ON_START="${SUDO_PROMPT_ON_START:-0}" # 1=allow interactive sudo prompt at startup AUTO_FIX_ON_START="${AUTO_FIX_ON_START:-0}" # 1=run fix script once on startup SUDO_KEEPALIVE_INTERVAL="${SUDO_KEEPALIVE_INTERVAL:-60}" FIX_SCRIPT="${FIX_SCRIPT:-$BASE_DIR/Scripts/fix_realsense_usb.sh}" SUDO_KEEPALIVE_PID="" LOG_DIR="${LOG_DIR:-$BASE_DIR/Logs}" PHOTO_SERVER_PORT="${PHOTO_SERVER_PORT:-8080}" PRE_KILL_OLD="${PRE_KILL_OLD:-1}" # 1=stop stale sanad/camera services from previous runs FREE_PORT_BEFORE_START="${FREE_PORT_BEFORE_START:-1}" # 1=kill listeners on PHOTO_SERVER_PORT ALLOW_EXISTING_PORT_SERVER="${ALLOW_EXISTING_PORT_SERVER:-0}" # 1=allow attaching to an already-running gallery server # PulseAudio device names (change if needed) SINK="${SINK:-alsa_output.usb-Anker_PowerConf_A3321-DEV-SN1-01.analog-stereo}" SOURCE="${SOURCE:-alsa_input.usb-Anker_PowerConf_A3321-DEV-SN1-01.mono-fallback}" # ALSA plugin fix (helps conda envs stop spamming ALSA errors) if [[ -d "/usr/lib/aarch64-linux-gnu/alsa-lib" ]]; then export ALSA_PLUGIN_DIR="/usr/lib/aarch64-linux-gnu/alsa-lib" elif [[ -d "/usr/lib/alsa-lib" ]]; then export ALSA_PLUGIN_DIR="/usr/lib/alsa-lib" fi if [[ -f "/usr/share/alsa/alsa.conf" ]]; then export ALSA_CONFIG_PATH="/usr/share/alsa/alsa.conf" fi export ALSA_LOG_LEVEL=0 echo "๐Ÿ“ Base dir: $BASE_DIR" echo "๐Ÿ‘ค Runtime user: $RUN_USER" echo "๐Ÿ  Runtime home: $RUN_USER_HOME" cd "$BASE_DIR" # ---------- logging dir ---------- if ! mkdir -p "$LOG_DIR" >/dev/null 2>&1; then LOG_DIR="$HOME/.ai_photographer_logs" mkdir -p "$LOG_DIR" fi if ! touch "$LOG_DIR/.write_test" >/dev/null 2>&1; then LOG_DIR="$HOME/.ai_photographer_logs" mkdir -p "$LOG_DIR" touch "$LOG_DIR/.write_test" >/dev/null 2>&1 || true fi rm -f "$LOG_DIR/.write_test" >/dev/null 2>&1 || true export AI_PHOTOGRAPHER_LOG_DIR="$LOG_DIR" # ---------- stale-process cleanup ---------- show_port_owner() { local port="$1" if command -v ss >/dev/null 2>&1; then # shellcheck disable=SC2016 ss -ltnp 2>/dev/null | awk -v p=":$port" '$4 ~ p {print $0}' || true fi } start_sudo_keepalive() { ( while true; do sleep "$SUDO_KEEPALIVE_INTERVAL" sudo -n true >/dev/null 2>&1 || exit 0 done ) & SUDO_KEEPALIVE_PID=$! echo "โœ… sudo cached (keepalive PID=$SUDO_KEEPALIVE_PID)" } setup_sudo_cache() { if [[ "$SUDO_ENABLE_FIX" != "1" ]]; then return 0 fi if ! command -v sudo >/dev/null 2>&1; then echo "โš ๏ธ sudo not found; /api/fix cannot run privileged fixes." return 0 fi if sudo -n true >/dev/null 2>&1; then echo "๐Ÿ” sudo credentials already cached." start_sudo_keepalive return 0 fi if [[ "$SUDO_PROMPT_ON_START" == "1" && -t 0 ]]; then echo "๐Ÿ” Preparing sudo for RealSense fix endpoint..." if sudo -v; then start_sudo_keepalive else echo "โš ๏ธ sudo auth failed; /api/fix may require manual terminal command." fi else echo "โš ๏ธ sudo not cached. Use SUDO_PROMPT_ON_START=1 to allow startup prompt." fi } get_port_pids() { local port="$1" { if command -v lsof >/dev/null 2>&1; then lsof -t -iTCP:"$port" -sTCP:LISTEN -Pn 2>/dev/null || true fi if command -v ss >/dev/null 2>&1; then ss -ltnp 2>/dev/null \ | awk -v p=":$port" '$4 ~ p {print $0}' \ | sed -n 's/.*pid=\([0-9][0-9]*\).*/\1/p' || true fi if command -v fuser >/dev/null 2>&1; then fuser -n tcp "$port" 2>/dev/null | tr ' ' '\n' || true fi } | sed '/^[[:space:]]*$/d' | sort -u } get_pid_cmdline() { local pid="$1" ps -o args= -p "$pid" 2>/dev/null || true } kill_one_pid() { local pid="$1" local signal="${2:-TERM}" local pgid="" [[ -z "${pid:-}" ]] && return 0 if ! kill "-$signal" "$pid" >/dev/null 2>&1; then sudo -n kill "-$signal" "$pid" >/dev/null 2>&1 || true fi pgid="$(ps -o pgid= -p "$pid" 2>/dev/null | tr -d ' ' || true)" if [[ -n "${pgid:-}" ]]; then if ! kill "-$signal" "--" "-$pgid" >/dev/null 2>&1; then sudo -n kill "-$signal" "--" "-$pgid" >/dev/null 2>&1 || true fi fi } kill_port_owner() { local port="$1" local pids pid attempt pids="$(get_port_pids "$port" || true)" [[ -z "${pids:-}" ]] && return 0 echo "๐Ÿ›‘ Attempting to free port $port (PID(s): $(echo "$pids" | tr '\n' ' '))" for attempt in 1 2 3; do while IFS= read -r pid; do [[ -z "${pid:-}" ]] && continue if [[ "$attempt" -lt 3 ]]; then kill_one_pid "$pid" TERM else kill_one_pid "$pid" KILL fi done <<< "$pids" sleep 1 pids="$(get_port_pids "$port" || true)" [[ -z "${pids:-}" ]] && return 0 if [[ "$attempt" -eq 2 ]]; then echo "โš ๏ธ Port $port still busy, sending SIGKILL..." fi done } report_port_owners() { local port="$1" local pids pid cmd pids="$(get_port_pids "$port" || true)" [[ -z "${pids:-}" ]] && return 0 while IFS= read -r pid; do [[ -z "${pid:-}" ]] && continue cmd="$(get_pid_cmdline "$pid")" echo " - PID $pid: ${cmd:-}" done <<< "$pids" } stop_stale_processes() { echo "๐Ÿงฝ Checking stale processes..." local stopped=0 # Previous Sanad app instances from same project copy if pgrep -f "$BASE_DIR/Gemini/voice_sanad.py" >/dev/null 2>&1; then echo "๐Ÿ›‘ Stopping previous voice_sanad.py processes..." pkill -f "$BASE_DIR/Gemini/voice_sanad.py" >/dev/null 2>&1 || sudo -n pkill -f "$BASE_DIR/Gemini/voice_sanad.py" >/dev/null 2>&1 || true stopped=1 fi # Legacy pre-restructure path support if pgrep -f "$BASE_DIR/voice_sanad.py" >/dev/null 2>&1; then echo "๐Ÿ›‘ Stopping previous legacy voice_sanad.py processes..." pkill -f "$BASE_DIR/voice_sanad.py" >/dev/null 2>&1 || sudo -n pkill -f "$BASE_DIR/voice_sanad.py" >/dev/null 2>&1 || true stopped=1 fi # Previous direct camera servers if pgrep -f "$DIRECT_CAMERA_SCRIPT" >/dev/null 2>&1; then echo "๐Ÿ›‘ Stopping previous direct camera service processes..." pkill -f "$DIRECT_CAMERA_SCRIPT" >/dev/null 2>&1 || sudo -n pkill -f "$DIRECT_CAMERA_SCRIPT" >/dev/null 2>&1 || true stopped=1 fi if [[ "$DIRECT_CAMERA_SCRIPT" != "$LEGACY_DIRECT_CAMERA_SCRIPT" ]] && pgrep -f "$LEGACY_DIRECT_CAMERA_SCRIPT" >/dev/null 2>&1; then echo "๐Ÿ›‘ Stopping previous legacy direct_camera_samples_server.py processes..." pkill -f "$LEGACY_DIRECT_CAMERA_SCRIPT" >/dev/null 2>&1 || sudo -n pkill -f "$LEGACY_DIRECT_CAMERA_SCRIPT" >/dev/null 2>&1 || true stopped=1 fi # Give OS a moment to release sockets/resources. if [[ "$stopped" -eq 1 ]]; then sleep 1 fi } # Try to cache sudo early so stale cleanup can stop root-owned leftovers. setup_sudo_cache if [[ "$PRE_KILL_OLD" == "1" ]]; then stop_stale_processes fi PORT_OWNER="$(show_port_owner "$PHOTO_SERVER_PORT" || true)" if [[ -n "${PORT_OWNER:-}" ]]; then echo "โš ๏ธ Port $PHOTO_SERVER_PORT currently in use:" echo "$PORT_OWNER" fi # ---------- conda init ---------- if [[ ! -f "$CONDA_SH" ]]; then echo "โŒ conda.sh not found: $CONDA_SH" exit 1 fi # shellcheck disable=SC1090 source "$CONDA_SH" # ---------- time sync wait ---------- echo "โฐ Checking system time..." while [ "$(date +%Y)" -lt 2025 ]; do echo "โณ Waiting for time sync... $(date)" sleep 2 done echo "โœ… Time synced: $(date)" # ---------- PulseAudio defaults (optional) ---------- pick_pactl_device() { local kind="$1" local requested="$2" local current="" local names="" local chosen="" if [[ "$kind" == "sink" ]]; then current="$(pactl info 2>/dev/null | sed -n 's/^Default Sink: //p' | head -n1)" names="$(pactl list short sinks 2>/dev/null | awk '{print $2}')" else current="$(pactl info 2>/dev/null | sed -n 's/^Default Source: //p' | head -n1)" names="$(pactl list short sources 2>/dev/null | awk '{print $2}')" fi if [[ -n "${requested:-}" ]] && echo "$names" | grep -Fxq "$requested"; then chosen="$requested" elif [[ -n "${current:-}" ]] && echo "$names" | grep -Fxq "$current"; then chosen="$current" else chosen="$(echo "$names" | grep -Ei 'anker|powerconf|usb' | head -n1 || true)" if [[ -z "${chosen:-}" ]]; then chosen="$(echo "$names" | head -n1 || true)" fi fi printf '%s' "$chosen" } if command -v pactl >/dev/null 2>&1; then echo "๐Ÿ”Š Checking audio server..." READY=0 for _ in {1..20}; do if timeout 0.3s pactl info >/dev/null 2>&1; then READY=1 break fi sleep 0.3 done if [[ "$READY" -eq 1 ]]; then RESOLVED_SINK="$(pick_pactl_device sink "$SINK")" RESOLVED_SOURCE="$(pick_pactl_device source "$SOURCE")" if [[ -n "${RESOLVED_SINK:-}" ]]; then if [[ "$RESOLVED_SINK" != "$SINK" ]]; then echo "โš ๏ธ Requested speaker not found. Using โ†’ $RESOLVED_SINK" else echo "๐ŸŽง Setting default speaker โ†’ $RESOLVED_SINK" fi pactl set-default-sink "$RESOLVED_SINK" || true else echo "โš ๏ธ No PulseAudio sink found. Continuing without speaker selection." fi if [[ -n "${RESOLVED_SOURCE:-}" ]]; then if [[ "$RESOLVED_SOURCE" != "$SOURCE" ]]; then echo "โš ๏ธ Requested microphone not found. Using โ†’ $RESOLVED_SOURCE" else echo "๐ŸŽค Setting default microphone โ†’ $RESOLVED_SOURCE" fi pactl set-default-source "$RESOLVED_SOURCE" || true else echo "โš ๏ธ No PulseAudio source found. Continuing without microphone selection." fi ACTIVE_SINK="$(pactl info 2>/dev/null | sed -n 's/^Default Sink: //p' | head -n1 || true)" ACTIVE_SOURCE="$(pactl info 2>/dev/null | sed -n 's/^Default Source: //p' | head -n1 || true)" [[ -n "${ACTIVE_SINK:-}" ]] && echo "๐Ÿ”ˆ Active speaker default: $ACTIVE_SINK" [[ -n "${ACTIVE_SOURCE:-}" ]] && echo "๐ŸŽ™๏ธ Active microphone default: $ACTIVE_SOURCE" else echo "โš ๏ธ PulseAudio/PipeWire not ready. Continuing anyway." fi else echo "โš ๏ธ pactl not found. Skipping audio defaults." fi # ---------- paths check ---------- if [[ ! -f "$PY_FILE" ]]; then echo "โŒ voice_sanad.py not found: $PY_FILE" exit 1 fi # ---------- startup mode / runtime profile ---------- # Primary config lives in Data/Settings/config.json. Keep legacy Data/config.json # and Scripts/config.json fallbacks so older robot copies can still boot during migrations. MODE_FROM_CFG="$(python - <<'PY' import json from pathlib import Path p = Path("Data/Settings/config.json") legacy_candidates = [Path("Data/config.json"), Path("Scripts/config.json")] if not p.exists(): for legacy in legacy_candidates: if legacy.exists(): p = legacy break mode = "manual" try: if p.exists(): d = json.loads(p.read_text(encoding="utf-8")) mode_block = d.get("mode") or {} mode = str(mode_block.get("default_mode", mode_block.get("current_mode", "manual"))).strip().lower() except Exception: mode = "manual" if mode == "command": mode = "ai" try: if p.exists(): d = json.loads(p.read_text(encoding="utf-8")) mode_block = d.get("mode") or {} mode_block["current_mode"] = mode d["mode"] = mode_block p.write_text(json.dumps(d, indent=2), encoding="utf-8") except Exception: pass print(mode if mode in ("manual", "ai") else "manual") PY )" echo "โœ… Runtime mode reset to default: $MODE_FROM_CFG" PHOTO_OUTPUT_DIR="$(python - <<'PY' import json from pathlib import Path base = Path(".").resolve() p = Path("Data/Settings/config.json") legacy_candidates = [Path("Data/config.json"), Path("Scripts/config.json")] if not p.exists(): for legacy in legacy_candidates: if legacy.exists(): p = legacy break value = "AI_Photographer/photos/Captures" try: if p.exists(): d = json.loads(p.read_text(encoding="utf-8")) paths = d.get("paths") or {} value = str(paths.get("photos_dir", value) or value).strip() except Exception: pass target = Path(value).expanduser() if not target.is_absolute(): parts = target.parts if parts and parts[0] == base.name: target = (base.parent / target).resolve() else: target = (base / target).resolve() print(target) PY )" echo "๐Ÿ—‚๏ธ Capture output dir: $PHOTO_OUTPUT_DIR" if [[ -z "${MANUAL_LEAN_RUNTIME:-}" ]]; then if [[ "$MODE_FROM_CFG" == "manual" ]]; then export MANUAL_LEAN_RUNTIME=0 else export MANUAL_LEAN_RUNTIME=0 fi fi if [[ "$MODE_FROM_CFG" == "manual" && "${MANUAL_LEAN_RUNTIME}" == "1" ]]; then echo "๐Ÿชถ Manual lean runtime enabled: direct camera/DDS/replay startup will be skipped." fi # ---------- start direct camera server (py3.10) ---------- TELE_PID="" TELE_LOG="$LOG_DIR/direct_camera.log" if [[ "$MODE_FROM_CFG" == "manual" && "${MANUAL_LEAN_RUNTIME}" == "1" ]]; then echo "๐Ÿ“ท Skipping direct camera startup for lean manual runtime." else kill_port_owner "$DIRECT_CAMERA_PORT" echo "๐Ÿ Activating camera env: $ENV_TELE" conda activate "$ENV_TELE" echo "๐Ÿ camera env python: $(which python)" python -c "import sys; print('โœ… camera env sys.executable:', sys.executable); print('โœ… camera env sys.version:', sys.version.split()[0])" echo "๐Ÿ“ท Starting direct camera server in background..." python "$DIRECT_CAMERA_SCRIPT" \ --camera "$DIRECT_CAMERA_SOURCE" \ --host "$DIRECT_CAMERA_HOST" \ --port "$DIRECT_CAMERA_PORT" \ --samples-dir "$PHOTO_OUTPUT_DIR" \ --capture-prefix "$PHOTO_PREFIX" \ --capture-ext jpg >"$TELE_LOG" 2>&1 & TELE_PID=$! echo "โœ… direct camera PID=$TELE_PID log=$TELE_LOG" fi cleanup() { echo "" echo "๐Ÿงน Cleaning up..." if [[ -n "${SUDO_KEEPALIVE_PID:-}" ]] && kill -0 "$SUDO_KEEPALIVE_PID" >/dev/null 2>&1; then echo "๐Ÿ›‘ Stopping sudo keepalive PID=$SUDO_KEEPALIVE_PID" kill "$SUDO_KEEPALIVE_PID" >/dev/null 2>&1 || true wait "$SUDO_KEEPALIVE_PID" >/dev/null 2>&1 || true fi if [[ -n "${TELE_PID:-}" ]] && kill -0 "$TELE_PID" >/dev/null 2>&1; then echo "๐Ÿ›‘ Stopping direct camera server PID=$TELE_PID" kill "$TELE_PID" >/dev/null 2>&1 || true wait "$TELE_PID" >/dev/null 2>&1 || true fi echo "โœ… Done." } trap cleanup EXIT INT TERM # Give server time to start if [[ -n "${TELE_PID:-}" ]]; then sleep 2 fi # ---------- run sanad (py3.11) ---------- echo "๐Ÿ Activating gemini env: $ENV_GEM" conda activate "$ENV_GEM" echo "๐Ÿ gemini python: $(which python)" python -c "import sys; print('โœ… gemini sys.executable:', sys.executable); print('โœ… gemini sys.version:', sys.version.split()[0])" # ---------- autonomous enable policy ---------- # If AUTONOMOUS_ENABLE is not explicitly provided, derive from startup default mode: # - full runtime -> arm autonomous services so AI mode can start live without restart # - lean manual runtime -> keep disabled if [[ -z "${AUTONOMOUS_ENABLE:-}" ]]; then if [[ "$MODE_FROM_CFG" == "manual" && "${MANUAL_LEAN_RUNTIME}" == "1" ]]; then export AUTONOMOUS_ENABLE=0 else export AUTONOMOUS_ENABLE=1 fi echo "๐Ÿค– AUTONOMOUS_ENABLE auto-set to $AUTONOMOUS_ENABLE (mode=$MODE_FROM_CFG, lean_manual=${MANUAL_LEAN_RUNTIME})" else echo "๐Ÿค– AUTONOMOUS_ENABLE preset to $AUTONOMOUS_ENABLE" fi # Ensure unitree_sdk2py can import using the local project modules. export PYTHONPATH="$SDK_PY_PATH:${BASE_DIR}:${PYTHONPATH:-}" echo "๐Ÿ”ง Using local AI_Photographer modules" export DIRECT_CAMERA_ENABLED="${DIRECT_CAMERA_ENABLED:-1}" export DIRECT_CAMERA_SERVER_URL="${DIRECT_CAMERA_SERVER_URL:-http://$DIRECT_CAMERA_HOST:$DIRECT_CAMERA_PORT}" echo "๐Ÿ”ง PYTHONPATH=$PYTHONPATH" echo "๐Ÿ“ท DIRECT_CAMERA_SERVER_URL=$DIRECT_CAMERA_SERVER_URL" echo "๐Ÿ”ง ALSA_PLUGIN_DIR=${ALSA_PLUGIN_DIR:-"(not set)"}" echo "๐Ÿ”ง ALSA_CONFIG_PATH=${ALSA_CONFIG_PATH:-"(not set)"}" echo "๐ŸŽฅ CAMERA_DEVICE=$CAMERA_DEVICE" echo "๐Ÿ–ผ๏ธ PHOTO_PREFIX=$PHOTO_PREFIX" echo "๐Ÿ”ง FIX_SCRIPT=$FIX_SCRIPT" echo "๐Ÿชต LOG_DIR=$LOG_DIR" # sudo cache is handled earlier by setup_sudo_cache. if [[ "$AUTO_FIX_ON_START" == "1" ]]; then if [[ -f "$FIX_SCRIPT" ]]; then echo "๐Ÿ› ๏ธ Running RealSense fix on startup..." if sudo bash "$FIX_SCRIPT" --fix; then echo "โœ… RealSense fix completed." else echo "โš ๏ธ RealSense fix failed; continuing startup." fi else echo "โš ๏ธ AUTO_FIX_ON_START=1 but fix script not found: $FIX_SCRIPT" fi fi # Final port cleanup right before launching main app (after sudo caching) if [[ "$FREE_PORT_BEFORE_START" == "1" ]]; then kill_port_owner "$PHOTO_SERVER_PORT" fi PORT_OWNER="$(show_port_owner "$PHOTO_SERVER_PORT" || true)" if [[ -n "${PORT_OWNER:-}" ]]; then echo "โš ๏ธ Port $PHOTO_SERVER_PORT remains in use:" echo "$PORT_OWNER" report_port_owners "$PHOTO_SERVER_PORT" if [[ "$ALLOW_EXISTING_PORT_SERVER" != "1" ]]; then echo "โŒ Refusing to continue while a stale server still owns port $PHOTO_SERVER_PORT." echo " Set ALLOW_EXISTING_PORT_SERVER=1 only if you intentionally want to attach to the existing server." exit 1 fi fi echo "๐Ÿš€ Starting Sanad AI_Photographer..." # Run in foreground (so trap cleanup runs when process exits) python "$PY_FILE"