AI_Photographer/Scripts/photo_sanad.sh
2026-04-12 18:52:37 +04:00

569 lines
19 KiB
Bash
Executable File

#!/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:-<unknown>}"
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"