release: bump version to 0.71.0
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
release / images (push) Has been cancelled
ci / delivery (push) Has been cancelled

This commit is contained in:
linkong
2026-06-11 16:47:24 +08:00
parent 8c204717cd
commit 899e3bce43
56 changed files with 4618 additions and 260 deletions

718
planet.sh
View File

@@ -116,6 +116,10 @@ FRONTEND_RUNTIME_SOURCE="${FRONTEND_RUNTIME_SOURCE:-}"
PLANET_STATE_DIR="${PLANET_STATE_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/planet}"
PLANET_CACHE_DIR="${PLANET_CACHE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/planet}"
PLANET_UV_TUNA_INDEX_URL="${PLANET_UV_TUNA_INDEX_URL:-https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple/}"
USBIPD_WIN_FALLBACK_VERSION="${USBIPD_WIN_FALLBACK_VERSION:-5.3.0}"
USBIPD_WIN_FALLBACK_URL_X64="${USBIPD_WIN_FALLBACK_URL_X64:-https://github.com/dorssel/usbipd-win/releases/download/v${USBIPD_WIN_FALLBACK_VERSION}/usbipd-win_${USBIPD_WIN_FALLBACK_VERSION}_x64.msi}"
USBIPD_WIN_FALLBACK_URL_ARM64="${USBIPD_WIN_FALLBACK_URL_ARM64:-https://github.com/dorssel/usbipd-win/releases/download/v${USBIPD_WIN_FALLBACK_VERSION}/usbipd-win_${USBIPD_WIN_FALLBACK_VERSION}_arm64.msi}"
USBIPD_WIN_DOWNLOAD_DIR="${USBIPD_WIN_DOWNLOAD_DIR:-$SCRIPT_DIR/downloads/usbipd-win}"
BACKEND_PID_FILE="$PLANET_STATE_DIR/backend.pid"
BACKEND_LOG_FILE="$PLANET_STATE_DIR/backend.log"
FRONTEND_PID_FILE="$PLANET_STATE_DIR/frontend.pid"
@@ -132,6 +136,7 @@ PLANET_PORT_STATE_FILE="$PLANET_STATE_DIR/ports.env"
FRONTEND_VITE_ENTRY="$SCRIPT_DIR/frontend/node_modules/vite/bin/vite.js"
MOTION_AGENT_PID_FILE="$PLANET_STATE_DIR/motion_agent.pid"
MOTION_AGENT_LOG_FILE="$PLANET_STATE_DIR/motion_agent.log"
MOTION_AGENT_SKIPPED_FILE="$PLANET_STATE_DIR/motion_agent.skipped"
AI_PROVIDER_BUILD_STAMP_FILE="$PLANET_CACHE_DIR/aiprovider_build.sha256"
AI_PROVIDER_BUILD_LOG_FILE="$PLANET_STATE_DIR/aiprovider_build.log"
AI_PROVIDER_IMAGE_NAME="${AI_PROVIDER_IMAGE_NAME:-planet-aiprovider:latest}"
@@ -144,6 +149,7 @@ AI_PROVIDER_RECREATE_REQUIRED=0
START_RUN_ACTIVE=0
START_RUN_COMPLETED=0
STARTED_BACKEND_THIS_RUN=0
MOTION_AGENT_SKIPPED_THIS_RUN=0
STARTED_FRONTEND_THIS_RUN=0
STARTED_MOTION_AGENT_THIS_RUN=0
AI_PROVIDER_RUNTIME_ENV_NAMES=(
@@ -1666,6 +1672,13 @@ detect_motion_agent_camera_indexes() {
local devices=""
local device=""
local index=""
local cv2_devices=""
cv2_devices="$(detect_motion_agent_camera_indexes_with_cv2 || true)"
if [ -n "$cv2_devices" ]; then
printf "%s" "$cv2_devices"
return 0
fi
if command -v v4l2-ctl >/dev/null 2>&1; then
devices="$(v4l2-ctl --list-devices 2>/dev/null | sed -nE 's/^[[:space:]]*\\/dev\\/video([0-9]+).*/\\1/p' || true)"
@@ -1684,10 +1697,348 @@ detect_motion_agent_camera_indexes() {
printf "%s\n" "$devices" | sort -n | awk '!seen[$0]++' | head -n 2 | paste -sd, -
}
detect_motion_agent_camera_indexes_with_cv2() {
local python_bin="$SCRIPT_DIR/.venv/bin/python"
[ -x "$python_bin" ] || return 1
"$python_bin" - <<'PY' 2>/dev/null
from __future__ import annotations
import glob
import re
try:
import cv2 # type: ignore
except Exception:
raise SystemExit(1)
indexes: list[int] = []
for path in glob.glob("/dev/video*"):
match = re.search(r"/dev/video(\d+)$", path)
if match:
indexes.append(int(match.group(1)))
usable: list[str] = []
for index in sorted(set(indexes)):
capture = cv2.VideoCapture(index, cv2.CAP_V4L2)
try:
if not capture or not capture.isOpened():
continue
ok, _frame = capture.read()
if ok:
usable.append(str(index))
finally:
if capture:
capture.release()
print(",".join(usable[:2]))
PY
}
is_wsl_environment() {
grep -qiE "(microsoft|wsl)" /proc/version 2>/dev/null
}
detect_windows_usbipd_camera_busids() {
local line=""
local busid=""
command -v usbipd.exe >/dev/null 2>&1 || return 0
usbipd.exe list 2>/dev/null | tr -d '\r' | while IFS= read -r line; do
[[ "$line" =~ ^[[:space:]]*([0-9]+-[0-9]+)[[:space:]] ]] || continue
busid="${match[1]}"
case "$line" in
*[Cc]amera*|*[Ww]ebcam*|*"USB Video"*|*UVC*|*摄像头*|*相机*)
printf "%s|%s\n" "$busid" "$line"
;;
esac
done
}
download_usbipd_win_msi() {
local output_path="$1"
local fallback_url="$2"
mkdir -p "$(dirname "$output_path")"
"$SCRIPT_DIR/.venv/bin/python" - "$output_path" "$fallback_url" <<'PY'
from __future__ import annotations
import json
import sys
import urllib.request
from pathlib import Path
output = Path(sys.argv[1])
fallback_url = sys.argv[2]
arch = "arm64" if "aarch64" in __import__("platform").machine().lower() or "arm64" in __import__("platform").machine().lower() else "x64"
latest_api = "https://api.github.com/repos/dorssel/usbipd-win/releases/latest"
def download(url: str) -> None:
request = urllib.request.Request(url, headers={"User-Agent": "planet-bootstrap"})
with urllib.request.urlopen(request, timeout=25) as response:
output.write_bytes(response.read())
try:
request = urllib.request.Request(latest_api, headers={"User-Agent": "planet-bootstrap"})
with urllib.request.urlopen(request, timeout=12) as response:
release = json.loads(response.read().decode("utf-8"))
assets = release.get("assets") or []
url = next(
(
asset.get("browser_download_url")
for asset in assets
if isinstance(asset, dict)
and str(asset.get("name", "")).endswith(".msi")
and arch in str(asset.get("name", "")).lower()
),
None,
)
if not url:
raise RuntimeError("latest release has no matching MSI asset")
download(url)
except Exception:
download(fallback_url)
PY
}
install_usbipd_win_from_msi() {
local msi_path="$1"
local msi_win_path=""
local script_path=""
msi_win_path="$(wslpath -w "$msi_path" 2>/dev/null || true)"
if [ -z "$msi_win_path" ]; then
log_warn "usbipd-win MSI 已下载,但无法转换 Windows 路径: ${msi_path}"
return 1
fi
script_path="$(mktemp "${TMPDIR:-/tmp}/planet-usbipd-install.XXXXXX.ps1")"
cat > "$script_path" <<EOF
\$ErrorActionPreference = 'Stop'
Start-Process msiexec.exe -Wait -ArgumentList '/i', '${msi_win_path}', '/qn', '/norestart'
EOF
log_warn "准备请求管理员 PowerShell 安装 usbipd-win"
if run_windows_admin_powershell_script "$script_path" "无法请求管理员 PowerShell 安装 usbipd-win"; then
rm -f "$script_path" 2>/dev/null || true
return 0
fi
rm -f "$script_path" 2>/dev/null || true
return 1
}
ensure_usbipd_win_for_wsl() {
local auto_install="${PLANET_MOTION_AGENT_USBIPD_AUTO_INSTALL:-1}"
local fallback_url="$USBIPD_WIN_FALLBACK_URL_X64"
local msi_path=""
local winget_status=0
command -v usbipd.exe >/dev/null 2>&1 && return 0
is_wsl_environment || return 1
case "$auto_install" in
0|false|no|off)
log_warn "未找到 usbipd.exe且 PLANET_MOTION_AGENT_USBIPD_AUTO_INSTALL=0"
return 1
;;
esac
if ! command -v powershell.exe >/dev/null 2>&1; then
log_warn "未找到 powershell.exe无法从 WSL 自动安装 usbipd-win"
return 1
fi
log_warn "未找到 usbipd.exe准备尝试自动安装 usbipd-win"
powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "\
\$ProgressPreference = 'SilentlyContinue'; \
if (Get-Command usbipd -ErrorAction SilentlyContinue) { exit 0 }; \
if (Get-Command winget -ErrorAction SilentlyContinue) { \
winget install -e --id dorssel.usbipd-win --accept-package-agreements --accept-source-agreements --silent; \
exit \$LASTEXITCODE \
}; \
exit 2" >/dev/null 2>&1 || winget_status=$?
if [ "$winget_status" -eq 0 ]; then
hash -r 2>/dev/null || true
if command -v usbipd.exe >/dev/null 2>&1; then
log_success "usbipd-win 已通过 winget 安装"
return 0
fi
log_warn "winget 已完成,但当前 WSL shell 暂未发现 usbipd.exe继续尝试 MSI fallback"
else
log_warn "winget 安装 usbipd-win 未完成,准备下载 MSI fallback"
fi
case "$(uname -m 2>/dev/null | tr '[:upper:]' '[:lower:]')" in
aarch64|arm64) fallback_url="$USBIPD_WIN_FALLBACK_URL_ARM64" ;;
esac
msi_path="$USBIPD_WIN_DOWNLOAD_DIR/usbipd-win-${USBIPD_WIN_FALLBACK_VERSION}.msi"
if [ ! -s "$msi_path" ]; then
log_note "下载 usbipd-win MSI 到: ${msi_path}"
if ! download_usbipd_win_msi "$msi_path" "$fallback_url"; then
log_warn "usbipd-win MSI 下载失败"
return 1
fi
fi
if install_usbipd_win_from_msi "$msi_path"; then
hash -r 2>/dev/null || true
if command -v usbipd.exe >/dev/null 2>&1; then
log_success "usbipd-win 已安装"
return 0
fi
log_warn "usbipd-win 已安装,但当前 WSL shell 仍未发现 usbipd.exe请重新打开终端后重试"
else
log_warn "usbipd-win 自动安装未完成MSI 已保留在 ${msi_path}"
fi
return 1
}
prepare_motion_agent_host_dependencies() {
[ "${MOTION_AGENT_REQUESTED:-1}" -eq 1 ] || return 0
is_wsl_environment || return 0
log_step "检查 Motion Agent WSL 宿主依赖"
if command -v usbipd.exe >/dev/null 2>&1; then
log_success "usbipd-win 已可用"
return 0
fi
if ensure_usbipd_win_for_wsl; then
log_success "usbipd-win 已就绪"
return 0
fi
log_warn "usbipd-win 自动准备未完成;普通前后端初始化继续"
log_note "如果要使用 Windows USB 摄像头,请稍后重开终端或执行:"
log_note " ./planet.sh restart --motion-agent-wsl-usbipd"
return 0
}
print_motion_agent_wsl_usbipd_manual_steps() {
local busid_hint="${1:-<BUSID>}"
log_note "可在 Windows PowerShell 中查看并透传摄像头:"
log_note " usbipd list"
log_note " usbipd bind --busid ${busid_hint} # 需要管理员 PowerShell仅首次共享时需要"
log_note " usbipd attach --wsl --busid ${busid_hint}"
log_note "透传后回到 WSL 执行ls /dev/video*"
}
request_motion_agent_wsl_usbipd_bind() {
local busid="$1"
local script_path=""
if ! [[ "$busid" =~ ^[0-9]+-[0-9]+$ ]]; then
log_warn "usbipd BUSID 格式异常,跳过自动 bind: ${busid}"
return 1
fi
script_path="$(mktemp "${TMPDIR:-/tmp}/planet-usbipd-bind.XXXXXX.ps1")"
cat > "$script_path" <<EOF
\$ErrorActionPreference = 'Stop'
usbipd bind --busid '${busid}'
EOF
log_warn "摄像头 ${busid} 尚未共享,准备弹出管理员 PowerShell 执行 usbipd bind"
if run_windows_admin_powershell_script "$script_path" "无法请求管理员 PowerShell 执行 usbipd bind"; then
rm -f "$script_path" 2>/dev/null || true
return 0
fi
rm -f "$script_path" 2>/dev/null || true
return 1
}
try_motion_agent_wsl_usbipd_camera() {
local enabled="${MOTION_AGENT_WSL_USBIPD:-${PLANET_MOTION_AGENT_WSL_USBIPD:-0}}"
local busid="${MOTION_AGENT_WSL_USBIPD_BUSID:-}"
local candidates=""
local candidate_count=0
local candidate_line=""
local attach_output=""
local attach_status=0
case "$enabled" in
1|true|yes|on) ;;
*) return 1 ;;
esac
is_wsl_environment || return 1
if ! ensure_usbipd_win_for_wsl; then
log_warn "未找到可用 usbipd.exe无法自动把 Windows USB 摄像头透传到 WSL"
log_note "也可在 Windows 手动安装winget install -e --id dorssel.usbipd-win"
return 1
fi
if [ -z "$busid" ]; then
candidates="$(detect_windows_usbipd_camera_busids || true)"
candidate_count="$(printf "%s\n" "$candidates" | sed '/^[[:space:]]*$/d' | wc -l | tr -d ' ')"
if [ "$candidate_count" -eq 1 ]; then
candidate_line="$(printf "%s\n" "$candidates" | sed -n '1p')"
busid="${candidate_line%%|*}"
log_note "Motion Agent 在 Windows USB 设备中发现摄像头 BUSID: ${busid}"
elif [ "$candidate_count" -gt 1 ]; then
log_warn "发现多个疑似摄像头 USB 设备,脚本不会猜测使用哪一个"
printf "%s\n" "$candidates" | sed 's/^/ /'
log_note "请指定:./planet.sh restart -m --motion-agent-wsl-usbipd-busid <BUSID>"
return 1
else
log_warn "usbipd-win 未列出明确的摄像头设备"
print_motion_agent_wsl_usbipd_manual_steps
return 1
fi
fi
if ! [[ "$busid" =~ ^[0-9]+-[0-9]+$ ]]; then
log_warn "usbipd BUSID 格式异常,无法自动透传摄像头: ${busid}"
return 1
fi
log_warn "准备通过 usbipd-win 将 Windows USB 摄像头 ${busid} 附加到 WSL"
log_note "附加期间该摄像头通常会从 Windows 应用中暂时断开。"
attach_output="$(usbipd.exe attach --wsl --busid "$busid" 2>&1 | tr -d '\r')" || attach_status=$?
if [ "$attach_status" -ne 0 ]; then
case "$attach_output" in
*"Device is not shared"*|*"not shared"*|*"usbipd bind"*)
if request_motion_agent_wsl_usbipd_bind "$busid"; then
log_note "usbipd bind 已完成,重试 attach 摄像头 ${busid}"
attach_status=0
attach_output="$(usbipd.exe attach --wsl --busid "$busid" 2>&1 | tr -d '\r')" || attach_status=$?
fi
;;
esac
fi
if [ "$attach_status" -ne 0 ]; then
log_warn "usbipd attach 未完成"
[ -z "$attach_output" ] || printf "%s\n" "$attach_output" | sed 's/^/ /'
print_motion_agent_wsl_usbipd_manual_steps "$busid"
return 1
fi
sleep 2
MOTION_AGENT_CAMERA_INDEXES="$(detect_motion_agent_camera_indexes)"
if [ -n "$MOTION_AGENT_CAMERA_INDEXES" ]; then
log_success "Motion Agent 已通过 usbipd-win 在 WSL 中发现摄像头 index: ${MOTION_AGENT_CAMERA_INDEXES}"
return 0
fi
log_warn "usbipd attach 已执行,但 WSL 仍未发现 /dev/video*"
print_motion_agent_wsl_usbipd_manual_steps "$busid"
return 1
}
ensure_motion_agent_live_deps() {
local auto_install="${PLANET_MOTION_AGENT_AUTO_INSTALL:-1}"
@@ -2419,8 +2770,66 @@ collect_port_pids() {
fi
fi
if command -v python3 >/dev/null 2>&1; then
pids="$(python3 - "$port" <<'PY' 2>/dev/null || true
from __future__ import annotations
import os
import socket
import sys
target_port = int(sys.argv[1])
target_hex = f"{target_port:04X}"
inodes: set[str] = set()
for table in ("/proc/net/tcp", "/proc/net/tcp6"):
try:
lines = open(table, "r", encoding="utf-8").read().splitlines()[1:]
except OSError:
continue
for line in lines:
parts = line.split()
if len(parts) < 10:
continue
local_address = parts[1]
state = parts[3]
inode = parts[9]
if state != "0A":
continue
try:
_address_hex, port_hex = local_address.rsplit(":", 1)
except ValueError:
continue
if port_hex.upper() == target_hex:
inodes.add(inode)
if not inodes:
raise SystemExit(0)
for pid in filter(str.isdigit, os.listdir("/proc")):
fd_dir = f"/proc/{pid}/fd"
try:
fds = os.listdir(fd_dir)
except OSError:
continue
for fd in fds:
try:
target = os.readlink(f"{fd_dir}/{fd}")
except OSError:
continue
if target.startswith("socket:[") and target[8:-1] in inodes:
print(pid)
break
PY
)"
if [ -n "$pids" ]; then
printf "%s\n" "$pids" | awk '!seen[$0]++'
return 0
fi
fi
if command -v ss >/dev/null 2>&1; then
pids="$(ss -ltnp "( sport = :${port} )" 2>/dev/null | sed -nE 's/.*pid=([0-9]+).*/\1/p' || true)"
pids="$(ss -ltnpH "( sport = :${port} )" 2>/dev/null | sed -nE 's/.*pid=([0-9]+).*/\1/p; s/.*pid=([0-9]+),.*/\1/p' || true)"
if [ -n "$pids" ]; then
printf "%s\n" "$pids" | awk '!seen[$0]++'
return 0
@@ -2525,6 +2934,43 @@ PY
fi
}
print_port_bind_probe_details() {
local port="$1"
command -v python3 >/dev/null 2>&1 || return 1
python3 - "$port" <<'PY' 2>/dev/null | while IFS= read -r line; do
import socket
import sys
port = int(sys.argv[1])
for family, host, label in (
(socket.AF_INET, "0.0.0.0", "IPv4 0.0.0.0"),
(socket.AF_INET, "127.0.0.1", "IPv4 127.0.0.1"),
(socket.AF_INET6, "::", "IPv6 ::"),
(socket.AF_INET6, "::1", "IPv6 ::1"),
):
sock = None
try:
sock = socket.socket(family)
if family == socket.AF_INET6 and hasattr(socket, "IPV6_V6ONLY"):
sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1)
sock.bind((host, port))
print(f"bind probe: {label}:{port} ok")
except OSError as exc:
print(f"bind probe: {label}:{port} failed errno={getattr(exc, 'errno', 'unknown')} {exc}")
finally:
if sock is not None:
try:
sock.close()
except OSError:
pass
PY
[ -n "$line" ] || continue
printf "${DIM} %s${NC}\n" "$line"
done
}
wait_for_port_release() {
local port="$1"
local max_attempts="${2:-$PORT_RELEASE_ATTEMPTS}"
@@ -2746,6 +3192,7 @@ EOF
if [ "$found" -eq 0 ]; then
log_note "未能在当前环境内定位端口 ${port} 的监听进程,可能被宿主机或外部网络命名空间占用。"
print_port_bind_probe_details "$port" || true
fi
}
@@ -2971,20 +3418,20 @@ parse_service_args() {
MOTION_AGENT_PORT="$DEFAULT_MOTION_AGENT_PORT"
MOTION_AGENT_CAMERA_INDEXES="${MOTION_AGENT_CAMERA_INDEXES:-}"
MOTION_AGENT_CAMERA_URLS="${MOTION_AGENT_CAMERA_URLS:-}"
MOTION_AGENT_MODE="${MOTION_AGENT_MODE:-auto}"
MOTION_AGENT_WSL_USBIPD="${MOTION_AGENT_WSL_USBIPD:-${PLANET_MOTION_AGENT_WSL_USBIPD:-0}}"
MOTION_AGENT_WSL_USBIPD_BUSID="${MOTION_AGENT_WSL_USBIPD_BUSID:-}"
BACKEND_PORT_REQUESTED=0
FRONTEND_PORT_REQUESTED=0
AI_PROVIDER_REQUESTED=0
MOTION_AGENT_REQUESTED=0
MOTION_AGENT_REQUESTED=1
MOTION_AGENT_EXPLICIT_REQUESTED=0
MOTION_AGENT_DISABLED=0
MOTION_AGENT_DRY_RUN=0
DATABASE_REQUESTED=0
FRONTEND_LAN_ENABLED=0
FRONTEND_LAN_HTTPS_ENABLED=0
case "${PLANET_START_MOTION_AGENT:-0}" in
1|true|yes|on)
MOTION_AGENT_REQUESTED=1
;;
esac
case "${MOTION_AGENT_DRY_RUN:-0}" in
1|true|yes|on)
MOTION_AGENT_DRY_RUN=1
@@ -3022,10 +3469,19 @@ parse_service_args() {
;;
-m|--motion-agent)
MOTION_AGENT_REQUESTED=1
MOTION_AGENT_EXPLICIT_REQUESTED=1
MOTION_AGENT_DISABLED=0
shift 1
;;
--non-motion-agent)
MOTION_AGENT_REQUESTED=0
MOTION_AGENT_DISABLED=1
shift 1
;;
--motion-agent-port)
MOTION_AGENT_REQUESTED=1
MOTION_AGENT_EXPLICIT_REQUESTED=1
MOTION_AGENT_DISABLED=0
if [ -n "$2" ] && [[ "$2" =~ ^[0-9]+$ ]]; then
MOTION_AGENT_PORT="$2"
shift 2
@@ -3036,11 +3492,15 @@ parse_service_args() {
;;
--motion-agent-dry-run)
MOTION_AGENT_REQUESTED=1
MOTION_AGENT_EXPLICIT_REQUESTED=1
MOTION_AGENT_DISABLED=0
MOTION_AGENT_DRY_RUN=1
shift 1
;;
--motion-agent-camera-indexes)
MOTION_AGENT_REQUESTED=1
MOTION_AGENT_EXPLICIT_REQUESTED=1
MOTION_AGENT_DISABLED=0
if [ -n "$2" ]; then
MOTION_AGENT_CAMERA_INDEXES="$2"
shift 2
@@ -3051,6 +3511,8 @@ parse_service_args() {
;;
--motion-agent-camera-urls)
MOTION_AGENT_REQUESTED=1
MOTION_AGENT_EXPLICIT_REQUESTED=1
MOTION_AGENT_DISABLED=0
if [ -n "$2" ]; then
MOTION_AGENT_CAMERA_URLS="$2"
shift 2
@@ -3059,6 +3521,38 @@ parse_service_args() {
exit 1
fi
;;
--motion-agent-mode)
MOTION_AGENT_REQUESTED=1
MOTION_AGENT_EXPLICIT_REQUESTED=1
MOTION_AGENT_DISABLED=0
if [ -n "$2" ] && [[ "$2" =~ ^(auto|single|dual|dual_redundant|single_fallback|calibrated_3d)$ ]]; then
MOTION_AGENT_MODE="$2"
shift 2
else
log_error "--motion-agent-mode 需要 auto/single/dual/dual_redundant/single_fallback/calibrated_3d"
exit 1
fi
;;
--motion-agent-wsl-usbipd)
MOTION_AGENT_REQUESTED=1
MOTION_AGENT_EXPLICIT_REQUESTED=1
MOTION_AGENT_DISABLED=0
MOTION_AGENT_WSL_USBIPD=1
shift 1
;;
--motion-agent-wsl-usbipd-busid)
MOTION_AGENT_REQUESTED=1
MOTION_AGENT_EXPLICIT_REQUESTED=1
MOTION_AGENT_DISABLED=0
MOTION_AGENT_WSL_USBIPD=1
if [ -n "$2" ]; then
MOTION_AGENT_WSL_USBIPD_BUSID="$2"
shift 2
else
log_error "--motion-agent-wsl-usbipd-busid 需要 usbipd list 中的 BUSID例如 3-2"
exit 1
fi
;;
-d|--database)
DATABASE_REQUESTED=1
shift 1
@@ -3188,6 +3682,33 @@ print_process_health_status() {
fi
}
print_motion_agent_health_status() {
local detail=""
local tracked_pid=""
if pgrep -f "python.*-m motion_agent" >/dev/null 2>&1 || {
[ -f "$MOTION_AGENT_PID_FILE" ] &&
tracked_pid="$(read_pid_file "$MOTION_AGENT_PID_FILE" || true)" &&
[ -n "$tracked_pid" ] &&
kill -0 "$tracked_pid" 2>/dev/null
}; then
echo -e "${DIM} Motion Agent:${NC} ${GREEN}online${NC}"
return
fi
if [ -f "$MOTION_AGENT_SKIPPED_FILE" ]; then
detail="$(sed -n 's/^reason=//p' "$MOTION_AGENT_SKIPPED_FILE" 2>/dev/null | head -n 1)"
if [ -n "$detail" ]; then
echo -e "${DIM} Motion Agent:${NC} ${YELLOW}skipped${NC} ${DIM}(${detail})${NC}"
else
echo -e "${DIM} Motion Agent:${NC} ${YELLOW}skipped${NC}"
fi
return
fi
echo -e "${DIM} Motion Agent:${NC} ${RED}offline${NC}"
}
motion_agent_pids() {
local pids=""
@@ -3214,6 +3735,73 @@ cleanup_motion_agent_processes() {
remove_pid_file "$MOTION_AGENT_PID_FILE"
}
mark_motion_agent_skipped() {
local reason="$1"
local detail="${2:-}"
mkdir -p "$PLANET_STATE_DIR"
{
printf "status=skipped\n"
printf "time=%s\n" "$(date '+%Y-%m-%d %H:%M:%S')"
printf "reason=%s\n" "$reason"
if [ -n "$detail" ]; then
printf "detail=%s\n" "$detail"
fi
} > "$MOTION_AGENT_SKIPPED_FILE"
typeset -g MOTION_AGENT_SKIPPED_THIS_RUN=1
remove_pid_file "$MOTION_AGENT_PID_FILE"
log_warn "Motion Agent skipped: ${reason}"
if [ -n "$detail" ]; then
log_note "$detail"
fi
}
clear_motion_agent_skipped_state() {
typeset -g MOTION_AGENT_SKIPPED_THIS_RUN=0
rm -f "$MOTION_AGENT_SKIPPED_FILE"
}
release_motion_agent_port_for_start() {
local port="$1"
local pids=""
local pid=""
cleanup_motion_agent_processes TERM
if wait_for_port_release "$port" 25 0.2; then
return 0
fi
log_warn "Motion Agent 端口 ${port} 清理后仍不可绑定,尝试强制清理"
cleanup_motion_agent_processes KILL
if wait_for_port_release "$port" 10 0.2; then
return 0
fi
pids="$(collect_port_pids "$port" || true)"
if [ -n "$pids" ]; then
log_warn "发现 Motion Agent 端口 ${port} 仍有监听进程,正在强制终止"
for pid in $pids; do
terminate_process_group KILL "$pid"
terminate_process_tree KILL "$pid"
done
if wait_for_port_release "$port" 10 0.2; then
return 0
fi
fi
if force_cleanup_external_port_listener "$port" "Motion Agent"; then
if wait_for_port_release "$port" 10 0.2; then
return 0
fi
fi
print_port_listener_details "$port"
mark_motion_agent_skipped \
"端口 ${port} 被外部环境占用,已跳过 Motion Agent 启动" \
"其他服务会继续启动;需要动捕时请释放端口或改用 --motion-agent-port <端口>。"
return 1
}
wait_for_motion_agent_ready() {
local port="$1"
local pid="$2"
@@ -3238,51 +3826,95 @@ start_motion_agent_service() {
local dry_run="$2"
local lan_enabled="${3:-0}"
local bind_host="127.0.0.1"
local auto_detected_camera_indexes=0
local validated_camera_indexes=""
local -a motion_args
clear_motion_agent_skipped_state
ensure_uv_backend_deps
if [ "$dry_run" -eq 0 ] && [ -z "$MOTION_AGENT_CAMERA_URLS" ] && [ -z "$MOTION_AGENT_CAMERA_INDEXES" ]; then
MOTION_AGENT_CAMERA_INDEXES="$(detect_motion_agent_camera_indexes)"
if [ -n "$MOTION_AGENT_CAMERA_INDEXES" ]; then
auto_detected_camera_indexes=1
log_note "Motion Agent 自动发现摄像头 index: ${MOTION_AGENT_CAMERA_INDEXES}"
else
if is_wsl_environment; then
log_warn "Motion Agent 未自动发现 /dev/video* 摄像头"
log_note "当前看起来是 WSLWindows 摄像头通常不会自动出现在 /dev/video*。"
log_note "真实识别请选择一种方式:"
log_note " 1. 用 --motion-agent-camera-urls 接手机/RTSP/HTTP 摄像头流"
log_note " 2. 用 usbipd-win 把 USB 摄像头透传到 WSL再重试"
log_note " 3. 仅验证 WebSocket/调试面板时,显式加 --motion-agent-dry-run"
case "${PLANET_MOTION_AGENT_WSL_ALLOW_DRY_RUN_FALLBACK:-0}" in
1|true|yes|on)
log_warn "已设置 PLANET_MOTION_AGENT_WSL_ALLOW_DRY_RUN_FALLBACK=1降级为 dry-run"
dry_run=1
;;
*)
MOTION_AGENT_CAMERA_MISSING=1
;;
esac
if try_motion_agent_wsl_usbipd_camera; then
auto_detected_camera_indexes=1
:
else
log_warn "Motion Agent 未自动发现 /dev/video* 摄像头"
log_note "当前看起来是 WSLWindows 摄像头通常不会自动出现在 /dev/video*。"
log_note "真实识别请选择一种方式:"
log_note " 1. 用 --motion-agent-camera-urls 接手机/RTSP/HTTP 摄像头流"
log_note " 2. 用 --motion-agent-wsl-usbipd 尝试通过 usbipd-win 自动透传唯一 USB 摄像头"
log_note " 3. 用 --motion-agent-wsl-usbipd-busid <BUSID> 指定 usbipd list 中的摄像头"
log_note " 4. 仅验证 WebSocket/调试面板时,显式加 --motion-agent-dry-run"
case "${PLANET_MOTION_AGENT_WSL_ALLOW_DRY_RUN_FALLBACK:-0}" in
1|true|yes|on)
log_warn "已设置 PLANET_MOTION_AGENT_WSL_ALLOW_DRY_RUN_FALLBACK=1降级为 dry-run"
dry_run=1
;;
*)
if [ "${MOTION_AGENT_EXPLICIT_REQUESTED:-0}" -eq 0 ]; then
log_warn "默认启动 Motion Agent 未找到摄像头,降级为 dry-run 协议服务"
dry_run=1
else
MOTION_AGENT_CAMERA_MISSING=1
fi
;;
esac
fi
else
log_warn "Motion Agent 未自动发现 /dev/video* 摄像头,默认尝试 index 0"
if [ "${MOTION_AGENT_EXPLICIT_REQUESTED:-0}" -eq 0 ]; then
log_warn "默认启动 Motion Agent 未自动发现摄像头,降级为 dry-run 协议服务"
dry_run=1
else
log_warn "Motion Agent 未自动发现 /dev/video* 摄像头,默认尝试 index 0"
fi
fi
fi
fi
if [ "${MOTION_AGENT_CAMERA_MISSING:-0}" -eq 1 ]; then
log_error "Motion Agent live 模式缺少可用摄像头"
log_note "WSL 示例:./planet.sh restart -m --motion-agent-camera-urls http://<手机IP>:8080/video"
log_note "WSL USB 示例:./planet.sh restart -m --motion-agent-wsl-usbipd"
log_note "或仅调试协议:./planet.sh restart -m --motion-agent-dry-run"
exit 1
fi
if [ "$dry_run" -eq 0 ]; then
ensure_motion_agent_live_deps
if [ "$auto_detected_camera_indexes" -eq 1 ] && [ -z "$MOTION_AGENT_CAMERA_URLS" ]; then
validated_camera_indexes="$(detect_motion_agent_camera_indexes_with_cv2 || true)"
if [ -n "$validated_camera_indexes" ]; then
if [ "$validated_camera_indexes" != "$MOTION_AGENT_CAMERA_INDEXES" ]; then
log_warn "Motion Agent 过滤不可打开的摄像头 index: ${MOTION_AGENT_CAMERA_INDEXES} -> ${validated_camera_indexes}"
fi
MOTION_AGENT_CAMERA_INDEXES="$validated_camera_indexes"
else
if [ "${MOTION_AGENT_EXPLICIT_REQUESTED:-0}" -eq 0 ]; then
log_warn "默认启动 Motion Agent 未找到可读帧摄像头,降级为 dry-run 协议服务"
dry_run=1
MOTION_AGENT_CAMERA_INDEXES=""
else
log_error "Motion Agent 未找到可打开并能读帧的摄像头 index"
log_note "可手动指定:--motion-agent-camera-indexes <index>"
log_note "WSL 下可改用:--motion-agent-camera-urls http://<手机IP>:8080/video"
exit 1
fi
fi
fi
fi
if ! release_motion_agent_port_for_start "$motion_agent_port"; then
return 0
fi
cleanup_motion_agent_processes TERM
wait_for_port_release "$motion_agent_port" 10 0.2 || true
if ! can_bind_port "$motion_agent_port"; then
log_error "Motion Agent 端口已被占用: ${motion_agent_port}"
print_port_listener_details "$motion_agent_port"
exit 1
mark_motion_agent_skipped \
"端口 ${motion_agent_port} 已被占用" \
"未能释放 Motion Agent 端口,已跳过动捕服务;其他服务继续启动。"
return 0
fi
: > "$MOTION_AGENT_LOG_FILE"
@@ -3290,7 +3922,7 @@ start_motion_agent_service() {
bind_host="0.0.0.0"
fi
motion_args=(-m motion_agent --host "$bind_host" --port "$motion_agent_port")
motion_args=(-m motion_agent --host "$bind_host" --port "$motion_agent_port" --mode "$MOTION_AGENT_MODE")
if [ -n "$MOTION_AGENT_CAMERA_URLS" ]; then
motion_args+=(--camera-urls "$MOTION_AGENT_CAMERA_URLS")
elif [ -n "$MOTION_AGENT_CAMERA_INDEXES" ]; then
@@ -3315,6 +3947,7 @@ start_motion_agent_service() {
print_port_listener_details "$motion_agent_port"
log_note "如只需验证 Web 端连接,可加 --motion-agent-dry-run。"
log_note "如需真实摄像头识别,请确认系统存在 /dev/video*;脚本会自动发现,也可用 --motion-agent-camera-indexes 1,2 覆盖。"
log_note "输入模式可用 --motion-agent-mode auto/single/dual_redundant/single_fallback 指定。"
log_note "WSL 下也可用 --motion-agent-camera-urls rtsp://... 或 http://... 接入手机/网络摄像头。"
log_note "依赖缺失时脚本会自动执行 uv add mediapipe opencv-python。"
cleanup_motion_agent_processes TERM
@@ -3326,6 +3959,7 @@ stop_motion_agent_service() {
cleanup_motion_agent_processes TERM
log_halt "Motion Agent 已停止"
fi
clear_motion_agent_skipped_state
}
cleanup_failed_start() {
@@ -3467,6 +4101,8 @@ guard_init_when_services_running() {
}
init() {
parse_service_args "$@"
if ! guard_init_when_services_running; then
return 0
fi
@@ -3492,6 +4128,8 @@ init() {
ensure_planet_env_files
log_success "环境变量文件已就绪"
prepare_motion_agent_host_dependencies
start_wait_session "启动数据库服务"
ensure_database_services_healthy
stop_wait_session
@@ -3704,7 +4342,9 @@ start() {
typeset -g STARTED_FRONTEND_THIS_RUN=1
if [ "$MOTION_AGENT_REQUESTED" -eq 1 ]; then
start_motion_agent_service "$MOTION_AGENT_PORT" "$MOTION_AGENT_DRY_RUN" "$FRONTEND_LAN_ENABLED"
typeset -g STARTED_MOTION_AGENT_THIS_RUN=1
if [ "${MOTION_AGENT_SKIPPED_THIS_RUN:-0}" -eq 0 ]; then
typeset -g STARTED_MOTION_AGENT_THIS_RUN=1
fi
fi
local frontend_scheme=""
@@ -3719,7 +4359,7 @@ start() {
log_note "智能星球仪表盘: ${frontend_scheme}://localhost:${FRONTEND_PORT}/admin"
log_note "AI Playground: ${frontend_scheme}://localhost:${FRONTEND_PORT}/playground"
log_note "智能星球文档: ${frontend_scheme}://localhost:${FRONTEND_PORT}/docs"
if [ "$MOTION_AGENT_REQUESTED" -eq 1 ]; then
if [ "$MOTION_AGENT_REQUESTED" -eq 1 ] && [ "${MOTION_AGENT_SKIPPED_THIS_RUN:-0}" -eq 0 ]; then
log_motion_agent_access_notes "$MOTION_AGENT_PORT" "$FRONTEND_LAN_ENABLED"
fi
if [ "$FRONTEND_LAN_ENABLED" -eq 1 ]; then
@@ -3878,7 +4518,7 @@ restart() {
local state_ai_provider_port=""
local state_motion_agent_port=""
if [ "$BACKEND_PORT_REQUESTED" -eq 0 ] && [ "$FRONTEND_PORT_REQUESTED" -eq 0 ] && [ "$AI_PROVIDER_REQUESTED" -eq 0 ] && [ "$MOTION_AGENT_REQUESTED" -eq 0 ] && [ "$DATABASE_REQUESTED" -eq 0 ]; then
if [ "$BACKEND_PORT_REQUESTED" -eq 0 ] && [ "$FRONTEND_PORT_REQUESTED" -eq 0 ] && [ "$AI_PROVIDER_REQUESTED" -eq 0 ] && [ "$MOTION_AGENT_EXPLICIT_REQUESTED" -eq 0 ] && [ "$DATABASE_REQUESTED" -eq 0 ]; then
stop_local_services_for_restart
sleep 1
start "$@"
@@ -3912,9 +4552,10 @@ restart() {
start_frontend_service "$FRONTEND_PORT" 1 "$FRONTEND_LAN_ENABLED" "$BACKEND_PORT"
fi
if [ "$MOTION_AGENT_REQUESTED" -eq 1 ]; then
if [ "$MOTION_AGENT_EXPLICIT_REQUESTED" -eq 1 ] && [ "$MOTION_AGENT_REQUESTED" -eq 1 ]; then
stop_motion_agent_service
sleep 1
typeset -g MOTION_AGENT_SKIPPED_THIS_RUN=0
start_motion_agent_service "$MOTION_AGENT_PORT" "$MOTION_AGENT_DRY_RUN" "$FRONTEND_LAN_ENABLED"
fi
@@ -3933,7 +4574,7 @@ restart() {
if [ "$AI_PROVIDER_REQUESTED" -eq 1 ]; then
state_ai_provider_port="$AI_PROVIDER_PORT"
fi
if [ "$MOTION_AGENT_REQUESTED" -eq 1 ]; then
if [ "$MOTION_AGENT_EXPLICIT_REQUESTED" -eq 1 ] && [ "$MOTION_AGENT_REQUESTED" -eq 1 ]; then
state_motion_agent_port="$MOTION_AGENT_PORT"
fi
local frontend_scheme=""
@@ -3957,7 +4598,7 @@ restart() {
log_lan_access_notes "$FRONTEND_PORT" "$BACKEND_PORT" "$AI_PROVIDER_PORT" "$frontend_scheme"
fi
fi
if [ "$MOTION_AGENT_REQUESTED" -eq 1 ]; then
if [ "$MOTION_AGENT_EXPLICIT_REQUESTED" -eq 1 ] && [ "$MOTION_AGENT_REQUESTED" -eq 1 ] && [ "${MOTION_AGENT_SKIPPED_THIS_RUN:-0}" -eq 0 ]; then
log_motion_agent_access_notes "$MOTION_AGENT_PORT" "$FRONTEND_LAN_ENABLED"
fi
}
@@ -3979,7 +4620,7 @@ health() {
print_http_health_status "后端" "http://localhost:${backend_port}/health"
print_http_health_status "AI Provider" "http://localhost:${ai_provider_port}/health"
print_frontend_health_status "$frontend_port"
print_process_health_status "Motion Agent" "python.*-m motion_agent" "$MOTION_AGENT_PID_FILE"
print_motion_agent_health_status
}
log() {
@@ -4041,7 +4682,8 @@ set -- "${GLOBAL_ARG_REMAINDER[@]}"
case "$1" in
init)
init
shift
init "$@"
;;
start)
shift
@@ -4070,10 +4712,10 @@ case "$1" in
log_error "用法: ./planet.sh {init|start|stop|destroy|restart|createuser|health|log}"
log_note "全局参数: -v, --verbose 在状态提示之间增量输出命令日志"
log_note "init 首次初始化空项目: 同步 uv/bun 依赖、生成缺失 env、启动数据库并写入默认数据"
log_note "start 启动服务,可选: -b <后端端口> -f <前端端口> -a <AI Provider 端口> -m/--motion-agent --motion-agent-port <端口> --motion-agent-camera-indexes 0,1 --motion-agent-camera-urls rtsp://... --motion-agent-dry-run --allow-lan --verbose"
log_note "start 启动服务,默认包含 Motion Agent可选: -b <后端端口> -f <前端端口> -a <AI Provider 端口> --non-motion-agent -m/--motion-agent --motion-agent-port <端口> --motion-agent-mode auto|single|dual_redundant|single_fallback --motion-agent-camera-indexes 0,1 --motion-agent-camera-urls rtsp://... --motion-agent-wsl-usbipd --motion-agent-wsl-usbipd-busid <BUSID> --motion-agent-dry-run --allow-lan --verbose"
log_note "stop 停止服务"
log_note "destroy 删除 Planet 容器、卷、镜像和本地编译状态,执行前需要输入 Y 确认"
log_note "restart 重启服务,可选: -b [后端端口] -f [前端端口] -a [AI Provider 端口] -m [Motion Agent] -d --allow-lan --verbose"
log_note "restart 重启服务,默认包含 Motion Agent可选: -b [后端端口] -f [前端端口] -a [AI Provider 端口] --non-motion-agent -m [Motion Agent] -d --allow-lan --verbose"
log_note "createuser 交互创建用户"
log_note "health 检查健康状态"
log_note "log 查看日志"